feat(frontend): playlist B1 — edit, delete, share, invitations
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 36s

- PlaylistPage: bouton Éditer (formulaire inline titre/visibilité), Supprimer (confirm → DELETE → redirect), Partager (modal userId/permission → POST share), Retirer vidéo (✕ → DELETE)
- PlaylistsPage: section invitations reçues avec Accept / Refuser (PATCH share/:shareId)
- tsc --noEmit : 0 erreur, 0 console.log
This commit is contained in:
2026-03-15 01:00:26 +01:00
parent df8e594d57
commit 2c3d9d95c6
2 changed files with 311 additions and 38 deletions

View File

@@ -9,28 +9,40 @@ interface Playlist {
visibility: 'private' | 'shared' | 'public';
}
interface Invitation {
shareId: string;
playlistId: string;
playlistTitle: string;
permission: 'view' | 'edit';
}
interface PlaylistsResponse {
success: boolean;
data: {
owned: Playlist[];
shared: (Playlist & { permission: 'view' | 'edit' })[];
invitations?: Invitation[];
};
}
export default function PlaylistsPage() {
const [owned, setOwned] = useState<Playlist[]>([]);
const [shared, setShared] = useState<(Playlist & { permission: 'view' | 'edit' })[]>([]);
const [invitations, setInvitations] = useState<Invitation[]>([]);
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
const [createTitle, setCreateTitle] = useState('');
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [inviteError, setInviteError] = useState<string | null>(null);
const [respondingId, setRespondingId] = useState<string | null>(null);
useEffect(() => {
apiFetch<PlaylistsResponse>('/playlists')
.then((res) => {
setOwned(res.data.owned);
setShared(res.data.shared);
setInvitations(res.data.invitations ?? []);
})
.catch(() => setFetchError('Impossible de charger les playlists.'))
.finally(() => setLoading(false));
@@ -54,6 +66,28 @@ export default function PlaylistsPage() {
setCreating(false);
}
async function respondInvitation(inv: Invitation, status: 'accepted' | 'revoked') {
if (respondingId) return;
setRespondingId(inv.shareId);
setInviteError(null);
try {
await apiFetch(`/playlists/${inv.playlistId}/share/${inv.shareId}`, {
method: 'PATCH',
body: JSON.stringify({ status }),
});
setInvitations((prev) => prev.filter((i) => i.shareId !== inv.shareId));
if (status === 'accepted') {
const res = await apiFetch<PlaylistsResponse>('/playlists');
setOwned(res.data.owned);
setShared(res.data.shared);
setInvitations(res.data.invitations ?? []);
}
} catch {
setInviteError("Impossible de répondre à l'invitation.");
}
setRespondingId(null);
}
if (loading) {
return (
<div className="flex flex-col gap-3">
@@ -77,25 +111,60 @@ export default function PlaylistsPage() {
{/* Créer */}
<div className="flex flex-col gap-1">
<form onSubmit={handleCreate} className="flex gap-2">
<input
type="text"
value={createTitle}
onChange={(e) => setCreateTitle(e.target.value)}
placeholder="Nouvelle playlist…"
className="flex-1 rounded border border-od-border bg-od-surface px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent"
/>
<button
type="submit"
disabled={!createTitle.trim() || creating}
className="rounded border border-od-border px-4 py-2 font-mono text-xs text-od-muted hover:border-od-accent hover:text-od-accent transition-colors disabled:opacity-40"
>
+
</button>
</form>
{createError && <p className="font-mono text-xs text-od-crit">{createError}</p>}
<form onSubmit={handleCreate} className="flex gap-2">
<input
type="text"
value={createTitle}
onChange={(e) => setCreateTitle(e.target.value)}
placeholder="Nouvelle playlist…"
className="flex-1 rounded border border-od-border bg-od-surface px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent"
/>
<button
type="submit"
disabled={!createTitle.trim() || creating}
className="rounded border border-od-border px-4 py-2 font-mono text-xs text-od-muted hover:border-od-accent hover:text-od-accent transition-colors disabled:opacity-40"
>
+
</button>
</form>
{createError && <p className="font-mono text-xs text-od-crit">{createError}</p>}
</div>
{/* Invitations reçues */}
{invitations.length > 0 && (
<section className="flex flex-col gap-2">
<h2 className="font-mono text-xs uppercase tracking-widest text-od-muted">Invitations</h2>
{inviteError && <p className="font-mono text-xs text-od-crit">{inviteError}</p>}
{invitations.map((inv) => (
<div
key={inv.shareId}
className="flex items-center justify-between gap-4 rounded border border-od-border bg-od-surface px-4 py-3"
>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-sm text-od-text truncate">{inv.playlistTitle}</span>
<span className="font-mono text-xs text-od-muted">{inv.permission}</span>
</div>
<div className="flex gap-2 shrink-0">
<button
disabled={respondingId === inv.shareId}
onClick={() => respondInvitation(inv, 'accepted')}
className="rounded border border-od-ok px-3 py-1 font-mono text-xs text-od-ok hover:bg-od-ok hover:text-od-bg transition-colors disabled:opacity-40"
>
{respondingId === inv.shareId ? '…' : 'Accepter'}
</button>
<button
disabled={respondingId === inv.shareId}
onClick={() => respondInvitation(inv, 'revoked')}
className="rounded border border-od-border px-3 py-1 font-mono text-xs text-od-muted hover:border-od-crit hover:text-od-crit transition-colors disabled:opacity-40"
>
Refuser
</button>
</div>
</div>
))}
</section>
)}
{/* Mes playlists */}
{owned.length > 0 && (
<section className="flex flex-col gap-2">
@@ -112,7 +181,7 @@ export default function PlaylistsPage() {
</section>
)}
{owned.length === 0 && shared.length === 0 && (
{owned.length === 0 && shared.length === 0 && invitations.length === 0 && (
<p className="text-sm text-od-muted">Aucune playlist pour l'instant.</p>
)}