From 40a44cdde26de5163c25943e575316154bb192b4 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 14:45:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(playlists):=20frontend=20foundation=20?= =?UTF-8?q?=E2=80=94=20page,=20add-to-playlist,=20nav?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the Playlists page (left rail of playlists + detail with drag&drop reorder, inline rename, one-step delete, remove item, Play all / row-click playback via the existing PlayerModal) and an AddToPlaylist popover (portaled, multi-toggle + inline new-playlist) wired into the VideoCard hover overlay and the PlayerModal. New api client methods + Playlist types, a 'playlists' page route + account-menu entry, and trilingual strings. Local only — YouTube sync comes in later phases. --- frontend/src/App.tsx | 3 + frontend/src/components/AddToPlaylist.tsx | 173 ++++++++++ frontend/src/components/Header.tsx | 8 +- frontend/src/components/PlayerModal.tsx | 9 +- frontend/src/components/Playlists.tsx | 349 ++++++++++++++++++++ frontend/src/components/VideoCard.tsx | 2 + frontend/src/i18n/locales/de/header.json | 1 + frontend/src/i18n/locales/de/playlists.json | 15 + frontend/src/i18n/locales/en/header.json | 1 + frontend/src/i18n/locales/en/playlists.json | 15 + frontend/src/i18n/locales/hu/header.json | 1 + frontend/src/i18n/locales/hu/playlists.json | 15 + frontend/src/lib/api.ts | 42 +++ frontend/src/lib/urlState.ts | 4 +- 14 files changed, 634 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/AddToPlaylist.tsx create mode 100644 frontend/src/components/Playlists.tsx create mode 100644 frontend/src/i18n/locales/de/playlists.json create mode 100644 frontend/src/i18n/locales/en/playlists.json create mode 100644 frontend/src/i18n/locales/hu/playlists.json diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 719da76..af987fc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,6 +24,7 @@ import Header from "./components/Header"; import Sidebar from "./components/Sidebar"; import Feed from "./components/Feed"; import Channels, { type ChannelStatusFilter } from "./components/Channels"; +import Playlists from "./components/Playlists"; import Stats from "./components/Stats"; import SettingsPanel from "./components/SettingsPanel"; import OnboardingWizard from "./components/OnboardingWizard"; @@ -218,6 +219,8 @@ export default function App() { /> ) : page === "stats" && meQuery.data!.role === "admin" ? ( + ) : page === "playlists" ? ( + ) : ( (null); + const panelRef = useRef(null); + const [open, setOpen] = useState(false); + const [coords, setCoords] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); + const [newName, setNewName] = useState(""); + const [busy, setBusy] = useState(false); + + const membership = useQuery({ + queryKey: ["playlist-membership", videoId], + queryFn: () => api.playlists(videoId), + enabled: open, + }); + + function place() { + const r = triggerRef.current?.getBoundingClientRect(); + if (!r) return; + const width = 240; + setCoords({ + top: Math.round(r.bottom + 6), + left: Math.round(Math.min(r.left, window.innerWidth - width - 8)), + }); + } + + function toggleOpen(e: React.MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + if (!open) place(); + setOpen((o) => !o); + } + + useEffect(() => { + if (!open) return; + function onDoc(e: MouseEvent) { + if ( + !panelRef.current?.contains(e.target as Node) && + !triggerRef.current?.contains(e.target as Node) + ) + setOpen(false); + } + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + document.addEventListener("mousedown", onDoc); + document.addEventListener("keydown", onKey); + window.addEventListener("resize", place); + window.addEventListener("scroll", place, true); + return () => { + document.removeEventListener("mousedown", onDoc); + document.removeEventListener("keydown", onKey); + window.removeEventListener("resize", place); + window.removeEventListener("scroll", place, true); + }; + }, [open]); + + function refresh() { + qc.invalidateQueries({ queryKey: ["playlist-membership", videoId] }); + qc.invalidateQueries({ queryKey: ["playlists"] }); + } + + async function toggle(pl: Playlist) { + if (busy) return; + setBusy(true); + try { + if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId); + else await api.addToPlaylist(pl.id, videoId); + refresh(); + } finally { + setBusy(false); + } + } + + async function createAndAdd(e: React.FormEvent) { + e.preventDefault(); + const name = newName.trim(); + if (!name || busy) return; + setBusy(true); + try { + const pl = await api.createPlaylist(name); + await api.addToPlaylist(pl.id, videoId); + setNewName(""); + refresh(); + } finally { + setBusy(false); + } + } + + const lists = membership.data ?? []; + + return ( + <> + + + {open && + createPortal( +
e.stopPropagation()} + > +
+ {t("playlists.addToPlaylist")} +
+
+ {lists.length === 0 && !membership.isLoading && ( +
+ {t("playlists.noneYet")} +
+ )} + {lists.map((pl) => ( + + ))} +
+
+ + setNewName(e.target.value)} + placeholder={t("playlists.newPlaylist")} + className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent" + /> + +
, + document.body + )} + + ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 15dc8d0..7729f51 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,6 +1,6 @@ import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { BarChart3, Home, Info, Library, LogOut, Search, Settings, Shield, Tv, User } from "lucide-react"; +import { BarChart3, Home, Info, Library, ListVideo, LogOut, Search, Settings, Shield, Tv, User } from "lucide-react"; import type { FeedFilters, Me } from "../lib/api"; import type { Page } from "../lib/urlState"; import { type LangCode } from "../i18n"; @@ -194,6 +194,12 @@ function AccountMenu({ {t("header.account.channels")} )} + {page !== "playlists" && ( + + )} {me.role === "admin" && page !== "stats" && ( + {index + 1} + + + {video.duration_seconds != null && ( + + {formatDuration(video.duration_seconds)} + + )} + + + ); +} + +export default function Playlists() { + const { t } = useTranslation(); + const qc = useQueryClient(); + const [selectedId, setSelectedId] = useState(null); + const [newName, setNewName] = useState(""); + const [renaming, setRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(""); + const [items, setItems] = useState([]); + const [active, setActive] = useState<{ video: Video; startAt: number | null } | null>(null); + + const listQuery = useQuery({ queryKey: ["playlists"], queryFn: () => api.playlists() }); + const playlists = listQuery.data ?? []; + + // Default the selection to the first playlist once the list loads. + useEffect(() => { + if (selectedId == null && playlists.length) setSelectedId(playlists[0].id); + }, [playlists, selectedId]); + + const detailQuery = useQuery({ + queryKey: ["playlist", selectedId], + queryFn: () => api.playlist(selectedId as number), + enabled: selectedId != null, + }); + const detail = detailQuery.data; + + useEffect(() => { + if (detail) setItems(detail.items); + }, [detail]); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }) + ); + + const createMut = useMutation({ + mutationFn: (name: string) => api.createPlaylist(name), + onSuccess: (pl: Playlist) => { + setNewName(""); + qc.invalidateQueries({ queryKey: ["playlists"] }); + setSelectedId(pl.id); + }, + }); + + function refreshAll() { + qc.invalidateQueries({ queryKey: ["playlists"] }); + qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); + } + + async function onDragEnd(e: DragEndEvent) { + const { active: a, over } = e; + if (!over || a.id === over.id || selectedId == null) 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; + const next = arrayMove(items, oldIndex, newIndex); + setItems(next); + await api.reorderPlaylist(selectedId, next.map((v) => v.id)); + qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); + } + + async function removeItem(videoId: string) { + if (selectedId == null) return; + setItems((cur) => cur.filter((v) => v.id !== videoId)); + await api.removeFromPlaylist(selectedId, videoId); + refreshAll(); + } + + async function saveRename() { + if (selectedId == null) return; + const name = renameValue.trim(); + setRenaming(false); + if (name && name !== detail?.name) { + await api.renamePlaylist(selectedId, name); + refreshAll(); + } + } + + async function deletePlaylist() { + if (selectedId == null || !detail) return; + if (!window.confirm(t("playlists.confirmDelete", { name: detail.name }))) return; + await api.deletePlaylist(selectedId); + setSelectedId(null); + qc.invalidateQueries({ queryKey: ["playlists"] }); + } + + function playerState(id: string, status: string) { + api.setState(id, status).then(() => { + qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); + qc.invalidateQueries({ queryKey: ["feed"] }); + }); + } + + return ( +
+ + +
+ {!detail ? ( +
+ {selectedId == null ? t("playlists.pickOne") : t("playlists.loading")} +
+ ) : ( + <> +
+
+ {items[0]?.thumbnail_url && ( + + )} +
+
+ {renaming ? ( + setRenameValue(e.target.value)} + onBlur={saveRename} + onKeyDown={(e) => { + if (e.key === "Enter") saveRename(); + if (e.key === "Escape") setRenaming(false); + }} + className="text-lg font-semibold bg-card border border-border rounded-md px-2 py-0.5 outline-none focus:border-accent" + /> + ) : ( +
+

{detail.name}

+ +
+ )} +
+ {t("playlists.itemCount", { count: items.length })} +
+
+ + +
+
+
+ + {items.length === 0 ? ( +
+
+ + {t("playlists.empty")} +
+
+ ) : ( + + v.id)} + strategy={verticalListSortingStrategy} + > +
+ {items.map((v, i) => ( + setActive({ video: v, startAt: null })} + onRemove={() => removeItem(v.id)} + /> + ))} +
+
+
+ )} + + )} +
+ + {active && ( + setActive(null)} + onState={playerState} + /> + )} +
+ ); +} diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index 12cfda4..489b14f 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -11,6 +11,7 @@ import { RotateCcw, } from "lucide-react"; import Avatar from "./Avatar"; +import AddToPlaylist from "./AddToPlaylist"; import clsx from "clsx"; import type { Video } from "../lib/api"; import { formatDuration, formatViews, relativeTime } from "../lib/format"; @@ -56,6 +57,7 @@ function Actions({ > +