Design responsive, logo, et correctif RLS upload photo

Design & UX :
- Renomme "Administration" en "Admin" (nav, page, docs).
- Logo original (marteau de juge stylisé navy/or) en favicon (src/app/icon.svg,
  remplace le favicon.ico par défaut de create-next-app) et dans la navbar
  (components/logo.tsx).
- Navbar : menu hamburger sur mobile (les liens débordaient à partir de
  ~4 entrées sur petit écran).
- Leaderboard : lignes en deux niveaux sur mobile (infos membre / contrôles
  juges) au lieu d'un seul flex-wrap qui devenait illisible.
- Contrôles de points juges (JudgePointControls) : boutons et champ montant
  réduits en mode compact pour tenir dans les colonnes du podium sur petit
  écran.
- Podium : scroll horizontal de secours (overflow-x-auto) si le contenu
  dépasse malgré tout sur très petits écrans.
- Table admin : pseudo/avatar sur une ligne, rôle/statut/actions sur une
  autre en mobile (le flex-1 sur l'input pseudo n'avait aucun effet en
  layout colonne).

Correctif sécurité/fonctionnel :
- storage.objects n'avait que des policies INSERT et UPDATE pour le bucket
  avatars, pas de policy SELECT. Postgres a besoin de lire la ligne
  existante pour évaluer la clause USING d'un UPDATE : sans SELECT, tout
  remplacement de photo (upsert) échouait avec "new row violates row-level
  security policy", même si les policies INSERT/UPDATE étaient correctes.
  Ajout d'une policy SELECT publique sur le bucket avatars (cohérent avec
  le fait que le bucket est déjà public en lecture).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Valentin ROBIN
