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
+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>