Réécriture SvelteKit statique (Svelte 5 runes + TS, adapter-static)

- Données PeerTube (GADE), Castopod (KUTE) et Mastodon (BOKANTE) bakées au
  build via +page.server.ts (throttle + retry 429 + déduplication)
- Charte OKI complète : tokens, thème sombre par défaut (clair opt-in,
  anti-FOUC), Archivo/Inter self-hébergées, flag-bar, sprite SVG (zéro emoji
  en interface, zéro Font Awesome/CDN)
- i18n FR/EN par routage [[locale]], bundles JSON, hreflang, %lang% serveur
- Pages : accueil, /video/[uuid] (embed, téléchargements, partage,
  commentaires, JSON-LD VideoObject), /categories/[id], /recherche (index
  JSON baké, recherche client), /direct (live + annonce multi-fuseaux),
  /dons, /mentions-legales, /offline + 404 kréyòl
- Motion gwoka : KineticText (scrub view()), ScrollProgressBar, FlagChip,
  reveal syncopé, View Transitions, gate prefers-reduced-motion unique
- PWA : manifest + Workbox generateSW (fallback /offline/), registerSW
  statique
- Sécurité : CSP par page en <meta> (SHA-256 des inline via
  scripts/postbuild-csp.mjs) + headers globaux (_headers Cloudflare et
  .htaccess o2switch), redirections des anciennes URLs PHP
