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>
This commit is contained in:
Valentin ROBIN
2026-08-27 17:35:13 +02:00
parent 3f594768b0
commit 5b5393cbd6
10 changed files with 463 additions and 34 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 à 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.
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",
+4 -1
View File
@@ -2,6 +2,7 @@
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { MentionText } from "@/components/mention-text";
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
import { EntryRanking } from "./entry-ranking";
import { GardienBar } from "./gardien-bar";
@@ -108,7 +109,9 @@ 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">
<MentionText text={revealedQuestion.text} pseudos={profiles.map((p) => p.pseudo)} />
</p>
</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>
);
+161 -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 { MentionText } from "@/components/mention-text";
import { MentionTextarea } from "@/components/mention-textarea";
import { extractMentionedPseudos } 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,19 @@ 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 };
async function handleDelete() {
const supabase = createClient();
@@ -109,19 +127,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 +184,9 @@ function QuestionRowItem({
</button>
</div>
<p className="min-w-0 flex-1 text-text-marble">{question.text}</p>
<p className="min-w-0 flex-1 text-text-marble">
<MentionText text={question.text} pseudos={pseudos} />
</p>
<div className="flex shrink-0 items-center gap-1">
<button
@@ -276,17 +317,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 +408,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 +491,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 +558,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();
+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>
);
}
+33
View File
@@ -0,0 +1,33 @@
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}</>;
}
+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>
);
}
+43
View File
@@ -0,0 +1,43 @@
// 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);
}