Compare commits

...

63 Commits

Author SHA1 Message Date
Valentin ROBIN 0c09c0cdd4 Sort les tags @pseudo du texte des questions du Char en métadonnée
Build and deploy / deploy (push) Successful in 37s
Le "@Priape" écrit en composant une question n'apparaît plus dans la
phrase affichée — stripMentions() le retire du texte (nettoyage des
espaces qu'il laisse) et le montre à part sous forme de badges
(MentionTags, avatar + pseudo), un peu comme une réponse, à la fois
dans la liste des Archontes et sur la question projetée sur /char.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:42:46 +02:00
Valentin ROBIN 5b5393cbd6 Ajoute glisser-déposer, randomisation et tags @pseudo sur /char/questions
Build and deploy / deploy (push) Successful in 37s
Réordonnancement par glisser-déposer (@dnd-kit, support tactile natif
pour une page pilotée depuis un téléphone) en complément des flèches
existantes, bouton de randomisation de l'ordre (confirmation à deux
temps), et tags @pseudo dans le texte des questions — détectés par
correspondance de texte (aucune colonne dédiée), avec autocomplétion
à la frappe, mise en évidence dans la liste et sur /char, et un
panneau de répartition des tags par membre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:35:13 +02:00
Valentin ROBIN 3f594768b0 Corrige le churn de refs par pièce dans La Corne d'Abondance
Build and deploy / deploy (push) Successful in 37s
Le ref inline dans pieces.map() était recréé à chaque rendu, forçant
React à recycler (null puis noeud) le ref DOM de chaque pièce affichée
à chaque mise à jour du jeu (lâcher, fusion) — coût croissant avec le
nombre de pièces et la fréquence de jeu, cause probable du plantage
signalé sur iPhone après plusieurs minutes. Extrait un composant
PieceView avec un ref stable via useCallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 19:01:18 +02:00
Valentin ROBIN 058b1ec140 Corrige une fuite mémoire dans La Corne d'Abondance (page blanche sur iPhone après plusieurs minutes)
Build and deploy / deploy (push) Successful in 36s
À chaque fusion, les id matter-js des deux pièces étaient ajoutés à un Set pour empêcher une double fusion sur la même frame, mais jamais retirés pendant la partie — seulement vidés au redémarrage complet. Sur une longue partie avec beaucoup de fusions, ce Set grossissait sans jamais être nettoyé, contribuant à faire grimper la mémoire jusqu'à ce que Safari/iOS recharge la page de force. Les id sont maintenant retirés dès qu'une fusion est traitée.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 18:42:58 +02:00
Valentin ROBIN 4f920175fd Redesign l'en-tête de la Corne d'Abondance, comme sur Icare
Build and deploy / deploy (push) Successful in 36s
Le bouton Classement vivait juste au-dessus du jeu, séparé par un simple gap — pas assez pour la preview de la prochaine pièce (qui grandit vers le haut avec le palier annoncé) sur les gros paliers, d'où le chevauchement signalé. Titre et Classement passent sur une seule ligne compacte en haut de page (même style qu'Icare), avec un espacement généreux avant le jeu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 15:28:19 +02:00
Valentin ROBIN e70f0c85bb Corrige la compression d'image sur Safari/iPhone (repli PNG au lieu de JPEG)
Build and deploy / deploy (push) Successful in 37s
canvas.toBlob demandait du WebP en dur pour compresser avant l'envoi. Safari ne sait pas l'encoder et retombe silencieusement sur du PNG (imposé par la spec) — un format quasi sans perte, bien plus lourd qu'un JPEG pour une photo, au point de dépasser la limite de taille du bucket malgré une photo source raisonnable (vécu en prod : 4 Mo sur iPhone). Le support WebP est maintenant détecté une fois, avec repli explicite sur JPEG plutôt que de laisser le navigateur choisir. S'applique au Mur (déjà corrigé pour l'extension/Content-Type) et maintenant aussi aux avatars (/profile, /signup).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 15:04:08 +02:00
Valentin ROBIN 358bed0c2e Passe de polish visuel sur tout le site
Corrige deux incohérences trouvées en chemin : le compteur de passages dans le char en gold-bright sur marbre (même défaut de contraste déjà vu ailleurs), et le point du Crieur toujours rouge même pour un honneur positif. Réutilise les ornements existants mais jamais appelés (meander-divider entre le podium et le classement, DiamondDivider sur /profile avec une nouvelle convention de carte secondaire). Ajoute une ambiance discrète (fond qui respire très lentement, halo doré sur les cartes marbre, désactivés sous prefers-reduced-motion), étend les confettis et un nouveau son à Icare pour la parité avec la Corne d'Abondance, des loading.tsx sur les pages les plus consultées, et un petit ornement sur les états vides plutôt qu'un texte brut.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 15:03:52 +02:00
Valentin ROBIN 187cdec0e2 Affiche le n°1 d'Icare et de la Corne d'Abondance sur /leaderboard
Build and deploy / deploy (push) Successful in 36s
Nouvelle section (game-highlights.tsx) juste sous les raccourcis : avatar, pseudo et score du meneur actuel de chaque mini-jeu, cliquable vers le jeu correspondant. Même règle que les podiums existants : rien affiché tant que le meneur est à 0. Score en text-sea (pas gold-bright, peu lisible sur fond marbre — même choix déjà fait pour Icare/Corne/l'Urne).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 19:39:38 +02:00
Valentin ROBIN 3aa2ea451c Corrige un décalage Content-Type/extension à l'upload d'images sur le Mur (Safari/iPhone)
Build and deploy / deploy (push) Successful in 36s
canvas.toBlob demandait du WebP en dur, mais un navigateur qui ne sait pas l'encoder (Safari, historiquement) retombe silencieusement sur du PNG — le fichier était quand même uploadé étiqueté "webp". L'extension et le Content-Type suivent maintenant le type réel du blob. Remplace aussi le message générique "Une erreur est survenue" par des messages distincts par étape (compression/upload/validation serveur), avec le détail technique loggé en console pour diagnostiquer plus vite la prochaine fois.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 19:33:36 +02:00
Valentin ROBIN 6922d33e39 Ajoute une pastille sur le raccourci du Mur de la Honte
Nombre de publications du jour, façon notification iOS, sur le raccourci /mur de /leaderboard — via wall_notes_for_date() (une lecture directe de wall_notes ne montrerait, à cause de la RLS, que ses propres notes, pas le total).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 19:33:29 +02:00
Valentin ROBIN 0eaebb7a05 Permet de consulter les jours précédents du Mur de la Honte, pour tout le monde
Build and deploy / deploy (push) Successful in 36s
wall_notes_today() devient wall_notes_for_date(p_date default null) — même garantie d'anonymat qu'avant, juste paramétrée par jour plutôt que figée sur aujourd'hui. Nouvelle RPC wall_note_days() (dates ayant au moins une note, sans contenu ni auteur) peuple un sélecteur de jour au-dessus de la grille, visible par tous — pas seulement les Archontes via l'historique complet, qui lui révèle l'auteur. wall_note_reaction_counts() gagne le même paramètre p_date. Le compteur "X/12 aujourd'hui" et l'éligibilité à publier restent toujours basés sur aujourd'hui, peu importe le jour consulté.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 18:15:34 +02:00
Valentin ROBIN 203afafec2 Ajoute des raccourcis vers les autres pages sur /leaderboard
Build and deploy / deploy (push) Successful in 36s
L'Agora sert déjà de page d'accueil de fait (/ y redirige) mais n'offrait aucun accès direct aux autres pages sans passer par le menu déroulant du chip. Ajoute une rangée de raccourcis (Icare, Corne d'Abondance, Char, Mur de la Honte, Urne, Crieur) juste au-dessus du classement, responsive (3 colonnes mobile, 6 en ligne sur écran plus large), mêmes icônes que le menu du header pour rester cohérent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 17:54:49 +02:00
Valentin ROBIN 4532403c95 Rend les photos du Mur de la Honte cliquables pour les agrandir
Build and deploy / deploy (push) Successful in 36s
Ajoute une visionneuse plein écran (mur du jour, sa propre note, et l'historique complet côté Archontes). Corrige au passage un bug introduit avec l'ouverture du Mur aux Archontes (V14) : leur propre note du jour ne se rafraîchissait jamais après le chargement initial, le refetch la filtrait encore par rôle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 17:46:19 +02:00
Valentin ROBIN ede1d6c74d Ajoute un son de fusion dans La Corne d'Abondance
Build and deploy / deploy (push) Successful in 36s
Pas de fichier audio dans le projet, donc un petit "pop" synthétisé via Web Audio (src/lib/melon/sound.ts), joué à chaque fusion. La hauteur baisse avec le palier de la pièce obtenue, pour que fusionner deux grosses pièces sonne différemment de deux petites.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 17:42:04 +02:00
Valentin ROBIN 5e75939b94 Ouvre Le Mur de la Honte aux Archontes, plafond quotidien relevé à 12
Build and deploy / deploy (push) Successful in 36s
post_wall_note() n'a plus de vérification de rôle : un Archonte peut désormais publier une note anonyme comme n'importe quel Citoyen. La policy Storage d'upload sur wall-images perd aussi son filtre par rôle (sinon possible de poster une note mais pas d'y joindre une image). Limite quotidienne relevée de 10 à 12 notes pour couvrir tout le groupe plutôt que les seuls Citoyens.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 17:30:46 +02:00
Valentin ROBIN 40a10170bb N'affiche plus de podium quand tout le monde est encore à 0
Build and deploy / deploy (push) Successful in 37s
Le classement "compétition" fait partager le rang 1 à tous les ex-aequo — sans score, tout le groupe se retrouvait sur la 1ère marche du podium. Le podium ne s'affiche plus tant que le meneur n'a pas dépassé 0, sur /leaderboard, /icare et /corne.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 17:05:09 +02:00
Valentin ROBIN 9f91856c53 Améliore la lisibilité de la Détection de triche sur /admin
Build and deploy / deploy (push) Successful in 36s
Ajoute l'avatar du joueur signalé (admin_list_members() le renvoyait déjà, juste jamais propagé), l'heure de début/fin de la partie (déduite de created_at et de la durée annoncée), et remplace la ligne de détails en flex-wrap par une grille à étiquettes qui reste lisible sur mobile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 17:02:14 +02:00
Valentin ROBIN 00743067cc Ajoute un ping serveur régulier comme troisième couche d'anti-triche sur Icare et la Corne
Build and deploy / deploy (push) Successful in 36s
duration_ms/actions/merges restaient entièrement déclaratifs (calculés et envoyés par le client en une fois à la fin), donc falsifiables par un appel RPC direct. Le client envoie maintenant un ping toutes les 5s pendant la partie (ping_game_run), horodaté par le serveur, jamais par le client. submit_icarus_score/submit_melon_score corrèlent le nombre de pings et leur écart réel avec la durée annoncée (parties ≥ 15s uniquement) et signalent toute incohérence dans game_cheat_flags (missing_pings, ping_span_mismatch) — toujours en signalement silencieux, jamais de blocage, comme pour le reste de l'anti-triche.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 16:58:51 +02:00
Valentin ROBIN 088cd92e5f Resynchronise schema.sql avec l'anti-triche par signalement d'Alexandre
submit_icarus_score/submit_melon_score (appliquées directement en base, jamais commitées) sont récupérées mot pour mot via pg_get_functiondef : signalement dans game_cheat_flags plutôt que rejet serveur, le score reste toujours accepté. L'ancienne couche par rejet strict (icarus_runs/start_icarus_run) devenue orpheline est explicitement supprimée plutôt que laissée à traîner. CLAUDE.md documente le nouveau mécanisme (V12) et n'a plus aucune mention de l'ancien.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 16:42:26 +02:00
Valentin ROBIN 50b354f11c Corrige la ligne de danger de la Corne (franchissement complet, pas au contact) et redesigne l'échelle de repère
Build and deploy / deploy (push) Successful in 36s
La pièce devait déjà entièrement dépasser la ligne rouge (bord bas, pas juste le sommet) pour compter dans le délai de grâce avant game over. L'échelle des tailles en bas de page passe d'une seule ligne à défilement horizontal à un flex-wrap sans scroll, avec un titre — l'intérêt d'une échelle de repère est de tout voir d'un coup d'œil. Corrige aussi une violation de react-hooks/purity (Date.now() en argument direct de useRef) introduite par le mécanisme anti-triche fusionné depuis main.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 16:21:11 +02:00
alexandre 6134bf22d0 fix build error
Build and deploy / deploy (push) Successful in 36s
2026-08-24 12:11:50 +02:00
alexandre b7f3f8b0fa Add discreet anticheat mechanism for the games
Build and deploy / deploy (push) Failing after 32s
2026-08-24 12:07:10 +02:00
Valentin ROBIN 35704e50fc Resserre les plafonds de score, ajoute une vérif par temps réel écoulé sur Icare, corrige le contraste de l'Urne
Build and deploy / deploy (push) Successful in 36s
5000 restait trop haut pour être un vrai plafond réaliste : Icare abaissé à 500 (calculé sur le rythme le plus rapide théoriquement atteignable dans le jeu), Corne à 2000 (estimation plus prudente, économie de score plus dure à borner). Ajoute icarus_runs/start_icarus_run() : le serveur enregistre l'instant réel de début de partie et rejette un score incohérent avec le temps écoulé, sans jamais faire confiance à une durée envoyée par le client. Corrige aussi le nombre de jetons de /urne, peu lisible en gold-bright sur fond marbre (passe à text-sea, même convention qu'Icare/Corne).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 11:28:02 +02:00
Valentin ROBIN dc99af6bb3 Durcit la sécurité : plafond de score réaliste, REVOKE explicites, banque de questions du Char réservée aux juges
Build and deploy / deploy (push) Successful in 36s
Un score falsifié envoyé directement à submit_icarus_score/submit_melon_score pouvait se convertir en vraies gloires au Tribunal (plafond abaissé de 1 000 000 à 5000, contrainte + RPC). Les fonctions cron-only award_*_points_if_due() n'étaient protégées que par un commentaire, pas un vrai REVOKE. Le bucket avatars n'avait aucune limite de taille/type. La banque complète des questions du Char (spoiler du jeu) était lisible par tout Citoyen via /char ou un appel direct — RLS restreinte aux juges, /char passe désormais par la RPC chariot_revealed_question().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 04:41:39 +02:00
Valentin ROBIN 3503dc2593 Corrige les pièces qui débordaient du bac à droite/en bas
Build and deploy / deploy (push) Successful in 36s
La bordure était posée directement sur le conteneur aux coordonnées
physiques (300x420) : à cause du box-sizing, la bordure mangeait sur
l'espace intérieur réel (292x416) sans que matter-js le sache, donc les
pièces pouvaient visuellement dépasser le cadre. La bordure vit
maintenant sur un conteneur externe non dimensionné, qui s'ajuste
automatiquement autour du conteneur physique sans réduire sa zone utile.

Ajoute aussi l'échelle de repère (ordre de fusion, taille proportionnelle
au rayon) et le second aperçu de la pièce suivante, travaillés en même
temps.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 04:02:40 +02:00
Valentin ROBIN b0bf54f20c Corrige le lâcher qui ignorait un simple tap sans glissement
Build and deploy / deploy (push) Successful in 36s
Sur mobile, taper sans glisser ne déclenche pas forcément pointermove
avant pointerup, donc la position de visée n'était jamais mise à jour
avant le lâcher : la pièce tombait à l'ancienne position plutôt qu'à
l'endroit tapé. La position se resynchronise maintenant au contact
(pointerdown) et juste avant le lâcher (pointerup).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 01:04:58 +02:00
Valentin ROBIN 05aa15cec0 Corrige la marge excessive de lâcher près des bords du bac
Build and deploy / deploy (push) Successful in 36s
Le calcul de position ajoutait WALL_THICKNESS (20px) en plus du rayon de
la pièce, alors que les murs physiques sont déjà à ras des bords visuels
du bac. Cette marge fixe pénalisait surtout les petites pièces, qui ne
pouvaient pas s'approcher des bords. Seul le rayon compte désormais.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 01:01:25 +02:00
Valentin ROBIN 4dd3dbef85 Affiche la deuxième pièce à venir dans La Corne d'Abondance
Build and deploy / deploy (push) Successful in 36s
Petit badge "Puis" à côté du score, taille d'affichage fixe (juste
informatif, pas de position à viser) — la file glisse d'un cran à chaque
lâcher : ce qui était annoncé en second devient la pièce à viser.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 00:58:03 +02:00
Valentin ROBIN 493ae444b4 Permet de fixer manuellement certains Citoyens à un palier précis
Build and deploy / deploy (push) Successful in 35s
TIER_OVERRIDES (id de profil → index de palier) + applyTierOverrides()
placent d'abord les Citoyens fixés à la main, puis remplissent le reste
dans l'ordre alphabétique existant. Épicure est placé en position 7.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 00:55:38 +02:00
Valentin ROBIN 3b64e1cdb5 Ajoute La Corne d'Abondance, jeu de fusion façon Watermelon Game
Build and deploy / deploy (push) Successful in 35s
Nouveau mini-jeu à /corne : des avatars de Citoyens tombent dans un bac
(physique matter-js), deux avatars de même taille qui se touchent
fusionnent en un plus gros, jusqu'au débordement soutenu (5s de grâce,
pièces clignotantes pour prévenir) qui met fin à la partie. Même
mécanique de gloires que Le Vol d'Icare (record personnel, classement,
gel + attribution automatique au Tribunal via pg_cron).

Un palier de taille par Citoyen (pas les Archontes) plutôt qu'un nombre
arbitraire : citizens[tier] donne l'association palier → Citoyen, fixe
pour tout le monde (les Citoyens sont triés par pseudo côté serveur, un
ordre déterministe). Rendu en DOM pour réutiliser le composant Avatar
existant (étendu pour accepter une taille en pixels arbitraire) plutôt
que de redessiner des images sur canvas — position et rotation de chaque
pièce appliquées en impératif depuis la boucle physique, jamais via
setState.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 00:52:07 +02:00
Valentin ROBIN c3a3577769 Corrige la distance du boost et ajoute des filets de vitesse continus
Build and deploy / deploy (push) Successful in 35s
La fenêtre de 3-8 colonnes du bouclier est maintenant une vraie distance :
chaque colonne rencontrée décompte, qu'elle soit touchée (cassée au lieu
de tuer) ou franchie sans contact (non cassée) — avant, seules les
colonnes touchées consommaient le bouclier, qui pouvait donc durer bien
plus longtemps que prévu.

Ajoute aussi des filets de vitesse continus autour d'Icare pendant toute
la durée du bouclier (pas seulement au bond initial de cueillette), pour
que la sensation d'accélération reste lisible même si Icare est figé.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 23:52:44 +02:00
Valentin ROBIN bdeeb68845 Rend le boost du Vol d'Icare aléatoire et incontrôlable
Build and deploy / deploy (push) Successful in 35s
Le nombre de charges (colonnes cassables) est désormais tiré entre 3 et 8
à chaque cueillette, au lieu d'un nombre fixe. Pendant que le bouclier est
actif, Icare est immobile (gravité coupée, taps/barre d'espace sans
effet) et le défilement accéléré (×1,7) : impossible d'agir sur la
trajectoire jusqu'à épuisement des charges, où le contrôle reprend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 23:38:42 +02:00
Valentin ROBIN b6a025c23f Ajoute un boost (éclair de Zeus) et le support clavier au Vol d'Icare
Build and deploy / deploy (push) Successful in 35s
Rare et exceptionnel (intervalle large de 15-25 colonnes, pas de plafond
par partie) : ramasser l'éclair donne un bouclier de 5 charges — toucher
une colonne (mortel normalement) la casse au contact et consomme une
charge, une par une, plutôt que de détruire un nombre fixe de colonnes
d'un coup à la cueillette. Cueillette animée d'un bond en avant d'Icare
avec traînée de vitesse, halo pulsant sur l'éclair, éclat de particules
sur chaque colonne cassée et liseré doré tant que le bouclier est actif.

Ajoute aussi le support clavier (barre d'espace = tap) pour jouer au
clavier sur ordinateur.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 23:29:19 +02:00
Valentin ROBIN 47ca05f1c6 Ajoute images et réactions sur les notes du Mur de la Honte
Build and deploy / deploy (push) Successful in 36s
Chaque note peut désormais porter une image facultative : compressée et
redimensionnée côté client (resizeImageToBlob, sans forcer un carré comme
pour les avatars) avant l'upload, avec une limite dure côté bucket
Supabase (3 Mo, webp/jpeg/png uniquement) en filet de sécurité. Le chemin
de stockage ne contient jamais le user_id, contrairement aux avatars,
pour ne pas faire fuiter l'auteur via l'URL publique de l'image.

Ajoute aussi des réactions emoji (👍😂😱❤️🔥) sur chaque note, avec le
même principe d'anonymat que le reste du Mur : le total par emoji est
public (wall_note_reaction_counts), mais personne ne voit qui a réagi.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 23:01:40 +02:00
Valentin ROBIN e8a9a0ddb9 Ajoute des justifications anonymes aux votes de L'Urne de l'Agora
Build and deploy / deploy (push) Successful in 35s
Un Citoyen peut désormais laisser un motif facultatif en déposant son
jeton (toujours deux étapes avant l'envoi). Le classement permet de
consulter les justifications reçues par une personne via une nouvelle RPC
urn_vote_reasons() — même principe que wall_notes_today : le contenu est
public, l'auteur ne l'est jamais, ce qui préserve la garantie de L'Urne
que personne (Archontes compris) ne voit qui a voté pour qui.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:48:25 +02:00
Valentin ROBIN 0dfd3e7e90 Passe Le Vol d'Icare en plein écran avec podium animé pour le top 3
Build and deploy / deploy (push) Successful in 35s
Le classement (dont un nouveau podium top 3, réutilisant celui de L'Agora)
vit maintenant dans une modale entièrement détachée de la mise en page du
jeu : une mise à jour Realtime du classement pendant une partie ne peut
plus redimensionner le canvas et casser la partie en cours. Le jeu occupe
tout l'écran disponible (contain avec letterboxing) au lieu d'une carte
fixe en haut de page. Corrige aussi le score du classement, en bleu au
lieu de doré (contraste insuffisant sur fond marbre clair).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:41:08 +02:00
Valentin ROBIN e542b09807 Migre le domaine vers agora.alxczl.fr et corrige la doc de déploiement
Build and deploy / deploy (push) Successful in 35s
.env.docker.example suit le nouveau domaine. README/CLAUDE.md décrivaient
encore un déploiement Vercel jamais réellement utilisé : corrigés pour
refléter le vrai pipeline self-hosted (Docker Compose + Traefik + CI Gitea
Actions via webhook).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:12:38 +02:00
Valentin ROBIN 86756c41da Corrige les comptes invités qui restaient bloqués sans mot de passe
Le pseudo temporaire fixe "Nouveau membre" entrait en collision (contrainte
unique) dès qu'une 2e personne était invitée avant que la 1re ait fini son
inscription ; il inclut maintenant un suffixe d'uuid. Le token d'invitation
pouvait aussi s'établir sur n'importe quelle page (pas seulement /signup),
laissant certains comptes connectés sans jamais voir le formulaire de mot
de passe — /signup affiche désormais un état de chargement au lieu d'un
écran vide, et le proxy renvoie systématiquement vers /signup tant que le
compte n'est pas finalisé.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:12:31 +02:00
Valentin ROBIN 1eef52a101 Retire Le Calendrier des Dieux du menu, affiche la date du Tribunal sur L'Agora
Build and deploy / deploy (push) Successful in 35s
/calendrier disparaît de la navigation (route toujours fonctionnelle
pour les Archontes qui y accèdent directement) : la date du Tribunal,
seule info largement utile au quotidien, est reprise en en-tête de
/leaderboard. Extrait la logique de décompte dans un helper partagé
src/lib/tribunal-date.ts plutôt que de la dupliquer.

Corrige au passage un espace JSX manquant sur /urne oublié lors du
dernier commit ("Tu as voté pour Xaujourd'hui").
2026-08-23 19:29:31 +02:00
Valentin ROBIN d02f1543e9 Ajoute L'Urne de l'Agora
Build and deploy / deploy (push) Successful in 36s
Nouvelle page /urne : vote quotidien inspiré du vote à l'urne de
l'Athènes antique. Chaque Citoyen dépose un jeton par jour sur une
autre personne (jamais lui-même), avec confirmation à deux temps
avant l'envoi — le vote est définitif pour la journée, aucune RPC de
modification. Les jetons s'accumulent sur toute la semaine dans un
classement public et cumulatif, identique pour tout le monde y
compris les Archontes : contraste volontaire avec Le Mur de la Honte,
ici personne (Archontes compris) ne voit qui a voté pour qui, seul le
total par personne est public.

Nouvelle table urn_votes (append-only, comme points_log) + deux RPC :
urn_vote_counts() agrège les votes sans jamais exposer une ligne
individuelle, cast_urn_vote() applique les règles (pas de vote pour
un juge ni pour soi-même, un vote par jour). Les Archontes ne votent
pas et ne peuvent pas recevoir de jetons.
2026-08-23 18:02:12 +02:00
Valentin ROBIN ab599d55c9 Ajoute Le Mur de la Honte
Build and deploy / deploy (push) Successful in 35s
Nouvelle page /mur : chaque Citoyen peut publier une note anonyme par
jour (dix places max sur le mur, remis à zéro chaque jour — filtrage
par date, jamais de suppression). L'anonymat n'est qu'à moitié réel :
les Archontes voient une section "Historique complet" (filtrable par
jour) avec l'auteur de chaque note, cohérent avec le thème du site où
les Archontes ont un ascendant sur les Citoyens.

Nouvelle table wall_notes + deux RPC : wall_notes_today() ne renvoie
jamais l'auteur (seul moyen de garantir l'anonymat côté serveur, la
RLS ne pouvant pas masquer une colonne pour certaines lignes) et
post_wall_note() applique les règles (un juge ne publie pas, une note
par jour, mur plafonné à 10). Pas de gel lié à la date du Tribunal :
contrairement au Char, c'est une mécanique de toute la semaine.
2026-08-23 17:14:12 +02:00
Valentin ROBIN ec6b1920af Ajoute l'historique des versions des questions soumises par les Citoyens
Build and deploy / deploy (push) Successful in 35s
Nouvelle table chariot_submission_history (append-only, comme
points_log) : chariot_submissions ne garde que la valeur actuelle
(upsert), donc sans ce journal les Archontes ne voyaient jamais les
versions précédentes d'une question modifiée plusieurs fois.
submit_chariot_question() y écrit désormais à chaque changement réel.

Sur /char/questions, chaque suggestion affiche un lien "Historique (N)"
(visible seulement si la personne a déjà modifié sa proposition) qui
déplie les versions précédentes avec leur date.
2026-08-22 00:16:42 +02:00
Valentin ROBIN 6badff38af Désactive le bouton d'enregistrement de la question quand rien n'a changé
Build and deploy / deploy (push) Successful in 35s
Ajoute un suivi de la dernière valeur sauvegardée (comme le pattern
déjà utilisé dans admin-members-table.tsx et profile-form.tsx) : le
bouton "Enregistrer" se grise et affiche "Déjà enregistrée" tant que
le texte n'a pas été modifié depuis la dernière sauvegarde, dès le
chargement de la page si la question n'a pas bougé.
2026-08-21 17:26:24 +02:00
Valentin ROBIN 76fa8cd451 Ajoute les suggestions de questions des Citoyens et les emails sur Admin
Build and deploy / deploy (push) Successful in 35s
Chaque Citoyen peut proposer une question pour Le Char depuis /profile
(upsert via submit_chariot_question, figée dès la date du Tribunal
comme les scores d'Icare). Les Archontes modèrent ces suggestions
directement sur /char/questions (ajouter à la banque ou rejeter).

/admin affiche maintenant l'email de chaque membre via une nouvelle
RPC admin_list_members(), seule façon d'exposer auth.users.email sans
passer par la service_role key côté client.

Corrige au passage deux bugs découverts pendant les tests : la
policy interne de admin_list_members() référençait id/role sans les
qualifier, ambigus avec les colonnes du RETURNS TABLE (erreur
Postgres 42702) ; et l'auteur d'une suggestion s'affichait toujours
comme "un Citoyen" car le embed profiles(...) avait été mal retypé en
tableau alors qu'il est retourné en objet à l'exécution.
2026-08-19 01:22:36 +02:00
Valentin ROBIN 0c4814f4d6 Corrige le Realtime qui ne diffusait aucun événement à travers l'app
Build and deploy / deploy (push) Successful in 37s
Les canaux Realtime (Char, Gardien, classement, journal, calendrier,
Icare) s'abonnaient juste après createClient(), avant que la session
ne soit chargée de façon asynchrone — le canal rejoignait donc
Supabase en rôle "anon" au lieu de "authenticated", et les policies
RLS "to authenticated" bloquaient silencieusement tous les événements
(l'abonnement lui-même réussissait, ce qui masquait le problème).

Ajoute waitForRealtimeAuth() dans src/lib/supabase/client.ts, qui
attend la session et pousse explicitement le token avant de
s'abonner. Vérifié avec deux sessions navigateur distinctes : les
changements se propagent maintenant réellement sans rechargement.
2026-08-19 00:40:02 +02:00
Valentin ROBIN 1e7ff621e4 Applique la police et l'icône console d'Admin aussi dans le menu
Build and deploy / deploy (push) Successful in 35s
Partage la police JetBrains Mono (déjà utilisée sur /admin) via un
module commun src/lib/fonts.ts, et l'applique à l'entrée "Admin" du
menu déroulant. Remplace son icône générique par une nouvelle icône
terminal (IconTerminal), cohérente avec la rupture volontaire de
thème sur cette page technique.
2026-08-11 18:51:12 +02:00
Valentin ROBIN 8c952b017b Fusionne La Roulette dans Le Jeu de l'Agora, corrige la Roulette et renomme Admin
Build and deploy / deploy (push) Successful in 35s
Déplace La Roulette de sa page dédiée vers une section de /char (tout
l'outillage de la soirée du Tribunal au même endroit) et retire la
route /roulette. Corrige un bug de superposition où le pointeur doré
de la roulette passait devant le menu déroulant du header (isolation
de contexte d'empilement manquante). Renomme "Le Conseil des
Archontes" en "Admin" avec une police monospace, rupture volontaire
avec le thème grec pour cette page technique.
2026-08-11 18:45:55 +02:00
Valentin ROBIN f30f77a394 Ajoute Le Jeu de l'Agora : Le Char et Le Gardien du Silence
Build and deploy / deploy (push) Successful in 36s
Page /char (16:9, projetée) pour piloter les deux jeux physiques du
Tribunal : Le Char (2 colonnes de 3 emplacements côte à côte avec un
VS et une illustration de char vu du dessus, sélection en un clic,
duels, compteur et classement des passages) et Le Gardien du Silence
(tirage aléatoire parmi les Citoyens, minuteur avec reroll auto et
manuel). Banque de questions gérée séparément sur /char/questions
(pensée pour être pilotée depuis un téléphone pendant que /char est
projetée depuis un ordinateur).
2026-08-11 18:17:45 +02:00
Valentin ROBIN 4e22001ae0 Ajoute une difficulté progressive au Vol d'Icare
Build and deploy / deploy (push) Successful in 1m36s
À partir du score 10, la vitesse de défilement augmente et l'écart
entre les colonnes se resserre progressivement, jusqu'à un palier à
score 50 (vitesse ×1.6, écart réduit à 75%) pour ne pas devenir
injouable sur une longue partie. Le gap est maintenant stocké par
colonne (plutôt qu'une constante globale) pour que les colonnes déjà
en place ne changent pas de taille rétroactivement.
2026-08-01 22:40:54 +02:00
Valentin ROBIN 7c5ce9dcb9 Peaufine Le Vol d'Icare : animation, overlay, contraste
Build and deploy / deploy (push) Successful in 36s
- Écran de fin (score + Rejouer) déplacé en overlay par-dessus le jeu
  (fond assombri, carte animée, confettis) plutôt qu'en dessous.
- Battement d'ailes repensé : cycle continu en boucle (visible même au
  repos, sans taper) + battement plus ample au tap qui prend le dessus
  brièvement. L'ancienne version ne bougeait qu'au moment du tap et
  retombait trop vite pour être perceptible en jeu réel.
- Corrige une aile qui semblait ne pas bouger : les deux ailes utilisaient
  des teintes différentes et l'une passait partiellement derrière le
  corps ; elles sont maintenant identiques et toutes deux dessinées
  au-dessus du corps. Corrige au passage un signe de rotation inversé.
- Corrige le contraste de plusieurs textes (« Ton record », instructions,
  pied de page) qui utilisaient une couleur prévue pour du texte sur
  marbre alors qu'ils s'affichent sur le fond sombre de la page.
2026-07-30 00:59:15 +02:00
Valentin ROBIN 33d2476ef6 Remplace la Course du Char par Le Vol d'Icare
Build and deploy / deploy (push) Successful in 37s
Après retour d'expérience, la course de char (vue du dessus, piste
générée par jour) laisse place à un Flappy Bird grec plus simple et
plus lisible sur téléphone : Icare vole entre des colonnes de temple,
touche l'écran pour battre des ailes, échec net au premier contact
(score remis à zéro).

Record personnel all-time (plus de piste quotidienne ni de fantômes) :
les scores se figent dès que la date du Tribunal est atteinte, puis les
gloires du top 3 sont attribuées automatiquement via une tâche
planifiée pg_cron, comme pour l'ancienne Course du Char.

schema.sql nettoie explicitement l'ancienne Course du Char (tables,
RPC, tâche planifiée) avant de poser le nouveau schéma, puisqu'elle
avait déjà été appliquée en prod.
2026-07-29 19:14:52 +02:00
Valentin ROBIN 6af56d2ade Ajoute La Course du Char : mini-jeu de course 2D quotidien
Build and deploy / deploy (push) Successful in 37s
Premier mini-jeu compétitif du Tribunal (/course) : piste 2D générée
de façon déterministe à partir de la date du jour (identique pour tout
le monde, jamais stockée côté serveur), pilotage au joystick tactile
virtuel, murs + obstacles qui ralentissent temporairement sans jamais
stopper la course, fantômes du top 3 du jour. Chacun rejoue autant
qu'il veut, seul le meilleur temps compte.

Moteur de jeu maison (src/lib/chariot/) : canvas + requestAnimationFrame,
sans dépendance externe, dans la continuité de l'existant (roulette).

Navigation : nouvelle entrée "La Course du Char" (IconChariot), route
protégée par le proxy comme le reste de l'app.
2026-07-27 18:54:04 +02:00
Valentin ROBIN 9f54b459f3 Généralise Podium et computeRanks au-delà des points
computeRanksBy(items, comparator) remplace la logique de classement
"ex-aequo partagent le rang" en la rendant générique ; computeRanks
devient un simple appel avec le comparateur points desc, sans rien
changer pour l'appelant existant. Podium accepte maintenant un prop
renderValue (+ valueLabel optionnel) à la place du "points/gloires" en
dur, pour pouvoir afficher un classement par temps (Course du Char)
avec le même composant que le classement par points.
2026-07-27 18:53:54 +02:00
Valentin ROBIN 777d9ebaea Ajoute le schéma de La Course du Char (tables, RPC, clôture auto)
- chariot_runs : meilleur temps + tracé fantôme par joueur et par jour,
  verrouillée comme points_log (écriture uniquement via RPC).
- submit_chariot_run() : calcule la date côté serveur, borne le temps,
  n'écrase que si strictement meilleur.
- close_daily_chariot_race() + chariot_race_closes : clôture la journée
  précédente et attribue les gloires du top 3 automatiquement, sans
  intervention d'un Archonte — planifiée via pg_cron (à activer une
  fois côté Dashboard Supabase).
- points_log.judge_id devient nullable (un point auto n'a pas de juge) ;
  Le Crieur affiche "Le Tribunal" pour ces décrets-là.
2026-07-27 18:53:45 +02:00
Valentin ROBIN f462cff8a7 Retire le bouton "Nouveau round" du Conseil des Archontes
Un seul round sur toute la semaine : plus besoin de pouvoir
réinitialiser le repère de classement depuis l'admin. La logique des
flèches de progression (previous_rank, reset_rank_reference) reste en
place, simplement plus rien ne la déclenche.
2026-07-27 18:53:22 +02:00
Valentin ROBIN 9d639e1828 Merge remote-tracking branch 'origin/main'
Build and deploy / deploy (push) Successful in 35s
2026-07-26 22:33:03 +02:00
Valentin ROBIN 02228ba123 Corrige l'établissement de session depuis un lien d'invitation
Le lien d'invitation Supabase redirige avec les tokens dans le FRAGMENT
d'URL (#access_token=...&refresh_token=...), jamais dans un paramètre
?code=. Un fragment n'est jamais envoyé au serveur par le navigateur
(une route serveur ne peut donc pas le lire), et le client
@supabase/ssr ne le détecte pas automatiquement (orienté sessions par
cookies). /signup parse maintenant ce fragment et appelle
auth.setSession() explicitement avant de vérifier l'utilisateur.

Supprime la route src/app/auth/callback créée par erreur sur
l'hypothèse d'un flux PKCE (?code=) qui ne correspond pas à ce que
Supabase envoie réellement pour ce type de lien.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 22:32:42 +02:00
alexandre b4ef6626fb fix(ci): only redeploy when specific files are changed 2026-07-26 18:30:59 +02:00
alexandre 538114c9e8 fix(docker): remove unused files
Build and deploy / deploy (push) Successful in 35s
2026-07-26 18:29:36 +02:00
alexandre b13afbf2ce fix(ci): rebuild lightningcss for runner platform
Build and deploy / deploy (push) Successful in 35s
2026-07-26 18:24:55 +02:00
Valentin ROBIN e64e8d3ff2 Merge remote-tracking branch 'origin/main'
Build and deploy / deploy (push) Failing after 28s
2026-07-26 18:08:56 +02:00
Valentin ROBIN 36adb143ca Inscription sur invitation uniquement
Ferme l'auto-inscription publique : l'app repose désormais sur le
mécanisme d'invitation natif de Supabase (Authentication → Users →
Invite user) plutôt que sur un formulaire /signup ouvert à tous.

- /login : retire le lien « Créer un compte ».
- /signup : entièrement repensée. Ce n'est plus un formulaire d'auto-
  inscription (email + mot de passe) mais une page de FINALISATION de
  compte invité. Au montage, vérifie s'il existe une session (établie
  par le clic sur le lien d'invitation reçu par email) :
    * pas de session → message « invitation requise » ;
    * session + profil déjà finalisé (pseudo réel, pas le défaut
      "Nouveau membre" posé par le trigger handle_new_user) → redirige
      directement vers le leaderboard ;
    * session + profil à finaliser → formulaire pseudo + mot de passe
      + photo, qui appelle `updateUser({ password })` puis met à jour
      `profiles` (au lieu de `signUp()`).
- proxy : /signup retiré de AUTH_PATHS — un compte fraîchement invité a
  déjà une session active et doit pouvoir rester sur cette page pour la
  finaliser, sans être renvoyé au leaderboard par le redirect prévu pour
  les utilisateurs déjà connectés.
- README/CLAUDE.md : documentent la désactivation de "Allow new users
  to sign up", la configuration Site URL/Redirect URLs (→ /signup) pour
  que le lien d'invitation atterrisse au bon endroit, et le flux
  d'invitation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 18:08:03 +02:00
Valentin ROBIN a807c4c85e Nouveau logo L'Agora (favicon, header, login, signup, classement)
Remplace le laurier dessiné à la main par le logo fourni (badge circulaire
« L'Agora ») comme identité visuelle principale : favicon (icon.png),
icône iOS (apple-icon.png, remplace icon.svg), marque du header, page
login, page signup, et grand format en en-tête du classement.

- components/agora-logo.tsx : composant partagé, `unoptimized` sur
  next/image — l'optimiseur d'images de Next.js aplatit systématiquement
  la transparence de ce fichier (PNG ou WebP) lors du redimensionnement
  à la volée, donc on sert l'asset statique tel quel.
- Asset retravaillé (public/logo-agora.webp) : le fichier fourni n'avait
  pas de vraie transparence (damier dessiné en dur, y compris dans les
  zones "vides" de l'illustration) et le cercle était mal centré dans
  son canevas carré (anneau doré rogné en haut). Recadré, recentré avec
  une marge uniforme, fond extérieur en transparence réelle, fond
  intérieur aplati en blanc crème (#F4ECD8, le token "marble" existant)
  pour se fondre dans les cartes marbre de l'app.
- Header : bandeau renommé « LE TRIBUNAL » → « L'AGORA » (à gauche) ;
  les icônes du menu déroulant restent inchangées (icônes distinctes
  par page, nécessaires pour s'y retrouver).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 18:07:19 +02:00
77 changed files with 8195 additions and 521 deletions
-9
View File
@@ -1,9 +0,0 @@
node_modules
.next
.git
.env*
!.env.local.example
!.env.docker.example
docker
supabase
*.md
+1 -1
View File
@@ -1,7 +1,7 @@
# Le Tribunal — Docker deployment # Le Tribunal — Docker deployment
# --- Domain --- # --- Domain ---
APP_DOMAIN=acropole.alxczl.fr APP_DOMAIN=agora.alxczl.fr
# --- Traefik --- # --- Traefik ---
TRAEFIK_NETWORK=proxy TRAEFIK_NETWORK=proxy
+11 -1
View File
@@ -3,6 +3,14 @@ run-name: ${{ gitea.actor }} is deploying Le Tribunal
on: on:
push: push:
branches: [main] branches: [main]
paths:
- "src/**"
- "public/**"
- "package.json"
- "package-lock.json"
- "next.config.ts"
- "tsconfig.json"
- "tailwind.config.*"
jobs: jobs:
deploy: deploy:
@@ -14,7 +22,9 @@ jobs:
with: with:
node-version: 22 node-version: 22
- run: npm ci - run: |
npm ci
npm rebuild lightningcss
- name: Build - name: Build
run: | run: |
+121 -18
View File
@@ -6,14 +6,30 @@ Application web pour un groupe d'amis (~12 personnes) qui anime un jeu et des ol
## 1. État actuel ## 1. État actuel
V1 à V5 sont livrées : V1 à V15 sont livrées :
- **V1** : comptes, profils, leaderboard statique. - **V1** : comptes, profils, leaderboard statique.
- **V2** : authentification par email réel (plus de pseudo+email interne), rôles `public`/`judge`, verrou de pseudo (modifiable une fois), section Admin. - **V2** : authentification par email réel (plus de pseudo+email interne), rôles `public`/`judge`, verrou de pseudo (modifiable une fois), section Admin.
- **V3** : attribution de points par les juges (RPC sécurisée, +/- avec confirmation explicite), journal public « le crieur », podium top 3, flèches de progression, recadrage/compression photo côté client. - **V3** : attribution de points par les juges (RPC sécurisée, +/- avec confirmation explicite), journal public « le crieur », podium top 3, flèches de progression, recadrage/compression photo côté client.
- **V4** : direction artistique Grèce antique (marbre/or/mer de nuit, polices Cinzel/Cormorant Garamond/Manrope, vocabulaire Archontes/Citoyens/Agora/Crieur), header global avec chip utilisateur. - **V4** : direction artistique Grèce antique (marbre/or/mer de nuit, polices Cinzel/Cormorant Garamond/Manrope, vocabulaire Archontes/Citoyens/Agora/Crieur), header global avec chip utilisateur.
- **V5** : navigation entièrement repliée dans le menu déroulant du chip (header épuré : logo + chip seulement), page **La Roulette** (tirage au sort animé), page **Le Calendrier des Dieux** (agenda de la semaine, panthéon, date du Tribunal), boutons de validation des points unifiés (`PointsConfirmControls`) entre podium et classement. - **V5** : navigation entièrement repliée dans le menu déroulant du chip (header épuré : logo + chip seulement), page La Roulette (tirage au sort animé — déplacée dans `/char` depuis, voir V7), page **Le Calendrier des Dieux** (agenda de la semaine, panthéon, date du Tribunal), boutons de validation des points unifiés (`PointsConfirmControls`) entre podium et classement.
- **V6** : premier mini-jeu compétitif, **Le Vol d'Icare** (`/icare`) — Flappy Bird grec (Icare vole entre des colonnes de temple), record personnel all-time (pas de piste par jour), touche l'écran pour battre des ailes, échec net au premier contact (score remis à zéro, comme l'original). Les scores sont figés dès que la date du Tribunal est atteinte, puis les gloires du top 3 sont attribuées **automatiquement** (tâche planifiée `pg_cron`, sans intervention d'un Archonte) — seul mécanisme de points de tout le projet qui ne passe pas par une décision de juge. Un boost (« éclair de Zeus »), rare et exceptionnel (intervalle large de 15 à 25 colonnes, pas de plafond par partie — une partie moyenne, ~50 de score, n'en voit naturellement que 1 à 2), apparaît au centre du trou de la colonne qui le porte (donc toujours atteignable) et active le bouclier pour une **distance** aléatoire (3 à 8 colonnes, pas un budget de casses) : chaque colonne rencontrée pendant cette fenêtre compte dans la distance, qu'elle soit touchée (elle casse alors au lieu de tuer, comptée comme passée) ou simplement franchie sans contact (elle n'est pas cassée pour autant). Pendant que le bouclier est actif, Icare est immobile (gravité coupée, les taps/la barre d'espace n'ont plus d'effet — impossible d'agir sur la trajectoire) et le défilement est accéléré (×1,7) : il fonce tout droit à travers les colonnes jusqu'à épuisement de la distance, où le contrôle normal reprend. Cueillette animée d'un bond en avant d'Icare (traînée de vitesse), halo pulsant sur l'éclair, éclat de particules sur chaque colonne cassée et fin liseré doré autour d'Icare tant que le bouclier est actif — entièrement dessiné sur canvas (`src/app/icare/icarus-game.tsx`), sans state React par frame.
- **V7** : outils numériques pour le vrai jeu du Tribunal (soirée physique/sociale, pas un mini-jeu digital), page **Le Jeu de l'Agora** (`/char`) — pensée **format paysage 16:9** (projetée sur grand écran) plutôt que mobile-first comme le reste de l'app. **Le Char** : 2 colonnes fixes de 3 emplacements affichées côte à côte avec un « VS » et une illustration de char vu du dessus entre les deux, affectation d'un Citoyen à un emplacement vide en un clic (liste des Citoyens libres directement cliquables, pas de menu déroulant), bouton « Retirer », boutons « Colonne A/B remporte le duel » (vide la colonne gagnante après une brève animation — les vainqueurs sortent, les perdants restent), compteur du nombre de fois que chacun est monté dans le char, avec un petit classement récapitulatif en bas de page (tout le monde, y compris ceux qui n'y sont jamais monté). La banque de questions (ajout/édition/réordonnancement/bascule afficher-masquer) vit sur une page séparée réservée aux Archontes, **`/char/questions`** — pensée pour être pilotée depuis un téléphone pendant que `/char` est projetée depuis un ordinateur ; `/char` n'affiche que la question actuellement révélée, en lecture seule. **Le Gardien du Silence** : rôle tiré au sort parmi les Citoyens (jamais les Archontes), affiché de façon discrète (barre compacte, pas une grande carte), minuteur avec reroll automatique à expiration (RPC auto-protégée, filet `pg_cron`) et bouton de reroll manuel pour les Archontes. L'app n'arbitre jamais le jeu lui-même, elle affiche/synchronise un état piloté à la main. **La Roulette** (tirage au sort animé parmi les membres, ou les Citoyens uniquement), initialement livrée en V5 sur sa propre page `/roulette`, a été fusionnée dans `/char` comme troisième section — la route et le lien de menu dédiés ont été retirés, tout l'outillage de la soirée du Tribunal vit maintenant au même endroit.
- **V8** : les Citoyens peuvent désormais proposer une question pour Le Char — une par personne (upsert), modifiable autant de fois que voulu depuis une nouvelle section sur `/profile`, jusqu'à ce que la date du Tribunal (`settings.tribunal_date`) soit atteinte (même mécanisme de gel que les scores d'Icare, no-op silencieux plutôt qu'une erreur). Les Archontes modèrent ces suggestions depuis une nouvelle section « Suggestions des Citoyens » sur `/char/questions` : chaque proposition affiche l'auteur, avec un bouton « Ajouter à la banque » (la copie dans `chariot_questions`, à la fin de l'ordre existant) et un bouton de rejet (suppression, confirmation à deux temps comme le reste de la page). Par ailleurs, `/admin` affiche maintenant l'adresse email de chaque membre (RPC `admin_list_members()`, seul moyen d'exposer `auth.users.email` aux juges sans passer par la `service_role key` côté client).
- **V9** : **Le Mur de la Honte** (`/mur`) — chaque Citoyen peut publier une note anonyme par jour (dix places maximum sur le mur, remis à zéro chaque jour — pas de suppression, juste un filtrage par date). L'anonymat n'est qu'à moitié réel : les Archontes voient une section « Historique complet » (filtrable par jour) avec l'auteur de chaque note jamais publiée, cohérent avec le thème du site (les Archontes ont un ascendant sur les Citoyens). Pas de gel lié à la date du Tribunal : contrairement au Char, c'est une mécanique de toute la semaine, pas de la seule soirée de clôture.
- **V10** : **L'Urne de l'Agora** (`/urne`) — vote quotidien inspiré du vote à l'urne de l'Athènes antique : chaque Citoyen dépose un jeton par jour sur une personne de son choix (jamais lui-même), confirmation à deux temps avant l'envoi (vote définitif pour la journée, aucune RPC de modification). Contrairement au Mur de la Honte, les jetons s'accumulent sur toute la semaine dans un classement public et cumulatif — **identique pour tout le monde, Archontes compris** : contraste volontaire avec le Mur, ici les Archontes n'ont aucune vision privilégiée sur qui a voté pour qui, seul le total par personne est public. Les Archontes ne votent pas et ne peuvent pas recevoir de jetons.
- **V11** : deuxième mini-jeu compétitif, **La Corne d'Abondance** (`/corne`) — jeu de fusion façon « Watermelon Game » : des avatars de Citoyens tombent dans un bac (physique gérée par `matter-js`, seule dépendance de ce type dans le projet — l'empilement stable de cercles est notoirement difficile à obtenir sans bugs de superposition/tremblement en physique artisanale), deux avatars de même taille qui se touchent fusionnent en un plus gros, jusqu'au débordement soutenu (5 secondes de délai de grâce, pour ne pas sanctionner un simple rebond) qui met fin à la partie — les pièces qui dépassent la ligne de danger clignotent (`.animate-danger-blink`) pour signaler que le compte à rebours est en cours. **Un palier de taille par Citoyen** (pas les Archontes) plutôt qu'un nombre de paliers arbitraire (`buildTierRadii`, `src/lib/melon/constants.ts`) : `citizens[tier]` (indexation directe, sans tirage) donne l'association palier → Citoyen, **fixe pour tout le monde** (pas juste pour une partie) puisque `corne/page.tsx` trie déjà les Citoyens par pseudo, un ordre déterministe. `TIER_OVERRIDES` (`src/lib/melon/constants.ts`, id de profil → index de palier) permet de fixer certains Citoyens à une taille précise à la main ; `applyTierOverrides()` les place d'abord, puis remplit le reste dans l'ordre alphabétique. Même mécanique de gloires que Le Vol d'Icare : record personnel all-time, scores figés à la date du Tribunal, gloires du top 3 attribuées automatiquement (`pg_cron`, sans intervention d'un Archonte) — n'importe qui (Archontes compris) peut jouer, seul le pool d'avatars affichés est limité aux Citoyens. Rendu en DOM (pas canvas) pour réutiliser tel quel le composant `Avatar` existant (`src/components/avatar.tsx`, étendu pour accepter une taille en pixels arbitraire en plus des tailles nommées) — la position **et la rotation** de chaque pièce sont appliquées en impératif (`style.transform`, `translate(...) rotate(body.angle)`) à 60 fps depuis la boucle physique matter-js, jamais via `setState`. Le halo de l'animation d'apparition doit porter `rounded-full` lui-même, sinon son ombre portée dessine un carré visible autour du rond ; l'apparition d'une pièce utilise un simple fondu (`.animate-fade-in`) plutôt que l'animation avec mise à l'échelle utilisée ailleurs dans l'app (`.animate-reveal`), pour que sa taille exacte soit lisible dès la première frame. Trait de visée vertical + preview flottante au-dessus du bac pour montrer où la prochaine pièce va tomber, plus un second aperçu, plus petit et fixe (pas de position à viser), pour voir venir deux coups à l'avance. La bordure du bac vit sur un conteneur externe non dimensionné (pas sur le conteneur aux coordonnées physiques 300×420) : la mettre directement dessus réduisait sa zone intérieure réelle (box-sizing) sans que matter-js le sache, laissant les pièces s'empiler visuellement en dehors du cadre à droite/en bas. Sous le bac, une échelle de repère (taille d'affichage réduite mais proportionnelle au vrai rayon du palier, une seule ligne défilante, chaque rond centré sur son propre milieu plutôt qu'aligné en bas — un alignement bas avec des tailles très variables décalait progressivement la flèche par rapport aux plus gros ronds) montre l'ordre complet de fusion du plus petit au plus grand Citoyen.
Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, le jeu du Tribunal lui-même (Char, ostracisme, timer du Gardien), mode grand écran. - **V12** : deuxième volet de l'anti-triche des mini-jeux, ajouté par Alexandre (pas depuis cette session, appliqué directement en base puis récupéré/recommité dans `schema.sql` a posteriori — dump de schéma + `pg_policies` + `pg_get_functiondef`) et remplaçant l'approche par rejet serveur strict décrite en §3bis (`icarus_runs`/`start_icarus_run()`, désormais **supprimées**, `drop table`/`drop function` en tête de la section 11 de `schema.sql`) par un **signalement plutôt qu'un blocage** : `IcarusGame`/`MelonGame` calculent une télémétrie de partie côté client (`duration_ms`, nombre d'actions, et pour Corne le nombre de fusions — types `IcarusRunTelemetry`/`MelonRunTelemetry`) et l'envoient avec le score à `submit_icarus_score`/`submit_melon_score` (nouveau paramètre `p_run jsonb`, en plus d'une surcharge à 1 paramètre conservée pour compat qui délègue avec `p_run = null`). Le score reste **toujours accepté** (jamais de `raise exception` pour une télémétrie suspecte, contrairement à l'ancien plafond/l'ancienne vérification de temps qui restent, eux, des rejets durs) ; en parallèle, une partie est journalisée dans `public.game_cheat_flags` (`user_id, game, score, severity, trigger_code, reason, details`) dès que sa télémétrie est absente/invalide, ou incohérente avec ce que le jeu permet physiquement — pour Icare : rythme de score trop rapide (< 450 ms/point), score positif sans aucun battement d'aile, cadence de battements extrême ; pour Corne : lâchers plus rapides que `DROP_COOLDOWN_MS`, plus de fusions que de pièces lâchées ne le permettent, score dépassant le gain maximal théorique par fusion (`maxTier × 2`, la valeur du bonus de fusion finale). Nouvelle section « Détection de triche » sur `/admin` (`admin-cheat-flags.tsx`), filtrable par jeu, pour que les Archontes vérifient les signalements à la main.
- **V13** : troisième couche de l'anti-triche des mini-jeux (à la demande explicite de l'utilisateur, après qu'un premier signalement réel via V12 a confirmé que le mécanisme fonctionnait) — un ping régulier pendant la partie, en plus du signalement fondé sur la télémétrie déclarative de V12. `public.game_run_pings` (`id, user_id, game, session_token, created_at`) journalise chaque ping, horodaté par le **serveur** (jamais par le client, contrairement à `duration_ms`/`actions`/`merges` qui restent déclaratifs) ; `ping_game_run(p_game, p_session_token)` l'écrit. Côté client, `IcarusGame`/`MelonGame` retrouvent un prop `onStart` (le composant de jeu reste agnostique de Supabase, comme avant — c'est `IcareView`/`CorneView` qui gèrent le token/l'intervalle) déclenché au premier battement d'aile pour Icare, au montage et à chaque `resetGame()` pour Corne ; `IcareView`/`CorneView` y génèrent un `session_token` (`crypto.randomUUID()`), lancent un `setInterval` de 5s qui appelle `ping_game_run()` (best-effort, erreurs ignorées), l'arrêtent à la fin de la partie ou au démontage du composant, et l'incluent dans `p_run.session_token` envoyé à `submit_icarus_score`/`submit_melon_score`. Ces deux RPC corrèlent alors le nombre de pings et l'écart réel entre le premier et le dernier avec `duration_ms` annoncé (uniquement si celui-ci dépasse 15s, pour ne jamais signaler une partie courte/un échec immédiat) et ajoutent deux nouveaux `trigger_code` à `game_cheat_flags` : `missing_pings` (zéro ping reçu) et `ping_span_mismatch` (écart réel entre pings très inférieur à la durée annoncée) — toujours en signalement, jamais en blocage, même philosophie que V12. Décision produit assumée (l'utilisateur l'a explicitement refusée pour V12 aussi) : les signalements restent **volontairement invisibles** pour la personne concernée — le but est de savoir qui triche sans le prévenir, y compris si un score signalé finit par recevoir de vraies gloires automatiquement au Tribunal.
- **V14** : Le Mur de la Honte (V9) s'ouvre aux Archontes — ils peuvent désormais publier une note anonyme comme n'importe quel Citoyen (`post_wall_note` n'a plus de vérification de rôle), et la limite quotidienne passe de 10 à 12 notes pour couvrir tout le groupe plutôt que les seuls Citoyens. La policy Storage d'upload sur `wall-images` perd elle aussi son filtre par rôle (sinon un Archonte aurait pu poster une note mais pas y joindre d'image). Contraste assumé avec V10 (l'Urne, où les Archontes ne votent ni ne reçoivent de jetons) : ici rien ne les distingue plus des Citoyens côté publication — ils gardent seulement, comme avant, leur visibilité privilégiée sur l'auteur de chaque note via « Historique complet ».
- **V15** : Le Mur de la Honte s'ouvre à la consultation des jours précédents, pour tout le monde (pas seulement les Archontes via « Historique complet », qui révèle lui l'auteur). `wall_notes_today()` devient `wall_notes_for_date(p_date default null)` — même garantie d'anonymat qu'avant (jamais de `user_id`), juste paramétrée par jour plutôt que hardcodée sur aujourd'hui. Nouvelle RPC `wall_note_days()` (dates ayant au moins une note, sans contenu ni auteur) peuple un sélecteur de jour au-dessus de la grille, visible par tous. Vue « aujourd'hui » toujours branchée sur le Realtime existant (`notes`/`reactionCounts`) ; un jour passé se charge à la demande dans un état séparé (`pastNotes`/`pastReactionCounts`, pas de Realtime dessus — un jour passé n'évolue plus) pour ne jamais mélanger les deux, notamment le compteur « X/12 aujourd'hui » et l'éligibilité à publier, qui restent toujours basés sur aujourd'hui quel que soit le jour affiché. `wall_note_reaction_counts()` gagne le même paramètre `p_date` (ancienne surcharge à 0 argument explicitement supprimée, même remarque que `post_wall_note`).
- **V16** : outillage de gestion des questions du Char (`/char/questions`), aucun changement de schéma. **Glisser-déposer** (`@dnd-kit/core`/`@dnd-kit/sortable`, nouvelle dépendance — choisie pour son support tactile natif via Pointer Events, essentiel puisque cette page est pensée pour être pilotée depuis un téléphone, voir V7) en plus des flèches monter/descendre existantes, pas en remplacement : chaque ligne expose une poignée dédiée (`IconGrip`, seul élément portant `{...attributes} {...listeners}`, pas toute la ligne, pour ne pas déclencher un glisser en tapant le texte ou les boutons) ; au dépôt comme au clic sur **« Randomiser l'ordre »** (confirmation à deux temps), les positions de toute la banque sont réassignées de façon dense (1..N) via `persistOrder()` — pas de contrainte unique sur `chariot_questions.position`, donc aucun risque de collision. **Tags de joueurs** dans le texte d'une question (`@Priape`) : repérés par simple correspondance de texte contre les pseudos existants (`src/lib/mentions.ts`, `findMentionRanges` — pseudos les plus longs testés en premier pour qu'un pseudo préfixe d'un autre ne vole pas son match, limite de mot après la mention pour qu'« @Priape » ne matche pas dans « @Priapee »), **aucune colonne ni table dédiée** : le tag est purement dérivé du texte, jamais stocké séparément. Autocomplétion à la frappe (`MentionTextarea`, détecte un « @partiel » juste avant le curseur) pendant la composition, mais à l'affichage le `@Priape` n'apparaît **jamais au milieu de la phrase** : `stripMentions()` le retire du texte (en nettoyant les espaces qu'il laisse) et le sort en métadonnée à part — une rangée de badges (`MentionTags`, avatar + pseudo) sous la question, « un peu comme une réponse ». Même paire texte-nettoyé/badges réutilisée à l'identique dans la liste de `/char/questions` **et** sur la question révélée projetée sur `/char`. Nouveau panneau « Répartition des tags » (carte secondaire, `border-gold/20`) sur `/char/questions` : compte, pour chaque membre (Archontes compris, comme les emplacements du Char eux-mêmes — aucune restriction de rôle), le nombre de questions distinctes qui le taguent (via `extractMentionedPseudos`, sur le texte brut), y compris les personnes jamais taguées — pour que les Archontes équilibrent la banque avant la soirée.
Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, mode grand écran.
--- ---
@@ -23,7 +39,9 @@ Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons
- **Style** : Tailwind CSS v4 (design tokens en variables CSS, voir `globals.css`) - **Style** : Tailwind CSS v4 (design tokens en variables CSS, voir `globals.css`)
- **Auth + Base + Stockage + Temps réel** : Supabase (Auth, Postgres, Storage, Realtime) - **Auth + Base + Stockage + Temps réel** : Supabase (Auth, Postgres, Storage, Realtime)
- **Recadrage photo** : `react-easy-crop` + canvas natif (resize/compression client, jamais d'upload de l'image brute) - **Recadrage photo** : `react-easy-crop` + canvas natif (resize/compression client, jamais d'upload de l'image brute)
- **Déploiement** : Vercel (front) + Supabase (managé) — pas encore déployé, tourne en local pour l'instant - **Physique 2D** : `matter-js` (uniquement le moteur de simulation — `Engine`/`Composite`/`Bodies`/`Body`/`Events`, jamais son module `Render`), pour La Corne d'Abondance
- **Glisser-déposer** : `@dnd-kit/core` + `@dnd-kit/sortable` (+ `@dnd-kit/utilities`), pour la banque de questions du Char (`/char/questions`) — support tactile natif (Pointer Events), nécessaire puisque cette page est pensée pour être pilotée depuis un téléphone (voir V7/V16)
- **Déploiement** : self-hosted, Docker Compose + Traefik (reverse proxy), Supabase (managé) — CI via Gitea Actions (`.gitea/workflows/deploy.yml`) qui build le standalone Next.js et le POST à un webhook (`docker/webhook/receive.py`) déclenchant un hot-swap sans downtime (`docker/entrypoint.sh`) ; domaine injecté par `APP_DOMAIN` (`.env.docker`, gitignored — seul `.env.docker.example` est versionné) dans le label Traefik `Host()`. Domaine actuel : `agora.alxczl.fr`.
Next.js 16 a renommé `middleware.ts` en `proxy.ts` (fichier `src/proxy.ts`, fonction `proxy`). `src/lib/supabase/middleware.ts` est un nom de fichier utilitaire interne, sans rapport avec cette convention. Next.js 16 a renommé `middleware.ts` en `proxy.ts` (fichier `src/proxy.ts`, fonction `proxy`). `src/lib/supabase/middleware.ts` est un nom de fichier utilitaire interne, sans rapport avec cette convention.
@@ -56,6 +74,39 @@ Le script est **idempotent** : toujours le ré-exécuter en entier après une mo
- `settings` : ligne unique (`id boolean primary key default true`, contrainte `check (id)`) — `tribunal_date`. - `settings` : ligne unique (`id boolean primary key default true`, contrainte `check (id)`) — `tribunal_date`.
- RLS : `SELECT` ouvert à tout authentifié ; `INSERT`/`UPDATE`/`DELETE` réservés au rôle `judge`, vérifié par policy (`exists (select 1 from profiles where id = auth.uid() and role = 'judge')`) — pas de trigger `BEFORE UPDATE` ici car il n'y a pas de colonne à protéger *dans une ligne par ailleurs modifiable par tous* (contrairement à `profiles`) : toute la table est verrouillée en écriture aux juges. - RLS : `SELECT` ouvert à tout authentifié ; `INSERT`/`UPDATE`/`DELETE` réservés au rôle `judge`, vérifié par policy (`exists (select 1 from profiles where id = auth.uid() and role = 'judge')`) — pas de trigger `BEFORE UPDATE` ici car il n'y a pas de colonne à protéger *dans une ligne par ailleurs modifiable par tous* (contrairement à `profiles`) : toute la table est verrouillée en écriture aux juges.
### `public.icarus_scores` (Le Vol d'Icare)
- `user_id, best_score, updated_at` — clé primaire `user_id` : un seul record personnel all-time par joueur (pas de notion de jour). Verrouillée comme `points_log` : lecture ouverte aux authentifiés, écriture uniquement via la RPC `submit_icarus_score`.
- Contrainte `check (best_score between 0 and 500)` sur `best_score` — la vraie garantie contre un score falsifié envoyé directement à la RPC (voir §3bis) ; `MAX_SCORE` dans `src/lib/icarus/constants.ts` n'est qu'une valeur de référence côté client, à garder alignée.
- ~~`public.icarus_runs`~~ : deuxième couche anti-triche par rejet serveur strict, **supprimée** (`drop table`) depuis V12, remplacée par `game_cheat_flags` — voir §3bis.
- `settings.icarus_points_awarded` : marqueur d'idempotence (un seul événement — l'attribution au début du Tribunal — pas une clôture quotidienne comme l'ancienne Course du Char).
- Génération des colonnes/obstacles entièrement côté client, sans graine partagée (pas de piste identique pour tout le monde à reproduire ici — chaque partie est procédurale).
### `public.melon_scores` (La Corne d'Abondance)
- `user_id, best_score, updated_at` — même gabarit exact que `icarus_scores` (clé primaire `user_id`, verrouillée en écriture, uniquement via `submit_melon_score`), avec sa propre contrainte `check (best_score between 0 and 2000)` — pas la même valeur qu'Icare, l'économie de score est différente (voir §3bis).
- `public.game_cheat_flags` (`id, user_id, game, score, severity, trigger_code, reason, details jsonb, created_at`) : anti-triche commun à Icare et Corne depuis V12 (voir §1) — journalise les parties suspectes plutôt que de les bloquer, affiché aux Archontes sur `/admin`. RLS `select` réservée aux juges (même pattern que `chariot_questions`), aucun grant d'écriture pour `authenticated` (comme `points_log`/`icarus_scores`) — écrit uniquement par `submit_icarus_score`/`submit_melon_score`.
- `public.game_run_pings` (`id, user_id, game, session_token, created_at`) : depuis V13 (voir §1) — un ping toutes les 5s pendant une partie, horodaté par le serveur, pour corroborer `duration_ms` (déclaratif) avec une preuve indépendante. Aucun grant du tout pour `authenticated`/`anon` (même principe que l'ancien `icarus_runs`) : seules `ping_game_run()` (insert) et `submit_icarus_score`/`submit_melon_score` (lecture + nettoyage) y touchent.
- `settings.melon_points_awarded` : marqueur d'idempotence, même principe que `icarus_points_awarded`.
- `submit_melon_score(p_score)` / `submit_melon_score(p_score, p_run)` / `award_melon_points_if_due()` : mêmes deux surcharges qu'`submit_icarus_score` (1 paramètre = compat, délègue avec `p_run = null` ; 2 paramètres = vrai point d'entrée), même borne fixe (2000, pas 500) et mêmes principes de signalement dans `game_cheat_flags`, y compris les vérifications par ping de V13 — voir V12/V13 en §1 pour le détail des vérifications propres à Corne (cooldown de lâcher, nombre de fusions, gain max par fusion). `award_melon_points_if_due` reste une copie conforme d'`award_icarus_points_if_due` (gel à `tribunal_date`, top 3 ex-aequo inclus, `judge_id = null` dans `points_log`, tâche `pg_cron` `award-melon-points`, `execute` révoqué).
- Le pool d'avatars des pièces (Citoyens uniquement, filtré côté serveur dans `corne/page.tsx` — un palier de taille par Citoyen) et les paliers/rayons du jeu vivent côté client (`src/lib/melon/constants.ts`), pas en base. N'importe qui peut jouer et apparaître dans `melon_scores`/le classement (Archontes compris) — seul le skin des pièces est restreint aux Citoyens.
### `public.chariot_questions` / `public.chariot_slots` / `public.chariot_entries` (Le Jeu de l'Agora — Le Char)
- `chariot_questions` : `id, text, position, created_at` — banque de questions ordonnée, réordonnée par swap de `position` (pas de contrainte unique dessus, deux `update` non transactionnels ; toujours trier `order by position, created_at`). RLS : **lecture réservée aux juges** (audit de sécurité — la liste complète serait un spoiler du jeu en cours pour un Citoyen qui la lirait directement, RLS ouverte à tout le monde jusque-là), écriture réservée aux juges (comme `days`/`events`). Gérée exclusivement depuis `/char/questions`, jamais depuis `/char`. `/char` (accessible à tous) ne lit plus jamais cette table directement : il passe par la RPC `chariot_revealed_question()`, qui ne renvoie que la question actuellement révélée (avec son numéro d'ordre, calculé côté serveur pour ne jamais exposer la position des autres questions).
- `chariot_slots` : `slot (pk, 1 à 6), user_id nullable` — 6 emplacements fixes pré-remplis une fois pour toutes (1-3 = Colonne A, 4-6 = Colonne B), jamais insert/delete côté client, seulement des `update` pour affecter/vider un emplacement. RLS : lecture ouverte à tout le monde (contrairement à `chariot_questions` depuis l'audit — rien à cacher ici, l'occupation des emplacements est publique par nature), écriture réservée aux juges.
- `chariot_entries` : `id, user_id, created_at` — journal append-only (comme `points_log`) d'une ligne à chaque affectation d'un Citoyen à un emplacement, sert uniquement à compter combien de fois chacun est monté dans le char (`count(*) group by user_id`, calculé côté client). RLS : lecture ouverte, insertion réservée aux juges, aucune mise à jour/suppression possible.
- `settings.chariot_revealed_question_id` : question actuellement affichée sur `/char`, directement modifiable par les juges (rien à protéger dans cette colonne-là) — bascule effectuée depuis `/char/questions`.
- Préfixe `chariot_*` (pas `char_*`) volontaire : sans rapport avec l'ancienne Course du Char digitale supprimée, qui utilisait déjà ce préfixe.
### `public.chariot_submissions` (suggestions de questions par les Citoyens, V8)
- `user_id (pk), text, updated_at` — une ligne par Citoyen (upsert, comme `icarus_scores`). Verrouillée comme `icarus_scores`/`points_log` : `insert`/`update` révoqués pour `authenticated`, seule la RPC `submit_chariot_question()` (SECURITY DEFINER) peut écrire.
- RLS `select` : le propriétaire voit sa propre ligne, les juges voient tout (modération) — pas de visibilité entre Citoyens.
- RLS `delete` : réservée aux juges (rejet direct depuis `/char/questions`, pas besoin de RPC pour une suppression simple).
- Gel réutilisant `settings.tribunal_date` (même déclencheur que `submit_icarus_score`) plutôt qu'une nouvelle colonne dédiée.
- `chariot_submission_history` : journal append-only (comme `points_log`) des versions successives d'une proposition — `chariot_submissions` n'upsertant que la valeur courante, sans ce journal les Archontes ne verraient jamais les versions précédentes d'une question modifiée plusieurs fois. Même RLS (propriétaire + juges), écrit uniquement par `submit_chariot_question()`. Affiché en repli sur `/char/questions` (« Historique (N) » par proposition, versions précédentes uniquement — la version actuelle est déjà affichée au-dessus).
### Colonnes sensibles : RLS + trigger, jamais confiance au client ### Colonnes sensibles : RLS + trigger, jamais confiance au client
La RLS est **au niveau ligne** : elle ne peut pas exprimer « cette colonne seulement si tel rôle ». Le verrouillage fin des colonnes (`role`, `points`, `pseudo_locked`, pseudo figé, `previous_rank`) passe par le trigger `enforce_profile_update()` : La RLS est **au niveau ligne** : elle ne peut pas exprimer « cette colonne seulement si tel rôle ». Le verrouillage fin des colonnes (`role`, `points`, `pseudo_locked`, pseudo figé, `previous_rank`) passe par le trigger `enforce_profile_update()` :
@@ -64,19 +115,63 @@ La RLS est **au niveau ligne** : elle ne peut pas exprimer « cette colonne seul
- `pseudo` : modifiable une fois par le propriétaire (auto-lock ensuite), ou à tout moment par un `judge`. - `pseudo` : modifiable une fois par le propriétaire (auto-lock ensuite), ou à tout moment par un `judge`.
- `auth.uid() is null` (SQL Editor, migrations) bypass le trigger : ces accès sont déjà fiables par construction. - `auth.uid() is null` (SQL Editor, migrations) bypass le trigger : ces accès sont déjà fiables par construction.
Même pattern sur `public.settings` depuis V7, via `enforce_settings_update()` : `gardien_holder_id`/`gardien_expires_at` sont bloqués en écriture directe pour **tout le monde**, y compris les juges — seule la RPC `reroll_gardien()` peut les modifier (flag `app.bypass_gardien_lock`), pour garantir que le Gardien est réellement tiré au sort et pas choisi à la main. `tribunal_date`, `icarus_points_awarded` et `chariot_revealed_question_id` restent, eux, directement modifiables par les juges (rien à protéger dans ces colonnes-là).
Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accès aux colonnes) est la convention du projet — le reproduire pour toute nouvelle colonne sensible plutôt que d'inventer autre chose. Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accès aux colonnes) est la convention du projet — le reproduire pour toute nouvelle colonne sensible plutôt que d'inventer autre chose.
### §3bis. Durcissement de sécurité (audit)
PostgreSQL accorde `EXECUTE` à `PUBLIC` par défaut sur toute fonction créée, sauf révocation explicite — un commentaire disant « pas de grant à authenticated » **n'est pas** une vraie restriction d'accès si le `REVOKE` correspondant n'est pas écrit, la fonction reste appelable via l'endpoint REST RPC de Supabase. `schema.sql` ouvre donc avec `alter default privileges in schema public revoke execute on functions from public;` (s'applique aux fonctions créées après cette ligne dans le script, pas rétroactivement) et chaque fonction cron-only (`award_icarus_points_if_due`, `award_melon_points_if_due`) porte en plus un `revoke execute ... from public, anon, authenticated;` explicite juste après sa définition.
Incident réel ayant motivé cet audit : les scores d'Icare/Corne n'ont **aucune vérification de gameplay côté serveur** (génération procédurale entièrement côté client, comme documenté depuis V6/V11) — c'est un choix assumé pour un classement amical, mais l'ancien plafond (1 000 000) rendait un score falsifié, envoyé directement à `submit_icarus_score`/`submit_melon_score` via l'API, capable de se convertir en **vraies gloires** au Tribunal (via l'attribution automatique du top 3). Plafond d'abord ramené à 5000 puis, après un deuxième passage d'audit jugeant encore ça trop haut pour être un vrai plafond réaliste, resserré à 500 pour Icare / 2000 pour Corne (valeurs différentes : l'économie de score de Corne, basée sur des fusions enchaînées plutôt qu'une vitesse de défilement fixe, est plus dure à borner précisément — voir juste en dessous). Appliqué à deux niveaux dans les deux cas : la contrainte `check` sur `best_score` (la vraie garantie, `alter table ... add constraint` — un `check` inline sur `create table if not exists` ne s'appliquerait pas rétroactivement à une table déjà créée) et, en défense en profondeur, la validation dans la RPC elle-même.
Pour Icare spécifiquement, une deuxième couche avait été ajoutée ici, orthogonale à la borne fixe : `public.icarus_runs` enregistrait le vrai instant de début de partie côté serveur et `submit_icarus_score` rejetait un score incohérent avec le temps réellement écoulé. **Cette approche a depuis été remplacée** par le mécanisme de signalement d'Alexandre (`game_cheat_flags`, voir V12 en §1) — `icarus_runs`/`start_icarus_run()` sont désormais supprimées (`drop table`/`drop function` en tête de la section 11 dans `schema.sql`), pas juste orphelines.
Bucket Storage `avatars` : `file_size_limit`/`allowed_mime_types` ajoutés (`on conflict (id) do update`, pas `do nothing`, pour que ré-exécuter `schema.sql` applique bien la limite à un bucket déjà existant) — sans ça, un upload direct à l'API de Storage (hors de l'app, où c'est toujours `cropImageToBlob` qui compresse) pouvait déposer un fichier arbitrairement gros ou non-image comme avatar. Même limite que `wall-images` (3 Mo, image/webp+jpeg+png).
Deuxième volet de l'audit (revue complète de tout le code applicatif, pas seulement `schema.sql`) : la RLS `select` de `chariot_questions` était ouverte à tout authentifié (`using (true)`), et `/char/page.tsx` en lisait la table entière pour l'embarquer dans le payload RSC envoyé à tout le monde — un Citoyen pouvait donc lire la banque complète des questions à venir (spoiler du jeu de la soirée) simplement en inspectant la page ou en rejouant l'appel Supabase depuis la console, alors même que `/char` n'en affiche jamais qu'une seule à l'écran. Corrigé en deux temps : RLS `select` de `chariot_questions` restreinte aux juges (seul `/char/questions`, déjà réservé aux juges, en a encore besoin), et nouvelle RPC `chariot_revealed_question()` qui ne renvoie que la question révélée (avec son numéro d'ordre) pour que `/char` n'ait plus jamais besoin de lire la table directement.
### RPC exposées ### RPC exposées
- `is_pseudo_taken(p_pseudo)` — anon + authenticated, ne renvoie qu'un booléen (l'anon ne peut pas lire `profiles`). - `is_pseudo_taken(p_pseudo)` — anon + authenticated, ne renvoie qu'un booléen (l'anon ne peut pas lire `profiles`).
- `award_points(p_target_id, p_delta, p_reason)` — authenticated, vérifie `role = 'judge'` côté serveur, jamais côté client. - `award_points(p_target_id, p_delta, p_reason)` — authenticated, vérifie `role = 'judge'` côté serveur, jamais côté client.
- `reset_rank_reference()` — authenticated, vérifie `role = 'judge'`, fige le classement courant dans `previous_rank`. - `reset_rank_reference()` — authenticated, vérifie `role = 'judge'`, fige le classement courant dans `previous_rank`.
- ~~`start_icarus_run()`~~ — **supprimée** (voir §3bis/V12), remplacée par `game_cheat_flags`.
- `submit_icarus_score(p_score)` — authenticated, surcharge à 1 paramètre conservée pour compat (ancien client, appel RPC direct) : délègue à la version à 2 paramètres avec `p_run = null`, systématiquement signalée comme suspecte (`missing_run_telemetry`) mais jamais bloquée.
- `ping_game_run(p_game, p_session_token)` — authenticated, appelée toutes les 5s pendant une partie (Icare ou Corne) : horodate un ping dans `game_run_pings` pour la vérification par ping de `submit_icarus_score`/`submit_melon_score` — voir V13 en §1.
- `submit_icarus_score(p_score, p_run)` — authenticated, seul vrai point d'entrée du client actuel (`p_run` en `jsonb`, la télémétrie de partie, avec `session_token` depuis V13). Vérifie côté serveur si la date du Tribunal est déjà passée (scores figés : no-op silencieux plutôt qu'une erreur, mais après le signalement — voir ci-dessous), n'écrase le record que s'il est strictement battu, rejette un score hors bornes (0-500, défense en profondeur — la contrainte `check` sur `icarus_scores.best_score` est la garantie réelle). Signale dans `game_cheat_flags` (jamais de rejet) : télémétrie manquante/invalide, rythme de score trop rapide pour la vitesse max du jeu, score positif sans battement d'aile enregistré, cadence de battements extrême, ou (V13, uniquement si `duration_ms` ≥ 15s) aucun ping serveur reçu / écart réel entre pings très inférieur à la durée annoncée — voir V12/V13 en §1.
- `award_icarus_points_if_due()`**`execute` explicitement révoqué de `public`/`anon`/`authenticated`** (voir §3bis), appelée uniquement par la tâche planifiée `pg_cron` (ou depuis le SQL Editor) : dès que la date du Tribunal est atteinte, attribue les gloires du top 3 automatiquement (`judge_id = null` dans `points_log`, affiché comme « Le Tribunal » dans Le Crieur), une seule fois (`settings.icarus_points_awarded`).
- `reroll_gardien(p_force default false)` — authenticated. Tire un nouveau Gardien parmi les Citoyens (jamais le détenteur actuel si possible) et repousse `gardien_expires_at` de 5 minutes. `p_force=true` (vérifie `role = 'judge'`) reroll immédiatement ; `p_force=false` (appelée par le minuteur côté client à expiration, et par `pg_cron` en filet) ne fait rien tant que `gardien_expires_at` n'est pas atteint — un seul `UPDATE ... WHERE` atomique (pas de `SELECT` puis `UPDATE`), pour qu'un reroll naturel avec plusieurs téléphones ouverts au même moment ne reroll qu'une seule fois.
- `submit_chariot_question(p_text)` — authenticated, rejette les juges (seuls les Citoyens proposent), valide un texte non vide (≤ 300 caractères), et applique le même gel que `submit_icarus_score` via `settings.tribunal_date` (no-op silencieux une fois l'Agora commencée). `insert ... on conflict (user_id) do update` : une ligne par personne dans `chariot_submissions`, plus une ligne append-only dans `chariot_submission_history` à chaque écriture réelle (pas lors du no-op figé).
- `chariot_revealed_question()` — authenticated, `language sql`, seul moyen pour un non-juge de connaître la question actuellement révélée sans jamais lire `chariot_questions` (RLS juges uniquement, voir §3bis) — renvoie zéro ligne tant qu'aucune question n'est révélée, et calcule `question_number` (position dans l'ordre complet) côté serveur pour afficher « Question N » sur `/char` sans exposer les autres lignes.
- `admin_list_members()` — authenticated, vérifie `role = 'judge'`, seule façon d'exposer `auth.users.email` (jamais stocké dans `profiles`) sur `/admin` sans passer par la `service_role key` côté client.
- `wall_notes_for_date(p_date default null)` — authenticated, `language sql`, renvoie les notes d'un jour donné (heure de Paris, `p_date` à `null` = aujourd'hui), y compris `image_url`, **sans `user_id`** — seul moyen de voir les notes des autres sans jamais exposer l'auteur côté client (la RLS ne peut pas masquer une colonne pour certaines lignes), ouvert à tout le monde y compris pour les jours précédents depuis V15.
- `wall_note_days()` — authenticated, `language sql`, renvoie les dates ayant au moins une note (jamais le contenu ni l'auteur) — peuple le sélecteur de jour de `wall_notes_for_date` sur `/mur`.
- `post_wall_note(p_text, p_image_url default null)` — authenticated, ouvert à tout le monde y compris les juges depuis V14 (plus de vérification de rôle), valide un texte non vide (≤ 200 caractères), puis vérifie que l'appelant n'a pas déjà publié aujourd'hui et que le mur du jour a moins de 12 notes (sinon `raise exception` dans les deux cas — pas de no-op silencieux ici, l'UI doit remonter l'erreur). `p_image_url` est facultatif et doit venir du bucket `wall-images` (vérifié par un `like`).
- `wall_note_reaction_counts(p_date default null)` — authenticated, `language sql`, renvoie `(note_id, emoji, count)` agrégé sur les notes d'un jour donné (même paramètre `p_date` que `wall_notes_for_date`, depuis V15) — même principe que `urn_vote_counts` : jamais qui a réagi, seulement le total.
- `toggle_wall_note_reaction(p_note_id, p_emoji)` — authenticated, bascule une réaction (ajoute si absente, retire si déjà posée en un `delete` puis, si rien n'a été supprimé, un `insert`). `p_emoji` limité à une liste fermée (contrainte `check` sur la colonne, revérifiée dans la RPC pour un message d'erreur clair).
- `urn_vote_counts()` — authenticated, `language sql`, renvoie `(target_id, votes)` agrégé sur tout l'historique (pas de filtre de date, contrairement à `wall_notes_for_date`) — seul moyen de calculer un total public sans jamais exposer une ligne individuelle (qui a voté pour qui).
- `urn_vote_reasons(p_target_id)` — authenticated, `language sql`, renvoie les justifications (`reason`, `created_at`) laissées pour une personne, **sans `voter_id`** — même principe que `wall_notes_for_date` : le contenu est public depuis le classement, l'auteur ne l'est jamais.
- `cast_urn_vote(p_target_id, p_reason default null)` — authenticated, rejette les juges (ni votants ni cibles), rejette le vote pour soi-même, vérifie que l'appelant n'a pas déjà voté aujourd'hui (heure de Paris) — vote définitif, aucune RPC de modification/suppression. `p_reason` est une justification facultative (≤ 200 caractères, `nullif(trim(...), '')` pour normaliser une chaîne vide en `null`).
### `public.wall_notes` (Le Mur de la Honte, V9)
- `id, user_id, text, image_url, created_at` — une ligne par note, jamais éditée/supprimée (comme `points_log`) ; le « reset quotidien » du compteur de publication n'est qu'un filtrage par date dans `wall_notes_for_date()`, pas une suppression — les notes restent consultables (anonymement) les jours suivants depuis V15. `image_url` est facultative, pointe vers le bucket `wall-images`.
- RLS `select` : le propriétaire voit sa propre note (sert à afficher « tu as déjà publié aujourd'hui » et à réafficher son propre texte), les juges voient tout avec l'auteur (embed `profiles(pseudo, avatar_url)`, section « Historique complet » sur `/mur`) — pas de visibilité entre Citoyens, c'est tout l'intérêt de `wall_notes_for_date()`.
- Pas de gel via `settings.tribunal_date` : contrairement au Char, c'est une mécanique de toute la semaine.
- Bucket Storage `wall-images` (public, `file_size_limit` 3 Mo, `allowed_mime_types` image/webp+jpeg+png — même limite que le bucket `avatars`, voir §3bis) : contrairement à `avatars`, le chemin d'objet est un UUID **sans le `user_id`** — sinon l'auteur fuiterait via l'URL publique de l'image, ce que `wall_notes_for_date()` prend justement soin de ne jamais exposer. Compression côté client avant upload (`resizeImageToBlob`, `src/lib/image.ts`, contrairement à `cropImageToBlob` qui force un carré pour les avatars) ; la limite du bucket n'est qu'un filet de sécurité, pas la seule garantie.
- `public.wall_note_reactions` (`id, note_id, user_id, emoji, created_at`, `unique (note_id, user_id, emoji)`) : réactions emoji sur une note, même principe d'anonymat que les notes elles-mêmes — RLS `select` limitée à `user_id = auth.uid()` (pour savoir lesquelles sont déjà activées), le total public passe uniquement par `wall_note_reaction_counts()`.
### `public.urn_votes` (L'Urne de l'Agora, V10)
- `id, voter_id, target_id, reason, created_at` — journal append-only (comme `points_log`/`chariot_entries`) d'un vote par ligne, jamais un upsert : les jetons s'accumulent d'un jour sur l'autre, contrairement à `wall_notes` qui ne montre que le jour courant. `reason` est une justification facultative laissée par le votant.
- RLS `select` : **pas d'exception juge** (contrairement à `chariot_submissions`/`wall_notes`) — le votant ne voit que sa propre ligne (pour savoir « j'ai déjà voté aujourd'hui »), personne d'autre, Archontes compris, ne peut voir qui a voté pour qui ; seul `urn_vote_counts()` expose un total agrégé, et `urn_vote_reasons()` le contenu des justifications sans leur auteur, identiques pour tout le monde.
--- ---
## 4. Authentification ## 4. Authentification
Email + mot de passe (Supabase Auth impose l'unicité de l'email). Confirmation d'email **activée** — nécessite un SMTP custom configuré côté Supabase (le service intégré est trop limité en volume, voir README). Le pseudo est un simple nom d'affichage stocké dans `profiles`, jamais l'email n'est montré aux autres membres. **Sur invitation uniquement**, plus d'auto-inscription publique : "Allow new users to sign up" est désactivé côté Supabase. Un Archonte invite via Authentication → Users → Invite user ; le lien reçu établit une session directement sur `/signup`, qui sert alors à finaliser le compte (mot de passe, pseudo, photo) — sans session valide, la page affiche un message « invitation requise ». Le profil est créé dès l'invitation par le trigger `handle_new_user` (pseudo temporaire `"Nouveau membre " + 8 premiers caractères de l'uuid` jusqu'à finalisation — le suffixe garantit l'unicité quand plusieurs personnes sont invitées avant d'avoir fini leur inscription ; `/signup` détecte l'état « pas encore finalisé » par préfixe, jamais par égalité stricte). Nécessite un SMTP custom côté Supabase (le service intégré est trop limité en volume, voir README). Le pseudo est un simple nom d'affichage stocké dans `profiles`, jamais l'email n'est montré aux autres Citoyens — seuls les Archontes le voient, sur `/admin` (via `admin_list_members()`, voir §3).
--- ---
@@ -87,14 +182,19 @@ Email + mot de passe (Supabase Auth impose l'unicité de l'email). Confirmation
| `/` | Redirige vers `/leaderboard` si connecté, sinon `/login` | | `/` | Redirige vers `/leaderboard` si connecté, sinon `/login` |
| `/login` | Email + mot de passe | | `/login` | Email + mot de passe |
| `/signup` | Email + mot de passe + pseudo + photo (recadrée) ; gère l'attente de confirmation email | | `/signup` | Email + mot de passe + pseudo + photo (recadrée) ; gère l'attente de confirmation email |
| `/leaderboard` | « L'Agora » — podium top 3 + liste, Realtime, contrôles de points pour les juges | | `/leaderboard` | « L'Agora » — podium top 3 + liste, Realtime, contrôles de points pour les juges, date/décompte du Tribunal en en-tête (`src/lib/tribunal-date.ts`, partagé avec `/calendrier`). Joue le rôle de page d'accueil (`/` y redirige) : une rangée de raccourcis (`quick-links.tsx`, icônes réutilisées du menu du header) vers les pages d'activité — jeux, Mur, Urne, Crieur — juste au-dessus du classement, avec une pastille façon iOS sur le raccourci du Mur (nombre de publications du jour, via `wall_notes_for_date()` — pas de lecture directe de `wall_notes`, dont la RLS ne laisserait voir que ses propres notes). Juste en dessous, `game-highlights.tsx` affiche le n°1 actuel d'Icare et de la Corne d'Abondance (lecture directe de `icarus_scores`/`melon_scores`, RLS déjà ouverte à tout authentifié) — même règle que les podiums de ces deux jeux : pas de champion affiché tant que le meneur est à 0 |
| `/roulette` | « La Roulette » — tirage au sort animé parmi les membres (ou Citoyens uniquement) | | `/icare` | « Le Vol d'Icare » — Flappy Bird grec, record personnel all-time, scores figés au Tribunal, points attribués automatiquement |
| `/calendrier` | « Le Calendrier des Dieux » — agenda de la semaine, panthéon, date du Tribunal, édition réservée aux juges | | `/corne` | « La Corne d'Abondance » — jeu de fusion façon Watermelon Game (avatars aléatoires, physique `matter-js`), même mécanique de gloires que Le Vol d'Icare |
| `/char` | « Le Jeu de l'Agora » — format paysage (projeté) : Le Char (2 colonnes de 3, boutons de duel), Le Gardien du Silence (tirage aléatoire, minuteur discret) et La Roulette (tirage au sort animé) |
| `/char/questions` | Banque de questions du Char (ajout/édition/réordonnancement par flèches ou glisser-déposer/randomisation/afficher-masquer/tags `@pseudo` avec répartition) + modération des suggestions des Citoyens (ajouter à la banque/rejeter) — juges uniquement, pilotée depuis un téléphone pendant que `/char` est projetée |
| `/mur` | « Le Mur de la Honte » — une note anonyme par personne par jour, Archontes compris depuis V14 (douze places, remis à zéro chaque jour) ; les jours précédents restent consultables anonymement par tous depuis V15 (sélecteur de jour) ; les Archontes y voient en plus l'historique complet avec l'auteur de chaque note |
| `/urne` | « L'Urne de l'Agora » — un jeton par Citoyen par jour sur une autre personne, classement cumulatif public identique pour tous, Archontes compris (aucune vision privilégiée ici) |
| `/calendrier` | « Le Calendrier des Dieux » — agenda de la semaine, panthéon, date du Tribunal, édition réservée aux juges. Retirée du menu de navigation (la date du Tribunal, seule info largement utile au quotidien, est reprise sur `/leaderboard`) mais la route reste fonctionnelle — les juges y accèdent directement par l'URL pour gérer le calendrier et changer la date |
| `/journal` | « Le Crieur » — fil live des décrets (attributions de points), lecture pour tous | | `/journal` | « Le Crieur » — fil live des décrets (attributions de points), lecture pour tous |
| `/profile` | Pseudo (verrouillable), photo, déconnexion | | `/profile` | Pseudo (verrouillable), photo, déconnexion, et pour les Citoyens : proposition d'une question pour Le Char (modifiable jusqu'au Tribunal) |
| `/admin` | « Le Conseil des Archontes » — juges uniquement, **vérifié côté serveur** (Server Component) — gestion des membres, reset du repère de classement | | `/admin` | « Admin » — juges uniquement, **vérifié côté serveur** (Server Component), habillée d'une police monospace (rupture volontaire avec le thème grec) — gestion des membres, reset du repère de classement |
`/leaderboard`, `/profile`, `/admin`, `/journal`, `/roulette`, `/calendrier` sont protégées par `src/proxy.ts` (redirection `/login` si non connecté). L'accès juge-only de `/admin` n'est **pas** géré par le proxy (il n'a pas facilement le rôle) — c'est la page elle-même qui vérifie et redirige ; même principe pour les boutons d'édition du Calendrier (RLS + vérification serveur, jamais un simple masquage front). `/leaderboard`, `/profile`, `/admin`, `/journal`, `/calendrier`, `/icare`, `/corne`, `/char` (et donc `/char/questions`, couverte par le même préfixe), `/mur`, `/urne` sont protégées par `src/proxy.ts` (redirection `/login` si non connecté). L'accès juge-only de `/admin` et `/char/questions` n'est **pas** géré par le proxy (il n'a pas facilement le rôle) — la page elle-même vérifie et redirige ; même principe pour les boutons d'édition du Calendrier (RLS + vérification serveur, jamais un simple masquage front).
Le header global (`components/header.tsx`) est rendu dans `layout.tsx` pour tout utilisateur connecté : logo (couronne de laurier + « Le Tribunal », jamais masqué même à 320px) à gauche, chip utilisateur (avatar, pseudo, points, jamais masqués) à droite. **Aucun lien de navigation dans le header lui-même** — le chip ouvre un menu déroulant qui contient toutes les pages (avec icônes), fermeture au clic extérieur/Échap, entrée courante surlignée. Le header global (`components/header.tsx`) est rendu dans `layout.tsx` pour tout utilisateur connecté : logo (couronne de laurier + « Le Tribunal », jamais masqué même à 320px) à gauche, chip utilisateur (avatar, pseudo, points, jamais masqués) à droite. **Aucun lien de navigation dans le header lui-même** — le chip ouvre un menu déroulant qui contient toutes les pages (avec icônes), fermeture au clic extérieur/Échap, entrée courante surlignée.
@@ -120,13 +220,18 @@ Polices (`next/font/google`, variables CSS, exposées comme `font-heading`/`font
Ornements en SVG/CSS inline (jamais d'image bitmap) : Ornements en SVG/CSS inline (jamais d'image bitmap) :
- `components/laurel-wreath.tsx` — couronne de laurier calculée par trigonométrie (deux arcs symétriques de feuilles), utilisée en logo, sur le rang 1 du podium, etc. Le favicon (`src/app/icon.svg`) est une version statique des mêmes coordonnées. - `components/laurel-wreath.tsx` — couronne de laurier calculée par trigonométrie (deux arcs symétriques de feuilles), utilisée en logo, sur le rang 1 du podium, etc. Le favicon (`src/app/icon.svg`) est une version statique des mêmes coordonnées.
- `.meander-divider` (classe globale) — frise à méandre grecque en `background-image` SVG data-URI, séparateur discret. - `.meander-divider` (classe globale) — frise à méandre grecque en `background-image` SVG data-URI, séparateur discret. Utilisée sous le header, et depuis la passe de polish visuel entre le podium et le reste du classement sur `/leaderboard` (`leaderboard-view.tsx`) — seulement quand un podium est réellement affiché.
- `components/diamond-divider.tsx` — séparateur ◆ avec filets dégradés. - `components/diamond-divider.tsx` — séparateur ◆ avec filets dégradés. Utilisé sur `/profile` entre la carte profil et la carte suggestion de question (deux actions distinctes, pas la même importance — voir la convention de carte secondaire ci-dessous).
- `.marble-surface` (classe globale) — texture marbre via dégradés (pas d'image de bruit). - `.marble-surface` (classe globale) — texture marbre via dégradés (pas d'image de bruit). Porte aussi, depuis la même passe, un léger halo doré respirant (`outline`, jamais `box-shadow`/`border` pour ne jamais entrer en conflit avec l'ombre/la bordure propre à chaque usage) — animation lente (7s), désactivée sous `prefers-reduced-motion`.
- `components/empty-state.tsx` — petit ◆ doré au-dessus d'un message "rien à afficher" (`/journal`, `/leaderboard`, `/mur`), pour qu'un état vide se lise comme un état normal de l'app plutôt qu'un oubli.
- `components/confetti.tsx` — partagé entre Le Vol d'Icare et La Corne d'Abondance (fin de partie), extrait de l'ancien code propre à Icare. À l'appelant de vérifier `prefers-reduced-motion` avant de le monter (comme déjà fait sur les deux jeux).
- Fond (`body`, `globals.css`) : le dégradé "mer" respire lentement (`--sea-glow`, `@property` pour une transition fluide côté navigateurs qui le supportent, dégradation sans casse sinon) plutôt que rester figé — même principe de subtilité, désactivé sous `prefers-reduced-motion`.
Convention de carte : `marble-surface rounded-2xl border border-gold/40 shadow-xl` (formulaires, podium) ou `rounded-lg border-gold/25` pour les lignes de liste. Bouton primaire : `bg-ink-2 text-gold-bright font-heading uppercase hover:bg-ink`. Bouton secondaire : `border border-gold/30 text-text-marble hover:bg-gold/10`. Convention de carte : `marble-surface rounded-2xl border border-gold/40 shadow-xl` (formulaires, podium) ou `rounded-lg border-gold/25` pour les lignes de liste. Carte secondaire (action annexe, pas le cœur de la page — ex. la suggestion de question sur `/profile`) : `border-gold/20 shadow-sm` plutôt que `border-gold/40 shadow-xl`, pour une hiérarchie visuelle claire sans sortir du langage marble-surface. Bouton primaire : `bg-ink-2 text-gold-bright font-heading uppercase hover:bg-ink`. Bouton secondaire : `border border-gold/30 text-text-marble hover:bg-gold/10`.
Vocabulaire diégétique (toujours accompagné du terme fonctionnel dans le code/les commentaires, pour que ça reste maintenable) : juges/admins → **Archontes**, membres → **Citoyens**, leaderboard → **L'Agora**, journal des points → **Le Crieur** (décrets), points positifs/négatifs → **Honneurs**/**Blâmes**, points → **gloires**, section admin → **Le Conseil des Archontes**. Sons : synthétisés via Web Audio (jamais de fichier audio dans le projet) — `src/lib/melon/sound.ts` (pop de fusion, La Corne d'Abondance) et `src/lib/icarus/sound.ts` (montée en dents de scie à la cueillette de l'éclair de Zeus, Le Vol d'Icare). `AudioContext` créé paresseusement au premier appel, toujours après un vrai geste utilisateur (contrainte d'autoplay des navigateurs).
Vocabulaire diégétique (toujours accompagné du terme fonctionnel dans le code/les commentaires, pour que ça reste maintenable) : juges/admins → **Archontes**, membres → **Citoyens**, leaderboard → **L'Agora**, journal des points → **Le Crieur** (décrets), points positifs/négatifs → **Honneurs**/**Blâmes**, points → **gloires**. Exception volontaire : la page `/admin` s'appelle simplement « Admin » (police monospace, rupture délibérée avec le vocabulaire diégétique et le thème grec, pour marquer que c'est un outil technique).
--- ---
@@ -143,6 +248,4 @@ Vocabulaire diégétique (toujours accompagné du terme fonctionnel dans le code
## 8. Roadmap (pas encore codé) ## 8. Roadmap (pas encore codé)
- **Éditions** : table `editions` (année, mood board) ; les points deviendront rattachables à une édition donnée. Le thème visuel (Grèce antique) reste fixe, ce n'est pas un système de re-thématisation par édition. - **Éditions** : table `editions` (année, mood board) ; les points deviendront rattachables à une édition donnée. Le thème visuel (Grèce antique) reste fixe, ce n'est pas un système de re-thématisation par édition.
- **Le jeu du Tribunal** : le Char (zone de pénalité), batailles de cul sec, ostracisme, timer du Gardien. (La Roulette est livrée en V5.)
- **Mode grand écran** : vue leaderboard optimisée pour vidéoprojecteur. - **Mode grand écran** : vue leaderboard optimisée pour vidéoprojecteur.
- **Déploiement** : pas encore fait (Vercel + Supabase managé prévus, voir README).
-28
View File
@@ -1,28 +0,0 @@
FROM node:22-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
RUN npm run build
FROM base AS runner
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=build /app/public ./public
COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "server.js"]
+21 -12
View File
@@ -28,7 +28,7 @@ Application web pour animer et scorer le jeu **« Le Tribunal »** entre amis pe
| Auth · Base · Stockage · Temps réel | Supabase (Auth, Postgres, Storage, Realtime) | | Auth · Base · Stockage · Temps réel | Supabase (Auth, Postgres, Storage, Realtime) |
| Recadrage photo | react-easy-crop + canvas (resize/compression client) | | Recadrage photo | react-easy-crop + canvas (resize/compression client) |
| Email transactionnel | SMTP custom (ex. Resend) branché sur Supabase Auth | | Email transactionnel | SMTP custom (ex. Resend) branché sur Supabase Auth |
| Déploiement | Vercel + Supabase (managé) | | Déploiement | Docker Compose + Traefik (self-hosted) + Supabase (managé) |
--- ---
@@ -37,7 +37,7 @@ Application web pour animer et scorer le jeu **« Le Tribunal »** entre amis pe
- [Node.js](https://nodejs.org/) 20.19+ (Next.js 16 / React 19) - [Node.js](https://nodejs.org/) 20.19+ (Next.js 16 / React 19)
- Un compte [Supabase](https://supabase.com/) (offre gratuite suffisante) - Un compte [Supabase](https://supabase.com/) (offre gratuite suffisante)
- Un fournisseur SMTP gratuit (ex. [Resend](https://resend.com/), 100 emails/jour) — le service email intégré de Supabase a un rate limit trop bas pour un usage réel - Un fournisseur SMTP gratuit (ex. [Resend](https://resend.com/), 100 emails/jour) — le service email intégré de Supabase a un rate limit trop bas pour un usage réel
- Un compte [Vercel](https://vercel.com/) pour le déploiement (optionnel en local) - Pour le déploiement (optionnel en local) : un serveur avec Docker + Traefik déjà en place, voir [Déploiement](#déploiement)
--- ---
@@ -56,9 +56,10 @@ npm install
1. Créez un nouveau projet sur [supabase.com](https://supabase.com/). 1. Créez un nouveau projet sur [supabase.com](https://supabase.com/).
2. Dans **SQL Editor**, exécutez tout le contenu de [`supabase/schema.sql`](supabase/schema.sql) (tables `profiles`/`points_log`/`days`/`events`/`settings`, policies RLS, triggers, RPC, bucket `avatars`, Realtime). Le script est idempotent, vous pouvez le ré-exécuter sans risque après une mise à jour. 2. Dans **SQL Editor**, exécutez tout le contenu de [`supabase/schema.sql`](supabase/schema.sql) (tables `profiles`/`points_log`/`days`/`events`/`settings`, policies RLS, triggers, RPC, bucket `avatars`, Realtime). Le script est idempotent, vous pouvez le ré-exécuter sans risque après une mise à jour.
3. Récupérez l'URL du projet et la clé `anon` dans **Project Settings → API**. 3. Récupérez l'URL du projet et la clé `anon` dans **Project Settings → API**.
4. Dans **Authentication → Providers → Email**, **activez "Confirm email"** (un vrai email par personne, condition d'unicité des comptes). 4. Dans **Authentication → Providers → Email**, **désactivez "Allow new users to sign up"** — l'inscription publique est fermée, seules les invitations créent un compte (voir [Authentification](#authentification)).
5. Dans **Authentication → Settings → SMTP Settings**, branchez un fournisseur SMTP custom (ex. Resend) — le service email par défaut de Supabase est bridé à quelques emails/heure. Voir [Email transactionnel](#email-transactionnel) ci-dessous. 5. Dans **Authentication → URL Configuration**, mettez **Site URL** sur `http://localhost:3000/signup` (puis votre domaine + `/signup` une fois déployé), et ajoutez la même valeur dans **Redirect URLs**. C'est la page où atterrit un lien d'invitation.
6. Une fois votre propre compte créé et confirmé via `/signup`, désignez-vous Archonte (voir [Rôles & administration](#rôles--administration)). 6. Dans **Authentication → Settings → SMTP Settings**, branchez un fournisseur SMTP custom (ex. Resend) — le service email par défaut de Supabase est bridé à quelques emails/heure. Voir [Email transactionnel](#email-transactionnel) ci-dessous.
7. Invitez-vous vous-même depuis **Authentication → Users → Invite user**, finalisez votre compte via le lien reçu, puis désignez-vous Archonte (voir [Rôles & administration](#rôles--administration)).
### 3. Variables d'environnement ### 3. Variables d'environnement
@@ -94,11 +95,17 @@ L'application tourne sur [http://localhost:3000](http://localhost:3000).
--- ---
## Déploiement (Vercel) ## Déploiement (Docker + Traefik)
1. Importez le dépôt dans Vercel. Self-hosted, pas de Vercel. Un serveur avec Docker Compose et un reverse proxy [Traefik](https://traefik.io/) déjà en place (réseau externe partagé, voir `TRAEFIK_NETWORK`), plus un pipeline Gitea Actions qui build et déploie automatiquement à chaque push sur la branche principale.
2. Renseignez les variables d'environnement (`NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`).
3. Déployez. Vercel rebuild automatiquement à chaque push sur la branche principale. 1. Sur le serveur, copiez `.env.docker.example` en `.env.docker` et renseignez :
- `APP_DOMAIN` : le domaine sur lequel l'app doit répondre (utilisé dans le label Traefik `Host(\`${APP_DOMAIN}\`)`).
- `TRAEFIK_NETWORK` : le nom du réseau Docker externe sur lequel Traefik écoute.
- `WEBHOOK_SECRET` : secret partagé avec le workflow Gitea Actions (`openssl rand -hex 16`).
2. `docker compose up -d` démarre le conteneur `app` (Next.js standalone) et le petit serveur webhook (`docker/webhook/receive.py`) qui reçoit les déploiements.
3. Le workflow `.gitea/workflows/deploy.yml` build l'app à chaque push sur `main` et POST le build vers le webhook, qui déclenche un hot-swap (voir `docker/entrypoint.sh`) sans downtime — pas de rebuild manuel nécessaire une fois configuré.
4. **Changer de domaine** : mettez à jour `APP_DOMAIN` dans `.env.docker` sur le serveur, puis recréez le conteneur (`docker compose up -d --force-recreate app`) pour que Traefik prenne en compte le nouveau label `Host()`. Pensez aussi à mettre à jour, côté Supabase Dashboard, **Authentication → URL Configuration** (Site URL + Redirect URLs) et le template d'email d'invitation (logo en dur), sans quoi les liens d'invitation et le logo pointeront encore vers l'ancien domaine.
--- ---
@@ -109,7 +116,7 @@ tribunal-app/
├─ src/ ├─ src/
│ ├─ app/ │ ├─ app/
│ │ ├─ login/page.tsx # Connexion (email + mot de passe) │ │ ├─ login/page.tsx # Connexion (email + mot de passe)
│ │ ├─ signup/page.tsx # Création de compte + photo recadrée │ │ ├─ signup/page.tsx # Finalisation d'un compte invité (mot de passe, pseudo, photo)
│ │ ├─ leaderboard/ │ │ ├─ leaderboard/
│ │ │ ├─ page.tsx # Fetch initial + rôle de l'utilisateur │ │ │ ├─ page.tsx # Fetch initial + rôle de l'utilisateur
│ │ │ └─ leaderboard-view.tsx # Realtime, podium, liste, contrôles juges │ │ │ └─ leaderboard-view.tsx # Realtime, podium, liste, contrôles juges
@@ -139,9 +146,11 @@ tribunal-app/
## Authentification ## Authentification
La connexion se fait par **email + mot de passe** ; Supabase impose l'unicité de l'email, ce qui empêche la création de comptes multiples. Le pseudo est purement un nom d'affichage (leaderboard, profil, journal) : l'email n'est jamais montré aux autres membres. La confirmation d'email est obligatoire (voir [SMTP](#email-transactionnel)). **Sur invitation uniquement** : l'auto-inscription publique est désactivée côté Supabase (Authentication → Providers → Email → "Allow new users to sign up" décoché). Il n'y a plus de bouton « Créer un compte » sur `/login`.
Le profil (`public.profiles`) est créé automatiquement à l'inscription via un trigger sur `auth.users` (fonctionne même si l'email n'est pas encore confirmé). Pour ajouter un membre : **Authentication → Users → Invite user** (email). Supabase lui envoie un email (via le SMTP configuré) contenant un lien d'invitation qui, une fois cliqué, établit directement une session sur `/signup` — c'est cette page qui sert alors à finaliser le compte (choix du mot de passe, du pseudo, upload de la photo). Sans session active (lien absent, invalide ou expiré), `/signup` affiche un message « invitation requise » plutôt qu'un formulaire d'inscription.
Le profil (`public.profiles`) est créé automatiquement dès l'invitation via un trigger sur `auth.users` (pseudo temporaire `"Nouveau membre"` tant que la finalisation n'a pas eu lieu). Le pseudo est purement un nom d'affichage (leaderboard, profil, journal) : l'email n'est jamais montré aux autres membres.
### Verrou de pseudo ### Verrou de pseudo
+71
View File
@@ -8,8 +8,12 @@
"name": "tribunal-app", "name": "tribunal-app",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@supabase/ssr": "^0.12.0", "@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.109.0", "@supabase/supabase-js": "^2.109.0",
"matter-js": "^0.20.0",
"next": "16.2.10", "next": "16.2.10",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
@@ -17,6 +21,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/matter-js": "^0.20.2",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
@@ -279,6 +284,59 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.10.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -1664,6 +1722,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/matter-js": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@types/matter-js/-/matter-js-0.20.2.tgz",
"integrity": "sha512-3PPKy3QxvZ89h9+wdBV2488I1JLVs7DEpIkPvgO8JC1mUdiVSO37ZIvVctOTD7hIq8OAL2gJ3ugGSuUip6DhCw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.19.43", "version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
@@ -5111,6 +5176,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/matter-js": {
"version": "0.20.0",
"resolved": "https://registry.npmjs.org/matter-js/-/matter-js-0.20.0.tgz",
"integrity": "sha512-iC9fYR7zVT3HppNnsFsp9XOoQdQN2tUyfaKg4CHLH8bN+j6GT4Gw7IH2rP0tflAebrHFw730RR3DkVSZRX8hwA==",
"license": "MIT"
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+5
View File
@@ -9,8 +9,12 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@supabase/ssr": "^0.12.0", "@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.109.0", "@supabase/supabase-js": "^2.109.0",
"matter-js": "^0.20.0",
"next": "16.2.10", "next": "16.2.10",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
@@ -18,6 +22,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/matter-js": "^0.20.2",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

+175
View File
@@ -0,0 +1,175 @@
"use client";
import { useMemo, useState } from "react";
import { Avatar } from "@/components/avatar";
type CheatFlag = {
id: number;
user_id: string;
pseudo: string;
avatar_url: string | null;
game: "icarus" | "melon";
score: number;
severity: "warning" | "high";
trigger_code: string;
reason: string;
details: {
duration_ms?: number | null;
actions?: number | null;
merges?: number | null;
previous_best?: number | null;
} | null;
created_at: string;
};
const GAME_LABELS: Record<CheatFlag["game"], string> = {
icarus: "Vol d'Icare",
melon: "Corne d'Abondance",
};
const TIME_FORMATTER = new Intl.DateTimeFormat("fr-FR", {
timeZone: "Europe/Paris",
timeStyle: "medium",
});
const DATE_FORMATTER = new Intl.DateTimeFormat("fr-FR", {
timeZone: "Europe/Paris",
dateStyle: "short",
});
function formatDuration(durationMs: number | null | undefined): string | null {
if (durationMs == null || !Number.isFinite(durationMs)) return null;
if (durationMs < 1000) return `${Math.round(durationMs)} ms`;
const seconds = durationMs / 1000;
if (seconds < 60) return `${seconds.toFixed(seconds < 10 ? 1 : 0)} s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.round(seconds % 60);
return `${minutes} min ${remainingSeconds.toString().padStart(2, "0")} s`;
}
function DetailCell({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-[0.65rem] tracking-wide text-text-mut uppercase">{label}</dt>
<dd className="text-text-marble">{value}</dd>
</div>
);
}
function FlagRow({ flag }: { flag: CheatFlag }) {
const duration = formatDuration(flag.details?.duration_ms);
// Le signalement est écrit au moment de la soumission du score, donc
// quasi simultané à la fin réelle de la partie (délai réseau négligeable)
// — sert directement de proxy pour "heure de fin". "Début" s'en déduit en
// retranchant la durée annoncée, seulement quand elle est connue (jamais
// le cas pour missing_run_telemetry, où aucune télémétrie n'a été reçue).
const endedAt = new Date(flag.created_at);
const startedAt =
flag.details?.duration_ms != null && Number.isFinite(flag.details.duration_ms)
? new Date(endedAt.getTime() - flag.details.duration_ms)
: null;
return (
<li className="marble-surface rounded-lg border border-gold/25 p-3 shadow-sm sm:p-4">
<div className="flex items-start gap-3">
<Avatar pseudo={flag.pseudo} avatarUrl={flag.avatar_url} size="sm" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span
className={
flag.severity === "high"
? "rounded-full border border-oxblood/40 bg-oxblood/10 px-2 py-0.5 text-[0.65rem] font-semibold tracking-wide text-oxblood uppercase"
: "rounded-full border border-gold/40 bg-gold/10 px-2 py-0.5 text-[0.65rem] font-semibold tracking-wide text-text-marble uppercase"
}
>
{flag.severity === "high" ? "Fort" : "À vérifier"}
</span>
<span className="truncate font-heading text-sm text-text-marble">{flag.pseudo}</span>
<span className="text-xs text-text-mut">· {GAME_LABELS[flag.game]}</span>
</div>
<p className="mt-1 text-sm text-text-marble">{flag.reason}</p>
</div>
<p className="shrink-0 font-heading text-lg text-sea">{flag.score}</p>
</div>
<dl className="mt-3 grid grid-cols-2 gap-x-3 gap-y-2 border-t border-gold/15 pt-3 text-xs sm:grid-cols-4">
<DetailCell label="Début" value={startedAt ? TIME_FORMATTER.format(startedAt) : "—"} />
<DetailCell label="Fin" value={TIME_FORMATTER.format(endedAt)} />
<DetailCell label="Durée" value={duration ?? "—"} />
{flag.details?.actions != null && <DetailCell label="Actions" value={String(flag.details.actions)} />}
{flag.details?.merges != null && <DetailCell label="Fusions" value={String(flag.details.merges)} />}
{flag.details?.previous_best != null && (
<DetailCell label="Ancien record" value={String(flag.details.previous_best)} />
)}
</dl>
<div className="mt-2 flex items-center justify-between text-[0.7rem] text-text-mut">
<span className="font-mono opacity-60">{flag.trigger_code}</span>
<span>{DATE_FORMATTER.format(endedAt)}</span>
</div>
</li>
);
}
export function AdminCheatFlags({ flags }: { flags: CheatFlag[] }) {
const [game, setGame] = useState<"all" | CheatFlag["game"]>("all");
const filtered = useMemo(
() => (game === "all" ? flags : flags.filter((flag) => flag.game === game)),
[flags, game],
);
const flaggedUsers = useMemo(() => new Set(flags.map((flag) => flag.user_id)).size, [flags]);
return (
<section className="mt-10 border-t border-gold/20 pt-8">
<div className="mb-4 flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="font-heading text-lg tracking-wide text-gold-bright uppercase">Détection de triche</h2>
<p className="mt-1 max-w-xl text-sm text-marble/60">
Les scores restent acceptés. Cette liste signale seulement les parties qui ont déclenché un contrôle
simple côté serveur.
</p>
</div>
<p className="text-xs text-marble/60">
{flags.length} signalement{flags.length > 1 ? "s" : ""} · {flaggedUsers} joueur
{flaggedUsers > 1 ? "s" : ""}
</p>
</div>
<div className="mb-4 flex flex-wrap gap-2">
{([
["all", "Tous"],
["icarus", "Icare"],
["melon", "Corne"],
] as const).map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => setGame(value)}
className={`rounded-md border px-3 py-1.5 text-xs font-medium transition ${
game === value
? "border-gold/60 bg-gold/15 text-gold-bright"
: "border-gold/25 text-marble/70 hover:bg-gold/10"
}`}
>
{label}
</button>
))}
</div>
{filtered.length === 0 ? (
<p className="rounded-lg border border-gold/20 px-4 py-5 text-center text-sm text-marble/60">
Aucun signalement pour ce filtre.
</p>
) : (
<ul className="flex flex-col gap-2">
{filtered.map((flag) => (
<FlagRow key={flag.id} flag={flag} />
))}
</ul>
)}
</section>
);
}
+12 -8
View File
@@ -12,6 +12,7 @@ type Member = {
role: string; role: string;
pseudo_locked: boolean; pseudo_locked: boolean;
avatar_url: string | null; avatar_url: string | null;
email: string;
}; };
function MemberRow({ member, isSelf }: { member: Member; isSelf: boolean }) { function MemberRow({ member, isSelf }: { member: Member; isSelf: boolean }) {
@@ -78,14 +79,17 @@ function MemberRow({ member, isSelf }: { member: Member; isSelf: boolean }) {
return ( return (
<li className="marble-surface flex flex-col gap-2 rounded-lg border border-gold/25 px-4 py-3 shadow-sm sm:flex-row sm:items-center sm:gap-4"> <li className="marble-surface flex flex-col gap-2 rounded-lg border border-gold/25 px-4 py-3 shadow-sm sm:flex-row sm:items-center sm:gap-4">
<div className="flex items-center gap-2"> <div className="flex flex-1 flex-col gap-1">
<Avatar pseudo={member.pseudo} avatarUrl={member.avatar_url} size="sm" /> <div className="flex items-center gap-2">
<input <Avatar pseudo={member.pseudo} avatarUrl={member.avatar_url} size="sm" />
type="text" <input
value={pseudo} type="text"
onChange={(event) => setPseudo(event.target.value)} value={pseudo}
className="w-full flex-1 rounded-md border border-gold/30 bg-white/50 px-2 py-1 text-sm text-text-marble outline-none focus:border-gold sm:w-auto" onChange={(event) => setPseudo(event.target.value)}
/> className="w-full flex-1 rounded-md border border-gold/30 bg-white/50 px-2 py-1 text-sm text-text-marble outline-none focus:border-gold sm:w-auto"
/>
</div>
<p className="truncate pl-10 text-xs text-text-mut">{member.email}</p>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
+52 -14
View File
@@ -1,7 +1,34 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import { jetbrainsMono } from "@/lib/fonts";
import { AdminMembersTable } from "./admin-members-table"; import { AdminMembersTable } from "./admin-members-table";
import { ResetRoundButton } from "./reset-round-button"; import { AdminCheatFlags } from "./admin-cheat-flags";
type AdminMember = {
id: string;
pseudo: string;
role: string;
pseudo_locked: boolean;
avatar_url: string | null;
email: string;
};
type CheatFlagRow = {
id: number;
user_id: string;
game: "icarus" | "melon";
score: number;
severity: "warning" | "high";
trigger_code: string;
reason: string;
details: {
duration_ms?: number | null;
actions?: number | null;
merges?: number | null;
previous_best?: number | null;
} | null;
created_at: string;
};
export default async function AdminPage() { export default async function AdminPage() {
const supabase = await createClient(); const supabase = await createClient();
@@ -25,23 +52,34 @@ export default async function AdminPage() {
redirect("/leaderboard"); redirect("/leaderboard");
} }
const { data: members } = await supabase const [{ data: members }, { data: cheatFlags }] = await Promise.all([
.from("profiles") supabase.rpc("admin_list_members"),
.select("id, pseudo, role, pseudo_locked, avatar_url") supabase
.order("pseudo", { ascending: true }); .from("game_cheat_flags")
.select("id, user_id, game, score, severity, trigger_code, reason, details, created_at")
.order("created_at", { ascending: false })
.limit(200),
]);
const memberRows = (members ?? []) as AdminMember[];
const flagRows = (cheatFlags ?? []) as CheatFlagRow[];
const memberById = new Map(memberRows.map((member) => [member.id, member]));
const flagsWithPseudo = flagRows.map((flag) => ({
...flag,
pseudo: memberById.get(flag.user_id)?.pseudo ?? "Un Citoyen",
avatar_url: memberById.get(flag.user_id)?.avatar_url ?? null,
}));
return ( return (
<div className="mx-auto w-full max-w-3xl px-4 py-8"> <div className="mx-auto w-full max-w-3xl px-4 py-8">
<div className="mb-6 flex flex-wrap items-center justify-between gap-3"> <div className="mb-6">
<div> <h1 className={`${jetbrainsMono.className} text-2xl tracking-tight text-gold-bright`}>
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase"> &gt; Admin
Le Conseil des Archontes </h1>
</h1> <p className="font-serif text-sm text-marble/60 italic">Administration des Citoyens</p>
<p className="font-serif text-sm text-marble/60 italic">Administration des Citoyens</p>
</div>
<ResetRoundButton />
</div> </div>
<AdminMembersTable members={members ?? []} currentUserId={user.id} /> <AdminMembersTable members={memberRows} currentUserId={user.id} />
<AdminCheatFlags flags={flagsWithPseudo} />
</div> </div>
); );
} }
-29
View File
@@ -1,29 +0,0 @@
"use client";
import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
export function ResetRoundButton() {
const [state, setState] = useState<"idle" | "loading" | "done">("idle");
async function handleClick() {
setState("loading");
const supabase = createClient();
const { error } = await supabase.rpc("reset_rank_reference");
setState(error ? "idle" : "done");
if (!error) {
setTimeout(() => setState("idle"), 2000);
}
}
return (
<button
type="button"
onClick={handleClick}
disabled={state === "loading"}
className="marble-surface rounded-md border border-gold/40 px-3 py-1.5 text-sm font-medium text-text-marble shadow-sm transition hover:bg-gold/10 disabled:opacity-50"
>
{state === "loading" ? "…" : state === "done" ? "Repère mis à jour ✓" : "Nouveau round (réinitialiser le repère)"}
</button>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+16 -22
View File
@@ -1,24 +1,11 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client"; import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
import { DayCard, type DayRow } from "./day-card"; import { DayCard, type DayRow } from "./day-card";
import { PANTHEON_PRESETS, getGodPreset } from "@/lib/pantheon"; import { PANTHEON_PRESETS, getGodPreset } from "@/lib/pantheon";
import { IconPlus } from "@/components/icons"; import { IconPlus } from "@/components/icons";
import { formatCountdown } from "@/lib/tribunal-date";
function formatCountdown(tribunalDate: string | null): string {
if (!tribunalDate) return "Aucune date n'a encore été fixée par les Archontes.";
const target = new Date(tribunalDate);
const now = new Date();
const diffMs = target.getTime() - now.getTime();
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
if (diffMs < 0) return "Le Tribunal a rendu son verdict.";
if (diffDays === 0) return "Le Tribunal siège aujourd'hui !";
if (diffDays === 1) return "Le Tribunal siège demain.";
return `Le Tribunal siège dans ${diffDays} jours.`;
}
export function CalendrierView({ export function CalendrierView({
isJudge, isJudge,
@@ -69,15 +56,22 @@ export function CalendrierView({
useEffect(() => { useEffect(() => {
const supabase = createClient(); const supabase = createClient();
const channel = supabase let channel: ReturnType<typeof supabase.channel> | null = null;
.channel("calendrier-changes") let cancelled = false;
.on("postgres_changes", { event: "*", schema: "public", table: "days" }, refetch)
.on("postgres_changes", { event: "*", schema: "public", table: "events" }, refetch) waitForRealtimeAuth(supabase).then(() => {
.on("postgres_changes", { event: "*", schema: "public", table: "settings" }, refetch) if (cancelled) return;
.subscribe(); channel = supabase
.channel("calendrier-changes")
.on("postgres_changes", { event: "*", schema: "public", table: "days" }, refetch)
.on("postgres_changes", { event: "*", schema: "public", table: "events" }, refetch)
.on("postgres_changes", { event: "*", schema: "public", table: "settings" }, refetch)
.subscribe();
});
return () => { return () => {
supabase.removeChannel(channel); cancelled = true;
if (channel) supabase.removeChannel(channel);
}; };
}, []); }, []);
+141
View File
@@ -0,0 +1,141 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { MentionTags } from "@/components/mention-tags";
import { stripMentions } from "@/lib/mentions";
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
import { EntryRanking } from "./entry-ranking";
import { GardienBar } from "./gardien-bar";
import { RouletteSection } from "./roulette-section";
import type { ProfileRow, RevealedQuestion, SettingsRow, SlotRow } from "./page";
import { SlotBoard } from "./slot-board";
export function CharView({
isJudge,
initialSlots,
initialEntryCounts,
initialRevealedQuestion,
initialSettings,
initialProfiles,
}: {
isJudge: boolean;
initialSlots: SlotRow[];
initialEntryCounts: Record<string, number>;
initialRevealedQuestion: RevealedQuestion;
initialSettings: SettingsRow;
initialProfiles: ProfileRow[];
}) {
const [slots, setSlots] = useState(initialSlots);
const [entryCounts, setEntryCounts] = useState(initialEntryCounts);
const [revealedQuestion, setRevealedQuestion] = useState(initialRevealedQuestion);
const [settings, setSettings] = useState(initialSettings);
const [profiles, setProfiles] = useState(initialProfiles);
// Une seule page projetée (16:9) : on ré-interroge tout à chaque
// changement, y compris settings — la question révélée est désormais
// pilotée depuis /char/questions (un autre appareil), donc cette page
// doit réagir en direct à ces changements-là aussi. La question révélée
// passe par la RPC chariot_revealed_question() (jamais une lecture
// directe de chariot_questions, réservée aux juges — spoiler sinon).
const refetch = useCallback(async () => {
const supabase = createClient();
const [{ data: sl }, { data: en }, { data: q }, { data: s }, { data: p }] = await Promise.all([
supabase.from("chariot_slots").select("slot, user_id").order("slot", { ascending: true }),
supabase.from("chariot_entries").select("user_id"),
supabase.rpc("chariot_revealed_question"),
supabase
.from("settings")
.select("chariot_revealed_question_id, gardien_holder_id, gardien_expires_at")
.eq("id", true)
.single(),
supabase.from("profiles").select("id, pseudo, avatar_url, role, points").order("pseudo", { ascending: true }),
]);
if (sl) setSlots(sl as SlotRow[]);
if (en) {
const counts: Record<string, number> = {};
for (const entry of en) counts[entry.user_id] = (counts[entry.user_id] ?? 0) + 1;
setEntryCounts(counts);
}
setRevealedQuestion((q?.[0] as RevealedQuestion) ?? null);
if (s) setSettings(s);
if (p) setProfiles(p);
}, []);
useEffect(() => {
const supabase = createClient();
let channel: ReturnType<typeof supabase.channel> | null = null;
let cancelled = false;
waitForRealtimeAuth(supabase).then(() => {
if (cancelled) return;
channel = supabase
.channel("char-changes")
.on("postgres_changes", { event: "*", schema: "public", table: "chariot_slots" }, refetch)
.on("postgres_changes", { event: "*", schema: "public", table: "chariot_entries" }, refetch)
.on("postgres_changes", { event: "*", schema: "public", table: "chariot_questions" }, refetch)
.on("postgres_changes", { event: "*", schema: "public", table: "settings" }, refetch)
.subscribe();
});
return () => {
cancelled = true;
if (channel) supabase.removeChannel(channel);
};
}, [refetch]);
const gardienHolder = profiles.find((p) => p.id === settings.gardien_holder_id) ?? null;
const revealedQuestionDisplay = revealedQuestion
? stripMentions(
revealedQuestion.text,
profiles.map((p) => p.pseudo),
)
: null;
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="font-heading text-lg tracking-[0.1em] text-gold-bright uppercase sm:text-xl">
Le Jeu de l&apos;Agora
</h1>
{isJudge && (
<Link
href="/char/questions"
className="text-xs font-medium text-text-mut underline hover:text-gold-bright"
>
Gérer les questions
</Link>
)}
</div>
{revealedQuestion && revealedQuestionDisplay && (
<div
key={revealedQuestion.id}
className="marble-surface animate-reveal rounded-2xl border border-gold/40 px-6 py-6 text-center shadow-xl sm:py-8"
>
<p className="mb-1 font-heading text-xs tracking-[0.2em] text-text-mut uppercase sm:text-sm">
Question {revealedQuestion.question_number}
</p>
<p className="font-heading text-xl text-text-marble sm:text-2xl lg:text-3xl">
{revealedQuestionDisplay.cleanText}
</p>
<MentionTags pseudos={revealedQuestionDisplay.mentioned} profiles={profiles} className="mt-3 justify-center" />
</div>
)}
<SlotBoard
isJudge={isJudge}
slots={slots}
profiles={profiles}
entryCounts={entryCounts}
onChanged={refetch}
/>
<GardienBar isJudge={isJudge} holder={gardienHolder} expiresAt={settings.gardien_expires_at} />
<EntryRanking profiles={profiles} entryCounts={entryCounts} />
<RouletteSection profiles={profiles} />
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { Avatar } from "@/components/avatar";
import type { ProfileRow } from "./page";
export function EntryRanking({
profiles,
entryCounts,
}: {
profiles: ProfileRow[];
entryCounts: Record<string, number>;
}) {
const sorted = [...profiles].sort((a, b) => {
const diff = (entryCounts[b.id] ?? 0) - (entryCounts[a.id] ?? 0);
return diff !== 0 ? diff : a.pseudo.localeCompare(b.pseudo);
});
return (
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-lg">
<p className="mb-2 text-center font-heading text-sm tracking-wide text-text-mut uppercase">
Passages dans le char
</p>
<ul className="grid grid-cols-2 gap-x-4 gap-y-1.5 sm:grid-cols-3 lg:grid-cols-4">
{sorted.map((p) => {
const count = entryCounts[p.id] ?? 0;
return (
<li key={p.id} className="flex min-w-0 items-center gap-1.5 text-xs">
<Avatar pseudo={p.pseudo} avatarUrl={p.avatar_url} size="xs" />
<span className={`truncate ${count === 0 ? "text-text-mut/60" : "text-text-marble"}`}>{p.pseudo}</span>
<span className="ml-auto shrink-0 font-heading text-sea">{count > 0 ? `×${count}` : "—"}</span>
</li>
);
})}
</ul>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useEffect, useState } from "react";
import { Avatar } from "@/components/avatar";
import { createClient } from "@/lib/supabase/client";
import type { ProfileRow } from "./page";
function computeSecondsLeft(expiresAt: string | null): number {
if (!expiresAt) return 0;
return Math.max(0, Math.round((new Date(expiresAt).getTime() - Date.now()) / 1000));
}
function formatCountdown(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return `${minutes}:${remainder.toString().padStart(2, "0")}`;
}
// Volontairement discret (une barre compacte, pas une grande carte) : ce
// n'est pas le sujet principal de la page projetée, juste un rappel présent.
export function GardienBar({
isJudge,
holder,
expiresAt,
}: {
isJudge: boolean;
holder: ProfileRow | null;
expiresAt: string | null;
}) {
const [secondsLeft, setSecondsLeft] = useState(() => computeSecondsLeft(expiresAt));
const [rerolling, setRerolling] = useState(false);
useEffect(() => {
const supabase = createClient();
let firedForThisExpiry = false;
function tick() {
const remaining = computeSecondsLeft(expiresAt);
setSecondsLeft(remaining);
if (remaining <= 0 && !firedForThisExpiry) {
firedForThisExpiry = true;
supabase.rpc("reroll_gardien", { p_force: false });
}
}
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [expiresAt]);
async function handleForceReroll() {
setRerolling(true);
const supabase = createClient();
await supabase.rpc("reroll_gardien", { p_force: true });
setRerolling(false);
}
return (
<div className="flex flex-wrap items-center justify-center gap-2 self-center rounded-full border border-gold/30 bg-ink-2/60 px-3 py-1.5 text-xs">
<span className="text-marble/60">Gardien du Silence :</span>
{holder ? (
<span key={holder.id} className="animate-podium-rise flex items-center gap-1.5">
<Avatar pseudo={holder.pseudo} avatarUrl={holder.avatar_url} size="xs" />
<span className="font-medium text-marble">{holder.pseudo}</span>
</span>
) : (
<span className="text-marble/50">personne</span>
)}
{expiresAt && <span className="font-heading text-gold-bright">{formatCountdown(secondsLeft)}</span>}
{isJudge && (
<button
type="button"
onClick={handleForceReroll}
disabled={rerolling}
className="rounded-full border border-gold/30 px-2 py-0.5 text-[0.65rem] text-marble/70 hover:bg-gold/10 disabled:opacity-50"
>
{rerolling ? "…" : "Changer"}
</button>
)}
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { PageLoading } from "@/components/page-loading";
export default function Loading() {
return <PageLoading />;
}
+93
View File
@@ -0,0 +1,93 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { CharView } from "./char-view";
export type ProfileRow = {
id: string;
pseudo: string;
avatar_url: string | null;
role: string;
points: number;
};
// Uniquement la question révélée (jamais la banque complète, réservée aux
// juges — voir la RPC chariot_revealed_question() et la RLS de
// chariot_questions dans supabase/schema.sql, audit de sécurité).
export type RevealedQuestion = {
id: string;
text: string;
question_number: number;
} | null;
export type SlotRow = {
slot: number;
user_id: string | null;
};
export type SettingsRow = {
chariot_revealed_question_id: string | null;
gardien_holder_id: string | null;
gardien_expires_at: string | null;
};
function buildEntryCounts(entries: { user_id: string }[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const entry of entries) {
counts[entry.user_id] = (counts[entry.user_id] ?? 0) + 1;
}
return counts;
}
export default async function CharPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
const { data: callerProfile } = await supabase
.from("profiles")
.select("role")
.eq("id", user.id)
.single();
const isJudge = callerProfile?.role === "judge";
const { data: slots } = await supabase
.from("chariot_slots")
.select("slot, user_id")
.order("slot", { ascending: true });
const { data: entries } = await supabase.from("chariot_entries").select("user_id");
const { data: revealedRows } = await supabase.rpc("chariot_revealed_question");
const revealedQuestion: RevealedQuestion = (revealedRows?.[0] as RevealedQuestion) ?? null;
const { data: settings } = await supabase
.from("settings")
.select("chariot_revealed_question_id, gardien_holder_id, gardien_expires_at")
.eq("id", true)
.single();
const { data: profiles } = await supabase
.from("profiles")
.select("id, pseudo, avatar_url, role, points")
.order("pseudo", { ascending: true });
return (
<div className="mx-auto w-full max-w-6xl px-4 py-6 sm:px-6">
<CharView
isJudge={isJudge}
initialSlots={(slots ?? []) as SlotRow[]}
initialEntryCounts={buildEntryCounts(entries ?? [])}
initialRevealedQuestion={revealedQuestion}
initialSettings={
settings ?? { chariot_revealed_question_id: null, gardien_holder_id: null, gardien_expires_at: null }
}
initialProfiles={profiles ?? []}
/>
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { QuestionsView } from "./questions-view";
export type QuestionRow = {
id: string;
text: string;
position: number;
created_at: string;
};
export type SubmissionRow = {
user_id: string;
text: string;
updated_at: string;
profiles: { pseudo: string; avatar_url: string | null } | null;
};
export type SubmissionHistoryRow = {
user_id: string;
text: string;
created_at: string;
};
export type ProfileRow = {
id: string;
pseudo: string;
avatar_url: string | null;
};
export default async function CharQuestionsPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
const { data: callerProfile } = await supabase
.from("profiles")
.select("role")
.eq("id", user.id)
.single();
// Page entièrement réservée aux Archontes : contrairement à /char (projeté,
// partagé avec tous), rien ici n'a de sens pour un Citoyen — et exposer la
// liste complète des questions à venir serait un spoiler du jeu en cours.
if (callerProfile?.role !== "judge") {
redirect("/char");
}
const { data: questions } = await supabase
.from("chariot_questions")
.select("id, text, position, created_at")
.order("position", { ascending: true })
.order("created_at", { ascending: true });
const { data: settings } = await supabase
.from("settings")
.select("chariot_revealed_question_id")
.eq("id", true)
.single();
const { data: submissions } = await supabase
.from("chariot_submissions")
.select("user_id, text, updated_at, profiles(pseudo, avatar_url)")
.order("updated_at", { ascending: true })
.returns<SubmissionRow[]>();
// Versions précédentes des propositions (chariot_submissions ne garde que
// la version actuelle) — pour la section "Historique" de la modération.
const { data: submissionHistory } = await supabase
.from("chariot_submission_history")
.select("user_id, text, created_at")
.order("created_at", { ascending: false });
// Tout le monde (Archontes compris, comme pour les emplacements du Char
// lui-même — voir chariot_slots) peut être visé par un tag "@pseudo" dans
// une question.
const { data: profiles } = await supabase
.from("profiles")
.select("id, pseudo, avatar_url")
.order("pseudo", { ascending: true });
return (
<div className="mx-auto w-full max-w-2xl px-4 py-8">
<div className="mb-6 text-center">
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">Les Questions du Char</h1>
<p className="font-serif text-sm text-marble/60 italic">
Réservé aux Archontes /char (projetée) n&apos;affiche que la question choisie ici.
</p>
<Link
href="/char"
className="mt-2 inline-block text-xs font-medium text-marble/60 underline hover:text-gold-bright"
>
Retourner au Char
</Link>
</div>
<QuestionsView
initialQuestions={questions ?? []}
initialRevealedId={settings?.chariot_revealed_question_id ?? null}
initialSubmissions={submissions ?? []}
initialSubmissionHistory={submissionHistory ?? []}
profiles={profiles ?? []}
/>
</div>
);
}
+598
View File
@@ -0,0 +1,598 @@
"use client";
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Avatar } from "@/components/avatar";
import { IconChevronDown, IconGrip, IconPencil, IconPlus, IconShuffle, IconTrash } from "@/components/icons";
import { MentionTags } from "@/components/mention-tags";
import { MentionTextarea } from "@/components/mention-textarea";
import { extractMentionedPseudos, stripMentions } from "@/lib/mentions";
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
import type { ProfileRow, QuestionRow, SubmissionHistoryRow, SubmissionRow } from "./page";
function formatHistoryDate(iso: string): string {
return new Date(iso).toLocaleString("fr-FR", {
dateStyle: "short",
timeStyle: "short",
timeZone: "Europe/Paris",
});
}
function QuestionForm({
initial,
nextPosition,
profiles,
onDone,
}: {
initial?: QuestionRow;
nextPosition: number;
profiles: ProfileRow[];
onDone: () => void;
}) {
const [text, setText] = useState(initial?.text ?? "");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
if (!text.trim()) {
setError("Question requise.");
return;
}
setSaving(true);
setError(null);
const supabase = createClient();
const { error: opError } = initial
? await supabase.from("chariot_questions").update({ text: text.trim() }).eq("id", initial.id)
: await supabase.from("chariot_questions").insert({ text: text.trim(), position: nextPosition });
setSaving(false);
if (opError) {
setError("Une erreur est survenue.");
return;
}
onDone();
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-2 rounded-md border border-gold/30 bg-white/40 p-3">
<MentionTextarea
value={text}
onChange={setText}
candidates={profiles}
rows={2}
placeholder="Écrire la question… (@pseudo pour taguer quelqu'un)"
className="w-full rounded-md border border-gold/30 bg-white/60 px-2 py-1.5 text-sm text-text-marble"
/>
{error && <p className="text-xs text-oxblood">{error}</p>}
<div className="flex gap-2">
<button
type="submit"
disabled={saving}
className="rounded-md bg-ink-2 px-3 py-1.5 text-xs font-medium text-gold-bright hover:bg-ink disabled:opacity-50"
>
{saving ? "…" : "Enregistrer"}
</button>
<button
type="button"
onClick={onDone}
className="rounded-md border border-gold/30 px-3 py-1.5 text-xs text-text-marble hover:bg-gold/10"
>
Annuler
</button>
</div>
</form>
);
}
function QuestionRowItem({
question,
orderNumber,
isFirst,
isLast,
isRevealed,
pseudos,
profiles,
onMove,
onToggleReveal,
onChanged,
}: {
question: QuestionRow;
orderNumber: number;
isFirst: boolean;
isLast: boolean;
isRevealed: boolean;
pseudos: string[];
profiles: ProfileRow[];
onMove: (direction: "up" | "down") => void;
onToggleReveal: () => void;
onChanged: () => void;
}) {
const [editing, setEditing] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
// Toujours appelé (même en édition) : dnd-kit a besoin de mesurer l'item
// à sa position dans la liste, même quand le glisser est temporairement
// indisponible (le formulaire d'édition n'a pas de poignée).
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: question.id });
const style = { transform: CSS.Transform.toString(transform), transition };
const { cleanText, mentioned } = stripMentions(question.text, pseudos);
async function handleDelete() {
const supabase = createClient();
await supabase.from("chariot_questions").delete().eq("id", question.id);
onChanged();
}
if (editing) {
return (
<div ref={setNodeRef} style={style}>
<QuestionForm
initial={question}
nextPosition={question.position}
profiles={profiles}
onDone={() => {
setEditing(false);
onChanged();
}}
/>
</div>
);
}
return (
<li
ref={setNodeRef}
style={style}
className={`flex items-start gap-2 rounded-md px-2 py-1.5 text-sm ${isRevealed ? "bg-gold/10" : ""} ${
isDragging ? "relative z-10 bg-marble shadow-lg" : ""
}`}
>
<button
type="button"
{...attributes}
{...listeners}
aria-label="Glisser pour réordonner"
title="Glisser pour réordonner"
style={{ touchAction: "none" }}
className="flex h-7 w-5 shrink-0 cursor-grab items-center justify-center text-text-mut hover:text-text-marble active:cursor-grabbing"
>
<IconGrip className="h-4 w-4" />
</button>
<span className="w-5 shrink-0 pt-0.5 text-right font-heading text-xs text-text-mut">{orderNumber}</span>
<div className="flex shrink-0 flex-col">
<button
type="button"
onClick={() => onMove("up")}
disabled={isFirst}
aria-label="Monter"
className="flex h-5 w-5 items-center justify-center rounded text-text-mut hover:bg-gold/10 disabled:opacity-30"
>
<IconChevronDown className="h-3.5 w-3.5 rotate-180" />
</button>
<button
type="button"
onClick={() => onMove("down")}
disabled={isLast}
aria-label="Descendre"
className="flex h-5 w-5 items-center justify-center rounded text-text-mut hover:bg-gold/10 disabled:opacity-30"
>
<IconChevronDown className="h-3.5 w-3.5" />
</button>
</div>
<div className="min-w-0 flex-1">
<p className="text-text-marble">{cleanText}</p>
<MentionTags pseudos={mentioned} profiles={profiles} className="mt-1" />
</div>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={onToggleReveal}
className={`rounded-md px-2 py-1 text-xs font-medium ${
isRevealed
? "bg-oxblood text-marble hover:bg-oxblood/80"
: "border border-gold/40 text-text-marble hover:bg-gold/10"
}`}
>
{isRevealed ? "Masquer" : "Afficher"}
</button>
<button
type="button"
onClick={() => setEditing(true)}
aria-label="Modifier"
title="Modifier"
className="flex h-7 w-7 items-center justify-center rounded-md text-text-mut hover:bg-gold/10 hover:text-text-marble"
>
<IconPencil className="h-3.5 w-3.5" />
</button>
{confirmingDelete ? (
<button
type="button"
onClick={handleDelete}
className="rounded-md bg-oxblood px-2 py-1 text-xs text-marble"
>
Confirmer ?
</button>
) : (
<button
type="button"
onClick={() => setConfirmingDelete(true)}
onBlur={() => setConfirmingDelete(false)}
aria-label="Supprimer"
title="Supprimer"
className="flex h-7 w-7 items-center justify-center rounded-md text-oxblood/70 hover:bg-oxblood/10 hover:text-oxblood"
>
<IconTrash className="h-3.5 w-3.5" />
</button>
)}
</div>
</li>
);
}
function SubmissionRowItem({
submission,
pastVersions,
onAddToBank,
onReject,
}: {
submission: SubmissionRow;
pastVersions: SubmissionHistoryRow[];
onAddToBank: () => void;
onReject: () => void;
}) {
const [confirmingReject, setConfirmingReject] = useState(false);
const [showHistory, setShowHistory] = useState(false);
const profile = submission.profiles;
const pseudo = profile?.pseudo ?? "un Citoyen";
return (
<li className="flex items-start gap-2 rounded-md px-2 py-1.5 text-sm">
<Avatar pseudo={pseudo} avatarUrl={profile?.avatar_url ?? null} size="sm" />
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-text-mut">{pseudo}</p>
<p className="text-text-marble">{submission.text}</p>
{pastVersions.length > 0 && (
<>
<button
type="button"
onClick={() => setShowHistory((v) => !v)}
className="mt-1 text-[0.7rem] font-medium text-text-mut underline hover:text-text-marble"
>
{showHistory ? "Masquer l'historique" : `Historique (${pastVersions.length})`}
</button>
{showHistory && (
<ul className="mt-1 flex flex-col gap-1 border-l-2 border-gold/20 pl-2">
{pastVersions.map((version) => (
<li key={version.created_at} className="text-xs text-text-mut">
<span className="text-text-marble">{version.text}</span>
{" — "}
{formatHistoryDate(version.created_at)}
</li>
))}
</ul>
)}
</>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={onAddToBank}
className="rounded-md border border-gold/40 px-2 py-1 text-xs font-medium text-text-marble hover:bg-gold/10"
>
Ajouter à la banque
</button>
{confirmingReject ? (
<button
type="button"
onClick={onReject}
className="rounded-md bg-oxblood px-2 py-1 text-xs text-marble"
>
Confirmer ?
</button>
) : (
<button
type="button"
onClick={() => setConfirmingReject(true)}
onBlur={() => setConfirmingReject(false)}
aria-label="Rejeter"
title="Rejeter"
className="flex h-7 w-7 items-center justify-center rounded-md text-oxblood/70 hover:bg-oxblood/10 hover:text-oxblood"
>
<IconTrash className="h-3.5 w-3.5" />
</button>
)}
</div>
</li>
);
}
export function QuestionsView({
initialQuestions,
initialRevealedId,
initialSubmissions,
initialSubmissionHistory,
profiles,
}: {
initialQuestions: QuestionRow[];
initialRevealedId: string | null;
initialSubmissions: SubmissionRow[];
initialSubmissionHistory: SubmissionHistoryRow[];
profiles: ProfileRow[];
}) {
const [questions, setQuestions] = useState(initialQuestions);
const [revealedId, setRevealedId] = useState(initialRevealedId);
const [submissions, setSubmissions] = useState(initialSubmissions);
const [submissionHistory, setSubmissionHistory] = useState(initialSubmissionHistory);
const [adding, setAdding] = useState(false);
const [confirmingRandomize, setConfirmingRandomize] = useState(false);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
const pseudos = useMemo(() => profiles.map((p) => p.pseudo), [profiles]);
// Combien de questions distinctes visent chaque personne — pour repérer
// à l'œil un déséquilibre avant la soirée (quelqu'un jamais tagué, ou au
// contraire visé par la moitié de la banque).
const distribution = useMemo(() => {
const counts = new Map<string, number>(profiles.map((p) => [p.pseudo, 0]));
for (const question of questions) {
const mentioned = new Set(extractMentionedPseudos(question.text, pseudos));
for (const pseudo of mentioned) counts.set(pseudo, (counts.get(pseudo) ?? 0) + 1);
}
return profiles
.map((profile) => ({ profile, count: counts.get(profile.pseudo) ?? 0 }))
.sort((a, b) => b.count - a.count || a.profile.pseudo.localeCompare(b.profile.pseudo));
}, [questions, profiles, pseudos]);
const hasAnyMention = distribution.some((d) => d.count > 0);
const refetch = useCallback(async () => {
const supabase = createClient();
const [{ data: q }, { data: s }, { data: sub }, { data: hist }] = await Promise.all([
supabase
.from("chariot_questions")
.select("id, text, position, created_at")
.order("position", { ascending: true })
.order("created_at", { ascending: true }),
supabase.from("settings").select("chariot_revealed_question_id").eq("id", true).single(),
supabase
.from("chariot_submissions")
.select("user_id, text, updated_at, profiles(pseudo, avatar_url)")
.order("updated_at", { ascending: true })
.returns<SubmissionRow[]>(),
supabase
.from("chariot_submission_history")
.select("user_id, text, created_at")
.order("created_at", { ascending: false }),
]);
if (q) setQuestions(q);
if (s) setRevealedId(s.chariot_revealed_question_id);
if (sub) setSubmissions(sub);
if (hist) setSubmissionHistory(hist);
}, []);
useEffect(() => {
const supabase = createClient();
let channel: ReturnType<typeof supabase.channel> | null = null;
let cancelled = false;
waitForRealtimeAuth(supabase).then(() => {
if (cancelled) return;
channel = supabase
.channel("char-questions-changes")
.on("postgres_changes", { event: "*", schema: "public", table: "chariot_questions" }, refetch)
.on("postgres_changes", { event: "*", schema: "public", table: "settings" }, refetch)
.on("postgres_changes", { event: "*", schema: "public", table: "chariot_submissions" }, refetch)
.on("postgres_changes", { event: "*", schema: "public", table: "chariot_submission_history" }, refetch)
.subscribe();
});
return () => {
cancelled = true;
if (channel) supabase.removeChannel(channel);
};
}, [refetch]);
async function moveQuestion(question: QuestionRow, direction: "up" | "down") {
const i = questions.findIndex((q) => q.id === question.id);
const j = direction === "up" ? i - 1 : i + 1;
if (j < 0 || j >= questions.length) return;
const other = questions[j];
const supabase = createClient();
await supabase.from("chariot_questions").update({ position: other.position }).eq("id", question.id);
await supabase.from("chariot_questions").update({ position: question.position }).eq("id", other.id);
refetch();
}
// Réassigne une position dense (1..N) selon le nouvel ordre — utilisé
// aussi bien après un glisser-déposer qu'après un mélange aléatoire,
// pas de contrainte unique sur la colonne donc aucun risque de collision.
async function persistOrder(ordered: QuestionRow[]) {
setQuestions(ordered);
const supabase = createClient();
await Promise.all(
ordered.map((question, index) => supabase.from("chariot_questions").update({ position: index + 1 }).eq("id", question.id)),
);
refetch();
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = questions.findIndex((q) => q.id === active.id);
const newIndex = questions.findIndex((q) => q.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
persistOrder(arrayMove(questions, oldIndex, newIndex));
}
function randomizeOrder() {
const shuffled = [...questions];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
setConfirmingRandomize(false);
persistOrder(shuffled);
}
async function toggleReveal(question: QuestionRow) {
const supabase = createClient();
const isRevealed = revealedId === question.id;
await supabase
.from("settings")
.update({ chariot_revealed_question_id: isRevealed ? null : question.id })
.eq("id", true);
refetch();
}
async function addSubmissionToBank(submission: SubmissionRow) {
const supabase = createClient();
const nextPosition = (questions.at(-1)?.position ?? 0) + 1;
await supabase.from("chariot_questions").insert({ text: submission.text, position: nextPosition });
await supabase.from("chariot_submissions").delete().eq("user_id", submission.user_id);
refetch();
}
async function rejectSubmission(userId: string) {
const supabase = createClient();
await supabase.from("chariot_submissions").delete().eq("user_id", userId);
refetch();
}
return (
<div className="flex flex-col gap-4">
{submissions.length > 0 && (
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-sm">
<p className="mb-2 font-heading text-sm tracking-wide text-text-mut uppercase">
Suggestions des Citoyens
</p>
<ul className="flex flex-col divide-y divide-gold/10">
{submissions.map((submission) => {
// Trié desc : la première entrée est la version actuelle
// (déjà affichée au-dessus), le reste est l'historique.
const pastVersions = submissionHistory
.filter((h) => h.user_id === submission.user_id)
.slice(1);
return (
<SubmissionRowItem
key={submission.user_id}
submission={submission}
pastVersions={pastVersions}
onAddToBank={() => addSubmissionToBank(submission)}
onReject={() => rejectSubmission(submission.user_id)}
/>
);
})}
</ul>
</div>
)}
{profiles.length > 0 && (
<div className="marble-surface rounded-2xl border border-gold/20 p-4 shadow-sm">
<p className="mb-2 font-heading text-sm tracking-wide text-text-mut uppercase">Répartition des tags</p>
{!hasAnyMention ? (
<p className="text-xs text-text-mut">
Aucune question ne tague quelqu&apos;un pour l&apos;instant écris « @pseudo » dans une question pour
la viser.
</p>
) : (
<ul className="flex flex-wrap gap-2">
{distribution.map(({ profile, count }) => (
<li
key={profile.id}
className={`flex items-center gap-1.5 rounded-full border px-2 py-1 text-xs ${
count === 0 ? "border-gold/15 text-text-mut" : "border-gold/40 text-text-marble"
}`}
>
<Avatar pseudo={profile.pseudo} avatarUrl={profile.avatar_url} size="xs" />
<span className="font-medium">{profile.pseudo}</span>
<span className={`font-heading ${count === 0 ? "text-text-mut" : "text-sea"}`}>{count}</span>
</li>
))}
</ul>
)}
</div>
)}
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-sm">
{questions.length > 1 && (
<div className="mb-2 flex justify-end">
{confirmingRandomize ? (
<button
type="button"
onClick={randomizeOrder}
onBlur={() => setConfirmingRandomize(false)}
className="flex items-center gap-1.5 rounded-md bg-oxblood px-2 py-1 text-xs font-medium text-marble"
>
Confirmer le mélange ?
</button>
) : (
<button
type="button"
onClick={() => setConfirmingRandomize(true)}
className="flex items-center gap-1.5 rounded-md border border-gold/30 px-2 py-1 text-xs font-medium text-text-marble hover:bg-gold/10"
>
<IconShuffle className="h-3.5 w-3.5" />
Randomiser l&apos;ordre
</button>
)}
</div>
)}
{questions.length === 0 && !adding && (
<p className="mb-2 text-sm text-text-mut">Aucune question dans la banque.</p>
)}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={questions.map((q) => q.id)} strategy={verticalListSortingStrategy}>
<ul className="flex flex-col divide-y divide-gold/10">
{questions.map((question, index) => (
<QuestionRowItem
key={question.id}
question={question}
orderNumber={index + 1}
isFirst={index === 0}
isLast={index === questions.length - 1}
isRevealed={revealedId === question.id}
pseudos={pseudos}
profiles={profiles}
onMove={(direction) => moveQuestion(question, direction)}
onToggleReveal={() => toggleReveal(question)}
onChanged={refetch}
/>
))}
</ul>
</SortableContext>
</DndContext>
<div className="mt-3">
{adding ? (
<QuestionForm
nextPosition={(questions.at(-1)?.position ?? 0) + 1}
profiles={profiles}
onDone={() => {
setAdding(false);
refetch();
}}
/>
) : (
<button
type="button"
onClick={() => setAdding(true)}
className="flex items-center gap-1.5 text-xs font-medium text-text-mut hover:text-text-marble"
>
<IconPlus className="h-3.5 w-3.5" />
Ajouter une question
</button>
)}
</div>
</div>
</div>
);
}
+164
View File
@@ -0,0 +1,164 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Avatar } from "@/components/avatar";
import { LaurelWreath } from "@/components/laurel-wreath";
import type { ProfileRow } from "./page";
const ITEM_WIDTH = 96;
const REPEATS = 6;
const SPIN_MS = 3200;
function useReducedMotion() {
const [reduced, setReduced] = useState(
() => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches,
);
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const handler = () => setReduced(query.matches);
query.addEventListener("change", handler);
return () => query.removeEventListener("change", handler);
}, []);
return reduced;
}
export function RouletteSection({ profiles }: { profiles: ProfileRow[] }) {
const reducedMotion = useReducedMotion();
const [citizensOnly, setCitizensOnly] = useState(false);
const [spinning, setSpinning] = useState(false);
const [strip, setStrip] = useState<ProfileRow[]>([]);
const [offset, setOffset] = useState(0);
const [transitionOn, setTransitionOn] = useState(false);
const [winner, setWinner] = useState<ProfileRow | null>(null);
const [lastWinner, setLastWinner] = useState<ProfileRow | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const pool = citizensOnly ? profiles.filter((m) => m.role !== "judge") : profiles;
function handleSpin() {
if (spinning || pool.length === 0) return;
const chosen = pool[Math.floor(Math.random() * pool.length)];
if (reducedMotion) {
setWinner(chosen);
setLastWinner(chosen);
return;
}
setWinner(null);
setSpinning(true);
const built: ProfileRow[] = [];
for (let i = 0; i < REPEATS; i++) built.push(...pool);
built.push(chosen);
setStrip(built);
setTransitionOn(false);
setOffset(0);
requestAnimationFrame(() => {
const containerWidth = containerRef.current?.offsetWidth ?? 320;
const finalIndex = built.length - 1;
const target = finalIndex * ITEM_WIDTH + ITEM_WIDTH / 2 - containerWidth / 2;
requestAnimationFrame(() => {
setTransitionOn(true);
setOffset(-target);
});
});
}
function handleTransitionEnd() {
if (!spinning) return;
setSpinning(false);
const chosen = strip[strip.length - 1];
setWinner(chosen);
setLastWinner(chosen);
}
return (
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-lg sm:p-6">
<p className="text-center font-heading text-sm tracking-wide text-text-mut uppercase">La Roulette</p>
<p className="mb-4 text-center font-serif text-xs text-text-mut/70 italic">Que le sort désigne l&apos;élu.</p>
{profiles.length === 0 ? (
<p className="text-center text-sm text-text-mut">Personne n&apos;est encore inscrit.</p>
) : (
<>
<label className="mb-4 flex items-center justify-center gap-2 text-sm text-text-marble">
<input
type="checkbox"
checked={citizensOnly}
onChange={(event) => setCitizensOnly(event.target.checked)}
disabled={spinning}
className="h-4 w-4 accent-gold"
/>
Tirer uniquement parmi les Citoyens (exclure les Archontes)
</label>
<div
ref={containerRef}
className="relative isolate mx-auto mb-6 h-32 max-w-full overflow-hidden rounded-lg bg-ink-2/5"
>
<div
className="absolute top-0 bottom-0 left-1/2 z-10 w-0.5 -translate-x-1/2 bg-gold-bright"
aria-hidden
/>
{strip.length > 0 && (
<div
className="flex h-full items-center"
style={{
transform: `translateX(${offset}px)`,
transition: transitionOn ? `transform ${SPIN_MS}ms cubic-bezier(0.11, 0.7, 0.14, 1)` : "none",
}}
onTransitionEnd={handleTransitionEnd}
>
{strip.map((member, index) => (
<div
key={`${member.id}-${index}`}
className="flex shrink-0 flex-col items-center gap-1"
style={{ width: ITEM_WIDTH }}
>
<Avatar pseudo={member.pseudo} avatarUrl={member.avatar_url} size="md" />
<span className="max-w-[5rem] truncate text-xs text-text-marble">{member.pseudo}</span>
</div>
))}
</div>
)}
</div>
<div className="flex justify-center">
<button
type="button"
onClick={handleSpin}
disabled={spinning || pool.length === 0}
className="rounded-md bg-ink-2 px-6 py-2.5 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink disabled:opacity-50"
>
{spinning ? "…" : winner ? "Rejouer" : "Tirer au sort"}
</button>
</div>
<div aria-live="polite" className="mt-8 flex flex-col items-center gap-2">
{winner && (
<div className="animate-reveal flex flex-col items-center gap-2 rounded-2xl px-8 py-6">
<LaurelWreath className="h-10 w-10" color="#C9A227" />
<div className="ring-4 ring-gold rounded-full p-1">
<Avatar pseudo={winner.pseudo} avatarUrl={winner.avatar_url} size="lg" />
</div>
<span className="font-heading text-2xl tracking-wide text-text-marble uppercase">
{winner.pseudo}
</span>
<span className="font-serif text-sm text-text-mut italic">a é désigné(e) par le sort</span>
</div>
)}
</div>
{lastWinner && !winner && (
<p className="mt-4 text-center text-xs text-text-mut">
Dernier élu : <span className="font-medium text-text-marble">{lastWinner.pseudo}</span>
</p>
)}
</>
)}
</div>
);
}
+199
View File
@@ -0,0 +1,199 @@
"use client";
import { useState } from "react";
import { Avatar } from "@/components/avatar";
import { ChariotEmblem } from "@/components/chariot-emblem";
import { createClient } from "@/lib/supabase/client";
import type { ProfileRow, SlotRow } from "./page";
const COLUMN_A_SLOTS = [1, 2, 3];
const COLUMN_B_SLOTS = [4, 5, 6];
function SlotCard({
slot,
profile,
entryCount,
isJudge,
availableProfiles,
onAssign,
onClear,
}: {
slot: number;
profile: ProfileRow | null;
entryCount: number;
isJudge: boolean;
availableProfiles: ProfileRow[];
onAssign: (userId: string) => void;
onClear: () => void;
}) {
const [picking, setPicking] = useState(false);
if (profile) {
return (
<div
key={profile.id}
className="animate-podium-rise marble-surface flex items-center gap-2 rounded-lg border border-gold/25 px-3 py-2"
>
<Avatar pseudo={profile.pseudo} avatarUrl={profile.avatar_url} size="sm" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-text-marble">{profile.pseudo}</p>
{entryCount > 0 && <p className="text-[0.65rem] text-text-mut">×{entryCount}</p>}
</div>
{isJudge && (
<button
type="button"
onClick={onClear}
className="shrink-0 rounded-md border border-oxblood/40 px-2 py-1 text-[0.65rem] text-oxblood hover:bg-oxblood/10"
>
Retirer
</button>
)}
</div>
);
}
return (
<div className="flex min-h-[2.5rem] items-center justify-center rounded-lg border border-dashed border-gold/25 px-2 py-2 text-xs">
{isJudge ? (
picking ? (
<div className="flex w-full flex-wrap items-center justify-center gap-1">
{availableProfiles.length === 0 ? (
<span className="text-text-mut/60">Personne de libre</span>
) : (
availableProfiles.map((p) => (
<button
key={p.id}
type="button"
onClick={() => {
onAssign(p.id);
setPicking(false);
}}
className="rounded-md border border-gold/30 bg-white/60 px-2 py-1 text-[0.7rem] text-text-marble hover:bg-gold/20"
>
{p.pseudo}
</button>
))
)}
</div>
) : (
<button type="button" onClick={() => setPicking(true)} className="text-text-mut hover:text-text-marble">
+ Emplacement {slot}
</button>
)
) : (
<span className="text-text-mut/60">Vide</span>
)}
</div>
);
}
export function SlotBoard({
isJudge,
slots,
profiles,
entryCounts,
onChanged,
}: {
isJudge: boolean;
slots: SlotRow[];
profiles: ProfileRow[];
entryCounts: Record<string, number>;
onChanged: () => void;
}) {
const [celebrating, setCelebrating] = useState<"a" | "b" | null>(null);
const profileById = new Map(profiles.map((p) => [p.id, p]));
const slotByNumber = new Map(slots.map((s) => [s.slot, s]));
const assignedIds = new Set(slots.filter((s) => s.user_id).map((s) => s.user_id as string));
const availableProfiles = profiles.filter((p) => !assignedIds.has(p.id));
async function assign(slot: number, userId: string) {
const supabase = createClient();
await supabase.from("chariot_slots").update({ user_id: userId }).eq("slot", slot);
await supabase.from("chariot_entries").insert({ user_id: userId });
onChanged();
}
async function clear(slot: number) {
const supabase = createClient();
await supabase.from("chariot_slots").update({ user_id: null }).eq("slot", slot);
onChanged();
}
// Les vainqueurs sortent du char : on laisse la lueur dorée se voir un
// instant avant de vider réellement leurs emplacements.
function resolveDuel(winner: "a" | "b") {
setCelebrating(winner);
const winningSlots = winner === "a" ? COLUMN_A_SLOTS : COLUMN_B_SLOTS;
setTimeout(async () => {
const supabase = createClient();
await supabase.from("chariot_slots").update({ user_id: null }).in("slot", winningSlots);
setCelebrating(null);
onChanged();
}, 700);
}
function renderColumn(label: string, slotNumbers: number[], side: "a" | "b") {
return (
<div
className={`marble-surface flex flex-col gap-2 rounded-2xl border p-4 shadow-lg ${
celebrating === side ? "animate-points-flash border-gold" : "border-gold/40"
}`}
>
<p className="text-center font-heading text-sm tracking-wide text-text-mut uppercase">{label}</p>
<div className="flex flex-col gap-2">
{slotNumbers.map((slot) => {
const row = slotByNumber.get(slot);
const profile = row?.user_id ? (profileById.get(row.user_id) ?? null) : null;
return (
<SlotCard
key={slot}
slot={slot}
profile={profile}
entryCount={profile ? (entryCounts[profile.id] ?? 0) : 0}
isJudge={isJudge}
availableProfiles={availableProfiles}
onAssign={(userId) => assign(slot, userId)}
onClear={() => clear(slot)}
/>
);
})}
</div>
</div>
);
}
return (
<section className="flex flex-col gap-3">
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-stretch gap-2 sm:gap-4">
{renderColumn("Colonne A", COLUMN_A_SLOTS, "a")}
<div className="flex flex-col items-center justify-center gap-1 px-0.5 sm:gap-1.5 sm:px-2">
<ChariotEmblem className="h-10 w-7 sm:h-16 sm:w-10" />
<span className="font-heading text-xs tracking-[0.15em] text-gold-bright sm:text-lg">VS</span>
</div>
{renderColumn("Colonne B", COLUMN_B_SLOTS, "b")}
</div>
{isJudge && (
<div className="flex flex-wrap justify-center gap-3">
<button
type="button"
onClick={() => resolveDuel("a")}
disabled={celebrating !== null}
className="rounded-md bg-ink-2 px-4 py-2 font-heading text-xs tracking-wide text-gold-bright uppercase transition hover:bg-ink disabled:opacity-50"
>
Colonne A remporte le duel
</button>
<button
type="button"
onClick={() => resolveDuel("b")}
disabled={celebrating !== null}
className="rounded-md bg-ink-2 px-4 py-2 font-heading text-xs tracking-wide text-gold-bright uppercase transition hover:bg-ink disabled:opacity-50"
>
Colonne B remporte le duel
</button>
</div>
)}
</section>
);
}
+206
View File
@@ -0,0 +1,206 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Avatar } from "@/components/avatar";
import { Podium } from "@/components/podium";
import { IconClose, IconHorn } from "@/components/icons";
import { computeRanksBy } from "@/lib/ranking";
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
import { MelonGame, type MelonRunTelemetry } from "./melon-game";
import type { MelonScoreEntry, MemberRow } from "./page";
// Anti-triche par ping — voir la même remarque dans icare-view.tsx et
// supabase/schema.sql section 18.
const PING_INTERVAL_MS = 5000;
export function CorneView({
citizens,
initialLeaderboard,
initialOwnBestScore,
isFrozen,
}: {
citizens: MemberRow[];
initialLeaderboard: MelonScoreEntry[];
initialOwnBestScore: number | null;
isFrozen: boolean;
}) {
const [leaderboard, setLeaderboard] = useState(initialLeaderboard);
const [ownBestScore, setOwnBestScore] = useState(initialOwnBestScore);
const [showRanking, setShowRanking] = useState(false);
const sessionTokenRef = useRef<string | null>(null);
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stopPinging = useCallback(() => {
if (pingIntervalRef.current !== null) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
}, []);
// Coupe le ping si le joueur quitte /corne en pleine partie (sinon
// l'intervalle continuerait d'appeler la RPC pour un composant démonté).
useEffect(() => stopPinging, [stopPinging]);
const refetch = useCallback(async () => {
const supabase = createClient();
const [{ data: scores }, { data: members }] = await Promise.all([
supabase.from("melon_scores").select("user_id, best_score").order("best_score", { ascending: false }),
supabase.from("profiles").select("id, pseudo, avatar_url"),
]);
const memberById = new Map((members ?? []).map((member) => [member.id, member]));
setLeaderboard(
(scores ?? []).map((row) => ({
id: row.user_id,
pseudo: memberById.get(row.user_id)?.pseudo ?? "Un Citoyen",
avatar_url: memberById.get(row.user_id)?.avatar_url ?? null,
best_score: row.best_score,
})),
);
}, []);
useEffect(() => {
const supabase = createClient();
let channel: ReturnType<typeof supabase.channel> | null = null;
let cancelled = false;
waitForRealtimeAuth(supabase).then(() => {
if (cancelled) return;
channel = supabase
.channel("melon-scores")
.on("postgres_changes", { event: "*", schema: "public", table: "melon_scores" }, () => {
refetch();
})
.subscribe();
});
return () => {
cancelled = true;
if (channel) supabase.removeChannel(channel);
};
}, [refetch]);
function handleStart() {
const supabase = createClient();
const token = crypto.randomUUID();
sessionTokenRef.current = token;
stopPinging();
pingIntervalRef.current = setInterval(() => {
// Best-effort : un ping manqué (réseau, onglet en arrière-plan) est
// sans conséquence, submit_melon_score reste tolérant (marge de 15s
// avant le premier ping attendu, voir schema.sql section 18).
supabase.rpc("ping_game_run", { p_game: "melon", p_session_token: token }).then(
() => {},
() => {},
);
}, PING_INTERVAL_MS);
}
async function handleFinish(score: number, run: MelonRunTelemetry) {
stopPinging();
const supabase = createClient();
const runWithToken = { ...run, session_token: sessionTokenRef.current };
const { data, error } = await supabase.rpc("submit_melon_score", { p_score: score, p_run: runWithToken });
if (!error && data) {
setOwnBestScore(data.best_score);
refetch();
}
}
// Le classement vit dans une modale, hors du flux de mise en page du jeu
// (même leçon qu'Icare : une mise à jour Realtime pendant une partie ne
// doit jamais pouvoir perturber le jeu en cours).
const ranked = useMemo(() => computeRanksBy(leaderboard, (a, b) => b.best_score - a.best_score), [leaderboard]);
// Tant que personne n'a marqué, tout le monde est à égalité à 0 et
// partagerait le rang 1 — pas de podium tant qu'il n'y a rien à
// départager, sinon tout le groupe se retrouverait sur la 1ère marche.
const hasScores = (ranked[0]?.best_score ?? 0) > 0;
const podiumProfiles = hasScores ? ranked.filter((p) => p.rank <= 3) : [];
const rest = hasScores ? ranked.filter((p) => p.rank > 3) : ranked;
return (
<div className="mx-auto flex w-full max-w-2xl flex-col items-center gap-4 px-4 py-8">
{/* Même en-tête compact que /icare (titre + Classement sur une seule
ligne) : l'ancien bouton isolé juste au-dessus du jeu pouvait se
faire chevaucher par la preview de la prochaine pièce sur les gros
paliers — ici il est tout en haut, loin du jeu, comme sur Icare. */}
<div className="flex w-full items-center justify-between">
<h1 className="font-heading text-sm tracking-[0.15em] text-gold-bright uppercase">
La Corne d&apos;Abondance
</h1>
<button
type="button"
onClick={() => setShowRanking(true)}
className="flex items-center gap-1.5 rounded-md border border-gold/30 px-3 py-1.5 text-xs font-medium text-marble/80 uppercase tracking-wide transition hover:bg-gold/10"
>
<IconHorn className="h-4 w-4" />
Classement
</button>
</div>
<div className="mt-10">
<MelonGame citizens={citizens} onStart={handleStart} onFinish={handleFinish} />
</div>
{ownBestScore !== null && (
<p className="text-center font-heading text-sm tracking-wide text-marble">
Ton record : <span className="text-gold-bright">{ownBestScore}</span>
</p>
)}
<p className="max-w-sm text-center text-xs text-marble/60">
{isFrozen
? "Les gloires du podium ont été attribuées automatiquement au début du Tribunal."
: "Au début du Tribunal, les scores seront figés et le podium recevra des gloires automatiquement — aucun Archonte n'a besoin d'intervenir."}
</p>
{showRanking && (
<div className="fixed inset-0 z-30 flex items-center justify-center bg-ink/80 p-4 backdrop-blur-sm">
<div className="marble-surface flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-gold/40 shadow-xl">
<div className="flex items-center justify-between border-b border-gold/20 px-4 py-3">
<h2 className="font-heading text-sm tracking-[0.1em] text-text-marble uppercase">Classement</h2>
<button
type="button"
onClick={() => setShowRanking(false)}
className="rounded-md p-1 text-text-mut transition hover:bg-gold/10 hover:text-text-marble"
aria-label="Fermer"
>
<IconClose className="h-5 w-5" />
</button>
</div>
<div className="overflow-y-auto px-4 py-4">
{podiumProfiles.length > 0 && (
<Podium profiles={podiumProfiles} renderValue={(member) => member.best_score} />
)}
{rest.length > 0 && (
<ol className="flex flex-col gap-1.5">
{rest.map((entry) => (
<li
key={entry.id}
className="flex items-center gap-3 rounded-lg border border-gold/25 px-3 py-2"
>
<span className="w-5 shrink-0 text-center font-heading text-sm text-text-mut">
{entry.rank}
</span>
<Avatar pseudo={entry.pseudo} avatarUrl={entry.avatar_url} size="xs" />
<span className="flex-1 truncate text-sm font-medium text-text-marble">{entry.pseudo}</span>
<span className="font-heading text-sm font-semibold text-sea">{entry.best_score}</span>
</li>
))}
</ol>
)}
{ranked.length === 0 && (
<p className="text-center text-sm text-text-mut">Personne n&apos;a encore joué.</p>
)}
</div>
</div>
</div>
)}
</div>
);
}
+635
View File
@@ -0,0 +1,635 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import Matter from "matter-js";
import { Avatar } from "@/components/avatar";
import {
buildTierRadii,
CONTAINER_HEIGHT,
CONTAINER_WIDTH,
DANGER_LINE_Y,
DROP_COOLDOWN_MS,
DROP_MAX_TIER_CAP,
DROP_MIN_TIER,
FIXED_DT_MS,
GAME_OVER_GRACE_MS,
MAX_FRAME_DT_MS,
WALL_THICKNESS,
} from "@/lib/melon/constants";
import { playMergeSound } from "@/lib/melon/sound";
import { Confetti } from "@/components/confetti";
import type { MemberRow } from "./page";
type Status = "playing" | "dead";
type Piece = { id: number; tier: number };
type PendingMerge = { pieceIdA: number; pieceIdB: number; tier: number; x: number; y: number };
type BodyMeta = { pieceId: number; tier: number };
function randomTier(min: number, max: number): number {
return min + Math.floor(Math.random() * (max - min + 1));
}
// Composant à part entière (pas juste un fragment JSX dans le .map() de
// MelonGame) uniquement pour pouvoir lui donner un ref stable via son
// propre useCallback([piece.id]) — un ref créé comme fonction fléchée
// inline dans une boucle de rendu est une NOUVELLE fonction à chaque
// rendu, ce que React interprète comme "le ref a changé" : il rappelle
// alors la fonction avec null puis avec le nœud, pour CHAQUE pièce déjà
// posée (pas seulement celle qui vient de changer), à chaque mise à jour
// de `pieces` (chaque lâcher, chaque fusion). Sur un plateau qui accumule
// des dizaines de pièces après plusieurs minutes de jeu, ce cycle inutile
// devient un vrai coût CPU répété très souvent — plausiblement la vraie
// cause du ralentissement/rechargement forcé constaté sur iPhone (la
// fuite du Set de fusion, corrigée séparément, n'était pas suffisante à
// elle seule). useCallback garde la même fonction tant que piece.id ne
// change pas, ce qu'une fonction fléchée inline ne peut pas faire.
function PieceView({
piece,
citizen,
diameter,
registerNode,
}: {
piece: Piece;
citizen: MemberRow | undefined;
diameter: number;
registerNode: (id: number, node: HTMLDivElement | null) => void;
}) {
const ref = useCallback((node: HTMLDivElement | null) => registerNode(piece.id, node), [piece.id, registerNode]);
return (
<div ref={ref} className="pointer-events-none absolute top-0 left-0">
{/* La position/rotation (transform) de ce conteneur est pilotée
impérativement à chaque frame depuis la boucle physique —
l'animation d'apparition vit sur cet enfant, jamais sur le
conteneur, pour ne pas entrer en conflit avec transform. Idem
pour animate-danger-blink (ajoutée/retirée sur ce même conteneur
par la boucle physique quand la pièce dépasse la ligne de
danger). rounded-full ici : sans ça le halo de l'animation
dessinait un carré visible autour du rond. animate-fade-in (pas
animate-reveal) : ce dernier passe par 82% puis 104% avant de se
stabiliser à 100%, ce qui faisait paraître une pièce tout juste
lâchée d'une taille différente de celle annoncée dans la
preview. */}
<div className="animate-fade-in rounded-full">
<Avatar pseudo={citizen?.pseudo ?? "?"} avatarUrl={citizen?.avatar_url ?? null} size={diameter} />
</div>
</div>
);
}
export type MelonRunTelemetry = {
v: 1;
duration_ms: number;
actions: number;
merges: number;
};
export function MelonGame({
citizens,
onStart,
onFinish,
}: {
citizens: MemberRow[];
onStart: () => void;
onFinish: (score: number, run: MelonRunTelemetry) => void;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const previewRef = useRef<HTMLDivElement | null>(null);
const guideLineRef = useRef<HTMLDivElement | null>(null);
const nodeRefs = useRef(new Map<number, HTMLDivElement>());
// Stable (jamais recréée) : passée à PieceView, qui la mémorise elle-même
// par pièce via son propre useCallback([piece.id]) — voir la remarque
// juste au-dessus de PieceView sur pourquoi ça compte.
const registerPieceNode = useCallback((id: number, node: HTMLDivElement | null) => {
if (node) nodeRefs.current.set(id, node);
else nodeRefs.current.delete(id);
}, []);
// Un palier de taille par Citoyen, pas un nombre arbitraire — voir
// buildTierRadii. citizens[tier] (indexation directe, sans tirage) donne
// l'association palier → Citoyen : fixe pour tout le monde, pas juste
// pour une partie — corne/page.tsx trie déjà les Citoyens par pseudo, ce
// qui donne un ordre déterministe et stable sans dépendre d'un choix
// arbitraire de personnes précises.
const tierRadii = useMemo(() => buildTierRadii(citizens.length), [citizens.length]);
const maxTier = tierRadii.length - 1;
const dropMaxTier = Math.min(DROP_MAX_TIER_CAP, maxTier);
const [status, setStatus] = useState<Status>("playing");
const [score, setScore] = useState(0);
const [pieces, setPieces] = useState<Piece[]>([]);
const [nextTier, setNextTier] = useState(() => randomTier(DROP_MIN_TIER, dropMaxTier));
// Palier annoncé après nextTier — juste informatif (pas de position à
// viser), pour voir venir deux coups à l'avance au lieu d'un seul.
const [secondTier, setSecondTier] = useState(() => randomTier(DROP_MIN_TIER, dropMaxTier));
const onStartRef = useRef(onStart);
useEffect(() => {
onStartRef.current = onStart;
}, [onStart]);
const onFinishRef = useRef(onFinish);
useEffect(() => {
onFinishRef.current = onFinish;
}, [onFinish]);
const statusRef = useRef<Status>("playing");
const scoreRef = useRef(0);
const nextTierRef = useRef(nextTier);
const secondTierRef = useRef(secondTier);
const dropXRef = useRef(CONTAINER_WIDTH / 2);
const engineRef = useRef<Matter.Engine | null>(null);
const bodiesRef = useRef(new Map<number, Matter.Body>());
const nextPieceIdRef = useRef(0);
const lastDropAtRef = useRef(-Infinity);
const pendingMergesRef = useRef<PendingMerge[]>([]);
const mergingBodyIdsRef = useRef(new Set<number>());
const dangerSinceRef = useRef<number | null>(null);
// Pas Date.now() directement en argument de useRef (impur pendant le
// rendu, react-hooks/purity) : la vraie valeur est posée dans l'effet de
// montage juste en dessous — resetGame() (rejouer après une partie) la
// réécrit de toute façon à chaque nouvelle partie.
const runStartedAtRef = useRef(0);
useEffect(() => {
runStartedAtRef.current = Date.now();
onStartRef.current();
}, []);
const dropCountRef = useRef(0);
const mergeCountRef = useRef(0);
const spawnPiece = useCallback(
(tier: number, x: number, y: number): Piece => {
const id = nextPieceIdRef.current++;
const radius = tierRadii[tier];
const body = Matter.Bodies.circle(x, y, radius, {
restitution: 0.2,
friction: 0.3,
frictionAir: 0.001,
});
const meta: BodyMeta = { pieceId: id, tier };
body.plugin = meta;
Matter.Composite.add(engineRef.current!.world, body);
bodiesRef.current.set(id, body);
return { id, tier };
},
[tierRadii],
);
// Positionne dropXRef (utilisé au lâcher) ainsi que la preview et le
// trait de visée qui suivent le pointeur — rappelée après chaque
// changement de palier (le rayon change, donc le décalage aussi) pour
// resynchroniser sans jamais lire de ref pendant un rendu (interdit par
// react-hooks/refs).
const setDropX = useCallback(
(x: number) => {
const radius = tierRadii[nextTierRef.current];
// Pas de WALL_THICKNESS ici : les murs physiques sont déjà à ras des
// bords visuels du bac (leur face intérieure est exactement à x=0 et
// x=CONTAINER_WIDTH), donc seul le rayon de la pièce limite jusqu'où
// son centre peut aller. Ajouter WALL_THICKNESS en plus créait une
// marge fixe de 20px des deux côtés — disproportionnée pour les
// petits paliers, qui ne pouvaient plus s'approcher des bords.
const clamped = Math.min(CONTAINER_WIDTH - radius, Math.max(radius, x));
dropXRef.current = clamped;
if (previewRef.current) {
previewRef.current.style.transform = `translateX(${clamped - radius}px)`;
}
if (guideLineRef.current) {
guideLineRef.current.style.transform = `translateX(${clamped}px)`;
}
},
[tierRadii],
);
const resetGame = useCallback(() => {
const engine = engineRef.current;
if (engine) {
for (const body of bodiesRef.current.values()) {
Matter.Composite.remove(engine.world, body);
}
}
bodiesRef.current.clear();
nodeRefs.current.clear();
pendingMergesRef.current = [];
mergingBodyIdsRef.current.clear();
dangerSinceRef.current = null;
runStartedAtRef.current = Date.now();
onStartRef.current();
dropCountRef.current = 0;
mergeCountRef.current = 0;
lastDropAtRef.current = -Infinity;
scoreRef.current = 0;
statusRef.current = "playing";
const firstNextTier = randomTier(DROP_MIN_TIER, dropMaxTier);
const firstSecondTier = randomTier(DROP_MIN_TIER, dropMaxTier);
nextTierRef.current = firstNextTier;
secondTierRef.current = firstSecondTier;
setNextTier(firstNextTier);
setSecondTier(firstSecondTier);
setPieces([]);
setScore(0);
setStatus("playing");
setDropX(CONTAINER_WIDTH / 2);
}, [dropMaxTier, setDropX]);
const handleDrop = useCallback(() => {
if (statusRef.current !== "playing") return;
const now = performance.now();
if (now - lastDropAtRef.current < DROP_COOLDOWN_MS) return;
lastDropAtRef.current = now;
const tier = nextTierRef.current;
const radius = tierRadii[tier];
// Même borne que setDropX (pas de WALL_THICKNESS en trop, voir
// là-bas) — dropXRef est déjà clampé de la même façon, ce recalcul ne
// fait que se protéger d'un dropXRef obtenu autrement (clavier, etc.).
const minX = radius;
const maxX = CONTAINER_WIDTH - radius;
const x = Math.min(maxX, Math.max(minX, dropXRef.current));
// spawnPiece a des effets de bord (crée un vrai corps matter-js,
// incrémente le compteur d'id) : appelé une seule fois ici, jamais
// depuis l'intérieur du updater de setPieces, qui doit rester pur.
const dropped = spawnPiece(tier, x, radius + 6);
dropCountRef.current += 1;
setPieces((current) => [...current, dropped]);
// La file glisse d'un cran : ce qui était annoncé en second devient la
// pièce à viser, et un nouveau palier est tiré pour la deuxième place.
const upcoming = secondTierRef.current;
const newSecond = randomTier(DROP_MIN_TIER, dropMaxTier);
nextTierRef.current = upcoming;
secondTierRef.current = newSecond;
setNextTier(upcoming);
setSecondTier(newSecond);
setDropX(dropXRef.current);
}, [spawnPiece, setDropX, tierRadii, dropMaxTier]);
const updateDropXFromEvent = useCallback(
(event: ReactPointerEvent) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
setDropX(event.clientX - rect.left);
},
[setDropX],
);
// Sur un tap sans glissement (mobile notamment), pointermove ne se
// déclenche pas forcément avant pointerup — sans repositionner ici aussi,
// le lâcher se faisait à l'ancienne position (celle d'une interaction
// précédente), pas là où le doigt vient de taper. onPointerDown en plus
// pour que le trait de visée bouge dès le contact, pas seulement au
// relâchement.
const handlePointerUp = useCallback(
(event: ReactPointerEvent) => {
updateDropXFromEvent(event);
handleDrop();
},
[updateDropXFromEvent, handleDrop],
);
// Clavier (ordinateur) : flèches pour viser, Espace/Entrée pour lâcher —
// même logique que le support clavier ajouté au Vol d'Icare.
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.code === "ArrowLeft" || event.code === "ArrowRight") {
event.preventDefault();
const step = event.code === "ArrowLeft" ? -16 : 16;
setDropX(dropXRef.current + step);
} else if (event.code === "Space" || event.code === "Enter") {
event.preventDefault();
handleDrop();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleDrop, setDropX]);
useEffect(() => {
const engine = Matter.Engine.create({ gravity: { x: 0, y: 1 } });
engineRef.current = engine;
const floor = Matter.Bodies.rectangle(
CONTAINER_WIDTH / 2,
CONTAINER_HEIGHT + WALL_THICKNESS / 2,
CONTAINER_WIDTH,
WALL_THICKNESS,
{ isStatic: true },
);
const leftWall = Matter.Bodies.rectangle(
-WALL_THICKNESS / 2,
CONTAINER_HEIGHT / 2,
WALL_THICKNESS,
CONTAINER_HEIGHT * 2,
{ isStatic: true },
);
const rightWall = Matter.Bodies.rectangle(
CONTAINER_WIDTH + WALL_THICKNESS / 2,
CONTAINER_HEIGHT / 2,
WALL_THICKNESS,
CONTAINER_HEIGHT * 2,
{ isStatic: true },
);
Matter.Composite.add(engine.world, [floor, leftWall, rightWall]);
// Les fusions sont détectées ici mais traitées après Engine.update (pas
// pendant), pour ne jamais modifier le monde matter-js au milieu d'un
// pas de simulation.
Matter.Events.on(engine, "collisionStart", (event) => {
for (const pair of event.pairs) {
const metaA = pair.bodyA.plugin as BodyMeta | undefined;
const metaB = pair.bodyB.plugin as BodyMeta | undefined;
if (!metaA || !metaB) continue;
if (metaA.tier !== metaB.tier) continue;
if (mergingBodyIdsRef.current.has(pair.bodyA.id) || mergingBodyIdsRef.current.has(pair.bodyB.id)) continue;
mergingBodyIdsRef.current.add(pair.bodyA.id);
mergingBodyIdsRef.current.add(pair.bodyB.id);
pendingMergesRef.current.push({
pieceIdA: metaA.pieceId,
pieceIdB: metaB.pieceId,
tier: metaA.tier,
x: (pair.bodyA.position.x + pair.bodyB.position.x) / 2,
y: (pair.bodyA.position.y + pair.bodyB.position.y) / 2,
});
}
});
let rafId: number;
let lastFrame: number | null = null;
let accumulator = 0;
function processPendingMerges() {
if (pendingMergesRef.current.length === 0) return;
const merges = pendingMergesRef.current;
pendingMergesRef.current = [];
const removedIds: number[] = [];
const spawned: Piece[] = [];
for (const merge of merges) {
const bodyA = bodiesRef.current.get(merge.pieceIdA);
const bodyB = bodiesRef.current.get(merge.pieceIdB);
if (!bodyA || !bodyB) continue;
Matter.Composite.remove(engine.world, [bodyA, bodyB]);
mergeCountRef.current += 1;
bodiesRef.current.delete(merge.pieceIdA);
bodiesRef.current.delete(merge.pieceIdB);
nodeRefs.current.delete(merge.pieceIdA);
nodeRefs.current.delete(merge.pieceIdB);
// Fuite mémoire corrigée ici : ces deux identifiants (les vrais id
// matter-js, pas les pieceId de l'app) étaient ajoutés à chaque
// fusion détectée dans collisionStart mais jamais retirés pendant
// la partie — seulement vidés au redémarrage complet du jeu. Sur
// une longue partie avec beaucoup de fusions, ce Set grossissait
// sans jamais être nettoyé (constaté en prod sur iPhone : page
// blanche puis rechargement après plusieurs minutes de jeu).
mergingBodyIdsRef.current.delete(bodyA.id);
mergingBodyIdsRef.current.delete(bodyB.id);
removedIds.push(merge.pieceIdA, merge.pieceIdB);
if (merge.tier < maxTier) {
const newTier = merge.tier + 1;
scoreRef.current += newTier;
spawned.push(spawnPiece(newTier, merge.x, merge.y));
playMergeSound(newTier, maxTier);
} else {
// Palier maximum déjà atteint : pas de pièce plus grande, juste
// un bonus de score pour la fusion finale.
scoreRef.current += maxTier * 2;
playMergeSound(maxTier, maxTier);
}
}
setScore(scoreRef.current);
setPieces((current) => [...current.filter((p) => !removedIds.includes(p.id)), ...spawned]);
}
// Renvoie les corps qui dépassent la ligne de danger (au repos, pas un
// simple rebond) — utilisé à la fois pour déclencher/prolonger le
// délai de grâce et pour savoir quelles pièces doivent clignoter. Le
// cercle doit avoir ENTIÈREMENT franchi la ligne (son bord bas,
// position.y + radius) plutôt que simplement y toucher (son bord haut,
// position.y - radius) : une pièce qui dépasse juste du sommet ne doit
// pas encore compter.
function overflowingBodyIds(): Set<number> {
const ids = new Set<number>();
for (const [id, body] of bodiesRef.current) {
const radius = body.circleRadius ?? 0;
if (body.position.y + radius < DANGER_LINE_Y && body.speed < 0.5) {
ids.add(id);
}
}
return ids;
}
function frame(now: number) {
if (lastFrame === null) lastFrame = now;
const frameDt = Math.min(now - lastFrame, MAX_FRAME_DT_MS);
lastFrame = now;
let overflowing = new Set<number>();
if (statusRef.current === "playing") {
accumulator += frameDt;
while (accumulator >= FIXED_DT_MS) {
Matter.Engine.update(engine, FIXED_DT_MS);
processPendingMerges();
accumulator -= FIXED_DT_MS;
}
overflowing = overflowingBodyIds();
if (overflowing.size > 0) {
if (dangerSinceRef.current === null) {
dangerSinceRef.current = now;
} else if (now - dangerSinceRef.current >= GAME_OVER_GRACE_MS) {
statusRef.current = "dead";
setStatus("dead");
onFinishRef.current(scoreRef.current, {
v: 1,
duration_ms: Math.max(0, Date.now() - runStartedAtRef.current),
actions: dropCountRef.current,
merges: mergeCountRef.current,
});
}
} else {
dangerSinceRef.current = null;
}
}
// translate d'abord (positionne le centre), rotate ensuite (tourne
// autour de ce centre, transform-origin par défaut) — sans ça les
// pièces glissaient sans jamais rouler, ce qui les faisait paraître
// figées/anguleuses plutôt que rondes. Le clignotement (classList,
// pas de state React) prévient que le délai de grâce avant game over
// est en cours pour cette pièce précisément.
for (const [id, body] of bodiesRef.current) {
const node = nodeRefs.current.get(id);
if (!node) continue;
const radius = body.circleRadius ?? 0;
node.style.transform = `translate(${body.position.x - radius}px, ${body.position.y - radius}px) rotate(${body.angle}rad)`;
node.classList.toggle("animate-danger-blink", overflowing.has(id));
}
rafId = requestAnimationFrame(frame);
}
rafId = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(rafId);
Matter.Events.off(engine, "collisionStart");
Matter.Composite.clear(engine.world, false);
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- monté une seule fois ; spawnPiece/maxTier sont stables tant que citizens.length ne change pas
}, []);
const prefersReducedMotion =
typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
return (
<div className="flex flex-col items-center gap-3">
<div className="flex items-center justify-between" style={{ width: CONTAINER_WIDTH }}>
<p className="font-heading text-2xl text-gold-bright">{score}</p>
{/* Deuxième pièce annoncée (après celle à viser) : juste
informatif, taille d'affichage fixe quel que soit le vrai
rayon du palier — pas de position à viser pour celle-ci. */}
<div className="flex items-center gap-1.5 opacity-70">
<span className="font-heading text-[0.6rem] tracking-wide text-marble/50 uppercase">Puis</span>
<Avatar
pseudo={citizens[secondTier]?.pseudo ?? "?"}
avatarUrl={citizens[secondTier]?.avatar_url ?? null}
size={32}
/>
</div>
</div>
{/* La bordure vit sur ce conteneur externe (sans largeur/hauteur
fixées : il s'ajuste automatiquement autour de l'enfant). La
bordure participant au box-sizing, la mettre directement sur le
conteneur aux coordonnées physiques (300×420) réduisait sa zone
intérieure réelle à 292×416 sans que la physique le sache — les
pièces pouvaient donc s'empiler jusqu'à x=300 alors que le cadre
visible s'arrêtait à 292, d'où le débordement à droite/en bas. */}
<div className="rounded-b-2xl border-x-4 border-b-4 border-gold/50">
<div
ref={containerRef}
onPointerDown={updateDropXFromEvent}
onPointerMove={updateDropXFromEvent}
onPointerUp={handlePointerUp}
className="relative touch-none overflow-visible rounded-b-2xl bg-ink-2/40 select-none"
style={{ width: CONTAINER_WIDTH, height: CONTAINER_HEIGHT }}
>
<div
className="pointer-events-none absolute inset-x-0 border-t border-dashed border-oxblood/50"
style={{ top: DANGER_LINE_Y }}
/>
{/* Trait de visée vertical, sous la preview, jusqu'au fond du bac —
positionné via le même setDropX que la preview. */}
<div
ref={(node) => {
guideLineRef.current = node;
if (node) node.style.transform = `translateX(${dropXRef.current}px)`;
}}
className="pointer-events-none absolute top-0 bottom-0 w-px border-l border-dashed border-gold/40"
/>
{pieces.map((piece) => (
<PieceView
key={piece.id}
piece={piece}
citizen={citizens[piece.tier]}
diameter={tierRadii[piece.tier] * 2}
registerNode={registerPieceNode}
/>
))}
<div
ref={(node) => {
previewRef.current = node;
// Position initiale posée ici (callback de ref, exécuté au
// montage du nœud) plutôt que dans un effet : un ref ne peut
// pas être lu pendant le rendu, et setDropX (appelé depuis
// resetGame/handleDrop) prend le relais pour toute mise à jour
// ultérieure.
if (node) {
const radius = tierRadii[nextTierRef.current];
node.style.transform = `translateX(${dropXRef.current - radius}px)`;
}
}}
className="pointer-events-none absolute left-0"
style={{ top: -(tierRadii[nextTier] * 2 + 12) }}
>
<Avatar
pseudo={citizens[nextTier]?.pseudo ?? "?"}
avatarUrl={citizens[nextTier]?.avatar_url ?? null}
size={tierRadii[nextTier] * 2}
/>
</div>
{status === "dead" && !prefersReducedMotion && <Confetti />}
{status === "dead" && (
<div className="absolute inset-0 flex items-center justify-center rounded-b-2xl bg-ink/70">
<div className="marble-surface animate-reveal flex flex-col items-center gap-2 rounded-xl border border-gold/40 px-6 py-4 text-center shadow-lg">
<p className="text-xs tracking-wide text-text-mut uppercase">Score</p>
<p className="font-heading text-3xl text-gold-bright">{score}</p>
<button
type="button"
onPointerUp={(event) => event.stopPropagation()}
onClick={resetGame}
className="mt-1 rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink"
>
Rejouer
</button>
</div>
</div>
)}
</div>
</div>
<p className="text-center text-xs text-marble/50">
Touche/clique pour viser et lâche deux avatars de même taille fusionnent.
</p>
{/* Échelle de repère : ordre de fusion complet, du plus petit au plus
grand — taille d'affichage proportionnelle au vrai rayon du
palier (mais réduite et plafonnée) pour que la progression se
voie d'un coup d'œil, sans dépendre du nombre de Citoyens.
flex-wrap (pas de scroll horizontal comme avant) : tout le monde
doit être visible sans interaction, l'intérêt d'une échelle de
repère est justement de tout voir d'un coup plutôt que de devoir
faire défiler. items-center (pas items-end) : aligner sur le bas
avec des tailles très variables donnait un décalage qui
grandissait avec la taille (la flèche, à décalage fixe, n'était
plus au bon endroit pour les gros ronds) — centrer chaque rond
sur son propre milieu reste symétrique quelle que soit sa
taille. Largeur plafonnée à max-w-sm (pas CONTAINER_WIDTH comme
avant) : la page (corne-view.tsx) est bien plus large que le bac
de jeu, la contrainte à 300px n'avait pas lieu d'être et forçait
le scroll pour rien. */}
<div className="marble-surface w-full max-w-sm rounded-lg border border-gold/25 p-3">
<p className="mb-2 text-center font-heading text-[0.65rem] tracking-[0.15em] text-text-mut uppercase">
Ordre de fusion
</p>
<div className="flex flex-wrap items-center justify-center gap-x-1.5 gap-y-3">
{citizens.map((citizen, tier) => (
<div key={citizen.id} className="flex items-center gap-1.5">
<Avatar
pseudo={citizen.pseudo}
avatarUrl={citizen.avatar_url}
size={Math.round(18 + (tierRadii[tier] / tierRadii[maxTier]) * 26)}
/>
{tier < citizens.length - 1 && <span className="text-xs text-gold/50"></span>}
</div>
))}
</div>
</div>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { applyTierOverrides, TIER_OVERRIDES } from "@/lib/melon/constants";
import { CorneView } from "./corne-view";
export type MelonScoreEntry = {
id: string;
pseudo: string;
avatar_url: string | null;
best_score: number;
};
export type MemberRow = {
id: string;
pseudo: string;
avatar_url: string | null;
};
export default async function CornePage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
const [{ data: scores }, { data: members }, { data: settings }] = await Promise.all([
supabase
.from("melon_scores")
.select("user_id, best_score")
.order("best_score", { ascending: false }),
supabase.from("profiles").select("id, pseudo, avatar_url, role").order("pseudo", { ascending: true }),
supabase.from("settings").select("tribunal_date").eq("id", true).single(),
]);
const memberById = new Map((members ?? []).map((member) => [member.id, member]));
const leaderboard: MelonScoreEntry[] = (scores ?? []).map((row) => ({
id: row.user_id,
pseudo: memberById.get(row.user_id)?.pseudo ?? "Un Citoyen",
avatar_url: memberById.get(row.user_id)?.avatar_url ?? null,
best_score: row.best_score,
}));
// Un palier de taille par Citoyen (pas les Archontes) : chacun est
// associé à une taille fixe pour toute la partie, voir corne-view.tsx.
// applyTierOverrides place d'abord les Citoyens fixés à la main
// (TIER_OVERRIDES), puis remplit le reste dans l'ordre alphabétique.
const orderedCitizens: MemberRow[] = (members ?? [])
.filter((member) => member.role === "public")
.map(({ id, pseudo, avatar_url }) => ({ id, pseudo, avatar_url }));
const citizens = applyTierOverrides(orderedCitizens, TIER_OVERRIDES);
const isFrozen = Boolean(settings?.tribunal_date && new Date() >= new Date(settings.tribunal_date));
const ownBestScore = leaderboard.find((entry) => entry.id === user.id)?.best_score ?? null;
return (
<CorneView
citizens={citizens}
initialLeaderboard={leaderboard}
initialOwnBestScore={ownBestScore}
isFrozen={isFrozen}
/>
);
}
+101 -1
View File
@@ -41,10 +41,22 @@
--font-sans: var(--font-manrope); --font-sans: var(--font-manrope);
} }
/* Intensité du dégradé "mer" du fond, en pourcentage — enregistrée via
@property pour qu'un navigateur qui la supporte anime une transition
fluide entre les deux valeurs de ambient-glow (sinon dégradation sans
casse : la valeur du keyframe s'applique quand même, juste sans lissage
intermédiaire). */
@property --sea-glow {
syntax: "<percentage>";
inherits: true;
initial-value: 30%;
}
body { body {
--sea-glow: 30%;
background: background:
radial-gradient(ellipse 80% 50% at 20% -10%, color-mix(in srgb, var(--ink-2) 75%, transparent) 0%, transparent 60%), radial-gradient(ellipse 80% 50% at 20% -10%, color-mix(in srgb, var(--ink-2) 75%, transparent) 0%, transparent 60%),
radial-gradient(ellipse 70% 60% at 100% 110%, color-mix(in srgb, var(--sea) 30%, transparent) 0%, transparent 65%), radial-gradient(ellipse 70% 60% at 100% 110%, color-mix(in srgb, var(--sea) var(--sea-glow), transparent) 0%, transparent 65%),
var(--ink); var(--ink);
background-attachment: fixed; background-attachment: fixed;
color: var(--foreground); color: var(--foreground);
@@ -112,6 +124,42 @@ body {
} }
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
/* Mer de nuit qui respire très lentement, plutôt qu'un fond figé — reste
imperceptible comme un vrai mouvement, juste une ambiance qui vit. */
@keyframes ambient-glow {
from {
--sea-glow: 22%;
}
to {
--sea-glow: 42%;
}
}
body {
animation: ambient-glow 40s ease-in-out infinite alternate;
}
/* Léger halo doré respirant sur les cartes marbre, façon lueur de
torche — un outline (jamais box-shadow/border, déjà utilisés par les
classes Tailwind de chaque carte) pour ne jamais entrer en conflit
avec l'ombre/la bordure propres à chaque usage. N'affecte pas la mise
en page (outline ne prend pas de place). */
@keyframes card-flicker {
0%,
100% {
outline-color: transparent;
}
50% {
outline-color: color-mix(in srgb, var(--gold) 35%, transparent);
}
}
.marble-surface {
outline: 1px solid transparent;
outline-offset: 2px;
animation: card-flicker 7s ease-in-out infinite;
}
@keyframes podium-rise { @keyframes podium-rise {
0% { 0% {
opacity: 0; opacity: 0;
@@ -164,4 +212,56 @@ body {
.animate-reveal { .animate-reveal {
animation: reveal-pop 700ms cubic-bezier(0.2, 0.7, 0.3, 1) both; animation: reveal-pop 700ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
} }
/* Simple fondu, sans mise à l'échelle — contrairement à reveal-pop,
utilisé sur des pièces de La Corne d'Abondance dont la taille exacte
doit être lisible dès la première frame (reveal-pop passe par 82% puis
104% avant de se stabiliser à 100%, ce qui faisait paraître une pièce
tout juste lâchée d'une taille différente de celle annoncée). */
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.animate-fade-in {
animation: fade-in 200ms ease-out both;
}
/* Avertissement de débordement (La Corne d'Abondance) : clignote sur les
pièces qui dépassent la ligne de danger pendant le délai de grâce
avant game over, pour signaler que le compte à rebours est en cours. */
@keyframes danger-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.25;
}
}
.animate-danger-blink {
animation: danger-blink 500ms ease-in-out infinite;
}
/* Confettis de fin de partie (Le Vol d'Icare) : chute + rotation + fondu,
dérive latérale et vitesse propres à chaque confetti via variables CSS. */
@keyframes confetti-fall {
0% {
transform: translate(0, -10%) rotate(0deg);
opacity: 1;
}
100% {
transform: translate(var(--confetti-drift, 0px), 340px) rotate(var(--confetti-spin, 360deg));
opacity: 0;
}
}
.animate-confetti-fall {
animation: confetti-fall 1200ms ease-in both;
}
} }
+209
View File
@@ -0,0 +1,209 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Avatar } from "@/components/avatar";
import { Podium } from "@/components/podium";
import { IconClose, IconTrophy } from "@/components/icons";
import { computeRanksBy } from "@/lib/ranking";
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
import { IcarusGame, type IcarusRunTelemetry } from "./icarus-game";
import type { IcarusScoreEntry } from "./page";
// Anti-triche par ping (complète le signalement d'Alexandre, voir
// supabase/schema.sql section 18) : la durée/nombre d'actions envoyées à la
// fin d'une partie sont déclaratives, un ping régulier horodaté par le
// serveur PENDANT la partie donne une preuve indépendante. Rythme choisi
// pour rester léger (pas de souci de charge à cette fréquence pour ~12
// joueurs) tout en laissant peu de marge à une partie fabriquée sans
// jamais avoir été réellement ouverte.
const PING_INTERVAL_MS = 5000;
export function IcareView({
initialLeaderboard,
initialOwnBestScore,
isFrozen,
}: {
initialLeaderboard: IcarusScoreEntry[];
initialOwnBestScore: number | null;
isFrozen: boolean;
}) {
const [leaderboard, setLeaderboard] = useState(initialLeaderboard);
const [ownBestScore, setOwnBestScore] = useState(initialOwnBestScore);
const [showRanking, setShowRanking] = useState(false);
const sessionTokenRef = useRef<string | null>(null);
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stopPinging = useCallback(() => {
if (pingIntervalRef.current !== null) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
}, []);
// Coupe le ping si le joueur quitte /icare en pleine partie (sinon
// l'intervalle continuerait d'appeler la RPC pour un composant démonté).
useEffect(() => stopPinging, [stopPinging]);
const refetch = useCallback(async () => {
const supabase = createClient();
const { data: scores } = await supabase
.from("icarus_scores")
.select("user_id, best_score")
.order("best_score", { ascending: false });
const userIds = (scores ?? []).map((row) => row.user_id);
const { data: profiles } =
userIds.length > 0
? await supabase.from("profiles").select("id, pseudo, avatar_url").in("id", userIds)
: { data: [] as { id: string; pseudo: string; avatar_url: string | null }[] };
const profileById = new Map((profiles ?? []).map((profile) => [profile.id, profile]));
setLeaderboard(
(scores ?? []).map((row) => ({
id: row.user_id,
pseudo: profileById.get(row.user_id)?.pseudo ?? "Un Citoyen",
avatar_url: profileById.get(row.user_id)?.avatar_url ?? null,
best_score: row.best_score,
})),
);
}, []);
useEffect(() => {
const supabase = createClient();
let channel: ReturnType<typeof supabase.channel> | null = null;
let cancelled = false;
waitForRealtimeAuth(supabase).then(() => {
if (cancelled) return;
channel = supabase
.channel("icarus-scores")
.on("postgres_changes", { event: "*", schema: "public", table: "icarus_scores" }, () => {
refetch();
})
.subscribe();
});
return () => {
cancelled = true;
if (channel) supabase.removeChannel(channel);
};
}, [refetch]);
function handleStart() {
const supabase = createClient();
const token = crypto.randomUUID();
sessionTokenRef.current = token;
stopPinging();
pingIntervalRef.current = setInterval(() => {
// Best-effort : un ping manqué (réseau, onglet en arrière-plan) est
// sans conséquence, submit_icarus_score reste tolérant (marge de 15s
// avant le premier ping attendu, voir schema.sql section 18).
supabase.rpc("ping_game_run", { p_game: "icarus", p_session_token: token }).then(
() => {},
() => {},
);
}, PING_INTERVAL_MS);
}
async function handleFinish(score: number, run: IcarusRunTelemetry) {
stopPinging();
const supabase = createClient();
const runWithToken = { ...run, session_token: sessionTokenRef.current };
const { data, error } = await supabase.rpc("submit_icarus_score", { p_score: score, p_run: runWithToken });
if (!error && data) {
setOwnBestScore(data.best_score);
refetch();
}
}
// Le classement vit dans une modale, complètement hors du flux de mise en
// page du jeu : sans ça, une mise à jour Realtime pendant une partie (le
// score d'un autre joueur qui change) redimensionnait le canvas et cassait
// la partie en cours.
const ranked = useMemo(() => computeRanksBy(leaderboard, (a, b) => b.best_score - a.best_score), [leaderboard]);
// Tant que personne n'a marqué, tout le monde est à égalité à 0 et
// partagerait le rang 1 — pas de podium tant qu'il n'y a rien à
// départager, sinon tout le groupe se retrouverait sur la 1ère marche.
const hasScores = (ranked[0]?.best_score ?? 0) > 0;
const podiumProfiles = hasScores ? ranked.filter((p) => p.rank <= 3) : [];
const rest = hasScores ? ranked.filter((p) => p.rank > 3) : ranked;
return (
<div className="relative flex flex-1 flex-col bg-ink">
<div className="flex items-center justify-between px-4 py-2">
<h1 className="font-heading text-sm tracking-[0.15em] text-gold-bright uppercase">Le Vol d&apos;Icare</h1>
<button
type="button"
onClick={() => setShowRanking(true)}
className="flex items-center gap-1.5 rounded-md border border-gold/30 px-3 py-1.5 text-xs font-medium text-marble/80 uppercase tracking-wide transition hover:bg-gold/10"
>
<IconTrophy className="h-4 w-4" />
Classement
</button>
</div>
<IcarusGame onStart={handleStart} onFinish={handleFinish} />
{showRanking && (
<div className="fixed inset-0 z-30 flex items-center justify-center bg-ink/80 p-4 backdrop-blur-sm">
<div className="marble-surface flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-gold/40 shadow-xl">
<div className="flex items-center justify-between border-b border-gold/20 px-4 py-3">
<h2 className="font-heading text-sm tracking-[0.1em] text-text-marble uppercase">Classement</h2>
<button
type="button"
onClick={() => setShowRanking(false)}
className="rounded-md p-1 text-text-mut transition hover:bg-gold/10 hover:text-text-marble"
aria-label="Fermer"
>
<IconClose className="h-5 w-5" />
</button>
</div>
<div className="overflow-y-auto px-4 py-4">
{isFrozen && (
<p className="mb-4 rounded-lg border border-gold/40 px-4 py-2 text-center text-sm text-text-marble">
Les scores sont figés depuis le début du Tribunal.
</p>
)}
{podiumProfiles.length > 0 && (
<Podium profiles={podiumProfiles} renderValue={(member) => member.best_score} />
)}
{rest.length > 0 && (
<ol className="flex flex-col gap-1.5">
{rest.map((entry) => (
<li
key={entry.id}
className="flex items-center gap-3 rounded-lg border border-gold/25 px-3 py-2"
>
<span className="w-5 shrink-0 text-center font-heading text-sm text-text-mut">
{entry.rank}
</span>
<Avatar pseudo={entry.pseudo} avatarUrl={entry.avatar_url} size="xs" />
<span className="flex-1 truncate text-sm font-medium text-text-marble">{entry.pseudo}</span>
<span className="font-heading text-sm font-semibold text-sea">{entry.best_score}</span>
</li>
))}
</ol>
)}
{ownBestScore !== null && (
<p className="mt-4 text-center font-heading text-sm tracking-wide text-text-marble">
Ton record : <span className="text-sea">{ownBestScore}</span>
</p>
)}
<p className="mt-4 text-center text-xs text-text-mut">
{isFrozen
? "Les gloires du podium ont été attribuées automatiquement au début du Tribunal."
: "Au début du Tribunal, les scores seront figés et le podium recevra des gloires automatiquement — aucun Archonte n'a besoin d'intervenir."}
</p>
</div>
</div>
</div>
)}
</div>
);
}
+718
View File
@@ -0,0 +1,718 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
BOOST_DASH_DISTANCE,
BOOST_DASH_MS,
BOOST_MAX_CHARGES,
BOOST_MAX_COLUMN_INTERVAL,
BOOST_MIN_CHARGES,
BOOST_MIN_COLUMN_INTERVAL,
BOOST_RADIUS,
BOOST_SPEED_MULTIPLIER,
COLUMN_GAP,
COLUMN_GAP_MARGIN,
COLUMN_SPACING,
COLUMN_WIDTH,
DIFFICULTY_RAMP_SPAN,
DIFFICULTY_START_SCORE,
FIXED_DT_MS,
FLAP_IMPULSE,
FORWARD_SPEED,
GAP_MIN_MULTIPLIER,
GROUND_Y,
ICARUS_RADIUS,
ICARUS_X,
MAX_FRAME_DT_MS,
SHATTER_ANIM_MS,
SPEED_MAX_MULTIPLIER,
TILT_MAX_RADIANS,
VIEWPORT_HEIGHT,
VIEWPORT_WIDTH,
WING_FLAP_ANIM_MS,
WING_FLAP_MAX_RADIANS,
WING_IDLE_PEAK_RADIANS,
WING_IDLE_PERIOD_MS,
WING_REST_RADIANS,
} from "@/lib/icarus/constants";
import {
collidesWithBoost,
collidesWithColumn,
createInitialIcarus,
hasHitGround,
stepIcarus,
} from "@/lib/icarus/physics";
import type { Boost, Column, IcarusState, ShatterEffect } from "@/lib/icarus/types";
import { playBoostSound } from "@/lib/icarus/sound";
import { Confetti } from "@/components/confetti";
type Status = "idle" | "flying" | "dead";
// 0 avant DIFFICULTY_START_SCORE, monte jusqu'à 1 sur DIFFICULTY_RAMP_SPAN
// points, puis reste à 1 (palier, pour ne pas devenir injouable).
function difficultyProgress(score: number): number {
if (score <= DIFFICULTY_START_SCORE) return 0;
return Math.min(1, (score - DIFFICULTY_START_SCORE) / DIFFICULTY_RAMP_SPAN);
}
function randomGapCenter(gap: number): number {
const minCenter = gap / 2 + COLUMN_GAP_MARGIN;
const maxCenter = GROUND_Y - gap / 2 - COLUMN_GAP_MARGIN;
return minCenter + Math.random() * Math.max(0, maxCenter - minCenter);
}
// Prochain boost dans BOOST_MIN..MAX_COLUMN_INTERVAL colonnes (tirage
// entier inclusif des deux bornes).
function randomBoostInterval(): number {
return (
BOOST_MIN_COLUMN_INTERVAL +
Math.floor(Math.random() * (BOOST_MAX_COLUMN_INTERVAL - BOOST_MIN_COLUMN_INTERVAL + 1))
);
}
// Distance du boost (en colonnes), tirée à chaque cueillette (tirage
// entier inclusif des deux bornes) : le bouclier dure le temps des N
// prochaines colonnes rencontrées, qu'elles soient cassées (touchées) ou
// simplement franchies sans contact — pas un budget de casses.
function randomBoostDistance(): number {
return BOOST_MIN_CHARGES + Math.floor(Math.random() * (BOOST_MAX_CHARGES - BOOST_MIN_CHARGES + 1));
}
// Une aile, dessinée pointant vers -x ; side=-1 la reflète pour l'aile
// opposée. scale(side,1) inverse déjà le sens de rotation pour le côté
// reflété — passer le même wingAngle (sans le renverser à la main) aux deux
// appels donne un battement symétrique ; les deux ailes sont dessinées avec
// la même couleur et par-dessus le corps pour qu'aucune des deux ne soit
// partiellement cachée (ce qui donnait l'impression qu'une seule bougeait).
function drawWing(ctx: CanvasRenderingContext2D, side: 1 | -1, wingAngle: number) {
ctx.save();
ctx.scale(side, 1);
ctx.rotate(wingAngle);
ctx.fillStyle = "#F4ECD8";
ctx.beginPath();
ctx.moveTo(-1, -2);
ctx.quadraticCurveTo(-ICARUS_RADIUS * 1.6, -ICARUS_RADIUS * 1.9, -ICARUS_RADIUS * 2.3, -ICARUS_RADIUS * 0.8);
ctx.quadraticCurveTo(-ICARUS_RADIUS * 1.3, -ICARUS_RADIUS * 0.15, -ICARUS_RADIUS * 0.3, ICARUS_RADIUS * 0.55);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = "#C9A227";
ctx.lineWidth = 1;
for (let i = 1; i <= 2; i++) {
ctx.beginPath();
ctx.moveTo(-1, -2);
ctx.lineTo(-ICARUS_RADIUS * (0.9 + i * 0.5), -ICARUS_RADIUS * (0.5 + i * 0.35));
ctx.stroke();
}
ctx.restore();
}
function drawIcarus(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
tilt: number,
wingAngle: number,
boosted: boolean,
) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(tilt);
if (boosted) {
ctx.save();
ctx.strokeStyle = "#E7C560";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 0, ICARUS_RADIUS + 5, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
ctx.fillStyle = "#E7C560";
ctx.beginPath();
ctx.ellipse(0, 0, ICARUS_RADIUS * 0.85, ICARUS_RADIUS, 0, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(ICARUS_RADIUS * 0.5, -ICARUS_RADIUS * 0.4, ICARUS_RADIUS * 0.48, 0, Math.PI * 2);
ctx.fill();
drawWing(ctx, -1, wingAngle);
drawWing(ctx, 1, wingAngle);
ctx.restore();
}
function drawColumnShaft(
ctx: CanvasRenderingContext2D,
x: number,
top: number,
height: number,
capAtBottom: boolean,
) {
if (height <= 0) return;
ctx.fillStyle = "#F4ECD8";
ctx.fillRect(x, top, COLUMN_WIDTH, height);
ctx.strokeStyle = "#C9A227";
ctx.lineWidth = 1;
for (let i = 1; i < 4; i++) {
const lineX = x + (COLUMN_WIDTH / 4) * i;
ctx.beginPath();
ctx.moveTo(lineX, top);
ctx.lineTo(lineX, top + height);
ctx.stroke();
}
const capHeight = Math.min(10, height);
const capY = capAtBottom ? top + height - capHeight : top;
ctx.fillStyle = "#E7C560";
ctx.fillRect(x - 4, capY, COLUMN_WIDTH + 8, capHeight);
}
function drawColumn(ctx: CanvasRenderingContext2D, column: Column) {
const gapTop = column.gapCenterY - column.gap / 2;
const gapBottom = column.gapCenterY + column.gap / 2;
drawColumnShaft(ctx, column.x, 0, gapTop, true);
drawColumnShaft(ctx, column.x, gapBottom, GROUND_Y - gapBottom, false);
}
// Éclair de Zeus ramassable : halo pulsant + éclair sombre par-dessus, pour
// rester lisible sur le halo doré.
function drawBoost(ctx: CanvasRenderingContext2D, boost: Boost, now: number) {
const pulse = 0.5 + 0.5 * Math.sin(now / 200);
ctx.save();
ctx.translate(boost.x, boost.y);
const glow = ctx.createRadialGradient(0, 0, 2, 0, 0, BOOST_RADIUS + 6 + pulse * 3);
glow.addColorStop(0, "#E7C560");
glow.addColorStop(1, "rgba(231, 197, 96, 0)");
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(0, 0, BOOST_RADIUS + 6 + pulse * 3, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#C9A227";
ctx.beginPath();
ctx.arc(0, 0, BOOST_RADIUS, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#0A1B33";
ctx.beginPath();
ctx.moveTo(-2, -9);
ctx.lineTo(4, -1);
ctx.lineTo(0, -1);
ctx.lineTo(3, 9);
ctx.lineTo(-5, 0);
ctx.lineTo(-1, 0);
ctx.closePath();
ctx.fill();
ctx.restore();
}
// Fragments qui s'écartent du centre d'une colonne détruite par un boost,
// en s'estompant — purement décoratif, dérivé du temps écoulé (pas de
// state React par effet).
function drawShatterEffect(ctx: CanvasRenderingContext2D, effect: ShatterEffect, now: number) {
const progress = Math.min(1, (now - effect.createdAtMs) / SHATTER_ANIM_MS);
if (progress >= 1) return;
ctx.save();
ctx.translate(effect.x, effect.y);
ctx.globalAlpha = 1 - progress;
ctx.strokeStyle = "#E7C560";
ctx.lineWidth = 2;
const pieces = 8;
for (let i = 0; i < pieces; i++) {
const angle = (i / pieces) * Math.PI * 2;
const dist = progress * 34;
ctx.beginPath();
ctx.moveTo(Math.cos(angle) * dist, Math.sin(angle) * dist);
ctx.lineTo(Math.cos(angle) * (dist + 9), Math.sin(angle) * (dist + 9));
ctx.stroke();
}
ctx.restore();
}
// Filets de vitesse continus tant que le bouclier est actif : Icare est
// figé (pas de mouvement propre), donc sans ça la sensation de vitesse ne
// venait que du défilement du décor — ces traits, qui naissent devant lui
// et filent vers l'arrière en boucle, rendent la vitesse lisible même à
// l'arrêt visuel d'Icare. Purement dérivé de "now" (pas de state), 6
// lignes déphasées pour ne pas paraître synchronisées.
function drawSpeedLines(ctx: CanvasRenderingContext2D, centerX: number, centerY: number, now: number) {
const lineCount = 6;
const cycleMs = 260;
ctx.save();
ctx.strokeStyle = "#E7C560";
ctx.lineCap = "round";
ctx.lineWidth = 2;
for (let i = 0; i < lineCount; i++) {
const seed = i * 137;
const phase = ((now + seed) % cycleMs) / cycleMs;
const yOffset = ((i % 3) - 1) * 14 + (((i * 53) % 20) - 10);
const length = 14 + (i % 3) * 6;
const startX = centerX + 36 - phase * 100;
const alpha = 0.6 * Math.sin(phase * Math.PI);
if (alpha <= 0) continue;
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.moveTo(startX, centerY + yOffset);
ctx.lineTo(startX - length, centerY + yOffset);
ctx.stroke();
}
ctx.restore();
}
export type IcarusRunTelemetry = {
v: 1;
duration_ms: number;
actions: number;
};
export function IcarusGame({
onStart,
onFinish,
}: {
onStart: () => void;
onFinish: (score: number, run: IcarusRunTelemetry) => void;
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const [status, setStatus] = useState<Status>("idle");
const [score, setScore] = useState(0);
const onStartRef = useRef(onStart);
useEffect(() => {
onStartRef.current = onStart;
}, [onStart]);
const onFinishRef = useRef(onFinish);
useEffect(() => {
onFinishRef.current = onFinish;
}, [onFinish]);
const icarusRef = useRef<IcarusState>(createInitialIcarus());
const columnsRef = useRef<Column[]>([]);
const statusRef = useRef<Status>("idle");
const scoreRef = useRef(0);
const flapRequestedRef = useRef(false);
const lastFlapAtRef = useRef<number | null>(null);
const boostsRef = useRef<Boost[]>([]);
const columnsSinceBoostRef = useRef(0);
const nextBoostThresholdRef = useRef(randomBoostInterval());
const boostColumnsRemainingRef = useRef(0);
const boostDashAtRef = useRef<number | null>(null);
const shatterEffectsRef = useRef<ShatterEffect[]>([]);
const nextEntityIdRef = useRef(0);
const runStartedAtRef = useRef<number | null>(null);
const actionCountRef = useRef(0);
const resetGame = useCallback(() => {
icarusRef.current = createInitialIcarus();
columnsRef.current = [];
scoreRef.current = 0;
statusRef.current = "idle";
flapRequestedRef.current = false;
lastFlapAtRef.current = null;
boostsRef.current = [];
columnsSinceBoostRef.current = 0;
nextBoostThresholdRef.current = randomBoostInterval();
boostColumnsRemainingRef.current = 0;
boostDashAtRef.current = null;
shatterEffectsRef.current = [];
runStartedAtRef.current = null;
actionCountRef.current = 0;
setStatus("idle");
setScore(0);
}, []);
const requestFlap = useCallback(() => {
if (statusRef.current === "dead") {
resetGame();
return;
}
if (statusRef.current === "idle") {
runStartedAtRef.current = Date.now();
actionCountRef.current = 1;
statusRef.current = "flying";
setStatus("flying");
onStartRef.current();
} else {
actionCountRef.current += 1;
}
flapRequestedRef.current = true;
}, [resetGame]);
// Sur ordinateur (pas de tap tactile) : la barre d'espace fait la même
// chose qu'un tap. event.repeat ignoré pour ne pas enchaîner des
// battements tant que la touche reste appuyée (même sémantique qu'un tap
// isolé) ; preventDefault pour empêcher le défilement de la page.
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.code !== "Space" || event.repeat) return;
event.preventDefault();
requestFlap();
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [requestFlap]);
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// "Contain" dans le conteneur (largeur ET hauteur, contrairement à avant
// où seule la largeur comptait) : nécessaire en plein écran, où la
// hauteur disponible varie et n'est plus déduite de la largeur.
function resize() {
const dpr = window.devicePixelRatio || 1;
const availableWidth = container!.clientWidth;
const availableHeight = container!.clientHeight;
let cssWidth = availableWidth;
let cssHeight = (cssWidth * VIEWPORT_HEIGHT) / VIEWPORT_WIDTH;
if (cssHeight > availableHeight) {
cssHeight = availableHeight;
cssWidth = (cssHeight * VIEWPORT_WIDTH) / VIEWPORT_HEIGHT;
}
canvas!.width = cssWidth * dpr;
canvas!.height = cssHeight * dpr;
canvas!.style.width = `${cssWidth}px`;
canvas!.style.height = `${cssHeight}px`;
}
resize();
const observer = new ResizeObserver(resize);
observer.observe(container);
let rafId: number;
let lastFrame: number | null = null;
let accumulator = 0;
function finishRun() {
statusRef.current = "dead";
setStatus("dead");
const startedAt = runStartedAtRef.current;
onFinishRef.current(scoreRef.current, {
v: 1,
duration_ms: startedAt === null ? 0 : Math.max(0, Date.now() - startedAt),
actions: actionCountRef.current,
});
}
function frame(now: number) {
if (lastFrame === null) lastFrame = now;
const frameDt = Math.min(now - lastFrame, MAX_FRAME_DT_MS);
lastFrame = now;
if (statusRef.current === "flying") {
accumulator += frameDt;
// Capturé puis effacé une fois pour toutes : évite qu'un même tap ne
// déclenche plusieurs battements si l'accumulateur itère plus d'une
// fois dans la même frame (après un ralentissement ponctuel).
let shouldFlap = flapRequestedRef.current;
flapRequestedRef.current = false;
while (accumulator >= FIXED_DT_MS) {
// Tant que le bouclier est actif, Icare est immobile (pas de
// gravité, les taps n'ont aucun effet) et le décor défile vite à
// sa place — impossible de toucher au mouvement pendant cette
// période.
if (boostColumnsRemainingRef.current > 0) {
shouldFlap = false;
} else {
if (shouldFlap) {
icarusRef.current = { ...icarusRef.current, velocityY: FLAP_IMPULSE };
lastFlapAtRef.current = now;
shouldFlap = false;
}
icarusRef.current = stepIcarus(icarusRef.current, FIXED_DT_MS);
}
// Difficulté progressive : vitesse en hausse et écart resserré au
// fil du score, jusqu'à un palier (voir difficultyProgress).
const progress = difficultyProgress(scoreRef.current);
const baseSpeed = FORWARD_SPEED * (1 + (SPEED_MAX_MULTIPLIER - 1) * progress);
// Défilement accéléré tant que le bouclier du boost est actif
// (sans risque : une colonne touchée casse au lieu de tuer).
const currentSpeed = boostColumnsRemainingRef.current > 0 ? baseSpeed * BOOST_SPEED_MULTIPLIER : baseSpeed;
const currentGap = COLUMN_GAP * (1 - (1 - GAP_MIN_MULTIPLIER) * progress);
for (const column of columnsRef.current) {
column.x -= currentSpeed * (FIXED_DT_MS / 1000);
if (!column.scored && column.x + COLUMN_WIDTH < ICARUS_X - ICARUS_RADIUS) {
column.scored = true;
scoreRef.current += 1;
setScore(scoreRef.current);
// Colonne franchie sans contact pendant le boost : compte
// quand même dans la distance du bouclier (voir plus bas).
if (boostColumnsRemainingRef.current > 0) {
boostColumnsRemainingRef.current -= 1;
}
}
}
columnsRef.current = columnsRef.current.filter((c) => c.x + COLUMN_WIDTH > 0);
const last = columnsRef.current[columnsRef.current.length - 1];
if (!last || last.x < VIEWPORT_WIDTH - COLUMN_SPACING) {
const spawned: Column = {
x: last ? last.x + COLUMN_SPACING : VIEWPORT_WIDTH,
gapCenterY: randomGapCenter(currentGap),
gap: currentGap,
scored: false,
};
columnsRef.current.push(spawned);
// Rare et exceptionnel : intervalle large (voir constants.ts),
// pas de plafond dur — placé au centre du trou de la colonne
// qui le porte, toujours atteignable en volant simplement au
// milieu du passage.
columnsSinceBoostRef.current += 1;
if (columnsSinceBoostRef.current >= nextBoostThresholdRef.current) {
columnsSinceBoostRef.current = 0;
nextBoostThresholdRef.current = randomBoostInterval();
boostsRef.current.push({
id: nextEntityIdRef.current++,
x: spawned.x + COLUMN_WIDTH / 2,
y: spawned.gapCenterY,
});
}
}
for (const boost of boostsRef.current) {
boost.x -= currentSpeed * (FIXED_DT_MS / 1000);
}
boostsRef.current = boostsRef.current.filter((b) => b.x + BOOST_RADIUS > 0);
const collectedIndex = boostsRef.current.findIndex((b) => collidesWithBoost(icarusRef.current.y, b));
if (collectedIndex !== -1) {
boostsRef.current.splice(collectedIndex, 1);
playBoostSound();
// Active le bouclier pour les N prochaines colonnes (distance
// aléatoire, voir plus bas) et déclenche l'animation de bond en
// avant. Icare se fige (vitesse verticale annulée, plus de
// gravité ni de prise en compte des taps tant que dure le
// bouclier, voir plus haut).
boostColumnsRemainingRef.current = randomBoostDistance();
boostDashAtRef.current = now;
icarusRef.current = { ...icarusRef.current, velocityY: 0 };
}
// Colonnes réellement touchées (mortelles en temps normal). Tant
// que la distance du bouclier n'est pas épuisée, une colonne
// touchée se casse au lieu de tuer et compte dans cette distance
// (comme une colonne franchie sans contact, voir plus haut) ;
// sinon mort normale.
const touchedColumns = columnsRef.current.filter((c) => collidesWithColumn(icarusRef.current.y, c));
if (touchedColumns.length > 0) {
if (boostColumnsRemainingRef.current > 0) {
const broken = touchedColumns.slice(0, boostColumnsRemainingRef.current);
boostColumnsRemainingRef.current -= broken.length;
for (const column of broken) {
if (!column.scored) {
column.scored = true;
scoreRef.current += 1;
}
shatterEffectsRef.current.push({
id: nextEntityIdRef.current++,
x: column.x + COLUMN_WIDTH / 2,
y: column.gapCenterY,
createdAtMs: now,
});
}
columnsRef.current = columnsRef.current.filter((c) => !broken.includes(c));
setScore(scoreRef.current);
} else {
finishRun();
break;
}
}
if (boostColumnsRemainingRef.current === 0 && hasHitGround(icarusRef.current)) {
finishRun();
break;
}
accumulator -= FIXED_DT_MS;
}
}
shatterEffectsRef.current = shatterEffectsRef.current.filter((e) => now - e.createdAtMs < SHATTER_ANIM_MS);
// Bond en avant à la cueillette : aller-retour (pic à mi-parcours),
// pas un déplacement qui persiste au-delà de l'animation.
const dashElapsed = boostDashAtRef.current === null ? Infinity : now - boostDashAtRef.current;
const dashProgress = Math.min(1, dashElapsed / BOOST_DASH_MS);
const dashOffset = dashProgress >= 1 ? 0 : Math.sin(dashProgress * Math.PI) * BOOST_DASH_DISTANCE;
// Battement continu en boucle (se voit même sans taper, dès l'écran de
// repos) : oscille doucement entre le repos et un pic modéré.
const idlePhase = (now % WING_IDLE_PERIOD_MS) / WING_IDLE_PERIOD_MS;
const idleWave = 0.5 - 0.5 * Math.cos(idlePhase * 2 * Math.PI);
const idleWingAngle = WING_REST_RADIANS + (WING_IDLE_PEAK_RADIANS - WING_REST_RADIANS) * idleWave;
// Battement plus ample déclenché par un tap, qui prend le dessus sur
// le cycle continu le temps de sa décrue.
const sinceFlapMs = now - (lastFlapAtRef.current ?? -Infinity);
const flapProgress = Math.max(0, 1 - sinceFlapMs / WING_FLAP_ANIM_MS);
const eased = flapProgress * flapProgress;
const tapWingAngle = WING_REST_RADIANS + (WING_FLAP_MAX_RADIANS - WING_REST_RADIANS) * eased;
const wingAngle = statusRef.current === "dead" ? WING_REST_RADIANS : Math.max(idleWingAngle, tapWingAngle);
render(
ctx!,
canvas!,
icarusRef.current,
columnsRef.current,
boostsRef.current,
shatterEffectsRef.current,
dashOffset,
boostColumnsRemainingRef.current > 0,
statusRef.current,
scoreRef.current,
wingAngle,
now,
);
rafId = requestAnimationFrame(frame);
}
rafId = requestAnimationFrame(frame);
return () => {
observer.disconnect();
cancelAnimationFrame(rafId);
};
}, []);
const prefersReducedMotion =
typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
return (
<div
ref={containerRef}
onPointerDown={requestFlap}
className="relative flex flex-1 touch-none items-center justify-center overflow-hidden bg-ink select-none"
>
<canvas ref={canvasRef} className="block" />
{status === "dead" && !prefersReducedMotion && <Confetti />}
{status === "idle" && (
<div className="pointer-events-none absolute inset-x-0 bottom-10 flex justify-center px-6">
<p className="marble-surface animate-reveal rounded-lg border border-gold/40 px-4 py-2 text-center text-sm text-text-marble shadow-lg">
Touche l&apos;écran pour battre des ailes et t&apos;envoler entre les colonnes.
</p>
</div>
)}
{status === "dead" && (
<div className="absolute inset-0 flex items-center justify-center bg-ink/50">
<div className="marble-surface animate-reveal flex flex-col items-center gap-2 rounded-xl border border-gold/40 px-6 py-4 text-center shadow-lg">
<p className="text-xs tracking-wide text-text-mut uppercase">Score</p>
<p className="font-heading text-3xl text-gold-bright">{score}</p>
<button
type="button"
onPointerDown={(event) => event.stopPropagation()}
onClick={resetGame}
className="mt-1 rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink"
>
Rejouer
</button>
</div>
</div>
)}
</div>
);
}
function render(
ctx: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
icarus: IcarusState,
columns: Column[],
boosts: Boost[],
shatterEffects: ShatterEffect[],
dashOffset: number,
boosted: boolean,
status: Status,
score: number,
wingAngle: number,
now: number,
) {
const dpr = window.devicePixelRatio || 1;
const cssWidth = canvas.width / dpr;
const worldScale = cssWidth / VIEWPORT_WIDTH;
ctx.save();
ctx.scale(dpr, dpr);
ctx.save();
ctx.scale(worldScale, worldScale);
ctx.clearRect(0, 0, VIEWPORT_WIDTH, VIEWPORT_HEIGHT);
ctx.fillStyle = "#0A1B33";
ctx.fillRect(0, 0, VIEWPORT_WIDTH, VIEWPORT_HEIGHT);
for (const column of columns) {
drawColumn(ctx, column);
}
for (const boost of boosts) {
drawBoost(ctx, boost, now);
}
for (const effect of shatterEffects) {
drawShatterEffect(ctx, effect, now);
}
ctx.fillStyle = "#2A2116";
ctx.fillRect(0, GROUND_Y, VIEWPORT_WIDTH, VIEWPORT_HEIGHT - GROUND_Y);
const icarusX = ICARUS_X + dashOffset;
if (boosted) {
drawSpeedLines(ctx, icarusX, icarus.y, now);
}
// Traînée de vitesse derrière Icare pendant le bond en avant.
if (dashOffset > 1) {
ctx.save();
ctx.translate(icarusX, icarus.y);
for (let i = 1; i <= 3; i++) {
ctx.globalAlpha = (dashOffset / BOOST_DASH_DISTANCE) * (0.3 / i);
ctx.fillStyle = "#E7C560";
ctx.beginPath();
ctx.ellipse(-i * 10, 0, ICARUS_RADIUS * 0.7, ICARUS_RADIUS * 0.5, 0, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
const tilt = Math.max(-1, Math.min(1, icarus.velocityY / 300)) * TILT_MAX_RADIANS;
drawIcarus(ctx, icarusX, icarus.y, tilt, wingAngle, boosted);
ctx.restore();
if (status === "flying") {
ctx.save();
ctx.fillStyle = "#F4ECD8";
ctx.font = "700 28px Cinzel, serif";
ctx.textAlign = "center";
ctx.fillText(String(score), cssWidth / 2, 44);
ctx.restore();
}
ctx.restore();
}
+53
View File
@@ -0,0 +1,53 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { IcareView } from "./icare-view";
export type IcarusScoreEntry = {
id: string;
pseudo: string;
avatar_url: string | null;
best_score: number;
};
export default async function IcarePage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
const { data: scores } = await supabase
.from("icarus_scores")
.select("user_id, best_score")
.order("best_score", { ascending: false });
const userIds = (scores ?? []).map((row) => row.user_id);
const { data: scoreProfiles } =
userIds.length > 0
? await supabase.from("profiles").select("id, pseudo, avatar_url").in("id", userIds)
: { data: [] as { id: string; pseudo: string; avatar_url: string | null }[] };
const profileById = new Map((scoreProfiles ?? []).map((profile) => [profile.id, profile]));
const leaderboard: IcarusScoreEntry[] = (scores ?? []).map((row) => ({
id: row.user_id,
pseudo: profileById.get(row.user_id)?.pseudo ?? "Un Citoyen",
avatar_url: profileById.get(row.user_id)?.avatar_url ?? null,
best_score: row.best_score,
}));
const { data: settings } = await supabase
.from("settings")
.select("tribunal_date")
.eq("id", true)
.single();
const isFrozen = Boolean(settings?.tribunal_date && new Date() >= new Date(settings.tribunal_date));
const ownBestScore = leaderboard.find((entry) => entry.id === user.id)?.best_score ?? null;
return (
<IcareView initialLeaderboard={leaderboard} initialOwnBestScore={ownBestScore} isFrozen={isFrozen} />
);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

-18
View File
@@ -1,18 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="#C9A227">
<rect x="0" y="0" width="100" height="100" rx="20" fill="#0A1B33" />
<ellipse cx="44.18" cy="85.39" rx="7.00" ry="2.80" transform="rotate(-168.00 44.18 85.39)"/>
<ellipse cx="31.70" cy="81.14" rx="6.71" ry="2.68" transform="rotate(-141.67 31.70 81.14)"/>
<ellipse cx="21.98" cy="71.26" rx="6.42" ry="2.57" transform="rotate(-115.33 21.98 71.26)"/>
<ellipse cx="17.50" cy="57.43" rx="6.13" ry="2.45" transform="rotate(-89.00 17.50 57.43)"/>
<ellipse cx="19.80" cy="42.39" rx="5.83" ry="2.33" transform="rotate(-62.67 19.80 42.39)"/>
<ellipse cx="28.97" cy="29.40" rx="5.54" ry="2.22" transform="rotate(-36.33 28.97 29.40)"/>
<ellipse cx="43.58" cy="21.56" rx="5.25" ry="2.10" transform="rotate(-10.00 43.58 21.56)"/>
<ellipse cx="55.82" cy="85.39" rx="7.00" ry="2.80" transform="rotate(-168.00 55.82 85.39)"/>
<ellipse cx="68.30" cy="81.14" rx="6.71" ry="2.68" transform="rotate(-141.67 68.30 81.14)"/>
<ellipse cx="78.02" cy="71.26" rx="6.42" ry="2.57" transform="rotate(-115.33 78.02 71.26)"/>
<ellipse cx="82.50" cy="57.43" rx="6.13" ry="2.45" transform="rotate(-89.00 82.50 57.43)"/>
<ellipse cx="80.20" cy="42.39" rx="5.83" ry="2.33" transform="rotate(-62.67 80.20 42.39)"/>
<ellipse cx="71.03" cy="29.40" rx="5.54" ry="2.22" transform="rotate(-36.33 71.03 29.40)"/>
<ellipse cx="56.42" cy="21.56" rx="5.25" ry="2.10" transform="rotate(-10.00 56.42 21.56)"/>
<circle cx="50" cy="85" r="2.4"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

+30 -17
View File
@@ -1,12 +1,13 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client"; import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
import { EmptyState } from "@/components/empty-state";
type Entry = { type Entry = {
id: number; id: number;
target_id: string; target_id: string;
judge_id: string; judge_id: string | null;
delta: number; delta: number;
reason: string | null; reason: string | null;
created_at: string; created_at: string;
@@ -31,31 +32,39 @@ export function JournalView({
useEffect(() => { useEffect(() => {
const supabase = createClient(); const supabase = createClient();
const channel = supabase let channel: ReturnType<typeof supabase.channel> | null = null;
.channel("journal-points-log") let cancelled = false;
.on(
"postgres_changes", waitForRealtimeAuth(supabase).then(() => {
{ event: "INSERT", schema: "public", table: "points_log" }, if (cancelled) return;
(payload) => { channel = supabase
const row = payload.new as Entry; .channel("journal-points-log")
setEntries((current) => [row, ...current]); .on(
}, "postgres_changes",
) { event: "INSERT", schema: "public", table: "points_log" },
.subscribe(); (payload) => {
const row = payload.new as Entry;
setEntries((current) => [row, ...current]);
},
)
.subscribe();
});
return () => { return () => {
supabase.removeChannel(channel); cancelled = true;
if (channel) supabase.removeChannel(channel);
}; };
}, []); }, []);
if (entries.length === 0) { if (entries.length === 0) {
return <p className="text-sm text-marble/60">Aucun décret pour l&apos;instant.</p>; return <EmptyState message="Aucun décret pour l'instant." />;
} }
return ( return (
<ol className="flex flex-col gap-2"> <ol className="flex flex-col gap-2">
{entries.map((entry) => { {entries.map((entry) => {
const judgePseudo = pseudoById[entry.judge_id] ?? "Un Archonte"; const judgePseudo =
entry.judge_id === null ? "Le Tribunal" : (pseudoById[entry.judge_id] ?? "Un Archonte");
const targetPseudo = pseudoById[entry.target_id] ?? "un Citoyen"; const targetPseudo = pseudoById[entry.target_id] ?? "un Citoyen";
const sign = entry.delta > 0 ? `+${entry.delta}` : `${entry.delta}`; const sign = entry.delta > 0 ? `+${entry.delta}` : `${entry.delta}`;
return ( return (
@@ -64,7 +73,11 @@ export function JournalView({
className="marble-surface flex gap-3 rounded-lg border border-gold/25 px-4 py-3 text-sm shadow-sm" className="marble-surface flex gap-3 rounded-lg border border-gold/25 px-4 py-3 text-sm shadow-sm"
> >
<span <span
className="mt-1 h-3 w-3 shrink-0 rounded-full bg-oxblood shadow-[0_0_0_2px_rgba(165,52,42,0.25)]" className={`mt-1 h-3 w-3 shrink-0 rounded-full ${
entry.delta >= 0
? "bg-olive shadow-[0_0_0_2px_rgba(94,107,59,0.25)]"
: "bg-oxblood shadow-[0_0_0_2px_rgba(165,52,42,0.25)]"
}`}
aria-hidden aria-hidden
/> />
<div> <div>
+57
View File
@@ -0,0 +1,57 @@
import Link from "next/link";
import type { ComponentType } from "react";
import { Avatar } from "@/components/avatar";
import { IconHorn, IconWings } from "@/components/icons";
export type GameLeader = { pseudo: string; avatarUrl: string | null; score: number } | null;
function LeaderCard({
href,
label,
icon: Icon,
leader,
}: {
href: string;
label: string;
icon: ComponentType<{ className?: string }>;
leader: GameLeader;
}) {
return (
<Link
href={href}
className="marble-surface flex items-center gap-3 rounded-xl border border-gold/25 p-3 shadow-sm transition hover:border-gold/50 hover:shadow-md"
>
<Icon className="h-5 w-5 shrink-0 text-gold" />
<div className="min-w-0 flex-1">
<p className="text-[0.65rem] font-medium tracking-wide text-text-mut uppercase">{label}</p>
{leader ? (
<div className="mt-1 flex items-center gap-2">
<Avatar pseudo={leader.pseudo} avatarUrl={leader.avatarUrl} size="xs" />
<span className="truncate text-sm font-medium text-text-marble">{leader.pseudo}</span>
</div>
) : (
<p className="mt-1 truncate text-sm text-text-mut">Personne n&apos;a encore joué</p>
)}
</div>
{leader && <span className="shrink-0 font-heading text-sm font-bold text-sea">{leader.score}</span>}
</Link>
);
}
// Comme les podiums (voir leaderboard/icare/corne) : un meneur à 0 n'est pas
// un vrai champion, juste un score par défaut — rien à afficher tant que
// personne n'a vraiment marqué (leader passé à null par la page).
export function GameHighlights({
icarusLeader,
melonLeader,
}: {
icarusLeader: GameLeader;
melonLeader: GameLeader;
}) {
return (
<div className="mb-6 grid grid-cols-1 gap-3 sm:grid-cols-2">
<LeaderCard href="/icare" label="Le Vol d'Icare" icon={IconWings} leader={icarusLeader} />
<LeaderCard href="/corne" label="La Corne d'Abondance" icon={IconHorn} leader={melonLeader} />
</div>
);
}
+47 -28
View File
@@ -1,10 +1,11 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { createClient } from "@/lib/supabase/client"; import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
import { Avatar } from "@/components/avatar"; import { Avatar } from "@/components/avatar";
import { Podium } from "@/components/podium"; import { Podium } from "@/components/podium";
import { JudgePointControls } from "@/components/judge-point-controls"; import { JudgePointControls } from "@/components/judge-point-controls";
import { EmptyState } from "@/components/empty-state";
import { computeRanks, computeProgress } from "@/lib/ranking"; import { computeRanks, computeProgress } from "@/lib/ranking";
type Profile = { type Profile = {
@@ -47,52 +48,70 @@ export function LeaderboardView({
useEffect(() => { useEffect(() => {
const supabase = createClient(); const supabase = createClient();
const channel = supabase let channel: ReturnType<typeof supabase.channel> | null = null;
.channel("leaderboard-profiles") let cancelled = false;
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "profiles" },
(payload) => {
if (payload.eventType === "DELETE") return;
const row = payload.new as Profile;
setProfiles((current) => { waitForRealtimeAuth(supabase).then(() => {
const exists = current.some((p) => p.id === row.id); if (cancelled) return;
return exists channel = supabase
? current.map((p) => (p.id === row.id ? { ...p, ...row } : p)) .channel("leaderboard-profiles")
: [...current, row]; .on(
}); "postgres_changes",
{ event: "*", schema: "public", table: "profiles" },
(payload) => {
if (payload.eventType === "DELETE") return;
const row = payload.new as Profile;
const previousPoints = pointsRef.current.get(row.id); setProfiles((current) => {
if (previousPoints !== undefined && previousPoints !== row.points) { const exists = current.some((p) => p.id === row.id);
setFlashingId(row.id); return exists
setTimeout(() => setFlashingId((current) => (current === row.id ? null : current)), 900); ? current.map((p) => (p.id === row.id ? { ...p, ...row } : p))
} : [...current, row];
pointsRef.current.set(row.id, row.points); });
},
) const previousPoints = pointsRef.current.get(row.id);
.subscribe(); if (previousPoints !== undefined && previousPoints !== row.points) {
setFlashingId(row.id);
setTimeout(() => setFlashingId((current) => (current === row.id ? null : current)), 900);
}
pointsRef.current.set(row.id, row.points);
},
)
.subscribe();
});
return () => { return () => {
supabase.removeChannel(channel); cancelled = true;
if (channel) supabase.removeChannel(channel);
}; };
}, []); }, []);
const ranked = useMemo(() => computeRanks(profiles), [profiles]); const ranked = useMemo(() => computeRanks(profiles), [profiles]);
const rest = ranked.filter((p) => p.rank > 3); // Tant que personne n'a de gloires, tout le monde est à égalité à 0 et
// partagerait le rang 1 — pas de podium tant qu'il n'y a rien à
// départager, sinon tout le groupe se retrouverait sur la 1ère marche.
const hasScores = (ranked[0]?.points ?? 0) > 0;
const podiumProfiles = hasScores ? ranked.filter((p) => p.rank <= 3) : [];
const rest = hasScores ? ranked.filter((p) => p.rank > 3) : ranked;
if (profiles.length === 0) { if (profiles.length === 0) {
return <p className="text-sm text-marble/60">Personne n&apos;est encore inscrit.</p>; return <EmptyState message="Personne n'est encore inscrit." />;
} }
return ( return (
<div> <div>
<Podium <Podium
profiles={ranked.filter((p) => p.rank <= 3)} profiles={podiumProfiles}
renderValue={(member) => member.points}
valueLabel="gloires"
isJudge={isJudge} isJudge={isJudge}
onApplyDelta={applyOptimisticDelta} onApplyDelta={applyOptimisticDelta}
/> />
{/* Frise à méandre : marque la transition podium -> reste du
classement, seulement quand il y a bien un podium au-dessus. */}
{podiumProfiles.length > 0 && <div className="meander-divider mb-4" />}
<ol className="flex flex-col gap-2"> <ol className="flex flex-col gap-2">
{rest.map((profile) => { {rest.map((profile) => {
const progress = computeProgress(profile.rank, profile.previous_rank); const progress = computeProgress(profile.rank, profile.previous_rank);
+5
View File
@@ -0,0 +1,5 @@
import { PageLoading } from "@/components/page-loading";
export default function Loading() {
return <PageLoading />;
}
+43 -9
View File
@@ -1,6 +1,10 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import { LeaderboardView } from "./leaderboard-view"; import { LeaderboardView } from "./leaderboard-view";
import { QuickLinks } from "./quick-links";
import { GameHighlights, type GameLeader } from "./game-highlights";
import { AgoraLogo } from "@/components/agora-logo";
import { formatCountdown, formatTribunalDateTime } from "@/lib/tribunal-date";
export default async function LeaderboardPage() { export default async function LeaderboardPage() {
const supabase = await createClient(); const supabase = await createClient();
@@ -12,22 +16,52 @@ export default async function LeaderboardPage() {
redirect("/login"); redirect("/login");
} }
const { data: profiles, error } = await supabase const [{ data: profiles, error }, { data: settings }, { data: wallNotesToday }, { data: icarusTop }, { data: melonTop }] =
.from("profiles") await Promise.all([
.select("id, pseudo, avatar_url, points, previous_rank, role") supabase
.order("points", { ascending: false }); .from("profiles")
.select("id, pseudo, avatar_url, points, previous_rank, role")
.order("points", { ascending: false }),
supabase.from("settings").select("tribunal_date").eq("id", true).single(),
// wall_notes_for_date() (jamais une lecture directe de wall_notes, dont
// la RLS ne laisserait voir que ses propres notes) : seul moyen de
// connaître le nombre total de publications du jour sans exposer qui a
// publié quoi — sert juste de compteur pour la pastille du raccourci.
supabase.rpc("wall_notes_for_date"),
supabase.from("icarus_scores").select("user_id, best_score").order("best_score", { ascending: false }).limit(1),
supabase.from("melon_scores").select("user_id, best_score").order("best_score", { ascending: false }).limit(1),
]);
const isJudge = profiles?.find((p) => p.id === user.id)?.role === "judge"; const isJudge = profiles?.find((p) => p.id === user.id)?.role === "judge";
const tribunalDate = settings?.tribunal_date ?? null;
const profileById = new Map((profiles ?? []).map((p) => [p.id, p]));
// Comme les podiums d'Icare/Corne : un meneur à 0 (personne n'a encore
// joué) n'est pas un champion, pas de couronne pour un score par défaut.
function buildLeader(rows: { user_id: string; best_score: number }[] | null): GameLeader {
const top = rows?.[0];
if (!top || top.best_score <= 0) return null;
const profile = profileById.get(top.user_id);
return { pseudo: profile?.pseudo ?? "Un Citoyen", avatarUrl: profile?.avatar_url ?? null, score: top.best_score };
}
const icarusLeader = buildLeader(icarusTop);
const melonLeader = buildLeader(melonTop);
return ( return (
<div className="mx-auto w-full max-w-2xl px-4 py-8"> <div className="mx-auto w-full max-w-2xl px-4 py-8">
<div className="mb-6 text-center"> <div className="mb-6 flex flex-col items-center text-center">
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase"> <AgoraLogo className="h-32 w-32 sm:h-40 sm:w-40" />
L&apos;Agora <p className="mt-2 font-serif text-sm text-marble/60 italic">Le classement des Citoyens</p>
</h1> <p className="mt-3 font-heading text-sm tracking-wide text-gold-bright uppercase">
<p className="font-serif text-sm text-marble/60 italic">Le classement des Citoyens</p> {formatCountdown(tribunalDate)}
</p>
{tribunalDate && <p className="text-xs text-marble/50">{formatTribunalDateTime(tribunalDate)}</p>}
</div> </div>
<QuickLinks wallNoteCount={wallNotesToday?.length ?? 0} />
<GameHighlights icarusLeader={icarusLeader} melonLeader={melonLeader} />
{error && ( {error && (
<p role="alert" className="text-sm text-oxblood"> <p role="alert" className="text-sm text-oxblood">
Impossible de charger le classement pour le moment. Impossible de charger le classement pour le moment.
+47
View File
@@ -0,0 +1,47 @@
import Link from "next/link";
import { IconHorn, IconPin, IconScroll, IconSword, IconVote, IconWings } from "@/components/icons";
// L'Agora (/leaderboard) sert de page d'accueil de fait (redirection depuis
// "/" une fois connecté) : ces raccourcis évitent de devoir passer par le
// menu déroulant du chip pour rejoindre les autres pages "d'activité".
// Volontairement sans /profile ni /admin (déjà dans le menu, pas des pages
// d'activité au même titre que les jeux/le Mur/l'Urne/le Crieur).
const LINKS = [
{ href: "/icare", label: "Le Vol d'Icare", icon: IconWings },
{ href: "/corne", label: "La Corne d'Abondance", icon: IconHorn },
{ href: "/char", label: "Le Jeu de l'Agora", icon: IconSword },
{ href: "/mur", label: "Le Mur de la Honte", icon: IconPin },
{ href: "/urne", label: "L'Urne de l'Agora", icon: IconVote },
{ href: "/journal", label: "Le Crieur", icon: IconScroll },
] as const;
export function QuickLinks({ wallNoteCount }: { wallNoteCount: number }) {
return (
<nav aria-label="Raccourcis" className="mb-6 grid grid-cols-3 gap-2 sm:grid-cols-6 sm:gap-3">
{LINKS.map(({ href, label, icon: Icon }) => {
// Pastille "façon iPhone" (nombre de publications du jour) : pour
// l'instant seul le Mur en a une, les autres raccourcis n'ont pas
// d'équivalent "à consulter aujourd'hui" aussi direct.
const badge = href === "/mur" ? wallNoteCount : 0;
return (
<Link
key={href}
href={href}
className="marble-surface relative flex flex-col items-center gap-1.5 rounded-xl border border-gold/25 px-2 py-3 text-center shadow-sm transition hover:border-gold/50 hover:shadow-md"
>
{badge > 0 && (
<span
className="absolute -top-1.5 -right-1.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-oxblood px-1 text-[0.65rem] font-bold text-marble shadow"
aria-label={`${badge} publication${badge > 1 ? "s" : ""} aujourd'hui sur le Mur de la Honte`}
>
{badge}
</span>
)}
<Icon className="h-6 w-6 text-gold" />
<span className="text-[0.65rem] leading-tight font-medium text-text-marble sm:text-xs">{label}</span>
</Link>
);
})}
</nav>
);
}
+3 -7
View File
@@ -1,9 +1,8 @@
"use client"; "use client";
import Link from "next/link";
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
import { LaurelWreath } from "@/components/laurel-wreath"; import { AgoraLogo } from "@/components/agora-logo";
function mapSignInError(message: string): string { function mapSignInError(message: string): string {
if (message.toLowerCase().includes("email not confirmed")) { if (message.toLowerCase().includes("email not confirmed")) {
@@ -43,7 +42,7 @@ export default function LoginPage() {
<div className="flex flex-1 items-center justify-center px-4 py-12"> <div className="flex flex-1 items-center justify-center px-4 py-12">
<div className="marble-surface w-full max-w-sm rounded-2xl border border-gold/40 p-6 shadow-xl sm:p-8"> <div className="marble-surface w-full max-w-sm rounded-2xl border border-gold/40 p-6 shadow-xl sm:p-8">
<div className="mb-4 flex flex-col items-center gap-2"> <div className="mb-4 flex flex-col items-center gap-2">
<LaurelWreath className="h-10 w-10" color="#A5342A" /> <AgoraLogo className="h-24 w-24" />
<h1 className="font-heading text-xl tracking-[0.1em] text-text-marble uppercase"> <h1 className="font-heading text-xl tracking-[0.1em] text-text-marble uppercase">
Connexion Connexion
</h1> </h1>
@@ -99,10 +98,7 @@ export default function LoginPage() {
</form> </form>
<p className="mt-6 text-center text-sm text-text-mut"> <p className="mt-6 text-center text-sm text-text-mut">
Pas encore de compte ?{" "} L&apos;inscription se fait uniquement sur invitation d&apos;un Archonte.
<Link href="/signup" className="font-medium text-oxblood underline hover:text-gold">
Créer un compte
</Link>
</p> </p>
</div> </div>
</div> </div>
+5
View File
@@ -0,0 +1,5 @@
import { PageLoading } from "@/components/page-loading";
export default function Loading() {
return <PageLoading />;
}
+619
View File
@@ -0,0 +1,619 @@
"use client";
import { useCallback, useEffect, useState, type ChangeEvent, type FormEvent } from "react";
import Image from "next/image";
import { Avatar } from "@/components/avatar";
import { IconCamera, IconClose } from "@/components/icons";
import { EmptyState } from "@/components/empty-state";
import { resizeImageToBlob } from "@/lib/image";
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
import type { MyReactionRow, ReactionCountRow, WallHistoryRow, WallNoteRow } from "./page";
const MAX_LENGTH = 200;
// Les Archontes peuvent désormais publier aussi (plus réservé aux
// Citoyens) : 12 places pour couvrir tout le groupe, pas seulement les
// Citoyens — doit rester aligné avec la borne côté serveur dans
// post_wall_note (supabase/schema.sql).
const MAX_NOTES_PER_DAY = 12;
const MAX_IMAGE_SOURCE_BYTES = 20 * 1024 * 1024;
const REACTION_EMOJIS = ["👍", "😂", "😱", "❤️", "🔥"];
// Rotation déterministe par note (jamais Math.random() au rendu, cf. la
// même contrainte déjà rencontrée cette session avec les hooks React) —
// donne l'effet "punaisé en vrac" sans risquer un hydration mismatch.
function rotationFor(id: string): number {
let hash = 0;
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) | 0;
return (Math.abs(hash) % 7) - 3;
}
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString("fr-FR", {
dateStyle: "short",
timeStyle: "short",
timeZone: "Europe/Paris",
});
}
// Clé de date locale (Europe/Paris) au format sv-SE (YYYY-MM-DD, triable),
// même pattern que calendrier/day-card.tsx.
function localDateKey(iso: string): string {
return new Date(iso).toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
}
function formatDateLabel(dateKey: string): string {
const label = new Date(`${dateKey}T12:00:00`).toLocaleDateString("fr-FR", {
weekday: "long",
day: "numeric",
month: "long",
timeZone: "Europe/Paris",
});
return label.charAt(0).toUpperCase() + label.slice(1);
}
function buildReactionCounts(rows: ReactionCountRow[]): Record<string, Record<string, number>> {
const map: Record<string, Record<string, number>> = {};
for (const row of rows) {
if (!map[row.note_id]) map[row.note_id] = {};
map[row.note_id][row.emoji] = row.count;
}
return map;
}
function buildMyReactions(rows: MyReactionRow[]): Record<string, Set<string>> {
const map: Record<string, Set<string>> = {};
for (const row of rows) {
if (!map[row.note_id]) map[row.note_id] = new Set();
map[row.note_id].add(row.emoji);
}
return map;
}
function ReactionBar({
counts,
mine,
onToggle,
}: {
counts: Record<string, number>;
mine: Set<string>;
onToggle: (emoji: string) => void;
}) {
return (
<div className="flex flex-wrap gap-1">
{REACTION_EMOJIS.map((emoji) => {
const count = counts[emoji] ?? 0;
const active = mine.has(emoji);
return (
<button
key={emoji}
type="button"
onClick={() => onToggle(emoji)}
className={`flex items-center gap-0.5 rounded-full border px-1.5 py-0.5 text-xs transition ${
active ? "border-gold bg-gold/20" : "border-gold/20 hover:bg-gold/10"
}`}
>
<span>{emoji}</span>
{count > 0 && <span className="text-[0.6rem] text-text-mut">{count}</span>}
</button>
);
})}
</div>
);
}
function NoteCard({
note,
reactionCounts,
myReactions,
onToggleReaction,
onImageClick,
}: {
note: WallNoteRow;
reactionCounts: Record<string, number>;
myReactions: Set<string>;
onToggleReaction: (emoji: string) => void;
onImageClick: (url: string) => void;
}) {
const imageUrl = note.image_url;
return (
<div
className="marble-surface flex min-h-[7rem] flex-col justify-between gap-2 rounded-lg border border-gold/30 p-3 shadow-md"
style={{ transform: `rotate(${rotationFor(note.id)}deg)` }}
>
<div className="flex flex-col gap-2">
{imageUrl && (
<button
type="button"
onClick={() => onImageClick(imageUrl)}
className="cursor-zoom-in"
aria-label="Agrandir l'image"
>
<Image src={imageUrl} alt="" width={300} height={200} className="max-h-32 w-full rounded-md object-cover" />
</button>
)}
<p className="text-sm text-text-marble">{note.text}</p>
</div>
<div className="flex items-center justify-between gap-2">
<ReactionBar counts={reactionCounts} mine={myReactions} onToggle={onToggleReaction} />
<span className="h-2 w-2 shrink-0 rounded-full bg-oxblood" aria-hidden />
</div>
</div>
);
}
export function MurView({
isJudge,
currentUserId,
initialNotes,
initialMyNoteToday,
initialHistory,
initialReactionCounts,
initialMyReactions,
availableDays,
}: {
isJudge: boolean;
currentUserId: string;
initialNotes: WallNoteRow[];
initialMyNoteToday: WallNoteRow | null;
initialHistory: WallHistoryRow[];
initialReactionCounts: ReactionCountRow[];
initialMyReactions: MyReactionRow[];
availableDays: string[];
}) {
const [notes, setNotes] = useState(initialNotes);
const [myNoteToday, setMyNoteToday] = useState(initialMyNoteToday);
const [history, setHistory] = useState(initialHistory);
const [reactionCounts, setReactionCounts] = useState(() => buildReactionCounts(initialReactionCounts));
const [myReactions, setMyReactions] = useState(() => buildMyReactions(initialMyReactions));
const [text, setText] = useState("");
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [imageError, setImageError] = useState<string | null>(null);
const [posting, setPosting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [historyDateFilter, setHistoryDateFilter] = useState("all");
const [zoomedImage, setZoomedImage] = useState<string | null>(null);
// Jour affiché dans la grille du haut (ouverte à tout le monde,
// contrairement à l'historique complet réservé aux Archontes plus bas) :
// null = aujourd'hui, réactif au Realtime comme avant. Un jour passé se
// charge à la demande (pas de Realtime dessus, un jour passé n'évolue
// plus) dans un état séparé pour ne jamais mélanger les deux.
const [viewedDate, setViewedDate] = useState<string | null>(null);
const [pastNotes, setPastNotes] = useState<WallNoteRow[]>([]);
const [pastReactionCounts, setPastReactionCounts] = useState<Record<string, Record<string, number>>>({});
const [pastLoading, setPastLoading] = useState(false);
const refetch = useCallback(async () => {
const supabase = createClient();
const [{ data: todayNotes }, { data: myNotes }, { data: hist }, { data: counts }, { data: mine }] =
await Promise.all([
supabase.rpc("wall_notes_today"),
// Depuis V14, les Archontes peuvent aussi publier — plus de
// filtre par rôle ici non plus (sinon leur note du jour ne se
// mettait jamais à jour après le chargement initial).
supabase
.from("wall_notes")
.select("id, text, image_url, created_at")
.order("created_at", { ascending: false }),
isJudge
? supabase
.from("wall_notes")
.select("id, user_id, text, image_url, created_at, profiles(pseudo, avatar_url)")
.order("created_at", { ascending: false })
.returns<WallHistoryRow[]>()
: Promise.resolve({ data: null }),
supabase.rpc("wall_note_reaction_counts"),
supabase.from("wall_note_reactions").select("note_id, emoji").eq("user_id", currentUserId),
]);
if (todayNotes) setNotes(todayNotes);
if (myNotes) {
const today = new Date().toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
setMyNoteToday(
myNotes.find(
(n) => new Date(n.created_at).toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" }) === today,
) ?? null,
);
}
if (hist) setHistory(hist);
if (counts) setReactionCounts(buildReactionCounts(counts));
if (mine) setMyReactions(buildMyReactions(mine));
}, [isJudge, currentUserId]);
useEffect(() => {
const supabase = createClient();
let channel: ReturnType<typeof supabase.channel> | null = null;
let cancelled = false;
waitForRealtimeAuth(supabase).then(() => {
if (cancelled) return;
channel = supabase
.channel("wall-notes-changes")
.on("postgres_changes", { event: "*", schema: "public", table: "wall_notes" }, refetch)
.on("postgres_changes", { event: "*", schema: "public", table: "wall_note_reactions" }, refetch)
.subscribe();
});
return () => {
cancelled = true;
if (channel) supabase.removeChannel(channel);
};
}, [refetch]);
const fetchDay = useCallback(async (dateKey: string) => {
const supabase = createClient();
const [{ data: dayNotes }, { data: dayCounts }] = await Promise.all([
supabase.rpc("wall_notes_for_date", { p_date: dateKey }),
supabase.rpc("wall_note_reaction_counts", { p_date: dateKey }),
]);
setPastNotes(dayNotes ?? []);
setPastReactionCounts(buildReactionCounts(dayCounts ?? []));
}, []);
async function handleSelectDate(value: string) {
if (value === "today") {
setViewedDate(null);
return;
}
setViewedDate(value);
setPastLoading(true);
await fetchDay(value);
setPastLoading(false);
}
function handleImageChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!file.type.startsWith("image/")) {
setImageError("Choisis une image.");
return;
}
if (file.size > MAX_IMAGE_SOURCE_BYTES) {
setImageError("Image trop lourde, choisis-en une autre.");
return;
}
setImageError(null);
setImageFile(file);
setImagePreview((current) => {
if (current) URL.revokeObjectURL(current);
return URL.createObjectURL(file);
});
}
function clearImage() {
setImageFile(null);
setImagePreview((current) => {
if (current) URL.revokeObjectURL(current);
return null;
});
setImageError(null);
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setError(null);
const trimmed = text.trim();
if (!trimmed) {
setError("Écris une note avant de publier.");
return;
}
setPosting(true);
const supabase = createClient();
let imageUrl: string | null = null;
if (imageFile) {
try {
const blob = await resizeImageToBlob(imageFile);
// L'extension/le Content-Type suivent le type RÉEL du blob rendu par
// canvas.toBlob (pas "image/webp" en dur) : certains navigateurs
// (Safari notamment, webp encodé via canvas longtemps mal supporté)
// retombent silencieusement sur du PNG malgré le type demandé —
// uploader ce PNG sous une étiquette "webp" trompeuse pouvait faire
// échouer des vérifications en aval qui, elles, regardent le vrai
// contenu (l'optimiseur d'image de next/image par exemple).
const extension = blob.type === "image/png" ? "png" : blob.type === "image/jpeg" ? "jpg" : "webp";
const path = `${crypto.randomUUID()}.${extension}`;
const { error: uploadError } = await supabase.storage
.from("wall-images")
.upload(path, blob, { contentType: blob.type || "image/webp" });
if (uploadError) {
setPosting(false);
console.error("Échec de l'envoi de l'image sur le Mur :", uploadError);
setError(`Envoi de l'image impossible (${uploadError.message}). Réessaie.`);
return;
}
const { data: publicUrlData } = supabase.storage.from("wall-images").getPublicUrl(path);
imageUrl = publicUrlData.publicUrl;
} catch (err) {
setPosting(false);
console.error("Échec du traitement de l'image sur le Mur :", err);
setError("Traitement de l'image impossible (format non supporté ?) — réessaie sans image ou avec une autre photo.");
return;
}
}
const { error: rpcError } = await supabase.rpc("post_wall_note", {
p_text: trimmed,
p_image_url: imageUrl,
});
setPosting(false);
if (rpcError) {
console.error("Échec de post_wall_note :", rpcError);
setError(
rpcError.message.includes("already posted")
? "Tu as déjà publié une note aujourd'hui."
: rpcError.message.includes("wall full")
? "Le mur est déjà complet pour aujourd'hui."
: rpcError.message.includes("image invalide")
? "L'image n'a pas pu être validée côté serveur — réessaie sans image ou avec une autre photo."
: `Publication impossible (${rpcError.message}). Réessaie.`,
);
refetch();
return;
}
setText("");
clearImage();
refetch();
}
async function handleToggleReaction(noteId: string, emoji: string) {
// Optimiste : le canal Realtime confirmera (ou corrigera) juste après —
// sauf en dehors d'aujourd'hui, où il n'y a pas de Realtime (un jour
// passé n'évolue plus) : on corrige alors en rechargeant ce jour-là.
const setCounts = viewedDate === null ? setReactionCounts : setPastReactionCounts;
setMyReactions((current) => {
const next = new Set(current[noteId] ?? []);
const wasActive = next.has(emoji);
if (wasActive) next.delete(emoji);
else next.add(emoji);
return { ...current, [noteId]: next };
});
setCounts((current) => {
const wasActive = myReactions[noteId]?.has(emoji) ?? false;
const noteCounts = { ...(current[noteId] ?? {}) };
noteCounts[emoji] = Math.max(0, (noteCounts[emoji] ?? 0) + (wasActive ? -1 : 1));
return { ...current, [noteId]: noteCounts };
});
const supabase = createClient();
const { error: rpcError } = await supabase.rpc("toggle_wall_note_reaction", {
p_note_id: noteId,
p_emoji: emoji,
});
if (rpcError) {
if (viewedDate === null) refetch();
else fetchDay(viewedDate);
}
}
// wallFull/le compteur restent toujours basés sur aujourd'hui (notes),
// jamais sur le jour affiché (displayedNotes) : consulter un jour passé
// ne doit jamais changer l'éligibilité à publier aujourd'hui.
const wallFull = notes.length >= MAX_NOTES_PER_DAY;
const todayKey = new Date().toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
const pastDays = availableDays.filter((day) => day !== todayKey);
const displayedNotes = viewedDate === null ? notes : pastNotes;
const displayedReactionCounts = viewedDate === null ? reactionCounts : pastReactionCounts;
const historyDates = Array.from(new Set(history.map((n) => localDateKey(n.created_at)))).sort((a, b) =>
b.localeCompare(a),
);
const filteredHistory =
historyDateFilter === "all" ? history : history.filter((n) => localDateKey(n.created_at) === historyDateFilter);
return (
<div className="flex flex-col gap-6">
<div>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<p className="text-xs font-medium tracking-wide text-marble/60 uppercase">
{viewedDate === null ? `${notes.length} / ${MAX_NOTES_PER_DAY} aujourd'hui` : formatDateLabel(viewedDate)}
</p>
{pastDays.length > 0 && (
<select
value={viewedDate ?? "today"}
onChange={(event) => handleSelectDate(event.target.value)}
className="rounded-md border border-gold/30 bg-white/50 px-2 py-1 text-xs text-text-marble outline-none focus:border-gold"
>
<option value="today">Aujourd&apos;hui</option>
{pastDays.map((day) => (
<option key={day} value={day}>
{formatDateLabel(day)}
</option>
))}
</select>
)}
</div>
{pastLoading ? (
<p className="text-center text-sm text-marble/60">Chargement</p>
) : displayedNotes.length === 0 ? (
<EmptyState message={viewedDate === null ? "Le mur est encore vide aujourd'hui." : "Le mur était vide ce jour-là."} />
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{displayedNotes.map((note) => (
<NoteCard
key={note.id}
note={note}
reactionCounts={displayedReactionCounts[note.id] ?? {}}
myReactions={myReactions[note.id] ?? new Set()}
onToggleReaction={(emoji) => handleToggleReaction(note.id, emoji)}
onImageClick={setZoomedImage}
/>
))}
</div>
)}
</div>
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-sm">
{myNoteToday ? (
<div>
<p className="text-sm text-olive">Ta note d&apos;aujourd&apos;hui est publiée.</p>
{myNoteToday.image_url && (
<button
type="button"
onClick={() => setZoomedImage(myNoteToday.image_url)}
className="cursor-zoom-in"
aria-label="Agrandir l'image"
>
<Image
src={myNoteToday.image_url}
alt=""
width={300}
height={200}
className="mt-2 max-h-40 w-full rounded-md object-cover"
/>
</button>
)}
<p className="mt-2 rounded-md border border-gold/20 bg-white/40 px-3 py-2 text-sm text-text-marble">
{myNoteToday.text}
</p>
<p className="mt-2 text-xs text-text-mut">Reviens demain pour en publier une nouvelle.</p>
</div>
) : wallFull ? (
<p className="text-sm text-text-mut">Le mur est complet pour aujourd&apos;hui, reviens demain !</p>
) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
<textarea
value={text}
onChange={(event) => setText(event.target.value)}
maxLength={MAX_LENGTH}
rows={2}
placeholder="Écrire une note anonyme…"
className="rounded-md border border-gold/30 bg-white/50 px-3 py-2 text-sm text-text-marble outline-none focus:border-gold"
/>
<p className="text-right text-[0.65rem] text-text-mut">
{text.length}/{MAX_LENGTH}
</p>
{imagePreview ? (
<div className="relative w-32">
<Image
src={imagePreview}
alt=""
width={128}
height={96}
unoptimized
className="h-24 w-32 rounded-md object-cover"
/>
<button
type="button"
onClick={clearImage}
className="absolute -top-2 -right-2 rounded-full bg-ink-2 p-1 text-marble"
aria-label="Retirer l'image"
>
<IconClose className="h-3 w-3" />
</button>
</div>
) : (
<label className="inline-flex w-fit cursor-pointer items-center gap-1.5 rounded-md border border-gold/30 px-3 py-1.5 text-xs font-medium text-text-marble hover:bg-gold/10">
<IconCamera className="h-4 w-4" />
Ajouter une image (optionnel)
<input type="file" accept="image/*" onChange={handleImageChange} className="hidden" />
</label>
)}
{imageError && <p className="text-xs text-oxblood">{imageError}</p>}
{error && (
<p role="alert" className="text-sm text-oxblood">
{error}
</p>
)}
<button
type="submit"
disabled={posting}
className="rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink disabled:opacity-50"
>
{posting ? "Publication…" : "Publier anonymement"}
</button>
</form>
)}
</div>
{isJudge && (
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-sm">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<p className="font-heading text-sm tracking-wide text-text-mut uppercase">Historique complet</p>
{historyDates.length > 0 && (
<select
value={historyDateFilter}
onChange={(event) => setHistoryDateFilter(event.target.value)}
className="rounded-md border border-gold/30 bg-white/50 px-2 py-1 text-xs text-text-marble outline-none focus:border-gold"
>
<option value="all">Tous les jours</option>
{historyDates.map((dateKey) => (
<option key={dateKey} value={dateKey}>
{formatDateLabel(dateKey)}
</option>
))}
</select>
)}
</div>
{filteredHistory.length === 0 ? (
<EmptyState
message={history.length === 0 ? "Aucune note publiée pour l'instant." : "Aucune note ce jour-là."}
className="text-text-mut"
/>
) : (
<ul className="flex flex-col divide-y divide-gold/10">
{filteredHistory.map((note) => (
<li key={note.id} className="flex items-start gap-2 py-1.5 text-sm">
<Avatar
pseudo={note.profiles?.pseudo ?? "?"}
avatarUrl={note.profiles?.avatar_url ?? null}
size="sm"
/>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-text-mut">
{note.profiles?.pseudo ?? "un Citoyen"} {formatDateTime(note.created_at)}
</p>
<p className="text-text-marble">{note.text}</p>
{note.image_url && (
<button
type="button"
onClick={() => setZoomedImage(note.image_url)}
className="cursor-zoom-in"
aria-label="Agrandir l'image"
>
<Image
src={note.image_url}
alt=""
width={200}
height={150}
className="mt-1 max-h-32 rounded-md object-cover"
/>
</button>
)}
</div>
</li>
))}
</ul>
)}
</div>
)}
{zoomedImage && (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-ink/90 p-4 backdrop-blur-sm">
<button
type="button"
onClick={() => setZoomedImage(null)}
className="absolute top-4 right-4 rounded-full bg-ink-2/80 p-2 text-marble transition hover:bg-ink-2"
aria-label="Fermer"
>
<IconClose className="h-5 w-5" />
</button>
<div className="relative h-[80vh] w-full max-w-2xl">
<Image src={zoomedImage} alt="" fill sizes="100vw" className="object-contain" />
</div>
</div>
)}
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { MurView } from "./mur-view";
export type WallNoteRow = {
id: string;
text: string;
image_url: string | null;
created_at: string;
};
export type WallHistoryRow = {
id: string;
user_id: string;
text: string;
image_url: string | null;
created_at: string;
profiles: { pseudo: string; avatar_url: string | null } | null;
};
export type ReactionCountRow = { note_id: string; emoji: string; count: number };
export type MyReactionRow = { note_id: string; emoji: string };
function isToday(iso: string): boolean {
const today = new Date().toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
const noteDate = new Date(iso).toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
return noteDate === today;
}
export default async function MurPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
const { data: callerProfile } = await supabase
.from("profiles")
.select("role")
.eq("id", user.id)
.single();
const isJudge = callerProfile?.role === "judge";
const [{ data: todayNotes }, { data: reactionCounts }, { data: myReactions }, { data: days }] = await Promise.all([
supabase.rpc("wall_notes_for_date"),
supabase.rpc("wall_note_reaction_counts"),
supabase.from("wall_note_reactions").select("note_id, emoji").eq("user_id", user.id),
supabase.rpc("wall_note_days"),
]);
const availableDays = (days ?? []).map((row: { note_date: string }) => row.note_date);
// Les Archontes peuvent désormais publier aussi (comme les Citoyens) :
// besoin de savoir s'ils ont déjà posté aujourd'hui, peu importe le rôle.
const { data: myNotes } = await supabase
.from("wall_notes")
.select("id, text, image_url, created_at")
.eq("user_id", user.id)
.order("created_at", { ascending: false });
const myNoteToday: WallNoteRow | null = (myNotes ?? []).find((n) => isToday(n.created_at)) ?? null;
let history: WallHistoryRow[] = [];
if (isJudge) {
const { data } = await supabase
.from("wall_notes")
.select("id, user_id, text, image_url, created_at, profiles(pseudo, avatar_url)")
.order("created_at", { ascending: false })
.returns<WallHistoryRow[]>();
history = data ?? [];
}
return (
<div className="mx-auto w-full max-w-3xl px-4 py-8">
<div className="mb-6 text-center">
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">Le Mur de la Honte</h1>
<p className="font-serif text-sm text-marble/60 italic">
Une note anonyme par jour, douze places sur le mur les jours précédents restent consultables, toujours
anonymement.
</p>
</div>
<MurView
isJudge={isJudge}
currentUserId={user.id}
initialNotes={todayNotes ?? []}
initialMyNoteToday={myNoteToday}
initialHistory={history}
initialReactionCounts={reactionCounts ?? []}
initialMyReactions={myReactions ?? []}
availableDays={availableDays}
/>
</div>
);
}
@@ -0,0 +1,91 @@
"use client";
import { useState, type FormEvent } from "react";
import { createClient } from "@/lib/supabase/client";
const MAX_LENGTH = 300;
export function ChariotSubmissionForm({
initialText,
frozen,
}: {
initialText: string;
frozen: boolean;
}) {
const [text, setText] = useState(initialText);
const [savedText, setSavedText] = useState(initialText);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
if (frozen) {
return (
<div>
<p className="rounded-md border border-gold/20 bg-white/40 px-3 py-2 text-sm text-text-marble">
{initialText || "Tu n'as proposé aucune question."}
</p>
<p className="mt-2 text-xs text-text-mut">Les propositions sont closes, l&apos;Agora a commencé.</p>
</div>
);
}
const dirty = text.trim() !== savedText;
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setError(null);
setSuccess(false);
const trimmed = text.trim();
if (!trimmed) {
setError("Écris une question avant d'enregistrer.");
return;
}
setSaving(true);
const supabase = createClient();
const { error: rpcError } = await supabase.rpc("submit_chariot_question", { p_text: trimmed });
setSaving(false);
if (rpcError) {
setError("Une erreur est survenue, réessaie.");
return;
}
setSavedText(trimmed);
setSuccess(true);
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
<textarea
value={text}
onChange={(event) => {
setText(event.target.value);
setSuccess(false);
}}
maxLength={MAX_LENGTH}
rows={3}
placeholder="Écrire une question…"
className="rounded-md border border-gold/30 bg-white/50 px-3 py-2 text-sm text-text-marble outline-none focus:border-gold"
/>
<p className="text-right text-[0.65rem] text-text-mut">
{text.length}/{MAX_LENGTH}
</p>
{error && (
<p role="alert" className="text-sm text-oxblood">
{error}
</p>
)}
{success && !dirty && <p className="text-sm text-olive">Question enregistrée.</p>}
<button
type="submit"
disabled={saving || !dirty}
className="rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink disabled:opacity-50"
>
{saving ? "Enregistrement…" : !dirty && savedText ? "Déjà enregistrée" : "Enregistrer"}
</button>
</form>
);
}
+29
View File
@@ -1,6 +1,8 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import { DiamondDivider } from "@/components/diamond-divider";
import { ProfileForm } from "./profile-form"; import { ProfileForm } from "./profile-form";
import { ChariotSubmissionForm } from "./chariot-submission-form";
export default async function ProfilePage() { export default async function ProfilePage() {
const supabase = await createClient(); const supabase = await createClient();
@@ -20,6 +22,17 @@ export default async function ProfilePage() {
const isJudge = profile?.role === "judge"; const isJudge = profile?.role === "judge";
let submissionText = "";
let frozen = false;
if (!isJudge) {
const [{ data: submission }, { data: settings }] = await Promise.all([
supabase.from("chariot_submissions").select("text").eq("user_id", user.id).maybeSingle(),
supabase.from("settings").select("tribunal_date").eq("id", true).single(),
]);
submissionText = submission?.text ?? "";
frozen = Boolean(settings?.tribunal_date) && new Date(settings!.tribunal_date!) <= new Date();
}
return ( return (
<div className="mx-auto w-full max-w-sm px-4 py-8"> <div className="mx-auto w-full max-w-sm px-4 py-8">
<div className="mb-6 text-center"> <div className="mb-6 text-center">
@@ -44,6 +57,22 @@ export default async function ProfilePage() {
initialPseudoLocked={profile?.pseudo_locked ?? false} initialPseudoLocked={profile?.pseudo_locked ?? false}
/> />
</div> </div>
{!isJudge && (
<>
{/* Carte secondaire (bordure plus fine, pas de grande ombre) :
une action ponctuelle annexe, pas le cœur de la page comme la
carte profil au-dessus distincte sans casser la cohérence
marble-surface. */}
<DiamondDivider className="my-5" />
<div className="marble-surface rounded-2xl border border-gold/20 p-6 shadow-sm">
<h2 className="mb-3 font-heading text-sm tracking-wide text-text-mut uppercase">
Ma question pour Le Char
</h2>
<ChariotSubmissionForm initialText={submissionText} frozen={frozen} />
</div>
</>
)}
</div> </div>
); );
} }
+7 -3
View File
@@ -64,14 +64,18 @@ export function ProfileForm({
let nextAvatarUrl = avatarUrl; let nextAvatarUrl = avatarUrl;
if (avatarBlob) { if (avatarBlob) {
const path = `${userId}.webp`; // Extension alignée sur le vrai type du blob (pas ".webp" en dur) :
// certains navigateurs (Safari) retombent sur du PNG si le WebP
// n'est pas supporté à l'encodage — voir cropImageToBlob.
const extension = avatarBlob.type === "image/jpeg" ? "jpg" : avatarBlob.type === "image/png" ? "png" : "webp";
const path = `${userId}.${extension}`;
const { error: uploadError } = await supabase.storage const { error: uploadError } = await supabase.storage
.from("avatars") .from("avatars")
.upload(path, avatarBlob, { upsert: true, contentType: "image/webp" }); .upload(path, avatarBlob, { upsert: true, contentType: avatarBlob.type || "image/webp" });
if (uploadError) { if (uploadError) {
setLoading(false); setLoading(false);
setError("Impossible d'envoyer la photo, réessaie."); setError(`Impossible d'envoyer la photo (${uploadError.message}). Réessaie.`);
return; return;
} }
-33
View File
@@ -1,33 +0,0 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { RouletteView } from "./roulette-view";
export default async function RoulettePage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
const { data: members } = await supabase
.from("profiles")
.select("id, pseudo, avatar_url, role")
.order("pseudo", { ascending: true });
return (
<div className="mx-auto w-full max-w-2xl px-4 py-8">
<div className="mb-6 text-center">
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">
La Roulette
</h1>
<p className="font-serif text-sm text-marble/60 italic">
Que le sort désigne l&apos;élu.
</p>
</div>
<RouletteView members={members ?? []} />
</div>
);
}
-162
View File
@@ -1,162 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Avatar } from "@/components/avatar";
import { LaurelWreath } from "@/components/laurel-wreath";
type Member = { id: string; pseudo: string; avatar_url: string | null; role: string };
const ITEM_WIDTH = 96;
const REPEATS = 6;
const SPIN_MS = 3200;
function useReducedMotion() {
const [reduced, setReduced] = useState(
() => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches,
);
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const handler = () => setReduced(query.matches);
query.addEventListener("change", handler);
return () => query.removeEventListener("change", handler);
}, []);
return reduced;
}
export function RouletteView({ members }: { members: Member[] }) {
const reducedMotion = useReducedMotion();
const [citizensOnly, setCitizensOnly] = useState(false);
const [spinning, setSpinning] = useState(false);
const [strip, setStrip] = useState<Member[]>([]);
const [offset, setOffset] = useState(0);
const [transitionOn, setTransitionOn] = useState(false);
const [winner, setWinner] = useState<Member | null>(null);
const [lastWinner, setLastWinner] = useState<Member | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const pool = citizensOnly ? members.filter((m) => m.role !== "judge") : members;
function handleSpin() {
if (spinning || pool.length === 0) return;
const chosen = pool[Math.floor(Math.random() * pool.length)];
if (reducedMotion) {
setWinner(chosen);
setLastWinner(chosen);
return;
}
setWinner(null);
setSpinning(true);
const built: Member[] = [];
for (let i = 0; i < REPEATS; i++) built.push(...pool);
built.push(chosen);
setStrip(built);
setTransitionOn(false);
setOffset(0);
requestAnimationFrame(() => {
const containerWidth = containerRef.current?.offsetWidth ?? 320;
const finalIndex = built.length - 1;
const target = finalIndex * ITEM_WIDTH + ITEM_WIDTH / 2 - containerWidth / 2;
requestAnimationFrame(() => {
setTransitionOn(true);
setOffset(-target);
});
});
}
function handleTransitionEnd() {
if (!spinning) return;
setSpinning(false);
const chosen = strip[strip.length - 1];
setWinner(chosen);
setLastWinner(chosen);
}
if (members.length === 0) {
return <p className="text-center text-sm text-marble/60">Personne n&apos;est encore inscrit.</p>;
}
return (
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-xl sm:p-8">
<label className="mb-4 flex items-center justify-center gap-2 text-sm text-text-marble">
<input
type="checkbox"
checked={citizensOnly}
onChange={(event) => setCitizensOnly(event.target.checked)}
disabled={spinning}
className="h-4 w-4 accent-gold"
/>
Tirer uniquement parmi les Citoyens (exclure les Archontes)
</label>
<div
ref={containerRef}
className="relative mx-auto mb-6 h-32 max-w-full overflow-hidden rounded-lg bg-ink-2/5"
>
<div
className="absolute top-0 bottom-0 left-1/2 z-10 w-0.5 -translate-x-1/2 bg-gold-bright"
aria-hidden
/>
{strip.length > 0 && (
<div
className="flex h-full items-center"
style={{
transform: `translateX(${offset}px)`,
transition: transitionOn ? `transform ${SPIN_MS}ms cubic-bezier(0.11, 0.7, 0.14, 1)` : "none",
}}
onTransitionEnd={handleTransitionEnd}
>
{strip.map((member, index) => (
<div
key={`${member.id}-${index}`}
className="flex shrink-0 flex-col items-center gap-1"
style={{ width: ITEM_WIDTH }}
>
<Avatar pseudo={member.pseudo} avatarUrl={member.avatar_url} size="md" />
<span className="max-w-[5rem] truncate text-xs text-text-marble">
{member.pseudo}
</span>
</div>
))}
</div>
)}
</div>
<div className="flex justify-center">
<button
type="button"
onClick={handleSpin}
disabled={spinning || pool.length === 0}
className="rounded-md bg-ink-2 px-6 py-2.5 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink disabled:opacity-50"
>
{spinning ? "…" : winner ? "Rejouer" : "Tirer au sort"}
</button>
</div>
<div aria-live="polite" className="mt-8 flex flex-col items-center gap-2">
{winner && (
<div className="animate-reveal flex flex-col items-center gap-2 rounded-2xl px-8 py-6">
<LaurelWreath className="h-10 w-10" color="#C9A227" />
<div className="ring-4 ring-gold rounded-full p-1">
<Avatar pseudo={winner.pseudo} avatarUrl={winner.avatar_url} size="lg" />
</div>
<span className="font-heading text-2xl tracking-wide text-text-marble uppercase">
{winner.pseudo}
</span>
<span className="font-serif text-sm text-text-mut italic">a é désigné(e) par le sort</span>
</div>
)}
</div>
{lastWinner && !winner && (
<p className="mt-4 text-center text-xs text-text-mut">
Dernier élu : <span className="font-medium text-text-marble">{lastWinner.pseudo}</span>
</p>
)}
</div>
);
}
+105 -62
View File
@@ -1,32 +1,82 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useState, type FormEvent } from "react"; import { useEffect, useState, type FormEvent } from "react";
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
import { Avatar } from "@/components/avatar"; import { Avatar } from "@/components/avatar";
import { AvatarPicker } from "@/components/avatar-picker"; import { AvatarPicker } from "@/components/avatar-picker";
import { LaurelWreath } from "@/components/laurel-wreath"; import { AgoraLogo } from "@/components/agora-logo";
function mapSignUpError(message: string): string { // Plus d'auto-inscription publique : cette page ne fonctionne que via un
// lien d'invitation envoyé par un Archonte (Supabase Authentication → Users
// → Invite user). Le clic sur ce lien établit une session ; cette page sert
// alors à finaliser le compte (mot de passe, pseudo, photo).
type Status = "checking" | "no-invite" | "ready" | "done";
function mapError(message: string): string {
const lower = message.toLowerCase(); const lower = message.toLowerCase();
if (lower.includes("already registered")) { if (lower.includes("password")) {
return "Un compte existe déjà avec cet email."; return "Le mot de passe doit faire au moins 6 caractères.";
}
if (lower.includes("rate limit")) {
return "Trop d'emails envoyés récemment (limite Supabase), réessaie dans quelques minutes.";
} }
return "Une erreur est survenue, réessaie."; return "Une erreur est survenue, réessaie.";
} }
export default function SignupPage() { export default function SignupPage() {
const [email, setEmail] = useState(""); const [status, setStatus] = useState<Status>("checking");
const [userId, setUserId] = useState<string | null>(null);
const [pseudo, setPseudo] = useState(""); const [pseudo, setPseudo] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [avatarBlob, setAvatarBlob] = useState<Blob | null>(null); const [avatarBlob, setAvatarBlob] = useState<Blob | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null); const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [confirmationSent, setConfirmationSent] = useState(false);
useEffect(() => {
async function checkInvite() {
const supabase = createClient();
// Le lien d'invitation Supabase redirige avec les tokens dans le
// FRAGMENT d'URL (#access_token=...&refresh_token=...), jamais reçu
// par un serveur (le navigateur ne l'envoie jamais en HTTP) et pas
// auto-détecté par ce client (@supabase/ssr est orienté sessions par
// cookies) : on l'établit donc explicitement à la main, une fois.
const hash = window.location.hash;
if (hash.includes("access_token")) {
const params = new URLSearchParams(hash.slice(1));
const access_token = params.get("access_token");
const refresh_token = params.get("refresh_token");
if (access_token && refresh_token) {
await supabase.auth.setSession({ access_token, refresh_token });
}
window.history.replaceState(null, "", window.location.pathname);
}
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
setStatus("no-invite");
return;
}
const { data: profile } = await supabase
.from("profiles")
.select("pseudo")
.eq("id", user.id)
.single();
if (profile && !profile.pseudo.startsWith("Nouveau membre")) {
window.location.href = "/leaderboard";
return;
}
setUserId(user.id);
setStatus("ready");
}
checkInvite();
}, []);
function handleCropped(blob: Blob) { function handleCropped(blob: Blob) {
setAvatarBlob(blob); setAvatarBlob(blob);
@@ -46,6 +96,7 @@ export default function SignupPage() {
setError("Le mot de passe doit faire au moins 6 caractères."); setError("Le mot de passe doit faire au moins 6 caractères.");
return; return;
} }
if (!userId) return;
setLoading(true); setLoading(true);
const supabase = createClient(); const supabase = createClient();
@@ -60,58 +111,72 @@ export default function SignupPage() {
return; return;
} }
const { data: signUpData, error: signUpError } = await supabase.auth.signUp({ const { error: passwordError } = await supabase.auth.updateUser({ password });
email, if (passwordError) {
password,
options: { data: { pseudo: trimmedPseudo } },
});
if (signUpError || !signUpData.user) {
setLoading(false); setLoading(false);
setError(mapSignUpError(signUpError?.message ?? "")); setError(mapError(passwordError.message));
return; return;
} }
// Sans session active (confirmation email en attente), impossible let avatarUrl: string | null = null;
// d'uploader la photo : la policy Storage exige un utilisateur if (avatarBlob) {
// authentifié. On l'ajoutera depuis /profile après la 1ère connexion. // Extension alignée sur le vrai type du blob (pas ".webp" en dur) :
if (signUpData.session && avatarBlob) { // certains navigateurs (Safari) retombent sur du PNG si le WebP
const path = `${signUpData.user.id}.webp`; // n'est pas supporté à l'encodage — voir cropImageToBlob.
const extension = avatarBlob.type === "image/jpeg" ? "jpg" : avatarBlob.type === "image/png" ? "png" : "webp";
const path = `${userId}.${extension}`;
const { error: uploadError } = await supabase.storage const { error: uploadError } = await supabase.storage
.from("avatars") .from("avatars")
.upload(path, avatarBlob, { upsert: true, contentType: "image/webp" }); .upload(path, avatarBlob, { upsert: true, contentType: avatarBlob.type || "image/webp" });
if (!uploadError) { if (!uploadError) {
const { data: publicUrlData } = supabase.storage.from("avatars").getPublicUrl(path); const { data: publicUrlData } = supabase.storage.from("avatars").getPublicUrl(path);
await supabase avatarUrl = publicUrlData.publicUrl;
.from("profiles") } else {
.update({ avatar_url: publicUrlData.publicUrl }) // Ne bloque pas la finalisation du compte pour une photo optionnelle
.eq("id", signUpData.user.id); // — juste de quoi diagnostiquer si quelqu'un signale l'absence
// d'avatar après inscription.
console.error("Échec de l'envoi de l'avatar à l'inscription :", uploadError);
} }
} }
const { error: profileError } = await supabase
.from("profiles")
.update({ pseudo: trimmedPseudo, ...(avatarUrl ? { avatar_url: avatarUrl } : {}) })
.eq("id", userId);
setLoading(false); setLoading(false);
if (!signUpData.session) { if (profileError) {
setConfirmationSent(true); setError(mapError(profileError.message));
return; return;
} }
window.location.href = "/leaderboard"; window.location.href = "/leaderboard";
} }
if (confirmationSent) { if (status === "checking") {
return ( return (
<div className="flex flex-1 items-center justify-center px-4 py-12"> <div className="flex flex-1 items-center justify-center px-4 py-12">
<div className="marble-surface w-full max-w-sm rounded-2xl border border-gold/40 p-6 text-center shadow-xl sm:p-8"> <div className="marble-surface w-full max-w-sm rounded-2xl border border-gold/40 p-6 text-center shadow-xl sm:p-8">
<LaurelWreath className="mx-auto mb-3 h-10 w-10" color="#5E6B3B" /> <AgoraLogo className="mx-auto mb-3 h-16 w-16 animate-pulse" />
<p className="text-sm text-text-marble/80">Connexion à ton invitation</p>
</div>
</div>
);
}
if (status === "no-invite") {
return (
<div className="flex flex-1 items-center justify-center px-4 py-12">
<div className="marble-surface w-full max-w-sm rounded-2xl border border-gold/40 p-6 text-center shadow-xl sm:p-8">
<AgoraLogo className="mx-auto mb-3 h-16 w-16" />
<h1 className="mb-4 font-heading text-xl tracking-[0.1em] text-text-marble uppercase"> <h1 className="mb-4 font-heading text-xl tracking-[0.1em] text-text-marble uppercase">
Compte créé ! Invitation requise
</h1> </h1>
<p className="text-sm text-text-marble/80"> <p className="text-sm text-text-marble/80">
Vérifie ta boîte mail (et les spams) pour confirmer ton adresse, puis La création de compte se fait uniquement sur invitation d&apos;un
connecte-toi. Tu pourras ajouter ta photo de profil ensuite depuis ton Archonte. Ce lien est invalide ou a expiré.
profil.
</p> </p>
<Link <Link
href="/login" href="/login"
@@ -128,9 +193,9 @@ export default function SignupPage() {
<div className="flex flex-1 items-center justify-center px-4 py-12"> <div className="flex flex-1 items-center justify-center px-4 py-12">
<div className="marble-surface w-full max-w-sm rounded-2xl border border-gold/40 p-6 shadow-xl sm:p-8"> <div className="marble-surface w-full max-w-sm rounded-2xl border border-gold/40 p-6 shadow-xl sm:p-8">
<div className="mb-4 flex flex-col items-center gap-2"> <div className="mb-4 flex flex-col items-center gap-2">
<LaurelWreath className="h-10 w-10" color="#A5342A" /> <AgoraLogo className="h-24 w-24" />
<h1 className="font-heading text-xl tracking-[0.1em] text-text-marble uppercase"> <h1 className="font-heading text-xl tracking-[0.1em] text-text-marble uppercase">
Créer un compte Bienvenue, Citoyen
</h1> </h1>
<p className="font-serif text-sm text-text-mut italic"> <p className="font-serif text-sm text-text-mut italic">
« Que ton nom soit gravé parmi les Citoyens. » « Que ton nom soit gravé parmi les Citoyens. »
@@ -138,21 +203,6 @@ export default function SignupPage() {
</div> </div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4"> <form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label htmlFor="email" className="text-sm font-medium text-text-marble">
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
className="rounded-md border border-gold/30 bg-white/50 px-3 py-2 text-text-marble outline-none focus:border-gold"
/>
</div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="pseudo" className="text-sm font-medium text-text-marble"> <label htmlFor="pseudo" className="text-sm font-medium text-text-marble">
Pseudo Pseudo
@@ -170,7 +220,7 @@ export default function SignupPage() {
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="password" className="text-sm font-medium text-text-marble"> <label htmlFor="password" className="text-sm font-medium text-text-marble">
Mot de passe Choisis un mot de passe
</label> </label>
<input <input
id="password" id="password"
@@ -209,16 +259,9 @@ export default function SignupPage() {
disabled={loading} disabled={loading}
className="mt-2 rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink disabled:opacity-50" className="mt-2 rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink disabled:opacity-50"
> >
{loading ? "Création…" : "Créer mon compte"} {loading ? "Enregistrement…" : "Rejoindre le Tribunal"}
</button> </button>
</form> </form>
<p className="mt-6 text-center text-sm text-text-mut">
Déjà un compte ?{" "}
<Link href="/login" className="font-medium text-oxblood underline hover:text-gold">
Se connecter
</Link>
</p>
</div> </div>
</div> </div>
); );
+80
View File
@@ -0,0 +1,80 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { UrneView } from "./urne-view";
export type CitizenRow = {
id: string;
pseudo: string;
avatar_url: string | null;
};
export type MyVoteRow = {
target_id: string;
created_at: string;
};
function isToday(iso: string): boolean {
const today = new Date().toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
const voteDate = new Date(iso).toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
return voteDate === today;
}
export default async function UrnePage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
const { data: callerProfile } = await supabase
.from("profiles")
.select("role")
.eq("id", user.id)
.single();
const isJudge = callerProfile?.role === "judge";
const [{ data: citizens }, { data: counts }] = await Promise.all([
supabase
.from("profiles")
.select("id, pseudo, avatar_url")
.eq("role", "public")
.order("pseudo", { ascending: true }),
supabase.rpc("urn_vote_counts"),
]);
const voteCounts: Record<string, number> = {};
for (const row of counts ?? []) {
voteCounts[row.target_id] = row.votes;
}
let myVoteToday: MyVoteRow | null = null;
if (!isJudge) {
const { data: myVotes } = await supabase
.from("urn_votes")
.select("target_id, created_at")
.eq("voter_id", user.id)
.order("created_at", { ascending: false });
myVoteToday = (myVotes ?? []).find((v) => isToday(v.created_at)) ?? null;
}
return (
<div className="mx-auto w-full max-w-2xl px-4 py-8">
<div className="mb-6 text-center">
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">L&apos;Urne de l&apos;Agora</h1>
<p className="font-serif text-sm text-marble/60 italic">
Un jeton par jour, un classement pour tous même les Archontes n&apos;en savent pas plus.
</p>
</div>
<UrneView
isJudge={isJudge}
currentUserId={user.id}
citizens={citizens ?? []}
initialVoteCounts={voteCounts}
initialMyVoteToday={myVoteToday}
/>
</div>
);
}
+302
View File
@@ -0,0 +1,302 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Avatar } from "@/components/avatar";
import { IconClose } from "@/components/icons";
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
import type { CitizenRow, MyVoteRow } from "./page";
type VoteReason = { reason: string; created_at: string };
function CitizenVoteRow({
citizen,
onVote,
}: {
citizen: CitizenRow;
onVote: (reason: string) => void;
}) {
const [picking, setPicking] = useState(false);
const [reason, setReason] = useState("");
if (!picking) {
return (
<li className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm">
<Avatar pseudo={citizen.pseudo} avatarUrl={citizen.avatar_url} size="sm" />
<span className="min-w-0 flex-1 truncate text-text-marble">{citizen.pseudo}</span>
<button
type="button"
onClick={() => setPicking(true)}
className="shrink-0 rounded-md border border-gold/40 px-3 py-1.5 text-xs font-medium text-text-marble hover:bg-gold/10"
>
Déposer un jeton
</button>
</li>
);
}
return (
<li className="flex flex-col gap-2 rounded-md bg-gold/5 px-2 py-2 text-sm">
<div className="flex items-center gap-2">
<Avatar pseudo={citizen.pseudo} avatarUrl={citizen.avatar_url} size="sm" />
<span className="min-w-0 flex-1 truncate text-text-marble">{citizen.pseudo}</span>
</div>
<input
type="text"
value={reason}
onChange={(event) => setReason(event.target.value)}
maxLength={200}
placeholder="Pourquoi ? (optionnel)"
className="rounded-md border border-gold/30 bg-white/50 px-2 py-1.5 text-xs text-text-marble outline-none focus:border-gold"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setPicking(false);
setReason("");
}}
className="rounded-md px-3 py-1.5 text-xs text-text-mut hover:bg-gold/10"
>
Annuler
</button>
<button
type="button"
onClick={() => onVote(reason.trim())}
className="rounded-md bg-oxblood px-3 py-1.5 text-xs font-medium text-marble"
>
Confirmer ?
</button>
</div>
</li>
);
}
function ReasonsModal({ citizen, onClose }: { citizen: CitizenRow; onClose: () => void }) {
const [reasons, setReasons] = useState<VoteReason[] | null>(null);
useEffect(() => {
let cancelled = false;
const supabase = createClient();
supabase.rpc("urn_vote_reasons", { p_target_id: citizen.id }).then(({ data }) => {
if (!cancelled) setReasons(data ?? []);
});
return () => {
cancelled = true;
};
}, [citizen.id]);
return (
<div
className="fixed inset-0 z-30 flex items-center justify-center bg-ink/80 p-4 backdrop-blur-sm"
onClick={onClose}
>
<div
className="marble-surface flex max-h-[80vh] w-full max-w-sm flex-col overflow-hidden rounded-2xl border border-gold/40 shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-2 border-b border-gold/20 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<Avatar pseudo={citizen.pseudo} avatarUrl={citizen.avatar_url} size="sm" />
<h2 className="truncate font-heading text-sm tracking-wide text-text-marble">
Pourquoi {citizen.pseudo} ?
</h2>
</div>
<button
type="button"
onClick={onClose}
className="shrink-0 rounded-md p-1 text-text-mut transition hover:bg-gold/10 hover:text-text-marble"
aria-label="Fermer"
>
<IconClose className="h-5 w-5" />
</button>
</div>
<div className="overflow-y-auto px-4 py-4">
{reasons === null && <p className="text-sm text-text-mut">Chargement</p>}
{reasons !== null && reasons.length === 0 && (
<p className="text-sm text-text-mut">
Personne n&apos;a laissé de justification pour l&apos;instant voter sans motif reste
possible.
</p>
)}
{reasons !== null && reasons.length > 0 && (
<ul className="flex flex-col gap-2">
{reasons.map((r) => (
<li
key={r.created_at}
className="rounded-lg border border-gold/25 px-3 py-2 font-serif text-sm text-text-marble italic"
>
« {r.reason} »
</li>
))}
</ul>
)}
<p className="mt-4 text-center text-xs text-text-mut">
Anonyme même les Archontes ne savent pas qui a écrit quoi.
</p>
</div>
</div>
</div>
);
}
export function UrneView({
isJudge,
currentUserId,
citizens,
initialVoteCounts,
initialMyVoteToday,
}: {
isJudge: boolean;
currentUserId: string;
citizens: CitizenRow[];
initialVoteCounts: Record<string, number>;
initialMyVoteToday: MyVoteRow | null;
}) {
const [voteCounts, setVoteCounts] = useState(initialVoteCounts);
const [myVoteToday, setMyVoteToday] = useState(initialMyVoteToday);
const [error, setError] = useState<string | null>(null);
const [selectedTarget, setSelectedTarget] = useState<CitizenRow | null>(null);
const refetch = useCallback(async () => {
const supabase = createClient();
const [{ data: counts }, { data: myVotes }] = await Promise.all([
supabase.rpc("urn_vote_counts"),
isJudge
? Promise.resolve({ data: null })
: supabase
.from("urn_votes")
.select("target_id, created_at")
.eq("voter_id", currentUserId)
.order("created_at", { ascending: false }),
]);
if (counts) {
const next: Record<string, number> = {};
for (const row of counts) next[row.target_id] = row.votes;
setVoteCounts(next);
}
if (myVotes) {
const today = new Date().toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
setMyVoteToday(
myVotes.find(
(v) => new Date(v.created_at).toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" }) === today,
) ?? null,
);
}
}, [isJudge, currentUserId]);
useEffect(() => {
const supabase = createClient();
let channel: ReturnType<typeof supabase.channel> | null = null;
let cancelled = false;
waitForRealtimeAuth(supabase).then(() => {
if (cancelled) return;
channel = supabase
.channel("urn-votes-changes")
.on("postgres_changes", { event: "*", schema: "public", table: "urn_votes" }, refetch)
.subscribe();
});
return () => {
cancelled = true;
if (channel) supabase.removeChannel(channel);
};
}, [refetch]);
async function handleVote(targetId: string, reason: string) {
setError(null);
const supabase = createClient();
const { error: rpcError } = await supabase.rpc("cast_urn_vote", {
p_target_id: targetId,
p_reason: reason || null,
});
if (rpcError) {
setError(
rpcError.message.includes("already voted")
? "Tu as déjà voté aujourd'hui."
: "Une erreur est survenue, réessaie.",
);
refetch();
return;
}
refetch();
}
const sortedCitizens = [...citizens].sort((a, b) => {
const diff = (voteCounts[b.id] ?? 0) - (voteCounts[a.id] ?? 0);
return diff !== 0 ? diff : a.pseudo.localeCompare(b.pseudo);
});
const myVoteTarget = myVoteToday ? citizens.find((c) => c.id === myVoteToday.target_id) : null;
const otherCitizens = citizens.filter((c) => c.id !== currentUserId);
return (
<div className="flex flex-col gap-6">
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-lg">
<p className="mb-2 text-center font-heading text-sm tracking-wide text-text-mut uppercase">Classement</p>
<p className="mb-3 text-center text-xs text-text-mut">Touche un nom pour voir pourquoi on a voté pour lui.</p>
<ul className="grid grid-cols-2 gap-x-4 gap-y-1.5 sm:grid-cols-3">
{sortedCitizens.map((citizen) => {
const count = voteCounts[citizen.id] ?? 0;
return (
<li key={citizen.id} className="min-w-0">
<button
type="button"
onClick={() => setSelectedTarget(citizen)}
className="flex w-full min-w-0 items-center gap-1.5 rounded-md py-0.5 text-left text-xs transition hover:bg-gold/10"
>
<Avatar pseudo={citizen.pseudo} avatarUrl={citizen.avatar_url} size="xs" />
<span className={`truncate ${count === 0 ? "text-text-mut/60" : "text-text-marble"}`}>
{citizen.pseudo}
</span>
<span className="ml-auto shrink-0 font-heading text-sea">
{count > 0 ? `×${count}` : "—"}
</span>
</button>
</li>
);
})}
</ul>
</div>
{!isJudge && (
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-sm">
{myVoteToday ? (
<div>
<p className="text-sm text-olive">
Tu as voté pour {myVoteTarget?.pseudo ?? "quelqu'un"}{" "}
aujourd&apos;hui.
</p>
<p className="mt-2 text-xs text-text-mut">Reviens demain pour déposer un nouveau jeton.</p>
</div>
) : (
<>
<p className="mb-2 font-heading text-sm tracking-wide text-text-mut uppercase">Déposer un jeton</p>
{error && (
<p role="alert" className="mb-2 text-sm text-oxblood">
{error}
</p>
)}
<ul className="flex flex-col divide-y divide-gold/10">
{otherCitizens.map((citizen) => (
<CitizenVoteRow
key={citizen.id}
citizen={citizen}
onVote={(reason) => handleVote(citizen.id, reason)}
/>
))}
</ul>
</>
)}
</div>
)}
{selectedTarget && <ReasonsModal citizen={selectedTarget} onClose={() => setSelectedTarget(null)} />}
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import Image from "next/image";
// unoptimized : l'optimiseur d'images de Next.js aplatit systématiquement la
// transparence de ce fichier (PNG ou WebP) lors du redimensionnement à la
// volée — on sert donc le fichier statique tel quel, sa transparence réelle
// est alors préservée par le navigateur.
export function AgoraLogo({ className = "h-9 w-9" }: { className?: string }) {
return (
<span className={`relative block shrink-0 ${className}`}>
<Image
src="/logo-agora.webp"
alt="L'Agora"
fill
priority
unoptimized
className="object-cover"
/>
</span>
);
}
+3 -2
View File
@@ -3,7 +3,8 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import Cropper, { type Area } from "react-easy-crop"; import Cropper, { type Area } from "react-easy-crop";
import { cropImageToBlob } from "@/lib/image"; import { cropImageToBlob } from "@/lib/image";
import { IconCamera, IconClose } from "@/components/icons"; import { IconClose } from "@/components/icons";
import { AgoraLogo } from "@/components/agora-logo";
export function AvatarPicker({ export function AvatarPicker({
hasExistingPhoto = false, hasExistingPhoto = false,
@@ -126,7 +127,7 @@ export function AvatarPicker({
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
className="flex items-center gap-2 rounded-md bg-ink-2 px-3 py-1.5 text-sm font-medium text-gold-bright hover:bg-ink" className="flex items-center gap-2 rounded-md bg-ink-2 px-3 py-1.5 text-sm font-medium text-gold-bright hover:bg-ink"
> >
<IconCamera className="h-4 w-4" /> <AgoraLogo className="h-4 w-4" />
{fileName || hasExistingPhoto ? "Changer ma photo" : "Choisir une photo"} {fileName || hasExistingPhoto ? "Changer ma photo" : "Choisir une photo"}
</button> </button>
+8 -2
View File
@@ -26,9 +26,13 @@ export function Avatar({
}: { }: {
pseudo: string; pseudo: string;
avatarUrl: string | null; avatarUrl: string | null;
size?: keyof typeof SIZE_CLASSES; // Un nombre = taille en pixels arbitraire (ex. les paliers de La Corne
// d'Abondance), en plus des tailles nommées existantes.
size?: keyof typeof SIZE_CLASSES | number;
}) { }) {
const sizeClass = SIZE_CLASSES[size]; const isCustomSize = typeof size === "number";
const sizeClass = isCustomSize ? "" : SIZE_CLASSES[size];
const customStyle = isCustomSize ? { width: size, height: size, fontSize: size * 0.35 } : undefined;
if (avatarUrl) { if (avatarUrl) {
// blob:/data: = aperçu local avant upload (cropper). next/image essaie // blob:/data: = aperçu local avant upload (cropper). next/image essaie
@@ -42,6 +46,7 @@ export function Avatar({
width={96} width={96}
height={96} height={96}
unoptimized={isLocalPreview} unoptimized={isLocalPreview}
style={customStyle}
className={`${sizeClass} rounded-full border-2 border-gold object-cover`} className={`${sizeClass} rounded-full border-2 border-gold object-cover`}
/> />
); );
@@ -49,6 +54,7 @@ export function Avatar({
return ( return (
<div <div
style={customStyle}
className={`${sizeClass} flex items-center justify-center rounded-full border-2 border-gold font-heading font-semibold text-marble ${fallbackColor(pseudo)}`} className={`${sizeClass} flex items-center justify-center rounded-full border-2 border-gold font-heading font-semibold text-marble ${fallbackColor(pseudo)}`}
aria-hidden aria-hidden
> >
+31
View File
@@ -0,0 +1,31 @@
// Char antique vu du dessus, stylisé : essieu + roues de profil (une roue
// vue depuis le dessus apparaît de champ, pas comme un disque), caisse
// en goutte, timon vers le joug. Même esprit que laurel-wreath.tsx (tracé
// SVG pur, pas d'image bitmap).
export function ChariotEmblem({
className = "h-12 w-8",
color = "#C9A227",
}: {
className?: string;
color?: string;
}) {
return (
<svg
viewBox="0 0 40 60"
className={className}
fill="none"
stroke={color}
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<line x1="6" y1="45" x2="34" y2="45" strokeWidth={2.4} />
<ellipse cx="6" cy="45" rx="2.2" ry="8" fill={color} stroke="none" />
<ellipse cx="34" cy="45" rx="2.2" ry="8" fill={color} stroke="none" />
<path d="M12 45 C12 30, 14 20, 20 14 C26 20, 28 30, 28 45 Z" />
<line x1="20" y1="14" x2="20" y2="4" />
<line x1="14" y1="4" x2="26" y2="4" />
</svg>
);
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { useState, type CSSProperties } from "react";
const CONFETTI_COLORS = ["#E7C560", "#C9A227", "#F4ECD8", "#A5342A", "#5E6B3B", "#2E6E7E", "#B08D57"];
type ConfettiPiece = {
id: number;
left: number;
color: string;
delay: number;
duration: number;
spin: number;
drift: number;
};
function generateConfettiPieces(): ConfettiPiece[] {
return Array.from({ length: 26 }, (_, index) => ({
id: index,
left: Math.random() * 100,
color: CONFETTI_COLORS[index % CONFETTI_COLORS.length],
delay: Math.random() * 250,
duration: 900 + Math.random() * 700,
spin: (Math.random() > 0.5 ? 1 : -1) * (360 + Math.random() * 360),
drift: (Math.random() - 0.5) * 70,
}));
}
// Partagé entre Le Vol d'Icare et La Corne d'Abondance (fin de partie) —
// à l'appelant de ne le monter que si prefers-reduced-motion ne le
// désactive pas, comme déjà fait sur les deux jeux.
export function Confetti() {
// Initialiseur paresseux de useState (n'exécute Math.random() qu'une
// seule fois, au montage — Confetti est remonté à chaque nouvelle partie
// terminée) plutôt qu'un appel impur dans le corps du composant.
const [pieces] = useState(generateConfettiPieces);
return (
<div className="pointer-events-none absolute inset-0 overflow-hidden">
{pieces.map((piece) => (
<span
key={piece.id}
className="animate-confetti-fall absolute top-0 h-2.5 w-1.5 rounded-[1px]"
style={
{
left: `${piece.left}%`,
backgroundColor: piece.color,
animationDelay: `${piece.delay}ms`,
animationDuration: `${piece.duration}ms`,
"--confetti-spin": `${piece.spin}deg`,
"--confetti-drift": `${piece.drift}px`,
} as CSSProperties
}
/>
))}
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
// Petit ornement (◆, comme diamond-divider.tsx) au-dessus des messages
// "rien à afficher" — sans ça ce sont des états qui ressemblent à une
// erreur/un oubli plutôt qu'un vrai état normal de l'app.
export function EmptyState({ message, className = "text-marble/60" }: { message: string; className?: string }) {
return (
<div className="flex flex-col items-center gap-2 py-2 text-center">
<span className="text-gold/50" aria-hidden>
</span>
<p className={`text-sm ${className}`}>{message}</p>
</div>
);
}
+20 -10
View File
@@ -6,20 +6,26 @@ import { useEffect, useRef, useState } from "react";
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
import { LaurelWreath } from "@/components/laurel-wreath"; import { LaurelWreath } from "@/components/laurel-wreath";
import { Avatar } from "@/components/avatar"; import { Avatar } from "@/components/avatar";
import { AgoraLogo } from "@/components/agora-logo";
import { jetbrainsMono } from "@/lib/fonts";
import { import {
IconWheel,
IconScroll, IconScroll,
IconPerson, IconPerson,
IconColumn, IconTerminal,
IconLogout, IconLogout,
IconChevronDown, IconChevronDown,
IconCalendar, IconWings,
IconSword,
IconPin,
IconVote,
IconHorn,
} from "@/components/icons"; } from "@/components/icons";
type MenuEntry = { type MenuEntry = {
href: string; href: string;
label: string; label: string;
icon: React.ReactNode; icon: React.ReactNode;
labelClassName?: string;
}; };
function MenuLink({ function MenuLink({
@@ -40,7 +46,7 @@ function MenuLink({
}`} }`}
> >
<span className={isActive ? "text-gold" : "text-text-mut"}>{entry.icon}</span> <span className={isActive ? "text-gold" : "text-text-mut"}>{entry.icon}</span>
{entry.label} <span className={entry.labelClassName}>{entry.label}</span>
</Link> </Link>
); );
} }
@@ -89,16 +95,20 @@ export function Header({
const entries: MenuEntry[] = [ const entries: MenuEntry[] = [
{ href: "/leaderboard", label: "Le Classement", icon: <LaurelWreath className="h-5 w-5" /> }, { href: "/leaderboard", label: "Le Classement", icon: <LaurelWreath className="h-5 w-5" /> },
{ href: "/roulette", label: "La Roulette", icon: <IconWheel /> }, { href: "/icare", label: "Le Vol d'Icare", icon: <IconWings /> },
{ href: "/calendrier", label: "Le Calendrier des Dieux", icon: <IconCalendar /> }, { href: "/corne", label: "La Corne d'Abondance", icon: <IconHorn /> },
{ href: "/char", label: "Le Jeu de l'Agora", icon: <IconSword /> },
{ href: "/mur", label: "Le Mur de la Honte", icon: <IconPin /> },
{ href: "/urne", label: "L'Urne de l'Agora", icon: <IconVote /> },
{ href: "/journal", label: "Le Crieur", icon: <IconScroll /> }, { href: "/journal", label: "Le Crieur", icon: <IconScroll /> },
{ href: "/profile", label: "Mon profil", icon: <IconPerson /> }, { href: "/profile", label: "Mon profil", icon: <IconPerson /> },
]; ];
if (isJudge) { if (isJudge) {
entries.push({ entries.push({
href: "/admin", href: "/admin",
label: "Le Conseil des Archontes", label: "Admin",
icon: <IconColumn />, icon: <IconTerminal />,
labelClassName: jetbrainsMono.className,
}); });
} }
@@ -106,9 +116,9 @@ export function Header({
<header className="sticky top-0 z-10 bg-ink-2/95 backdrop-blur"> <header className="sticky top-0 z-10 bg-ink-2/95 backdrop-blur">
<div className="mx-auto flex max-w-4xl items-center justify-between gap-2 px-2 py-2.5 sm:px-4 sm:py-3"> <div className="mx-auto flex max-w-4xl items-center justify-between gap-2 px-2 py-2.5 sm:px-4 sm:py-3">
<Link href="/leaderboard" className="flex min-w-0 items-center gap-1.5 sm:gap-2"> <Link href="/leaderboard" className="flex min-w-0 items-center gap-1.5 sm:gap-2">
<LaurelWreath className="h-5 w-5 shrink-0 sm:h-7 sm:w-7" color="#E7C560" /> <AgoraLogo className="h-7 w-7 sm:h-9 sm:w-9" />
<span className="whitespace-nowrap font-heading text-[0.6rem] tracking-[0.08em] text-gold-bright uppercase sm:text-sm sm:tracking-[0.15em]"> <span className="whitespace-nowrap font-heading text-[0.6rem] tracking-[0.08em] text-gold-bright uppercase sm:text-sm sm:tracking-[0.15em]">
Le Tribunal L&apos;Agora
</span> </span>
</Link> </Link>
+89
View File
@@ -40,6 +40,15 @@ export function IconColumn({ className = base }: IconProps) {
); );
} }
export function IconTerminal({ className = base }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
<rect x="3" y="4.5" width="18" height="15" rx="2" />
<path d="M7 9.5l3 3-3 3M12.5 15.5h4.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function IconLogout({ className = base }: IconProps) { export function IconLogout({ className = base }: IconProps) {
return ( return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden> <svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
@@ -188,6 +197,29 @@ export function IconSword({ className = base }: IconProps) {
); );
} }
export function IconVote({ className = base }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
<path
d="M8 4h8M9 4v3.5L6.5 12a4 4 0 0 0-.5 2v4a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-4a4 4 0 0 0-.5-2L15 7.5V4"
strokeLinejoin="round"
strokeLinecap="round"
/>
<path d="M9 14h6" strokeLinecap="round" />
</svg>
);
}
export function IconPin({ className = base }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
<rect x="5" y="6.5" width="14" height="14" rx="1.5" transform="rotate(-5 12 13.5)" strokeLinejoin="round" />
<circle cx="12" cy="4.5" r="1.8" fill="currentColor" stroke="none" />
<line x1="12" y1="6" x2="12" y2="8.5" strokeLinecap="round" />
</svg>
);
}
export function IconOwl({ className = base }: IconProps) { export function IconOwl({ className = base }: IconProps) {
return ( return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden> <svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
@@ -241,3 +273,60 @@ export function IconBolt({ className = base }: IconProps) {
</svg> </svg>
); );
} }
export function IconWings({ className = base }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
<path d="M12 5v14" strokeLinecap="round" />
<path
d="M12 8c-2-3-6-4-9-3 1 3 1 6 4 8 2 1.4 4 1.2 5 .5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 8c2-3 6-4 9-3-1 3-1 6-4 8-2 1.4-4 1.2-5 .5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function IconHorn({ className = base }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
<path
d="M5 8c0-2.5 2-4 4.5-4 5 0 9.5 4.5 9.5 10.5 0 2-1.2 3-2.5 3-1.6 0-2-1.3-3.3-1.3-1.4 0-1.7 1.6-3.4 1.6-2.8 0-4.8-3.6-4.8-7.3 0-1 .2-1.8.5-2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="17" cy="18.5" r="1.6" fill="currentColor" stroke="none" />
<circle cx="20" cy="15" r="1.2" fill="currentColor" stroke="none" />
<circle cx="20.5" cy="19.5" r="1" fill="currentColor" stroke="none" />
</svg>
);
}
export function IconGrip({ className = base }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="currentColor" stroke="none" aria-hidden>
<circle cx="9" cy="6" r="1.6" />
<circle cx="15" cy="6" r="1.6" />
<circle cx="9" cy="12" r="1.6" />
<circle cx="15" cy="12" r="1.6" />
<circle cx="9" cy="18" r="1.6" />
<circle cx="15" cy="18" r="1.6" />
</svg>
);
}
export function IconShuffle({ className = base }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
<path d="M3.5 6.5h3c1.8 0 2.8.6 3.9 2l5.7 7.3c1.1 1.4 2.1 2 3.9 2h1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M3.5 17.8h3c1.8 0 2.8-.6 3.9-2l.6-.8" strokeLinecap="round" strokeLinejoin="round" />
<path d="M14.6 8.5l.9-1.2c1.1-1.4 2.1-2 3.9-2h1.1" strokeLinecap="round" strokeLinejoin="round" />
<path d="M19 3.5l2.5 2.8L19 9M19 15l2.5 2.8L19 20.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { Avatar } from "@/components/avatar";
export function MentionTags({
pseudos,
profiles,
className = "",
}: {
pseudos: string[];
profiles: { pseudo: string; avatar_url: string | null }[];
className?: string;
}) {
if (pseudos.length === 0) return null;
return (
<div className={`flex flex-wrap items-center gap-1.5 ${className}`}>
<span className="font-heading text-[0.65rem] tracking-[0.15em] text-text-mut uppercase">Concerne</span>
{pseudos.map((pseudo) => {
const profile = profiles.find((p) => p.pseudo === pseudo);
return (
<span
key={pseudo}
className="flex items-center gap-1 rounded-full border border-gold/40 px-2 py-0.5 text-xs font-medium text-text-marble"
>
<Avatar pseudo={pseudo} avatarUrl={profile?.avatar_url ?? null} size="xs" />
{pseudo}
</span>
);
})}
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
"use client";
import { useMemo, useRef, useState } from "react";
type MentionCandidate = { pseudo: string; avatar_url: string | null };
// Détecte un "@partiel" juste avant le curseur (pas ailleurs dans le texte)
// pour proposer des suggestions au fil de la frappe, comme un @mention
// classique — sans dépendance externe, la logique tient en une regex.
function findActiveQuery(value: string, caret: number): { start: number; query: string } | null {
const uptoCaret = value.slice(0, caret);
const match = uptoCaret.match(/@([\p{L}\p{N}_-]*)$/u);
if (!match) return null;
return { start: caret - match[0].length, query: match[1] };
}
export function MentionTextarea({
value,
onChange,
candidates,
rows = 2,
placeholder,
className,
}: {
value: string;
onChange: (next: string) => void;
candidates: MentionCandidate[];
rows?: number;
placeholder?: string;
className?: string;
}) {
const [caret, setCaret] = useState(0);
const [open, setOpen] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const activeQuery = open ? findActiveQuery(value, caret) : null;
const suggestions = useMemo(() => {
if (!activeQuery) return [];
const q = activeQuery.query.toLowerCase();
return candidates.filter((c) => c.pseudo.toLowerCase().includes(q)).slice(0, 6);
}, [activeQuery, candidates]);
function applySuggestion(pseudo: string) {
if (!activeQuery) return;
const before = value.slice(0, activeQuery.start);
const after = value.slice(caret);
const next = `${before}@${pseudo} ${after}`;
onChange(next);
setOpen(false);
requestAnimationFrame(() => {
const pos = before.length + pseudo.length + 2;
textareaRef.current?.focus();
textareaRef.current?.setSelectionRange(pos, pos);
});
}
return (
<div className="relative">
<textarea
ref={textareaRef}
placeholder={placeholder}
value={value}
rows={rows}
className={className}
onChange={(event) => {
onChange(event.target.value);
setCaret(event.target.selectionStart);
setOpen(true);
}}
onKeyUp={(event) => setCaret(event.currentTarget.selectionStart)}
onClick={(event) => setCaret(event.currentTarget.selectionStart)}
onBlur={() => {
// Délai pour laisser le clic sur une suggestion se déclencher
// avant que le blur ne referme la liste.
setTimeout(() => setOpen(false), 120);
}}
/>
{activeQuery && suggestions.length > 0 && (
<ul className="absolute z-10 mt-1 w-full max-w-xs rounded-md border border-gold/40 bg-marble shadow-lg">
{suggestions.map((c) => (
<li key={c.pseudo}>
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => applySuggestion(c.pseudo)}
className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-sm text-text-marble hover:bg-gold/10"
>
<span className="font-medium text-gold-bright">@{c.pseudo}</span>
</button>
</li>
))}
</ul>
)}
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { AgoraLogo } from "@/components/agora-logo";
// Utilisé par les loading.tsx des pages les plus consultées : sans ça, le
// temps de la requête d'un Server Component se traduit par un écran vide,
// ce qui a déjà fait croire à un vrai bug une fois (/signup, voir
// l'historique du projet) — même un indicateur minimal évite ce doute.
export function PageLoading() {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-16">
<AgoraLogo className="h-16 w-16 animate-pulse" />
<p className="font-heading text-xs tracking-[0.2em] text-marble/50 uppercase">Chargement</p>
</div>
);
}
+14 -7
View File
@@ -1,9 +1,10 @@
import type { ReactNode } from "react";
import { Avatar } from "@/components/avatar"; import { Avatar } from "@/components/avatar";
import { JudgePointControls } from "@/components/judge-point-controls"; import { JudgePointControls } from "@/components/judge-point-controls";
import { LaurelWreath } from "@/components/laurel-wreath"; import { LaurelWreath } from "@/components/laurel-wreath";
import { toRoman, type RankedProfile } from "@/lib/ranking"; import { toRoman, type Ranked } from "@/lib/ranking";
type PodiumProfile = { id: string; pseudo: string; avatar_url: string | null; points: number }; type PodiumProfile = { id: string; pseudo: string; avatar_url: string | null };
const SLOT_STYLES: Record< const SLOT_STYLES: Record<
number, number,
@@ -14,16 +15,20 @@ const SLOT_STYLES: Record<
3: { pedestal: "h-14 sm:h-20", order: "order-3", ring: "ring-bronze", delay: "0ms" }, 3: { pedestal: "h-14 sm:h-20", order: "order-3", ring: "ring-bronze", delay: "0ms" },
}; };
export function Podium({ export function Podium<T extends PodiumProfile>({
profiles, profiles,
renderValue,
valueLabel,
isJudge = false, isJudge = false,
onApplyDelta, onApplyDelta,
}: { }: {
profiles: RankedProfile<PodiumProfile>[]; profiles: Ranked<T>[];
renderValue: (member: Ranked<T>) => ReactNode;
valueLabel?: string;
isJudge?: boolean; isJudge?: boolean;
onApplyDelta?: (id: string, delta: number) => void; onApplyDelta?: (id: string, delta: number) => void;
}) { }) {
const byRank = new Map<number, RankedProfile<PodiumProfile>[]>(); const byRank = new Map<number, Ranked<T>[]>();
for (const profile of profiles) { for (const profile of profiles) {
const group = byRank.get(profile.rank) ?? []; const group = byRank.get(profile.rank) ?? [];
group.push(profile); group.push(profile);
@@ -77,9 +82,11 @@ export function Podium({
isFirst ? "text-xl sm:text-2xl" : "text-base sm:text-lg" isFirst ? "text-xl sm:text-2xl" : "text-base sm:text-lg"
}`} }`}
> >
{member.points} {renderValue(member)}
</span> </span>
<span className="-mt-1 text-[0.65rem] text-text-mut">gloires</span> {valueLabel && (
<span className="-mt-1 text-[0.65rem] text-text-mut">{valueLabel}</span>
)}
{isJudge && onApplyDelta && ( {isJudge && onApplyDelta && (
<JudgePointControls <JudgePointControls
memberId={member.id} memberId={member.id}
+8
View File
@@ -0,0 +1,8 @@
import { JetBrains_Mono } from "next/font/google";
// Police console/terminale, réservée à la page Admin (et son entrée de
// menu) — rupture volontaire avec le thème grec pour cet outil technique.
export const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
weight: ["500", "700"],
});
+83
View File
@@ -0,0 +1,83 @@
// Format classique Flappy Bird : le personnage reste à une position d'écran
// fixe, le décor défile de droite à gauche (pas de piste qui change de
// forme selon le jour — parcours procédural à chaque partie, sans graine
// partagée, puisqu'il n'y a plus de comparaison "même tracé pour tous").
export const VIEWPORT_WIDTH = 340;
export const VIEWPORT_HEIGHT = 500;
export const GROUND_HEIGHT = 36;
export const GROUND_Y = VIEWPORT_HEIGHT - GROUND_HEIGHT;
export const ICARUS_X = VIEWPORT_WIDTH * 0.3;
export const ICARUS_RADIUS = 12;
export const ICARUS_START_Y = VIEWPORT_HEIGHT / 2;
export const GRAVITY = 900; // unités/seconde²
export const FLAP_IMPULSE = -300; // vitesse verticale imposée à chaque battement d'aile
export const MAX_FALL_SPEED = 480;
export const TILT_MAX_RADIANS = (45 * Math.PI) / 180;
// Animation des ailes (indépendante de l'inclinaison du corps) : un battement
// continu en boucle (pour que ça se voie même sans taper, dès l'écran de
// repos) + un battement plus ample et net à chaque tap, qui prend le dessus
// brièvement sur le cycle continu.
export const WING_REST_RADIANS = -(20 * Math.PI) / 180;
export const WING_IDLE_PEAK_RADIANS = (15 * Math.PI) / 180;
export const WING_IDLE_PERIOD_MS = 480;
export const WING_FLAP_MAX_RADIANS = (75 * Math.PI) / 180;
export const WING_FLAP_ANIM_MS = 260;
export const FORWARD_SPEED = 130; // unités/seconde, vitesse de défilement des colonnes de base
export const COLUMN_WIDTH = 46;
export const COLUMN_GAP = 128; // écart de base entre les colonnes d'une paire
export const COLUMN_SPACING = 210;
export const COLUMN_GAP_MARGIN = 50; // distance mini entre le centre du trou et le sol/plafond
// Difficulté progressive : au-delà de DIFFICULTY_START_SCORE, la vitesse
// augmente et l'écart entre les colonnes se resserre progressivement,
// jusqu'à un palier atteint à DIFFICULTY_START_SCORE + DIFFICULTY_RAMP_SPAN
// (au-delà, la difficulté n'augmente plus — évite un jeu qui devient
// littéralement impossible sur une très longue partie).
export const DIFFICULTY_START_SCORE = 10;
export const DIFFICULTY_RAMP_SPAN = 40;
export const SPEED_MAX_MULTIPLIER = 1.6;
export const GAP_MIN_MULTIPLIER = 0.75;
export const FIXED_DT_MS = 1000 / 60;
export const MAX_FRAME_DT_MS = 250; // évite la "spirale de la mort" après un onglet en arrière-plan
// Boost (éclair de Zeus) : apparaît tous les BOOST_MIN..MAX_COLUMN_INTERVAL
// colonnes (tirage aléatoire à chaque fois), au centre du trou de la
// colonne qui le porte. Le ramasser active le bouclier pour les
// BOOST_MIN..MAX_CHARGES prochaines colonnes (distance, pas un budget de
// casses) : tant qu'il en reste, toucher une colonne (ce qui tuerait
// normalement) la casse à la place — mais une colonne franchie sans
// contact compte aussi dans cette distance, elle n'est pas cassée pour
// autant.
export const BOOST_RADIUS = 14; // taille du rond dessiné
// Rayon de ramassage réel, plus généreux que le rond dessiné : sinon seul
// un vol quasi pile au centre du trou le ramassait, malgré une fréquence
// d'apparition correcte (vérifié par simulation).
export const BOOST_COLLECT_RADIUS = 34;
// Rare et exceptionnel plutôt que fréquent : intervalle large, pas de
// plafond dur — une partie moyenne (~50 de score) n'en voit naturellement
// que 1 à 2, mais une partie plus longue peut légitimement en voir plus.
export const BOOST_MIN_COLUMN_INTERVAL = 15;
export const BOOST_MAX_COLUMN_INTERVAL = 25;
export const BOOST_MIN_CHARGES = 3;
export const BOOST_MAX_CHARGES = 8;
// Vitesse de défilement multipliée tant que le bouclier est actif (sans
// risque : une colonne touchée casse au lieu de tuer pendant cette
// période) — renforce la sensation de vitesse du bouclier actif.
export const BOOST_SPEED_MULTIPLIER = 1.7;
// Animation de cueillette : Icare fait un bond en avant (aller-retour,
// pic à mi-parcours) plutôt que le halo statique d'avant.
export const BOOST_DASH_MS = 350;
export const BOOST_DASH_DISTANCE = 22;
export const SHATTER_ANIM_MS = 450;
export const MIN_SCORE = 0;
// Doit rester aligné avec la contrainte icarus_scores_best_score_check
// (supabase/schema.sql) — celle-ci est la vraie garantie, cette constante
// n'est qu'une valeur de référence côté client.
export const MAX_SCORE = 500;
+48
View File
@@ -0,0 +1,48 @@
import {
BOOST_COLLECT_RADIUS,
COLUMN_WIDTH,
GRAVITY,
GROUND_Y,
ICARUS_RADIUS,
ICARUS_START_Y,
ICARUS_X,
MAX_FALL_SPEED,
} from "./constants";
import type { Boost, Column, IcarusState } from "./types";
export function createInitialIcarus(): IcarusState {
return { y: ICARUS_START_Y, velocityY: 0 };
}
export function stepIcarus(state: IcarusState, dtMs: number): IcarusState {
const dt = dtMs / 1000;
const velocityY = Math.min(MAX_FALL_SPEED, state.velocityY + GRAVITY * dt);
const y = Math.max(0, state.y + velocityY * dt);
return { y, velocityY };
}
export function hasHitGround(icarus: IcarusState): boolean {
return icarus.y + ICARUS_RADIUS >= GROUND_Y;
}
export function collidesWithColumn(icarusY: number, column: Column): boolean {
const columnLeft = column.x;
const columnRight = column.x + COLUMN_WIDTH;
const overlapsX = columnRight > ICARUS_X - ICARUS_RADIUS && columnLeft < ICARUS_X + ICARUS_RADIUS;
if (!overlapsX) return false;
const gapTop = column.gapCenterY - column.gap / 2;
const gapBottom = column.gapCenterY + column.gap / 2;
return icarusY - ICARUS_RADIUS < gapTop || icarusY + ICARUS_RADIUS > gapBottom;
}
// Zone de ramassage volontairement plus large que le rond dessiné
// (BOOST_RADIUS) : à taille réelle, seul un vol quasi pile au centre du
// trou l'aurait touché, ce qui le rendait ramassé bien plus rarement que
// prévu malgré une fréquence d'apparition correcte.
export function collidesWithBoost(icarusY: number, boost: Boost): boolean {
const dx = boost.x - ICARUS_X;
const dy = boost.y - icarusY;
const combined = ICARUS_RADIUS + BOOST_COLLECT_RADIUS;
return dx * dx + dy * dy < combined * combined;
}
+44
View File
@@ -0,0 +1,44 @@
// Petit effet synthétisé via Web Audio, même principe que melon/sound.ts
// (pas de fichier audio dans le projet, cohérent avec le reste de l'app) :
// joué à la cueillette de l'éclair de Zeus — une montée rapide de
// fréquence en dents de scie, pour évoquer une charge d'énergie plutôt
// qu'un choc (le "pop" de fusion de la Corne, en onde sinusoïdale plus
// douce, joue un rôle différent).
let audioContext: AudioContext | null = null;
function getAudioContext(): AudioContext | null {
if (typeof window === "undefined") return null;
if (!audioContext) {
const Ctor =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctor) return null;
audioContext = new Ctor();
}
if (audioContext.state === "suspended") {
audioContext.resume().catch(() => {});
}
return audioContext;
}
export function playBoostSound(): void {
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
const oscillator = ctx.createOscillator();
const gain = ctx.createGain();
oscillator.type = "sawtooth";
oscillator.frequency.setValueAtTime(280, now);
oscillator.frequency.exponentialRampToValueAtTime(1400, now + 0.16);
gain.gain.setValueAtTime(0.0001, now);
gain.gain.exponentialRampToValueAtTime(0.18, now + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.22);
oscillator.connect(gain);
gain.connect(ctx.destination);
oscillator.start(now);
oscillator.stop(now + 0.24);
}
+16
View File
@@ -0,0 +1,16 @@
// gap est stocké par colonne (pas une constante globale) : la difficulté
// progressive resserre l'écart des colonnes créées après un certain score,
// sans changer la forme des colonnes déjà en place.
export type Column = { x: number; gapCenterY: number; gap: number; scored: boolean };
export type IcarusState = { y: number; velocityY: number };
// Éclair de Zeus ramassable, placé au centre du trou d'une colonne (donc
// toujours atteignable sans effort de vol supplémentaire) : casse les
// prochaines colonnes non encore franchies.
export type Boost = { id: number; x: number; y: number };
// Effet visuel transitoire (éclat d'une colonne détruite par un boost),
// horodaté avec le "now" de requestAnimationFrame pour calculer sa
// progression/disparition au rendu, sans state React ni re-render.
export type ShatterEffect = { id: number; x: number; y: number; createdAtMs: number };
+57 -2
View File
@@ -9,8 +9,28 @@ function loadImage(src: string): Promise<HTMLImageElement> {
}); });
} }
// Certains navigateurs (Safari, historiquement) ne savent pas encoder du
// WebP via canvas.toBlob/toDataURL : la spec impose alors un repli
// silencieux sur du PNG, un format quasi sans perte — beaucoup plus lourd
// qu'un JPEG pour une photo, au point de dépasser la limite de taille du
// bucket malgré la "compression" (vécu en prod avec une photo iPhone de
// 4 Mo). Détecter le support réel et demander explicitement du JPEG dans
// ce cas — plutôt que de laisser le navigateur retomber sur du PNG —
// garantit une vraie compression avec perte dans tous les cas.
let cachedMimeType: string | null = null;
function preferredMimeType(): string {
if (cachedMimeType) return cachedMimeType;
const canvas = document.createElement("canvas");
canvas.width = 1;
canvas.height = 1;
cachedMimeType = canvas.toDataURL("image/webp").startsWith("data:image/webp") ? "image/webp" : "image/jpeg";
return cachedMimeType;
}
// Découpe la zone sélectionnée, redimensionne (sans jamais agrandir) à // Découpe la zone sélectionnée, redimensionne (sans jamais agrandir) à
// maxSize et compresse en WebP côté client avant l'envoi vers le bucket. // maxSize et compresse (WebP si le navigateur sait l'encoder, sinon JPEG)
// côté client avant l'envoi vers le bucket.
export async function cropImageToBlob( export async function cropImageToBlob(
imageSrc: string, imageSrc: string,
crop: PixelCrop, crop: PixelCrop,
@@ -33,8 +53,43 @@ export async function cropImageToBlob(
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
canvas.toBlob( canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error("Échec de la compression de l'image"))), (blob) => (blob ? resolve(blob) : reject(new Error("Échec de la compression de l'image"))),
"image/webp", preferredMimeType(),
quality, quality,
); );
}); });
} }
// Redimensionne (sans jamais agrandir) au format d'origine — contrairement
// à cropImageToBlob qui force un carré pour les avatars — à maxDimension
// sur le plus grand côté, et compresse (WebP si supporté, sinon JPEG —
// voir preferredMimeType) avant l'envoi vers le bucket. Le bucket a aussi
// sa propre limite de taille côté serveur (défense en profondeur) : cette
// compression n'est qu'un premier filtre, pas la seule garantie.
export async function resizeImageToBlob(file: File, maxDimension = 1600, quality = 0.82): Promise<Blob> {
const src = URL.createObjectURL(file);
try {
const image = await loadImage(src);
const scale = Math.min(1, maxDimension / Math.max(image.width, image.height));
const outputWidth = Math.round(image.width * scale);
const outputHeight = Math.round(image.height * scale);
const canvas = document.createElement("canvas");
canvas.width = outputWidth;
canvas.height = outputHeight;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Canvas non supporté par ce navigateur");
}
ctx.drawImage(image, 0, 0, outputWidth, outputHeight);
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error("Échec de la compression de l'image"))),
preferredMimeType(),
quality,
);
});
} finally {
URL.revokeObjectURL(src);
}
}
+85
View File
@@ -0,0 +1,85 @@
// Zone de jeu en pixels CSS fixes (pas de canvas redimensionné comme Icare
// — un conteneur DOM de taille fixe suffit et évite toute logique de
// "world scale").
export const CONTAINER_WIDTH = 300;
export const CONTAINER_HEIGHT = 420;
export const WALL_THICKNESS = 20;
// Un palier par Citoyen existant (chacun associé à une taille fixe pour
// toute la partie, voir melon-game.tsx) plutôt qu'un nombre de paliers
// arbitraire : le nombre de tailles suit donc le nombre de Citoyens, pas
// une constante fixe. Rayon minimal + croissance géométrique assez marquée
// (×1.25 par palier — un ×1.19 précédent rendait deux paliers consécutifs
// trop proches en taille : une fusion semblait "changer le visage d'une
// pièce déjà posée" plutôt que "remplacer deux petites pièces par une
// nettement plus grosse"), plafonnée à MAX_RADIUS pour rester dans
// CONTAINER_WIDTH même avec beaucoup de Citoyens.
const MIN_RADIUS = 15;
const RADIUS_GROWTH = 1.25;
const MAX_RADIUS = 110;
export function buildTierRadii(tierCount: number): number[] {
const count = Math.max(1, tierCount);
return Array.from({ length: count }, (_, index) =>
Math.min(MAX_RADIUS, Math.round(MIN_RADIUS * RADIUS_GROWTH ** index)),
);
}
// Palier de départ pour la pièce à lâcher : parmi les plus petits
// uniquement (sinon les premières fusions seraient trop rares/tardives).
export const DROP_MIN_TIER = 0;
export const DROP_MAX_TIER_CAP = 3;
export const DROP_COOLDOWN_MS = 450;
// Ligne de débordement (depuis le haut du bac) + délai de grâce avant game
// over : évite qu'un simple rebond juste après une fusion ne déclenche une
// fin de partie instantanée.
export const DANGER_LINE_Y = CONTAINER_HEIGHT * 0.15;
export const GAME_OVER_GRACE_MS = 5000;
export const FIXED_DT_MS = 1000 / 60;
export const MAX_FRAME_DT_MS = 250;
export const MIN_SCORE = 0;
// Doit rester aligné avec la contrainte melon_scores_best_score_check
// (supabase/schema.sql) — celle-ci est la vraie garantie, cette constante
// n'est qu'une valeur de référence côté client.
export const MAX_SCORE = 2000;
// Positions figées à la main pour certains Citoyens (id de profil → index
// de palier, 0 = le plus petit) — les Citoyens restants remplissent les
// paliers non réservés, dans leur ordre existant (alphabétique par
// pseudo, voir corne/page.tsx). "Position N" telle que demandée par un
// humain (compte à partir de 1) correspond à l'index N-1 ici.
export const TIER_OVERRIDES: Record<number, string> = {
6: "7f0f24dc-ede0-4190-a64d-976393073c28", // Épicure, position 7
};
export function applyTierOverrides<T extends { id: string }>(
citizens: T[],
overrides: Record<number, string>,
): T[] {
const byId = new Map(citizens.map((c) => [c.id, c]));
const usedIds = new Set<string>();
const result: (T | undefined)[] = new Array(citizens.length);
for (const [tierKey, id] of Object.entries(overrides)) {
const tier = Number(tierKey);
const citizen = byId.get(id);
if (citizen && tier >= 0 && tier < citizens.length) {
result[tier] = citizen;
usedIds.add(id);
}
}
const remaining = citizens.filter((c) => !usedIds.has(c.id));
let remainingIndex = 0;
for (let tier = 0; tier < result.length; tier++) {
if (!result[tier]) {
result[tier] = remaining[remainingIndex++];
}
}
return result as T[];
}
+50
View File
@@ -0,0 +1,50 @@
// Petit "pop" synthétisé via Web Audio (pas de fichier audio dans le
// projet, cohérent avec le reste de l'app qui dessine tout plutôt que de
// dépendre d'assets externes — voir les ornements SVG/canvas ailleurs).
// AudioContext créé paresseusement au premier appel : le construire avant
// une interaction utilisateur déclenche des avertissements navigateur
// (autoplay policy) — ici le premier merge n'arrive jamais avant un
// premier lâcher, donc toujours après un vrai geste utilisateur.
let audioContext: AudioContext | null = null;
function getAudioContext(): AudioContext | null {
if (typeof window === "undefined") return null;
if (!audioContext) {
const Ctor =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctor) return null;
audioContext = new Ctor();
}
if (audioContext.state === "suspended") {
audioContext.resume().catch(() => {});
}
return audioContext;
}
// Fréquence plus grave à mesure que le palier de la pièce issue de la
// fusion monte : deux petites pièces qui fusionnent ne doit pas "sonner"
// comme deux grosses.
export function playMergeSound(tier: number, maxTier: number): void {
const ctx = getAudioContext();
if (!ctx) return;
const progress = maxTier > 0 ? Math.min(1, Math.max(0, tier / maxTier)) : 0;
const baseFreq = 720 - progress * 380;
const now = ctx.currentTime;
const oscillator = ctx.createOscillator();
const gain = ctx.createGain();
oscillator.type = "sine";
oscillator.frequency.setValueAtTime(baseFreq, now);
oscillator.frequency.exponentialRampToValueAtTime(baseFreq * 1.6, now + 0.08);
gain.gain.setValueAtTime(0.0001, now);
gain.gain.exponentialRampToValueAtTime(0.25, now + 0.01);
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.18);
oscillator.connect(gain);
gain.connect(ctx.destination);
oscillator.start(now);
oscillator.stop(now + 0.2);
}
+75
View File
@@ -0,0 +1,75 @@
// Mentions "@Pseudo" dans le texte d'une question du Char — repère qui est
// visé par une question sans ajouter de colonne : le pseudo mentionné est
// retrouvé par simple correspondance de texte contre la liste des membres
// (pas de state séparé à synchroniser avec une édition du texte).
export type MentionRange = { start: number; end: number; pseudo: string };
function isWordChar(char: string | undefined): boolean {
return !!char && /[\p{L}\p{N}_]/u.test(char);
}
// Les pseudos les plus longs sont recherchés en premier pour qu'un pseudo
// qui en préfixe un autre (ex. "Priape" / "Priapee") ne vole pas le match
// du plus long à l'endroit où il apparaît vraiment.
export function findMentionRanges(text: string, pseudos: string[]): MentionRange[] {
const candidates = [...new Set(pseudos)].filter(Boolean).sort((a, b) => b.length - a.length);
const lowerText = text.toLowerCase();
const claimed = new Array<boolean>(text.length).fill(false);
const ranges: MentionRange[] = [];
for (const pseudo of candidates) {
const needle = `@${pseudo.toLowerCase()}`;
let fromIndex = 0;
while (fromIndex <= lowerText.length) {
const idx = lowerText.indexOf(needle, fromIndex);
if (idx === -1) break;
fromIndex = idx + 1;
const end = idx + needle.length;
if (isWordChar(text[end])) continue;
if (claimed.slice(idx, end).some(Boolean)) continue;
for (let i = idx; i < end; i++) claimed[i] = true;
ranges.push({ start: idx, end, pseudo });
}
}
return ranges.sort((a, b) => a.start - b.start);
}
export function extractMentionedPseudos(text: string, pseudos: string[]): string[] {
return findMentionRanges(text, pseudos).map((range) => range.pseudo);
}
// Retire les "@Pseudo" du texte affiché (ils vivent à part, en métadonnée à
// côté de la question — un peu comme une réponse — plutôt qu'au milieu de
// la phrase) et renvoie les pseudos visés, dans leur ordre d'apparition et
// sans doublon si une même personne est taguée plusieurs fois.
export function stripMentions(text: string, pseudos: string[]): { cleanText: string; mentioned: string[] } {
const ranges = findMentionRanges(text, pseudos);
if (ranges.length === 0) return { cleanText: text, mentioned: [] };
let cleanText = "";
let cursor = 0;
const seen = new Set<string>();
const mentioned: string[] = [];
for (const range of ranges) {
cleanText += text.slice(cursor, range.start);
if (!seen.has(range.pseudo)) {
seen.add(range.pseudo);
mentioned.push(range.pseudo);
}
cursor = range.end;
}
cleanText += text.slice(cursor);
// Nettoie les espaces laissés par les mentions retirées (double espace
// au milieu de la phrase, espace parasite avant une ponctuation, bords).
cleanText = cleanText
.replace(/[ \t]{2,}/g, " ")
.replace(/\s+([,.!?;:])/g, "$1")
.trim();
return { cleanText, mentioned };
}
+16 -10
View File
@@ -1,21 +1,27 @@
export type RankedProfile<T extends { points: number }> = T & { rank: number }; export type Ranked<T> = T & { rank: number };
export type RankedProfile<T extends { points: number }> = Ranked<T>;
// Classement "compétition" : les ex-aequo partagent le même rang, et le // Classement "compétition" générique : les ex-aequo (selon le comparateur)
// rang suivant saute en conséquence (1, 2, 2, 4 — pas 1, 2, 2, 3). // partagent le même rang, et le rang suivant saute en conséquence
export function computeRanks<T extends { points: number }>(profiles: T[]): RankedProfile<T>[] { // (1, 2, 2, 4 — pas 1, 2, 2, 3).
const sorted = [...profiles].sort((a, b) => b.points - a.points); export function computeRanksBy<T>(items: T[], compare: (a: T, b: T) => number): Ranked<T>[] {
let lastPoints: number | null = null; const sorted = [...items].sort(compare);
let lastItem: T | null = null;
let lastRank = 0; let lastRank = 0;
return sorted.map((profile, index) => { return sorted.map((item, index) => {
if (lastPoints === null || profile.points !== lastPoints) { if (lastItem === null || compare(item, lastItem) !== 0) {
lastRank = index + 1; lastRank = index + 1;
lastPoints = profile.points; lastItem = item;
} }
return { ...profile, rank: lastRank }; return { ...item, rank: lastRank };
}); });
} }
export function computeRanks<T extends { points: number }>(profiles: T[]): RankedProfile<T>[] {
return computeRanksBy(profiles, (a, b) => b.points - a.points);
}
const ROMAN_DIGITS: [number, string][] = [ const ROMAN_DIGITS: [number, string][] = [
[10, "X"], [10, "X"],
[9, "IX"], [9, "IX"],
+16
View File
@@ -1,4 +1,5 @@
import { createBrowserClient } from "@supabase/ssr"; import { createBrowserClient } from "@supabase/ssr";
import type { SupabaseClient } from "@supabase/supabase-js";
export function createClient() { export function createClient() {
return createBrowserClient( return createBrowserClient(
@@ -6,3 +7,18 @@ export function createClient() {
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
); );
} }
// createClient() charge la session (et donc le token Realtime) de façon
// asynchrone après coup : un .channel().subscribe() lancé juste après
// rejoint le canal encore en rôle "anon", et les policies RLS
// "to authenticated" ne laissent alors jamais passer aucun événement
// (le canal s'abonne "avec succès" mais ne reçoit plus rien ensuite).
// À appeler avant tout .channel(...).subscribe().
export async function waitForRealtimeAuth(supabase: SupabaseClient) {
const {
data: { session },
} = await supabase.auth.getSession();
if (session) {
supabase.realtime.setAuth(session.access_token);
}
}
+38 -2
View File
@@ -1,8 +1,23 @@
import { createServerClient } from "@supabase/ssr"; import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server"; import { NextResponse, type NextRequest } from "next/server";
const PROTECTED_PATHS = ["/leaderboard", "/profile", "/admin", "/journal", "/roulette", "/calendrier"]; const PROTECTED_PATHS = [
const AUTH_PATHS = ["/login", "/signup"]; "/leaderboard",
"/profile",
"/admin",
"/journal",
"/calendrier",
"/icare",
"/char",
"/mur",
"/urne",
"/corne",
];
// /signup n'est PAS dans AUTH_PATHS : un compte fraîchement invité a déjà une
// session (établie par le lien d'invitation) mais doit pouvoir rester sur
// /signup pour finaliser mot de passe/pseudo/photo sans être renvoyé au
// leaderboard par ce redirect avant d'avoir terminé.
const AUTH_PATHS = ["/login"];
export async function updateSession(request: NextRequest) { export async function updateSession(request: NextRequest) {
let response = NextResponse.next({ request }); let response = NextResponse.next({ request });
@@ -33,6 +48,7 @@ export async function updateSession(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
const isProtected = PROTECTED_PATHS.some((path) => pathname.startsWith(path)); const isProtected = PROTECTED_PATHS.some((path) => pathname.startsWith(path));
const isAuthPath = AUTH_PATHS.some((path) => pathname.startsWith(path)); const isAuthPath = AUTH_PATHS.some((path) => pathname.startsWith(path));
const isSignup = pathname.startsWith("/signup");
if (!user && isProtected) { if (!user && isProtected) {
const url = request.nextUrl.clone(); const url = request.nextUrl.clone();
@@ -40,6 +56,26 @@ export async function updateSession(request: NextRequest) {
return NextResponse.redirect(url); return NextResponse.redirect(url);
} }
// Le token d'un lien d'invitation Supabase s'établit dès qu'il touche le
// navigateur (detectSessionInUrl du SDK), sur N'IMPORTE QUELLE page, pas
// seulement /signup. Si le compte n'a pas encore fini son inscription
// (pseudo toujours au placeholder du trigger handle_new_user), on le
// renvoie systématiquement vers /signup pour ne jamais le laisser bloqué
// connecté mais sans mot de passe choisi.
if (user && !isSignup) {
const { data: profile } = await supabase
.from("profiles")
.select("pseudo")
.eq("id", user.id)
.single();
if (profile?.pseudo.startsWith("Nouveau membre")) {
const url = request.nextUrl.clone();
url.pathname = "/signup";
return NextResponse.redirect(url);
}
}
if (user && isAuthPath) { if (user && isAuthPath) {
const url = request.nextUrl.clone(); const url = request.nextUrl.clone();
url.pathname = "/leaderboard"; url.pathname = "/leaderboard";
+28
View File
@@ -0,0 +1,28 @@
// Partagé entre /calendrier (édition de la date par les Archontes) et
// /leaderboard (affichage seul) — évite de dupliquer la même logique de
// décompte à deux endroits.
export function formatCountdown(tribunalDate: string | null): string {
if (!tribunalDate) return "Aucune date n'a encore été fixée par les Archontes.";
const target = new Date(tribunalDate);
const now = new Date();
const diffMs = target.getTime() - now.getTime();
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
if (diffMs < 0) return "Le Tribunal a rendu son verdict.";
if (diffDays === 0) return "Le Tribunal siège aujourd'hui !";
if (diffDays === 1) return "Le Tribunal siège demain.";
return `Le Tribunal siège dans ${diffDays} jours.`;
}
export function formatTribunalDateTime(tribunalDate: string): string {
const label = new Date(tribunalDate).toLocaleString("fr-FR", {
weekday: "long",
day: "numeric",
month: "long",
hour: "2-digit",
minute: "2-digit",
timeZone: "Europe/Paris",
});
return label.charAt(0).toUpperCase() + label.slice(1);
}
+1766 -4
View File
File diff suppressed because it is too large Load Diff