forked from ORGANISATION-KA-INTERNATIONALE/FEDIVERSE-OKI
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:
@@ -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`.
|
||||
- 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
|
||||
|
||||
```bash
|
||||
|
||||
@@ -2,16 +2,24 @@
|
||||
import { onMount } from 'svelte';
|
||||
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>(
|
||||
null
|
||||
);
|
||||
let reached = false;
|
||||
|
||||
function compute(): typeof remaining {
|
||||
const targetTime = new Date(target.replace(' ', 'T')).getTime();
|
||||
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 {
|
||||
days: Math.floor(diff / 86_400_000),
|
||||
hours: Math.floor((diff % 86_400_000) / 3_600_000),
|
||||
|
||||
@@ -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>
|
||||
@@ -164,6 +164,28 @@ export const importantTags = ['ANNUKUTECED', 'podcast', 'fediverse', 'audio', 'v
|
||||
|
||||
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 = [
|
||||
{
|
||||
name: 'GADE',
|
||||
|
||||
@@ -149,6 +149,11 @@
|
||||
"text": "You are offline. Pages you already visited remain available.",
|
||||
"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": {
|
||||
"title": "Paj la pa la",
|
||||
"text": "This page does not exist or has been moved.",
|
||||
|
||||
@@ -149,6 +149,11 @@
|
||||
"text": "Vous êtes hors ligne. Les pages déjà visitées restent disponibles.",
|
||||
"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": {
|
||||
"title": "Paj la pa la",
|
||||
"text": "Cette page n'existe pas ou a été déplacée.",
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
import { onNavigate } from '$app/navigation';
|
||||
import Footer from '$lib/components/Footer.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 ScrollProgressBar from '$lib/components/motion/ScrollProgressBar.svelte';
|
||||
import { prefersReducedMotion } from '$lib/motion/tokens';
|
||||
@@ -29,9 +31,15 @@
|
||||
<meta name="generator" content="SvelteKit" />
|
||||
</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} />
|
||||
<OfflineIndicator label={data.t.offline.text} />
|
||||
|
||||
{#if data.lock}
|
||||
<CountdownLock lock={data.lock} locale={data.locale} t={data.t} />
|
||||
{:else}
|
||||
<Nav
|
||||
locale={data.locale}
|
||||
t={data.t}
|
||||
@@ -64,6 +72,7 @@
|
||||
<Footer locale={data.locale} t={data.t} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.shell {
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
import { countdown } from '$lib/config';
|
||||
import { getBundle } from '$lib/i18n';
|
||||
import type { LayoutLoad } from './$types';
|
||||
|
||||
export const load: LayoutLoad = ({ params }) => {
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<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 HeroSection from '$lib/components/HeroSection.svelte';
|
||||
import MastodonTimeline from '$lib/components/MastodonTimeline.svelte';
|
||||
@@ -149,20 +150,49 @@
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
<section class="section" aria-labelledby="tags-heading">
|
||||
<div class="section-header" use:reveal={0}>
|
||||
<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>
|
||||
<section class="section info-tags" aria-label={t.home.about}>
|
||||
{#if about.enabled && about.description[locale]}
|
||||
<article class="info-section" use:reveal={0}>
|
||||
<div class="section-header">
|
||||
<FlagChip />
|
||||
<KineticText
|
||||
as="h2"
|
||||
class="section-title"
|
||||
text={about.title[locale] || t.home.about}
|
||||
/>
|
||||
</div>
|
||||
<p class="info-description">{about.description[locale]}</p>
|
||||
{#if about.description2[locale]}
|
||||
<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>
|
||||
|
||||
<style>
|
||||
@@ -199,4 +229,37 @@
|
||||
flex-wrap: wrap;
|
||||
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>
|
||||
|
||||
@@ -4,12 +4,42 @@
|
||||
import FlagChip from '$lib/components/motion/FlagChip.svelte';
|
||||
import KineticText from '$lib/components/motion/KineticText.svelte';
|
||||
import { hrefFor, t as format } from '$lib/i18n';
|
||||
import { site } from '$lib/config';
|
||||
import { reveal } from '$lib/motion/reveal';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
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>
|
||||
|
||||
<Seo
|
||||
@@ -17,6 +47,7 @@
|
||||
description={format(t.meta.categoryDescription, { name: data.name })}
|
||||
path={hrefFor(locale, `/categories/${data.id}`)}
|
||||
{locale}
|
||||
{jsonLd}
|
||||
/>
|
||||
|
||||
<section aria-labelledby="category-heading">
|
||||
|
||||
@@ -71,20 +71,50 @@
|
||||
}
|
||||
]);
|
||||
|
||||
const jsonLd = $derived({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'VideoObject',
|
||||
name: video.title,
|
||||
description: video.description.slice(0, 300),
|
||||
thumbnailUrl: video.thumbnail,
|
||||
uploadDate: video.date,
|
||||
duration: `PT${Math.floor(video.duration / 60)}M${Math.floor(video.duration % 60)}S`,
|
||||
embedUrl: video.embedUrl,
|
||||
url: pageUrl,
|
||||
...(peertube.showVideoViews
|
||||
? { interactionStatistic: { '@type': 'InteractionCounter', interactionType: { '@type': 'WatchAction' }, userInteractionCount: video.views } }
|
||||
: {})
|
||||
});
|
||||
const jsonLd = $derived([
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'VideoObject',
|
||||
name: video.title,
|
||||
description: video.description.slice(0, 300),
|
||||
thumbnailUrl: video.thumbnail,
|
||||
uploadDate: video.date,
|
||||
duration: `PT${Math.floor(video.duration / 60)}M${Math.floor(video.duration % 60)}S`,
|
||||
embedUrl: video.embedUrl,
|
||||
url: pageUrl,
|
||||
...(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>
|
||||
|
||||
<Seo
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user