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:
+79
-56
@@ -6,19 +6,40 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { LaurelWreath } from "@/components/laurel-wreath";
|
||||
import { Avatar } from "@/components/avatar";
|
||||
import {
|
||||
IconWheel,
|
||||
IconScroll,
|
||||
IconPerson,
|
||||
IconColumn,
|
||||
IconLogout,
|
||||
IconChevronDown,
|
||||
} from "@/components/icons";
|
||||
|
||||
function NavTab({ href, children }: { href: string; children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const isActive = pathname.startsWith(href);
|
||||
type MenuEntry = {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
};
|
||||
|
||||
function MenuLink({
|
||||
entry,
|
||||
isActive,
|
||||
onNavigate,
|
||||
}: {
|
||||
entry: MenuEntry;
|
||||
isActive: boolean;
|
||||
onNavigate: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`rounded-md px-3 py-1.5 font-heading text-xs tracking-wide uppercase transition-colors ${
|
||||
isActive ? "bg-gold/15 text-gold-bright" : "text-marble/80 hover:text-gold-bright"
|
||||
href={entry.href}
|
||||
onClick={onNavigate}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
|
||||
isActive ? "bg-gold/15 text-ink-2 font-medium" : "text-text-marble hover:bg-gold/10"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
<span className={isActive ? "text-gold" : "text-text-mut"}>{entry.icon}</span>
|
||||
{entry.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +56,7 @@ export function Header({
|
||||
points: number;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -45,8 +67,15 @@ export function Header({
|
||||
setMenuOpen(false);
|
||||
}
|
||||
}
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") setMenuOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleSignOut() {
|
||||
@@ -57,69 +86,68 @@ export function Header({
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
const entries: MenuEntry[] = [
|
||||
{ href: "/leaderboard", label: "Le Classement", icon: <LaurelWreath className="h-5 w-5" /> },
|
||||
{ href: "/roulette", label: "La Roulette", icon: <IconWheel /> },
|
||||
{ href: "/journal", label: "Le Crieur", icon: <IconScroll /> },
|
||||
{ href: "/profile", label: "Mon profil", icon: <IconPerson /> },
|
||||
];
|
||||
if (isJudge) {
|
||||
entries.push({
|
||||
href: "/admin",
|
||||
label: "Le Conseil des Archontes",
|
||||
icon: <IconColumn />,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-10 bg-ink-2/95 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-4xl items-center justify-between px-4 py-3">
|
||||
<Link href="/leaderboard" className="flex items-center gap-2">
|
||||
<LaurelWreath className="h-7 w-7 shrink-0" color="#E7C560" />
|
||||
<span className="hidden font-heading text-sm tracking-[0.15em] text-gold-bright uppercase sm:inline">
|
||||
<div className="mx-auto flex max-w-4xl items-center justify-between gap-2 px-2 py-2.5 sm:px-4 sm:py-3">
|
||||
<Link href="/leaderboard" className="flex min-w-0 items-center gap-1.5 sm:gap-2">
|
||||
<LaurelWreath className="h-5 w-5 shrink-0 sm:h-7 sm:w-7" color="#E7C560" />
|
||||
<span className="whitespace-nowrap font-heading text-[0.6rem] tracking-[0.08em] text-gold-bright uppercase sm:text-sm sm:tracking-[0.15em]">
|
||||
Le Tribunal
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div ref={menuRef} className="relative">
|
||||
<div ref={menuRef} className="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen((open) => !open)}
|
||||
aria-expanded={menuOpen}
|
||||
className="flex items-center gap-2 rounded-full px-1.5 py-1 transition-colors hover:bg-marble/10"
|
||||
aria-label="Ouvrir le menu"
|
||||
className="flex items-center gap-1.5 rounded-full py-1 pr-1 pl-1.5 transition-colors hover:bg-marble/10 sm:gap-2 sm:pr-1.5"
|
||||
>
|
||||
<Avatar pseudo={pseudo} avatarUrl={avatarUrl} size="sm" />
|
||||
<span className="hidden flex-col items-start leading-tight sm:flex">
|
||||
<span className="text-sm font-medium text-marble">{pseudo}</span>
|
||||
<span
|
||||
className={`font-heading text-[0.65rem] tracking-wide uppercase ${
|
||||
isJudge ? "text-gold-bright" : "text-text-mut"
|
||||
}`}
|
||||
>
|
||||
{isJudge ? "Archonte" : "Citoyen"} · {points} gloires
|
||||
</span>
|
||||
<Avatar pseudo={pseudo} avatarUrl={avatarUrl} size="xs" />
|
||||
<span className="max-w-[4.5rem] truncate text-xs font-medium text-marble sm:max-w-[9rem] sm:text-sm">
|
||||
{pseudo}
|
||||
</span>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={`h-4 w-4 text-marble/60 transition-transform ${menuOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span className="shrink-0 rounded-full bg-gold/15 px-1.5 py-0.5 text-[0.65rem] font-semibold text-gold-bright sm:px-2 sm:text-xs">
|
||||
{points}
|
||||
</span>
|
||||
<IconChevronDown
|
||||
className={`h-3.5 w-3.5 shrink-0 text-marble/60 transition-transform sm:h-4 sm:w-4 ${menuOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{menuOpen && (
|
||||
<div className="marble-surface absolute right-0 z-20 mt-2 w-52 rounded-lg border border-gold/40 py-1 shadow-lg">
|
||||
<Link
|
||||
href="/profile"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="block px-4 py-2 text-sm hover:bg-gold/10"
|
||||
>
|
||||
Mon profil
|
||||
</Link>
|
||||
{isJudge && (
|
||||
<Link
|
||||
href="/admin"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="block px-4 py-2 text-sm hover:bg-gold/10"
|
||||
>
|
||||
Le Conseil des Archontes
|
||||
</Link>
|
||||
)}
|
||||
<div className="marble-surface absolute right-0 z-20 mt-2 w-64 overflow-hidden rounded-lg border border-gold/40 py-1 shadow-lg">
|
||||
{entries.map((entry) => (
|
||||
<MenuLink
|
||||
key={entry.href}
|
||||
entry={entry}
|
||||
isActive={pathname.startsWith(entry.href)}
|
||||
onNavigate={() => setMenuOpen(false)}
|
||||
/>
|
||||
))}
|
||||
<div className="my-1 border-t border-gold/20" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignOut}
|
||||
disabled={signingOut}
|
||||
className="block w-full px-4 py-2 text-left text-sm text-oxblood hover:bg-gold/10 disabled:opacity-50"
|
||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-oxblood transition-colors hover:bg-oxblood/10 disabled:opacity-50"
|
||||
>
|
||||
<IconLogout className="h-5 w-5" />
|
||||
{signingOut ? "…" : "Se déconnecter"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -127,11 +155,6 @@ export function Header({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="mx-auto flex max-w-4xl items-center gap-1 px-4 pb-2">
|
||||
<NavTab href="/leaderboard">L'Agora</NavTab>
|
||||
<NavTab href="/journal">Le Crieur</NavTab>
|
||||
</nav>
|
||||
|
||||
<div className="meander-divider" />
|
||||
</header>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user