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
+82
View File
@@ -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;
}
}
+8 -1
View File
@@ -105,7 +105,14 @@ export function ProfileForm({
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col items-center gap-3">
<Avatar pseudo={pseudo || "?"} avatarUrl={avatarPreview ?? avatarUrl} size="lg" />
<AvatarPicker label="Changer la photo" onCropped={handleCropped} />
<AvatarPicker
hasExistingPhoto={Boolean(avatarUrl)}
onCropped={handleCropped}
onClear={() => {
setAvatarBlob(null);
setAvatarPreview(null);
}}
/>
</div>
<div className="flex flex-col gap-1">
+33
View File
@@ -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 (
<div className="mx-auto w-full max-w-2xl px-4 py-8">
<div className="mb-6 text-center">
<h1 className="font-heading text-2xl tracking-[0.1em] text-gold-bright uppercase">
La Roulette
</h1>
<p className="font-serif text-sm text-marble/60 italic">
Que le sort désigne l&apos;élu.
</p>
</div>
<RouletteView members={members ?? []} />
</div>
);
}
+162
View File
@@ -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<Member[]>([]);
const [offset, setOffset] = useState(0);
const [transitionOn, setTransitionOn] = useState(false);
const [winner, setWinner] = useState<Member | null>(null);
const [lastWinner, setLastWinner] = useState<Member | null>(null);
const containerRef = useRef<HTMLDivElement>(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 <p className="text-center text-sm text-marble/60">Personne n&apos;est encore inscrit.</p>;
}
return (
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-xl sm:p-8">
<label className="mb-4 flex items-center justify-center gap-2 text-sm text-text-marble">
<input
type="checkbox"
checked={citizensOnly}
onChange={(event) => setCitizensOnly(event.target.checked)}
disabled={spinning}
className="h-4 w-4 accent-gold"
/>
Tirer uniquement parmi les Citoyens (exclure les Archontes)
</label>
<div
ref={containerRef}
className="relative mx-auto mb-6 h-32 max-w-full overflow-hidden rounded-lg bg-ink-2/5"
>
<div
className="absolute top-0 bottom-0 left-1/2 z-10 w-0.5 -translate-x-1/2 bg-gold-bright"
aria-hidden
/>
{strip.length > 0 && (
<div
className="flex h-full items-center"
style={{
transform: `translateX(${offset}px)`,
transition: transitionOn ? `transform ${SPIN_MS}ms cubic-bezier(0.11, 0.7, 0.14, 1)` : "none",
}}
onTransitionEnd={handleTransitionEnd}
>
{strip.map((member, index) => (
<div
key={`${member.id}-${index}`}
className="flex shrink-0 flex-col items-center gap-1"
style={{ width: ITEM_WIDTH }}
>
<Avatar pseudo={member.pseudo} avatarUrl={member.avatar_url} size="md" />
<span className="max-w-[5rem] truncate text-xs text-text-marble">
{member.pseudo}
</span>
</div>
))}
</div>
)}
</div>
<div className="flex justify-center">
<button
type="button"
onClick={handleSpin}
disabled={spinning || pool.length === 0}
className="rounded-md bg-ink-2 px-6 py-2.5 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink disabled:opacity-50"
>
{spinning ? "…" : winner ? "Rejouer" : "Tirer au sort"}
</button>
</div>
<div aria-live="polite" className="mt-8 flex flex-col items-center gap-2">
{winner && (
<div className="animate-reveal flex flex-col items-center gap-2 rounded-2xl px-8 py-6">
<LaurelWreath className="h-10 w-10" color="#C9A227" />
<div className="ring-4 ring-gold rounded-full p-1">
<Avatar pseudo={winner.pseudo} avatarUrl={winner.avatar_url} size="lg" />
</div>
<span className="font-heading text-2xl tracking-wide text-text-marble uppercase">
{winner.pseudo}
</span>
<span className="font-serif text-sm text-text-mut italic">a é désigné(e) par le sort</span>
</div>
)}
</div>
{lastWinner && !winner && (
<p className="mt-4 text-center text-xs text-text-mut">
Dernier élu : <span className="font-medium text-text-marble">{lastWinner.pseudo}</span>
</p>
)}
</div>
);
}
+7 -1
View File
@@ -189,7 +189,13 @@ export default function SignupPage() {
Photo de profil (optionnel)
</span>
<Avatar pseudo={pseudo || "?"} avatarUrl={avatarPreview} size="lg" />
<AvatarPicker label="Photo de profil" onCropped={handleCropped} />
<AvatarPicker
onCropped={handleCropped}
onClear={() => {
setAvatarBlob(null);
setAvatarPreview(null);
}}
/>
</div>
{error && (
+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>
);
}
+1
View File
@@ -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",
+79 -56
View File
@@ -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&apos;Agora</NavTab>
<NavTab href="/journal">Le Crieur</NavTab>
</nav>
<div className="meander-divider" />
</header>
);
+91
View File
@@ -0,0 +1,91 @@
type IconProps = { className?: string };
const base = "h-5 w-5";
export function IconWheel({ className = base }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
<circle cx="12" cy="12" r="8.5" />
<circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none" />
<path d="M12 3.5V9M12 15v5.5M20.5 12H15M9 12H3.5M18 6l-3.8 3.8M9.8 14.2 6 18M18 18l-3.8-3.8M9.8 9.8 6 6" strokeLinecap="round" />
</svg>
);
}
export function IconScroll({ className = base }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
<path d="M6 4.5a2 2 0 1 0 0 4h11a2 2 0 1 1 0 4H8" strokeLinecap="round" />
<path d="M6 4.5v13a2 2 0 0 0 2 2h1M17 8.5V17a2 2 0 0 1-2 2H8" strokeLinecap="round" />
<circle cx="6" cy="4.5" r="0" />
</svg>
);
}
export function IconPerson({ className = base }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
<circle cx="12" cy="8" r="3.5" />
<path d="M4.5 20c1.4-3.8 4.4-5.8 7.5-5.8s6.1 2 7.5 5.8" strokeLinecap="round" />
</svg>
);
}
export function IconColumn({ className = base }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
<path d="M4 4h16M4 20h16" strokeLinecap="round" />
<path d="M7 6.5v11.5M11 6.5v11.5M13 6.5v11.5M17 6.5v11.5" strokeLinecap="round" />
</svg>
);
}
export function IconLogout({ className = base }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
<path d="M9 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h3" strokeLinecap="round" strokeLinejoin="round" />
<path d="M16 8l4 4-4 4M20 12H9" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function IconCamera({ className = base }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
<path d="M4 8.5a2 2 0 0 1 2-2h1.2l1-1.6h7.6l1 1.6H18a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8.5Z" strokeLinejoin="round" />
<circle cx="12" cy="12.5" r="3.2" />
</svg>
);
}
export function IconCheck({ className = base }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={2.4} aria-hidden>
<path d="M5 12.5l4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function IconCross({ className = base }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={2.4} aria-hidden>
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
</svg>
);
}
export function IconClose({ className = base }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={2} aria-hidden>
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
</svg>
);
}
export function IconChevronDown({ className = base }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={2} aria-hidden>
<path d="M6 9l6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
+50 -3
View File
@@ -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 (
<div className="flex flex-wrap items-center gap-1 sm:gap-1.5">
const amountRow = (
<div className="flex flex-wrap items-center justify-center gap-1 sm:gap-1.5">
<button
type="button"
onClick={() => setPending((p) => p - parsedAmount)}
@@ -81,8 +82,54 @@ export function JudgePointControls({
>
+
</button>
</div>
);
{!compact && pending !== 0 && (
if (compact) {
return (
<div className="flex flex-col items-center gap-1.5">
{amountRow}
{pending !== 0 && (
<>
<span
className={`text-sm font-semibold ${pending > 0 ? "text-olive" : "text-oxblood"}`}
>
{pending > 0 ? `+${pending}` : pending}
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleConfirm}
disabled={submitting}
aria-label="Valider les points"
title="Valider les points"
className="flex h-10 w-10 items-center justify-center rounded-full border border-olive/50 bg-olive/10 text-olive transition hover:bg-olive/20 disabled:opacity-50"
>
<IconCheck className="h-5 w-5" />
</button>
<button
type="button"
onClick={handleCancel}
disabled={submitting}
aria-label="Annuler"
title="Annuler"
className="flex h-10 w-10 items-center justify-center rounded-full border border-oxblood/50 bg-oxblood/10 text-oxblood transition hover:bg-oxblood/20 disabled:opacity-50"
>
<IconCross className="h-5 w-5" />
</button>
</div>
</>
)}
{error && <span className="text-xs text-oxblood">{error}</span>}
</div>
);
}
return (
<div className="flex flex-wrap items-center gap-1 sm:gap-1.5">
{amountRow}
{pending !== 0 && (
<input
type="text"
value={reason}
+40 -17
View File
@@ -1,17 +1,17 @@
import { Avatar } from "@/components/avatar";
import { JudgePointControls } from "@/components/judge-point-controls";
import { LaurelWreath } from "@/components/laurel-wreath";
import type { RankedProfile } from "@/lib/ranking";
import { toRoman, type RankedProfile } from "@/lib/ranking";
type PodiumProfile = { id: string; pseudo: string; avatar_url: string | null; points: number };
const SLOT_STYLES: Record<
number,
{ pedestal: string; order: string; ring: string; bg: string }
{ pedestal: string; order: string; ring: string; delay: string }
> = {
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 (
<div className="marble-surface mb-8 overflow-x-auto rounded-2xl border border-gold/40 px-2 py-6 shadow-xl sm:px-8">
<div className="marble-surface mb-8 overflow-x-auto rounded-2xl border border-gold/40 px-2 py-8 shadow-xl sm:px-8">
<div className="flex min-w-fit items-end justify-center gap-2 sm:gap-8">
{topRanks.map((rank) => {
const members = byRank.get(rank)!;
const style = SLOT_STYLES[rank] ?? SLOT_STYLES[3];
const isFirst = rank === 1;
return (
<div key={rank} className={`flex flex-col items-center gap-3 ${style.order}`}>
<div className="flex flex-col items-center gap-3">
{members.map((member) => (
<div key={member.id} className="flex flex-col items-center gap-1">
{rank === 1 && <LaurelWreath className="h-6 w-6" color="#C9A227" />}
<Avatar
pseudo={member.pseudo}
avatarUrl={member.avatar_url}
size={rank === 1 ? "lg" : "md"}
/>
<div
key={member.id}
className="animate-podium-rise flex flex-col items-center gap-1"
style={{ animationDelay: style.delay }}
>
<div className="h-6">
{isFirst && (
<LaurelWreath className="animate-wreath-pop h-6 w-6" color="#C9A227" />
)}
</div>
<div
className={`rounded-full ${isFirst ? "podium-glow ring-4 ring-gold p-0.5" : ""}`}
>
<Avatar
pseudo={member.pseudo}
avatarUrl={member.avatar_url}
size={isFirst ? "lg" : "md"}
/>
</div>
<span className="max-w-[6rem] truncate font-heading text-sm text-text-marble sm:max-w-[7rem]">
{member.pseudo}
</span>
<span className="text-xs font-medium text-text-mut">
{member.points} gloires
<span
className={`font-heading font-bold text-gold-bright ${
isFirst ? "text-xl sm:text-2xl" : "text-base sm:text-lg"
}`}
>
{member.points}
</span>
<span className="-mt-1 text-[0.65rem] text-text-mut">gloires</span>
{isJudge && onApplyDelta && (
<JudgePointControls
memberId={member.id}
@@ -70,9 +91,11 @@ export function Podium({
))}
</div>
<div
className={`flex w-16 sm:w-28 items-start justify-center rounded-t-md ${style.bg} pt-2 ring-1 sm:ring-2 ${style.pedestal} ${style.ring}`}
className={`pedestal-marble flex w-16 sm:w-32 items-start justify-center rounded-t-md pt-2 ring-1 sm:ring-2 ${style.pedestal} ${style.ring}`}
>
<span className="font-heading text-lg text-text-marble sm:text-2xl">{rank}</span>
<span className="engraved font-heading text-xl text-text-marble/70 sm:text-3xl">
{toRoman(rank)}
</span>
</div>
</div>
);
+20
View File
@@ -16,6 +16,26 @@ export function computeRanks<T extends { points: number }>(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 {
+1 -1
View File
@@ -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) {