Compare commits

...

6 commits

Author SHA1 Message Date
isUnknown
0505cc7b8e Feat: pages Article + navigation blog/article interne
All checks were successful
Deploy / Deploy to Production (push) Successful in 19s
- Router: findSlideIndex() avec fallback parent path
  (/blog/slug → /blog) pour sub-pages
- article.json.php: réécriture — date, intro, cover, body (blocks→HTML),
  related articles (fallback siblings si vide)
- Article.svelte: sous-composant — topbar date+retour, titre Terminal,
  intro, cover, body rich text (styles :global pour blocks Kirby),
  related articles grid, responsive
- Blog.svelte: gère deux modes (liste + article) —
  intercepte les clics article via stopPropagation (avant le router),
  fetch article data, pushState pour URL, popstate pour back/forward,
  direct navigation /blog/slug sur mount

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-10 16:55:34 +01:00
isUnknown
3ab4b21e8c Feat: implémentation front page Blog
- blog.json.php : réécriture — intro (writer), featured (pages field),
  articles triés par date desc (featured exclu de la liste)
- layout.css : .page-scrollable + .page-container (styles génériques
  pour pages scrollables type blog/article)
- Blog.svelte : page scrollable avec header intro, article featured
  (image large + excerpt), liste articles avec dividers, responsive
  mobile/tablet via @custom-media

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-10 16:47:49 +01:00
isUnknown
2352b621e5 Fix: blog articles triés par published desc, info date fr
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 16:39:27 +01:00
isUnknown
37b9483fe2 Fix: blog articles triés par date desc, info en format fr
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 16:30:21 +01:00
isUnknown
144a191e03 Revert "Fix: affichage cards articles blog dans le panel"
This reverts commit f7e58e629a.
2026-03-10 16:23:08 +01:00
isUnknown
f7e58e629a Fix: affichage cards articles blog dans le panel
- Ajout image query/cover/ratio sur la section articles
  (même config que la section featured)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 16:22:00 +01:00
7 changed files with 670 additions and 77 deletions

View file

@ -43,17 +43,12 @@ tabs:
cover: true
ratio: 16/9
info: "{{ page.date.toDate('d/m/Y') }}"
articlesList:
articles:
label: Articles
type: pages
headline: Liste des articles
layout: cards
size: full
sortBy: published desc
image:
query: page.cover.toFile
ratio: 16/9
cover: true
template: article
info: "{{ page.date.toDate('d/m/Y') }}"
create: article
sortBy: date desc
info: "{{ page.published.toDate('d/m/Y') }}"

View file

