Compare commits
36 Commits
e542b09807
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c09c0cdd4 | |||
| 5b5393cbd6 | |||
| 3f594768b0 | |||
| 058b1ec140 | |||
| 4f920175fd | |||
| e70f0c85bb | |||
| 358bed0c2e | |||
| 187cdec0e2 | |||
| 3aa2ea451c | |||
| 6922d33e39 | |||
| 0eaebb7a05 | |||
| 203afafec2 | |||
| 4532403c95 | |||
| ede1d6c74d | |||
| 5e75939b94 | |||
| 40a10170bb | |||
| 9f91856c53 | |||
| 00743067cc | |||
| 088cd92e5f | |||
| 50b354f11c | |||
| 6134bf22d0 | |||
| b7f3f8b0fa | |||
| 35704e50fc | |||
| dc99af6bb3 | |||
| 3503dc2593 | |||
| b0bf54f20c | |||
| 05aa15cec0 | |||
| 4dd3dbef85 | |||
| 493ae444b4 | |||
| 3b64e1cdb5 | |||
| c3a3577769 | |||
| bdeeb68845 | |||
| b6a025c23f | |||
| 47ca05f1c6 | |||
| e8a9a0ddb9 | |||
| 0dfd3e7e90 |
@@ -6,17 +6,28 @@ Application web pour un groupe d'amis (~12 personnes) qui anime un jeu et des ol
|
||||
|
||||
## 1. État actuel
|
||||
|
||||
V1 à V10 sont livrées :
|
||||
V1 à V15 sont livrées :
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -28,6 +39,8 @@ 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`)
|
||||
- **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.
|
||||
@@ -64,13 +77,24 @@ Le script est **idempotent** : toujours le ré-exécuter en entier après une mo
|
||||
### `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 ouverte, écriture réservée aux juges (comme `days`/`events`). Gérée exclusivement depuis `/char/questions`, jamais depuis `/char`.
|
||||
- `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. Même RLS que `chariot_questions`.
|
||||
- `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.
|
||||
@@ -95,31 +119,53 @@ Même pattern sur `public.settings` depuis V7, via `enforce_settings_update()` :
|
||||
|
||||
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
|
||||
|
||||
- `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.
|
||||
- `reset_rank_reference()` — authenticated, vérifie `role = 'judge'`, fige le classement courant dans `previous_rank`.
|
||||
- `submit_icarus_score(p_score)` — authenticated, 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), n'écrase le record que s'il est strictement battu.
|
||||
- `award_icarus_points_if_due()` — **pas de grant à authenticated**, 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`).
|
||||
- ~~`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_today()` — authenticated, `language sql`, renvoie les notes du jour (heure de Paris) **sans `user_id`** — seul moyen de laisser un Citoyen voir les notes des autres sans jamais exposer l'auteur côté client (la RLS ne peut pas masquer une colonne pour certaines lignes).
|
||||
- `post_wall_note(p_text)` — authenticated, rejette les juges (seuls les Citoyens publient), 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 10 notes (sinon `raise exception` dans les deux cas — pas de no-op silencieux ici, l'UI doit remonter l'erreur).
|
||||
- `urn_vote_counts()` — authenticated, `language sql`, renvoie `(target_id, votes)` agrégé sur tout l'historique (pas de filtre de date, contrairement à `wall_notes_today`) — seul moyen de calculer un total public sans jamais exposer une ligne individuelle (qui a voté pour qui).
|
||||
- `cast_urn_vote(p_target_id)` — 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.
|
||||
- `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, created_at` — une ligne par note, jamais éditée/supprimée (comme `points_log`) ; le « reset quotidien » n'est qu'un filtrage par date dans `wall_notes_today()`, pas une suppression.
|
||||
- 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_today()`.
|
||||
- `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, 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.
|
||||
- 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é, identique pour tout le monde.
|
||||
- `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.
|
||||
|
||||
---
|
||||
|
||||
@@ -136,18 +182,19 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè
|
||||
| `/` | 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`) |
|
||||
| `/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 |
|
||||
| `/mur` | « Le Mur de la Honte » — une note anonyme par Citoyen par jour (dix places, remis à zéro chaque jour) ; les Archontes y voient en plus l'historique complet avec l'auteur de chaque note |
|
||||
| `/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 |
|
||||
| `/profile` | Pseudo (verrouillable), photo, déconnexion, et pour les Citoyens : proposition d'une question pour Le Char (modifiable jusqu'au Tribunal) |
|
||||
| `/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`, `/calendrier`, `/icare`, `/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).
|
||||
`/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.
|
||||
|
||||
@@ -173,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).
|
||||
|
||||
|
||||
Generated
+71
@@ -8,8 +8,12 @@
|
||||
"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",
|
||||
"next": "16.2.10",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
@@ -17,6 +21,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/matter-js": "^0.20.2",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@@ -279,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",
|
||||
@@ -1664,6 +1722,13 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "20.19.43",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||
@@ -5111,6 +5176,12 @@
|
||||
"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": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
|
||||
@@ -9,8 +9,12 @@
|
||||
"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",
|
||||
"next": "16.2.10",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
@@ -18,6 +22,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/matter-js": "^0.20.2",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+46
-2
@@ -2,6 +2,33 @@ import { redirect } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { jetbrainsMono } from "@/lib/fonts";
|
||||
import { AdminMembersTable } from "./admin-members-table";
|
||||
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() {
|
||||
const supabase = await createClient();
|
||||
@@ -25,7 +52,23 @@ export default async function AdminPage() {
|
||||
redirect("/leaderboard");
|
||||
}
|
||||
|
||||
const { data: members } = await supabase.rpc("admin_list_members");
|
||||
const [{ data: members }, { data: cheatFlags }] = await Promise.all([
|
||||
supabase.rpc("admin_list_members"),
|
||||
supabase
|
||||
.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 (
|
||||
<div className="mx-auto w-full max-w-3xl px-4 py-8">
|
||||
@@ -35,7 +78,8 @@ export default async function AdminPage() {
|
||||
</h1>
|
||||
<p className="font-serif text-sm text-marble/60 italic">Administration des Citoyens</p>
|
||||
</div>
|
||||
<AdminMembersTable members={members ?? []} currentUserId={user.id} />
|
||||
<AdminMembersTable members={memberRows} currentUserId={user.id} />
|
||||
<AdminCheatFlags flags={flagsWithPseudo} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+25
-17
@@ -2,48 +2,48 @@
|
||||
|
||||
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, QuestionRow, SettingsRow, SlotRow } from "./page";
|
||||
import type { ProfileRow, RevealedQuestion, SettingsRow, SlotRow } from "./page";
|
||||
import { SlotBoard } from "./slot-board";
|
||||
|
||||
export function CharView({
|
||||
isJudge,
|
||||
initialSlots,
|
||||
initialEntryCounts,
|
||||
initialQuestions,
|
||||
initialRevealedQuestion,
|
||||
initialSettings,
|
||||
initialProfiles,
|
||||
}: {
|
||||
isJudge: boolean;
|
||||
initialSlots: SlotRow[];
|
||||
initialEntryCounts: Record<string, number>;
|
||||
initialQuestions: QuestionRow[];
|
||||
initialRevealedQuestion: RevealedQuestion;
|
||||
initialSettings: SettingsRow;
|
||||
initialProfiles: ProfileRow[];
|
||||
}) {
|
||||
const [slots, setSlots] = useState(initialSlots);
|
||||
const [entryCounts, setEntryCounts] = useState(initialEntryCounts);
|
||||
const [questions, setQuestions] = useState(initialQuestions);
|
||||
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 chariot_questions/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.
|
||||
// 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
|
||||
.from("chariot_questions")
|
||||
.select("id, text, position, created_at")
|
||||
.order("position", { ascending: true })
|
||||
.order("created_at", { ascending: true }),
|
||||
supabase.rpc("chariot_revealed_question"),
|
||||
supabase
|
||||
.from("settings")
|
||||
.select("chariot_revealed_question_id, gardien_holder_id, gardien_expires_at")
|
||||
@@ -57,7 +57,7 @@ export function CharView({
|
||||
for (const entry of en) counts[entry.user_id] = (counts[entry.user_id] ?? 0) + 1;
|
||||
setEntryCounts(counts);
|
||||
}
|
||||
if (q) setQuestions(q);
|
||||
setRevealedQuestion((q?.[0] as RevealedQuestion) ?? null);
|
||||
if (s) setSettings(s);
|
||||
if (p) setProfiles(p);
|
||||
}, []);
|
||||
@@ -84,8 +84,13 @@ export function CharView({
|
||||
};
|
||||
}, [refetch]);
|
||||
|
||||
const revealedQuestion = questions.find((q) => q.id === settings.chariot_revealed_question_id) ?? null;
|
||||
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">
|
||||
@@ -103,15 +108,18 @@ 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"
|
||||
>
|
||||
<p className="mb-1 font-heading text-xs tracking-[0.2em] text-text-mut uppercase sm:text-sm">
|
||||
Question {questions.findIndex((q) => q.id === revealedQuestion.id) + 1}
|
||||
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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageLoading } from "@/components/page-loading";
|
||||
|
||||
export default function Loading() {
|
||||
return <PageLoading />;
|
||||
}
|
||||
+9
-10
@@ -10,12 +10,14 @@ export type ProfileRow = {
|
||||
points: number;
|
||||
};
|
||||
|
||||
export type QuestionRow = {
|
||||
// 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;
|
||||
position: number;
|
||||
created_at: string;
|
||||
};
|
||||
question_number: number;
|
||||
} | null;
|
||||
|
||||
export type SlotRow = {
|
||||
slot: number;
|
||||
@@ -60,11 +62,8 @@ export default async function CharPage() {
|
||||
|
||||
const { data: entries } = await supabase.from("chariot_entries").select("user_id");
|
||||
|
||||
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: revealedRows } = await supabase.rpc("chariot_revealed_question");
|
||||
const revealedQuestion: RevealedQuestion = (revealedRows?.[0] as RevealedQuestion) ?? null;
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from("settings")
|
||||
@@ -83,7 +82,7 @@ export default async function CharPage() {
|
||||
isJudge={isJudge}
|
||||
initialSlots={(slots ?? []) as SlotRow[]}
|
||||
initialEntryCounts={buildEntryCounts(entries ?? [])}
|
||||
initialQuestions={questions ?? []}
|
||||
initialRevealedQuestion={revealedQuestion}
|
||||
initialSettings={
|
||||
settings ?? { chariot_revealed_question_id: null, gardien_holder_id: null, gardien_expires_at: null }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<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,10 +493,64 @@ 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'un pour l'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'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
|
||||
@@ -411,17 +560,22 @@ export function QuestionsView({
|
||||
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();
|
||||
|
||||
@@ -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'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'a encore joué.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+84
-1
@@ -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;
|
||||
@@ -165,6 +213,41 @@ body {
|
||||
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 {
|
||||
|
||||
+108
-16
@@ -1,12 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
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 } from "./icarus-game";
|
||||
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,
|
||||
@@ -18,6 +29,20 @@ export function IcareView({
|
||||
}) {
|
||||
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();
|
||||
@@ -64,54 +89,121 @@ export function IcareView({
|
||||
};
|
||||
}, [refetch]);
|
||||
|
||||
async function handleFinish(score: number) {
|
||||
function handleStart() {
|
||||
const supabase = createClient();
|
||||
const { data, error } = await supabase.rpc("submit_icarus_score", { p_score: score });
|
||||
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="flex flex-col gap-6">
|
||||
<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'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="marble-surface rounded-lg border border-gold/40 px-4 py-2 text-center text-sm text-text-marble">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{ranked.length > 0 && (
|
||||
{podiumProfiles.length > 0 && (
|
||||
<Podium profiles={podiumProfiles} renderValue={(member) => member.best_score} />
|
||||
)}
|
||||
|
||||
{rest.length > 0 && (
|
||||
<ol className="flex flex-col gap-1.5">
|
||||
{ranked.map((entry) => (
|
||||
{rest.map((entry) => (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="marble-surface flex items-center gap-3 rounded-lg border border-gold/25 px-3 py-2 shadow-sm"
|
||||
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>
|
||||
<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-gold-bright">{entry.best_score}</span>
|
||||
<span className="font-heading text-sm font-semibold text-sea">{entry.best_score}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
{ownBestScore !== null && (
|
||||
<p className="text-center font-heading text-sm tracking-wide text-marble">
|
||||
Ton record : <span className="text-gold-bright">{ownBestScore}</span>
|
||||
<p className="mt-4 text-center font-heading text-sm tracking-wide text-text-marble">
|
||||
Ton record : <span className="text-sea">{ownBestScore}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<IcarusGame onFinish={handleFinish} />
|
||||
|
||||
<p className="text-center text-xs text-marble/60">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
+385
-84
@@ -1,7 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
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,
|
||||
@@ -16,6 +24,7 @@ import {
|
||||
ICARUS_RADIUS,
|
||||
ICARUS_X,
|
||||
MAX_FRAME_DT_MS,
|
||||
SHATTER_ANIM_MS,
|
||||
SPEED_MAX_MULTIPLIER,
|
||||
TILT_MAX_RADIANS,
|
||||
VIEWPORT_HEIGHT,
|
||||
@@ -26,13 +35,19 @@ import {
|
||||
WING_IDLE_PERIOD_MS,
|
||||
WING_REST_RADIANS,
|
||||
} from "@/lib/icarus/constants";
|
||||
import { collidesWithColumn, createInitialIcarus, hasHitGround, stepIcarus } from "@/lib/icarus/physics";
|
||||
import type { Column, IcarusState } from "@/lib/icarus/types";
|
||||
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";
|
||||
|
||||
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 {
|
||||
@@ -46,6 +61,23 @@ function randomGapCenter(gap: number): number {
|
||||
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
|
||||
@@ -75,11 +107,28 @@ function drawWing(ctx: CanvasRenderingContext2D, side: 1 | -1, wingAngle: number
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawIcarus(ctx: CanvasRenderingContext2D, y: number, tilt: number, wingAngle: number) {
|
||||
function drawIcarus(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
tilt: number,
|
||||
wingAngle: number,
|
||||
boosted: boolean,
|
||||
) {
|
||||
ctx.save();
|
||||
ctx.translate(ICARUS_X, y);
|
||||
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);
|
||||
@@ -129,12 +178,122 @@ function drawColumn(ctx: CanvasRenderingContext2D, column: Column) {
|
||||
drawColumnShaft(ctx, column.x, gapBottom, GROUND_Y - gapBottom, false);
|
||||
}
|
||||
|
||||
export function IcarusGame({ onFinish }: { onFinish: (score: number) => void }) {
|
||||
// É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;
|
||||
@@ -146,27 +305,67 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
|
||||
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);
|
||||
|
||||
function resetGame() {
|
||||
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;
|
||||
}
|
||||
|
||||
function requestFlap() {
|
||||
flapRequestedRef.current = true;
|
||||
if (statusRef.current === "idle") {
|
||||
runStartedAtRef.current = Date.now();
|
||||
actionCountRef.current = 1;
|
||||
statusRef.current = "flying";
|
||||
setStatus("flying");
|
||||
} else if (statusRef.current === "dead") {
|
||||
resetGame();
|
||||
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;
|
||||
@@ -175,10 +374,19 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
|
||||
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 cssWidth = container!.clientWidth;
|
||||
const cssHeight = cssWidth * (VIEWPORT_HEIGHT / VIEWPORT_WIDTH);
|
||||
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`;
|
||||
@@ -193,6 +401,17 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
|
||||
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);
|
||||
@@ -207,17 +426,28 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
|
||||
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 currentSpeed = FORWARD_SPEED * (1 + (SPEED_MAX_MULTIPLIER - 1) * progress);
|
||||
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) {
|
||||
@@ -226,25 +456,92 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
|
||||
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) {
|
||||
columnsRef.current.push({
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const hitColumn = columnsRef.current.some((c) => collidesWithColumn(icarusRef.current.y, c));
|
||||
if (hasHitGround(icarusRef.current) || hitColumn) {
|
||||
statusRef.current = "dead";
|
||||
setStatus("dead");
|
||||
onFinishRef.current(scoreRef.current);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -252,6 +549,14 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -267,7 +572,20 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
|
||||
|
||||
const wingAngle = statusRef.current === "dead" ? WING_REST_RADIANS : Math.max(idleWingAngle, tapWingAngle);
|
||||
|
||||
render(ctx!, canvas!, icarusRef.current, columnsRef.current, statusRef.current, scoreRef.current, wingAngle);
|
||||
render(
|
||||
ctx!,
|
||||
canvas!,
|
||||
icarusRef.current,
|
||||
columnsRef.current,
|
||||
boostsRef.current,
|
||||
shatterEffectsRef.current,
|
||||
dashOffset,
|
||||
boostColumnsRemainingRef.current > 0,
|
||||
statusRef.current,
|
||||
scoreRef.current,
|
||||
wingAngle,
|
||||
now,
|
||||
);
|
||||
rafId = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
@@ -283,16 +601,23 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
|
||||
typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div
|
||||
ref={containerRef}
|
||||
onPointerDown={requestFlap}
|
||||
className="relative w-full max-w-xs touch-none select-none overflow-hidden rounded-2xl border border-gold/40 shadow-xl"
|
||||
className="relative flex flex-1 touch-none items-center justify-center overflow-hidden bg-ink select-none"
|
||||
>
|
||||
<canvas ref={canvasRef} className="block h-full w-full" />
|
||||
<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'écran pour battre des ailes et t'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">
|
||||
@@ -310,63 +635,6 @@ export function IcarusGame({ onFinish }: { onFinish: (score: number) => void })
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status === "idle" && (
|
||||
<p className="text-center text-sm text-marble/60">
|
||||
Touche l'écran pour battre des ailes et t'envoler entre les colonnes.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -375,9 +643,14 @@ function render(
|
||||
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;
|
||||
@@ -396,11 +669,39 @@ function render(
|
||||
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, icarus.y, tilt, wingAngle);
|
||||
drawIcarus(ctx, icarusX, icarus.y, tilt, wingAngle, boosted);
|
||||
|
||||
ctx.restore();
|
||||
|
||||
|
||||
@@ -48,14 +48,6 @@ export default async function IcarePage() {
|
||||
const ownBestScore = leaderboard.find((entry) => entry.id === user.id)?.best_score ?? 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">Le Vol d'Icare</h1>
|
||||
<p className="font-serif text-sm text-marble/60 italic">
|
||||
Vole entre les colonnes, aussi loin que tes ailes de cire le permettent.
|
||||
</p>
|
||||
</div>
|
||||
<IcareView initialLeaderboard={leaderboard} initialOwnBestScore={ownBestScore} isFrozen={isFrozen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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 = {
|
||||
@@ -86,22 +87,31 @@ export function LeaderboardView({
|
||||
}, []);
|
||||
|
||||
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) {
|
||||
return <p className="text-sm text-marble/60">Personne n'est encore inscrit.</p>;
|
||||
return <EmptyState message="Personne n'est encore inscrit." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Podium
|
||||
profiles={ranked.filter((p) => p.rank <= 3)}
|
||||
profiles={podiumProfiles}
|
||||
renderValue={(member) => member.points}
|
||||
valueLabel="gloires"
|
||||
isJudge={isJudge}
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageLoading } from "@/components/page-loading";
|
||||
|
||||
export default function Loading() {
|
||||
return <PageLoading />;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
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";
|
||||
|
||||
@@ -14,17 +16,37 @@ export default async function LeaderboardPage() {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const [{ data: profiles, error }, { data: settings }] = await Promise.all([
|
||||
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">
|
||||
@@ -36,6 +58,10 @@ export default async function LeaderboardPage() {
|
||||
{tribunalDate && <p className="text-xs text-marble/50">{formatTribunalDateTime(tribunalDate)}</p>}
|
||||
</div>
|
||||
|
||||
<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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageLoading } from "@/components/page-loading";
|
||||
|
||||
export default function Loading() {
|
||||
return <PageLoading />;
|
||||
}
|
||||
+376
-26
@@ -1,12 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
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 { WallHistoryRow, WallNoteRow } from "./page";
|
||||
import type { MyReactionRow, ReactionCountRow, WallHistoryRow, WallNoteRow } from "./page";
|
||||
|
||||
const MAX_LENGTH = 200;
|
||||
const MAX_NOTES_PER_DAY = 10;
|
||||
// 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) —
|
||||
@@ -41,54 +51,160 @@ function formatDateLabel(dateKey: string): string {
|
||||
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||
}
|
||||
|
||||
function NoteCard({ note }: { note: WallNoteRow }) {
|
||||
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>
|
||||
<span className="self-end h-2 w-2 rounded-full bg-oxblood" aria-hidden />
|
||||
</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 }] = await Promise.all([
|
||||
const [{ data: todayNotes }, { data: myNotes }, { data: hist }, { data: counts }, { data: mine }] =
|
||||
await Promise.all([
|
||||
supabase.rpc("wall_notes_today"),
|
||||
isJudge
|
||||
? Promise.resolve({ data: null })
|
||||
: supabase
|
||||
// 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, created_at")
|
||||
.select("id, text, image_url, created_at")
|
||||
.order("created_at", { ascending: false }),
|
||||
isJudge
|
||||
? supabase
|
||||
.from("wall_notes")
|
||||
.select("id, user_id, text, created_at, profiles(pseudo, avatar_url)")
|
||||
.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) {
|
||||
@@ -100,7 +216,9 @@ export function MurView({
|
||||
);
|
||||
}
|
||||
if (hist) setHistory(hist);
|
||||
}, [isJudge]);
|
||||
if (counts) setReactionCounts(buildReactionCounts(counts));
|
||||
if (mine) setMyReactions(buildMyReactions(mine));
|
||||
}, [isJudge, currentUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
const supabase = createClient();
|
||||
@@ -112,6 +230,7 @@ export function MurView({
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -121,6 +240,58 @@ export function MurView({
|
||||
};
|
||||
}, [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);
|
||||
@@ -133,27 +304,106 @@ export function MurView({
|
||||
|
||||
setPosting(true);
|
||||
const supabase = createClient();
|
||||
const { error: rpcError } = await supabase.rpc("post_wall_note", { p_text: trimmed });
|
||||
|
||||
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."
|
||||
: "Une erreur est survenue, réessaie.",
|
||||
: 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),
|
||||
);
|
||||
@@ -163,26 +413,65 @@ export function MurView({
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<p className="mb-3 text-center text-xs font-medium tracking-wide text-marble/60 uppercase">
|
||||
{notes.length} / {MAX_NOTES_PER_DAY}{" "}
|
||||
aujourd'hui
|
||||
<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>
|
||||
{notes.length === 0 ? (
|
||||
<p className="text-center text-sm text-marble/60">Le mur est encore vide aujourd'hui.</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'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">
|
||||
{notes.map((note) => (
|
||||
<NoteCard key={note.id} note={note} />
|
||||
{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>
|
||||
|
||||
{!isJudge && (
|
||||
<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'aujourd'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>
|
||||
@@ -203,6 +492,35 @@ export function MurView({
|
||||
<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}
|
||||
@@ -218,7 +536,6 @@ export function MurView({
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isJudge && (
|
||||
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-sm">
|
||||
@@ -240,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) => (
|
||||
@@ -257,6 +575,22 @@ export function MurView({
|
||||
{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>
|
||||
))}
|
||||
@@ -264,6 +598,22 @@ export function MurView({
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
+23
-8
@@ -5,6 +5,7 @@ import { MurView } from "./mur-view";
|
||||
export type WallNoteRow = {
|
||||
id: string;
|
||||
text: string;
|
||||
image_url: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
@@ -12,10 +13,14 @@ 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" });
|
||||
@@ -39,23 +44,28 @@ export default async function MurPage() {
|
||||
.single();
|
||||
const isJudge = callerProfile?.role === "judge";
|
||||
|
||||
const { data: todayNotes } = await supabase.rpc("wall_notes_today");
|
||||
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);
|
||||
|
||||
let myNoteToday: WallNoteRow | null = null;
|
||||
if (!isJudge) {
|
||||
// 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, created_at")
|
||||
.select("id, text, image_url, created_at")
|
||||
.eq("user_id", user.id)
|
||||
.order("created_at", { ascending: false });
|
||||
myNoteToday = (myNotes ?? []).find((n) => isToday(n.created_at)) ?? null;
|
||||
}
|
||||
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, created_at, profiles(pseudo, avatar_url)")
|
||||
.select("id, user_id, text, image_url, created_at, profiles(pseudo, avatar_url)")
|
||||
.order("created_at", { ascending: false })
|
||||
.returns<WallHistoryRow[]>();
|
||||
history = data ?? [];
|
||||
@@ -66,14 +76,19 @@ export default async function MurPage() {
|
||||
<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, dix places sur le mur — la journée passée, tout s'efface.
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
<>
|
||||
{/* 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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+145
-19
@@ -2,36 +2,145 @@
|
||||
|
||||
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";
|
||||
|
||||
function CitizenVoteRow({ citizen, onVote }: { citizen: CitizenRow; onVote: () => void }) {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
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>
|
||||
{confirming ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onVote}
|
||||
className="shrink-0 rounded-md bg-oxblood px-3 py-1.5 text-xs font-medium text-marble"
|
||||
>
|
||||
Confirmer ?
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(true)}
|
||||
onBlur={() => setConfirming(false)}
|
||||
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'a laissé de justification pour l'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({
|
||||
@@ -50,6 +159,7 @@ export function UrneView({
|
||||
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();
|
||||
@@ -97,10 +207,13 @@ export function UrneView({
|
||||
};
|
||||
}, [refetch]);
|
||||
|
||||
async function handleVote(targetId: string) {
|
||||
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 });
|
||||
const { error: rpcError } = await supabase.rpc("cast_urn_vote", {
|
||||
p_target_id: targetId,
|
||||
p_reason: reason || null,
|
||||
});
|
||||
|
||||
if (rpcError) {
|
||||
setError(
|
||||
@@ -126,18 +239,25 @@ export function UrneView({
|
||||
<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="flex min-w-0 items-center gap-1.5 text-xs">
|
||||
<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-gold-bright">
|
||||
<span className="ml-auto shrink-0 font-heading text-sea">
|
||||
{count > 0 ? `×${count}` : "—"}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -164,13 +284,19 @@ export function UrneView({
|
||||
)}
|
||||
<ul className="flex flex-col divide-y divide-gold/10">
|
||||
{otherCitizens.map((citizen) => (
|
||||
<CitizenVoteRow key={citizen.id} citizen={citizen} onVote={() => handleVote(citizen.id)} />
|
||||
<CitizenVoteRow
|
||||
key={citizen.id}
|
||||
citizen={citizen}
|
||||
onVote={(reason) => handleVote(citizen.id, reason)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTarget && <ReasonsModal citizen={selectedTarget} onClose={() => setSelectedTarget(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,9 +26,13 @@ export function Avatar({
|
||||
}: {
|
||||
pseudo: string;
|
||||
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) {
|
||||
// blob:/data: = aperçu local avant upload (cropper). next/image essaie
|
||||
@@ -42,6 +46,7 @@ export function Avatar({
|
||||
width={96}
|
||||
height={96}
|
||||
unoptimized={isLocalPreview}
|
||||
style={customStyle}
|
||||
className={`${sizeClass} rounded-full border-2 border-gold object-cover`}
|
||||
/>
|
||||
);
|
||||
@@ -49,6 +54,7 @@ export function Avatar({
|
||||
|
||||
return (
|
||||
<div
|
||||
style={customStyle}
|
||||
className={`${sizeClass} flex items-center justify-center rounded-full border-2 border-gold font-heading font-semibold text-marble ${fallbackColor(pseudo)}`}
|
||||
aria-hidden
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
IconSword,
|
||||
IconPin,
|
||||
IconVote,
|
||||
IconHorn,
|
||||
} from "@/components/icons";
|
||||
|
||||
type MenuEntry = {
|
||||
@@ -95,6 +96,7 @@ export function Header({
|
||||
const entries: MenuEntry[] = [
|
||||
{ href: "/leaderboard", label: "Le Classement", icon: <LaurelWreath className="h-5 w-5" /> },
|
||||
{ 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 /> },
|
||||
|
||||
@@ -291,3 +291,42 @@ export function IconWings({ className = base }: IconProps) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -46,5 +46,38 @@ 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;
|
||||
export const MAX_SCORE = 1000000;
|
||||
// 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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BOOST_COLLECT_RADIUS,
|
||||
COLUMN_WIDTH,
|
||||
GRAVITY,
|
||||
GROUND_Y,
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
ICARUS_X,
|
||||
MAX_FALL_SPEED,
|
||||
} from "./constants";
|
||||
import type { Column, IcarusState } from "./types";
|
||||
import type { Boost, Column, IcarusState } from "./types";
|
||||
|
||||
export function createInitialIcarus(): IcarusState {
|
||||
return { y: ICARUS_START_Y, velocityY: 0 };
|
||||
@@ -34,3 +35,14 @@ export function collidesWithColumn(icarusY: number, column: Column): boolean {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -4,3 +4,13 @@
|
||||
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
@@ -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,8 +53,43 @@ 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -11,6 +11,7 @@ const PROTECTED_PATHS = [
|
||||
"/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
|
||||
|
||||
+1038
-40
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user