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>
This commit is contained in:
Valentin ROBIN
2026-08-27 17:42:46 +02:00
parent 5b5393cbd6
commit 0c09c0cdd4
6 changed files with 82 additions and 42 deletions
+1 -1
View File
@@ -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.
+11 -3
View File
@@ -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 (
<div className="flex flex-col gap-4">
@@ -101,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"
@@ -110,8 +117,9 @@ export function CharView({
Question {revealedQuestion.question_number}
</p>
<p className="font-heading text-xl text-text-marble sm:text-2xl lg:text-3xl">
<MentionText text={revealedQuestion.text} pseudos={profiles.map((p) => p.pseudo)} />
{revealedQuestionDisplay.cleanText}
</p>
<MentionTags pseudos={revealedQuestionDisplay.mentioned} profiles={profiles} className="mt-3 justify-center" />
</div>
)}
+7 -5
View File
@@ -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({
</button>
</div>
<p className="min-w-0 flex-1 text-text-marble">
<MentionText text={question.text} pseudos={pseudos} />
</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
+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>
);
}
-33
View File
@@ -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(<Fragment key={`t${index}`}>{text.slice(cursor, range.start)}</Fragment>);
nodes.push(
<span key={`m${index}`} className={mentionClassName}>
{text.slice(range.start, range.end)}
</span>,
);
cursor = range.end;
});
if (cursor < text.length) nodes.push(<Fragment key="tail">{text.slice(cursor)}</Fragment>);
return <>{nodes}</>;
}
+32
View File
@@ -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<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 };
}