feat(playlists): YouTube-mirrored playlists in the UI (read-only) + sync button

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.
This commit is contained in:
npeter83 2026-06-15 19:37:17 +02:00
parent 240b6fb611
commit 12339009b5
6 changed files with 92 additions and 23 deletions

View file

@ -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 ( return (
<> <>

View file

@ -23,27 +23,32 @@ import {
Pencil, Pencil,
Play, Play,
Plus, Plus,
RefreshCw,
Trash2, Trash2,
X, X,
Youtube,
} from "lucide-react"; } from "lucide-react";
import { api, type Playlist, type Video } from "../lib/api"; import { api, type Playlist, type Video } from "../lib/api";
import { formatDuration } from "../lib/format"; import { formatDuration } from "../lib/format";
import { notify } from "../lib/notifications";
import PlayerModal from "./PlayerModal"; import PlayerModal from "./PlayerModal";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
function Row({ function Row({
video, video,
index, index,
readOnly,
onPlay, onPlay,
onRemove, onRemove,
}: { }: {
video: Video; video: Video;
index: number; index: number;
readOnly?: boolean;
onPlay: () => void; onPlay: () => void;
onRemove: () => void; onRemove: () => void;
}) { }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: video.id }); useSortable({ id: video.id, disabled: readOnly });
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
@ -55,6 +60,9 @@ function Row({
style={style} style={style}
className="flex items-center gap-2.5 p-2 rounded-lg border border-border bg-card" className="flex items-center gap-2.5 p-2 rounded-lg border border-border bg-card"
> >
{readOnly ? (
<span className="w-4" />
) : (
<button <button
{...attributes} {...attributes}
{...listeners} {...listeners}
@ -63,6 +71,7 @@ function Row({
> >
<GripVertical className="w-4 h-4" /> <GripVertical className="w-4 h-4" />
</button> </button>
)}
<span className="text-xs text-muted w-4 text-center tabular-nums">{index + 1}</span> <span className="text-xs text-muted w-4 text-center tabular-nums">{index + 1}</span>
<button <button
onClick={onPlay} onClick={onPlay}
@ -84,6 +93,7 @@ function Row({
{formatDuration(video.duration_seconds)} {formatDuration(video.duration_seconds)}
</span> </span>
)} )}
{!readOnly && (
<button <button
onClick={onRemove} onClick={onRemove}
title="remove" title="remove"
@ -92,6 +102,7 @@ function Row({
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
)}
</div> </div>
); );
} }
@ -149,6 +160,25 @@ export default function Playlists() {
const plName = (p: { kind: string; name: string }) => const plName = (p: { kind: string; name: string }) =>
p.kind === "watch_later" ? t("playlists.watchLater") : p.name; p.kind === "watch_later" ? t("playlists.watchLater") : p.name;
const builtin = detail?.kind === "watch_later"; 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({ const createMut = useMutation({
mutationFn: (name: string) => api.createPlaylist(name), mutationFn: (name: string) => api.createPlaylist(name),
@ -166,7 +196,7 @@ export default function Playlists() {
async function onDragEnd(e: DragEndEvent) { async function onDragEnd(e: DragEndEvent) {
const { active: a, over } = e; 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 oldIndex = items.findIndex((v) => v.id === a.id);
const newIndex = items.findIndex((v) => v.id === over.id); const newIndex = items.findIndex((v) => v.id === over.id);
if (oldIndex < 0 || newIndex < 0) return; if (oldIndex < 0 || newIndex < 0) return;
@ -222,7 +252,19 @@ export default function Playlists() {
return ( return (
<div className="flex h-full min-h-0"> <div className="flex h-full min-h-0">
<aside className="w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-3"> <aside className="w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-3">
<div className="text-sm font-semibold mb-2">{t("playlists.title")}</div> <div className="flex items-center justify-between mb-2">
<span className="text-sm font-semibold">{t("playlists.title")}</span>
<button
onClick={syncYoutube}
disabled={syncing}
title={t("playlists.syncYoutube")}
aria-label={t("playlists.syncYoutube")}
className="inline-flex items-center gap-1 text-[11px] text-muted hover:text-accent disabled:opacity-40 transition"
>
<RefreshCw className={`w-3.5 h-3.5 ${syncing ? "animate-spin" : ""}`} />
<Youtube className="w-3.5 h-3.5" />
</button>
</div>
{playlists.length === 0 && !listQuery.isLoading && ( {playlists.length === 0 && !listQuery.isLoading && (
<div className="text-xs text-muted px-1 py-2">{t("playlists.noneYet")}</div> <div className="text-xs text-muted px-1 py-2">{t("playlists.noneYet")}</div>
)} )}
@ -243,7 +285,12 @@ export default function Playlists() {
)} )}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="text-[13px] text-fg truncate">{plName(pl)}</div> <div className="flex items-center gap-1 text-[13px] text-fg">
<span className="truncate">{plName(pl)}</span>
{pl.source === "youtube" && (
<Youtube className="w-3 h-3 shrink-0 text-muted" />
)}
</div>
<div className="text-[11px] text-muted"> <div className="text-[11px] text-muted">
{t("playlists.itemCount", { count: pl.item_count })} {t("playlists.itemCount", { count: pl.item_count })}
</div> </div>
@ -301,7 +348,7 @@ export default function Playlists() {
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2> <h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
{!builtin && ( {editable && (
<button <button
onClick={() => { onClick={() => {
setRenameValue(detail.name); setRenameValue(detail.name);
@ -326,7 +373,7 @@ export default function Playlists() {
> >
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")} <Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
</button> </button>
{!builtin && ( {editable && (
<button <button
onClick={deletePlaylist} onClick={deletePlaylist}
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition" className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition"
@ -334,6 +381,11 @@ export default function Playlists() {
<Trash2 className="w-3.5 h-3.5" /> {t("playlists.delete")} <Trash2 className="w-3.5 h-3.5" /> {t("playlists.delete")}
</button> </button>
)} )}
{mirrored && (
<span className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1.5 rounded-lg text-muted">
<Youtube className="w-3.5 h-3.5" /> {t("playlists.ytReadonly")}
</span>
)}
</div> </div>
</div> </div>
</div> </div>
@ -361,6 +413,7 @@ export default function Playlists() {
key={v.id} key={v.id}
video={v} video={v}
index={i} index={i}
readOnly={mirrored}
onPlay={() => setPlayingIndex(i)} onPlay={() => setPlayingIndex(i)}
onRemove={() => removeItem(v.id)} onRemove={() => removeItem(v.id)}
/> />

View file

@ -1,6 +1,10 @@
{ {
"title": "Wiedergabelisten", "title": "Wiedergabelisten",
"watchLater": "Später ansehen", "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", "noneYet": "Noch keine Wiedergabelisten",
"newPlaylist": "Neue Liste…", "newPlaylist": "Neue Liste…",
"itemCount_one": "{{count}} Video", "itemCount_one": "{{count}} Video",

View file

@ -1,6 +1,10 @@
{ {
"title": "Playlists", "title": "Playlists",
"watchLater": "Watch later", "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", "noneYet": "No playlists yet",
"newPlaylist": "New playlist…", "newPlaylist": "New playlist…",
"itemCount_one": "{{count}} video", "itemCount_one": "{{count}} video",

View file

@ -1,6 +1,10 @@
{ {
"title": "Lejátszási listák", "title": "Lejátszási listák",
"watchLater": "Megnézem később", "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", "noneYet": "Még nincs lejátszási lista",
"newPlaylist": "Új lista…", "newPlaylist": "Új lista…",
"itemCount_one": "{{count}} videó", "itemCount_one": "{{count}} videó",

View file

@ -344,6 +344,8 @@ export const api = {
}), }),
watchLaterRemove: (videoId: string) => watchLaterRemove: (videoId: string) =>
req(`/api/playlists/watch-later/${videoId}`, { method: "DELETE" }), req(`/api/playlists/watch-later/${videoId}`, { method: "DELETE" }),
syncYoutubePlaylists: (): Promise<{ synced: number; reason?: string }> =>
req("/api/playlists/sync-youtube", { method: "POST" }),
// --- quota usage --- // --- quota usage ---
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"), myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),