Feat: pages Article + navigation blog/article interne
All checks were successful
Deploy / Deploy to Production (push) Successful in 19s
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>
This commit is contained in:
parent
3ab4b21e8c
commit
0505cc7b8e
4 changed files with 465 additions and 97 deletions
|
|
@ -1,76 +1,179 @@
|
|||
<script>
|
||||
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>
|
||||
|
||||
<section class="blog page-scrollable">
|
||||
<div class="page-container">
|
||||
<!-- 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">
|
||||
|
||||
<!-- Intro -->
|
||||
{#if data?.intro}
|
||||
<header class="blog-header">
|
||||
{@html data.intro}
|
||||
</header>
|
||||
{/if}
|
||||
{#if data?.intro}
|
||||
<header class="blog-header">
|
||||
{@html data.intro}
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
<!-- Featured article -->
|
||||
{#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 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}
|
||||
<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}
|
||||
|
||||
<!-- Articles list -->
|
||||
{#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}
|
||||
</article>
|
||||
<hr class="blog-divider" />
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
</div>
|
||||
{#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}
|
||||
|
||||
<Footer />
|
||||
{#if articleLoading}
|
||||
<p class="blog-loading">Chargement…</p>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
|
|
@ -186,6 +289,13 @@
|
|||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* --- Loading --- */
|
||||
.blog-loading {
|
||||
text-align: center;
|
||||
padding: 4rem 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* --- Mobile --- */
|
||||
@media (--mobile) {
|
||||
.blog-header {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue