Sort les tags @pseudo du texte des questions du Char en métadonnée
Build and deploy / deploy (push) Successful in 37s
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:
@@ -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`).
|
- **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.
|
Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, mode grand écran.
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
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 { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
|
||||||
import { EntryRanking } from "./entry-ranking";
|
import { EntryRanking } from "./entry-ranking";
|
||||||
import { GardienBar } from "./gardien-bar";
|
import { GardienBar } from "./gardien-bar";
|
||||||
@@ -84,6 +85,12 @@ export function CharView({
|
|||||||
}, [refetch]);
|
}, [refetch]);
|
||||||
|
|
||||||
const gardienHolder = profiles.find((p) => p.id === settings.gardien_holder_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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -101,7 +108,7 @@ export function CharView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{revealedQuestion && (
|
{revealedQuestion && revealedQuestionDisplay && (
|
||||||
<div
|
<div
|
||||||
key={revealedQuestion.id}
|
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"
|
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}
|
Question {revealedQuestion.question_number}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-heading text-xl text-text-marble sm:text-2xl lg:text-3xl">
|
<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>
|
</p>
|
||||||
|
<MentionTags pseudos={revealedQuestionDisplay.mentioned} profiles={profiles} className="mt-3 justify-center" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { CSS } from "@dnd-kit/utilities";
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Avatar } from "@/components/avatar";
|
import { Avatar } from "@/components/avatar";
|
||||||
import { IconChevronDown, IconGrip, IconPencil, IconPlus, IconShuffle, IconTrash } from "@/components/icons";
|
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 { MentionTextarea } from "@/components/mention-textarea";
|
||||||
import { extractMentionedPseudos } from "@/lib/mentions";
|
import { extractMentionedPseudos, stripMentions } from "@/lib/mentions";
|
||||||
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
|
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
|
||||||
import type { ProfileRow, QuestionRow, SubmissionHistoryRow, SubmissionRow } from "./page";
|
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).
|
// indisponible (le formulaire d'édition n'a pas de poignée).
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: question.id });
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: question.id });
|
||||||
const style = { transform: CSS.Transform.toString(transform), transition };
|
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||||
|
const { cleanText, mentioned } = stripMentions(question.text, pseudos);
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
@@ -184,9 +185,10 @@ function QuestionRowItem({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="min-w-0 flex-1 text-text-marble">
|
<div className="min-w-0 flex-1">
|
||||||
<MentionText text={question.text} pseudos={pseudos} />
|
<p className="text-text-marble">{cleanText}</p>
|
||||||
</p>
|
<MentionTags pseudos={mentioned} profiles={profiles} className="mt-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}</>;
|
|
||||||
}
|
|
||||||
@@ -41,3 +41,35 @@ export function findMentionRanges(text: string, pseudos: string[]): MentionRange
|
|||||||
export function extractMentionedPseudos(text: string, pseudos: string[]): string[] {
|
export function extractMentionedPseudos(text: string, pseudos: string[]): string[] {
|
||||||
return findMentionRanges(text, pseudos).map((range) => range.pseudo);
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user