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:
@@ -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>}
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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`}
|
||||
>
|
||||
+
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user