- Doc : docs/DEPLOIEMENT-SVELTEKIT.md
This commit is contained in:
sucupira
2026-07-23 00:49:02 -04:00
parent 5b1a78782a
commit d6efb6736f
147 changed files with 14567 additions and 15145 deletions
+317
View File
@@ -0,0 +1,317 @@
<script lang="ts">
import { onMount } from 'svelte';
import { formatDate, formatDuration, t as format, type Bundle } from '$lib/i18n';
import type { Episode, Locale } from '$lib/types';
import Icon from './Icon.svelte';
let {
episodes,
locale,
t
}: {
episodes: Episode[];
locale: Locale;
t: Bundle;
} = $props();
let audio: HTMLAudioElement;
let currentIndex = $state(-1);
let playing = $state(false);
let currentTime = $state(0);
let duration = $state(0);
let muted = $state(false);
let canSetVolume = $state(true);
const current = $derived(currentIndex >= 0 ? episodes[currentIndex] : null);
function detectVolumeSupport(): boolean {
const probe = document.createElement('audio');
try {
probe.volume = 0.5;
return probe.volume === 0.5;
} catch {
return false;
}
}
onMount(() => {
canSetVolume = detectVolumeSupport();
});
function updateMediaSession(episode: Episode) {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.metadata = new MediaMetadata({
title: episode.title,
artist: 'ANNU KUTE CED',
album: episode.podcast,
artwork: episode.image ? [{ src: episode.image, sizes: '512x512' }] : []
});
navigator.mediaSession.setActionHandler('play', () => audio?.play());
navigator.mediaSession.setActionHandler('pause', () => audio?.pause());
navigator.mediaSession.setActionHandler('nexttrack', () => playAt(currentIndex + 1));
navigator.mediaSession.setActionHandler('previoustrack', () => playAt(currentIndex - 1));
}
function playAt(index: number) {
if (index < 0 || index >= episodes.length) return;
currentIndex = index;
const episode = episodes[index];
audio.src = episode.audioUrl;
audio.play();
updateMediaSession(episode);
}
function toggle(index: number) {
if (currentIndex === index && playing) {
audio.pause();
} else if (currentIndex === index) {
audio.play();
} else {
playAt(index);
}
}
function onEnded() {
if (currentIndex + 1 < episodes.length) {
playAt(currentIndex + 1);
} else {
playing = false;
currentIndex = -1;
}
}
function onSeek(event: Event) {
const input = event.currentTarget as HTMLInputElement;
audio.currentTime = Number(input.value);
}
</script>
<audio
bind:this={audio}
preload="none"
onplay={() => (playing = true)}
onpause={() => (playing = false)}
onended={onEnded}
ontimeupdate={() => (currentTime = audio.currentTime)}
ondurationchange={() => (duration = audio.duration)}
onvolumechange={() => (muted = audio.muted)}
></audio>
<div class="player" class:has-current={current !== null}>
<ul class="episode-list">
{#each episodes as episode, i (episode.id)}
<li class="episode" class:active={currentIndex === i}>
{#if episode.image}
<img
class="episode-cover"
src={episode.image}
alt={format(t.a11y.episodeCover, { title: episode.title })}
loading="lazy"
decoding="async"
width="64"
height="64"
/>
{/if}
<div class="episode-body">
<h3 class="episode-title">
<a href={episode.link} target="_blank" rel="external noopener noreferrer">
{episode.title}
</a>
</h3>
<p class="episode-meta text-muted">
{format(t.podcast.episodeFrom, { date: formatDate(episode.date, locale) })}
{#if episode.duration > 0}· {formatDuration(episode.duration)}{/if}
</p>
</div>
<button
class="play-button"
onclick={() => toggle(i)}
aria-label={currentIndex === i && playing
? t.podcast.pause
: format(t.podcast.play, { title: episode.title })}
>
<Icon name={currentIndex === i && playing ? 'pause' : 'play'} />
</button>
</li>
{/each}
</ul>
{#if current}
<div class="now-playing" role="region" aria-label={t.podcast.nowPlaying}>
<div class="now-info">
<span class="now-label">{t.podcast.nowPlaying}</span>
<span class="now-title">{current.title}</span>
</div>
<input
type="range"
class="seek"
min="0"
max={duration || 0}
step="0.1"
value={currentTime}
oninput={onSeek}
aria-label={t.podcast.nowPlaying}
/>
<div class="now-controls">
<span class="time text-muted">{formatDuration(currentTime)} / {formatDuration(duration || current.duration)}</span>
<button class="ctrl" onclick={() => playAt(currentIndex + 1)} disabled={currentIndex + 1 >= episodes.length} aria-label={t.podcast.next}>
<Icon name="next" />
</button>
{#if canSetVolume}
<button class="ctrl" onclick={() => (audio.muted = !audio.muted)} aria-label={muted ? t.podcast.unmute : t.podcast.mute}>
<Icon name={muted ? 'mute' : 'volume'} />
</button>
{/if}
</div>
</div>
{/if}
</div>
<style>
.episode-list {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.episode {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1);
background: var(--card-bg);
border: var(--border-card);
border-left: 4px solid transparent;
border-radius: var(--radius-md);
transition:
border-color var(--dur-tanbou) var(--ease-ka),
transform var(--dur-tanbou) var(--ease-ka);
}
.episode:hover {
transform: translateY(-2px);
border-color: var(--accent);
}
.episode.active {
border-left-color: var(--accent);
}
.episode-cover {
width: 64px;
height: 64px;
object-fit: cover;
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.episode-body {
flex: 1;
min-width: 0;
}
.episode-title {
font-size: 0.95rem;
font-weight: 600;
}
.episode-title a:hover {
color: var(--accent);
}
.episode-meta {
font-size: 0.8rem;
}
.play-button {
display: grid;
place-items: center;
width: 44px;
height: 44px;
flex-shrink: 0;
border: var(--border-btn);
border-radius: var(--radius-sm);
color: var(--accent);
transition:
background var(--dur-tanbou) var(--ease-syncope),
color var(--dur-tanbou) var(--ease-syncope);
}
.play-button:hover {
background: var(--accent);
color: #0d0d0d;
}
.now-playing {
position: sticky;
bottom: 0;
margin-top: var(--space-2);
padding: var(--space-1) var(--space-2);
background: var(--surface);
border: 1px solid var(--accent);
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.now-info {
display: flex;
align-items: baseline;
gap: var(--space-1);
min-width: 0;
}
.now-label {
font-family: var(--font-display);
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--accent);
flex-shrink: 0;
}
.now-title {
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.seek {
width: 100%;
accent-color: var(--or-oki);
}
.now-controls {
display: flex;
align-items: center;
gap: var(--space-1);
}
.time {
font-size: 0.75rem;
margin-right: auto;
}
.ctrl {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
color: var(--accent);
}
.ctrl:disabled {
opacity: 0.4;
cursor: default;
}
.ctrl:not(:disabled):hover {
background: var(--card-bg);
}
</style>
+80
View File
@@ -0,0 +1,80 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Locale } from '$lib/types';
let { target, locale }: { target: string; locale: Locale } = $props();
let remaining = $state<{ days: number; hours: number; minutes: number; seconds: number } | null>(
null
);
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;
return {
days: Math.floor(diff / 86_400_000),
hours: Math.floor((diff % 86_400_000) / 3_600_000),
minutes: Math.floor((diff % 3_600_000) / 60_000),
seconds: Math.floor((diff % 60_000) / 1000)
};
}
onMount(() => {
remaining = compute();
const interval = setInterval(() => (remaining = compute()), 1000);
return () => clearInterval(interval);
});
const labels = $derived(
locale === 'en'
? { days: 'days', hours: 'hours', minutes: 'minutes', seconds: 'seconds' }
: { days: 'jours', hours: 'heures', minutes: 'minutes', seconds: 'secondes' }
);
</script>
{#if remaining}
<div class="countdown" role="timer" aria-live="off">
{#each [['days', remaining.days], ['hours', remaining.hours], ['minutes', remaining.minutes], ['seconds', remaining.seconds]] as [key, value] (key)}
<div class="unit">
<span class="value">{String(value).padStart(2, '0')}</span>
<span class="label">{labels[key as keyof typeof labels]}</span>
</div>
{/each}
</div>
{/if}
<style>
.countdown {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.unit {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-1) var(--space-2);
background: var(--card-bg);
border: var(--border-card);
border-top: 4px solid var(--accent);
border-radius: var(--radius-sm);
min-width: 80px;
}
.value {
font-family: var(--font-display);
font-weight: 900;
font-size: 2rem;
font-variant-numeric: tabular-nums;
color: var(--accent);
}
.label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--muted);
}
</style>
+235
View File
@@ -0,0 +1,235 @@
<script lang="ts">
import { page } from '$app/state';
import { asset } from '$app/paths';
import { categories, categoryIcons, importantTags, instances, site, social } from '$lib/config';
import { alternatePath, hrefFor, stripLocale, type Bundle } from '$lib/i18n';
import type { Locale } from '$lib/types';
import Icon from './Icon.svelte';
let { locale, t }: { locale: Locale; t: Bundle } = $props();
const year = new Date().getFullYear();
const alternate = $derived(alternatePath(page.url.pathname, locale === 'en' ? 'fr' : 'en'));
const currentPath = $derived(stripLocale(page.url.pathname));
const socialLinks = $derived(
[
{ name: 'mastodon', url: social.mastodon, label: 'Mastodon', rel: 'me noreferrer' },
{ name: 'facebook', url: social.facebook, label: 'Facebook', rel: 'noreferrer' },
{ name: 'youtube', url: social.youtube, label: 'YouTube', rel: 'noreferrer' },
{ name: 'instagram', url: social.instagram, label: 'Instagram', rel: 'noreferrer' },
{ name: 'x-logo', url: social.x, label: 'X', rel: 'noreferrer' },
{ name: 'tiktok', url: social.tiktok, label: 'TikTok', rel: 'noreferrer' }
].filter((link) => link.url)
);
</script>
<div class="flag-bar" aria-hidden="true"></div>
<footer class="footer">
<div class="footer-header">
<a href={hrefFor(locale, '/')} class="footer-logo">
<img src={asset(site.logo)} alt={t.a11y.logo} width="160" height="160" />
</a>
<div class="footer-contact-info">
<div class="footer-contact">{t.footer.contact}</div>
<div class="footer-email">
<a href="mailto:{site.contactEmail}">{site.contactEmail}</a>
</div>
</div>
</div>
<div class="footer-columns">
<div class="footer-column">
<h2 class="footer-title">{t.footer.categories}</h2>
<ul class="footer-links">
<li>
<a href={hrefFor(locale, '/')} class:active={currentPath === '/'}>{t.nav.home}</a>
</li>
<li>
<a href={hrefFor(locale, '/direct')} class:active={currentPath === '/direct/'}>
{t.nav.direct}
</a>
</li>
{#each categories as category (category.id)}
<li>
<a
href={hrefFor(locale, `/categories/${category.id}`)}
class:active={currentPath === `/categories/${category.id}/`}
>
{locale === 'en' ? category.en : category.fr}
</a>
</li>
{/each}
</ul>
</div>
<div class="footer-column">
<h2 class="footer-title">{t.footer.hashtags}</h2>
<ul class="footer-links">
{#each importantTags as tag (tag)}
<li>
<a href={hrefFor(locale, `/recherche?q=${encodeURIComponent('#' + tag)}`)}>{tag}</a>
</li>
{/each}
</ul>
</div>
<div class="footer-column">
<h2 class="footer-title">{t.footer.instances}</h2>
<ul class="footer-links">
{#each instances as instance (instance.name)}
<li>
<a href={instance.url} target="_blank" rel="external noopener noreferrer">
{instance.name}{instance.software}
</a>
</li>
{/each}
</ul>
</div>
<div class="footer-column">
<h2 class="footer-title">{t.footer.legal}</h2>
<ul class="footer-links">
<li>
<a href={hrefFor(locale, '/mentions-legales')}>{t.footer.legalNotice}</a>
</li>
<li>
<a href={site.sourceCodeUrl} target="_blank" rel="external noopener noreferrer">
<Icon name="git" inline /> {t.footer.sourceCode}
</a>
</li>
<li>
<a href={alternate} hreflang={locale === 'en' ? 'fr' : 'en'} rel="alternate">
<Icon name="pawol" inline /> {t.nav.langSwitch}
</a>
</li>
</ul>
</div>
</div>
{#if socialLinks.length > 0}
<div class="footer-social">
{#each socialLinks as link (link.name)}
<a
href={link.url}
target="_blank"
rel="external {link.rel}"
aria-label={link.label}
class="icon-button"
>
<Icon name={link.name} />
</a>
{/each}
</div>
{/if}
<p class="footer-federated">
{t.footer.federated} ·
<a href="https://o-k-i.net" target="_blank" rel="external noopener noreferrer">o-k-i.net</a>
</p>
<p class="footer-copyright">
{site.name} {year}
<a href={site.licenseUrl} target="_blank" rel="external noopener noreferrer">
{t.footer.copyright}
</a>
</p>
</footer>
<style>
.footer {
background: var(--surface);
padding: var(--space-4) var(--space-3) var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.footer-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
}
.footer-logo img {
display: block;
width: 80px;
}
.footer-contact {
font-family: var(--font-display);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
font-size: 0.8rem;
color: var(--muted);
}
.footer-email a {
color: var(--accent);
}
.footer-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--space-3);
}
.footer-title {
font-family: var(--font-display);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
font-size: 0.85rem;
margin-bottom: var(--space-1);
color: var(--accent);
}
.footer-links {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.footer-links a {
color: var(--muted);
transition: color var(--dur-tanbou) var(--ease-ka);
}
.footer-links a:hover,
.footer-links a.active {
color: var(--accent);
}
.footer-social {
display: flex;
gap: var(--space-1);
}
.icon-button {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: var(--radius-sm);
color: var(--accent);
transition: background var(--dur-tanbou) var(--ease-ka);
}
.icon-button:hover {
background: var(--card-bg);
}
.footer-federated,
.footer-copyright {
font-size: 0.85rem;
color: var(--muted);
}
.footer-federated a,
.footer-copyright a {
color: var(--accent);
}
</style>
+109
View File
@@ -0,0 +1,109 @@
<script lang="ts">
import { hero, peertube } from '$lib/config';
import { hrefFor, type Bundle } from '$lib/i18n';
import type { Locale, VideoSummary } from '$lib/types';
import Icon from './Icon.svelte';
let {
live,
locale,
t
}: {
live: VideoSummary | null;
locale: Locale;
t: Bundle;
} = $props();
const embedSrc = $derived(
hero.type === 'live' && live
? `${peertube.url}/videos/embed/${live.id}?autoplay=0`
: hero.type === 'video'
? `${peertube.url}/videos/embed/${hero.videoId}`
: hero.type === 'playlist' && hero.playlistInstanceUrl && hero.playlistId
? `${hero.playlistInstanceUrl}/videos/embed?playlistId=${hero.playlistId}`
: null
);
const embedTitle = $derived(live?.title ?? hero.videoTitle);
</script>
{#if embedSrc}
<section class="hero" aria-label={t.home.watchLive}>
{#if hero.type === 'live' && live}
<p class="live-flag">
<Icon name="live-dot" class="live-icon" />
<span class="live-text">{t.home.liveNow}</span>
<a href={hrefFor(locale, '/direct')} class="live-link">{t.home.watchLive}</a>
</p>
{/if}
<div class="hero-frame">
<iframe
src={embedSrc}
title={embedTitle}
allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen
sandbox="allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms"
></iframe>
</div>
</section>
{/if}
<style>
.hero {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.live-flag {
display: flex;
align-items: center;
gap: var(--space-1);
font-family: var(--font-display);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.live-flag :global(.live-icon) {
color: var(--rouge-oki);
animation: pulse 1.2s var(--ease-syncope) infinite;
}
.live-text {
color: var(--rouge-oki);
}
.live-link {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 0.15em;
}
.hero-frame {
aspect-ratio: 16 / 9;
border: var(--border-card);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--surface);
}
.hero-frame iframe {
width: 100%;
height: 100%;
border: 0;
display: block;
}
@keyframes pulse {
50% {
opacity: 0.35;
}
}
@media (prefers-reduced-motion: reduce) {
.live-flag :global(.live-icon) {
animation: none;
}
}
</style>
+10
View File
@@ -0,0 +1,10 @@
<script lang="ts">
import { asset } from '$app/paths';
let { name, class: klass = '', inline = false }: { name: string; class?: string; inline?: boolean } =
$props();
</script>
<svg class={['icon', klass, { 'icon--inline': inline }]} aria-hidden="true">
<use href={asset(`/icons.svg#${name}`)} />
</svg>
+107
View File
@@ -0,0 +1,107 @@
<script lang="ts">
import { mastodon } from '$lib/config';
import { formatDate, t as format, type Bundle } from '$lib/i18n';
import type { Locale, Post } from '$lib/types';
import Icon from './Icon.svelte';
let {
posts,
locale,
t
}: {
posts: Post[];
locale: Locale;
t: Bundle;
} = $props();
</script>
<ol class="timeline">
{#each posts as post (post.id)}
<li class="post card">
<header class="post-header">
<Icon name="mastodon" />
<a
href={post.url}
target="_blank"
rel="external noopener noreferrer"
class="post-date text-muted"
>
{format(t.timeline.postFrom, { date: formatDate(post.date, locale) })}
</a>
</header>
<!-- Contenu first-party : posts du compte Mastodon du site -->
<div class="post-content">{@html post.contentHtml}</div>
{#if post.media.length > 0}
<div class="post-media">
{#each post.media as media (media.url)}
<img
src={media.previewUrl ?? media.url}
alt={media.description || t.timeline.mediaAlt}
loading="lazy"
decoding="async"
/>
{/each}
</div>
{/if}
</li>
{/each}
</ol>
<p class="more">
<a href={mastodon.accountUrl} target="_blank" rel="external me noreferrer" class="btn">
<Icon name="mastodon" /> <span>{t.home.seeMorePosts}</span>
</a>
</p>
<style>
.timeline {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.post {
padding: var(--space-2);
border-left-color: var(--accent);
}
.post-header {
display: flex;
align-items: center;
gap: var(--space-1);
margin-bottom: 0.5rem;
}
.post-date {
font-size: 0.8rem;
}
.post-content {
font-size: 0.95rem;
overflow-wrap: anywhere;
}
.post-content :global(a) {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 0.15em;
}
.post-media {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--space-1);
margin-top: var(--space-1);
}
.post-media img {
border-radius: var(--radius-sm);
border: var(--border-card);
width: 100%;
}
.more {
margin-top: var(--space-2);
}
</style>
+219
View File
@@ -0,0 +1,219 @@
<script lang="ts">
import { onMount } from 'svelte';
import { donations, social, site } from '$lib/config';
import { hrefFor, type Bundle } from '$lib/i18n';
import type { Locale } from '$lib/types';
import Icon from './Icon.svelte';
let {
locale,
t,
menuOpen,
onToggleMenu
}: {
locale: Locale;
t: Bundle;
menuOpen: boolean;
onToggleMenu: () => void;
} = $props();
let light = $state(false);
let installPrompt: BeforeInstallPromptEvent | null = $state.raw(null);
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
}
onMount(() => {
light = document.documentElement.classList.contains('light-theme');
const onBeforeInstall = (event: Event) => {
event.preventDefault();
installPrompt = event as BeforeInstallPromptEvent;
};
window.addEventListener('beforeinstallprompt', onBeforeInstall);
return () => window.removeEventListener('beforeinstallprompt', onBeforeInstall);
});
function toggleTheme() {
light = !light;
document.documentElement.classList.toggle('light-theme', light);
try {
localStorage.setItem('kute-theme', light ? 'light' : 'dark');
} catch {
// stockage indisponible : préférence non persistée
}
}
async function install() {
if (!installPrompt) return;
await installPrompt.prompt();
installPrompt = null;
}
const socialLinks = $derived(
[
{ name: 'mastodon', url: social.mastodon, label: 'Mastodon', rel: 'me noreferrer' },
{ name: 'instagram', url: social.instagram, label: 'Instagram', rel: 'noreferrer' },
{ name: 'tiktok', url: social.tiktok, label: 'TikTok', rel: 'noreferrer' },
{ name: 'facebook', url: social.facebook, label: 'Facebook', rel: 'noreferrer' },
{ name: 'youtube', url: social.youtube, label: 'YouTube', rel: 'noreferrer' },
{ name: 'x-logo', url: social.x, label: 'X', rel: 'noreferrer' }
].filter((link) => link.url)
);
</script>
<header class="header">
<div class="search-container">
<form action={hrefFor(locale, '/recherche')} method="get" role="search" aria-label={t.nav.searchLabel}>
<label for="search-input" class="sr-only">{t.nav.searchLabel}</label>
<input
type="search"
id="search-input"
name="q"
placeholder={t.nav.searchPlaceholder}
aria-describedby="search-help"
/>
<button type="submit" aria-label={t.nav.searchButton}>
<Icon name="search" />
</button>
<div id="search-help" class="sr-only">{t.nav.searchHelp}</div>
</form>
</div>
<nav class="social-icons" aria-label={t.nav.socialLabel}>
{#each socialLinks as link (link.name)}
<a
href={link.url}
target="_blank"
rel="external {link.rel}"
class="icon-button"
aria-label={link.label}
>
<Icon name={link.name} />
</a>
{/each}
</nav>
<div class="action-icons">
{#if donations.enabled}
<a
href={hrefFor(locale, '/dons')}
class="icon-button"
aria-label={t.nav.donate}
title={t.nav.donate}
>
<Icon name="heart" />
</a>
{/if}
<button class="icon-button" onclick={toggleTheme} aria-label={t.nav.themeToggle} title={t.nav.themeToggle}>
<Icon name={light ? 'moon' : 'sun'} />
</button>
{#if installPrompt}
<button class="icon-button" onclick={install} aria-label={t.nav.installApp} title={t.nav.installApp}>
<Icon name="download" />
</button>
{/if}
<button
class="icon-button mobile-menu-toggle"
onclick={onToggleMenu}
aria-expanded={menuOpen}
aria-controls="mobile-menu"
aria-label={menuOpen ? t.nav.closeMenu : t.nav.openMenu}
>
<Icon name={menuOpen ? 'close' : 'menu'} />
</button>
</div>
</header>
<div class="flag-bar" aria-hidden="true"></div>
<style>
.header {
position: sticky;
top: 0;
z-index: 200;
display: flex;
align-items: center;
gap: var(--space-3);
height: var(--header-h);
padding: 0 var(--space-3);
background: var(--surface);
}
.search-container {
flex: 1;
max-width: 560px;
}
form {
display: flex;
align-items: stretch;
border: var(--border-card);
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--card-bg);
}
form:focus-within {
border-color: var(--accent);
}
input {
flex: 1;
min-width: 0;
padding: 0.5rem var(--space-1);
background: transparent;
border: none;
color: var(--fg);
}
input:focus-visible {
outline: none;
}
form button {
display: grid;
place-items: center;
padding: 0 var(--space-2);
color: var(--accent);
}
.social-icons,
.action-icons {
display: flex;
align-items: center;
gap: 0.25rem;
}
.social-icons {
margin-left: auto;
}
.icon-button {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: var(--radius-sm);
color: var(--accent);
transition: background var(--dur-tanbou) var(--ease-ka);
}
.icon-button:hover {
background: var(--card-bg);
}
.mobile-menu-toggle {
display: none;
}
@media (max-width: 900px) {
.social-icons {
display: none;
}
.mobile-menu-toggle {
display: grid;
}
}
</style>
+63
View File
@@ -0,0 +1,63 @@
<script lang="ts">
import { page } from '$app/state';
import { site } from '$lib/config';
import { alternatePath, localeFromPath } from '$lib/i18n';
import type { Locale } from '$lib/types';
let {
title,
description,
path = '',
locale = 'fr',
type = 'website',
image = site.ogImage,
jsonLd = null
}: {
title: string;
description: string;
path?: string;
locale?: Locale;
type?: string;
image?: string;
jsonLd?: Record<string, unknown> | Record<string, unknown>[] | null;
} = $props();
const canonical = $derived(`${site.baseUrl}${path}`);
const frPath = $derived(alternatePath(page.url.pathname, 'fr'));
const enPath = $derived(alternatePath(page.url.pathname, 'en'));
const imageUrl = $derived(image.startsWith('http') ? image : `${site.baseUrl}${image}`);
// JSON-LD first-party (généré au build), balise fermante échappée pour {@html}
const jsonLdHtml = $derived(
jsonLd ? JSON.stringify(jsonLd).replaceAll('</', '<\\/') : null
);
</script>
<svelte:head>
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
<link rel="alternate" hreflang="fr" href="{site.baseUrl}{frPath}" />
<link rel="alternate" hreflang="en" href="{site.baseUrl}{enPath}" />
<link rel="alternate" hreflang="x-default" href="{site.baseUrl}{frPath}" />
<meta property="og:type" content={type} />
<meta property="og:site_name" content={site.name} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={imageUrl} />
<meta property="og:image:width" content="512" />
<meta property="og:image:height" content="512" />
<meta property="og:locale" content={locale === 'en' ? 'en_US' : 'fr_FR'} />
<meta property="og:locale:alternate" content={locale === 'en' ? 'fr_FR' : 'en_US'} />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={imageUrl} />
{#if jsonLdHtml}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- JSON-LD first-party généré au build -->
{@html `<script type="application/ld+json">${jsonLdHtml}<\/script>`}
{/if}
</svelte:head>
+99
View File
@@ -0,0 +1,99 @@
<script lang="ts">
import { formatDuration, hrefFor, t as format, type Bundle } from '$lib/i18n';
import type { Locale, VideoSummary } from '$lib/types';
let {
videos,
locale,
t
}: {
videos: VideoSummary[];
locale: Locale;
t: Bundle;
} = $props();
</script>
<div class="shorts-carousel" role="region" aria-label={t.home.shorts}>
{#each videos as video (video.id)}
<a href={hrefFor(locale, `/video/${video.id}`)} class="short-card">
<div class="short-thumb">
<img
src={video.thumbnail}
alt={format(t.a11y.videoThumbnail, { title: video.title })}
loading="lazy"
decoding="async"
/>
<span class="short-duration">{formatDuration(video.duration)}</span>
</div>
<span class="short-title">{video.title}</span>
</a>
{/each}
</div>
<style>
.shorts-carousel {
display: flex;
gap: var(--space-2);
overflow-x: auto;
scroll-snap-type: x mandatory;
padding-bottom: var(--space-1);
-webkit-overflow-scrolling: touch;
}
.short-card {
flex: 0 0 160px;
scroll-snap-align: start;
display: flex;
flex-direction: column;
gap: 0.4rem;
border-radius: var(--radius-md);
transition: transform var(--dur-tanbou) var(--ease-ka);
}
.short-card:hover {
transform: translateY(-2px);
}
.short-thumb {
position: relative;
aspect-ratio: 9 / 16;
border-radius: var(--radius-md);
overflow: hidden;
border: var(--border-card);
background: var(--surface);
}
.short-card:hover .short-thumb {
border-color: var(--accent);
}
.short-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.short-duration {
position: absolute;
right: 0.3rem;
bottom: 0.3rem;
padding: 0.05rem 0.35rem;
font-size: 0.7rem;
font-weight: 600;
background: rgba(13, 13, 13, 0.85);
color: #fff8e7;
border-radius: var(--radius-sm);
}
.short-title {
font-size: 0.8rem;
font-weight: 600;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
+169
View File
@@ -0,0 +1,169 @@
<script lang="ts">
import { page } from '$app/state';
import { asset } from '$app/paths';
import { onMount } from 'svelte';
import { categories, categoryIcons, donations, hero, importantTags, site } from '$lib/config';
import { hrefFor, stripLocale, type Bundle } from '$lib/i18n';
import type { Locale } from '$lib/types';
import Icon from './Icon.svelte';
let { locale, t, onNavigate = undefined }: { locale: Locale; t: Bundle; onNavigate?: () => void } =
$props();
const currentPath = $derived(stripLocale(page.url.pathname));
let currentTag = $state('');
onMount(() => {
const query = new URLSearchParams(window.location.search).get('q') ?? '';
currentTag = query.startsWith('#') ? query.slice(1) : '';
});
function isActive(path: string): boolean {
if (path === '/') return currentPath === '/';
return currentPath.startsWith(path);
}
function ariaCurrent(active: boolean): 'page' | undefined {
return active ? 'page' : undefined;
}
</script>
<div class="sidebar-inner">
<a href={hrefFor(locale, '/')} class="logo" aria-label={t.nav.backHome} onclick={onNavigate}>
<img src={asset(site.logo)} alt={t.a11y.logo} width="160" height="160" />
</a>
<nav class="sidebar-nav" aria-label={t.nav.mainLabel}>
<a
href={hrefFor(locale, '/')}
class="nav-item"
class:active={isActive('/') && !currentPath.startsWith('/recherche')}
aria-current={ariaCurrent(isActive('/'))}
onclick={onNavigate}
>
<Icon name="home" /> <span>{t.nav.home}</span>
</a>
{#if hero.type === 'live'}
<a
href={hrefFor(locale, '/direct')}
class="nav-item"
class:active={isActive('/direct')}
aria-current={ariaCurrent(isActive('/direct'))}
onclick={onNavigate}
>
<Icon name="broadcast" /> <span>{t.nav.direct}</span>
</a>
{/if}
{#if donations.enabled}
<a
href={hrefFor(locale, '/dons')}
class="nav-item"
class:active={isActive('/dons')}
aria-current={ariaCurrent(isActive('/dons'))}
onclick={onNavigate}
>
<Icon name="heart" /> <span>{t.nav.donate}</span>
</a>
{/if}
<div class="nav-divider" role="separator"></div>
<h2 class="nav-heading">{t.nav.categories}</h2>
{#each categories as category (category.id)}
{@const path = `/categories/${category.id}`}
<a
href={hrefFor(locale, path)}
class="nav-item"
class:active={isActive(path)}
aria-current={ariaCurrent(isActive(path))}
onclick={onNavigate}
>
<Icon name={categoryIcons[category.id] ?? 'zetwal'} />
<span>{locale === 'en' ? category.en : category.fr}</span>
</a>
{/each}
<div class="nav-divider" role="separator"></div>
<h2 class="nav-heading"><Icon name="hashtag" inline /> {t.nav.hashtags}</h2>
{#each importantTags as tag (tag)}
<a
href={hrefFor(locale, `/recherche?q=${encodeURIComponent('#' + tag)}`)}
class="nav-item tag-item"
class:active={currentTag.toLowerCase() === tag.toLowerCase()}
aria-current={ariaCurrent(currentTag.toLowerCase() === tag.toLowerCase())}
onclick={onNavigate}
>
<Icon name="hashtag" /> <span>{tag}</span>
</a>
{/each}
</nav>
</div>
<style>
.sidebar-inner {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-3) var(--space-2);
}
.logo {
display: block;
width: 120px;
margin: 0 auto;
}
.logo img {
display: block;
width: 100%;
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-item {
display: flex;
align-items: center;
gap: var(--space-1);
padding: 0.5rem var(--space-1);
border-radius: var(--radius-sm);
border-left: 4px solid transparent;
transition:
background var(--dur-tanbou) var(--ease-ka),
border-color var(--dur-tanbou) var(--ease-ka);
}
.nav-item:hover {
background: var(--card-bg);
}
.nav-item.active {
background: var(--card-bg);
border-left-color: var(--accent);
}
.nav-heading {
font-family: var(--font-display);
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--muted);
padding: 0.5rem var(--space-1) 0.25rem;
}
.nav-divider {
height: 1px;
background: var(--line);
margin: var(--space-1) 0;
}
</style>
+147
View File
@@ -0,0 +1,147 @@
<script lang="ts">
import { peertube } from '$lib/config';
import { formatDate, formatDuration, formatViewCount, hrefFor, t as format, type Bundle } from '$lib/i18n';
import type { Locale, VideoSummary } from '$lib/types';
let {
video,
locale,
t,
eager = false
}: {
video: VideoSummary;
locale: Locale;
t: Bundle;
eager?: boolean;
} = $props();
</script>
<article class="video-card">
<a href={hrefFor(locale, `/video/${video.id}`)} class="thumbnail-link">
<div class="thumbnail-wrap">
<img
src={video.thumbnail}
alt={format(t.a11y.videoThumbnail, { title: video.title })}
loading={eager ? 'eager' : 'lazy'}
decoding="async"
/>
<span class="video-duration" aria-label="{t.video.duration}: {formatDuration(video.duration)}">
{#if video.isLive}
<span class="live-badge">{t.home.liveNow}</span>
{:else}
{formatDuration(video.duration)}
{/if}
</span>
</div>
</a>
<div class="video-info">
<img
class="channel-avatar"
src={video.channelAvatar}
alt={format(t.a11y.channelAvatar, { channel: video.channel })}
loading="lazy"
decoding="async"
width="36"
height="36"
/>
<div class="video-meta">
<h3 class="video-title">
<a href={hrefFor(locale, `/video/${video.id}`)}>{video.title}</a>
</h3>
<p class="video-channel">{video.channel}</p>
<p class="video-stats text-muted">
{#if peertube.showVideoViews}
{format(t.video.views, { count: formatViewCount(video.views) })} ·
{/if}
{formatDate(video.date, locale)}
</p>
</div>
</div>
</article>
<style>
.video-card {
background: var(--card-bg);
border: var(--border-card);
border-radius: var(--radius-md);
overflow: hidden;
transition:
transform var(--dur-tanbou) var(--ease-ka),
border-color var(--dur-tanbou) var(--ease-ka);
}
.video-card:hover {
transform: translateY(-2px);
border-color: var(--accent);
}
.thumbnail-wrap {
position: relative;
aspect-ratio: 16 / 9;
background: var(--surface);
}
.thumbnail-wrap img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.video-duration {
position: absolute;
right: 0.4rem;
bottom: 0.4rem;
padding: 0.1rem 0.4rem;
font-size: 0.75rem;
font-weight: 600;
background: rgba(13, 13, 13, 0.85);
color: #fff8e7;
border-radius: var(--radius-sm);
}
.live-badge {
color: var(--rouge-oki);
font-family: var(--font-display);
font-weight: 700;
letter-spacing: 0.03em;
}
.video-info {
display: flex;
gap: var(--space-1);
padding: var(--space-1);
}
.channel-avatar {
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
object-fit: cover;
flex-shrink: 0;
}
.video-meta {
min-width: 0;
}
.video-title {
font-size: 0.95rem;
font-weight: 600;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.video-title a:hover {
color: var(--accent);
}
.video-channel,
.video-stats {
font-size: 0.8rem;
}
</style>
+51
View File
@@ -0,0 +1,51 @@
<span class="flag-chip" aria-hidden="true"></span>
<style>
.flag-chip {
display: inline-block;
width: 56px;
height: 6px;
flex-shrink: 0;
background: linear-gradient(
to right,
var(--noir-oki) 0 25%,
var(--or-oki) 25% 50%,
var(--vert-oki) 50% 75%,
var(--rouge-oki) 75% 100%
);
border: 1px solid var(--line);
}
:global(html.light-theme) .flag-chip {
background: linear-gradient(
to right,
#000 0 25%,
var(--or-oki) 25% 50%,
var(--vert-oki) 50% 75%,
var(--rouge-oki) 75% 100%
);
}
@media (prefers-reduced-motion: no-preference) {
:global(html.js) .flag-chip {
transform: scaleX(0);
transform-origin: left;
animation: flag-draw var(--dur-mesure) var(--ease-ka) both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
}
@supports not (animation-timeline: view()) {
:global(html.js) .flag-chip {
animation: none;
transform: none;
}
}
}
@keyframes flag-draw {
to {
transform: scaleX(1);
}
}
</style>
@@ -0,0 +1,67 @@
<script lang="ts">
import { syncopatedDelay } from '$lib/motion/tokens';
let {
text,
as = 'h2',
class: klass = '',
id = undefined
}: {
text: string;
as?: 'h1' | 'h2' | 'h3' | 'h4';
class?: string;
id?: string;
} = $props();
const words = $derived(text.split(/\s+/).filter(Boolean));
const ranges = $derived(
words.map((_, i) => {
const start = 4 + Math.round((syncopatedDelay(i) / 960) * 30);
return { from: `${Math.min(start, 60)}%`, to: `${Math.min(start + 25, 85)}%` };
})
);
</script>
<svelte:element this={as} {id} class={['kinetic', klass]} aria-label={text}>
{#each words as word, i (`${i}-${word}`)}
<span class="word" aria-hidden="true" style:--from={ranges[i].from} style:--to={ranges[i].to}
>{word}</span
>
{/each}
</svelte:element>
<style>
.kinetic {
font-family: var(--font-display);
font-weight: 800;
text-transform: uppercase;
letter-spacing: -0.01em;
}
.word {
display: inline-block;
}
.word + .word {
margin-left: 0.28em;
}
@keyframes kinetic-in {
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
:global(html.js) .word {
opacity: 0;
transform: translateY(0.5em);
animation: kinetic-in linear both;
animation-timeline: view();
animation-range: entry var(--from) entry var(--to);
}
}
}
</style>
@@ -0,0 +1,70 @@
<script lang="ts">
import { onMount } from 'svelte';
import { prefersReducedMotion } from '$lib/motion/tokens';
let { label = '' }: { label?: string } = $props();
let bar: HTMLDivElement;
onMount(() => {
if (prefersReducedMotion()) return;
let raf = 0;
let ticking = false;
const update = () => {
ticking = false;
const scrollable = document.documentElement.scrollHeight - window.innerHeight;
const ratio = scrollable > 0 ? window.scrollY / scrollable : 0;
bar.style.transform = `scaleX(${Math.min(1, Math.max(0, ratio))})`;
bar.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
};
const onScroll = () => {
if (!ticking) {
ticking = true;
raf = requestAnimationFrame(update);
}
};
update();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll, { passive: true });
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
};
});
</script>
<div
bind:this={bar}
class="progress"
role="progressbar"
aria-label={label}
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
></div>
<style>
.progress {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 3px;
z-index: 250;
background: linear-gradient(to right, var(--or-oki), var(--vert-oki));
transform: scaleX(0);
transform-origin: left;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.progress {
display: none;
}
}
</style>