From 740a14af4b77a79fab31a0ba79c080127f2f255d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 22:13:32 +0200 Subject: [PATCH] feat(playlists): rail sorting, consolidated item sort, persist selection 1) Fix: the selected playlist is now persisted (localStorage) and scrolled into view, so F5 keeps it instead of jumping to the first one. 2) Consolidate the in-detail sort: one key select (Title/Duration/Channel) + an asc/desc direction toggle (was separate A-Z / Z-A / shortest / longest options); add Channel as a sort key. Direction also orders the channel groups when grouping. 3) Left rail sorting: by name / item count / total length, asc/desc, plus an 'unsynced first' toggle that floats playlists with unpushed edits to the top. Backend: list/summary now return total_duration_seconds (grouped sum). The rail also shows each playlist's total length. Sort prefs persist to localStorage. --- backend/app/routes/playlists.py | 42 +++- frontend/src/components/Playlists.tsx | 203 ++++++++++++++++---- frontend/src/i18n/locales/de/playlists.json | 16 +- frontend/src/i18n/locales/en/playlists.json | 16 +- frontend/src/i18n/locales/hu/playlists.json | 16 +- frontend/src/lib/api.ts | 1 + 6 files changed, 237 insertions(+), 57 deletions(-) diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py index e7942f6..375a2fe 100644 --- a/backend/app/routes/playlists.py +++ b/backend/app/routes/playlists.py @@ -31,7 +31,13 @@ def _mark_dirty(pl: Playlist) -> None: pl.dirty = True -def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = None) -> dict: +def _summary( + db: Session, + pl: Playlist, + count: int, + total_duration: int = 0, + has_video: bool | None = None, +) -> dict: cover = db.scalar( select(Video.thumbnail_url) .join(PlaylistItem, PlaylistItem.video_id == Video.id) @@ -47,6 +53,7 @@ def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = Non "yt_playlist_id": pl.yt_playlist_id, "dirty": pl.dirty, "item_count": count, + "total_duration_seconds": total_duration, "cover_thumbnail": cover, } if has_video is not None: @@ -54,6 +61,18 @@ def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = Non return out +def _total_duration(db: Session, playlist_id: int) -> int: + return ( + db.scalar( + select(func.coalesce(func.sum(Video.duration_seconds), 0)) + .select_from(PlaylistItem) + .join(Video, Video.id == PlaylistItem.video_id) + .where(PlaylistItem.playlist_id == playlist_id) + ) + or 0 + ) + + @router.get("") def list_playlists( contains: str | None = None, @@ -81,6 +100,17 @@ def list_playlists( .group_by(PlaylistItem.playlist_id) ).all() ) + durations = dict( + db.execute( + select( + PlaylistItem.playlist_id, + func.coalesce(func.sum(Video.duration_seconds), 0), + ) + .join(Video, Video.id == PlaylistItem.video_id) + .where(PlaylistItem.playlist_id.in_(ids)) + .group_by(PlaylistItem.playlist_id) + ).all() + ) member: set[int] = set() if contains: member = set( @@ -94,7 +124,13 @@ def list_playlists( .all() ) return [ - _summary(db, p, counts.get(p.id, 0), (p.id in member) if contains else None) + _summary( + db, + p, + counts.get(p.id, 0), + durations.get(p.id, 0), + (p.id in member) if contains else None, + ) for p in pls ] @@ -339,7 +375,7 @@ def rename_playlist( count = db.scalar( select(func.count()).where(PlaylistItem.playlist_id == pl.id) ) - return _summary(db, pl, count or 0) + return _summary(db, pl, count or 0, _total_duration(db, pl.id)) @router.delete("/{playlist_id}") diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 0053e4e..6ccf771 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -17,10 +17,13 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { + ArrowDown, + ArrowUp, Check, GripVertical, ListPlus, Pencil, + Pin, Play, Plus, RefreshCw, @@ -37,29 +40,34 @@ import PlayerModal from "./PlayerModal"; import UndoToolbar from "./UndoToolbar"; import { useConfirm } from "./ConfirmProvider"; -type SortMode = "manual" | "title-asc" | "title-desc" | "dur-asc" | "dur-desc"; +type SortKey = "manual" | "title" | "duration" | "channel"; +type SortDir = "asc" | "desc"; -function comparator(mode: SortMode): ((a: Video, b: Video) => number) | null { - switch (mode) { - case "title-asc": - return (a, b) => (a.title ?? "").localeCompare(b.title ?? ""); - case "title-desc": - return (a, b) => (b.title ?? "").localeCompare(a.title ?? ""); - case "dur-asc": - return (a, b) => (a.duration_seconds ?? Infinity) - (b.duration_seconds ?? Infinity); - case "dur-desc": - return (a, b) => (b.duration_seconds ?? -Infinity) - (a.duration_seconds ?? -Infinity); - default: - return null; - } +function comparator(key: SortKey, dir: SortDir): ((a: Video, b: Video) => number) | null { + if (key === "manual") return null; + const sign = dir === "asc" ? 1 : -1; + if (key === "duration") + return (a, b) => { + const av = a.duration_seconds; + const bv = b.duration_seconds; + if (av == null && bv == null) return 0; + if (av == null) return 1; // nulls always last, both directions + if (bv == null) return -1; + return sign * (av - bv); + }; + return (a, b) => { + const av = (key === "channel" ? a.channel_title : a.title) ?? ""; + const bv = (key === "channel" ? b.channel_title : b.title) ?? ""; + return sign * av.localeCompare(bv); + }; } // Sort a playlist's items. When `group` is on, items are grouped by channel (groups ordered -// by channel name A–Z) and the chosen sort is applied within each group; with sort "manual" -// the within-group order is left as-is. Returns the same array reference when nothing would -// change, so it doesn't create a spurious undo entry. -function sortItems(items: Video[], mode: SortMode, group: boolean): Video[] { - const cmp = comparator(mode); +// by channel name in the chosen direction) and the chosen sort is applied within each group; +// with key "manual" the within-group order is left as-is. Returns the same array reference +// when nothing would change, so it doesn't create a spurious undo entry. +function sortItems(items: Video[], key: SortKey, dir: SortDir, group: boolean): Video[] { + const cmp = comparator(key, dir); if (!group) return cmp ? [...items].sort(cmp) : items; const groups = new Map(); for (const v of items) { @@ -68,7 +76,8 @@ function sortItems(items: Video[], mode: SortMode, group: boolean): Video[] { if (g) g.push(v); else groups.set(k, [v]); } - const keys = [...groups.keys()].sort((a, b) => a.localeCompare(b)); + const sign = dir === "asc" ? 1 : -1; + const keys = [...groups.keys()].sort((a, b) => sign * a.localeCompare(b)); const out: Video[] = []; for (const k of keys) { const g = groups.get(k)!; @@ -83,6 +92,9 @@ const idsKey = (items: Video[]) => .sort() .join(","); +type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: SortDir; dirtyFirst: boolean }; +const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false }; + function Row({ video, index, @@ -160,7 +172,29 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); - const [selectedId, setSelectedId] = useState(null); + // Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL). + const [selectedId, setSelectedId] = useState(() => { + const s = localStorage.getItem("siftlode.playlist"); + return s ? Number(s) : null; + }); + useEffect(() => { + if (selectedId != null) localStorage.setItem("siftlode.playlist", String(selectedId)); + }, [selectedId]); + const selectedRef = useRef(null); + const scrolledRef = useRef(false); + // How the left playlist rail is ordered (persisted). + const [plSort, setPlSort] = useState(() => { + try { + const s = localStorage.getItem("siftlode.plSort"); + if (s) return { ...PL_SORT_DEFAULT, ...JSON.parse(s) }; + } catch { + /* ignore */ + } + return PL_SORT_DEFAULT; + }); + useEffect(() => { + localStorage.setItem("siftlode.plSort", JSON.stringify(plSort)); + }, [plSort]); const [newName, setNewName] = useState(""); const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(""); @@ -175,7 +209,8 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { }, }); const items = order.value; - const [sortMode, setSortMode] = useState("manual"); + const [sortKey, setSortKey] = useState("manual"); + const [sortDir, setSortDir] = useState("asc"); const [groupBy, setGroupBy] = useState(false); // Tracks the membership (id set) currently loaded into `order`, so a refetch that only // re-confirms our own reorder doesn't wipe the undo history — we reset history only when @@ -225,20 +260,21 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { // Reset the sort controls when switching playlists (the order itself is per-playlist). useEffect(() => { - setSortMode("manual"); + setSortKey("manual"); + setSortDir("asc"); setGroupBy(false); }, [selectedId]); - function applySort(mode: SortMode, group: boolean) { - order.set(sortItems(items, mode, group)); + function applySort(key: SortKey, dir: SortDir, group: boolean) { + order.set(sortItems(items, key, dir, group)); } const undo = () => { - setSortMode("manual"); + setSortKey("manual"); setGroupBy(false); order.undo(); }; const redo = () => { - setSortMode("manual"); + setSortKey("manual"); setGroupBy(false); order.redo(); }; @@ -250,6 +286,35 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { // Watch later is a built-in playlist: show a localized name and no rename/delete. const plName = (p: { kind: string; name: string }) => p.kind === "watch_later" ? t("playlists.watchLater") : p.name; + + // The left rail in the chosen order. "custom" keeps the server position order (Array.sort + // is stable); "dirty first" floats playlists with unpushed edits to the top. + const sortedPlaylists = useMemo(() => { + const sign = plSort.dir === "asc" ? 1 : -1; + return [...playlists].sort((a, b) => { + if (plSort.dirtyFirst && a.dirty !== b.dirty) return a.dirty ? -1 : 1; + switch (plSort.key) { + case "name": + return sign * plName(a).localeCompare(plName(b)); + case "count": + return sign * (a.item_count - b.item_count); + case "duration": + return sign * ((a.total_duration_seconds ?? 0) - (b.total_duration_seconds ?? 0)); + default: + return 0; // custom: server order + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playlists, plSort]); + + // Scroll the restored selection into view once after the rail first renders. + useEffect(() => { + if (!scrolledRef.current && selectedRef.current) { + selectedRef.current.scrollIntoView({ block: "nearest" }); + scrolledRef.current = true; + } + }, [sortedPlaylists]); + const builtin = detail?.kind === "watch_later"; // YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips // a dirty mirror so it won't clobber unpushed edits); the chip just marks their origin. @@ -349,7 +414,7 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { const newIndex = items.findIndex((v) => v.id === over.id); if (oldIndex < 0 || newIndex < 0) return; // A manual drag breaks any active sort/grouping; reflect that in the controls. - setSortMode("manual"); + setSortKey("manual"); setGroupBy(false); order.set(arrayMove(items, oldIndex, newIndex)); // onApply persists } @@ -432,13 +497,56 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { + {playlists.length > 1 && ( +
+ + + +
+ )} {playlists.length === 0 && !listQuery.isLoading && (
{t("playlists.noneYet")}
)}
- {playlists.map((pl) => ( + {sortedPlaylists.map((pl) => (
{t("playlists.itemCount", { count: pl.item_count })} + {pl.total_duration_seconds > 0 && + ` · ${formatDuration(pl.total_duration_seconds)}`}
@@ -589,27 +699,42 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
{t("playlists.sortLabel")} +