diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 79a6da7..8072b20 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import VideoPage from './pages/VideoPage'; import PlaylistsPage from './pages/PlaylistsPage'; import PlaylistPage from './pages/PlaylistPage'; import AdminPage from './pages/AdminPage'; +import ProfilePage from './pages/ProfilePage'; type Theme = 'dark' | 'light'; @@ -38,6 +39,7 @@ function App() { } /> } /> } /> + } /> diff --git a/frontend/src/components/UserBadge.tsx b/frontend/src/components/UserBadge.tsx new file mode 100644 index 0000000..2aba2b5 --- /dev/null +++ b/frontend/src/components/UserBadge.tsx @@ -0,0 +1,18 @@ +import type { User } from '../context/AuthContext'; + +interface UserBadgeProps { + user: User; +} + +export default function UserBadge({ user }: UserBadgeProps) { + const planLabel = user.plan?.slug ?? 'free'; + + return ( + + {user.nickname} + + {planLabel} + + + ); +} diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index bbb44a8..9153605 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -1,6 +1,8 @@ +import { useEffect, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import { apiFetch } from '../../lib/api'; import type { User } from '../../context/AuthContext'; +import UserBadge from '../UserBadge'; interface HeaderProps { theme: 'dark' | 'light'; @@ -10,8 +12,23 @@ interface HeaderProps { } export default function Header({ theme, onToggleTheme, user, onLogout }: HeaderProps) { + const [open, setOpen] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [open]); + async function handleLogout() { await apiFetch('/auth/logout', { method: 'POST' }).catch(() => {}); + setOpen(false); onLogout(); } @@ -39,7 +56,7 @@ export default function Header({ theme, onToggleTheme, user, onLogout }: HeaderP Playlists )} - {user?.roles?.includes('admin') && ( + {user?.roles?.some((r) => r === 'admin' || r === 'super_admin') && ( admin @@ -57,14 +74,31 @@ export default function Header({ theme, onToggleTheme, user, onLogout }: HeaderP {user ? ( -
- {user.nickname} +
+ {open && ( +
+ setOpen(false)} + className="block px-3 py-2 text-xs text-od-muted hover:text-od-text transition-colors" + > + Profil + + +
+ )}
) : ( void; -} - -interface MeResponse { - success: boolean; - data: { user: User }; -} - -export function useAuth(): AuthState { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - let cancelled = false; - - apiFetch('/auth/me') - .then((res) => { if (!cancelled) setUser(res.data.user); }) - .catch(() => { if (!cancelled) setUser(null); }) - .finally(() => { if (!cancelled) setLoading(false); }); - - return () => { cancelled = true; }; - }, []); - - return { user, loading, setUser }; -} +// Réexporte depuis AuthContext — source unique de vérité auth. +// Ne pas dupliquer User ou la logique de fetch ici. +export type { User } from '../context/AuthContext'; +export { useAuthContext as useAuth } from '../context/AuthContext'; diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx new file mode 100644 index 0000000..977aab0 --- /dev/null +++ b/frontend/src/pages/ProfilePage.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react'; +import { apiFetch, ApiError } from '../lib/api'; +import { useAuthContext } from '../context/AuthContext'; +import type { User } from '../context/AuthContext'; + +interface MeResponse { + success: boolean; + data: { user: User }; +} + +export default function ProfilePage() { + const { user, setUser } = useAuthContext(); + + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(user?.nickname ?? ''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + if (!user) return null; + + function handleEdit() { + setDraft(user!.nickname); + setError(null); + setEditing(true); + } + + function handleCancel() { + setEditing(false); + setError(null); + } + + async function handleSave() { + const trimmed = draft.trim(); + if (!trimmed || trimmed === user!.nickname) { + setEditing(false); + return; + } + + setSaving(true); + setError(null); + try { + await apiFetch('/users/me', { + method: 'PATCH', + body: JSON.stringify({ nickname: trimmed }), + }); + const res = await apiFetch('/auth/me'); + setUser(res.data.user); + setEditing(false); + } catch (e) { + setError(e instanceof ApiError ? `Erreur ${e.status}` : 'Erreur réseau'); + } finally { + setSaving(false); + } + } + + const planLabel = user.plan?.name ?? 'Free'; + const planSlug = user.plan?.slug ?? 'free'; + + return ( +
+

Profil

+ + {/* Infos compte */} +
+

+ Compte +

+ +
+ + {/* Email */} +
+ Email + + {user.email ?? '—'} + +
+ + {/* Nickname */} +
+ Pseudo + {editing ? ( +
+
+ setDraft(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') handleCancel(); }} + maxLength={32} + 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" + autoFocus + /> + + +
+ {error && ( + {error} + )} +
+ ) : ( +
+ {user.nickname} + +
+ )} +
+ +
+
+ + {/* Plan */} +
+

+ Abonnement +

+ +
+

{planLabel}

+ + {planSlug} + +
+
+
+ ); +}