Compare commits
10 Commits
0eaebb7a05
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c09c0cdd4 | |||
| 5b5393cbd6 | |||
| 3f594768b0 | |||
| 058b1ec140 | |||
| 4f920175fd | |||
| e70f0c85bb | |||
| 358bed0c2e | |||
| 187cdec0e2 | |||
| 3aa2ea451c | |||
| 6922d33e39 |
@@ -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`).
|
- **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.
|
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)
|
- **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)
|
- **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
|
- **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`.
|
- **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.
|
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` |
|
| `/` | Redirige vers `/leaderboard` si connecté, sinon `/login` |
|
||||||
| `/login` | Email + mot de passe |
|
| `/login` | Email + mot de passe |
|
||||||
| `/signup` | Email + mot de passe + pseudo + photo (recadrée) ; gère l'attente de confirmation email |
|
| `/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 |
|
| `/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 |
|
| `/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 |
|
| `/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` | « 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 |
|
| `/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) |
|
| `/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 |
|
| `/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) :
|
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.
|
- `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.
|
- `.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.
|
- `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).
|
- `.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).
|
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",
|
"name": "tribunal-app",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"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/ssr": "^0.12.0",
|
||||||
"@supabase/supabase-js": "^2.109.0",
|
"@supabase/supabase-js": "^2.109.0",
|
||||||
"matter-js": "^0.20.0",
|
"matter-js": "^0.20.0",
|
||||||
@@ -281,6 +284,59 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/ssr": "^0.12.0",
|
||||||
"@supabase/supabase-js": "^2.109.0",
|
"@supabase/supabase-js": "^2.109.0",
|
||||||
"matter-js": "^0.20.0",
|
"matter-js": "^0.20.0",
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { MentionTags } from "@/components/mention-tags";
|
||||||
|
import { stripMentions } from "@/lib/mentions";
|
||||||
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
|
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
|
||||||
import { EntryRanking } from "./entry-ranking";
|
import { EntryRanking } from "./entry-ranking";
|
||||||
import { GardienBar } from "./gardien-bar";
|
import { GardienBar } from "./gardien-bar";
|
||||||
@@ -83,6 +85,12 @@ export function CharView({
|
|||||||
}, [refetch]);
|
}, [refetch]);
|
||||||
|
|
||||||
const gardienHolder = profiles.find((p) => p.id === settings.gardien_holder_id) ?? null;
|
const gardienHolder = profiles.find((p) => p.id === settings.gardien_holder_id) ?? null;
|
||||||
|
const revealedQuestionDisplay = revealedQuestion
|
||||||
|
? stripMentions(
|
||||||
|
revealedQuestion.text,
|
||||||
|
profiles.map((p) => p.pseudo),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -100,7 +108,7 @@ export function CharView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{revealedQuestion && (
|
{revealedQuestion && revealedQuestionDisplay && (
|
||||||
<div
|
<div
|
||||||
key={revealedQuestion.id}
|
key={revealedQuestion.id}
|
||||||
className="marble-surface animate-reveal rounded-2xl border border-gold/40 px-6 py-6 text-center shadow-xl sm:py-8"
|
className="marble-surface animate-reveal rounded-2xl border border-gold/40 px-6 py-6 text-center shadow-xl sm:py-8"
|
||||||
@@ -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">
|
<p className="mb-1 font-heading text-xs tracking-[0.2em] text-text-mut uppercase sm:text-sm">
|
||||||
Question {revealedQuestion.question_number}
|
Question {revealedQuestion.question_number}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-heading text-xl text-text-marble sm:text-2xl lg:text-3xl">{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function EntryRanking({
|
|||||||
<li key={p.id} className="flex min-w-0 items-center gap-1.5 text-xs">
|
<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" />
|
<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={`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>
|
</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;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProfileRow = {
|
||||||
|
id: string;
|
||||||
|
pseudo: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export default async function CharQuestionsPage() {
|
export default async function CharQuestionsPage() {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
const {
|
const {
|
||||||
@@ -71,6 +77,14 @@ export default async function CharQuestionsPage() {
|
|||||||
.select("user_id, text, created_at")
|
.select("user_id, text, created_at")
|
||||||
.order("created_at", { ascending: false });
|
.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 (
|
return (
|
||||||
<div className="mx-auto w-full max-w-2xl px-4 py-8">
|
<div className="mx-auto w-full max-w-2xl px-4 py-8">
|
||||||
<div className="mb-6 text-center">
|
<div className="mb-6 text-center">
|
||||||
@@ -90,6 +104,7 @@ export default async function CharQuestionsPage() {
|
|||||||
initialRevealedId={settings?.chariot_revealed_question_id ?? null}
|
initialRevealedId={settings?.chariot_revealed_question_id ?? null}
|
||||||
initialSubmissions={submissions ?? []}
|
initialSubmissions={submissions ?? []}
|
||||||
initialSubmissionHistory={submissionHistory ?? []}
|
initialSubmissionHistory={submissionHistory ?? []}
|
||||||
|
profiles={profiles ?? []}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
"use client";
|
"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 { 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 { 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 {
|
function formatHistoryDate(iso: string): string {
|
||||||
return new Date(iso).toLocaleString("fr-FR", {
|
return new Date(iso).toLocaleString("fr-FR", {
|
||||||
@@ -17,10 +23,12 @@ function formatHistoryDate(iso: string): string {
|
|||||||
function QuestionForm({
|
function QuestionForm({
|
||||||
initial,
|
initial,
|
||||||
nextPosition,
|
nextPosition,
|
||||||
|
profiles,
|
||||||
onDone,
|
onDone,
|
||||||
}: {
|
}: {
|
||||||
initial?: QuestionRow;
|
initial?: QuestionRow;
|
||||||
nextPosition: number;
|
nextPosition: number;
|
||||||
|
profiles: ProfileRow[];
|
||||||
onDone: () => void;
|
onDone: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [text, setText] = useState(initial?.text ?? "");
|
const [text, setText] = useState(initial?.text ?? "");
|
||||||
@@ -51,12 +59,13 @@ function QuestionForm({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-2 rounded-md border border-gold/30 bg-white/40 p-3">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-2 rounded-md border border-gold/30 bg-white/40 p-3">
|
||||||
<textarea
|
<MentionTextarea
|
||||||
placeholder="Écrire la question…"
|
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => setText(e.target.value)}
|
onChange={setText}
|
||||||
|
candidates={profiles}
|
||||||
rows={2}
|
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>}
|
{error && <p className="text-xs text-oxblood">{error}</p>}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -85,6 +94,8 @@ function QuestionRowItem({
|
|||||||
isFirst,
|
isFirst,
|
||||||
isLast,
|
isLast,
|
||||||
isRevealed,
|
isRevealed,
|
||||||
|
pseudos,
|
||||||
|
profiles,
|
||||||
onMove,
|
onMove,
|
||||||
onToggleReveal,
|
onToggleReveal,
|
||||||
onChanged,
|
onChanged,
|
||||||
@@ -94,12 +105,20 @@ function QuestionRowItem({
|
|||||||
isFirst: boolean;
|
isFirst: boolean;
|
||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
isRevealed: boolean;
|
isRevealed: boolean;
|
||||||
|
pseudos: string[];
|
||||||
|
profiles: ProfileRow[];
|
||||||
onMove: (direction: "up" | "down") => void;
|
onMove: (direction: "up" | "down") => void;
|
||||||
onToggleReveal: () => void;
|
onToggleReveal: () => void;
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [confirmingDelete, setConfirmingDelete] = 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() {
|
async function handleDelete() {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
@@ -109,19 +128,40 @@ function QuestionRowItem({
|
|||||||
|
|
||||||
if (editing) {
|
if (editing) {
|
||||||
return (
|
return (
|
||||||
<QuestionForm
|
<div ref={setNodeRef} style={style}>
|
||||||
initial={question}
|
<QuestionForm
|
||||||
nextPosition={question.position}
|
initial={question}
|
||||||
onDone={() => {
|
nextPosition={question.position}
|
||||||
setEditing(false);
|
profiles={profiles}
|
||||||
onChanged();
|
onDone={() => {
|
||||||
}}
|
setEditing(false);
|
||||||
/>
|
onChanged();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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>
|
<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">
|
<div className="flex shrink-0 flex-col">
|
||||||
@@ -145,7 +185,10 @@ function QuestionRowItem({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<button
|
<button
|
||||||
@@ -276,17 +319,38 @@ export function QuestionsView({
|
|||||||
initialRevealedId,
|
initialRevealedId,
|
||||||
initialSubmissions,
|
initialSubmissions,
|
||||||
initialSubmissionHistory,
|
initialSubmissionHistory,
|
||||||
|
profiles,
|
||||||
}: {
|
}: {
|
||||||
initialQuestions: QuestionRow[];
|
initialQuestions: QuestionRow[];
|
||||||
initialRevealedId: string | null;
|
initialRevealedId: string | null;
|
||||||
initialSubmissions: SubmissionRow[];
|
initialSubmissions: SubmissionRow[];
|
||||||
initialSubmissionHistory: SubmissionHistoryRow[];
|
initialSubmissionHistory: SubmissionHistoryRow[];
|
||||||
|
profiles: ProfileRow[];
|
||||||
}) {
|
}) {
|
||||||
const [questions, setQuestions] = useState(initialQuestions);
|
const [questions, setQuestions] = useState(initialQuestions);
|
||||||
const [revealedId, setRevealedId] = useState(initialRevealedId);
|
const [revealedId, setRevealedId] = useState(initialRevealedId);
|
||||||
const [submissions, setSubmissions] = useState(initialSubmissions);
|
const [submissions, setSubmissions] = useState(initialSubmissions);
|
||||||
const [submissionHistory, setSubmissionHistory] = useState(initialSubmissionHistory);
|
const [submissionHistory, setSubmissionHistory] = useState(initialSubmissionHistory);
|
||||||
const [adding, setAdding] = useState(false);
|
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 refetch = useCallback(async () => {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
@@ -346,6 +410,37 @@ export function QuestionsView({
|
|||||||
refetch();
|
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) {
|
async function toggleReveal(question: QuestionRow) {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const isRevealed = revealedId === question.id;
|
const isRevealed = revealedId === question.id;
|
||||||
@@ -398,30 +493,89 @@ export function QuestionsView({
|
|||||||
</div>
|
</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">
|
<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 && (
|
{questions.length === 0 && !adding && (
|
||||||
<p className="mb-2 text-sm text-text-mut">Aucune question dans la banque.</p>
|
<p className="mb-2 text-sm text-text-mut">Aucune question dans la banque.</p>
|
||||||
)}
|
)}
|
||||||
<ul className="flex flex-col divide-y divide-gold/10">
|
|
||||||
{questions.map((question, index) => (
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
<QuestionRowItem
|
<SortableContext items={questions.map((q) => q.id)} strategy={verticalListSortingStrategy}>
|
||||||
key={question.id}
|
<ul className="flex flex-col divide-y divide-gold/10">
|
||||||
question={question}
|
{questions.map((question, index) => (
|
||||||
orderNumber={index + 1}
|
<QuestionRowItem
|
||||||
isFirst={index === 0}
|
key={question.id}
|
||||||
isLast={index === questions.length - 1}
|
question={question}
|
||||||
isRevealed={revealedId === question.id}
|
orderNumber={index + 1}
|
||||||
onMove={(direction) => moveQuestion(question, direction)}
|
isFirst={index === 0}
|
||||||
onToggleReveal={() => toggleReveal(question)}
|
isLast={index === questions.length - 1}
|
||||||
onChanged={refetch}
|
isRevealed={revealedId === question.id}
|
||||||
/>
|
pseudos={pseudos}
|
||||||
))}
|
profiles={profiles}
|
||||||
</ul>
|
onMove={(direction) => moveQuestion(question, direction)}
|
||||||
|
onToggleReveal={() => toggleReveal(question)}
|
||||||
|
onChanged={refetch}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
{adding ? (
|
{adding ? (
|
||||||
<QuestionForm
|
<QuestionForm
|
||||||
nextPosition={(questions.at(-1)?.position ?? 0) + 1}
|
nextPosition={(questions.at(-1)?.position ?? 0) + 1}
|
||||||
|
profiles={profiles}
|
||||||
onDone={() => {
|
onDone={() => {
|
||||||
setAdding(false);
|
setAdding(false);
|
||||||
refetch();
|
refetch();
|
||||||
|
|||||||
@@ -122,25 +122,27 @@ export function CorneView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-2xl flex-col items-center gap-4 px-4 py-8">
|
<div className="mx-auto flex w-full max-w-2xl flex-col items-center gap-4 px-4 py-8">
|
||||||
<div className="text-center">
|
{/* Même en-tête compact que /icare (titre + Classement sur une seule
|
||||||
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">
|
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
|
La Corne d'Abondance
|
||||||
</h1>
|
</h1>
|
||||||
<p className="font-serif text-sm text-marble/60 italic">
|
<button
|
||||||
Fais fusionner les Citoyens jusqu'au débordement.
|
type="button"
|
||||||
</p>
|
onClick={() => setShowRanking(true)}
|
||||||
|
className="flex items-center gap-1.5 rounded-md border border-gold/30 px-3 py-1.5 text-xs font-medium text-marble/80 uppercase tracking-wide transition hover:bg-gold/10"
|
||||||
|
>
|
||||||
|
<IconHorn className="h-4 w-4" />
|
||||||
|
Classement
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="mt-10">
|
||||||
type="button"
|
<MelonGame citizens={citizens} onStart={handleStart} onFinish={handleFinish} />
|
||||||
onClick={() => setShowRanking(true)}
|
</div>
|
||||||
className="flex items-center gap-1.5 rounded-md border border-gold/30 px-3 py-1.5 text-xs font-medium text-marble/80 uppercase tracking-wide transition hover:bg-gold/10"
|
|
||||||
>
|
|
||||||
<IconHorn className="h-4 w-4" />
|
|
||||||
Classement
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<MelonGame citizens={citizens} onStart={handleStart} onFinish={handleFinish} />
|
|
||||||
|
|
||||||
{ownBestScore !== null && (
|
{ownBestScore !== null && (
|
||||||
<p className="text-center font-heading text-sm tracking-wide text-marble">
|
<p className="text-center font-heading text-sm tracking-wide text-marble">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
WALL_THICKNESS,
|
WALL_THICKNESS,
|
||||||
} from "@/lib/melon/constants";
|
} from "@/lib/melon/constants";
|
||||||
import { playMergeSound } from "@/lib/melon/sound";
|
import { playMergeSound } from "@/lib/melon/sound";
|
||||||
|
import { Confetti } from "@/components/confetti";
|
||||||
import type { MemberRow } from "./page";
|
import type { MemberRow } from "./page";
|
||||||
|
|
||||||
type Status = "playing" | "dead";
|
type Status = "playing" | "dead";
|
||||||
@@ -31,6 +32,54 @@ function randomTier(min: number, max: number): number {
|
|||||||
return min + Math.floor(Math.random() * (max - min + 1));
|
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 = {
|
export type MelonRunTelemetry = {
|
||||||
v: 1;
|
v: 1;
|
||||||
duration_ms: number;
|
duration_ms: number;
|
||||||
@@ -51,6 +100,13 @@ export function MelonGame({
|
|||||||
const previewRef = useRef<HTMLDivElement | null>(null);
|
const previewRef = useRef<HTMLDivElement | null>(null);
|
||||||
const guideLineRef = useRef<HTMLDivElement | null>(null);
|
const guideLineRef = useRef<HTMLDivElement | null>(null);
|
||||||
const nodeRefs = useRef(new Map<number, HTMLDivElement>());
|
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
|
// Un palier de taille par Citoyen, pas un nombre arbitraire — voir
|
||||||
// buildTierRadii. citizens[tier] (indexation directe, sans tirage) donne
|
// buildTierRadii. citizens[tier] (indexation directe, sans tirage) donne
|
||||||
@@ -324,6 +380,15 @@ export function MelonGame({
|
|||||||
bodiesRef.current.delete(merge.pieceIdB);
|
bodiesRef.current.delete(merge.pieceIdB);
|
||||||
nodeRefs.current.delete(merge.pieceIdA);
|
nodeRefs.current.delete(merge.pieceIdA);
|
||||||
nodeRefs.current.delete(merge.pieceIdB);
|
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);
|
removedIds.push(merge.pieceIdA, merge.pieceIdB);
|
||||||
|
|
||||||
if (merge.tier < maxTier) {
|
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
|
// 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 (
|
return (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<div className="flex items-center justify-between" style={{ width: CONTAINER_WIDTH }}>
|
<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"
|
className="pointer-events-none absolute top-0 bottom-0 w-px border-l border-dashed border-gold/40"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{pieces.map((piece) => {
|
{pieces.map((piece) => (
|
||||||
const citizen = citizens[piece.tier];
|
<PieceView
|
||||||
const diameter = tierRadii[piece.tier] * 2;
|
key={piece.id}
|
||||||
return (
|
piece={piece}
|
||||||
<div
|
citizen={citizens[piece.tier]}
|
||||||
key={piece.id}
|
diameter={tierRadii[piece.tier] * 2}
|
||||||
ref={(node) => {
|
registerNode={registerPieceNode}
|
||||||
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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={(node) => {
|
ref={(node) => {
|
||||||
@@ -525,6 +572,8 @@ export function MelonGame({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{status === "dead" && !prefersReducedMotion && <Confetti />}
|
||||||
|
|
||||||
{status === "dead" && (
|
{status === "dead" && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center rounded-b-2xl bg-ink/70">
|
<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">
|
<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);
|
--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 {
|
body {
|
||||||
|
--sea-glow: 30%;
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse 80% 50% at 20% -10%, color-mix(in srgb, var(--ink-2) 75%, transparent) 0%, transparent 60%),
|
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);
|
var(--ink);
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
@@ -112,6 +124,42 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
@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 {
|
@keyframes podium-rise {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -43,11 +43,11 @@ import {
|
|||||||
stepIcarus,
|
stepIcarus,
|
||||||
} from "@/lib/icarus/physics";
|
} from "@/lib/icarus/physics";
|
||||||
import type { Boost, Column, IcarusState, ShatterEffect } from "@/lib/icarus/types";
|
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";
|
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
|
// 0 avant DIFFICULTY_START_SCORE, monte jusqu'à 1 sur DIFFICULTY_RAMP_SPAN
|
||||||
// points, puis reste à 1 (palier, pour ne pas devenir injouable).
|
// points, puis reste à 1 (palier, pour ne pas devenir injouable).
|
||||||
function difficultyProgress(score: number): number {
|
function difficultyProgress(score: number): number {
|
||||||
@@ -499,6 +499,7 @@ export function IcarusGame({
|
|||||||
const collectedIndex = boostsRef.current.findIndex((b) => collidesWithBoost(icarusRef.current.y, b));
|
const collectedIndex = boostsRef.current.findIndex((b) => collidesWithBoost(icarusRef.current.y, b));
|
||||||
if (collectedIndex !== -1) {
|
if (collectedIndex !== -1) {
|
||||||
boostsRef.current.splice(collectedIndex, 1);
|
boostsRef.current.splice(collectedIndex, 1);
|
||||||
|
playBoostSound();
|
||||||
// Active le bouclier pour les N prochaines colonnes (distance
|
// Active le bouclier pour les N prochaines colonnes (distance
|
||||||
// aléatoire, voir plus bas) et déclenche l'animation de bond en
|
// aléatoire, voir plus bas) et déclenche l'animation de bond en
|
||||||
// avant. Icare se fige (vitesse verticale annulée, plus de
|
// 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(
|
function render(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
|
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
|
||||||
|
import { EmptyState } from "@/components/empty-state";
|
||||||
|
|
||||||
type Entry = {
|
type Entry = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -56,7 +57,7 @@ export function JournalView({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (entries.length === 0) {
|
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 (
|
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"
|
className="marble-surface flex gap-3 rounded-lg border border-gold/25 px-4 py-3 text-sm shadow-sm"
|
||||||
>
|
>
|
||||||
<span
|
<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
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<div>
|
<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 { Avatar } from "@/components/avatar";
|
||||||
import { Podium } from "@/components/podium";
|
import { Podium } from "@/components/podium";
|
||||||
import { JudgePointControls } from "@/components/judge-point-controls";
|
import { JudgePointControls } from "@/components/judge-point-controls";
|
||||||
|
import { EmptyState } from "@/components/empty-state";
|
||||||
import { computeRanks, computeProgress } from "@/lib/ranking";
|
import { computeRanks, computeProgress } from "@/lib/ranking";
|
||||||
|
|
||||||
type Profile = {
|
type Profile = {
|
||||||
@@ -94,7 +95,7 @@ export function LeaderboardView({
|
|||||||
const rest = hasScores ? ranked.filter((p) => p.rank > 3) : ranked;
|
const rest = hasScores ? ranked.filter((p) => p.rank > 3) : ranked;
|
||||||
|
|
||||||
if (profiles.length === 0) {
|
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 (
|
return (
|
||||||
@@ -107,6 +108,10 @@ export function LeaderboardView({
|
|||||||
onApplyDelta={applyOptimisticDelta}
|
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">
|
<ol className="flex flex-col gap-2">
|
||||||
{rest.map((profile) => {
|
{rest.map((profile) => {
|
||||||
const progress = computeProgress(profile.rank, profile.previous_rank);
|
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 { createClient } from "@/lib/supabase/server";
|
||||||
import { LeaderboardView } from "./leaderboard-view";
|
import { LeaderboardView } from "./leaderboard-view";
|
||||||
import { QuickLinks } from "./quick-links";
|
import { QuickLinks } from "./quick-links";
|
||||||
|
import { GameHighlights, type GameLeader } from "./game-highlights";
|
||||||
import { AgoraLogo } from "@/components/agora-logo";
|
import { AgoraLogo } from "@/components/agora-logo";
|
||||||
import { formatCountdown, formatTribunalDateTime } from "@/lib/tribunal-date";
|
import { formatCountdown, formatTribunalDateTime } from "@/lib/tribunal-date";
|
||||||
|
|
||||||
@@ -15,17 +16,37 @@ export default async function LeaderboardPage() {
|
|||||||
redirect("/login");
|
redirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
const [{ data: profiles, error }, { data: settings }] = await Promise.all([
|
const [{ data: profiles, error }, { data: settings }, { data: wallNotesToday }, { data: icarusTop }, { data: melonTop }] =
|
||||||
supabase
|
await Promise.all([
|
||||||
.from("profiles")
|
supabase
|
||||||
.select("id, pseudo, avatar_url, points, previous_rank, role")
|
.from("profiles")
|
||||||
.order("points", { ascending: false }),
|
.select("id, pseudo, avatar_url, points, previous_rank, role")
|
||||||
supabase.from("settings").select("tribunal_date").eq("id", true).single(),
|
.order("points", { ascending: false }),
|
||||||
]);
|
supabase.from("settings").select("tribunal_date").eq("id", true).single(),
|
||||||
|
// wall_notes_for_date() (jamais une lecture directe de wall_notes, dont
|
||||||
|
// la RLS ne laisserait voir que ses propres notes) : seul moyen de
|
||||||
|
// 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 isJudge = profiles?.find((p) => p.id === user.id)?.role === "judge";
|
||||||
const tribunalDate = settings?.tribunal_date ?? null;
|
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 (
|
return (
|
||||||
<div className="mx-auto w-full max-w-2xl px-4 py-8">
|
<div className="mx-auto w-full max-w-2xl px-4 py-8">
|
||||||
<div className="mb-6 flex flex-col items-center text-center">
|
<div className="mb-6 flex flex-col items-center text-center">
|
||||||
@@ -37,7 +58,9 @@ export default async function LeaderboardPage() {
|
|||||||
{tribunalDate && <p className="text-xs text-marble/50">{formatTribunalDateTime(tribunalDate)}</p>}
|
{tribunalDate && <p className="text-xs text-marble/50">{formatTribunalDateTime(tribunalDate)}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<QuickLinks />
|
<QuickLinks wallNoteCount={wallNotesToday?.length ?? 0} />
|
||||||
|
|
||||||
|
<GameHighlights icarusLeader={icarusLeader} melonLeader={melonLeader} />
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="text-sm text-oxblood">
|
<p role="alert" className="text-sm text-oxblood">
|
||||||
|
|||||||
@@ -15,19 +15,33 @@ const LINKS = [
|
|||||||
{ href: "/journal", label: "Le Crieur", icon: IconScroll },
|
{ href: "/journal", label: "Le Crieur", icon: IconScroll },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function QuickLinks() {
|
export function QuickLinks({ wallNoteCount }: { wallNoteCount: number }) {
|
||||||
return (
|
return (
|
||||||
<nav aria-label="Raccourcis" className="mb-6 grid grid-cols-3 gap-2 sm:grid-cols-6 sm:gap-3">
|
<nav aria-label="Raccourcis" className="mb-6 grid grid-cols-3 gap-2 sm:grid-cols-6 sm:gap-3">
|
||||||
{LINKS.map(({ href, label, icon: Icon }) => (
|
{LINKS.map(({ href, label, icon: Icon }) => {
|
||||||
<Link
|
// Pastille "façon iPhone" (nombre de publications du jour) : pour
|
||||||
key={href}
|
// l'instant seul le Mur en a une, les autres raccourcis n'ont pas
|
||||||
href={href}
|
// d'équivalent "à consulter aujourd'hui" aussi direct.
|
||||||
className="marble-surface flex flex-col items-center gap-1.5 rounded-xl border border-gold/25 px-2 py-3 text-center shadow-sm transition hover:border-gold/50 hover:shadow-md"
|
const badge = href === "/mur" ? wallNoteCount : 0;
|
||||||
>
|
return (
|
||||||
<Icon className="h-6 w-6 text-gold" />
|
<Link
|
||||||
<span className="text-[0.65rem] leading-tight font-medium text-text-marble sm:text-xs">{label}</span>
|
key={href}
|
||||||
</Link>
|
href={href}
|
||||||
))}
|
className="marble-surface relative flex flex-col items-center gap-1.5 rounded-xl border border-gold/25 px-2 py-3 text-center shadow-sm transition hover:border-gold/50 hover:shadow-md"
|
||||||
|
>
|
||||||
|
{badge > 0 && (
|
||||||
|
<span
|
||||||
|
className="absolute -top-1.5 -right-1.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-oxblood px-1 text-[0.65rem] font-bold text-marble shadow"
|
||||||
|
aria-label={`${badge} publication${badge > 1 ? "s" : ""} aujourd'hui sur le Mur de la Honte`}
|
||||||
|
>
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Icon className="h-6 w-6 text-gold" />
|
||||||
|
<span className="text-[0.65rem] leading-tight font-medium text-text-marble sm:text-xs">{label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageLoading } from "@/components/page-loading";
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <PageLoading />;
|
||||||
|
}
|
||||||
+25
-12
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState, type ChangeEvent, type FormEvent } fr
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Avatar } from "@/components/avatar";
|
import { Avatar } from "@/components/avatar";
|
||||||
import { IconCamera, IconClose } from "@/components/icons";
|
import { IconCamera, IconClose } from "@/components/icons";
|
||||||
|
import { EmptyState } from "@/components/empty-state";
|
||||||
import { resizeImageToBlob } from "@/lib/image";
|
import { resizeImageToBlob } from "@/lib/image";
|
||||||
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
|
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
|
||||||
import type { MyReactionRow, ReactionCountRow, WallHistoryRow, WallNoteRow } from "./page";
|
import type { MyReactionRow, ReactionCountRow, WallHistoryRow, WallNoteRow } from "./page";
|
||||||
@@ -308,20 +309,30 @@ export function MurView({
|
|||||||
if (imageFile) {
|
if (imageFile) {
|
||||||
try {
|
try {
|
||||||
const blob = await resizeImageToBlob(imageFile);
|
const blob = await resizeImageToBlob(imageFile);
|
||||||
const path = `${crypto.randomUUID()}.webp`;
|
// L'extension/le Content-Type suivent le type RÉEL du blob rendu par
|
||||||
|
// canvas.toBlob (pas "image/webp" en dur) : certains navigateurs
|
||||||
|
// (Safari notamment, webp encodé via canvas longtemps mal supporté)
|
||||||
|
// retombent silencieusement sur du PNG malgré le type demandé —
|
||||||
|
// uploader ce PNG sous une étiquette "webp" trompeuse pouvait faire
|
||||||
|
// échouer des vérifications en aval qui, elles, regardent le vrai
|
||||||
|
// contenu (l'optimiseur d'image de next/image par exemple).
|
||||||
|
const extension = blob.type === "image/png" ? "png" : blob.type === "image/jpeg" ? "jpg" : "webp";
|
||||||
|
const path = `${crypto.randomUUID()}.${extension}`;
|
||||||
const { error: uploadError } = await supabase.storage
|
const { error: uploadError } = await supabase.storage
|
||||||
.from("wall-images")
|
.from("wall-images")
|
||||||
.upload(path, blob, { contentType: "image/webp" });
|
.upload(path, blob, { contentType: blob.type || "image/webp" });
|
||||||
if (uploadError) {
|
if (uploadError) {
|
||||||
setPosting(false);
|
setPosting(false);
|
||||||
setError("Impossible d'envoyer l'image, réessaie.");
|
console.error("Échec de l'envoi de l'image sur le Mur :", uploadError);
|
||||||
|
setError(`Envoi de l'image impossible (${uploadError.message}). Réessaie.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { data: publicUrlData } = supabase.storage.from("wall-images").getPublicUrl(path);
|
const { data: publicUrlData } = supabase.storage.from("wall-images").getPublicUrl(path);
|
||||||
imageUrl = publicUrlData.publicUrl;
|
imageUrl = publicUrlData.publicUrl;
|
||||||
} catch {
|
} catch (err) {
|
||||||
setPosting(false);
|
setPosting(false);
|
||||||
setError("Impossible de traiter l'image, réessaie.");
|
console.error("Échec du traitement de l'image sur le Mur :", err);
|
||||||
|
setError("Traitement de l'image impossible (format non supporté ?) — réessaie sans image ou avec une autre photo.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -333,12 +344,15 @@ export function MurView({
|
|||||||
setPosting(false);
|
setPosting(false);
|
||||||
|
|
||||||
if (rpcError) {
|
if (rpcError) {
|
||||||
|
console.error("Échec de post_wall_note :", rpcError);
|
||||||
setError(
|
setError(
|
||||||
rpcError.message.includes("already posted")
|
rpcError.message.includes("already posted")
|
||||||
? "Tu as déjà publié une note aujourd'hui."
|
? "Tu as déjà publié une note aujourd'hui."
|
||||||
: rpcError.message.includes("wall full")
|
: rpcError.message.includes("wall full")
|
||||||
? "Le mur est déjà complet pour aujourd'hui."
|
? "Le mur est déjà complet pour aujourd'hui."
|
||||||
: "Une erreur est survenue, réessaie.",
|
: rpcError.message.includes("image invalide")
|
||||||
|
? "L'image n'a pas pu être validée côté serveur — réessaie sans image ou avec une autre photo."
|
||||||
|
: `Publication impossible (${rpcError.message}). Réessaie.`,
|
||||||
);
|
);
|
||||||
refetch();
|
refetch();
|
||||||
return;
|
return;
|
||||||
@@ -421,9 +435,7 @@ export function MurView({
|
|||||||
{pastLoading ? (
|
{pastLoading ? (
|
||||||
<p className="text-center text-sm text-marble/60">Chargement…</p>
|
<p className="text-center text-sm text-marble/60">Chargement…</p>
|
||||||
) : displayedNotes.length === 0 ? (
|
) : displayedNotes.length === 0 ? (
|
||||||
<p className="text-center text-sm text-marble/60">
|
<EmptyState message={viewedDate === null ? "Le mur est encore vide aujourd'hui." : "Le mur était vide ce jour-là."} />
|
||||||
{viewedDate === null ? "Le mur est encore vide aujourd'hui." : "Le mur était vide ce jour-là."}
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
||||||
{displayedNotes.map((note) => (
|
{displayedNotes.map((note) => (
|
||||||
@@ -545,9 +557,10 @@ export function MurView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{filteredHistory.length === 0 ? (
|
{filteredHistory.length === 0 ? (
|
||||||
<p className="text-sm text-text-mut">
|
<EmptyState
|
||||||
{history.length === 0 ? "Aucune note publiée pour l'instant." : "Aucune note ce jour-là."}
|
message={history.length === 0 ? "Aucune note publiée pour l'instant." : "Aucune note ce jour-là."}
|
||||||
</p>
|
className="text-text-mut"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ul className="flex flex-col divide-y divide-gold/10">
|
<ul className="flex flex-col divide-y divide-gold/10">
|
||||||
{filteredHistory.map((note) => (
|
{filteredHistory.map((note) => (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
import { DiamondDivider } from "@/components/diamond-divider";
|
||||||
import { ProfileForm } from "./profile-form";
|
import { ProfileForm } from "./profile-form";
|
||||||
import { ChariotSubmissionForm } from "./chariot-submission-form";
|
import { ChariotSubmissionForm } from "./chariot-submission-form";
|
||||||
|
|
||||||
@@ -58,12 +59,19 @@ export default async function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isJudge && (
|
{!isJudge && (
|
||||||
<div className="marble-surface mt-4 rounded-2xl border border-gold/40 p-6 shadow-xl sm:p-8">
|
<>
|
||||||
<h2 className="mb-3 font-heading text-sm tracking-wide text-text-mut uppercase">
|
{/* Carte secondaire (bordure plus fine, pas de grande ombre) :
|
||||||
Ma question pour Le Char
|
une action ponctuelle annexe, pas le cœur de la page comme la
|
||||||
</h2>
|
carte profil au-dessus — distincte sans casser la cohérence
|
||||||
<ChariotSubmissionForm initialText={submissionText} frozen={frozen} />
|
marble-surface. */}
|
||||||
</div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -64,14 +64,18 @@ export function ProfileForm({
|
|||||||
let nextAvatarUrl = avatarUrl;
|
let nextAvatarUrl = avatarUrl;
|
||||||
|
|
||||||
if (avatarBlob) {
|
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
|
const { error: uploadError } = await supabase.storage
|
||||||
.from("avatars")
|
.from("avatars")
|
||||||
.upload(path, avatarBlob, { upsert: true, contentType: "image/webp" });
|
.upload(path, avatarBlob, { upsert: true, contentType: avatarBlob.type || "image/webp" });
|
||||||
|
|
||||||
if (uploadError) {
|
if (uploadError) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setError("Impossible d'envoyer la photo, réessaie.");
|
setError(`Impossible d'envoyer la photo (${uploadError.message}). Réessaie.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-2
@@ -120,14 +120,23 @@ export default function SignupPage() {
|
|||||||
|
|
||||||
let avatarUrl: string | null = null;
|
let avatarUrl: string | null = null;
|
||||||
if (avatarBlob) {
|
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
|
const { error: uploadError } = await supabase.storage
|
||||||
.from("avatars")
|
.from("avatars")
|
||||||
.upload(path, avatarBlob, { upsert: true, contentType: "image/webp" });
|
.upload(path, avatarBlob, { upsert: true, contentType: avatarBlob.type || "image/webp" });
|
||||||
|
|
||||||
if (!uploadError) {
|
if (!uploadError) {
|
||||||
const { data: publicUrlData } = supabase.storage.from("avatars").getPublicUrl(path);
|
const { data: publicUrlData } = supabase.storage.from("avatars").getPublicUrl(path);
|
||||||
avatarUrl = publicUrlData.publicUrl;
|
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>
|
</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) à
|
// 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(
|
export async function cropImageToBlob(
|
||||||
imageSrc: string,
|
imageSrc: string,
|
||||||
crop: PixelCrop,
|
crop: PixelCrop,
|
||||||
@@ -33,7 +53,7 @@ export async function cropImageToBlob(
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
canvas.toBlob(
|
canvas.toBlob(
|
||||||
(blob) => (blob ? resolve(blob) : reject(new Error("Échec de la compression de l'image"))),
|
(blob) => (blob ? resolve(blob) : reject(new Error("Échec de la compression de l'image"))),
|
||||||
"image/webp",
|
preferredMimeType(),
|
||||||
quality,
|
quality,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -41,10 +61,10 @@ export async function cropImageToBlob(
|
|||||||
|
|
||||||
// Redimensionne (sans jamais agrandir) au format d'origine — contrairement
|
// Redimensionne (sans jamais agrandir) au format d'origine — contrairement
|
||||||
// à cropImageToBlob qui force un carré pour les avatars — à maxDimension
|
// à cropImageToBlob qui force un carré pour les avatars — à maxDimension
|
||||||
// sur le plus grand côté, et compresse en WebP avant l'envoi vers le
|
// sur le plus grand côté, et compresse (WebP si supporté, sinon JPEG —
|
||||||
// bucket. Le bucket a aussi sa propre limite de taille côté serveur
|
// voir preferredMimeType) avant l'envoi vers le bucket. Le bucket a aussi
|
||||||
// (défense en profondeur) : cette compression n'est qu'un premier filtre,
|
// sa propre limite de taille côté serveur (défense en profondeur) : cette
|
||||||
// pas la seule garantie.
|
// compression n'est qu'un premier filtre, pas la seule garantie.
|
||||||
export async function resizeImageToBlob(file: File, maxDimension = 1600, quality = 0.82): Promise<Blob> {
|
export async function resizeImageToBlob(file: File, maxDimension = 1600, quality = 0.82): Promise<Blob> {
|
||||||
const src = URL.createObjectURL(file);
|
const src = URL.createObjectURL(file);
|
||||||
try {
|
try {
|
||||||
@@ -65,7 +85,7 @@ export async function resizeImageToBlob(file: File, maxDimension = 1600, quality
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
canvas.toBlob(
|
canvas.toBlob(
|
||||||
(blob) => (blob ? resolve(blob) : reject(new Error("Échec de la compression de l'image"))),
|
(blob) => (blob ? resolve(blob) : reject(new Error("Échec de la compression de l'image"))),
|
||||||
"image/webp",
|
preferredMimeType(),
|
||||||
quality,
|
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