From 12339009b5fc0e40f8dad2b60c0aff0ae238a7c4 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 19:37:17 +0200 Subject: [PATCH] feat(playlists): YouTube-mirrored playlists in the UI (read-only) + sync button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show YouTube-sourced playlists with a YouTube icon; they're read-only for now (no rename/delete/reorder/remove, excluded from the add-to-playlist popover) with a 'synced from YouTube' note — editing comes with the write-back phase. Add a 'Sync from YouTube' button in the Playlists rail (api.syncYoutubePlaylists) with a result toast. Trilingual strings. --- frontend/src/components/AddToPlaylist.tsx | 4 +- frontend/src/components/Playlists.tsx | 97 ++++++++++++++++----- frontend/src/i18n/locales/de/playlists.json | 4 + frontend/src/i18n/locales/en/playlists.json | 4 + frontend/src/i18n/locales/hu/playlists.json | 4 + frontend/src/lib/api.ts | 2 + 6 files changed, 92 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx index ab27f28..e46d447 100644 --- a/frontend/src/components/AddToPlaylist.tsx +++ b/frontend/src/components/AddToPlaylist.tsx @@ -118,7 +118,9 @@ export default function AddToPlaylist({ } } - const lists = membership.data ?? []; + // YouTube-mirrored playlists are read-only until the write-back phase, so they can't be + // added to here yet. + const lists = (membership.data ?? []).filter((pl) => pl.source !== "youtube"); return ( <> diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index b8ba592..c38c696 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -23,27 +23,32 @@ import { Pencil, Play, Plus, + RefreshCw, Trash2, X, + Youtube, } from "lucide-react"; import { api, type Playlist, type Video } from "../lib/api"; import { formatDuration } from "../lib/format"; +import { notify } from "../lib/notifications"; import PlayerModal from "./PlayerModal"; import { useConfirm } from "./ConfirmProvider"; function Row({ video, index, + readOnly, onPlay, onRemove, }: { video: Video; index: number; + readOnly?: boolean; onPlay: () => void; onRemove: () => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = - useSortable({ id: video.id }); + useSortable({ id: video.id, disabled: readOnly }); const style = { transform: CSS.Transform.toString(transform), transition, @@ -55,14 +60,18 @@ function Row({ style={style} className="flex items-center gap-2.5 p-2 rounded-lg border border-border bg-card" > - + {readOnly ? ( + + ) : ( + + )} {index + 1} + {!readOnly && ( + + )} ); } @@ -149,6 +160,25 @@ export default function Playlists() { const plName = (p: { kind: string; name: string }) => p.kind === "watch_later" ? t("playlists.watchLater") : p.name; const builtin = detail?.kind === "watch_later"; + // YouTube-mirrored playlists are read-only here until the write-back phase. + const mirrored = detail?.source === "youtube"; + const editable = !builtin && !mirrored; + const [syncing, setSyncing] = useState(false); + + async function syncYoutube() { + if (syncing) return; + setSyncing(true); + try { + const r = await api.syncYoutubePlaylists(); + qc.invalidateQueries({ queryKey: ["playlists"] }); + qc.invalidateQueries({ queryKey: ["playlist"] }); + notify({ message: t("playlists.syncedToast", { count: r.synced }) }); + } catch { + notify({ level: "warning", message: t("playlists.syncFailed") }); + } finally { + setSyncing(false); + } + } const createMut = useMutation({ mutationFn: (name: string) => api.createPlaylist(name), @@ -166,7 +196,7 @@ export default function Playlists() { async function onDragEnd(e: DragEndEvent) { const { active: a, over } = e; - if (!over || a.id === over.id || selectedId == null) return; + if (!over || a.id === over.id || selectedId == null || mirrored) return; const oldIndex = items.findIndex((v) => v.id === a.id); const newIndex = items.findIndex((v) => v.id === over.id); if (oldIndex < 0 || newIndex < 0) return; @@ -222,7 +252,19 @@ export default function Playlists() { return (
-
{plName(pl)}
+
+ {plName(pl)} + {pl.source === "youtube" && ( + + )} +
{t("playlists.itemCount", { count: pl.item_count })}
@@ -301,7 +348,7 @@ export default function Playlists() { ) : (

{plName(detail)}

- {!builtin && ( + {editable && ( - {!builtin && ( + {editable && ( )} + {mirrored && ( + + {t("playlists.ytReadonly")} + + )}
@@ -361,6 +413,7 @@ export default function Playlists() { key={v.id} video={v} index={i} + readOnly={mirrored} onPlay={() => setPlayingIndex(i)} onRemove={() => removeItem(v.id)} /> diff --git a/frontend/src/i18n/locales/de/playlists.json b/frontend/src/i18n/locales/de/playlists.json index 6d4851f..4b8728d 100644 --- a/frontend/src/i18n/locales/de/playlists.json +++ b/frontend/src/i18n/locales/de/playlists.json @@ -1,6 +1,10 @@ { "title": "Wiedergabelisten", "watchLater": "Später ansehen", + "syncYoutube": "Von YouTube synchronisieren", + "syncedToast": "{{count}} Wiedergabelisten von YouTube synchronisiert", + "syncFailed": "Synchronisierung von YouTube fehlgeschlagen", + "ytReadonly": "Von YouTube synchronisiert — vorerst schreibgeschützt", "noneYet": "Noch keine Wiedergabelisten", "newPlaylist": "Neue Liste…", "itemCount_one": "{{count}} Video", diff --git a/frontend/src/i18n/locales/en/playlists.json b/frontend/src/i18n/locales/en/playlists.json index 9bef503..85ed5d9 100644 --- a/frontend/src/i18n/locales/en/playlists.json +++ b/frontend/src/i18n/locales/en/playlists.json @@ -1,6 +1,10 @@ { "title": "Playlists", "watchLater": "Watch later", + "syncYoutube": "Sync from YouTube", + "syncedToast": "Synced {{count}} playlists from YouTube", + "syncFailed": "Couldn't sync from YouTube", + "ytReadonly": "Synced from YouTube — read-only for now", "noneYet": "No playlists yet", "newPlaylist": "New playlist…", "itemCount_one": "{{count}} video", diff --git a/frontend/src/i18n/locales/hu/playlists.json b/frontend/src/i18n/locales/hu/playlists.json index dcfcedd..0f30811 100644 --- a/frontend/src/i18n/locales/hu/playlists.json +++ b/frontend/src/i18n/locales/hu/playlists.json @@ -1,6 +1,10 @@ { "title": "Lejátszási listák", "watchLater": "Megnézem később", + "syncYoutube": "Szinkron YouTube-ról", + "syncedToast": "{{count}} lista szinkronizálva a YouTube-ról", + "syncFailed": "Nem sikerült a YouTube-szinkron", + "ytReadonly": "YouTube-ról szinkronizálva — egyelőre csak olvasható", "noneYet": "Még nincs lejátszási lista", "newPlaylist": "Új lista…", "itemCount_one": "{{count}} videó", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f41535c..3472d9b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -344,6 +344,8 @@ export const api = { }), watchLaterRemove: (videoId: string) => req(`/api/playlists/watch-later/${videoId}`, { method: "DELETE" }), + syncYoutubePlaylists: (): Promise<{ synced: number; reason?: string }> => + req("/api/playlists/sync-youtube", { method: "POST" }), // --- quota usage --- myUsage: (): Promise => req("/api/quota/my-usage"),