Files
siftlode/frontend/src/components/PlaylistsRail.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

407 lines
18 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { ListVideo, Pin, Plus, RefreshCw } from "lucide-react";
import { YouTubeIcon } from "../lib/brandIcons";
import { api, type Playlist } from "../lib/api";
import { compareText } from "../lib/collate";
import { formatRuntime } from "../lib/format";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
import { type PanelLayout } from "../lib/panelLayout";
import FilterRail from "./FilterRail";
import { Button } from "./ui/Button";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { useComfortPin, pinPlaylist } from "./comfortPin";
import { ContextMenu } from "./ui/ContextMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { type PanelItem } from "./ui/PanelGroups";
import SortControl from "./ui/SortControl";
import { type SortOption } from "../lib/columnSort";
import { usePlaylists, usePlaylistsActions } from "./playlistsContext";
import { useMe } from "../lib/useMe";
import { playlistName } from "../lib/playlistName";
import { usePlaylistActions } from "./usePlaylistActions";
import PlaylistLimitsHint from "./PlaylistLimitsHint";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { MoreButton } from "./ui/MoreButton";
type PlSort = {
key: "custom" | "name" | "count" | "duration" | "recent";
dir: "asc" | "desc";
dirtyFirst: boolean;
};
const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false };
// One geometry for the row in both of its shapes (the selectable button, and the rename field it
// becomes) — the row must not resize the moment you rename it.
const ROW_BASE = "w-full flex items-center gap-2.5 p-2 rounded-lg border transition";
// The Playlists module's left rail — lifted to App level (like the filter rails) so it floats in the
// shared SidePanel with the same collapse-to-tab behavior. It owns the list query + its own rail
// controls (sort / new / sync); the selected playlist is App state so the module's detail pane
// (Playlists.tsx) reacts to it. Grouped into two islands: "manage" and the playlist list.
export default function PlaylistsRail({
layout,
setLayout,
collapsed,
onToggleCollapse,
}: {
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
collapsed: boolean;
onToggleCollapse: () => void;
}) {
const canWrite = useMe().can_write;
// The name filter + the selected playlist live in PlaylistsProvider (shared with the header search
// box and the Playlists detail pane); this rail owns the list query + its own controls.
const { search, selectedId } = usePlaylists();
const { setSelectedId } = usePlaylistsActions();
const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient();
const [plSort, setPlSort] = useState<PlSort>(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT));
const plSortOptions: SortOption[] = [
{ key: "custom", label: t("playlists.railSortCustom"), directionless: true },
{ key: "name", label: t("playlists.railSortName"), defaultDir: "asc" },
{ key: "count", label: t("playlists.railSortCount"), defaultDir: "desc" },
{ key: "duration", label: t("playlists.railSortDuration"), defaultDir: "desc" },
// "the one I just touched, first". Reads `updated_at`, which the API bumps for item
// adds/removes/moves/reorders too, not just renames (see the backend's _touch). NOT the
// default: a rail that reshuffles itself while you look at it costs more than it saves.
{ key: "recent", label: t("playlists.railSortRecent"), defaultDir: "desc" },
];
useEffect(() => {
writeAccount(LS.plSort, plSort);
}, [plSort]);
const [newName, setNewName] = useState("");
const [syncing, setSyncing] = useState(false);
const plActions = usePlaylistActions();
// Right-click / long-press anywhere on a rail row. ONE menu for the whole list (see ui/ContextMenu).
const railMenu = useContextMenu<Playlist>();
// The comfort-shelf pin composes INTO this menu rather than mounting a second one: a row can only
// have one right-click menu, and rename/delete/pin are all things you do to the same playlist.
const comfortPin = useComfortPin(railMenu.open);
// Renaming happens IN the row that was right-clicked, not in the detail header — the point of the
// context menu is that the action lands where the gesture did.
const [renamingId, setRenamingId] = useState<number | null>(null);
const [renameValue, setRenameValue] = useState("");
const selectedRef = useRef<HTMLButtonElement | null>(null);
const scrolledRef = useRef(false);
const listQuery = useQuery({
queryKey: qk.playlists(),
queryFn: () => api.playlists(),
refetchOnWindowFocus: true,
});
// Memoised so the effect and the sort below depend on a value that only changes with the query —
// `?? []` would otherwise mint a new empty array on every render while the list is loading.
const playlists = useMemo(() => listQuery.data ?? [], [listQuery.data]);
// Keep the selection valid: default to the first playlist, and if the selected one disappears
// drop to the next available (or clear when none remain). Waits for the first load.
useEffect(() => {
if (!listQuery.data) return;
if (!playlists.length) {
if (selectedId != null) setSelectedId(null);
return;
}
if (selectedId == null || !playlists.some((p) => p.id === selectedId)) {
setSelectedId(playlists[0]!.id); // playlists is non-empty (the length guard above returns)
}
}, [playlists, selectedId, listQuery.data, setSelectedId]);
// useCallback because the two memos below sort by it: as a fresh closure each render it could not
// be named in their dep lists, and `t` — which is what actually changes it, on a language switch —
// was therefore invisible to them. A rename in the other language now re-sorts.
const plName = useCallback((p: { kind: string; name: string }) => playlistName(p, t), [t]);
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 * compareText(plName(a), plName(b), lang);
case "count":
return sign * (a.item_count - b.item_count);
case "duration":
return sign * ((a.total_duration_seconds ?? 0) - (b.total_duration_seconds ?? 0));
case "recent":
return sign * (Date.parse(a.updated_at) - Date.parse(b.updated_at));
default:
return 0;
}
});
}, [playlists, plSort, plName, lang]);
const q = search.trim().toLowerCase();
const visiblePlaylists = useMemo(
() =>
q ? sortedPlaylists.filter((p) => plName(p).toLowerCase().includes(q)) : sortedPlaylists,
[sortedPlaylists, q, plName],
);
useEffect(() => {
if (!scrolledRef.current && selectedRef.current) {
selectedRef.current.scrollIntoView({ block: "nearest" });
scrolledRef.current = true;
}
}, [sortedPlaylists]);
async function syncYoutube() {
if (syncing) return;
setSyncing(true);
try {
const r = await api.syncYoutubePlaylists();
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() });
notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) });
} catch (e) {
notifyYouTubeActionError(e, t("playlists.syncFailed"));
}
setSyncing(false);
}
const createMut = useMutation({
mutationFn: (name: string) => api.createPlaylist(name),
meta: { failTitle: "errors.writeFailed.createPlaylist" },
onSuccess: (pl: Playlist) => {
setNewName("");
qc.invalidateQueries({ queryKey: qk.playlists() });
setSelectedId(pl.id);
},
});
// Enter and Escape unmount the focused <input> and re-render the row as a NEW <button>, so without
// this a keyboard user was dropped on <body> and lost their place in the rail.
// The row is focused from its ref callback, i.e. as that button attaches: measured in Chrome, a
// focus issued after the commit sticks and nothing takes it back — the first attempt that
// "never worked" focused before the replacement button existed. Set by Enter/Escape only: a
// blur-commit means the user put the focus somewhere else on purpose.
const refocusRowRef = useRef<number | null>(null);
const renameInputRef = useRef<HTMLInputElement | null>(null);
// Opening this field from the context menu used to race the menu's own focus return (floating-ui
// hands the focus back to the trigger from a microtask queued as the popover unmounts, and the field
// mounted in that same commit). `MenuItemList` now runs the item's action on a fresh task AFTER that
// return, so the field simply mounts and is focused here; measured in Chrome, the field is
// focused and survives.
useEffect(() => {
if (renamingId !== null) renameInputRef.current?.focus();
}, [renamingId]);
async function commitRename(pl: Playlist) {
const name = renameValue.trim();
setRenamingId(null);
if (name && name !== pl.name) await plActions.rename(pl.id, name);
}
const ctxPl = railMenu.target;
// The list's ONE menu (usePlaylistActions.items), the same the page header's "…" opens; the rail
// renames IN the row that was right-clicked — the action lands where the gesture did.
const railItems: ActionMenuItem[] = !ctxPl
? []
: plActions.items(ctxPl, {
onRename: () => {
setRenameValue(ctxPl.name);
setRenamingId(ctxPl.id);
},
pin: comfortPin.item(pinPlaylist(ctxPl.id), plName(ctxPl)),
});
const items: PanelItem[] = [
{
id: "manage",
title: t("playlists.manageGroup"),
body: (
<div className="flex flex-col gap-2">
<Button variant="outline" size="xs" fullWidth onClick={syncYoutube} disabled={syncing}>
<RefreshCw className={`w-3.5 h-3.5 ${syncing ? "animate-spin" : ""}`} />
{t("playlists.syncYoutube")}
</Button>
{playlists.length > 1 && (
<SortControl
label={t("playlists.sortLabel")}
ascLabel={t("playlists.dirAsc")}
descLabel={t("playlists.dirDesc")}
options={plSortOptions}
sort={{ key: plSort.key, dir: plSort.dir }}
onChange={(next) =>
next && setPlSort({ ...plSort, key: next.key as PlSort["key"], dir: next.dir })
}
trailing={
<IconButton
variant="outline"
onClick={() => setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })}
label={t("playlists.dirtyFirst")}
pressed={plSort.dirtyFirst}
>
<Pin />
</IconButton>
}
/>
)}
<form
onSubmit={(e) => {
e.preventDefault();
if (newName.trim()) createMut.mutate(newName.trim());
}}
className="flex items-center gap-1.5"
>
<Plus className="w-4 h-4 text-muted shrink-0" />
<Input
className="flex-1"
size="sm"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t("playlists.newPlaylist")}
disabled={!canWrite}
/>
</form>
</div>
),
},
{
id: "list",
title: t("playlists.listGroup"),
body: (
<>
{playlists.length === 0 &&
(listQuery.isError && !listQuery.data ? (
<Pressable
onClick={() => listQuery.refetch()}
className="text-xs text-danger px-1 py-1 hover:underline"
>
{t("common.loadError")} — {t("common.retry")}
</Pressable>
) : listQuery.isLoading ? (
<div className="text-xs text-muted px-1 py-1">{t("common.loading")}</div>
) : (
<div className="text-xs text-muted px-1 py-1">{t("playlists.noneYet")}</div>
))}
{playlists.length > 0 && visiblePlaylists.length === 0 && (
<div className="text-xs text-muted px-1 py-1">{t("playlists.noMatches")}</div>
)}
<div className="space-y-1">
{visiblePlaylists.map((pl) =>
renamingId === pl.id ? (
// The row becomes its own rename field. A <button> can't contain an <input>, so the
// whole row swaps rather than the label alone — but it KEEPS the cover, because
// that is what holds the row at its two-line height. Without it the rail jumps by
// ~12px the moment you start renaming, and every row below it moves under the
// pointer.
<div key={pl.id} className={`${ROW_BASE} bg-accent/10 border-accent`}>
<div className="w-8.5 h-8.5 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>
<Input
className="flex-1"
size="sm"
ref={renameInputRef}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => void commitRename(pl)}
onKeyDown={(e) => {
// preventDefault: the focus lands on the row before this keystroke is done,
// and a real Enter's own activation would then press it (see useReturnFocus).
if (e.key === "Enter") {
e.preventDefault();
refocusRowRef.current = pl.id;
void commitRename(pl);
}
// Unmounting the input fires no blur in React, so Escape really does cancel.
if (e.key === "Escape") {
e.preventDefault();
refocusRowRef.current = pl.id;
setRenamingId(null);
}
}}
aria-label={t("playlists.rename")}
/>
</div>
) : (
// `group`: the row's "…" shows on its hover or focus (`.reveal`), and always on touch.
// A sibling of the row button, not inside it — a button cannot hold a button.
<div key={pl.id} className="group relative" {...railMenu.triggerProps(pl)}>
<Pressable
selectable
ref={(el) => {
if (pl.id === selectedId) selectedRef.current = el;
// Back from the rename field by Enter/Escape — see refocusRowRef.
if (el && refocusRowRef.current === pl.id) {
refocusRowRef.current = null;
el.focus();
}
}}
onClick={() => setSelectedId(pl.id)}
layer={pl.id !== selectedId}
// pr-9: the "…" sits over the row's right end, so the name truncates short of it.
className={`${ROW_BASE} pr-9 ${
pl.id === selectedId ? "bg-accent/15 border-accent" : "border-transparent"
}`}
>
<div className="w-8.5 h-8.5 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="flex items-center gap-1 text-[0.8125rem] text-fg">
<span className="truncate">{plName(pl)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<YouTubeIcon
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
/>
)}
</div>
<div className="text-2xs text-muted">
{t("playlists.itemCount", { count: pl.item_count })}
{pl.total_duration_seconds > 0 &&
` · ${formatRuntime(pl.total_duration_seconds, lang)}`}
</div>
</div>
</Pressable>
<MoreButton
label={t("playlists.railMenu")}
expanded={railMenu.open && railMenu.target === pl}
menuProps={railMenu.buttonProps(pl)}
className={`absolute right-1.5 top-1/2 -translate-y-1/2 ${
railMenu.open && railMenu.target === pl ? "" : "reveal"
}`}
/>
</div>
),
)}
</div>
<ContextMenu menu={railMenu} ariaLabel={t("playlists.railMenu")} items={railItems} />
<PlaylistLimitsHint />
</>
),
},
];
return (
<FilterRail
kind="playlists"
title={t("playlists.title")}
icon={<ListVideo className="w-4 h-4" />}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
layout={layout}
setLayout={setLayout}
items={items}
/>
);
}