Corrections UI, refonte podium, page Roulette, navigation en menu unique

Upload de photo (signup + profil) :
- Remplace le contrôle natif du navigateur par un composant entièrement
  en français : input masqué (sr-only) déclenché par un vrai bouton
  (icône appareil photo + « Choisir une photo »/« Changer ma photo »),
  nom du fichier sélectionné affiché (tronqué) avec une croix pour le
  retirer, « Aucune photo sélectionnée » sinon.

Header : plus jamais masqué sur mobile
- "LE TRIBUNAL" et le chip utilisateur (photo + pseudo + points) restent
  visibles à toutes les tailles, y compris 320px — tailles de police et
  troncature au lieu de `hidden`. Vérifié sans débordement horizontal à
  320/375/414/768px.

Navigation : tout dans le menu déroulant
- Retire les onglets du header (ne garde que logo + chip). Le menu
  déroulant du chip contient désormais toutes les pages (Le Classement,
  La Roulette, Le Crieur, Mon profil, Le Conseil des Archontes si juge)
  avec une icône chacune (components/icons.tsx), fermeture au clic
  extérieur et à Échap, entrée de la page courante surlignée,
  déconnexion séparée en oxblood.

Podium :
- Contrôles de points en icônes ✓/✕ rondes (40×40, olive/oxblood) sous
  la carte du membre, aria-label + tooltip — uniquement sur le podium,
  le reste du classement garde ses contrôles textuels.
- Refonte visuelle : socles marbre veiné avec arête lumineuse
  (.pedestal-marble), chiffres romains gravés (toRoman(), .engraved),
  couronne de laurier + halo doré sur le 1er, accents argent/bronze sur
  2e/3e, points en gros chiffres dorés, animation d'entrée décalée par
  socle (~600ms, respecte prefers-reduced-motion).

Nouvelle page /roulette :
- Tirage aléatoire uniforme calculé avant l'animation (le random ne sert
  qu'à choisir, l'animation ne fait que le mettre en scène). Carrousel
  horizontal des avatars avec décélération (cubic-bezier long), repère
  central doré, révélation finale (couronne + halo + zoom) avec le
  pseudo en Cinzel. Filtre Citoyens/tous, bouton Rejouer, dernier élu
  affiché, prefers-reduced-motion affiche le résultat directement.
  Route protégée par le proxy comme les autres pages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Valentin ROBIN
2026-07-12 20:44:32 +02:00
parent 9f4e58019b
commit 0ea6a36189
13 changed files with 629 additions and 87 deletions
+55 -8
View File
@@ -1,17 +1,23 @@
"use client";
import { useState } from "react";
import { useRef, useState } from "react";
import Cropper, { type Area } from "react-easy-crop";
import { cropImageToBlob } from "@/lib/image";
import { IconCamera, IconClose } from "@/components/icons";
export function AvatarPicker({
label,
hasExistingPhoto = false,
onCropped,
onClear,
}: {
label: string;
hasExistingPhoto?: boolean;
onCropped: (blob: Blob) => void;
onClear?: () => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [rawImageSrc, setRawImageSrc] = useState<string | null>(null);
const [pendingFileName, setPendingFileName] = useState<string | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
@@ -25,6 +31,7 @@ export function AvatarPicker({
const reader = new FileReader();
reader.onload = () => {
setRawImageSrc(reader.result as string);
setPendingFileName(file.name);
setCrop({ x: 0, y: 0 });
setZoom(1);
setError(null);
@@ -37,12 +44,23 @@ export function AvatarPicker({
try {
const blob = await cropImageToBlob(rawImageSrc, croppedAreaPixels);
onCropped(blob);
setFileName(pendingFileName);
setRawImageSrc(null);
setPendingFileName(null);
} catch {
setError("Impossible de traiter cette image, réessaie.");
}
}
function handleClearSelection() {
setFileName(null);
onClear?.();
}
function truncate(name: string): string {
return name.length > 28 ? `${name.slice(0, 12)}${name.slice(-10)}` : name;
}
if (rawImageSrc) {
return (
<div className="flex flex-col items-center gap-3">
@@ -72,7 +90,10 @@ export function AvatarPicker({
<div className="flex gap-2">
<button
type="button"
onClick={() => setRawImageSrc(null)}
onClick={() => {
setRawImageSrc(null);
setPendingFileName(null);
}}
className="rounded-md border border-gold/40 px-3 py-1.5 text-sm text-text-marble hover:bg-gold/10"
>
Annuler
@@ -91,15 +112,41 @@ export function AvatarPicker({
}
return (
<label className="text-sm">
<span className="sr-only">{label}</span>
<div className="flex flex-col items-center gap-2">
<input
ref={inputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="text-sm file:mr-3 file:rounded-md file:border-0 file:bg-ink-2 file:px-3 file:py-1.5 file:text-gold-bright"
className="sr-only"
aria-label="Choisir un fichier image"
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
className="flex items-center gap-2 rounded-md bg-ink-2 px-3 py-1.5 text-sm font-medium text-gold-bright hover:bg-ink"
>
<IconCamera className="h-4 w-4" />
{fileName || hasExistingPhoto ? "Changer ma photo" : "Choisir une photo"}
</button>
{fileName ? (
<div className="flex items-center gap-1.5 text-xs text-text-mut">
<span>{truncate(fileName)}</span>
<button
type="button"
onClick={handleClearSelection}
aria-label="Retirer la photo sélectionnée"
className="rounded-full p-0.5 text-text-mut hover:bg-oxblood/10 hover:text-oxblood"
>
<IconClose className="h-3.5 w-3.5" />
</button>
</div>
) : (
<span className="text-xs text-text-mut">Aucune photo sélectionnée</span>
)}
{error && <p className="text-xs text-oxblood">{error}</p>}
</label>
</div>
);
}