feat: profile avatar, callback setUser fix, admin description/thumbnail, pagination limit=100
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 26s

This commit is contained in:
2026-03-15 02:45:50 +01:00
parent 61d8a5257d
commit 8e78ce50b5
5 changed files with 144 additions and 64 deletions

View File

@@ -9,6 +9,17 @@ export default function UserBadge({ user }: UserBadgeProps) {
return ( return (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
{user.avatar ? (
<img
src={user.avatar}
alt={user.nickname}
className="h-6 w-6 rounded-full object-cover border border-od-border"
/>
) : (
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-od-surface border border-od-border font-mono text-[10px] text-od-accent">
{user.nickname[0].toUpperCase()}
</span>
)}
<span className="font-mono text-xs text-od-accent">{user.nickname}</span> <span className="font-mono text-xs text-od-accent">{user.nickname}</span>
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded border border-od-border text-od-muted"> <span className="font-mono text-[10px] px-1.5 py-0.5 rounded border border-od-border text-od-muted">
{planLabel} {planLabel}

View File

@@ -5,6 +5,7 @@ export interface User {
id: string; id: string;
email: string | null; email: string | null;
nickname: string; nickname: string;
avatar?: string | null;
plan?: { slug: string; name: string; level: number } | null; plan?: { slug: string; name: string; level: number } | null;
subscriptionLevel?: number; subscriptionLevel?: number;
roles: string[]; roles: string[];

View File

@@ -97,7 +97,8 @@ function VideosTab() {
const [fetchError, setFetchError] = useState<string | null>(null); const [fetchError, setFetchError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const [form, setForm] = useState({ const [form, setForm] = useState({
title: '', storageType: 'youtube', storageKey: '', title: '', description: '', thumbnailUrl: '',
storageType: 'youtube', storageKey: '',
requiredLevel: 0, isPublished: false, requiredLevel: 0, isPublished: false,
}); });
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -106,7 +107,7 @@ function VideosTab() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
apiFetch<{ success: boolean; data: { videos: Video[] } }>('/admin/videos') apiFetch<{ success: boolean; data: { videos: Video[] } }>('/admin/videos?limit=100')
.then((r) => setVideos(r.data.videos)) .then((r) => setVideos(r.data.videos))
.catch(() => setFetchError('Impossible de charger les vidéos.')) .catch(() => setFetchError('Impossible de charger les vidéos.'))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@@ -144,7 +145,7 @@ function VideosTab() {
{ method: 'POST', body: JSON.stringify(form) } { method: 'POST', body: JSON.stringify(form) }
); );
setVideos((v) => [r.data.video, ...v]); setVideos((v) => [r.data.video, ...v]);
setForm({ title: '', storageType: 'youtube', storageKey: '', requiredLevel: 0, isPublished: false }); setForm({ title: '', description: '', thumbnailUrl: '', storageType: 'youtube', storageKey: '', requiredLevel: 0, isPublished: false });
} catch { } catch {
setError('Erreur lors de la création.'); setError('Erreur lors de la création.');
} }
@@ -189,6 +190,21 @@ function VideosTab() {
className="rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent" className="rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent"
/> />
<textarea
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
placeholder="Description (optionnel)"
rows={2}
className="rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent resize-none"
/>
<input
value={form.thumbnailUrl}
onChange={(e) => setForm((f) => ({ ...f, thumbnailUrl: e.target.value }))}
placeholder="URL thumbnail (optionnel)"
className="rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent"
/>
<div className="flex gap-2"> <div className="flex gap-2">
<select <select
value={form.storageType} value={form.storageType}
@@ -312,7 +328,7 @@ function UsersTab({ isSuperAdmin }: { isSuperAdmin: boolean }) {
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users'), apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users?limit=100'),
apiFetch<{ success: boolean; data: { plans: Plan[] } }>('/admin/plans'), apiFetch<{ success: boolean; data: { plans: Plan[] } }>('/admin/plans'),
]) ])
.then(([ur, pr]) => { .then(([ur, pr]) => {
@@ -333,7 +349,7 @@ function UsersTab({ isSuperAdmin }: { isSuperAdmin: boolean }) {
method: 'POST', method: 'POST',
body: JSON.stringify({ planId }), body: JSON.stringify({ planId }),
}); });
const r = await apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users'); const r = await apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users?limit=100');
setUsers(r.data.users); setUsers(r.data.users);
setSelectedPlan((s) => { const n = { ...s }; delete n[userId]; return n; }); setSelectedPlan((s) => { const n = { ...s }; delete n[userId]; return n; });
} catch { } catch {
@@ -352,7 +368,7 @@ function UsersTab({ isSuperAdmin }: { isSuperAdmin: boolean }) {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ roles: [role] }), body: JSON.stringify({ roles: [role] }),
}); });
const r = await apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users'); const r = await apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users?limit=100');
setUsers(r.data.users); setUsers(r.data.users);
setSelectedRole((s) => { const n = { ...s }; delete n[userId]; return n; }); setSelectedRole((s) => { const n = { ...s }; delete n[userId]; return n; });
} catch { } catch {

View File

@@ -1,29 +1,38 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { apiFetch } from '../lib/api'; import { apiFetch } from '../lib/api';
import { useAuthContext } from '../context/AuthContext';
import type { User } from '../context/AuthContext';
interface SessionResponse {
success: boolean;
data: { user: User };
}
export default function CallbackPage() { export default function CallbackPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { setUser } = useAuthContext();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const token = params.get('token'); const token = params.get('token');
// Pas de token dans l'URL → retour silencieux
if (!token) { if (!token) {
navigate('/', { replace: true }); navigate('/', { replace: true });
return; return;
} }
// Envoie le token au backend → backend valide + pose le cookie httpOnly apiFetch<SessionResponse>('/auth/session', {
apiFetch<void>('/auth/session', {
method: 'POST', method: 'POST',
body: JSON.stringify({ token }), body: JSON.stringify({ token }),
}) })
.then(() => navigate('/', { replace: true })) .then((res) => {
setUser(res.data.user);
navigate('/', { replace: true });
})
.catch(() => setError("Échec de l'authentification. Réessaie.")); .catch(() => setError("Échec de l'authentification. Réessaie."));
}, [navigate]); }, [navigate, setUser]);
if (error) { if (error) {
return ( return (

View File

@@ -11,41 +11,42 @@ interface MeResponse {
export default function ProfilePage() { export default function ProfilePage() {
const { user, setUser } = useAuthContext(); const { user, setUser } = useAuthContext();
const [editing, setEditing] = useState(false); const [editingNickname, setEditingNickname] = useState(false);
const [draft, setDraft] = useState(user?.nickname ?? ''); const [draftNickname, setDraftNickname] = useState(user?.nickname ?? '');
const [editingAvatar, setEditingAvatar] = useState(false);
const [draftAvatar, setDraftAvatar] = useState(user?.avatar ?? '');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
if (!user) return null; if (!user) return null;
function handleEdit() { function startEditNickname() {
setDraft(user!.nickname); setDraftNickname(user!.nickname);
setError(null); setError(null);
setEditing(true); setEditingNickname(true);
} }
function handleCancel() { function startEditAvatar() {
setEditing(false); setDraftAvatar(user!.avatar ?? '');
setError(null);
setEditingAvatar(true);
}
function cancel() {
setEditingNickname(false);
setEditingAvatar(false);
setError(null); setError(null);
} }
async function handleSave() { async function save(patch: { nickname?: string; avatar?: string | null }) {
const trimmed = draft.trim();
if (!trimmed || trimmed === user!.nickname) {
setEditing(false);
return;
}
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
await apiFetch('/users/me', { await apiFetch('/users/me', { method: 'PATCH', body: JSON.stringify(patch) });
method: 'PATCH',
body: JSON.stringify({ nickname: trimmed }),
});
const res = await apiFetch<MeResponse>('/auth/me'); const res = await apiFetch<MeResponse>('/auth/me');
setUser(res.data.user); setUser(res.data.user);
setEditing(false); setEditingNickname(false);
setEditingAvatar(false);
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? `Erreur ${e.status}` : 'Erreur réseau'); setError(e instanceof ApiError ? `Erreur ${e.status}` : 'Erreur réseau');
} finally { } finally {
@@ -53,6 +54,19 @@ export default function ProfilePage() {
} }
} }
async function handleSaveNickname() {
const trimmed = draftNickname.trim();
if (!trimmed || trimmed === user!.nickname) { cancel(); return; }
await save({ nickname: trimmed });
}
async function handleSaveAvatar() {
const trimmed = draftAvatar.trim();
const val = trimmed === '' ? null : trimmed;
if (val === (user!.avatar ?? null)) { cancel(); return; }
await save({ avatar: val });
}
const planLabel = user.plan?.name ?? 'Free'; const planLabel = user.plan?.name ?? 'Free';
const planSlug = user.plan?.slug ?? 'free'; const planSlug = user.plan?.slug ?? 'free';
@@ -60,63 +74,95 @@ export default function ProfilePage() {
<div className="max-w-lg space-y-8"> <div className="max-w-lg space-y-8">
<h1 className="font-mono text-sm text-od-accent">Profil</h1> <h1 className="font-mono text-sm text-od-accent">Profil</h1>
{/* Infos compte */} {/* Compte */}
<section className="space-y-4"> <section className="space-y-4">
<h2 className="text-xs font-semibold text-od-muted uppercase tracking-widest"> <h2 className="text-xs font-semibold text-od-muted uppercase tracking-widest">Compte</h2>
Compte
</h2>
<div className="rounded border border-od-border bg-od-surface divide-y divide-od-border"> <div className="rounded border border-od-border bg-od-surface divide-y divide-od-border">
{/* Avatar */}
<div className="flex items-center justify-between px-4 py-3 gap-4">
<span className="text-xs text-od-muted shrink-0">Avatar</span>
{editingAvatar ? (
<div className="flex flex-col items-end gap-2 flex-1 min-w-0">
<div className="flex items-center gap-2 w-full">
<input
value={draftAvatar}
onChange={(e) => setDraftAvatar(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSaveAvatar(); if (e.key === 'Escape') cancel(); }}
placeholder="URL https://… (vide = supprimer)"
disabled={saving}
className="flex-1 min-w-0 rounded border border-od-border bg-od-bg px-2 py-0.5 font-mono text-xs text-od-text focus:border-od-accent focus:outline-none disabled:opacity-50 placeholder-od-muted"
autoFocus
/>
<button onClick={handleSaveAvatar} disabled={saving}
className="font-mono text-xs text-od-accent hover:opacity-80 transition-opacity disabled:opacity-40">
{saving ? '…' : '✓'}
</button>
<button onClick={cancel} disabled={saving}
className="font-mono text-xs text-od-muted hover:text-od-crit transition-colors disabled:opacity-40">
</button>
</div>
{draftAvatar && (
<img src={draftAvatar} alt="preview" className="h-10 w-10 rounded-full object-cover border border-od-border" />
)}
{error && <span className="font-mono text-[10px] text-od-crit">{error}</span>}
</div>
) : (
<div className="flex items-center gap-3">
{user.avatar ? (
<img src={user.avatar} alt={user.nickname} className="h-8 w-8 rounded-full object-cover border border-od-border" />
) : (
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-od-surface-hi border border-od-border font-mono text-sm text-od-accent">
{user.nickname[0].toUpperCase()}
</span>
)}
<button onClick={startEditAvatar}
className="font-mono text-xs text-od-muted hover:text-od-accent transition-colors">
modifier
</button>
</div>
)}
</div>
{/* Email */} {/* Email */}
<div className="flex items-center justify-between px-4 py-3"> <div className="flex items-center justify-between px-4 py-3">
<span className="text-xs text-od-muted">Email</span> <span className="text-xs text-od-muted">Email</span>
<span className="font-mono text-xs text-od-text"> <span className="font-mono text-xs text-od-text">{user.email ?? '—'}</span>
{user.email ?? '—'}
</span>
</div> </div>
{/* Nickname */} {/* Nickname */}
<div className="flex items-center justify-between px-4 py-3"> <div className="flex items-center justify-between px-4 py-3">
<span className="text-xs text-od-muted">Pseudo</span> <span className="text-xs text-od-muted">Pseudo</span>
{editing ? ( {editingNickname ? (
<div className="flex flex-col items-end gap-1"> <div className="flex flex-col items-end gap-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
value={draft} value={draftNickname}
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraftNickname(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') handleCancel(); }} onKeyDown={(e) => { if (e.key === 'Enter') handleSaveNickname(); if (e.key === 'Escape') cancel(); }}
maxLength={32} maxLength={100}
disabled={saving} disabled={saving}
className="w-36 rounded border border-od-border bg-od-bg px-2 py-0.5 font-mono text-xs text-od-text focus:border-od-accent focus:outline-none disabled:opacity-50" className="w-36 rounded border border-od-border bg-od-bg px-2 py-0.5 font-mono text-xs text-od-text focus:border-od-accent focus:outline-none disabled:opacity-50"
autoFocus autoFocus
/> />
<button <button onClick={handleSaveNickname} disabled={saving}
onClick={handleSave} className="font-mono text-xs text-od-accent hover:opacity-80 transition-opacity disabled:opacity-40">
disabled={saving}
className="font-mono text-xs text-od-accent hover:opacity-80 transition-opacity disabled:opacity-40"
>
{saving ? '…' : '✓'} {saving ? '…' : '✓'}
</button> </button>
<button <button onClick={cancel} disabled={saving}
onClick={handleCancel} className="font-mono text-xs text-od-muted hover:text-od-crit transition-colors disabled:opacity-40">
disabled={saving}
className="font-mono text-xs text-od-muted hover:text-od-crit transition-colors disabled:opacity-40"
>
</button> </button>
</div> </div>
{error && ( {error && <span className="font-mono text-[10px] text-od-crit">{error}</span>}
<span className="font-mono text-[10px] text-od-crit">{error}</span>
)}
</div> </div>
) : ( ) : (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="font-mono text-xs text-od-text">{user.nickname}</span> <span className="font-mono text-xs text-od-text">{user.nickname}</span>
<button <button onClick={startEditNickname}
onClick={handleEdit} className="font-mono text-xs text-od-muted hover:text-od-accent transition-colors">
className="font-mono text-xs text-od-muted hover:text-od-accent transition-colors"
>
modifier modifier
</button> </button>
</div> </div>
@@ -128,10 +174,7 @@ export default function ProfilePage() {
{/* Plan */} {/* Plan */}
<section className="space-y-4"> <section className="space-y-4">
<h2 className="text-xs font-semibold text-od-muted uppercase tracking-widest"> <h2 className="text-xs font-semibold text-od-muted uppercase tracking-widest">Abonnement</h2>
Abonnement
</h2>
<div className="rounded border border-od-border bg-od-surface px-4 py-3 flex items-center justify-between"> <div className="rounded border border-od-border bg-od-surface px-4 py-3 flex items-center justify-between">
<p className="text-xs text-od-text">{planLabel}</p> <p className="text-xs text-od-text">{planLabel}</p>
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded border border-od-border text-od-muted"> <span className="font-mono text-[10px] px-1.5 py-0.5 rounded border border-od-border text-od-muted">