Permet de consulter les jours précédents du Mur de la Honte, pour tout le monde
Build and deploy / deploy (push) Successful in 36s
Build and deploy / deploy (push) Successful in 36s
wall_notes_today() devient wall_notes_for_date(p_date default null) — même garantie d'anonymat qu'avant, juste paramétrée par jour plutôt que figé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 — pas seulement les Archontes via l'historique complet, qui lui révèle l'auteur. wall_note_reaction_counts() gagne le même paramètre p_date. Le compteur "X/12 aujourd'hui" et l'éligibilité à publier restent toujours basés sur aujourd'hui, peu importe le jour consulté. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@ Application web pour un groupe d'amis (~12 personnes) qui anime un jeu et des ol
|
|||||||
|
|
||||||
## 1. État actuel
|
## 1. État actuel
|
||||||
|
|
||||||
V1 à V14 sont livrées :
|
V1 à V15 sont livrées :
|
||||||
- **V1** : comptes, profils, leaderboard statique.
|
- **V1** : comptes, profils, leaderboard statique.
|
||||||
- **V2** : authentification par email réel (plus de pseudo+email interne), rôles `public`/`judge`, verrou de pseudo (modifiable une fois), section Admin.
|
- **V2** : authentification par email réel (plus de pseudo+email interne), rôles `public`/`judge`, verrou de pseudo (modifiable une fois), section Admin.
|
||||||
- **V3** : attribution de points par les juges (RPC sécurisée, +/- avec confirmation explicite), journal public « le crieur », podium top 3, flèches de progression, recadrage/compression photo côté client.
|
- **V3** : attribution de points par les juges (RPC sécurisée, +/- avec confirmation explicite), journal public « le crieur », podium top 3, flèches de progression, recadrage/compression photo côté client.
|
||||||
@@ -25,6 +25,8 @@ V1 à V14 sont livrées :
|
|||||||
|
|
||||||
- **V14** : Le Mur de la Honte (V9) s'ouvre aux Archontes — ils peuvent désormais publier une note anonyme comme n'importe quel Citoyen (`post_wall_note` n'a plus de vérification de rôle), et la limite quotidienne passe de 10 à 12 notes pour couvrir tout le groupe plutôt que les seuls Citoyens. La policy Storage d'upload sur `wall-images` perd elle aussi son filtre par rôle (sinon un Archonte aurait pu poster une note mais pas y joindre d'image). Contraste assumé avec V10 (l'Urne, où les Archontes ne votent ni ne reçoivent de jetons) : ici rien ne les distingue plus des Citoyens côté publication — ils gardent seulement, comme avant, leur visibilité privilégiée sur l'auteur de chaque note via « Historique complet ».
|
- **V14** : Le Mur de la Honte (V9) s'ouvre aux Archontes — ils peuvent désormais publier une note anonyme comme n'importe quel Citoyen (`post_wall_note` n'a plus de vérification de rôle), et la limite quotidienne passe de 10 à 12 notes pour couvrir tout le groupe plutôt que les seuls Citoyens. La policy Storage d'upload sur `wall-images` perd elle aussi son filtre par rôle (sinon un Archonte aurait pu poster une note mais pas y joindre d'image). Contraste assumé avec V10 (l'Urne, où les Archontes ne votent ni ne reçoivent de jetons) : ici rien ne les distingue plus des Citoyens côté publication — ils gardent seulement, comme avant, leur visibilité privilégiée sur l'auteur de chaque note via « Historique complet ».
|
||||||
|
|
||||||
|
- **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`).
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -140,20 +142,21 @@ Deuxième volet de l'audit (revue complète de tout le code applicatif, pas seul
|
|||||||
- `submit_chariot_question(p_text)` — authenticated, rejette les juges (seuls les Citoyens proposent), valide un texte non vide (≤ 300 caractères), et applique le même gel que `submit_icarus_score` via `settings.tribunal_date` (no-op silencieux une fois l'Agora commencée). `insert ... on conflict (user_id) do update` : une ligne par personne dans `chariot_submissions`, plus une ligne append-only dans `chariot_submission_history` à chaque écriture réelle (pas lors du no-op figé).
|
- `submit_chariot_question(p_text)` — authenticated, rejette les juges (seuls les Citoyens proposent), valide un texte non vide (≤ 300 caractères), et applique le même gel que `submit_icarus_score` via `settings.tribunal_date` (no-op silencieux une fois l'Agora commencée). `insert ... on conflict (user_id) do update` : une ligne par personne dans `chariot_submissions`, plus une ligne append-only dans `chariot_submission_history` à chaque écriture réelle (pas lors du no-op figé).
|
||||||
- `chariot_revealed_question()` — authenticated, `language sql`, seul moyen pour un non-juge de connaître la question actuellement révélée sans jamais lire `chariot_questions` (RLS juges uniquement, voir §3bis) — renvoie zéro ligne tant qu'aucune question n'est révélée, et calcule `question_number` (position dans l'ordre complet) côté serveur pour afficher « Question N » sur `/char` sans exposer les autres lignes.
|
- `chariot_revealed_question()` — authenticated, `language sql`, seul moyen pour un non-juge de connaître la question actuellement révélée sans jamais lire `chariot_questions` (RLS juges uniquement, voir §3bis) — renvoie zéro ligne tant qu'aucune question n'est révélée, et calcule `question_number` (position dans l'ordre complet) côté serveur pour afficher « Question N » sur `/char` sans exposer les autres lignes.
|
||||||
- `admin_list_members()` — authenticated, vérifie `role = 'judge'`, seule façon d'exposer `auth.users.email` (jamais stocké dans `profiles`) sur `/admin` sans passer par la `service_role key` côté client.
|
- `admin_list_members()` — authenticated, vérifie `role = 'judge'`, seule façon d'exposer `auth.users.email` (jamais stocké dans `profiles`) sur `/admin` sans passer par la `service_role key` côté client.
|
||||||
- `wall_notes_today()` — authenticated, `language sql`, renvoie les notes du jour (heure de Paris), y compris `image_url`, **sans `user_id`** — seul moyen de laisser un Citoyen voir les notes des autres sans jamais exposer l'auteur côté client (la RLS ne peut pas masquer une colonne pour certaines lignes).
|
- `wall_notes_for_date(p_date default null)` — authenticated, `language sql`, renvoie les notes d'un jour donné (heure de Paris, `p_date` à `null` = aujourd'hui), y compris `image_url`, **sans `user_id`** — seul moyen de voir les notes des autres sans jamais exposer l'auteur côté client (la RLS ne peut pas masquer une colonne pour certaines lignes), ouvert à tout le monde y compris pour les jours précédents depuis V15.
|
||||||
|
- `wall_note_days()` — authenticated, `language sql`, renvoie les dates ayant au moins une note (jamais le contenu ni l'auteur) — peuple le sélecteur de jour de `wall_notes_for_date` sur `/mur`.
|
||||||
- `post_wall_note(p_text, p_image_url default null)` — authenticated, ouvert à tout le monde y compris les juges depuis V14 (plus de vérification de rôle), valide un texte non vide (≤ 200 caractères), puis vérifie que l'appelant n'a pas déjà publié aujourd'hui et que le mur du jour a moins de 12 notes (sinon `raise exception` dans les deux cas — pas de no-op silencieux ici, l'UI doit remonter l'erreur). `p_image_url` est facultatif et doit venir du bucket `wall-images` (vérifié par un `like`).
|
- `post_wall_note(p_text, p_image_url default null)` — authenticated, ouvert à tout le monde y compris les juges depuis V14 (plus de vérification de rôle), valide un texte non vide (≤ 200 caractères), puis vérifie que l'appelant n'a pas déjà publié aujourd'hui et que le mur du jour a moins de 12 notes (sinon `raise exception` dans les deux cas — pas de no-op silencieux ici, l'UI doit remonter l'erreur). `p_image_url` est facultatif et doit venir du bucket `wall-images` (vérifié par un `like`).
|
||||||
- `wall_note_reaction_counts()` — authenticated, `language sql`, renvoie `(note_id, emoji, count)` agrégé sur les notes du jour — même principe que `urn_vote_counts` : jamais qui a réagi, seulement le total.
|
- `wall_note_reaction_counts(p_date default null)` — authenticated, `language sql`, renvoie `(note_id, emoji, count)` agrégé sur les notes d'un jour donné (même paramètre `p_date` que `wall_notes_for_date`, depuis V15) — même principe que `urn_vote_counts` : jamais qui a réagi, seulement le total.
|
||||||
- `toggle_wall_note_reaction(p_note_id, p_emoji)` — authenticated, bascule une réaction (ajoute si absente, retire si déjà posée en un `delete` puis, si rien n'a été supprimé, un `insert`). `p_emoji` limité à une liste fermée (contrainte `check` sur la colonne, revérifiée dans la RPC pour un message d'erreur clair).
|
- `toggle_wall_note_reaction(p_note_id, p_emoji)` — authenticated, bascule une réaction (ajoute si absente, retire si déjà posée en un `delete` puis, si rien n'a été supprimé, un `insert`). `p_emoji` limité à une liste fermée (contrainte `check` sur la colonne, revérifiée dans la RPC pour un message d'erreur clair).
|
||||||
- `urn_vote_counts()` — authenticated, `language sql`, renvoie `(target_id, votes)` agrégé sur tout l'historique (pas de filtre de date, contrairement à `wall_notes_today`) — seul moyen de calculer un total public sans jamais exposer une ligne individuelle (qui a voté pour qui).
|
- `urn_vote_counts()` — authenticated, `language sql`, renvoie `(target_id, votes)` agrégé sur tout l'historique (pas de filtre de date, contrairement à `wall_notes_for_date`) — seul moyen de calculer un total public sans jamais exposer une ligne individuelle (qui a voté pour qui).
|
||||||
- `urn_vote_reasons(p_target_id)` — authenticated, `language sql`, renvoie les justifications (`reason`, `created_at`) laissées pour une personne, **sans `voter_id`** — même principe que `wall_notes_today` : le contenu est public depuis le classement, l'auteur ne l'est jamais.
|
- `urn_vote_reasons(p_target_id)` — authenticated, `language sql`, renvoie les justifications (`reason`, `created_at`) laissées pour une personne, **sans `voter_id`** — même principe que `wall_notes_for_date` : le contenu est public depuis le classement, l'auteur ne l'est jamais.
|
||||||
- `cast_urn_vote(p_target_id, p_reason default null)` — authenticated, rejette les juges (ni votants ni cibles), rejette le vote pour soi-même, vérifie que l'appelant n'a pas déjà voté aujourd'hui (heure de Paris) — vote définitif, aucune RPC de modification/suppression. `p_reason` est une justification facultative (≤ 200 caractères, `nullif(trim(...), '')` pour normaliser une chaîne vide en `null`).
|
- `cast_urn_vote(p_target_id, p_reason default null)` — authenticated, rejette les juges (ni votants ni cibles), rejette le vote pour soi-même, vérifie que l'appelant n'a pas déjà voté aujourd'hui (heure de Paris) — vote définitif, aucune RPC de modification/suppression. `p_reason` est une justification facultative (≤ 200 caractères, `nullif(trim(...), '')` pour normaliser une chaîne vide en `null`).
|
||||||
|
|
||||||
### `public.wall_notes` (Le Mur de la Honte, V9)
|
### `public.wall_notes` (Le Mur de la Honte, V9)
|
||||||
|
|
||||||
- `id, user_id, text, image_url, created_at` — une ligne par note, jamais éditée/supprimée (comme `points_log`) ; le « reset quotidien » n'est qu'un filtrage par date dans `wall_notes_today()`, pas une suppression. `image_url` est facultative, pointe vers le bucket `wall-images`.
|
- `id, user_id, text, image_url, created_at` — une ligne par note, jamais éditée/supprimée (comme `points_log`) ; le « reset quotidien » du compteur de publication n'est qu'un filtrage par date dans `wall_notes_for_date()`, pas une suppression — les notes restent consultables (anonymement) les jours suivants depuis V15. `image_url` est facultative, pointe vers le bucket `wall-images`.
|
||||||
- RLS `select` : le propriétaire voit sa propre note (sert à afficher « tu as déjà publié aujourd'hui » et à réafficher son propre texte), les juges voient tout avec l'auteur (embed `profiles(pseudo, avatar_url)`, section « Historique complet » sur `/mur`) — pas de visibilité entre Citoyens, c'est tout l'intérêt de `wall_notes_today()`.
|
- RLS `select` : le propriétaire voit sa propre note (sert à afficher « tu as déjà publié aujourd'hui » et à réafficher son propre texte), les juges voient tout avec l'auteur (embed `profiles(pseudo, avatar_url)`, section « Historique complet » sur `/mur`) — pas de visibilité entre Citoyens, c'est tout l'intérêt de `wall_notes_for_date()`.
|
||||||
- Pas de gel via `settings.tribunal_date` : contrairement au Char, c'est une mécanique de toute la semaine.
|
- Pas de gel via `settings.tribunal_date` : contrairement au Char, c'est une mécanique de toute la semaine.
|
||||||
- Bucket Storage `wall-images` (public, `file_size_limit` 3 Mo, `allowed_mime_types` image/webp+jpeg+png — même limite que le bucket `avatars`, voir §3bis) : contrairement à `avatars`, le chemin d'objet est un UUID **sans le `user_id`** — sinon l'auteur fuiterait via l'URL publique de l'image, ce que `wall_notes_today()` prend justement soin de ne jamais exposer. Compression côté client avant upload (`resizeImageToBlob`, `src/lib/image.ts`, contrairement à `cropImageToBlob` qui force un carré pour les avatars) ; la limite du bucket n'est qu'un filet de sécurité, pas la seule garantie.
|
- Bucket Storage `wall-images` (public, `file_size_limit` 3 Mo, `allowed_mime_types` image/webp+jpeg+png — même limite que le bucket `avatars`, voir §3bis) : contrairement à `avatars`, le chemin d'objet est un UUID **sans le `user_id`** — sinon l'auteur fuiterait via l'URL publique de l'image, ce que `wall_notes_for_date()` prend justement soin de ne jamais exposer. Compression côté client avant upload (`resizeImageToBlob`, `src/lib/image.ts`, contrairement à `cropImageToBlob` qui force un carré pour les avatars) ; la limite du bucket n'est qu'un filet de sécurité, pas la seule garantie.
|
||||||
- `public.wall_note_reactions` (`id, note_id, user_id, emoji, created_at`, `unique (note_id, user_id, emoji)`) : réactions emoji sur une note, même principe d'anonymat que les notes elles-mêmes — RLS `select` limitée à `user_id = auth.uid()` (pour savoir lesquelles sont déjà activées), le total public passe uniquement par `wall_note_reaction_counts()`.
|
- `public.wall_note_reactions` (`id, note_id, user_id, emoji, created_at`, `unique (note_id, user_id, emoji)`) : réactions emoji sur une note, même principe d'anonymat que les notes elles-mêmes — RLS `select` limitée à `user_id = auth.uid()` (pour savoir lesquelles sont déjà activées), le total public passe uniquement par `wall_note_reaction_counts()`.
|
||||||
|
|
||||||
### `public.urn_votes` (L'Urne de l'Agora, V10)
|
### `public.urn_votes` (L'Urne de l'Agora, V10)
|
||||||
@@ -181,7 +184,7 @@ Deuxième volet de l'audit (revue complète de tout le code applicatif, pas seul
|
|||||||
| `/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/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 |
|
||||||
| `/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 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 |
|
||||||
| `/journal` | « Le Crieur » — fil live des décrets (attributions de points), lecture pour tous |
|
| `/journal` | « Le Crieur » — fil live des décrets (attributions de points), lecture pour tous |
|
||||||
|
|||||||
+77
-11
@@ -149,6 +149,7 @@ export function MurView({
|
|||||||
initialHistory,
|
initialHistory,
|
||||||
initialReactionCounts,
|
initialReactionCounts,
|
||||||
initialMyReactions,
|
initialMyReactions,
|
||||||
|
availableDays,
|
||||||
}: {
|
}: {
|
||||||
isJudge: boolean;
|
isJudge: boolean;
|
||||||
currentUserId: string;
|
currentUserId: string;
|
||||||
@@ -157,6 +158,7 @@ export function MurView({
|
|||||||
initialHistory: WallHistoryRow[];
|
initialHistory: WallHistoryRow[];
|
||||||
initialReactionCounts: ReactionCountRow[];
|
initialReactionCounts: ReactionCountRow[];
|
||||||
initialMyReactions: MyReactionRow[];
|
initialMyReactions: MyReactionRow[];
|
||||||
|
availableDays: string[];
|
||||||
}) {
|
}) {
|
||||||
const [notes, setNotes] = useState(initialNotes);
|
const [notes, setNotes] = useState(initialNotes);
|
||||||
const [myNoteToday, setMyNoteToday] = useState(initialMyNoteToday);
|
const [myNoteToday, setMyNoteToday] = useState(initialMyNoteToday);
|
||||||
@@ -171,6 +173,15 @@ export function MurView({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [historyDateFilter, setHistoryDateFilter] = useState("all");
|
const [historyDateFilter, setHistoryDateFilter] = useState("all");
|
||||||
const [zoomedImage, setZoomedImage] = useState<string | null>(null);
|
const [zoomedImage, setZoomedImage] = useState<string | null>(null);
|
||||||
|
// Jour affiché dans la grille du haut (ouverte à tout le monde,
|
||||||
|
// contrairement à l'historique complet réservé aux Archontes plus bas) :
|
||||||
|
// null = aujourd'hui, réactif au Realtime comme avant. Un jour passé se
|
||||||
|
// charge à la demande (pas de Realtime dessus, un jour passé n'évolue
|
||||||
|
// plus) dans un état séparé pour ne jamais mélanger les deux.
|
||||||
|
const [viewedDate, setViewedDate] = useState<string | null>(null);
|
||||||
|
const [pastNotes, setPastNotes] = useState<WallNoteRow[]>([]);
|
||||||
|
const [pastReactionCounts, setPastReactionCounts] = useState<Record<string, Record<string, number>>>({});
|
||||||
|
const [pastLoading, setPastLoading] = useState(false);
|
||||||
|
|
||||||
const refetch = useCallback(async () => {
|
const refetch = useCallback(async () => {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
@@ -228,6 +239,27 @@ export function MurView({
|
|||||||
};
|
};
|
||||||
}, [refetch]);
|
}, [refetch]);
|
||||||
|
|
||||||
|
const fetchDay = useCallback(async (dateKey: string) => {
|
||||||
|
const supabase = createClient();
|
||||||
|
const [{ data: dayNotes }, { data: dayCounts }] = await Promise.all([
|
||||||
|
supabase.rpc("wall_notes_for_date", { p_date: dateKey }),
|
||||||
|
supabase.rpc("wall_note_reaction_counts", { p_date: dateKey }),
|
||||||
|
]);
|
||||||
|
setPastNotes(dayNotes ?? []);
|
||||||
|
setPastReactionCounts(buildReactionCounts(dayCounts ?? []));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleSelectDate(value: string) {
|
||||||
|
if (value === "today") {
|
||||||
|
setViewedDate(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setViewedDate(value);
|
||||||
|
setPastLoading(true);
|
||||||
|
await fetchDay(value);
|
||||||
|
setPastLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
function handleImageChange(event: ChangeEvent<HTMLInputElement>) {
|
function handleImageChange(event: ChangeEvent<HTMLInputElement>) {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
@@ -318,7 +350,11 @@ export function MurView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleToggleReaction(noteId: string, emoji: string) {
|
async function handleToggleReaction(noteId: string, emoji: string) {
|
||||||
// Optimiste : le canal Realtime confirmera (ou corrigera) juste après.
|
// Optimiste : le canal Realtime confirmera (ou corrigera) juste après —
|
||||||
|
// sauf en dehors d'aujourd'hui, où il n'y a pas de Realtime (un jour
|
||||||
|
// passé n'évolue plus) : on corrige alors en rechargeant ce jour-là.
|
||||||
|
const setCounts = viewedDate === null ? setReactionCounts : setPastReactionCounts;
|
||||||
|
|
||||||
setMyReactions((current) => {
|
setMyReactions((current) => {
|
||||||
const next = new Set(current[noteId] ?? []);
|
const next = new Set(current[noteId] ?? []);
|
||||||
const wasActive = next.has(emoji);
|
const wasActive = next.has(emoji);
|
||||||
@@ -326,7 +362,7 @@ export function MurView({
|
|||||||
else next.add(emoji);
|
else next.add(emoji);
|
||||||
return { ...current, [noteId]: next };
|
return { ...current, [noteId]: next };
|
||||||
});
|
});
|
||||||
setReactionCounts((current) => {
|
setCounts((current) => {
|
||||||
const wasActive = myReactions[noteId]?.has(emoji) ?? false;
|
const wasActive = myReactions[noteId]?.has(emoji) ?? false;
|
||||||
const noteCounts = { ...(current[noteId] ?? {}) };
|
const noteCounts = { ...(current[noteId] ?? {}) };
|
||||||
noteCounts[emoji] = Math.max(0, (noteCounts[emoji] ?? 0) + (wasActive ? -1 : 1));
|
noteCounts[emoji] = Math.max(0, (noteCounts[emoji] ?? 0) + (wasActive ? -1 : 1));
|
||||||
@@ -338,11 +374,22 @@ export function MurView({
|
|||||||
p_note_id: noteId,
|
p_note_id: noteId,
|
||||||
p_emoji: emoji,
|
p_emoji: emoji,
|
||||||
});
|
});
|
||||||
if (rpcError) refetch();
|
if (rpcError) {
|
||||||
|
if (viewedDate === null) refetch();
|
||||||
|
else fetchDay(viewedDate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wallFull/le compteur restent toujours basés sur aujourd'hui (notes),
|
||||||
|
// jamais sur le jour affiché (displayedNotes) : consulter un jour passé
|
||||||
|
// ne doit jamais changer l'éligibilité à publier aujourd'hui.
|
||||||
const wallFull = notes.length >= MAX_NOTES_PER_DAY;
|
const wallFull = notes.length >= MAX_NOTES_PER_DAY;
|
||||||
|
|
||||||
|
const todayKey = new Date().toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
|
||||||
|
const pastDays = availableDays.filter((day) => day !== todayKey);
|
||||||
|
const displayedNotes = viewedDate === null ? notes : pastNotes;
|
||||||
|
const displayedReactionCounts = viewedDate === null ? reactionCounts : pastReactionCounts;
|
||||||
|
|
||||||
const historyDates = Array.from(new Set(history.map((n) => localDateKey(n.created_at)))).sort((a, b) =>
|
const historyDates = Array.from(new Set(history.map((n) => localDateKey(n.created_at)))).sort((a, b) =>
|
||||||
b.localeCompare(a),
|
b.localeCompare(a),
|
||||||
);
|
);
|
||||||
@@ -352,19 +399,38 @@ export function MurView({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-3 text-center text-xs font-medium tracking-wide text-marble/60 uppercase">
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
{notes.length} / {MAX_NOTES_PER_DAY}{" "}
|
<p className="text-xs font-medium tracking-wide text-marble/60 uppercase">
|
||||||
aujourd'hui
|
{viewedDate === null ? `${notes.length} / ${MAX_NOTES_PER_DAY} aujourd'hui` : formatDateLabel(viewedDate)}
|
||||||
</p>
|
</p>
|
||||||
{notes.length === 0 ? (
|
{pastDays.length > 0 && (
|
||||||
<p className="text-center text-sm text-marble/60">Le mur est encore vide aujourd'hui.</p>
|
<select
|
||||||
|
value={viewedDate ?? "today"}
|
||||||
|
onChange={(event) => handleSelectDate(event.target.value)}
|
||||||
|
className="rounded-md border border-gold/30 bg-white/50 px-2 py-1 text-xs text-text-marble outline-none focus:border-gold"
|
||||||
|
>
|
||||||
|
<option value="today">Aujourd'hui</option>
|
||||||
|
{pastDays.map((day) => (
|
||||||
|
<option key={day} value={day}>
|
||||||
|
{formatDateLabel(day)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{pastLoading ? (
|
||||||
|
<p className="text-center text-sm text-marble/60">Chargement…</p>
|
||||||
|
) : displayedNotes.length === 0 ? (
|
||||||
|
<p className="text-center text-sm text-marble/60">
|
||||||
|
{viewedDate === null ? "Le mur est encore vide aujourd'hui." : "Le mur était vide ce jour-là."}
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<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">
|
||||||
{notes.map((note) => (
|
{displayedNotes.map((note) => (
|
||||||
<NoteCard
|
<NoteCard
|
||||||
key={note.id}
|
key={note.id}
|
||||||
note={note}
|
note={note}
|
||||||
reactionCounts={reactionCounts[note.id] ?? {}}
|
reactionCounts={displayedReactionCounts[note.id] ?? {}}
|
||||||
myReactions={myReactions[note.id] ?? new Set()}
|
myReactions={myReactions[note.id] ?? new Set()}
|
||||||
onToggleReaction={(emoji) => handleToggleReaction(note.id, emoji)}
|
onToggleReaction={(emoji) => handleToggleReaction(note.id, emoji)}
|
||||||
onImageClick={setZoomedImage}
|
onImageClick={setZoomedImage}
|
||||||
|
|||||||
@@ -44,11 +44,13 @@ export default async function MurPage() {
|
|||||||
.single();
|
.single();
|
||||||
const isJudge = callerProfile?.role === "judge";
|
const isJudge = callerProfile?.role === "judge";
|
||||||
|
|
||||||
const [{ data: todayNotes }, { data: reactionCounts }, { data: myReactions }] = await Promise.all([
|
const [{ data: todayNotes }, { data: reactionCounts }, { data: myReactions }, { data: days }] = await Promise.all([
|
||||||
supabase.rpc("wall_notes_today"),
|
supabase.rpc("wall_notes_for_date"),
|
||||||
supabase.rpc("wall_note_reaction_counts"),
|
supabase.rpc("wall_note_reaction_counts"),
|
||||||
supabase.from("wall_note_reactions").select("note_id, emoji").eq("user_id", user.id),
|
supabase.from("wall_note_reactions").select("note_id, emoji").eq("user_id", user.id),
|
||||||
|
supabase.rpc("wall_note_days"),
|
||||||
]);
|
]);
|
||||||
|
const availableDays = (days ?? []).map((row: { note_date: string }) => row.note_date);
|
||||||
|
|
||||||
// Les Archontes peuvent désormais publier aussi (comme les Citoyens) :
|
// Les Archontes peuvent désormais publier aussi (comme les Citoyens) :
|
||||||
// besoin de savoir s'ils ont déjà posté aujourd'hui, peu importe le rôle.
|
// besoin de savoir s'ils ont déjà posté aujourd'hui, peu importe le rôle.
|
||||||
@@ -74,7 +76,8 @@ export default async function MurPage() {
|
|||||||
<div className="mb-6 text-center">
|
<div className="mb-6 text-center">
|
||||||
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">Le Mur de la Honte</h1>
|
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">Le Mur de la Honte</h1>
|
||||||
<p className="font-serif text-sm text-marble/60 italic">
|
<p className="font-serif text-sm text-marble/60 italic">
|
||||||
Une note anonyme par jour, douze places sur le mur — la journée passée, tout s'efface.
|
Une note anonyme par jour, douze places sur le mur — les jours précédents restent consultables, toujours
|
||||||
|
anonymement.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<MurView
|
<MurView
|
||||||
@@ -85,6 +88,7 @@ export default async function MurPage() {
|
|||||||
initialHistory={history}
|
initialHistory={history}
|
||||||
initialReactionCounts={reactionCounts ?? []}
|
initialReactionCounts={reactionCounts ?? []}
|
||||||
initialMyReactions={myReactions ?? []}
|
initialMyReactions={myReactions ?? []}
|
||||||
|
availableDays={availableDays}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+38
-9
@@ -1369,12 +1369,16 @@ end $$;
|
|||||||
-- Seul moyen pour un Citoyen de voir les notes des autres sans jamais
|
-- Seul moyen pour un Citoyen de voir les notes des autres sans jamais
|
||||||
-- exposer qui les a écrites côté client : la RLS ne peut pas masquer une
|
-- exposer qui les a écrites côté client : la RLS ne peut pas masquer une
|
||||||
-- colonne pour certaines lignes, donc cette RPC (SECURITY DEFINER) fait le
|
-- colonne pour certaines lignes, donc cette RPC (SECURITY DEFINER) fait le
|
||||||
-- filtre elle-même et ne renvoie jamais user_id. Le type de retour change
|
-- filtre elle-même et ne renvoie jamais user_id. Paramétrée par jour
|
||||||
-- (ajout d'image_url) : drop explicite requis, create or replace ne peut
|
-- (p_date, Europe/Paris) plutôt que hardcodée sur aujourd'hui — remplace
|
||||||
-- pas changer le type de retour d'une fonction existante.
|
-- wall_notes_today(), pour que n'importe qui (pas seulement les Archontes
|
||||||
|
-- via « Historique complet » plus bas, qui révèle lui l'auteur) puisse
|
||||||
|
-- consulter les jours précédents, toujours sans jamais exposer qui a
|
||||||
|
-- écrit quoi. p_date à null (par défaut) = aujourd'hui, pour que l'appel
|
||||||
|
-- de chargement initial de la page reste un simple appel sans argument.
|
||||||
drop function if exists public.wall_notes_today();
|
drop function if exists public.wall_notes_today();
|
||||||
|
|
||||||
create or replace function public.wall_notes_today()
|
create or replace function public.wall_notes_for_date(p_date date default null)
|
||||||
returns table (id uuid, text text, image_url text, created_at timestamptz)
|
returns table (id uuid, text text, image_url text, created_at timestamptz)
|
||||||
language sql
|
language sql
|
||||||
security definer
|
security definer
|
||||||
@@ -1382,11 +1386,28 @@ set search_path = public
|
|||||||
as $$
|
as $$
|
||||||
select w.id, w.text, w.image_url, w.created_at
|
select w.id, w.text, w.image_url, w.created_at
|
||||||
from public.wall_notes w
|
from public.wall_notes w
|
||||||
where (w.created_at at time zone 'Europe/Paris')::date = (now() at time zone 'Europe/Paris')::date
|
where (w.created_at at time zone 'Europe/Paris')::date = coalesce(p_date, (now() at time zone 'Europe/Paris')::date)
|
||||||
order by w.created_at asc;
|
order by w.created_at asc;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
grant execute on function public.wall_notes_today() to authenticated;
|
grant execute on function public.wall_notes_for_date(date) to authenticated;
|
||||||
|
|
||||||
|
-- Liste des jours ayant au moins une note (jamais le contenu ni l'auteur,
|
||||||
|
-- juste la date) : peuple le sélecteur de jour de wall_notes_for_date()
|
||||||
|
-- côté client, pour tout le monde — contrairement à l'historique complet
|
||||||
|
-- avec auteur plus bas, réservé aux Archontes.
|
||||||
|
create or replace function public.wall_note_days()
|
||||||
|
returns table (note_date date)
|
||||||
|
language sql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
select distinct (w.created_at at time zone 'Europe/Paris')::date as note_date
|
||||||
|
from public.wall_notes w
|
||||||
|
order by note_date desc;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
grant execute on function public.wall_note_days() to authenticated;
|
||||||
|
|
||||||
-- Seul point d'entrée pour publier une note. Pas de gel via tribunal_date
|
-- Seul point d'entrée pour publier une note. Pas de gel via tribunal_date
|
||||||
-- (contrairement au Char) : contrairement à la soirée de clôture, c'est une
|
-- (contrairement au Char) : contrairement à la soirée de clôture, c'est une
|
||||||
@@ -1523,7 +1544,15 @@ exception
|
|||||||
when duplicate_object then null;
|
when duplicate_object then null;
|
||||||
end $$;
|
end $$;
|
||||||
|
|
||||||
create or replace function public.wall_note_reaction_counts()
|
-- Paramétrée par jour (comme wall_notes_for_date ci-dessus, même p_date à
|
||||||
|
-- null = aujourd'hui, donc les anciens appels sans argument continuent de
|
||||||
|
-- fonctionner à l'identique). L'ancienne surcharge à 0 argument est
|
||||||
|
-- explicitement supprimée : create or replace ne remplace que la même
|
||||||
|
-- signature, sinon les deux versions coexisteraient (même remarque que
|
||||||
|
-- pour post_wall_note plus haut).
|
||||||
|
drop function if exists public.wall_note_reaction_counts();
|
||||||
|
|
||||||
|
create or replace function public.wall_note_reaction_counts(p_date date default null)
|
||||||
returns table (note_id uuid, emoji text, count bigint)
|
returns table (note_id uuid, emoji text, count bigint)
|
||||||
language sql
|
language sql
|
||||||
security definer
|
security definer
|
||||||
@@ -1532,11 +1561,11 @@ as $$
|
|||||||
select r.note_id, r.emoji, count(*) as count
|
select r.note_id, r.emoji, count(*) as count
|
||||||
from public.wall_note_reactions r
|
from public.wall_note_reactions r
|
||||||
join public.wall_notes w on w.id = r.note_id
|
join public.wall_notes w on w.id = r.note_id
|
||||||
where (w.created_at at time zone 'Europe/Paris')::date = (now() at time zone 'Europe/Paris')::date
|
where (w.created_at at time zone 'Europe/Paris')::date = coalesce(p_date, (now() at time zone 'Europe/Paris')::date)
|
||||||
group by r.note_id, r.emoji;
|
group by r.note_id, r.emoji;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
grant execute on function public.wall_note_reaction_counts() to authenticated;
|
grant execute on function public.wall_note_reaction_counts(date) to authenticated;
|
||||||
|
|
||||||
-- Bascule une réaction (ajoute si absente, retire si déjà posée) en un seul
|
-- Bascule une réaction (ajoute si absente, retire si déjà posée) en un seul
|
||||||
-- aller-retour : un delete puis, si rien n'a été supprimé, un insert —
|
-- aller-retour : un delete puis, si rien n'a été supprimé, un insert —
|
||||||
|
|||||||
Reference in New Issue
Block a user