Compare commits
5 Commits
e70f0c85bb
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c09c0cdd4 | |||
| 5b5393cbd6 | |||
| 3f594768b0 | |||
| 058b1ec140 | |||
| 4f920175fd |
@@ -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 |
|
||||
|
||||
Generated
+56
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'un pour l'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'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();
|
||||
|
||||
@@ -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'Abondance
|
||||
</h1>
|
||||
<p className="font-serif text-sm text-marble/60 italic">
|
||||
Fais fusionner les Citoyens jusqu'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">
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user