@ -6,26 +6,19 @@ if ($related->isEmpty()) {
}
$specificData = [
'date' => $page->date()->toDate('Y-m-d'),
'dateFormatted' => $page->date()->toDate('d/m/Y'),
'intro' => $page->intro()->value(),
'author' => [
'name' => $page->authorName()->value(),
'role' => $page->authorRole()->value(),
'photo' => $page->authorPhoto()->toFile()?->url()
],
'cover' => $page->cover()->toFile()?->url(),
'content' => $page->articleContent()->toBlocks(),
'tags' => $page->tags()->split(),
'related' => $related->map(function($rec) {
'date' => $page->date()->toDate('d/m/Y'),
'intro' => $page->intro()->value(),
'cover' => $page->cover()->toFile()?->url(),
'body' => (string) $page->body()->toBlocks(),
'related' => $related->map(function($rec) {
return [
'title' => $rec->title()->value(),
'url' => $rec->url(),
'category' => $rec->category()->value(),
'cover' => $rec->cover()->toFile()?->thumb(['width' => 400])->url()
'slug' => $rec->slug(),
'date' => $rec->date()->toDate('d/m/Y'),
'cover' => $rec->cover()->toFile()?->url(),
];
})->values(),
'parentUrl' => $page->parent()->url()
'parentUrl' => $page->parent()->url(),
];
$pageData = array_merge($genericData, $specificData);

View file

@ -1,23 +1,25 @@
<?php
$featured = $page->featured()->toPages()->first();
$mapArticle = function($article) {
return [
'title' => $article->title()->value(),
'slug' => $article->slug(),
'date' => $article->date()->toDate('d/m/Y'),
'intro' => $article->intro()->excerpt(200),
'cover' => $article->cover()->toFile()?->url(),
];
};
$articles = $page->children()->listed()->sortBy('date', 'desc');
$specificData = [
'intro' => [
'title' => $page->introTitle()->value(),
'text' => $page->introText()->value()
],
'articles' => $page->children()->listed()->sortBy('date', 'desc')->map(function($article) {
return [
'title' => $article->title()->value(),
'slug' => $article->slug(),
'url' => $article->url(),
'date' => $article->date()->toDate('Y-m-d'),
'dateFormatted' => $article->date()->toDate('d/m/Y'),
'intro' => $article->intro()->excerpt(200),
'cover' => $article->cover()->toFile()?->url(),
'authorName' => $article->authorName()->value(),
'authorPhoto' => $article->authorPhoto()->toFile()?->url()
];
})->values()
'intro' => $page->intro()->value(),
'featured' => $featured ? $mapArticle($featured) : null,
'articles' => ($featured ? $articles->not($featured) : $articles)
->map($mapArticle)
->values(),
];
$pageData = array_merge($genericData, $specificData);

View file

@ -8,16 +8,39 @@ function normalizePath(path) {
return path === "/" ? "/home" : path;
}
/**
* Trouve l'index de la slide correspondant au path.
* Si le path exact n'existe pas, essaie le chemin parent
* (ex: /blog/article-slug /blog).
*/
function findSlideIndex(path) {
let idx = slides.getIndexByPath(path);
if (idx !== -1) return idx;
const parentPath = path.replace(/\/[^/]+$/, "");
if (parentPath) return slides.getIndexByPath(parentPath);
return -1;
}
async function loadSlide(path) {
const idx = slides.getIndexByPath(path);
let slidePath = path;
let idx = slides.getIndexByPath(slidePath);
// Sub-page: resolve to parent slide (ex: /blog/slug → /blog)
if (idx === -1) {
const parentPath = path.replace(/\/[^/]+$/, "");
idx = slides.getIndexByPath(parentPath);
if (idx !== -1) slidePath = parentPath;
}
if (idx !== -1) {
const slide = slides.all[idx];
if (slide.loaded || slide.loading) return;
slides.setLoading(path, true);
slides.setLoading(slidePath, true);
}
try {
const response = await fetch(`${path}.json`);
// Fetch the actual slide path (parent), not the sub-page
const response = await fetch(`${slidePath}.json`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
@ -28,10 +51,10 @@ async function loadSlide(path) {
siteInitialized = true;
}
slides.setData(path, data);
slides.setData(slidePath, data);
} catch (error) {
console.error(`[router] Failed to load slide ${path}:`, error);
slides.setLoading(path, false);
console.error(`[router] Failed to load slide ${slidePath}:`, error);
slides.setLoading(slidePath, false);
}
}
@ -48,15 +71,17 @@ export function slideTo(path, { skipHistory = false } = {}) {
history.pushState({}, "", path === "/home" ? "/" : path);
}
const idx = slides.getIndexByPath(path);
const idx = findSlideIndex(path);
const slidePath = idx !== -1 ? slides.all[idx].path : path;
if (idx !== -1 && slides.all[idx].title) {
document.title = `${slides.all[idx].title} — World Game`;
}
slides.slideTo(path);
slides.slideTo(slidePath);
if (idx !== -1 && !slides.all[idx].loaded) {
loadSlide(path);
loadSlide(slidePath);
}
}
@ -65,12 +90,14 @@ export async function initRouter() {
await loadSlide(initialPath);
const idx = slides.getIndexByPath(initialPath);
const idx = findSlideIndex(initialPath);
if (idx !== -1) {
slides.setActiveIndex(idx);
}
loadAllSlidesInBackground(initialPath);
loadAllSlidesInBackground(
idx !== -1 ? slides.all[idx].path : initialPath
);
window.addEventListener("popstate", () => {
const path = normalizePath(window.location.pathname);

View file

@ -13,6 +13,18 @@
overflow-y: hidden;
}
/* Pages scrollables (blog, article, etc.) */
.page-scrollable {
height: 100%;
overflow-y: auto;
}
.page-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 50px;
}
/* Vertical Lines */
.vertical-line {
z-index: var(--z-base);

View file

@ -1,24 +1,262 @@
<script>
import { fade } from 'svelte/transition'
let { data } = $props()
/**
* Vue article — sous-composant de Blog.svelte.
* Reçoit les données article via props, pas via le slide system.
*/
let { data, onBack } = $props()
</script>
<div class="article" transition:fade>
<div class="article__container">
<h1>{data?.title || 'Article'}</h1>
<p>Article view - To be implemented</p>
<article class="article">
<!-- Date + retour -->
<div class="article-topbar">
<time class="article-date">{data.date}</time>
<button class="article-back" onclick={onBack}> Retour</button>
</div>
</div>
<!-- Titre -->
<h1 class="article-title font-face-terminal">{data.title}</h1>
<!-- Intro -->
{#if data.intro}
<div class="article-intro">{@html data.intro}</div>
{/if}
<!-- Cover -->
{#if data.cover}
<div class="article-cover">
<img src={data.cover} alt={data.title} />
</div>
{/if}
<!-- Body (Kirby blocks → HTML) -->
{#if data.body}
<div class="article-body">{@html data.body}</div>
{/if}
<!-- Articles recommandés -->
{#if data.related?.length}
<section class="article-related">
<h2>Nos recommandations</h2>
<div class="article-related-grid">
{#each data.related as rec}
<a href="/blog/{rec.slug}" class="article-related-card">
{#if rec.cover}
<img src={rec.cover} alt={rec.title} />
{/if}
<span class="article-related-title">{rec.title}</span>
</a>
{/each}
</div>
</section>
{/if}
</article>
<style>
.article {
min-height: 100vh;
padding: 8rem 2rem 4rem;
color: #fff;
max-width: 900px;
margin: 0 auto;
padding: 4rem 2rem 6rem;
}
.article__container {
max-width: 1200px;
margin: 0 auto;
/* --- Topbar --- */
.article-topbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.article-date {
color: rgba(255, 255, 255, 0.7);
font-size: var(--font-size-paragraph);
}
.article-back {
background: none;
color: var(--color-primary);
font-family: "Danzza Medium", sans-serif;
font-size: var(--font-size-paragraph-small);
cursor: pointer;
transition: opacity 0.2s;
}
.article-back:hover {
opacity: 0.7;
}
/* --- Titre --- */
.article-title {
font-size: var(--font-size-title-main);
text-align: center;
margin-bottom: 1.5rem;
line-height: 1.2;
}
/* --- Intro --- */
.article-intro {
text-align: center;
font-size: var(--font-size-subtitle);
line-height: 1.6;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 2.5rem;
max-width: 700px;
margin-left: auto;
margin-right: auto;
}
/* --- Cover --- */
.article-cover {
margin-bottom: 3rem;
}
.article-cover img {
width: 100%;
height: auto;
border-radius: 8px;
}
/* --- Body (rich text from Kirby blocks) --- */
.article-body {
line-height: 1.8;
margin-bottom: 4rem;
}
.article-body :global(h2),
.article-body :global(h3) {
font-family: "Danzza Bold", sans-serif;
margin: 2rem 0 1rem;
line-height: 1.3;
}
.article-body :global(h2) {
font-size: 29px;
}
.article-body :global(h3) {
font-size: 22px;
}
.article-body :global(p) {
margin-bottom: 1.25rem;
font-size: var(--font-size-paragraph);
}
.article-body :global(a) {
color: var(--color-primary);
text-decoration: underline;
}
.article-body :global(ul),
.article-body :global(ol) {
margin: 1.25rem 0;
padding-left: 1.5rem;
}
.article-body :global(li) {
margin-bottom: 0.5rem;
}
.article-body :global(blockquote) {
border-left: 4px solid var(--color-primary);
padding: 1.25rem 1.25rem 1.25rem 2rem;
margin: 2rem 0;
background: rgba(4, 254, 160, 0.05);
}
.article-body :global(blockquote p) {
font-style: italic;
font-size: 19px;
margin-bottom: 0.5rem;
}
.article-body :global(figure) {
margin: 2rem 0;
text-align: center;
}
.article-body :global(figure img) {
max-width: 100%;
height: auto;
border-radius: 8px;
}
.article-body :global(figcaption) {
margin-top: 0.5rem;
font-style: italic;
color: rgba(255, 255, 255, 0.7);
font-size: var(--font-size-caption);
}
/* --- Recommandations --- */
.article-related {
border-top: 1px solid rgba(255, 255, 255, 0.15);
padding-top: 3rem;
}
.article-related h2 {
font-family: "Danzza Bold", sans-serif;
font-size: var(--font-size-title-section);
margin-bottom: 2rem;
}
.article-related-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
}
.article-related-card {
display: flex;
flex-direction: column;
gap: 0.75rem;
transition: transform 0.3s;
}
.article-related-card:hover {
transform: translateY(-5px);
}
.article-related-card img {
width: 100%;
height: 180px;
object-fit: cover;
border-radius: 8px;
}
.article-related-title {
font-family: "Danzza Bold", sans-serif;
font-size: var(--font-size-paragraph);
}
/* --- Mobile --- */
@media (--mobile) {
.article {
padding: 3rem 1.25rem 4rem;
}
.article-title {
font-size: var(--font-size-title-section-mobile);
}
.article-intro {
font-size: var(--font-size-paragraph-mobile);
}
.article-related-grid {
grid-template-columns: 1fr;
}
}
/* --- Tablet --- */
@media (--tablet-only) {
.article-title {
font-size: var(--font-size-title-main-tablet);
}
.article-related-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View file

@ -1,24 +1,350 @@
<script>
import { fade } from 'svelte/transition'
import { onMount } from 'svelte'
import { slides } from '@state/slides.svelte'
import Footer from '@components/layout/Footer.svelte'
import Article from '@views/Article.svelte'
let { data } = $props()
const isActive = $derived(slides.active?.id === 'blog')
const featured = $derived(data?.featured ?? null)
const articles = $derived(data?.articles ?? [])
// --- Article sub-page state ---
let articleData = $state(null)
let articleLoading = $state(false)
let sectionEl = $state(null)
// Extract slug from current URL (for direct navigation to /blog/slug)
function getSlugFromUrl() {
const parts = window.location.pathname.split('/').filter(Boolean)
// parts = ['blog', 'slug'] → slug = 'slug'
return parts.length >= 2 && parts[0] === 'blog' ? parts[1] : null
}
async function openArticle(slug) {
articleLoading = true
try {
const res = await fetch(`/blog/${slug}.json`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
articleData = await res.json()
scrollToTop()
} catch (e) {
console.error('[blog] Failed to load article:', e)
articleData = null
} finally {
articleLoading = false
}
}
function backToList() {
articleData = null
history.pushState({}, '', '/blog')
document.title = `${data?.title ?? 'Blog'} — World Game`
scrollToTop()
}
function scrollToTop() {
sectionEl?.scrollTo(0, 0)
}
// Handle article link clicks (intercepted BEFORE the router's document handler)
function handleClick(e) {
const link = e.target.closest('a[href]')
if (!link) return
const url = new URL(link.href, window.location.origin)
if (url.origin !== window.location.origin) return
const match = url.pathname.match(/^\/blog\/([^/]+)$/)
if (match) {
e.preventDefault()
e.stopPropagation()
const slug = match[1]
history.pushState({}, '', `/blog/${slug}`)
openArticle(slug)
}
}
// Handle browser back/forward within blog
function handlePopState() {
if (!isActive) return
const slug = getSlugFromUrl()
if (slug && (!articleData || articleData.uri !== `blog/${slug}`)) {
openArticle(slug)
} else if (!slug && articleData) {
articleData = null
scrollToTop()
}
}
// On mount: check if URL is /blog/slug (direct navigation)
onMount(() => {
const slug = getSlugFromUrl()
if (slug) openArticle(slug)
window.addEventListener('popstate', handlePopState)
return () => {
window.removeEventListener('popstate', handlePopState)
}
})
// Reset article view when leaving the blog slide
$effect(() => {
if (!isActive && articleData) {
articleData = null
}
})
</script>
<div class="blog" transition:fade>
<div class="blog__container">
<h1>{data?.title || 'Blog'}</h1>
<p>Blog view - To be implemented</p>
</div>
</div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<section
class="blog page-scrollable"
bind:this={sectionEl}
onclick={handleClick}
>
{#if articleData}
<!-- Article view -->
<Article data={articleData} onBack={backToList} />
{:else}
<!-- Blog list -->
<div class="page-container">
{#if data?.intro}
<header class="blog-header">
{@html data.intro}
</header>
{/if}
{#if featured}
<article class="blog-card blog-card--featured">
<div class="blog-card-text">
<time class="blog-card-date">{featured.date}</time>
<h2 class="blog-card-title">
<a href="/blog/{featured.slug}">{featured.title}</a>
</h2>
{#if featured.intro}
<p class="blog-card-excerpt">{featured.intro}</p>
{/if}
<a href="/blog/{featured.slug}" class="blog-card-readmore">
Lire l'article <span class="arrow"></span>
</a>
</div>
{#if featured.cover}
<div class="blog-card-image blog-card-image--featured">
<a href="/blog/{featured.slug}">
<img src={featured.cover} alt={featured.title} />
</a>
</div>
{/if}
</article>
<hr class="blog-divider" />
{/if}
{#each articles as article, i}
<article class="blog-card">
<div class="blog-card-text">
<time class="blog-card-date">{article.date}</time>
<h2 class="blog-card-title">
<a href="/blog/{article.slug}">{article.title}</a>
</h2>
<a href="/blog/{article.slug}" class="blog-card-readmore">
Lire l'article <span class="arrow"></span>
</a>
</div>
{#if article.cover}
<div class="blog-card-image">
<a href="/blog/{article.slug}">
<img src={article.cover} alt={article.title} />
</a>
</div>
{/if}
</article>
{#if i < articles.length - 1}
<hr class="blog-divider" />
{/if}
{/each}
{#if articleLoading}
<p class="blog-loading">Chargement…</p>
{/if}
</div>
<Footer />
{/if}
</section>
<style>
.blog {
min-height: 100vh;
padding: 8rem 2rem 4rem;
color: #fff;
background: #0d0e22;
color: var(--color-text);
}
.blog__container {
max-width: 1200px;
/* --- Header / Intro --- */
.blog-header {
text-align: center;
padding: 6rem 0 3rem;
}
.blog-header :global(h1) {
font-family: "Terminal", sans-serif;
font-size: var(--font-size-title-main);
text-transform: uppercase;
margin-bottom: 1.5rem;
}
.blog-header :global(p) {
font-size: var(--font-size-subtitle);
line-height: 1.6;
max-width: 640px;
margin: 0 auto;
opacity: 0.9;
}
/* --- Card (article item) --- */
.blog-card {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 2rem;
padding: 1.5rem 0;
}
.blog-card-text {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 640px;
}
.blog-card-date {
color: #d9d9d9;
font-size: var(--font-size-paragraph);
}
.blog-card-title {
font-family: "Danzza Bold", sans-serif;
font-size: var(--font-size-title-section);
line-height: 1.3;
}
.blog-card-title a {
transition: color 0.2s;
}
.blog-card-title a:hover {
color: var(--color-primary);
}
.blog-card-excerpt {
color: #d9d9d9;
font-size: var(--font-size-paragraph);
line-height: 1.6;
}
.blog-card-readmore {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--color-primary);
font-family: "Danzza Medium", sans-serif;
font-size: var(--font-size-paragraph);
transition: gap 0.2s;
}
.blog-card-readmore:hover {
gap: 1rem;
}
/* --- Image --- */
.blog-card-image img {
width: 300px;
height: 169px;
object-fit: cover;
transition: transform 0.3s;
}
.blog-card-image img:hover {
transform: scale(1.05);
}
.blog-card-image--featured img {
width: auto;
height: 300px;
}
/* --- Divider --- */
.blog-divider {
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.15);
margin: 0;
}
/* --- Featured --- */
.blog-card--featured .blog-card-title {
font-size: 36px;
line-height: 1.3;
}
/* --- Loading --- */
.blog-loading {
text-align: center;
padding: 4rem 0;
opacity: 0.6;
}
/* --- Mobile --- */
@media (--mobile) {
.blog-header {
padding: 4rem 0 2rem;
}
.blog-header :global(h1) {
font-size: var(--font-size-title-main-mobile);
}
.blog-header :global(p) {
font-size: var(--font-size-subtitle-mobile);
}
.blog-card {
flex-direction: column;
}
.blog-card-image img,
.blog-card-image--featured img {
width: 100%;
height: auto;
aspect-ratio: 16/9;
}
.blog-card--featured .blog-card-title {
font-size: var(--font-size-title-section-mobile);
}
.blog-card-title {
font-size: var(--font-size-title-section-mobile);
}
}
/* --- Tablet --- */
@media (--tablet-only) {
.blog-header :global(h1) {
font-size: var(--font-size-title-main-tablet);
}
.blog-card {
flex-direction: column;
}
.blog-card-image img,
.blog-card-image--featured img {
width: 100%;
height: auto;
aspect-ratio: 16/9;
}
}
</style>