From da9dcf93d55b2c1b00e94974cd55196054e347c8 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 14:37:09 +0200 Subject: [PATCH 1/7] =?UTF-8?q?feat(playlists):=20backend=20foundation=20?= =?UTF-8?q?=E2=80=94=20model,=20migration,=20CRUD=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add per-user local playlists: Playlist + PlaylistItem models (with forward-looking kind/source/yt_playlist_id/dirty columns for the later YouTube-sync phases) and migration 0011. New /api/playlists routes: list (with optional contains= membership flags for the add-to-playlist popover), create, get detail (items serialized via the feed serializer, with per-user watch state), rename, delete, add/remove item, and reorder. Watch later (built-in) is protected from deletion. --- backend/alembic/versions/0011_playlists.py | 88 +++++++ backend/app/main.py | 3 +- backend/app/models.py | 61 +++++ backend/app/routes/playlists.py | 261 +++++++++++++++++++++ 4 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/0011_playlists.py create mode 100644 backend/app/routes/playlists.py diff --git a/backend/alembic/versions/0011_playlists.py b/backend/alembic/versions/0011_playlists.py new file mode 100644 index 0000000..efd5c05 --- /dev/null +++ b/backend/alembic/versions/0011_playlists.py @@ -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") diff --git a/backend/app/main.py b/backend/app/main.py index cb07a68..34fef9e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -26,7 +26,7 @@ from starlette.middleware.sessions import SessionMiddleware from app import auth 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 @@ -66,6 +66,7 @@ app.include_router(tags.router) app.include_router(feed.router) app.include_router(me.router) app.include_router(channels.router) +app.include_router(playlists.router) app.include_router(admin.router) app.include_router(quota.router) app.include_router(version.router) diff --git a/backend/app/models.py b/backend/app/models.py index 46bb24c..546e645 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -301,3 +301,64 @@ class AppState(Base): sync_paused: Mapped[bool] = mapped_column( 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") diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py new file mode 100644 index 0000000..ba90de3 --- /dev/null +++ b/backend/app/routes/playlists.py @@ -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=`, 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} From 37804ee39399e66be981977048d19b200a0470b4 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 14:45:18 +0200 Subject: [PATCH 2/7] =?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({ > + + + + + )} + + ); +} diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index e52f3c9..d9dd279 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -29,6 +29,7 @@ import { 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, @@ -98,6 +99,7 @@ function Row({ export default function Playlists() { const { t } = useTranslation(); const qc = useQueryClient(); + const confirm = useConfirm(); const [selectedId, setSelectedId] = useState(null); const [newName, setNewName] = useState(""); const [renaming, setRenaming] = useState(false); @@ -173,7 +175,13 @@ export default function Playlists() { async function deletePlaylist() { if (selectedId == null || !detail) return; - if (!window.confirm(t("playlists.confirmDelete", { name: detail.name }))) 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; await api.deletePlaylist(selectedId); setSelectedId(null); qc.invalidateQueries({ queryKey: ["playlists"] }); diff --git a/frontend/src/i18n/locales/de/common.json b/frontend/src/i18n/locales/de/common.json index fe81995..96a3e73 100644 --- a/frontend/src/i18n/locales/de/common.json +++ b/frontend/src/i18n/locales/de/common.json @@ -3,6 +3,8 @@ "termsOfService": "Nutzungsbedingungen", "close": "Schließen", "cancel": "Abbrechen", + "confirm": "Bestätigen", + "confirmTitle": "Bist du sicher?", "save": "Speichern", "loading": "Wird geladen…", "language": "Sprache", diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index f594fe5..a33b2b4 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -3,6 +3,8 @@ "termsOfService": "Terms of Service", "close": "Close", "cancel": "Cancel", + "confirm": "Confirm", + "confirmTitle": "Are you sure?", "save": "Save", "loading": "Loading…", "language": "Language", diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json index 4584d03..d155cd1 100644 --- a/frontend/src/i18n/locales/hu/common.json +++ b/frontend/src/i18n/locales/hu/common.json @@ -3,6 +3,8 @@ "termsOfService": "Felhasználási feltételek", "close": "Bezárás", "cancel": "Mégse", + "confirm": "Megerősítés", + "confirmTitle": "Biztos vagy benne?", "save": "Mentés", "loading": "Betöltés…", "language": "Nyelv", diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 44320e3..88bb2c6 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import App from "./App"; import ErrorBoundary from "./components/ErrorBoundary"; +import { ConfirmProvider } from "./components/ConfirmProvider"; import PrivacyPolicy from "./components/legal/PrivacyPolicy"; import Terms from "./components/legal/Terms"; import "./i18n"; @@ -21,7 +22,9 @@ const root = path === "/terms" ? : ( - + + + ); From 26e72d5d007471ca037a7c5c9044f5830f355f7d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 15:15:56 +0200 Subject: [PATCH 7/7] fix(playlists): correctly clear/reselect after deleting a playlist The post-delete auto-select effect re-picked the just-deleted id from the still-stale playlists cache, so its detail lingered. Select the next remaining playlist directly on delete (or null), drop the deleted detail from the cache, and make the effect re-validate the selection against the current list (clear when empty, reselect when the current id is gone). Also fix the header title showing "Channel manager" on the Playlists page. --- frontend/src/components/Header.tsx | 6 +++++- frontend/src/components/Playlists.tsx | 20 ++++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 7729f51..1d535f5 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -90,7 +90,11 @@ export default function Header({ ) : (
- {page === "stats" ? t("header.usageStats") : t("header.channelManager")} + {page === "stats" + ? t("header.usageStats") + : page === "playlists" + ? t("header.account.playlists") + : t("header.channelManager")}
)} diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index d9dd279..b547cf5 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -110,9 +110,16 @@ export default function Playlists() { const listQuery = useQuery({ queryKey: ["playlists"], queryFn: () => api.playlists() }); const playlists = listQuery.data ?? []; - // Default the selection to the first playlist once the list loads. + // 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 (selectedId == null && playlists.length) setSelectedId(playlists[0].id); + 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({ @@ -182,8 +189,13 @@ export default function Playlists() { danger: true, }); if (!ok) return; - await api.deletePlaylist(selectedId); - setSelectedId(null); + 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"] }); }