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} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 719da76..f45520e 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"; @@ -47,6 +48,17 @@ const DEFAULT_FILTERS: FeedFilters = { }; 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 { try { @@ -69,7 +81,7 @@ export default function App() { const [filters, setFiltersState] = useState(loadInitialFilters); const [view, setView] = useState<"grid" | "list">("grid"); const [sidebarLayout, setSidebarLayoutState] = useState(loadLayout); - const [page, setPageState] = useState(readPage); + const [page, setPageState] = useState(loadInitialPage); const [settingsOpen, setSettingsOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false); const [channelFilter, setChannelFilter] = useState("all"); @@ -89,6 +101,7 @@ export default function App() { function setPage(next: Page) { setPageState(next); + localStorage.setItem(PAGE_KEY, next); } function setSidebarLayout(next: SidebarLayout) { @@ -218,6 +231,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, + }); + + // 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 ( + <> + + + {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/Channels.tsx b/frontend/src/components/Channels.tsx index 2e32004..3db9eb1 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -18,6 +18,7 @@ import { formatEta } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; import Avatar from "./Avatar"; +import { useConfirm } from "./ConfirmProvider"; export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden"; @@ -43,6 +44,7 @@ export default function Channels({ }) { const { t } = useTranslation(); const qc = useQueryClient(); + const confirm = useConfirm(); // 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. @@ -309,13 +311,14 @@ export default function Channels({ c={c} userTags={userTags} canWrite={canWrite} - onUnsubscribe={() => { - if ( - window.confirm( - t("channels.confirmUnsubscribe", { name: c.title ?? c.id }) - ) - ) - unsubscribe.mutate(c.id); + onUnsubscribe={async () => { + const ok = await confirm({ + title: t("channels.row.unsubscribeOnYoutube"), + message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }), + confirmLabel: t("channels.row.unsubscribeOnYoutube"), + danger: true, + }); + if (ok) unsubscribe.mutate(c.id); }} onView={() => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))} onPriority={(d) => bumpPriority(c.id, c.priority, d)} diff --git a/frontend/src/components/ConfirmProvider.tsx b/frontend/src/components/ConfirmProvider.tsx new file mode 100644 index 0000000..6794a9b --- /dev/null +++ b/frontend/src/components/ConfirmProvider.tsx @@ -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 +// 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; + +const ConfirmContext = createContext(() => 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( + (opts) => new Promise((resolve) => setState({ opts, resolve })), + [] + ); + + function close(ok: boolean) { + state?.resolve(ok); + setState(null); + } + + return ( + + {children} + {state && ( + close(false)} + maxWidth="max-w-sm" + > +

{state.opts.message}

+
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 15dc8d0..1d535f5 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"; @@ -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")}
)} @@ -194,6 +198,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 confirm = useConfirm(); + 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 ?? []; + + // 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 ( +
+ + +
+ {selectedId == null || !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({ > +