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;
|
||||
--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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
</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 && (
|
||||
|
||||
Reference in New Issue
Block a user