9f4e58019b
Remplace le thème générique (tokens navy/gold/ivory re-thématisables par édition) par une direction artistique fixe et codée en dur : tribunal égéen de nuit — marbre, or, mer sombre, sceaux de cire. Fondations (globals.css, layout.tsx) : - Polices next/font/google : Cinzel (titres, tracking large), Cormorant Garamond (citations italiques), Manrope (interface). - Nouveaux tokens couleur : ink/ink-2 (fond mer de nuit), marble/marble-2 (surfaces claires), gold/gold-bright (accents), oxblood (blâmes/erreurs), olive (honneurs/succès), sea, text-marble/text-mut (texte sur marbre — jamais de doré en texte courant, contraste insuffisant). - Classes utilitaires : .marble-surface (texture par dégradés, pas d'image de bruit), .meander-divider (frise à méandre grecque en SVG data-URI). Composants d'ornement (aucune image bitmap) : - components/laurel-wreath.tsx : couronne de laurier calculée par trigonométrie (deux arcs symétriques de feuilles), réutilisée en logo, favicon (icon.svg régénéré avec les mêmes coordonnées) et couronne du rang 1 du podium. - components/diamond-divider.tsx : séparateur ◆ avec filets dégradés. Header global (components/header.tsx, remplace nav-bar.tsx) : - Logo laurier + "LE TRIBUNAL" à gauche (cliquable vers le leaderboard). - Chip utilisateur à droite : avatar (bordure dorée), pseudo, badge de rôle (Archonte/Citoyen), points de gloire, menu déroulant (Mon profil, Le Conseil des Archontes si juge, Se déconnecter). - Onglets L'Agora / Le Crieur, frise à méandre en séparateur. - Responsive : nom du site et pseudo masqués sous le breakpoint sm, l'avatar reste toujours visible. Vocabulaire diégétique (toujours avec le terme fonctionnel à côté) : juges → Archontes, membres → Citoyens, leaderboard → L'Agora, journal → Le Crieur (décrets avec sceau de cire oxblood), points positifs/négatifs → Honneurs/Blâmes, points → gloires, section admin → Le Conseil des Archontes. Avatar : bordure dorée, couleur de fallback dérivée d'un hash du pseudo (au lieu d'une couleur fixe). Toutes les pages (login, signup, leaderboard/podium, journal, admin, profil) sont passées sur les nouveaux tokens/polices/vocabulaire. README.md et CLAUDE.md : section "Ré-thématisation" remplacée par "Direction artistique" documentant la palette, les polices et les ornements ; toute mention du système de re-thème par édition retirée. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
148 lines
4.4 KiB
TypeScript
148 lines
4.4 KiB
TypeScript
"use client";
|
|
|
|
import { useRouter } from "next/navigation";
|
|
import { useState, type FormEvent } from "react";
|
|
import { createClient } from "@/lib/supabase/client";
|
|
import { Avatar } from "@/components/avatar";
|
|
import { AvatarPicker } from "@/components/avatar-picker";
|
|
|
|
function mapError(message: string): string {
|
|
if (message.includes("duplicate key")) {
|
|
return "Ce pseudo est déjà pris.";
|
|
}
|
|
if (message.includes("pseudo is locked")) {
|
|
return "Ton pseudo est déjà figé, contacte un Archonte.";
|
|
}
|
|
return "Une erreur est survenue, réessaie.";
|
|
}
|
|
|
|
export function ProfileForm({
|
|
userId,
|
|
isJudge,
|
|
initialPseudo,
|
|
initialAvatarUrl,
|
|
initialPseudoLocked,
|
|
}: {
|
|
userId: string;
|
|
isJudge: boolean;
|
|
initialPseudo: string;
|
|
initialAvatarUrl: string | null;
|
|
initialPseudoLocked: boolean;
|
|
}) {
|
|
const router = useRouter();
|
|
const [pseudo, setPseudo] = useState(initialPseudo);
|
|
const [pseudoLocked, setPseudoLocked] = useState(initialPseudoLocked);
|
|
const [avatarUrl, setAvatarUrl] = useState(initialAvatarUrl);
|
|
const [avatarBlob, setAvatarBlob] = useState<Blob | null>(null);
|
|
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [success, setSuccess] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const pseudoChanged = pseudo.trim() !== initialPseudo;
|
|
const canEditPseudo = !pseudoLocked || isJudge;
|
|
|
|
function handleCropped(blob: Blob) {
|
|
setAvatarBlob(blob);
|
|
setAvatarPreview(URL.createObjectURL(blob));
|
|
}
|
|
|
|
async function handleSubmit(event: FormEvent) {
|
|
event.preventDefault();
|
|
setError(null);
|
|
setSuccess(false);
|
|
|
|
const trimmedPseudo = pseudo.trim();
|
|
if (!trimmedPseudo) {
|
|
setError("Choisis un pseudo valide.");
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
const supabase = createClient();
|
|
|
|
let nextAvatarUrl = avatarUrl;
|
|
|
|
if (avatarBlob) {
|
|
const path = `${userId}.webp`;
|
|
const { error: uploadError } = await supabase.storage
|
|
.from("avatars")
|
|
.upload(path, avatarBlob, { upsert: true, contentType: "image/webp" });
|
|
|
|
if (uploadError) {
|
|
setLoading(false);
|
|
setError("Impossible d'envoyer la photo, réessaie.");
|
|
return;
|
|
}
|
|
|
|
const { data: publicUrlData } = supabase.storage.from("avatars").getPublicUrl(path);
|
|
nextAvatarUrl = `${publicUrlData.publicUrl}?t=${Date.now()}`;
|
|
}
|
|
|
|
const { error: updateError } = await supabase
|
|
.from("profiles")
|
|
.update({ pseudo: trimmedPseudo, avatar_url: nextAvatarUrl })
|
|
.eq("id", userId);
|
|
|
|
setLoading(false);
|
|
|
|
if (updateError) {
|
|
setError(mapError(updateError.message));
|
|
return;
|
|
}
|
|
|
|
setAvatarUrl(nextAvatarUrl);
|
|
setAvatarBlob(null);
|
|
setAvatarPreview(null);
|
|
setSuccess(true);
|
|
if (pseudoChanged && !isJudge) {
|
|
setPseudoLocked(true);
|
|
}
|
|
router.refresh();
|
|
}
|
|
|
|
return (
|
|
<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} />
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<label htmlFor="pseudo" className="text-sm font-medium text-text-marble">
|
|
Pseudo
|
|
</label>
|
|
<input
|
|
id="pseudo"
|
|
type="text"
|
|
required
|
|
disabled={!canEditPseudo}
|
|
value={pseudo}
|
|
onChange={(event) => setPseudo(event.target.value)}
|
|
className="rounded-md border border-gold/30 bg-white/50 px-3 py-2 text-text-marble outline-none focus:border-gold disabled:bg-gold/5 disabled:text-text-mut"
|
|
/>
|
|
{!canEditPseudo && (
|
|
<p className="text-xs text-text-mut">
|
|
Pseudo figé — contacte un Archonte pour le changer.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{error && (
|
|
<p role="alert" className="text-sm text-oxblood">
|
|
{error}
|
|
</p>
|
|
)}
|
|
{success && <p className="text-sm text-olive">Profil mis à jour.</p>}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="mt-2 rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink disabled:opacity-50"
|
|
>
|
|
{loading ? "Enregistrement…" : "Enregistrer"}
|
|
</button>
|
|
</form>
|
|
);
|
|
}
|