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 bf769379cb
commit 40a44cdde2
14 changed files with 634 additions and 4 deletions

View file

@ -68,6 +68,26 @@ export interface FeedResponse {
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 {
tags: number[];
tagMode: "or" | "and";
@ -294,6 +314,28 @@ export const api = {
req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
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 ---
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
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));
}
export type Page = "feed" | "channels" | "stats";
export type Page = "feed" | "channels" | "stats" | "playlists";
export function readPage(): 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