{view === "subscribed" && statsBlock}
{view === "subscribed" && layout === "cards" && sortControl}
@@ -458,41 +425,35 @@ export default function Channels() {
search lives in the shared top SearchBar, tag create/delete in the manager. */}
+ {/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate
+ "Manage tags" button; the tooltip explains what tags are + that it's clickable. */}
-
- {t("channels.tags.yourTags")}
-
+ setTagManagerOpen(true)}
+ className="mr-1 inline-flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-xs uppercase tracking-wide text-muted transition hover:border-accent hover:text-accent"
+ >
+
+ {t("channels.tags.label")}
+
- {userTags.map((tg) => {
- const active = tagFilter.includes(tg.id);
- return (
-
- setTagFilter((f) => (active ? f.filter((x) => x !== tg.id) : [...f, tg.id]))
- }
- aria-pressed={active}
- className="pill text-xs px-2 py-1 rounded-full border"
- >
- {tg.name}
-
- );
- })}
+ {userTags.map((tg) => (
+
toggleTagFilter(tg.id)}
+ aria-pressed={tagFilter.includes(String(tg.id))}
+ className={filterChipClass}
+ >
+ {tg.name}
+
+ ))}
{tagFilter.length > 0 && (
setTagFilter([])}
+ onClick={() => setFilters({ ...filters, tags: [] })}
className="text-xs px-2 py-1 text-muted hover:text-accent transition"
>
{t("datatable.clear")}
)}
-
setTagManagerOpen(true)}
- className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
- >
-
- {t("channels.tags.manage")}
-
{actionButtons}
@@ -521,37 +482,33 @@ export default function Channels() {
) : view === "blocked" ? (
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
- {(blockedQuery.data?.length ?? 0) === 0 ? (
-
{t("channels.blocked.empty")}
- ) : (
- <>
-
-
-
- {t("channels.blocked.title")}
-
-
-
- {blockedQuery.data!.map((c) => (
-
- {c.title ?? c.id}
- unblock.mutate(c.id)}
- title={t("channel.unblock")}
- aria-label={t("channel.unblock")}
- className="rounded-full p-0.5 text-muted hover:text-danger transition"
- >
-
-
-
- ))}
-
-
{t("channels.blocked.hint")}
- >
- )}
+
}
+ title={t("channels.blocked.title")}
+ hint={t("channels.blocked.hint")}
+ emptyText={t("channels.blocked.empty")}
+ actionLabel={t("channel.unblock")}
+ actionIcon={
}
+ onAction={(id) => unblock.mutate(id)}
+ onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
+ />
+
+ ) : view === "hidden" ? (
+ /* Hidden channels — kept out of the Mine feed but still in the shared Library; managed here
+ instead of dimmed in the subscribed table. Same card grid as Blocked (ExcludedChannelList). */
+
+ }
+ title={t("channels.hidden.title")}
+ hint={t("channels.hidden.hint")}
+ emptyText={t("channels.hidden.empty")}
+ actionLabel={t("channels.hidden.unhide")}
+ actionIcon={ }
+ onAction={(id) => patch.mutate({ id, body: { hidden: false } })}
+ onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
+ />
) : (
/* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when
@@ -580,7 +537,8 @@ export default function Channels() {
controlsBandClassName="px-4 max-w-384 mx-auto"
sort={sort}
onSortChange={setSort}
- rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
+ filters={filters}
+ onFiltersChange={setFilters}
emptyText={t("channels.empty")}
/>
) : (
@@ -591,7 +549,6 @@ export default function Channels() {
leadKey="priority"
aboutKey="about"
actionsKey="actions"
- rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
emptyText={t("channels.empty")}
/>
)}
@@ -600,13 +557,3 @@ export default function Channels() {
>
);
}
-
-function Stat({ label, value, hint }: { label: string; value: string | number; hint?: string }) {
- return (
-
-
- {value} {label}
-
-
- );
-}
diff --git a/frontend/src/components/ChannelsProvider.dom.test.tsx b/frontend/src/components/ChannelsProvider.dom.test.tsx
new file mode 100644
index 00000000..5012e794
--- /dev/null
+++ b/frontend/src/components/ChannelsProvider.dom.test.tsx
@@ -0,0 +1,73 @@
+// @vitest-environment jsdom
+//
+// ChannelsProvider's tab-history behaviour: setView pushes a `_chTab` history entry + persists the tab,
+// and a genuine browser Back (popstate carrying `_chTab`) restores + re-persists it. This wiring was
+// re-routed through usePersistedTab in R13 S2b, so it's exercised here rather than only by hand E2E.
+//
+// Uses REAL storage (jsdom localStorage/sessionStorage) with the tab's account pinned to 1, so
+// usePersistedTab / useSortState / useFilterState all resolve their account-scoped keys; only
+// NavigationProvider (the sole runtime dependency) is mocked, pinned to the channels page.
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import type { ReactNode } from "react";
+
+vi.mock("./NavigationProvider", () => ({
+ useNavigation: () => ({ page: "channels" }),
+ useNavigationActions: () => ({ setPage: vi.fn() }),
+}));
+
+import { ChannelsProvider, useChannels, useChannelsActions } from "./ChannelsProvider";
+
+const wrapper = ({ children }: { children: ReactNode }) => (
+
{children}
+);
+const render = () => renderHook(() => ({ ...useChannels(), ...useChannelsActions() }), { wrapper });
+const VIEW_KEY = "siftlode.channelsView.1"; // account-scoped (accountKey suffixes the pinned id)
+
+beforeEach(() => {
+ localStorage.clear();
+ sessionStorage.setItem("siftlode.activeAccount", "1"); // pin the tab's account
+ window.history.replaceState({ sfPage: "channels" }, ""); // setView only owns an entry on this page
+});
+
+describe("ChannelsProvider tab history", () => {
+ it("setView pushes a _chTab history entry and persists the tab", () => {
+ const { result } = render();
+ expect(result.current.view).toBe("subscribed"); // default
+
+ act(() => result.current.setView("hidden"));
+
+ expect(result.current.view).toBe("hidden");
+ expect(window.history.state).toMatchObject({ sfPage: "channels", _chTab: "hidden" });
+ expect(localStorage.getItem(VIEW_KEY)).toBe("hidden");
+ });
+
+ it("does NOT push a new entry when re-selecting the current tab", () => {
+ const { result } = render();
+ const spy = vi.spyOn(window.history, "pushState");
+ act(() => result.current.setView("subscribed")); // already the current tab
+ expect(spy).not.toHaveBeenCalled();
+ spy.mockRestore();
+ });
+
+ it("a genuine Back (popstate carrying _chTab) restores and re-persists that tab", () => {
+ const { result } = render();
+ act(() => result.current.setView("blocked"));
+ expect(result.current.view).toBe("blocked");
+
+ // Browser Back to an entry stamped with a different tab.
+ act(() =>
+ window.dispatchEvent(new PopStateEvent("popstate", { state: { _chTab: "discovery" } })),
+ );
+
+ expect(result.current.view).toBe("discovery");
+ expect(localStorage.getItem(VIEW_KEY)).toBe("discovery");
+ });
+
+ it("ignores a popstate for an entry outside the manager (no _chTab)", () => {
+ const { result } = render();
+ act(() => result.current.setView("hidden"));
+ act(() => window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "feed" } })));
+ expect(result.current.view).toBe("hidden"); // unchanged
+ });
+});
diff --git a/frontend/src/components/ChannelsProvider.tsx b/frontend/src/components/ChannelsProvider.tsx
index 6b6680ce..c4d5b26f 100644
--- a/frontend/src/components/ChannelsProvider.tsx
+++ b/frontend/src/components/ChannelsProvider.tsx
@@ -8,41 +8,47 @@ import {
useState,
type ReactNode,
} from "react";
-import { getAccountRaw, LS, readAccount, setAccountRaw, writeAccount } from "../lib/storage";
+import { getAccountRaw, LS } from "../lib/storage";
import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout";
-import { parseSortState, type SortState } from "../lib/columnSort";
+import { type SortState } from "../lib/columnSort";
+import { useSortState } from "../lib/useSortState";
+import { useFilterState } from "../lib/useFilterState";
+import { usePersistedTab } from "./Tabs";
+import type { FilterMap } from "./tableHeader";
import { useNavigation, useNavigationActions } from "./NavigationProvider";
-import type { ChannelStatusFilter, ChannelsView } from "./Channels";
+import type { ChannelsView } from "./Channels";
-// Owns the Channels module's shared local state — the rail-less manager's name filter, status chip,
-// active tab, the "drop a stale column filter" token, and the focus-a-channel intent — lifted out of
+// Owns the Channels module's shared local state — the rail-less manager's name filter, column-filter
+// map, active tab, the "drop a stale column filter" token, and the focus-a-channel intent — lifted out of
// App so the Channels page, the header search box, and the cross-module callers (the feed filter
// sidebar and the notifications inbox both "focus this channel in the manager") read it via hooks
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
// stable useCallback setters, one-line hooks; composed in main.tsx around
).
//
// Nested INSIDE NavigationProvider so its actions can drive navigation: focusChannel and
-// goToFullHistory both jump to the Channels page (and force the subscriptions tab). status / view are
+// goToFullHistory both jump to the Channels page (and force the subscriptions tab). filters / view are
// per-account persisted (localStorage); search / focus / the reset token are ephemeral. An account
// switch does a full reload, so init-from-storage is all the hydration the persisted ones need.
-const STATUS_FILTERS: readonly ChannelStatusFilter[] = [
- "needs_full",
- "fully_synced",
- "hidden",
- "all",
-];
-
-function clampStatus(v: string | null): ChannelStatusFilter {
- return STATUS_FILTERS.includes(v as ChannelStatusFilter) ? (v as ChannelStatusFilter) : "all";
-}
function clampView(v: string | null): ChannelsView {
- return v === "discovery" ? "discovery" : v === "blocked" ? "blocked" : "subscribed";
+ return v === "discovery" || v === "blocked" || v === "hidden" ? v : "subscribed";
+}
+
+// One-time carry-forward of the retired single-value sync chip (LS.legacyChannelFilter, vocab
+// all/needs_full/fully_synced) into the new filter map's `sync` key (vocab ""/needsFull/fully), so a
+// user who had a chip pinned keeps it. Only consulted when channelsFilterMap is still empty.
+function migrateSyncChip(): FilterMap | null {
+ const old = getAccountRaw(LS.legacyChannelFilter);
+ if (old === "needs_full") return { sync: "needsFull" };
+ if (old === "fully_synced") return { sync: "fully" };
+ return null;
}
type ChannelsState = {
search: string;
- statusFilter: ChannelStatusFilter;
+ // The subscribed table's column filters, lifted out of DataTable so the top status chips, the Sync
+ // column funnel, and the card layout share ONE filter state (the sync chip lives at `filters.sync`).
+ filters: FilterMap;
view: ChannelsView;
// The subscribed table's sort, lifted out of DataTable so the card layout shares it.
sort: SortState;
@@ -57,7 +63,7 @@ type ChannelsState = {
interface ChannelsActions {
setSearch: (q: string) => void;
- setStatusFilter: (f: ChannelStatusFilter) => void;
+ setFilters: (f: FilterMap) => void;
setView: (v: ChannelsView) => void;
setLayout: (l: ChannelLayout) => void;
setSort: (s: SortState) => void;
@@ -70,7 +76,7 @@ interface ChannelsActions {
const StateContext = createContext
({
search: "",
- statusFilter: "all",
+ filters: {},
view: "subscribed",
sort: null,
filtersResetToken: 0,
@@ -83,7 +89,7 @@ const StateContext = createContext({
const LayoutContext = createContext("table");
const ActionsContext = createContext({
setSearch: () => {},
- setStatusFilter: () => {},
+ setFilters: () => {},
setView: () => {},
setLayout: () => {},
setSort: () => {},
@@ -108,18 +114,20 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
const { setPage } = useNavigationActions();
const [search, setSearchState] = useState("");
- const [statusFilter, setStatusFilterState] = useState(() =>
- clampStatus(getAccountRaw(LS.channelFilter)),
- );
- const [view, setViewState] = useState(() =>
- clampView(getAccountRaw(LS.channelsView)),
- );
- const [layout, setLayoutState] = useState(() =>
- parseChannelLayout(getAccountRaw(LS.channelLayout)),
- );
- const [sort, setSortState] = useState(() =>
- parseSortState(readAccount(LS.channelSort, null)),
- );
+ // The subscribed table's column-filter map, owned here (lifted out of DataTable) so the top status
+ // chips, the Sync column funnel, and the card layout share one state; useFilterState persists it.
+ const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip);
+ // view + layout are persisted tabs (usePersistedTab = the shared account-scoped tab-state hook), so
+ // they get init-from-storage AND the account-pin re-hydrate for free (this provider mounts above
+ // , which pins the account only after /api/me). Stored un-clamped; clampView/parseChannelLayout
+ // heal them for display. setView layers the history logic (below) on top of the raw setter.
+ const [viewRaw, setViewRaw] = usePersistedTab(LS.channelsView, "subscribed");
+ const view = clampView(viewRaw);
+ const [layoutRaw, setLayout] = usePersistedTab(LS.channelLayout, "table");
+ const layout = parseChannelLayout(layoutRaw);
+ // Sort is owned here (lifted out of DataTable) so the card layout shares it; ColumnSortControl heals
+ // it against the live columns for display, so the provider stores it un-healed (no options passed).
+ const [sort, setSort] = useSortState(LS.channelSort);
const [filtersResetToken, setFiltersResetToken] = useState(0);
const [focusName, setFocusName] = useState(null);
const [focusToken, setFocusToken] = useState(0);
@@ -131,37 +139,26 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
viewRef.current = view;
const setSearch = useCallback((q: string) => setSearchState(q), []);
- const setStatusFilter = useCallback((f: ChannelStatusFilter) => {
- setStatusFilterState(f);
- setAccountRaw(LS.channelFilter, f);
- }, []);
// The active tab rides in history.state (`_chTab`), the same way the channel page (`_chan`) and the
// live YouTube search (`_yt`) do — so Back steps back through the tabs you actually visited instead
// of jumping straight out to the previous module.
- const setView = useCallback((v: ChannelsView) => {
- // Re-selecting the tab you are already on is not a navigation — bail before pushing, or clicking
- // the active tab (and goToFullHistory forcing "subscribed") stacks Back entries that do nothing.
- if (v === viewRef.current) return;
- setViewState(v);
- setAccountRaw(LS.channelsView, v);
- // Only own an entry while the manager is already on screen. Arriving from elsewhere
- // (focusChannel / goToFullHistory) lets setPage push the entry — the stamp effect labels it — so
- // one navigation never costs two Back presses.
- if (window.history.state?.sfPage === "channels") {
- // A FRESH state, like setPage's: carrying the old entry's `_chan`/`_yt` markers over would make
- // Back re-open the channel overlay or a spent search on top of the tab.
- window.history.pushState({ sfPage: "channels", _chTab: v }, "");
- }
- }, []);
- const setLayout = useCallback((l: ChannelLayout) => {
- setLayoutState(l);
- setAccountRaw(LS.channelLayout, l);
- }, []);
- const setSort = useCallback((s: SortState) => {
- setSortState(s);
- writeAccount(LS.channelSort, s);
- }, []);
-
+ const setView = useCallback(
+ (v: ChannelsView) => {
+ // Re-selecting the tab you are already on is not a navigation — bail before pushing, or clicking
+ // the active tab (and goToFullHistory forcing "subscribed") stacks Back entries that do nothing.
+ if (v === viewRef.current) return;
+ setViewRaw(v); // persists + guards the account-pin re-hydrate (via usePersistedTab)
+ // Only own an entry while the manager is already on screen. Arriving from elsewhere
+ // (focusChannel / goToFullHistory) lets setPage push the entry — the stamp effect labels it — so
+ // one navigation never costs two Back presses.
+ if (window.history.state?.sfPage === "channels") {
+ // A FRESH state, like setPage's: carrying the old entry's `_chan`/`_yt` markers over would make
+ // Back re-open the channel overlay or a spent search on top of the tab.
+ window.history.pushState({ sfPage: "channels", _chTab: v }, "");
+ }
+ },
+ [setViewRaw],
+ );
const focusChannel = useCallback(
(name: string) => {
setFocusName(name);
@@ -172,11 +169,13 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
[setView, setPage],
);
const goToFullHistory = useCallback(() => {
- setStatusFilter("needs_full");
- setView("subscribed"); // the status filter applies to the subscriptions tab
- setFiltersResetToken((n) => n + 1); // drop any stale column filter hiding the rows
+ // One controlled map replaces the old status chip: sync=needsFull, and dropping every other
+ // column filter (the whole map is set fresh) so no stale funnel hides the rows we're sent to see.
+ setFilters({ sync: "needsFull" });
+ setView("subscribed"); // the sync filter applies to the subscriptions tab
+ setFiltersResetToken((n) => n + 1); // also clear the ephemeral name search + tag chips
setPage("channels");
- }, [setStatusFilter, setView, setPage]);
+ }, [setFilters, setView, setPage]);
// Clear the manager focus when leaving the Channels page so it doesn't re-apply on a later visit.
useEffect(() => {
@@ -196,29 +195,27 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
function onPop(e: PopStateEvent) {
const tab = e.state?._chTab;
if (!tab) return; // an entry outside the manager — leave the tab alone
- const next = clampView(tab as string);
- setViewState(next);
- setAccountRaw(LS.channelsView, next);
+ setViewRaw(clampView(tab as string)); // persists the restored tab (setViewRaw is stable)
}
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
- }, []);
+ }, [setViewRaw]);
const state = useMemo(
- () => ({ search, statusFilter, view, sort, filtersResetToken, focusName, focusToken }),
- [search, statusFilter, view, sort, filtersResetToken, focusName, focusToken],
+ () => ({ search, filters, view, sort, filtersResetToken, focusName, focusToken }),
+ [search, filters, view, sort, filtersResetToken, focusName, focusToken],
);
const actions = useMemo(
() => ({
setSearch,
- setStatusFilter,
+ setFilters,
setView,
setLayout,
setSort,
focusChannel,
goToFullHistory,
}),
- [setSearch, setStatusFilter, setView, setLayout, setSort, focusChannel, goToFullHistory],
+ [setSearch, setFilters, setView, setLayout, setSort, focusChannel, goToFullHistory],
);
return (
diff --git a/frontend/src/components/ColumnSortControl.tsx b/frontend/src/components/ColumnSortControl.tsx
index d38d4d13..2599ac1c 100644
--- a/frontend/src/components/ColumnSortControl.tsx
+++ b/frontend/src/components/ColumnSortControl.tsx
@@ -1,11 +1,12 @@
-import { ArrowDown, ArrowUp } from "lucide-react";
import { type Column } from "./DataTable";
import { type SortState } from "../lib/columnSort";
+import { optionsFromColumns } from "../lib/sortOptions";
+import SortControl from "./SortControl";
-// A sort control for views without clickable column headers (the channel manager's card layout):
-// a dropdown of the sortable columns + an asc/desc toggle, mirroring the feed toolbar's sort. It's
-// driven by the SAME `SortState` the DataTable header uses, so table and cards stay in sync. The
-// dropdown's options come straight from the columns' `sortable`/`sortValue` metadata — no new list.
+// The headerless sort control for views without clickable column headers (the channel manager's and
+// discovery's card layouts). Now a thin adapter over the generic `SortControl`, feeding it the
+// column-derived options and a "no sort" default — kept as its own component so the existing call
+// sites stay untouched and table/cards keep sharing one `SortState`.
export default function ColumnSortControl({
columns,
sort,
@@ -25,44 +26,15 @@ export default function ColumnSortControl({
ascLabel: string;
descLabel: string;
}) {
- const sortable = columns.filter((c) => c.sortable && c.sortValue);
- // A stored sort can name a column this table no longer offers (renamed, or `sortable` dropped).
- // `sortRows` already leaves the rows unsorted then, so treat it as no sort here too — otherwise the
- // select falls back to showing "default" while a direction arrow claims a sort that isn't applied.
- const active = sortable.some((c) => c.key === sort?.key) ? sort : null;
return (
-
-
{
- const key = e.target.value;
- // Keep the direction when re-picking the same column; a new column starts ascending.
- onChange(key ? { key, dir: active?.key === key ? active.dir : "asc" } : null);
- }}
- aria-label={label}
- className="bg-surface border border-border rounded-lg px-2 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
- >
- {defaultLabel}
- {sortable.map((c) => (
-
- {c.header}
-
- ))}
-
- {active && (
-
onChange({ key: active.key, dir: active.dir === "asc" ? "desc" : "asc" })}
- title={active.dir === "asc" ? ascLabel : descLabel}
- aria-label={active.dir === "asc" ? ascLabel : descLabel}
- className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
- >
- {active.dir === "asc" ? (
-
- ) : (
-
- )}
-
- )}
-
+
);
}
diff --git a/frontend/src/components/CountBadge.tsx b/frontend/src/components/CountBadge.tsx
new file mode 100644
index 00000000..a99265bb
--- /dev/null
+++ b/frontend/src/components/CountBadge.tsx
@@ -0,0 +1,15 @@
+// A small count pill shown beside a tab label or filter chip (pending requests, item counts). One
+// source for the count-badge token so Tabs and FilterChipGroup can't drift. The `active` variant sits
+// on an accent-filled parent (a pressed tab/chip), so it tints against accent-fg; the resting variant
+// carries its own accent tint. Callers guard on `count > 0` — this just renders the pill.
+export default function CountBadge({ count, active }: { count: number; active?: boolean }) {
+ return (
+
+ {count}
+
+ );
+}
diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx
index a1750074..3c781b9b 100644
--- a/frontend/src/components/DataTable.tsx
+++ b/frontend/src/components/DataTable.tsx
@@ -25,6 +25,11 @@ interface Persisted {
size?: number;
}
+// Stable empty-map identity for the controlled-but-value-omitted fallback, so `filters` keeps a
+// constant reference across renders (a fresh `{}` would churn the persist effect + re-filter every
+// render). Mirrors controlled sort's stable `null` fallback.
+const EMPTY_FILTERS: FilterMap = {};
+
function loadPersist(key?: string): Persisted {
const empty: Persisted = { sort: null, filters: {}, page: 0 };
if (!key) return empty;
@@ -53,6 +58,8 @@ export default function DataTable({
resetFiltersToken,
sort: sortProp,
onSortChange,
+ filters: filtersProp,
+ onFiltersChange,
}: {
rows: T[];
columns: Column[];
@@ -82,6 +89,13 @@ export default function DataTable({
// persistKey-backed sort.
sort?: SortState;
onSortChange?: (next: SortState) => void;
+ // CONTROLLED filters (optional): symmetric to controlled sort. When `onFiltersChange` is given the
+ // caller owns the column-filter map (and its persistence) — so the channel manager's top status
+ // chips, the Sync column funnel, and the card layout all share ONE filter state — and the table
+ // reflects `filters` and reports funnel changes instead of keeping its own. Omit both for the
+ // default internal, persistKey-backed filters.
+ filters?: FilterMap;
+ onFiltersChange?: (next: FilterMap) => void;
}) {
const { t } = useTranslation();
const initial = loadPersist(persistKey);
@@ -90,17 +104,42 @@ export default function DataTable({
const controlledSort = onSortChange !== undefined;
const [internalSort, setInternalSort] = useState(initial.sort);
const sort = controlledSort ? (sortProp ?? null) : internalSort;
- const [filters, setFilters] = useState(initial.filters);
+ // Filters are controlled when the caller passes onFiltersChange (it then owns/persists the map);
+ // otherwise the table keeps its own, seeded from and saved to persistKey — symmetric to sort.
+ const controlledFilters = onFiltersChange !== undefined;
+ const [internalFilters, setInternalFilters] = useState(initial.filters);
+ const filters = controlledFilters ? (filtersProp ?? EMPTY_FILTERS) : internalFilters;
const [page, setPage] = useState(initial.page);
const [size, setSize] = useState(initial.size ?? pageSize);
+ // Commit a whole new filter map through the owner (controlled) or internal state. A ref holds the
+ // latest resolved map so the externalFilter/reset effects can derive from it without taking
+ // `filters` as a dep (which would re-run them on every filter change).
+ const filtersRef = useRef(filters);
+ filtersRef.current = filters;
+ const commitFilters = (next: FilterMap) => {
+ if (controlledFilters) onFiltersChange!(next);
+ else setInternalFilters(next);
+ };
+
+ // Persist page/size always, but only the sort/filter slices this table actually OWNS — a controlled
+ // caller persists those itself, so writing them here would just leave a stale, never-read duplicate.
useEffect(() => {
- if (persistKey) localStorage.setItem(persistKey, JSON.stringify({ sort, filters, page, size }));
- }, [persistKey, sort, filters, page, size]);
+ if (!persistKey) return;
+ localStorage.setItem(
+ persistKey,
+ JSON.stringify({
+ sort: controlledSort ? null : sort,
+ filters: controlledFilters ? {} : filters,
+ page,
+ size,
+ }),
+ );
+ }, [persistKey, controlledSort, controlledFilters, sort, filters, page, size]);
useEffect(() => {
if (!externalFilter) return;
- setFilters((f) => ({ ...f, [externalFilter.key]: externalFilter.value }));
+ commitFilters({ ...filtersRef.current, [externalFilter.key]: externalFilter.value });
setPage(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [externalFilter?.key, externalFilter?.value]);
@@ -113,8 +152,9 @@ export default function DataTable({
useEffect(() => {
if (resetFiltersToken === undefined || resetFiltersToken === resetRef.current) return;
resetRef.current = resetFiltersToken;
- setFilters({});
+ commitFilters({});
setPage(0);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [resetFiltersToken]);
const sorted = sortRows(filterRows(rows, columns, filters), columns, sort);
@@ -132,7 +172,7 @@ export default function DataTable({
setPage(0);
}
function applyFilter(key: string, value: string | string[]) {
- setFilters((f) => ({ ...f, [key]: value }));
+ commitFilters({ ...filters, [key]: value });
setPage(0);
}
@@ -196,7 +236,7 @@ export default function DataTable({
{paged.map((row) => (
diff --git a/frontend/src/components/ExcludedChannelList.tsx b/frontend/src/components/ExcludedChannelList.tsx
new file mode 100644
index 00000000..5caf33dc
--- /dev/null
+++ b/frontend/src/components/ExcludedChannelList.tsx
@@ -0,0 +1,70 @@
+import { type ReactNode } from "react";
+import ChannelLink from "./ChannelLink";
+
+// The presentation shared by the channel manager's "excluded channel" tabs — Hidden and Blocked.
+// A responsive grid of proper channel cards (avatar + name + open-on-YouTube, via ChannelLink) each
+// with one reversing action, instead of a bare row of name chips. Both tabs pass the same shape; only
+// the header icon/label, the action, and the copy differ.
+export interface ExcludedChannel {
+ id: string;
+ title: string | null;
+ handle?: string | null;
+ thumbnail_url: string | null;
+}
+
+export default function ExcludedChannelList({
+ channels,
+ icon,
+ title,
+ hint,
+ emptyText,
+ actionLabel,
+ actionIcon,
+ onAction,
+ onView,
+}: {
+ channels: ExcludedChannel[];
+ icon: ReactNode;
+ title: string;
+ hint: string;
+ emptyText: string;
+ actionLabel: string;
+ actionIcon: ReactNode;
+ onAction: (id: string) => void;
+ onView?: (c: ExcludedChannel) => void;
+}) {
+ if (channels.length === 0) return {emptyText}
;
+ return (
+ <>
+
+ {icon}
+ {title}
+
+
+ {channels.map((c) => (
+
+
+ onView(c) : undefined}
+ />
+
+
onAction(c.id)}
+ title={actionLabel}
+ aria-label={actionLabel}
+ className="shrink-0 inline-flex items-center gap-1 rounded-full border border-border px-2 py-1 text-xs text-muted transition hover:border-accent hover:text-accent"
+ >
+ {actionIcon}
+ {actionLabel}
+
+
+ ))}
+
+ {hint}
+ >
+ );
+}
diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx
index f932e961..8440a519 100644
--- a/frontend/src/components/Feed.tsx
+++ b/frontend/src/components/Feed.tsx
@@ -8,8 +8,6 @@ import {
} from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import {
- ArrowDown,
- ArrowUp,
ArrowLeft,
Columns2,
Grid3x3,
@@ -34,12 +32,15 @@ import {
SORT_KEYS,
type SortKey,
} from "../lib/feedSort";
+import { type SortOption } from "../lib/columnSort";
import { FEED_VIEWS, type FeedView } from "../lib/feedView";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
import ActiveFilterChips from "./ActiveFilterChips";
import { LoadingState, StateMessage } from "./QueryState";
import { PageToolbar } from "./PageShell";
import { useWizard } from "./WizardProvider";
+import SortControl from "./SortControl";
+import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
import ViewSwitcher from "./ViewSwitcher";
import VirtualFeed from "./VirtualFeed";
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
@@ -547,36 +548,51 @@ export default function Feed({
);
const { key: sortKey, dir: sortDir } = parseSort(filters.sort);
+ // The feed's sort vocabulary as source-agnostic options for the shared SortControl. Relevance only
+ // ranks meaningfully with a search term (offered then); shuffle/relevance are directionless.
+ const sortOptions: SortOption[] = (
+ filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys
+ ).map((k) => ({
+ key: k,
+ label: t("feed.sortKey." + k),
+ directionless: DIRECTIONLESS.includes(k),
+ defaultDir: DIRECTIONLESS.includes(k)
+ ? undefined
+ : SORT_DEFAULT_DIR[k as Exclude],
+ }));
const toolbar = (
- {SHOW_IDS.map((id) => (
-
setFilters({ ...filters, show: id })}
- aria-pressed={filters.show === id}
- className="pill text-xs px-3 py-1.5 rounded-full border"
- >
- {t("sidebar.show." + id)}
-
- ))}
+
({
+ value: id,
+ label: t("sidebar.show." + id),
+ testId: `feed-show-${id}`,
+ }))}
+ value={filters.show}
+ onChange={(show) => setFilters({ ...filters, show })}
+ />
- {CONTENT.map((c) => {
- const on = filters[c.key];
- return (
- setFilters({ ...filters, [c.key]: !on })}
- aria-pressed={on}
- className="pill text-xs px-3 py-1.5 rounded-full border"
- >
- {t(c.label)}
-
- );
- })}
+ {/* Content-type toggles are MULTI-select (independent booleans) — same chip look via
+ filterChipClass, but not a single-select group. */}
+
+ {CONTENT.map((c) => {
+ const on = filters[c.key];
+ return (
+ setFilters({ ...filters, [c.key]: !on })}
+ aria-pressed={on}
+ className={filterChipClass}
+ >
+ {t(c.label)}
+
+ );
+ })}
+
{/* The Source filter (organic / include-search / search-only) is a global-catalog concept —
it selects how videos ENTERED the library. On a channel-scoped view we already show all
of this one channel's videos (librarySource is pinned to "all"), so the selector would
@@ -615,58 +631,33 @@ export default function Feed({
{t("feed.sortLabel")}
-
{
- const key = e.target.value as SortKey;
- const dir = DIRECTIONLESS.includes(key)
- ? "desc"
- : SORT_DEFAULT_DIR[key as Exclude];
+ {
+ if (!next) return; // the feed is always sorted — no "no sort" option is offered
setFilters({
...filters,
- sort: buildSort(key, dir),
- seed: key === "shuffle" ? rollSeed() : undefined,
+ sort: buildSort(next.key as SortKey, next.dir),
+ seed: next.key === "shuffle" ? rollSeed() : undefined,
});
}}
- aria-label={t("feed.sortLabel")}
- className="bg-surface border border-border rounded-lg px-2 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
- >
- {/* Relevance only ranks meaningfully when there's a search term — offer it then. */}
- {(filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys).map((k) => (
-
- {t("feed.sortKey." + k)}
-
- ))}
-
- {!DIRECTIONLESS.includes(sortKey) && (
-
- setFilters({
- ...filters,
- sort: buildSort(sortKey, sortDir === "asc" ? "desc" : "asc"),
- })
- }
- title={sortDir === "asc" ? t("feed.dirAsc") : t("feed.dirDesc")}
- aria-label={sortDir === "asc" ? t("feed.dirAsc") : t("feed.dirDesc")}
- className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
- >
- {sortDir === "asc" ? (
-
- ) : (
-
- )}
-
- )}
- {sortKey === "shuffle" && (
-
setFilters({ ...filters, seed: rollSeed() })}
- title={t("sidebar.reshuffle")}
- aria-label={t("sidebar.reshuffle")}
- className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
- >
-
-
- )}
+ label={t("feed.sortLabel")}
+ ascLabel={t("feed.dirAsc")}
+ descLabel={t("feed.dirDesc")}
+ trailing={
+ sortKey === "shuffle" ? (
+
setFilters({ ...filters, seed: rollSeed() })}
+ title={t("sidebar.reshuffle")}
+ aria-label={t("sidebar.reshuffle")}
+ className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
+ >
+
+
+ ) : undefined
+ }
+ />
{
+ it("renders one chip per option with labels", () => {
+ render( );
+ expect(screen.getAllByRole("button").map((b) => b.textContent)).toEqual([
+ "All",
+ "Needs full",
+ "Fully synced3",
+ ]);
+ });
+
+ it("marks exactly the active chip aria-pressed", () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Needs full" }).getAttribute("aria-pressed")).toBe(
+ "true",
+ );
+ expect(screen.getByRole("button", { name: "All" }).getAttribute("aria-pressed")).toBe("false");
+ });
+
+ it("clicking a chip fires onChange with its value", () => {
+ const onChange = vi.fn();
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: "Needs full" }));
+ expect(onChange).toHaveBeenCalledWith("needsFull");
+ });
+
+ it("re-clicking the active chip still fires onChange (caller decides toggle-off)", () => {
+ const onChange = vi.fn();
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: "All" }));
+ expect(onChange).toHaveBeenCalledWith("all");
+ });
+
+ it("preserves per-option testId and exposes the group aria-label", () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId("chip-all")).toBeTruthy();
+ expect(screen.getByRole("group", { name: "Sync state" })).toBeTruthy();
+ });
+
+ it("exports the canonical chip class for multi-select siblings to reuse", () => {
+ expect(filterChipClass).toContain("rounded-full");
+ expect(filterChipClass).toContain("pill");
+ });
+});
diff --git a/frontend/src/components/FilterChipGroup.tsx b/frontend/src/components/FilterChipGroup.tsx
new file mode 100644
index 00000000..99bc79d9
--- /dev/null
+++ b/frontend/src/components/FilterChipGroup.tsx
@@ -0,0 +1,58 @@
+import { type ReactNode } from "react";
+import CountBadge from "./CountBadge";
+
+// Canonical filter-chip geometry — the rounded-full "Filters bar" look used app-wide for a selectable
+// filter chip. Exported so single-select groups (this component) AND standalone/multi-select filter
+// chips (e.g. the feed content-type toggles) render identically from ONE source of truth. The active
+// paint comes entirely from the `.pill[aria-pressed="true"]` recipe (index.css) — carry no
+// bg/text/border-colour utilities here or a utility class would beat the pressed fill.
+export const filterChipClass =
+ "pill inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border shadow-xs active:translate-y-px";
+
+export interface FilterChipOption {
+ value: T;
+ label: string;
+ count?: number; // optional trailing count badge
+ icon?: ReactNode;
+ title?: string; // optional per-chip tooltip
+ testId?: string; // optional data-testid (preserve existing e2e hooks)
+}
+
+// A single-select row of filter chips (exactly one active), radio-like. For a multi-select chip row,
+// render plain buttons with `filterChipClass` instead — same look, different (independent) semantics.
+export default function FilterChipGroup({
+ options,
+ value,
+ onChange,
+ ariaLabel,
+ className = "flex flex-wrap gap-1.5",
+}: {
+ options: readonly FilterChipOption[];
+ value: T;
+ onChange: (v: T) => void;
+ ariaLabel: string;
+ // Override the row wrapper (e.g. "contents" to dissolve it into a shared toolbar flex row).
+ className?: string;
+}) {
+ return (
+
+ {options.map((o) => {
+ const on = value === o.value;
+ return (
+ onChange(o.value)}
+ title={o.title}
+ aria-pressed={on}
+ className={filterChipClass}
+ >
+ {o.icon}
+ {o.label}
+ {o.count != null && o.count > 0 && }
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx
index 59adce81..c96ad3be 100644
--- a/frontend/src/components/NavigationProvider.tsx
+++ b/frontend/src/components/NavigationProvider.tsx
@@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
import { isPage, readPage, type Page } from "../lib/urlState";
import { pageTitleKey } from "../lib/pageMeta";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
+import { useAccountRehydrate } from "../lib/useAccountRehydrate";
import { setNavigator } from "../lib/nav";
// Owns the app's navigation + browser-history state, lifted out of the App god component so pages
@@ -103,19 +104,42 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
const channelViewRef = useRef(channelView);
channelViewRef.current = channelView;
- const setPage = useCallback((next: Page) => {
- // A channel page overlays the content column, so a nav click must close it even when the
- // underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
- if (next === pageRef.current && !channelViewRef.current) return;
- setChannelView(null); // leave any open channel page when navigating via the rail
- setPageState(next);
- setAccountRaw(PAGE_KEY, next);
- // Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the
- // app. The URL stays clean — the page rides in history.state. A fresh { sfPage } (no carried-over
- // _yt/_chan markers) so each page starts at its root.
- window.history.pushState({ sfPage: next }, "");
- }, []);
+ // The landing page is per-account (PAGE_KEY), but this provider mounts above , which pins the
+ // tab's account only after /api/me — so on a fresh tab `loadInitialPage` reads an unknown account and
+ // falls back to "feed". Re-read once the account is pinned and correct the CURRENT history entry
+ // (replaceState, not push) so Back isn't given a phantom "feed" step. markDirty (set by setPage)
+ // skips this if the user already navigated in the pre-pin window.
+ const markDirty = useAccountRehydrate(() => {
+ const stored = loadInitialPage();
+ if (stored === pageRef.current) return;
+ setPageState(stored);
+ setAccountRaw(PAGE_KEY, stored); // no-op re-write, keeps storage authoritative
+ // Correct the CURRENT entry (replace, not push) and PRESERVE any markers already on it — a channel
+ // (_chan) or YT search (_yt) opened in the pre-pin window survives on top of the restored page,
+ // rather than being orphaned from history so Back can't close it.
+ window.history.replaceState({ ...(window.history.state || {}), sfPage: stored }, "");
+ });
+ const setPage = useCallback(
+ (next: Page) => {
+ // A channel page overlays the content column, so a nav click must close it even when the
+ // underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
+ if (next === pageRef.current && !channelViewRef.current) return;
+ markDirty();
+ setChannelView(null); // leave any open channel page when navigating via the rail
+ setPageState(next);
+ setAccountRaw(PAGE_KEY, next);
+ // Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the
+ // app. The URL stays clean — the page rides in history.state. A fresh { sfPage } (no carried-over
+ // _yt/_chan markers) so each page starts at its root.
+ window.history.pushState({ sfPage: next }, "");
+ },
+ [markDirty],
+ );
+
+ // A sub-view (channel / YT search) is an OVERLAY, not a page change, so it does NOT markDirty: a
+ // pre-pin account re-hydrate should still restore the stored page underneath — the re-hydrate's
+ // replaceState spreads the _chan/_yt markers, so the overlay survives on top of the restored page.
const openChannel = useCallback((id: string, name?: string) => {
setChannelView({ id, name });
const st = window.history.state || {};
@@ -129,6 +153,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
}, []);
const enterYtSearch = useCallback((q: string) => {
+ // Overlay, not a page change — see openChannel: no markDirty, the re-hydrate preserves _yt.
setYtSearch(q);
// Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps it
// once the results resolve, so a reload only restores a confirmed scrape-sourced search.
diff --git a/frontend/src/components/PlaylistsProvider.tsx b/frontend/src/components/PlaylistsProvider.tsx
index 42c42559..25ba37d9 100644
--- a/frontend/src/components/PlaylistsProvider.tsx
+++ b/frontend/src/components/PlaylistsProvider.tsx
@@ -1,5 +1,6 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
+import { useAccountRehydrate } from "../lib/useAccountRehydrate";
// Owns the Playlists module's shared local state — the rail's name filter and the selected playlist
// — lifted out of the App god component so the header search box, the PlaylistsRail, and the
@@ -35,18 +36,28 @@ export function usePlaylistsActions(): PlaylistsActions {
return useContext(ActionsContext);
}
+function readSelectedId(): number | null {
+ const s = getAccountRaw(LS.playlist);
+ return s ? Number(s) : null;
+}
+
export function PlaylistsProvider({ children }: { children: ReactNode }) {
const [search, setSearchState] = useState("");
- const [selectedId, setSelectedIdState] = useState(() => {
- const s = getAccountRaw(LS.playlist);
- return s ? Number(s) : null;
- });
+ const [selectedId, setSelectedIdState] = useState(readSelectedId);
+
+ // Re-read the selected playlist once the account is pinned after mount (first load on a fresh tab):
+ // this provider mounts above , which pins the account only after /api/me.
+ const markDirty = useAccountRehydrate(() => setSelectedIdState(readSelectedId()));
const setSearch = useCallback((q: string) => setSearchState(q), []);
- const setSelectedId = useCallback((id: number | null) => {
- setSelectedIdState(id);
- if (id != null) setAccountRaw(LS.playlist, String(id));
- }, []);
+ const setSelectedId = useCallback(
+ (id: number | null) => {
+ markDirty();
+ setSelectedIdState(id);
+ if (id != null) setAccountRaw(LS.playlist, String(id));
+ },
+ [markDirty],
+ );
const state = useMemo(() => ({ search, selectedId }), [search, selectedId]);
const actions = useMemo(
diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx
index e66e6a3b..8fdfdd17 100644
--- a/frontend/src/components/PlaylistsRail.tsx
+++ b/frontend/src/components/PlaylistsRail.tsx
@@ -2,7 +2,7 @@ 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 { ArrowDown, ArrowUp, ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
+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";
@@ -11,6 +11,8 @@ import { LS, readAccountMerged, writeAccount } from "../lib/storage";
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
import SidePanel from "./SidePanel";
import PanelGroups, { 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";
@@ -45,6 +47,12 @@ export default function PlaylistsRail({
const { t } = useTranslation();
const qc = useQueryClient();
const [plSort, setPlSort] = useState(() => 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]);
@@ -149,43 +157,26 @@ export default function PlaylistsRail({
{t("playlists.syncYoutube")}
{playlists.length > 1 && (
-
-
setPlSort({ ...plSort, key: e.target.value as PlSort["key"] })}
- aria-label={t("playlists.sortLabel")}
- className="flex-1 min-w-0 bg-surface border border-border rounded-md px-1.5 py-1 text-2xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
- >
- {t("playlists.railSortCustom")}
- {t("playlists.railSortName")}
- {t("playlists.railSortCount")}
- {t("playlists.railSortDuration")}
-
-
setPlSort({ ...plSort, dir: plSort.dir === "asc" ? "desc" : "asc" })}
- disabled={plSort.key === "custom"}
- title={plSort.dir === "asc" ? t("playlists.dirAsc") : t("playlists.dirDesc")}
- className="p-1 rounded-md border border-border text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-30 transition"
- >
- {plSort.dir === "asc" ? (
-
- ) : (
-
- )}
-
-
setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })}
- title={t("playlists.dirtyFirst")}
- aria-pressed={plSort.dirtyFirst}
- className={`p-1 rounded-md border transition ${
- plSort.dirtyFirst
- ? "border-accent text-accent"
- : "border-border text-muted hover:text-fg hover:border-accent"
- }`}
- >
-
-
-
+
+ next && setPlSort({ ...plSort, key: next.key as PlSort["key"], dir: next.dir })
+ }
+ trailing={
+ setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })}
+ title={t("playlists.dirtyFirst")}
+ aria-pressed={plSort.dirtyFirst}
+ className="pill shrink-0 rounded-lg border p-1.5"
+ >
+
+
+ }
+ />
)}
+
+
{personCards.length > 0 && (
{personCards.map((p) => (
diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx
index 60ca7dac..f77e9606 100644
--- a/frontend/src/components/PlexPlaylistView.tsx
+++ b/frontend/src/components/PlexPlaylistView.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState, type ReactNode } from "react";
+import { useEffect, useMemo, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -35,6 +35,7 @@ import { api, type PlexCard } from "../lib/api";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { useConfirm } from "./ConfirmProvider";
import { LoadingState, StateMessage } from "./QueryState";
+import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
@@ -499,18 +500,10 @@ export default function PlexPlaylistView({
onRemove,
};
- const LayoutBtn = ({ v, icon, label }: { v: Layout; icon: ReactNode; label: string }) => (
-
setLayout(v)}
- title={label}
- aria-label={label}
- aria-pressed={layout === v}
- data-soft="true"
- className={`pill rounded-lg p-2 text-sm ${layout === v ? "" : "glass-card glass-hover text-muted"}`}
- >
- {icon}
-
- );
+ const layoutOptions: ViewOption
[] = [
+ { id: "accordion", label: t("plex.playlist.layoutAccordion"), icon: Rows3 },
+ { id: "tree", label: t("plex.playlist.layoutTree"), icon: ListTree },
+ ];
return (
@@ -536,19 +529,13 @@ export default function PlexPlaylistView({
) : (
{q.data?.title ?? " "}
)}
- {/* Layout preference: Accordion (A) or Tree (C). */}
-
- }
- label={t("plex.playlist.layoutAccordion")}
- />
- }
- label={t("plex.playlist.layoutTree")}
- />
-
+ {/* Layout preference: Accordion or Tree — the shared list-view switcher. */}
+
{
setName(q.data?.title ?? "");
diff --git a/frontend/src/components/PlexProvider.tsx b/frontend/src/components/PlexProvider.tsx
index 9354ef2e..9fe36f7f 100644
--- a/frontend/src/components/PlexProvider.tsx
+++ b/frontend/src/components/PlexProvider.tsx
@@ -1,6 +1,9 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
+import { parseSortState, type SortState } from "../lib/columnSort";
+import { PLEX_SORT_FALLBACK } from "../lib/plexSort";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
+import { useAccountRehydrate } from "../lib/useAccountRehydrate";
// Owns the Plex module's shared local state — the left-rail library scope / watch-state / sort /
// expanded filters, the header search term, and the "open this playlist" hand-off — lifted out of
@@ -8,8 +11,10 @@ import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
// stable useCallback setters, one-line hooks; composed in main.tsx around ).
//
-// scope / show / sort / filters / q are per-account persisted (localStorage); the account switch does
-// a full reload, so init-from-storage is all the hydration they need. `playlistOpen` is an ephemeral
+// scope / show / sort / filters / q are per-account persisted (localStorage). init-from-storage seeds
+// them, and useAccountRehydrate re-reads once the tab's account is pinned — this provider mounts above
+// , which pins it only after /api/me, so a fresh tab would otherwise show defaults until a reload
+// (an account SWITCH does full-reload, but the FIRST load does not). `playlistOpen` is an ephemeral
// hand-off (the rail sets it, PlexBrowse consumes it into a history subview then clears it). The Plex
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
// into (and is never greeted by a stale value from) the feed.
@@ -17,7 +22,7 @@ import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
type PlexState = {
scope: string; // movie | show | both (unified cross-library scope)
show: string;
- sort: string;
+ sort: SortState; // {key,dir}; healed against the scope's options at the point of use
filters: PlexFilters;
q: string;
playlistOpen: number | null;
@@ -26,7 +31,7 @@ type PlexState = {
interface PlexActions {
setScope: (v: string) => void;
setShow: (v: string) => void;
- setSort: (v: string) => void;
+ setSort: (v: SortState) => void;
setFilters: (f: PlexFilters) => void;
setQ: (q: string) => void;
setPlaylistOpen: (id: number | null) => void;
@@ -35,7 +40,7 @@ interface PlexActions {
const StateContext = createContext({
scope: "both",
show: "all",
- sort: "title",
+ sort: PLEX_SORT_FALLBACK,
filters: EMPTY_PLEX_FILTERS,
q: "",
playlistOpen: null,
@@ -56,49 +61,115 @@ export function usePlexActions(): PlexActions {
return useContext(ActionsContext);
}
+// The per-account init readers, shared by the initial useState seed AND the account-pin re-hydrate.
+function readPlexScope(): string {
+ const v = getAccountRaw(LS.plexLibrary) ?? "both";
+ return ["movie", "show", "both"].includes(v) ? v : "both";
+}
+const readPlexShow = (): string => getAccountRaw(LS.plexShow) ?? "all";
+const readPlexFiltersRaw = (): string => getAccountRaw(LS.plexFilters) ?? "{}";
+const readPlexQ = (): string => getAccountRaw(LS.plexQ) ?? "";
+// Sort is a {key,dir} SortState. Migrate the old shape: a legacy bare-key string in plexSort plus the
+// direction that used to live in plexFilters.sortDir — so an existing sort preference survives the
+// upgrade rather than snapping back to the default.
+function readPlexSort(): SortState {
+ const raw = getAccountRaw(LS.plexSort);
+ if (!raw) return PLEX_SORT_FALLBACK;
+ let wasJson = false;
+ try {
+ const parsed = parseSortState(JSON.parse(raw));
+ wasJson = true;
+ if (parsed) return parsed;
+ } catch {
+ /* not JSON → a legacy bare-key string, handled below */
+ }
+ // Valid JSON that wasn't a SortState is corrupt — fall back rather than treat the JSON text as a
+ // key. Only a non-JSON raw is a genuine legacy bare key.
+ if (wasJson) return PLEX_SORT_FALLBACK;
+ let dir: "asc" | "desc" = "asc";
+ try {
+ if (
+ (JSON.parse(getAccountRaw(LS.plexFilters) ?? "{}") as { sortDir?: string }).sortDir === "desc"
+ )
+ dir = "desc";
+ } catch {
+ /* ignore */
+ }
+ return { key: raw, dir };
+}
+
export function PlexProvider({ children }: { children: ReactNode }) {
// The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
// (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".)
- const [scope, setScopeState] = useState(() => {
- const v = getAccountRaw(LS.plexLibrary) ?? "both";
- return ["movie", "show", "both"].includes(v) ? v : "both";
- });
- const [show, setShowState] = useState(() => getAccountRaw(LS.plexShow) ?? "all");
- const [sort, setSortState] = useState(() => getAccountRaw(LS.plexSort) ?? "title");
+ const [scope, setScopeState] = useState(readPlexScope);
+ const [show, setShowState] = useState(readPlexShow);
+ const [sort, setSortState] = useState(readPlexSort);
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
- const [filtersRaw, setFiltersRaw] = useState(() => getAccountRaw(LS.plexFilters) ?? "{}");
+ const [filtersRaw, setFiltersRaw] = useState(readPlexFiltersRaw);
const filters = useMemo(() => {
try {
- return { ...EMPTY_PLEX_FILTERS, ...JSON.parse(filtersRaw) };
+ // Drop the legacy `sortDir` — direction now lives in the sort SortState. Left here it would leak
+ // into the facets query key and be re-persisted on every filter change.
+ const { sortDir: _drop, ...rest } = JSON.parse(filtersRaw);
+ return { ...EMPTY_PLEX_FILTERS, ...rest };
} catch {
return EMPTY_PLEX_FILTERS;
}
}, [filtersRaw]);
// Survives F5 (persisted), unlike the feed search — see the scope note above.
- const [q, setQState] = useState(() => getAccountRaw(LS.plexQ) ?? "");
+ const [q, setQState] = useState(readPlexQ);
const [playlistOpen, setPlaylistOpen] = useState(null);
- const setScope = useCallback((v: string) => {
- setScopeState(v);
- setAccountRaw(LS.plexLibrary, v);
- }, []);
- const setShow = useCallback((v: string) => {
- setShowState(v);
- setAccountRaw(LS.plexShow, v);
- }, []);
- const setSort = useCallback((v: string) => {
- setSortState(v);
- setAccountRaw(LS.plexSort, v);
- }, []);
- const setFilters = useCallback((f: PlexFilters) => {
- const s = JSON.stringify(f);
- setFiltersRaw(s);
- setAccountRaw(LS.plexFilters, s);
- }, []);
- const setQ = useCallback((v: string) => {
- setQState(v);
- setAccountRaw(LS.plexQ, v);
- }, []);
+ // Re-read every per-account slice once the account is pinned after mount (first load on a fresh tab).
+ const markDirty = useAccountRehydrate(() => {
+ setScopeState(readPlexScope());
+ setShowState(readPlexShow());
+ setSortState(readPlexSort());
+ setFiltersRaw(readPlexFiltersRaw());
+ setQState(readPlexQ());
+ });
+
+ const setScope = useCallback(
+ (v: string) => {
+ markDirty();
+ setScopeState(v);
+ setAccountRaw(LS.plexLibrary, v);
+ },
+ [markDirty],
+ );
+ const setShow = useCallback(
+ (v: string) => {
+ markDirty();
+ setShowState(v);
+ setAccountRaw(LS.plexShow, v);
+ },
+ [markDirty],
+ );
+ const setSort = useCallback(
+ (v: SortState) => {
+ markDirty();
+ setSortState(v);
+ setAccountRaw(LS.plexSort, JSON.stringify(v));
+ },
+ [markDirty],
+ );
+ const setFilters = useCallback(
+ (f: PlexFilters) => {
+ markDirty();
+ const s = JSON.stringify(f);
+ setFiltersRaw(s);
+ setAccountRaw(LS.plexFilters, s);
+ },
+ [markDirty],
+ );
+ const setQ = useCallback(
+ (v: string) => {
+ markDirty();
+ setQState(v);
+ setAccountRaw(LS.plexQ, v);
+ },
+ [markDirty],
+ );
const state = useMemo(
() => ({ scope, show, sort, filters, q, playlistOpen }),
diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx
index 2b1ac4b9..236865cf 100644
--- a/frontend/src/components/PlexSidebar.tsx
+++ b/frontend/src/components/PlexSidebar.tsx
@@ -1,14 +1,19 @@
-import { useState, type ReactNode } from "react";
+import { useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
-import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
+import { api, plexFilterCount, type PlexFilters } from "../lib/api";
+import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort";
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
import { useDebounced } from "../lib/useDebounced";
import { useScrollFade } from "../lib/useScrollFade";
import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups";
+import SortControl from "./SortControl";
+import SegmentedToggle from "./SegmentedToggle";
+import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
+import { usePlexClearFilters } from "./usePlexActiveFilters";
import { usePlex, usePlexActions } from "./PlexProvider";
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups
@@ -24,8 +29,6 @@ type Props = {
};
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
-const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] as const;
-const SHOW_SORTS = ["added", "release", "year", "rating", "title"] as const;
const RATING_STEPS = [5, 6, 7, 8, 9];
const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const;
const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[] = [
@@ -34,8 +37,8 @@ const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[
{ key: "long", min: 120 * 60, max: null },
];
-// The any/all combine toggle for a multi-value filter (genres, actors). Same segmented-pill look as
-// the feed rail's tag MATCH toggle (rounded-full track + inset active pill), not a boxed button row.
+// The any/all combine toggle for a multi-value filter (genres, actors) — a thin binding of the shared
+// SegmentedToggle to the Plex match vocabulary (same look as the feed rail's tag MATCH toggle).
function MatchToggle({
mode,
onChange,
@@ -45,27 +48,16 @@ function MatchToggle({
}) {
const { t } = useTranslation();
return (
-
-
- {t("plex.filter.matchLabel")}
-
-
- {(["any", "all"] as const).map((m) => (
- onChange(m)}
- aria-pressed={mode === m}
- className="pill rounded-full px-2 py-0.5"
- >
- {t(`plex.filter.match.${m}`)}
-
- ))}
-
-
+
);
}
@@ -95,7 +87,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
onOpenPlaylist(pl.id);
}
- const { sortDir: _sd, collectionTitle: _ct, ...facetKey } = filters;
+ const { collectionTitle: _ct, ...facetKey } = filters;
const facetsQ = useQuery({
queryKey: qk.plexFacets(scope, show, facetKey),
queryFn: () => api.plexFacets(scope, filters, show),
@@ -114,21 +106,18 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
const collections = collectionsQ.data?.collections ?? [];
const fCount = plexFilterCount(filters);
- const activeCount = fCount + (show !== "all" ? 1 : 0) + (sort !== "title" ? 1 : 0);
+ // Heal against the current scope so a stale out-of-scope key (e.g. movie "duration" after switching
+ // to shows) doesn't inflate the active-filter count while the control + grid show the default.
+ const healedSort = healPlexSort(sort, scope);
+ const activeCount = fCount + (show !== "all" ? 1 : 0) + (healedSort.key !== "title" ? 1 : 0);
const anyActive = activeCount > 0;
const patch = (p: Partial) => setFilters({ ...filters, ...p });
const toggleIn = (arr: string[], v: string) =>
arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v];
- const clearAll = () => {
- setFilters(EMPTY_PLEX_FILTERS);
- setShow("all");
- setSort("title");
- };
+ const clearAll = usePlexClearFilters();
- const sorts = [...(scope === "movie" ? MOVIE_SORTS : SHOW_SORTS)].sort((a, b) =>
- t(`plex.sort.${a}`).localeCompare(t(`plex.sort.${b}`)),
- );
+ const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`));
const durBucketKey = DURATION_BUCKETS.find(
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
)?.key;
@@ -143,20 +132,17 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
id: "scope",
title: t("plex.filter.scope"),
body: (
-
- {(["both", "movie", "show"] as const).map((s) => (
- setScope(s)}
- aria-pressed={s === scope}
- className={`pill min-w-0 px-1.5 py-1.5 rounded-lg text-xs font-medium truncate ${
- s === scope ? "" : "glass-card glass-hover"
- }`}
- >
- {t(`plex.filter.scopeOpt.${s}`)}
-
- ))}
-
+ ({
+ value: s,
+ label: t(`plex.filter.scopeOpt.${s}`),
+ }))}
+ value={scope}
+ onChange={setScope}
+ />
),
},
{
@@ -205,39 +191,26 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
id: "watch",
title: t("plex.filter.show"),
body: (
-
- {SHOW_OPTS.map((s) => (
- setShow(s)}>
- {t(`plex.filter.showOpt.${s}`)}
-
- ))}
-
+ ({ value: s, label: t(`plex.filter.showOpt.${s}`) }))}
+ value={show}
+ onChange={setShow}
+ />
),
},
{
id: "sort",
title: t("plex.filter.sort"),
body: (
- <>
-
- {sorts.map((s) => (
- setSort(s)}>
- {t(`plex.sort.${s}`)}
-
- ))}
-
-
- {(["asc", "desc"] as const).map((d) => (
- patch({ sortDir: d })}
- >
- {t(`plex.filter.dir.${d}`)}
-
- ))}
-
- >
+ setSort(next ?? PLEX_SORT_FALLBACK)}
+ label={t("plex.filter.sort")}
+ ascLabel={t("plex.filter.dir.asc")}
+ descLabel={t("plex.filter.dir.desc")}
+ />
),
},
...(peopleActive
@@ -328,16 +301,15 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
id: "rating",
title: t("plex.filter.rating"),
body: (
-
- patch({ ratingMin: null })}>
- {t("plex.filter.any")}
-
- {RATING_STEPS.map((r) => (
- patch({ ratingMin: r })}>
- {r}+
-
- ))}
-
+ ({ value: String(r), label: `${r}+` })),
+ ]}
+ onChange={(v) => patch({ ratingMin: v === "" ? null : Number(v) })}
+ />
),
},
...(facets && facets.genres.length > 0
@@ -353,17 +325,22 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
onChange={(m) => patch({ genreMode: m })}
/>
)}
-
+
{facets.genres.map((g) => (
- patch({ genres: toggleIn(filters.genres, g.value) })}
+ aria-pressed={filters.genres.includes(g.value)}
+ className={filterChipClass}
>
{g.value}
-
+
))}
-
+
>
),
},
@@ -394,25 +371,26 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
id: "duration",
title: t("plex.filter.duration"),
body: (
-
- patch({ durationMin: null, durationMax: null })}
- >
- {t("plex.filter.any")}
-
- {DURATION_BUCKETS.map((b) => (
- patch({ durationMin: b.min, durationMax: b.max })}
- >
- {t(`plex.filter.durationOpt.${b.key}`)}
-
- ))}
-
+