Complète le portage des fonctionnalités du README

- Bloc « À propos » configurable (titre, 2 paragraphes, image légendée)
  + aside hashtags, masqué tant que non défini (ex-MOVEMENT_*)
- JSON-LD CollectionPage (catégories) et BreadcrumbList (vidéo, catégorie)
- Indicateur visuel de perte de connexion (online/offline)
- Verrou maintenance par compte à rebours multi-fuseaux (ex-COUNTDOWN_*) :
  build-time, titre kréyòl, redirection automatique à l'échéance
- browserconfig.xml (tuiles Windows)
This commit is contained in:
sucupira
2026-07-23 01:05:01 -04:00
parent d6efb6736f
commit 28c2334927
13 changed files with 386 additions and 33 deletions
+4
View File
@@ -42,6 +42,10 @@ Exemple de cron quotidien (sur une machine de build) :
- `manifest.webmanifest` + service worker Workbox (`vite-plugin-pwa`, generateSW) : précache du shell, `navigateFallback: /offline/index.html`. - `manifest.webmanifest` + service worker Workbox (`vite-plugin-pwa`, generateSW) : précache du shell, `navigateFallback: /offline/index.html`.
- Enregistrement par `static/registerSW.js` (chemins absolus), jamais par injection du plugin. - Enregistrement par `static/registerSW.js` (chemins absolus), jamais par injection du plugin.
## Configuration
Toute la configuration d'instance vit dans `src/lib/config.ts` (équivalent des anciennes constantes PHP) : instances agrégées, catégories prioritaires, hashtags, hero, dons, bloc « À propos » (`about`), annonce du prochain live (`nextLive`) et **verrou maintenance** (`countdown`). Quand `countdown.enabled` vaut `true`, le build rend la page de compte à rebours multi-fuseaux (titre kréyòl, redirection automatique à l'échéance) à la place de tout le site — l'équivalent statique de l'ancien `COUNTDOWN_ENABLED`.
## Vérifications avant mise en production ## Vérifications avant mise en production
```bash ```bash
+10 -2
View File
@@ -2,16 +2,24 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { Locale } from '$lib/types'; import type { Locale } from '$lib/types';
let { target, locale }: { target: string; locale: Locale } = $props(); let { target, locale, onReached = undefined }: { target: string; locale: Locale; onReached?: () => void } = $props();
let remaining = $state<{ days: number; hours: number; minutes: number; seconds: number } | null>( let remaining = $state<{ days: number; hours: number; minutes: number; seconds: number } | null>(
null null
); );
let reached = false;
function compute(): typeof remaining { function compute(): typeof remaining {
const targetTime = new Date(target.replace(' ', 'T')).getTime(); const targetTime = new Date(target.replace(' ', 'T')).getTime();
const diff = targetTime - Date.now(); const diff = targetTime - Date.now();
if (Number.isNaN(targetTime) || diff <= 0) return null; if (Number.isNaN(targetTime)) return null;
if (diff <= 0) {
if (!reached) {
reached = true;
onReached?.();
}
return null;
}
return { return {
days: Math.floor(diff / 86_400_000), days: Math.floor(diff / 86_400_000),
hours: Math.floor((diff % 86_400_000) / 3_600_000), hours: Math.floor((diff % 86_400_000) / 3_600_000),
+99
View File
@@ -0,0 +1,99 @@
<script lang="ts">
import CountdownClock from './CountdownClock.svelte';
import Icon from './Icon.svelte';
import type { Bundle } from '$lib/i18n';
import type { Locale } from '$lib/types';
let {
lock,
locale,
t
}: {
lock: { targetDate: string; schedule: { label: string; time: string }[] };
locale: Locale;
t: Bundle;
} = $props();
function onReached() {
window.location.assign('/');
}
</script>
<div class="flag-bar" aria-hidden="true"></div>
<main class="lock">
<Icon name="ka" class="lock-icon" />
<h1 class="lock-title">{t.countdown.title}</h1>
<p class="text-muted">{t.countdown.text}</p>
<CountdownClock target={lock.targetDate} {locale} {onReached} />
<h2 class="schedule-heading">{t.direct.inTimezones}</h2>
<ul class="schedule">
{#each lock.schedule as slot (slot.label)}
<li>
<span class="zone">{slot.label}</span>
<span>{slot.time}</span>
</li>
{/each}
</ul>
<p class="text-muted small">{t.countdown.back}</p>
</main>
<div class="flag-bar" aria-hidden="true"></div>
<style>
.lock {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: var(--space-3);
padding: var(--space-5) var(--space-3);
}
.lock :global(.lock-icon) {
width: 96px;
height: 96px;
}
.lock-title {
font-family: var(--font-display);
font-weight: 900;
text-transform: uppercase;
letter-spacing: -0.01em;
font-size: clamp(1.6rem, 5vw, 2.8rem);
}
.schedule-heading {
font-family: var(--font-display);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--muted);
}
.schedule {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.schedule li {
display: flex;
gap: var(--space-2);
}
.zone {
font-weight: 600;
min-width: 12ch;
color: var(--accent);
text-align: right;
}
.small {
font-size: 0.85rem;
}
</style>
@@ -0,0 +1,50 @@
<script lang="ts">
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
let { label }: { label: string } = $props();
let offline = $state(false);
onMount(() => {
const update = () => (offline = !navigator.onLine);
update();
window.addEventListener('online', update);
window.addEventListener('offline', update);
return () => {
window.removeEventListener('online', update);
window.removeEventListener('offline', update);
};
});
</script>
{#if offline}
<div class="offline-banner" role="status">
<Icon name="ka" inline />
<span>{label}</span>
</div>
{/if}
<style>
.offline-banner {
position: fixed;
bottom: var(--space-2);
left: 50%;
transform: translateX(-50%);
z-index: 300;
display: flex;
align-items: center;
gap: var(--space-1);
padding: 0.5rem var(--space-2);
background: var(--surface);
border: 1px solid var(--rouge-oki);
border-radius: var(--radius-sm);
font-size: 0.85rem;
font-weight: 600;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
.offline-banner :global(.icon) {
color: var(--rouge-oki);
}
</style>
+22
View File
@@ -164,6 +164,28 @@ export const importantTags = ['ANNUKUTECED', 'podcast', 'fediverse', 'audio', 'v
export const popularTags = ['podcast', 'annukuteced', 'fediverse', 'audio'] as const; export const popularTags = ['podcast', 'annukuteced', 'fediverse', 'audio'] as const;
export const about = {
enabled: false,
title: { fr: 'À propos', en: 'About' } as Record<'fr' | 'en', string>,
description: { fr: '', en: '' } as Record<'fr' | 'en', string>,
description2: { fr: '', en: '' } as Record<'fr' | 'en', string>,
image: '/images/movement_presentation.png',
imageAlt: { fr: '', en: '' } as Record<'fr' | 'en', string>,
imageCaption: { fr: '', en: '' } as Record<'fr' | 'en', string>
};
export const countdown = {
enabled: false,
targetDate: '2025-10-11 00:00:00',
timezones: {
"Ma'ohi Nui": 'Pacific/Tahiti',
'Martinique / Guadeloupe': 'America/Martinique',
Guyane: 'America/Cayenne',
France: 'Europe/Paris',
Kanaky: 'Pacific/Noumea'
} as Record<string, string>
};
export const instances = [ export const instances = [
{ {
name: 'GADE', name: 'GADE',
+5
View File
@@ -149,6 +149,11 @@
"text": "You are offline. Pages you already visited remain available.", "text": "You are offline. Pages you already visited remain available.",
"cta": "Back to home" "cta": "Back to home"
}, },
"countdown": {
"title": "An nou tann !",
"text": "The site is under maintenance or getting ready for launch. See you at the end of the countdown.",
"back": "You will be automatically redirected to the home page."
},
"notFound": { "notFound": {
"title": "Paj la pa la", "title": "Paj la pa la",
"text": "This page does not exist or has been moved.", "text": "This page does not exist or has been moved.",
+5
View File
@@ -149,6 +149,11 @@
"text": "Vous êtes hors ligne. Les pages déjà visitées restent disponibles.", "text": "Vous êtes hors ligne. Les pages déjà visitées restent disponibles.",
"cta": "Retour à l'accueil" "cta": "Retour à l'accueil"
}, },
"countdown": {
"title": "An nou tann !",
"text": "Le site est en maintenance ou se prépare au lancement. Rendez-vous à la fin du compte à rebours.",
"back": "Vous serez redirigé automatiquement vers l'accueil."
},
"notFound": { "notFound": {
"title": "Paj la pa la", "title": "Paj la pa la",
"text": "Cette page n'existe pas ou a été déplacée.", "text": "Cette page n'existe pas ou a été déplacée.",
+10 -1
View File
@@ -4,6 +4,8 @@
import { onNavigate } from '$app/navigation'; import { onNavigate } from '$app/navigation';
import Footer from '$lib/components/Footer.svelte'; import Footer from '$lib/components/Footer.svelte';
import Nav from '$lib/components/Nav.svelte'; import Nav from '$lib/components/Nav.svelte';
import CountdownLock from '$lib/components/CountdownLock.svelte';
import OfflineIndicator from '$lib/components/OfflineIndicator.svelte';
import Sidebar from '$lib/components/Sidebar.svelte'; import Sidebar from '$lib/components/Sidebar.svelte';
import ScrollProgressBar from '$lib/components/motion/ScrollProgressBar.svelte'; import ScrollProgressBar from '$lib/components/motion/ScrollProgressBar.svelte';
import { prefersReducedMotion } from '$lib/motion/tokens'; import { prefersReducedMotion } from '$lib/motion/tokens';
@@ -29,9 +31,15 @@
<meta name="generator" content="SvelteKit" /> <meta name="generator" content="SvelteKit" />
</svelte:head> </svelte:head>
<a href="#main-content" class="skip-link">{data.t.nav.skipToContent}</a> {#if !data.lock}
<a href="#main-content" class="skip-link">{data.t.nav.skipToContent}</a>
{/if}
<ScrollProgressBar label={data.t.a11y.progressLabel} /> <ScrollProgressBar label={data.t.a11y.progressLabel} />
<OfflineIndicator label={data.t.offline.text} />
{#if data.lock}
<CountdownLock lock={data.lock} locale={data.locale} t={data.t} />
{:else}
<Nav <Nav
locale={data.locale} locale={data.locale}
t={data.t} t={data.t}
@@ -64,6 +72,7 @@
<Footer locale={data.locale} t={data.t} /> <Footer locale={data.locale} t={data.t} />
</div> </div>
</div> </div>
{/if}
<style> <style>
.shell { .shell {
+19 -1
View File
@@ -1,7 +1,25 @@
import { countdown } from '$lib/config';
import { getBundle } from '$lib/i18n'; import { getBundle } from '$lib/i18n';
import type { LayoutLoad } from './$types'; import type { LayoutLoad } from './$types';
export const load: LayoutLoad = ({ params }) => { export const load: LayoutLoad = ({ params }) => {
const locale = params.locale === 'en' ? ('en' as const) : ('fr' as const); const locale = params.locale === 'en' ? ('en' as const) : ('fr' as const);
return { locale, t: getBundle(locale) };
let lock = null;
if (countdown.enabled && countdown.targetDate) {
const target = new Date(countdown.targetDate.replace(' ', 'T'));
lock = {
targetDate: countdown.targetDate,
schedule: Object.entries(countdown.timezones).map(([label, timeZone]) => ({
label,
time: new Intl.DateTimeFormat(locale === 'en' ? 'en-GB' : 'fr-FR', {
dateStyle: 'full',
timeStyle: 'short',
timeZone
}).format(target)
}))
};
}
return { locale, t: getBundle(locale), lock };
}; };
+78 -15
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { castopod, hero, popularTags, site } from '$lib/config'; import { castopod, hero, popularTags, site, about } from '$lib/config';
import { asset } from '$app/paths';
import AudioPlayer from '$lib/components/AudioPlayer.svelte'; import AudioPlayer from '$lib/components/AudioPlayer.svelte';
import HeroSection from '$lib/components/HeroSection.svelte'; import HeroSection from '$lib/components/HeroSection.svelte';
import MastodonTimeline from '$lib/components/MastodonTimeline.svelte'; import MastodonTimeline from '$lib/components/MastodonTimeline.svelte';
@@ -149,20 +150,49 @@
</section> </section>
{/each} {/each}
<section class="section" aria-labelledby="tags-heading"> <section class="section info-tags" aria-label={t.home.about}>
<div class="section-header" use:reveal={0}> {#if about.enabled && about.description[locale]}
<FlagChip /> <article class="info-section" use:reveal={0}>
<KineticText as="h2" id="tags-heading" class="section-title" text={t.home.popularTags} /> <div class="section-header">
</div> <FlagChip />
<ul class="tags-list"> <KineticText
{#each popularTags as tag (tag)} as="h2"
<li> class="section-title"
<a class="tag" href={hrefFor(locale, `/recherche?q=${encodeURIComponent('#' + tag)}`)}> text={about.title[locale] || t.home.about}
<Icon name="hashtag" inline /> {tag} />
</a> </div>
</li> <p class="info-description">{about.description[locale]}</p>
{/each} {#if about.description2[locale]}
</ul> <p class="info-description">{about.description2[locale]}</p>
{/if}
{#if about.imageAlt[locale]}
<figure class="movement-figure">
<img src={asset(about.image)} alt={about.imageAlt[locale]} class="info-image" loading="lazy" decoding="async" />
{#if about.imageCaption[locale]}
<figcaption class="movement-caption text-muted">
{about.imageCaption[locale]}
</figcaption>
{/if}
</figure>
{/if}
</article>
{/if}
<aside class="tags-section-container" aria-labelledby="tags-heading" use:reveal={1}>
<div class="section-header">
<FlagChip />
<KineticText as="h2" id="tags-heading" class="section-title" text={t.home.popularTags} />
</div>
<ul class="tags-list">
{#each popularTags as tag (tag)}
<li>
<a class="tag" href={hrefFor(locale, `/recherche?q=${encodeURIComponent('#' + tag)}`)}>
<Icon name="hashtag" inline /> {tag}
</a>
</li>
{/each}
</ul>
</aside>
</section> </section>
<style> <style>
@@ -199,4 +229,37 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--space-1); gap: var(--space-1);
} }
.info-tags {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--space-4);
align-items: start;
}
@media (max-width: 800px) {
.info-tags {
grid-template-columns: 1fr;
}
}
.info-description {
margin-bottom: var(--space-2);
max-width: 65ch;
}
.movement-figure {
margin-top: var(--space-2);
}
.info-image {
border-radius: var(--radius-md);
border: var(--border-card);
display: block;
}
.movement-caption {
font-size: 0.85rem;
margin-top: 0.35rem;
}
</style> </style>
@@ -4,12 +4,42 @@
import FlagChip from '$lib/components/motion/FlagChip.svelte'; import FlagChip from '$lib/components/motion/FlagChip.svelte';
import KineticText from '$lib/components/motion/KineticText.svelte'; import KineticText from '$lib/components/motion/KineticText.svelte';
import { hrefFor, t as format } from '$lib/i18n'; import { hrefFor, t as format } from '$lib/i18n';
import { site } from '$lib/config';
import { reveal } from '$lib/motion/reveal'; import { reveal } from '$lib/motion/reveal';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
const { locale, t } = $derived(data); const { locale, t } = $derived(data);
const jsonLd = $derived([
{
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: format(t.meta.categoryTitle, { name: data.name }),
url: `${site.baseUrl}${hrefFor(locale, `/categories/${data.id}`)}`,
isPartOf: { '@type': 'WebSite', name: site.name, url: `${site.baseUrl}/` },
inLanguage: locale === 'en' ? 'en' : 'fr'
},
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t.nav.home,
item: `${site.baseUrl}${hrefFor(locale, '/')}`
},
{
'@type': 'ListItem',
position: 2,
name: data.name,
item: `${site.baseUrl}${hrefFor(locale, `/categories/${data.id}`)}`
}
]
}
]);
</script> </script>
<Seo <Seo
@@ -17,6 +47,7 @@
description={format(t.meta.categoryDescription, { name: data.name })} description={format(t.meta.categoryDescription, { name: data.name })}
path={hrefFor(locale, `/categories/${data.id}`)} path={hrefFor(locale, `/categories/${data.id}`)}
{locale} {locale}
{jsonLd}
/> />
<section aria-labelledby="category-heading"> <section aria-labelledby="category-heading">
@@ -71,20 +71,50 @@
} }
]); ]);
const jsonLd = $derived({ const jsonLd = $derived([
'@context': 'https://schema.org', {
'@type': 'VideoObject', '@context': 'https://schema.org',
name: video.title, '@type': 'VideoObject',
description: video.description.slice(0, 300), name: video.title,
thumbnailUrl: video.thumbnail, description: video.description.slice(0, 300),
uploadDate: video.date, thumbnailUrl: video.thumbnail,
duration: `PT${Math.floor(video.duration / 60)}M${Math.floor(video.duration % 60)}S`, uploadDate: video.date,
embedUrl: video.embedUrl, duration: `PT${Math.floor(video.duration / 60)}M${Math.floor(video.duration % 60)}S`,
url: pageUrl, embedUrl: video.embedUrl,
...(peertube.showVideoViews url: pageUrl,
? { interactionStatistic: { '@type': 'InteractionCounter', interactionType: { '@type': 'WatchAction' }, userInteractionCount: video.views } } ...(peertube.showVideoViews
: {}) ? { interactionStatistic: { '@type': 'InteractionCounter', interactionType: { '@type': 'WatchAction' }, userInteractionCount: video.views } }
}); : {})
},
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t.nav.home,
item: `${site.baseUrl}${hrefFor(locale, '/')}`
},
...(data.categoryName && video.category
? [
{
'@type': 'ListItem',
position: 2,
name: data.categoryName,
item: `${site.baseUrl}${hrefFor(locale, `/categories/${video.category}`)}`
}
]
: []),
{
'@type': 'ListItem',
position: data.categoryName ? 3 : 2,
name: video.title,
item: pageUrl
}
]
}
]);
</script> </script>
<Seo <Seo
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/images/android-chrome-192x192.png"/>
<TileColor>#0D0D0D</TileColor>
</tile>
</msapplication>
</browserconfig>