Merge feature/s4a-playlists-foundation: local playlists foundation

S4a (local only; YouTube sync in later phases):
- feat(playlists): backend model + migration 0011 + CRUD API
- feat(playlists): Playlists page (rail + detail, drag-reorder, rename, delete,
  remove item, play) + AddToPlaylist popover (cards + player) + nav
- feat(ui): reusable app-styled confirm dialog (ConfirmProvider/useConfirm),
  replacing window.confirm
- fixes: popover overflow flip, post-delete refresh/reselect, page persistence
  across reloads, header title on the Playlists page
This commit is contained in:
npeter83 2026-06-15 15:17:35 +02:00
commit 52d8ce14e7
24 changed files with 1191 additions and 15 deletions

View file

@ -0,0 +1,88 @@
"""local playlists: playlists + playlist_items
Revision ID: 0011_playlists
Revises: 0010_watch_progress
Create Date: 2026-06-15
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0011_playlists"
down_revision: Union[str, None] = "0010_watch_progress"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"playlists",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column(
"kind", sa.String(length=16), nullable=False, server_default="user"
),
sa.Column(
"source", sa.String(length=16), nullable=False, server_default="local"
),
sa.Column("yt_playlist_id", sa.String(length=64), nullable=True),
sa.Column(
"dirty", sa.Boolean(), nullable=False, server_default="false"
),
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index("ix_playlists_user_id", "playlists", ["user_id"])
op.create_table(
"playlist_items",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"playlist_id",
sa.Integer(),
sa.ForeignKey("playlists.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"video_id",
sa.String(length=16),
sa.ForeignKey("videos.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"added_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.UniqueConstraint("playlist_id", "video_id", name="uq_playlist_video"),
)
op.create_index("ix_playlist_items_playlist_id", "playlist_items", ["playlist_id"])
op.create_index("ix_playlist_items_video_id", "playlist_items", ["video_id"])
def downgrade() -> None:
op.drop_index("ix_playlist_items_video_id", table_name="playlist_items")
op.drop_index("ix_playlist_items_playlist_id", table_name="playlist_items")
op.drop_table("playlist_items")
op.drop_index("ix_playlists_user_id", table_name="playlists")
op.drop_table("playlists")

View file

@ -26,7 +26,7 @@ from starlette.middleware.sessions import SessionMiddleware
from app import auth from app import auth
from app.config import settings from app.config import settings
from app.routes import admin, channels, feed, health, me, quota, sync, tags, version from app.routes import admin, channels, feed, health, me, playlists, quota, sync, tags, version
from app.scheduler import shutdown_scheduler, start_scheduler from app.scheduler import shutdown_scheduler, start_scheduler
@ -66,6 +66,7 @@ app.include_router(tags.router)
app.include_router(feed.router) app.include_router(feed.router)
app.include_router(me.router) app.include_router(me.router)
app.include_router(channels.router) app.include_router(channels.router)
app.include_router(playlists.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(quota.router) app.include_router(quota.router)
app.include_router(version.router) app.include_router(version.router)

View file

@ -301,3 +301,64 @@ class AppState(Base):
sync_paused: Mapped[bool] = mapped_column( sync_paused: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false" Boolean, default=False, server_default="false"
) )
class Playlist(Base):
"""A per-user named, ordered collection of videos from the shared catalog.
`kind`: 'user' (normal) or 'watch_later' (built-in, undeletable, one per user).
`source`: 'local' (created here) or 'youtube' (mirrored from the user's YouTube
playlists). `yt_playlist_id` links a mirrored/exported playlist to its YouTube id.
`dirty` marks a YouTube-linked playlist with local edits not yet pushed, so the read
sync won't clobber them. (source/yt_playlist_id/dirty are forward-looking for the YT
sync phases; the foundation phase only uses local playlists.)"""
__tablename__ = "playlists"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
name: Mapped[str] = mapped_column(String(255))
kind: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
source: Mapped[str] = mapped_column(
String(16), default="local", server_default="local"
)
yt_playlist_id: Mapped[str | None] = mapped_column(String(64))
dirty: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
items: Mapped[list["PlaylistItem"]] = relationship(
back_populates="playlist",
cascade="all, delete-orphan",
order_by="PlaylistItem.position",
)
class PlaylistItem(Base):
"""A video's membership in a playlist, with its order within that playlist."""
__tablename__ = "playlist_items"
__table_args__ = (
UniqueConstraint("playlist_id", "video_id", name="uq_playlist_video"),
)
id: Mapped[int] = mapped_column(primary_key=True)
playlist_id: Mapped[int] = mapped_column(
ForeignKey("playlists.id", ondelete="CASCADE"), index=True
)
video_id: Mapped[str] = mapped_column(
ForeignKey("videos.id", ondelete="CASCADE"), index=True
)
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
added_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
playlist: Mapped["Playlist"] = relationship(back_populates="items")