2026-07-11 16:47:43 +02:00
parent abbd079cd2
commit e6f4463cb0
12 changed files with 171 additions and 76 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ Application web pour un groupe d'amis (~12 personnes) qui anime un jeu et des ol
V1, V2 et V3 sont livrées :
- **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 Administration.
- **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.
Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/thèmes, le jeu du Tribunal lui-même (Char, roulette, ostracisme, timer du Gardien), mode grand écran.
+4 -4
View File
@@ -10,7 +10,7 @@ Application web pour animer et scorer le jeu **« Le Tribunal »** entre amis pe
- 👤 **Connexion / déconnexion**, pseudo affiché partout (jamais l'email)
- 🖼️ **Profil éditable** : pseudo modifiable **une seule fois** puis figé (sauf par un juge), photo recadrée en rond, redimensionnée et compressée côté client
- 🏆 **Leaderboard en direct** (Supabase Realtime) : podium top 3 (or/argent/bronze, gestion des égalités), flèches ▲▼ de progression depuis le dernier « round »
- ⚖️ **Rôles** : `public` / `judge`. Les juges attribuent des points (+/- avec montant personnalisé et motif, confirmation explicite avant envoi) et gèrent les membres depuis une section **Administration**
- ⚖️ **Rôles** : `public` / `judge`. Les juges attribuent des points (+/- avec montant personnalisé et motif, confirmation explicite avant envoi) et gèrent les membres depuis une section **Admin**
- 📣 **Le crieur** : journal public et en direct de toutes les attributions de points
- 📱 **Responsive**, pensé mobile-first et propre en vidéoprojection
@@ -137,7 +137,7 @@ Le profil (`public.profiles`) est créé automatiquement à l'inscription via un
### Verrou de pseudo
Chaque membre peut changer son pseudo **une seule fois** depuis `/profile`. Dès l'enregistrement, `pseudo_locked` passe à `true` et le champ devient en lecture seule (« pseudo figé — contacte un juge pour le changer »). Un juge peut toujours modifier le pseudo de quiconque et, s'il le souhaite, redéverrouiller un compte depuis **Administration**.
Chaque membre peut changer son pseudo **une seule fois** depuis `/profile`. Dès l'enregistrement, `pseudo_locked` passe à `true` et le champ devient en lecture seule (« pseudo figé — contacte un juge pour le changer »). Un juge peut toujours modifier le pseudo de quiconque et, s'il le souhaite, redéverrouiller un compte depuis **Admin**.
### Rôles & administration
@@ -148,7 +148,7 @@ select id, pseudo from public.profiles;
update public.profiles set role = 'judge' where id = '<uuid-du-membre>';
```
Ensuite, un juge peut promouvoir/rétrograder d'autres membres et éditer leur pseudo depuis la page **Administration** (visible uniquement par les juges, et vérifié côté serveur — pas seulement caché en front).
Ensuite, un juge peut promouvoir/rétrograder d'autres membres et éditer leur pseudo depuis la page **Admin** (visible uniquement par les juges, et vérifié côté serveur — pas seulement caché en front).
### Email transactionnel
@@ -171,7 +171,7 @@ Le service email intégré de Supabase (sans SMTP custom) limite l'envoi à quel
- **Attribution de points** : depuis le leaderboard, un juge clique `+`/`` (montant personnalisable, motif optionnel) — le delta reste **en attente** (affiché en couleur) jusqu'à un clic sur **Confirmer** (envoie la RPC `award_points`) ou **Annuler**. Le client n'écrit jamais directement la colonne `points` : la RPC vérifie le rôle juge côté serveur, applique le delta (négatif autorisé, pas de plancher à 0) et journalise l'opération.
- **Le crieur** (`/journal`) : fil de tous les événements (« Untel a donné +5 à Untel — motif »), visible par tous les authentifiés, alimenté par Supabase Realtime.
- **Podium** : top 3 mis en avant (or/argent/bronze), gère proprement les égalités (rangs partagés) et les groupes de moins de 3 membres.
- **Flèches de progression** : ▲/▼/= comparent le rang actuel au dernier repère. Un juge fige un nouveau repère via **Nouveau round** dans **Administration**.
- **Flèches de progression** : ▲/▼/= comparent le rang actuel au dernier repère. Un juge fige un nouveau repère via **Nouveau round** dans **Admin**.
---
+36 -33
View File
@@ -78,46 +78,49 @@ function MemberRow({ member, isSelf }: { member: Member; isSelf: boolean }) {
return (
<li className="flex flex-col gap-2 rounded-lg border border-navy/10 bg-white px-4 py-3 shadow-sm sm:flex-row sm:items-center sm:gap-4">
<Avatar pseudo={member.pseudo} avatarUrl={member.avatar_url} size="sm" />
<div className="flex items-center gap-2">
<Avatar pseudo={member.pseudo} avatarUrl={member.avatar_url} size="sm" />
<input
type="text"
value={pseudo}
onChange={(event) => setPseudo(event.target.value)}
className="w-full flex-1 rounded-md border border-navy/20 bg-white px-2 py-1 text-sm outline-none focus:border-gold sm:w-auto"
/>
</div>
<input
type="text"
value={pseudo}
onChange={(event) => setPseudo(event.target.value)}
className="flex-1 rounded-md border border-navy/20 bg-white px-2 py-1 text-sm outline-none focus:border-gold"
/>
<div className="flex flex-wrap items-center gap-2">
<select
value={role}
onChange={(event) => setRole(event.target.value as Role)}
disabled={isSelf}
className="rounded-md border border-navy/20 bg-white px-2 py-1 text-sm outline-none focus:border-gold disabled:opacity-50"
>
<option value="public">public</option>
<option value="judge">judge</option>
</select>
<select
value={role}
onChange={(event) => setRole(event.target.value as Role)}
disabled={isSelf}
className="rounded-md border border-navy/20 bg-white px-2 py-1 text-sm outline-none focus:border-gold disabled:opacity-50"
>
<option value="public">public</option>
<option value="judge">judge</option>
</select>
{pseudoLocked ? (
<button
type="button"
onClick={handleUnlock}
disabled={saving}
className="whitespace-nowrap rounded-md border border-navy/20 px-2 py-1 text-xs text-navy hover:bg-navy/5 disabled:opacity-50"
>
Déverrouiller le pseudo
</button>
) : (
<span className="whitespace-nowrap text-xs text-ink/50">pseudo libre</span>
)}
{pseudoLocked ? (
<button
type="button"
onClick={handleUnlock}
disabled={saving}
className="whitespace-nowrap rounded-md border border-navy/20 px-2 py-1 text-xs text-navy hover:bg-navy/5 disabled:opacity-50"
onClick={handleSave}
disabled={saving || !dirty}
className="whitespace-nowrap rounded-md bg-navy px-3 py-1.5 text-sm font-medium text-ivory transition hover:bg-navy/90 disabled:opacity-40 sm:ml-auto"
>
Déverrouiller le pseudo
{saving ? "…" : "Enregistrer"}
</button>
) : (
<span className="whitespace-nowrap text-xs text-ink/50">pseudo libre</span>
)}
<button
type="button"
onClick={handleSave}
disabled={saving || !dirty}
className="whitespace-nowrap rounded-md bg-navy px-3 py-1.5 text-sm font-medium text-ivory transition hover:bg-navy/90 disabled:opacity-40"
>
{saving ? "…" : "Enregistrer"}
</button>
</div>
{error && <p className="text-xs text-red-700">{error}</p>}
{success && !dirty && <p className="text-xs text-green-700">OK</p>}
+1 -1
View File
@@ -33,7 +33,7 @@ export default async function AdminPage() {
return (
<div className="mx-auto w-full max-w-3xl px-4 py-8">
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold text-navy">Administration</h1>
<h1 className="text-2xl font-semibold text-navy">Admin</h1>
<ResetRoundButton />
</div>
<AdminMembersTable members={members ?? []} currentUserId={user.id} />
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
<rect x="58" y="74" width="30" height="12" rx="3" fill="#0F2748" />
<rect x="54" y="70" width="38" height="6" rx="3" fill="#0F2748" opacity="0.7" />
<g transform="rotate(45 50 46)">
<rect x="30" y="10" width="18" height="46" rx="6" fill="#C9A227" />
<rect x="18" y="8" width="42" height="20" rx="6" fill="#0F2748" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 420 B

+16 -11
View File
@@ -99,20 +99,25 @@ export function LeaderboardView({
return (
<li
key={profile.id}
className={`flex flex-wrap items-center gap-3 rounded-lg border border-navy/10 bg-white px-4 py-3 shadow-sm transition-colors ${
className={`flex flex-col gap-2 rounded-lg border border-navy/10 bg-white px-4 py-3 shadow-sm transition-colors sm:flex-row sm:items-center sm:gap-3 ${
flashingId === profile.id ? "animate-points-flash" : ""
}`}
>
<span className="w-6 shrink-0 text-center font-semibold text-navy/60">
{profile.rank}
</span>
<Avatar pseudo={profile.pseudo} avatarUrl={profile.avatar_url} size="sm" />
<span className="flex-1 truncate font-medium">{profile.pseudo}</span>
<ProgressBadge direction={progress.direction} amount={progress.amount} />
<span className="font-semibold text-navy">{profile.points}</span>
{isJudge && (
<JudgePointControls memberId={profile.id} onApplyDelta={applyOptimisticDelta} />
)}
<div className="flex items-center gap-3">
<span className="w-6 shrink-0 text-center font-semibold text-navy/60">
{profile.rank}
</span>
<Avatar pseudo={profile.pseudo} avatarUrl={profile.avatar_url} size="sm" />
<span className="flex-1 truncate font-medium sm:flex-initial">{profile.pseudo}</span>
<ProgressBadge direction={progress.direction} amount={progress.amount} />
<span className="font-semibold text-navy sm:hidden">{profile.points}</span>
</div>
<div className="flex items-center gap-3 sm:ml-auto">
<span className="hidden font-semibold text-navy sm:inline">{profile.points}</span>
{isJudge && (
<JudgePointControls memberId={profile.id} onApplyDelta={applyOptimisticDelta} />
)}
</div>
</li>
);
})}
+8 -4
View File
@@ -50,12 +50,16 @@ export function JudgePointControls({
setReason("");
}
const buttonHeight = compact ? "h-6" : "h-7";
const buttonWidth = compact ? "w-6" : "w-7";
const inputWidth = compact ? "w-9" : "w-12";
return (
<div className="flex flex-wrap items-center gap-1.5">
<div className="flex flex-wrap items-center gap-1 sm:gap-1.5">
<button
type="button"
onClick={() => setPending((p) => p - parsedAmount)}
className="h-7 w-7 shrink-0 rounded-md border border-navy/20 text-sm font-bold text-navy hover:bg-navy/5"
className={`${buttonHeight} ${buttonWidth} shrink-0 rounded-md border border-navy/20 text-sm font-bold text-navy hover:bg-navy/5`}
aria-label={`Retirer ${parsedAmount} points`}
>
@@ -65,12 +69,12 @@ export function JudgePointControls({
min={1}
value={amount}
onChange={(event) => setAmount(event.target.value)}
className="h-7 w-12 rounded-md border border-navy/20 px-1 text-center text-xs"
className={`${buttonHeight} ${inputWidth} rounded-md border border-navy/20 px-1 text-center text-xs`}
/>
<button
type="button"
onClick={() => setPending((p) => p + parsedAmount)}
className="h-7 w-7 shrink-0 rounded-md border border-navy/20 text-sm font-bold text-navy hover:bg-navy/5"
className={`${buttonHeight} ${buttonWidth} shrink-0 rounded-md border border-navy/20 text-sm font-bold text-navy hover:bg-navy/5`}
aria-label={`Ajouter ${parsedAmount} points`}
>
+
+12
View File
@@ -0,0 +1,12 @@
export function Logo({ className = "h-7 w-7" }: { className?: string }) {
return (
<svg viewBox="0 0 100 100" fill="none" className={className} aria-hidden>
<rect x="58" y="74" width="30" height="12" rx="3" fill="#C9A227" />
<rect x="54" y="70" width="38" height="6" rx="3" fill="#C9A227" opacity="0.7" />
<g transform="rotate(45 50 46)">
<rect x="30" y="10" width="18" height="46" rx="6" fill="#F4ECD8" />
<rect x="18" y="8" width="42" height="20" rx="6" fill="#C9A227" />
</g>
</svg>
);
}
+71 -17
View File
@@ -4,15 +4,25 @@ import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { Logo } from "@/components/logo";
function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
function NavLink({
href,
onNavigate,
children,
}: {
href: string;
onNavigate?: () => void;
children: React.ReactNode;
}) {
const pathname = usePathname();
const isActive = pathname.startsWith(href);
return (
<Link
href={href}
className={`rounded-md px-2 py-1 transition-colors ${
onClick={onNavigate}
className={`block rounded-md px-3 py-2 transition-colors sm:px-2 sm:py-1 ${
isActive ? "bg-ivory/10 text-gold" : "hover:text-gold"
}`}
>
@@ -24,6 +34,7 @@ function NavLink({ href, children }: { href: string; children: React.ReactNode }
export function NavBar({ isJudge }: { isJudge: boolean }) {
const router = useRouter();
const [signingOut, setSigningOut] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
async function handleSignOut() {
setSigningOut(true);
@@ -33,25 +44,68 @@ export function NavBar({ isJudge }: { isJudge: boolean }) {
router.refresh();
}
const links = (
<>
<NavLink href="/leaderboard" onNavigate={() => setMenuOpen(false)}>
Leaderboard
</NavLink>
<NavLink href="/journal" onNavigate={() => setMenuOpen(false)}>
Le crieur
</NavLink>
<NavLink href="/profile" onNavigate={() => setMenuOpen(false)}>
Profil
</NavLink>
{isJudge && (
<NavLink href="/admin" onNavigate={() => setMenuOpen(false)}>
Admin
</NavLink>
)}
<button
type="button"
onClick={handleSignOut}
disabled={signingOut}
className="block w-full rounded-md px-3 py-2 text-left transition-colors hover:text-gold disabled:opacity-50 sm:w-auto sm:px-2 sm:py-1"
>
{signingOut ? "…" : "Déconnexion"}
</button>
</>
);
return (
<nav className="sticky top-0 z-10 bg-navy text-ivory shadow-md">
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
<span className="font-semibold tracking-wide">Le Tribunal</span>
<div className="flex items-center gap-1 text-sm sm:gap-2">
<NavLink href="/leaderboard">Leaderboard</NavLink>
<NavLink href="/journal">Le crieur</NavLink>
<NavLink href="/profile">Profil</NavLink>
{isJudge && <NavLink href="/admin">Administration</NavLink>}
<button
type="button"
onClick={handleSignOut}
disabled={signingOut}
className="rounded-md px-2 py-1 transition-colors hover:text-gold disabled:opacity-50"
>
{signingOut ? "…" : "Déconnexion"}
</button>
</div>
<Link href="/leaderboard" className="flex items-center gap-2 font-semibold tracking-wide">
<Logo className="h-6 w-6 sm:h-7 sm:w-7" />
<span>Le Tribunal</span>
</Link>
<div className="hidden items-center gap-1 text-sm sm:flex sm:gap-2">{links}</div>
<button
type="button"
onClick={() => setMenuOpen((open) => !open)}
aria-label={menuOpen ? "Fermer le menu" : "Ouvrir le menu"}
aria-expanded={menuOpen}
className="flex h-9 w-9 items-center justify-center rounded-md hover:bg-ivory/10 sm:hidden"
>
<span className="sr-only">Menu</span>
{menuOpen ? (
<svg viewBox="0 0 24 24" className="h-6 w-6" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
</svg>
) : (
<svg viewBox="0 0 24 24" className="h-6 w-6" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M4 7h16M4 12h16M4 17h16" strokeLinecap="round" />
</svg>
)}
</button>
</div>
{menuOpen && (
<div className="flex flex-col gap-1 border-t border-ivory/10 px-4 py-2 text-sm sm:hidden">
{links}
</div>
)}
</nav>
);
}
+4 -4
View File
@@ -36,8 +36,8 @@ export function Podium({
}
return (
<div className="mb-8 rounded-xl border border-navy/10 bg-white px-4 py-6 shadow-sm sm:px-8">
<div className="flex items-end justify-center gap-4 sm:gap-8">
<div className="mb-8 overflow-x-auto rounded-xl border border-navy/10 bg-white px-2 py-6 shadow-sm sm:px-8">
<div className="flex min-w-fit items-end justify-center gap-2 sm:gap-8">
{topRanks.map((rank) => {
const members = byRank.get(rank)!;
const style = SLOT_STYLES[rank] ?? SLOT_STYLES[3];
@@ -52,7 +52,7 @@ export function Podium({
avatarUrl={member.avatar_url}
size={rank === 1 ? "lg" : "md"}
/>
<span className="max-w-[7rem] truncate text-sm font-medium text-ink">
<span className="max-w-[6rem] truncate text-sm font-medium text-ink sm:max-w-[7rem]">
{member.pseudo}
</span>
<span className="text-xs font-medium text-navy/60">{member.points} pts</span>
@@ -67,7 +67,7 @@ export function Podium({
))}
</div>
<div
className={`flex w-20 sm:w-28 items-start justify-center rounded-t-md ${style.bg} pt-2 ring-1 sm:ring-2 ${style.pedestal} ${style.ring}`}
className={`flex w-16 sm:w-28 items-start justify-center rounded-t-md ${style.bg} pt-2 ring-1 sm:ring-2 ${style.pedestal} ${style.ring}`}
>
<span className="text-lg font-bold text-navy sm:text-2xl">{rank}</span>
</div>
+10 -1
View File
@@ -198,7 +198,16 @@ insert into storage.buckets (id, name, public)
values ('avatars', 'avatars', true)
on conflict (id) do nothing;
-- Bucket public => la lecture se fait via l'URL publique, pas besoin de policy SELECT.
-- Le bucket public sert la lecture via l'URL publique (hors RLS), mais une
-- policy SELECT reste nécessaire : pour un upload en upsert (remplacement
-- d'une photo existante), Postgres doit pouvoir lire la ligne existante en
-- interne pour évaluer la clause USING de l'UPDATE — sans SELECT, l'upsert
-- échoue avec "new row violates row-level security policy" même si les
-- policies INSERT/UPDATE sont correctes.
drop policy if exists "Avatars are publicly viewable" on storage.objects;
create policy "Avatars are publicly viewable"
on storage.objects for select
using (bucket_id = 'avatars');
drop policy if exists "Users can upload their own avatar" on storage.objects;
create policy "Users can upload their own avatar"