Compare commits

..

8 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
31 changed files with 972 additions and 178 deletions
+14 -6
View File
@@ -27,6 +27,8 @@ V1 à V15 sont livrées :
- **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.
---
@@ -38,6 +40,7 @@ Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons
- **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)
- **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.
@@ -179,11 +182,11 @@ Deuxième volet de l'audit (revue complète de tout le code applicatif, pas seul
| `/` | Redirige vers `/leaderboard` si connecté, sinon `/login` |
| `/login` | Email + mot de passe |
| `/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, 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) |
| `/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 |
| `/icare` | « Le Vol d'Icare » — Flappy Bird grec, record personnel all-time, scores figés au Tribunal, points attribués automatiquement |
| `/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/afficher-masquer) + 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 |
| `/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 |
@@ -217,11 +220,16 @@ Polices (`next/font/google`, variables CSS, exposées comme `font-heading`/`font
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.
- `.meander-divider` (classe globale) — frise à méandre grecque en `background-image` SVG data-URI, séparateur discret.
- `components/diamond-divider.tsx` — séparateur ◆ avec filets dégradés.
- `.marble-surface` (classe globale) — texture marbre via dégradés (pas d'image de bruit).
- `.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. 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). 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`.
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).
+56
View File
@@ -8,6 +8,9 @@
"name": "tribunal-app",
"version": "0.1.0",
"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/supabase-js": "^2.109.0",
"matter-js": "^0.20.0",
@@ -281,6 +284,59 @@
"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": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+3
View File
@@ -9,6 +9,9 @@
"lint": "eslint"
},
"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/supabase-js": "^2.109.0",
"matter-js": "^0.20.0",
+13 -2
View File
@@ -2,6 +2,8 @@
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";
@@ -83,6 +85,12 @@ export function CharView({
}, [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">
@@ -100,7 +108,7 @@ export function CharView({
)}
</div>
{revealedQuestion && (
{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"
@@ -108,7 +116,10 @@ export function CharView({
<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">{revealedQuestion.text}</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>
)}
+1 -1
View File
@@ -25,7 +25,7 @@ export function EntryRanking({
<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-gold-bright">{count > 0 ? `×${count}` : "—"}</span>
<span className="ml-auto shrink-0 font-heading text-sea">{count > 0 ? `×${count}` : "—"}</span>
</li>
);
})}
+5
View File
@@ -0,0 +1,5 @@
import { PageLoading } from "@/components/page-loading";
export default function Loading() {
return <PageLoading />;
}
+15
View File
@@ -23,6 +23,12 @@ export type SubmissionHistoryRow = {
created_at: string;
};
export type ProfileRow = {
id: string;
pseudo: string;
avatar_url: string | null;
};
export default async function CharQuestionsPage() {
const supabase = await createClient();
const {
@@ -71,6 +77,14 @@ export default async function CharQuestionsPage() {
.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">
@@ -90,6 +104,7 @@ export default async function CharQuestionsPage() {
initialRevealedId={settings?.chariot_revealed_question_id ?? null}
initialSubmissions={submissions ?? []}
initialSubmissionHistory={submissionHistory ?? []}
profiles={profiles ?? []}
/>
</div>
);
+186 -32
View File
@@ -1,10 +1,16 @@
"use client";
import { useCallback, useEffect, useState } from "react";
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, IconPencil, IconPlus, IconTrash } from "@/components/icons";
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 { QuestionRow, SubmissionHistoryRow, SubmissionRow } from "./page";
import type { ProfileRow, QuestionRow, SubmissionHistoryRow, SubmissionRow } from "./page";
function formatHistoryDate(iso: string): string {
return new Date(iso).toLocaleString("fr-FR", {
@@ -17,10 +23,12 @@ function formatHistoryDate(iso: string): string {
function QuestionForm({
initial,
nextPosition,
profiles,
onDone,
}: {
initial?: QuestionRow;
nextPosition: number;
profiles: ProfileRow[];
onDone: () => void;
}) {
const [text, setText] = useState(initial?.text ?? "");
@@ -51,12 +59,13 @@ function QuestionForm({
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-2 rounded-md border border-gold/30 bg-white/40 p-3">
<textarea
placeholder="Écrire la question…"
<MentionTextarea
value={text}
onChange={(e) => setText(e.target.value)}
onChange={setText}
candidates={profiles}
rows={2}
className="rounded-md border border-gold/30 bg-white/60 px-2 py-1.5 text-sm text-text-marble"
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">
@@ -85,6 +94,8 @@ function QuestionRowItem({
isFirst,
isLast,
isRevealed,
pseudos,
profiles,
onMove,
onToggleReveal,
onChanged,
@@ -94,12 +105,20 @@ function QuestionRowItem({
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();
@@ -109,19 +128,40 @@ function QuestionRowItem({
if (editing) {
return (
<QuestionForm
initial={question}
nextPosition={question.position}
onDone={() => {
setEditing(false);
onChanged();
}}
/>
<div ref={setNodeRef} style={style}>
<QuestionForm
initial={question}
nextPosition={question.position}
profiles={profiles}
onDone={() => {
setEditing(false);
onChanged();
}}
/>
</div>
);
}
return (
<li className={`flex items-start gap-2 rounded-md px-2 py-1.5 text-sm ${isRevealed ? "bg-gold/10" : ""}`}>
<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">
@@ -145,7 +185,10 @@ function QuestionRowItem({
</button>
</div>
<p className="min-w-0 flex-1 text-text-marble">{question.text}</p>
<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
@@ -276,17 +319,38 @@ export function QuestionsView({
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();
@@ -346,6 +410,37 @@ export function QuestionsView({
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;
@@ -398,30 +493,89 @@ export function QuestionsView({
</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>
)}
<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}
onMove={(direction) => moveQuestion(question, direction)}
onToggleReveal={() => toggleReveal(question)}
onChanged={refetch}
/>
))}
</ul>
<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();
+17 -15
View File
@@ -122,25 +122,27 @@ export function CorneView({
return (
<div className="mx-auto flex w-full max-w-2xl flex-col items-center gap-4 px-4 py-8">
<div className="text-center">
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">
{/* 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>
<p className="font-serif text-sm text-marble/60 italic">
Fais fusionner les Citoyens jusqu&apos;au débordement.
</p>
<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>
<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>
<MelonGame citizens={citizens} onStart={handleStart} onFinish={handleFinish} />
<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">
+79 -30
View File
@@ -17,6 +17,7 @@ import {
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";
@@ -31,6 +32,54 @@ 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;
@@ -51,6 +100,13 @@ export function MelonGame({
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
@@ -324,6 +380,15 @@ export function MelonGame({
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) {
@@ -422,6 +487,9 @@ export function MelonGame({
// 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 }}>
@@ -471,36 +539,15 @@ export function MelonGame({
className="pointer-events-none absolute top-0 bottom-0 w-px border-l border-dashed border-gold/40"
/>
{pieces.map((piece) => {
const citizen = citizens[piece.tier];
const diameter = tierRadii[piece.tier] * 2;
return (
<div
key={piece.id}
ref={(node) => {
if (node) nodeRefs.current.set(piece.id, node);
}}
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>
);
})}
{pieces.map((piece) => (
<PieceView
key={piece.id}
piece={piece}
citizen={citizens[piece.tier]}
diameter={tierRadii[piece.tier] * 2}
registerNode={registerPieceNode}
/>
))}
<div
ref={(node) => {
@@ -525,6 +572,8 @@ export function MelonGame({
/>
</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">
+49 -1
View File
@@ -41,10 +41,22 @@
--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 {
--sea-glow: 30%;
background:
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);
background-attachment: fixed;
color: var(--foreground);
@@ -112,6 +124,42 @@ body {
}
@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 {
0% {
opacity: 0;
+3 -52
View File
@@ -43,11 +43,11 @@ import {
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";
const CONFETTI_COLORS = ["#E7C560", "#C9A227", "#F4ECD8", "#A5342A", "#5E6B3B", "#2E6E7E", "#B08D57"];
// 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 {
@@ -499,6 +499,7 @@ export function IcarusGame({
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
@@ -637,56 +638,6 @@ export function IcarusGame({
);
}
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,
}));
}
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 React.CSSProperties
}
/>
))}
</div>
);
}
function render(
ctx: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
+7 -2
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
import { EmptyState } from "@/components/empty-state";
type Entry = {
id: number;
@@ -56,7 +57,7 @@ export function JournalView({
}, []);
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 (
@@ -72,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"
>
<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
/>
<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>
);
}
+6 -1
View File
@@ -5,6 +5,7 @@ import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
import { Avatar } from "@/components/avatar";
import { Podium } from "@/components/podium";
import { JudgePointControls } from "@/components/judge-point-controls";
import { EmptyState } from "@/components/empty-state";
import { computeRanks, computeProgress } from "@/lib/ranking";
type Profile = {
@@ -94,7 +95,7 @@ export function LeaderboardView({
const rest = hasScores ? ranked.filter((p) => p.rank > 3) : ranked;
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 (
@@ -107,6 +108,10 @@ export function LeaderboardView({
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">
{rest.map((profile) => {
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 />;
}
+30 -12
View File
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
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";
@@ -15,22 +16,37 @@ export default async function LeaderboardPage() {
redirect("/login");
}
const [{ data: profiles, error }, { data: settings }, { data: wallNotesToday }] = await Promise.all([
supabase
.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"),
]);
const [{ data: profiles, error }, { data: settings }, { data: wallNotesToday }, { data: icarusTop }, { data: melonTop }] =
await Promise.all([
supabase
.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 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 (
<div className="mx-auto w-full max-w-2xl px-4 py-8">
<div className="mb-6 flex flex-col items-center text-center">
@@ -44,6 +60,8 @@ export default async function LeaderboardPage() {
<QuickLinks wallNoteCount={wallNotesToday?.length ?? 0} />
<GameHighlights icarusLeader={icarusLeader} melonLeader={melonLeader} />
{error && (
<p role="alert" className="text-sm text-oxblood">
Impossible de charger le classement pour le moment.
+5
View File
@@ -0,0 +1,5 @@
import { PageLoading } from "@/components/page-loading";
export default function Loading() {
return <PageLoading />;
}
+6 -6
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState, type ChangeEvent, type FormEvent } fr
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";
@@ -434,9 +435,7 @@ export function MurView({
{pastLoading ? (
<p className="text-center text-sm text-marble/60">Chargement</p>
) : displayedNotes.length === 0 ? (
<p className="text-center text-sm text-marble/60">
{viewedDate === null ? "Le mur est encore vide aujourd'hui." : "Le mur était vide ce jour-là."}
</p>
<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) => (
@@ -558,9 +557,10 @@ export function MurView({
)}
</div>
{filteredHistory.length === 0 ? (
<p className="text-sm text-text-mut">
{history.length === 0 ? "Aucune note publiée pour l'instant." : "Aucune note ce jour-là."}
</p>
<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) => (
+14 -6
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { DiamondDivider } from "@/components/diamond-divider";
import { ProfileForm } from "./profile-form";
import { ChariotSubmissionForm } from "./chariot-submission-form";
@@ -58,12 +59,19 @@ export default async function ProfilePage() {
</div>
{!isJudge && (
<div className="marble-surface mt-4 rounded-2xl border border-gold/40 p-6 shadow-xl sm:p-8">
<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>
<>
{/* 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>
);
+7 -3
View File
@@ -64,14 +64,18 @@ export function ProfileForm({
let nextAvatarUrl = avatarUrl;
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
.from("avatars")
.upload(path, avatarBlob, { upsert: true, contentType: "image/webp" });
.upload(path, avatarBlob, { upsert: true, contentType: avatarBlob.type || "image/webp" });
if (uploadError) {
setLoading(false);
setError("Impossible d'envoyer la photo, réessaie.");
setError(`Impossible d'envoyer la photo (${uploadError.message}). Réessaie.`);
return;
}
+11 -2
View File
@@ -120,14 +120,23 @@ export default function SignupPage() {
let avatarUrl: string | null = null;
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
.from("avatars")
.upload(path, avatarBlob, { upsert: true, contentType: "image/webp" });
.upload(path, avatarBlob, { upsert: true, contentType: avatarBlob.type || "image/webp" });
if (!uploadError) {
const { data: publicUrlData } = supabase.storage.from("avatars").getPublicUrl(path);
avatarUrl = publicUrlData.publicUrl;
} else {
// Ne bloque pas la finalisation du compte pour une photo optionnelle
// — 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);
}
}
+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>
);
}
+24
View File
@@ -306,3 +306,27 @@ export function IconHorn({ className = base }: IconProps) {
</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>
);
}
+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);
}
+27 -7
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) à
// 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(
imageSrc: string,
crop: PixelCrop,
@@ -33,7 +53,7 @@ export async function cropImageToBlob(
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error("Échec de la compression de l'image"))),
"image/webp",
preferredMimeType(),
quality,
);
});
@@ -41,10 +61,10 @@ export async function cropImageToBlob(
// 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 en WebP 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.
// 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 {
@@ -65,7 +85,7 @@ export async function resizeImageToBlob(file: File, maxDimension = 1600, quality
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error("Échec de la compression de l'image"))),
"image/webp",
preferredMimeType(),
quality,
);
});
+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 };
}