View file

@ -0,0 +1,261 @@
"""Local playlists: per-user named, ordered collections of videos from the shared
catalog. Foundation phase = local only (create / rename / delete, add / remove / reorder
items). YouTube two-way sync (mirror existing playlists, push edits) lands in later phases."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select, and_
from sqlalchemy.orm import Session, aliased
from app.auth import current_user
from app.db import get_db
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
from app.routes.feed import _serialize
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
pl = db.get(Playlist, playlist_id)
if pl is None or pl.user_id != user.id:
raise HTTPException(status_code=404, detail="Unknown playlist")
return pl
def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = None) -> dict:
cover = db.scalar(
select(Video.thumbnail_url)
.join(PlaylistItem, PlaylistItem.video_id == Video.id)
.where(PlaylistItem.playlist_id == pl.id)
.order_by(PlaylistItem.position)
.limit(1)
)
out = {
"id": pl.id,
"name": pl.name,
"kind": pl.kind,
"source": pl.source,
"yt_playlist_id": pl.yt_playlist_id,
"item_count": count,
"cover_thumbnail": cover,
}
if has_video is not None:
out["has_video"] = has_video
return out
@router.get("")
def list_playlists(
contains: str | None = None,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> list[dict]:
"""The user's playlists (newest custom order). With `contains=<video_id>`, each entry
also reports whether that video is already in it used by the add-to-playlist popover."""
pls = (
db.execute(
select(Playlist)
.where(Playlist.user_id == user.id)
.order_by(Playlist.position, Playlist.created_at)
)
.scalars()
.all()
)
if not pls:
return []
ids = [p.id for p in pls]
counts = dict(
db.execute(
select(PlaylistItem.playlist_id, func.count())
.where(PlaylistItem.playlist_id.in_(ids))
.group_by(PlaylistItem.playlist_id)
).all()
)
member: set[int] = set()
if contains:
member = set(
db.execute(
select(PlaylistItem.playlist_id).where(
PlaylistItem.playlist_id.in_(ids),
PlaylistItem.video_id == contains,
)
)
.scalars()
.all()
)
return [
_summary(db, p, counts.get(p.id, 0), (p.id in member) if contains else None)
for p in pls
]
@router.post("")
def create_playlist(
payload: dict,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
name = (payload.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name is required")
next_pos = (
db.scalar(
select(func.coalesce(func.max(Playlist.position), -1)).where(
Playlist.user_id == user.id
)
)
+ 1
)
pl = Playlist(user_id=user.id, name=name, position=next_pos)
db.add(pl)
db.commit()
return _summary(db, pl, 0)
@router.get("/{playlist_id}")
def get_playlist(
playlist_id: int,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = _own_playlist(db, user, playlist_id)
state = aliased(VideoState)
rows = db.execute(
select(
Video.id,
Video.title,
Video.channel_id,
Channel.title.label("channel_title"),
Channel.thumbnail_url.label("channel_thumbnail"),
Channel.handle.label("channel_handle"),
Video.published_at,
Video.thumbnail_url,
Video.duration_seconds,
Video.view_count,
Video.is_short,
Video.live_status,
func.coalesce(state.status, "new").label("status"),
func.coalesce(state.position_seconds, 0).label("position_seconds"),
)
.join(PlaylistItem, PlaylistItem.video_id == Video.id)
.join(Channel, Channel.id == Video.channel_id)
.outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id))
.where(PlaylistItem.playlist_id == pl.id)
.order_by(PlaylistItem.position)
).all()
return {
"id": pl.id,
"name": pl.name,
"kind": pl.kind,
"source": pl.source,
"yt_playlist_id": pl.yt_playlist_id,
"items": [_serialize(r) for r in rows],
}
@router.patch("/{playlist_id}")
def rename_playlist(
playlist_id: int,
payload: dict,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = _own_playlist(db, user, playlist_id)
if "name" in payload:
name = (payload.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name cannot be empty")
pl.name = name
db.commit()
count = db.scalar(
select(func.count()).where(PlaylistItem.playlist_id == pl.id)
)
return _summary(db, pl, count or 0)
@router.delete("/{playlist_id}")
def delete_playlist(
playlist_id: int,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = _own_playlist(db, user, playlist_id)
if pl.kind == "watch_later":
raise HTTPException(status_code=400, detail="The Watch later list can't be deleted.")
db.delete(pl) # items cascade
db.commit()
return {"deleted": playlist_id}
@router.post("/{playlist_id}/items")
def add_item(
playlist_id: int,
payload: dict,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = _own_playlist(db, user, playlist_id)
video_id = payload.get("video_id")
if not video_id or db.get(Video, video_id) is None:
raise HTTPException(status_code=404, detail="Unknown video")
exists = db.scalar(
select(PlaylistItem).where(
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
)
)
if exists is None:
next_pos = (
db.scalar(
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
PlaylistItem.playlist_id == pl.id
)
)
+ 1
)
db.add(
PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos)
)
db.commit()
return {"playlist_id": pl.id, "video_id": video_id}
@router.delete("/{playlist_id}/items/{video_id}")
def remove_item(
playlist_id: int,
video_id: str,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = _own_playlist(db, user, playlist_id)
item = db.scalar(
select(PlaylistItem).where(
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
)
)
if item is not None:
db.delete(item)
db.commit()
return {"playlist_id": pl.id, "video_id": video_id}
@router.put("/{playlist_id}/order")
def reorder_items(
playlist_id: int,
payload: dict,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = _own_playlist(db, user, playlist_id)
order = payload.get("video_ids") or []
items = {
it.video_id: it
for it in db.execute(
select(PlaylistItem).where(PlaylistItem.playlist_id == pl.id)
).scalars()
}
pos = 0
for vid in order:
it = items.get(vid)
if it is not None:
it.position = pos
pos += 1
db.commit()
return {"playlist_id": pl.id, "count": pos}

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";
@ -47,6 +48,17 @@ const DEFAULT_FILTERS: FeedFilters = {
}; };
const FILTERS_KEY = "subfeed.filters"; const FILTERS_KEY = "subfeed.filters";
const PAGE_KEY = "siftlode.page";
// Page is navigation state, not a filter, but it's also kept out of the address bar — so
// persist it to localStorage to survive a reload. A share link's ?page= still wins.
function loadInitialPage(): Page {
const params = new URLSearchParams(window.location.search);
if (params.has("page")) return readPage();
const stored = localStorage.getItem(PAGE_KEY);
if (stored === "channels" || stored === "stats" || stored === "playlists") return stored;
return "feed";
}
function loadStoredFilters(): FeedFilters { function loadStoredFilters(): FeedFilters {
try { try {
@ -69,7 +81,7 @@ export default function App() {
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters); const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
const [view, setView] = useState<"grid" | "list">("grid"); const [view, setView] = useState<"grid" | "list">("grid");
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout); const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
const [page, setPageState] = useState<Page>(readPage); const [page, setPageState] = useState<Page>(loadInitialPage);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [wizardOpen, setWizardOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false);
const [channelFilter, setChannelFilter] = useState<ChannelStatusFilter>("all"); const [channelFilter, setChannelFilter] = useState<ChannelStatusFilter>("all");
@ -89,6 +101,7 @@ export default function App() {
function setPage(next: Page) { function setPage(next: Page) {
setPageState(next); setPageState(next);
localStorage.setItem(PAGE_KEY, next);
} }
function setSidebarLayout(next: SidebarLayout) { function setSidebarLayout(next: SidebarLayout) {
@ -218,6 +231,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,186 @@
import { useEffect, useLayoutEffect, 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,
});
// Re-place once the panel is actually rendered (so we know its real height) and whenever
// its content — hence height — changes, so the flip-above decision uses the true size.
useLayoutEffect(() => {
if (open) place();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, membership.data]);
function place() {
const r = triggerRef.current?.getBoundingClientRect();
if (!r) return;
const width = 240;
const margin = 8;
// Use the panel's real height once it's mounted; estimate on the first open.
const h = panelRef.current?.offsetHeight ?? 300;
let top = r.bottom + 6;
if (top + h > window.innerHeight - margin) {
// Not enough room below — open upward, clamped to the viewport.
top = Math.max(margin, r.top - h - 6);
}
const left = Math.min(Math.max(margin, r.left), window.innerWidth - width - margin);
setCoords({ top: Math.round(top), left: Math.round(left) });
}
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

@ -18,6 +18,7 @@ import { formatEta } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import { useConfirm } from "./ConfirmProvider";
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden"; export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
@ -43,6 +44,7 @@ export default function Channels({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm();
// A YouTube-gated action (sync, backfill, unsubscribe) that 403s means the user hasn't // A YouTube-gated action (sync, backfill, unsubscribe) that 403s means the user hasn't
// granted the needed scope — surface that with a "Connect" action instead of a vague fail. // granted the needed scope — surface that with a "Connect" action instead of a vague fail.
@ -309,13 +311,14 @@ export default function Channels({
c={c} c={c}
userTags={userTags} userTags={userTags}
canWrite={canWrite} canWrite={canWrite}
onUnsubscribe={() => { onUnsubscribe={async () => {
if ( const ok = await confirm({
window.confirm( title: t("channels.row.unsubscribeOnYoutube"),
t("channels.confirmUnsubscribe", { name: c.title ?? c.id }) message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }),
) confirmLabel: t("channels.row.unsubscribeOnYoutube"),
) danger: true,
unsubscribe.mutate(c.id); });
if (ok) unsubscribe.mutate(c.id);
}} }}
onView={() => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))} onView={() => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
onPriority={(d) => bumpPriority(c.id, c.priority, d)} onPriority={(d) => bumpPriority(c.id, c.priority, d)}

View file

@ -0,0 +1,74 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import Modal from "./Modal";
// App-styled, promise-based replacement for window.confirm. Wrap the tree in
// <ConfirmProvider> once, then anywhere: const confirm = useConfirm();
// if (await confirm({ message: "…", danger: true })) { … }
export interface ConfirmOptions {
title?: string;
message: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
}
type ConfirmFn = (opts: ConfirmOptions) => Promise<boolean>;
const ConfirmContext = createContext<ConfirmFn>(() => Promise.resolve(false));
export function useConfirm(): ConfirmFn {
return useContext(ConfirmContext);
}
export function ConfirmProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const [state, setState] = useState<{
opts: ConfirmOptions;
resolve: (ok: boolean) => void;
} | null>(null);
const confirm = useCallback<ConfirmFn>(
(opts) => new Promise<boolean>((resolve) => setState({ opts, resolve })),
[]
);
function close(ok: boolean) {
state?.resolve(ok);
setState(null);
}
return (
<ConfirmContext.Provider value={confirm}>
{children}
{state && (
<Modal
title={state.opts.title ?? t("common.confirmTitle")}
onClose={() => close(false)}
maxWidth="max-w-sm"
>
<p className="text-sm text-muted leading-relaxed">{state.opts.message}</p>
<div className="flex justify-end gap-2 mt-5">
<button
onClick={() => close(false)}
className="px-4 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg hover:bg-surface transition"
>
{state.opts.cancelLabel ?? t("common.cancel")}
</button>
<button
autoFocus
onClick={() => close(true)}
className={`px-4 py-2 rounded-lg text-sm font-semibold transition ${
state.opts.danger
? "bg-red-500 text-white hover:bg-red-600"
: "bg-accent text-accent-fg hover:opacity-90"
}`}
>
{state.opts.confirmLabel ?? t("common.confirm")}
</button>
</div>
</Modal>
)}
</ConfirmContext.Provider>
);
}

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";
@ -90,7 +90,11 @@ export default function Header({
</div> </div>
) : ( ) : (
<div className="flex-1 text-center text-sm font-semibold"> <div className="flex-1 text-center text-sm font-semibold">
{page === "stats" ? t("header.usageStats") : t("header.channelManager")} {page === "stats"
? t("header.usageStats")
: page === "playlists"
? t("header.account.playlists")
: t("header.channelManager")}
</div> </div>
)} )}
@ -194,6 +198,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,369 @@
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";
import { useConfirm } from "./ConfirmProvider";
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 confirm = useConfirm();
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 ?? [];
// Keep the selection valid: default to the first playlist, and if the selected one
// disappears (e.g. just deleted) drop to the next available, or clear when none remain.
useEffect(() => {
if (!playlists.length) {
if (selectedId != null) setSelectedId(null);
return;
}
if (selectedId == null || !playlists.some((p) => p.id === selectedId)) {
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;
const ok = await confirm({
title: t("playlists.delete"),
message: t("playlists.confirmDelete", { name: detail.name }),
confirmLabel: t("playlists.delete"),
danger: true,
});
if (!ok) return;
const deletedId = selectedId;
// Pick the next selection from what's left *now* (don't go via null, or the
// auto-select effect would re-pick the just-deleted id from the stale list).
const remaining = playlists.filter((p) => p.id !== deletedId);
setSelectedId(remaining[0]?.id ?? null);
await api.deletePlaylist(deletedId);
qc.removeQueries({ queryKey: ["playlist", deletedId] });
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">
{selectedId == null || !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

@ -3,6 +3,8 @@
"termsOfService": "Nutzungsbedingungen", "termsOfService": "Nutzungsbedingungen",
"close": "Schließen", "close": "Schließen",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"confirm": "Bestätigen",
"confirmTitle": "Bist du sicher?",
"save": "Speichern", "save": "Speichern",
"loading": "Wird geladen…", "loading": "Wird geladen…",
"language": "Sprache", "language": "Sprache",

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

@ -3,6 +3,8 @@
"termsOfService": "Terms of Service", "termsOfService": "Terms of Service",
"close": "Close", "close": "Close",
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Confirm",
"confirmTitle": "Are you sure?",
"save": "Save", "save": "Save",
"loading": "Loading…", "loading": "Loading…",
"language": "Language", "language": "Language",

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

@ -3,6 +3,8 @@
"termsOfService": "Felhasználási feltételek", "termsOfService": "Felhasználási feltételek",
"close": "Bezárás", "close": "Bezárás",
"cancel": "Mégse", "cancel": "Mégse",
"confirm": "Megerősítés",
"confirmTitle": "Biztos vagy benne?",
"save": "Mentés", "save": "Mentés",
"loading": "Betöltés…", "loading": "Betöltés…",
"language": "Nyelv", "language": "Nyelv",

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

View file

@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App"; import App from "./App";
import ErrorBoundary from "./components/ErrorBoundary"; import ErrorBoundary from "./components/ErrorBoundary";
import { ConfirmProvider } from "./components/ConfirmProvider";
import PrivacyPolicy from "./components/legal/PrivacyPolicy"; import PrivacyPolicy from "./components/legal/PrivacyPolicy";
import Terms from "./components/legal/Terms"; import Terms from "./components/legal/Terms";
import "./i18n"; import "./i18n";
@ -21,7 +22,9 @@ const root =
path === "/terms" ? <Terms /> : ( path === "/terms" ? <Terms /> : (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ErrorBoundary> <ErrorBoundary>
<ConfirmProvider>
<App /> <App />
</ConfirmProvider>
</ErrorBoundary> </ErrorBoundary>
</QueryClientProvider> </QueryClientProvider>
); );