forked from ORGANISATION-KA-INTERNATIONALE/FEDIVERSE-OKI
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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user