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:
@@ -10,6 +10,8 @@
|
|||||||
--oxblood: #a5342a;
|
--oxblood: #a5342a;
|
||||||
--sea: #2e6e7e;
|
--sea: #2e6e7e;
|
||||||
--olive: #5e6b3b;
|
--olive: #5e6b3b;
|
||||||
|
--silver: #c7cdd6;
|
||||||
|
--bronze: #b08d57;
|
||||||
--text-marble: #2a2116;
|
--text-marble: #2a2116;
|
||||||
--text-mut: #7a6a4c;
|
--text-mut: #7a6a4c;
|
||||||
|
|
||||||
@@ -27,6 +29,8 @@
|
|||||||
--color-oxblood: var(--oxblood);
|
--color-oxblood: var(--oxblood);
|
||||||
--color-sea: var(--sea);
|
--color-sea: var(--sea);
|
||||||
--color-olive: var(--olive);
|
--color-olive: var(--olive);
|
||||||
|
--color-silver: var(--silver);
|
||||||
|
--color-bronze: var(--bronze);
|
||||||
--color-text-marble: var(--text-marble);
|
--color-text-marble: var(--text-marble);
|
||||||
--color-text-mut: var(--text-mut);
|
--color-text-mut: var(--text-mut);
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
@@ -83,3 +87,81 @@ body {
|
|||||||
.animate-points-flash {
|
.animate-points-flash {
|
||||||
animation: points-flash 900ms ease-out;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -105,7 +105,14 @@ export function ProfileForm({
|
|||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<Avatar pseudo={pseudo || "?"} avatarUrl={avatarPreview ?? avatarUrl} size="lg" />
|
<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>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|||||||
@@ -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'élu.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<RouletteView members={members ?? []} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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 été 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -189,7 +189,13 @@ export default function SignupPage() {
|
|||||||
Photo de profil (optionnel)
|
Photo de profil (optionnel)
|
||||||
</span>
|
</span>
|
||||||
<Avatar pseudo={pseudo || "?"} avatarUrl={avatarPreview} size="lg" />
|
<Avatar pseudo={pseudo || "?"} avatarUrl={avatarPreview} size="lg" />
|
||||||
<AvatarPicker label="Photo de profil" onCropped={handleCropped} />
|
<AvatarPicker
|
||||||
|
onCropped={handleCropped}
|
||||||
|
onClear={() => {
|
||||||
|
setAvatarBlob(null);
|
||||||
|
setAvatarPreview(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import Cropper, { type Area } from "react-easy-crop";
|
import Cropper, { type Area } from "react-easy-crop";
|
||||||
import { cropImageToBlob } from "@/lib/image";
|
import { cropImageToBlob } from "@/lib/image";
|
||||||
|
import { IconCamera, IconClose } from "@/components/icons";
|
||||||
|
|
||||||
export function AvatarPicker({
|
export function AvatarPicker({
|
||||||
label,
|
hasExistingPhoto = false,
|
||||||
onCropped,
|
onCropped,
|
||||||
|
onClear,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
hasExistingPhoto?: boolean;
|
||||||
onCropped: (blob: Blob) => void;
|
onCropped: (blob: Blob) => void;
|
||||||
|
onClear?: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [rawImageSrc, setRawImageSrc] = useState<string | null>(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 [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||||
@@ -25,6 +31,7 @@ export function AvatarPicker({
|
|||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
setRawImageSrc(reader.result as string);
|
setRawImageSrc(reader.result as string);
|
||||||
|
setPendingFileName(file.name);
|
||||||
setCrop({ x: 0, y: 0 });
|
setCrop({ x: 0, y: 0 });
|
||||||
setZoom(1);
|
setZoom(1);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -37,12 +44,23 @@ export function AvatarPicker({
|
|||||||
try {
|
try {
|
||||||
const blob = await cropImageToBlob(rawImageSrc, croppedAreaPixels);
|
const blob = await cropImageToBlob(rawImageSrc, croppedAreaPixels);
|
||||||
onCropped(blob);
|
onCropped(blob);
|
||||||
|
setFileName(pendingFileName);
|
||||||
setRawImageSrc(null);
|
setRawImageSrc(null);
|
||||||
|
setPendingFileName(null);
|
||||||
} catch {
|
} catch {
|
||||||
setError("Impossible de traiter cette image, réessaie.");
|
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) {
|
if (rawImageSrc) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
@@ -72,7 +90,10 @@ export function AvatarPicker({
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="rounded-md border border-gold/40 px-3 py-1.5 text-sm text-text-marble hover:bg-gold/10"
|
||||||
>
|
>
|
||||||
Annuler
|
Annuler
|
||||||
@@ -91,15 +112,41 @@ export function AvatarPicker({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label className="text-sm">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<span className="sr-only">{label}</span>
|
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
onChange={handleFileChange}
|
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>}
|
{error && <p className="text-xs text-oxblood">{error}</p>}
|
||||||
</label>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ function fallbackColor(pseudo: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SIZE_CLASSES = {
|
const SIZE_CLASSES = {
|
||||||
|
xs: "h-7 w-7 text-[0.6rem]",
|
||||||
sm: "h-8 w-8 text-xs",
|
sm: "h-8 w-8 text-xs",
|
||||||
md: "h-12 w-12 text-sm",
|
md: "h-12 w-12 text-sm",
|
||||||
lg: "h-24 w-24 text-2xl",
|
lg: "h-24 w-24 text-2xl",
|
||||||
|
|||||||
+79
-56
@@ -6,19 +6,40 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { createClient } from "@/lib/supabase/client";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
import { LaurelWreath } from "@/components/laurel-wreath";
|
import { LaurelWreath } from "@/components/laurel-wreath";
|
||||||
import { Avatar } from "@/components/avatar";
|
import { Avatar } from "@/components/avatar";
|
||||||
|
import {
|
||||||
|
IconWheel,
|
||||||
|
IconScroll,
|
||||||
|
IconPerson,
|
||||||
|
IconColumn,
|
||||||
|
IconLogout,
|
||||||
|
IconChevronDown,
|
||||||
|
} from "@/components/icons";
|
||||||
|
|
||||||
function NavTab({ href, children }: { href: string; children: React.ReactNode }) {
|
type MenuEntry = {
|
||||||
const pathname = usePathname();
|
href: string;
|
||||||
const isActive = pathname.startsWith(href);
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function MenuLink({
|
||||||
|
entry,
|
||||||
|
isActive,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
entry: MenuEntry;
|
||||||
|
isActive: boolean;
|
||||||
|
onNavigate: () => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={entry.href}
|
||||||
className={`rounded-md px-3 py-1.5 font-heading text-xs tracking-wide uppercase transition-colors ${
|
onClick={onNavigate}
|
||||||
isActive ? "bg-gold/15 text-gold-bright" : "text-marble/80 hover:text-gold-bright"
|
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>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -35,6 +56,7 @@ export function Header({
|
|||||||
points: number;
|
points: number;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [signingOut, setSigningOut] = useState(false);
|
const [signingOut, setSigningOut] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -45,8 +67,15 @@ export function Header({
|
|||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") setMenuOpen(false);
|
||||||
|
}
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
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() {
|
async function handleSignOut() {
|
||||||
@@ -57,69 +86,68 @@ export function Header({
|
|||||||
router.refresh();
|
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 (
|
return (
|
||||||
<header className="sticky top-0 z-10 bg-ink-2/95 backdrop-blur">
|
<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">
|
<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 items-center gap-2">
|
<Link href="/leaderboard" className="flex min-w-0 items-center gap-1.5 sm:gap-2">
|
||||||
<LaurelWreath className="h-7 w-7 shrink-0" color="#E7C560" />
|
<LaurelWreath className="h-5 w-5 shrink-0 sm:h-7 sm:w-7" color="#E7C560" />
|
||||||
<span className="hidden font-heading text-sm tracking-[0.15em] text-gold-bright uppercase sm:inline">
|
<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
|
Le Tribunal
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div ref={menuRef} className="relative">
|
<div ref={menuRef} className="relative shrink-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMenuOpen((open) => !open)}
|
onClick={() => setMenuOpen((open) => !open)}
|
||||||
aria-expanded={menuOpen}
|
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" />
|
<Avatar pseudo={pseudo} avatarUrl={avatarUrl} size="xs" />
|
||||||
<span className="hidden flex-col items-start leading-tight sm:flex">
|
<span className="max-w-[4.5rem] truncate text-xs font-medium text-marble sm:max-w-[9rem] sm:text-sm">
|
||||||
<span className="text-sm font-medium text-marble">{pseudo}</span>
|
{pseudo}
|
||||||
<span
|
|
||||||
className={`font-heading text-[0.65rem] tracking-wide uppercase ${
|
|
||||||
isJudge ? "text-gold-bright" : "text-text-mut"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isJudge ? "Archonte" : "Citoyen"} · {points} gloires
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
<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">
|
||||||
viewBox="0 0 24 24"
|
{points}
|
||||||
className={`h-4 w-4 text-marble/60 transition-transform ${menuOpen ? "rotate-180" : ""}`}
|
</span>
|
||||||
fill="none"
|
<IconChevronDown
|
||||||
stroke="currentColor"
|
className={`h-3.5 w-3.5 shrink-0 text-marble/60 transition-transform sm:h-4 sm:w-4 ${menuOpen ? "rotate-180" : ""}`}
|
||||||
strokeWidth={2}
|
/>
|
||||||
>
|
|
||||||
<path d="M6 9l6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div className="marble-surface absolute right-0 z-20 mt-2 w-52 rounded-lg border border-gold/40 py-1 shadow-lg">
|
<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">
|
||||||
<Link
|
{entries.map((entry) => (
|
||||||
href="/profile"
|
<MenuLink
|
||||||
onClick={() => setMenuOpen(false)}
|
key={entry.href}
|
||||||
className="block px-4 py-2 text-sm hover:bg-gold/10"
|
entry={entry}
|
||||||
>
|
isActive={pathname.startsWith(entry.href)}
|
||||||
Mon profil
|
onNavigate={() => setMenuOpen(false)}
|
||||||
</Link>
|
/>
|
||||||
{isJudge && (
|
))}
|
||||||
<Link
|
<div className="my-1 border-t border-gold/20" />
|
||||||
href="/admin"
|
|
||||||
onClick={() => setMenuOpen(false)}
|
|
||||||
className="block px-4 py-2 text-sm hover:bg-gold/10"
|
|
||||||
>
|
|
||||||
Le Conseil des Archontes
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSignOut}
|
onClick={handleSignOut}
|
||||||
disabled={signingOut}
|
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"}
|
{signingOut ? "…" : "Se déconnecter"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,11 +155,6 @@ export function Header({
|
|||||||
</div>
|
</div>
|
||||||
</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" />
|
<div className="meander-divider" />
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import { IconCheck, IconCross } from "@/components/icons";
|
||||||
|
|
||||||
export function JudgePointControls({
|
export function JudgePointControls({
|
||||||
memberId,
|
memberId,
|
||||||
@@ -54,8 +55,8 @@ export function JudgePointControls({
|
|||||||
const buttonWidth = compact ? "w-6" : "w-7";
|
const buttonWidth = compact ? "w-6" : "w-7";
|
||||||
const inputWidth = compact ? "w-9" : "w-12";
|
const inputWidth = compact ? "w-9" : "w-12";
|
||||||
|
|
||||||
return (
|
const amountRow = (
|
||||||
<div className="flex flex-wrap items-center gap-1 sm:gap-1.5">
|
<div className="flex flex-wrap items-center justify-center gap-1 sm:gap-1.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPending((p) => p - parsedAmount)}
|
onClick={() => setPending((p) => p - parsedAmount)}
|
||||||
@@ -81,8 +82,54 @@ export function JudgePointControls({
|
|||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={reason}
|
value={reason}
|
||||||
|
|||||||
+40
-17
@@ -1,17 +1,17 @@
|
|||||||
import { Avatar } from "@/components/avatar";
|
import { Avatar } from "@/components/avatar";
|
||||||
import { JudgePointControls } from "@/components/judge-point-controls";
|
import { JudgePointControls } from "@/components/judge-point-controls";
|
||||||
import { LaurelWreath } from "@/components/laurel-wreath";
|
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 };
|
type PodiumProfile = { id: string; pseudo: string; avatar_url: string | null; points: number };
|
||||||
|
|
||||||
const SLOT_STYLES: Record<
|
const SLOT_STYLES: Record<
|
||||||
number,
|
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" },
|
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-ink-2/30", bg: "bg-ink-2/10" },
|
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-oxblood/25", bg: "bg-oxblood/10" },
|
3: { pedestal: "h-14 sm:h-20", order: "order-3", ring: "ring-bronze", delay: "0ms" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Podium({
|
export function Podium({
|
||||||
@@ -37,28 +37,49 @@ export function Podium({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex min-w-fit items-end justify-center gap-2 sm:gap-8">
|
||||||
{topRanks.map((rank) => {
|
{topRanks.map((rank) => {
|
||||||
const members = byRank.get(rank)!;
|
const members = byRank.get(rank)!;
|
||||||
const style = SLOT_STYLES[rank] ?? SLOT_STYLES[3];
|
const style = SLOT_STYLES[rank] ?? SLOT_STYLES[3];
|
||||||
|
const isFirst = rank === 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={rank} className={`flex flex-col items-center gap-3 ${style.order}`}>
|
<div key={rank} className={`flex flex-col items-center gap-3 ${style.order}`}>
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
{members.map((member) => (
|
{members.map((member) => (
|
||||||
<div key={member.id} className="flex flex-col items-center gap-1">
|
<div
|
||||||
{rank === 1 && <LaurelWreath className="h-6 w-6" color="#C9A227" />}
|
key={member.id}
|
||||||
<Avatar
|
className="animate-podium-rise flex flex-col items-center gap-1"
|
||||||
pseudo={member.pseudo}
|
style={{ animationDelay: style.delay }}
|
||||||
avatarUrl={member.avatar_url}
|
>
|
||||||
size={rank === 1 ? "lg" : "md"}
|
<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]">
|
<span className="max-w-[6rem] truncate font-heading text-sm text-text-marble sm:max-w-[7rem]">
|
||||||
{member.pseudo}
|
{member.pseudo}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-medium text-text-mut">
|
<span
|
||||||
{member.points} gloires
|
className={`font-heading font-bold text-gold-bright ${
|
||||||
|
isFirst ? "text-xl sm:text-2xl" : "text-base sm:text-lg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{member.points}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="-mt-1 text-[0.65rem] text-text-mut">gloires</span>
|
||||||
{isJudge && onApplyDelta && (
|
{isJudge && onApplyDelta && (
|
||||||
<JudgePointControls
|
<JudgePointControls
|
||||||
memberId={member.id}
|
memberId={member.id}
|
||||||
@@ -70,9 +91,11 @@ export function Podium({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 type Progress = { direction: "up" | "down" | "same" | "none"; amount: number };
|
||||||
|
|
||||||
export function computeProgress(currentRank: number, previousRank: number | null): Progress {
|
export function computeProgress(currentRank: number, previousRank: number | null): Progress {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createServerClient } from "@supabase/ssr";
|
import { createServerClient } from "@supabase/ssr";
|
||||||
import { NextResponse, type NextRequest } from "next/server";
|
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"];
|
const AUTH_PATHS = ["/login", "/signup"];
|
||||||
|
|
||||||
export async function updateSession(request: NextRequest) {
|
export async function updateSession(request: NextRequest) {
|
||||||
|
|||||||
Reference in New Issue
Block a user