From 0c09c0cdd48efb9805f6b1c865bd67426eea1fa8 Mon Sep 17 00:00:00 2001 From: Valentin ROBIN Date: Thu, 27 Aug 2026 17:42:46 +0200 Subject: [PATCH] =?UTF-8?q?Sort=20les=20tags=20@pseudo=20du=20texte=20des?= =?UTF-8?q?=20questions=20du=20Char=20en=20m=C3=A9tadonn=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 2 +- src/app/char/char-view.tsx | 14 +++++++--- src/app/char/questions/questions-view.tsx | 12 +++++---- src/components/mention-tags.tsx | 31 +++++++++++++++++++++ src/components/mention-text.tsx | 33 ----------------------- src/lib/mentions.ts | 32 ++++++++++++++++++++++ 6 files changed, 82 insertions(+), 42 deletions(-) create mode 100644 src/components/mention-tags.tsx delete mode 100644 src/components/mention-text.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 3a9531a..49591a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ 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 à l'affichage, jamais stocké séparément. Autocomplétion à la frappe (`MentionTextarea`, détecte un « @partiel » juste avant le curseur) et mise en évidence (`MentionText`, `text-sea` — jamais `text-gold-bright`, trop clair sur `marble-surface`, même remarque que `game-highlights`) réutilisées à 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, y compris les personnes jamais taguées — pour que les Archontes équilibrent la banque avant la soirée. +- **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. diff --git a/src/app/char/char-view.tsx b/src/app/char/char-view.tsx index f0deb1d..7690905 100644 --- a/src/app/char/char-view.tsx +++ b/src/app/char/char-view.tsx @@ -2,7 +2,8 @@ import Link from "next/link"; import { useCallback, useEffect, useState } from "react"; -import { MentionText } from "@/components/mention-text"; +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"; @@ -84,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 (
@@ -101,7 +108,7 @@ export function CharView({ )}
- {revealedQuestion && ( + {revealedQuestion && revealedQuestionDisplay && (

- p.pseudo)} /> + {revealedQuestionDisplay.cleanText}

+
)} diff --git a/src/app/char/questions/questions-view.tsx b/src/app/char/questions/questions-view.tsx index 63b2ce8..c6c2b69 100644 --- a/src/app/char/questions/questions-view.tsx +++ b/src/app/char/questions/questions-view.tsx @@ -6,9 +6,9 @@ import { CSS } from "@dnd-kit/utilities"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Avatar } from "@/components/avatar"; import { IconChevronDown, IconGrip, IconPencil, IconPlus, IconShuffle, IconTrash } from "@/components/icons"; -import { MentionText } from "@/components/mention-text"; +import { MentionTags } from "@/components/mention-tags"; import { MentionTextarea } from "@/components/mention-textarea"; -import { extractMentionedPseudos } from "@/lib/mentions"; +import { extractMentionedPseudos, stripMentions } from "@/lib/mentions"; import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client"; import type { ProfileRow, QuestionRow, SubmissionHistoryRow, SubmissionRow } from "./page"; @@ -118,6 +118,7 @@ function QuestionRowItem({ // 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(); @@ -184,9 +185,10 @@ function QuestionRowItem({ -

- -

+
+

{cleanText}

+ +
+ ); +} diff --git a/src/components/mention-text.tsx b/src/components/mention-text.tsx deleted file mode 100644 index 75819ee..0000000 --- a/src/components/mention-text.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Fragment } from "react"; -import { findMentionRanges } from "@/lib/mentions"; - -export function MentionText({ - text, - pseudos, - // gold-bright est réservé aux fonds sombres (ink) : sur marble-surface, - // il est trop clair pour un contraste correct — voir la remarque sur - // text-sea dans CLAUDE.md (même correction déjà faite pour game-highlights). - mentionClassName = "font-semibold text-sea", -}: { - text: string; - pseudos: string[]; - mentionClassName?: string; -}) { - const ranges = findMentionRanges(text, pseudos); - if (ranges.length === 0) return <>{text}; - - const nodes: React.ReactNode[] = []; - let cursor = 0; - ranges.forEach((range, index) => { - if (range.start > cursor) nodes.push({text.slice(cursor, range.start)}); - nodes.push( - - {text.slice(range.start, range.end)} - , - ); - cursor = range.end; - }); - if (cursor < text.length) nodes.push({text.slice(cursor)}); - - return <>{nodes}; -} diff --git a/src/lib/mentions.ts b/src/lib/mentions.ts index 4aa3654..c789eb4 100644 --- a/src/lib/mentions.ts +++ b/src/lib/mentions.ts @@ -41,3 +41,35 @@ export function findMentionRanges(text: string, pseudos: string[]): MentionRange 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(); + 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 }; +}