Compare commits
8 Commits
3aa2ea451c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c09c0cdd4 | |||
| 5b5393cbd6 | |||
| 3f594768b0 | |||
| 058b1ec140 | |||
| 4f920175fd | |||
| e70f0c85bb | |||
| 358bed0c2e | |||
| 187cdec0e2 |
@@ -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.
|
||||
@@ -179,11 +182,11 @@ Deuxième volet de l'audit (revue complète de tout le code applicatif, pas seul
|
||||
| `/` | Redirige vers `/leaderboard` si connecté, sinon `/login` |
|
||||
| `/login` | Email + mot de passe |
|
||||
| `/signup` | Email + mot de passe + pseudo + photo (recadrée) ; gère l'attente de confirmation email |
|
||||
| `/leaderboard` | « L'Agora » — podium top 3 + liste, Realtime, contrôles de points pour les juges, date/décompte du Tribunal en en-tête (`src/lib/tribunal-date.ts`, partagé avec `/calendrier`). Joue le rôle de page d'accueil (`/` y redirige) : une rangée de raccourcis (`quick-links.tsx`, icônes réutilisées du menu du header) vers les pages d'activité — jeux, Mur, Urne, Crieur — juste au-dessus du classement, avec une pastille façon iOS sur le raccourci du Mur (nombre de publications du jour, via `wall_notes_for_date()` — pas de lecture directe de `wall_notes`, dont la RLS ne laisserait voir que ses propres notes) |
|
||||
| `/leaderboard` | « L'Agora » — podium top 3 + liste, Realtime, contrôles de points pour les juges, date/décompte du Tribunal en en-tête (`src/lib/tribunal-date.ts`, partagé avec `/calendrier`). Joue le rôle de page d'accueil (`/` y redirige) : une rangée de raccourcis (`quick-links.tsx`, icônes réutilisées du menu du header) vers les pages d'activité — jeux, Mur, Urne, Crieur — juste au-dessus du classement, avec une pastille façon iOS sur le raccourci du Mur (nombre de publications du jour, via `wall_notes_for_date()` — pas de lecture directe de `wall_notes`, dont la RLS ne laisserait voir que ses propres notes). Juste en dessous, `game-highlights.tsx` affiche le n°1 actuel d'Icare et de la Corne d'Abondance (lecture directe de `icarus_scores`/`melon_scores`, RLS déjà ouverte à tout authentifié) — même règle que les podiums de ces deux jeux : pas de champion affiché tant que le meneur est à 0 |
|
||||
| `/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 |
|
||||
@@ -217,11 +220,16 @@ Polices (`next/font/google`, variables CSS, exposées comme `font-heading`/`font
|
||||
|
||||
Ornements en SVG/CSS inline (jamais d'image bitmap) :
|
||||
- `components/laurel-wreath.tsx` — couronne de laurier calculée par trigonométrie (deux arcs symétriques de feuilles), utilisée en logo, sur le rang 1 du podium, etc. Le favicon (`src/app/icon.svg`) est une version statique des mêmes coordonnées.
|
||||
- `.meander-divider` (classe globale) — frise à méandre grecque en `background-image` SVG data-URI, séparateur discret.
|
||||
- `components/diamond-divider.tsx` — séparateur ◆ avec filets dégradés.
|
||||
- `.marble-surface` (classe globale) — texture marbre via dégradés (pas d'image de bruit).
|
||||
- `.meander-divider` (classe globale) — frise à méandre grecque en `background-image` SVG data-URI, séparateur discret. Utilisée sous le header, et depuis la passe de polish visuel entre le podium et le reste du classement sur `/leaderboard` (`leaderboard-view.tsx`) — seulement quand un podium est réellement affiché.
|
||||
- `components/diamond-divider.tsx` — séparateur ◆ avec filets dégradés. Utilisé sur `/profile` entre la carte profil et la carte suggestion de question (deux actions distinctes, pas la même importance — voir la convention de carte secondaire ci-dessous).
|
||||
- `.marble-surface` (classe globale) — texture marbre via dégradés (pas d'image de bruit). Porte aussi, depuis la même passe, un léger halo doré respirant (`outline`, jamais `box-shadow`/`border` pour ne jamais entrer en conflit avec l'ombre/la bordure propre à chaque usage) — animation lente (7s), désactivée sous `prefers-reduced-motion`.
|
||||
- `components/empty-state.tsx` — petit ◆ doré au-dessus d'un message "rien à afficher" (`/journal`, `/leaderboard`, `/mur`), pour qu'un état vide se lise comme un état normal de l'app plutôt qu'un oubli.
|
||||
- `components/confetti.tsx` — partagé entre Le Vol d'Icare et La Corne d'Abondance (fin de partie), extrait de l'ancien code propre à Icare. À l'appelant de vérifier `prefers-reduced-motion` avant de le monter (comme déjà fait sur les deux jeux).
|
||||
- Fond (`body`, `globals.css`) : le dégradé "mer" respire lentement (`--sea-glow`, `@property` pour une transition fluide côté navigateurs qui le supportent, dégradation sans casse sinon) plutôt que rester figé — même principe de subtilité, désactivé sous `prefers-reduced-motion`.
|
||||
|
||||
Convention de carte : `marble-surface rounded-2xl border border-gold/40 shadow-xl` (formulaires, podium) ou `rounded-lg border-gold/25` pour les lignes de liste. Bouton primaire : `bg-ink-2 text-gold-bright font-heading uppercase hover:bg-ink`. Bouton secondaire : `border border-gold/30 text-text-marble hover:bg-gold/10`.
|
||||
Convention de carte : `marble-surface rounded-2xl border border-gold/40 shadow-xl` (formulaires, podium) ou `rounded-lg border-gold/25` pour les lignes de liste. Carte secondaire (action annexe, pas le cœur de la page — ex. la suggestion de question sur `/profile`) : `border-gold/20 shadow-sm` plutôt que `border-gold/40 shadow-xl`, pour une hiérarchie visuelle claire sans sortir du langage marble-surface. Bouton primaire : `bg-ink-2 text-gold-bright font-heading uppercase hover:bg-ink`. Bouton secondaire : `border border-gold/30 text-text-marble hover:bg-gold/10`.
|
||||
|
||||
Sons : synthétisés via Web Audio (jamais de fichier audio dans le projet) — `src/lib/melon/sound.ts` (pop de fusion, La Corne d'Abondance) et `src/lib/icarus/sound.ts` (montée en dents de scie à la cueillette de l'éclair de Zeus, Le Vol d'Icare). `AudioContext` créé paresseusement au premier appel, toujours après un vrai geste utilisateur (contrainte d'autoplay des navigateurs).
|
||||
|
||||
Vocabulaire diégétique (toujours accompagné du terme fonctionnel dans le code/les commentaires, pour que ça reste maintenable) : juges/admins → **Archontes**, membres → **Citoyens**, leaderboard → **L'Agora**, journal des points → **Le Crieur** (décrets), points positifs/négatifs → **Honneurs**/**Blâmes**, points → **gloires**. Exception volontaire : la page `/admin` s'appelle simplement « Admin » (police monospace, rupture délibérée avec le vocabulaire diégétique et le thème grec, pour marquer que c'est un outil technique).
|
||||
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export function EntryRanking({
|
||||
<li key={p.id} className="flex min-w-0 items-center gap-1.5 text-xs">
|
||||
<Avatar pseudo={p.pseudo} avatarUrl={p.avatar_url} size="xs" />
|
||||
<span className={`truncate ${count === 0 ? "text-text-mut/60" : "text-text-marble"}`}>{p.pseudo}</span>
|
||||
<span className="ml-auto shrink-0 font-heading text-gold-bright">{count > 0 ? `×${count}` : "—"}</span>
|
||||
<span className="ml-auto shrink-0 font-heading text-sea">{count > 0 ? `×${count}` : "—"}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageLoading } from "@/components/page-loading";
|
||||
|
||||
export default function Loading() {
|
||||
return <PageLoading />;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
WALL_THICKNESS,
|
||||
} from "@/lib/melon/constants";
|
||||
import { playMergeSound } from "@/lib/melon/sound";
|
||||
import { Confetti } from "@/components/confetti";
|
||||
import type { MemberRow } from "./page";
|
||||
|
||||
type Status = "playing" | "dead";
|
||||
@@ -31,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;
|
||||
@@ -51,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
|
||||
@@ -324,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) {
|
||||
@@ -422,6 +487,9 @@ export function MelonGame({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- monté une seule fois ; spawnPiece/maxTier sont stables tant que citizens.length ne change pas
|
||||
}, []);
|
||||
|
||||
const prefersReducedMotion =
|
||||
typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="flex items-center justify-between" style={{ width: CONTAINER_WIDTH }}>
|
||||
@@ -471,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) => {
|
||||
@@ -525,6 +572,8 @@ export function MelonGame({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status === "dead" && !prefersReducedMotion && <Confetti />}
|
||||
|
||||
{status === "dead" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-b-2xl bg-ink/70">
|
||||
<div className="marble-surface animate-reveal flex flex-col items-center gap-2 rounded-xl border border-gold/40 px-6 py-4 text-center shadow-lg">
|
||||
|
||||
+49
-1
@@ -41,10 +41,22 @@
|
||||
--font-sans: var(--font-manrope);
|
||||
}
|
||||
|
||||
/* Intensité du dégradé "mer" du fond, en pourcentage — enregistrée via
|
||||
@property pour qu'un navigateur qui la supporte anime une transition
|
||||
fluide entre les deux valeurs de ambient-glow (sinon dégradation sans
|
||||
casse : la valeur du keyframe s'applique quand même, juste sans lissage
|
||||
intermédiaire). */
|
||||
@property --sea-glow {
|
||||
syntax: "<percentage>";
|
||||
inherits: true;
|
||||
initial-value: 30%;
|
||||
}
|
||||
|
||||
body {
|
||||
--sea-glow: 30%;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 20% -10%, color-mix(in srgb, var(--ink-2) 75%, transparent) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 70% 60% at 100% 110%, color-mix(in srgb, var(--sea) 30%, transparent) 0%, transparent 65%),
|
||||
radial-gradient(ellipse 70% 60% at 100% 110%, color-mix(in srgb, var(--sea) var(--sea-glow), transparent) 0%, transparent 65%),
|
||||
var(--ink);
|
||||
background-attachment: fixed;
|
||||
color: var(--foreground);
|
||||
@@ -112,6 +124,42 @@ body {
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
/* Mer de nuit qui respire très lentement, plutôt qu'un fond figé — reste
|
||||
imperceptible comme un vrai mouvement, juste une ambiance qui vit. */
|
||||
@keyframes ambient-glow {
|
||||
from {
|
||||
--sea-glow: 22%;
|
||||
}
|
||||
to {
|
||||
--sea-glow: 42%;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
animation: ambient-glow 40s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
/* Léger halo doré respirant sur les cartes marbre, façon lueur de
|
||||
torche — un outline (jamais box-shadow/border, déjà utilisés par les
|
||||
classes Tailwind de chaque carte) pour ne jamais entrer en conflit
|
||||
avec l'ombre/la bordure propres à chaque usage. N'affecte pas la mise
|
||||
en page (outline ne prend pas de place). */
|
||||
@keyframes card-flicker {
|
||||
0%,
|
||||
100% {
|
||||
outline-color: transparent;
|
||||
}
|
||||
50% {
|
||||
outline-color: color-mix(in srgb, var(--gold) 35%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.marble-surface {
|
||||
outline: 1px solid transparent;
|
||||
outline-offset: 2px;
|
||||
animation: card-flicker 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes podium-rise {
|
||||
0% {
|
||||
opacity: 0;
|
||||
|
||||
@@ -43,11 +43,11 @@ import {
|
||||
stepIcarus,
|
||||
} from "@/lib/icarus/physics";
|
||||
import type { Boost, Column, IcarusState, ShatterEffect } from "@/lib/icarus/types";
|
||||
import { playBoostSound } from "@/lib/icarus/sound";
|
||||
import { Confetti } from "@/components/confetti";
|
||||
|
||||
type Status = "idle" | "flying" | "dead";
|
||||
|
||||
const CONFETTI_COLORS = ["#E7C560", "#C9A227", "#F4ECD8", "#A5342A", "#5E6B3B", "#2E6E7E", "#B08D57"];
|
||||
|
||||
// 0 avant DIFFICULTY_START_SCORE, monte jusqu'à 1 sur DIFFICULTY_RAMP_SPAN
|
||||
// points, puis reste à 1 (palier, pour ne pas devenir injouable).
|
||||
function difficultyProgress(score: number): number {
|
||||
@@ -499,6 +499,7 @@ export function IcarusGame({
|
||||
const collectedIndex = boostsRef.current.findIndex((b) => collidesWithBoost(icarusRef.current.y, b));
|
||||
if (collectedIndex !== -1) {
|
||||
boostsRef.current.splice(collectedIndex, 1);
|
||||
playBoostSound();
|
||||
// Active le bouclier pour les N prochaines colonnes (distance
|
||||
// aléatoire, voir plus bas) et déclenche l'animation de bond en
|
||||
// avant. Icare se fige (vitesse verticale annulée, plus de
|
||||
@@ -637,56 +638,6 @@ export function IcarusGame({
|
||||
);
|
||||
}
|
||||
|
||||
type ConfettiPiece = {
|
||||
id: number;
|
||||
left: number;
|
||||
color: string;
|
||||
delay: number;
|
||||
duration: number;
|
||||
spin: number;
|
||||
drift: number;
|
||||
};
|
||||
|
||||
function generateConfettiPieces(): ConfettiPiece[] {
|
||||
return Array.from({ length: 26 }, (_, index) => ({
|
||||
id: index,
|
||||
left: Math.random() * 100,
|
||||
color: CONFETTI_COLORS[index % CONFETTI_COLORS.length],
|
||||
delay: Math.random() * 250,
|
||||
duration: 900 + Math.random() * 700,
|
||||
spin: (Math.random() > 0.5 ? 1 : -1) * (360 + Math.random() * 360),
|
||||
drift: (Math.random() - 0.5) * 70,
|
||||
}));
|
||||
}
|
||||
|
||||
function Confetti() {
|
||||
// Initialiseur paresseux de useState (n'exécute Math.random() qu'une
|
||||
// seule fois, au montage — Confetti est remonté à chaque nouvelle partie
|
||||
// terminée) plutôt qu'un appel impur dans le corps du composant.
|
||||
const [pieces] = useState(generateConfettiPieces);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
{pieces.map((piece) => (
|
||||
<span
|
||||
key={piece.id}
|
||||
className="animate-confetti-fall absolute top-0 h-2.5 w-1.5 rounded-[1px]"
|
||||
style={
|
||||
{
|
||||
left: `${piece.left}%`,
|
||||
backgroundColor: piece.color,
|
||||
animationDelay: `${piece.delay}ms`,
|
||||
animationDuration: `${piece.duration}ms`,
|
||||
"--confetti-spin": `${piece.spin}deg`,
|
||||
"--confetti-drift": `${piece.drift}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function render(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
canvas: HTMLCanvasElement,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
|
||||
type Entry = {
|
||||
id: number;
|
||||
@@ -56,7 +57,7 @@ export function JournalView({
|
||||
}, []);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return <p className="text-sm text-marble/60">Aucun décret pour l'instant.</p>;
|
||||
return <EmptyState message="Aucun décret pour l'instant." />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -72,7 +73,11 @@ export function JournalView({
|
||||
className="marble-surface flex gap-3 rounded-lg border border-gold/25 px-4 py-3 text-sm shadow-sm"
|
||||
>
|
||||
<span
|
||||
className="mt-1 h-3 w-3 shrink-0 rounded-full bg-oxblood shadow-[0_0_0_2px_rgba(165,52,42,0.25)]"
|
||||
className={`mt-1 h-3 w-3 shrink-0 rounded-full ${
|
||||
entry.delta >= 0
|
||||
? "bg-olive shadow-[0_0_0_2px_rgba(94,107,59,0.25)]"
|
||||
: "bg-oxblood shadow-[0_0_0_2px_rgba(165,52,42,0.25)]"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import Link from "next/link";
|
||||
import type { ComponentType } from "react";
|
||||
import { Avatar } from "@/components/avatar";
|
||||
import { IconHorn, IconWings } from "@/components/icons";
|
||||
|
||||
export type GameLeader = { pseudo: string; avatarUrl: string | null; score: number } | null;
|
||||
|
||||
function LeaderCard({
|
||||
href,
|
||||
label,
|
||||
icon: Icon,
|
||||
leader,
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
leader: GameLeader;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="marble-surface flex items-center gap-3 rounded-xl border border-gold/25 p-3 shadow-sm transition hover:border-gold/50 hover:shadow-md"
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0 text-gold" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[0.65rem] font-medium tracking-wide text-text-mut uppercase">{label}</p>
|
||||
{leader ? (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Avatar pseudo={leader.pseudo} avatarUrl={leader.avatarUrl} size="xs" />
|
||||
<span className="truncate text-sm font-medium text-text-marble">{leader.pseudo}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-1 truncate text-sm text-text-mut">Personne n'a encore joué</p>
|
||||
)}
|
||||
</div>
|
||||
{leader && <span className="shrink-0 font-heading text-sm font-bold text-sea">{leader.score}</span>}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Comme les podiums (voir leaderboard/icare/corne) : un meneur à 0 n'est pas
|
||||
// un vrai champion, juste un score par défaut — rien à afficher tant que
|
||||
// personne n'a vraiment marqué (leader passé à null par la page).
|
||||
export function GameHighlights({
|
||||
icarusLeader,
|
||||
melonLeader,
|
||||
}: {
|
||||
icarusLeader: GameLeader;
|
||||
melonLeader: GameLeader;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-6 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<LeaderCard href="/icare" label="Le Vol d'Icare" icon={IconWings} leader={icarusLeader} />
|
||||
<LeaderCard href="/corne" label="La Corne d'Abondance" icon={IconHorn} leader={melonLeader} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
|
||||
import { Avatar } from "@/components/avatar";
|
||||
import { Podium } from "@/components/podium";
|
||||
import { JudgePointControls } from "@/components/judge-point-controls";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { computeRanks, computeProgress } from "@/lib/ranking";
|
||||
|
||||
type Profile = {
|
||||
@@ -94,7 +95,7 @@ export function LeaderboardView({
|
||||
const rest = hasScores ? ranked.filter((p) => p.rank > 3) : ranked;
|
||||
|
||||
if (profiles.length === 0) {
|
||||
return <p className="text-sm text-marble/60">Personne n'est encore inscrit.</p>;
|
||||
return <EmptyState message="Personne n'est encore inscrit." />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -107,6 +108,10 @@ export function LeaderboardView({
|
||||
onApplyDelta={applyOptimisticDelta}
|
||||
/>
|
||||
|
||||
{/* Frise à méandre : marque la transition podium -> reste du
|
||||
classement, seulement quand il y a bien un podium au-dessus. */}
|
||||
{podiumProfiles.length > 0 && <div className="meander-divider mb-4" />}
|
||||
|
||||
<ol className="flex flex-col gap-2">
|
||||
{rest.map((profile) => {
|
||||
const progress = computeProgress(profile.rank, profile.previous_rank);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageLoading } from "@/components/page-loading";
|
||||
|
||||
export default function Loading() {
|
||||
return <PageLoading />;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { LeaderboardView } from "./leaderboard-view";
|
||||
import { QuickLinks } from "./quick-links";
|
||||
import { GameHighlights, type GameLeader } from "./game-highlights";
|
||||
import { AgoraLogo } from "@/components/agora-logo";
|
||||
import { formatCountdown, formatTribunalDateTime } from "@/lib/tribunal-date";
|
||||
|
||||
@@ -15,7 +16,8 @@ export default async function LeaderboardPage() {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const [{ data: profiles, error }, { data: settings }, { data: wallNotesToday }] = await Promise.all([
|
||||
const [{ data: profiles, error }, { data: settings }, { data: wallNotesToday }, { data: icarusTop }, { data: melonTop }] =
|
||||
await Promise.all([
|
||||
supabase
|
||||
.from("profiles")
|
||||
.select("id, pseudo, avatar_url, points, previous_rank, role")
|
||||
@@ -26,11 +28,25 @@ export default async function LeaderboardPage() {
|
||||
// connaître le nombre total de publications du jour sans exposer qui a
|
||||
// publié quoi — sert juste de compteur pour la pastille du raccourci.
|
||||
supabase.rpc("wall_notes_for_date"),
|
||||
supabase.from("icarus_scores").select("user_id, best_score").order("best_score", { ascending: false }).limit(1),
|
||||
supabase.from("melon_scores").select("user_id, best_score").order("best_score", { ascending: false }).limit(1),
|
||||
]);
|
||||
|
||||
const isJudge = profiles?.find((p) => p.id === user.id)?.role === "judge";
|
||||
const tribunalDate = settings?.tribunal_date ?? null;
|
||||
|
||||
const profileById = new Map((profiles ?? []).map((p) => [p.id, p]));
|
||||
// Comme les podiums d'Icare/Corne : un meneur à 0 (personne n'a encore
|
||||
// joué) n'est pas un champion, pas de couronne pour un score par défaut.
|
||||
function buildLeader(rows: { user_id: string; best_score: number }[] | null): GameLeader {
|
||||
const top = rows?.[0];
|
||||
if (!top || top.best_score <= 0) return null;
|
||||
const profile = profileById.get(top.user_id);
|
||||
return { pseudo: profile?.pseudo ?? "Un Citoyen", avatarUrl: profile?.avatar_url ?? null, score: top.best_score };
|
||||
}
|
||||
const icarusLeader = buildLeader(icarusTop);
|
||||
const melonLeader = buildLeader(melonTop);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl px-4 py-8">
|
||||
<div className="mb-6 flex flex-col items-center text-center">
|
||||
@@ -44,6 +60,8 @@ export default async function LeaderboardPage() {
|
||||
|
||||
<QuickLinks wallNoteCount={wallNotesToday?.length ?? 0} />
|
||||
|
||||
<GameHighlights icarusLeader={icarusLeader} melonLeader={melonLeader} />
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-oxblood">
|
||||
Impossible de charger le classement pour le moment.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageLoading } from "@/components/page-loading";
|
||||
|
||||
export default function Loading() {
|
||||
return <PageLoading />;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState, type ChangeEvent, type FormEvent } fr
|
||||
import Image from "next/image";
|
||||
import { Avatar } from "@/components/avatar";
|
||||
import { IconCamera, IconClose } from "@/components/icons";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { resizeImageToBlob } from "@/lib/image";
|
||||
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
|
||||
import type { MyReactionRow, ReactionCountRow, WallHistoryRow, WallNoteRow } from "./page";
|
||||
@@ -434,9 +435,7 @@ export function MurView({
|
||||
{pastLoading ? (
|
||||
<p className="text-center text-sm text-marble/60">Chargement…</p>
|
||||
) : displayedNotes.length === 0 ? (
|
||||
<p className="text-center text-sm text-marble/60">
|
||||
{viewedDate === null ? "Le mur est encore vide aujourd'hui." : "Le mur était vide ce jour-là."}
|
||||
</p>
|
||||
<EmptyState message={viewedDate === null ? "Le mur est encore vide aujourd'hui." : "Le mur était vide ce jour-là."} />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
||||
{displayedNotes.map((note) => (
|
||||
@@ -558,9 +557,10 @@ export function MurView({
|
||||
)}
|
||||
</div>
|
||||
{filteredHistory.length === 0 ? (
|
||||
<p className="text-sm text-text-mut">
|
||||
{history.length === 0 ? "Aucune note publiée pour l'instant." : "Aucune note ce jour-là."}
|
||||
</p>
|
||||
<EmptyState
|
||||
message={history.length === 0 ? "Aucune note publiée pour l'instant." : "Aucune note ce jour-là."}
|
||||
className="text-text-mut"
|
||||
/>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-gold/10">
|
||||
{filteredHistory.map((note) => (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { DiamondDivider } from "@/components/diamond-divider";
|
||||
import { ProfileForm } from "./profile-form";
|
||||
import { ChariotSubmissionForm } from "./chariot-submission-form";
|
||||
|
||||
@@ -58,12 +59,19 @@ export default async function ProfilePage() {
|
||||
</div>
|
||||
|
||||
{!isJudge && (
|
||||
<div className="marble-surface mt-4 rounded-2xl border border-gold/40 p-6 shadow-xl sm:p-8">
|
||||
<>
|
||||
{/* Carte secondaire (bordure plus fine, pas de grande ombre) :
|
||||
une action ponctuelle annexe, pas le cœur de la page comme la
|
||||
carte profil au-dessus — distincte sans casser la cohérence
|
||||
marble-surface. */}
|
||||
<DiamondDivider className="my-5" />
|
||||
<div className="marble-surface rounded-2xl border border-gold/20 p-6 shadow-sm">
|
||||
<h2 className="mb-3 font-heading text-sm tracking-wide text-text-mut uppercase">
|
||||
Ma question pour Le Char
|
||||
</h2>
|
||||
<ChariotSubmissionForm initialText={submissionText} frozen={frozen} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -64,14 +64,18 @@ export function ProfileForm({
|
||||
let nextAvatarUrl = avatarUrl;
|
||||
|
||||
if (avatarBlob) {
|
||||
const path = `${userId}.webp`;
|
||||
// Extension alignée sur le vrai type du blob (pas ".webp" en dur) :
|
||||
// certains navigateurs (Safari) retombent sur du PNG si le WebP
|
||||
// n'est pas supporté à l'encodage — voir cropImageToBlob.
|
||||
const extension = avatarBlob.type === "image/jpeg" ? "jpg" : avatarBlob.type === "image/png" ? "png" : "webp";
|
||||
const path = `${userId}.${extension}`;
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from("avatars")
|
||||
.upload(path, avatarBlob, { upsert: true, contentType: "image/webp" });
|
||||
.upload(path, avatarBlob, { upsert: true, contentType: avatarBlob.type || "image/webp" });
|
||||
|
||||
if (uploadError) {
|
||||
setLoading(false);
|
||||
setError("Impossible d'envoyer la photo, réessaie.");
|
||||
setError(`Impossible d'envoyer la photo (${uploadError.message}). Réessaie.`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+11
-2
@@ -120,14 +120,23 @@ export default function SignupPage() {
|
||||
|
||||
let avatarUrl: string | null = null;
|
||||
if (avatarBlob) {
|
||||
const path = `${userId}.webp`;
|
||||
// Extension alignée sur le vrai type du blob (pas ".webp" en dur) :
|
||||
// certains navigateurs (Safari) retombent sur du PNG si le WebP
|
||||
// n'est pas supporté à l'encodage — voir cropImageToBlob.
|
||||
const extension = avatarBlob.type === "image/jpeg" ? "jpg" : avatarBlob.type === "image/png" ? "png" : "webp";
|
||||
const path = `${userId}.${extension}`;
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from("avatars")
|
||||
.upload(path, avatarBlob, { upsert: true, contentType: "image/webp" });
|
||||
.upload(path, avatarBlob, { upsert: true, contentType: avatarBlob.type || "image/webp" });
|
||||
|
||||
if (!uploadError) {
|
||||
const { data: publicUrlData } = supabase.storage.from("avatars").getPublicUrl(path);
|
||||
avatarUrl = publicUrlData.publicUrl;
|
||||
} else {
|
||||
// Ne bloque pas la finalisation du compte pour une photo optionnelle
|
||||
// — juste de quoi diagnostiquer si quelqu'un signale l'absence
|
||||
// d'avatar après inscription.
|
||||
console.error("Échec de l'envoi de l'avatar à l'inscription :", uploadError);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type CSSProperties } from "react";
|
||||
|
||||
const CONFETTI_COLORS = ["#E7C560", "#C9A227", "#F4ECD8", "#A5342A", "#5E6B3B", "#2E6E7E", "#B08D57"];
|
||||
|
||||
type ConfettiPiece = {
|
||||
id: number;
|
||||
left: number;
|
||||
color: string;
|
||||
delay: number;
|
||||
duration: number;
|
||||
spin: number;
|
||||
drift: number;
|
||||
};
|
||||
|
||||
function generateConfettiPieces(): ConfettiPiece[] {
|
||||
return Array.from({ length: 26 }, (_, index) => ({
|
||||
id: index,
|
||||
left: Math.random() * 100,
|
||||
color: CONFETTI_COLORS[index % CONFETTI_COLORS.length],
|
||||
delay: Math.random() * 250,
|
||||
duration: 900 + Math.random() * 700,
|
||||
spin: (Math.random() > 0.5 ? 1 : -1) * (360 + Math.random() * 360),
|
||||
drift: (Math.random() - 0.5) * 70,
|
||||
}));
|
||||
}
|
||||
|
||||
// Partagé entre Le Vol d'Icare et La Corne d'Abondance (fin de partie) —
|
||||
// à l'appelant de ne le monter que si prefers-reduced-motion ne le
|
||||
// désactive pas, comme déjà fait sur les deux jeux.
|
||||
export function Confetti() {
|
||||
// Initialiseur paresseux de useState (n'exécute Math.random() qu'une
|
||||
// seule fois, au montage — Confetti est remonté à chaque nouvelle partie
|
||||
// terminée) plutôt qu'un appel impur dans le corps du composant.
|
||||
const [pieces] = useState(generateConfettiPieces);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
{pieces.map((piece) => (
|
||||
<span
|
||||
key={piece.id}
|
||||
className="animate-confetti-fall absolute top-0 h-2.5 w-1.5 rounded-[1px]"
|
||||
style={
|
||||
{
|
||||
left: `${piece.left}%`,
|
||||
backgroundColor: piece.color,
|
||||
animationDelay: `${piece.delay}ms`,
|
||||
animationDuration: `${piece.duration}ms`,
|
||||
"--confetti-spin": `${piece.spin}deg`,
|
||||
"--confetti-drift": `${piece.drift}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Petit ornement (◆, comme diamond-divider.tsx) au-dessus des messages
|
||||
// "rien à afficher" — sans ça ce sont des états qui ressemblent à une
|
||||
// erreur/un oubli plutôt qu'un vrai état normal de l'app.
|
||||
export function EmptyState({ message, className = "text-marble/60" }: { message: string; className?: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-2 text-center">
|
||||
<span className="text-gold/50" aria-hidden>
|
||||
◆
|
||||
</span>
|
||||
<p className={`text-sm ${className}`}>{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,14 @@
|
||||
import { AgoraLogo } from "@/components/agora-logo";
|
||||
|
||||
// Utilisé par les loading.tsx des pages les plus consultées : sans ça, le
|
||||
// temps de la requête d'un Server Component se traduit par un écran vide,
|
||||
// ce qui a déjà fait croire à un vrai bug une fois (/signup, voir
|
||||
// l'historique du projet) — même un indicateur minimal évite ce doute.
|
||||
export function PageLoading() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-16">
|
||||
<AgoraLogo className="h-16 w-16 animate-pulse" />
|
||||
<p className="font-heading text-xs tracking-[0.2em] text-marble/50 uppercase">Chargement…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Petit effet synthétisé via Web Audio, même principe que melon/sound.ts
|
||||
// (pas de fichier audio dans le projet, cohérent avec le reste de l'app) :
|
||||
// joué à la cueillette de l'éclair de Zeus — une montée rapide de
|
||||
// fréquence en dents de scie, pour évoquer une charge d'énergie plutôt
|
||||
// qu'un choc (le "pop" de fusion de la Corne, en onde sinusoïdale plus
|
||||
// douce, joue un rôle différent).
|
||||
let audioContext: AudioContext | null = null;
|
||||
|
||||
function getAudioContext(): AudioContext | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
if (!audioContext) {
|
||||
const Ctor =
|
||||
window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!Ctor) return null;
|
||||
audioContext = new Ctor();
|
||||
}
|
||||
if (audioContext.state === "suspended") {
|
||||
audioContext.resume().catch(() => {});
|
||||
}
|
||||
return audioContext;
|
||||
}
|
||||
|
||||
export function playBoostSound(): void {
|
||||
const ctx = getAudioContext();
|
||||
if (!ctx) return;
|
||||
|
||||
const now = ctx.currentTime;
|
||||
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
oscillator.type = "sawtooth";
|
||||
oscillator.frequency.setValueAtTime(280, now);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(1400, now + 0.16);
|
||||
|
||||
gain.gain.setValueAtTime(0.0001, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.18, now + 0.02);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.22);
|
||||
|
||||
oscillator.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
oscillator.start(now);
|
||||
oscillator.stop(now + 0.24);
|
||||
}
|
||||
+27
-7
@@ -9,8 +9,28 @@ function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
});
|
||||
}
|
||||
|
||||
// Certains navigateurs (Safari, historiquement) ne savent pas encoder du
|
||||
// WebP via canvas.toBlob/toDataURL : la spec impose alors un repli
|
||||
// silencieux sur du PNG, un format quasi sans perte — beaucoup plus lourd
|
||||
// qu'un JPEG pour une photo, au point de dépasser la limite de taille du
|
||||
// bucket malgré la "compression" (vécu en prod avec une photo iPhone de
|
||||
// 4 Mo). Détecter le support réel et demander explicitement du JPEG dans
|
||||
// ce cas — plutôt que de laisser le navigateur retomber sur du PNG —
|
||||
// garantit une vraie compression avec perte dans tous les cas.
|
||||
let cachedMimeType: string | null = null;
|
||||
|
||||
function preferredMimeType(): string {
|
||||
if (cachedMimeType) return cachedMimeType;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
cachedMimeType = canvas.toDataURL("image/webp").startsWith("data:image/webp") ? "image/webp" : "image/jpeg";
|
||||
return cachedMimeType;
|
||||
}
|
||||
|
||||
// Découpe la zone sélectionnée, redimensionne (sans jamais agrandir) à
|
||||
// maxSize et compresse en WebP côté client avant l'envoi vers le bucket.
|
||||
// maxSize et compresse (WebP si le navigateur sait l'encoder, sinon JPEG)
|
||||
// côté client avant l'envoi vers le bucket.
|
||||
export async function cropImageToBlob(
|
||||
imageSrc: string,
|
||||
crop: PixelCrop,
|
||||
@@ -33,7 +53,7 @@ export async function cropImageToBlob(
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => (blob ? resolve(blob) : reject(new Error("Échec de la compression de l'image"))),
|
||||
"image/webp",
|
||||
preferredMimeType(),
|
||||
quality,
|
||||
);
|
||||
});
|
||||
@@ -41,10 +61,10 @@ export async function cropImageToBlob(
|
||||
|
||||
// Redimensionne (sans jamais agrandir) au format d'origine — contrairement
|
||||
// à cropImageToBlob qui force un carré pour les avatars — à maxDimension
|
||||
// sur le plus grand côté, et compresse en WebP avant l'envoi vers le
|
||||
// bucket. Le bucket a aussi sa propre limite de taille côté serveur
|
||||
// (défense en profondeur) : cette compression n'est qu'un premier filtre,
|
||||
// pas la seule garantie.
|
||||
// sur le plus grand côté, et compresse (WebP si supporté, sinon JPEG —
|
||||
// voir preferredMimeType) avant l'envoi vers le bucket. Le bucket a aussi
|
||||
// sa propre limite de taille côté serveur (défense en profondeur) : cette
|
||||
// compression n'est qu'un premier filtre, pas la seule garantie.
|
||||
export async function resizeImageToBlob(file: File, maxDimension = 1600, quality = 0.82): Promise<Blob> {
|
||||
const src = URL.createObjectURL(file);
|
||||
try {
|
||||
@@ -65,7 +85,7 @@ export async function resizeImageToBlob(file: File, maxDimension = 1600, quality
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => (blob ? resolve(blob) : reject(new Error("Échec de la compression de l'image"))),
|
||||
"image/webp",
|
||||
preferredMimeType(),
|
||||
quality,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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