e6f4463cb0
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>
150 lines
4.4 KiB
TypeScript
150 lines
4.4 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { createClient } from "@/lib/supabase/client";
|
|
import { Avatar } from "@/components/avatar";
|
|
|
|
type Role = "public" | "judge";
|
|
|
|
type Member = {
|
|
id: string;
|
|
pseudo: string;
|
|
role: string;
|
|
pseudo_locked: boolean;
|
|
avatar_url: string | null;
|
|
};
|
|
|
|
function MemberRow({ member, isSelf }: { member: Member; isSelf: boolean }) {
|
|
const [savedPseudo, setSavedPseudo] = useState(member.pseudo);
|
|
const [savedRole, setSavedRole] = useState<Role>(member.role === "judge" ? "judge" : "public");
|
|
const [pseudo, setPseudo] = useState(member.pseudo);
|
|
const [role, setRole] = useState<Role>(member.role === "judge" ? "judge" : "public");
|
|
const [pseudoLocked, setPseudoLocked] = useState(member.pseudo_locked);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [success, setSuccess] = useState(false);
|
|
|
|
const dirty = pseudo.trim() !== savedPseudo || role !== savedRole;
|
|
|
|
async function handleSave() {
|
|
setError(null);
|
|
setSuccess(false);
|
|
const trimmed = pseudo.trim();
|
|
if (!trimmed) {
|
|
setError("Pseudo invalide.");
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
const supabase = createClient();
|
|
const { error: updateError } = await supabase
|
|
.from("profiles")
|
|
.update({ pseudo: trimmed, role })
|
|
.eq("id", member.id);
|
|
setSaving(false);
|
|
|
|
if (updateError) {
|
|
setError(
|
|
updateError.message.includes("duplicate key")
|
|
? "Ce pseudo est déjà pris."
|
|
: "Une erreur est survenue.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
setSavedPseudo(trimmed);
|
|
setSavedRole(role);
|
|
setSuccess(true);
|
|
}
|
|
|
|
async function handleUnlock() {
|
|
setError(null);
|
|
setSuccess(false);
|
|
setSaving(true);
|
|
const supabase = createClient();
|
|
const { error: updateError } = await supabase
|
|
.from("profiles")
|
|
.update({ pseudo_locked: false })
|
|
.eq("id", member.id);
|
|
setSaving(false);
|
|
|
|
if (updateError) {
|
|
setError("Une erreur est survenue.");
|
|
return;
|
|
}
|
|
|
|
setPseudoLocked(false);
|
|
}
|
|
|
|
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">
|
|
<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>
|
|
|
|
<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>
|
|
|
|
{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>
|
|
)}
|
|
|
|
<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 sm:ml-auto"
|
|
>
|
|
{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>}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
export function AdminMembersTable({
|
|
members,
|
|
currentUserId,
|
|
}: {
|
|
members: Member[];
|
|
currentUserId: string;
|
|
}) {
|
|
if (members.length === 0) {
|
|
return <p className="text-sm text-ink/70">Aucun membre inscrit.</p>;
|
|
}
|
|
|
|
return (
|
|
<ul className="flex flex-col gap-2">
|
|
{members.map((member) => (
|
|
<MemberRow key={member.id} member={member} isSelf={member.id === currentUserId} />
|
|
))}
|
|
</ul>
|
|
);
|
|
}
|