feat(playlists): frontend foundation — page, add-to-playlist, nav

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.
This commit is contained in:
npeter83 2026-06-15 14:45:18 +02:00
parent da9dcf93d5
commit 37804ee393
14 changed files with 634 additions and 4 deletions

View file

@ -24,6 +24,7 @@ import Header from "./components/Header";
import Sidebar from "./components/Sidebar"; import Sidebar from "./components/Sidebar";
import Feed from "./components/Feed"; import Feed from "./components/Feed";
import Channels, { type ChannelStatusFilter } from "./components/Channels"; import Channels, { type ChannelStatusFilter } from "./components/Channels";
import Playlists from "./components/Playlists";
import Stats from "./components/Stats"; import Stats from "./components/Stats";
import SettingsPanel from "./components/SettingsPanel"; import SettingsPanel from "./components/SettingsPanel";
import OnboardingWizard from "./components/OnboardingWizard"; import OnboardingWizard from "./components/OnboardingWizard";
@ -218,6 +219,8 @@ export default function App() {
/> />
) : page === "stats" && meQuery.data!.role === "admin" ? ( ) : page === "stats" && meQuery.data!.role === "admin" ? (
<Stats /> <Stats />
) : page === "playlists" ? (
<Playlists />
) : ( ) : (
<Feed <Feed
filters={filters} filters={filters}

View file

@ -0,0 +1,173 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ListPlus, Plus } from "lucide-react";
import { api, type Playlist } from "../lib/api";
// A small popover (portaled to body, so the feed grid can't clip it) for toggling a
// video's membership in the user's playlists, with inline "new playlist" creation.
export default function AddToPlaylist({
videoId,
className,
}: {
videoId: string;
className?: string;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const triggerRef = useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(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 (
<>
<button
ref={triggerRef}
onClick={toggleOpen}
title={t("playlists.addToPlaylist")}
aria-label={t("playlists.addToPlaylist")}
className={className ?? "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"}
>
<ListPlus className="w-4 h-4" />
</button>
{open &&
createPortal(
<div
ref={panelRef}
style={{ position: "fixed", top: coords.top, left: coords.left, width: 240 }}
className="z-50 glass rounded-xl p-2 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="text-xs text-muted px-1.5 pb-1.5">
{t("playlists.addToPlaylist")}
</div>
<div className="max-h-56 overflow-y-auto">
{lists.length === 0 && !membership.isLoading && (
<div className="text-xs text-muted px-1.5 py-2">
{t("playlists.noneYet")}
</div>
)}
{lists.map((pl) => (
<button
key={pl.id}
onClick={() => toggle(pl)}
disabled={busy}
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg hover:bg-card/60 transition disabled:opacity-50"
>
<span
className={`grid place-items-center w-4 h-4 rounded border ${
pl.has_video
? "bg-accent border-accent text-accent-fg"
: "border-border"
}`}
>
{pl.has_video && <Check className="w-3 h-3" />}
</span>
<span className="truncate">{pl.name}</span>
</button>
))}
</div>
<form
onSubmit={createAndAdd}
className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5"
>
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
<input
value={newName}
onChange={(e) => 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"
/>
</form>
</div>,
document.body
)}
</>
);
}

View file

@ -1,6 +1,6 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useTranslation } from "react-i18next"; 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 { FeedFilters, Me } from "../lib/api";
import type { Page } from "../lib/urlState"; import type { Page } from "../lib/urlState";
import { type LangCode } from "../i18n"; import { type LangCode } from "../i18n";
@ -194,6 +194,12 @@ function AccountMenu({
{t("header.account.channels")} {t("header.account.channels")}
</button> </button>
)} )}
{page !== "playlists" && (
<button onClick={() => { setPage("playlists"); setOpen(false); }} className={itemClass}>
<ListVideo className="w-4 h-4" />
{t("header.account.playlists")}
</button>
)}
{me.role === "admin" && page !== "stats" && ( {me.role === "admin" && page !== "stats" && (
<button onClick={() => { setPage("stats"); setOpen(false); }} className={itemClass}> <button onClick={() => { setPage("stats"); setOpen(false); }} className={itemClass}>
<BarChart3 className="w-4 h-4" /> <BarChart3 className="w-4 h-4" />

View file

@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Check, CheckCheck, X } from "lucide-react"; import { ArrowLeft, Check, CheckCheck, X } from "lucide-react";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
import { api, type Video } from "../lib/api"; import { api, type Video } from "../lib/api";
import { formatDuration, formatViews, relativeTime } from "../lib/format"; import { formatDuration, formatViews, relativeTime } from "../lib/format";
@ -518,12 +519,18 @@ export default function PlayerModal({
</div> </div>
)} )}
{!navigated && (
<AddToPlaylist
videoId={video.id}
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
/>
)}
{!navigated && ( {!navigated && (
<button <button
onClick={() => setWatched(!watched)} onClick={() => setWatched(!watched)}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")} title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
className={ className={
"ml-auto shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " + "shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
(watched (watched
? "bg-accent text-accent-fg shadow-sm hover:opacity-90" ? "bg-accent text-accent-fg shadow-sm hover:opacity-90"
: "text-muted hover:text-fg hover:bg-surface border border-border") : "text-muted hover:text-fg hover:bg-surface border border-border")

View file

@ -0,0 +1,349 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
DndContext,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
Check,
GripVertical,
ListPlus,
Pencil,
Play,
Plus,
Trash2,
X,
} from "lucide-react";
import { api, type Playlist, type Video } from "../lib/api";
import { formatDuration } from "../lib/format";
import PlayerModal from "./PlayerModal";
function Row({
video,
index,
onPlay,
onRemove,
}: {
video: Video;
index: number;
onPlay: () => void;
onRemove: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: video.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className="flex items-center gap-2.5 p-2 rounded-lg border border-border bg-card"
>
<button
{...attributes}
{...listeners}
className="text-muted hover:text-fg cursor-grab active:cursor-grabbing"
aria-label="reorder"
>
<GripVertical className="w-4 h-4" />
</button>
<span className="text-xs text-muted w-4 text-center tabular-nums">{index + 1}</span>
<button
onClick={onPlay}
className="relative w-[62px] h-[35px] rounded overflow-hidden bg-surface shrink-0 group"
>
{video.thumbnail_url && (
<img src={video.thumbnail_url} alt="" className="w-full h-full object-cover" />
)}
<span className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 group-hover:opacity-100 transition">
<Play className="w-4 h-4 text-white fill-current" />
</span>
</button>
<button onClick={onPlay} className="min-w-0 flex-1 text-left">
<div className="text-[13px] text-fg truncate">{video.title}</div>
<div className="text-[11px] text-muted truncate">{video.channel_title}</div>
</button>
{video.duration_seconds != null && (
<span className="text-[11px] text-muted tabular-nums">
{formatDuration(video.duration_seconds)}
</span>
)}
<button
onClick={onRemove}
title="remove"
className="text-muted hover:text-fg p-1"
aria-label="remove from playlist"
>
<X className="w-4 h-4" />
</button>
</div>
);
}
export default function Playlists() {
const { t } = useTranslation();
const qc = useQueryClient();
const [selectedId, setSelectedId] = useState<number | null>(null);
const [newName, setNewName] = useState("");
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState("");
const [items, setItems] = useState<Video[]>([]);
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 (
<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">
<div className="text-sm font-semibold mb-2">{t("playlists.title")}</div>
{playlists.length === 0 && !listQuery.isLoading && (
<div className="text-xs text-muted px-1 py-2">{t("playlists.noneYet")}</div>
)}
<div className="space-y-1">
{playlists.map((pl) => (
<button
key={pl.id}
onClick={() => setSelectedId(pl.id)}
className={`w-full flex items-center gap-2.5 p-2 rounded-lg border transition text-left ${
pl.id === selectedId
? "bg-accent/15 border-accent"
: "border-transparent hover:bg-card/60"
}`}
>
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
{pl.cover_thumbnail && (
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
)}
</div>
<div className="min-w-0">
<div className="text-[13px] text-fg truncate">{pl.name}</div>
<div className="text-[11px] text-muted">
{t("playlists.itemCount", { count: pl.item_count })}
</div>
</div>
</button>
))}
</div>
<form
onSubmit={(e) => {
e.preventDefault();
if (newName.trim()) createMut.mutate(newName.trim());
}}
className="flex items-center gap-1.5 mt-3 pt-3 border-t border-border"
>
<Plus className="w-4 h-4 text-muted shrink-0" />
<input
value={newName}
onChange={(e) => 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"
/>
</form>
</aside>
<main className="flex-1 min-w-0 overflow-y-auto p-4">
{!detail ? (
<div className="text-muted text-sm p-4">
{selectedId == null ? t("playlists.pickOne") : t("playlists.loading")}
</div>
) : (
<>
<div className="flex gap-3 items-start mb-4">
<div className="w-24 h-[54px] rounded-lg bg-card overflow-hidden shrink-0">
{items[0]?.thumbnail_url && (
<img
src={items[0].thumbnail_url}
alt=""
className="w-full h-full object-cover"
/>
)}
</div>
<div className="min-w-0 flex-1">
{renaming ? (
<input
autoFocus
value={renameValue}
onChange={(e) => 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"
/>
) : (
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold truncate">{detail.name}</h2>
<button
onClick={() => {
setRenameValue(detail.name);
setRenaming(true);
}}
title={t("playlists.rename")}
className="text-muted hover:text-fg"
>
<Pencil className="w-3.5 h-3.5" />
</button>
</div>
)}
<div className="text-xs text-muted mt-0.5">
{t("playlists.itemCount", { count: items.length })}
</div>
<div className="flex gap-2 mt-3 flex-wrap">
<button
onClick={() => items[0] && setActive({ video: items[0], startAt: null })}
disabled={!items.length}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg bg-accent text-accent-fg disabled:opacity-40 hover:opacity-90 transition"
>
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
</button>
<button
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"
>
<Trash2 className="w-3.5 h-3.5" /> {t("playlists.delete")}
</button>
</div>
</div>
</div>
{items.length === 0 ? (
<div className="text-sm text-muted grid place-items-center text-center py-12">
<div>
<ListPlus className="w-6 h-6 mx-auto mb-2 opacity-60" />
{t("playlists.empty")}
</div>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
>
<SortableContext
items={items.map((v) => v.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1.5 max-w-3xl">
{items.map((v, i) => (
<Row
key={v.id}
video={v}
index={i}
onPlay={() => setActive({ video: v, startAt: null })}
onRemove={() => removeItem(v.id)}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</>
)}
</main>
{active && (
<PlayerModal
video={active.video}
startAt={active.startAt}
onClose={() => setActive(null)}
onState={playerState}
/>
)}
</div>
);
}

View file

@ -11,6 +11,7 @@ import {
RotateCcw, RotateCcw,
} from "lucide-react"; } from "lucide-react";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
import clsx from "clsx"; import clsx from "clsx";
import type { Video } from "../lib/api"; import type { Video } from "../lib/api";
import { formatDuration, formatViews, relativeTime } from "../lib/format"; import { formatDuration, formatViews, relativeTime } from "../lib/format";
@ -56,6 +57,7 @@ function Actions({
> >
<Bookmark className="w-4 h-4" /> <Bookmark className="w-4 h-4" />
</button> </button>
<AddToPlaylist videoId={video.id} />
<button <button
onClick={act("hidden")} onClick={act("hidden")}
title={video.status === "hidden" ? t("card.unhide") : t("card.hide")} title={video.status === "hidden" ? t("card.unhide") : t("card.hide")}

View file

@ -14,6 +14,7 @@
"admin": "Admin", "admin": "Admin",
"feed": "Feed", "feed": "Feed",
"channels": "Kanäle", "channels": "Kanäle",
"playlists": "Wiedergabelisten",
"stats": "Statistik", "stats": "Statistik",
"settings": "Einstellungen", "settings": "Einstellungen",
"about": "Über", "about": "Über",

View file

@ -0,0 +1,15 @@
{
"title": "Wiedergabelisten",
"noneYet": "Noch keine Wiedergabelisten",
"newPlaylist": "Neue Liste…",
"itemCount_one": "{{count}} Video",
"itemCount_other": "{{count}} Videos",
"pickOne": "Wähle links eine Liste.",
"loading": "Wird geladen…",
"empty": "Diese Liste ist leer — füge Videos aus dem Feed hinzu.",
"playAll": "Alle abspielen",
"delete": "Löschen",
"rename": "Umbenennen",
"confirmDelete": "Die Wiedergabeliste „{{name}}“ löschen? Kann nicht rückgängig gemacht werden.",
"addToPlaylist": "Zur Wiedergabeliste hinzufügen"
}

View file

@ -14,6 +14,7 @@
"admin": "Admin", "admin": "Admin",
"feed": "Feed", "feed": "Feed",
"channels": "Channels", "channels": "Channels",
"playlists": "Playlists",
"stats": "Stats", "stats": "Stats",
"settings": "Settings", "settings": "Settings",
"about": "About", "about": "About",

View file

@ -0,0 +1,15 @@
{
"title": "Playlists",
"noneYet": "No playlists yet",
"newPlaylist": "New playlist…",
"itemCount_one": "{{count}} video",
"itemCount_other": "{{count}} videos",
"pickOne": "Pick a playlist on the left.",
"loading": "Loading…",
"empty": "This playlist is empty — add videos from the feed.",
"playAll": "Play all",
"delete": "Delete",
"rename": "Rename",
"confirmDelete": "Delete the playlist “{{name}}”? This can't be undone.",
"addToPlaylist": "Add to playlist"
}

View file

@ -14,6 +14,7 @@
"admin": "Adminisztrátor", "admin": "Adminisztrátor",
"feed": "Hírfolyam", "feed": "Hírfolyam",
"channels": "Csatornák", "channels": "Csatornák",
"playlists": "Lejátszási listák",
"stats": "Statisztika", "stats": "Statisztika",
"settings": "Beállítások", "settings": "Beállítások",
"about": "Névjegy", "about": "Névjegy",

View file

@ -0,0 +1,15 @@
{
"title": "Lejátszási listák",
"noneYet": "Még nincs lejátszási lista",
"newPlaylist": "Új lista…",
"itemCount_one": "{{count}} videó",
"itemCount_other": "{{count}} videó",
"pickOne": "Válassz egy listát a bal oldalon.",
"loading": "Betöltés…",
"empty": "Ez a lista üres — adj hozzá videókat a hírfolyamból.",
"playAll": "Összes lejátszása",
"delete": "Törlés",
"rename": "Átnevezés",
"confirmDelete": "Törlöd a(z) „{{name}}” listát? Nem vonható vissza.",
"addToPlaylist": "Hozzáadás listához"
}

View file

@ -68,6 +68,26 @@ export interface FeedResponse {
limit: number; limit: number;
} }
export interface Playlist {
id: number;
name: string;
kind: string; // "user" | "watch_later"
source: string; // "local" | "youtube"
yt_playlist_id: string | null;
item_count: number;
cover_thumbnail: string | null;
has_video?: boolean; // only set when listed with ?contains=<video_id>
}
export interface PlaylistDetail {
id: number;
name: string;
kind: string;
source: string;
yt_playlist_id: string | null;
items: Video[];
}
export interface FeedFilters { export interface FeedFilters {
tags: number[]; tags: number[];
tagMode: "or" | "and"; tagMode: "or" | "and";
@ -294,6 +314,28 @@ export const api = {
req(`/api/channels/${id}/subscription`, { method: "DELETE" }), req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }), syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }),
// --- playlists ---
playlists: (containsVideoId?: string): Promise<Playlist[]> =>
req(`/api/playlists${containsVideoId ? `?contains=${containsVideoId}` : ""}`),
playlist: (id: number): Promise<PlaylistDetail> => req(`/api/playlists/${id}`),
createPlaylist: (name: string): Promise<Playlist> =>
req("/api/playlists", { method: "POST", body: JSON.stringify({ name }) }),
renamePlaylist: (id: number, name: string): Promise<Playlist> =>
req(`/api/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }),
deletePlaylist: (id: number) => req(`/api/playlists/${id}`, { method: "DELETE" }),
addToPlaylist: (id: number, videoId: string) =>
req(`/api/playlists/${id}/items`, {
method: "POST",
body: JSON.stringify({ video_id: videoId }),
}),
removeFromPlaylist: (id: number, videoId: string) =>
req(`/api/playlists/${id}/items/${videoId}`, { method: "DELETE" }),
reorderPlaylist: (id: number, videoIds: string[]) =>
req(`/api/playlists/${id}/order`, {
method: "PUT",
body: JSON.stringify({ video_ids: videoIds }),
}),
// --- quota usage --- // --- quota usage ---
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"), myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`), adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`),

View file

@ -78,11 +78,11 @@ export function hasFilterParams(params: URLSearchParams): boolean {
return KEYS.some((k) => params.has(k)); return KEYS.some((k) => params.has(k));
} }
export type Page = "feed" | "channels" | "stats"; export type Page = "feed" | "channels" | "stats" | "playlists";
export function readPage(): Page { export function readPage(): Page {
const p = new URLSearchParams(window.location.search).get("page"); const p = new URLSearchParams(window.location.search).get("page");
return p === "channels" || p === "stats" ? p : "feed"; return p === "channels" || p === "stats" || p === "playlists" ? p : "feed";
} }
/** Build a shareable absolute URL that reproduces the current filters (+ page). Used by the /** Build a shareable absolute URL that reproduces the current filters (+ page). Used by the