From 0ea6a36189338a531245f0615978f65ced0eb4e8 Mon Sep 17 00:00:00 2001 From: Valentin ROBIN Date: Sun, 12 Jul 2026 20:44:32 +0200 Subject: [PATCH] Corrections UI, refonte podium, page Roulette, navigation en menu unique MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/globals.css | 82 ++++++++++++ src/app/profile/profile-form.tsx | 9 +- src/app/roulette/page.tsx | 33 +++++ src/app/roulette/roulette-view.tsx | 162 ++++++++++++++++++++++++ src/app/signup/page.tsx | 8 +- src/components/avatar-picker.tsx | 63 +++++++-- src/components/avatar.tsx | 1 + src/components/header.tsx | 135 ++++++++++++-------- src/components/icons.tsx | 91 +++++++++++++ src/components/judge-point-controls.tsx | 53 +++++++- src/components/podium.tsx | 57 ++++++--- src/lib/ranking.ts | 20 +++ src/lib/supabase/middleware.ts | 2 +- 13 files changed, 629 insertions(+), 87 deletions(-) create mode 100644 src/app/roulette/page.tsx create mode 100644 src/app/roulette/roulette-view.tsx create mode 100644 src/components/icons.tsx diff --git a/src/app/globals.css b/src/app/globals.css index a1a1de3..b2052e8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -10,6 +10,8 @@ --oxblood: #a5342a; --sea: #2e6e7e; --olive: #5e6b3b; + --silver: #c7cdd6; + --bronze: #b08d57; --text-marble: #2a2116; --text-mut: #7a6a4c; @@ -27,6 +29,8 @@ --color-oxblood: var(--oxblood); --color-sea: var(--sea); --color-olive: var(--olive); + --color-silver: var(--silver); + --color-bronze: var(--bronze); --color-text-marble: var(--text-marble); --color-text-mut: var(--text-mut); --color-background: var(--background); @@ -83,3 +87,81 @@ body { .animate-points-flash { animation: points-flash 900ms ease-out; } + +/* Chiffre romain "gravé" dans le socle du podium. */ +.engraved { + text-shadow: + 0 1px 0 rgba(255, 255, 255, 0.35), + 0 -1px 1px rgba(0, 0, 0, 0.35); +} + +/* Socle du podium : marbre veiné + arête lumineuse en haut. */ +.pedestal-marble { + position: relative; + background: + linear-gradient(100deg, transparent 0%, rgba(255, 255, 255, 0.12) 18%, transparent 32%), + linear-gradient(80deg, transparent 55%, rgba(255, 255, 255, 0.08) 68%, transparent 80%), + linear-gradient(180deg, color-mix(in srgb, var(--marble) 92%, white) 0%, var(--marble) 40%, var(--marble-2) 100%); + box-shadow: + inset 0 2px 0 rgba(255, 255, 255, 0.55), + 0 10px 18px -6px rgba(10, 27, 51, 0.55); +} + +.podium-glow { + box-shadow: 0 0 30px 6px color-mix(in srgb, var(--gold-bright) 45%, transparent); +} + +@media (prefers-reduced-motion: no-preference) { + @keyframes podium-rise { + 0% { + opacity: 0; + transform: translateY(28px) scale(0.92); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + .animate-podium-rise { + animation: podium-rise 600ms cubic-bezier(0.2, 0.7, 0.3, 1) both; + } + + @keyframes wreath-pop { + 0% { + opacity: 0; + transform: scale(0.5) translateY(6px); + } + 70% { + opacity: 1; + transform: scale(1.08) translateY(0); + } + 100% { + transform: scale(1) translateY(0); + } + } + + .animate-wreath-pop { + animation: wreath-pop 500ms cubic-bezier(0.2, 0.7, 0.3, 1) 500ms both; + } + + @keyframes reveal-pop { + 0% { + opacity: 0; + transform: scale(0.82); + box-shadow: 0 0 0 0 color-mix(in srgb, var(--gold-bright) 0%, transparent); + } + 60% { + opacity: 1; + transform: scale(1.04); + } + 100% { + transform: scale(1); + box-shadow: 0 0 40px 8px color-mix(in srgb, var(--gold-bright) 45%, transparent); + } + } + + .animate-reveal { + animation: reveal-pop 700ms cubic-bezier(0.2, 0.7, 0.3, 1) both; + } +} diff --git a/src/app/profile/profile-form.tsx b/src/app/profile/profile-form.tsx index db19bbd..4241c8f 100644 --- a/src/app/profile/profile-form.tsx +++ b/src/app/profile/profile-form.tsx @@ -105,7 +105,14 @@ export function ProfileForm({
- + { + setAvatarBlob(null); + setAvatarPreview(null); + }} + />
diff --git a/src/app/roulette/page.tsx b/src/app/roulette/page.tsx new file mode 100644 index 0000000..fb6d9e0 --- /dev/null +++ b/src/app/roulette/page.tsx @@ -0,0 +1,33 @@ +import { redirect } from "next/navigation"; +import { createClient } from "@/lib/supabase/server"; +import { RouletteView } from "./roulette-view"; + +export default async function RoulettePage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + redirect("/login"); + } + + const { data: members } = await supabase + .from("profiles") + .select("id, pseudo, avatar_url, role") + .order("pseudo", { ascending: true }); + + return ( +
+
+

+ La Roulette +

+

+ Que le sort désigne l'élu. +

+
+ +
+ ); +} diff --git a/src/app/roulette/roulette-view.tsx b/src/app/roulette/roulette-view.tsx new file mode 100644 index 0000000..01a2aa6 --- /dev/null +++ b/src/app/roulette/roulette-view.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Avatar } from "@/components/avatar"; +import { LaurelWreath } from "@/components/laurel-wreath"; + +type Member = { id: string; pseudo: string; avatar_url: string | null; role: string }; + +const ITEM_WIDTH = 96; +const REPEATS = 6; +const SPIN_MS = 3200; + +function useReducedMotion() { + const [reduced, setReduced] = useState( + () => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches, + ); + useEffect(() => { + const query = window.matchMedia("(prefers-reduced-motion: reduce)"); + const handler = () => setReduced(query.matches); + query.addEventListener("change", handler); + return () => query.removeEventListener("change", handler); + }, []); + return reduced; +} + +export function RouletteView({ members }: { members: Member[] }) { + const reducedMotion = useReducedMotion(); + const [citizensOnly, setCitizensOnly] = useState(false); + const [spinning, setSpinning] = useState(false); + const [strip, setStrip] = useState([]); + const [offset, setOffset] = useState(0); + const [transitionOn, setTransitionOn] = useState(false); + const [winner, setWinner] = useState(null); + const [lastWinner, setLastWinner] = useState(null); + const containerRef = useRef(null); + + const pool = citizensOnly ? members.filter((m) => m.role !== "judge") : members; + + function handleSpin() { + if (spinning || pool.length === 0) return; + + const chosen = pool[Math.floor(Math.random() * pool.length)]; + + if (reducedMotion) { + setWinner(chosen); + setLastWinner(chosen); + return; + } + + setWinner(null); + setSpinning(true); + + const built: Member[] = []; + for (let i = 0; i < REPEATS; i++) built.push(...pool); + built.push(chosen); + setStrip(built); + setTransitionOn(false); + setOffset(0); + + requestAnimationFrame(() => { + const containerWidth = containerRef.current?.offsetWidth ?? 320; + const finalIndex = built.length - 1; + const target = finalIndex * ITEM_WIDTH + ITEM_WIDTH / 2 - containerWidth / 2; + requestAnimationFrame(() => { + setTransitionOn(true); + setOffset(-target); + }); + }); + } + + function handleTransitionEnd() { + if (!spinning) return; + setSpinning(false); + const chosen = strip[strip.length - 1]; + setWinner(chosen); + setLastWinner(chosen); + } + + if (members.length === 0) { + return

Personne n'est encore inscrit.

; + } + + return ( +
+ + +
+
+ {strip.length > 0 && ( +
+ {strip.map((member, index) => ( +
+ + + {member.pseudo} + +
+ ))} +
+ )} +
+ +
+ +
+ +
+ {winner && ( +
+ +
+ +
+ + {winner.pseudo} + + a été désigné(e) par le sort +
+ )} +
+ + {lastWinner && !winner && ( +

+ Dernier élu : {lastWinner.pseudo} +

+ )} +
+ ); +} diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index f180c12..28c5bbc 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -189,7 +189,13 @@ export default function SignupPage() { Photo de profil (optionnel) - + { + setAvatarBlob(null); + setAvatarPreview(null); + }} + />
{error && ( diff --git a/src/components/avatar-picker.tsx b/src/components/avatar-picker.tsx index 8555723..d2c791b 100644 --- a/src/components/avatar-picker.tsx +++ b/src/components/avatar-picker.tsx @@ -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(null); const [rawImageSrc, setRawImageSrc] = useState(null); + const [pendingFileName, setPendingFileName] = useState(null); + const [fileName, setFileName] = useState(null); const [crop, setCrop] = useState({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); const [croppedAreaPixels, setCroppedAreaPixels] = useState(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 (
@@ -72,7 +90,10 @@ export function AvatarPicker({
+ + {fileName ? ( +
+ {truncate(fileName)} + +
+ ) : ( + Aucune photo sélectionnée + )} + {error &&

{error}

} - +
); } diff --git a/src/components/avatar.tsx b/src/components/avatar.tsx index 848c434..4a61689 100644 --- a/src/components/avatar.tsx +++ b/src/components/avatar.tsx @@ -13,6 +13,7 @@ function fallbackColor(pseudo: string): string { } const SIZE_CLASSES = { + xs: "h-7 w-7 text-[0.6rem]", sm: "h-8 w-8 text-xs", md: "h-12 w-12 text-sm", lg: "h-24 w-24 text-2xl", diff --git a/src/components/header.tsx b/src/components/header.tsx index be3d3ff..7cb02d6 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -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 ( - {children} + {entry.icon} + {entry.label} ); } @@ -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(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: }, + { href: "/roulette", label: "La Roulette", icon: }, + { href: "/journal", label: "Le Crieur", icon: }, + { href: "/profile", label: "Mon profil", icon: }, + ]; + if (isJudge) { + entries.push({ + href: "/admin", + label: "Le Conseil des Archontes", + icon: , + }); + } + return (
-
- - - +
+ + + Le Tribunal -
+
{menuOpen && ( -
- setMenuOpen(false)} - className="block px-4 py-2 text-sm hover:bg-gold/10" - > - Mon profil - - {isJudge && ( - setMenuOpen(false)} - className="block px-4 py-2 text-sm hover:bg-gold/10" - > - Le Conseil des Archontes - - )} +
+ {entries.map((entry) => ( + setMenuOpen(false)} + /> + ))} +
@@ -127,11 +155,6 @@ export function Header({
- -
); diff --git a/src/components/icons.tsx b/src/components/icons.tsx new file mode 100644 index 0000000..62718ca --- /dev/null +++ b/src/components/icons.tsx @@ -0,0 +1,91 @@ +type IconProps = { className?: string }; + +const base = "h-5 w-5"; + +export function IconWheel({ className = base }: IconProps) { + return ( + + + + + + ); +} + +export function IconScroll({ className = base }: IconProps) { + return ( + + + + + + ); +} + +export function IconPerson({ className = base }: IconProps) { + return ( + + + + + ); +} + +export function IconColumn({ className = base }: IconProps) { + return ( + + + + + ); +} + +export function IconLogout({ className = base }: IconProps) { + return ( + + + + + ); +} + +export function IconCamera({ className = base }: IconProps) { + return ( + + + + + ); +} + +export function IconCheck({ className = base }: IconProps) { + return ( + + + + ); +} + +export function IconCross({ className = base }: IconProps) { + return ( + + + + ); +} + +export function IconClose({ className = base }: IconProps) { + return ( + + + + ); +} + +export function IconChevronDown({ className = base }: IconProps) { + return ( + + + + ); +} diff --git a/src/components/judge-point-controls.tsx b/src/components/judge-point-controls.tsx index a0a78a6..9c475e0 100644 --- a/src/components/judge-point-controls.tsx +++ b/src/components/judge-point-controls.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { createClient } from "@/lib/supabase/client"; +import { IconCheck, IconCross } from "@/components/icons"; export function JudgePointControls({ memberId, @@ -54,8 +55,8 @@ export function JudgePointControls({ const buttonWidth = compact ? "w-6" : "w-7"; const inputWidth = compact ? "w-9" : "w-12"; - return ( -
+ const amountRow = ( +
+
+ ); - {!compact && pending !== 0 && ( + if (compact) { + return ( +
+ {amountRow} + {pending !== 0 && ( + <> + 0 ? "text-olive" : "text-oxblood"}`} + > + {pending > 0 ? `+${pending}` : pending} + +
+ + +
+ + )} + {error && {error}} +
+ ); + } + + return ( +
+ {amountRow} + + {pending !== 0 && ( = { - 1: { pedestal: "h-28 sm:h-36", order: "order-2", ring: "ring-gold", bg: "bg-gold/20" }, - 2: { pedestal: "h-20 sm:h-28", order: "order-1", ring: "ring-ink-2/30", bg: "bg-ink-2/10" }, - 3: { pedestal: "h-14 sm:h-20", order: "order-3", ring: "ring-oxblood/25", bg: "bg-oxblood/10" }, + 1: { pedestal: "h-28 sm:h-40", order: "order-2", ring: "ring-gold", delay: "300ms" }, + 2: { pedestal: "h-20 sm:h-28", order: "order-1", ring: "ring-silver", delay: "150ms" }, + 3: { pedestal: "h-14 sm:h-20", order: "order-3", ring: "ring-bronze", delay: "0ms" }, }; export function Podium({ @@ -37,28 +37,49 @@ export function Podium({ } return ( -
+
{topRanks.map((rank) => { const members = byRank.get(rank)!; const style = SLOT_STYLES[rank] ?? SLOT_STYLES[3]; + const isFirst = rank === 1; + return (
{members.map((member) => ( -
- {rank === 1 && } - +
+
+ {isFirst && ( + + )} +
+ +
+ +
+ {member.pseudo} - - {member.points} gloires + + {member.points} + gloires {isJudge && onApplyDelta && (
- {rank} + + {toRoman(rank)} +
); diff --git a/src/lib/ranking.ts b/src/lib/ranking.ts index 0369104..5683d25 100644 --- a/src/lib/ranking.ts +++ b/src/lib/ranking.ts @@ -16,6 +16,26 @@ export function computeRanks(profiles: T[]): Ranke }); } +const ROMAN_DIGITS: [number, string][] = [ + [10, "X"], + [9, "IX"], + [5, "V"], + [4, "IV"], + [1, "I"], +]; + +export function toRoman(value: number): string { + let remaining = value; + let result = ""; + for (const [digit, symbol] of ROMAN_DIGITS) { + while (remaining >= digit) { + result += symbol; + remaining -= digit; + } + } + return result || "0"; +} + export type Progress = { direction: "up" | "down" | "same" | "none"; amount: number }; export function computeProgress(currentRank: number, previousRank: number | null): Progress { diff --git a/src/lib/supabase/middleware.ts b/src/lib/supabase/middleware.ts index e7f3b50..a7c5e24 100644 --- a/src/lib/supabase/middleware.ts +++ b/src/lib/supabase/middleware.ts @@ -1,7 +1,7 @@ import { createServerClient } from "@supabase/ssr"; import { NextResponse, type NextRequest } from "next/server"; -const PROTECTED_PATHS = ["/leaderboard", "/profile", "/admin", "/journal"]; +const PROTECTED_PATHS = ["/leaderboard", "/profile", "/admin", "/journal", "/roulette"]; const AUTH_PATHS = ["/login", "/signup"]; export async function updateSession(request: NextRequest) {