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>
123 lines
3.5 KiB
TypeScript
123 lines
3.5 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { createClient } from "@/lib/supabase/client";
|
||
|
||
export function JudgePointControls({
|
||
memberId,
|
||
onApplyDelta,
|
||
compact = false,
|
||
}: {
|
||
memberId: string;
|
||
onApplyDelta: (id: string, delta: number) => void;
|
||
compact?: boolean;
|
||
}) {
|
||
const [amount, setAmount] = useState("1");
|
||
const [reason, setReason] = useState("");
|
||
const [pending, setPending] = useState(0);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const parsedAmount = Math.max(1, Math.abs(Number.parseInt(amount, 10) || 1));
|
||
|
||
function handleCancel() {
|
||
setPending(0);
|
||
setReason("");
|
||
setError(null);
|
||
}
|
||
|
||
async function handleConfirm() {
|
||
if (pending === 0) return;
|
||
setSubmitting(true);
|
||
setError(null);
|
||
|
||
const supabase = createClient();
|
||
const { error: rpcError } = await supabase.rpc("award_points", {
|
||
p_target_id: memberId,
|
||
p_delta: pending,
|
||
p_reason: reason.trim() || null,
|
||
});
|
||
|
||
setSubmitting(false);
|
||
|
||
if (rpcError) {
|
||
setError("Échec, réessaie.");
|
||
return;
|
||
}
|
||
|
||
onApplyDelta(memberId, pending);
|
||
setPending(0);
|
||
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 sm:gap-1.5">
|
||
<button
|
||
type="button"
|
||
onClick={() => setPending((p) => p - parsedAmount)}
|
||
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`}
|
||
>
|
||
−
|
||
</button>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
value={amount}
|
||
onChange={(event) => setAmount(event.target.value)}
|
||
className={`${buttonHeight} ${inputWidth} rounded-md border border-navy/20 px-1 text-center text-xs`}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => setPending((p) => p + parsedAmount)}
|
||
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`}
|
||
>
|
||
+
|
||
</button>
|
||
|
||
{!compact && pending !== 0 && (
|
||
<input
|
||
type="text"
|
||
value={reason}
|
||
onChange={(event) => setReason(event.target.value)}
|
||
placeholder="motif (optionnel)"
|
||
className="h-7 w-28 rounded-md border border-navy/20 px-2 text-xs sm:w-36"
|
||
/>
|
||
)}
|
||
|
||
{pending !== 0 && (
|
||
<>
|
||
<span
|
||
className={`text-sm font-semibold ${pending > 0 ? "text-green-700" : "text-red-700"}`}
|
||
>
|
||
{pending > 0 ? `+${pending}` : pending}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={handleConfirm}
|
||
disabled={submitting}
|
||
className="rounded-md bg-navy px-2 py-1 text-xs font-medium text-ivory hover:bg-navy/90 disabled:opacity-50"
|
||
>
|
||
{submitting ? "…" : "Confirmer"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleCancel}
|
||
disabled={submitting}
|
||
className="rounded-md border border-navy/20 px-2 py-1 text-xs text-navy hover:bg-navy/5 disabled:opacity-50"
|
||
>
|
||
Annuler
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
{error && <span className="text-xs text-red-700">{error}</span>}
|
||
</div>
|
||
);
|
||
}
|