Scaffold V1: auth, profils et leaderboard
Next.js 16 + Tailwind + Supabase (Auth, Postgres, Storage). Connexion par pseudo/mot de passe via email interne dérivé, RLS avec points protégés au niveau colonne, pages login/signup/leaderboard/profile. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { slugify } from "@/lib/auth";
|
||||
import { Avatar } from "@/components/avatar";
|
||||
|
||||
function mapError(message: string): string {
|
||||
if (message.includes("duplicate key")) {
|
||||
return "Ce pseudo est déjà pris.";
|
||||
}
|
||||
return "Une erreur est survenue, réessaie.";
|
||||
}
|
||||
|
||||
export function ProfileForm({
|
||||
userId,
|
||||
initialPseudo,
|
||||
initialAvatarUrl,
|
||||
}: {
|
||||
userId: string;
|
||||
initialPseudo: string;
|
||||
initialAvatarUrl: string | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pseudo, setPseudo] = useState(initialPseudo);
|
||||
const [avatarUrl, setAvatarUrl] = useState(initialAvatarUrl);
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const previewUrl = avatarFile ? URL.createObjectURL(avatarFile) : avatarUrl;
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
const slug = slugify(pseudo);
|
||||
if (!slug) {
|
||||
setError("Choisis un pseudo valide.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const supabase = createClient();
|
||||
|
||||
let nextAvatarUrl = avatarUrl;
|
||||
|
||||
if (avatarFile) {
|
||||
const ext = avatarFile.name.split(".").pop() ?? "jpg";
|
||||
const path = `${userId}.${ext}`;
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from("avatars")
|
||||
.upload(path, avatarFile, { upsert: true });
|
||||
|
||||
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, slug, avatar_url: nextAvatarUrl })
|
||||
.eq("id", userId);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (updateError) {
|
||||
setError(mapError(updateError.message));
|
||||
return;
|
||||
}
|
||||
|
||||
setAvatarUrl(nextAvatarUrl);
|
||||
setAvatarFile(null);
|
||||
setSuccess(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={previewUrl ?? null} size="lg" />
|
||||
<label className="text-sm">
|
||||
<span className="sr-only">Changer la photo</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(event) => setAvatarFile(event.target.files?.[0] ?? null)}
|
||||
className="text-sm file:mr-3 file:rounded-md file:border-0 file:bg-navy file:px-3 file:py-1.5 file:text-ivory"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="pseudo" className="text-sm font-medium">
|
||||
Pseudo
|
||||
</label>
|
||||
<input
|
||||
id="pseudo"
|
||||
type="text"
|
||||
required
|
||||
value={pseudo}
|
||||
onChange={(event) => setPseudo(event.target.value)}
|
||||
className="rounded-md border border-navy/20 bg-white px-3 py-2 text-ink outline-none focus:border-gold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{success && <p className="text-sm text-green-700">Profil mis à jour.</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="mt-2 rounded-md bg-navy px-4 py-2 font-medium text-ivory transition hover:bg-navy/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Enregistrement…" : "Enregistrer"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user