8 Commits

Author SHA1 Message Date
sucupira 3c0b094e92 Réécrit le README pour la version SvelteKit
Remplace la documentation PHP par celle de la branche svelte (FR/EN) :
architecture statique et bake au build, fonctionnalités portées (Funkwhale
inclus), référence de src/lib/config.ts, modèle CSP par page, PWA, structure
du projet, prérequis Node + ImageMagick, mesures Lighthouse, déploiement
2026-07-23 08:04:41 -04:00
sucupira 6c2df9b1cd Optimise les images distantes au build et fiabilise la QA
- scripts/fetch-podcast-cover.mjs : pochettes Castopod (flux + épisodes)
  téléchargées et converties en WebP 512px au prebuild (2,3 Mo → 59 Ko)
- scripts/optimize-remote-images.mjs : vignettes PeerTube (640px), avatars
  (96px) et médias Mastodon (800px) optimisés en WebP + mapping
  src/lib/server/image-map.json consommé par mapImage()
- a11y : liens footer soulignés dans les paragraphes, cibles tactiles ≥24px
  sur les liens des posts Mastodon
- image-map.ts : import JSON statique (le createRequire pointait sur le bundle)
- doc : étape prebuild documentée (dépendance ImageMagick)

Lighthouse mobile : 97/100/100/100 — LCP 2,4s, TBT 50ms, CLS 0,
poids accueil 900 Ko (budgets du playbook respectés)
2026-07-23 03:37:03 -04:00
sucupira ffe7f12fb9 Porte la section Funkwhale (MIZIK) et corrige la CSP par page
- src/lib/server/funkwhale.ts : 50 morceaux aléatoires de mizik.o-k-i.net,
  filtre local, mapping vers le lecteur audio intégré (10 affichés)
- Section « Morceaux » sur l'accueil entre podcast et timeline, FR/EN
- Bus audio partagé (audio-bus.svelte.ts) : un seul flux à la fois entre
  le lecteur podcast et le lecteur musique
- Lecteur audio : méta générique (artiste — album) pour les morceaux
- CSP : mizik.o-k-i.net ajouté à img-src/media-src
- Fix critique : la balise meta CSP embarque désormais la politique complète
  (lue depuis _headers) — default-src 'self' seul bloquait par intersection
  les images et médias des instances ; frame-ancestors retiré de la meta
  (ignoré hors en-tête)
2026-07-23 01:19:22 -04:00
sucupira 28c2334927 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)
2026-07-23 01:05:01 -04:00
sucupira d6efb6736f 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
2026-07-23 00:49:02 -04:00
cedric 5b1a78782a docs: add one-command README HTML/PDF export 2026-07-23 05:48:43 +04:00
cedric 3d28d3832f docs: rewrite README as bilingual FR/EN reference 2026-07-23 05:10:04 +04:00
cedric eaacd23702 docs: rebrand fork for ANNU KUTE CED podcast hub 2026-07-23 04:17:49 +04:00
219 changed files with 16234 additions and 15802 deletions
+25 -18
View File
@@ -1,19 +1,31 @@
# Configuration locale node_modules
includes/config.local.php
# Fichiers personnalisables pour chaque installation # Output
.htaccess .output
sitemap.xml .vercel
robots.txt .netlify
site.webmanifest .wrangler
mentions-legales.php /.svelte-kit
dons.php /build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Fichiers de l'IDE/éditeur # Fichiers de l'IDE/éditeur
.vscode/ .vscode/
.idea/ .idea/
*.sublime-* *.sublime-*
.DS_Store
# Fichiers temporaires # Fichiers temporaires
*.log *.log
@@ -22,15 +34,10 @@ dons.php
tmp/ tmp/
temp/ temp/
# Fichiers de cache # Artefacts de documentation générés (docs/generate-readme-pdf.sh)
cache/ README.html
README.pdf
# Dossier pour les images d'annonces (tout ignorer sauf .gitkeep) # Dossier pour les images d'annonces (tout ignorer sauf .gitkeep)
uploads/* uploads/*
!uploads/.gitkeep !uploads/.gitkeep
# Fichiers de dépendances (si nécessaire)
# vendor/
# node_modules/
img/movement_presentation.png
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+761 -670
View File
File diff suppressed because it is too large Load Diff
-128
View File
@@ -1,128 +0,0 @@
<?php
// Inclure la configuration
require_once '../includes/config.php';
// Vérifier que la requête est faite par AJAX
if (!isset($_SERVER['HTTP_X_REQUESTED_WITH']) || strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) !== 'xmlhttprequest') {
http_response_code(403); // Accès non autorisé
echo json_encode(['error' => 'Accès non autorisé']);
exit;
}
// Vérifier l'origine de la requête
if (!validateAjaxOrigin()) {
http_response_code(403);
echo json_encode(['error' => 'Origine non autorisée']);
exit;
}
// Vérifier le token CSRF
if (!isset($_POST['csrf_token']) || !validateCSRFToken($_POST['csrf_token'])) {
http_response_code(403);
echo json_encode(['error' => 'Token CSRF invalide']);
exit;
}
// Récupérer les paramètres
$type = isset($_GET['type']) ? $_GET['type'] : '';
$page = isset($_GET['page']) ? intval($_GET['page']) : 1;
$categoryId = isset($_GET['category']) ? intval($_GET['category']) : 0;
// Vérifier que le type est valide
if (!in_array($type, ['recent', 'trending', 'independence', 'category'])) {
http_response_code(400); // Requête incorrecte
echo json_encode(['error' => 'Type de vidéos non valide']);
exit;
}
// Vérifier que la catégorie est fournie si le type est 'category'
if ($type === 'category' && $categoryId <= 0) {
http_response_code(400); // Requête incorrecte
echo json_encode(['error' => 'ID de catégorie manquant ou invalide']);
exit;
}
// Récupérer les vidéos en fonction du type
$videos = [];
$offset = $page * LOAD_MORE_COUNT;
switch ($type) {
case 'recent':
// Récupérer les vidéos récentes
$data = callPeerTubeApi('videos', [
'sort' => '-publishedAt',
'count' => LOAD_MORE_COUNT,
'start' => $offset,
'isLocal' => true
]);
$videos = formatVideosData($data['data'] ?? []);
break;
case 'trending':
// Récupérer les vidéos tendances
$data = callPeerTubeApi('videos', [
'sort' => '-views',
'count' => LOAD_MORE_COUNT,
'start' => $offset,
'isLocal' => true
]);
$videos = formatVideosData($data['data'] ?? []);
break;
case 'independence':
// Récupérer les vidéos sur l'indépendance
$data = callPeerTubeApi('videos', [
'tagsOneOf' => TAG_INDEPENDENCE,
'count' => LOAD_MORE_COUNT,
'start' => $offset,
'isLocal' => true
]);
$videos = formatVideosData($data['data'] ?? []);
break;
case 'category':
// Récupérer les vidéos de la catégorie
$data = callPeerTubeApi('videos', [
'categoryOneOf' => $categoryId,
'count' => LOAD_MORE_COUNT,
'start' => $offset,
'sort' => '-publishedAt', // Les plus récentes d'abord
'isLocal' => true
]);
$videos = formatVideosData($data['data'] ?? []);
break;
}
// Préparer la réponse HTML
$html = '';
foreach ($videos as $video) {
$html .= '<div class="video-card" data-video-id="' . htmlspecialchars($video['id']) . '">';
$html .= ' <div class="video-thumbnail">';
$html .= ' <img src="' . htmlspecialchars($video['thumbnail']) . '" alt="' . htmlspecialchars($video['title']) . '">';
$html .= ' <div class="video-play-icon">';
$html .= ' <i class="fas fa-play-circle"></i>';
$html .= ' </div>';
$html .= ' <div class="video-duration">' . formatDuration($video['duration']) . '</div>';
$html .= ' </div>';
$html .= ' <div class="video-info">';
$html .= ' <h3 class="video-title">' . htmlspecialchars($video['title']) . '</h3>';
$html .= ' <div class="video-channel">' . htmlspecialchars($video['channel']) . '</div>';
$html .= ' <div class="video-metadata">';
if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS) {
$html .= ' <span class="video-views"><i class="fas fa-eye"></i> ' . formatViewCount($video['views']) . ' vues</span>';
}
$html .= ' <span class="video-date"><i class="far fa-calendar-alt"></i> ' . formatDate($video['date']) . '</span>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
}
// Retourner la réponse
echo json_encode([
'success' => true,
'html' => $html,
'page' => $page,
'hasMore' => count($videos) >= LOAD_MORE_COUNT
]);
?>
-9
View File
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="img/android-chrome-192x192.png"/>
<TileColor>#FF0000</TileColor>
</tile>
</msapplication>
</browserconfig>
-172
View File
@@ -1,172 +0,0 @@
<?php
// Inclure la configuration
require_once 'includes/config.php';
// Vérifier si le mode compte à rebours est activé
if (defined('COUNTDOWN_ENABLED') && COUNTDOWN_ENABLED === true) {
// Rediriger vers la page de compte à rebours
require_once 'countdown.php';
exit;
}
// Inclure les fonctions de données structurées
require_once 'includes/structured-data.php';
// Appliquer les en-têtes de sécurité
setSecurityHeaders();
// Récupération et validation de l'ID de catégorie
$categoryId = isset($_GET['id']) ? $_GET['id'] : null;
$categoryId = $categoryId ? validateCategoryId($categoryId) : null;
// Récupérer les catégories disponibles
$allCategories = PEERTUBE_CATEGORIES;
// Récupérer les vidéos de la catégorie si un ID est fourni
if ($categoryId && isset($allCategories[$categoryId])) {
// Récupérer le nom de la catégorie
$categoryName = $allCategories[$categoryId];
// Récupérer les vidéos de cette catégorie via l'API PeerTube
$videos = getVideosByCategory($categoryId, CATEGORY_VIDEOS_COUNT); // Récupérer le nombre défini dans la config
} else {
// Si aucune catégorie valide n'est spécifiée, rediriger vers l'accueil
header('Location: index.php');
exit;
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="<?php echo generateCSRFToken(); ?>">
<title><?php echo htmlspecialchars($categoryName); ?> - <?php echo SITE_NAME; ?></title>
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
<!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="img/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png">
<link rel="manifest" href="site.webmanifest">
<link rel="icon" type="image/x-icon" href="img/favicon.ico">
<meta name="theme-color" content="#ffffff">
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="Catégorie : <?php echo htmlspecialchars($categoryName); ?> - <?php echo SITE_NAME; ?>">
<meta property="og:description" content="Découvrez toutes les vidéos de la catégorie <?php echo htmlspecialchars($categoryName); ?> sur <?php echo SITE_NAME; ?>. Contenu multimédia de qualité et exclusif.">
<meta property="og:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
<meta property="og:url" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; ?>">
<meta property="og:type" content="website">
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
<meta property="og:locale" content="fr_FR">
<!-- Twitter Card Meta Tags -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Catégorie : <?php echo htmlspecialchars($categoryName); ?> - <?php echo SITE_NAME; ?>">
<meta name="twitter:description" content="Découvrez toutes les vidéos de la catégorie <?php echo htmlspecialchars($categoryName); ?> sur <?php echo SITE_NAME; ?>. Contenu multimédia de qualité et exclusif.">
<meta name="twitter:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
<!-- Données structurées JSON-LD pour la page de catégorie -->
<?php
$categoryUrl = getBaseUrl() . '/categories.php?id=' . $categoryId;
$categoryDescription = 'Découvrez toutes les vidéos de la catégorie ' . $categoryName . ' sur ' . SITE_NAME;
$collectionJsonLd = generateVideoCollectionJsonLd($categoryName, $categoryDescription, $videos, $categoryUrl);
outputJsonLd($collectionJsonLd);
// Générer les fils d'Ariane pour la catégorie
$breadcrumbs = [
['name' => 'Accueil', 'url' => getBaseUrl()],
['name' => 'Catégories', 'url' => getBaseUrl() . '/#categories'],
['name' => $categoryName, 'url' => $categoryUrl]
];
$breadcrumbJsonLd = generateBreadcrumbJsonLd($breadcrumbs);
outputJsonLd($breadcrumbJsonLd);
?>
<!-- Script pour éviter le flash en mode sombre -->
<script nonce="<?php echo getCspNonce(); ?>">
(function() {
const savedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const shouldUseDark = savedTheme === 'dark' || (!savedTheme && systemPrefersDark);
if (shouldUseDark) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
</head>
<body>
<?php include 'includes/sidebar.php'; ?>
<!-- Contenu principal -->
<div class="main-content">
<?php include 'includes/header.php'; ?>
<!-- Section catégorie -->
<div class="video-section category-page" data-category-id="<?php echo $categoryId; ?>">
<div class="section-header">
<div class="section-logo">
<img src="img/logo.png" alt="<?php echo SITE_NAME; ?>">
</div>
<h2 class="section-title">Catégorie : <?php echo htmlspecialchars($categoryName); ?></h2>
</div>
<?php if (empty($videos)): ?>
<div class="no-videos-message">
<i class="fas fa-video-slash"></i>
<h3>Aucune vidéo dans cette catégorie</h3>
<p>Il n'y a pas encore de vidéos dans la catégorie "<?php echo htmlspecialchars($categoryName); ?>".</p>
<a href="index.php" class="btn-primary">Retour à l'accueil</a>
</div>
<?php else: ?>
<div class="video-grid category-videos">
<?php foreach ($videos as $video): ?>
<div class="video-card" data-video-id="<?php echo $video['id']; ?>">
<div class="video-thumbnail">
<img src="<?php echo $video['thumbnail']; ?>" alt="<?php echo htmlspecialchars($video['title']); ?>">
<div class="video-play-icon">
<i class="fas fa-play-circle"></i>
</div>
<span class="video-duration"><?php echo formatDuration($video['duration']); ?></span>
</div>
<div class="video-info">
<h3 class="video-title"><?php echo htmlspecialchars($video['title']); ?></h3>
<div class="video-channel">
<?php if (strpos($video['channelAvatar'], 'default-avatar') !== false || empty($video['channelAvatar'])): ?>
<div class="channel-avatar-placeholder">
<i class="fas fa-user-circle"></i>
</div>
<?php else: ?>
<img src="<?php echo $video['channelAvatar']; ?>" alt="<?php echo htmlspecialchars($video['channel']); ?>" class="channel-avatar">
<?php endif; ?>
<span class="channel-name"><?php echo htmlspecialchars($video['channel']); ?></span>
</div>
<div class="video-metadata">
<?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?>
<span class="video-views"><i class="fas fa-eye"></i> <?php echo formatViewCount($video['views']); ?> vues</span>
<?php endif; ?>
<span class="video-date"><i class="far fa-calendar-alt"></i> <?php echo formatDate($video['date']); ?></span>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php if (count($videos) >= CATEGORY_VIDEOS_COUNT): ?>
<button class="view-more" data-page="1">Voir plus</button>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
<?php include 'includes/footer.php'; ?>
<?php include 'includes/mobile-menu.php'; ?>
<script src="js/main.js"></script>
<script src="js/categories.js"></script>
</body>
</html>
-57
View File
@@ -1,57 +0,0 @@
RewriteEngine On
# ======================
# SÉCURITÉ
# ======================
# Bloquer l'accès aux fichiers de configuration
<Files ~ "\.(inc|conf|config|local)$">
Require all denied
</Files>
# Bloquer les fichiers PHP dans includes/
<Files "includes/*.php">
Require all denied
</Files>
# Protéger les répertoires sensibles
RewriteRule ^(includes|cache|docs|conf)/ - [F,L]
# Bloquer les fichiers samples et backups
<FilesMatch "\.(sample|bak|backup|log|tmp)$">
Require all denied
</FilesMatch>
# Empêcher l'exploration des répertoires
Options -Indexes
# Bloquer l'accès aux fichiers cachés
<FilesMatch "^\.">
Require all denied
</FilesMatch>
# ======================
# RÉÉCRITURE D'URL
# ======================
# Masquer l'extension .php
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^([^\.]+)$ $1.php [NC,L]
# Rediriger les URLs avec .php vers les URLs sans extension
RewriteCond %{THE_REQUEST} /([^.]+)\.php [NC]
RewriteRule ^ /%1 [NC,L,R=301]
# Pour accéder à page.php via /page
RewriteCond %{REQUEST_FILENAME}.php -f
RewriteRule ^([^/]+)$ $1.php [L]
# ======================
# HTTPS
# ======================
# Force HTTPS
RewriteCond %{HTTP:X-Forwarded-Proto} !https
RewriteCond %{HTTPS} !on
RewriteRule ^(.*) https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
-96
View File
@@ -1,96 +0,0 @@
server {
listen 80;
server_name votre-domaine.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name votre-domaine.com;
root /path/to/your/site;
index index.php index.html;
# SSL Configuration (adaptez selon votre certificat)
ssl_certificate /path/to/your/certificate.crt;
ssl_certificate_key /path/to/your/private.key;
# ======================
# SÉCURITÉ
# ======================
# Bloquer l'accès aux fichiers de configuration
location ~* \.(php|inc|conf|config|local)$ {
deny all;
return 404;
}
# Protéger les répertoires sensibles
location ~ ^/(includes|cache|docs|conf)/ {
deny all;
return 404;
}
# Bloquer les fichiers samples et backups
location ~* \.(sample|bak|backup|log|tmp)$ {
deny all;
return 404;
}
# Bloquer l'accès aux fichiers cachés
location ~ /\. {
deny all;
return 404;
}
# Empêcher l'exploration des répertoires
autoindex off;
# ======================
# RÉÉCRITURE D'URL
# ======================
# Masquer l'extension .php et redirection
location / {
try_files $uri $uri/ @rewrite;
}
location @rewrite {
rewrite ^/([^.]+)$ /$1.php last;
}
# Rediriger les URLs avec .php vers les URLs sans extension
location ~ ^/(.+)\.php$ {
return 301 /$1;
}
# Traitement des fichiers PHP
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock; # Adaptez selon votre version PHP
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
}
# ======================
# OPTIMISATIONS
# ======================
# Cache des fichiers statiques
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Compression gzip
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# Headers de sécurité
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
-187
View File
@@ -1,187 +0,0 @@
<?php
// Inclure la configuration
require_once 'includes/config.php';
// Inclure les fonctions de sécurité
require_once 'includes/security.php';
// Appliquer les en-têtes de sécurité
setSecurityHeaders();
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="<?php echo generateCSRFToken(); ?>">
<title><?php echo SITE_NAME; ?> - Ouverture prochaine</title>
<!-- Styles -->
<link rel="stylesheet" href="css/countdown.css?v=<?php echo filemtime('css/countdown.css'); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
<!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="img/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png">
<link rel="manifest" href="site.webmanifest">
<link rel="icon" type="image/x-icon" href="img/favicon.ico">
<meta name="theme-color" content="#FF0000">
<!-- PWA Meta Tags -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="<?php echo SITE_NAME; ?>">
<meta name="application-name" content="<?php echo SITE_NAME; ?>">
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="<?php echo SITE_NAME; ?> - Ouverture prochaine">
<meta property="og:description" content="La plateforme multimédia <?php echo SITE_NAME; ?> ouvrira ses portes le <?php
$targetDate = new DateTime(COUNTDOWN_TARGET_DATE);
setlocale(LC_TIME, 'fr_FR.UTF-8');
echo strftime('%e %B %Y', $targetDate->getTimestamp());
?>. Restez connectés !">
<meta property="og:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
<meta property="og:url" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; ?>">
<meta property="og:type" content="website">
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
<meta property="og:locale" content="fr_FR">
<!-- Twitter Card Meta Tags -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="<?php echo SITE_NAME; ?> - Ouverture prochaine">
<meta name="twitter:description" content="La plateforme multimédia <?php echo SITE_NAME; ?> ouvrira ses portes le <?php
$targetDate = new DateTime(COUNTDOWN_TARGET_DATE);
setlocale(LC_TIME, 'fr_FR.UTF-8');
echo strftime('%e %B %Y', $targetDate->getTimestamp());
?>. Restez connectés !">
<meta name="twitter:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
<!-- Configuration JavaScript -->
<script nonce="<?php echo getCspNonce(); ?>">
const COUNTDOWN_TARGET_DATE = '<?php echo defined('COUNTDOWN_TARGET_DATE') ? COUNTDOWN_TARGET_DATE : '2025-10-11 00:00:00'; ?>';
</script>
</head>
<body>
<div class="countdown-overlay">
<div class="countdown-container">
<!-- Logo -->
<img src="img/logo.png" alt="Logo <?php echo SITE_NAME; ?>" class="countdown-logo">
<!-- Titre principal -->
<h1 class="countdown-title"><?php echo SITE_NAME; ?></h1>
<!-- Sous-titre -->
<p class="countdown-subtitle">Plateforme Multimédia - Ouverture prochaine</p>
<!-- Compte à rebours -->
<div class="countdown-timer">
<div class="countdown-unit">
<span class="countdown-number" id="countdown-days">00</span>
<span class="countdown-label">Jours</span>
</div>
<div class="countdown-unit">
<span class="countdown-number" id="countdown-hours">00</span>
<span class="countdown-label">Heures</span>
</div>
<div class="countdown-unit">
<span class="countdown-number" id="countdown-minutes">00</span>
<span class="countdown-label">Minutes</span>
</div>
<div class="countdown-unit">
<span class="countdown-number" id="countdown-seconds">00</span>
<span class="countdown-label">Secondes</span>
</div>
</div>
<!-- Message -->
<div class="countdown-message">
<p>La plateforme ouvrira ses portes le <strong><?php
$targetDate = new DateTime(COUNTDOWN_TARGET_DATE);
setlocale(LC_TIME, 'fr_FR.UTF-8');
echo strftime('%e %B %Y à %H:%M', $targetDate->getTimestamp());
?></strong> (heure de La Réunion).</p>
<!-- Heures des autres territoires -->
<div class="countdown-other-timezones">
<?php
$timezones = defined('COUNTDOWN_TIMEZONES') ? COUNTDOWN_TIMEZONES : [
'Martinique / Guadeloupe' => 'America/Martinique',
'Guyane' => 'America/Cayenne',
'France' => 'Europe/Paris',
'Ma\'ohi Nui' => 'Pacific/Tahiti',
'Kanaky' => 'Pacific/Noumea'
];
foreach($timezones as $name => $timezone):
$targetDateLocal = new DateTime(COUNTDOWN_TARGET_DATE, new DateTimeZone(DEFAULT_TIMEZONE));
$targetDateLocal->setTimezone(new DateTimeZone($timezone));
// Vérifier si c'est un jour différent
$reunionDate = new DateTime(COUNTDOWN_TARGET_DATE);
$dayDiff = $targetDateLocal->format('j') - $reunionDate->format('j');
$dayIndicator = '';
if ($dayDiff > 0) {
$dayIndicator = ' <span class="day-indicator next-day">+1j</span>';
} elseif ($dayDiff < 0) {
$dayIndicator = ' <span class="day-indicator prev-day">-1j</span>';
}
?>
<div class="timezone-item">
<span class="timezone-name"><?php echo $name; ?></span>
<span class="timezone-time">
<?php echo $targetDateLocal->format('H:i'); ?>
<?php echo $dayIndicator; ?>
</span>
</div>
<?php endforeach; ?>
</div>
<p>Restez connectés et suivez-nous sur nos réseaux sociaux !</p>
</div>
<!-- Réseaux sociaux -->
<div class="countdown-social">
<?php if (defined('FACEBOOK_URL') && !empty(FACEBOOK_URL)): ?>
<a href="<?php echo FACEBOOK_URL; ?>" target="_blank" rel="noopener noreferrer" aria-label="Facebook">
<i class="fab fa-facebook-f"></i>
</a>
<?php endif; ?>
<?php if (defined('X_URL') && !empty(X_URL)): ?>
<a href="<?php echo X_URL; ?>" target="_blank" rel="noopener noreferrer" aria-label="X (Twitter)">
<i class="fab fa-x-twitter"></i>
</a>
<?php endif; ?>
<?php if (defined('INSTAGRAM_URL') && !empty(INSTAGRAM_URL)): ?>
<a href="<?php echo INSTAGRAM_URL; ?>" target="_blank" rel="noopener noreferrer" aria-label="Instagram">
<i class="fab fa-instagram"></i>
</a>
<?php endif; ?>
<?php if (defined('YOUTUBE_URL') && !empty(YOUTUBE_URL)): ?>
<a href="<?php echo YOUTUBE_URL; ?>" target="_blank" rel="noopener noreferrer" aria-label="YouTube">
<i class="fab fa-youtube"></i>
</a>
<?php endif; ?>
<?php if (defined('TIKTOK_URL') && !empty(TIKTOK_URL)): ?>
<a href="<?php echo TIKTOK_URL; ?>" target="_blank" rel="noopener noreferrer" aria-label="TikTok">
<i class="fab fa-tiktok"></i>
</a>
<?php endif; ?>
<?php if (defined('MASTODON_URL') && !empty(MASTODON_URL)): ?>
<a href="<?php echo MASTODON_URL; ?>" target="_blank" rel="noopener noreferrer" aria-label="Mastodon">
<i class="fab fa-mastodon"></i>
</a>
<?php endif; ?>
</div>
</div>
</div>
<!-- Scripts -->
<script src="js/countdown.js"></script>
</body>
</html>
-279
View File
@@ -1,279 +0,0 @@
/* Styles pour la liste compacte de podcasts Castopod */
.castopod-container {
background: var(--card-bg);
border-radius: 12px;
border: 1px solid var(--border-color);
box-shadow: var(--card-shadow);
overflow: hidden;
height: 400px;
display: flex;
flex-direction: column;
}
.castopod-section {
width: 100%;
min-width: 0;
margin-bottom: 1em;
}
.castopod-header {
background: var(--primary-red);
color: white;
padding: 12px 16px;
font-size: 1rem;
font-weight: bold;
display: flex;
align-items: center;
gap: 8px;
}
.castopod-header a {
color: white;
text-decoration: none;
transition: opacity 0.2s ease;
}
.castopod-header a:hover {
opacity: 0.8;
text-decoration: underline;
}
.castopod-episodes-list {
flex: 1;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--primary-red) transparent;
}
.castopod-episodes-list::-webkit-scrollbar {
width: 6px;
}
.castopod-episodes-list::-webkit-scrollbar-track {
background: transparent;
}
.castopod-episodes-list::-webkit-scrollbar-thumb {
background-color: var(--primary-red);
border-radius: 3px;
}
.castopod-episode-item {
display: flex;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color);
color: inherit;
transition: background-color 0.2s ease;
gap: 8px;
position: relative;
}
.castopod-episode-item:hover {
background: var(--hover-bg);
}
.castopod-episode-item:last-child {
border-bottom: none;
}
.castopod-episode-item.playing {
background: var(--hover-bg);
}
.castopod-episode-item.playing .castopod-episode-title {
color: var(--primary-red);
}
.castopod-episode-thumb {
width: 60px;
height: 60px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
background: var(--tag-bg);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
position: relative;
border: none;
padding: 0;
cursor: pointer;
transition: transform 0.2s ease;
}
.castopod-episode-thumb:hover {
transform: scale(1.05);
}
.castopod-episode-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.castopod-play-icon,
.castopod-pause-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 1.5rem;
color: rgba(255, 255, 255, 0.9);
text-shadow: 0 0 10px rgba(0, 0, 0, 0.8);
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
}
.castopod-episode-item:hover .castopod-play-icon {
opacity: 1;
}
.castopod-episode-item.playing .castopod-play-icon {
opacity: 0;
}
.castopod-episode-item.playing .castopod-pause-icon {
opacity: 1;
}
.castopod-episode-item.playing:hover .castopod-pause-icon {
opacity: 1;
}
.castopod-episode-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.castopod-episode-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-color);
margin: 0 0 4px 0;
line-height: 1.2;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.castopod-podcast-badge {
font-size: 0.65rem;
color: var(--primary-red);
font-weight: 600;
margin: 0 0 4px 0;
display: flex;
align-items: center;
gap: 4px;
}
.castopod-podcast-badge i {
font-size: 0.6rem;
}
.castopod-episode-meta {
font-size: 0.65rem;
color: var(--text-secondary);
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}
.castopod-episode-meta i {
font-size: 0.6rem;
}
.castopod-no-episodes {
text-align: center;
color: var(--text-secondary);
padding: 20px;
font-style: italic;
font-size: 0.9rem;
}
/* Lien externe vers Castopod */
.castopod-external-link {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
flex-shrink: 0;
color: var(--text-secondary);
text-decoration: none;
border-radius: 50%;
transition: all 0.2s ease;
margin-left: auto;
}
.castopod-external-link:hover {
background: var(--tag-bg);
color: var(--primary-red);
transform: scale(1.1);
}
.castopod-external-link i {
font-size: 0.85rem;
}
/* Masquer l'élément audio */
.castopod-episode-item audio {
display: none;
}
/* Ajustement du layout selon les contenus présents */
/* Ordre dans le HTML : 1. Castopod, 2. Mastodon, 3. WordPress */
/* Responsive Design */
@media (max-width: 1024px) {
/* Mobile/Tablette: layout vertical */
.timeline-container:has(.castopod-section) {
grid-template-columns: 1fr !important;
}
.castopod-section {
width: 100%;
}
.castopod-container {
height: auto;
max-height: 400px;
}
}
@media (max-width: 768px) {
.castopod-episode-thumb {
width: 65px;
height: 65px;
}
.castopod-episode-item {
padding: 10px 12px;
gap: 10px;
}
.castopod-episode-title {
font-size: 0.85rem;
}
.castopod-episode-meta {
font-size: 0.65rem;
}
.castopod-header {
padding: 10px 12px;
font-size: 0.9rem;
}
.castopod-play-icon {
font-size: 1.3rem;
}
}
-157
View File
@@ -1,157 +0,0 @@
/* Styles pour la page des catégories */
/* Page d'accueil des catégories */
.categories-showcase {
margin: 30px 0;
}
.categories-showcase h1 {
font-size: 2rem;
margin-bottom: 10px;
color: #333;
text-align: center;
}
.section-description {
text-align: center;
color: #666;
margin-bottom: 30px;
}
.categories-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.category-card-large {
position: relative;
height: 200px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
transition: transform 0.3s;
}
.category-card-large:hover {
transform: translateY(-5px);
}
.category-card-large img {
width: 100%;
height: 100%;
object-fit: cover;
}
.category-card-large .category-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0.3));
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 20px;
}
.category-name-large {
color: white;
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 5px;
}
.category-description-short {
color: rgba(255, 255, 255, 0.8);
font-size: 0.9rem;
}
/* Page d'une catégorie spécifique */
.category-header {
margin-bottom: 30px;
}
.category-banner {
height: 250px;
background-size: cover;
background-position: center;
position: relative;
border-radius: 8px;
overflow: hidden;
margin-bottom: 20px;
}
.category-banner .category-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(to top, rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.3));
display: flex;
align-items: center;
justify-content: center;
}
.category-banner h1 {
color: white;
font-size: 2.5rem;
text-align: center;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
}
.category-description {
text-align: center;
max-width: 800px;
margin: 0 auto;
color: #555;
font-size: 1.1rem;
line-height: 1.6;
}
.category-videos {
margin-bottom: 40px;
}
.no-videos {
text-align: center;
padding: 40px 0;
color: #666;
}
/* Media Queries */
@media (min-width: 768px) {
.categories-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 992px) {
.categories-grid {
grid-template-columns: repeat(4, 1fr);
}
}
@media (max-width: 576px) {
.categories-grid {
grid-template-columns: 1fr;
}
.category-card-large {
height: 150px;
}
.category-banner {
height: 180px;
}
.category-banner h1 {
font-size: 1.8rem;
}
.category-description {
font-size: 0.95rem;
}
}
-350
View File
@@ -1,350 +0,0 @@
/* Styles pour la page de compte à rebours */
body {
margin: 0;
padding: 0;
overflow-x: hidden;
}
.countdown-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
min-height: 100vh;
background: linear-gradient(135deg, #000000 0%, #1a1a1a 50%, #000000 100%);
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
z-index: 9999;
color: white;
text-align: center;
padding: 20px;
box-sizing: border-box;
}
.countdown-container {
max-width: 800px;
width: 100%;
margin: auto;
padding: 40px 0;
}
.countdown-logo {
width: 150px;
height: auto;
margin-bottom: 30px;
filter: drop-shadow(0 0 20px rgba(255, 0, 0, 0.3));
}
.countdown-title {
font-size: 3rem;
font-weight: bold;
margin-bottom: 10px;
color: #FF0000;
text-shadow: 0 0 30px rgba(255, 0, 0, 0.5);
}
.countdown-subtitle {
font-size: 1.5rem;
margin-bottom: 20px;
color: #ffffff;
opacity: 0.9;
}
.countdown-timer {
display: flex;
justify-content: center;
gap: 30px;
margin-bottom: 40px;
flex-wrap: wrap;
}
.countdown-unit {
text-align: center;
background: rgba(255, 0, 0, 0.1);
border: 2px solid #FF0000;
border-radius: 15px;
padding: 20px;
min-width: 120px;
backdrop-filter: blur(10px);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.countdown-unit:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(255, 0, 0, 0.3);
}
.countdown-number {
font-size: 3rem;
font-weight: bold;
color: #FF0000;
display: block;
text-shadow: 0 0 20px rgba(255, 0, 0, 0.6);
}
.countdown-label {
font-size: 1rem;
color: #ffffff;
text-transform: uppercase;
letter-spacing: 1px;
margin-top: 10px;
}
.countdown-message {
font-size: 1.2rem;
margin-bottom: 30px;
color: #ffffff;
opacity: 0.8;
line-height: 1.6;
}
.countdown-other-timezones {
margin: 25px 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.timezone-item {
background: linear-gradient(135deg, rgba(255, 0, 0, 0.15) 0%, rgba(255, 0, 0, 0.05) 100%);
padding: 12px 16px;
border-radius: 12px;
font-size: 0.95rem;
color: #ffffff;
backdrop-filter: blur(15px);
border: 1px solid rgba(255, 0, 0, 0.3);
text-align: center;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.timezone-item::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
transition: left 0.5s ease;
}
.timezone-item:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(255, 0, 0, 0.2);
border-color: rgba(255, 0, 0, 0.5);
}
.timezone-item:hover::before {
left: 100%;
}
.timezone-name {
display: block;
font-weight: bold;
color: #FF0000;
margin-bottom: 4px;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.3px;
line-height: 1.1;
word-wrap: break-word;
overflow-wrap: break-word;
}
.timezone-time {
display: block;
font-size: 1.1rem;
font-weight: bold;
text-shadow: 0 0 10px rgba(255, 255, 255, 0.3);
}
.day-indicator {
font-size: 0.75rem;
font-weight: bold;
padding: 2px 6px;
border-radius: 10px;
margin-left: 5px;
text-shadow: none;
}
.day-indicator.next-day {
background: rgba(0, 255, 0, 0.2);
color: #00ff88;
border: 1px solid rgba(0, 255, 0, 0.4);
}
.day-indicator.prev-day {
background: rgba(255, 165, 0, 0.2);
color: #ffaa00;
border: 1px solid rgba(255, 165, 0, 0.4);
}
.countdown-social {
display: flex;
justify-content: center;
gap: 20px;
margin-top: 40px;
}
.countdown-social a {
color: #ffffff;
font-size: 2rem;
transition: all 0.3s ease;
}
.countdown-social a:hover {
color: #FF0000;
transform: scale(1.2);
box-shadow: 0 0 20px rgba(255, 0, 0, 0.4);
}
/* Animation de pulsation pour le titre */
@keyframes pulse {
0%, 100% {
text-shadow: 0 0 30px rgba(255, 0, 0, 0.5);
}
50% {
text-shadow: 0 0 50px rgba(255, 0, 0, 0.8);
}
}
.countdown-title {
animation: pulse 2s infinite;
}
/* Responsive */
@media (max-width: 768px) {
.countdown-container {
padding: 20px 0;
}
.countdown-title {
font-size: 2rem;
}
.countdown-subtitle {
font-size: 1.2rem;
}
.countdown-timer {
gap: 15px;
}
.countdown-unit {
min-width: 80px;
padding: 15px 10px;
}
.countdown-number {
font-size: 2rem;
}
.countdown-label {
font-size: 0.8rem;
}
.countdown-logo {
width: 100px;
}
.countdown-other-timezones {
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
}
.timezone-item {
padding: 10px 12px;
}
.timezone-name {
font-size: 0.75rem;
}
.timezone-time {
font-size: 1rem;
}
.day-indicator {
font-size: 0.65rem;
padding: 1px 4px;
}
.countdown-social {
gap: 15px;
}
.countdown-social a {
font-size: 1.5rem;
}
}
@media (max-width: 480px) {
.countdown-timer {
gap: 10px;
}
.countdown-unit {
min-width: 70px;
padding: 10px 5px;
}
.countdown-number {
font-size: 1.5rem;
}
.countdown-label {
font-size: 0.7rem;
}
.countdown-other-timezones {
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 8px;
}
.timezone-item {
padding: 8px 10px;
}
.timezone-name {
font-size: 0.7rem;
}
.timezone-time {
font-size: 0.9rem;
}
.day-indicator {
font-size: 0.6rem;
padding: 1px 3px;
margin-left: 3px;
}
}
/* Animation d'entrée */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.countdown-container > * {
animation: fadeInUp 0.8s ease forwards;
}
.countdown-container > *:nth-child(1) { animation-delay: 0.1s; }
.countdown-container > *:nth-child(2) { animation-delay: 0.2s; }
.countdown-container > *:nth-child(3) { animation-delay: 0.3s; }
.countdown-container > *:nth-child(4) { animation-delay: 0.4s; }
.countdown-container > *:nth-child(5) { animation-delay: 0.5s; }
-491
View File
@@ -1,491 +0,0 @@
/* Styles pour la page de dons */
/* Ajustements pour la page de dons */
.main-content .container {
max-width: none;
padding: 0;
}
/* Hero section */
.donation-hero {
background: linear-gradient(135deg, var(--primary-red), #cc0000);
color: white;
padding: 60px 0;
text-align: center;
margin-bottom: 40px;
}
.donation-hero-content h1 {
font-size: 2.5rem;
margin-bottom: 16px;
font-weight: bold;
}
.donation-hero-content h1 i {
color: #ffdddd;
margin-right: 12px;
}
.hero-subtitle {
font-size: 1.2rem;
opacity: 0.9;
max-width: 600px;
margin: 0 auto;
}
/* Section principale */
.donation-main {
margin-bottom: 60px;
max-width: 1200px;
margin-left: auto;
margin-right: auto;
padding: 0 20px;
}
.donation-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
align-items: start;
}
/* Message de don */
.donation-message h2 {
color: var(--primary-red);
margin-bottom: 20px;
font-size: 1.8rem;
}
.donation-message p {
margin-bottom: 16px;
line-height: 1.6;
color: var(--text-color);
}
.donation-message ul {
list-style: none;
padding: 0;
margin: 20px 0;
}
.donation-message li {
padding: 8px 0;
display: flex;
align-items: center;
color: var(--text-color);
}
.donation-message li i {
color: var(--primary-red);
margin-right: 12px;
width: 20px;
text-align: center;
}
/* Interface de don */
.donation-interface {
background: var(--card-bg);
border-radius: 12px;
padding: 30px;
box-shadow: var(--card-shadow);
border: 1px solid var(--border-color);
}
.donation-interface h3 {
color: var(--primary-red);
margin-bottom: 24px;
font-size: 1.5rem;
text-align: center;
}
/* Boutons de montants */
.donation-amounts {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 30px;
}
.donation-btn {
background: white;
border: 2px solid var(--border-color);
border-radius: 8px;
padding: 16px 20px;
font-size: 1.2rem;
font-weight: bold;
color: var(--text-color);
cursor: pointer;
transition: all 0.3s ease;
text-align: center;
}
.donation-btn:hover {
border-color: var(--primary-red);
background: #fff5f5;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 0, 0, 0.15);
}
.donation-btn.active {
background: var(--primary-red);
color: white;
border-color: var(--primary-red);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 0, 0, 0.3);
}
/* Montant personnalisé */
.custom-amount {
margin-bottom: 24px;
}
.custom-amount label {
display: block;
margin-bottom: 12px;
font-weight: 600;
color: var(--text-color);
}
.custom-amount-input {
display: flex;
align-items: center;
gap: 8px;
}
.custom-amount-input input {
flex: 1;
padding: 12px 16px;
border: 2px solid var(--border-color);
border-radius: 8px;
font-size: 1.1rem;
transition: border-color 0.3s ease;
}
.custom-amount-input input:focus {
outline: none;
border-color: var(--primary-red);
box-shadow: 0 0 0 3px rgba(255, 0, 0, 0.1);
}
.custom-amount-input .currency {
font-weight: bold;
color: var(--text-secondary);
font-size: 1.1rem;
}
.btn-custom-donate {
background: var(--primary-red);
color: white;
border: none;
border-radius: 8px;
padding: 12px 24px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
font-size: 1rem;
}
.btn-custom-donate:hover {
background: #cc0000;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(255, 0, 0, 0.3);
}
/* Sécurité */
.donation-security {
text-align: center;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.donation-security p {
margin: 0;
color: var(--text-secondary);
font-size: 0.9rem;
}
.donation-security i {
color: #28a745;
margin-right: 8px;
}
/* Section d'informations */
.donation-info {
margin-top: 60px;
max-width: 1200px;
margin-left: auto;
margin-right: auto;
padding: 0 20px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 30px;
}
.info-card {
text-align: center;
padding: 30px 20px;
background: var(--card-bg);
border-radius: 12px;
border: 1px solid var(--border-color);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.info-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
}
.info-card i {
font-size: 2.5rem;
color: var(--primary-red);
margin-bottom: 16px;
}
.info-card h4 {
color: var(--text-color);
margin-bottom: 12px;
font-size: 1.3rem;
}
.info-card p {
color: var(--text-secondary);
line-height: 1.5;
margin: 0;
}
/* Responsive Design */
@media (max-width: 1024px) {
.donation-content {
grid-template-columns: 1fr;
gap: 30px;
}
.info-grid {
grid-template-columns: 1fr;
gap: 20px;
}
}
@media (max-width: 768px) {
.donation-hero {
padding: 40px 0;
}
.donation-hero-content h1 {
font-size: 2rem;
}
.hero-subtitle {
font-size: 1.1rem;
}
.donation-interface {
padding: 20px;
}
.donation-amounts {
grid-template-columns: 1fr;
}
.custom-amount-input {
flex-direction: column;
align-items: stretch;
}
.custom-amount-input .currency {
order: -1;
text-align: center;
margin-bottom: 8px;
}
}
@media (max-width: 480px) {
.donation-hero-content h1 {
font-size: 1.6rem;
}
.donation-btn {
padding: 14px 16px;
font-size: 1.1rem;
}
}
/* Plateformes de don externes */
.donation-platforms {
background: var(--card-bg);
border-radius: 12px;
padding: 30px;
box-shadow: var(--card-shadow);
border: 1px solid var(--border-color);
margin-bottom: 30px;
}
.donation-platforms h3 {
color: var(--primary-red);
margin-bottom: 24px;
font-size: 1.5rem;
text-align: center;
}
.platforms-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.platform-btn {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: var(--card-bg);
border: 2px solid var(--border-color);
border-radius: 12px;
text-decoration: none;
color: var(--text-color);
transition: all 0.3s ease;
}
.platform-btn:hover {
transform: translateY(-4px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
}
.platform-btn.liberapay {
border-color: #f6c915;
}
.platform-btn.liberapay:hover {
background: rgba(246, 201, 21, 0.1);
border-color: #f6c915;
}
.platform-btn.kofi {
border-color: #13C3FF;
}
.platform-btn.kofi:hover {
background: rgba(19, 195, 255, 0.1);
border-color: #13C3FF;
}
.platform-icon {
width: 60px;
height: 60px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.platform-icon svg {
width: 100%;
height: 100%;
}
.platform-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.platform-name {
font-size: 1.2rem;
font-weight: bold;
color: var(--text-color);
}
.platform-desc {
font-size: 0.9rem;
color: var(--text-secondary);
}
/* Onglets don ponctuel / mensuel */
.donation-tabs {
display: flex;
gap: 8px;
margin-bottom: 24px;
background: var(--main-bg);
padding: 4px;
border-radius: 8px;
}
.tab-btn {
flex: 1;
padding: 12px 16px;
background: transparent;
border: none;
border-radius: 6px;
font-weight: 600;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.tab-btn:hover {
background: var(--hover-bg);
color: var(--text-color);
}
.tab-btn.active {
background: var(--primary-red);
color: white;
}
.tab-btn i {
font-size: 0.9rem;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.donation-btn-custom {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
/* Dark theme adjustments */
[data-theme="dark"] .donation-security {
background: var(--card-bg);
border-color: var(--border-color);
}
[data-theme="dark"] .donation-btn {
background: var(--card-bg);
border-color: var(--border-color);
}
[data-theme="dark"] .donation-btn:hover {
background: rgba(255, 0, 0, 0.1);
}
[data-theme="dark"] .platform-btn {
background: var(--main-bg);
}
/* Responsive pour les plateformes */
@media (max-width: 768px) {
.platforms-grid {
grid-template-columns: 1fr;
}
.donation-tabs {
flex-direction: column;
}
}
-273
View File
@@ -1,273 +0,0 @@
/* Styles pour la liste compacte de morceaux Funkwhale */
.funkwhale-container {
background: var(--card-bg);
border-radius: 12px;
border: 1px solid var(--border-color);
box-shadow: var(--card-shadow);
overflow: hidden;
height: 400px;
display: flex;
flex-direction: column;
}
.funkwhale-section {
width: 100%;
min-width: 0;
}
.funkwhale-header {
background: var(--primary-red);
color: white;
padding: 12px 16px;
font-size: 1rem;
font-weight: bold;
display: flex;
align-items: center;
gap: 8px;
}
.funkwhale-header a {
color: white;
text-decoration: none;
transition: opacity 0.2s ease;
}
.funkwhale-header a:hover {
opacity: 0.8;
text-decoration: underline;
}
.funkwhale-tracks-list {
flex: 1;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--primary-red) transparent;
}
.funkwhale-tracks-list::-webkit-scrollbar {
width: 6px;
}
.funkwhale-tracks-list::-webkit-scrollbar-track {
background: transparent;
}
.funkwhale-tracks-list::-webkit-scrollbar-thumb {
background-color: var(--primary-red);
border-radius: 3px;
}
.funkwhale-track-item {
display: flex;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color);
color: inherit;
transition: background-color 0.2s ease;
gap: 8px;
position: relative;
}
.funkwhale-track-item:hover {
background: var(--hover-bg);
}
.funkwhale-track-item:last-child {
border-bottom: none;
}
.funkwhale-track-item.playing {
background: var(--hover-bg);
}
.funkwhale-track-item.playing .funkwhale-track-title {
color: var(--primary-red);
}
.funkwhale-track-thumb {
width: 60px;
height: 60px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
background: var(--tag-bg);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
position: relative;
border: none;
padding: 0;
cursor: pointer;
transition: transform 0.2s ease;
}
.funkwhale-track-thumb:hover {
transform: scale(1.05);
}
.funkwhale-track-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.funkwhale-play-icon,
.funkwhale-pause-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 1.5rem;
color: rgba(255, 255, 255, 0.9);
text-shadow: 0 0 10px rgba(0, 0, 0, 0.8);
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
}
.funkwhale-track-item:hover .funkwhale-play-icon {
opacity: 1;
}
.funkwhale-track-item.playing .funkwhale-play-icon {
opacity: 0;
}
.funkwhale-track-item.playing .funkwhale-pause-icon {
opacity: 1;
}
.funkwhale-track-item.playing:hover .funkwhale-pause-icon {
opacity: 1;
}
.funkwhale-track-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.funkwhale-track-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-color);
margin: 0 0 4px 0;
line-height: 1.2;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.funkwhale-track-artist {
font-size: 0.7rem;
color: var(--primary-red);
font-weight: 600;
margin: 0 0 4px 0;
display: flex;
align-items: center;
gap: 4px;
}
.funkwhale-track-artist i {
font-size: 0.65rem;
}
.funkwhale-track-meta {
font-size: 0.65rem;
color: var(--text-secondary);
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}
.funkwhale-track-meta i {
font-size: 0.6rem;
}
.funkwhale-no-tracks {
text-align: center;
color: var(--text-secondary);
padding: 20px;
font-style: italic;
font-size: 0.9rem;
}
/* Lien externe vers Funkwhale */
.funkwhale-external-link {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
flex-shrink: 0;
color: var(--text-secondary);
text-decoration: none;
border-radius: 50%;
transition: all 0.2s ease;
margin-left: auto;
}
.funkwhale-external-link:hover {
background: var(--tag-bg);
color: var(--primary-red);
transform: scale(1.1);
}
.funkwhale-external-link i {
font-size: 0.85rem;
}
/* Masquer l'élément audio */
.funkwhale-track-item audio {
display: none;
}
/* Responsive Design */
@media (max-width: 1024px) {
.funkwhale-section {
width: 100%;
}
.funkwhale-container {
height: auto;
max-height: 400px;
}
}
@media (max-width: 768px) {
.funkwhale-track-thumb {
width: 65px;
height: 65px;
}
.funkwhale-track-item {
padding: 10px 12px;
gap: 10px;
}
.funkwhale-track-title {
font-size: 0.85rem;
}
.funkwhale-track-artist {
font-size: 0.7rem;
}
.funkwhale-track-meta {
font-size: 0.65rem;
}
.funkwhale-header {
padding: 10px 12px;
font-size: 0.9rem;
}
.funkwhale-play-icon {
font-size: 1.3rem;
}
}
-117
View File
@@ -1,117 +0,0 @@
/* Layout grid pour Hero, Castopod, Funkwhale, Mastodon et WordPress */
/* Ce fichier surcharge les anciens styles pour le nouveau layout responsive */
/* Force le wrapper parent à être un simple bloc, pas une grille */
.hero-mastodon-wrapper {
display: block !important;
grid-template-columns: none !important;
width: 100%;
padding: 0 20px;
margin-bottom: 30px;
}
/* Le vrai container grid qui contient tous les éléments */
.timeline-wordpress-container {
display: grid !important;
gap: 20px;
margin: 0;
width: 100%;
}
/* Supprime toutes les limitations de largeur sur les anciens containers */
.timeline-container {
max-width: none !important;
width: 100% !important;
justify-self: auto !important;
grid-template-columns: none !important;
display: block !important;
}
/* S'assurer que les sections prennent toute la largeur disponible */
.castopod-section,
.funkwhale-section,
.mastodon-section,
.wordpress-section {
width: 100% !important;
max-width: none !important;
min-width: 0 !important;
}
/* Desktop : 2 éléments par ligne */
@media (min-width: 1200px) {
.timeline-wordpress-container {
grid-template-columns: repeat(2, 1fr) !important;
}
/* Hero prend toute la largeur si présent */
.hero-video-container,
.hero-live-container,
.hero-playlist-container {
grid-column: 1 / -1 !important;
}
/* Centrer Mastodon quand il est seul (HERO_TYPE='none', pas de Castopod ni Funkwhale) */
.timeline-wordpress-container:not(:has(.castopod-section)):not(:has(.funkwhale-section)) {
grid-template-columns: 1fr !important;
justify-items: center !important;
}
.timeline-wordpress-container:not(:has(.castopod-section)):not(:has(.funkwhale-section)) .mastodon-section {
max-width: 800px !important;
width: 100% !important;
}
/* Centrer le dernier élément quand il est seul sur sa ligne (nombre impair d'éléments) */
/* Cas : Castopod + Funkwhale + Mastodon (3 éléments) */
/* Mastodon sera le 3e élément (impair) et doit être centré */
.timeline-wordpress-container:has(.castopod-section):has(.funkwhale-section) .mastodon-section:nth-child(3) {
grid-column: 1 / -1 !important;
max-width: 800px !important;
width: 800px !important;
margin: 0 auto !important;
justify-self: center !important;
}
}
/* Tablette et Mobile : 1 élément par ligne */
@media (max-width: 1199px) {
.timeline-wordpress-container {
grid-template-columns: 1fr !important;
}
.hero-mastodon-wrapper {
padding: 0 15px;
}
/* En mobile, tous les éléments prennent toute la largeur */
.castopod-section,
.funkwhale-section,
.mastodon-section,
.wordpress-section {
width: 100% !important;
max-width: none !important;
}
}
@media (max-width: 768px) {
.hero-mastodon-wrapper {
padding: 0 10px;
}
}
/* Hauteurs fixes pour les containers scrollables */
.castopod-container,
.funkwhale-container,
.mt-container,
.wordpress-container {
height: 400px;
}
@media (max-width: 768px) {
.castopod-container,
.funkwhale-container,
.mt-container,
.wordpress-container {
height: 350px;
}
}
-1
View File
File diff suppressed because one or more lines are too long
-206
View File
@@ -1,206 +0,0 @@
/* Styles pour la page de recherche */
.search-page {
margin: 30px 0;
}
.search-header {
margin-bottom: 30px;
}
.search-header h1 {
font-size: 1.8rem;
margin-bottom: 15px;
color: #333;
}
.search-container {
max-width: 600px;
margin-bottom: 20px;
}
.search-container form {
display: flex;
border: 1px solid #ddd;
border-radius: 4px;
overflow: hidden;
}
.search-container input {
flex: 1;
padding: 12px 15px;
border: none;
font-size: 16px;
}
.search-container button {
background-color: #0d6efd;
color: white;
border: none;
padding: 0 20px;
cursor: pointer;
font-size: 18px;
}
.search-results-count {
margin-bottom: 20px;
color: #666;
font-size: 14px;
}
.search-results {
display: flex;
flex-direction: column;
gap: 20px;
}
.search-result-item {
display: flex;
background-color: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: transform 0.2s;
}
.search-result-item:hover {
transform: translateY(-3px);
}
.search-result-thumbnail {
width: 246px;
height: 138px;
position: relative;
flex-shrink: 0;
}
.search-result-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-duration {
position: absolute;
bottom: 8px;
right: 8px;
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
}
.search-result-info {
padding: 15px;
flex: 1;
}
.search-result-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
color: #333;
}
.search-result-metadata {
display: flex;
font-size: 13px;
color: #666;
margin-bottom: 8px;
}
.search-result-views {
margin-right: 15px;
}
.search-result-channel {
font-size: 14px;
color: #0d6efd;
margin-bottom: 8px;
}
.search-result-description {
font-size: 14px;
color: #555;
margin-bottom: 10px;
line-height: 1.4;
}
.search-result-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag {
font-size: 12px;
color: var(--primary-red);
}
.no-results, .search-instructions {
text-align: center;
padding: 60px 0;
color: #666;
}
.no-results i, .search-instructions i {
color: #ccc;
margin-bottom: 20px;
}
.no-results h2, .search-instructions h2 {
font-size: 20px;
margin-bottom: 10px;
color: #333;
}
.no-results p, .search-instructions p {
font-size: 16px;
max-width: 500px;
margin: 0 auto;
}
/* Media Queries */
@media (max-width: 768px) {
.search-result-item {
flex-direction: column;
}
.search-result-thumbnail {
width: 100%;
height: 0;
padding-top: 56.25%; /* 16:9 aspect ratio */
}
.search-result-thumbnail img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.search-header h1 {
font-size: 1.5rem;
}
}
@media (max-width: 576px) {
.search-container input {
padding: 10px;
}
.search-container button {
padding: 0 15px;
}
.search-result-title {
font-size: 16px;
}
.search-result-description {
font-size: 13px;
}
}
-3414
View File
File diff suppressed because it is too large Load Diff
-1327
View File
File diff suppressed because it is too large Load Diff
-204
View File
@@ -1,204 +0,0 @@
/* Styles pour la liste compacte d'articles WordPress */
/* Container principal pour timeline + wordpress */
.timeline-container {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 20px;
margin: 0;
align-items: start;
width: 100%;
}
.wordpress-container {
background: var(--card-bg);
border-radius: 12px;
border: 1px solid var(--border-color);
box-shadow: var(--card-shadow);
overflow: hidden;
height: 400px;
display: flex;
flex-direction: column;
}
.wordpress-section {
width: 100%;
min-width: 0;
}
/* Assurer que le container Mastodon garde son scroll */
.timeline-container .mt-container {
height: 400px !important;
overflow-y: auto !important;
}
.wordpress-header {
background: var(--primary-red);
color: white;
padding: 12px 16px;
font-size: 1rem;
font-weight: bold;
display: flex;
align-items: center;
gap: 8px;
}
.wordpress-header a {
color: white;
text-decoration: none;
transition: opacity 0.2s ease;
}
.wordpress-header a:hover {
opacity: 0.8;
text-decoration: underline;
}
.wordpress-posts-list {
flex: 1;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--primary-red) transparent;
}
.wordpress-posts-list::-webkit-scrollbar {
width: 6px;
}
.wordpress-posts-list::-webkit-scrollbar-track {
background: transparent;
}
.wordpress-posts-list::-webkit-scrollbar-thumb {
background-color: var(--primary-red);
border-radius: 3px;
}
.wordpress-post-item {
display: flex;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color);
text-decoration: none;
color: inherit;
transition: background-color 0.2s ease;
gap: 8px;
}
.wordpress-post-item:hover {
background: var(--hover-bg);
text-decoration: none;
color: inherit;
}
.wordpress-post-item:last-child {
border-bottom: none;
}
.wordpress-post-thumb {
width: 40px;
height: 40px;
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
background: var(--tag-bg);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
}
.wordpress-post-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.wordpress-post-info {
flex: 1;
min-width: 0;
}
.wordpress-post-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-color);
margin: 0 0 4px 0;
line-height: 1.2;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.wordpress-post-meta {
font-size: 0.65rem;
color: var(--text-secondary);
display: flex;
gap: 6px;
}
.wordpress-no-posts {
text-align: center;
color: var(--text-secondary);
padding: 20px;
font-style: italic;
font-size: 0.9rem;
}
/* Ajustement du container Mastodon */
.mt-container {
width: 100%;
min-width: 0;
}
/* Responsive Design */
@media (max-width: 1024px) {
/* Mobile/Tablette: layout vertical */
.timeline-container {
grid-template-columns: 1fr;
gap: 15px;
}
.wordpress-section {
width: 100%;
}
/* Maintenir la hauteur et le scroll du container Mastodon en mobile */
.timeline-container .mt-container {
height: 400px !important;
overflow-y: auto !important;
max-height: 400px !important;
}
.wordpress-container {
height: auto;
}
}
@media (max-width: 768px) {
.wordpress-post-thumb {
width: 50px;
height: 50px;
}
.wordpress-post-item {
padding: 10px 12px;
gap: 10px;
}
.wordpress-post-title {
font-size: 0.85rem;
}
.wordpress-post-meta {
font-size: 0.65rem;
}
.wordpress-header {
padding: 10px 12px;
font-size: 0.9rem;
}
}
-278
View File
@@ -1,278 +0,0 @@
<?php
// Inclure la configuration
require_once 'includes/config.php';
// Vérifier si le mode compte à rebours est activé
if (defined('COUNTDOWN_ENABLED') && COUNTDOWN_ENABLED === true) {
// Rediriger vers la page de compte à rebours
require_once 'countdown.php';
exit;
}
// Inclure les fonctions de données structurées
require_once 'includes/structured-data.php';
// Appliquer les en-têtes de sécurité
setSecurityHeaders();
// Vérifier s'il y a un direct en cours
$liveStream = getLiveStream();
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Direct - <?php echo SITE_NAME; ?></title>
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
<!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="img/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png">
<link rel="manifest" href="site.webmanifest">
<link rel="icon" type="image/x-icon" href="img/favicon.ico">
<meta name="theme-color" content="#ffffff">
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="Direct - <?php echo SITE_NAME; ?>">
<meta property="og:description" content="Suivez nos diffusions en direct sur <?php echo SITE_NAME; ?>. Contenu en temps réel, discussions et événements exclusifs.">
<meta property="og:image" content="<?php echo getBaseUrl(); ?>/img/logo.png">
<meta property="og:url" content="<?php echo getBaseUrl(); ?>/direct.php">
<meta property="og:type" content="website">
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
<meta property="og:locale" content="fr_FR">
<!-- Twitter Card Meta Tags -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Direct - <?php echo SITE_NAME; ?>">
<meta name="twitter:description" content="Suivez nos diffusions en direct sur <?php echo SITE_NAME; ?>. Contenu en temps réel, discussions et événements exclusifs.">
<meta name="twitter:image" content="<?php echo getBaseUrl(); ?>/img/logo.png">
<!-- Données structurées JSON-LD pour la page direct -->
<script type="application/ld+json" nonce="<?php echo getCspNonce(); ?>">
{
"@context": "https://schema.org",
"@type": "WebPage",
"name": "Direct - <?php echo SITE_NAME; ?>",
"description": "Suivez nos diffusions en direct sur <?php echo SITE_NAME; ?>. Contenu en temps réel.",
"url": "<?php echo getBaseUrl(); ?>/direct.php",
"isPartOf": {
"@type": "WebSite",
"name": "<?php echo SITE_NAME; ?>",
"url": "<?php echo getBaseUrl(); ?>"
},
"publisher": {
"@type": "Organization",
"name": "<?php echo ORGANIZATION_NAME; ?>",
"url": "<?php echo getBaseUrl(); ?>",
"logo": {
"@type": "ImageObject",
"url": "<?php echo getBaseUrl(); ?>/img/logo.png"
}
}
}
</script>
<?php
// Générer les fils d'Ariane pour la page direct
$breadcrumbs = [
['name' => 'Accueil', 'url' => getBaseUrl()],
['name' => 'Direct', 'url' => getBaseUrl() . '/direct.php']
];
$breadcrumbJsonLd = generateBreadcrumbJsonLd($breadcrumbs);
outputJsonLd($breadcrumbJsonLd);
?>
<!-- Script pour éviter le flash en mode sombre -->
<script nonce="<?php echo getCspNonce(); ?>">
(function() {
const savedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const shouldUseDark = savedTheme === 'dark' || (!savedTheme && systemPrefersDark);
if (shouldUseDark) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
</head>
<body>
<?php include 'includes/sidebar.php'; ?>
<!-- Contenu principal -->
<div class="main-content">
<?php include 'includes/header.php'; ?>
<!-- Section Direct -->
<div class="live-page">
<div class="section-header">
<div class="section-logo">
<img src="img/logo.png" alt="<?php echo SITE_NAME; ?>">
</div>
<h2 class="section-title">Diffusion en direct</h2>
</div>
<div class="live-container">
<?php
// Vérifier s'il y a un direct en cours
$liveStream = getLiveStream();
if ($liveStream) {
// Afficher le direct en cours
?>
<div class="live-badge large">
<i class="fas fa-circle"></i> EN DIRECT
</div>
<div class="live-player">
<iframe
src="<?php echo PEERTUBE_URL; ?>/videos/embed/<?php echo $liveStream['id']; ?>?autoplay=1"
frameborder="0"
allowfullscreen="allowfullscreen"
allow="autoplay; fullscreen"
title="<?php echo htmlspecialchars($liveStream['title']); ?>">
</iframe>
</div>
<div class="live-info">
<h1 class="live-title"><?php echo htmlspecialchars($liveStream['title']); ?></h1>
<div class="live-channel-info">
<?php if (strpos($liveStream['channelAvatar'], 'default-avatar.png') !== false || empty($liveStream['channelAvatar'])): ?>
<div class="channel-avatar-placeholder">
<i class="fas fa-user-circle"></i>
</div>
<?php else: ?>
<img src="<?php echo $liveStream['channelAvatar']; ?>" alt="<?php echo $liveStream['channel']; ?>" class="channel-avatar">
<?php endif; ?>
<span class="channel-name"><?php echo $liveStream['channel']; ?></span>
</div>
<?php if (!empty($liveStream['description'])): ?>
<div class="live-description">
<?php echo markdown_to_html($liveStream['description']); ?>
</div>
<?php endif; ?>
</div>
<?php
} else {
// Aucun direct en cours - vérifier s'il y a une annonce configurée
$showNextLiveAnnouncement = defined('NEXT_LIVE_ENABLED') && NEXT_LIVE_ENABLED === true;
if ($showNextLiveAnnouncement) {
// Afficher l'annonce du prochain live
// Définir l'image de fond si disponible
$bgImageStyle = '';
if (!empty(NEXT_LIVE_IMAGE) && file_exists(NEXT_LIVE_IMAGE)) {
$bgImageStyle = 'background-image: url(\'' . htmlspecialchars(NEXT_LIVE_IMAGE) . '\');';
}
?>
<div class="next-live-announcement" style="<?php echo $bgImageStyle; ?>" nonce="<?php echo getCspNonce(); ?>">
<?php if (!empty(NEXT_LIVE_IMAGE) && file_exists(NEXT_LIVE_IMAGE)): ?>
<div class="next-live-image-container">
<img src="<?php echo htmlspecialchars(NEXT_LIVE_IMAGE); ?>"
alt="<?php echo htmlspecialchars(NEXT_LIVE_TITLE); ?>"
class="next-live-image">
</div>
<?php endif; ?>
<div class="next-live-content">
<i class="fas fa-calendar-alt"></i>
<?php
if (!empty(NEXT_LIVE_DATE)) {
$liveDate = new DateTime(NEXT_LIVE_DATE, new DateTimeZone(DEFAULT_TIMEZONE));
$dayFormatter = new IntlDateFormatter(
'fr_FR',
IntlDateFormatter::FULL,
IntlDateFormatter::NONE,
DEFAULT_TIMEZONE,
IntlDateFormatter::GREGORIAN,
'EEEE d MMMM'
);
$formattedDay = $dayFormatter->format($liveDate);
$formattedDay = ucfirst($formattedDay);
$dynamicTitle = NEXT_LIVE_TITLE . ' - ' . $formattedDay;
} else {
$dynamicTitle = NEXT_LIVE_TITLE;
}
?>
<h2><?php echo htmlspecialchars($dynamicTitle); ?></h2>
<?php
if (!empty(NEXT_LIVE_DATE)) {
$liveHour = $liveDate->format('H\hi');
$dynamicDescription = 'Rejoignez-nous à ' . $liveHour . '. ' . NEXT_LIVE_DESCRIPTION;
} else {
$dynamicDescription = NEXT_LIVE_DESCRIPTION;
}
?>
<p><?php echo nl2br(htmlspecialchars($dynamicDescription)); ?></p>
<?php if (!empty(NEXT_LIVE_DATE)): ?>
<div class="next-live-datetime">
<p class="next-live-date">
<i class="fas fa-clock"></i>
<?php
$formatter = new IntlDateFormatter(
'fr_FR',
IntlDateFormatter::FULL,
IntlDateFormatter::SHORT,
DEFAULT_TIMEZONE
);
echo $formatter->format($liveDate);
$offset = $liveDate->format('P');
echo ' <span class="utc-offset">(UTC' . $offset . ')</span>';
?>
</p>
<!-- Autres fuseaux horaires -->
<div class="next-live-timezones">
<?php
// Ordre croissant : du plus en retard au plus en avance
$timezones = [
'Ma\'ohi Nui' => 'Pacific/Tahiti',
'Martinique / Guadeloupe' => 'America/Martinique',
'Guyane' => 'America/Cayenne',
'France' => 'Europe/Paris',
'Kanaky' => 'Pacific/Noumea'
];
foreach($timezones as $name => $timezone):
$liveDateLocal = clone $liveDate;
$liveDateLocal->setTimezone(new DateTimeZone($timezone));
// Vérifier si c'est un jour différent
$dayDiff = $liveDateLocal->format('j') - $liveDate->format('j');
$dayIndicator = '';
if ($dayDiff > 0) {
$dayIndicator = ' <span class="day-shift">+1j</span>';
} elseif ($dayDiff < 0) {
$dayIndicator = ' <span class="day-shift">-1j</span>';
}
?>
<span class="timezone-item">
<strong><?php echo $name; ?> :</strong> <?php echo $liveDateLocal->format('H\hi'); ?><?php echo $dayIndicator; ?>
</span>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<a href="index.php" class="btn-primary">Retour à l'accueil</a>
</div>
</div>
<?php
} else {
?>
<div class="no-live-message">
<i class="fas fa-tv"></i>
<h2>Aucun direct en cours</h2>
<p>Revenez plus tard pour découvrir nos prochaines diffusions en direct.</p>
<a href="index.php" class="btn-primary">Retour à l'accueil</a>
</div>
<?php
}
}
?>
</div>
</div>
</div>
<?php include 'includes/footer.php'; ?>
<?php include 'includes/mobile-menu.php'; ?>
<script src="js/main.js"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
## Available Svelte MCP Tools:
### 1. list-sections
Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths.
When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections.
### 2. get-documentation
Retrieves full documentation content for specific sections. Accepts single or multiple sections.
After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task.
### 3. svelte-autofixer
Analyzes Svelte code and returns issues and suggestions.
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
### 4. playground-link
Generates a Svelte Playground link with the provided code.
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
@@ -0,0 +1,213 @@
# CHARTE OKI — DESIGN SYSTEM & HARMONISATION MULTI-PROJETS
### ORGANISATION KA INTERNATIONALE · appliquer la recette SvelteKit avec la marque OKI
> **Nature de ce document :** référence de marque opérationnelle, destinée aux humains et aux agents de code. Il consolide le `BRAND.md` existant (valeurs mesurées sur o-k-i.net, juillet 2026 — **source canonique** pour couleurs, typo, voix) et y ajoute les tokens prêts à l'emploi, les règles de motion, l'iconographie SVG, et la matrice d'application projet par projet. **En cas de conflit : ce document prime sur la recette générique pour tout ce qui touche à la marque ; la recette prime pour tout ce qui touche à la méthode technique.**
---
## 1. IDENTITÉ EN UNE PHRASE
Un studio web militant caribéen dont la marque est un **drapeau panafricain — noir, or, vert, rouge — posé sur un fond presque noir, en capitales Archivo**. La marque est afrofuturiste caribéenne : un drapeau, pas une charte corporate.
Trois conséquences non négociables :
1. **Le thème sombre est l'identité par défaut** ; le clair est une variante opt-in (`html.light-theme`) qui assombrit les accents pour rester WCAG AA.
2. **L'or porte toute l'interaction** — c'est la seule couleur d'action.
3. **Angles nets partout** — aucune forme organique, aucune bulle très arrondie.
---
## 2. TOKENS — PRÊTS À L'EMPLOI
### 2.1 Couleurs (mesurées sur le site réel)
```css
:root {
/* ── Noyau (thème sombre = défaut) ── */
--noir-oki: #0D0D0D; /* background */
--noir-profond: #1A0F1A; /* surface */
--blanc-creme: #FFF8E7; /* foreground */
--line: rgba(255, 255, 255, 0.10); /* filets — jamais de gris plein */
--or-oki: #FDB813; /* accent : SEULE couleur d'action */
--rouge-oki: #FF1654; /* signal, ponctuation — jamais un lien/bouton */
/* ── Étendue (parcimonie : décorations et sémantique) ── */
--vert-oki: #00D66C; /* succès, validation, dons mensuels */
--turquoise-caraibes: #00CED1;
--jaune-soleil: #FFD700;
--orange-flamme: #FF6B35;
--violet-nuit: #6B2D5C;
--bleu-ocean: #0077BE;
--or-clair: #FFE066; /* survol des boutons */
--gris-sombre: #2D1B2E;
/* ── Sémantique dérivée ── */
--muted: color-mix(in srgb, var(--blanc-creme) 70%, transparent);
--card-bg: rgba(255, 255, 255, 0.03);
}
```
Règles d'usage (mesurées, à faire respecter) :
- Texte secondaire = blanc crème à 7085 % d'opacité, **pas** une couleur dédiée.
- Filets = blanc à 10 %, jamais de gris plein.
- Rouge = signaler · Vert = valider · Or = agir. Ne jamais permuter ces rôles.
### 2.2 Typographie
```css
:root {
--font-display: 'Archivo', 'Arial Black', sans-serif; /* 600900, TITRES + BOUTONS */
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; /* 400800 */
}
```
- Titres h1h4 : Archivo, **MAJUSCULES**, `letter-spacing: -0.01em`.
- Boutons : Archivo 700, uppercase, `letter-spacing: 0.03em`, bordure 2 px.
- Corps : Inter, `line-height: 1.5`. Graisses minimales : 400 body / 600 display (lisibilité écrans bas de gamme).
- **Self-hébergement obligatoire** : woff2 dans `fonts/` + `fonts/fonts.css`, `font-display: swap`. Aucun appel Google Fonts (contradiction avec la doctrine souveraineté — bug en cours de correction sur o-k-i.net).
### 2.3 Formes, espacements, layout
```css
:root {
--radius-sm: 3px; /* boutons, tags, badges */
--radius-md: 6px; /* cartes — jamais plus */
--border-card: 1px solid var(--line);
--border-btn: 2px solid var(--or-oki);
--space-1: 0.75rem; --space-2: 1rem; --space-3: 1.5rem; --space-4: 2rem; --space-5: 4rem;
--container: 1200px;
}
```
- Sections : `padding: var(--space-5) var(--space-4)` · Grille : `repeat(auto-fit, minmax(300px, 1fr))`.
- **Carte OKI canonique** : fond blanc 3 %, liseré gauche 4 px d'accent (vert ou or), survol `translateY(-2px à -4px)` + bordure d'accent.
- **Tag OKI canonique** : fond or 8 % + bordure or 1 px, rayon 3 px.
### 2.4 La flag-bar — signature n°1
Bandeau **6 px, 4 segments francs** : noir 025 % · or 2550 % · vert 5075 % · rouge 75100 %. Arrêts nets, **jamais de dégradé**.
```css
.flag-bar {
height: 6px;
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%);
}
```
Placement : bord inférieur de la navigation fixe ; peut ponctuer un héros ou un pied de page. **Une occurrence visible par écran maximum.** C'est l'élément le plus reconnaissable de la marque — le diluer, c'est le tuer.
### 2.5 Logotype
Monogramme « OKI » : arche or, point vert, accent rouge sur noir. Fichier canonique `logos/favicon-1.png` (512×512, transparent — aussi og:image et apple-touch-icon) ; déclinaisons `logos/favicon-0.ico`, `assets/maskable_oki.png`. Les `logos/header-img-*` sont des marques de **partenaires** (Joukawouvè, Aktivist Vybz, cyber-mawonaj, KA UBUNTU) : provenance conservée, **ne jamais les utiliser comme logo OKI**.
---
## 3. MOTION OKI — LES CADENCES GWOKA
La marque ne bouge pas comme une startup : elle pulse comme un ka. Tokens obligatoires dans tout projet OKI animé :
```css
:root {
--ease-ka: cubic-bezier(0.22, 1, 0.36, 1); /* temps fort : attaque franche, longue tenue */
--ease-syncope: cubic-bezier(0.65, 0, 0.35, 1); /* contretemps : symétrique, urgent */
--dur-tanbou: 120ms; /* double-croche — micro-interactions, hovers */
--dur-mesure: 480ms; /* une mesure — entrées de section */
--dur-phrase: 960ms; /* deux mesures — transitions de page */
}
```
Règles de motion :
1. **Le stagger de marque OKI est syncopé (3+3+2)**, pas linéaire : délais `[0, 120, 300, 360, 600, 660, 900, 960]` ms pour 8 éléments. C'est subtil — c'est le but : le rythme est une identité, pas un effet.
2. Uniquement `transform` + `opacity` (recette P3/budgets).
3. `prefers-reduced-motion` : gate unique au provider, contenu statique complet (le site actuel n'a **aucune** garde — à corriger partout).
4. Durées toujours lues depuis les tokens, jamais hardcodées.
---
## 4. ICONOGRAPHIE — LA RÈGLE « ZÉRO EMOJI EN PRODUCTION »
Le site actuel utilise des emojis (✊🏿🔓🤝🏿🛡️) : rendu dépendant de l'OS, incohérent avec la précision du drapeau. **Tout projet harmonisé remplace les emojis par le set SVG OKI.**
Spécifications du set :
- ViewBox 24×24, **stroke 2 px**, angles nets (pas de courbes organiques), une seule couleur : `currentColor` (or par défaut sur noir).
- Remplissage réservé aux pictogrammes ≤ 16 px.
- Grille de nommage en KA : les fichiers portent des noms kréyòl.
Set initial à produire (correspondances avec l'existant) :
| Fichier | Sujet | Remplace / usage |
|---|---|---|
| `ka.svg` | tambour ka | icône maîtresse, 404, loader |
| `lambi.svg` | conque lambi | communication, annonces, « bòkaz » |
| `zetwal.svg` | étoile 4 branches | navigation, favoris, instances fédivers |
| `mawon.svg` | silhouette/brisure de chaîne stylisée | souveraineté, valeurs |
| `lakanmou.svg` | flamme | engagement, dons ponctuels |
| `jaden.svg` | pousse/jardin | solidarité, lakou, projets |
| `kannen.svg` | canne à sucre stylisée | patrimoine, histoire |
| `lanme.svg` | vague | océan, liens internationaux, Réunion↔Gwada |
| `glo.svg` | poing levé géométrique | ✊🏿 — luttes, tarifs solidaires |
| `kle.svg` | clé/cadenas ouvert | 🔓 — liberté, open-source |
| `pawol.svg` | bulle de parole angulaire | langues, traduction, PAWÒL-NU |
| `mizik-note.svg` | note de musique | MIZIK, DJANGOKAM |
Chaque picto livré en `<symbol>` dans un sprite `icons.svg` + usage `<svg><use href="icons.svg#ka"/></svg>` (une seule requête, stylable en CSS).
---
## 5. VOIX & TON (canonique, reprise du BRAND.md)
Vouvoiement direct et empathique, phrases courtes. On parle d'abord de la cause du visiteur — « Vous luttez, vous organisez, vous mobilisez » — puis de l'outil. La technique sert la lutte, jamais l'inverse.
- **À dire :** sur mesure · tarifs adaptés à vos moyens · organisations engagées · transition numérique · écosystème numérique libre · à la hauteur.
- **À éviter :** « template générique », jargon corporate/marketing, « clé en main », startup-speak.
- Piliers : ORGANISATION KA · INTERNATIONALE · Des solutions pour chaque besoin · Le budget ne doit jamais être un frein.
- **Langues :** FR par défaut ; le KA (kréyòl) en signature, titres de sections symboliques et microcopy (404, loader, remerciements) ; EN en version dédiée. Attribut `lang` correct à chaque bascule.
## 6. IMAGERIE (canonique)
Illustration numérique afrofuturiste caribéenne : scènes au coucher de soleil, personnages caribéens, pochettes musicales typographiées. Couleurs chaudes saturées sur fonds sombres. **Jamais** de photo de banque d'images corporate, jamais d'illustration générique de startup, jamais d'icône ou de logo en guise d'image. Formats : AVIF/WebP, self-hébergés, `loading="lazy"` hors LCP.
---
## 7. MATRICE D'APPLICATION PAR PROJET
Harmoniser ≠ tout refaire : chaque plateforme a un canal de personnalisation officiel. N'UTILISER QUE ces canaux (jamais de fork pour du style).
| Projet | Logiciel | Canal de theming | Application OKI |
|---|---|---|---|
| **o-k-i.net** | SvelteKit (migration en cours) | Natif | Référence absolue : tokens §2, flag-bar, village SVG, KineticText gwoka |
| **BOKANTE** | Mastodon | CSS personnalisé admin + `custom.css` | Variables Mastodon remappées sur tokens OKI : fond `--noir-oki`, accent `--or-oki`, boutons Archivo uppercase ; logo colonne = monogramme OKI ; flag-bar en haut de la colonne de composition |
| **GADE** | PeerTube | Thème CSS admin (champ « CSS personnalisé ») | Mêmes remappages ; player : contrôles or sur noir ; page À-propos = texte marque (« vidéos sans algorithme, dédiées aux cultures afro-diasporiques ») |
| **NIYAJ** | Nextcloud | App **Theming** officielle | Couleur primaire `#FDB813`, fond sombre, logo + slogan « Votre cloud, vos données » ; favicon OKI |
| **MIZIK** | Funkwhale | `custom.css` admin | Fond `--noir-oki`, accents or, player accentué or ; typographie : Inter si surchargeable, sinon système |
| **KUTE** | App PHP maison | Natif | Appliquer les tokens §2 en entier — c'est un projet first-party, aucune excuse de ne pas être au niveau d'o-k-i.net |
| **LABOLA** | Forgejo/Gitea | `custom/templates` + `custom/public/css` | Thème sombre OKI, accent or ; logo forge = monogramme ; **ne pas toucher aux templates Go au-delà du header/footer** (fragile aux upgrades) |
| **PAWÒL-NU / djangokam.pawol.nu / nyyoka.pawol.nu** | Selon stack | Natif | Charte complète + liberté artistique accrue (pochettes, kinetic type poussé) : c'est la vitrine culturelle, le motion peut y être plus riche qu'ailleurs |
| **fediverse.o-k-i.net** | Statique | Natif | **Constellation zétwal** : chaque instance = étoile SVG reliée, hover/focus = éclat or + label ; DOM/SVG léger uniquement |
### Le footer fédéré commun (tous les sous-domaines)
```
[monogramme OKI] Un service libre opéré par ORGANISATION KA INTERNATIONALE · o-k-i.net
```
+ flag-bar 6 px au-dessus du footer. Une seule ligne, `--muted`, lien or. C'est le fil rouge qui fait l'écosystème.
### Règles cross-projets
1. **Un domaine, une famille** : `*.o-k-i.net` = écosystème fédivers et outils ; `pawol.nu` = projets culturels/musicaux. Ne pas mélanger.
2. Chaque service porte un **nom KA + le nom du logiciel en sous-titre** (pattern existant : « BOKANTE — Mastodon », « NIYAJ — Nextcloud »). Ne jamais masquer le logiciel sous-jacent : la transparence est un pilier.
3. Favicon = monogramme OKI partout (déclinaisons ico/png/maskable).
4. og:image par service : monogramme sur `--noir-oki` + nom du service en Archivo or.
5. Toute nouvelle page statique du réseau démarre du template o-k-i.net (tokens inclus), jamais de zéro.
---
## 8. CHECKLIST D'HARMONISATION D'UN PROJET (DoD marque)
- [ ] Thème sombre par défaut, clair opt-in contrasté AA.
- [ ] Tokens §2 importés (pas de valeur recopiée à la main — un fichier `oki-tokens.css` partagé).
- [ ] Or = seule couleur d'action ; rouge/vert dans leurs rôles sémantiques.
- [ ] Archivo/Inter self-hébergées, zéro fonte externe.
- [ ] Flag-bar présente, segments francs, ≤ 1 par écran.
- [ ] Zéro emoji en interface (set SVG §4).
- [ ] Tokens motion §3 + gate `prefers-reduced-motion`.
- [ ] Footer fédéré + monogramme + favicon OKI.
- [ ] Voix conforme §5 (test : aucune occurrence de « clé en main », « solution innovante », « disruptive »).
- [ ] Zéro requête tierce au chargement (fonts, CDN, analytics) — vérifié onglet réseau.
- [ ] Nom KA + logiciel en sous-titre visible en page d'accueil du service.
@@ -0,0 +1,494 @@
# PLAYBOOK UNIFIÉ — OKI × SVELTEKIT
### Document de référence opérationnel pour agent de code — marque OKI, méthode Awwwards-grade, conventions Svelte 5, briques réutilisables
> **Nature :** fusion de `charte-oki-design-system.md`, `recette-sveltekit-playbook-agent.md`, `recette-sveltekit-15-sites-awwwards.md`, `svelte-5_code_writer.md`, `svelte_core_bestpractices.md` et `AGENTS.md`, enrichie des leçons réelles de **6 missions** (juillet 2026) : migration o-k-i.net, refonte atlas-fediverse, finalisation exitchatcontrol, migration gwada-sirius, refonte UX/motion du jeu JWE, réparation et modernisation d'oki-podcast-reader (voir §5, §5b et §5c). Document **local, non versionné** (`.gitignore`).
>
> **Usage :** à fournir à l'agent pour toute demande du type « transforme ce projet web en projet Svelte aux couleurs OKI, style Awwwards » ou « crée un site/PWA dans cette charte ». Compléter avec le brief de mission du §7.
>
> **Priorités en cas de conflit :** la marque (§1) prime pour couleurs, typo, voix, iconographie · la méthode (§2) prime pour la technique · les budgets et l'accessibilité ne se négocient jamais.
---
## 0. TYPES DE MISSION
**A. Transformation** d'un site existant (HTML statique, WordPress, Eleventy, SPA sans DA) → SvelteKit aux couleurs OKI. Toujours commencer par l'audit (§2.4 phase 1) — le descriptif du site source est **toujours** à vérifier contre le dépôt réel (la migration o-k-i.net partait d'un brief « HTML statique » alors que le site était Eleventy + 38 JSON i18n).
**B. Création** d'un nouveau site/PWA dans la charte OKI. Commencer au §2.4 phase 2, en copiant les briques du §4.
**C. Finalisation** — le chantier est déjà avancé (migration à 90 %, WIP non commité). Le travail est de **finir, pas recommencer** : builder d'abord, committer l'existant cohérent, puis combler les manques (typiquement SEO et régressions du changement de générateur).
**D. Refonte ciblée UX/motion** — la stack est déjà SvelteKit. Le travail est chirurgical : remplacer les pièces défaillantes (fond, carte, timeline, layout) et appliquer charte + motion, sans tout réécrire.
Quatre règles transverses, apprises sur 5 projets :
1. **Contenu éditorial jamais réécrit** sans instruction explicite ; les données priment sur les suppositions (ex. KUTE = Castopod dans les JSON, pas « app PHP » comme le brief le supposait).
2. **Toujours vérifier l'existence de git en premier** (atlas-fediverse n'avait AUCUN dépôt — `git init` + commit de l'existant avant toute modification, sinon travail irréversible).
3. **Charte §7 « harmoniser ≠ tout refaire »** : si le projet a déjà un design system de la famille OKI (tokens panafricains, polices accessibilité), on ne rebrande pas — on applique méthode et qualité (exitchatcontrol, gwada-sirius). La charte complète ne s'applique qu'aux projets sans identité propre (atlas).
4. **Vérifier l'état réel avant de scoper** : sur 5 projets, 2 étaient déjà SvelteKit (atlas, JWE), 1 en migration à 90 % (exitchatcontrol), 1 à 80 % (gwada-sirius) — une seule vraie migration complète.
---
## 1. MARQUE OKI (canonique — prime sur tout le reste)
### 1.1 Identité en une phrase
Un studio web militant caribéen dont la marque est un **drapeau panafricain — noir, or, vert, rouge — posé sur un fond presque noir, en capitales Archivo**. Afrofuturiste caribéen : un drapeau, pas une charte corporate.
Trois conséquences non négociables :
1. **Thème sombre = identité par défaut** ; le clair est un opt-in (`html.light-theme`) qui assombrit les accents pour WCAG AA.
2. **L'or porte toute l'interaction** — seule couleur d'action.
3. **Angles nets partout** — aucune forme organique, aucune bulle très arrondie.
### 1.2 Tokens couleurs (mesurés sur le site réel)
```css
:root {
/* Noyau (thème sombre = défaut) */
--noir-oki: #0D0D0D; /* background */
--noir-profond: #1A0F1A; /* surface */
--blanc-creme: #FFF8E7; /* foreground */
--line: rgba(255,255,255,0.10); /* filets — jamais de gris plein */
--or-oki: #FDB813; /* accent : SEULE couleur d'action */
--rouge-oki: #FF1654; /* signal, ponctuation — jamais un lien/bouton */
/* Étendue (parcimonie) */
--vert-oki: #00D66C; /* succès, validation, dons mensuels */
--turquoise-caraibes: #00CED1;
--jaune-soleil: #FFD700;
--orange-flamme: #FF6B35;
--violet-nuit: #6B2D5C;
--bleu-ocean: #0077BE;
--or-clair: #FFE066; /* survol des boutons */
--gris-sombre: #2D1B2E;
/* Sémantique dérivée */
--muted: color-mix(in srgb, var(--blanc-creme) 70%, transparent);
--card-bg: rgba(255,255,255,0.03);
}
/* Thème clair — opt-in, accents assombris WCAG AA */
html.light-theme {
--noir-oki: #FFF8E7; --blanc-creme: #0D0D0D;
--noir-profond: #F5F0E8; --gris-sombre: #E8DDD0;
--or-oki: #B87A00; --vert-oki: #006B3D; --rouge-oki: #A01030;
--turquoise-caraibes: #006B75; --bleu-ocean: #004B7F;
--or-clair: #D99000; --jaune-soleil: #B87A00; --orange-flamme: #C85000;
--line: rgba(0,0,0,0.12); --card-bg: rgba(0,0,0,0.03);
}
```
Règles d'usage : texte secondaire = crème à 70-85 % (`--muted`), pas de couleur dédiée · filets = blanc 10 % · **Rouge = signaler · Vert = valider · Or = agir — ne jamais permuter**.
### 1.3 Typographie
```css
--font-display: 'Archivo', 'Arial Black', sans-serif; /* 600-900 : TITRES + BOUTONS */
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; /* 400-800 */
```
- Titres h1-h4 : Archivo, MAJUSCULES, `letter-spacing: -0.01em`.
- Boutons : Archivo 700, uppercase, `letter-spacing: 0.03em`, bordure 2 px.
- Corps : Inter, `line-height: 1.5`.
- **Self-hébergement obligatoire** : woff2 dans `static/fonts/` + `fonts.css`, `font-display: swap`, preload de la display 700. Aucun appel Google Fonts (doctrine souveraineté). Source pratique : `@fontsource/archivo` + `@fontsource/inter` (npm), copier les `*-latin-*-normal.woff2`.
### 1.4 Formes, espacements, layout
```css
--radius-sm: 3px; --radius-md: 6px; /* jamais plus */
--border-card: 1px solid var(--line);
--border-btn: 2px solid var(--or-oki);
--space-1: .75rem; --space-2: 1rem; --space-3: 1.5rem; --space-4: 2rem; --space-5: 4rem;
--container: 1200px;
```
- Sections : `padding: var(--space-5) 0` · Grilles : `repeat(auto-fit, minmax(300px, 1fr))`.
- **Carte canonique** : fond `--card-bg`, liseré gauche 4 px d'accent, survol `translateY(-2px à -4px)` + bordure d'accent.
- **Tag canonique** : fond or 8 % + bordure or 1 px, rayon 3 px.
- **Bouton canonique** : balayage or `::after scaleX(0→1)` origin left au hover, `--ease-syncope`.
### 1.5 Flag-bar — signature n°1
Bandeau **6 px, 4 segments francs** : noir 0-25 % · or 25-50 % · vert 50-75 % · rouge 75-100 %. Arrêts nets, **jamais de dégradé entre segments**.
```css
.flag-bar { height: 6px; background: linear-gradient(to right,
#0D0D0D 0 25%, var(--or-oki) 25% 50%, var(--vert-oki) 50% 75%, var(--rouge-oki) 75% 100%); }
```
Placement : bord inférieur de la nav fixe + haut du footer. **Une occurrence pleine largeur visible par écran max.** Pour ponctuer ailleurs (titres de section, loader), utiliser le **flag chip** : même 4 segments, 56 × 6 px (voir §4). En thème clair, le segment noir devient `#000` pur.
### 1.6 Logotype
Monogramme « OKI » : arche or, point vert, accent rouge sur noir. Fichier canonique 512×512 transparent (aussi og:image, apple-touch-icon, maskable PWA). Ne jamais utiliser un logo de partenaire comme logo OKI.
### 1.7 Motion — les cadences gwoka
```css
--ease-ka: cubic-bezier(0.22, 1, 0.36, 1); /* temps fort : attaque franche, longue tenue */
--ease-syncope: cubic-bezier(0.65, 0, 0.35, 1); /* contretemps : symétrique, urgent */
--dur-tanbou: 120ms; /* double-croche — micro-interactions, hovers */
--dur-mesure: 480ms; /* une mesure — entrées de section */
--dur-phrase: 960ms; /* deux mesures — transitions de page, moments solennels */
```
1. **Le stagger de marque est syncopé (3+3+2)** : délais `[0, 120, 300, 360, 600, 660, 900, 960]` ms pour 8 éléments (puis cycles de +1200 ms). Le rythme est une identité, pas un effet.
2. Uniquement `transform` + `opacity` (+ `clip-path` pour les masques).
3. **Gate unique `prefers-reduced-motion`** au niveau global, contenu statique complet sans JS ni motion.
4. Durées toujours lues depuis les tokens, jamais hardcodées.
### 1.8 Iconographie — zéro emoji en production
Set SVG OKI : viewBox 24×24, **stroke 2 px**, angles nets, `currentColor` (or par défaut), remplissage réservé aux ≤ 16 px, **noms kréyòl**. Livré en `<symbol>` dans un sprite `icons.svg` + `<svg class="icon"><use href="/icons.svg#ID"/></svg>`.
Set de référence (existant dans o-k-i.net) : `ka` (tambour, icône maîtresse, 404/loader) · `lambi` (conque — annonces) · `zetwal` (étoile 4 branches — navigation, instances) · `mawon` (brisure de chaîne — souveraineté) · `lakanmou` (flamme — engagement, dons) · `jaden` (pousse — solidarité, projets) · `kannen` (canne — patrimoine, institutions) · `lanme` (vague — international) · `glo` (poing — luttes, tarifs solidaires) · `kle` (cadenas ouvert — liberté, open-source) · `pawol` (bulle angulaire — langues, traduction) · `mizik-note` (note — musique).
**Pattern contenu** : si des emojis vivent dans les textes (JSON), utiliser un helper `splitLeadingEmoji()` qui convertit l'emoji décoratif en picto du sprite sans toucher au texte (implémenté dans o-k-i.net `sections/pictos.ts`).
### 1.9 Voix & ton
Vouvoiement direct et empathique, phrases courtes. La cause du visiteur d'abord, l'outil ensuite. **À dire :** sur mesure · tarifs adaptés à vos moyens · organisations engagées · transition numérique · écosystème numérique libre. **À éviter :** « template générique », « clé en main », jargon corporate, startup-speak. Piliers : ORGANISATION KA · INTERNATIONALE · Des solutions pour chaque besoin · Le budget ne doit jamais être un frein. **Langues :** FR par défaut ; KA (kréyòl) en signature et microcopy (404, loader, remerciements — attribut `lang="gcf"`) ; EN en version dédiée.
### 1.10 Imagerie
Illustration afrofuturiste caribéenne : couleurs chaudes saturées sur fonds sombres. Jamais de stock corporate, jamais d'illustration startup générique. AVIF/WebP self-hébergés, `loading="lazy"` hors LCP.
### 1.11 Écosystème
- Nom KA + logiciel en sous-titre (« BOKANTE — Mastodon ») : la transparence est un pilier.
- Footer fédéré sur tous les sous-domaines : `[monogramme] Un service libre opéré par ORGANISATION KA INTERNATIONALE · o-k-i.net` + flag-bar 6 px au-dessus.
- Favicon = monogramme partout. `*.o-k-i.net` = outils/fédivers ; `pawol.nu` = projets culturels (motion plus riche autorisé).
---
## 2. MÉTHODE TECHNIQUE (la recette)
### 2.1 Les 5 principes
- **P1 — Le scroll est le moteur.** Une seule instance Lenis au layout racine, cadencée par le ticker GSAP unique. Jamais de double rAF.
- **P2 — Bake au build, pas au runtime.** Tout ce qui ne dépend pas d'une entrée live est précalculé : images responsive (vite-imagetools), textures, polices.
- **P3 — DOM d'abord, WebGL seulement où ça paie.** Zentry a fait du « 3D » primé en pur DOM (clip-path, preserve-3d, masks). Pour l'audience OKI (mobile 4G, Mali-G52) : **DOM/SVG uniquement** par défaut.
- **P4 — Le motion se designe avant les pages** (tokens §1.7 d'abord, composants ensuite).
- **P5 — Chaque état est un moment designé** : loader, 404, vide, hors-ligne — en KA avec le tambour `ka`.
### 2.2 Interdictions absolues
- Jamais de dépendance installée sans être importée et utilisée (les outils d'audit — lighthouse, puppeteer — s'installent en `--no-save` ou se retirent).
- Jamais de SSR désactivé globalement pour « faire marcher » une lib client.
- Jamais animer autre chose que `transform`/`opacity`/`clip-path` en JS.
- Jamais de page dont le contenu est invisible sans JavaScript.
- Jamais de service tiers (fonts, CDN, analytics) — tout asset se self-héberge.
### 2.3 Stack cible
```
SvelteKit 2 + Svelte 5 (runes) + TypeScript strict
@sveltejs/adapter-static (prerender intégral, trailingSlash 'always')
vite-imagetools (AVIF/WebP responsive au build)
vite-plugin-pwa (generateSW — shell hors-ligne)
lenis + gsap (imports dynamiques uniquement)
@fontsource/* (polices woff2 copiées dans static/fonts/)
CSS vanilla : oki-tokens.css + base.css + styles scopés — pas de Tailwind
```
### 2.4 Workflow en 7 phases
1. **Audit** (toujours) : pages, sections, assets, liens externes, stack mesurée, bugs de production listés AVANT toute refonte, contradictions doctrine/outillage (ex. discours anti-GAFAM + Google Fonts). Rapport validé avant de coder.
2. **Tokens & thème** : `oki-tokens.css` (§1), thème sombre défaut + clair opt-in, fonts self-hébergées, anti-FOUC par script externe (CSP).
3. **Architecture** : routes, i18n, SEO, layout. `prerender = true` partout. URLs historiques conservées.
4. **Composants** : uniquement ceux pertinents pour CE site. Chacun avec cleanup et garde reduced-motion.
5. **Motion** : transitions Svelte natives · scrub via `animation-timeline: view()/scroll()` natif d'abord, fallback GSAP ScrollTrigger en import dynamique · une seule horloge (gsap.ticker → Lenis).
6. **Performance** : budgets §2.7, pipeline images, mesure sur profil mobile.
7. **Accessibilité & dégradation** : ladder §2.8, revue clavier, contenu canvas/SVG doublé en DOM.
### 2.5 Architecture de référence (éprouvée sur o-k-i.net)
```
src/
app.html # %lang% + scripts externes (theme, lang-redirect, registerSW)
hooks.server.ts # lang fr/en via transformPageChunk (replaceAll '%lang%')
app.d.ts / imagetools.d.ts
lib/
styles/oki-tokens.css # charte §1 — LE fichier de tokens partagé
styles/base.css # reset, primitives (btn, card, tag, flag-bar, icon), gate reduced-motion
i18n/ # bundles JSON par locale + index.ts (getBundle, localeFromPath, alternatePath)
assets/images/ # sources pour vite-imagetools
motion/ # tokens.ts, scroll.ts (Lenis), reveal.ts, tilt.ts
components/ # Seo, Nav, Footer, ResponsiveImage, motion/, icons/, sections/
routes/
+layout.svelte # skip-link, loader, progress, nav, footer, View Transitions, Lenis
+layout.ts # prerender = true, trailingSlash = 'always'
+error.svelte # 404/erreurs designée (KA)
offline/+page.svelte # cible navigateFallback du SW
static/
fonts/ icons.svg images/ theme.js lang-redirect.js registerSW.js
manifest.webmanifest 404.html robots.txt sitemap.xml _headers .htaccess
```
### 2.6 Composants motion (contrats)
- **KineticText** — titrage au scroll, split par **mots** (jamais caractères — apostrophes), `aria-label` sur titres h1-h4 (jamais sur span : prohibé), scrub `animation-timeline: view()` + fallback ScrollTrigger dynamique, stagger syncopé converti en plages de scroll.
- **ScrollProgressBar** — `scaleX` via rAF + écriture DOM directe (jamais d'état réactif par frame), `role="progressbar"`.
- **PageTransition** — `onNavigate` + View Transitions API, durée lue des tokens ; fallback sobre = navigation instantanée.
- **IntroLoader** — cérémonie 1re visite (`sessionStorage`), skippable, 100 % CSS pilotée par une classe posée par le script externe anti-FOUC (pas de JS inline — CSP).
- **Marquee** — bande typographique CSS pure, contenu dupliqué `aria-hidden`, pause au hover, coupée en reduced-motion.
- **use:reveal** — action IO, cascade syncopée via `--d`, état caché **uniquement** sous `html.js` + `prefers-reduced-motion: no-preference`.
- **use:tilt** — tilt 3D pointeur via variables CSS `--rx/--ry` + rAF, off tactile + reduced-motion.
- **FlagChip** — mini flag-bar 56×6 px qui se dessine (`scaleX`) à l'entrée du titre.
- **Village écosystème** — scène SVG isométrique des services, chaque bâtiment = `<a>` focusable (`aria-label` « NOM — Logiciel »), hover/focus = liseré or + label, fallback grille accessible (reduced-motion + mobile étroit, bascule `display: none` pour éviter les doubles tab stops).
- **CursorTracker, WebGL** : non retenus pour l'audience OKI (mobile-dominante).
### 2.7 Budgets (mobile 4G, Android entrée de gamme)
| Ressource | Budget | Mesuré o-k-i.net |
|---|---|---|
| JS initial compressé | ≤ 170 Ko | 69 Ko gzip (+45 Ko dynamiques) |
| Poids total accueil | ≤ 2 Mo | ~0,9 Mo |
| Média hero avant interaction | ≤ 400 Ko | ~12 Ko (logo) |
| Lighthouse mobile | ≥ 90/95/95/95 | 91/100/100/100 |
| Animations JS concurrentes | ≤ 8-12, transform/opacity | OK |
| LCP / INP / CLS | < 2,5 s / < 200 ms / < 0,05 | 2,9 s / 59 ms TBT / 0,001 |
### 2.8 Accessibilité & ladder de dégradation
1. `saveData`/`effectiveType` ≤ 3g → images statiques, zéro préchargement.
2. JS désactivé → contenu SSR complet et lisible (états cachés conditionnés à `html.js`).
3. `prefers-reduced-motion` → gate unique : pas de Lenis, pas de loader, pas de scrub, contenu statique complet.
4. Canvas/SVG décoratifs `aria-hidden` + miroir DOM sémantique.
5. `lang` correct à chaque bascule ; focus déplacé sur le contenu après transition de route ; skip-link ; focus visible or.
6. Pièges éprouvés : `aria-label` interdit sur `<span>` générique · `<figcaption>` enfant direct de `<figure>` · liens dans le texte soulignés (scoper la règle à `main` pour ne pas toucher nav/footer).
### 2.9 PWA légère
- `static/manifest.webmanifest` manuel (nom KA, monogramme any + maskable, `theme_color`/`background_color` `--noir-oki`, `display: standalone`).
- `vite-plugin-pwa` : `registerType: 'autoUpdate'`, `injectRegister: false`, `manifest: false`, workbox `navigateFallback: '/offline/index.html'`, `navigateFallbackDenylist` pour les assets binaires, `globIgnores` pour les gros fichiers (PDF), `maximumFileSizeToCacheInBytes: 3 Mo`, `cleanupOutdatedCaches`, `clientsClaim`, `skipWaiting`.
- Enregistrement par **fichier statique** `static/registerSW.js` avec chemins absolus (`navigator.serviceWorker.register('/sw.js', { scope: '/' })`), lié dans `app.html`. Ne PAS utiliser l'injection du plugin : sans `@vite-pwa/sveltekit` elle n'atteint pas le HTML pré-rendu, et le `registerSW.js` généré utilise un chemin relatif `./sw.js` cassé sur les routes imbriquées (`/en/`, `/dons/`).
### 2.10 Sécurité & CSP
CSP de référence (tout self-hébergé) :
```
default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';
img-src 'self' data:; font-src 'self'; connect-src 'self';
frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
```
+ HSTS `max-age=31536000`, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy` restrictive. Livrée en **deux formats** : `static/.htaccess` (Apache/o2switch : + `ErrorDocument 404 /404.html`, redirect HTTPS, cache immutable `/_app/` et `/fonts/`) et `static/_headers` (Cloudflare Pages). Règles : **aucun script inline** (snippets thème/langue en fichiers externes) ; `style` attributes OK (`unsafe-inline` style) ; 404 statique autonome (`static/404.html`, zéro dépendance).
### 2.11 SEO
Composant `Seo.svelte` : title/description par page, canonical, hreflang fr/en/x-default via mapping de paires d'URLs, OG ×10 (og:image self-hébergée), Twitter Card, JSON-LD (Organization + entités du projet). `robots.txt` + `sitemap.xml` statiques à jour. Astuce : les `</script>` dans les template literals `{@html}` doivent être échappés (`<\/script>`).
---
## 3. CONVENTIONS SVELTE 5 (obligatoires)
- **Runes only** : `$state` (uniquement pour du réactif — sinon variable plain ; gros objets réassignés : `$state.raw`), `$derived` (jamais d'`$effect` pour calculer), `$props` (traiter les props comme changeantes : valeurs dépendantes en `$derived`).
- **Événements** : `onclick={...}` (jamais `on:click`) ; listeners window/document via `<svelte:window>` / `<svelte:document>`.
- **`{#each}` à clé unique** (jamais l'index).
- **CSS** : variables JS → directive `style:--var={val}` ; styles enfants via custom properties, `:global` en dernier recours ; états globaux (`html.js`, `html.light-theme`, `html.intro-pending`) via `:global(...)`.
- **États réactifs par-frame interdits** : scrollY/pointeur → variables plain + flush rAF (ou variables CSS directement).
- **Init/cleanup** : GSAP/Lenis/IO dans `onMount`/`$effect` avec cleanup symétrique (`kill()`/`revert()`/`destroy()`/`disconnect()`).
- **Liens internes** : `resolve()` / assets via `asset()` de `$app/paths` ; externes en `rel="external noopener noreferrer"` + `target="_blank"`.
- **{@html}** : uniquement sur contenu first-party (JSON du dépôt), commenté comme tel.
- **Autofixer (AGENTS.md)** : après tout composant modifié —
```bash
npx @sveltejs/mcp list-sections # doc Svelte 5
npx @sveltejs/mcp get-documentation "$state,$derived"
npx @sveltejs/mcp svelte-autofixer ./src/lib/MonComposant.svelte
```
(Échapper `\$` dans le code inline. La sortie est un objet JS, pas du JSON strict.) `npm run check` à **0 erreur / 0 warning** avant tout commit.
---
## 4. BRIQUES RÉUTILISABLES (référence : dépôt o-k-i.net, branche `svelte`)
Copier et adapter, ne pas réécrire. Chaque brique a un contrat stable.
| Brique | Chemin dans o-k-i.net | Contrat / usage |
|---|---|---|
| Tokens charte | `src/lib/styles/oki-tokens.css` | importé en premier dans `+layout.svelte` |
| Primitives | `src/lib/styles/base.css` | `.btn .card .tag .icon .flag-bar .container .section`, gate reduced-motion, reveal |
| Fonts | `static/fonts/` + `fonts.css` | Archivo 600-900, Inter 400-800, woff2 latin, preload 700 |
| Anti-FOUC thème | `static/theme.js` | pose `.js`, `.light-theme`, `.intro-pending` ; localStorage `oki-theme` |
| Redirection langue | `static/lang-redirect.js` | préférence `oki-lang-pref`, sinon `navigator.language` |
| i18n | `src/lib/i18n/index.ts` | bundles JSON par locale ; `getBundle(locale)`, `localeFromPath`, `alternatePath` (paires FR↔EN) |
| SEO | `src/lib/components/Seo.svelte` | `<Seo title? description? path locale />` — meta + OG + Twitter + JSON-LD |
| Lang par route | `src/hooks.server.ts` | `%lang%` dans `app.html` + `transformPageChunk` |
| Lenis | `src/lib/motion/scroll.ts` | `initSmoothScroll()` (dynamic), `scrollToAnchor(hash)` |
| Tokens motion | `src/lib/motion/tokens.ts` | `prefersReducedMotion()`, `syncopatedDelay(i)` (3+3+2) |
| Reveal | `src/lib/motion/reveal.ts` | `use:reveal={index}` — cascade gwoka |
| Tilt | `src/lib/motion/tilt.ts` | `use:tilt` — CSS `--rx/--ry` |
| KineticText | `components/motion/KineticText.svelte` | `<KineticText text as="h2" class="section-title" />` |
| ScrollProgressBar | `components/motion/ScrollProgressBar.svelte` | global, gradient or→vert |
| IntroLoader | `components/motion/IntroLoader.svelte` | cérémonie 1re visite, skippable |
| Marquee | `components/motion/Marquee.svelte` | `<Marquee {t} />` |
| FlagChip | `components/motion/FlagChip.svelte` | ornement de titre, dessin au scroll |
| Images | `components/ResponsiveImage.svelte` + `imagetools.d.ts` | `import pic from './x.png?format=avif;webp;png&w=…&as=picture'` → `<ResponsiveImage picture={pic} alt />`. **Le wildcard `declare module '*&as=picture'` doit vivre dans un `.d.ts` NON-module** |
| Sprite OKI | `static/icons.svg` | 12 pictos KA (§1.8) — `<use href="/icons.svg#ID">` |
| Logos marques | `components/icons/BrandIcon.svelte` | `<BrandIcon name="mastodon|peertube|nextcloud|gitea|castopod|discord|telegram|whatsapp|email|stoat|tiktok|x" />` |
| Village | `components/sections/HostingVillage.svelte` | scène SVG écosystème + fallback grille |
| Nav/Footer | `components/Nav.svelte`, `Footer.svelte` | nav active (IO), dropdown accessible, thème, langue ; footer fédéré |
| Emojis→pictos | `components/sections/pictos.ts` | `splitLeadingEmoji(str)`, `pictoFor(emoji)` |
| Bridge tokens | `atlas-fediverse/src/styles/oki-bridge.css` | alias des anciens noms de tokens vers la charte — applique la charte à un projet existant **sans retoucher chaque composant** |
| Layout baké | `atlas-fediverse/scripts/build-layout.mjs` | d3-force en script node → positions déterministes figées en JSON (init hashée FNV-1a + ticks fixes = bit-identique). Rejouer quand les données changent |
| État partagé inter-sections | `atlas-fediverse/src/lib/software-modal.svelte.ts` | module `$state` + `requestX()/consumeX()` : ouvrir une modale d'un composant depuis une autre section (timeline → catalogue) |
| Layout app 100dvh | `JWE/app/src/lib/styles/jeu.css` | `height: 100dvh` (pas min-height) + overflow par panneau + overlays consolidés (une puce de statut, rangées de boutons hors de la carte, barre d'actions opaque z-indexée) — zéro scroll, zéro superposition |
| Count-up score | `JWE/app/src/lib/motion/countup.ts` | `use:compte={cible}` : rAF → écriture DOM directe, span animé `aria-hidden` + valeur finale en `.sr-only` (région `aria-live`) |
| Reveal SSR | `JWE/app/src/lib/motion/reveal.ts` | variante dont l'état initial est rendu côté serveur, masqué uniquement sous `html.js` + `no-preference` |
| i18n dossiers | `gwada-sirius/src/lib/i18n/index.ts` | catalogues au format Paraglide/inlang consommés par un routeur maison (pas de middleware) — statique-friendly, l'outillage inlang reste utilisable |
| Support volume iOS | `svelte-podcast/src/lib/volume-support.ts` | `can_set_volume()` : sonde l'écriture de `volume` sur un élément jetable (mémoïsée) — détecte iOS par capacité, pas par UA sniffing |
| PWA | `vite.config.ts` (bloc VitePWA) + `static/registerSW.js` + `static/manifest.webmanifest` | voir §2.9 |
| Headers | `static/.htaccess` + `static/_headers` | voir §2.10 |
| 404 / offline | `static/404.html` + `routes/+error.svelte` + `routes/offline/` | KA + tambour `ka`, autonomes |
---
## 5. LEÇONS DE LA MIGRATION o-k-i.net (pièges déjà résolus — ne pas les ré-apprendre)
1. **Toujours auditer le dépôt réel** : le brief disait « HTML statique », c'était Eleventy + i18n JSON. Le contenu vivait dans les données — les reprendre, pas réécrire.
2. **Les données priment sur le brief** : le village suit `services.json` (4 instances), pas les exemples du brief ; la section cible se décide d'après le contenu JSON, pas le titre supposé.
3. **vite preview meurt si l'on rebuilde pendant qu'il tourne** (il sert `.svelte-kit/output`) — redémarrer après chaque build.
4. **CSP `script-src 'self'`** : anti-FOUC, lang-redirect et registerSW en fichiers externes statiques ; aucun inline.
5. **Preview headless** : `--virtual-time-budget` fige les animations d'entrée (faux négatifs visuels) — valider le motion en temps réel via puppeteer (`--no-save`), pas en screenshot one-shot.
6. **Puppeteer QA** : `page.emulateMediaFeatures` pour reduced-motion ; le tilt se teste par `dispatchEvent(PointerEvent)` (le scroll Lenis décale `mouse.move`).
7. **eslint/autofixer `no-navigation-without-resolve`** : `resolve()` pour routes internes, `asset()` pour fichiers statiques, `rel="external"` pour sortir du scope de la règle — sans jamais envelopper une URL externe ou une ancre `#`.
8. **Grep de vérité** avant de livrer : zéro domaine tiers dans `build/` (hors liens `<a>` métier), zéro emoji dans le HTML buildé, toutes les URLs en 200, `og:` ×10, JSON-LD présents.
9. **Lighthouse après chaque passe** (mobile) et corriger ce qui est remonté — les deux points gagnés sur o-k-i.net : `aria-label` sur span (KineticText) et liens non soulignés.
10. **Lighthouse `link-in-text-block`** : scoper `text-decoration: underline` à `main` — sinon la nav et le footer héritent de soulignés partout.
## 5b. LEÇONS DES MISSIONS atlas / exitchatcontrol / gwada-sirius / JWE
**Gouvernance & process**
1. **git d'abord** : atlas n'avait aucun dépôt — `git init` + commit de l'existant avant toute ligne de code.
2. **WIP non commité d'autrui** : builder d'abord ; si c'est cohérent, committer tel quel, puis améliorer par commits séparés (exitchatcontrol).
3. **Orchestration d'agents parallèles** : sérialiser tout ce qui touche `package.json` / npm (un seul agent ou le parent) ; définir les contrats (props des composants, noms de tokens, chemins) AVANT de lancer — les agents s'intègrent alors sans conflit, et découvrent même le travail des autres (module `software-modal` réutilisé en vol).
4. **Conflits de ports** : avant tout audit automatisé, vérifier ce qui tourne (`vite preview` d'un autre projet sur le même port a faussé un audit axe — vérifier le `<title>` servi).
**Technique SvelteKit**
5. **`paths.relative: false`** quand `paths.base` est utilisé : par défaut `$app/paths.base` vaut `".."` au prerender, ce qui casse toutes les comparaisons de pathname (`startsWith(base)`, switcher de langue, `aria-current`) et produit des hrefs relatifs bizarres dans le HTML prérendu. L'hydratation masque le bug — le vérifier dans le HTML buildé, pas seulement au clic.
6. **`lang` par route avec base path** : retirer `base` dans `hooks.server.ts` AVANT de déduire la locale du premier segment.
7. **i18n sans middleware** : un routeur par dossiers (`/`, `/en/`, `/ht/`) + loader JSON maison bat Paraglide-officiel pour le statique (URLs et prerender sous contrôle total) tout en gardant les catalogues au format inlang.
8. **Îlots → composants natifs** : le montage manuel (`mount.js` + IntersectionObserver) disparaît ; le fallback SSR (table, liste) devient le markup même du composant — meilleur pour no-JS ET pour le CLS.
9. **Leaflet sous SSR** : `await import('leaflet')` dans `onMount` (l'import statique plante côté Node), CSS en import statique OK, `L.divIcon` CSS au lieu d'images de marqueurs. Tuiles OSM = exception documentée au zéro-tiers, à faire trancher par le propriétaire.
10. **CSP d'un jeu/app riche** (adapter-node → `hooks.server.ts`) : énumérer les domaines réels en inspectant le code ET les node_modules (Mapillary a besoin de `graph.` + `tiles.` + `images.mapillary.com`, Wikimedia d'`upload.` + `commons.`) — puis jouer une manche complète en capturant la console : zéro violation exigée.
11. **Layout « app » sans scroll** : `height: 100dvh` (jamais `min-height`) + overflow géré par panneau + indices dans des panneaux scrollables internes. Les bugs de superposition mobile viennent d'overlays absolus empilés : consolider les badges en une puce, sortir les rangées de boutons de la carte, barre d'actions opaque avec `z-index` explicite, `pointer-events: none` sur tout conteneur décoratif. Tester chaque bouton par `elementFromPoint`.
12. **Le « moment de révélation »** (jeux) : ligne de distance dessinée + score en count-up + marqueur qui tombe. Count-up : rAF → DOM direct, jamais d'état réactif par frame ; span animé `aria-hidden` + valeur finale `.sr-only` dans une région `aria-live`.
13. **CSP hashée** (script postbuild qui hashe les inline) : un JSON-LD inline ajouté est hashé automatiquement — mais vérifier le compte d'empreintes après build.
14. **Icônes PNG lourdes** : `PIL.Image.quantize(256)` suffit pour des logos (121 Ko → 9 Ko, perte invisible) — toujours relire l'image après.
**Contenu & données**
15. **Audit factuel des données** en plus de l'audit technique : la timeline d'atlas contenait 4 dates fausses et un compteur périmé (Twitter 2021→2022, Mastodon 2015→2016, PeerTube 2015→2017, Bluesky 2023→2024, « 55+ »→105). Les champs de données inutilisés (`month`, `softwareId`) sont souvent des fonctionnalités gratuites.
16. **Régressions SEO typiques au changement de générateur** : sitemap, OG hors fiches, og:image, canonical — checklist à passer systématiquement (exitchatcontrol).
**Déploiement YunoHost**
17. Le package ne consomme que des **archives de tag** : taguer l'app (`vX.Y.Z`), pousser le tag, télécharger l'archive `/archive/<tag>.tar.gz` depuis la forge, recalculer son sha256, mettre à jour `manifest.toml` (version `X.Y.Z~ynhN` + url + sha256), commit/push le package. `autoupdate.strategy = "latest_forgejo_tag"` détecte les tags suivants.
## 5c. LEÇONS DE LA MISSION oki-podcast-reader (lecteur audio)
**Gouvernance & forks**
1. **Sécuriser le WIP AVANT tout** : toute la transformation applicative était non commitée sur un fork — premier commit de protection avant la première analyse détaillée (règle générale : `git add -A && git commit "WIP sécurisé"` dès qu'un working tree contient du travail non versionné).
2. **Fork de librairie → application** : purger la dette de packaging (champs `exports`/`files`/`peerDependencies`, changesets, publint, workflows CI de la lib) — le `package.json` d'une app est `private: true` et nu.
3. **Le remote `origin` d'un fork pointe chez l'auteur original** : le renommer `upstream` avant d'ajouter le remote de déploiement, sinon le push part chez l'original (échoue ou pire).
**Upgrades de framework**
4. **Le mode compat Svelte 5 est une voie complète** : SvelteKit 1→2 + Svelte 4→5 SANS réécriture en runes — la syntaxe legacy (`on:click`, `$:`, `export let`, `<slot>`) compile à 0 erreur/0 warning. Upgrader les paquets, traiter uniquement les breaking changes (imports `vitePreprocess`, options TS supprimées, sérialisation), ne JAMAIS réécrire la syntaxe dans la même passe. La réécriture en runes est une mission séparée, facultative.
5. **Respecter le gestionnaire de paquets du projet** (yarn.lock ≠ package-lock.json) — ne pas mélanger.
**Règle d'architecture des loads (la plus généralisable)**
6. **Universal load (`+page.ts`) = le corps brut des fetch est inliné dans le HTML** : un flux RSS de 10 Mo produisait une page de 11,9 Mo. **Server load (`+page.server.ts`) garde le fetch côté serveur** — règle d'un coup : bloat supprimé (88 %), ET les problèmes CORS en navigation client disparus (le navigateur ne refait jamais le fetch : il lit `__data.json`). Toute donnée tierce fetchée appartient à `+page.server.ts`. Corollaire : les pages qui en dépendent ne buildent pas hors-ligne — à documenter.
7. Les redirections CORS se contrôlent à chaque saut : une 302 sans `Access-Control-Allow-Origin` tue le fetch navigateur même si la destination finale l'autorise — raison de plus pour fetcher côté serveur.
**Audio / média**
8. **Les préférences média persistées doivent être re-appliquées en continu** (souscription), pas seulement au chargement de la source — et `?? 1`, jamais `|| 1` (un 0 persisté est une valeur légitime).
9. **`HTMLMediaElement.volume` est en lecture seule sur iOS** : détecter par capacité (sonder l'écriture sur un élément jetable, mémoïser), masquer le slider, garder le mute (`el.muted`, lui, fonctionne). Source de vérité unique dans le store persisté, synchronisée dans les deux sens.
10. **Cibles tactiles sur les lecteurs** : épaisseur visuelle ≠ zone de hit (piste fine de 10 px OK si la zone fait 44 px). Un `<input type="range">` ne repositionne PAS le thumb au tap tactile — handlers `touchstart`/`touchmove` explicites requis (tester avec `page.tap` + `hasTouch`).
11. **Media Session API est non négociable pour une app audio** (contrôles écran verrouillé) : `metadata` à chaque piste + handlers `play`/`pause`/`previoustrack`/`nexttrack`, gardé par `'mediaSession' in navigator`.
12. **SW d'une app audio** : ne JAMAIS intercepter les streams (requêtes `Range`, extensions média) — précache shell, cache-first sur pochettes uniquement.
**Perf de listes**
13. **`content-visibility: auto` + `contain-intrinsic-size`** : le gain de perf de listes le moins cher qui existe (CSS seul). Avant toute virtualisation : pagination « charger plus » 50×50 (1022 lignes DOM → 50).
---
## 6. PATTERNS AWWWARDS (distillé des 15 sites — quoi voler, à quel coût)
| Pattern (source) | Coût | Statut pour OKI |
|---|---|---|
| DOM-only masks/clip-path « 3D » (Zentry) | 5 % du coût GPU | **Adopté** (wipes pochettes) |
| Motion system spec'd avant les pages (Zentry, P4) | 0 | **Adopté** (tokens gwoka) |
| Hover craft budgété — 5 interactions intentionnelles (Noomo) | faible | **Adopté** (balayage boutons, tilt, soulignés, village) |
| Loader-as-fiction (KPR) | faible | **Adopté** (IntroLoader) |
| États designés : 404, vide, offline (Studio375) | faible | **Adopté** (KA + tambour) |
| Dollhouse IA — sections = lieux physiques (Kriss.ai) | moyen | **Adopté** (village créole) |
| Constellation zetwal (charte §7 — prescription portails fédivers) | faible | **Adopté** (hero atlas, remplace un shader Three.js de 708 Ko) |
| Layout baké au build — positions de graphe figées (déclinaison P2) | faible | **Adopté** (carte atlas : SVG 2D déterministe, 750 Ko de JS) |
| Moment de révélation designé — ligne de distance + count-up + chute de marqueur (jeux) | faible | **Adopté** (JWE) |
| Bilingual kinetic type — la bascule de langue comme événement (Nudot) | faible | **Candidat** (évolution du lang-switch FR/KA) |
| Chaptered scroll-comic (ten.375.studio) | moyen | **Candidat** (lore DJANGOKAM, patrimoine) |
| Scroll flipbook connection-aware (Apple) | moyen | **Candidat** si séquence visuelle un jour (gating `effectiveType` obligatoire) |
| Bake noise/textures offline (David Whyte) | faible | **Candidat** (fonds génératifs bakés) |
| Backstage / process public (Immersive Garden) | faible | **Candidat** (contenu pédagogique = souveraineté) |
| Cursor-as-light, velocity particles (Unseen, Igloo) | élevé | **Rejeté** (audience tactile) |
| WebGL full-UI (Igloo), skeletal scroll camera (HAPE), flipbook géant | très élevé | **Rejeté** (budgets 4G/Mali-G52) |
| Resolution gating (KPR) | — | **Interdit** (hostile mobile) |
---
## 7. TEMPLATE DE BRIEF DE MISSION (à remplir par projet)
```markdown
# MISSION : [Transformer X / Créer Y] selon le PLAYBOOK UNIFIÉ OKI × SVELTEKIT
## 1. Contexte
- Site/projet : [URL/dépôt] — stack actuelle mesurée : [à auditer, §2.4 phase 1]
- Identité : [association/projet, positionnement]
- Écosystème lié (liens externes à préserver) : [liste]
- Bugs de production confirmés : [liste audit]
## 2. Contenu éditorial
- [Reprendre à l'identique / réécriture autorisée : périmètre]
- Langues : [FR / EN / KA — URLs]
## 3. Exigences techniques
- Stack : §2.3 du playbook (défaut) — écarts éventuels : [liste]
- URLs à conserver : [liste + ancres]
- Hébergement cible : [o2switch statique / Cloudflare Pages]
- PWA : [oui/non]
## 4. Motion & composants (sélection depuis §2.6 — rester sobre)
- [ ] KineticText titres [ ] IntroLoader [ ] Marquee [ ] Village/scène
- [ ] Autre : [préciser] — WebGL : [non par défaut]
## 5. Périmètre de cette session
- [ ] …
## 6. Critères d'acceptation
- DoD §8 intégralement vérifiée + [spécifiques projet]
```
---
## 8. DOD — DÉFINITION DE « TERMINÉ » (tout doit être vrai)
**Fondations**
- [ ] Rapport d'audit livré ; bugs pré-existants corrigés ou explicitement reportés · **git vérifié/initialisé avant tout travail**.
- [ ] Tokens charte importés (fichier partagé, pas de valeurs recopiées) ; thème sombre défaut, clair opt-in AA.
- [ ] Fonts self-hébergées ; zéro requête tierce au chargement (vérifié onglet réseau + grep du build).
- [ ] `npm run check` : 0 erreur, 0 warning · `npm run build` vert · `svelte-autofixer` propre sur les fichiers modifiés.
- [ ] Si `paths.base` : `paths.relative: false` et hrefs du HTML prérendu inspectés (pas de `../` ni de double préfixe).
- [ ] **Audit factuel des données** (dates, compteurs, champs inutilisés) en plus de l'audit technique.
- [ ] Toute donnée tierce fetchée passe par `+page.server.ts` (un universal load inline le corps brut des fetch dans le HTML).
**Marque**
- [ ] Or = seule couleur d'action ; rouge/vert dans leurs rôles · angles nets (≤ 6 px).
- [ ] Flag-bar segments francs, ≤ 1 pleine largeur par écran · zéro emoji en interface (sprite SVG).
- [ ] Tokens motion gwoka + stagger syncopé · gate `prefers-reduced-motion` vérifiée manuellement.
- [ ] Voix conforme (test : aucune occurrence de « clé en main », « solution innovante », « disruptive »).
**Expérience & accessibilité**
- [ ] Navigation clavier complète (tab order, focus visible, skip-link, village/scène/carte focusable).
- [ ] **Zéro superposition** : chaque bouton testé par `elementFromPoint` (mobile 390 px) ; `pointer-events: none` sur tout conteneur décoratif.
- [ ] Layout app : `height: 100dvh` si « tout visible sans scroll » est exigé — `scrollHeight === innerHeight` mesuré mobile ET desktop.
- [ ] Page fonctionnelle et lisible avec JS désactivé.
- [ ] 404, vide et hors-ligne designés (KA + tambour) · loader designé si présent, skippable.
- [ ] `lang` correct par locale · hreflang · OG complet self-hébergé · JSON-LD.
**Mesures**
- [ ] Lighthouse mobile : Performance ≥ 90, Accessibilité ≥ 95, Best Practices ≥ 95, SEO ≥ 95.
- [ ] JS initial ≤ 170 Ko gzip · accueil ≤ 2 Mo · images optimisées (aucune > 200 Ko hors hero, lazy hors LCP).
- [ ] Toutes les URLs historiques préservées ou redirigées · sitemap/robots à jour.
@@ -0,0 +1,79 @@
# PROMPT KIMI CLI — Transformation de o-k-i.net en SvelteKit
> **Mode d'emploi :** copie l'intégralité du bloc ci-dessous (entre les lignes `---`) dans Kimi CLI, en joignant les deux documents de référence : `recette-sveltekit-playbook-agent.md` (la méthode) et `charte-oki-design-system.md` (la marque). Ajuste la section « Périmètre de cette session » selon ce que tu veux faire en une passe.
---
# MISSION : Migrer o-k-i.net vers SvelteKit selon la recette et la charte OKI
Tu es un agent de code senior. Ta mission : reconstruire le site **o-k-i.net** (ORGANISATION KA INTERNATIONALE) en SvelteKit, en suivant **à la lettre** le playbook `recette-sveltekit-playbook-agent.md` (méthode, budgets, DoD) et la charte `charte-oki-design-system.md` (couleurs, typo, voix, iconographie — elle prime pour tout ce qui est marque).
## 1. Contexte du site existant (audit réel, juillet 2026)
**Identité :** association tech militante guadeloupéenne — « Le numérique au service de vos luttes ». Applis web sur mesure à tarifs solidaires pour associations, collectifs et organisations militantes. Positionnement souveraineté numérique : indépendance des GAFAM, logiciels libres, hébergement éthique. L'identité visuelle est afrofuturiste caribéenne : drapeau panafricain (noir/or/vert/rouge), **thème sombre par défaut**.
**Écosystème lié (liens externes à préserver, ne pas migrer) :**
- bokante.o-k-i.net → instance Mastodon
- gade.o-k-i.net → instance PeerTube
- mizik.o-k-i.net → instance Funkwhale
- niyaj.o-k-i.net → Nextcloud
- kute.o-k-i.net → application PHP maison
- labola.o-k-i.net → forge (code source public, dont celui du site)
- fediverse.o-k-i.net → portail fédivers
- pawol.nu + djangokam.pawol.nu + nyyoka.pawol.nu → paroles/traductions/karaokés (projet DJANGOKAM, artiste IA caribéen)
**Stack actuelle mesurée :** HTML statique + CSS vanilla (~44 KB) + 3 petits JS vanilla (main.js, theme.js, lang-redirect.js). Hébergement o2switch (mutualisé FR). Images via BunnyCDN (`organisationka.b-cdn.net`). Headers de sécurité exemplaires à **conserver à l'identique ou renforcer** : CSP `default-src 'self'` (+ style 'unsafe-inline' + fonts.googleapis.com), HSTS, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy` restrictive.
**Bugs et défauts de production confirmés (à corriger dans la migration) :**
1. **CRITIQUE — Toutes les images renvoient 403** (pull zone BunnyCDN cassée) : logo header, logos partenaires, portrait DJANGOKAM, pochettes, logos clients, og:image. Décision cible : **self-héberger toutes les images** dans `static/images/` (AVIF/WebP générés au build via vite-imagetools), supprimer toute dépendance à BunnyCDN, retirer `https://organisationka.b-cdn.net` de la CSP.
2. **Google Fonts** chargé (fonts.googleapis.com + gstatic.com) alors que le site revendique l'indépendance des GAFAM → self-héberger Archivo (600-900) et Inter (400-800) en woff2, retirer les deux domaines de la CSP.
3. Aucun `prefers-reduced-motion` nulle part.
4. Iconographie 100 % emoji (✊🏿🔓🤝🏿🛡️💼🏛️) → set SVG conforme à la charte (angles nets, stroke 2 px, or sur noir).
5. Les 3 cards « publics » (Militants / Associations & Collectifs / Entreprises) apparaissent en double dans la page → une seule occurrence.
6. Lazy-loading d'images partiel (4/~14) → systématique hors LCP.
**Contenu éditorial (conserver tel quel, sauf mention contraire) :** one-page FR à ancres — Hero (« ORGANISATION KA INTERNATIONALE », 3 badges, 2 CTA) · Solutions ×3 publics · Projets web · Partenaires (Joukawouvè, Aktivist Vybz #AKV, cyber-mawonaj) · DJANGOKAM (artiste IA caribéen, lore Wanakaera/Karukera, liens pawol.nu, plateformes musicales, presse) · Mission + 4 valeurs (Engagement, Liberté, Solidarité, Souveraineté) · Développement web sur mesure · Clients (KA UBUNTU kaubuntu.com, KA UBUNTU Hub kaubuntu.re PWA, KBM kbm.gp) · Services hébergés (BOKANTE, GADE, NIYAJ, etc.) · FAQ « Qu'est-ce que le Ka ? » · CTA final + contact (kontak@o-k-i.net, WhatsApp, Telegram, Discord, forge) · footer avec mentions légales. Pages satellites : `/dons/` (Stripe + Liberapay), `/en/` (version anglaise complète), `/mentions-legales/`.
## 2. Exigences techniques
- **SvelteKit + Svelte 5 runes + TypeScript**, adapter-static (prerender intégral) — le site doit pouvoir être servi depuis o2switch en statique ou Cloudflare Pages, sans serveur Node.
- **i18n FR/EN** : FR par défaut, EN sur `/en/` comme actuellement ; attributs `lang` corrects ; pas de lib lourde si un routage par dossier suffit.
- **Thème sombre par défaut** (identité de marque), thème clair en opt-in persisté en localStorage, appliqué sans FOUC via snippet dans `app.html`.
- **Conserver toutes les URLs** existantes (/, /dons/, /en/, /mentions-legales/, ancres #solutions #projets #partenaires #djangokam #hebergement #contact).
- **Headers de sécurité** : reproduire la CSP et les headers actuels (via `_headers` Cloudflare ou doc d'htaccess o2switch), en retirant les domaines devenus inutiles (Google Fonts, BunnyCDN).
- **PWA légère** : manifest + service worker de cache du shell (vite-plugin-pwa) — le site doit rester lisible hors-ligne.
- OpenGraph complet (10 balises actuelles) reconduit, og:image self-hébergée.
- Budgets du playbook §4 : JS initial ≤ 170 KB, page d'accueil ≤ 2 MB, Lighthouse mobile ≥ 90/95/95/95.
## 3. Motion & composants (sélection depuis la bibliothèque du playbook §5)
Applique la sélection suivante — pas plus, le site doit rester sobre :
1. **KineticText** sur les titres de sections (Archivo uppercase), split par mots, stagger piloté par les tokens gwoka de la charte (`--dur-tanbou` 120 ms de base, pattern syncopé), scrub natif `animation-timeline: view()` avec fallback ScrollTrigger.
2. **ScrollProgressBar** en haut de page, gradient or→vert de la charte.
3. **PageTransition** légère (View Transitions + fallback), durée `--dur-mesure` 480 ms.
4. **Village écosystème** : remplacer la grille de cards « Services hébergés » par une scène SVG isométrique « village créole » (case à ka = BOKANTE, karbay = GADE, silo nuage = NIYAJ, scène de musique = MIZIK…), chaque bâtiment = lien vers l'instance, hover/focus = liseré or + label. **DOM/SVG uniquement, aucun WebGL** (P3 du playbook). Fallback : grille de cards accessible pour reduced-motion/mobile étroit.
5. **Flag-bar** (signature marque, 4 segments francs 6 px) : sous la nav fixe + en haut du footer, jamais plus d'une occurrence visible par écran.
6. **404 designé** en KA (texte créole + ka/tambour SVG), page hors-ligne PWA assortie.
7. CursorTracker : **ne pas installer** (audience mobile-dominante, ROI faible).
8. WebGL : **aucun** sur cette migration.
## 4. Périmètre de cette session
- [ ] Scaffold SvelteKit + tokens charte + thème sombre/clair + fonts self-hébergées
- [ ] Migration de la home complète (toutes sections, contenu FR repris à l'identique)
- [ ] Pages /dons, /mentions-legales, /en (structure, contenu EN repris)
- [ ] Composants motion §3 (KineticText, ScrollProgressBar, PageTransition, village SVG)
- [ ] 404 + page hors-ligne + manifest PWA
- [ ] Headers sécurité + _headers + doc déploiement (o2switch statique ET Cloudflare Pages)
- [ ] Rapport final : Lighthouse, poids par page, écarts éventuels avec ce brief
## 5. Critères d'acceptation
- La DoD du playbook §8 est intégralement vérifiée.
- Zéro requête vers un domaine tiers au chargement (vérifier l'onglet réseau) : ni Google, ni BunnyCDN, ni aucun CDN.
- Toutes les images présentes et optimisées (plus aucun 403, plus aucune image > 200 KB hors hero).
- Le contenu éditorial FR est repris à l'identique (aucune réécriture non demandée), hormis la déduplication des cards « publics ».
- La nav au clavier fonctionne sur tout le village SVG (chaque bâtiment focusable, label lisible).
- `npm run build && npm run preview` : zéro erreur, zéro warning a11y.
Commence par la Phase 1 du playbook (audit de confirmation du dépôt actuel) et présente-moi le plan avant d'écrire le moindre composant.
@@ -0,0 +1,908 @@
# 15 Award Sites → The SvelteKit Recipe
### An autopsy of Awwwards-grade websites + an actionable implementation guide for Cyber-mawonaj
> **Method note.** Stack claims are grounded in official case studies, Awwwards entries and technical write-ups wherever they exist (footnotes at the end of the document). Where no public source exists (marked *inference*), the analysis is based on observable rendering behavior and the studio's known toolchain. Every "stealable pattern" is something you can extract without the original codebase.
---
# PART 1 — SITE-BY-SITE ANALYSIS
---
## 1. unseen.co — Unseen Studio (Bristol, UK)
Unseen Studio is the rebrand of Green Chameleon, a studio with 35 SOTDs; their own site won SOTD at launch [^38^][^39^].
**[A] Visual language.** Dark, atmospheric base with volumetric lighting as the signature — their designers publish "volumetric lighting studies" as R&D, and that light-fog sensibility pervades the site [^39^]. Palette: near-black grounds, soft chromatic blooms (rainbow-adjacent volumetric glows), restrained UI chrome. Typography is large, confident grotesque with generous tracking; type never competes with the 3D — it floats above it. Composition is free-form asymmetry around a central 3D stage.
**[B] Motion.** Physics-feeling, inertia-heavy. Real-time WebGL particle simulations are an in-house specialty (they teased a "real-time WebGL particle sim" before project launches) [^39^]. Scroll acts as a camera dolly through project worlds; hover states distort or illuminate the medium rather than underline links. Cursor is part of the lighting rig.
**[C] Tech stack.** WebGL/Three.js confirmed by their own hiring and project tags (#webgl #threejs) [^39^]. Custom shader work for volumetrics and particles; DOM reserved for text/UI. Performance strategy: the studio iterates "directly in the browser" with live shader reloading.
**[D] UX.** Scroll-driven portfolio-as-journey. Navigation is minimal; the work *is* the menu. Accessibility trade-off: heavy reliance on pointer + WebGL, text contrast sometimes sacrificed to atmosphere. Mobile gets a simplified but genuine version, not a broken one.
**[E] Critical assessment.**
-**Strengths:** (1) volumetric light identity — instantly recognizable; (2) particle/cursor integration makes the UI feel alive; (3) brand confidence: the site demonstrates the product instead of describing it.
- ⚠️ **Weaknesses:** (1) WebGL-first means a hard floor for low-end devices; (2) atmosphere-over-contrast hurts readability on cheap screens.
- 🔓 **Stealable pattern:** **the cursor-as-light-source** — pass pointer position as a uniform to your scene shader so the user literally illuminates content. One uniform, huge perceived interactivity.
---
## 2. animejs.com — Anime.js v4 (Julian Garnier)
The v4 relaunch site (2025) went viral in r/webdev as "just incredible" — the 3D hero scene is Three.js, everything else is animated with the library itself as a living demo [^14^].
**[A] Visual language.** Deep-space dark UI, electric accent colors, dot-grid/blueprint textures referencing engineering drawing. Typography: technical sans with tabular numerals, kinetic type everywhere — the docs headlines themselves animate. Atmosphere: glassy panels + grain, "lab instrument" aesthetic.
**[B] Motion.** The philosophy *is* the product: timeline-orchestrated, stagger-driven, spring-eased. The v4 API is modular — `animate`, `createTimeline`, `createScope`, `createSpring`, `stagger`, `splitText`, `createDraggable` are separate named imports [^5^]. Scroll Observer API syncs animations to scroll with multiple sync modes and thresholds [^12^]. Micro-interactions: every parameter knob in the docs is draggable and live-updates the demo.
**[C] Tech stack.** Three.js for the hero scene; Anime.js v4 for all DOM/SVG animation [^14^]. Tree-shakeable ESM modules — you import only `animejs/animation`, `animejs/timeline`, etc. [^5^]. This modularity is itself a lesson: ship ≤ what you use.
**[D] UX.** Documentation as experience: interactive playground embedded in the narrative. Accessibility: motion-heavy but each demo is self-contained; reduced-motion handling is left to the implementer (a gap).
**[E] Critical assessment.**
-**Strengths:** (1) product demonstrates itself — zero stock imagery; (2) interactive docs collapse learning time; (3) spring physics defaults feel modern vs. GSAP's classic easings.
- ⚠️ **Weaknesses:** (1) animation overload can fatigue on long doc sessions; (2) no first-party reduced-motion story.
- 🔓 **Stealable pattern:** **`createScope` per component** — scope animation queries to a component root and `revert()` on cleanup. Maps 1:1 to Svelte's component lifecycle (see §6).
---
## 3. immersive-g.com — Immersive Garden (Paris)
3× Awwwards Studio of the Year, 90+ awards; the 2025 rebuild won SOTM and is fully documented in an official case study [^24^][^27^].
**[A] Visual language.** Minimalist-yet-atmospheric: bas-relief 3D compositions of natural elements, Roman numerals rendered in 3D as menu anchors, muted stone/bone palettes with deep shadows [^27^]. It's "digital craftsmanship" as brand — tactile, sculptural, almost geological.
**[B] Motion.** Restrained and ceremonial. GSAP for transitions, **Lenis for scroll** [^27^]. Nothing bounces; everything glides. The wow is in scene transitions and reveal choreography, not jitter.
**[C] Tech stack (confirmed):** Three.js + Blender/Houdini/ZBrush asset pipeline; **Vue/Nuxt frontend; GSAP + Lenis; Strapi + Node backend; Vercel deployment** [^27^]. Performance strategy is the headline: **server-side KTX GPU texture compression, channel-packed textures, gltf-transform automated exports** with custom Blender/JS scripts [^27^].
**[D] UX.** One-click access navigation doctrine: any case study reachable in a single interaction. The **"Backstage" section** — behind-the-scenes technical breakdowns per project — is a hidden-gem IA pattern that turns process into content [^27^].
**[E] Critical assessment.**
-**Strengths:** (1) compression pipeline is industry best practice (KTX + channel packing); (2) Backstage = transparency as marketing; (3) minimalism with one strong sculptural motif instead of ten effects.
- ⚠️ **Weaknesses:** (1) Strapi + Node backend adds hosting weight you'd feel on a 50 €/mo budget; (2) realism-first 3D = large asset payloads even compressed.
- 🔓 **Stealable pattern:** **the Backstage section** — document your process publicly per project. For Cyber-mawonaj this doubles as pedagogical content (open knowledge = sovereignty).
---
## 4. davidwhyte.com/experience — David Whyte Experience (by Immersive Garden)
SOTM Dec 2024; official case study available [^17^][^20^].
**[A] Visual language.** Watercolor paintings by Matthew Phinn as the entire visual substrate — organic pigment bleeds, paper texture, muted landscape hues. Typography is literary serif, quiet. This is the anti-neon site: proof that "immersive" doesn't mean cyber.
**[B] Motion.** Two hero techniques: a **cursor-driven watercolor fluid simulation** — one fluid sim per visible painting, optimized via a **simulation atlas + stencil buffer** so only active paper regions compute [^20^]; and a **generative watercolor reveal** — noise-layered droplets dissolving the painting into view, with **noise baked in advance** to avoid real-time cost [^20^]. Long-press reveals video of the real location.
**[C] Tech stack (confirmed):** Nuxt/Vue, Sass + BEM, Three.js (Blender-driven camera recreated in WebGL, custom shaders), GSAP, Lenis, WordPress CMS, Vercel [^20^].
**[D] UX.** Scroll through landscapes; each poem gets full-screen focus. Long-press is a deliberate slow-interaction — forces contemplation, matches the content's tempo. Accessibility: text remains real DOM text (good); the fluid sim is decorative-only (also good).
**[E] Critical assessment.**
-**Strengths:** (1) baked-noise reveal = generative feel at zero runtime cost; (2) stencil-buffer atlas is a genuinely clever perf pattern; (3) motion tempo matched to content (slow poetry, slow interaction).
- ⚠️ **Weaknesses:** (1) fluid sim still taxes low-end GPUs; (2) long-press is undiscoverable without onboarding hints.
- 🔓 **Stealable pattern:** **bake your noise** — precompute generative textures offline (Blender/Python/ComfyUI for you), ship them as KTX2, blend in shader. Generative look, static-asset perf. Perfect fit for your ComfyUI pipeline.
---
## 5. kriss.ai/home/aftercare — Kriss.ai (by Studio 28K)
Awwwards case study available [^28^].
**[A] Visual language.** Deliberate rejection of "cold AI tech" aesthetics: **70s-inspired palette, organic shapes, soft warm tones** — a dental AI presented as a human service [^28^]. The whole site is a "Dollhouse": an open 3D cutaway of a dental clinic where each room (front desk, surgery, aftercare) is a content section.
**[B] Motion.** Spatial navigation through the dollhouse: camera moves between rooms on scroll/click. Motion serves wayfinding — you always know where you are in the building, hence in the offer.
**[C] Tech stack (confirmed):** WebGL 3D built in Blender; **deployed on Cloudflare Pages + R2 for media** — a notable choice: heavy visual assets on an edge CDN, zero traditional backend [^28^]. Figma → Blender → VSCode pipeline.
**[D] UX.** Metaphor-driven IA: product features mapped to physical rooms. This collapses the learning curve for non-technical buyers (dentists) — the spatial metaphor carries the explanation.
**[E] Critical assessment.**
-**Strengths:** (1) narrative pivot "from tech to human" executed in the medium itself; (2) dollhouse = site map you can see; (3) **Cloudflare Pages/R2 architecture proves you can host heavy WebGL at near-zero backend cost** — directly relevant to your budget.
- ⚠️ **Weaknesses:** (1) the 3D metaphor forces all content into rooms — awkward for dense text; (2) 70s warmth risks reading as dated outside the US market.
- 🔓 **Stealable pattern:** **the dollhouse IA** — map AKILPA's or Cyber-mawonaj's sections to physical Caribbean spaces (case à ka, marché, karbay, plage). Navigation becomes cultural storytelling. Free pattern, no shaders required.
---
## 6. zentry.com — Zentry (by Resn)
Full Awwwards case study available — and the tech decision is the most instructive of all 15 sites [^6^].
**[A] Visual language.** Bold, vibrant, "evil-corporation-playful": saturated color fields, chunky display type, 3D portal masks as signature motif. Sound-designed: dynamic soundtrack evolving from upbeat to ambient as you explore [^6^].
**[B] Motion.** Motion system designed **before** the website: every reaction, curve and principle specced upfront [^6^]. Flashy, fast, reactive — but (see below) done entirely in DOM.
**[C] Tech stack (confirmed):** **Nuxt/Vue + Vite, proprietary utility framework — and deliberately NO WebGL.** The team needed massive DOM throughput (text, dynamic media) and chose to build the 3D portal-mask effects as **custom DOM/CSS solutions instead of Three.js**, explicitly to minimize complexity and maximize DOM performance [^6^].
**[D] UX.** Portal motifs as content reveals; sound as orientation layer. The lesson: "immersive" is a design outcome, not a rendering technology.
**[E] Critical assessment.**
-**Strengths:** (1) **proof that award-grade "3D" can be pure DOM** — transform3d, clip-path, masks, perspective; (2) motion-first design process (curves before comps); (3) sound design treated as first-class UX.
- ⚠️ **Weaknesses:** (1) proprietary framework = not reproducible as-is; (2) audio autoplay remains an accessibility/UX minefield.
- 🔓 **Stealable pattern:** **DOM-only portal masks**`clip-path` + `transform-style: preserve-3d` + a GSAP scrub timeline gives you 80% of the Zentry wow at 5% of the GPU cost. **This is your #1 pattern for 4G/low-end Gwada devices.**
---
## 7. igloo.inc — Igloo Inc (by Abeto + Bureaux)
SOTD Jul 2024 (7.92/10); unusually detailed case study + WebGPU showcase write-up [^3^][^7^][^1^]. **And it's built in Svelte — your stack.**
**[A] Visual language.** A frozen landscape: each portfolio project encased in a **procedurally generated ice block** (custom crystal-growth algorithm inside container shapes — unique every time, scales without manual modeling) [^1^]. Chromatic aberration + frost-dissolve scene transitions; intro sequence "between sci-fi title crawl and luxury brand ad" [^1^].
**[B] Motion.** Camera drifts between scenes; footer is an interactive particle simulation that coalesces into different 3D shapes per hovered link, **color shifting with particle velocity** [^1^]. Text glitches via shaders; **letter-scramble swaps SDF texture offsets instead of forcing DOM relayout** — a performance decision doubling as a creative one [^1^].
**[C] Tech stack (confirmed):** **Three.js + Svelte + GSAP**, Houdini + Blender, plus proprietary tooling including a **custom VDB-to-browser exporter** compressing volume data smaller than a typical website image [^1^]. Entire UI rendered in WebGL. Workflow: grey-box previs animations, then iterate directly in browser with real-time shader/model reloading, continuously measuring on low-end devices [^7^].
**[D] UX.** Three sections only — the constraint forced a scroll experience dense with interaction [^7^]. Navigation = the journey itself.
**[E] Critical assessment.**
-**Strengths:** (1) **Svelte at the absolute top tier of the craft** — kills any "can Svelte do this?" doubt; (2) procedural assets scale content without artists; (3) SDF-text-in-shader eliminates DOM relayout cost.
- ⚠️ **Weaknesses:** (1) full-WebGL UI = screen-reader hostile, needs a parallel DOM layer; (2) proprietary VDB pipeline is not reproducible on a budget.
- 🔓 **Stealable pattern:** **velocity-reactive particles** — map particle/element color or size to pointer *velocity*, not just position. Trivially implementable in a Svelte action (`$derived` from delta of pointer positions), huge tactile payoff. Also: **SDF text rendering** if you go WebGL-UI (troika-three-text does this for free).
---
## 8. kprverse.com — KPR (by Resn)
SOTD Dec 2022 (7.98) + SOTM; official making-of available [^35^][^45^].
**[A] Visual language.** "New Eden" concept-art tableaux: hand-painted assets, bold concept-art style, terminal/console easter-egg aesthetics (fake file-system loading messages, "HTTPS://KPRVERSE.COM/KPCO/..." loader strings) [^41^]. Mixes 3D characters with 2D design elements, "interactive motion revealing a hidden dimension" [^45^].
**[B] Motion.** **Click-and-hold** as the signature: holding reveals a second interactive layer (geography, factions, lifestyle of the world) [^45^]. Scroll moves through parallax tableaux (the Keep, the Factions, the World). Loading is theatrical: console-style progress theater.
**[C] Tech stack.** Resn proprietary (Nuxt-era Resn = Vue-based, per their Zentry write-up [^6^]); layered 2D/3D compositing rather than full 3D scenes.
**[D] UX.** Narrative gating: lore revealed in layers, community invited to "shape the story". Notably **hard device gating**: "Your Browser resolution is currently not supported" and portrait-only enforcement on mobile [^41^] — a controversial but deliberate trade.
**[E] Critical assessment.**
-**Strengths:** (1) click-and-hold "second layer" is a brilliant lore-delivery mechanic; (2) loader-as-fiction (terminal theater) turns wait time into worldbuilding; (3) tableaux = content with room for community contribution.
- ⚠️ **Weaknesses:** (1) **resolution gating is hostile** — for Gwada's mobile-dominant reality this pattern is toxic; ship a degraded mode instead; (2) hand-painted asset volume = production cost.
- 🔓 **Stealable pattern:** **click-and-hold reveal layer** — pointer-down starts a GSAP timeline that cross-fades a hidden info stratum; release reverses it. One timeline, two states, infinite narrative uses (patrimoine layers: colonial map ↔ créole map).
---
## 9. hape.io — HAPE (by Dogstudio/DEPT for Digimental)
SOTD Apr 2022 (7.84, dev award animations 9.4/10); official case study available [^36^][^37^][^40^].
**[A] Visual language.** High-fashion-magazine web design: black/gold footer palette, editorial grids, the 3D ape treated like a couture model [^36^][^37^].
**[B] Motion.** **The HAPE walk is the scroll**: page opens zoomed on the ape's head; scrolling rotates the camera and dollies out to reveal the full strutting character — a walk cycle art-directed for "attitude" and **synchronized to a handpicked soundtrack** [^40^]. A "hapewalk mode" hands rotation/zoom control to the user [^40^].
**[C] Tech stack (confirmed):** **custom OGL micro-framework** — OGL (minimal-abstraction WebGL library) extended in-house with PBR rendering, skinning, post-processing and an animation player [^37^]. Built in 4 weeks [^37^].
**[D] UX.** One character, one gesture (scroll), one attitude — radical focus. Results: 57K visitors launch month, 4.58 min average session [^37^].
**[E] Critical assessment.**
-**Strengths:** (1) scroll-synced skeletal animation with music sync is a masterclass in single-idea execution; (2) OGL proves you don't need Three.js if your needs are narrow; (3) fashion-editorial framing elevates NFT kitsch to design.
- ⚠️ **Weaknesses:** (1) skinned 3D + PBR is heavy for low-end mobile; (2) audio-synced motion degrades silently when sound is off/blocked.
- 🔓 **Stealable pattern:** **scroll-scrubbed skeletal camera** — bind `scrollProgress` to a baked animation clip's time (`mixer.setTime(progress * duration)`). Works with any GLTF walk/dance cycle. Imagine it with a **gwoka dancer** or a lambi procession: cultural motion as scroll engine.
---
## 10. labs.noomoagency.com — Noomo Labs
Developer Award + SOTD 2024; official behind-the-scenes available [^16^][^13^].
**[A] Visual language.** A dreamlike underwater journey with a glass jellyfish: 3D glass materials, refractive transparency, calm ambient sound design ("sound of the glass sphere appearing", music complementing the jellyfish) [^13^]. It's a hub for experiments — R&D made public.
**[B] Motion.** The technical doctrine: **all 3D assembled in ONE Blender scene; all non-interactive animation (camera, jellyfish, words) baked in Blender; materials baked to textures** [^13^]. Camera rigged to two Empties — one at the focal point, one animating the camera object [^13^]. Runtime = playback + interactive garnish.
**[C] Tech stack (confirmed):** Three.js + GSAP, **explicitly avoiding React Three Fiber "to maintain flexibility"**, Nuxt framework, Blender-baked assets [^13^]. The storytelling sister site scrolls a Three.js camera through five differently-lit "rooms" of case studies [^8^].
**[D] UX.** Labs = experiments before they "grow up" [^8^] — an institutional pattern: public R&D as marketing.
**[E] Critical assessment.**
-**Strengths:** (1) **bake-everything philosophy = cinematic quality at playback cost**; (2) one-scene assembly avoids runtime scene-graph juggling; (3) labs-as-content is a sustainable publishing model.
- ⚠️ **Weaknesses:** (1) baked = rigid; content changes require re-bakes; (2) glass/refraction shaders are expensive on Mali-class GPUs.
- 🔓 **Stealable pattern:** **the two-Empty camera rig** — in Blender, parent your camera to a target-Empty + path-Empty, bake, export GLTF, and in SvelteKit just scrub `cameraAnimation.time` from Lenis scroll progress. Zero camera math at runtime. Your ComfyUI/Blender pipeline already supports this.
---
## 11. noomoagency.com — Noomo Agency
Awwwards **Website of the Year** winner [^9^][^16^].
**[A] Visual language.** Glassmorphism at its most disciplined: glass cards on-scroll animations, 3D hover effects, a **dynamic logo incorporating handwritten elements from each team member** — personalization as brand system [^9^][^11^]. Boutique-agency warmth inside high-tech execution.
**[B] Motion.** "Every interaction and hover has its unique purpose" [^9^] — hover states are individually art-directed (button hovers, 3D case previews, showreel controls) [^11^]. Scroll animations drive case reveals; 3D transitions between pages.
**[C] Tech stack (confirmed):** **Nuxt 3 front AND back (Node), Three.js + GSAP** [^9^].
**[D] UX.** Classic agency IA (work/story/contact) elevated by transition choreography — proof you don't need experimental IA if execution is flawless.
**[E] Critical assessment.**
-**Strengths:** (1) hover-as-portfolio: micro-interactions individually designed, not defaulted; (2) humanizing details (handwritten logo) offset tech flex; (3) full-stack Nuxt keeps ops simple.
- ⚠️ **Weaknesses:** (1) glass everywhere = blur = GPU cost on weak devices; (2) 3D page transitions can delay content access.
- 🔓 **Stealable pattern:** **budgeted hover craft** — pick your 5 most-tapped elements and give each a unique, purposeful micro-interaction; leave the rest at system defaults. Award juries notice the ratio of intentional to generic interactions.
---
## 12. nudot.com.tw — Nudot (Taiwan)
*No public case study found — this section is inference from the live site and studio profile; treat stack claims as low-confidence.*
**[A] Visual language.** Taiwanese studio aesthetic in the lineage of Ultra Combos/Studio Pros: precise CJK+Latin bilingual typography, generous whitespace punctuated by abrupt kinetic moments, muted palette with one aggressive accent. Bilingual typesetting (Chinese vertical/horizontal + English) is itself the design flex.
**[B] Motion.** Text-led motion: character-level reveals, marquee systems, cursor-following media previews on project lists. Motion is typographic rather than 3D.
**[C] Tech stack (inference).** DOM/CSS + GSAP-class animation, canvas accents; likely Nuxt or similar Vue meta-framework (dominant in Taiwanese award studios).
**[D] UX.** Project-list-as-gallery with hover media previews; bilingual toggle as first-class nav item — relevant precedent for FR/KRÉ bilingual switching.
**[E] Critical assessment.**
-**Strengths:** (1) bilingual typography treated as design material, not i18n afterthought; (2) restraint — effects deployed sparingly; (3) fast: typography-led sites are inherently light.
- ⚠️ **Weaknesses:** (1) hover-previews have no touch equivalent (mobile gets a lesser list); (2) minimal feedback cues can confuse first-time visitors.
- 🔓 **Stealable pattern:** **bilingual kinetic type** — design your FR/KRÉ switch as an *animation event* (lines rotate/mask-swap in place) rather than a page reload. Language toggle becomes brand moment. Implement with `splitText` + a shared-layout transition.
---
## 13. 375.studio/en/studio — Studio375 (Vicenza, Italy)
SOTD Apr 30, 2026 (7.33, plus Developer Award) [^2^][^15^].
**[A] Visual language.** Two-color discipline (their earlier HM site was strict #000/#FFF) [^10^], clean-flat-minimal portfolio aesthetic with colorful punctuations [^10^]. Communications-agency clarity rather than tech-lab showing-off.
**[B] Motion.** Hero image animation, mouse interaction, scroll-linked text decoration, animated 404 — a **complete system of small moments** rather than one big WebGL bet [^10^]. Dev-award scores show the craft is in transitions (animations 7.8, their strongest dev metric) [^2^].
**[C] Tech stack (inference).** DOM-first, GSAP-class orchestration; no heavy WebGL signature. Accessibility dev-score 7.2 is above the award-site average [^2^] — suggests semantic care.
**[D] UX.** Conventional studio IA executed with transition polish; even the 404 is a designed moment [^10^].
**[E] Critical assessment.**
-**Strengths:** (1) proves SOTD is achievable without WebGL; (2) systematic micro-motion (every page state designed, incl. errors); (3) strong accessibility score for the category.
- ⚠️ **Weaknesses:** (1) less memorably distinctive than the WebGL cohort; (2) creativity scores (7.57) lag the experimental sites.
- 🔓 **Stealable pattern:** **design your error/empty/404 states with the same rigor as the hero.** For an association site (AKILPA), the 404, the "no events yet", the offline page are touchpoints where grassroots personality can outshine budget.
---
## 14. ten.375.studio/en — "Ten Years Away" (Studio375)
SOTD Jun 26, 2026 (7.32; users rated creativity 9.3) [^4^].
**[A] Visual language.** **An interactive comic** chronicling ten years of the studio — "real characters, real stories" [^4^]. Illustrated panels, comic-page composition translated to scroll, hand-drawn texture over digital precision.
**[B] Motion.** Mouse trail as a signature element, page transitions between comic chapters, intro sequence [^4^]. Motion serves panel-to-panel reading rhythm — scroll as page-turn.
**[C] Tech stack (inference).** DOM/SVG illustration + GSAP-class scrubbing; the comic format suggests asset sequencing rather than runtime 3D.
**[D] UX.** Narrative chronology as IA: ten years = ten chapters. Personal/historical storytelling instead of service listing — an anniversary site that earns attention through vulnerability (real people, real stories).
**[E] Critical assessment.**
-**Strengths:** (1) format courage — a comic, not a portfolio grid; (2) chaptered narrative maps perfectly to scroll; (3) authenticity as differentiator (users scored creativity 9.3, far above jury [^4^]).
- ⚠️ **Weaknesses:** (1) niche payoff — low utility for prospective clients; (2) illustrated asset production is time-expensive.
- 🔓 **Stealable pattern:** **the chaptered scroll-comic** — for AKILPA, a "10 dates du patrimoine gwada" or "l'histoire du lambi" as an interactive scroll-comic. Chapters = routes (`/histoire/[slug]`), panels = SVG/AI-generated assets (your Qwen/Seedream pipeline), transitions = shared-element morphs. High storytelling ROI per byte.
---
## 15. apple.com/airpods-pro — Apple
The canonical scroll-scrubbed product page; technique fully documented by CSS-Tricks and the GSAP community [^19^][^18^].
**[A] Visual language.** Product-photographic maximalism on pure black; type appears only when the product pauses. No texture tricks — the hardware render *is* the visual language.
**[B] Motion.** **Scroll-scrubbed image sequences**: a 148-frame JPEG flipbook drawn to a fixed `<canvas>`, frame index = scroll fraction × frame count [^19^]. Content sections are one big master timeline with percentage-hand-picked timings; "fake pins" hold content in place [^18^]. requestAnimationFrame for tear-free frame swaps [^19^].
**[C] Tech stack (documented technique).** Canvas 2D + image preloading; no WebGL needed [^19^]. **Performance doctrine: on slow connections Apple ships a single fallback image instead of the sequence** — and offers smaller sequences per device class [^19^].
**[D] UX.** Scroll = product film. Information hierarchy: specs arrive only after desire is built. Accessibility: canvas content is mirrored by real DOM text below.
**[E] Critical assessment.**
-**Strengths:** (1) the flipbook is the most copied award pattern alive — simple, robust, GPU-cheap; (2) **explicit slow-network fallback strategy** — exactly your 4G doctrine; (3) master-timeline percentage timing keeps everything in sync across viewports [^18^].
- ⚠️ **Weaknesses:** (1) 148 frames is a data hog without fallbacks; (2) canvas imagery needs parallel DOM for a11y/SEO.
- 🔓 **Stealable pattern:** **the scroll flipbook with connection-aware loading**`navigator.connection.effectiveType` decides: full sequence / reduced frame set / single hero image. Ship it as a Svelte action (see §5, bonus component). **This is the single most Gwada-appropriate award pattern in this list.**
---
---
# PART 2 — THE SVELTEKIT RECIPE
## 1. Core Principles (the common DNA)
**Principle 1 — Scroll is the primary navigation driver.** 13 of 15 sites bind narrative progression to scroll. Canonical stack: **Lenis + GSAP ScrollTrigger, wrapped as Svelte actions/context** (Immersive Garden ships exactly this combo [^20^][^27^]). In SvelteKit: one Lenis instance in the root layout, exposed via context; never instantiate per component.
**Principle 2 — Bake at build, not at runtime.** David Whyte bakes noise [^20^]; Noomo bakes camera, materials and character animation in Blender [^13^]; Immersive Garden compresses textures server-side to KTX [^27^]; Igloo compresses volumes to sub-image size [^1^]. Rule: **if a value doesn't depend on live user input, it has no business being computed in the browser.** Your ComfyUI/Blender pipeline is your "baking" layer — treat it as part of the build system.
**Principle 3 — DOM first, WebGL only where it pays.** Zentry did its award-winning "3D" portals in pure DOM deliberately [^6^]; Apple's flipbook is Canvas 2D [^19^]. Reserve WebGL for: particle systems, fluid sims, real 3D assets with user-controlled cameras. Everything else: CSS transforms, clip-path, SVG. **For 4G Guadeloupe this principle is non-negotiable.**
**Principle 4 — Motion is designed before pages.** Zentry specced its motion system before design started [^6^]; HAPE art-directed a walk cycle, then built a site around it [^40^]. Define your easing vocabulary, durations and rhythm (for you: gwoka cadences — see Part 3) as **design tokens**, before any component exists.
**Principle 5 — Every state is a designed moment.** Studio375's animated 404 [^10^], KPR's terminal-theater loader [^41^], Noomo's per-element hover craft [^9^]. Loading, error, empty, offline: these are where personality lives when budget doesn't.
---
## 2. SvelteKit Architecture Patterns
### 2.1 Route structure for immersive experiences
```
src/routes/
├── +layout.svelte ← SmoothScrollProvider (Lenis) + CursorTracker live HERE
├── +layout.ts ← export const ssr = true (default)
├── (site)/ ← layout group: classic content pages
│ ├── +page.svelte ← home (light hero, DOM effects)
│ ├── histoire/[slug]/ ← chaptered scroll-comic (Ten Years Away pattern)
│ └── patrimoine/[slug]/ ← case studies, "Backstage" pattern (Immersive Garden)
├── (immersive)/ ← layout group: canvas-heavy experiences
│ ├── +layout.svelte ← WebGLCanvas provider, ssr disabled per-page
│ └── mawonaj/+page.svelte
└── +error.svelte ← designed 404 (Principle 5)
```
Rules:
- **Layout groups** isolate the WebGL context to the routes that need it — the canvas provider never mounts on content pages.
- **Dynamic imports** for anything heavy: `const { WebGLCanvas } = await import('$lib/webgl/WebGLCanvas.svelte')` behind `{#await}` or `{#if browser}` gates.
- **Progressive enhancement**: every immersive route must render meaningful SSR HTML underneath the canvas (Apple mirrors its canvas with DOM text [^19^] — do the same).
### 2.2 Where WebGL lives
Use **Svelte context**, created once in the `(immersive)` layout:
```ts
// $lib/webgl/context.ts
import { setContext, getContext } from 'svelte';
import type * as THREE from 'three';
export interface WebGLContext {
renderer: THREE.WebGLRenderer;
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
canvas: HTMLCanvasElement;
}
const KEY = Symbol('webgl');
export const provideWebGL = (ctx: WebGLContext) => setContext(KEY, ctx);
export const useWebGL = () => getContext<WebGLContext>(KEY);
```
Child scenes register/unregister in `$effect` cleanup. **One renderer per app, many scenes.** Never two `<canvas>` WebGL contexts on one page for low-end targets.
For Three.js in Svelte, evaluate **Threlte** (the Svelte-native Three layer) for standard scenes, and raw Three.js for custom shader-heavy work (Igloo-style). Both coexist: Threlte components render into your single context.
### 2.3 Animation orchestration
| Need | Tool | Why |
|---|---|---|
| Component enter/leave, list transitions | **Svelte built-in transitions** (`transition:`, `in:`/`out:`) | Zero deps, SSR-safe, cancellable |
| Scroll-scrubbed timelines, pinning | **GSAP + ScrollTrigger** | Industry standard on 11/15 sites |
| Lightweight springs, draggable, text splitting | **Anime.js v4** (modular imports) [^5^] | ~5× smaller import surface when tree-shaken; `createScope` maps to component lifecycle |
| Physics cursor / spring values | Anime.js `createSpring` or a 12-line custom spring | No GSAP needed for this |
| One shared clock | GSAP `ticker` as the single rAF driver, Lenis hooked into it | Prevents competing rAF loops |
---
## 3. CSS Architecture for Award Sites
### 3.1 Tailwind + CSS variables for dynamic theming
```css
/* app.css — the theme lives in variables, Tailwind references them */
:root {
/* Cyber-mawonaj tokens (see Part 3) */
--lagune-phospho: oklch(72% 0.13 210);
--roche-volcan: oklch(18% 0.02 260);
--corail: oklch(70% 0.16 25);
--or-circuit: oklch(80% 0.14 90);
--foret-neon: oklch(55% 0.15 155);
--ease-ka: cubic-bezier(0.22, 1, 0.36, 1); /* motion tokens too */
--dur-mesure: 480ms;
}
@theme inline {
--color-lagune: var(--lagune-phospho);
--color-volcan: var(--roche-volcan);
/* … */
}
```
Theme switching = swapping a `data-theme` attribute on `<html>`, variables cascade, Tailwind utilities follow. No JS class churn, no FOUC if set in `%sveltekit.head%`.
### 3.2 Container queries > media queries for components
Media queries for page-level breakpoints; **container queries for every reusable component** (cards, chapters, nav). Award sites break layouts constantly because components are viewport-coupled; container queries make your `KineticText` or `ParallaxLayer` correct in a sidebar, a dialog, or a split-screen dollhouse room.
### 3.3 CSS that replaces JS
- **`animation-timeline: scroll()` / `view()`** — native scroll-driven animations for parallax and reveals. ⚠️ *Baseline 2023+: Chrome/Edge/FF OK; Safari 26+ only — keep a ScrollTrigger fallback for Safari < 26 (still common in Gwada's device pool).* Flag: **needs progressive enhancement wrapper.**
- **`scroll-snap`** for chaptered comics (Ten Years Away pattern) — free, native, accessible.
- **`:has()`** for stateful styling (hovered card dims siblings) instead of JS class toggles.
- **View Transitions API** for route morphs (see PageTransition, §5). ⚠️ *Chrome/Edge/Safari 18+; Firefox behind flag — design as enhancement.*
---
## 4. Performance Budget & Optimization
### 4.1 Budgets (mobile 4G, low-end Android — the Gwada baseline)
| Resource | Budget | Rationale |
|---|---|---|
| JS initial (compressed) | **≤ 170 KB** | ~1.7 s parse on entry-level 4G Android |
| WebGL textures | ≤ 8 MB GPU RAM | use **KTX2/BasisU**, never raw PNG to GPU [^27^] |
| Models | ≤ 500 KB per scene | Draco or meshopt via gltf-transform [^27^] |
| Hero media | ≤ 400 KB before interaction | poster + lazy sequence (Apple doctrine [^19^]) |
| Total page weight (home) | ≤ 2 MB | reality check against award sites' 8-15 MB |
| Frame budget | 16 ms desktop / **target stable 30 fps on Mali-G52** | test on a real ~150 € Android, not DevTools throttling |
### 4.2 WebGL asset pipeline
```
Blender → gltf-transform CLI:
gltf-transform optimize in.glb out.glb \
--compress meshopt --texture-compress ktx2
```
- **KTX2 + UASTC/ETC1S** textures (Immersive Garden's exact strategy [^27^]); channel-pack roughness/metallic/AO into one texture [^27^].
- **LOD**: two model tiers; pick at runtime from `renderer.capabilities` + `deviceMemory`.
- **Instancing** for repeated elements (lambi shells, vegetation, stars).
- **Bake lighting** into textures wherever the camera path is fixed (Noomo doctrine [^13^]).
### 4.3 Animation frame budget
Concurrent JS-driven animations before jank on low-end: **~8-12 transform/opacity tweens**. Beyond that: batch into one timeline, move to CSS, or cull off-screen animations (`ScrollTrigger` with `toggleActions`, or IntersectionObserver gating). Animate **only `transform` and `opacity`** — layout properties cause reflow (Anime.js v4 docs say the same [^5^]).
### 4.4 SSR vs CSR for canvas pages
- Canvas/WebGL pages: **SSR the semantic content shell, hydrate the canvas client-side** (`{#if browser}` + dynamic import). Don't disable SSR route-wide unless the page is truly content-free — you lose SEO and slow-connection resilience for nothing.
- Lenis/GSAP init: `onMount` only, always.
- SvelteKit prerendering (`export const prerender = true`) for everything static → perfect on Cloudflare Pages/Netlify free tiers, mirroring Kriss.ai's edge-CDN architecture [^28^].
---
## 5. Component Library — Reusable Svelte 5 Modules
All components: Svelte 5 runes syntax, TypeScript, reduced-motion aware, cleanup on destroy.
### 5.1 SmoothScrollProvider — Lenis as layout-level context
```svelte
<!-- $lib/components/SmoothScrollProvider.svelte -->
<script lang="ts">
import { onMount, setContext, type Snippet } from 'svelte';
import Lenis from 'lenis';
import gsap from 'gsap';
let { children }: { children: Snippet } = $props();
let lenis: Lenis | null = $state(null);
setContext('lenis', { get: () => lenis });
onMount(() => {
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduced) return; // native scroll = the fallback
lenis = new Lenis({ lerp: 0.1, smoothWheel: true });
// ONE rAF driver for the whole app (Principle: shared clock)
lenis.on('scroll', () => {}); // ScrollTrigger.update hook lives in KineticText
gsap.ticker.add((t) => lenis?.raf(t * 1000));
gsap.ticker.lagSmoothing(0);
return () => { lenis?.destroy(); lenis = null; };
});
</script>
{@render children()}
```
### 5.2 WebGLCanvas — single renderer, context-provided scene graph
```svelte
<!-- $lib/webgl/WebGLCanvas.svelte -->
<script lang="ts">
import { onMount, type Snippet } from 'svelte';
import { provideWebGL } from './context';
let { children, dpr = 1.5 }: { children: Snippet; dpr?: number } = $props();
let canvas: HTMLCanvasElement;
let ready = $state(false);
onMount(async () => {
const THREE = await import('three'); // code-split: three never hits content pages
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, dpr)); // cap DPR: free FPS on retina
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 100);
const onResize = () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
};
onResize();
addEventListener('resize', onResize);
renderer.setAnimationLoop(() => renderer.render(scene, camera));
provideWebGL({ renderer, scene, camera, canvas });
ready = true;
return () => {
removeEventListener('resize', onResize);
renderer.setAnimationLoop(null);
renderer.dispose();
};
});
</script>
<canvas bind:this={canvas} class="fixed inset-0 -z-10" aria-hidden="true"></canvas>
{#if ready}{@render children()}{/if}
```
### 5.3 KineticText — scroll-driven split typography
```svelte
<!-- $lib/components/KineticText.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
let {
text,
as = 'h2',
scrub = true
}: { text: string; as?: keyof HTMLElementTagNameMap; scrub?: boolean } = $props();
let el: HTMLElement;
onMount(async () => {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const { animate } = await import('animejs');
const { splitText } = await import('animejs/text');
const { onScroll } = await import('animejs'); // Scroll Observer [^12^]
const words = splitText({ text, by: 'word', tag: 'span' });
el.append(...words.nodes as Node[]);
const anim = animate(words.nodes, {
opacity: { to: 1 },
translateY: { to: 0 },
delay: (el, i) => i * 40, // stagger → swap for gwoka pattern (Part 3)
ease: 'out(3)',
duration: 600,
autoplay: scrub
? onScroll({ target: el, enter: 'bottom-=10%', leave: 'top+=10%', sync: 0.5 })
: true
});
return () => anim.revert();
});
</script>
<svelte:element this={as} bind:this={el} aria-label={text} class="kinetic">{text}</svelte:element>
```
### 5.4 ParallaxLayer — multi-plane depth, CSS-first
```svelte
<!-- $lib/components/ParallaxLayer.svelte -->
<script lang="ts">
import { onMount, type Snippet } from 'svelte';
let { depth = 0.3, children }: { depth?: number; children: Snippet } = $props();
let el: HTMLElement;
onMount(async () => {
// Prefer native scroll-driven animations where supported (no JS per frame)
if (CSS.supports('animation-timeline: view()')) {
el.style.animationTimeline = 'view()';
el.style.animationName = 'parallax-y';
el.style.setProperty('--depth', String(depth));
return;
}
const gsap = (await import('gsap')).default;
const { ScrollTrigger } = await import('gsap/ScrollTrigger');
gsap.registerPlugin(ScrollTrigger);
const tween = gsap.to(el, {
yPercent: -depth * 100,
ease: 'none',
scrollTrigger: { trigger: el, start: 'top bottom', end: 'bottom top', scrub: true }
});
return () => { tween.scrollTrigger?.kill(); tween.kill(); };
});
</script>
<div bind:this={el} class="parallax-layer">{@render children()}</div>
<style>
@keyframes parallax-y { to { transform: translateY(calc(var(--depth, 0.3) * -30%)); } }
</style>
```
### 5.5 CursorTracker — physics cursor + velocity uniform (Igloo pattern)
```svelte
<!-- $lib/components/CursorTracker.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
let dot: HTMLDivElement;
onMount(async () => {
if (matchMedia('(pointer: coarse)').matches) return; // no cursor on touch
const { createSpring } = await import('animejs');
const spring = createSpring({ stiffness: 120, damping: 14 });
let x = 0, y = 0, px = 0, py = 0;
const onMove = (e: PointerEvent) => { x = e.clientX; y = e.clientY; };
addEventListener('pointermove', onMove, { passive: true });
let raf = 0;
const tick = () => {
const vx = x - px, vy = y - py; // pointer velocity
const speed = Math.min(Math.hypot(vx, vy) / 40, 1);
dot.style.transform = `translate(${spring.solve(x, px)}px, ${spring.solve(y, py)}px) scale(${1 + speed * 0.6})`;
// → also write (x, y, speed) into your WebGL uniforms here: cursor-as-light-source (Unseen pattern)
px = x; py = y;
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => { removeEventListener('pointermove', onMove); cancelAnimationFrame(raf); };
});
</script>
<div bind:this={dot} class="cursor-dot" aria-hidden="true"></div>
```
### 5.6 PageTransition — GSAP route transitions (SvelteKit-safe)
```svelte
<!-- +layout.svelte (site group) -->
<script lang="ts">
import { onNavigate } from '$app/navigation';
import { page } from '$app/state';
onNavigate((nav) => {
// Prefer native View Transitions where available (progressive enhancement)
if (!document.startViewTransition) return;
return new Promise((resolve) => {
document.startViewTransition(async () => { resolve(); await nav.complete; });
});
});
</script>
{#key page.url.pathname}
<main class="page"><slot /></main>
{/key}
<style>
.page { view-transition-name: page; }
::view-transition-old(page) { animation: page-out 0.35s var(--ease-ka) both; }
::view-transition-new(page) { animation: page-in 0.45s var(--ease-ka) both; }
@keyframes page-out { to { opacity: 0; transform: translateY(-2%); } }
@keyframes page-in { from { opacity: 0; transform: translateY(3%); } }
</style>
```
*GSAP alternative for browsers without View Transitions:* animate out in `onNavigate`'s returned promise, animate in via `afterNavigate`. Keep durations in your CSS custom properties so both paths share the same motion tokens.
### 5.7 ScrollProgressBar — zétwal minimal progress
```svelte
<!-- $lib/components/ScrollProgressBar.svelte -->
<script lang="ts">
let progress = $state(0);
const onscroll = () => {
const max = document.documentElement.scrollHeight - innerHeight;
progress = max > 0 ? scrollY / max : 0;
};
</script>
<svelte:window {onscroll} />
<div class="progress" role="progressbar" aria-valuenow={Math.round(progress * 100)}
aria-valuemin="0" aria-valuemax="100" aria-label="Progression de lecture">
<span style:transform="scaleX({progress})"></span>
</div>
<style>
.progress { position: fixed; inset: 0 0 auto; height: 3px; z-index: 50; }
.progress span { display: block; height: 100%; transform-origin: left;
background: linear-gradient(90deg, var(--lagune-phospho), var(--or-circuit)); }
</style>
```
### 5.8 Bonus — FlipbookCanvas (Apple pattern, connection-aware)
```ts
// $lib/actions/flipbook.ts — Svelte action
export function flipbook(canvas: HTMLCanvasElement, opts: { frames: (q: 'full'|'lite') => string[]; total: number }) {
const conn = (navigator as any).connection;
const quality = conn?.effectiveType === '4g' && !conn?.saveData ? 'full' : 'lite';
const urls = opts.frames(quality);
if (conn?.effectiveType === 'slow-2g' || conn?.effectiveType === '2g' || conn?.saveData) {
// Apple doctrine: single fallback image, no sequence [^19^]
const img = new Image(); img.src = urls[0];
img.onload = () => canvas.getContext('2d')?.drawImage(img, 0, 0);
return {};
}
const imgs = urls.map((u) => { const i = new Image(); i.src = u; return i; });
const ctx = canvas.getContext('2d')!;
const draw = (p: number) => {
const f = Math.min(urls.length - 1, Math.floor(p * urls.length));
if (imgs[f]?.complete) ctx.drawImage(imgs[f], 0, 0, canvas.width, canvas.height);
};
const onscroll = () => {
const max = document.documentElement.scrollHeight - innerHeight;
requestAnimationFrame(() => draw(max > 0 ? scrollY / max : 0));
};
addEventListener('scroll', onscroll, { passive: true });
return { destroy: () => removeEventListener('scroll', onscroll) };
}
```
---
## 6. Svelte-Specific Translations (React/Next.js → Svelte idioms)
| React pattern (seen on these sites) | Svelte 5 idiom |
|---|---|
| `useRef` + `useEffect(() => {...}, [])` for GSAP init | `$effect(() => { /* init */; return () => cleanup })` — or better, a **Svelte action** `use:gsapThing` |
| `useContext(WebGLContext)` | `setContext`/`getContext` with a `Symbol` key (§2.2) |
| `useState` for animation params | `$state` rune; params read inside `$effect` re-run the tween — guard with `gsap.quickTo` or `quickSetter` for per-frame writes |
| Derived uniforms (`useMemo`) | `$derived(() => computeUniforms(pointer, scroll))` — recalculates only on dependency change |
| R3F `<Canvas>` + hooks | **Threlte** for declarative scenes; raw Three in `onMount` for shader-heavy work (Noomo avoided R3F for flexibility [^13^] — same logic applies) |
| `framer-motion` layout animations | Svelte built-in `flip` (from `svelte/animate`) + `crossfade` (from `svelte/transition`) — free, SSR-safe |
| Zustand/Redux for UI state | `$state` in a `.svelte.ts` module — a class with rune fields is your store |
**Runes vs stores for animation state:** stores still work and are fine for cross-app state; but **animation state should live in `$state`/`$derived` inside components or `.svelte.ts` modules** — runes give finer-grained reactivity (no subscriber fan-out per frame). Critical rule: **never write per-frame values (scrollY, pointer) into reactive state that drives DOM** — write them to refs/plain variables and flush via rAF, or you'll re-render at 60 Hz.
**GSAP + runes compatibility:** GSAP mutates the DOM directly — Svelte doesn't care as long as GSAP's targets aren't nodes Svelte also patches (avoid tweening attributes Svelte controls via bindings). Wrap GSAP targets in `{@html}`-free static markup or bind via `bind:this`, init in `$effect`, kill in the cleanup function. Anime.js v4's `createScope({ root })` + `scope.revert()` is the same lifecycle shape and tree-shakes better [^5^].
---
## 7. Accessibility & Fallbacks
1. **Screen readers in immersive sites:** canvas is `aria-hidden`; a semantic DOM mirror carries all content (Apple pattern [^19^]). If you render UI text in WebGL (Igloo), ship a visually-hidden parallel DOM (`sr-only` list of the same links/labels).
2. **Reduced motion:** one global gate — `matchMedia('(prefers-reduced-motion: reduce)')` checked in `SmoothScrollProvider` (no Lenis), `KineticText` (no split/scrub), `ParallaxLayer` (static), flipbook (poster frame). Implement it **once, at the provider level**, not per component.
3. **Graceful degradation ladder** (the Gwada ladder):
- `navigator.connection.saveData` or `effectiveType` ≤ 3g → static images, no autoplay video, no sequence preloading [^19^]
- `deviceMemory ≤ 4` or WebGL context creation fails → DOM-effects-only mode (Zentry proves DOM-only can still win awards [^6^])
- `<noscript>` / JS-disabled → SSR content is complete and readable (this is why SSR matters)
4. **Focus management:** route transitions must move focus to the new page's `<h1>`; scroll-jacking (Lenis) must never break keyboard scrolling — Lenis preserves native scroll, which is why it's the right engine choice.
5. **Bilingual a11y:** `lang` attributes per text node (`fr` / `gcf`) — screen readers switch voices; crucial for Kréyòl content.
---
## 8. Tooling Recommendations
### Essential packages (open-source first, per your doctrine)
```jsonc
{
"dependencies": {
"lenis": "^1.3", // smooth scroll — the award-site standard [^20^][^27^]
"gsap": "^3.13", // timelines, ScrollTrigger, ticker
"animejs": "^4.2", // modular springs/stagger/splitText [^5^]
"three": "^0.1xx", // WebGL — dynamic-import only
"@threlte/core": "^8", // optional: declarative Three in Svelte
"troika-three-text": "^0.5" // SDF text in WebGL (Igloo pattern, if needed)
},
"devDependencies": {
"@gltf-transform/cli": "^4",// meshopt/KTX2 pipeline (§4.2)
"vite-imagetools": "^7", // responsive AVIF/WebP at build
"sharp": "^0.33"
}
}
```
- **Vite plugins:** `vite-imagetools` (build-time responsive images), `vite-plugin-pwa` (offline mode = real resilience on flaky 4G, plus an installable PWA for AKILPA), basic `manualChunks` splitting `three` into its own async chunk.
- **HMR for WebGL:** Vite HMR reloads Svelte components but not your GL state — keep scene setup idempotent (init/dispose symmetric in `$effect`), store debug params in URL hash so a reload restores your camera/scrub position. Shader files as `?raw` imports hot-reload cleanly.
- **Hosting (≤50 €/mo, actually near 0 €):** **Cloudflare Pages + R2** — the exact Kriss.ai architecture [^28^]; free tier absorbs heavy media via edge CDN. Netlify/Vercel free tiers work too; avoid anything Node-server-based (Immersive Garden's Strapi [^27^] is out of budget — use a headless Git-based CMS or plain Markdown in-repo).
---
# PART 3 — CYBER-MAWONAJ ADAPTATION
How the 15-site DNA translates for a Caribbean, sovereign, 4G-first aesthetic. This is not "apply a tropical skin" — it's re-deriving each decision from the local context.
### Color system (CSS tokens, oklch for perceptual control)
```css
:root {
--lagune-phospho: oklch(72% 0.13 210); /* phosphorescent lagoon blue — primary accent */
--roche-volcan: oklch(18% 0.02 260); /* volcanic rock black — dominant ground */
--corail: oklch(70% 0.16 25); /* coral pink — secondary accent */
--or-circuit: oklch(80% 0.14 90); /* circuit gold — data/tech highlights */
--foret-neon: oklch(55% 0.15 155); /* deep forest green, used with neon edge-glow */
--terre-rouge-a: oklch(45% 0.12 40); /* terre rouge gradient stop A */
--terre-rouge-b: oklch(30% 0.09 55); /* terre rouge gradient stop B — use for section grounds */
}
```
Dark-mode-first (`--roche-volcan` ground) = OLED battery saving + the bioluminescence aesthetic for free. The **arc-en-ciel / rainbow-serpent** motif belongs in *gradients and particle hues* (velocity-shifted color, Igloo pattern [^1^]) — never as static rainbow fills.
### Cultural signifiers → concrete UI patterns
| Signifier | Pattern (source site) | Implementation |
|---|---|---|
| **Lambi shell** | UI motif: shell-spiral as scroll progress, loader, section dividers | SVG spiral `stroke-dashoffset` scrub (createDrawable, animejs/svg [^5^]); KTX2 shell texture for WebGL hero |
| **Zétwal (stars)** | Navigation: constellation nav where sections are stars | DOM + SVG lines; hover = star brightens + label reveals; light-years cheaper than a WebGL starfield |
| **Dollhouse créole** | Kriss.ai spatial IA [^28^] | AKILPA sections as case à ka / marché / karbay / plage rooms — isometric DOM illustration, no WebGL needed |
| **Terre rouge** | Section backgrounds gradient | Pure CSS, zero cost |
| **Mawon/maroon resistance** | Visual language: glitch-free, hand-made texture; KPR's community-shaped tableaux [^45^] | UGC/content layers: community-submitted photos as parallax planes; "Backstage" section documenting the process (Immersive Garden pattern [^27^]) = transparency as sovereignty |
### Typography (FR/KRÉ bilingual, low-res safe)
- **Weights: ≥ 400 body, ≥ 600 display** — thin weights die on low-DPI screens (your constraint is correct).
- Suggested open fonts: **Space Grotesk** (display, tech-caribbean tension) + **Inter** or **Atkinson Hyperlegible** (body, legibility-first, open license). Atkinson is the sovereignty-aligned choice: designed for low-vision readers.
- Kinetic type via `splitText` on **words, not characters**, for Kréyòl — character-splitting can break apostrophe-heavy words (`mwen`, `tèt'w`) visually and for screen readers. Keep `aria-label` with the intact string (component §5.3 does this).
- Language switch as animation event (Nudot pattern): mask-swap lines in place.
### Motion design — gwoka cadences as easing/timing tokens
Award sites ship custom ease curves; yours can carry rhythm. Practical translation:
```css
:root {
/* ka/belair-inspired: strong downbeat, syncopated release */
--ease-ka: cubic-bezier(0.22, 1, 0.36, 1); /* downbeat: fast attack, long settle */
--ease-syncope: cubic-bezier(0.65, 0, 0.35, 1); /* off-beat: symmetric, urgent */
--dur-tanbou: 120ms; /* sixteenth @ ~125 BPM — micro-interactions */
--dur-mesure: 480ms; /* one bar — entrances */
--dur-phrase: 960ms; /* two bars — page transitions */
}
```
Stagger patterns: instead of linear `i * 40ms`, use **syncopated stagger** `[0, 120, 240, 360, 480, 600, 720, 840]` → remap to `[0, 120, 300, 360, 600, 660, 900, 960]` (3+3+2 feel). Subtle, but rhythm is identity.
### The Gwada connectivity doctrine (non-negotiables)
1. **Connection-aware everything** — flipbook component §5.8 is the reference implementation [^19^].
2. **DOM-first effects** — Zentry is your patron saint here [^6^].
3. **PWA with offline shell** — an association site that works in a dead zone is a political statement (souveraineté numérique).
4. **Test hardware:** a ~150 € Android on real 4G in Gwada, not Lighthouse throttling.
5. **Edge hosting:** Cloudflare Pages/R2 [^28^] — points of presence in the Americas, near-zero cost, aligns with the 50 €/mo ceiling (realistically: < 5 €/mo).
---
# PART 4 — COMPARISON MATRIX
| # | Site | Visual | Motion | Tech | UX | Stealable pattern |
|---|---|---|---|---|---|---|
| 1 | unseen.co | Volumetric light, dark bloom | Inertial, particle-driven | Three.js [^39^] | Scroll-journey portfolio | Cursor-as-light uniform |
| 2 | animejs.com | Lab-instrument dark, kinetic type | Springs, staggers, scroll-observer | Three hero + Anime.js v4 modules [^5^][^14^] | Interactive docs | `createScope` per component |
| 3 | immersive-g.com | Bas-relief minimalism | Ceremonial glide | Nuxt, Three, GSAP, Lenis, Strapi, KTX pipeline [^27^] | One-click access + Backstage | Backstage process section |
| 4 | davidwhyte.com/experience | Watercolor organic | Fluid sim, baked-noise reveals | Nuxt, Three, GSAP, Lenis, WP [^20^] | Slow contemplative tempo | Bake generative noise offline |
| 5 | kriss.ai/aftercare | 70s warm dollhouse | Room-to-room camera | WebGL/Blender, **Cloudflare Pages+R2** [^28^] | Spatial metaphor IA | Dollhouse IA (créole rooms) |
| 6 | zentry.com | Bold saturated portals | Motion-system-first | **Nuxt, pure DOM — no WebGL** [^6^] | Portal reveals + sound | DOM-only 3D portal masks ⭐ |
| 7 | igloo.inc | Procedural ice, chromatic frost | Velocity particles, SDF glitch | **Three + Svelte + GSAP** [^1^] | 3-section scroll journey | Velocity-reactive particles |
| 8 | kprverse.com | Concept-art tableaux | Click-and-hold layers | Resn (Nuxt-class) [^45^] | Lore gating, terminal loader | Click-hold reveal layer |
| 9 | hape.io | Fashion editorial | Scroll-scrubbed walk + music sync | Custom OGL micro-framework [^37^] | Single-idea focus | Scroll-scrubbed animation clip |
| 10 | labs.noomoagency.com | Glass jellyfish dream | Baked camera playback | Three + GSAP, no R3F, Blender-baked [^13^] | Public R&D hub | Two-Empty baked camera rig |
| 11 | noomoagency.com | Disciplined glassmorphism | Per-element hover craft | Nuxt 3 full-stack, Three, GSAP [^9^] | Classic IA, flawless transitions | Budgeted hover craft (5 elements) |
| 12 | nudot.com.tw | Bilingual kinetic type | Character reveals, hover previews | DOM/GSAP-class *(inference)* | Bilingual as feature | Language-switch as animation |
| 13 | 375.studio | Two-color discipline | Systematic micro-motion | DOM-first *(inference)* | Every state designed | Designed 404/empty states |
| 14 | ten.375.studio | Interactive comic | Panel scrubbing, mouse trail | DOM/SVG *(inference)* | Chaptered narrative | Chaptered scroll-comic ⭐ |
| 15 | apple.com/airpods-pro | Photographic black | Scroll flipbook, master timeline | Canvas 2D image sequence [^19^][^18^] | Desire before specs | Connection-aware flipbook ⭐ |
⭐ = highest ROI for Cyber-mawonaj constraints (4G, low-end, sovereignty).
---
## Sources
[^1^]: https://www.webgpu.com/showcase/igloo-inc-procedural-crystals/ — Igloo Inc: Crystal Growth Algorithms, Shader-Driven UI, and Volume Data (Feb 2026)
[^3^]: https://www.awwwards.com/sites/igloo-inc — Igloo Inc, Awwwards SOTD Jul 23 2024
[^4^]: https://www.awwwards.com/sites/ten-years-away — Ten Years Away, Awwwards SOTD Jun 26 2026
[^5^]: https://github.com/juliangarnier/anime/issues/1105 — Anime.js v4 LLM guiding file (API modules, createScope, splitText)
[^6^]: https://www.awwwards.com/zentry-case-study.html — Zentry case study (Resn): Nuxt/Vue/Vite, DOM-only 3D portal masks, no WebGL
[^7^]: https://www.awwwards.com/igloo-inc-case-study.html — Igloo Inc case study (Abeto × Bureaux)
[^8^]: https://www.webgpu.com/showcase/noomo-3d-storytelling-threejs/ — Noomo 3D storytelling site write-up
[^9^]: https://medium.com/@noomo-agency/noomo-agency-website-of-the-year-winner-on-awwwards-aad757327994 — Noomo: Nuxt 3, Three.js, GSAP
[^10^]: https://www.awwwards.com/sites/studio375 — Studio375 HM (2023): elements incl. animated 404
[^11^]: https://www.awwwards.com/sites/noomo-agency — Noomo Agency SOTD Sep 21 2023, elements list
[^12^]: https://animejs.com/ — Anime.js feature docs (Scroll Observer, stagger, SVG toolset)
[^13^]: https://www.awwwards.com/noomo-labs-a-hub-for-cutting-edge-immersive-experiences.html — Noomo Labs behind the scenes: Three+GSAP, no R3F, Blender-baked, two-Empty camera
[^14^]: https://www.reddit.com/r/webdev/comments/1jqmqkn/ — r/webdev on animejs.com v4 site (Three.js hero)
[^15^]: https://www.awwwards.com/Studio375/ — Studio375 profile & awards
[^16^]: https://noomoagency.com/ — Noomo Agency honors list (WOTY, Labs awards)
[^17^]: https://www.awwwards.com/sites/david-whyte-experience — David Whyte Experience SOTD Dec 31 2024
[^18^]: https://gsap.com/community/forums/topic/25188-airpods-image-sequence-animation-using-scrolltrigger/ — master-timeline percentage technique
[^19^]: https://css-tricks.com/lets-make-one-of-those-fancy-scrolling-animations-used-on-apple-product-pages/ — Apple flipbook technique + slow-connection fallback
[^20^]: https://www.awwwards.com/case-study-david-whyte-experience-by-immersive-garden.html — fluid-sim atlas, baked noise, full stack (Nuxt/Three/GSAP/Lenis/WP/Vercel)
[^24^]: https://www.awwwards.com/immersivegarden/ — Immersive Garden profile (Studio of the Year ×3)
[^27^]: https://www.awwwards.com/case-study-immersive-gardens-new-website.html — KTX compression, channel packing, gltf-transform; Nuxt/Three/GSAP/Lenis/Strapi/Vercel
[^28^]: https://www.awwwards.com/case-study-ai-with-a-human-touch-the-new-face-of-kriss-ai.html — Kriss.ai dollhouse concept; Cloudflare Pages + R2
[^35^]: https://www.awwwards.com/sites/kpr — KPR SOTD Dec 26 2022
[^36^]: https://www.awwwards.com/sites/hape — HAPE SOTD Apr 27 2022
[^37^]: https://www.deptagency.com/case/creating-a-3d-nft-walking-hape/ — HAPE: custom OGL micro-framework, PBR, skinning; 4-week build
[^38^]: https://www.awwwards.com/directory/agency-studio/ — Unseen Studio directory stats (35 SOTD)
[^39^]: https://x.com/uns__nstudio — Unseen Studio: rebrand from GC, volumetric studies, particle sims, three.js tags
[^40^]: https://dogstudio.co/cases/hape-prime/ — HAPE walk concept, scroll camera, soundtrack sync
[^41^]: https://kprverse.com/ — KPR live site: terminal loader strings, device gating messages
[^45^]: https://www.awwwards.com/kpr-by-resn-wins-sotm.html — KPR by Resn SOTM: click-and-hold layers, tableaux, 3D+2D mix
@@ -0,0 +1,211 @@
# RECETTE SVELTEKIT — PLAYBOOK POUR AGENT DE CODE
### Transformer un site web classique en expérience de niveau Awwwards
> **Nature de ce document :** instructions opérationnelles destinées à un agent de code (Kimi CLI ou équivalent). Chaque règle est impérative et vérifiable. Ce playbook est distillé de l'analyse de 15 sites primés (Zentry, Igloo.inc, Immersive Garden, Noomo, Apple AirPods Pro, KPR, HAPE, Kriss.ai, Studio375, animejs.com, etc.). Il est volontairement autonome : tu n'as besoin d'aucun autre document pour l'appliquer, sauf si l'utilisateur fournit une charte de marque (qui alors **prime sur ce document** pour tout ce qui est couleurs, typographie, voix et iconographie).
---
## 0. MISSION
Transformer un site vitrine « classique » (HTML statique, WordPress, ou SPA sans direction artistique) en un site SvelteKit à forte identité visuelle et motion design maîtrisé, **sans sacrifier performance, accessibilité ni SEO**. Le résultat doit fonctionner parfaitement sur mobile 4G et matériel bas de gamme.
**Interdictions absolues :**
- Ne jamais installer de dépendance sans l'importer et l'utiliser.
- Ne jamais désactiver le SSR globalement pour « faire marcher » une lib client.
- Ne jamais animer autre chose que `transform` et `opacity` en JS.
- Ne jamais livrer une page dont le contenu est invisible sans JavaScript.
- Ne jamais réintroduire de service tiers (fonts, CDN, analytics) si le projet affirme une doctrine de souveraineté — tout asset se self-héberge.
---
## 1. LES 5 PRINCIPES (ADN commun des sites primés)
**P1 — Le scroll est le moteur de navigation principal.** Une seule instance Lenis au layout racine, partagée via contexte Svelte, cadencée par le ticker GSAP unique. Jamais de double boucle rAF.
**P2 — Bake au build, pas au runtime.** Toute valeur qui ne dépend pas d'une entrée utilisateur en direct est précalculée hors-ligne : bruit génératif baké en textures, caméras 3D bakées dans Blender (rig deux-Empty), animations squelettiques exportées en clips GLTF, textures compressées KTX2 via gltf-transform, images responsive générées au build (vite-imagetools).
**P3 — DOM d'abord, WebGL seulement où ça paie.** Zentry a produit des effets « 3D » primés en pur DOM (clip-path, preserve-3d, masks). Réserver WebGL à : systèmes de particules, simulations de fluide, vrais assets 3D à caméra contrôlée. Un flipbook canvas 2D (pattern Apple) couvre la plupart des besoins de « produit qui tourne au scroll ».
**P4 — Le motion se designe avant les pages.** Définir d'abord les tokens de mouvement (courbes, durées, staggers) en custom properties CSS, puis les composants. Toute durée/courbe hardcodée dans un composant est une erreur.
**P5 — Chaque état est un moment designé.** Loader, 404, état vide, hors-ligne : autant de touchpoints de marque. Le 404 générique du framework est interdit.
---
## 2. WORKFLOW EN 7 PHASES
### Phase 1 — AUDIT (toujours commencer ici)
- [ ] Inventorier pages, sections, assets, liens externes, formulaires du site source.
- [ ] Mesurer la stack existante (headers HTTP, poids des assets, dépendances tierces).
- [ ] Identifier les bugs de production (images cassées, mixed content, liens morts) et les lister AVANT toute refonte — une migration ne doit jamais masquer un bug existant.
- [ ] Identifier les contradictions doctrine/outillage (ex. discours anti-GAFAM + Google Fonts).
- [ ] Produire un court rapport d'audit et le faire valider avant de coder.
### Phase 2 — TOKENS & THÈME
- [ ] Créer `app.css` avec toutes les couleurs en `oklch()` (ou valeurs de la charte si fournie), typographies, échelle d'espacement, rayons, **tokens de motion** (courbes + durées).
- [ ] Déclarer le mapping Tailwind via `@theme inline` pour que les utilitaires référencent les variables.
- [ ] Thème via `data-theme` sur `<html>`, appliqué côté serveur dans `app.html` pour éviter tout FOUC.
- [ ] Fonts : woff2 self-hébergées dans `static/fonts/`, `@font-face` avec `font-display: swap`, `preload` de la display uniquement.
### Phase 3 — ARCHITECTURE
```
src/routes/
├── +layout.svelte ← SmoothScrollProvider + CursorTracker (si pertinent)
├── (site)/ ← pages de contenu (SSR complet)
│ ├── +page.svelte
│ └── [section]/[slug]/
├── (immersive)/ ← layout group : canvas lourds isolés ici
│ └── experience/+page.svelte
└── +error.svelte ← 404 designé (P5)
```
- [ ] Layout groups pour isoler tout contexte WebGL des pages de contenu.
- [ ] Imports dynamiques (`await import('three')`) derrière `{#if browser}` pour tout ce qui dépasse ~50 KB.
- [ ] `prerender = true` sur toutes les routes statiques ; adapter `adapter-static` ou `adapter-cloudflare` selon l'hébergement cible.
- [ ] Conserver strictement les URLs existantes (redirections 301 si renommage).
### Phase 4 — COMPOSANTS (bibliothèque de référence, §5)
- [ ] Installer uniquement les composants pertinents pour CE site — la bibliothèque n'est pas un package à copier en entier.
- [ ] Chaque composant livré avec : cleanup dans `$effect`, garde `prefers-reduced-motion`, aucune fuite de listener.
### Phase 5 — MOTION
- [ ] Transitions d'entrée/sortie de composants : transitions Svelte natives.
- [ ] Scrub/pinning lié au scroll : GSAP ScrollTrigger (import dynamique), OU `animation-timeline: view()` natif avec fallback ScrollTrigger pour Safari ancien.
- [ ] Micro-physics (curseur, springs) : Anime.js v4 imports modulaires (`animejs`, `animejs/text`) ou spring custom de 15 lignes.
- [ ] Vérifier le budget : ≤ 8-12 tweens concurrents sur mobile bas de gamme ; culler les animations hors viewport.
### Phase 6 — PERFORMANCE (budgets §4)
- [ ] Pipeline assets : `gltf-transform optimize --compress meshopt --texture-compress ktx2` pour tout GLB ; AVIF/WebP responsive pour les images.
- [ ] Media hero ≤ 400 KB avant interaction ; séquences d'images avec chargement conditionné par `navigator.connection`.
- [ ] Mesurer sur profil mobile 4G (CPU ×4, réseau « Fast 4G ») : LCP < 2,5 s, INP < 200 ms, CLS < 0,05.
### Phase 7 — ACCESSIBILITÉ & DÉGRADATION (§6)
- [ ] La ladder de dégradation complète et testée.
- [ ] Revue clavier complète (tab order, focus visible, skip-link).
- [ ] Contenu canvas doublé en DOM sémantique.
---
## 3. ORCHESTRATION D'ANIMATION — RÈGLES SVELTE 5
| Besoin | Outil | Règle |
|---|---|---|
| Entrée/sortie composant, listes | Transitions Svelte natives | Zéro dépendance, SSR-safe |
| Scrub scroll, pinning | GSAP + ScrollTrigger | Import dynamique, init dans `$effect`, kill au cleanup |
| Springs, stagger, split text | Anime.js v4 modulaire | `createScope({ root })` + `scope.revert()` au cleanup |
| Une seule horloge | `gsap.ticker` pilote Lenis | Aucune autre boucle rAF sauf canvas WebGL |
**Règles runes impératives :**
- État d'animation : `$state` / `$derived` dans le composant ou un module `.svelte.ts`. Jamais de store Svelte legacy pour des valeurs par-frame.
- **Ne jamais écrire des valeurs par-frame (scrollY, pointeur) dans de l'état réactif qui rend du DOM** — écrire dans des variables plain et flusher via rAF.
- GSAP ne doit jamais cibler un nœud que Svelte patche via binding ; cibler via `bind:this` sur du markup statique.
- Tout init GSAP/Anime/Lenis se fait dans `$effect` (ou `onMount`) avec fonction cleanup qui `kill()`/`revert()`/`destroy()`.
---
## 4. BUDGETS PERFORMANCE (mobile 4G, Android entrée de gamme)
| Ressource | Budget |
|---|---|
| JS initial compressé | ≤ 170 KB |
| Textures GPU | ≤ 8 MB, KTX2/BasisU uniquement |
| Modèles 3D | ≤ 500 KB/scène, meshopt ou Draco |
| Média hero avant interaction | ≤ 400 KB |
| Poids total page d'accueil | ≤ 2 MB |
| Animations JS concurrentes | ≤ 8-12 (transform/opacity only) |
| DevicePixelRatio canvas | plafonné à 1.5 |
| Objectif frame rate | 30 fps stables sur Mali-G52, 60 fps desktop |
**SSR vs CSR :** les pages canvas rendent un shell sémantique en SSR et hydratent le canvas côté client. Le prerender statique est le défaut ; l'hébergement cible est un CDN edge (Cloudflare Pages/R2, Netlify) — pas de serveur Node sauf justification explicite.
---
## 5. BIBLIOTHÈQUE DE COMPOSANTS (contrats + implémentations condensées)
### 5.1 SmoothScrollProvider — Lenis au layout racine
```svelte
<script lang="ts">
import { onMount, setContext, type Snippet } from 'svelte';
import Lenis from 'lenis';
import gsap from 'gsap';
let { children }: { children: Snippet } = $props();
let lenis: Lenis | null = $state(null);
setContext('lenis', { get: () => lenis });
onMount(() => {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
lenis = new Lenis({ lerp: 0.1 });
gsap.ticker.add((t) => lenis?.raf(t * 1000));
gsap.ticker.lagSmoothing(0);
return () => { lenis?.destroy(); lenis = null; };
});
</script>
{@render children()}
```
### 5.2 KineticText — titrage cinétique au scroll
Split par **mots** (jamais par caractères pour les langues à apostrophes), `aria-label` avec la chaîne intacte, scrub via ScrollTrigger ou `animation-timeline: view()` en premier choix, stagger piloté par les tokens de motion du projet.
### 5.3 ParallaxLayer — profondeur multi-plans
CSS-first : si `CSS.supports('animation-timeline: view()')`, animer via keyframes + `view()` ; sinon fallback GSAP ScrollTrigger `scrub: true`, `yPercent` proportionnel à une prop `depth` (01). Toujours `transform`, jamais `top/margin`.
### 5.4 CursorTracker — curseur physique (desktop uniquement)
Garde `matchMedia('(pointer: coarse)')` → ne rien monter sur tactile. Spring (Anime.js `createSpring` ou custom), scale modulé par la **vélocité** du pointeur (pattern Igloo). Exposer position + vélocité via contexte pour alimenter des uniforms WebGL éventuels (curseur-source-de-lumière, pattern Unseen).
### 5.5 PageTransition — transitions de routes
`onNavigate` + View Transitions API en enhancement progressif ; fallback GSAP (out dans la promise d'`onNavigate`, in via `afterNavigate`). Durées lues depuis les custom properties CSS pour que les deux chemins partagent les mêmes tokens.
### 5.6 ScrollProgressBar — barre de progression
`<div>` fixe + `scaleX(progress)`, `role="progressbar"` avec `aria-valuenow`, listener scroll passif, calcul dans rAF.
### 5.7 FlipbookCanvas — séquence d'images scrubée (pattern Apple)
Action Svelte `use:flipbook`. Sélection de qualité via `navigator.connection.effectiveType` + `saveData` : `2g/slow-2g/saveData`**image fixe unique** ; `3g` → jeu réduit ; `4g` → séquence complète. Frames dessinées sur `<canvas>` via rAF, index = fraction de scroll. Préchargement progressif (ne pas bloquer le LCP).
### 5.8 WebGLCanvas — renderer unique (seulement si P3 le justifie)
Un seul `WebGLRenderer` pour toute l'app, fourni via `setContext` avec clé `Symbol`, scènes enfants enregistrées/retirées au cleanup, `setPixelRatio(min(dpr, 1.5))`, `setAnimationLoop` stoppé et `renderer.dispose()` au démontage. Import dynamique de `three`.
---
## 6. ACCESSIBILITÉ & LADDER DE DÉGRADATION
**Gate unique de reduced-motion** au niveau provider : si `prefers-reduced-motion: reduce` → pas de Lenis, pas de scrub, pas de flipbook (poster), contenu statique complet. Ne jamais implémenter la garde composant par composant.
**Ladder (dans l'ordre) :**
1. `saveData` ou `effectiveType` ≤ 3g → images statiques, zéro préchargement de séquence, vidéo non autoplay.
2. `deviceMemory` ≤ 4 ou échec de création de contexte WebGL → mode DOM-effects-only.
3. JS désactivé → le contenu SSR est complet et lisible.
4. Canvas/WebGL toujours `aria-hidden` avec miroir DOM sémantique du contenu.
5. Attribut `lang` correct sur chaque bascule de langue ; focus déplacé sur le `<h1>` après chaque transition de route.
---
## 7. OUTILLAGE
```jsonc
{
"dependencies": {
"lenis": "^1.3",
"gsap": "^3.13",
"animejs": "^4.2"
// "three": uniquement si P3 le justifie — import dynamique obligatoire
},
"devDependencies": {
"@sveltejs/adapter-static": "^3",
"@gltf-transform/cli": "^4",
"vite-imagetools": "^7",
"vite-plugin-pwa": "^1" // shell hors-ligne
}
}
```
HMR/WebGL : init/dispose symétriques dans `$effect`, shaders en imports `?raw` (hot-reload propre), paramètres de debug dans le hash d'URL.
---
## 8. DÉFINITION DE « TERMINÉ » (DoD — tout doit être vrai)
- [ ] Rapport d'audit livré et bugs de production pré-existants corrigés ou explicitement reportés.
- [ ] Aucune ressource chargée depuis un domaine tiers (fonts, CDN, scripts) sauf décision documentée.
- [ ] Lighthouse mobile : Performance ≥ 90, Accessibilité ≥ 95, Best Practices ≥ 95, SEO ≥ 95.
- [ ] Navigation clavier complète + `prefers-reduced-motion` vérifié manuellement.
- [ ] Page fonctionnelle et lisible avec JS désactivé.
- [ ] 404, vide et hors-ligne designés (P5).
- [ ] Toutes les URLs historiques préservées ou redirigées.
- [ ] Build de production vert, zéro warning d'accessibilité Svelte.
@@ -0,0 +1,61 @@
# Svelte 5 Code Writer
## CLI Tools
You have access to `@sveltejs/mcp` CLI for Svelte-specific assistance. Use these commands via `npx`:
### List Documentation Sections
```bash
npx @sveltejs/mcp list-sections
```
Lists all available Svelte 5 and SvelteKit documentation sections with titles and paths.
### Get Documentation
```bash
npx @sveltejs/mcp get-documentation "<section1>,<section2>,..."
```
Retrieves full documentation for specified sections. Use after `list-sections` to fetch relevant docs.
**Example:**
```bash
npx @sveltejs/mcp get-documentation "$state,$derived,$effect"
```
### Svelte Autofixer
```bash
npx @sveltejs/mcp svelte-autofixer "<code_or_path>" [options]
```
Analyzes Svelte code and suggests fixes for common issues.
**Options:**
- `--async` - Enable async Svelte mode (default: false)
- `--svelte-version` - Target version: 4 or 5 (default: 5)
**Examples:**
```bash
# Analyze inline code (escape $ as \$)
npx @sveltejs/mcp svelte-autofixer '<script>let count = \$state(0);</script>'
# Analyze a file
npx @sveltejs/mcp svelte-autofixer ./src/lib/Component.svelte
# Target Svelte 4
npx @sveltejs/mcp svelte-autofixer ./Component.svelte --svelte-version 4
```
**Important:** When passing code with runes (`$state`, `$derived`, etc.) via the terminal, escape the `$` character as `\$` to prevent shell variable substitution.
## Workflow
1. **Uncertain about syntax?** Run `list-sections` then `get-documentation` for relevant topics
2. **Reviewing/debugging?** Run `svelte-autofixer` on the code to detect issues
3. **Always validate** - Run `svelte-autofixer` before finalizing any Svelte component
@@ -0,0 +1,172 @@
## `$state`
Only use the `$state` rune for variables that should be _reactive_ — in other words, variables that cause an `$effect`, `$derived` or template expression to update. Everything else can be a normal variable.
Objects and arrays (`$state({...})` or `$state([...])`) are made deeply reactive, meaning mutation will trigger updates. This has a trade-off: in exchange for fine-grained reactivity, the objects must be proxied, which has performance overhead. In cases where you're dealing with large objects that are only ever reassigned (rather than mutated), use `$state.raw` instead. This is often the case with API responses, for example.
## `$derived`
To compute something from state, use `$derived` rather than `$effect`:
```js
// do this
let square = $derived(num * num);
// don't do this
let square;
$effect(() => {
square = num * num;
});
```
> [!NOTE] `$derived` is given an expression, _not_ a function. If you need to use a function (because the expression is complex, for example) use `$derived.by`.
Deriveds are writable — you can assign to them, just like `$state`, except that they will re-evaluate when their expression changes.
If the derived expression is an object or array, it will be returned as-is — it is _not_ made deeply reactive. You can, however, use `$state` inside `$derived.by` in the rare cases that you need this.
## `$effect`
Effects are an escape hatch and should mostly be avoided. In particular, avoid updating state inside effects.
- If you need to sync state to an external library such as D3, it is often neater to use [`{@attach ...}`](references/@attach.md)
- If you need to run some code in response to user interaction, put the code directly in an event handler or use a [function binding](references/bind.md) as appropriate
- If you need to log values for debugging purposes, use [`$inspect`](references/$inspect.md)
- If you need to observe something external to Svelte, use [`createSubscriber`](references/svelte-reactivity.md)
Never wrap the contents of an effect in `if (browser) {...}` or similar — effects do not run on the server.
## `$props`
Treat props as though they will change. For example, values that depend on props should usually use `$derived`:
```js
// @errors: 2451
let { type } = $props();
// do this
let color = $derived(type === 'danger' ? 'red' : 'green');
// don't do this — `color` will not update if `type` changes
let color = type === 'danger' ? 'red' : 'green';
```
## `$inspect.trace`
`$inspect.trace` is a debugging tool for reactivity. If something is not updating properly or running more than it should you can add `$inspect.trace(label)` as the first line of an `$effect` or `$derived.by` (or any function they call) to trace their dependencies and discover which one triggered an update.
## Events
Any element attribute starting with `on` is treated as an event listener:
```svelte
<button onclick={() => {...}}>click me</button>
<!-- attribute shorthand also works -->
<button {onclick}>...</button>
<!-- so do spread attributes -->
<button {...props}>...</button>
```
If you need to attach listeners to `window` or `document` you can use `<svelte:window>` and `<svelte:document>`:
```svelte
<svelte:window onkeydown={...} />
<svelte:document onvisibilitychange={...} />
```
Avoid using `onMount` or `$effect` for this.
## Snippets
[Snippets](references/snippet.md) are a way to define reusable chunks of markup that can be instantiated with the [`{@render ...}`](references/@render.md) tag, or passed to components as props. They must be declared within the template.
```svelte
{#snippet greeting(name)}
<p>hello {name}!</p>
{/snippet}
{@render greeting('world')}
```
> [!NOTE] Snippets declared at the top level of a component (i.e. not inside elements or blocks) can be referenced inside `<script>`. A snippet that doesn't reference component state is also available in a `<script module>`, in which case it can be exported for use by other components.
## Each blocks
Prefer to use [keyed each blocks](references/each.md) — this improves performance by allowing Svelte to surgically insert or remove items rather than updating the DOM belonging to existing items.
> [!NOTE] The key _must_ uniquely identify the object. Do not use the index as a key.
Avoid destructuring if you need to mutate the item (with something like `bind:value={item.count}`, for example).
## Using JavaScript variables in CSS
If you have a JS variable that you want to use inside CSS you can set a CSS custom property with the `style:` directive.
```svelte
<div style:--columns={columns}>...</div>
```
You can then reference `var(--columns)` inside the component's `<style>`.
## Styling child components
The CSS in a component's `<style>` is scoped to that component. If a parent component needs to control the child's styles, the preferred way is to use CSS custom properties:
```svelte
<!-- Parent.svelte -->
<Child --color="red" />
<!-- Child.svelte -->
<h1>Hello</h1>
<style>
h1 {
color: var(--color);
}
</style>
```
If this is impossible (for example, the child component comes from a library) you can use `:global` to override styles:
```svelte
<div>
<Child />
</div>
<style>
div :global {
h1 {
color: red;
}
}
</style>
```
## Context
Consider using context instead of declaring state in a shared module. This will scope the state to the part of the app that needs it, and eliminate the possibility of it leaking between users when server-side rendering.
Use `createContext` rather than `setContext` and `getContext`, as it provides type safety.
## Async Svelte
If using version 5.36 or higher, you can use [await expressions](references/await-expressions.md) and [hydratable](references/hydratable.md) to use promises directly inside components. Note that these require the `experimental.async` option to be enabled in `svelte.config.js` as they are not yet considered fully stable.
## Avoid legacy features
Always use runes mode for new code, and avoid features that have more modern replacements:
- use `$state` instead of implicit reactivity (e.g. `let count = 0; count += 1`)
- use `$derived` and `$effect` instead of `$:` assignments and statements (but only use effects when there is no better solution)
- use `$props` instead of `export let`, `$$props` and `$$restProps`
- use `onclick={...}` instead of `on:click={...}`
- use `{#snippet ...}` and `{@render ...}` instead of `<slot>` and `$$slots` and `<svelte:fragment>`
- use `<DynamicComponent>` instead of `<svelte:component this={DynamicComponent}>`
- use `import Self from './ThisComponent.svelte'` and `<Self>` instead of `<svelte:self>`
- use classes with `$state` fields to share reactivity between components, instead of using stores
- use `{@attach ...}` instead of `use:action`
- use clsx-style arrays and objects in `class` attributes, instead of the `class:` directive
+58
View File
@@ -0,0 +1,58 @@
# Déploiement — ANNU KUTE CED (SvelteKit)
Branche `svelte` : le hub est un site **100 % statique** (SvelteKit + adapter-static, prerender intégral). Les données PeerTube / Castopod / Mastodon sont **récupérées au build** : pour rafraîchir le contenu, on rebuild (cron, webhook ou CI).
## Build
```bash
npm ci
npm run build # prebuild : optimise les pochettes Castopod (scripts/fetch-podcast-cover.mjs,
# nécessite ImageMagick) et les vignettes/médias distants (scripts/optimize-remote-images.mjs)
# puis génère build/ + injecte la CSP par page (scripts/postbuild-csp.mjs)
npm run preview # vérification locale
```
Le dossier `build/` est le site complet, déposable tel quel sur n'importe quel hébergement statique.
## o2switch (Apache, mutualisé)
1. Déposer le contenu de `build/` à la racine web du domaine (`public_html/` ou équivalent).
2. Le `.htaccess` fourni dans `build/` gère : redirection HTTPS, redirections des anciennes URLs PHP (`index.php`, `video.php?uuid=…`, `categories.php?id=…`, `recherche.php?q=…`, `direct.php`, `dons.php`, `mentions-legales.php`), `ErrorDocument 404 /404.html`, headers de sécurité, cache immutable pour `/_app/` et `/fonts/`.
3. Rafraîchissement du contenu : rebuild en local ou en CI puis `rsync -av --delete build/ user@host:public_html/`.
Exemple de cron quotidien (sur une machine de build) :
```bash
0 5 * * * cd /chemin/annu-kute-ced && git pull && npm ci --prefer-offline && npm run build && rsync -az --delete build/ user@o2switch:public_html/
```
## Cloudflare Pages
- Build command : `npm run build`
- Build output directory : `build`
- Le fichier `_headers` (dans `build/`) applique CSP, HSTS, `X-Frame-Options`, `nosniff`, `Referrer-Policy`, `Permissions-Policy` et le cache immutable.
- Les anciennes URLs PHP peuvent être redirigées via `_redirects` si besoin (le `.htaccess` ne s'applique pas chez Cloudflare).
## Sécurité — modèle CSP
- En-tête global (`_headers` / `.htaccess`) : `style-src`, `img-src`, `font-src`, `connect-src 'self'`, `media-src`, `frame-src https://gade.o-k-i.net`, `frame-ancestors 'none'`, `base-uri`, `form-action`, `object-src 'none'`.
- `script-src` est délivré **par page** via une balise `<meta http-equiv="Content-Security-Policy">` injectée par `scripts/postbuild-csp.mjs` : chaque page ne déclare que les empreintes SHA-256 de ses scripts inline (script de démarrage SvelteKit + JSON-LD). Aucun `'unsafe-inline'` pour les scripts.
- Seules exceptions sortantes (chargement de ressources) : les instances de l'écosystème first-party `gade.o-k-i.net` (vignettes, embeds, flux), `kute.o-k-i.net` (audio, pochettes), `bokante.o-k-i.net` (médias Mastodon). Aucun autre domaine tiers.
## PWA
- `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
npm run check # 0 erreur, 0 warning
npm run build # fetch des données + prerender + CSP
```
Puis, sur le build : zéro requête tierce au chargement hors instances de l'écosystème (onglet réseau), toutes les routes historiques redirigées, `sitemap.xml` et `robots.txt` servis.
+58
View File
@@ -0,0 +1,58 @@
<!-- Injected by Asciidoctor when generating the print/PDF HTML variant.
Usage: asciidoctor -a docinfodir=docs -a docinfo=shared README.adoc -o README-print.html
Fixes color-emoji rendering in headless Chromium and adapts the layout for print. -->
<style>
/* Font stacks with an explicit emoji fallback. "Noto Color Emoji" must be
installed system-wide (package: fonts-noto-color-emoji). */
* {
font-family: "Noto Sans", "Noto Color Emoji", sans-serif !important;
}
pre, code, kbd, samp {
font-family: "Noto Sans Mono", "Noto Color Emoji", monospace !important;
}
@media print {
/* The left-floating TOC is screen-only; bring it back into the flow */
#header, #toc {
position: static !important;
width: auto !important;
max-width: none !important;
float: none !important;
height: auto !important;
overflow: visible !important;
}
#content {
margin: 0 !important;
padding: 0 !important;
max-width: none !important;
}
body {
margin: 0;
font-size: 10pt;
line-height: 1.35;
}
/* Keep headings with their content, break tables between rows only */
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
}
tr, td, th, .admonitionblock, .imageblock {
page-break-inside: avoid;
}
/* Long code listings may overflow the page width: allow wrapping */
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
img {
max-width: 100%;
}
/* Links are not clickable in a PDF: keep them readable, not blue */
a {
color: inherit;
text-decoration: none;
}
a[href^="http"]::after {
content: "";
}
}
</style>
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Generate the documentation export(s) from README.adoc.
#
# docs/generate-readme-pdf.sh # README.html + README.pdf
# docs/generate-readme-pdf.sh --html # README.html only
# docs/generate-readme-pdf.sh --pdf # README.pdf only
#
# Both outputs land in the project root and are Git-ignored.
#
# Requirements:
# - asciidoctor (e.g. gem install asciidoctor)
# - chromium (headless PDF rendering)
# - fonts-noto-color-emoji (color emoji in the PDF)
#
# Why Chromium instead of asciidoctor-pdf (used by the VSCodium extension)?
# Its Prawn engine cannot embed color-emoji fonts (CBDT/COLR), so emoji
# disappear. Chromium handles them fine as long as "Noto Color Emoji"
# is installed. The docs/docinfo.html stylesheet (emoji fallback + print
# rules) is injected only for this build, via Asciidoctor's docinfo
# mechanism, and adapts the layout for print (TOC in flow, table/page
# breaks, wrapped code listings).
set -euo pipefail
cd "$(dirname "$0")/.."
MODE="${1:---all}"
HTML_OUT="README.html"
PDF_OUT="README.pdf"
build_html() {
asciidoctor -b html5 -a docinfodir=docs -a docinfo=shared README.adoc -o "$1"
echo "HTML generated: $1"
}
build_pdf() {
# Resolve to an absolute path for the file:// URL
local html_path="$1"
[[ "$html_path" != /* ]] && html_path="$PWD/$html_path"
chromium --headless --disable-gpu --no-sandbox \
--no-pdf-header-footer \
--print-to-pdf="$PDF_OUT" \
"file://$html_path"
echo "PDF generated: $PDF_OUT"
}
case "$MODE" in
--html)
build_html "$HTML_OUT"
;;
--pdf)
TMP_HTML="$(mktemp --suffix=.html)"
trap 'rm -f "$TMP_HTML"' EXIT
build_html "$TMP_HTML" >/dev/null
build_pdf "$TMP_HTML"
;;
--all)
build_html "$HTML_OUT"
build_pdf "$HTML_OUT"
;;
-h|--help)
sed -n '2,10p' "$0"
exit 0
;;
*)
echo "Usage: $0 [--html|--pdf|--all]" >&2
exit 1
;;
esac
-295
View File
@@ -1,295 +0,0 @@
<?php
/**
* Page de dons - Template d'exemple
* Support via LiberaPay, Ko-fi et Stripe
*
* Pour personnaliser cette page :
* 1. Copiez ce fichier vers dons.php
* 2. Activez les dons dans config.local.php :
* define('DONATIONS_ENABLED', true);
* 3. Configurez au moins une plateforme de don :
* - define('LIBERAPAY_URL', 'https://liberapay.com/votre-compte/donate');
* - define('KOFI_URL', 'https://ko-fi.com/votre-compte');
* - define('STRIPE_ENABLED', true); + configurez les liens STRIPE_ONE_TIME_LINKS et STRIPE_MONTHLY_LINKS
* 4. Personnalisez le contenu selon vos besoins
*/
require_once 'includes/config.php';
require_once 'includes/security.php';
// Vérifier si les dons sont activés
if (!defined('DONATIONS_ENABLED') || !DONATIONS_ENABLED) {
http_response_code(404);
include '404.php';
exit;
}
// Vérifier qu'au moins une plateforme de don est configurée
$hasLiberaPay = !empty(LIBERAPAY_URL);
$hasKofi = !empty(KOFI_URL);
$hasStripe = defined('STRIPE_ENABLED') && STRIPE_ENABLED;
if (!$hasLiberaPay && !$hasKofi && !$hasStripe) {
http_response_code(500);
error_log('Aucune plateforme de don configurée');
include '500.php';
exit;
}
// Définir les headers de sécurité
setSecurityHeaders();
// Configuration des montants et devise
$donationAmounts = defined('DONATION_AMOUNTS') ? DONATION_AMOUNTS : [1, 5, 10, 20, 50];
$currency = defined('DONATION_CURRENCY') ? DONATION_CURRENCY : 'EUR';
$currencySymbol = $currency === 'EUR' ? '€' : '$';
// Récupérer les liens Stripe s'ils sont configurés
$stripeOneTimeLinks = defined('STRIPE_ONE_TIME_LINKS') ? STRIPE_ONE_TIME_LINKS : [];
$stripeMonthlyLinks = defined('STRIPE_MONTHLY_LINKS') ? STRIPE_MONTHLY_LINKS : [];
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="<?php echo generateCSRFToken(); ?>">
<!-- PERSONNALISEZ: Titre et description de votre page de dons -->
<title>Soutenir <?php echo ORGANIZATION_NAME; ?> - Dons</title>
<meta name="description" content="Soutenez <?php echo ORGANIZATION_NAME; ?> par un don. Chaque contribution compte pour maintenir notre plateforme indépendante.">
<!-- Styles -->
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
<link rel="stylesheet" href="css/donations.css?v=<?php echo filemtime('css/donations.css'); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" crossorigin="anonymous">
<!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="img/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png">
<link rel="manifest" href="site.webmanifest">
<link rel="icon" type="image/x-icon" href="img/favicon.ico">
<meta name="theme-color" content="#FF0000">
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="Soutenir <?php echo ORGANIZATION_NAME; ?>">
<meta property="og:description" content="Soutenez <?php echo ORGANIZATION_NAME; ?> par un don et aidez-nous à maintenir notre plateforme indépendante.">
<meta property="og:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
<meta property="og:url" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/dons.php'; ?>">
<meta property="og:type" content="website">
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
<!-- Schema.org pour les dons -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "<?php echo SITE_NAME; ?>",
"description": "<?php echo SITE_DESCRIPTION; ?>",
"url": "<?php echo 'https://' . $_SERVER['HTTP_HOST']; ?>",
"potentialAction": {
"@type": "DonateAction"
}
}
</script>
<!-- Script pour éviter le flash en mode sombre -->
<script>
(function() {
const savedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const shouldUseDark = savedTheme === 'dark' || (!savedTheme && systemPrefersDark);
if (shouldUseDark) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
</head>
<body>
<?php include 'includes/sidebar.php'; ?>
<main class="main-content">
<?php include 'includes/header.php'; ?>
<div class="container">
<!-- En-tête de la page - PERSONNALISEZ -->
<section class="donation-hero">
<div class="donation-hero-content">
<h1><i class="fas fa-heart"></i> Soutenir <?php echo ORGANIZATION_NAME; ?></h1>
<p class="hero-subtitle">Votre soutien est essentiel pour maintenir notre plateforme indépendante</p>
</div>
</section>
<!-- Section principale de don -->
<section class="donation-main">
<div class="donation-content">
<div class="donation-message">
<!-- PERSONNALISEZ: Votre message de don -->
<h2>Pourquoi nous soutenir ?</h2>
<p><?php echo SITE_NAME; ?> est une plateforme multimédia indépendante.
Vos dons nous permettent de :</p>
<ul>
<!-- PERSONNALISEZ: Vos objectifs -->
<li><i class="fas fa-server"></i> Maintenir nos serveurs et notre infrastructure</li>
<li><i class="fas fa-shield-alt"></i> Préserver notre indépendance et notre souveraineté numérique</li>
<li><i class="fas fa-tools"></i> Développer de nouvelles fonctionnalités</li>
<li><i class="fas fa-users"></i> Soutenir la création de contenu libre et accessible</li>
</ul>
<p><strong>Chaque don, même petit, fait la différence !</strong></p>
</div>
<!-- Plateformes de don externes -->
<div class="donation-platforms">
<h3>Plateformes de don</h3>
<div class="platforms-grid">
<?php if ($hasLiberaPay): ?>
<a href="<?php echo htmlspecialchars(LIBERAPAY_URL); ?>"
target="_blank"
rel="noopener noreferrer"
class="platform-btn liberapay">
<div class="platform-icon">
<svg viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-78.37-208.06)" fill="#f6c915">
<path d="m104.28 271.1c-3.571 0-6.373-0.466-8.41-1.396-2.037-0.93-3.495-2.199-4.375-3.809-0.88-1.609-1.308-3.457-1.282-5.544 0.025-2.086 0.313-4.311 0.868-6.675l9.579-40.05 11.69-1.81-10.484 43.44c-0.202 0.905-0.314 1.735-0.339 2.489-0.026 0.754 0.113 1.421 0.415 1.999 0.302 0.579 0.817 1.044 1.546 1.395 0.729 0.352 1.742 0.527 3.036 0.527 2.037 0 3.957-0.299 5.762-0.896 1.805-0.598 3.469-1.457 4.993-2.578 1.524-1.121 2.906-2.504 4.149-4.149s2.37-3.545 3.393-5.697l8.07-33.69 11.52-1.81-8.608 35.95c-1.121 4.623-2.704 8.473-4.749 11.55-2.046 3.076-4.502 5.553-7.369 7.43-2.867 1.878-6.067 3.182-9.602 3.912-3.535 0.729-7.355 1.094-11.46 1.094m43.28-33.84c-0.754 0-1.371-0.604-1.371-1.371 0-0.754 0.617-1.371 1.371-1.371s1.371 0.617 1.371 1.371c0 0.767-0.617 1.371-1.371 1.371m-33.96-19.09c-3.846 0-7.027-0.402-9.543-1.207-2.516-0.805-4.493-1.958-5.933-3.458-1.44-1.5-2.479-3.282-3.118-5.346-0.639-2.063-0.959-4.408-0.959-7.036 0-4.092 0.678-7.933 2.035-11.52 1.357-3.588 3.268-6.705 5.733-9.35 2.465-2.646 5.446-4.718 8.943-6.217s7.384-2.249 11.68-2.249c3.571 0 6.689 0.377 9.355 1.132 2.666 0.754 4.881 1.81 6.645 3.169l-3.244 8.921c-1.739-1.106-3.645-1.984-5.718-2.635-2.073-0.652-4.339-0.977-6.797-0.977-2.921 0-5.557 0.603-7.908 1.81-2.352 1.206-4.368 2.851-6.049 4.937-1.681 2.086-2.977 4.549-3.887 7.389-0.91 2.841-1.366 5.87-1.366 9.089 0 3.47 0.704 6.121 2.111 7.954 1.408 1.833 3.72 2.749 6.936 2.749 1.561 0 3.118-0.201 4.674-0.603 1.556-0.402 3.031-0.977 4.424-1.725l2.073 8.582c-1.534 1.005-3.319 1.785-5.356 2.34-2.037 0.556-4.312 0.834-6.826 0.834"/>
</g>
</svg>
</div>
<div class="platform-info">
<span class="platform-name">LiberaPay</span>
<span class="platform-desc">Don récurrent ou ponctuel</span>
</div>
</a>
<?php endif; ?>
<?php if ($hasKofi): ?>
<a href="<?php echo htmlspecialchars(KOFI_URL); ?>"
target="_blank"
rel="noopener noreferrer"
class="platform-btn kofi">
<div class="platform-icon">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="#13C3FF" d="M23.881 8.948c-.773-4.085-4.859-4.593-4.859-4.593H.723c-.604 0-.679.798-.679.798s-.082 7.324-.022 11.822c.164 2.424 2.586 2.672 2.586 2.672s8.267-.023 11.966-.049c2.438-.426 2.683-2.566 2.658-3.734 4.352.24 7.422-2.831 6.649-6.916zm-11.062 3.511c-1.246 1.453-4.011 3.976-4.011 3.976s-.121.119-.31.023c-.076-.057-.108-.09-.108-.09-.443-.441-3.368-3.049-4.034-3.954-.709-.965-1.041-2.7-.091-3.71.951-1.01 3.005-1.086 4.363.407 0 0 1.565-1.782 3.468-.963 1.904.82 1.832 3.011.723 4.311zm6.173.478c-.928.116-1.682.028-1.682.028V7.284h1.77s1.971.551 1.971 2.638c0 1.913-.985 2.667-2.059 3.015z"/>
</svg>
</div>
<div class="platform-info">
<span class="platform-name">Ko-fi</span>
<span class="platform-desc">Offrez-nous un café</span>
</div>
</a>
<?php endif; ?>
</div>
</div>
<!-- Interface de don Stripe -->
<?php if ($hasStripe): ?>
<div class="donation-interface">
<h3>Don via Stripe</h3>
<!-- Onglets don ponctuel / mensuel -->
<div class="donation-tabs">
<button class="tab-btn active" onclick="switchTab('onetime')">
<i class="fas fa-hand-holding-heart"></i> Don ponctuel
</button>
<button class="tab-btn" onclick="switchTab('monthly')">
<i class="fas fa-sync-alt"></i> Don mensuel
</button>
</div>
<!-- Don ponctuel -->
<div id="onetime-tab" class="tab-content active">
<div class="donation-amounts">
<?php foreach ($donationAmounts as $amount): ?>
<?php if (isset($stripeOneTimeLinks[$amount]) && !empty($stripeOneTimeLinks[$amount])): ?>
<a href="<?php echo htmlspecialchars($stripeOneTimeLinks[$amount]); ?>"
class="donation-btn"
target="_blank"
rel="noopener noreferrer">
<?php echo $amount . $currencySymbol; ?>
</a>
<?php endif; ?>
<?php endforeach; ?>
<?php if (isset($stripeOneTimeLinks['custom']) && !empty($stripeOneTimeLinks['custom'])): ?>
<a href="<?php echo htmlspecialchars($stripeOneTimeLinks['custom']); ?>"
class="donation-btn donation-btn-custom"
target="_blank"
rel="noopener noreferrer">
<i class="fas fa-euro-sign"></i> Autre montant
</a>
<?php endif; ?>
</div>
</div>
<!-- Don mensuel -->
<div id="monthly-tab" class="tab-content">
<div class="donation-amounts">
<?php foreach ($donationAmounts as $amount): ?>
<?php if (isset($stripeMonthlyLinks[$amount]) && !empty($stripeMonthlyLinks[$amount])): ?>
<a href="<?php echo htmlspecialchars($stripeMonthlyLinks[$amount]); ?>"
class="donation-btn"
target="_blank"
rel="noopener noreferrer">
<?php echo $amount . $currencySymbol; ?>/mois
</a>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
<div class="donation-security">
<p><i class="fas fa-shield-alt"></i>
Paiement 100% sécurisé via Stripe. Aucune donnée bancaire n'est stockée sur ce site.</p>
</div>
</div>
<?php endif; ?>
</div>
</section>
<!-- Informations supplémentaires - PERSONNALISEZ -->
<section class="donation-info">
<div class="info-grid">
<div class="info-card">
<i class="fas fa-server"></i>
<h4>Hébergement & Infrastructure</h4>
<p>Maintenir nos serveurs, notre plateforme et nos outils numériques représente des coûts mensuels importants.</p>
</div>
<div class="info-card">
<i class="fas fa-tools"></i>
<h4>Maintenance & Développement</h4>
<p>Assurer la sécurité, les mises à jour et l'évolution de nos outils technologiques demande un investissement constant.</p>
</div>
<div class="info-card">
<i class="fas fa-shield-alt"></i>
<h4>Indépendance Numérique</h4>
<p>Vos dons nous permettent de rester indépendants des plateformes commerciales et de préserver notre souveraineté numérique.</p>
</div>
</div>
</section>
</div>
</main>
<?php include 'includes/footer.php'; ?>
<?php include 'includes/mobile-menu.php'; ?>
<!-- Script pour les onglets -->
<script>
function switchTab(tab) {
// Cacher tous les contenus et désactiver tous les boutons
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
});
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('active');
});
// Afficher le contenu sélectionné et activer le bouton
document.getElementById(tab + '-tab').classList.add('active');
event.target.classList.add('active');
}
</script>
<script src="js/main.js"></script>
</body>
</html>
-6
View File
@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="50px" height="50px">
<!-- Cercle rouge -->
<circle cx="256" cy="256" r="240" fill="#FF0000"/>
<!-- Triangle blanc (forme play) -->
<path d="M203.691 395.025C194.731 400.206 183.2 393.695 183.2 383.164V128.836C183.2 118.305 194.731 111.794 203.691 116.975L382.293 243.638C390.253 248.264 390.253 263.736 382.293 268.362L203.691 395.025Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 436 B

-287
View File
@@ -1,287 +0,0 @@
<?php
/**
* Configuration par défaut de FEDIVERSE OKI
*
* Ce fichier contient les paramètres de configuration par défaut.
* Il est utilisé pour initialiser les variables non définies dans config.local.php.
*/
if (!defined('APP_HOST_NAME')) define('APP_HOST_NAME', 'fediverse.o-k-i.net');
if (!defined('ORGANIZATION_SHORT_NAME')) define('ORGANIZATION_SHORT_NAME', 'OKI');
if (!defined('ORGANIZATION_NAME')) define('ORGANIZATION_NAME', 'ORGANISATION KA INTERNATIONALE');
// Configuration de base - ces valeurs seront utilisées si elles ne sont pas définies dans config.local.php
if (!defined('PEERTUBE_URL')) define('PEERTUBE_URL', 'https://gade.o-k-i.net');
if (!defined('PEERTUBE_DISPLAY_NAME')) define('PEERTUBE_DISPLAY_NAME', 'gade.o-k-i.net');
if (!defined('API_KEY')) define('API_KEY', '');
if (!defined('TAG_INDEPENDENCE')) define('TAG_INDEPENDENCE', 'indépendance');
if (!defined('SHORTS_MAX_DURATION')) define('SHORTS_MAX_DURATION', 180); // 3 minutes max pour les shorts
// Pagination et affichage
if (!defined('COUNT_VIDEO_SEARCH')) define('COUNT_VIDEO_SEARCH', 20);
if (!defined('VIDEOS_PER_PAGE')) define('VIDEOS_PER_PAGE', 12);
if (!defined('FEATURED_VIDEOS_COUNT')) define('FEATURED_VIDEOS_COUNT', 6);
if (!defined('RECENT_VIDEOS_COUNT')) define('RECENT_VIDEOS_COUNT', 6);
if (!defined('SHORTS_COUNT')) define('SHORTS_COUNT', 6);
if (!defined('SHORTS_COUNT_SEARCH')) define('SHORTS_COUNT_SEARCH', 100);
if (!defined('TRENDING_VIDEOS_COUNT')) define('TRENDING_VIDEOS_COUNT', 6);
if (!defined('INDEPENDENCE_VIDEOS_COUNT')) define('INDEPENDENCE_VIDEOS_COUNT', 6);
if (!defined('CATEGORY_VIDEOS_COUNT')) define('CATEGORY_VIDEOS_COUNT', 6);
if (!defined('LOAD_MORE_COUNT')) define('LOAD_MORE_COUNT', 6);
// Affichage des métadonnées vidéo
if (!defined('SHOW_VIDEO_VIEWS')) define('SHOW_VIDEO_VIEWS', false); // Masquer le nombre de vues par défaut
// Catégories prioritaires avec noms personnalisés (dans l'ordre d'affichage souhaité)
// format: [ID catégorie => Nom personnalisé]
if (!defined('PRIORITY_CATEGORIES')) {
define('PRIORITY_CATEGORIES', [
11 => 'Actualités & Politique',
15 => 'Science et Technologie',
4 => 'Art',
9 => 'Humour',
10 => 'Divertissement'
]);
}
// =========================================
// Configuration Mastodon
// =========================================
if (!defined('MASTODON_INSTANCE_URL')) define('MASTODON_INSTANCE_URL', 'https://bokante.o-k-i.net');
if (!defined('MASTODON_DATE_FORMAT')) define('MASTODON_DATE_FORMAT', 'fr-FR');
if (!defined('MASTODON_BTN_SEE_MORE')) define('MASTODON_BTN_SEE_MORE', 'Voir plus de post');
if (!defined('MASTODON_BTN_RELOAD')) define('MASTODON_BTN_RELOAD', 'Rafraichir');
if (!defined('MASTODON_MAX_POST_FETCH')) define('MASTODON_MAX_POST_FETCH', '10');
if (!defined('MASTODON_MAX_POST_SHOW')) define('MASTODON_MAX_POST_SHOW', '10');
// URL du stockage S3 pour les médias Mastodon (laissez vide pour désactiver)
// Format: https://votre-bucket.s3.region.provider.com
// if (!defined('MASTODON_S3_MEDIA_URL')) define('MASTODON_S3_MEDIA_URL', 'https://s3.eu-central-003.backblazeb2.com');
// Informations du site
if (!defined('SITE_NAME')) define('SITE_NAME', 'FEDIVERSE OKI');
if (!defined('SITE_DESCRIPTION')) define('SITE_DESCRIPTION', 'Plateforme multimedia indépendante');
if (!defined('SITE_LOGO')) define('SITE_LOGO', 'img/logo.png');
if (!defined('SITE_FAVICON')) define('SITE_FAVICON', 'img/favicon.png');
// Configuration du fuseau horaire par défaut
// Liste des fuseaux disponibles : https://www.php.net/manual/fr/timezones.php
if (!defined('DEFAULT_TIMEZONE')) define('DEFAULT_TIMEZONE', 'America/Guadeloupe');
// Réseaux sociaux
if (!defined('FACEBOOK_URL')) define('FACEBOOK_URL', '#');
if (!defined('X_URL')) define('X_URL', '#');
if (!defined('INSTAGRAM_URL')) define('INSTAGRAM_URL', '#');
if (!defined('YOUTUBE_URL')) define('YOUTUBE_URL', '#');
if (!defined('TIKTOK_URL')) define('TIKTOK_URL', '#');
if (!defined('MASTODON_URL')) define('MASTODON_URL', 'https://bokante.o-k-i.net/@admin');
// Contacts
if (!defined('CONTACT_EMAIL')) define('CONTACT_EMAIL', 'kontak.o-k-i.net');
// Mentions légales
if (!defined('LEGAL_COPYRIGHT')) define('LEGAL_COPYRIGHT', 'OKI');
if (!defined('LEGAL_WEBMASTER_NAME')) define('LEGAL_WEBMASTER_NAME', 'Cédric Famibelle-Pronzola');
if (!defined('LEGAL_WEBMASTER_EMAIL')) define('LEGAL_WEBMASTER_EMAIL', 'contact@cedric-pronzola.dev');
if (!defined('LEGAL_HOST_NAME')) define('LEGAL_HOST_NAME', 'o2Switch');
if (!defined('LEGAL_HOST_COMPANY')) define('LEGAL_HOST_COMPANY', 'société au capital de 100 000 €');
if (!defined('LEGAL_HOST_RCS')) define('LEGAL_HOST_RCS', 'immatriculée au RCS de Clermont-Ferrand sous le numéro 510 909 807');
if (!defined('LEGAL_HOST_ADDRESS')) define('LEGAL_HOST_ADDRESS', '222 boulevard Gustave Flaubert, 63000 Clermont-Ferrand, France');
if (!defined('LEGAL_CONTACT_EMAIL')) define('LEGAL_CONTACT_EMAIL', 'kontak@o-k-i.net');
if (!defined('LEGAL_LICENSE')) define('LEGAL_LICENSE', 'GNU Affero General Public License version 3 (AGPL-V3)');
if (!defined('LEGAL_LICENSE_URL')) define('LEGAL_LICENSE_URL', 'https://www.gnu.org/licenses/agpl-3.0.html');
if (!defined('LEGAL_SOURCE_CODE_URL')) define('LEGAL_SOURCE_CODE_URL', 'https://codeberg.org/OKI/fediverse.o-k-i.net');
if (!defined('LEGAL_SERVICE_DESCRIPTION')) define('LEGAL_SERVICE_DESCRIPTION', 'est une plateforme multimédia indépendante.');
// Fonctionnalités
define('ENABLE_SEARCH', true);
if (!defined('ENABLE_USER_ACCOUNTS')) define('ENABLE_USER_ACCOUNTS', false);
// Cache
if (!defined('CACHE_ENABLED')) define('CACHE_ENABLED', false);
if (!defined('CACHE_DURATION')) define('CACHE_DURATION', 3600); // En secondes (1 heure)
// =========================================
// Configuration de la section Hero (bannière d'accueil)
// =========================================
// Type de contenu à afficher dans la section hero
// Options: 'live' (direct PeerTube), 'playlist' (playlist audio/vidéo), 'video' (vidéo unique), 'none' (masquer)
if (!defined('HERO_TYPE')) define('HERO_TYPE', 'live');
// Configuration pour le direct (HERO_TYPE = 'live')
if (!defined('LIVE_ACCOUNT_NAME')) define('LIVE_ACCOUNT_NAME', 'admin');
// Configuration pour une vidéo unique (HERO_TYPE = 'video')
if (!defined('HERO_VIDEO_ID')) define('HERO_VIDEO_ID', '1aJ2u9euwF9fWKQhFxwFio');
if (!defined('HERO_VIDEO_TITLE')) define('HERO_VIDEO_TITLE', 'Vidéo de présentation');
// Configuration pour les playlists (HERO_TYPE = 'playlist')
// Type de plateforme: 'peertube', 'funkwhale', 'castopod'
if (!defined('PLAYLIST_PLATFORM')) define('PLAYLIST_PLATFORM', 'peertube');
// URL de base de la plateforme de playlist
if (!defined('PLAYLIST_INSTANCE_URL')) define('PLAYLIST_INSTANCE_URL', '');
// ID de la playlist à afficher
if (!defined('PLAYLIST_ID')) define('PLAYLIST_ID', '');
// Titre de la playlist (optionnel, sinon récupéré via API)
if (!defined('PLAYLIST_TITLE')) define('PLAYLIST_TITLE', '');
// Description de la playlist (optionnel)
if (!defined('PLAYLIST_DESCRIPTION')) define('PLAYLIST_DESCRIPTION', '');
// =========================================
// Configuration Castopod
// =========================================
// Activer l'affichage des podcasts Castopod
if (!defined('CASTOPOD_ENABLED')) define('CASTOPOD_ENABLED', true);
// URL de l'instance Castopod
if (!defined('CASTOPOD_URL')) define('CASTOPOD_URL', 'https://kute.o-k-i.net');
// Liste des slugs de podcasts à afficher (tableau)
// Format: ['slug1', 'slug2', 'slug3']
// Les épisodes de tous les podcasts seront mélangés et triés par date
if (!defined('CASTOPOD_PODCAST_SLUGS')) {
define('CASTOPOD_PODCAST_SLUGS', ['joukawouve']);
}
// Nombre d'épisodes à afficher (total, tous podcasts confondus)
if (!defined('CASTOPOD_EPISODES_COUNT')) define('CASTOPOD_EPISODES_COUNT', 5);
// =========================================
// Configuration Funkwhale
// =========================================
// Activer l'affichage des morceaux Funkwhale
if (!defined('FUNKWHALE_ENABLED')) define('FUNKWHALE_ENABLED', false);
// URL de l'instance Funkwhale
if (!defined('FUNKWHALE_URL')) define('FUNKWHALE_URL', 'https://mizik.o-k-i.net');
// Nombre de morceaux à afficher
if (!defined('FUNKWHALE_TRACKS_COUNT')) define('FUNKWHALE_TRACKS_COUNT', 10);
// Tags pour filtrer les vidéos selon les catégories
if (!defined('TAG_SHORT')) define('TAG_SHORT', 'short');
// Hashtags importants à afficher dans la sidebar, footer et menu mobile
if (!defined('IMPORTANT_TAGS')) {
define('IMPORTANT_TAGS', [
'Chlordécone',
'RCI',
'Mé67',
'Traduction',
'fediverse'
]);
}
// Hashtags populaires à afficher sur la page d'accueil
if (!defined('POPULAR_TAGS')) {
define('POPULAR_TAGS', [
'fediverse',
'guadeloupe',
'Gaza',
'Arte'
]);
}
// =========================================
// Système de compte à rebours / maintenance
// =========================================
// Activer le mode compte à rebours (true/false) - valeur par défaut
if (!defined('COUNTDOWN_ENABLED')) define('COUNTDOWN_ENABLED', false);
// Date de fin du compte à rebours par défaut (format: Y-m-d H:i:s)
if (!defined('COUNTDOWN_TARGET_DATE')) define('COUNTDOWN_TARGET_DATE', '2025-10-11 00:00:00');
// Territoires et fuseaux horaires par défaut pour la page de compte à rebours
if (!defined('COUNTDOWN_TIMEZONES')) {
define('COUNTDOWN_TIMEZONES', [
'Martinique / Guadeloupe' => 'America/Martinique',
'Guyane' => 'America/Cayenne',
'France' => 'Europe/Paris',
'Ma\'ohi Nui' => 'Pacific/Tahiti',
'Kanaky' => 'Pacific/Noumea'
]);
}
// =========================================
// Intégration WordPress par défaut
// =========================================
// URL du site WordPress par défaut
if (!defined('WORDPRESS_URL')) define('WORDPRESS_URL', '');
// Nombre d'articles WordPress à afficher par défaut
if (!defined('WORDPRESS_POSTS_COUNT')) define('WORDPRESS_POSTS_COUNT', 6);
// Activation des articles WordPress par défaut
if (!defined('WORDPRESS_ENABLED')) define('WORDPRESS_ENABLED', false);
// =========================================
// Système de dons par défaut
// =========================================
// Activation du système de dons par défaut
if (!defined('DONATIONS_ENABLED')) define('DONATIONS_ENABLED', false);
// URLs des plateformes de don
if (!defined('LIBERAPAY_URL')) define('LIBERAPAY_URL', ''); // Ex: https://liberapay.com/votre-compte/donate
if (!defined('KOFI_URL')) define('KOFI_URL', ''); // Ex: https://ko-fi.com/votre-compte
if (!defined('STRIPE_ENABLED')) define('STRIPE_ENABLED', false);
// Configuration Stripe
if (!defined('STRIPE_ONE_TIME_LINKS')) {
define('STRIPE_ONE_TIME_LINKS', [
1 => '', // Lien Stripe pour don de 1€
5 => '', // Lien Stripe pour don de 5€
10 => '', // Lien Stripe pour don de 10€
20 => '', // Lien Stripe pour don de 20€
50 => '', // Lien Stripe pour don de 50€
'custom' => '' // Lien Stripe pour montant personnalisé
]);
}
if (!defined('STRIPE_MONTHLY_LINKS')) {
define('STRIPE_MONTHLY_LINKS', [
1 => '', // Lien Stripe pour don mensuel de 1€
5 => '', // Lien Stripe pour don mensuel de 5€
10 => '', // Lien Stripe pour don mensuel de 10€
20 => '', // Lien Stripe pour don mensuel de 20€
50 => '', // Lien Stripe pour don mensuel de 50€
]);
}
// Montants de dons suggérés par défaut
if (!defined('DONATION_AMOUNTS')) define('DONATION_AMOUNTS', [1, 5, 10, 20, 50]);
// Devise par défaut
if (!defined('DONATION_CURRENCY')) define('DONATION_CURRENCY', 'EUR');
// =========================================
// Annonce du prochain live
// =========================================
// Activer/désactiver l'annonce du prochain live par défaut
if (!defined('NEXT_LIVE_ENABLED')) define('NEXT_LIVE_ENABLED', false);
// Titre de l'annonce du prochain live
if (!defined('NEXT_LIVE_TITLE')) define('NEXT_LIVE_TITLE', 'Prochain live');
// Description de l'annonce du prochain live
if (!defined('NEXT_LIVE_DESCRIPTION')) define('NEXT_LIVE_DESCRIPTION', 'Rejoignez-nous pour notre prochain live !');
// Date du prochain live (format: Y-m-d H:i:s)
if (!defined('NEXT_LIVE_DATE')) define('NEXT_LIVE_DATE', '');
// Chemin vers l'image d'annonce du prochain live (relatif à la racine du site)
// Exemple: 'uploads/next-live.jpg'
if (!defined('NEXT_LIVE_IMAGE')) define('NEXT_LIVE_IMAGE', 'uploads/next-live.jpg');
?>
-429
View File
@@ -1,429 +0,0 @@
<?php
/**
* Configuration locale pour l'instance de PeerTube
*
* Ce fichier est un exemple de configuration locale.
* Pour l'utiliser:
* 1. Copiez ce fichier vers config.local.php
* 2. Décommentez et modifiez les valeurs selon vos besoins
*
* Note: config.local.php ne doit pas être versionné dans git
*/
define('APP_HOST_NAME', 'fediverse.o-k-i.net');
// define('ORGANIZATION_SHORT_NAME', 'OKI');
// define('ORGANIZATION_NAME', 'ORGANISATION KA INTERNATIONALE');
// =========================================
// Configuration de l'API PeerTube
// =========================================
// URL de l'API PeerTube (obligatoire)
// define('PEERTUBE_URL', 'https://votre-instance.fr');
// define('PEERTUBE_DISPLAY_NAME', 'votre-instance.fr');
// Clé d'API PeerTube (optionnelle)
// define('API_KEY', 'votre_cle_api');
// Afficher le nombre de vues sur les vidéos (par défaut: false)
// define('SHOW_VIDEO_VIEWS', true);
// =========================================
// Configuration de la section Hero (bannière d'accueil)
// =========================================
// Type de contenu à afficher dans la section hero
// Options: 'live' (direct PeerTube), 'video' (vidéo unique), 'playlist' (playlist audio/vidéo), 'none' (masquer)
// define('HERO_TYPE', 'live');
// Configuration pour le direct (HERO_TYPE = 'live')
// define('LIVE_ACCOUNT_NAME', 'admin');
// Configuration pour une vidéo unique (HERO_TYPE = 'video')
// ID de la vidéo (extrait de l'URL: https://gade.o-k-i.net/w/VIDEO_ID)
// define('HERO_VIDEO_ID', '1aJ2u9euwF9fWKQhFxwFio');
// Titre de la vidéo (optionnel, pour l'accessibilité)
// define('HERO_VIDEO_TITLE', 'Vidéo de présentation');
// Configuration pour les playlists (HERO_TYPE = 'playlist')
// Type de plateforme: 'peertube', 'funkwhale', 'castopod'
// define('PLAYLIST_PLATFORM', 'peertube');
// URL de base de la plateforme de playlist
// define('PLAYLIST_INSTANCE_URL', 'https://votre-instance.fr');
// ID de la playlist à afficher
// define('PLAYLIST_ID', 'votre-playlist-uuid');
// Titre de la playlist (optionnel, sinon récupéré via API)
// define('PLAYLIST_TITLE', 'Ma Playlist');
// Description de la playlist (optionnel)
// define('PLAYLIST_DESCRIPTION', 'Description de la playlist');
// =========================================
// Filtres et tags
// =========================================
// Tag pour les vidéos sur l'indépendance
// define('TAG_INDEPENDENCE', 'indépendance');
// Tag pour les shorts
// define('TAG_SHORT', 'short');
// Hashtags importants à afficher dans la sidebar, footer et menu mobile
define('IMPORTANT_TAGS', [
'Chlordécone',
'RCI',
'Mé67',
'Traduction',
'fediverse'
]);
// Hashtags populaires à afficher sur la page d'accueil
define('POPULAR_TAGS', [
'fediverse',
'guadeloupe',
'Gaza',
'Arte'
]);
// Durée maximale des shorts en secondes
define('SHORTS_MAX_DURATION', 180); // 3 minutes
// =========================================
// Pagination et nombre de vidéos à afficher
// =========================================
// Nombre de vidéos par page
// define('VIDEOS_PER_PAGE', 12);
// Limite de vidéo à chercher
// define('COUNT_VIDEO_SEARCH', 6);
// Nombre de vidéos à la une
// define('FEATURED_VIDEOS_COUNT', 6);
// Nombre de vidéos récentes
// define('RECENT_VIDEOS_COUNT', 6);
// Nombre de shorts
// define('SHORTS_COUNT', 6);
// define('SHORTS_COUNT_SEARCH', 50)
// Nombre de vidéos tendances
// define('TRENDING_VIDEOS_COUNT', 6);
// Nombre de vidéos indépendance
// define('INDEPENDENCE_VIDEOS_COUNT', 6);
// Nombre de vidéos par catégorie
// define('CATEGORY_VIDEOS_COUNT', 6);
// Nombre de vidéos chargées avec "Voir plus"
// define('LOAD_MORE_COUNT', 6);
// =========================================
// Catégories à afficher
// =========================================
// Catégories prioritaires avec noms personnalisés (dans l'ordre d'affichage souhaité)
// Liste des catégories : (disponible ici => https://mon_instance_peertube.fr/api/v1/videos/categories)
// 1 : Music
// 2 : Films
// 3 : Vehicles
// 4 : Art
// 5 : Sports
// 6 : Travels
// 7 : Gaming
// 8 : People
// 9 : Comedy
// 10 : Entertainment
// 11 : News & Politics
// 12 : How To
// 13 : Education
// 14 : Activism
// 15 : Science & Technology
// 16 : Animals
// 17 : Kids
// 18 : Food
define('PRIORITY_CATEGORIES', [
11 => 'Actualités & Politique',
15 => 'Science et Technologie',
4 => 'Art',
9 => 'Humour',
10 => 'Divertissement'
// Ajoutez d'autres catégories selon vos besoins
]);
// =========================================
// Informations du site
// =========================================
// Nom du site
// define('SITE_NAME', 'FEDIVERSE OKI');
// Description du site
// define('SITE_DESCRIPTION', 'Plateforme multimedia indépendante');
// Logo du site
// define('SITE_LOGO', 'img/logo.png');
// Favicon du site
// define('SITE_FAVICON', 'img/favicon.png');
// Fuseau horaire par défaut pour l'affichage des dates
// Liste des fuseaux disponibles : https://www.php.net/manual/fr/timezones.php
// define('DEFAULT_TIMEZONE', 'America/Guadeloupe');
// =========================================
// Réseaux sociaux
// =========================================
// URL de la page Facebook
// define('FACEBOOK_URL', 'https://facebook.com/votrepage');
// URL du compte X (anciennement Twitter)
// define('X_URL', 'https://x.com/votrecompte');
// URL du compte Instagram
// define('INSTAGRAM_URL', 'https://instagram.com/votrecompte');
// URL de la chaîne YouTube
// define('YOUTUBE_URL', 'https://youtube.com/votrechaine');
// URL du compte TikTok
// define('TIKTOK_URL', 'https://tiktok.com/@votrecompte');
// URL du compte Mastodon
// define('MASTODON_URL', 'https://bokante.o-k-i.net/@admin');
// =========================================
// Contact
// =========================================
// Email de contact
// define('CONTACT_EMAIL', 'contact@votredomaine.com');
// =========================================
// Fonctionnalités
// =========================================
// Activer/désactiver les commentaires
// define('ENABLE_COMMENTS', true);
// Activer/désactiver la recherche
// define('ENABLE_SEARCH', true);
// Activer/désactiver les comptes utilisateurs
// define('ENABLE_USER_ACCOUNTS', false);
// =========================================
// Cache
// =========================================
// Activer/désactiver le cache
// define('CACHE_ENABLED', false);
// Durée du cache en secondes
// define('CACHE_DURATION', 3600); // 1 heure
// =========================================
// Configuration Mastodon
// =========================================
// URL de l'instance Mastodon
// define('MASTODON_INSTANCE_URL', 'https://mastodon.social');
// Format de date pour l'affichage des posts
// define('MASTODON_DATE_FORMAT', 'fr-FR');
// Texte du bouton "Voir plus"
// define('MASTODON_BTN_SEE_MORE', 'Voir plus de post');
// Texte du bouton "Rafraichir"
// define('MASTODON_BTN_RELOAD', 'Rafraichir');
// Nombre maximum de posts à récupérer
// define('MASTODON_MAX_POST_FETCH', '10');
// Nombre maximum de posts à afficher
// define('MASTODON_MAX_POST_SHOW', '10');
// URL du stockage S3 pour les médias Mastodon (optionnel)
// Format: https://votre-bucket.s3.region.provider.com
// Laissez vide ou commentez pour désactiver
// define('MASTODON_S3_MEDIA_URL', 'https://s3.eu-central-003.backblazeb2.com');
// =========================================
// Mentions légales
// =========================================
// define('LEGAL_COPYRIGHT', 'OKI');
// define('LEGAL_WEBMASTER_NAME', 'Cédric Famibelle-Pronzola');
// define('LEGAL_WEBMASTER_EMAIL', 'contact@cedric-pronzola.dev');
// define('LEGAL_HOST_NAME', 'o2Switch');
// define('LEGAL_HOST_COMPANY', 'société au capital de 100 000 €');
// define('LEGAL_HOST_RCS', 'immatriculée au RCS de Clermont-Ferrand sous le numéro 510 909 807');
// define('LEGAL_HOST_ADDRESS', '222 boulevard Gustave Flaubert, 63000 Clermont-Ferrand, France');
// define('LEGAL_CONTACT_EMAIL', 'kontak@o-k-i.net');
// define('LEGAL_LICENSE', 'GNU Affero General Public License version 3 (AGPL-V3)');
// define('LEGAL_LICENSE_URL', 'https://www.gnu.org/licenses/agpl-3.0.html');
// define('LEGAL_SOURCE_CODE_URL', 'https://codeberg.org/OKI/fediverse.o-k-i.net');
// define('LEGAL_SERVICE_DESCRIPTION', ' est une plateforme multimédia indépendante.');
// =========================================
// Système de compte à rebours / maintenance
// =========================================
// Activer le mode compte à rebours (true/false)
define('COUNTDOWN_ENABLED', false);
// Date de fin du compte à rebours (format: Y-m-d H:i:s)
define('COUNTDOWN_TARGET_DATE', '2025-10-11 00:00:00');
// Territoires et fuseaux horaires à afficher sur la page de compte à rebours
define('COUNTDOWN_TIMEZONES', [
'Martinique / Guadeloupe' => 'America/Martinique',
'Guyane' => 'America/Cayenne',
'France' => 'Europe/Paris',
'Ma\'ohi Nui' => 'Pacific/Tahiti',
'Kanaky' => 'Pacific/Noumea'
]);
// =========================================
// Intégration WordPress
// =========================================
// Activer/désactiver l'affichage des articles WordPress
define('WORDPRESS_ENABLED', false);
// Nombre d'articles WordPress à afficher
// define('WORDPRESS_POSTS_COUNT', 6);
// URL du site WordPress pour récupérer les articles (sans trailing slash)
// define('WORDPRESS_URL', 'https://votre-site-wordpress.com');
// =========================================
// Intégration Castopod (Podcasts)
// =========================================
// Activer/désactiver l'affichage des podcasts Castopod
// define('CASTOPOD_ENABLED', true);
// URL de l'instance Castopod
// define('CASTOPOD_URL', 'https://kute.o-k-i.net');
// Liste des slugs de podcasts à afficher (tableau)
// Les épisodes de tous les podcasts seront mélangés et triés par date
// define('CASTOPOD_PODCAST_SLUGS', [
// 'joukawouve',
// 'cspcc',
// 'radyobokaz'
// ]);
// Nombre d'épisodes à afficher (total, tous podcasts confondus)
// define('CASTOPOD_EPISODES_COUNT', 5);
// IMPORTANT : Le cache est fortement recommandé pour éviter le rate limiting de Castopod
// Castopod applique un rate limit strict (HTTP 429) sur les requêtes RSS
// Avec le cache activé, les données sont mises en cache pendant CACHE_DURATION secondes
// define('CACHE_ENABLED', true);
// define('CACHE_DURATION', 3600); // 1 heure recommandé
// =========================================
// Intégration Funkwhale (Musique)
// =========================================
// Activer/désactiver l'affichage des morceaux Funkwhale
// define('FUNKWHALE_ENABLED', true);
// URL de l'instance Funkwhale
// define('FUNKWHALE_URL', 'https://mizik.o-k-i.net');
// Nombre de morceaux aléatoires à afficher
// define('FUNKWHALE_TRACKS_COUNT', 10);
// =========================================
// Système de dons
// =========================================
// Activer/désactiver le système de dons
// define('DONATIONS_ENABLED', true);
// URLs des plateformes de don
// define('LIBERAPAY_URL', 'https://liberapay.com/votre-compte/donate');
// define('KOFI_URL', 'https://ko-fi.com/votre-compte');
// Activer/désactiver les dons via Stripe
// define('STRIPE_ENABLED', false);
// Liens Stripe pour les dons ponctuels
// define('STRIPE_ONE_TIME_LINKS', [
// 1 => 'https://buy.stripe.com/xxxxx', // Lien pour don de 1€
// 5 => 'https://buy.stripe.com/xxxxx', // Lien pour don de 5€
// 10 => 'https://buy.stripe.com/xxxxx', // Lien pour don de 10€
// 20 => 'https://buy.stripe.com/xxxxx', // Lien pour don de 20€
// 50 => 'https://buy.stripe.com/xxxxx', // Lien pour don de 50€
// 'custom' => 'https://buy.stripe.com/xxxxx' // Lien pour montant personnalisé
// ]);
// Liens Stripe pour les dons mensuels (récurrents)
// define('STRIPE_MONTHLY_LINKS', [
// 1 => 'https://buy.stripe.com/xxxxx', // Lien pour don mensuel de 1€
// 5 => 'https://buy.stripe.com/xxxxx', // Lien pour don mensuel de 5€
// 10 => 'https://buy.stripe.com/xxxxx', // Lien pour don mensuel de 10€
// 20 => 'https://buy.stripe.com/xxxxx', // Lien pour don mensuel de 20€
// 50 => 'https://buy.stripe.com/xxxxx', // Lien pour don mensuel de 50€
// ]);
// Montants de dons suggérés (en euros par défaut)
// define('DONATION_AMOUNTS', [1, 5, 10, 20, 50]);
// Devise pour les dons
// define('DONATION_CURRENCY', 'EUR');
// =========================================
// Texte de présentation du mouvement
// =========================================
// Titre de la section de présentation (par défaut: "À propos")
// define('MOVEMENT_TITLE', 'Notre mouvement');
// Premier paragraphe de description (pour désactiver le bloc de présentation, commentez cette ligne)
// define('MOVEMENT_DESCRIPTION', 'Nous sommes une association à but non lucratif dédiée à la promotion de nos langues et au traitement de l\'actualité.');
// Deuxième paragraphe de description (optionnel)
// define('MOVEMENT_DESCRIPTION_2', 'Parallèlement, nous proposons des alternatives aux géants du numérique comme les GAFAM, en privilégiant l\'utilisation de logiciels libres.');
// Image du mouvement à afficher dans la section de présentation
// define('MOVEMENT_IMAGE', 'img/movement_presentation.png');
// Texte alternatif pour l'image du mouvement (accessibilité)
// define('MOVEMENT_IMAGE_ALT', 'Texte alternatif pour l\'image du mouvement');
// Légende de l'image (peut contenir du HTML simple comme <br>)
// define('MOVEMENT_CAPTION', 'Légende de l\'image');
// =========================================
// Annonce du prochain live
// =========================================
// Activer/désactiver l'annonce du prochain live par défaut
// define('NEXT_LIVE_ENABLED', true);
// Titre de l'annonce du prochain live
// define('NEXT_LIVE_TITLE', 'Prochain live');
// Description de l'annonce du prochain live
// define('NEXT_LIVE_DESCRIPTION', 'Rejoignez-nous pour notre prochain live !');
// Date du prochain live (format: Y-m-d H:i:s)
// define('NEXT_LIVE_DATE', '2025-10-11 10:00:00');
// Chemin vers l'image d'annonce du prochain live (relatif à la racine du site)
// Exemple: 'uploads/next-live.jpg'
// define('NEXT_LIVE_IMAGE', 'uploads/next-live.jpeg');
-1012
View File
File diff suppressed because it is too large Load Diff
-77
View File
@@ -1,77 +0,0 @@
<?php
// Inclure la configuration si ce n'est pas déjà fait
if (!function_exists('getTrendingVideos')) {
require_once __DIR__ . '/config.php';
}
// Récupérer les vidéos tendances depuis l'API PeerTube
$featuredVideos = getTrendingVideos(FEATURED_VIDEOS_COUNT);
// Affichage des vidéos
foreach ($featuredVideos as $video):
?>
<div class="video-card" data-video-id="<?php echo $video['id']; ?>">
<div class="video-thumbnail">
<img src="<?php echo $video['thumbnail']; ?>" alt="<?php echo $video['title']; ?>" data-src="<?php echo $video['thumbnail']; ?>">
<div class="video-duration"><?php echo formatDuration($video['duration']); ?></div>
</div>
<div class="video-info">
<h3 class="video-title"><?php echo $video['title']; ?></h3>
<div class="video-channel"><?php echo $video['channel']; ?></div>
<div class="video-metadata">
<?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?>
<span class="video-views"><?php echo formatViewCount($video['views']); ?> vues</span>
<?php endif; ?>
<span class="video-date"><?php echo formatDate($video['date']); ?></span>
</div>
</div>
</div>
<?php endforeach; ?>
<?php
// Fonctions utilitaires (dans un vrai projet, ces fonctions seraient dans un fichier séparé)
function formatDuration($seconds) {
$hours = floor($seconds / 3600);
$minutes = floor(($seconds % 3600) / 60);
$remainingSeconds = $seconds % 60;
if ($hours > 0) {
return sprintf('%d:%02d:%02d', $hours, $minutes, $remainingSeconds);
} else {
return sprintf('%d:%02d', $minutes, $remainingSeconds);
}
}
function formatViewCount($views) {
if ($views >= 1000000) {
return round($views / 1000000, 1) . 'M';
} elseif ($views >= 1000) {
return round($views / 1000, 1) . 'K';
} else {
return $views;
}
}
function formatDate($dateString) {
$date = new DateTime($dateString);
$now = new DateTime();
$interval = $now->diff($date);
if ($interval->days == 0) {
return 'Aujourd\'hui';
} elseif ($interval->days == 1) {
return 'Hier';
} elseif ($interval->days < 7) {
return 'Il y a ' . $interval->days . ' jours';
} elseif ($interval->days < 30) {
$weeks = floor($interval->days / 7);
return 'Il y a ' . $weeks . ' semaine' . ($weeks > 1 ? 's' : '');
} elseif ($interval->days < 365) {
$months = floor($interval->days / 30);
return 'Il y a ' . $months . ' mois';
} else {
$years = floor($interval->days / 365);
return 'Il y a ' . $years . ' an' . ($years > 1 ? 's' : '');
}
}
?>
-99
View File
@@ -1,99 +0,0 @@
<!-- Footer -->
<div class="footer">
<div class="footer-header">
<div class="footer-logo">
<img src="img/logo.png" alt="<?php echo SITE_NAME; ?>">
</div>
<div class="footer-contact-info">
<div class="footer-contact">CONTACT</div>
<div class="footer-email"><a href="mailto:<?php echo CONTACT_EMAIL; ?>"><?php echo CONTACT_EMAIL; ?></a></div>
</div>
</div>
<div class="footer-columns">
<div class="footer-column">
<h3 class="footer-title">Catégories</h3>
<div>
<ul class="footer-links">
<li><a href="index.php" <?php echo ($currentPage === 'index.php') ? 'class="active"' : ''; ?>>Accueil</a></li>
<?php if (!defined('HERO_TYPE') || HERO_TYPE === 'live'): ?>
<li><a href="direct.php" <?php echo ($currentPage === 'direct.php') ? 'class="active"' : ''; ?>>Direct</a></li>
<?php endif; ?>
<?php
if (defined('PRIORITY_CATEGORIES') && !empty(PRIORITY_CATEGORIES)) {
foreach (PRIORITY_CATEGORIES as $id => $name) {
$isActive = ($currentPage === 'categories.php' && $currentCategoryId === $id);
echo '<li><a href="categories.php?id=' . $id . '"' . ($isActive ? ' class="active"' : '') . '>' . htmlspecialchars($name) . '</a></li>';
}
}
?>
</ul>
</div>
</div>
<div class="footer-column">
<h3 class="footer-title">Hashtags</h3>
<div>
<ul class="footer-links">
<?php
if (defined('IMPORTANT_TAGS') && !empty(IMPORTANT_TAGS)):
foreach (IMPORTANT_TAGS as $tag):
$encodedTag = urlencode('#' . $tag);
$isActive = ($isTagSearch && strtolower($currentTag) === strtolower($tag));
?>
<li><a href="recherche.php?q=<?php echo $encodedTag; ?>" <?php echo $isActive ? 'class="active"' : ''; ?>><?php echo htmlspecialchars($tag); ?></a></li>
<?php
endforeach;
endif;
?>
</ul>
</div>
</div>
<div class="footer-column">
<h3 class="footer-title">Informations légales</h3>
<div>
<ul class="footer-links">
<li><a href="mentions-legales.php" <?php echo ($currentPage === 'mentions-legales.php') ? 'class="active"' : ''; ?>>Mentions légales</a></li>
<li>
<a href="<?php echo LEGAL_SOURCE_CODE_URL; ?>" target="_blank" rel="noopener noreferrer">
<i class="fab fa-git-alt"></i> Code source
</a>
</li>
</ul>
</div>
</div>
</div>
<div class="footer-social">
<?php if (!empty(MASTODON_URL) && MASTODON_URL !== '#'): ?>
<a target="_blank" rel="me noreferrer" href="<?php echo MASTODON_URL; ?>"><i class="fab fa-mastodon icon-mastodon"></i></a>
<?php endif; ?>
<?php if (!empty(FACEBOOK_URL) && FACEBOOK_URL !== '#'): ?>
<a target="_blank" rel="noreferrer" href="<?php echo FACEBOOK_URL; ?>"><i class="fab fa-facebook icon-facebook"></i></a>
<?php endif; ?>
<?php if (!empty(YOUTUBE_URL) && YOUTUBE_URL !== '#'): ?>
<a target="_blank" rel="noreferrer" href="<?php echo YOUTUBE_URL; ?>"><i class="fab fa-youtube icon-youtube"></i></a>
<?php endif; ?>
<?php if (!empty(INSTAGRAM_URL) && INSTAGRAM_URL !== '#'): ?>
<a target="_blank" rel="noreferrer" href="<?php echo INSTAGRAM_URL; ?>"><i class="fab fa-instagram icon-instagram"></i></a>
<?php endif; ?>
<?php if (!empty(X_URL) && X_URL !== '#'): ?>
<a target="_blank" rel="noreferrer" href="<?php echo X_URL; ?>"><i class="fab fa-x-twitter icon-x"></i></a>
<?php endif; ?>
<?php if (!empty(TIKTOK_URL) && TIKTOK_URL !== '#'): ?>
<a target="_blank" rel="noreferrer" href="<?php echo TIKTOK_URL; ?>"><i class="fab fa-tiktok icon-tiktok"></i></a>
<?php endif; ?>
</div>
<div class="footer-copyright">
<?php echo LEGAL_COPYRIGHT; ?> <?php echo date('Y'); ?> - Licence libre <a href="<?php echo LEGAL_LICENSE_URL; ?>" target="_blank" rel="noopener noreferrer">GNU AGPL-V3</a>
</div>
</div>
-84
View File
@@ -1,84 +0,0 @@
<!-- Header avec barre de recherche et icônes -->
<header class="header" role="banner">
<div class="search-container">
<form action="recherche.php" method="get" role="search" aria-label="Recherche de vidéos">
<label for="search-input" class="sr-only">Rechercher des vidéos</label>
<input type="text" id="search-input" name="q" placeholder="Rechercher..." aria-describedby="search-help">
<button type="submit" aria-label="Lancer la recherche">
<i class="fas fa-search" aria-hidden="true"></i>
</button>
<div id="search-help" class="sr-only">Tapez vos mots-clés pour rechercher des vidéos</div>
</form>
</div>
<nav class="social-icons" aria-label="Réseaux sociaux">
<?php if (!empty(MASTODON_URL) && MASTODON_URL !== '#'): ?>
<a target="_blank" rel="me noreferrer" href="<?php echo MASTODON_URL; ?>" class="icon-button" aria-label="Suivre sur Mastodon">
<i class="fab fa-mastodon icon-mastodon" aria-hidden="true"></i>
</a>
<?php endif; ?>
<?php if (!empty(INSTAGRAM_URL) && INSTAGRAM_URL !== '#'): ?>
<a target="_blank" rel="noreferrer" href="<?php echo INSTAGRAM_URL; ?>" class="icon-button" aria-label="Suivre sur Instagram">
<i class="fab fa-instagram icon-instagram" aria-hidden="true"></i>
</a>
<?php endif; ?>
<?php if (!empty(TIKTOK_URL) && TIKTOK_URL !== '#'): ?>
<a target="_blank" rel="noreferrer" href="<?php echo TIKTOK_URL; ?>" class="icon-button" aria-label="Suivre sur TikTok">
<i class="fab fa-tiktok icon-tiktok" aria-hidden="true"></i>
</a>
<?php endif; ?>
<?php
// Vérifier s'il y a au moins un réseau social dans le menu déroulant
$hasDropdownSocial = (!empty(FACEBOOK_URL) && FACEBOOK_URL !== '#') ||
(!empty(YOUTUBE_URL) && YOUTUBE_URL !== '#') ||
(!empty(X_URL) && X_URL !== '#');
if ($hasDropdownSocial):
?>
<div class="more-social-container">
<button type="button" class="icon-button more-social-toggle" aria-expanded="false" aria-controls="social-dropdown" aria-label="Voir plus de réseaux sociaux">
<i class="fas fa-ellipsis-h" aria-hidden="true"></i>
</button>
<div id="social-dropdown" class="more-social-dropdown" role="menu">
<?php if (!empty(FACEBOOK_URL) && FACEBOOK_URL !== '#'): ?>
<a target="_blank" rel="noreferrer" href="<?php echo FACEBOOK_URL; ?>" class="more-social-item" role="menuitem">
<i class="fab fa-facebook icon-facebook" aria-hidden="true"></i> Facebook
</a>
<?php endif; ?>
<?php if (!empty(YOUTUBE_URL) && YOUTUBE_URL !== '#'): ?>
<a target="_blank" rel="noreferrer" href="<?php echo YOUTUBE_URL; ?>" class="more-social-item" role="menuitem">
<i class="fab fa-youtube icon-youtube" aria-hidden="true"></i> YouTube
</a>
<?php endif; ?>
<?php if (!empty(X_URL) && X_URL !== '#'): ?>
<a target="_blank" rel="noreferrer" href="<?php echo X_URL; ?>" class="more-social-item" role="menuitem">
<i class="fab fa-x-twitter icon-x" aria-hidden="true"></i> X
</a>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
</nav>
<div class="action-icons">
<?php if (defined('DONATIONS_ENABLED') && DONATIONS_ENABLED): ?>
<a href="dons.php" class="icon-button donation-link" aria-label="Soutenir <?php echo SITE_NAME; ?>" title="Faire un don">
<i class="fas fa-heart" aria-hidden="true"></i>
</a>
<?php endif; ?>
<button id="theme-toggle" class="icon-button" aria-label="Basculer entre mode clair et sombre" title="Changer le thème">
<i class="fas fa-sun" aria-hidden="true"></i>
</button>
<button id="install-pwa" class="icon-button install-pwa-button" style="display: none;" nonce="<?php echo getCspNonce(); ?>" aria-label="Installer l'application">
<i class="fas fa-download" aria-hidden="true"></i>
</button>
<button class="mobile-menu-toggle" aria-expanded="false" aria-controls="mobile-menu" aria-label="Ouvrir le menu de navigation">
<i class="fas fa-bars" aria-hidden="true"></i>
</button>
</div>
</header>
-291
View File
@@ -1,291 +0,0 @@
<?php
/**
* Section Hero - Affichage conditionnel (Direct / Playlist / Vidéo / Rien)
*
* Ce fichier gère l'affichage de la section hero en fonction de la configuration:
* - HERO_TYPE = 'live': Affiche le direct PeerTube
* - HERO_TYPE = 'video': Affiche une vidéo PeerTube unique
* - HERO_TYPE = 'playlist': Affiche une playlist (PeerTube/Funkwhale/Castopod)
* - HERO_TYPE = 'none': N'affiche rien
*/
// Type de hero défini dans config
$heroType = defined('HERO_TYPE') ? HERO_TYPE : 'live';
// Si le type est 'none', ne rien afficher
if ($heroType === 'none') {
return;
}
?>
<section class="hero" aria-labelledby="hero-section-title">
<h2 id="hero-section-title" class="sr-only">
<?php
if ($heroType === 'live') {
echo 'Diffusion en direct';
} elseif ($heroType === 'video') {
echo 'Vidéo';
} else {
echo 'Playlist';
}
?>
</h2>
<?php if ($heroType === 'live'): ?>
<!-- Mode DIRECT -->
<?php
// Vérifier s'il y a un direct en cours
$liveStream = getLiveStream();
if ($liveStream) {
// Afficher le direct en cours
?>
<div class="live-badge">
<i class="fas fa-circle"></i> DIRECT
</div>
<div class="hero-video-container">
<iframe
src="<?php echo PEERTUBE_URL; ?>/videos/embed/<?php echo $liveStream['id']; ?>?autoplay=1&muted=1"
frameborder="0"
allowfullscreen="allowfullscreen"
allow="autoplay; fullscreen"
title="Diffusion en direct: <?php echo htmlspecialchars($liveStream['title']); ?>"
aria-describedby="live-description">
</iframe>
<div id="live-description" class="sr-only">
Lecteur vidéo pour la diffusion en direct de <?php echo htmlspecialchars($liveStream['channel']); ?>
</div>
</div>
<div class="hero-video-info">
<h2><?php echo htmlspecialchars($liveStream['title']); ?></h2>
<div class="hero-channel-info">
<?php if (strpos($liveStream['channelAvatar'], 'default-avatar.png') !== false || empty($liveStream['channelAvatar'])): ?>
<div class="channel-avatar-placeholder" role="img" aria-label="Avatar par défaut">
<i class="fas fa-user-circle" aria-hidden="true"></i>
</div>
<?php else: ?>
<img src="<?php echo $liveStream['channelAvatar']; ?>" alt="Avatar de la chaîne <?php echo htmlspecialchars($liveStream['channel']); ?>" class="channel-avatar">
<?php endif; ?>
<span class="channel-name"><?php echo htmlspecialchars($liveStream['channel']); ?></span>
</div>
</div>
<?php
} else {
// Aucun direct en cours - vérifier s'il y a une annonce configurée
$showNextLiveAnnouncement = defined('NEXT_LIVE_ENABLED') && NEXT_LIVE_ENABLED === true;
if ($showNextLiveAnnouncement) {
// Afficher l'annonce du prochain live
// Définir l'image de fond si disponible
$bgImageStyle = '';
if (!empty(NEXT_LIVE_IMAGE) && file_exists(NEXT_LIVE_IMAGE)) {
$bgImageStyle = 'background-image: url(\'' . htmlspecialchars(NEXT_LIVE_IMAGE) . '\');';
}
?>
<div class="hero-next-live" style="<?php echo $bgImageStyle; ?>" nonce="<?php echo getCspNonce(); ?>">
<?php if (!empty(NEXT_LIVE_IMAGE) && file_exists(NEXT_LIVE_IMAGE)): ?>
<div class="hero-next-live-image-container">
<img src="<?php echo htmlspecialchars(NEXT_LIVE_IMAGE); ?>"
alt="<?php echo htmlspecialchars(NEXT_LIVE_TITLE); ?>"
class="hero-next-live-image">
</div>
<?php endif; ?>
<div class="hero-next-live-content">
<i class="fas fa-calendar-alt"></i>
<?php
if (!empty(NEXT_LIVE_DATE)) {
$liveDate = new DateTime(NEXT_LIVE_DATE, new DateTimeZone(DEFAULT_TIMEZONE));
$dayFormatter = new IntlDateFormatter(
'fr_FR',
IntlDateFormatter::FULL,
IntlDateFormatter::NONE,
DEFAULT_TIMEZONE,
IntlDateFormatter::GREGORIAN,
'EEEE d MMMM'
);
$formattedDay = $dayFormatter->format($liveDate);
$formattedDay = ucfirst($formattedDay);
$dynamicTitle = NEXT_LIVE_TITLE . ' - ' . $formattedDay;
} else {
$dynamicTitle = NEXT_LIVE_TITLE;
}
?>
<h2><?php echo htmlspecialchars($dynamicTitle); ?></h2>
<?php
if (!empty(NEXT_LIVE_DATE)) {
$liveHour = $liveDate->format('H\hi');
$dynamicDescription = 'Rejoignez-nous à ' . $liveHour . '. ' . NEXT_LIVE_DESCRIPTION;
} else {
$dynamicDescription = NEXT_LIVE_DESCRIPTION;
}
?>
<p><?php echo nl2br(htmlspecialchars($dynamicDescription)); ?></p>
<?php if (!empty(NEXT_LIVE_DATE)): ?>
<div class="hero-next-live-datetime">
<p class="hero-next-live-date">
<i class="fas fa-clock"></i>
<?php
$formatter = new IntlDateFormatter(
'fr_FR',
IntlDateFormatter::FULL,
IntlDateFormatter::SHORT,
DEFAULT_TIMEZONE
);
echo $formatter->format($liveDate);
$offset = $liveDate->format('P');
echo ' <span class="utc-offset">(UTC' . $offset . ')</span>';
?>
</p>
<!-- Autres fuseaux horaires -->
<div class="hero-next-live-timezones">
<?php
// Ordre croissant : du plus en retard au plus en avance
$timezones = [
'Ma\'ohi Nui' => 'Pacific/Tahiti',
'Martinique / Guadeloupe' => 'America/Martinique',
'Guyane' => 'America/Cayenne',
'France' => 'Europe/Paris',
'Kanaky' => 'Pacific/Noumea'
];
foreach($timezones as $name => $timezone):
$liveDateLocal = clone $liveDate;
$liveDateLocal->setTimezone(new DateTimeZone($timezone));
$dayDiff = $liveDateLocal->format('j') - $liveDate->format('j');
$dayIndicator = '';
if ($dayDiff > 0) {
$dayIndicator = ' <span class="day-shift">+1j</span>';
} elseif ($dayDiff < 0) {
$dayIndicator = ' <span class="day-shift">-1j</span>';
}
?>
<span class="hero-timezone-item">
<strong><?php echo $name; ?> :</strong> <?php echo $liveDateLocal->format('H\hi'); ?><?php echo $dayIndicator; ?>
</span>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
<?php
} else {
?>
<div class="hero-no-live">
<i class="fas fa-tv"></i>
<h2>Aucun direct en cours</h2>
<p>Revenez plus tard pour découvrir nos prochaines diffusions en direct.</p>
</div>
<?php
}
}
?>
<?php elseif ($heroType === 'playlist'): ?>
<!-- Mode PLAYLIST -->
<?php
$platform = defined('PLAYLIST_PLATFORM') ? PLAYLIST_PLATFORM : 'peertube';
$instanceUrl = defined('PLAYLIST_INSTANCE_URL') ? PLAYLIST_INSTANCE_URL : '';
$playlistId = defined('PLAYLIST_ID') ? PLAYLIST_ID : '';
$playlistTitle = defined('PLAYLIST_TITLE') ? PLAYLIST_TITLE : 'Playlist';
$playlistDescription = defined('PLAYLIST_DESCRIPTION') ? PLAYLIST_DESCRIPTION : '';
if (!empty($instanceUrl) && !empty($playlistId)):
?>
<div class="hero-playlist">
<div class="hero-playlist-header">
<i class="fas fa-list-music"></i>
<div class="hero-playlist-info">
<h2><?php echo htmlspecialchars($playlistTitle); ?></h2>
<?php if (!empty($playlistDescription)): ?>
<p><?php echo htmlspecialchars($playlistDescription); ?></p>
<?php endif; ?>
</div>
</div>
<div class="hero-playlist-container">
<?php if ($platform === 'peertube'): ?>
<!-- Embed PeerTube Playlist -->
<iframe
src="<?php echo rtrim($instanceUrl, '/'); ?>/video-playlists/embed/<?php echo $playlistId; ?>"
frameborder="0"
allowfullscreen="allowfullscreen"
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
title="<?php echo htmlspecialchars($playlistTitle); ?>">
</iframe>
<?php elseif ($platform === 'funkwhale'): ?>
<!-- Embed Funkwhale Playlist -->
<iframe
src="<?php echo rtrim($instanceUrl, '/'); ?>/front/embed.html?&type=playlist&id=<?php echo $playlistId; ?>"
frameborder="0"
allowfullscreen="allowfullscreen"
title="<?php echo htmlspecialchars($playlistTitle); ?>">
</iframe>
<?php elseif ($platform === 'castopod'): ?>
<!-- Embed Castopod Podcast -->
<iframe
src="<?php echo rtrim($instanceUrl, '/'); ?>/<?php echo $playlistId; ?>/embed/dark"
frameborder="0"
allowfullscreen="allowfullscreen"
title="<?php echo htmlspecialchars($playlistTitle); ?>">
</iframe>
<?php endif; ?>
</div>
<div class="hero-playlist-footer">
<a href="<?php
if ($platform === 'peertube') {
echo rtrim($instanceUrl, '/') . '/w/p/' . $playlistId;
} elseif ($platform === 'funkwhale') {
echo rtrim($instanceUrl, '/') . '/library/playlists/' . $playlistId;
} elseif ($platform === 'castopod') {
echo rtrim($instanceUrl, '/') . '/' . $playlistId;
}
?>" target="_blank" rel="noopener noreferrer" class="hero-playlist-link">
<i class="fas fa-external-link-alt"></i>
Voir sur <?php echo ucfirst($platform); ?>
</a>
</div>
</div>
<?php else: ?>
<div class="hero-no-live">
<i class="fas fa-list-music"></i>
<h2>Playlist non configurée</h2>
<p>Configurez PLAYLIST_INSTANCE_URL et PLAYLIST_ID dans votre fichier de configuration.</p>
</div>
<?php endif; ?>
<?php elseif ($heroType === 'video'): ?>
<!-- Mode VIDÉO -->
<?php
$videoId = defined('HERO_VIDEO_ID') ? HERO_VIDEO_ID : '';
$videoTitle = defined('HERO_VIDEO_TITLE') ? HERO_VIDEO_TITLE : 'Vidéo de présentation';
if (!empty($videoId)):
?>
<div class="hero-video-container">
<iframe
src="<?php echo PEERTUBE_URL; ?>/videos/embed/<?php echo $videoId; ?>"
frameborder="0"
allowfullscreen="allowfullscreen"
sandbox="allow-same-origin allow-scripts allow-popups"
allow="autoplay; fullscreen"
title="<?php echo htmlspecialchars($videoTitle); ?>">
</iframe>
</div>
<?php else: ?>
<div class="hero-no-live">
<i class="fas fa-video"></i>
<h2>Vidéo non configurée</h2>
<p>Configurez HERO_VIDEO_ID dans votre fichier de configuration.</p>
</div>
<?php endif; ?>
<?php endif; ?>
</section>
-94
View File
@@ -1,94 +0,0 @@
<?php
/**
* Fonction simple pour convertir du texte Markdown en HTML
* Adaptée pour PeerTube qui ne prend en charge qu'un sous-ensemble limité d'éléments:
* - Emphase (italique/gras)
* - Liens
* - Retours à la ligne
* - Listes
*
* @param string $markdown Le texte au format Markdown à convertir
* @return string Le texte converti en HTML
*/
function markdown_to_html($markdown) {
// Échapper tout le contenu pour éviter les injections XSS
$markdown = htmlspecialchars($markdown, ENT_QUOTES, 'UTF-8');
// Tableau pour stocker les liens convertis
$links = [];
$link_count = 0;
// Conversion des liens Markdown [texte](url)
$markdown = preg_replace_callback('/\[([^\]]+)\]\(([^)]+)\)/s', function($matches) use (&$links, &$link_count) {
$text = $matches[1];
$url = $matches[2];
// Assurer que l'URL est correctement formée
if (!preg_match('/^https?:\/\//i', $url)) {
// Ajouter http:// si l'URL ne commence pas par http:// ou https://
$url = 'http://' . $url;
}
$placeholder = "___LINK_{$link_count}___";
$links[$placeholder] = '<a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '" target="_blank" rel="noopener noreferrer">' . $text . '</a>';
$link_count++;
return $placeholder;
}, $markdown);
// Détection et conversion des URLs brutes en liens cliquables
// 1. URLs avec protocole http/https
$protocolUrlPattern = '/(https?:\/\/[^\s<]+[^\s<\.)])/i';
$markdown = preg_replace_callback($protocolUrlPattern, function($matches) use (&$links, &$link_count) {
$url = $matches[1];
$placeholder = "___LINK_{$link_count}___";
$links[$placeholder] = '<a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '" target="_blank" rel="noopener noreferrer">' . $url . '</a>';
$link_count++;
return $placeholder;
}, $markdown);
// 2. Domaines sans protocole (comme "o-k-i.net", "gong.gp", "NUVEL.NU")
// Détecte les domaines avec TLD communs qui ne font pas partie d'autre chose
$domainPattern = '/\b([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+([a-zA-Z]{2,63})\b/';
$markdown = preg_replace_callback($domainPattern, function($matches) use (&$links, &$link_count) {
$domain = $matches[0];
// Éviter de convertir des éléments qui ressemblent à des versions/numéros ou qui sont déjà dans des liens
if (preg_match('/^v?\d+\.\d+/', $domain) || strpos($domain, '___LINK_') !== false) {
return $domain;
}
$placeholder = "___LINK_{$link_count}___";
$links[$placeholder] = '<a href="http://' . htmlspecialchars($domain, ENT_QUOTES, 'UTF-8') . '" target="_blank" rel="noopener noreferrer">' . $domain . '</a>';
$link_count++;
return $placeholder;
}, $markdown);
// Conversion du texte en gras et italique (**texte** et *texte*)
$markdown = preg_replace('/\*\*(.*?)\*\*/s', '<strong>$1</strong>', $markdown);
$markdown = preg_replace('/\*(.*?)\*/s', '<em>$1</em>', $markdown);
// Conversion des listes à puces
$markdown = preg_replace('/^- (.*?)$/m', '<li>$1</li>', $markdown);
$markdown = preg_replace('/(<li>.*?<\/li>\n?)+/s', '<ul>$0</ul>', $markdown);
// Conversion des listes numérotées
$markdown = preg_replace('/^\d+\. (.*?)$/m', '<li>$1</li>', $markdown);
$markdown = preg_replace('/(<li>.*?<\/li>\n?)+/s', '<ol>$0</ol>', $markdown);
// Gestion des retours à la ligne
$markdown = nl2br($markdown);
// Nettoyage des balises br dans les listes
$markdown = preg_replace('/<\/li><br \/>/', '</li>', $markdown);
// Restaurer les liens
foreach ($links as $placeholder => $link) {
$markdown = str_replace($placeholder, $link, $markdown);
}
return $markdown;
}
-124
View File
@@ -1,124 +0,0 @@
<!-- Menu mobile (masqué par défaut) -->
<div class="mobile-menu">
<button class="mobile-menu-close">
<i class="fas fa-times"></i>
</button>
<div class="search-container">
<form action="recherche.php" method="get">
<input type="text" name="q" placeholder="Rechercher...">
<button type="submit"><i class="fas fa-search"></i></button>
</form>
</div>
<div>
<a href="index.php" class="nav-item <?php echo ($currentPage === 'index.php') ? 'active' : ''; ?>">
<i class="fas fa-home"></i> Accueil
</a>
<?php if (!defined('HERO_TYPE') || HERO_TYPE === 'live'): ?>
<a href="direct.php" class="nav-item <?php echo ($currentPage === 'direct.php') ? 'active' : ''; ?>">
<i class="fas fa-broadcast-tower"></i> Direct
</a>
<?php endif; ?>
<hr class="nav-divider">
<?php
// Afficher les catégories prioritaires
if (defined('PRIORITY_CATEGORIES') && !empty(PRIORITY_CATEGORIES)) {
// Tableau associatif des icônes pour les catégories
$categoryIcons = [
1 => 'fas fa-music', // Musique
2 => 'fas fa-film', // Films
3 => 'fas fa-car', // Véhicules
4 => 'fas fa-palette', // Jeux
5 => 'fas fa-running', // Sport
6 => 'fas fa-laugh', // Humour
7 => 'fas fa-gamepad', // Art
8 => 'fas fa-person', // Personnalités
9 => 'fas fa-face-grin-tears', // Comédie
10 => 'fas fa-tv', // Divertissement
11 => 'fas fa-globe', // Actualité & Politique
12 => 'fas fa-chalkboard-user', // Tutoriel
13 => 'fas fa-graduation-cap', // Education
14 => 'fas fa-fist-raised', // Activisme
15 => 'fas fa-microscope', // Science & Technologie
16 => 'fas fa-paw', // Animaux
17 => 'fas fa-child', // Enfants
18 => 'fas fa-utensils' // Cuisine
];
foreach (PRIORITY_CATEGORIES as $id => $name) {
$isActive = ($currentPage === 'categories.php' && $currentCategoryId === $id);
$icon = isset($categoryIcons[$id]) ? $categoryIcons[$id] : 'fas fa-folder';
echo '<a href="categories.php?id=' . $id . '" class="nav-item ' . ($isActive ? 'active' : '') . '">';
echo '<i class="' . $icon . '"></i> ' . htmlspecialchars($name);
echo '</a>';
}
}
?>
<hr class="nav-divider">
</div>
<div>
<h3 class="mobile-section-title">Hashtags populaires</h3>
<?php
if (defined('IMPORTANT_TAGS') && !empty(IMPORTANT_TAGS)):
foreach (IMPORTANT_TAGS as $tag):
$encodedTag = urlencode('#' . $tag);
$isActive = ($isTagSearch && strtolower($currentTag) === strtolower($tag));
?>
<a href="recherche.php?q=<?php echo $encodedTag; ?>" class="nav-item <?php echo $isActive ? 'active' : ''; ?>">
<i class="fas fa-hashtag"></i> <?php echo htmlspecialchars($tag); ?>
</a>
<?php
endforeach;
endif;
?>
</div>
<hr class="nav-divider">
<?php
// Vérifier s'il y a au moins un réseau social à afficher
$hasSocialNetworks = (!empty(MASTODON_URL) && MASTODON_URL !== '#') ||
(!empty(FACEBOOK_URL) && FACEBOOK_URL !== '#') ||
(!empty(INSTAGRAM_URL) && INSTAGRAM_URL !== '#') ||
(!empty(TIKTOK_URL) && TIKTOK_URL !== '#') ||
(!empty(YOUTUBE_URL) && YOUTUBE_URL !== '#') ||
(!empty(X_URL) && X_URL !== '#');
if ($hasSocialNetworks):
?>
<div>
<h3 class="mobile-section-title">Suivez-nous</h3>
<div class="mobile-social-icons">
<?php if (!empty(MASTODON_URL) && MASTODON_URL !== '#'): ?>
<a target="_blank" rel="me noreferrer" href="<?php echo MASTODON_URL; ?>"><i class="fab fa-mastodon icon-mastodon"></i></a>
<?php endif; ?>
<?php if (!empty(FACEBOOK_URL) && FACEBOOK_URL !== '#'): ?>
<a target="_blank" rel="noreferrer" href="<?php echo FACEBOOK_URL; ?>"><i class="fab fa-facebook icon-facebook"></i></a>
<?php endif; ?>
<?php if (!empty(INSTAGRAM_URL) && INSTAGRAM_URL !== '#'): ?>
<a target="_blank" rel="noreferrer" href="<?php echo INSTAGRAM_URL; ?>"><i class="fab fa-instagram icon-instagram"></i></a>
<?php endif; ?>
<?php if (!empty(TIKTOK_URL) && TIKTOK_URL !== '#'): ?>
<a target="_blank" rel="noreferrer" href="<?php echo TIKTOK_URL; ?>"><i class="fab fa-tiktok icon-tiktok"></i></a>
<?php endif; ?>
<?php if (!empty(YOUTUBE_URL) && YOUTUBE_URL !== '#'): ?>
<a target="_blank" rel="noreferrer" href="<?php echo YOUTUBE_URL; ?>"><i class="fab fa-youtube icon-youtube"></i></a>
<?php endif; ?>
<?php if (!empty(X_URL) && X_URL !== '#'): ?>
<a target="_blank" rel="noreferrer" href="<?php echo X_URL; ?>"><i class="fab fa-x-twitter icon-x"></i></a>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
</div>
-82
View File
@@ -1,82 +0,0 @@
<?php
// Fichier d'initialisation PWA à inclure dans toutes les pages
function addPWAHeaders() {
// Meta tags PWA
echo '<meta name="mobile-web-app-capable" content="yes">' . "\n";
echo '<meta name="apple-mobile-web-app-capable" content="yes">' . "\n";
echo '<meta name="apple-mobile-web-app-status-bar-style" content="default">' . "\n";
echo '<meta name="apple-mobile-web-app-title" content="' . SITE_NAME . '">' . "\n";
echo '<meta name="application-name" content="' . SITE_NAME . '">' . "\n";
echo '<meta name="msapplication-TileColor" content="#FF0000">' . "\n";
echo '<meta name="msapplication-config" content="browserconfig.xml">' . "\n";
echo '<meta name="theme-color" content="#FF0000">' . "\n";
// Manifest
echo '<link rel="manifest" href="site.webmanifest">' . "\n";
}
function addPWAScripts() {
?>
<!-- PWA Service Worker -->
<script nonce="<?php echo getCspNonce(); ?>">
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js')
.then(function(registration) {
console.log('Service Worker enregistré avec succès:', registration.scope);
// Écouter les mises à jour
registration.addEventListener('updatefound', function() {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', function() {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// Nouvelle version disponible
console.log('Nouvelle version disponible');
if (confirm('Une nouvelle version est disponible. Voulez-vous recharger la page ?')) {
window.location.reload();
}
}
});
});
})
.catch(function(err) {
console.log('Échec de l\'enregistrement du Service Worker:', err);
});
});
}
// Gestion de l'installation PWA
let deferredPrompt;
const installButton = document.getElementById('install-pwa');
window.addEventListener('beforeinstallprompt', function(e) {
e.preventDefault();
deferredPrompt = e;
// Afficher le bouton d'installation s'il existe
if (installButton) {
installButton.style.display = 'block';
installButton.addEventListener('click', function() {
deferredPrompt.prompt();
deferredPrompt.userChoice.then(function(choiceResult) {
if (choiceResult.outcome === 'accepted') {
console.log('PWA installée');
}
deferredPrompt = null;
installButton.style.display = 'none';
});
});
}
});
// Masquer le bouton après installation
window.addEventListener('appinstalled', function() {
console.log('PWA installée avec succès');
if (installButton) {
installButton.style.display = 'none';
}
});
</script>
<?php
}
?>
-29
View File
@@ -1,29 +0,0 @@
<?php
// Inclure la configuration si ce n'est pas déjà fait
if (!function_exists('getRecentVideos')) {
require_once __DIR__ . '/config.php';
}
// Récupérer les vidéos récentes depuis l'API PeerTube
$recentVideos = getRecentVideos();
// Affichage des vidéos
foreach ($recentVideos as $video):
?>
<div class="video-card" data-video-id="<?php echo $video['id']; ?>">
<div class="video-thumbnail">
<img src="<?php echo $video['thumbnail']; ?>" alt="<?php echo $video['title']; ?>" data-src="<?php echo $video['thumbnail']; ?>">
<div class="video-duration"><?php echo formatDuration($video['duration']); ?></div>
</div>
<div class="video-info">
<h3 class="video-title"><?php echo $video['title']; ?></h3>
<div class="video-channel"><?php echo $video['channel']; ?></div>
<div class="video-metadata">
<?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?>
<span class="video-views"><?php echo formatViewCount($video['views']); ?> vues</span>
<?php endif; ?>
<span class="video-date"><?php echo formatDate($video['date']); ?></span>
</div>
</div>
</div>
<?php endforeach; ?>
-327
View File
@@ -1,327 +0,0 @@
<?php
/**
* Fonctions de sécurité pour la validation et l'assainissement des entrées
*/
/**
* Valide et assainit un ID de vidéo UUID
*
* @param string $id ID à valider
* @return string|false ID validé ou false si invalide
*/
function validateVideoId($id) {
if (empty($id)) {
return false;
}
// Nettoyer l'entrée
$id = trim($id);
// Vérifier le format UUID (format PeerTube)
if (!preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i', $id)) {
return false;
}
return $id;
}
/**
* Valide et assainit une requête de recherche
*
* @param string $query Requête à valider
* @return string|false Requête validée ou false si invalide
*/
function validateSearchQuery($query) {
if (empty($query)) {
return false;
}
// Nettoyer l'entrée
$query = trim($query);
// Limiter la longueur
if (strlen($query) > 200) {
return false;
}
// Supprimer les caractères dangereux mais garder les caractères utiles pour la recherche
$query = preg_replace('/[<>"\']/', '', $query);
return $query;
}
/**
* Valide un numéro de page
*
* @param mixed $page Page à valider
* @return int Page validée (minimum 1)
*/
function validatePageNumber($page) {
$page = intval($page);
return max(1, $page);
}
/**
* Valide un ID de catégorie
*
* @param mixed $categoryId ID de catégorie à valider
* @return int|false ID validé ou false si invalide
*/
function validateCategoryId($categoryId) {
$categoryId = intval($categoryId);
// Les IDs de catégorie PeerTube sont entre 1 et 20
if ($categoryId < 1 || $categoryId > 20) {
return false;
}
return $categoryId;
}
/**
* Valide et assainit un User-Agent
*
* @param string $userAgent User-Agent à valider
* @return bool True si valide
*/
function validateUserAgent($userAgent) {
if (empty($userAgent)) {
return false;
}
// Bloquer les User-Agents suspects
$blockedPatterns = [
'/curl/i',
'/wget/i',
'/python/i',
'/bot/i',
'/scanner/i',
'/sqlmap/i'
];
foreach ($blockedPatterns as $pattern) {
if (preg_match($pattern, $userAgent)) {
return false;
}
}
return true;
}
/**
* Valide les en-têtes HTTP pour détecter les tentatives d'attaque
*
* @return bool True si les en-têtes sont sûrs
*/
function validateHttpHeaders() {
// Vérifier le User-Agent
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
if (!validateUserAgent($userAgent)) {
error_log('SECURITY: Suspicious User-Agent detected: ' . $userAgent);
return false;
}
// Vérifier les en-têtes suspects
$suspiciousHeaders = [
'HTTP_X_FORWARDED_FOR',
'HTTP_X_REAL_IP',
'HTTP_CLIENT_IP'
];
foreach ($suspiciousHeaders as $header) {
if (isset($_SERVER[$header])) {
$value = $_SERVER[$header];
// Bloquer les IPs privées dans les en-têtes de forwarding
if (filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
error_log('SECURITY: Suspicious IP in header ' . $header . ': ' . $value);
}
}
}
return true;
}
/**
* Génère un token CSRF sécurisé
*
* @return string Token CSRF
*/
function generateCSRFToken() {
// Démarrer la session seulement si les en-têtes n'ont pas été envoyés
if (session_status() === PHP_SESSION_NONE && !headers_sent()) {
session_start();
}
if (!isset($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
/**
* Valide un token CSRF
*
* @param string $token Token à valider
* @return bool True si le token est valide
*/
function validateCSRFToken($token) {
if (session_status() === PHP_SESSION_NONE && !headers_sent()) {
session_start();
}
if (!isset($_SESSION['csrf_token'])) {
return false;
}
return hash_equals($_SESSION['csrf_token'], $token);
}
/**
* Génère ou récupère le nonce CSP de la requête courante.
* Doit être appelé après setSecurityHeaders() pour que le nonce soit envoyé dans le header CSP.
*
* @return string Nonce CSP
*/
function getCspNonce() {
if (!isset($GLOBALS['csp_nonce'])) {
$GLOBALS['csp_nonce'] = base64_encode(random_bytes(16));
}
return $GLOBALS['csp_nonce'];
}
/**
* Applique des en-têtes de sécurité HTTP
*/
function setSecurityHeaders() {
// Protection contre le clickjacking (permettre les iframes du même site)
header('X-Frame-Options: SAMEORIGIN');
// Protection contre le MIME sniffing
header('X-Content-Type-Options: nosniff');
// Protection XSS basique
header('X-XSS-Protection: 1; mode=block');
// Politique de référent
header('Referrer-Policy: strict-origin-when-cross-origin');
// Isolation cross-origin
header('Cross-Origin-Resource-Policy: same-origin');
header('Cross-Origin-Opener-Policy: same-origin');
// COEP retiré : require-corp bloque les iframes/images/vidéos cross-origin (PeerTube, Mastodon, Castopod, Funkwhale)
// car ces services ne renvoient pas le header CORP approprié.
// Permissions Policy (feature policy)
header('Permissions-Policy: accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(self), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(self), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()');
// Content Security Policy avec support Mastodon et PeerTube
$nonce = getCspNonce();
$mastodonDomain = '';
$peertubeDomain = '';
// Extraire le domaine Mastodon si configuré
if (defined('MASTODON_INSTANCE_URL')) {
$mastodonParsed = parse_url(MASTODON_INSTANCE_URL);
if ($mastodonParsed && isset($mastodonParsed['host'])) {
$mastodonDomain = $mastodonParsed['scheme'] . '://' . $mastodonParsed['host'];
}
}
// Extraire le domaine PeerTube si configuré
if (defined('PEERTUBE_URL')) {
$peertubeParsed = parse_url(PEERTUBE_URL);
if ($peertubeParsed && isset($peertubeParsed['host'])) {
$peertubeDomain = $peertubeParsed['scheme'] . '://' . $peertubeParsed['host'];
}
}
// Détecter si on est en développement local
$isLocalDev = in_array($_SERVER['HTTP_HOST'] ?? '', ['127.0.0.1:8080', '127.0.0.1:8001', 'localhost:8080', 'localhost:8001', '127.0.0.1', 'localhost']);
$csp = "default-src 'self'; ";
$csp .= "style-src 'self' 'nonce-{$nonce}' https://cdnjs.cloudflare.com; ";
$csp .= "script-src 'self' 'nonce-{$nonce}' https://cdnjs.cloudflare.com https://plausible.io; ";
// Images : autoriser les domaines connus plus HTTPS général pour le contenu fédéré
$imgSrc = "'self' data: " . ($mastodonDomain ? $mastodonDomain : '') . " " . ($peertubeDomain ? $peertubeDomain : '');
if ($isLocalDev) {
$imgSrc .= " https: http:";
} else {
$imgSrc .= " https:";
}
$csp .= "img-src " . $imgSrc . "; ";
$csp .= "font-src 'self' https://cdnjs.cloudflare.com; ";
// Frames : autoriser PeerTube uniquement
$frameSrc = "'self' " . ($peertubeDomain ? $peertubeDomain : '');
if ($isLocalDev) {
$frameSrc .= " https: http:";
}
$csp .= "frame-src " . $frameSrc . "; ";
// Connexions : autoriser Mastodon, PeerTube et Plausible
$connectSrc = "'self' https://plausible.io " . ($mastodonDomain ? $mastodonDomain : '') . " " . ($peertubeDomain ? $peertubeDomain : '');
if ($isLocalDev) {
$connectSrc .= " ws: wss:"; // WebSockets pour le dev
}
$csp .= "connect-src " . $connectSrc . "; ";
// Médias : autoriser 'self', Mastodon, PeerTube et S3 Mastodon
$mediaSrc = "'self'";
if ($mastodonDomain) {
$mediaSrc .= " " . $mastodonDomain;
}
if ($peertubeDomain) {
$mediaSrc .= " " . $peertubeDomain;
}
// Ajouter l'URL S3 Mastodon si configurée (pour les médias externalisés)
if (defined('MASTODON_S3_MEDIA_URL') && !empty(MASTODON_S3_MEDIA_URL)) {
$s3Parsed = parse_url(MASTODON_S3_MEDIA_URL);
if ($s3Parsed && isset($s3Parsed['host'])) {
$s3Domain = $s3Parsed['scheme'] . '://' . $s3Parsed['host'];
$mediaSrc .= " " . $s3Domain;
}
}
if ($isLocalDev) {
$mediaSrc .= " https: http:";
} else {
$mediaSrc .= " https:";
}
$csp .= "media-src " . $mediaSrc . "; ";
$csp .= "object-src 'none'; ";
$csp .= "base-uri 'self'; ";
$csp .= "frame-ancestors 'self';";
header('Content-Security-Policy: ' . $csp);
// HTTPS strict transport security (seulement si HTTPS)
if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') {
header('Strict-Transport-Security: max-age=63072000; includeSubDomains; preload');
}
}
/**
* Valide l'origine de la requête pour les requêtes AJAX
*
* @return bool True si l'origine est valide
*/
function validateAjaxOrigin() {
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$host = $_SERVER['HTTP_HOST'] ?? '';
if (empty($origin) || empty($host)) {
return false;
}
$expectedOrigin = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . $host;
return $origin === $expectedOrigin;
}
?>
-91
View File
@@ -1,91 +0,0 @@
<!-- Sidebar de navigation -->
<nav class="sidebar" role="navigation" aria-label="Navigation principale">
<a href="/" class="logo" aria-label="Retour à l'accueil">
<img src="img/logo.png" alt="Logo <?php echo SITE_NAME; ?>">
</a>
<?php
// Détecter la page courante et ses paramètres
$currentPage = basename($_SERVER['PHP_SELF']);
$currentCategoryId = isset($_GET['id']) ? intval($_GET['id']) : null;
$currentQuery = isset($_GET['q']) ? trim($_GET['q']) : '';
$isTagSearch = !empty($currentQuery) && substr($currentQuery, 0, 1) === '#';
$currentTag = $isTagSearch ? substr($currentQuery, 1) : '';
?>
<div class="sidebar-nav">
<a href="/" class="nav-item <?php echo ($currentPage === 'index.php') ? 'active' : ''; ?>" data-title="Accueil" aria-current="<?php echo ($currentPage === 'index.php') ? 'page' : 'false'; ?>">
<i class="fas fa-home" aria-hidden="true"></i> <span>Accueil</span>
</a>
<?php if (!defined('HERO_TYPE') || HERO_TYPE === 'live'): ?>
<a href="direct.php" class="nav-item <?php echo ($currentPage === 'direct.php') ? 'active' : ''; ?>" data-title="Direct" aria-current="<?php echo ($currentPage === 'direct.php') ? 'page' : 'false'; ?>">
<i class="fas fa-broadcast-tower" aria-hidden="true"></i> <span>Direct</span>
</a>
<?php endif; ?>
<?php if (defined('DONATIONS_ENABLED') && DONATIONS_ENABLED): ?>
<a href="dons.php" class="nav-item donation-nav-link <?php echo ($currentPage === 'dons.php') ? 'active' : ''; ?>" data-title="Soutenir" aria-current="<?php echo ($currentPage === 'dons.php') ? 'page' : 'false'; ?>">
<i class="fas fa-heart" aria-hidden="true"></i> <span>Soutenir</span>
</a>
<?php endif; ?>
<div class="nav-divider"></div>
<?php
// Afficher les catégories prioritaires
if (defined('PRIORITY_CATEGORIES') && !empty(PRIORITY_CATEGORIES)) {
// Tableau associatif des icônes pour les catégories
$categoryIcons = [
1 => 'fas fa-music', // Musique
2 => 'fas fa-film', // Films
3 => 'fas fa-car', // Véhicules
4 => 'fas fa-palette', // Jeux
5 => 'fas fa-running', // Sport
6 => 'fas fa-laugh', // Humour
7 => 'fas fa-gamepad', // Art
8 => 'fas fa-person', // Personnalités
9 => 'fas fa-face-grin-tears', // Comédie
10 => 'fas fa-tv', // Divertissement
11 => 'fas fa-globe', // Actualité & Politique
12 => 'fas fa-chalkboard-user', // Tutoriel
13 => 'fas fa-graduation-cap', // Education
14 => 'fas fa-fist-raised', // Activisme
15 => 'fas fa-microscope', // Science & Technologie
16 => 'fas fa-paw', // Animaux
17 => 'fas fa-child', // Enfants
18 => 'fas fa-utensils' // Cuisine
];
foreach (PRIORITY_CATEGORIES as $id => $name) {
$isActive = ($currentPage === 'categories.php' && $currentCategoryId === $id);
$icon = isset($categoryIcons[$id]) ? $categoryIcons[$id] : 'fas fa-folder';
echo '<a href="categories.php?id=' . $id . '" class="nav-item ' . ($isActive ? 'active' : '') . '" data-title="' . htmlspecialchars($name) . '" aria-current="' . ($isActive ? 'page' : 'false') . '">';
echo '<i class="' . $icon . '" aria-hidden="true"></i> <span>' . htmlspecialchars($name) . '</span>';
echo '</a>';
}
}
?>
<div class="nav-divider"></div>
<div class="category-title" role="heading" aria-level="2">
<i class="fas fa-hashtag" aria-hidden="true"></i> <span>Hashtags</span>
</div>
<?php
if (defined('IMPORTANT_TAGS') && !empty(IMPORTANT_TAGS)):
foreach (IMPORTANT_TAGS as $tag):
$encodedTag = urlencode('#' . $tag);
$isActive = ($isTagSearch && strtolower($currentTag) === strtolower($tag));
?>
<a href="recherche.php?q=<?php echo $encodedTag; ?>" class="tag-item <?php echo $isActive ? 'active' : ''; ?>" data-title="<?php echo htmlspecialchars($tag); ?>" aria-current="<?php echo $isActive ? 'page' : 'false'; ?>">
<i class="fas fa-hashtag tag-icon" aria-hidden="true"></i> <span><?php echo htmlspecialchars($tag); ?></span>
</a>
<?php
endforeach;
endif;
?>
</div>
</nav>
-187
View File
@@ -1,187 +0,0 @@
<?php
/**
* Cache simple pour les API PeerTube - optimisation minimale
*/
class SimpleAPICache {
private $cacheDir;
private $enabled;
public function __construct() {
$this->cacheDir = __DIR__ . '/../cache/api';
$this->enabled = true;
// Créer le répertoire de cache s'il n'existe pas
if (!is_dir($this->cacheDir)) {
mkdir($this->cacheDir, 0755, true);
}
}
/**
* Génère une clé de cache
*/
private function getCacheKey($endpoint, $params = []) {
$key = $endpoint;
if (!empty($params)) {
ksort($params);
$key .= '_' . md5(serialize($params));
}
return 'cache_' . md5($key) . '.json';
}
/**
* Récupère depuis le cache
*/
public function get($endpoint, $params = []) {
if (!$this->enabled) return null;
$file = $this->cacheDir . '/' . $this->getCacheKey($endpoint, $params);
if (!file_exists($file)) {
return null;
}
$data = json_decode(file_get_contents($file), true);
// Vérifier l'expiration
if (isset($data['expires']) && time() > $data['expires']) {
unlink($file);
return null;
}
return $data['content'] ?? null;
}
/**
* Stocke dans le cache
*/
public function set($endpoint, $params, $content, $ttl = 300) {
if (!$this->enabled) return;
$file = $this->cacheDir . '/' . $this->getCacheKey($endpoint, $params);
$data = [
'content' => $content,
'expires' => time() + $ttl,
'created' => time()
];
file_put_contents($file, json_encode($data));
}
/**
* Nettoie le cache expiré
*/
public function cleanup() {
$files = glob($this->cacheDir . '/cache_*.json');
$cleaned = 0;
foreach ($files as $file) {
$data = json_decode(file_get_contents($file), true);
if (isset($data['expires']) && time() > $data['expires']) {
unlink($file);
$cleaned++;
}
}
return $cleaned;
}
}
// Instance globale
$GLOBALS['simple_api_cache'] = new SimpleAPICache();
/**
* Version cachée de callPeerTubeApi - remplace l'originale
*/
function callPeerTubeApiCached($endpoint, $params = []) {
$cache = $GLOBALS['simple_api_cache'];
// TTL selon le type de contenu
$ttlMap = [
'videos/categories' => 3600, // 1 heure
'videos' => 600, // 10 minutes (augmenté)
'search/videos' => 600, // 10 minutes
'wp-posts' => 900, // 15 minutes pour WordPress
'accounts' => 300 // 5 minutes pour live streams
];
// TTL dynamique selon l'endpoint
$ttl = 300; // Défaut 5 minutes
foreach ($ttlMap as $pattern => $time) {
if (strpos($endpoint, $pattern) !== false) {
$ttl = $time;
break;
}
}
// Essayer le cache d'abord
$cached = $cache->get($endpoint, $params);
if ($cached !== null) {
return $cached;
}
// Appeler l'API originale
$data = callPeerTubeApiOriginal($endpoint, $params);
// Mettre en cache si on a des données
if (!empty($data)) {
$cache->set($endpoint, $params, $data, $ttl);
}
return $data;
}
/**
* Cache générique pour tout type d'API
*/
function callApiCached($cacheKey, $callable, $ttl = 900) {
$cache = $GLOBALS['simple_api_cache'];
// Essayer le cache d'abord
$cached = $cache->get($cacheKey, []);
if ($cached !== null) {
return $cached;
}
// Appeler la fonction fournie
try {
$data = call_user_func($callable);
} catch (Exception $e) {
error_log('API call failed for ' . $cacheKey . ': ' . $e->getMessage());
return [];
}
// Mettre en cache si on a des données
if (!empty($data)) {
$cache->set($cacheKey, [], $data, $ttl);
}
return $data;
}
/**
* Helpers simples pour le cache générique
*/
function getFromCache($key) {
if (!defined('CACHE_ENABLED') || !CACHE_ENABLED) {
return null;
}
$cache = $GLOBALS['simple_api_cache'];
return $cache->get($key, []);
}
function saveToCache($key, $data, $ttl = null) {
if (!defined('CACHE_ENABLED') || !CACHE_ENABLED) {
return;
}
$ttl = $ttl ?? (defined('CACHE_DURATION') ? CACHE_DURATION : 3600);
$cache = $GLOBALS['simple_api_cache'];
$cache->set($key, [], $data, $ttl);
}
// Nettoyage automatique occasionnel
if (rand(1, 100) === 1) {
$GLOBALS['simple_api_cache']->cleanup();
}
?>
-320
View File
@@ -1,320 +0,0 @@
<?php
/**
* Fonctions pour générer des données structurées JSON-LD
* pour améliorer le SEO et l'affichage dans les moteurs de recherche
*/
/**
* Génère le JSON-LD pour un objet WebSite
*
* @return string JSON-LD pour le site web
*/
function generateWebSiteJsonLd() {
$baseUrl = getBaseUrl();
$data = [
"@context" => "https://schema.org",
"@type" => "WebSite",
"name" => SITE_NAME,
"description" => SITE_DESCRIPTION,
"url" => $baseUrl,
"potentialAction" => [
"@type" => "SearchAction",
"target" => [
"@type" => "EntryPoint",
"urlTemplate" => $baseUrl . "/recherche.php?q={search_term_string}"
],
"query-input" => "required name=search_term_string"
],
"publisher" => [
"@type" => "Organization",
"name" => "OKI",
"url" => $baseUrl,
"logo" => [
"@type" => "ImageObject",
"url" => $baseUrl . "/img/logo.png"
]
]
];
return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
}
/**
* Génère le JSON-LD pour un objet VideoObject
*
* @param array $videoData Données de la vidéo depuis l'API PeerTube
* @param array $video Données formatées de la vidéo
* @return string JSON-LD pour la vidéo
*/
function generateVideoObjectJsonLd($videoData, $video) {
$baseUrl = getBaseUrl();
$videoUrl = $baseUrl . "/video.php?id=" . $video['id'];
// Construire l'URL de la vignette
$thumbnailUrl = isset($videoData['thumbnailPath'])
? PEERTUBE_URL . $videoData['thumbnailPath']
: $baseUrl . "/img/default-thumbnail.jpg";
// Formater la durée en format ISO 8601 (PT1H30M pour 1h30min)
$duration = formatDurationISO8601($video['duration'] ?? 0);
// Construire les données de base
$data = [
"@context" => "https://schema.org",
"@type" => "VideoObject",
"name" => $video['title'],
"description" => !empty($video['description'])
? truncateText(strip_tags($video['description']), 300)
: "Regardez cette vidéo sur " . SITE_NAME,
"url" => $videoUrl,
"thumbnailUrl" => $thumbnailUrl,
"uploadDate" => formatDateISO8601($video['date']),
"duration" => $duration,
"publisher" => [
"@type" => "Organization",
"name" => SITE_NAME,
"url" => $baseUrl,
"logo" => [
"@type" => "ImageObject",
"url" => $baseUrl . "/img/logo.png"
]
]
];
// Ajouter les informations de la chaîne/créateur
if (!empty($video['channel'])) {
$data["creator"] = [
"@type" => "Person",
"name" => $video['channel'],
"url" => PEERTUBE_URL . "/c/" . ($videoData['channel']['name'] ?? '')
];
}
// Ajouter les statistiques d'interaction
if (isset($video['views']) && $video['views'] > 0) {
$data["interactionStatistic"] = [
"@type" => "InteractionCounter",
"interactionType" => [
"@type" => "WatchAction"
],
"userInteractionCount" => $video['views']
];
}
// Ajouter les likes si disponibles
if (isset($video['likes']) && $video['likes'] > 0) {
if (!isset($data["interactionStatistic"])) {
$data["interactionStatistic"] = [];
} else {
// Convertir en array si c'était un seul élément
if (isset($data["interactionStatistic"]["@type"])) {
$data["interactionStatistic"] = [$data["interactionStatistic"]];
}
}
$data["interactionStatistic"][] = [
"@type" => "InteractionCounter",
"interactionType" => [
"@type" => "LikeAction"
],
"userInteractionCount" => $video['likes']
];
}
// Ajouter les tags/mots-clés si disponibles
if (!empty($video['tags'])) {
$data["keywords"] = implode(", ", $video['tags']);
}
// Ajouter les informations de licence si disponibles
if (isset($videoData['licence']) && !empty($videoData['licence'])) {
$licenceId = $videoData['licence']['id'];
$licenceMapping = [
1 => "https://creativecommons.org/licenses/by/4.0/",
2 => "https://creativecommons.org/licenses/by-sa/4.0/",
3 => "https://creativecommons.org/licenses/by-nd/4.0/",
4 => "https://creativecommons.org/licenses/by-nc/4.0/",
5 => "https://creativecommons.org/licenses/by-nc-sa/4.0/",
6 => "https://creativecommons.org/licenses/by-nc-nd/4.0/",
7 => "https://creativecommons.org/publicdomain/zero/1.0/"
];
if (isset($licenceMapping[$licenceId])) {
$data["license"] = $licenceMapping[$licenceId];
}
}
// Ajouter la catégorie si disponible
if (isset($videoData['category']) && !empty($videoData['category'])) {
$data["genre"] = $videoData['category']['label'] ?? 'Vidéo';
}
// Ajouter les dimensions si c'est un short (format portrait)
if (isset($video['aspectRatio']) && $video['aspectRatio'] <= 1) {
$data["videoFrameSize"] = "Portrait";
}
return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
}
/**
* Génère le JSON-LD pour les fils d'Ariane (BreadcrumbList)
*
* @param array $breadcrumbs Tableau des fils d'Ariane [['name' => 'Nom', 'url' => 'URL']]
* @return string JSON-LD pour les fils d'Ariane
*/
function generateBreadcrumbJsonLd($breadcrumbs) {
$baseUrl = getBaseUrl();
$listItems = [];
foreach ($breadcrumbs as $index => $crumb) {
$listItems[] = [
"@type" => "ListItem",
"position" => $index + 1,
"name" => $crumb['name'],
"item" => $crumb['url']
];
}
$data = [
"@context" => "https://schema.org",
"@type" => "BreadcrumbList",
"itemListElement" => $listItems
];
return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
}
/**
* Génère le JSON-LD pour une page de collection de vidéos
*
* @param string $name Nom de la collection
* @param string $description Description de la collection
* @param array $videos Tableau des vidéos
* @param string $url URL de la page de collection
* @return string JSON-LD pour la collection
*/
function generateVideoCollectionJsonLd($name, $description, $videos, $url) {
$baseUrl = getBaseUrl();
$videoItems = [];
foreach ($videos as $video) {
$videoItems[] = [
"@type" => "VideoObject",
"name" => $video['title'],
"url" => $baseUrl . "/video.php?id=" . $video['id'],
"thumbnailUrl" => $video['thumbnail'],
"uploadDate" => formatDateISO8601($video['date']),
"duration" => formatDurationISO8601($video['duration'] ?? 0)
];
}
$data = [
"@context" => "https://schema.org",
"@type" => "CollectionPage",
"name" => $name,
"description" => $description,
"url" => $url,
"mainEntity" => [
"@type" => "ItemList",
"itemListElement" => $videoItems,
"numberOfItems" => count($videos)
],
"publisher" => [
"@type" => "Organization",
"name" => SITE_NAME,
"url" => $baseUrl
]
];
return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
}
/**
* Formate une durée en secondes au format ISO 8601 (PTnHnMnS)
*
* @param int $seconds Durée en secondes
* @return string Durée au format ISO 8601
*/
function formatDurationISO8601($seconds) {
$hours = floor($seconds / 3600);
$minutes = floor(($seconds % 3600) / 60);
$remainingSeconds = $seconds % 60;
$duration = 'PT';
if ($hours > 0) {
$duration .= $hours . 'H';
}
if ($minutes > 0) {
$duration .= $minutes . 'M';
}
if ($remainingSeconds > 0 || ($hours === 0 && $minutes === 0)) {
$duration .= $remainingSeconds . 'S';
}
return $duration;
}
/**
* Formate une date au format ISO 8601
*
* @param string $dateString Date à formater
* @return string Date au format ISO 8601
*/
function formatDateISO8601($dateString) {
try {
$date = new DateTime($dateString);
return $date->format('c'); // Format ISO 8601
} catch (Exception $e) {
// En cas d'erreur, retourner la date actuelle
return (new DateTime())->format('c');
}
}
/**
* Tronque un texte à une longueur donnée
*
* @param string $text Texte à tronquer
* @param int $length Longueur maximale
* @return string Texte tronqué
*/
function truncateText($text, $length = 200) {
if (strlen($text) <= $length) {
return $text;
}
$truncated = substr($text, 0, $length);
$lastSpace = strrpos($truncated, ' ');
if ($lastSpace !== false) {
$truncated = substr($truncated, 0, $lastSpace);
}
return $truncated . '...';
}
/**
* Obtient l'URL de base du site
*
* @return string URL de base
*/
function getBaseUrl() {
$scheme = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
return $scheme . '://' . $host;
}
/**
* Génère et affiche un script JSON-LD
*
* @param string $jsonLd Données JSON-LD
*/
function outputJsonLd($jsonLd) {
$nonce = getCspNonce();
echo '<script type="application/ld+json" nonce="' . $nonce . '">' . "\n" . $jsonLd . "\n" . '</script>' . "\n";
}
?>
-202
View File
@@ -1,202 +0,0 @@
<?php
/**
* Fonctions pour l'intégration WordPress via l'API REST
*/
/**
* Appelle l'API WordPress REST pour récupérer les articles
*
* @param string $endpoint Point de terminaison de l'API (ex: 'posts')
* @param array $params Paramètres optionnels pour la requête
* @return array Données retournées par l'API
*/
function callWordPressApi($endpoint, $params = []) {
// Vérifier que WordPress est activé et configuré
if (!defined('WORDPRESS_ENABLED') || !WORDPRESS_ENABLED || empty(WORDPRESS_URL)) {
return [];
}
// Validation de l'URL WordPress pour prévenir SSRF
if (!isValidWordPressUrl(WORDPRESS_URL)) {
error_log('SECURITY: Invalid WordPress URL detected: ' . WORDPRESS_URL);
return [];
}
// Construire l'URL de l'API
$url = WORDPRESS_URL . '/wp-json/wp/v2/' . ltrim($endpoint, '/');
// Ajouter les paramètres à l'URL
if (!empty($params)) {
$url .= '?' . http_build_query($params);
}
// Initialiser cURL avec options de sécurité
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
curl_setopt($ch, CURLOPT_MAXREDIRS, 0);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
curl_setopt($ch, CURLOPT_USERAGENT, 'fediverse-oki-WordPress-Integration/1.0');
// Exécuter la requête
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
$error = curl_error($ch);
curl_close($ch);
// Traiter la réponse
if ($response === false || !empty($error)) {
error_log('WordPress API error: ' . $error);
return [];
}
if ($httpCode < 200 || $httpCode >= 300) {
error_log('WordPress API HTTP error: ' . $httpCode . ' for URL: ' . $url);
return [];
}
// Validation du Content-Type
if (!str_contains($contentType, 'application/json')) {
error_log('WordPress API error: Invalid content type: ' . $contentType . ' for URL: ' . $url);
return [];
}
// Décoder la réponse JSON
$data = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
error_log('WordPress API JSON decode error: ' . json_last_error_msg() . ' for URL: ' . $url);
return [];
}
return $data ?: [];
}
/**
* Valide l'URL WordPress pour prévenir les attaques SSRF
*
* @param string $url URL à valider
* @return bool True si l'URL est valide et sûre
*/
function isValidWordPressUrl($url) {
// Vérifier que l'URL est bien formée
$parsed = parse_url($url);
if (!$parsed || !isset($parsed['scheme']) || !isset($parsed['host'])) {
return false;
}
// Autoriser uniquement HTTPS (ou HTTP en développement)
if (!in_array($parsed['scheme'], ['https', 'http'])) {
return false;
}
// Bloquer les adresses IP privées et locales
$host = $parsed['host'];
if (filter_var($host, FILTER_VALIDATE_IP)) {
if (!filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return false;
}
}
// Bloquer localhost et autres domaines dangereux
$blockedHosts = ['localhost', '127.0.0.1', '::1', '0.0.0.0', 'metadata.google.internal'];
if (in_array(strtolower($host), $blockedHosts)) {
return false;
}
return true;
}
/**
* Récupère les articles WordPress récents
*
* @param int $count Nombre d'articles à récupérer
* @return array Liste des articles formatés
*/
function getWordPressPosts($count = null) {
if ($count === null) {
$count = defined('WORDPRESS_POSTS_COUNT') ? WORDPRESS_POSTS_COUNT : 6;
}
// Utiliser le cache pour les posts WordPress
return callApiCached('wp-posts-' . $count, function() use ($count) {
// Récupérer les posts via l'API WordPress REST
$posts = callWordPressApi('posts', [
'per_page' => $count,
'_embed' => 'true' // Inclure les médias embarqués (featured image)
]);
return formatWordPressPosts($posts);
}, 900); // 15 minutes de cache
}
/**
* Formate les données des articles WordPress
*
* @param array $posts Données brutes des articles
* @return array Articles formatés
*/
function formatWordPressPosts($posts) {
if (empty($posts)) {
return [];
}
$formattedPosts = [];
foreach ($posts as $post) {
// Récupérer l'image mise en avant
$featuredImage = '';
if (isset($post['_embedded']['wp:featuredmedia'][0]['source_url'])) {
$featuredImage = $post['_embedded']['wp:featuredmedia'][0]['source_url'];
}
// Nettoyer l'extrait HTML
$excerpt = '';
if (isset($post['excerpt']['rendered'])) {
$excerpt = wp_strip_all_tags($post['excerpt']['rendered']);
$excerpt = trim($excerpt);
// Limiter à 150 caractères
if (strlen($excerpt) > 150) {
$excerpt = substr($excerpt, 0, 147) . '...';
}
}
// Formater la date
$date = '';
if (isset($post['date'])) {
$dateObj = new DateTime($post['date']);
$date = $dateObj->format('d/m/Y');
}
$formattedPosts[] = [
'id' => $post['id'],
'title' => html_entity_decode($post['title']['rendered'], ENT_QUOTES, 'UTF-8'),
'excerpt' => $excerpt,
'featured_image' => $featuredImage,
'link' => $post['link'],
'date' => $date,
'author' => isset($post['_embedded']['author'][0]['name']) ? $post['_embedded']['author'][0]['name'] : ''
];
}
return $formattedPosts;
}
/**
* Fonction utilitaire pour nettoyer le HTML (similaire à wp_strip_all_tags de WordPress)
*
* @param string $string Chaîne à nettoyer
* @return string Chaîne nettoyée
*/
function wp_strip_all_tags($string) {
$string = preg_replace('@<(script|style)[^>]*?>.*?</\\1>@si', '', $string);
$string = strip_tags($string);
$string = preg_replace('/\s+/', ' ', $string);
return trim($string);
}
?>
-747
View File
@@ -1,747 +0,0 @@
<?php
// Inclure la configuration
require_once 'includes/config.php';
// Vérifier si le mode compte à rebours est activé
if (defined('COUNTDOWN_ENABLED') && COUNTDOWN_ENABLED === true) {
// Rediriger vers la page de compte à rebours
require_once 'countdown.php';
exit;
}
// Inclure les fonctions de données structurées
require_once 'includes/structured-data.php';
// Appliquer les en-têtes de sécurité
setSecurityHeaders();
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="<?php echo generateCSRFToken(); ?>">
<title><?php echo SITE_NAME; ?></title>
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
<link rel="stylesheet" href="css/mastodon-timeline.min.css?v=<?php echo filemtime('css/mastodon-timeline.min.css'); ?>">
<?php if (defined('WORDPRESS_ENABLED') && WORDPRESS_ENABLED): ?>
<link rel="stylesheet" href="css/wordpress-posts.css?v=<?php echo filemtime('css/wordpress-posts.css'); ?>">
<?php endif; ?>
<?php if (defined('CASTOPOD_ENABLED') && CASTOPOD_ENABLED): ?>
<link rel="stylesheet" href="css/castopod-podcasts.css?v=<?php echo filemtime('css/castopod-podcasts.css'); ?>">
<?php endif; ?>
<?php if (defined('FUNKWHALE_ENABLED') && FUNKWHALE_ENABLED): ?>
<link rel="stylesheet" href="css/funkwhale-tracks.css?v=<?php echo filemtime('css/funkwhale-tracks.css'); ?>">
<?php endif; ?>
<link rel="stylesheet" href="css/grid-layout.css?v=<?php echo filemtime('css/grid-layout.css'); ?>">
<!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="img/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png">
<link rel="manifest" href="site.webmanifest">
<link rel="icon" type="image/x-icon" href="img/favicon.ico">
<meta name="theme-color" content="#FF0000">
<!-- PWA Meta Tags -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="<?php echo SITE_NAME; ?>">
<meta name="application-name" content="<?php echo SITE_NAME; ?>">
<meta name="msapplication-TileColor" content="#FF0000">
<meta name="msapplication-config" content="browserconfig.xml">
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="<?php echo SITE_NAME; ?>">
<meta property="og:description" content="Découvrez notre plateforme multimédia avec des vidéos, des shorts et des directs. Tendances, catégories et contenus exclusifs vous attendent.">
<meta property="og:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
<meta property="og:url" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; ?>">
<meta property="og:type" content="website">
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
<meta property="og:locale" content="fr_FR">
<!-- Twitter Card Meta Tags -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="<?php echo SITE_NAME; ?>">
<meta name="twitter:description" content="Découvrez notre plateforme multimédia avec des vidéos, des shorts et des directs. Tendances, catégories et contenus exclusifs vous attendent.">
<meta name="twitter:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
<!-- Données structurées JSON-LD pour le site web -->
<?php
$websiteJsonLd = generateWebSiteJsonLd();
outputJsonLd($websiteJsonLd);
// Générer les fils d'Ariane pour la page d'accueil
$breadcrumbs = [
['name' => 'Accueil', 'url' => getBaseUrl()]
];
$breadcrumbJsonLd = generateBreadcrumbJsonLd($breadcrumbs);
outputJsonLd($breadcrumbJsonLd);
?>
<!-- Script pour éviter le flash en mode sombre -->
<script nonce="<?php echo getCspNonce(); ?>">
(function() {
const savedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const shouldUseDark = savedTheme === 'dark' || (!savedTheme && systemPrefersDark);
if (shouldUseDark) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
<!-- ------ Script Plausible ------ -->
<!-- <script defer data-domain="<?php echo $_SERVER['HTTP_HOST'] ?>" src="https://plausible.io/js/script.hash.outbound-links.pageview-props.tagged-events.js"></script> -->
<!-- <script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script> -->
<!-- ------ Script Plausible ------ -->
</head>
<body>
<?php include 'includes/sidebar.php'; ?>
<!-- Contenu principal -->
<main class="main-content" id="main-content" role="main">
<?php include 'includes/header.php'; ?>
<!-- Hero and Mastodon container -->
<div class="hero-mastodon-wrapper">
<div class="timeline-wordpress-container">
<!-- Hero Banner -->
<?php include 'includes/hero-section.php'; ?>
<!-- Section Podcasts Castopod -->
<?php if (defined('CASTOPOD_ENABLED') && CASTOPOD_ENABLED && defined('CASTOPOD_URL') && !empty(CASTOPOD_URL)): ?>
<div class="castopod-section">
<div class="castopod-container">
<div class="castopod-header">
<i class="fas fa-podcast"></i>
Podcasts
<?php if (!empty(CASTOPOD_URL)): ?>
<a href="<?php echo htmlspecialchars(CASTOPOD_URL); ?>"
target="_blank"
rel="noopener noreferrer"
title="Visiter <?php echo htmlspecialchars(parse_url(CASTOPOD_URL, PHP_URL_HOST)); ?>">
<?php echo htmlspecialchars(parse_url(CASTOPOD_URL, PHP_URL_HOST)); ?>
</a>
<?php endif; ?>
</div>
<div class="castopod-episodes-list">
<?php
$castopodEpisodes = getCastopodEpisodes();
if (empty($castopodEpisodes)) {
echo '<div class="castopod-no-episodes">Aucun épisode disponible</div>';
} else {
foreach ($castopodEpisodes as $index => $episode):
?>
<div class="castopod-episode-item"
data-episode-id="<?php echo $index; ?>"
data-audio-url="<?php echo htmlspecialchars($episode['audioUrl']); ?>">
<button class="castopod-episode-thumb castopod-play-btn"
type="button"
aria-label="Lire <?php echo htmlspecialchars($episode['title']); ?>">
<?php if (!empty($episode['image'])): ?>
<img src="<?php echo htmlspecialchars($episode['image']); ?>"
alt="<?php echo htmlspecialchars($episode['title']); ?>"
loading="lazy">
<div class="castopod-play-icon">
<i class="fas fa-play-circle"></i>
</div>
<div class="castopod-pause-icon">
<i class="fas fa-pause-circle"></i>
</div>
<?php else: ?>
<i class="fas fa-podcast"></i>
<?php endif; ?>
</button>
<div class="castopod-episode-info">
<h3 class="castopod-episode-title">
<?php echo htmlspecialchars($episode['title']); ?>
</h3>
<?php if (!empty($episode['podcastTitle'])): ?>
<div class="castopod-podcast-badge">
<i class="fas fa-podcast"></i> <?php echo htmlspecialchars($episode['podcastTitle']); ?>
</div>
<?php endif; ?>
<div class="castopod-episode-meta">
<span><i class="far fa-calendar"></i> <?php echo htmlspecialchars($episode['formattedDate']); ?></span>
<?php if (!empty($episode['formattedDuration'])): ?>
<span>•</span>
<span><i class="far fa-clock"></i> <?php echo htmlspecialchars($episode['formattedDuration']); ?></span>
<?php endif; ?>
</div>
</div>
<a href="<?php echo htmlspecialchars($episode['link']); ?>"
class="castopod-external-link"
target="_blank"
rel="noopener noreferrer"
title="Voir sur Castopod"
aria-label="Voir <?php echo htmlspecialchars($episode['title']); ?> sur Castopod">
<i class="fas fa-external-link-alt"></i>
</a>
<!-- Élément audio caché -->
<audio preload="none">
<source src="<?php echo htmlspecialchars($episode['audioUrl']); ?>" type="audio/mpeg">
</audio>
</div>
<?php
endforeach;
}
?>
</div>
</div>
</div>
<?php endif; ?>
<!-- Section Morceaux Funkwhale -->
<?php if (defined('FUNKWHALE_ENABLED') && FUNKWHALE_ENABLED && defined('FUNKWHALE_URL') && !empty(FUNKWHALE_URL)): ?>
<div class="funkwhale-section">
<div class="funkwhale-container">
<div class="funkwhale-header">
<i class="fas fa-music"></i>
Morceaux
<?php if (!empty(FUNKWHALE_URL)): ?>
<a href="<?php echo htmlspecialchars(FUNKWHALE_URL); ?>"
target="_blank"
rel="noopener noreferrer"
title="Visiter <?php echo htmlspecialchars(parse_url(FUNKWHALE_URL, PHP_URL_HOST)); ?>">
<?php echo htmlspecialchars(parse_url(FUNKWHALE_URL, PHP_URL_HOST)); ?>
</a>
<?php endif; ?>
</div>
<div class="funkwhale-tracks-list">
<?php
$funkwhaleTracks = getFunkwhaleTracks();
if (empty($funkwhaleTracks)) {
echo '<div class="funkwhale-no-tracks">Aucun morceau disponible</div>';
} else {
foreach ($funkwhaleTracks as $index => $track):
?>
<div class="funkwhale-track-item"
data-track-id="<?php echo $index; ?>"
data-audio-url="<?php echo htmlspecialchars($track['audioUrl']); ?>">
<button class="funkwhale-track-thumb funkwhale-play-btn"
type="button"
aria-label="Lire <?php echo htmlspecialchars($track['title']); ?>">
<?php if (!empty($track['cover'])): ?>
<img src="<?php echo htmlspecialchars($track['cover']); ?>"
alt="<?php echo htmlspecialchars($track['album']); ?>"
loading="lazy">
<div class="funkwhale-play-icon">
<i class="fas fa-play-circle"></i>
</div>
<div class="funkwhale-pause-icon">
<i class="fas fa-pause-circle"></i>
</div>
<?php else: ?>
<i class="fas fa-music"></i>
<?php endif; ?>
</button>
<div class="funkwhale-track-info">
<h3 class="funkwhale-track-title">
<?php echo htmlspecialchars($track['title']); ?>
</h3>
<div class="funkwhale-track-artist">
<i class="fas fa-user"></i> <?php echo htmlspecialchars($track['artist']); ?>
</div>
<div class="funkwhale-track-meta">
<span><i class="fas fa-compact-disc"></i> <?php echo htmlspecialchars($track['album']); ?></span>
<?php if (!empty($track['formattedDuration'])): ?>
<span>•</span>
<span><i class="far fa-clock"></i> <?php echo htmlspecialchars($track['formattedDuration']); ?></span>
<?php endif; ?>
</div>
</div>
<a href="<?php echo htmlspecialchars($track['link']); ?>"
class="funkwhale-external-link"
target="_blank"
rel="noopener noreferrer"
title="Voir sur Funkwhale"
aria-label="Voir <?php echo htmlspecialchars($track['title']); ?> sur Funkwhale">
<i class="fas fa-external-link-alt"></i>
</a>
<!-- Élément audio caché -->
<audio preload="none">
<source src="<?php echo htmlspecialchars($track['audioUrl']); ?>" type="audio/mpeg">
</audio>
</div>
<?php
endforeach;
}
?>
</div>
</div>
</div>
<?php endif; ?>
<!-- Timeline Mastodon -->
<div class="mastodon-section">
<div id="mt-container" class="mt-container">
<div class="mt-header">
<h2 class="mt-title">Fil d'actualités <i class="fab fa-mastodon"></i></h2>
</div>
<div class="mt-body" role="feed">
<div class="mt-loading-spinner"></div>
</div>
</div>
</div>
<!-- Section Articles WordPress -->
<?php if (defined('WORDPRESS_ENABLED') && WORDPRESS_ENABLED && !empty(WORDPRESS_URL)): ?>
<div class="wordpress-section">
<div class="wordpress-container">
<div class="wordpress-header">
<i class="fas fa-newspaper"></i>
Articles <?php if (!empty(WORDPRESS_URL)): ?>
<a href="<?php echo htmlspecialchars(WORDPRESS_URL); ?>"
target="_blank"
rel="noopener noreferrer"
title="Visiter <?php echo htmlspecialchars(parse_url(WORDPRESS_URL, PHP_URL_HOST)); ?>">
<?php echo htmlspecialchars(parse_url(WORDPRESS_URL, PHP_URL_HOST)); ?>
</a>
<?php endif; ?>
</div>
<div class="wordpress-posts-list">
<?php
$wordpressPosts = getWordPressPosts();
if (empty($wordpressPosts)) {
echo '<div class="wordpress-no-posts">Aucun article disponible</div>';
} else {
foreach ($wordpressPosts as $post):
?>
<a href="<?php echo htmlspecialchars($post['link']); ?>"
class="wordpress-post-item"
target="_blank"
rel="noopener noreferrer"
title="<?php echo htmlspecialchars($post['title']); ?>">
<div class="wordpress-post-thumb">
<?php if (!empty($post['featured_image'])): ?>
<img src="<?php echo htmlspecialchars($post['featured_image']); ?>"
alt="<?php echo htmlspecialchars($post['title']); ?>"
loading="lazy">
<?php else: ?>
<i class="fas fa-file-alt"></i>
<?php endif; ?>
</div>
<div class="wordpress-post-info">
<h3 class="wordpress-post-title">
<?php echo htmlspecialchars($post['title']); ?>
</h3>
<div class="wordpress-post-meta">
<span><?php echo htmlspecialchars($post['date']); ?></span>
<?php if (!empty($post['author'])): ?>
<span>•</span>
<span><?php echo htmlspecialchars($post['author']); ?></span>
<?php endif; ?>
</div>
</div>
</a>
<?php
endforeach;
}
?>
</div>
</div>
</div>
<?php endif; ?>
</div>
</div>
<!-- Hashtags en ligne -->
<div class="tags-section">
<?php
if (defined('POPULAR_TAGS') && !empty(POPULAR_TAGS)):
foreach (POPULAR_TAGS as $tag):
$encodedTag = urlencode('#' . $tag);
?>
<a href="recherche.php?q=<?php echo $encodedTag; ?>" class="tag">#<?php echo htmlspecialchars($tag); ?></a>
<?php
endforeach;
endif;
?>
</div>
<!-- Séparateur stylisé -->
<hr class="section-divider">
<!-- Section Shorts -->
<section class="video-section" aria-labelledby="shorts-heading">
<div class="section-header">
<div class="section-logo">
<img src="img/logo.png" alt="Logo <?php echo SITE_NAME; ?>" aria-hidden="true">
</div>
<h2 id="shorts-heading" class="section-title">Shorts</h2>
</div>
<div class="carousel">
<div class="carousel-container">
<?php
// Récupérer les shorts depuis l'API PeerTube
$shorts = getShorts();
// Traiter le cas où aucun short n'est trouvé
if (empty($shorts)) {
echo '<div class="no-results">Aucun short disponible pour le moment</div>';
} else {
foreach ($shorts as $video):
?>
<div class="carousel-item">
<article class="video-card short-card" data-video-id="<?php echo $video['id']; ?>">
<div class="video-thumbnail short-thumbnail">
<img src="<?php echo $video['thumbnail']; ?>" alt="Miniature de la vidéo: <?php echo htmlspecialchars($video['title']); ?>">
<div class="video-duration" aria-label="Durée: <?php echo formatDuration($video['duration']); ?>">
<?php echo formatDuration($video['duration']); ?>
</div>
<div class="short-play-overlay" aria-hidden="true">
<i class="fas fa-play-circle"></i>
</div>
<div class="short-title-overlay">
<?php echo htmlspecialchars($video['title']); ?>
</div>
</div>
</article>
</div>
<?php
endforeach;
}
?>
</div>
<?php if (!empty($shorts) && count($shorts) > 1): ?>
<div class="carousel-controls">
<?php for ($i = 0; $i < count($shorts); $i++): ?>
<div class="carousel-dot <?php echo $i === 0 ? 'active' : ''; ?>"></div>
<?php endfor; ?>
</div>
<?php endif; ?>
</div>
</section>
<!-- Séparateur stylisé -->
<hr class="section-divider">
<!-- Section Dernières vidéos -->
<section class="video-section" aria-labelledby="recent-videos-heading">
<header class="section-header">
<div class="section-logo">
<img src="img/logo.png" alt="Logo <?php echo SITE_NAME; ?>" aria-hidden="true">
</div>
<h2 id="recent-videos-heading" class="section-title">Dernières vidéos</h2>
</header>
<div class="video-grid">
<?php
// Récupérer les vidéos récentes depuis l'API PeerTube
$recentVideos = getRecentVideos();
// Traiter le cas où aucune vidéo n'est trouvée
if (empty($recentVideos)) {
echo '<div class="no-results">Aucune vidéo disponible pour le moment</div>';
} else {
foreach ($recentVideos as $video):
?>
<article class="video-card" data-video-id="<?php echo $video['id']; ?>">
<div class="video-thumbnail">
<img src="<?php echo $video['thumbnail']; ?>" alt="Miniature de la vidéo: <?php echo htmlspecialchars($video['title']); ?>">
<div class="video-play-icon" aria-hidden="true">
<i class="fas fa-play-circle"></i>
</div>
<div class="video-duration" aria-label="Durée: <?php echo formatDuration($video['duration']); ?>">
<?php echo formatDuration($video['duration']); ?>
</div>
</div>
<div class="video-info">
<h3 class="video-title"><?php echo htmlspecialchars($video['title']); ?></h3>
<div class="video-channel">
<?php if (strpos($video['channelAvatar'], 'default-avatar.png') !== false || empty($video['channelAvatar'])): ?>
<div class="channel-avatar-placeholder">
<i class="fas fa-user-circle"></i>
</div>
<?php else: ?>
<img src="<?php echo $video['channelAvatar']; ?>" alt="<?php echo $video['channel']; ?>" class="channel-avatar">
<?php endif; ?>
<span class="channel-name"><?php echo $video['channel']; ?></span>
</div>
<div class="video-metadata">
<?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?>
<span class="video-views"><i class="fas fa-eye"></i> <?php echo formatViewCount($video['views']); ?> vues</span>
<?php endif; ?>
<span class="video-date"><i class="far fa-calendar-alt"></i> <?php echo formatDate($video['date']); ?></span>
</div>
</div>
</article>
<?php
endforeach;
}
?>
</div>
<button class="view-more">Voir plus</button>
</section>
<!-- Séparateur stylisé -->
<hr class="section-divider">
<!-- Section Tendances -->
<section class="video-section" aria-labelledby="trending-videos-heading">
<header class="section-header">
<div class="section-logo">
<img src="img/logo.png" alt="Logo <?php echo SITE_NAME; ?>" aria-hidden="true">
</div>
<h2 id="trending-videos-heading" class="section-title">Tendances</h2>
</header>
<div class="video-grid">
<?php
// Récupérer les vidéos tendances depuis l'API PeerTube
$trendingVideos = getTrendingVideos();
// Traiter le cas où aucune vidéo n'est trouvée
if (empty($trendingVideos)) {
echo '<div class="no-results">Aucune vidéo disponible pour le moment</div>';
} else {
foreach ($trendingVideos as $video):
?>
<article class="video-card" data-video-id="<?php echo $video['id']; ?>">
<div class="video-thumbnail">
<img src="<?php echo $video['thumbnail']; ?>" alt="<?php echo $video['title']; ?>">
<div class="video-play-icon">
<i class="fas fa-play-circle"></i>
</div>
<div class="video-duration"><?php echo formatDuration($video['duration']); ?></div>
</div>
<div class="video-info">
<h3 class="video-title"><?php echo $video['title']; ?></h3>
<div class="video-channel">
<?php if (strpos($video['channelAvatar'], 'default-avatar.png') !== false || empty($video['channelAvatar'])): ?>
<div class="channel-avatar-placeholder">
<i class="fas fa-user-circle"></i>
</div>
<?php else: ?>
<img src="<?php echo $video['channelAvatar']; ?>" alt="<?php echo $video['channel']; ?>" class="channel-avatar">
<?php endif; ?>
<span class="channel-name"><?php echo $video['channel']; ?></span>
</div>
<div class="video-metadata">
<?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?>
<span class="video-views"><i class="fas fa-eye"></i> <?php echo formatViewCount($video['views']); ?> vues</span>
<?php endif; ?>
<span class="video-date"><i class="far fa-calendar-alt"></i> <?php echo formatDate($video['date']); ?></span>
</div>
</div>
</article>
<?php
endforeach;
}
?>
</div>
<button class="view-more">Voir plus</button>
</section>
<!-- Séparateur stylisé -->
<hr class="section-divider">
<!-- Sections par catégorie -->
<?php
// Récupérer les catégories avec leurs vidéos
$displayCategories = getDisplayCategories();
// Afficher chaque catégorie qui a des vidéos
foreach ($displayCategories as $category):
if (!empty($category['videos'])):
?>
<!-- Section Catégorie: <?php echo $category['name']; ?> -->
<section class="video-section" data-category-id="<?php echo $category['id']; ?>" aria-labelledby="category-heading-<?php echo $category['id']; ?>">
<header class="section-header">
<div class="section-logo">
<img src="img/logo.png" alt="Logo <?php echo SITE_NAME; ?>" aria-hidden="true">
</div>
<h2 id="category-heading-<?php echo $category['id']; ?>" class="section-title"><?php echo htmlspecialchars($category['name']); ?></h2>
</header>
<div class="video-grid">
<?php foreach ($category['videos'] as $video): ?>
<article class="video-card" data-video-id="<?php echo $video['id']; ?>">
<div class="video-thumbnail">
<img src="<?php echo $video['thumbnail']; ?>" alt="<?php echo $video['title']; ?>">
<div class="video-play-icon">
<i class="fas fa-play-circle"></i>
</div>
<div class="video-duration"><?php echo formatDuration($video['duration']); ?></div>
</div>
<div class="video-info">
<h3 class="video-title"><?php echo $video['title']; ?></h3>
<div class="video-channel">
<?php if (strpos($video['channelAvatar'], 'default-avatar.png') !== false || empty($video['channelAvatar'])): ?>
<div class="channel-avatar-placeholder">
<i class="fas fa-user-circle"></i>
</div>
<?php else: ?>
<img src="<?php echo $video['channelAvatar']; ?>" alt="<?php echo $video['channel']; ?>" class="channel-avatar">
<?php endif; ?>
<span class="channel-name"><?php echo $video['channel']; ?></span>
</div>
<div class="video-metadata">
<?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?>
<span class="video-views"><i class="fas fa-eye"></i> <?php echo formatViewCount($video['views']); ?> vues</span>
<?php endif; ?>
<span class="video-date"><i class="far fa-calendar-alt"></i> <?php echo formatDate($video['date']); ?></span>
</div>
</div>
</article>
<?php endforeach; ?>
</div>
<button class="view-more">Voir plus</button>
</section>
<!-- Séparateur stylisé -->
<hr class="section-divider">
<?php
endif;
endforeach;
?>
<!-- Section Flexbox pour Informations et Tendances Hashtags -->
<section class="info-tags-container">
<?php if (defined('MOVEMENT_DESCRIPTION') && !empty(MOVEMENT_DESCRIPTION)): ?>
<!-- Section Informations -->
<article class="info-section">
<h2 class="section-title centered"><?php echo defined('MOVEMENT_TITLE') && !empty(MOVEMENT_TITLE) ? MOVEMENT_TITLE : 'À propos'; ?></h2>
<p class="info-description"><?php echo MOVEMENT_DESCRIPTION; ?></p>
<?php if (defined('MOVEMENT_DESCRIPTION_2') && !empty(MOVEMENT_DESCRIPTION_2)): ?>
<p class="info-description"><?php echo MOVEMENT_DESCRIPTION_2; ?></p>
<?php endif; ?>
<?php if (defined('MOVEMENT_IMAGE') && !empty(MOVEMENT_IMAGE)): ?>
<figure class="movement-figure">
<img src="<?php echo MOVEMENT_IMAGE; ?>" alt="<?php echo defined('MOVEMENT_IMAGE_ALT') ? MOVEMENT_IMAGE_ALT : ''; ?>" class="info-image">
<?php if (defined('MOVEMENT_CAPTION') && !empty(MOVEMENT_CAPTION)): ?>
<figcaption class="movement-caption">
<?php echo MOVEMENT_CAPTION; ?>
</figcaption>
<?php endif; ?>
</figure>
<?php endif; ?>
</article>
<?php endif; ?>
<!-- Section Tendances Hashtags -->
<aside class="tags-section-container" aria-labelledby="hashtags-heading">
<h2 id="hashtags-heading" class="section-title centered">Tendances</h2>
<div class="tags-section">
<?php
if (defined('POPULAR_TAGS') && !empty(POPULAR_TAGS)):
foreach (POPULAR_TAGS as $tag):
$encodedTag = urlencode('#' . $tag);
?>
<a href="recherche.php?q=<?php echo $encodedTag; ?>" class="tag">#<?php echo htmlspecialchars($tag); ?></a>
<?php
endforeach;
endif;
?>
</div>
</aside>
</section>
</main>
<?php include 'includes/footer.php'; ?>
<?php include 'includes/mobile-menu.php'; ?>
<script src="js/main.js"></script>
<script src="js/pleroma-adapter.js"></script>
<script src="js/mastodon-timeline.umd.js"></script>
<script src="js/mastodon-config.php?v=<?php echo md5(MASTODON_INSTANCE_URL . MASTODON_DATE_FORMAT . MASTODON_BTN_SEE_MORE . MASTODON_BTN_RELOAD . MASTODON_MAX_POST_FETCH . MASTODON_MAX_POST_SHOW); ?>"></script>
<!-- PWA Service Worker -->
<script nonce="<?php echo getCspNonce(); ?>">
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js')
.then(function(registration) {
console.log('Service Worker enregistré avec succès:', registration.scope);
// Écouter les mises à jour
registration.addEventListener('updatefound', function() {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', function() {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// Nouvelle version disponible
console.log('Nouvelle version disponible');
if (confirm('Une nouvelle version est disponible. Voulez-vous recharger la page ?')) {
window.location.reload();
}
}
});
});
})
.catch(function(err) {
console.log('Échec de l\'enregistrement du Service Worker:', err);
});
});
}
// Gestion de l'installation PWA
let deferredPrompt;
const installButton = document.getElementById('install-pwa');
window.addEventListener('beforeinstallprompt', function(e) {
e.preventDefault();
deferredPrompt = e;
// Afficher le bouton d'installation s'il existe
if (installButton) {
installButton.style.display = 'block';
installButton.addEventListener('click', function() {
deferredPrompt.prompt();
deferredPrompt.userChoice.then(function(choiceResult) {
if (choiceResult.outcome === 'accepted') {
console.log('PWA installée');
}
deferredPrompt = null;
installButton.style.display = 'none';
});
});
}
});
// Masquer le bouton après installation
window.addEventListener('appinstalled', function() {
console.log('PWA installée avec succès');
if (installButton) {
installButton.style.display = 'none';
}
});
</script>
<?php if ((defined('FUNKWHALE_ENABLED') && FUNKWHALE_ENABLED) || (defined('CASTOPOD_ENABLED') && CASTOPOD_ENABLED)): ?>
<!-- Lecteur audio unifié pour Funkwhale et Castopod -->
<script src="js/audio-player.js?v=<?php echo @filemtime('js/audio-player.js') ?: time(); ?>"></script>
<?php endif; ?>
</body>
</html>
-147
View File
@@ -1,147 +0,0 @@
/**
* Lecteur audio intégré universel pour Funkwhale et Castopod
* Permet de lire les morceaux/épisodes directement dans la page
*/
(function() {
'use strict';
let currentlyPlayingItem = null;
let currentAudioElement = null;
/**
* Arrête l'élément en cours de lecture
*/
function stopCurrentItem() {
if (currentAudioElement) {
currentAudioElement.pause();
currentAudioElement.currentTime = 0;
}
if (currentlyPlayingItem) {
currentlyPlayingItem.classList.remove('playing');
currentlyPlayingItem = null;
}
}
/**
* Démarre la lecture d'un élément
*/
function playItem(item, audioElement) {
// Arrêter l'élément actuel s'il y en a un
if (currentlyPlayingItem && currentlyPlayingItem !== item) {
stopCurrentItem();
}
currentlyPlayingItem = item;
currentAudioElement = audioElement;
// Marquer comme en cours de lecture
item.classList.add('playing');
// Démarrer la lecture
audioElement.play().catch(function(error) {
console.error('Erreur lors de la lecture:', error);
stopCurrentItem();
});
}
/**
* Met en pause l'élément
*/
function pauseItem(item, audioElement) {
audioElement.pause();
item.classList.remove('playing');
}
/**
* Initialise les contrôles du lecteur pour un type d'élément
*/
function initPlayerForType(itemSelector, buttonSelector) {
const items = document.querySelectorAll(itemSelector);
items.forEach(function(item) {
const playButton = item.querySelector(buttonSelector);
const audioElement = item.querySelector('audio');
if (!playButton || !audioElement) {
return;
}
// Gestion du clic sur le bouton play/pause
playButton.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
if (item.classList.contains('playing')) {
pauseItem(item, audioElement);
} else {
playItem(item, audioElement);
}
});
// Gérer la fin de la lecture
audioElement.addEventListener('ended', function() {
item.classList.remove('playing');
// Passer automatiquement à l'élément suivant
const nextItem = item.nextElementSibling;
if (nextItem && (nextItem.classList.contains('funkwhale-track-item') ||
nextItem.classList.contains('castopod-episode-item'))) {
const nextAudio = nextItem.querySelector('audio');
if (nextAudio) {
playItem(nextItem, nextAudio);
}
} else {
// Réinitialiser si c'était le dernier élément
currentlyPlayingItem = null;
currentAudioElement = null;
}
});
// Gérer les erreurs de chargement
audioElement.addEventListener('error', function(e) {
console.error('Erreur de chargement audio:', e);
item.classList.remove('playing');
// Afficher un message d'erreur temporaire
const titleSelector = item.classList.contains('funkwhale-track-item') ?
'.funkwhale-track-title' : '.castopod-episode-title';
const title = item.querySelector(titleSelector);
if (title) {
const originalText = title.textContent;
title.textContent = 'Erreur de lecture';
title.style.color = '#ff0000';
setTimeout(function() {
title.textContent = originalText;
title.style.color = '';
}, 3000);
}
});
});
}
/**
* Initialise tous les lecteurs
*/
function initPlayer() {
// Initialiser le lecteur Funkwhale
initPlayerForType('.funkwhale-track-item', '.funkwhale-play-btn');
// Initialiser le lecteur Castopod
initPlayerForType('.castopod-episode-item', '.castopod-play-btn');
}
// Initialiser le lecteur quand le DOM est prêt
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPlayer);
} else {
initPlayer();
}
// Nettoyer à la fermeture de la page
window.addEventListener('beforeunload', function() {
stopCurrentItem();
});
})();
-97
View File
@@ -1,97 +0,0 @@
document.addEventListener("DOMContentLoaded", () => {
// Gestion des clics sur les vidéos
const videoCards = document.querySelectorAll(".video-card");
for (const videoCard of videoCards) {
videoCard.addEventListener("click", function () {
const videoId = this.dataset.videoId;
if (videoId) {
window.location.href = `video.php?id=${videoId}`;
}
});
}
// Gestion du bouton "Voir plus"
const viewMoreBtn = document.querySelector(".view-more");
if (viewMoreBtn) {
viewMoreBtn.addEventListener("click", function () {
const page = Number.parseInt(this.dataset.page);
const categoryId =
document.querySelector(".video-section").dataset.categoryId;
// Changer le texte du bouton pendant le chargement
this.textContent = "Chargement...";
this.disabled = true;
// Préparer les données avec token CSRF
const formData = new FormData();
formData.append('csrf_token', document.querySelector('meta[name="csrf-token"]').getAttribute('content'));
// Faire la requête AJAX
fetch(
`ajax/load-more-videos.php?type=category&page=${page}&category=${categoryId}`,
{
method: 'POST',
headers: {
"X-Requested-With": "XMLHttpRequest",
},
body: formData
}
)
.then((response) => response.json())
.then((data) => {
if (data.success) {
// Ajouter les nouvelles vidéos à la grille
const videoGrid = document.querySelector(".video-grid");
const tempDiv = document.createElement("div");
tempDiv.innerHTML = data.html;
// Ajouter chaque vidéo à la grille
while (tempDiv.firstChild) {
videoGrid.appendChild(tempDiv.firstChild);
}
// Mettre à jour le numéro de page
this.dataset.page = data.page + 1;
// Réinitialiser le texte du bouton
this.textContent = "Voir plus";
this.disabled = false;
// Si plus de vidéos à charger, masquer le bouton
if (!data.hasMore) {
this.style.display = "none";
}
// Initialiser les clics sur les nouvelles vidéos
const cards = document.querySelectorAll(".video-card:not([data-click-initialized])")
for (const card of cards) {
card.setAttribute("data-click-initialized", "true");
card.addEventListener("click", function () {
const videoId = this.dataset.videoId;
if (videoId) {
window.location.href = `video.php?id=${videoId}`;
}
});
}
} else {
// Gérer l'erreur
this.textContent = "Erreur lors du chargement";
setTimeout(() => {
this.textContent = "Voir plus";
this.disabled = false;
}, 2000);
}
})
.catch((error) => {
console.error("Erreur:", error);
this.textContent = "Erreur lors du chargement";
setTimeout(() => {
this.textContent = "Voir plus";
this.disabled = false;
}, 2000);
});
});
}
});
-97
View File
@@ -1,97 +0,0 @@
/**
* Script de compte à rebours pour la page de maintenance
*/
class CountdownTimer {
constructor(targetDate) {
this.targetDate = new Date(targetDate).getTime();
this.elements = {
days: document.getElementById('countdown-days'),
hours: document.getElementById('countdown-hours'),
minutes: document.getElementById('countdown-minutes'),
seconds: document.getElementById('countdown-seconds')
};
this.start();
}
start() {
// Mise à jour immédiate
this.updateTimer();
// Mise à jour chaque seconde
this.interval = setInterval(() => {
this.updateTimer();
}, 1000);
}
updateTimer() {
const now = new Date().getTime();
const distance = this.targetDate - now;
if (distance < 0) {
// Le compte à rebours est terminé
this.stop();
this.onComplete();
return;
}
const days = Math.floor(distance / (1000 * 60 * 60 * 24));
const hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((distance % (1000 * 60)) / 1000);
// Mise à jour des éléments du DOM
if (this.elements.days) this.elements.days.textContent = this.formatNumber(days);
if (this.elements.hours) this.elements.hours.textContent = this.formatNumber(hours);
if (this.elements.minutes) this.elements.minutes.textContent = this.formatNumber(minutes);
if (this.elements.seconds) this.elements.seconds.textContent = this.formatNumber(seconds);
}
formatNumber(num) {
return num.toString().padStart(2, '0');
}
stop() {
if (this.interval) {
clearInterval(this.interval);
}
}
onComplete() {
// Rediriger vers la page principale quand le compte à rebours est terminé
window.location.href = '/';
}
}
// Animation des éléments au chargement
document.addEventListener('DOMContentLoaded', function() {
// Initialiser le compte à rebours si les éléments existent
if (document.getElementById('countdown-days')) {
// La date cible est fournie par PHP
if (typeof COUNTDOWN_TARGET_DATE !== 'undefined') {
new CountdownTimer(COUNTDOWN_TARGET_DATE);
}
}
// Animation d'apparition progressive
const elements = document.querySelectorAll('.countdown-container > *');
elements.forEach((element, index) => {
element.style.opacity = '0';
element.style.transform = 'translateY(30px)';
setTimeout(() => {
element.style.transition = 'opacity 0.8s ease, transform 0.8s ease';
element.style.opacity = '1';
element.style.transform = 'translateY(0)';
}, 100 * (index + 1));
});
});
// Gérer la fermeture propre du timer
window.addEventListener('beforeunload', function() {
// Arrêter le timer si il existe
if (window.countdownTimer) {
window.countdownTimer.stop();
}
});
-529
View File
@@ -1,529 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
// Détection de l'état de connexion
function updateOnlineStatus() {
const offlineIndicator = document.querySelector('.offline-indicator');
if (!navigator.onLine) {
if (!offlineIndicator) {
const indicator = document.createElement('div');
indicator.className = 'offline-indicator show';
indicator.textContent = 'Mode hors ligne - Fonctionnalités limitées';
document.body.insertBefore(indicator, document.body.firstChild);
}
} else {
if (offlineIndicator) {
offlineIndicator.remove();
}
}
}
// Écouter les changements de connexion
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
// Vérifier l'état initial
updateOnlineStatus();
// Gestion du menu mobile
const mobileMenuToggle = document.querySelector('.mobile-menu-toggle');
const mobileMenu = document.querySelector('.mobile-menu');
const mobileMenuClose = document.querySelector('.mobile-menu-close');
if (mobileMenuToggle) {
mobileMenuToggle.addEventListener('click', function() {
mobileMenu.classList.add('active');
document.body.style.overflow = 'hidden'; // Empêche le défilement de la page
});
}
if (mobileMenuClose) {
mobileMenuClose.addEventListener('click', function() {
mobileMenu.classList.remove('active');
document.body.style.overflow = ''; // Réactive le défilement
});
}
// Gestion du theme (mode clair/sombre)
const themeToggle = document.querySelector('#theme-toggle');
const html = document.documentElement;
// Charger le thème sauvegardé ou détecter la préférence système
function initTheme() {
const savedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
let currentTheme = savedTheme;
if (!savedTheme) {
currentTheme = systemPrefersDark ? 'dark' : 'light';
}
applyTheme(currentTheme);
updateThemeIcon(currentTheme);
}
// Appliquer le thème
function applyTheme(theme) {
if (theme === 'dark') {
html.setAttribute('data-theme', 'dark');
} else {
html.removeAttribute('data-theme');
}
}
// Mettre à jour l'icône du bouton
function updateThemeIcon(theme) {
if (themeToggle) {
const icon = themeToggle.querySelector('i');
if (theme === 'dark') {
icon.classList.remove('fa-sun');
icon.classList.add('fa-moon');
themeToggle.setAttribute('aria-label', 'Basculer vers le mode clair');
themeToggle.setAttribute('title', 'Mode clair');
} else {
icon.classList.remove('fa-moon');
icon.classList.add('fa-sun');
themeToggle.setAttribute('aria-label', 'Basculer vers le mode sombre');
themeToggle.setAttribute('title', 'Mode sombre');
}
}
}
// Basculer le thème
function toggleTheme() {
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
applyTheme(newTheme);
updateThemeIcon(newTheme);
localStorage.setItem('theme', newTheme);
}
// Event listener pour le bouton
if (themeToggle) {
themeToggle.addEventListener('click', toggleTheme);
}
// Écouter les changements de préférence système
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
if (!localStorage.getItem('theme')) {
const newTheme = e.matches ? 'dark' : 'light';
applyTheme(newTheme);
updateThemeIcon(newTheme);
}
});
// Initialiser le thème
initTheme();
// Gestion des carousels
const carousels = document.querySelectorAll('.carousel');
carousels.forEach(carousel => {
const container = carousel.querySelector('.carousel-container');
const dots = carousel.querySelectorAll('.carousel-dot');
const items = carousel.querySelectorAll('.carousel-item');
if (dots.length > 0 && items.length > 0) {
let currentIndex = 0;
let startX = 0;
let startY = 0;
let currentX = 0;
let isDragging = false;
let startTransform = 0;
let isTap = true; // Nouveau: détecter si c'est un tap simple
let touchStartTime = 0;
let touchTarget = null; // Stocker la cible du touch
function getItemWidth() {
return items[0].offsetWidth + parseInt(getComputedStyle(items[0]).marginRight);
}
function updateCarousel(index) {
const itemWidth = getItemWidth();
const newTransform = -index * itemWidth;
container.style.transform = `translateX(${newTransform}px)`;
// Mettre à jour les dots
dots.forEach(d => d.classList.remove('active'));
if (dots[index]) {
dots[index].classList.add('active');
}
currentIndex = index;
}
dots.forEach((dot, index) => {
dot.addEventListener('click', function() {
updateCarousel(index);
});
});
container.addEventListener('touchstart', function(e) {
isDragging = true;
isTap = true;
touchStartTime = Date.now();
touchTarget = e.target; // Capturer la cible du touch
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
currentX = startX; // IMPORTANT: initialiser currentX à startX pour éviter les faux swipes
const transform = window.getComputedStyle(container).transform;
if (transform !== 'none') {
const matrix = new DOMMatrix(transform);
startTransform = matrix.m41;
} else {
startTransform = 0;
}
container.style.transition = 'none';
}, { passive: true });
container.addEventListener('touchmove', function(e) {
if (!isDragging) return;
currentX = e.touches[0].clientX;
const currentY = e.touches[0].clientY;
const diffX = Math.abs(currentX - startX);
const diffY = Math.abs(currentY - startY);
// Si le mouvement dépasse 10px, ce n'est plus un tap
if (diffX > 10 || diffY > 10) {
isTap = false;
}
const diff = currentX - startX;
const newTransform = startTransform + diff;
container.style.transform = `translateX(${newTransform}px)`;
}, { passive: true });
container.addEventListener('touchend', function(e) {
if (!isDragging) return;
isDragging = false;
container.style.transition = 'transform 0.5s ease';
const diff = currentX - startX;
const threshold = 50; // Distance minimale pour considérer un swipe
const tapThreshold = 10; // Distance maximale pour un tap
const tapTimeThreshold = 300; // Temps maximal pour un tap (ms)
const touchDuration = Date.now() - touchStartTime;
// Si c'est un tap (mouvement minimal et rapide)
if (isTap && Math.abs(diff) < tapThreshold && touchDuration < tapTimeThreshold) {
// Vérifier si le tap était sur une video-card
let videoCard = touchTarget;
// Remonter le DOM pour trouver la video-card
while (videoCard && !videoCard.classList.contains('video-card')) {
videoCard = videoCard.parentElement;
// Arrêter si on sort du container
if (videoCard === container || !videoCard) break;
}
// Si on a trouvé une video-card, naviguer vers la vidéo
if (videoCard && videoCard.classList.contains('video-card')) {
const videoId = videoCard.dataset.videoId;
if (videoId) {
window.location.href = 'video.php?id=' + videoId;
return;
}
}
// Sinon, juste repositionner le carousel
updateCarousel(currentIndex);
currentX = 0;
isTap = true; // Réinitialiser pour le prochain tap
return;
}
// Gérer comme un swipe normal
if (Math.abs(diff) > threshold) {
if (diff > 0 && currentIndex > 0) {
// Swipe vers la droite - item précédent
updateCarousel(currentIndex - 1);
} else if (diff < 0 && currentIndex < items.length - 1) {
// Swipe vers la gauche - item suivant
updateCarousel(currentIndex + 1);
} else {
// Retour à la position actuelle
updateCarousel(currentIndex);
}
} else {
// Mouvement trop petit, retour à la position actuelle
updateCarousel(currentIndex);
}
currentX = 0;
isTap = true; // Réinitialiser pour le prochain tap
}, { passive: true });
// Support du drag sur desktop (optionnel)
container.addEventListener('mousedown', function(e) {
isDragging = true;
isTap = true;
touchStartTime = Date.now();
touchTarget = e.target; // Capturer la cible
startX = e.clientX;
startY = e.clientY;
currentX = startX; // IMPORTANT: initialiser currentX à startX pour éviter les faux swipes
const transform = window.getComputedStyle(container).transform;
if (transform !== 'none') {
const matrix = new DOMMatrix(transform);
startTransform = matrix.m41;
} else {
startTransform = 0;
}
container.style.transition = 'none';
container.style.cursor = 'grabbing';
e.preventDefault();
});
document.addEventListener('mousemove', function(e) {
if (!isDragging) return;
currentX = e.clientX;
const currentY = e.clientY;
const diffX = Math.abs(currentX - startX);
const diffY = Math.abs(currentY - startY);
// Si le mouvement dépasse 10px, ce n'est plus un clic simple
if (diffX > 10 || diffY > 10) {
isTap = false;
}
const diff = currentX - startX;
const newTransform = startTransform + diff;
container.style.transform = `translateX(${newTransform}px)`;
});
document.addEventListener('mouseup', function(e) {
if (!isDragging) return;
isDragging = false;
container.style.transition = 'transform 0.5s ease';
container.style.cursor = 'grab';
const diff = currentX - startX;
const threshold = 50;
const tapThreshold = 10;
const tapTimeThreshold = 300;
const clickDuration = Date.now() - touchStartTime;
// Si c'est un clic simple (mouvement minimal et rapide)
if (isTap && Math.abs(diff) < tapThreshold && clickDuration < tapTimeThreshold) {
// Vérifier si le clic était sur une video-card
let videoCard = touchTarget;
// Remonter le DOM pour trouver la video-card
while (videoCard && !videoCard.classList.contains('video-card')) {
videoCard = videoCard.parentElement;
if (videoCard === container || !videoCard) break;
}
// Si on a trouvé une video-card, naviguer vers la vidéo
if (videoCard && videoCard.classList.contains('video-card')) {
const videoId = videoCard.dataset.videoId;
if (videoId) {
window.location.href = 'video.php?id=' + videoId;
return;
}
}
updateCarousel(currentIndex);
currentX = 0;
isTap = true; // Réinitialiser pour le prochain tap
return;
}
// Gérer comme un drag normal
if (Math.abs(diff) > threshold) {
if (diff > 0 && currentIndex > 0) {
updateCarousel(currentIndex - 1);
} else if (diff < 0 && currentIndex < items.length - 1) {
updateCarousel(currentIndex + 1);
} else {
updateCarousel(currentIndex);
}
} else {
updateCarousel(currentIndex);
}
currentX = 0;
isTap = true; // Réinitialiser pour le prochain tap
});
}
});
// Gestion des clics sur les vidéos
function initVideoCardClicks() {
const videoCards = document.querySelectorAll('.video-card');
videoCards.forEach(card => {
// Ne pas ajouter de listener click sur les cartes dans un carousel
// car elles sont déjà gérées par le carousel lui-même
const isInCarousel = card.closest('.carousel-container');
if (!isInCarousel && !card.hasAttribute('data-click-initialized')) {
card.setAttribute('data-click-initialized', 'true');
card.addEventListener('click', function() {
const videoId = this.dataset.videoId;
if (videoId) {
window.location.href = 'video.php?id=' + videoId;
}
});
}
});
}
// Initialiser les clics sur les vidéos existantes
initVideoCardClicks();
// Lazy loading des images
function initLazyLoading() {
if ('IntersectionObserver' in window) {
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
const src = img.getAttribute('data-src');
if (src) {
img.src = src;
img.removeAttribute('data-src');
}
observer.unobserve(img);
}
});
});
document.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});
} else {
// Fallback pour les navigateurs qui ne supportent pas IntersectionObserver
document.querySelectorAll('img[data-src]').forEach(img => {
const src = img.getAttribute('data-src');
if (src) {
img.src = src;
img.removeAttribute('data-src');
}
});
}
}
// Initialiser le lazy loading
initLazyLoading();
// Gestion des boutons "Voir plus"
const viewMoreButtons = document.querySelectorAll('.view-more');
viewMoreButtons.forEach(button => {
// Déterminer le type de vidéos à charger
const section = button.closest('.video-section');
const sectionTitle = section.querySelector('.section-title').textContent.trim().toLowerCase();
const videoGrid = section.querySelector('.video-grid');
let videoType = '';
let categoryId = null;
if (sectionTitle.includes('dernières')) {
videoType = 'recent';
} else if (sectionTitle.includes('tendances')) {
videoType = 'trending';
} else if (sectionTitle.includes('indépendance')) {
videoType = 'independence';
} else {
// Vérifier si c'est une section de catégorie
const categorySection = section.querySelector('[data-category-id]');
if (categorySection) {
videoType = 'category';
categoryId = categorySection.dataset.categoryId;
} else if (section.hasAttribute('data-category-id')) {
videoType = 'category';
categoryId = section.dataset.categoryId;
}
}
// Si aucun type reconnu, ne pas configurer l'événement
if (!videoType) return;
// Stocker le numéro de page actuel
button.dataset.page = '1';
button.addEventListener('click', function() {
const page = parseInt(this.dataset.page);
// Changer le texte du bouton pendant le chargement
button.textContent = 'Chargement...';
button.disabled = true;
// Préparer l'URL avec les paramètres
let url = `ajax/load-more-videos.php?type=${videoType}&page=${page}`;
if (videoType === 'category' && categoryId) {
url += `&category=${categoryId}`;
}
// Préparer les données avec token CSRF
const formData = new FormData();
formData.append('csrf_token', document.querySelector('meta[name="csrf-token"]').getAttribute('content'));
// Faire la requête AJAX
fetch(url, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Ajouter les nouvelles vidéos à la grille
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.html;
// Ajouter chaque vidéo à la grille
while (tempDiv.firstChild) {
videoGrid.appendChild(tempDiv.firstChild);
}
// Mettre à jour le numéro de page
this.dataset.page = data.page + 1;
// Réinitialiser le texte du bouton
button.textContent = 'Voir plus';
button.disabled = false;
// Si plus de vidéos à charger, masquer le bouton
if (!data.hasMore) {
button.style.display = 'none';
}
// Initialiser les clics sur les nouvelles vidéos
initVideoCardClicks();
// Initialiser le lazy loading pour les nouvelles images
initLazyLoading();
} else {
// En cas d'erreur, afficher un message et réactiver le bouton
console.error('Erreur lors du chargement des vidéos:', data.error);
button.textContent = 'Voir plus';
button.disabled = false;
}
})
.catch(error => {
console.error('Erreur lors de la requête AJAX:', error);
button.textContent = 'Voir plus';
button.disabled = false;
});
});
});
// Scroll vers le haut avec effet moderne quand on clique sur le logo du footer
const footerLogo = document.querySelector('.footer-logo');
if (footerLogo) {
footerLogo.addEventListener('click', function() {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
});
}
});
-23
View File
@@ -1,23 +0,0 @@
<?php
// Inclure la configuration
require_once '../includes/config.php';
// Définir le type de contenu en JavaScript
header('Content-Type: application/javascript');
// Empêcher la mise en cache navigateur de la configuration dynamique
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: 0');
?>
window.addEventListener("load", () => {
const mastodonTimeline = new MastodonTimeline.Init({
instanceUrl: '<?php echo MASTODON_INSTANCE_URL; ?>',
dateFormatLocale: '<?php echo MASTODON_DATE_FORMAT; ?>',
btnSeeMore: "<?php echo MASTODON_BTN_SEE_MORE; ?>",
btnReload: "<?php echo MASTODON_BTN_RELOAD; ?>",
maxNbPostFetch: '<?php echo MASTODON_MAX_POST_FETCH; ?>',
maxNbPostShow: '<?php echo MASTODON_MAX_POST_SHOW; ?>',
defaultTheme: 'dark',
ignorePaginationLink: true // Ignorer l'en-tête Link pour les versions Mastodon v4+
});
});
File diff suppressed because one or more lines are too long
-133
View File
@@ -1,133 +0,0 @@
/**
* Pleroma to Mastodon API Response Adapter
*
* This script intercepts fetch requests to Pleroma instances and adapts
* the responses to match the structure expected by mastodon-timeline.umd.js
*/
(function() {
'use strict';
// Store original fetch
const originalFetch = window.fetch;
// Override fetch globally
window.fetch = async function(...args) {
const response = await originalFetch.apply(this, args);
// Only intercept Pleroma API timeline and emoji requests
const url = args[0];
if (typeof url === 'string' &&
(url.includes('/api/v1/timelines/') ||
url.includes('/api/v1/accounts/') && url.includes('/statuses'))) {
// Clone the response so we can modify it
const clonedResponse = response.clone();
try {
const data = await clonedResponse.json();
// Adapt the response structure
const adaptedData = Array.isArray(data)
? data.map(adaptPost)
: adaptPost(data);
// Create a new response with adapted data
return new Response(JSON.stringify(adaptedData), {
status: response.status,
statusText: response.statusText,
headers: response.headers
});
} catch (error) {
console.warn('Pleroma adapter: Failed to parse response, returning original', error);
return response;
}
}
return response;
};
/**
* Adapt a single Pleroma post to Mastodon format
*/
function adaptPost(post) {
if (!post || typeof post !== 'object') {
return post;
}
// Create a copy to avoid mutating the original
const adapted = { ...post };
// Adapt media attachments
if (adapted.media_attachments && Array.isArray(adapted.media_attachments)) {
adapted.media_attachments = adapted.media_attachments.map(adaptMediaAttachment);
}
// Adapt reblog if present
if (adapted.reblog) {
adapted.reblog = adaptPost(adapted.reblog);
}
return adapted;
}
/**
* Adapt media attachment to include missing meta fields
*/
function adaptMediaAttachment(attachment) {
if (!attachment || typeof attachment !== 'object') {
return attachment;
}
// If meta already exists and has required fields, return as-is
if (attachment.meta?.original?.width && attachment.meta?.small?.aspect) {
return attachment;
}
// Create adapted attachment
const adapted = { ...attachment };
// Default dimensions (will work for most cases)
const defaultWidth = 1280;
const defaultHeight = 720;
const defaultAspect = defaultWidth / defaultHeight;
// Try to extract dimensions from Pleroma's meta if available
let width = defaultWidth;
let height = defaultHeight;
if (attachment.pleroma?.mime_type) {
// For videos, use 16:9 ratio
if (attachment.pleroma.mime_type.startsWith('video/')) {
width = 1920;
height = 1080;
}
// For images, use standard web dimensions
else if (attachment.pleroma.mime_type.startsWith('image/')) {
width = 1200;
height = 800;
}
}
// Ensure meta structure exists
adapted.meta = adapted.meta || {};
// Add original dimensions
adapted.meta.original = adapted.meta.original || {
width: width,
height: height,
aspect: width / height
};
// Add small (preview) dimensions
adapted.meta.small = adapted.meta.small || {
width: Math.floor(width / 2),
height: Math.floor(height / 2),
aspect: width / height
};
return adapted;
}
console.log('Pleroma adapter initialized: API responses will be adapted for Mastodon compatibility');
})();
-12
View File
@@ -1,12 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
// Gestion des clics sur les vidéos
const videoCards = document.querySelectorAll('.video-card');
videoCards.forEach(card => {
card.addEventListener('click', function() {
const videoId = this.dataset.videoId;
if (videoId) {
window.location.href = 'video.php?id=' + videoId;
}
});
});
});
-242
View File
@@ -1,242 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mentions Légales - FEDIVERSE OKI</title>
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
<!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="img/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png">
<link rel="manifest" href="site.webmanifest">
<link rel="icon" type="image/x-icon" href="img/favicon.ico">
<meta name="theme-color" content="#ffffff">
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="Mentions Légales - FEDIVERSE OKI">
<meta property="og:description" content="Consultez les mentions légales de FEDIVERSE OKI. Informations légales, conditions d'utilisation et politique de confidentialité de notre plateforme multimédia.">
<meta property="og:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
<meta property="og:url" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; ?>">
<meta property="og:type" content="website">
<meta property="og:site_name" content="FEDIVERSE OKI">
<meta property="og:locale" content="fr_FR">
<!-- Twitter Card Meta Tags -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Mentions Légales - FEDIVERSE OKI">
<meta name="twitter:description" content="Consultez les mentions légales de FEDIVERSE OKI. Informations légales, conditions d'utilisation et politique de confidentialité de notre plateforme multimédia.">
<meta name="twitter:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
<!-- Script pour éviter le flash en mode sombre -->
<script>
(function() {
const savedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const shouldUseDark = savedTheme === 'dark' || (!savedTheme && systemPrefersDark);
if (shouldUseDark) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
</head>
<body>
<?php
// Inclure la configuration
require_once 'includes/config.php';
// Appliquer les en-têtes de sécurité
setSecurityHeaders();
?>
<?php include 'includes/sidebar.php'; ?>
<!-- Contenu principal -->
<div class="main-content">
<?php include 'includes/header.php'; ?>
<!-- Section Mentions Légales -->
<div class="section-header">
<div class="section-logo">
<img src="img/logo.png" alt="FEDIVERSE OKI">
</div>
<h2 class="section-title">Mentions Légales</h2>
</div>
<div class="info-section">
<h3 class="info-header">1. Acceptation des conditions</h3>
<p class="info-text">
En accédant et en utilisant le site web <strong><?php echo APP_HOST_NAME; ?></strong>, vous acceptez les présentes conditions générales d'utilisation.
Si vous n'acceptez pas ces conditions, veuillez ne pas utiliser ce site.
</p>
<h3 class="info-header">2. Description du service</h3>
<p class="info-text">
<strong><?php echo SITE_NAME; ?></strong> <?php echo LEGAL_SERVICE_DESCRIPTION; ?>
</p>
<h3 class="info-header">3. Responsabilité des utilisateurs</h3>
<p class="info-text">
Les utilisateurs s'engagent à ne pas utiliser le site à des fins illégales ou préjudiciables.
L'utilisateur est seul responsable de tout contenu qu'il pourrait partager via les fonctionnalités
interactives du site, le cas échéant.
</p>
<h3 class="info-header">4. Modification des conditions</h3>
<p class="info-text">
<strong><?php echo ORGANIZATION_SHORT_NAME; ?> - <?php echo ORGANIZATION_NAME; ?></strong>, se réserve le droit de modifier ces conditions à tout moment. Les utilisateurs seront
informés des modifications importantes. L'utilisation continue du site après une modification des
conditions constitue une acceptation de ces modifications.
</p>
<h3 class="info-header">5. Loi applicable et juridiction</h3>
<p class="info-text">
Les présentes conditions sont régies par le droit français <strong>(pour l'instant)</strong>. Tout litige relatif à l'interprétation
ou à l'exécution des présentes conditions sera soumis aux tribunaux compétents.
</p>
<h3 class="info-header">6. Licences Creative Commons</h3>
<p class="info-text">
Certains contenus proposés sur <strong><?php echo APP_HOST_NAME; ?></strong> sont disponibles sous licence <strong>Creative Commons</strong>.
Les licences <strong>Creative Commons</strong> permettent aux créateurs de contenu de partager leur travail tout
en définissant les conditions d'utilisation.
</p>
<div class="cc-licenses">
<div class="cc-license">
<a href="https://creativecommons.org/publicdomain/zero/1.0/deed.fr" target="_blank" rel="noopener noreferrer">
<img src="https://mirrors.creativecommons.org/presskit/buttons/88x31/png/cc-zero.png" alt="CC0" class="cc-logo">
</a>
<h4>CC0 (Domaine Public)</h4>
<p>Aucun droit d'auteur. Vous pouvez copier, modifier, distribuer et représenter l'œuvre, même à des fins commerciales, sans demander d'autorisation.</p>
</div>
<div class="cc-license">
<a href="https://creativecommons.org/licenses/by/4.0/deed.fr" target="_blank" rel="noopener noreferrer">
<img src="https://mirrors.creativecommons.org/presskit/buttons/88x31/png/by.png" alt="CC BY" class="cc-logo">
</a>
<h4>CC BY (Attribution)</h4>
<p>Vous êtes autorisé à partager et adapter l'œuvre, même à des fins commerciales, à condition de créditer l'auteur original.</p>
</div>
<div class="cc-license">
<a href="https://creativecommons.org/licenses/by-sa/4.0/deed.fr" target="_blank" rel="noopener noreferrer">
<img src="https://mirrors.creativecommons.org/presskit/buttons/88x31/png/by-sa.png" alt="CC BY-SA" class="cc-logo">
</a>
<h4>CC BY-SA (Attribution - Partage dans les Mêmes Conditions)</h4>
<p>Vous êtes autorisé à partager et adapter l'œuvre, même à des fins commerciales, à condition de créditer l'auteur original et de partager votre travail sous la même licence.</p>
</div>
<div class="cc-license">
<a href="https://creativecommons.org/licenses/by-nd/4.0/deed.fr" target="_blank" rel="noopener noreferrer">
<img src="https://mirrors.creativecommons.org/presskit/buttons/88x31/png/by-nd.png" alt="CC BY-ND" class="cc-logo">
</a>
<h4>CC BY-ND (Attribution - Pas de Modification)</h4>
<p>Vous êtes autorisé à partager l'œuvre, même à des fins commerciales, à condition de créditer l'auteur original et de ne pas modifier l'œuvre.</p>
</div>
<div class="cc-license">
<a href="https://creativecommons.org/licenses/by-nc/4.0/deed.fr" target="_blank" rel="noopener noreferrer">
<img src="https://mirrors.creativecommons.org/presskit/buttons/88x31/png/by-nc.png" alt="CC BY-NC" class="cc-logo">
</a>
<h4>CC BY-NC (Attribution - Pas d'Utilisation Commerciale)</h4>
<p>Vous êtes autorisé à partager et adapter l'œuvre à des fins non commerciales, à condition de créditer l'auteur original.</p>
</div>
<div class="cc-license">
<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/deed.fr" target="_blank" rel="noopener noreferrer">
<img src="https://mirrors.creativecommons.org/presskit/buttons/88x31/png/by-nc-sa.png" alt="CC BY-NC-SA" class="cc-logo">
</a>
<h4>CC BY-NC-SA (Attribution - Pas d'Utilisation Commerciale - Partage dans les Mêmes Conditions)</h4>
<p>Vous êtes autorisé à partager et adapter l'œuvre à des fins non commerciales, à condition de créditer l'auteur original et de partager votre travail sous la même licence.</p>
</div>
<div class="cc-license">
<a href="https://creativecommons.org/licenses/by-nc-nd/4.0/deed.fr" target="_blank" rel="noopener noreferrer">
<img src="https://mirrors.creativecommons.org/presskit/buttons/88x31/png/by-nc-nd.png" alt="CC BY-NC-ND" class="cc-logo">
</a>
<h4>CC BY-NC-ND (Attribution - Pas d'Utilisation Commerciale - Pas de Modification)</h4>
<p>Vous êtes autorisé à partager l'œuvre à des fins non commerciales, à condition de créditer l'auteur original et de ne pas modifier l'œuvre.</p>
</div>
</div>
<p class="info-text">
Pour plus d'informations sur les licences Creative Commons, veuillez consulter le site officiel :
<a href="https://creativecommons.org/licenses/" target="_blank" rel="noopener noreferrer">Creative Commons</a>.
</p>
<h3 class="info-header">7. Hébergement et Webmaster</h3>
<p class="info-text">
Ce site web est hébergé par <strong><?php echo LEGAL_HOST_NAME; ?></strong>, <strong><?php echo LEGAL_HOST_COMPANY; ?></strong>, <strong><?php echo LEGAL_HOST_RCS; ?></strong>, dont le siège social est situé au <strong><?php echo LEGAL_HOST_ADDRESS; ?></strong>.
</p>
<p class="info-text">
Le webmaster du site est <strong><?php echo LEGAL_WEBMASTER_NAME; ?></strong>. Pour toute question technique concernant le site, vous pouvez le contacter via l'adresse email <a href="mailto:<?php echo LEGAL_WEBMASTER_EMAIL; ?>"><?php echo LEGAL_WEBMASTER_EMAIL; ?></a>.
</p>
<h3 class="info-header">8. Licence du site</h3>
<p class="info-text">
Le code source du site <strong><?php echo APP_HOST_NAME; ?></strong> est publié sous licence <?php echo LEGAL_LICENSE; ?>. Cette licence garantit à tous les utilisateurs la liberté d'exécuter, d'étudier, de modifier et de redistribuer le logiciel, à condition que les modifications soient également partagées sous les mêmes conditions.
</p>
<p class="info-text">
La licence AGPL-V3 étend la licence GPL standard en imposant que le code source soit disponible pour tous ceux qui interagissent avec le programme à travers un réseau. Pour consulter l'intégralité de la licence, vous pouvez visiter <a href="<?php echo LEGAL_LICENSE_URL; ?>" target="_blank" rel="noopener noreferrer">le site officiel de GNU</a>.
</p>
<p class="info-text">
Le code source de ce site est disponible sur Codeberg : <a href="<?php echo LEGAL_SOURCE_CODE_URL; ?>" target="_blank" rel="noopener noreferrer"><?php echo LEGAL_SOURCE_CODE_URL; ?></a>
</p>
<h3 class="info-header">9. Icônes et marques tierces</h3>
<p class="info-text">
<strong>Font Awesome</strong> : Les icônes utilisées sur ce site proviennent de Font Awesome, distribuées sous licence SIL OFL 1.1, compatible avec notre licence AGPL-V3.
</p>
<p class="info-text">
<strong>Marques déposées</strong> : Les noms et logos des réseaux sociaux (Facebook, YouTube, Instagram, TikTok, X/Twitter, Mastodon) sont des marques déposées de leurs propriétaires respectifs. Leur utilisation sur ce site se limite à l'identification de nos comptes officiels sur ces plateformes et relève de l'usage loyal.
</p>
<h3 class="info-header">10. Vie privée, cookies et publicité</h3>
<p class="info-text">
<strong>Cookies</strong> : Ce site utilise uniquement les cookies de session PHP, indispensables au bon fonctionnement technique du site. Ces cookies temporaires disparaissent dès que vous fermez votre navigateur - comme par magie ! 🍪✨
<br>Aucun cookie publicitaire, de tracking ou de pistage n'est utilisé. L'extension "<i>I still don't care about cookies</i>" reste donc largement inutile sur notre site - vous n'aurez jamais à vous soucier des bannières de consentement chez nous !
</p>
<p class="info-text">
<strong>Publicité</strong> : Nous avons fait le choix de ne diffuser aucune publicité sur <strong><?php echo APP_HOST_NAME; ?></strong>. Votre expérience de navigation est entièrement libre de toute annonce commerciale.
</p>
<p class="info-text">
<strong>Bloqueurs de publicité</strong> : Si vous utilisez un bloqueur de publicité, celui-ci ne bloquera que d'éventuels outils d'analyse tels que Plausible, qui respectent votre vie privée et ne collectent que des données anonymes à des fins statistiques.
<br>Nous vous invitons donc gentiment à le désactiver pour notre site - promis, nous ne vous montrerons pas de pubs pour de la vanille bourbon que vous avez regardée il y a trois semaines !
<br>Vos données nous aident simplement à savoir si quelqu'un regarde ce qu'on fait... 😊
</p>
<p class="info-text">
<strong>Adresse IP et technologie P2P</strong> : Notre plateforme utilise la technologie <strong>P2P (Peer-to-Peer)</strong> pour diffuser les vidéos.
<br>Si vous avez déjà utilisé <strong>μTorrent, eMule, iMesh, Shareaza</strong> ou tout autre logiciel de <strong>partage de fichiers</strong>, vous connaissez le principe : au lieu d'avoir un seul serveur qui s'épuise à envoyer la vidéo à tout le monde, chaque utilisateur qui regarde une vidéo aide aussi à la diffuser aux autres.
<br>C'est comme si, au lieu d'avoir <strong>un seul serveur</strong>, tout le monde devenait un <strong>mini-serveur</strong> !
</p>
<p class="info-text">
Pour que cette magie opère, nous devons utiliser votre <strong>adresse IP</strong> - ne vous inquiétez pas, nous ne la revendons pas à des sites qui veulent vous bombarder de publicités pour des <strong>lentilles de Cilaos</strong>.
<br>Elle sert uniquement à établir ces connexions <strong>P2P</strong> qui rendent le visionnage de vidéos <strong>plus fluide pour tout le monde</strong>.
</p>
<h3 class="info-header">11. Contact</h3>
<p class="info-text">
Pour toute question concernant ces conditions générales d'utilisation, veuillez nous contacter à
l'adresse suivante : <a href="mailto:<?php echo LEGAL_CONTACT_EMAIL; ?>"><?php echo LEGAL_CONTACT_EMAIL; ?></a>
</p>
<p class="info-text">
Date de dernière mise à jour : VOTRE-DATE-MAJ
</p>
</div>
</div>
<?php include 'includes/footer.php'; ?>
<?php include 'includes/mobile-menu.php'; ?>
<script src="js/main.js"></script>
</body>
</html>
+6585
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "annu-kute-ced",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"postbuild": "node scripts/postbuild-csp.mjs",
"prebuild": "node scripts/fetch-podcast-cover.mjs && node scripts/optimize-remote-images.mjs"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/node": "^26.1.1",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"typescript": "^6.0.3",
"vite": "^8.0.16",
"vite-plugin-pwa": "^1.3.0"
},
"dependencies": {
"@fontsource/archivo": "^5.3.0",
"@fontsource/inter": "^5.3.0"
}
}
-265
View File
@@ -1,265 +0,0 @@
<?php
// Inclure la configuration
require_once 'includes/config.php';
// Vérifier si le mode compte à rebours est activé
if (defined('COUNTDOWN_ENABLED') && COUNTDOWN_ENABLED === true) {
// Rediriger vers la page de compte à rebours
require_once 'countdown.php';
exit;
}
// Inclure les fonctions de données structurées
require_once 'includes/structured-data.php';
// Appliquer les en-têtes de sécurité
setSecurityHeaders();
// Récupération et validation de la requête de recherche et de la page courante
$query = isset($_GET['q']) ? $_GET['q'] : '';
$query = validateSearchQuery($query);
$currentSearchPage = validatePageNumber($_GET['page'] ?? 1);
// Déterminer si la recherche est par tag
$isTagSearch = !empty($query) && substr($query, 0, 1) === '#';
$searchTag = $isTagSearch ? substr($query, 1) : '';
// Rechercher les vidéos via l'API PeerTube si une requête est soumise
$searchResults = !empty($query) ? searchVideos($query, COUNT_VIDEO_SEARCH) : [];
// Définir le nombre total de résultats
$resultsCount = count($searchResults);
// Calculer le nombre total de pages
$totalPages = ceil($resultsCount / VIDEOS_PER_PAGE);
// S'assurer que la page actuelle est valide
$currentSearchPage = min($currentSearchPage, max(1, $totalPages));
// Calculer les indices de début et de fin pour la page actuelle
$startIndex = ($currentSearchPage - 1) * VIDEOS_PER_PAGE;
$endIndex = min($startIndex + VIDEOS_PER_PAGE, $resultsCount);
// Extraire les vidéos pour la page actuelle
$currentPageVideos = [];
if ($resultsCount > 0) {
$currentPageVideos = array_slice($searchResults, $startIndex, VIDEOS_PER_PAGE);
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php echo !empty($query) ? 'Recherche: ' . htmlspecialchars($query) . ' - ' : 'Recherche - '; ?><?php echo SITE_NAME; ?></title>
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
<!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="img/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png">
<link rel="manifest" href="site.webmanifest">
<link rel="icon" type="image/x-icon" href="img/favicon.ico">
<meta name="theme-color" content="#ffffff">
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="<?php echo !empty($query) ? 'Recherche: ' . htmlspecialchars($query) . ' - ' : 'Recherche - '; ?><?php echo SITE_NAME; ?>">
<meta property="og:description" content="<?php echo !empty($query) ? 'Résultats de recherche pour \"' . htmlspecialchars($query) . '\" sur ' . SITE_NAME . '. Découvrez des vidéos correspondantes à votre recherche.' : 'Recherchez des vidéos sur ' . SITE_NAME . '. Plateforme multimédia avec un contenu de qualité et exclusif.'; ?>">
<meta property="og:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
<meta property="og:url" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; ?>">
<meta property="og:type" content="website">
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
<meta property="og:locale" content="fr_FR">
<!-- Twitter Card Meta Tags -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="<?php echo !empty($query) ? 'Recherche: ' . htmlspecialchars($query) . ' - ' : 'Recherche - '; ?><?php echo SITE_NAME; ?>">
<meta name="twitter:description" content="<?php echo !empty($query) ? 'Résultats de recherche pour \"' . htmlspecialchars($query) . '\" sur ' . SITE_NAME . '. Découvrez des vidéos correspondantes à votre recherche.' : 'Recherchez des vidéos sur ' . SITE_NAME . '. Plateforme multimédia avec un contenu de qualité et exclusif.'; ?>">
<meta name="twitter:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
<?php if (!empty($query) && !empty($currentPageVideos)): ?>
<!-- Données structurées JSON-LD pour la page de recherche -->
<?php
$searchUrl = getBaseUrl() . '/recherche.php?q=' . urlencode($query);
$searchDescription = $isTagSearch
? 'Vidéos avec le tag ' . htmlspecialchars($searchTag) . ' sur ' . SITE_NAME
: 'Résultats de recherche pour "' . htmlspecialchars($query) . '" sur ' . SITE_NAME;
$searchTitle = $isTagSearch
? 'Tag: ' . htmlspecialchars($searchTag)
: 'Recherche: ' . htmlspecialchars($query);
$collectionJsonLd = generateVideoCollectionJsonLd($searchTitle, $searchDescription, $currentPageVideos, $searchUrl);
outputJsonLd($collectionJsonLd);
// Générer les fils d'Ariane pour la recherche
$breadcrumbs = [
['name' => 'Accueil', 'url' => getBaseUrl()],
['name' => 'Recherche', 'url' => getBaseUrl() . '/recherche.php'],
['name' => $searchTitle, 'url' => $searchUrl]
];
$breadcrumbJsonLd = generateBreadcrumbJsonLd($breadcrumbs);
outputJsonLd($breadcrumbJsonLd);
?>
<?php endif; ?>
<!-- Script pour éviter le flash en mode sombre -->
<script nonce="<?php echo getCspNonce(); ?>">
(function() {
const savedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const shouldUseDark = savedTheme === 'dark' || (!savedTheme && systemPrefersDark);
if (shouldUseDark) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
</head>
<body>
<?php include 'includes/sidebar.php'; ?>
<!-- Contenu principal -->
<div class="main-content">
<?php include 'includes/header.php'; ?>
<!-- Section de recherche -->
<div class="video-section search-page" data-search-query="<?php echo htmlspecialchars($query); ?>">
<div class="section-header">
<div class="section-logo">
<img src="img/logo.png" alt="<?php echo SITE_NAME; ?>">
</div>
<?php if (!empty($query)): ?>
<?php if ($isTagSearch): ?>
<h2 class="section-title">Vidéos avec le hashtag : "<?php echo htmlspecialchars($searchTag); ?>"</h2>
<?php else: ?>
<h2 class="section-title">Résultats pour : "<?php echo htmlspecialchars($query); ?>"</h2>
<?php endif; ?>
<?php else: ?>
<h2 class="section-title">Rechercher des vidéos</h2>
<?php endif; ?>
</div>
<?php if (!empty($query)): ?>
<?php if ($resultsCount > 0): ?>
<div class="search-results-count">
<?php if ($isTagSearch): ?>
<p><?php echo $resultsCount; ?> vidéo<?php echo $resultsCount > 1 ? 's' : ''; ?> avec le hashtag #<?php echo htmlspecialchars($searchTag); ?></p>
<?php else: ?>
<p><?php echo $resultsCount; ?> résultat<?php echo $resultsCount > 1 ? 's' : ''; ?> trouvé<?php echo $resultsCount > 1 ? 's' : ''; ?></p>
<?php endif; ?>
</div>
<div class="video-grid category-videos">
<?php foreach ($currentPageVideos as $video): ?>
<div class="video-card" data-video-id="<?php echo $video['id']; ?>">
<div class="video-thumbnail">
<img src="<?php echo $video['thumbnail']; ?>" alt="<?php echo htmlspecialchars($video['title']); ?>">
<div class="video-play-icon">
<i class="fas fa-play-circle"></i>
</div>
<span class="video-duration"><?php echo formatDuration($video['duration']); ?></span>
</div>
<div class="video-info">
<h3 class="video-title"><?php echo htmlspecialchars($video['title']); ?></h3>
<div class="video-channel">
<?php if (strpos($video['channelAvatar'], 'default-avatar') !== false || empty($video['channelAvatar'])): ?>
<div class="channel-avatar-placeholder">
<i class="fas fa-user-circle"></i>
</div>
<?php else: ?>
<img src="<?php echo $video['channelAvatar']; ?>" alt="<?php echo htmlspecialchars($video['channel']); ?>" class="channel-avatar">
<?php endif; ?>
<span class="channel-name"><?php echo htmlspecialchars($video['channel']); ?></span>
</div>
<div class="video-metadata">
<?php if (defined('SHOW_VIDEO_VIEWS') && SHOW_VIDEO_VIEWS): ?>
<span class="video-views"><i class="fas fa-eye"></i> <?php echo formatViewCount($video['views']); ?> vues</span>
<?php endif; ?>
<span class="video-date"><i class="far fa-calendar-alt"></i> <?php echo formatDate($video['date']); ?></span>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php if ($totalPages > 1): ?>
<div class="pagination">
<?php if ($currentSearchPage > 1): ?>
<a href="recherche.php?q=<?php echo htmlspecialchars(urlencode($query)); ?>&page=<?php echo intval($currentSearchPage - 1); ?>" class="page-link prev">
<i class="fas fa-chevron-left"></i> Précédent
</a>
<?php endif; ?>
<div class="page-numbers">
<?php
// Déterminer la plage de pages à afficher
$pageRange = 2; // Nombre de pages à afficher de chaque côté
$startPage = max(1, $currentSearchPage - $pageRange);
$endPage = min($totalPages, $currentSearchPage + $pageRange);
// Afficher le lien vers la première page si nécessaire
if ($startPage > 1) {
echo '<a href="recherche.php?q=' . htmlspecialchars(urlencode($query)) . '&page=1" class="page-number">1</a>';
if ($startPage > 2) {
echo '<span class="page-dots">...</span>';
}
}
// Afficher les liens de pagination
for ($i = $startPage; $i <= $endPage; $i++) {
if ($i == $currentSearchPage) {
echo '<span class="page-number current">' . $i . '</span>';
} else {
echo '<a href="recherche.php?q=' . htmlspecialchars(urlencode($query)) . '&page=' . intval($i) . '" class="page-number">' . intval($i) . '</a>';
}
}
// Afficher le lien vers la dernière page si nécessaire
if ($endPage < $totalPages) {
if ($endPage < $totalPages - 1) {
echo '<span class="page-dots">...</span>';
}
echo '<a href="recherche.php?q=' . htmlspecialchars(urlencode($query)) . '&page=' . intval($totalPages) . '" class="page-number">' . intval($totalPages) . '</a>';
}
?>
</div>
<?php if ($currentSearchPage < $totalPages): ?>
<a href="recherche.php?q=<?php echo htmlspecialchars(urlencode($query)); ?>&page=<?php echo intval($currentSearchPage + 1); ?>" class="page-link next">
Suivant <i class="fas fa-chevron-right"></i>
</a>
<?php endif; ?>
</div>
<?php endif; ?>
<?php else: ?>
<div class="no-videos-message">
<i class="fas fa-search-minus"></i>
<h3>Aucun résultat trouvé</h3>
<?php if ($isTagSearch): ?>
<p>Aucune vidéo n'a été trouvée avec le hashtag "#<?php echo htmlspecialchars($searchTag); ?>".</p>
<?php else: ?>
<p>Aucune vidéo ne correspond à votre recherche "<?php echo htmlspecialchars($query); ?>".</p>
<?php endif; ?>
<p>Essayez avec d'autres termes ou consultez les vidéos populaires sur la <a href="index.php">page d'accueil</a>.</p>
</div>
<?php endif; ?>
<?php else: ?>
<div class="search-instructions">
<i class="fas fa-search"></i>
<h3>Effectuez une recherche</h3>
<p>Utilisez le formulaire ci-dessus pour rechercher des vidéos par titre, description ou tags.</p>
<p class="mt-2"><strong>Astuce :</strong> Pour rechercher par tag, commencez votre recherche par # (exemple: #réunion)</p>
</div>
<?php endif; ?>
</div>
</div>
<?php include 'includes/footer.php'; ?>
<?php include 'includes/mobile-menu.php'; ?>
<script src="js/main.js"></script>
<script src="js/search.js"></script>
</body>
</html>
-25
View File
@@ -1,25 +0,0 @@
User-agent: *
Allow: /
Allow: /index
Allow: /index.php
Allow: /direct
Allow: /direct.php
Allow: /categories
Allow: /categories.php
Allow: /video
Allow: /video.php
Allow: /recherche
Allow: /recherche.php
Allow: /mentions-legales
Allow: /mentions-legales.php
Allow: /img/
Disallow: /includes/
Disallow: /js/
Disallow: /css/
Disallow: /ajax/
Disallow: /docs/
Disallow: /conf/
Disallow: /cache/
# Sitemap
Sitemap: https://VOTRE-DOMAINE/sitemap.xml
+51
View File
@@ -0,0 +1,51 @@
import { writeFileSync, statSync, mkdirSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { join, dirname, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
const FEED_URL = 'https://kute.o-k-i.net/@annu_kute_cedric/feed';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const EPISODES_DIR = join(ROOT, 'static', 'images', 'episodes');
const CHANNEL_COVER = join(ROOT, 'static', 'images', 'podcast-cover.webp');
mkdirSync(EPISODES_DIR, { recursive: true });
const xml = await (await fetch(FEED_URL)).text();
const urls = new Set();
const channel = xml.split('<item')[0] ?? '';
const channelMatch =
channel.match(/<itunes:image[^>]*href="([^"]+)"/i) ?? channel.match(/<url>([^<]+)<\/url>/i);
if (channelMatch) urls.add(channelMatch[1]);
for (const item of xml.split(/<item[\s>]/i).slice(1)) {
const match = item.match(/<itunes:image[^>]*href="([^"]+)"/i);
if (match) urls.add(match[1]);
}
let total = 0;
for (const url of urls) {
const isChannel = url === channelMatch?.[1];
const out = isChannel
? CHANNEL_COVER
: join(EPISODES_DIR, basename(url).replace(/\.(png|jpe?g|gif|webp)$/i, '.webp'));
try {
const res = await fetch(url);
if (!res.ok) {
console.warn(`[covers] HTTP ${res.status} pour ${url}`);
continue;
}
const source = Buffer.from(await res.arrayBuffer());
const tmp = `/tmp/opencode/cover-${Date.now()}`;
writeFileSync(tmp, source);
execFileSync('convert', [tmp, '-resize', '512x512>', '-quality', '82', '-define', 'webp:method=6', out]);
const size = statSync(out).size;
total += size;
console.log(`[covers] ${basename(out)} : ${Math.round(source.length / 1024)} Ko → ${Math.round(size / 1024)} Ko`);
} catch (err) {
console.warn(`[covers] échec pour ${url}:`, err.message);
}
}
console.log(`[covers] total optimisé : ${Math.round(total / 1024)} Ko`);
+112
View File
@@ -0,0 +1,112 @@
import { createHash } from 'node:crypto';
import { mkdirSync, writeFileSync, statSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const OUT_DIR = join(ROOT, 'static', 'images', 'remote');
const MAP_FILE = join(ROOT, 'src', 'lib', 'server', 'image-map.json');
const PEERTUBE = 'https://gade.o-k-i.net';
const MASTODON = 'https://bokante.o-k-i.net';
const MASTODON_ACCT = 'cedric';
const PRIORITY_CATEGORIES = [11, 15, 4, 9, 10];
mkdirSync(OUT_DIR, { recursive: true });
const urls = new Map();
function localPathFor(remoteUrl, width) {
const hash = createHash('sha1').update(remoteUrl).digest('hex').slice(0, 16);
return { local: `/images/remote/${hash}.webp`, width };
}
async function collectVideos(params, width) {
const query = new URLSearchParams({ isLocal: 'true', ...params });
try {
const res = await fetch(`${PEERTUBE}/api/v1/videos?${query}`);
if (!res.ok) return;
const data = await res.json();
for (const video of data?.data ?? []) {
if (video.previewPath) {
const url = `${PEERTUBE}${video.previewPath}`;
if (!urls.has(url)) urls.set(url, localPathFor(url, width));
}
const avatar = video.channel?.avatars?.[0]?.path;
if (avatar) {
const url = `${PEERTUBE}${avatar}`;
if (!urls.has(url)) urls.set(url, localPathFor(url, 96));
}
}
} catch (err) {
console.warn('[images] collectVideos échoué:', err.message);
}
}
// Pool récent (couvre récentes, shorts et l'essentiel des catégories) + tendances + chaque catégorie prioritaire
await collectVideos({ sort: '-publishedAt', count: '100' }, 640);
await collectVideos({ sort: '-trending', count: '6' }, 640);
for (const id of PRIORITY_CATEGORIES) {
await collectVideos({ categoryOneOf: String(id), sort: '-publishedAt', count: '6' }, 640);
}
// Médias de la timeline Mastodon
try {
const account = await (
await fetch(`${MASTODON}/api/v1/accounts/lookup?acct=${encodeURIComponent(MASTODON_ACCT)}`)
).json();
if (account?.id) {
const statuses = await (
await fetch(`${MASTODON}/api/v1/accounts/${account.id}/statuses?limit=10&exclude_replies=true`)
).json();
for (const status of statuses ?? []) {
const source = status.reblog ?? status;
for (const media of source.media_attachments ?? []) {
const url = media.preview_url ?? media.url;
if (url && !urls.has(url)) urls.set(url, localPathFor(url, 800));
}
}
}
} catch (err) {
console.warn('[images] médias Mastodon échoués:', err.message);
}
console.log(`[images] ${urls.size} image(s) distante(s) à optimiser`);
const map = {};
let total = 0;
let done = 0;
for (const [remote, { local, width }] of urls) {
const out = join(OUT_DIR, local.split('/').pop());
try {
const res = await fetch(remote);
if (!res.ok) {
console.warn(`[images] HTTP ${res.status} pour ${remote}`);
continue;
}
const source = Buffer.from(await res.arrayBuffer());
const tmp = `/tmp/opencode/img-${done}`;
writeFileSync(tmp, source);
execFileSync('convert', [
tmp,
'-auto-orient',
'-resize',
`${width}x${width}>`,
'-quality',
'80',
'-define',
'webp:method=6',
out
]);
map[remote] = local;
total += statSync(out).size;
done++;
} catch (err) {
console.warn(`[images] échec pour ${remote}:`, err.message);
}
}
writeFileSync(MAP_FILE, JSON.stringify(map, null, '\t') + '\n');
console.log(`[images] ${done} optimisée(s), ${Math.round(total / 1024)} Ko au total`);
+58
View File
@@ -0,0 +1,58 @@
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const BUILD_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'build');
const scriptRe = /<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi;
function* walk(dir) {
for (const entry of readdirSync(dir)) {
const path = join(dir, entry);
if (statSync(path).isDirectory()) yield* walk(path);
else if (entry.endsWith('.html')) yield path;
}
}
// La politique complète (hors script-src) est lue depuis build/_headers,
// unique source de vérité partagée avec .htaccess
const headers = readFileSync(join(BUILD_DIR, '_headers'), 'utf8');
const cspLine = headers
.split('\n')
.map((line) => line.trim())
.find((line) => line.startsWith('Content-Security-Policy:'));
if (!cspLine) throw new Error('[csp] Content-Security-Policy introuvable dans build/_headers');
const sharedPolicy = cspLine
.slice('Content-Security-Policy:'.length)
.trim()
// frame-ancestors est ignoré (et logué) dans une balise meta : il reste porté par l'en-tête HTTP
.replace(/frame-ancestors 'none';\s*/, '');
let pages = 0;
for (const file of walk(BUILD_DIR)) {
const html = readFileSync(file, 'utf8');
const hashes = new Set();
for (const match of html.matchAll(scriptRe)) {
const content = match[1];
if (!content.trim()) continue;
hashes.add(`'sha256-${createHash('sha256').update(content).digest('base64')}'`);
}
if (hashes.size === 0) continue;
const csp = `default-src 'self'; script-src 'self' ${[...hashes].join(' ')}; ${sharedPolicy}`;
const meta = `<meta http-equiv="Content-Security-Policy" content="${csp}" />`;
const anchor = html.match(/<meta charset="[^"]+"\s*\/?>/);
if (!anchor) {
console.warn(`[csp] pas d'ancre charset dans ${file}, page ignorée`);
continue;
}
writeFileSync(file, html.replace(anchor[0], `${anchor[0]}\n\t\t${meta}`));
pages++;
}
console.log(`[csp] meta CSP complète injectée dans ${pages} page(s)`);
-43
View File
@@ -1,43 +0,0 @@
{
"name": "FEDIVERSE OKI - ORGANISATION KA INTERNATIONALE",
"short_name": "FEDIVERSE OKI",
"description": "Plateforme multimédia indépendante",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#FF0000",
"orientation": "portrait-primary",
"icons": [
{
"src": "img/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "img/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "img/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
},
{
"src": "img/favicon-32x32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "img/favicon-16x16.png",
"sizes": "16x16",
"type": "image/png"
}
],
"categories": ["entertainment", "news", "social"],
"lang": "fr",
"scope": "/",
"prefer_related_applications": false
}
-70
View File
@@ -1,70 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://VOTRE-DOMAINE/</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://VOTRE-DOMAINE/index</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://VOTRE-DOMAINE/index.php</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://VOTRE-DOMAINE/direct</loc>
<changefreq>hourly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://VOTRE-DOMAINE/direct.php</loc>
<changefreq>hourly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://VOTRE-DOMAINE/mentions-legales</loc>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://VOTRE-DOMAINE/mentions-legales.php</loc>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://VOTRE-DOMAINE/recherche</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://VOTRE-DOMAINE/recherche.php</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://VOTRE-DOMAINE/categories</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://VOTRE-DOMAINE/categories.php</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://VOTRE-DOMAINE/video</loc>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://VOTRE-DOMAINE/video.php</loc>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<!-- Les pages dynamiques comme categories.php?id=X et video.php?id=X ne sont pas incluses
car elles dépendent des contenus disponibles sur l'instance PeerTube -->
</urlset>
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="%lang%">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
<meta name="theme-color" content="#0D0D0D" />
<link rel="icon" href="/images/favicon.ico" sizes="48x48" />
<link rel="icon" type="image/png" href="/images/favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="/images/favicon-16x16.png" sizes="16x16" />
<link rel="apple-touch-icon" href="/images/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="preload" href="/fonts/archivo-latin-700-normal.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/inter-latin-400-normal.woff2" as="font" type="font/woff2" crossorigin />
<link rel="stylesheet" href="/fonts/fonts.css" />
<script src="/theme.js"></script>
<script src="/registerSW.js" defer></script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const firstSegment = event.url.pathname.split('/')[1];
const lang = firstSegment === 'en' ? 'en' : 'fr';
return resolve(event, {
transformPageChunk: ({ html }) => html.replaceAll('%lang%', lang)
});
};
+14
View File
@@ -0,0 +1,14 @@
let current: HTMLAudioElement | null = $state(null);
export function claimAudio(element: HTMLAudioElement) {
if (current && current !== element) {
current.pause();
}
current = element;
}
export function releaseAudio(element: HTMLAudioElement) {
if (current === element) {
current = null;
}
}
+329
View File
@@ -0,0 +1,329 @@
<script lang="ts">
import { onMount } from 'svelte';
import { claimAudio, releaseAudio } from '$lib/audio-bus.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,
playTemplate = undefined
}: {
episodes: Episode[];
locale: Locale;
t: Bundle;
playTemplate?: string;
} = $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];
claimAudio(audio);
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() {
releaseAudio(audio);
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;
releaseAudio(audio);
}}
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">
{#if episode.date}
{format(t.podcast.episodeFrom, { date: formatDate(episode.date, locale) })}
{:else}
{episode.podcast}{#if episode.description}{episode.description}{/if}
{/if}
{#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(playTemplate ?? 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>
+88
View File
@@ -0,0 +1,88 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Locale } from '$lib/types';
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)) 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),
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>
+99
View File
@@ -0,0 +1,99 @@
<script lang="ts">
import CountdownClock from './CountdownClock.svelte';
import Icon from './Icon.svelte';
import type { Bundle } from '$lib/i18n';
import type { Locale } from '$lib/types';
let {
lock,
locale,
t
}: {
lock: { targetDate: string; schedule: { label: string; time: string }[] };
locale: Locale;
t: Bundle;
} = $props();
function onReached() {
window.location.assign('/');
}
</script>
<div class="flag-bar" aria-hidden="true"></div>
<main class="lock">
<Icon name="ka" class="lock-icon" />
<h1 class="lock-title">{t.countdown.title}</h1>
<p class="text-muted">{t.countdown.text}</p>
<CountdownClock target={lock.targetDate} {locale} {onReached} />
<h2 class="schedule-heading">{t.direct.inTimezones}</h2>
<ul class="schedule">
{#each lock.schedule as slot (slot.label)}
<li>
<span class="zone">{slot.label}</span>
<span>{slot.time}</span>
</li>
{/each}
</ul>
<p class="text-muted small">{t.countdown.back}</p>
</main>
<div class="flag-bar" aria-hidden="true"></div>
<style>
.lock {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: var(--space-3);
padding: var(--space-5) var(--space-3);
}
.lock :global(.lock-icon) {
width: 96px;
height: 96px;
}
.lock-title {
font-family: var(--font-display);
font-weight: 900;
text-transform: uppercase;
letter-spacing: -0.01em;
font-size: clamp(1.6rem, 5vw, 2.8rem);
}
.schedule-heading {
font-family: var(--font-display);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--muted);
}
.schedule {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.schedule li {
display: flex;
gap: var(--space-2);
}
.zone {
font-weight: 600;
min-width: 12ch;
color: var(--accent);
text-align: right;
}
.small {
font-size: 0.85rem;
}
</style>
+237
View File
@@ -0,0 +1,237 @@
<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);
text-decoration: underline;
text-underline-offset: 0.15em;
}
</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>
+110
View File
@@ -0,0 +1,110 @@
<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;
line-height: 1.9;
overflow-wrap: anywhere;
}
.post-content :global(a) {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 0.15em;
display: inline-block;
padding: 0.35rem 0.15rem;
}
.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>
@@ -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>
+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>
+216
View File
@@ -0,0 +1,216 @@
export const site = {
hostName: 'annukuteced.buzz',
baseUrl: 'https://annukuteced.buzz',
name: 'ANNU KUTE CED',
shortName: 'ANNU KUTE CED',
description: {
fr: 'Hub multimédia du podcast ANNU KUTE CED',
en: 'Multimedia hub for the ANNU KUTE CED podcast'
},
logo: '/images/logo.png',
ogImage: '/images/android-chrome-512x512.png',
contactEmail: 'kontak@o-k-i.net',
licenseName: 'GNU Affero General Public License version 3 (AGPL-V3)',
licenseUrl: 'https://www.gnu.org/licenses/agpl-3.0.html',
sourceCodeUrl: 'https://labola.o-k-i.net/cedric/annu-kute-ced',
defaultTimezone: 'America/Guadeloupe'
} as const;
export const legal = {
copyright: 'ANNU KUTE CED',
webmasterName: 'Cédric Famibelle-Pronzola',
webmasterEmail: 'contact@cedric-pronzola.dev',
hostName: 'o2Switch',
hostCompany: 'société au capital de 100 000 €',
hostRcs: 'immatriculée au RCS de Clermont-Ferrand sous le numéro 510 909 807',
hostAddress: '222 boulevard Gustave Flaubert, 63000 Clermont-Ferrand, France',
contactEmail: 'kontak@o-k-i.net'
} as const;
export const peertube = {
url: 'https://gade.o-k-i.net',
displayName: 'gade.o-k-i.net',
channelHandle: 'annu_kute_ced',
channelUrl: 'https://gade.o-k-i.net/c/annu_kute_ced/videos',
liveAccountName: 'annu_kute_ced',
showVideoViews: false,
tagShort: 'short',
tagIndependence: 'indépendance',
shortsMaxDuration: 180,
counts: {
recent: 6,
trending: 6,
shorts: 6,
independence: 6,
category: 6,
perPage: 12,
search: 20,
searchPool: 200
}
} as const;
export const hero = {
type: 'live' as 'live' | 'video' | 'playlist' | 'none',
videoId: '1aJ2u9euwF9fWKQhFxwFio',
videoTitle: 'Vidéo de présentation',
playlistPlatform: 'peertube' as 'peertube' | 'funkwhale' | 'castopod',
playlistInstanceUrl: '',
playlistId: '',
playlistTitle: '',
playlistDescription: ''
};
export const castopod = {
enabled: true,
url: 'https://kute.o-k-i.net',
podcastSlugs: ['annu_kute_cedric'],
podcastUrl: 'https://kute.o-k-i.net/@annu_kute_cedric',
episodesCount: 5
} as const;
export const mastodon = {
instanceUrl: 'https://bokante.o-k-i.net',
accountUrl: 'https://bokante.o-k-i.net/@cedric',
accountAcct: 'cedric',
maxPosts: 10
} as const;
export const funkwhale = {
enabled: true,
url: 'https://mizik.o-k-i.net',
displayName: 'mizik.o-k-i.net',
tracksCount: 10,
fetchSize: 50
} as const;
export const social = {
mastodon: mastodon.accountUrl,
facebook: '',
youtube: '',
instagram: '',
x: '',
tiktok: ''
} as const;
export const donations = {
enabled: true,
liberapayUrl: '',
kofiUrl: '',
stripeEnabled: false,
amounts: [1, 5, 10, 20, 50],
currency: 'EUR',
stripeOneTimeLinks: {} as Record<number | 'custom', string>,
stripeMonthlyLinks: {} as Record<number, string>
};
export const nextLive = {
enabled: false,
title: 'Prochain live',
description: 'Rejoignez-nous pour notre prochain live !',
date: '',
image: '/images/next-live.jpg',
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 categories: ReadonlyArray<{ id: number; fr: string; en: string }> = [
{ id: 11, fr: 'Actualité & Politique', en: 'News & Politics' },
{ id: 15, fr: 'Science & Technologie', en: 'Science & Technology' },
{ id: 4, fr: 'Jeux', en: 'Gaming' },
{ id: 9, fr: 'Comédie', en: 'Comedy' },
{ id: 10, fr: 'Divertissement', en: 'Entertainment' }
];
export const categoryNames: Readonly<Record<number, { fr: string; en: string }>> = {
1: { fr: 'Musique', en: 'Music' },
2: { fr: 'Films', en: 'Films' },
3: { fr: 'Véhicules', en: 'Vehicles' },
4: { fr: 'Jeux', en: 'Gaming' },
5: { fr: 'Sport', en: 'Sports' },
6: { fr: 'Humour', en: 'Humor' },
7: { fr: 'Art', en: 'Art' },
8: { fr: 'Personnalités', en: 'People' },
9: { fr: 'Comédie', en: 'Comedy' },
10: { fr: 'Divertissement', en: 'Entertainment' },
11: { fr: 'Actualité & Politique', en: 'News & Politics' },
12: { fr: 'Tutoriel', en: 'How-to' },
13: { fr: 'Éducation', en: 'Education' },
14: { fr: 'Activisme', en: 'Activism' },
15: { fr: 'Science & Technologie', en: 'Science & Technology' },
16: { fr: 'Animaux', en: 'Animals' },
17: { fr: 'Enfants', en: 'Kids' },
18: { fr: 'Cuisine', en: 'Cooking' }
};
export const categoryIcons: Readonly<Record<number, string>> = {
1: 'mizik-note',
2: 'zetwal',
3: 'zetwal',
4: 'zetwal',
5: 'zetwal',
6: 'pawol',
7: 'jaden',
8: 'glo',
9: 'pawol',
10: 'zetwal',
11: 'lanme',
12: 'kle',
13: 'jaden',
14: 'glo',
15: 'kle',
16: 'jaden',
17: 'jaden',
18: 'lakanmou'
};
export const importantTags = ['ANNUKUTECED', 'podcast', 'fediverse', 'audio', 'vidéo'] as const;
export const popularTags = ['podcast', 'annukuteced', 'fediverse', 'audio'] as const;
export const about = {
enabled: false,
title: { fr: 'À propos', en: 'About' } as Record<'fr' | 'en', string>,
description: { fr: '', en: '' } as Record<'fr' | 'en', string>,
description2: { fr: '', en: '' } as Record<'fr' | 'en', string>,
image: '/images/movement_presentation.png',
imageAlt: { fr: '', en: '' } as Record<'fr' | 'en', string>,
imageCaption: { fr: '', en: '' } as Record<'fr' | 'en', string>
};
export const countdown = {
enabled: false,
targetDate: '2025-10-11 00:00:00',
timezones: {
"Ma'ohi Nui": 'Pacific/Tahiti',
'Martinique / Guadeloupe': 'America/Martinique',
Guyane: 'America/Cayenne',
France: 'Europe/Paris',
Kanaky: 'Pacific/Noumea'
} as Record<string, string>
};
export const instances = [
{
name: 'GADE',
software: 'PeerTube',
url: peertube.url,
icon: 'zetwal'
},
{
name: 'KUTE',
software: 'Castopod',
url: castopod.url,
icon: 'ka'
},
{
name: 'BOKANTE',
software: 'Mastodon',
url: mastodon.instanceUrl,
icon: 'lambi'
}
] as const;
+175
View File
@@ -0,0 +1,175 @@
{
"meta": {
"homeTitle": "ANNU KUTE CED — Podcast multimedia hub",
"homeDescription": "Videos, audio episodes and news from the ANNU KUTE CED podcast: the PeerTube channel, the Castopod podcast and the Mastodon timeline gathered in one place, free from big platforms.",
"videoDescription": "Watch “{title}” on the ANNU KUTE CED multimedia hub.",
"categoryTitle": "{name} category",
"categoryDescription": "Videos from the {name} category on the ANNU KUTE CED hub.",
"searchTitle": "Search",
"searchDescription": "Search videos on the ANNU KUTE CED multimedia hub.",
"directTitle": "Live",
"directDescription": "The current ANNU KUTE CED live stream and the next live announcement.",
"donsTitle": "Support us",
"donsDescription": "Support the ANNU KUTE CED podcast: one-time or monthly donations.",
"legalTitle": "Legal notice",
"legalDescription": "Legal notice of the ANNU KUTE CED multimedia hub.",
"offlineTitle": "Offline",
"notFoundTitle": "Page not found"
},
"nav": {
"home": "Home",
"direct": "Live",
"donate": "Support",
"categories": "Categories",
"hashtags": "Hashtags",
"searchPlaceholder": "Search…",
"searchLabel": "Video search",
"searchButton": "Run search",
"searchHelp": "Type keywords to search videos. Prefix with # to search a hashtag.",
"socialLabel": "Social networks",
"mainLabel": "Main navigation",
"openMenu": "Open navigation menu",
"closeMenu": "Close menu",
"themeToggle": "Toggle dark and light mode",
"installApp": "Install the app",
"langSwitch": "Version française",
"backHome": "Back to home",
"moreSocial": "More social networks",
"skipToContent": "Skip to content"
},
"home": {
"shorts": "Shorts",
"recent": "Recent videos",
"trending": "Trending",
"podcast": "Latest episodes",
"podcastSubtitle": "The audio podcast on KUTE — Castopod",
"music": "Tracks",
"musicSubtitle": "Random selection on MIZIK — Funkwhale",
"seeAllTracks": "Explore MIZIK",
"timeline": "News",
"timelineSubtitle": "The Mastodon timeline on BOKANTE",
"seeMore": "See more",
"seeAllVideos": "All videos on GADE",
"seeAllEpisodes": "All episodes on KUTE",
"seeMorePosts": "See more posts",
"popularTags": "Popular hashtags",
"about": "About",
"liveNow": "LIVE",
"watchLive": "Watch the live"
},
"video": {
"views": "{count} views",
"publishedOn": "Published on {date}",
"download": "Download",
"share": "Share",
"copyLink": "Copy link",
"copied": "Link copied",
"embedCode": "Embed code",
"comments": "Comments",
"noComments": "No comments yet.",
"suggested": "Suggested videos",
"license": "License",
"category": "Category",
"tags": "Hashtags",
"watchOnPeertube": "Watch on GADE — PeerTube",
"duration": "Duration",
"by": "by {channel}"
},
"category": {
"videosIn": "Videos in category",
"empty": "No video in this category yet."
},
"search": {
"title": "Search",
"resultsFor": "Results for “{query}”",
"tagResults": "Videos with hashtag #{tag}",
"noResults": "No result for “{query}”.",
"hint": "Search by keywords or by hashtag with the # prefix.",
"loading": "Loading the search index…",
"resultCount": "{count} result(s)"
},
"direct": {
"title": "Live",
"liveNow": "The live stream is on",
"noLive": "No live right now",
"noLiveText": "The next live has not been announced yet. Follow us on Mastodon to be notified.",
"nextLive": "Next live",
"followForNews": "Follow on Mastodon",
"inTimezones": "The schedule in your territory"
},
"podcast": {
"listen": "Listen",
"play": "Play episode {title}",
"pause": "Pause",
"next": "Next episode",
"nowPlaying": "Now playing",
"episodeFrom": "Episode from {date}",
"mute": "Mute",
"unmute": "Unmute"
},
"music": {
"play": "Play {title}"
},
"timeline": {
"reload": "Refresh",
"postFrom": "Post from {date}",
"mediaAlt": "Post media"
},
"donate": {
"title": "Support ANNU KUTE CED",
"intro": "Budget should never be a barrier — but your donations help us maintain the podcast, the hosting and the free tools.",
"oneTime": "One-time donation",
"monthly": "Monthly donation",
"liberapay": "Support via Liberapay",
"kofi": "Support via Ko-fi",
"custom": "Custom amount",
"whyTitle": "Why donate",
"whyText": "Your donations fund ethical hosting, production time and the free tools of the ecosystem.",
"contact": "For other ways to support, write to us:",
"notConfigured": "The donations page is being set up. Contact us to support the podcast."
},
"legal": {
"title": "Legal notice",
"editor": "Site publisher",
"host": "Hosting",
"contact": "Contact",
"license": "License",
"sourceCode": "Source code",
"data": "Personal data",
"dataText": "This site sets no tracking cookie and collects no personal data. Multimedia content is served by the ecosystem instances (GADE, KUTE, BOKANTE).",
"serviceDescription": "is the multimedia hub of the ANNU KUTE CED podcast."
},
"footer": {
"contact": "CONTACT",
"categories": "Categories",
"hashtags": "Hashtags",
"legal": "Legal information",
"legalNotice": "Legal notice",
"sourceCode": "Source code",
"copyright": "Free license GNU AGPL-V3",
"federated": "A free service operated by ORGANISATION KA INTERNATIONALE",
"instances": "The ecosystem"
},
"offline": {
"title": "Pa gen koneksyon",
"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.",
"cta": "Back to home"
},
"a11y": {
"videoThumbnail": "Thumbnail of video {title}",
"channelAvatar": "Avatar of channel {channel}",
"logo": "ANNU KUTE CED logo",
"episodeCover": "Cover of episode {title}",
"progressLabel": "Page reading progress"
}
}
+175
View File
@@ -0,0 +1,175 @@
{
"meta": {
"homeTitle": "ANNU KUTE CED — Hub multimédia du podcast",
"homeDescription": "Vidéos, épisodes audio et actualités du podcast ANNU KUTE CED : la chaîne PeerTube, le podcast Castopod et la timeline Mastodon réunis en un seul lieu, sans dépendre des grandes plateformes.",
"videoDescription": "Regarder « {title} » sur le hub multimédia ANNU KUTE CED.",
"categoryTitle": "Catégorie {name}",
"categoryDescription": "Vidéos de la catégorie {name} sur le hub ANNU KUTE CED.",
"searchTitle": "Recherche",
"searchDescription": "Rechercher des vidéos dans le hub multimédia ANNU KUTE CED.",
"directTitle": "Direct",
"directDescription": "Le direct d'ANNU KUTE CED en cours et l'annonce du prochain live.",
"donsTitle": "Soutenir",
"donsDescription": "Soutenir le podcast ANNU KUTE CED : dons ponctuels ou mensuels.",
"legalTitle": "Mentions légales",
"legalDescription": "Mentions légales du hub multimédia ANNU KUTE CED.",
"offlineTitle": "Hors ligne",
"notFoundTitle": "Page introuvable"
},
"nav": {
"home": "Accueil",
"direct": "Direct",
"donate": "Soutenir",
"categories": "Catégories",
"hashtags": "Hashtags",
"searchPlaceholder": "Rechercher…",
"searchLabel": "Recherche de vidéos",
"searchButton": "Lancer la recherche",
"searchHelp": "Tapez vos mots-clés pour rechercher des vidéos. Préfixez par # pour chercher un hashtag.",
"socialLabel": "Réseaux sociaux",
"mainLabel": "Navigation principale",
"openMenu": "Ouvrir le menu de navigation",
"closeMenu": "Fermer le menu",
"themeToggle": "Basculer entre mode sombre et clair",
"installApp": "Installer l'application",
"langSwitch": "English version",
"backHome": "Retour à l'accueil",
"moreSocial": "Voir plus de réseaux sociaux",
"skipToContent": "Aller au contenu"
},
"home": {
"shorts": "Shorts",
"recent": "Vidéos récentes",
"trending": "Tendances",
"podcast": "Derniers épisodes",
"podcastSubtitle": "Le podcast audio sur KUTE — Castopod",
"music": "Morceaux",
"musicSubtitle": "Sélection aléatoire sur MIZIK — Funkwhale",
"seeAllTracks": "Explorer MIZIK",
"timeline": "Actualités",
"timelineSubtitle": "La timeline Mastodon sur BOKANTE",
"seeMore": "Voir plus",
"seeAllVideos": "Toutes les vidéos sur GADE",
"seeAllEpisodes": "Tous les épisodes sur KUTE",
"seeMorePosts": "Voir plus de posts",
"popularTags": "Hashtags populaires",
"about": "À propos",
"liveNow": "EN DIRECT",
"watchLive": "Regarder le direct"
},
"video": {
"views": "{count} vues",
"publishedOn": "Publié le {date}",
"download": "Télécharger",
"share": "Partager",
"copyLink": "Copier le lien",
"copied": "Lien copié",
"embedCode": "Code d'intégration",
"comments": "Commentaires",
"noComments": "Aucun commentaire pour le moment.",
"suggested": "Vidéos suggérées",
"license": "Licence",
"category": "Catégorie",
"tags": "Hashtags",
"watchOnPeertube": "Regarder sur GADE — PeerTube",
"duration": "Durée",
"by": "par {channel}"
},
"category": {
"videosIn": "Vidéos de la catégorie",
"empty": "Aucune vidéo dans cette catégorie pour le moment."
},
"search": {
"title": "Recherche",
"resultsFor": "Résultats pour « {query} »",
"tagResults": "Vidéos avec le hashtag #{tag}",
"noResults": "Aucun résultat pour « {query} ».",
"hint": "Recherchez par mots-clés ou par hashtag avec le préfixe #.",
"loading": "Chargement de l'index de recherche…",
"resultCount": "{count} résultat(s)"
},
"direct": {
"title": "Direct",
"liveNow": "Le direct est en cours",
"noLive": "Aucun direct en cours",
"noLiveText": "Le prochain live n'est pas encore annoncé. Suivez-nous sur Mastodon pour être prévenu.",
"nextLive": "Prochain live",
"followForNews": "Suivre sur Mastodon",
"inTimezones": "L'horaire dans votre territoire"
},
"podcast": {
"listen": "Écouter",
"play": "Lire l'épisode {title}",
"pause": "Mettre en pause",
"next": "Épisode suivant",
"nowPlaying": "En cours de lecture",
"episodeFrom": "Épisode du {date}",
"mute": "Couper le son",
"unmute": "Rétablir le son"
},
"music": {
"play": "Lire {title}"
},
"timeline": {
"reload": "Rafraîchir",
"postFrom": "Post du {date}",
"mediaAlt": "Média du post"
},
"donate": {
"title": "Soutenir ANNU KUTE CED",
"intro": "Le budget ne doit jamais être un frein — mais vos dons nous aident à maintenir le podcast, l'hébergement et les outils libres.",
"oneTime": "Don ponctuel",
"monthly": "Don mensuel",
"liberapay": "Soutenir via Liberapay",
"kofi": "Soutenir via Ko-fi",
"custom": "Montant libre",
"whyTitle": "Pourquoi donner",
"whyText": "Vos dons financent l'hébergement éthique, le temps de production et les outils libres de l'écosystème.",
"contact": "Pour d'autres moyens de soutien, écrivez-nous :",
"notConfigured": "La page de dons est en cours de configuration. Contactez-nous pour soutenir le podcast."
},
"legal": {
"title": "Mentions légales",
"editor": "Éditeur du site",
"host": "Hébergement",
"contact": "Contact",
"license": "Licence",
"sourceCode": "Code source",
"data": "Données personnelles",
"dataText": "Ce site ne dépose aucun cookie de suivi et ne collecte aucune donnée personnelle. Les contenus multimédias sont servis par les instances de l'écosystème (GADE, KUTE, BOKANTE).",
"serviceDescription": "est le hub multimédia du podcast ANNU KUTE CED."
},
"footer": {
"contact": "CONTACT",
"categories": "Catégories",
"hashtags": "Hashtags",
"legal": "Informations légales",
"legalNotice": "Mentions légales",
"sourceCode": "Code source",
"copyright": "Licence libre GNU AGPL-V3",
"federated": "Un service libre opéré par ORGANISATION KA INTERNATIONALE",
"instances": "L'écosystème"
},
"offline": {
"title": "Pa gen koneksyon",
"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.",
"cta": "Retour à l'accueil"
},
"a11y": {
"videoThumbnail": "Vignette de la vidéo {title}",
"channelAvatar": "Avatar de la chaîne {channel}",
"logo": "Logo ANNU KUTE CED",
"episodeCover": "Pochette de l'épisode {title}",
"progressLabel": "Progression de lecture de la page"
}
}
+75
View File
@@ -0,0 +1,75 @@
import { resolve } from '$app/paths';
import type { Locale } from '$lib/types';
import fr from './fr.json';
import en from './en.json';
export type Bundle = typeof fr;
const bundles: Record<Locale, Bundle> = { fr, en: en as Bundle };
export const locales: Locale[] = ['fr', 'en'];
export const defaultLocale: Locale = 'fr';
export function getBundle(locale: Locale): Bundle {
return bundles[locale] ?? bundles[defaultLocale];
}
export function localeFromPath(pathname: string): Locale {
return pathname.split('/')[1] === 'en' ? 'en' : 'fr';
}
export function stripLocale(pathname: string): string {
if (pathname === '/en') return '/';
if (pathname.startsWith('/en/')) return pathname.slice(3);
return pathname;
}
export function hrefFor(locale: Locale, path: string): string {
const clean = path.startsWith('/') ? path : `/${path}`;
const full = locale === 'en' ? `/en${clean === '/' ? '/' : clean}` : clean;
const [pathname, rest = ''] = full.split(/(?=[?#])/);
const withSlash = pathname.endsWith('/') ? pathname : `${pathname}/`;
// Chemin interne calculé (locale préfixée) : cast requis par le typage des routes
return resolve(`${withSlash}${rest}` as '/');
}
export function alternatePath(pathname: string, target: Locale): string {
const bare = stripLocale(pathname);
return hrefFor(target, bare);
}
export function t(template: string, vars: Record<string, string | number> = {}): string {
return template.replace(/\{(\w+)\}/g, (_, key) => String(vars[key] ?? `{${key}}`));
}
export function formatDate(dateString: string, locale: Locale): string {
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) return dateString;
return new Intl.DateTimeFormat(locale === 'en' ? 'en-GB' : 'fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric'
}).format(date);
}
export function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
return `${m}:${String(s).padStart(2, '0')}`;
}
export function formatViewCount(views: number): string {
if (views >= 1_000_000) return `${(views / 1_000_000).toFixed(1)}M`;
if (views >= 1_000) return `${(views / 1_000).toFixed(1)}K`;
return String(views);
}
export function formatFileSize(bytes: number): string {
if (bytes <= 0) return '';
const units = ['o', 'Ko', 'Mo', 'Go'];
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / 1024 ** exponent;
return `${value.toFixed(value >= 10 || exponent === 0 ? 0 : 1)} ${units[exponent]}`;
}
+27
View File
@@ -0,0 +1,27 @@
import { prefersReducedMotion, syncopatedDelay } from './tokens';
export function reveal(node: HTMLElement, index = 0) {
if (prefersReducedMotion()) return;
node.classList.add('reveal');
node.style.setProperty('--d', `${syncopatedDelay(index)}ms`);
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
node.classList.add('is-visible');
observer.disconnect();
}
}
},
{ threshold: 0.1, rootMargin: '0px 0px -8% 0px' }
);
observer.observe(node);
return {
destroy() {
observer.disconnect();
}
};
}
+11
View File
@@ -0,0 +1,11 @@
export function prefersReducedMotion(): boolean {
if (typeof window === 'undefined') return true;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
const SYNCOPATED_PATTERN = [0, 120, 300, 360, 600, 660, 900, 960];
export function syncopatedDelay(index: number): number {
const cycle = Math.floor(index / SYNCOPATED_PATTERN.length);
return SYNCOPATED_PATTERN[index % SYNCOPATED_PATTERN.length] + cycle * 1200;
}
+101
View File
@@ -0,0 +1,101 @@
import { existsSync } from 'node:fs';
import { castopod } from '$lib/config';
import { fetchText } from './http';
import type { Episode } from '$lib/types';
// Pochette du flux optimisée au prebuild (scripts/fetch-podcast-cover.mjs)
const LOCAL_COVER = '/images/podcast-cover.webp';
const hasLocalCover = existsSync(`static${LOCAL_COVER}`);
function localEpisodeCover(remoteUrl: string | null): string | null {
if (!remoteUrl) return null;
const file = remoteUrl.split('/').pop()?.replace(/\.(png|jpe?g|gif|webp)$/i, '.webp');
if (!file) return remoteUrl;
const local = `/images/episodes/${file}`;
return existsSync(`static${local}`) ? local : remoteUrl;
}
function tag(xml: string, name: string): string {
const cdata = new RegExp(`<${name}[^>]*><!\\[CDATA\\[([\\s\\S]*?)\\]\\]></${name}>`, 'i');
const plain = new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`, 'i');
const match = xml.match(cdata) ?? xml.match(plain);
return match?.[1]?.trim() ?? '';
}
function attr(xml: string, tagName: string, attrName: string): string {
const tagMatch = xml.match(new RegExp(`<${tagName}\\b[^>]*>`, 'i'));
if (!tagMatch) return '';
const attrMatch = tagMatch[0].match(new RegExp(`${attrName}="([^"]*)"`, 'i'));
return attrMatch?.[1] ?? '';
}
function stripTags(html: string): string {
return html
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#0?39;|&apos;/g, "'")
.replace(/\s+/g, ' ')
.trim();
}
function parseDuration(raw: string): number {
if (!raw) return 0;
const parts = raw.split(':').map(Number);
if (parts.some(Number.isNaN)) return 0;
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
if (parts.length === 2) return parts[0] * 60 + parts[1];
return parts[0] ?? 0;
}
async function getEpisodesForSlug(slug: string): Promise<Episode[]> {
const feedUrl = `${castopod.url}/@${slug}/feed`;
const xml = await fetchText(feedUrl);
if (!xml) return [];
const channelImage =
tag(xml.split('<item')[0] ?? '', 'url') || attr(xml.split('<item')[0] ?? '', 'itunes:image', 'href');
const items = xml.split(/<item[\s>]/i).slice(1);
return items.map((item, index) => {
const body = item.split('</item>')[0] ?? item;
const title = tag(body, 'title');
const link = tag(body, 'link');
const guid = tag(body, 'guid') || link || `${slug}-${index}`;
const description = stripTags(tag(body, 'description') || tag(body, 'itunes:summary'));
const remoteImage = attr(body, 'itunes:image', 'href') || channelImage || null;
// Les pochettes du flux (souvent lourdes) sont remplacées par leurs versions optimisées au build
const image =
hasLocalCover && remoteImage === channelImage ? LOCAL_COVER : localEpisodeCover(remoteImage);
return {
id: guid,
title,
description,
audioUrl: attr(body, 'enclosure', 'url'),
audioType: attr(body, 'enclosure', 'type') || 'audio/mpeg',
duration: parseDuration(tag(body, 'itunes:duration')),
date: tag(body, 'pubDate'),
image,
link,
podcast: slug
};
});
}
export async function getCastopodEpisodes(count = castopod.episodesCount): Promise<Episode[]> {
const all: Episode[] = [];
for (const slug of castopod.podcastSlugs) {
const episodes = await getEpisodesForSlug(slug);
all.push(...episodes);
if (castopod.podcastSlugs.length > 1) {
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
return all
.filter((episode) => episode.audioUrl)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.slice(0, count);
}
+66
View File
@@ -0,0 +1,66 @@
import { funkwhale } from '$lib/config';
import { fetchJson } from './http';
import type { Episode } from '$lib/types';
interface FwUpload {
listen_url?: string;
duration?: number;
}
interface FwTrack {
id?: number;
title?: string;
is_local?: boolean;
artist?: { name?: string; channel?: string | null };
album?: { title?: string; cover?: { urls?: { medium_square_crop?: string; original?: string } } };
uploads?: FwUpload[];
}
export async function getFunkwhaleTracks(count: number = funkwhale.tracksCount): Promise<Episode[]> {
const data = await fetchJson<{ results?: FwTrack[] }>(
`${funkwhale.url}/api/v1/tracks?ordering=random&page_size=${funkwhale.fetchSize}&scope=all`
);
if (!data?.results) return [];
const localDomain = new URL(funkwhale.url).host;
const tracks: Episode[] = [];
for (const item of data.results) {
// Ne garder que les morceaux locaux à l'instance
if (item.is_local === false) continue;
if (item.artist?.channel) {
try {
if (new URL(item.artist.channel).host !== localDomain) continue;
} catch {
// channel non-URL : on conserve
}
}
const upload = item.uploads?.[0];
if (!upload?.listen_url) continue;
const audioUrl = upload.listen_url.startsWith('http')
? upload.listen_url
: `${funkwhale.url}${upload.listen_url}`;
tracks.push({
id: `fw-${item.id ?? tracks.length}`,
title: item.title ?? '',
description: item.album?.title ?? '',
audioUrl,
audioType: 'audio/mpeg',
duration: upload.duration ?? 0,
date: '',
image: item.album?.cover?.urls?.medium_square_crop ?? item.album?.cover?.urls?.original ?? null,
link: `${funkwhale.url}/library/tracks/${item.id ?? ''}`,
podcast: item.artist?.name ?? ''
});
}
// Sélection aléatoire parmi les morceaux récupérés
for (let i = tracks.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[tracks[i], tracks[j]] = [tracks[j], tracks[i]];
}
return tracks.slice(0, count);
}
+108
View File
@@ -0,0 +1,108 @@
const UA = 'annu-kute-ced/2.0 (+https://annukuteced.buzz)';
const MAX_CONCURRENT = 4;
const MAX_RETRIES = 6;
let active = 0;
const waiting: (() => void)[] = [];
const cache = new Map<string, Promise<string | null>>();
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function limited<T>(fn: () => Promise<T>): Promise<T> {
if (active >= MAX_CONCURRENT) {
await new Promise<void>((resolve) => waiting.push(resolve));
}
active++;
try {
return await fn();
} finally {
active--;
waiting.shift()?.();
}
}
async function doFetch(url: string, accept: string, attempt = 0): Promise<string | null> {
try {
const res = await fetch(url, {
headers: { 'User-Agent': UA, Accept: accept },
signal: AbortSignal.timeout(30_000)
});
if (res.status === 429 && attempt < MAX_RETRIES) {
const retryAfter = Number(res.headers.get('retry-after')) || (attempt + 1) * 2;
console.warn(`[data] HTTP 429 pour ${url} — nouvelle tentative dans ${retryAfter}s`);
await sleep(retryAfter * 1000);
return doFetch(url, accept, attempt + 1);
}
if (!res.ok) {
console.error(`[data] HTTP ${res.status} for ${url}`);
return null;
}
return await res.text();
} catch (err) {
if (attempt < 2) {
await sleep(1000 * (attempt + 1));
return doFetch(url, accept, attempt + 1);
}
console.error(`[data] fetch failed for ${url}:`, err);
return null;
}
}
function cached(url: string, accept: string): Promise<string | null> {
let pending = cache.get(url);
if (!pending) {
pending = limited(() => doFetch(url, accept));
cache.set(url, pending);
}
return pending;
}
export async function fetchJson<T>(url: string): Promise<T | null> {
const text = await cached(url, 'application/json');
if (text === null) return null;
try {
return JSON.parse(text) as T;
} catch {
console.error(`[data] invalid JSON for ${url}`);
return null;
}
}
export async function fetchText(url: string): Promise<string | null> {
return cached(url, 'text/xml, application/rss+xml, text/plain, */*');
}
export function escapeHtml(input: string): string {
return input
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
export function sanitizeHtml(html: string): string {
return html
.replace(/<(script|style|iframe|object|embed|form|link|meta)[^>]*>[\s\S]*?<\/\1>/gi, '')
.replace(/<(script|style|iframe|object|embed|form|link|meta)[^>]*\/?>/gi, '')
.replace(/\son\w+="[^"]*"/gi, '')
.replace(/\son\w+='[^']*'/gi, '')
.replace(/(href|src)="javascript:[^"]*"/gi, '$1="#"');
}
export function markdownToHtml(markdown: string): string {
const escaped = escapeHtml(markdown.trim());
const blocks = escaped.split(/\n{2,}/);
return blocks
.map((block) => {
let html = block
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" rel="external noopener noreferrer" target="_blank">$1</a>')
.replaceAll('\n', '<br />');
return `<p>${html}</p>`;
})
.join('');
}
+61
View File
@@ -0,0 +1,61 @@
{
"https://gade.o-k-i.net/lazy-static/thumbnails/ee8fe93e-62b0-45a3-b200-3b9a7d34ad37.jpg": "/images/remote/8d484efeec42b22e.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/713279b3-0a30-4e88-a4cd-2bd9015706ff.jpg": "/images/remote/2b57d5382f67e8a6.webp",
"https://gade.o-k-i.net/lazy-static/avatars/b483874d-df89-4403-aabb-684f4eeffce8.png": "/images/remote/b82953cc708a503e.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/65458991-cc2d-4f88-b04b-1ecb4c831196.jpg": "/images/remote/1e1a6bad9723ecba.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/19ed28dd-429b-4b86-9f5b-032b0fd8a555.jpg": "/images/remote/a105bc4c5812eb6d.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/a02a736a-1ca2-48cb-bd04-6adb8ab8bd3f.jpg": "/images/remote/7e49bf87876caf2b.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/df2d06a4-c5f1-40b2-85c6-bb2cc6f75f0f.jpg": "/images/remote/23a4aee1ddac377d.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/ac2d53df-9432-419a-bafa-4e92a23a065a.png": "/images/remote/ca91ce3a8c6c048c.webp",
"https://gade.o-k-i.net/lazy-static/avatars/1af26d32-dd03-4ef9-9cb7-e4680630b93a.png": "/images/remote/d3efbaf0d582a4de.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/7a40594d-6ee8-4a93-a891-acf6a06706e5.jpg": "/images/remote/d668c254603561d5.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/c433fb22-085b-4b95-840d-a244a16b9d86.jpg": "/images/remote/7f3ee600df8bf0a9.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/eeee79a2-13a7-41f3-8cf7-c0356e8b4af1.jpg": "/images/remote/8b91a7098be22de6.webp",
"https://gade.o-k-i.net/lazy-static/avatars/68710d9c-f623-4db3-950c-5dcb9519ea89.png": "/images/remote/72f6f94d5459b36c.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/aa49c8f7-e746-4087-ac8a-b18911ae34bb.jpg": "/images/remote/953bc828f4a909ed.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/04471380-b2fc-410d-a255-d1ed2c566582.jpg": "/images/remote/dd3dfb419e66a44c.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/f9dcc845-f6dc-4383-abac-192a74d3ddbb.jpg": "/images/remote/2cd6b3491e675fe9.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/ee9d0e2b-a266-4e5e-b708-f8a68dd38cd8.jpg": "/images/remote/7794b4aa91e0886e.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/63aab582-1e95-403f-8fd4-2cd1355e4c48.jpg": "/images/remote/06c7423ee81dbae1.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/f06d5990-2113-428d-8364-cedc2dd5163f.jpg": "/images/remote/38183e7822943589.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/e034ba48-4696-4747-8090-36a1ffb7ead8.jpg": "/images/remote/83b50cf812d906f0.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/8a1726da-b05c-4f4c-9d02-a84aa80089cb.jpg": "/images/remote/ce0279994a8d0c22.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/eae89876-94f3-4c32-b5bb-d1cd44551ca5.jpg": "/images/remote/626f2aa1c4d7030a.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/82061a8c-4435-4759-b905-5c8a10eb8a8f.jpg": "/images/remote/efa5aca9038f196e.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/b1472219-de2b-4a85-8f95-bd544eaf5797.jpg": "/images/remote/940f43beb51345fa.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/28acfa31-e05d-49c6-89b0-2da42502416e.jpg": "/images/remote/64ba2429385bdd5f.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/9b9fde1a-be2f-4c7c-8372-f3d65aad73ac.jpg": "/images/remote/674d7ec610419571.webp",
"https://gade.o-k-i.net/lazy-static/avatars/35d753a1-cd9f-4f64-999f-483ef4c8d0f6.jpg": "/images/remote/ff2cc37374f4a362.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/730fcddd-645e-4377-9b93-f38835818b7d.jpg": "/images/remote/a7ced37c3c927888.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/66cf4194-3e6c-4c09-adb1-b3ae6591c6a0.jpg": "/images/remote/4ebf139c7ad742c0.webp",
"https://gade.o-k-i.net/lazy-static/avatars/bcdc941d-1be8-4c67-b0c7-d40313ef9aaf.png": "/images/remote/5a4d501bd6d6088c.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/14fa6efc-9a9a-4c36-a044-02300a92af47.jpg": "/images/remote/461e77261af8212f.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/7b7768e4-eeac-4024-a59e-052bdfbefe25.jpg": "/images/remote/a652b99264ef113c.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/f6730ffc-8236-4df3-83f9-bce65b1c5973.jpg": "/images/remote/cc2a19d9f6ac44b1.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/9fe95de1-ea17-41f9-ae02-d4b93f33f3e9.jpg": "/images/remote/e0d61d23865f0262.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/a1799f29-ffd4-4c9f-a4a6-7cdfe56cd48c.png": "/images/remote/880d1ba7cf4187a5.webp",
"https://gade.o-k-i.net/lazy-static/avatars/df91692b-df37-4350-a5e4-3b9a1f56af8e.png": "/images/remote/035f2b8e8f5ee0dd.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/ad988f0b-1fd7-46bc-93b2-0d8b9587b880.jpg": "/images/remote/2713aa6bd6511e7e.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/f60a9ca6-7a62-44c2-a242-016fdc530577.jpg": "/images/remote/67b3bca007b144c5.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/3f130e71-7cfd-473b-8ea2-80df832c1241.jpg": "/images/remote/b9e62fd5fa9ce102.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/61f25602-6dd7-4fe1-9269-af0fe3f73527.jpg": "/images/remote/e7c1aec80a4fc8b6.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/23fdd0ad-8256-45b2-b8fa-15496fef9d3f.jpg": "/images/remote/d9d7e08baf18edfe.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/ce2c5680-a787-4028-8768-ca64e8c18fba.jpg": "/images/remote/11f1ace7f75ed731.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/45e971a5-3131-46bc-8a39-db10d68dbd29.jpg": "/images/remote/09eb4898f46c76db.webp",
"https://gade.o-k-i.net/lazy-static/avatars/60cbad62-3881-4e34-aa3a-726b80b5d334.png": "/images/remote/62c8fe68d5ea8108.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/bb2aafc3-46bd-4059-a3a7-cd08d4097113.jpg": "/images/remote/4fc5ba257091f2f6.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/160eda0d-326c-480b-81e4-aabe88b46729.jpg": "/images/remote/8ab957ee76be64c8.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/d00d12e6-c5c8-4df8-b0fd-5a49f9675bd4.jpg": "/images/remote/61d4b3032d0c87dd.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/3521d0ed-ae0d-4610-88ff-96c4703d8849.jpg": "/images/remote/d47f4bcf0566064e.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/05e25c9a-ef93-4ec4-b7a9-5453745d7af5.jpg": "/images/remote/4ceaa3d9b7bf41aa.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/32220182-8186-4e66-a2d6-03c722fe13af.jpg": "/images/remote/254e260419aa661e.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/5e7b2bd0-edfe-49ca-abee-fbe21321c3d0.jpg": "/images/remote/57e3480631c3524f.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/cbe90101-8276-4a39-bce0-4e3b5ade98a7.jpg": "/images/remote/1f6ca697aa450bf5.webp",
"https://gade.o-k-i.net/lazy-static/thumbnails/6ee72db1-d86c-4e6c-8deb-de1672ef40f5.png": "/images/remote/16b11ca906d64dcf.webp",
"https://bokante.o-k-i.net/system/media_attachments/files/116/961/470/179/848/592/small/a1f65d6923b825c6.png": "/images/remote/f0e99c1fd601e8f4.webp",
"https://bokante.o-k-i.net/system/media_attachments/files/116/961/462/013/833/918/small/ab87d35ebbc5b3ff.jpg": "/images/remote/b0f9e20257ed9d70.webp",
"https://bokante.o-k-i.net/system/media_attachments/files/116/961/174/942/983/872/small/9a39261de8c0a287.png": "/images/remote/ac55ca23fae564d8.webp",
"https://bokante.o-k-i.net/system/media_attachments/files/116/944/486/745/892/539/small/fce48c574fa53917.png": "/images/remote/69a1a813f6b9324e.webp",
"https://bokante.o-k-i.net/system/media_attachments/files/116/943/727/051/792/872/small/d7c8bcea86822972.png": "/images/remote/656fc21dfbab689a.webp",
"https://bokante.o-k-i.net/system/media_attachments/files/116/936/109/649/552/039/small/c80ad03182b67097.png": "/images/remote/e925811cec427259.webp"
}
+10
View File
@@ -0,0 +1,10 @@
import { existsSync } from 'node:fs';
import map from './image-map.json' with { type: 'json' };
const imageMap = map as Record<string, string>;
export function mapImage(remoteUrl: string): string {
const local = imageMap[remoteUrl];
if (local && existsSync(`static${local}`)) return local;
return remoteUrl;
}

Some files were not shown because too many files have changed in this diff Show More