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
|
|
@ -6,26 +6,19 @@ if ($related->isEmpty()) {
|
||||||
}
|
}
|
||||||
|
|
||||||
$specificData = [
|
$specificData = [
|
||||||
'date' => $page->date()->toDate('Y-m-d'),
|
'date' => $page->date()->toDate('d/m/Y'),
|
||||||
'dateFormatted' => $page->date()->toDate('d/m/Y'),
|
'intro' => $page->intro()->value(),
|
||||||
'intro' => $page->intro()->value(),
|
'cover' => $page->cover()->toFile()?->url(),
|
||||||
'author' => [
|
'body' => (string) $page->body()->toBlocks(),
|
||||||
'name' => $page->authorName()->value(),
|
'related' => $related->map(function($rec) {
|
||||||
'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) {
|
|
||||||
return [
|
return [
|
||||||
'title' => $rec->title()->value(),
|
'title' => $rec->title()->value(),
|
||||||
'url' => $rec->url(),
|
'slug' => $rec->slug(),
|
||||||
'category' => $rec->category()->value(),
|
'date' => $rec->date()->toDate('d/m/Y'),
|
||||||
'cover' => $rec->cover()->toFile()?->thumb(['width' => 400])->url()
|
'cover' => $rec->cover()->toFile()?->url(),
|
||||||
];
|
];
|
||||||
})->values(),
|
})->values(),
|
||||||
'parentUrl' => $page->parent()->url()
|
'parentUrl' => $page->parent()->url(),
|
||||||
];
|
];
|
||||||
|
|
||||||
$pageData = array_merge($genericData, $specificData);
|
$pageData = array_merge($genericData, $specificData);
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,39 @@ function normalizePath(path) {
|
||||||
return path === "/" ? "/home" : 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) {
|
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) {
|
if (idx !== -1) {
|
||||||
const slide = slides.all[idx];
|
const slide = slides.all[idx];
|
||||||
if (slide.loaded || slide.loading) return;
|
if (slide.loaded || slide.loading) return;
|
||||||
slides.setLoading(path, true);
|
slides.setLoading(slidePath, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
|
|
@ -28,10 +51,10 @@ async function loadSlide(path) {
|
||||||
siteInitialized = true;
|
siteInitialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
slides.setData(path, data);
|
slides.setData(slidePath, data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[router] Failed to load slide ${path}:`, error);
|
console.error(`[router] Failed to load slide ${slidePath}:`, error);
|
||||||
slides.setLoading(path, false);
|
slides.setLoading(slidePath, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,15 +71,17 @@ export function slideTo(path, { skipHistory = false } = {}) {
|
||||||
history.pushState({}, "", path === "/home" ? "/" : path);
|
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) {
|
if (idx !== -1 && slides.all[idx].title) {
|
||||||
document.title = `${slides.all[idx].title} — World Game`;
|
document.title = `${slides.all[idx].title} — World Game`;
|
||||||
}
|
}
|
||||||
|
|
||||||
slides.slideTo(path);
|
slides.slideTo(slidePath);
|
||||||
|
|
||||||
if (idx !== -1 && !slides.all[idx].loaded) {
|
if (idx !== -1 && !slides.all[idx].loaded) {
|
||||||
loadSlide(path);
|
loadSlide(slidePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,12 +90,14 @@ export async function initRouter() {
|
||||||
|
|
||||||
await loadSlide(initialPath);
|
await loadSlide(initialPath);
|
||||||
|
|
||||||
const idx = slides.getIndexByPath(initialPath);
|
const idx = findSlideIndex(initialPath);
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
slides.setActiveIndex(idx);
|
slides.setActiveIndex(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadAllSlidesInBackground(initialPath);
|
loadAllSlidesInBackground(
|
||||||
|
idx !== -1 ? slides.all[idx].path : initialPath
|
||||||
|
);
|
||||||
|
|
||||||
window.addEventListener("popstate", () => {
|
window.addEventListener("popstate", () => {
|
||||||
const path = normalizePath(window.location.pathname);
|
const path = normalizePath(window.location.pathname);
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,262 @@
|
||||||
<script>
|
<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>
|
</script>
|
||||||
|
|
||||||
<div class="article" transition:fade>
|
<article class="article">
|
||||||
<div class="article__container">
|
|
||||||
<h1>{data?.title || 'Article'}</h1>
|
<!-- Date + retour -->
|
||||||
<p>Article view - To be implemented</p>
|
<div class="article-topbar">
|
||||||
|
<time class="article-date">{data.date}</time>
|
||||||
|
<button class="article-back" onclick={onBack}>← Retour</button>
|
||||||
</div>
|
</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>
|
<style>
|
||||||
.article {
|
.article {
|
||||||
min-height: 100vh;
|
max-width: 900px;
|
||||||
padding: 8rem 2rem 4rem;
|
margin: 0 auto;
|
||||||
color: #fff;
|
padding: 4rem 2rem 6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article__container {
|
/* --- Topbar --- */
|
||||||
max-width: 1200px;
|
.article-topbar {
|
||||||
margin: 0 auto;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,76 +1,179 @@
|
||||||
<script>
|
<script>
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { slides } from '@state/slides.svelte'
|
||||||
import Footer from '@components/layout/Footer.svelte'
|
import Footer from '@components/layout/Footer.svelte'
|
||||||
|
import Article from '@views/Article.svelte'
|
||||||
|
|
||||||
let { data } = $props()
|
let { data } = $props()
|
||||||
|
|
||||||
|
const isActive = $derived(slides.active?.id === 'blog')
|
||||||
const featured = $derived(data?.featured ?? null)
|
const featured = $derived(data?.featured ?? null)
|
||||||
const articles = $derived(data?.articles ?? [])
|
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>
|
</script>
|
||||||
|
|
||||||
<section class="blog page-scrollable">
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div class="page-container">
|
<!-- 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}
|
||||||
{#if data?.intro}
|
<header class="blog-header">
|
||||||
<header class="blog-header">
|
{@html data.intro}
|
||||||
{@html data.intro}
|
</header>
|
||||||
</header>
|
{/if}
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Featured article -->
|
{#if featured}
|
||||||
{#if featured}
|
<article class="blog-card blog-card--featured">
|
||||||
<article class="blog-card blog-card--featured">
|
<div class="blog-card-text">
|
||||||
<div class="blog-card-text">
|
<time class="blog-card-date">{featured.date}</time>
|
||||||
<time class="blog-card-date">{featured.date}</time>
|
<h2 class="blog-card-title">
|
||||||
<h2 class="blog-card-title">
|
<a href="/blog/{featured.slug}">{featured.title}</a>
|
||||||
<a href="/blog/{featured.slug}">{featured.title}</a>
|
</h2>
|
||||||
</h2>
|
{#if featured.intro}
|
||||||
{#if featured.intro}
|
<p class="blog-card-excerpt">{featured.intro}</p>
|
||||||
<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}
|
{/if}
|
||||||
<a href="/blog/{featured.slug}" class="blog-card-readmore">
|
</article>
|
||||||
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}
|
|
||||||
<hr class="blog-divider" />
|
<hr class="blog-divider" />
|
||||||
{/if}
|
{/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>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -186,6 +289,13 @@
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Loading --- */
|
||||||
|
.blog-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Mobile --- */
|
/* --- Mobile --- */
|
||||||
@media (--mobile) {
|
@media (--mobile) {
|
||||||
.blog-header {
|
.blog-header {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue