Compare commits

...

5 Commits

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 15:28:19 +02:00
12 changed files with 594 additions and 80 deletions
+4 -1
View File
@@ -27,6 +27,8 @@ V1 à V15 sont livrées :
- **V15** : Le Mur de la Honte s'ouvre à la consultation des jours précédents, pour tout le monde (pas seulement les Archontes via « Historique complet », qui révèle lui l'auteur). `wall_notes_today()` devient `wall_notes_for_date(p_date default null)` — même garantie d'anonymat qu'avant (jamais de `user_id`), juste paramétrée par jour plutôt que hardcodée sur aujourd'hui. Nouvelle RPC `wall_note_days()` (dates ayant au moins une note, sans contenu ni auteur) peuple un sélecteur de jour au-dessus de la grille, visible par tous. Vue « aujourd'hui » toujours branchée sur le Realtime existant (`notes`/`reactionCounts`) ; un jour passé se charge à la demande dans un état séparé (`pastNotes`/`pastReactionCounts`, pas de Realtime dessus — un jour passé n'évolue plus) pour ne jamais mélanger les deux, notamment le compteur « X/12 aujourd'hui » et l'éligibilité à publier, qui restent toujours basés sur aujourd'hui quel que soit le jour affiché. `wall_note_reaction_counts()` gagne le même paramètre `p_date` (ancienne surcharge à 0 argument explicitement supprimée, même remarque que `post_wall_note`).
- **V16** : outillage de gestion des questions du Char (`/char/questions`), aucun changement de schéma. **Glisser-déposer** (`@dnd-kit/core`/`@dnd-kit/sortable`, nouvelle dépendance — choisie pour son support tactile natif via Pointer Events, essentiel puisque cette page est pensée pour être pilotée depuis un téléphone, voir V7) en plus des flèches monter/descendre existantes, pas en remplacement : chaque ligne expose une poignée dédiée (`IconGrip`, seul élément portant `{...attributes} {...listeners}`, pas toute la ligne, pour ne pas déclencher un glisser en tapant le texte ou les boutons) ; au dépôt comme au clic sur **« Randomiser l'ordre »** (confirmation à deux temps), les positions de toute la banque sont réassignées de façon dense (1..N) via `persistOrder()` — pas de contrainte unique sur `chariot_questions.position`, donc aucun risque de collision. **Tags de joueurs** dans le texte d'une question (`@Priape`) : repérés par simple correspondance de texte contre les pseudos existants (`src/lib/mentions.ts`, `findMentionRanges` — pseudos les plus longs testés en premier pour qu'un pseudo préfixe d'un autre ne vole pas son match, limite de mot après la mention pour qu'« @Priape » ne matche pas dans « @Priapee »), **aucune colonne ni table dédiée** : le tag est purement dérivé du texte, jamais stocké séparément. Autocomplétion à la frappe (`MentionTextarea`, détecte un « @partiel » juste avant le curseur) pendant la composition, mais à l'affichage le `@Priape` n'apparaît **jamais au milieu de la phrase** : `stripMentions()` le retire du texte (en nettoyant les espaces qu'il laisse) et le sort en métadonnée à part — une rangée de badges (`MentionTags`, avatar + pseudo) sous la question, « un peu comme une réponse ». Même paire texte-nettoyé/badges réutilisée à l'identique dans la liste de `/char/questions` **et** sur la question révélée projetée sur `/char`. Nouveau panneau « Répartition des tags » (carte secondaire, `border-gold/20`) sur `/char/questions` : compte, pour chaque membre (Archontes compris, comme les emplacements du Char eux-mêmes — aucune restriction de rôle), le nombre de questions distinctes qui le taguent (via `extractMentionedPseudos`, sur le texte brut), y compris les personnes jamais taguées — pour que les Archontes équilibrent la banque avant la soirée.
Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, mode grand écran.
---
@@ -38,6 +40,7 @@ Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons
- **Auth + Base + Stockage + Temps réel** : Supabase (Auth, Postgres, Storage, Realtime)
- **Recadrage photo** : `react-easy-crop` + canvas natif (resize/compression client, jamais d'upload de l'image brute)
- **Physique 2D** : `matter-js` (uniquement le moteur de simulation — `Engine`/`Composite`/`Bodies`/`Body`/`Events`, jamais son module `Render`), pour La Corne d'Abondance
- **Glisser-déposer** : `@dnd-kit/core` + `@dnd-kit/sortable` (+ `@dnd-kit/utilities`), pour la banque de questions du Char (`/char/questions`) — support tactile natif (Pointer Events), nécessaire puisque cette page est pensée pour être pilotée depuis un téléphone (voir V7/V16)
- **Déploiement** : self-hosted, Docker Compose + Traefik (reverse proxy), Supabase (managé) — CI via Gitea Actions (`.gitea/workflows/deploy.yml`) qui build le standalone Next.js et le POST à un webhook (`docker/webhook/receive.py`) déclenchant un hot-swap sans downtime (`docker/entrypoint.sh`) ; domaine injecté par `APP_DOMAIN` (`.env.docker`, gitignored — seul `.env.docker.example` est versionné) dans le label Traefik `Host()`. Domaine actuel : `agora.alxczl.fr`.
Next.js 16 a renommé `middleware.ts` en `proxy.ts` (fichier `src/proxy.ts`, fonction `proxy`). `src/lib/supabase/middleware.ts` est un nom de fichier utilitaire interne, sans rapport avec cette convention.
@@ -183,7 +186,7 @@ Deuxième volet de l'audit (revue complète de tout le code applicatif, pas seul
| `/icare` | « Le Vol d'Icare » — Flappy Bird grec, record personnel all-time, scores figés au Tribunal, points attribués automatiquement |
| `/corne` | « La Corne d'Abondance » — jeu de fusion façon Watermelon Game (avatars aléatoires, physique `matter-js`), même mécanique de gloires que Le Vol d'Icare |
| `/char` | « Le Jeu de l'Agora » — format paysage (projeté) : Le Char (2 colonnes de 3, boutons de duel), Le Gardien du Silence (tirage aléatoire, minuteur discret) et La Roulette (tirage au sort animé) |
| `/char/questions` | Banque de questions du Char (ajout/édition/réordonnancement/afficher-masquer) + modération des suggestions des Citoyens (ajouter à la banque/rejeter) — juges uniquement, pilotée depuis un téléphone pendant que `/char` est projetée |
| `/char/questions` | Banque de questions du Char (ajout/édition/réordonnancement par flèches ou glisser-déposer/randomisation/afficher-masquer/tags `@pseudo` avec répartition) + modération des suggestions des Citoyens (ajouter à la banque/rejeter) — juges uniquement, pilotée depuis un téléphone pendant que `/char` est projetée |
| `/mur` | « Le Mur de la Honte » — une note anonyme par personne par jour, Archontes compris depuis V14 (douze places, remis à zéro chaque jour) ; les jours précédents restent consultables anonymement par tous depuis V15 (sélecteur de jour) ; les Archontes y voient en plus l'historique complet avec l'auteur de chaque note |
| `/urne` | « L'Urne de l'Agora » — un jeton par Citoyen par jour sur une autre personne, classement cumulatif public identique pour tous, Archontes compris (aucune vision privilégiée ici) |
| `/calendrier` | « Le Calendrier des Dieux » — agenda de la semaine, panthéon, date du Tribunal, édition réservée aux juges. Retirée du menu de navigation (la date du Tribunal, seule info largement utile au quotidien, est reprise sur `/leaderboard`) mais la route reste fonctionnelle — les juges y accèdent directement par l'URL pour gérer le calendrier et changer la date |
+56
View File
@@ -8,6 +8,9 @@
"name": "tribunal-app",
"version": "0.1.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.109.0",
"matter-js": "^0.20.0",
@@ -281,6 +284,59 @@
"node": ">=6.9.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+3
View File
@@ -9,6 +9,9 @@
"lint": "eslint"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.109.0",
"matter-js": "^0.20.0",
+13 -2
View File
@@ -2,6 +2,8 @@
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { MentionTags } from "@/components/mention-tags";
import { stripMentions } from "@/lib/mentions";
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
import { EntryRanking } from "./entry-ranking";
import { GardienBar } from "./gardien-bar";
@@ -83,6 +85,12 @@ export function CharView({
}, [refetch]);
const gardienHolder = profiles.find((p) => p.id === settings.gardien_holder_id) ?? null;
const revealedQuestionDisplay = revealedQuestion
? stripMentions(
revealedQuestion.text,
profiles.map((p) => p.pseudo),
)
: null;
return (
<div className="flex flex-col gap-4">
@@ -100,7 +108,7 @@ export function CharView({
)}
</div>
{revealedQuestion && (
{revealedQuestion && revealedQuestionDisplay && (
<div
key={revealedQuestion.id}
className="marble-surface animate-reveal rounded-2xl border border-gold/40 px-6 py-6 text-center shadow-xl sm:py-8"
@@ -108,7 +116,10 @@ export function CharView({
<p className="mb-1 font-heading text-xs tracking-[0.2em] text-text-mut uppercase sm:text-sm">
Question {revealedQuestion.question_number}
</p>
<p className="font-heading text-xl text-text-marble sm:text-2xl lg:text-3xl">{revealedQuestion.text}</p>
<p className="font-heading text-xl text-text-marble sm:text-2xl lg:text-3xl">
{revealedQuestionDisplay.cleanText}
</p>
<MentionTags pseudos={revealedQuestionDisplay.mentioned} profiles={profiles} className="mt-3 justify-center" />
</div>
)}
+15
View File
@@ -23,6 +23,12 @@ export type SubmissionHistoryRow = {
created_at: string;
};
export type ProfileRow = {
id: string;
pseudo: string;
avatar_url: string | null;
};
export default async function CharQuestionsPage() {
const supabase = await createClient();
const {
@@ -71,6 +77,14 @@ export default async function CharQuestionsPage() {
.select("user_id, text, created_at")
.order("created_at", { ascending: false });
// Tout le monde (Archontes compris, comme pour les emplacements du Char
// lui-même — voir chariot_slots) peut être visé par un tag "@pseudo" dans
// une question.
const { data: profiles } = await supabase
.from("profiles")
.select("id, pseudo, avatar_url")
.order("pseudo", { ascending: true });
return (
<div className="mx-auto w-full max-w-2xl px-4 py-8">
<div className="mb-6 text-center">
@@ -90,6 +104,7 @@ export default async function CharQuestionsPage() {
initialRevealedId={settings?.chariot_revealed_question_id ?? null}
initialSubmissions={submissions ?? []}
initialSubmissionHistory={submissionHistory ?? []}
profiles={profiles ?? []}
/>
</div>
);
+163 -9
View File
@@ -1,10 +1,16 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Avatar } from "@/components/avatar";
import { IconChevronDown, IconPencil, IconPlus, IconTrash } from "@/components/icons";
import { IconChevronDown, IconGrip, IconPencil, IconPlus, IconShuffle, IconTrash } from "@/components/icons";
import { MentionTags } from "@/components/mention-tags";
import { MentionTextarea } from "@/components/mention-textarea";
import { extractMentionedPseudos, stripMentions } from "@/lib/mentions";
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
import type { QuestionRow, SubmissionHistoryRow, SubmissionRow } from "./page";
import type { ProfileRow, QuestionRow, SubmissionHistoryRow, SubmissionRow } from "./page";
function formatHistoryDate(iso: string): string {
return new Date(iso).toLocaleString("fr-FR", {
@@ -17,10 +23,12 @@ function formatHistoryDate(iso: string): string {
function QuestionForm({
initial,
nextPosition,
profiles,
onDone,
}: {
initial?: QuestionRow;
nextPosition: number;
profiles: ProfileRow[];
onDone: () => void;
}) {
const [text, setText] = useState(initial?.text ?? "");
@@ -51,12 +59,13 @@ function QuestionForm({
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-2 rounded-md border border-gold/30 bg-white/40 p-3">
<textarea
placeholder="Écrire la question…"
<MentionTextarea
value={text}
onChange={(e) => setText(e.target.value)}
onChange={setText}
candidates={profiles}
rows={2}
className="rounded-md border border-gold/30 bg-white/60 px-2 py-1.5 text-sm text-text-marble"
placeholder="Écrire la question… (@pseudo pour taguer quelqu'un)"
className="w-full rounded-md border border-gold/30 bg-white/60 px-2 py-1.5 text-sm text-text-marble"
/>
{error && <p className="text-xs text-oxblood">{error}</p>}
<div className="flex gap-2">
@@ -85,6 +94,8 @@ function QuestionRowItem({
isFirst,
isLast,
isRevealed,
pseudos,
profiles,
onMove,
onToggleReveal,
onChanged,
@@ -94,12 +105,20 @@ function QuestionRowItem({
isFirst: boolean;
isLast: boolean;
isRevealed: boolean;
pseudos: string[];
profiles: ProfileRow[];
onMove: (direction: "up" | "down") => void;
onToggleReveal: () => void;
onChanged: () => void;
}) {
const [editing, setEditing] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
// Toujours appelé (même en édition) : dnd-kit a besoin de mesurer l'item
// à sa position dans la liste, même quand le glisser est temporairement
// indisponible (le formulaire d'édition n'a pas de poignée).
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: question.id });
const style = { transform: CSS.Transform.toString(transform), transition };
const { cleanText, mentioned } = stripMentions(question.text, pseudos);
async function handleDelete() {
const supabase = createClient();
@@ -109,19 +128,40 @@ function QuestionRowItem({
if (editing) {
return (
<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&apos;un pour l&apos;instant écris « @pseudo » dans une question pour
la viser.
</p>
) : (
<ul className="flex flex-wrap gap-2">
{distribution.map(({ profile, count }) => (
<li
key={profile.id}
className={`flex items-center gap-1.5 rounded-full border px-2 py-1 text-xs ${
count === 0 ? "border-gold/15 text-text-mut" : "border-gold/40 text-text-marble"
}`}
>
<Avatar pseudo={profile.pseudo} avatarUrl={profile.avatar_url} size="xs" />
<span className="font-medium">{profile.pseudo}</span>
<span className={`font-heading ${count === 0 ? "text-text-mut" : "text-sea"}`}>{count}</span>
</li>
))}
</ul>
)}
</div>
)}
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-sm">
{questions.length > 1 && (
<div className="mb-2 flex justify-end">
{confirmingRandomize ? (
<button
type="button"
onClick={randomizeOrder}
onBlur={() => setConfirmingRandomize(false)}
className="flex items-center gap-1.5 rounded-md bg-oxblood px-2 py-1 text-xs font-medium text-marble"
>
Confirmer le mélange ?
</button>
) : (
<button
type="button"
onClick={() => setConfirmingRandomize(true)}
className="flex items-center gap-1.5 rounded-md border border-gold/30 px-2 py-1 text-xs font-medium text-text-marble hover:bg-gold/10"
>
<IconShuffle className="h-3.5 w-3.5" />
Randomiser l&apos;ordre
</button>
)}
</div>
)}
{questions.length === 0 && !adding && (
<p className="mb-2 text-sm text-text-mut">Aucune question dans la banque.</p>
)}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={questions.map((q) => q.id)} strategy={verticalListSortingStrategy}>
<ul className="flex flex-col divide-y divide-gold/10">
{questions.map((question, index) => (
<QuestionRowItem
@@ -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();
+9 -7
View File
@@ -122,15 +122,14 @@ export function CorneView({
return (
<div className="mx-auto flex w-full max-w-2xl flex-col items-center gap-4 px-4 py-8">
<div className="text-center">
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">
{/* Même en-tête compact que /icare (titre + Classement sur une seule
ligne) : l'ancien bouton isolé juste au-dessus du jeu pouvait se
faire chevaucher par la preview de la prochaine pièce sur les gros
paliers — ici il est tout en haut, loin du jeu, comme sur Icare. */}
<div className="flex w-full items-center justify-between">
<h1 className="font-heading text-sm tracking-[0.15em] text-gold-bright uppercase">
La Corne d&apos;Abondance
</h1>
<p className="font-serif text-sm text-marble/60 italic">
Fais fusionner les Citoyens jusqu&apos;au débordement.
</p>
</div>
<button
type="button"
onClick={() => setShowRanking(true)}
@@ -139,8 +138,11 @@ export function CorneView({
<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">
+72 -29
View File
@@ -32,6 +32,54 @@ function randomTier(min: number, max: number): number {
return min + Math.floor(Math.random() * (max - min + 1));
}
// Composant à part entière (pas juste un fragment JSX dans le .map() de
// MelonGame) uniquement pour pouvoir lui donner un ref stable via son
// propre useCallback([piece.id]) — un ref créé comme fonction fléchée
// inline dans une boucle de rendu est une NOUVELLE fonction à chaque
// rendu, ce que React interprète comme "le ref a changé" : il rappelle
// alors la fonction avec null puis avec le nœud, pour CHAQUE pièce déjà
// posée (pas seulement celle qui vient de changer), à chaque mise à jour
// de `pieces` (chaque lâcher, chaque fusion). Sur un plateau qui accumule
// des dizaines de pièces après plusieurs minutes de jeu, ce cycle inutile
// devient un vrai coût CPU répété très souvent — plausiblement la vraie
// cause du ralentissement/rechargement forcé constaté sur iPhone (la
// fuite du Set de fusion, corrigée séparément, n'était pas suffisante à
// elle seule). useCallback garde la même fonction tant que piece.id ne
// change pas, ce qu'une fonction fléchée inline ne peut pas faire.
function PieceView({
piece,
citizen,
diameter,
registerNode,
}: {
piece: Piece;
citizen: MemberRow | undefined;
diameter: number;
registerNode: (id: number, node: HTMLDivElement | null) => void;
}) {
const ref = useCallback((node: HTMLDivElement | null) => registerNode(piece.id, node), [piece.id, registerNode]);
return (
<div ref={ref} className="pointer-events-none absolute top-0 left-0">
{/* La position/rotation (transform) de ce conteneur est pilotée
impérativement à chaque frame depuis la boucle physique —
l'animation d'apparition vit sur cet enfant, jamais sur le
conteneur, pour ne pas entrer en conflit avec transform. Idem
pour animate-danger-blink (ajoutée/retirée sur ce même conteneur
par la boucle physique quand la pièce dépasse la ligne de
danger). rounded-full ici : sans ça le halo de l'animation
dessinait un carré visible autour du rond. animate-fade-in (pas
animate-reveal) : ce dernier passe par 82% puis 104% avant de se
stabiliser à 100%, ce qui faisait paraître une pièce tout juste
lâchée d'une taille différente de celle annoncée dans la
preview. */}
<div className="animate-fade-in rounded-full">
<Avatar pseudo={citizen?.pseudo ?? "?"} avatarUrl={citizen?.avatar_url ?? null} size={diameter} />
</div>
</div>
);
}
export type MelonRunTelemetry = {
v: 1;
duration_ms: number;
@@ -52,6 +100,13 @@ export function MelonGame({
const previewRef = useRef<HTMLDivElement | null>(null);
const guideLineRef = useRef<HTMLDivElement | null>(null);
const nodeRefs = useRef(new Map<number, HTMLDivElement>());
// Stable (jamais recréée) : passée à PieceView, qui la mémorise elle-même
// par pièce via son propre useCallback([piece.id]) — voir la remarque
// juste au-dessus de PieceView sur pourquoi ça compte.
const registerPieceNode = useCallback((id: number, node: HTMLDivElement | null) => {
if (node) nodeRefs.current.set(id, node);
else nodeRefs.current.delete(id);
}, []);
// Un palier de taille par Citoyen, pas un nombre arbitraire — voir
// buildTierRadii. citizens[tier] (indexation directe, sans tirage) donne
@@ -325,6 +380,15 @@ export function MelonGame({
bodiesRef.current.delete(merge.pieceIdB);
nodeRefs.current.delete(merge.pieceIdA);
nodeRefs.current.delete(merge.pieceIdB);
// Fuite mémoire corrigée ici : ces deux identifiants (les vrais id
// matter-js, pas les pieceId de l'app) étaient ajoutés à chaque
// fusion détectée dans collisionStart mais jamais retirés pendant
// la partie — seulement vidés au redémarrage complet du jeu. Sur
// une longue partie avec beaucoup de fusions, ce Set grossissait
// sans jamais être nettoyé (constaté en prod sur iPhone : page
// blanche puis rechargement après plusieurs minutes de jeu).
mergingBodyIdsRef.current.delete(bodyA.id);
mergingBodyIdsRef.current.delete(bodyB.id);
removedIds.push(merge.pieceIdA, merge.pieceIdB);
if (merge.tier < maxTier) {
@@ -475,36 +539,15 @@ export function MelonGame({
className="pointer-events-none absolute top-0 bottom-0 w-px border-l border-dashed border-gold/40"
/>
{pieces.map((piece) => {
const citizen = citizens[piece.tier];
const diameter = tierRadii[piece.tier] * 2;
return (
<div
{pieces.map((piece) => (
<PieceView
key={piece.id}
ref={(node) => {
if (node) nodeRefs.current.set(piece.id, node);
}}
className="pointer-events-none absolute top-0 left-0"
>
{/* La position/rotation (transform) de ce conteneur est
pilotée impérativement à chaque frame depuis la boucle
physique — l'animation d'apparition vit sur cet enfant,
jamais sur le conteneur, pour ne pas entrer en conflit
avec transform. Idem pour animate-danger-blink (ajoutée/
retirée sur ce même conteneur par la boucle physique quand
la pièce dépasse la ligne de danger). rounded-full ici :
sans ça le halo de l'animation dessinait un carré visible
autour du rond. animate-fade-in (pas animate-reveal) : ce
dernier passe par 82% puis 104% avant de se stabiliser à
100%, ce qui faisait paraître une pièce tout juste lâchée
d'une taille différente de celle annoncée dans la
preview. */}
<div className="animate-fade-in rounded-full">
<Avatar pseudo={citizen?.pseudo ?? "?"} avatarUrl={citizen?.avatar_url ?? null} size={diameter} />
</div>
</div>
);
})}
piece={piece}
citizen={citizens[piece.tier]}
diameter={tierRadii[piece.tier] * 2}
registerNode={registerPieceNode}
/>
))}
<div
ref={(node) => {
+24
View File
@@ -306,3 +306,27 @@ export function IconHorn({ className = base }: IconProps) {
</svg>
);
}
export function IconGrip({ className = base }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="currentColor" stroke="none" aria-hidden>
<circle cx="9" cy="6" r="1.6" />
<circle cx="15" cy="6" r="1.6" />
<circle cx="9" cy="12" r="1.6" />
<circle cx="15" cy="12" r="1.6" />
<circle cx="9" cy="18" r="1.6" />
<circle cx="15" cy="18" r="1.6" />
</svg>
);
}
export function IconShuffle({ className = base }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
<path d="M3.5 6.5h3c1.8 0 2.8.6 3.9 2l5.7 7.3c1.1 1.4 2.1 2 3.9 2h1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M3.5 17.8h3c1.8 0 2.8-.6 3.9-2l.6-.8" strokeLinecap="round" strokeLinejoin="round" />
<path d="M14.6 8.5l.9-1.2c1.1-1.4 2.1-2 3.9-2h1.1" strokeLinecap="round" strokeLinejoin="round" />
<path d="M19 3.5l2.5 2.8L19 9M19 15l2.5 2.8L19 20.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { Avatar } from "@/components/avatar";
export function MentionTags({
pseudos,
profiles,
className = "",
}: {
pseudos: string[];
profiles: { pseudo: string; avatar_url: string | null }[];
className?: string;
}) {
if (pseudos.length === 0) return null;
return (
<div className={`flex flex-wrap items-center gap-1.5 ${className}`}>
<span className="font-heading text-[0.65rem] tracking-[0.15em] text-text-mut uppercase">Concerne</span>
{pseudos.map((pseudo) => {
const profile = profiles.find((p) => p.pseudo === pseudo);
return (
<span
key={pseudo}
className="flex items-center gap-1 rounded-full border border-gold/40 px-2 py-0.5 text-xs font-medium text-text-marble"
>
<Avatar pseudo={pseudo} avatarUrl={profile?.avatar_url ?? null} size="xs" />
{pseudo}
</span>
);
})}
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
"use client";
import { useMemo, useRef, useState } from "react";
type MentionCandidate = { pseudo: string; avatar_url: string | null };
// Détecte un "@partiel" juste avant le curseur (pas ailleurs dans le texte)
// pour proposer des suggestions au fil de la frappe, comme un @mention
// classique — sans dépendance externe, la logique tient en une regex.
function findActiveQuery(value: string, caret: number): { start: number; query: string } | null {
const uptoCaret = value.slice(0, caret);
const match = uptoCaret.match(/@([\p{L}\p{N}_-]*)$/u);
if (!match) return null;
return { start: caret - match[0].length, query: match[1] };
}
export function MentionTextarea({
value,
onChange,
candidates,
rows = 2,
placeholder,
className,
}: {
value: string;
onChange: (next: string) => void;
candidates: MentionCandidate[];
rows?: number;
placeholder?: string;
className?: string;
}) {
const [caret, setCaret] = useState(0);
const [open, setOpen] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const activeQuery = open ? findActiveQuery(value, caret) : null;
const suggestions = useMemo(() => {
if (!activeQuery) return [];
const q = activeQuery.query.toLowerCase();
return candidates.filter((c) => c.pseudo.toLowerCase().includes(q)).slice(0, 6);
}, [activeQuery, candidates]);
function applySuggestion(pseudo: string) {
if (!activeQuery) return;
const before = value.slice(0, activeQuery.start);
const after = value.slice(caret);
const next = `${before}@${pseudo} ${after}`;
onChange(next);
setOpen(false);
requestAnimationFrame(() => {
const pos = before.length + pseudo.length + 2;
textareaRef.current?.focus();
textareaRef.current?.setSelectionRange(pos, pos);
});
}
return (
<div className="relative">
<textarea
ref={textareaRef}
placeholder={placeholder}
value={value}
rows={rows}
className={className}
onChange={(event) => {
onChange(event.target.value);
setCaret(event.target.selectionStart);
setOpen(true);
}}
onKeyUp={(event) => setCaret(event.currentTarget.selectionStart)}
onClick={(event) => setCaret(event.currentTarget.selectionStart)}
onBlur={() => {
// Délai pour laisser le clic sur une suggestion se déclencher
// avant que le blur ne referme la liste.
setTimeout(() => setOpen(false), 120);
}}
/>
{activeQuery && suggestions.length > 0 && (
<ul className="absolute z-10 mt-1 w-full max-w-xs rounded-md border border-gold/40 bg-marble shadow-lg">
{suggestions.map((c) => (
<li key={c.pseudo}>
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => applySuggestion(c.pseudo)}
className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-sm text-text-marble hover:bg-gold/10"
>
<span className="font-medium text-gold-bright">@{c.pseudo}</span>
</button>
</li>
))}
</ul>
)}
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
// Mentions "@Pseudo" dans le texte d'une question du Char — repère qui est
// visé par une question sans ajouter de colonne : le pseudo mentionné est
// retrouvé par simple correspondance de texte contre la liste des membres
// (pas de state séparé à synchroniser avec une édition du texte).
export type MentionRange = { start: number; end: number; pseudo: string };
function isWordChar(char: string | undefined): boolean {
return !!char && /[\p{L}\p{N}_]/u.test(char);
}
// Les pseudos les plus longs sont recherchés en premier pour qu'un pseudo
// qui en préfixe un autre (ex. "Priape" / "Priapee") ne vole pas le match
// du plus long à l'endroit où il apparaît vraiment.
export function findMentionRanges(text: string, pseudos: string[]): MentionRange[] {
const candidates = [...new Set(pseudos)].filter(Boolean).sort((a, b) => b.length - a.length);
const lowerText = text.toLowerCase();
const claimed = new Array<boolean>(text.length).fill(false);
const ranges: MentionRange[] = [];
for (const pseudo of candidates) {
const needle = `@${pseudo.toLowerCase()}`;
let fromIndex = 0;
while (fromIndex <= lowerText.length) {
const idx = lowerText.indexOf(needle, fromIndex);
if (idx === -1) break;
fromIndex = idx + 1;
const end = idx + needle.length;
if (isWordChar(text[end])) continue;
if (claimed.slice(idx, end).some(Boolean)) continue;
for (let i = idx; i < end; i++) claimed[i] = true;
ranges.push({ start: idx, end, pseudo });
}
}
return ranges.sort((a, b) => a.start - b.start);
}
export function extractMentionedPseudos(text: string, pseudos: string[]): string[] {
return findMentionRanges(text, pseudos).map((range) => range.pseudo);
}
// Retire les "@Pseudo" du texte affiché (ils vivent à part, en métadonnée à
// côté de la question — un peu comme une réponse — plutôt qu'au milieu de
// la phrase) et renvoie les pseudos visés, dans leur ordre d'apparition et
// sans doublon si une même personne est taguée plusieurs fois.
export function stripMentions(text: string, pseudos: string[]): { cleanText: string; mentioned: string[] } {
const ranges = findMentionRanges(text, pseudos);
if (ranges.length === 0) return { cleanText: text, mentioned: [] };
let cleanText = "";
let cursor = 0;
const seen = new Set<string>();
const mentioned: string[] = [];
for (const range of ranges) {
cleanText += text.slice(cursor, range.start);
if (!seen.has(range.pseudo)) {
seen.add(range.pseudo);
mentioned.push(range.pseudo);
}
cursor = range.end;
}
cleanText += text.slice(cursor);
// Nettoie les espaces laissés par les mentions retirées (double espace
// au milieu de la phrase, espace parasite avant une ponctuation, bords).
cleanText = cleanText
.replace(/[ \t]{2,}/g, " ")
.replace(/\s+([,.!?;:])/g, "$1")
.trim();
return { cleanText, mentioned };
}