Files
siftlode/frontend/src/components/PlaylistsRail.tsx
T
peter 4fe6f38f1b refactor(rail): extract shared FilterRail shell + name the MediaSource seam (E4 S7)
The three filter rails — feed Sidebar, PlexSidebar, PlaylistsRail — each rendered
the same SidePanel chrome (title bar, customize `editing` state, edit-toggle,
reset-to-layout-default, edit hint) around a PanelGroups body, differing only in
identity + filter content. Extracted that shell into components/FilterRail.tsx:
each rail now supplies only what differs (title/icon, active-filter count +
Clear-all action, its `items`, and optional content above/below the groups —
`top` for the feed's scope toggle, `children` for its tag manager). FilterRail
owns the `editing` state, so all three drop their duplicated useState + SidePanel
wiring.

MediaSource facade (lib/mediaSource.ts): a thin `MediaSourceKind = "feed" | "plex"`
naming the two browsable, filterable sources (a PanelId minus "playlists", which
is a rail but not a source). Already unified in practice — one SortControl, the
source-agnostic ActiveFilter[] chips, PanelId layouts, now the shared shell — so
this is formalization, not new capability: a single named seam for a future third
source or a cross-source feature. The two media-source rails annotate their `kind`
with it; PlaylistsRail passes the plain PanelId. Per the recon, the playlist-dialog
merge was deliberately NOT forced (it proved forced + low value). UX-3 control-vocab
sweep deliberately deferred — the rails already use the canonical primitives.

Behaviour-preserving. Gate green (tsc, knip, lint 0-err, 247 vitest). Self-E2E
(localdev): all three rails render via FilterRail — feed (scope toggle + panel
groups + Customize→editing with reset + 56 reorder affordances, exits clean), Plex
(filter groups + customize), Playlists (sections + customize); 0 JS console errors.
2026-08-20 00:47:09 +02:00

274 lines
10 KiB
TypeScript

import { 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, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api";
import { formatDuration } 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 { type PanelItem } from "./PanelGroups";
import SortControl from "./SortControl";
import { type SortOption } from "../lib/columnSort";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { useMe } from "../lib/useMe";
import { playlistName } from "../lib/playlistName";
type PlSort = {
key: "custom" | "name" | "count" | "duration";
dir: "asc" | "desc";
dirtyFirst: boolean;
};
const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false };
// 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 } = useTranslation();
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" },
];
useEffect(() => {
writeAccount(LS.plSort, plSort);
}, [plSort]);
const [newName, setNewName] = useState("");
const [syncing, setSyncing] = useState(false);
const selectedRef = useRef<HTMLButtonElement | null>(null);
const scrolledRef = useRef(false);
const listQuery = useQuery({
queryKey: qk.playlists(),
queryFn: () => api.playlists(),
refetchOnWindowFocus: true,
});
const playlists = 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)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playlists, selectedId, listQuery.data]);
const plName = (p: { kind: string; name: string }) => playlistName(p, 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 * 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;
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playlists, plSort]);
const q = search.trim().toLowerCase();
const visiblePlaylists = useMemo(
() =>
q ? sortedPlaylists.filter((p) => plName(p).toLowerCase().includes(q)) : sortedPlaylists,
// eslint-disable-next-line react-hooks/exhaustive-deps
[sortedPlaylists, q],
);
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"));
} finally {
setSyncing(false);
}
}
const createMut = useMutation({
mutationFn: (name: string) => api.createPlaylist(name),
onSuccess: (pl: Playlist) => {
setNewName("");
qc.invalidateQueries({ queryKey: qk.playlists() });
setSelectedId(pl.id);
},
});
const items: PanelItem[] = [
{
id: "manage",
title: t("playlists.manageGroup"),
body: (
<div className="flex flex-col gap-2">
<button
onClick={syncYoutube}
disabled={syncing}
className="w-full inline-flex items-center justify-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs text-muted hover:border-accent hover:text-accent disabled:opacity-40 transition"
>
<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={
<button
onClick={() => setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })}
title={t("playlists.dirtyFirst")}
aria-pressed={plSort.dirtyFirst}
className="pill shrink-0 rounded-lg border p-1.5"
>
<Pin className="h-4 w-4" />
</button>
}
/>
)}
<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
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t("playlists.newPlaylist")}
disabled={!canWrite}
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40 disabled:opacity-50"
/>
</form>
</div>
),
},
{
id: "list",
title: t("playlists.listGroup"),
body: (
<>
{playlists.length === 0 &&
(listQuery.isError && !listQuery.data ? (
<button
type="button"
onClick={() => listQuery.refetch()}
className="text-xs text-danger px-1 py-1 text-left hover:underline"
>
{t("common.loadError")} — {t("common.retry")}
</button>
) : 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) => (
<button
key={pl.id}
ref={pl.id === selectedId ? selectedRef : null}
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="flex items-center gap-1 text-[0.8125rem] text-fg">
<span className="truncate">{plName(pl)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube
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 &&
` · ${formatDuration(pl.total_duration_seconds)}`}
</div>
</div>
</button>
))}
</div>
</>
),
},
];
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}
/>
);
}