From 2c3d9d95c622fd42770258b74ecf7cec79e6ba1c Mon Sep 17 00:00:00 2001 From: Tetardtek Date: Sun, 15 Mar 2026 01:00:26 +0100 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20playlist=20B1=20=E2=80=94=20e?= =?UTF-8?q?dit,=20delete,=20share,=20invitations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/pages/PlaylistPage.tsx | 244 ++++++++++++++++++++++++--- frontend/src/pages/PlaylistsPage.tsx | 105 ++++++++++-- 2 files changed, 311 insertions(+), 38 deletions(-) diff --git a/frontend/src/pages/PlaylistPage.tsx b/frontend/src/pages/PlaylistPage.tsx index 5715ae8..60f7de9 100644 --- a/frontend/src/pages/PlaylistPage.tsx +++ b/frontend/src/pages/PlaylistPage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { useParams, Link } from 'react-router-dom'; +import { useParams, Link, useNavigate } from 'react-router-dom'; import { apiFetch, ApiError } from '../lib/api'; interface Video { @@ -27,9 +27,25 @@ interface PlaylistResponse { export default function PlaylistPage() { const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); const [data, setData] = useState(null); const [error, setError] = useState<'forbidden' | 'not_found' | null>(null); const [loading, setLoading] = useState(true); + const [actionError, setActionError] = useState(null); + + // Edit inline form + const [editOpen, setEditOpen] = useState(false); + const [editTitle, setEditTitle] = useState(''); + const [editVisibility, setEditVisibility] = useState<'private' | 'shared' | 'public'>('private'); + const [saving, setSaving] = useState(false); + + // Share modal + const [shareOpen, setShareOpen] = useState(false); + const [shareUserId, setShareUserId] = useState(''); + const [sharePermission, setSharePermission] = useState<'view' | 'edit'>('view'); + const [sharing, setSharing] = useState(false); + const [shareError, setShareError] = useState(null); + const [shareOk, setShareOk] = useState(false); useEffect(() => { if (!id) return; @@ -42,6 +58,73 @@ export default function PlaylistPage() { .finally(() => setLoading(false)); }, [id]); + function openEdit() { + if (!data) return; + setEditTitle(data.playlist.title); + setEditVisibility(data.playlist.visibility); + setEditOpen(true); + setActionError(null); + } + + async function handleEdit(e: React.FormEvent) { + e.preventDefault(); + if (!id || saving) return; + setSaving(true); + setActionError(null); + try { + const res = await apiFetch<{ success: boolean; data: { playlist: Playlist } }>( + `/playlists/${id}`, + { method: 'PATCH', body: JSON.stringify({ title: editTitle.trim(), visibility: editVisibility }) } + ); + setData((prev) => prev ? { ...prev, playlist: res.data.playlist } : prev); + setEditOpen(false); + } catch { + setActionError('Impossible de modifier la playlist.'); + } + setSaving(false); + } + + async function handleDelete() { + if (!id || !confirm('Supprimer cette playlist ?')) return; + setActionError(null); + try { + await apiFetch(`/playlists/${id}`, { method: 'DELETE' }); + navigate('/playlists'); + } catch { + setActionError('Impossible de supprimer la playlist.'); + } + } + + async function handleRemoveVideo(videoId: string) { + if (!id || !confirm('Retirer cette vidéo de la playlist ?')) return; + setActionError(null); + try { + await apiFetch(`/playlists/${id}/videos/${videoId}`, { method: 'DELETE' }); + setData((prev) => prev ? { ...prev, videos: prev.videos.filter((v) => v.id !== videoId) } : prev); + } catch { + setActionError('Impossible de retirer la vidéo.'); + } + } + + async function handleShare(e: React.FormEvent) { + e.preventDefault(); + if (!id || !shareUserId.trim() || sharing) return; + setSharing(true); + setShareError(null); + setShareOk(false); + try { + await apiFetch(`/playlists/${id}/share`, { + method: 'POST', + body: JSON.stringify({ userId: shareUserId.trim(), permission: sharePermission }), + }); + setShareOk(true); + setShareUserId(''); + } catch { + setShareError("Impossible d'envoyer l'invitation."); + } + setSharing(false); + } + if (loading) { return (
@@ -77,41 +160,162 @@ export default function PlaylistPage() { } const { playlist, videos, permission } = data; + const isOwner = permission === 'owner'; return (
-
-
-

{playlist.title}

- {permission} -
- {playlist.description && ( -

{playlist.description}

+ {/* Header */} +
+ {editOpen ? ( +
+

Modifier

+ setEditTitle(e.target.value)} + required + 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" + /> + +
+ + +
+
+ ) : ( + <> +
+

{playlist.title}

+ {permission} +
+ {playlist.description && ( +

{playlist.description}

+ )} + {isOwner && ( +
+ + + +
+ )} + )} + {actionError &&

{actionError}

}
+ {/* Share modal */} + {shareOpen && ( +
+
+

Partager

+
+ setShareUserId(e.target.value)} + placeholder="ID utilisateur" + required + 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" + /> + + {shareError &&

{shareError}

} + {shareOk &&

Invitation envoyée.

} +
+ + +
+
+
+
+ )} + + {/* Videos */} {videos.length === 0 ? (

Aucune vidéo dans cette playlist.

) : (
{videos.map((v, i) => ( - {i + 1} - {v.thumbnailUrl && ( - + + {v.thumbnailUrl && ( + + )} + {v.title} + {v.duration && ( + + {Math.floor(v.duration / 60)}:{String(v.duration % 60).padStart(2, '0')} + + )} + + {isOwner && ( + )} - {v.title} - {v.duration && ( - - {Math.floor(v.duration / 60)}:{String(v.duration % 60).padStart(2, '0')} - - )} - +
))}
)} diff --git a/frontend/src/pages/PlaylistsPage.tsx b/frontend/src/pages/PlaylistsPage.tsx index 159f9fc..44d2566 100644 --- a/frontend/src/pages/PlaylistsPage.tsx +++ b/frontend/src/pages/PlaylistsPage.tsx @@ -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([]); const [shared, setShared] = useState<(Playlist & { permission: 'view' | 'edit' })[]>([]); + const [invitations, setInvitations] = useState([]); const [loading, setLoading] = useState(true); const [fetchError, setFetchError] = useState(null); const [createTitle, setCreateTitle] = useState(''); const [creating, setCreating] = useState(false); const [createError, setCreateError] = useState(null); + const [inviteError, setInviteError] = useState(null); + const [respondingId, setRespondingId] = useState(null); useEffect(() => { apiFetch('/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('/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 (
@@ -77,25 +111,60 @@ export default function PlaylistsPage() { {/* Créer */}
-
- 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" - /> - -
- {createError &&

{createError}

} +
+ 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" + /> + +
+ {createError &&

{createError}

}
+ {/* Invitations reçues */} + {invitations.length > 0 && ( +
+

Invitations

+ {inviteError &&

{inviteError}

} + {invitations.map((inv) => ( +
+
+ {inv.playlistTitle} + {inv.permission} +
+
+ + +
+
+ ))} +
+ )} + {/* Mes playlists */} {owned.length > 0 && (
@@ -112,7 +181,7 @@ export default function PlaylistsPage() {
)} - {owned.length === 0 && shared.length === 0 && ( + {owned.length === 0 && shared.length === 0 && invitations.length === 0 && (

Aucune playlist pour l'instant.

)}