release: 0.64.0 (r13-sort-filter-unification)

This commit is contained in:
2026-08-15 04:54:08 +02:00
63 changed files with 2788 additions and 873 deletions
+1 -1
View File
@@ -1 +1 @@
0.63.3
0.64.0
@@ -0,0 +1,127 @@
// @vitest-environment jsdom
//
// Contract of the reusable ⋯ overflow menu. Labels/handlers are plain props (no i18n), so we assert
// the open/select/close + disabled-item behaviour. The menu content only mounts while open, and the
// AnchoredPopover platform is floating-ui based, so we stub the two observers autoUpdate wires and
// wrap in a FloatingTree (its real-app parent) so nested-layer dispatch has a node to register.
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import ActionMenu, { type ActionMenuItem } from "./ActionMenu";
beforeAll(() => {
// floating-ui's autoUpdate observes with ResizeObserver + IntersectionObserver (layoutShift); jsdom
// ships neither, and the floating element mounts (so they run) the moment the menu opens.
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(cleanup);
const renderMenu = (items: ActionMenuItem[]) =>
render(
<FloatingTree>
<ActionMenu ariaLabel="Catalog actions" items={items} />
</FloatingTree>,
);
describe("ActionMenu", () => {
it("renders a labelled trigger and keeps items unmounted until opened", () => {
renderMenu([{ id: "sync", label: "Sync", onSelect: vi.fn() }]);
expect(screen.getByRole("button", { name: "Catalog actions" })).toBeTruthy();
expect(screen.queryByRole("menuitem")).toBeNull();
});
// floating-ui never computes a position in jsdom, so the surface stays visibility:hidden; the items
// are in the DOM but the a11y tree treats them as hidden — hence `{ hidden: true }` on role queries.
it("opens on click and shows one menuitem per item", () => {
renderMenu([
{ id: "sync", label: "Sync", onSelect: vi.fn() },
{ id: "backfill", label: "Backfill", onSelect: vi.fn() },
]);
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
expect(screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent)).toEqual([
"Sync",
"Backfill",
]);
});
it("selecting an item fires its onSelect and closes the menu", () => {
const onSelect = vi.fn();
renderMenu([{ id: "sync", label: "Sync", onSelect }]);
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
fireEvent.click(screen.getByText("Sync"));
expect(onSelect).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("menuitem", { hidden: true })).toBeNull();
});
it("a disabled item is inert — rendered disabled and never fires onSelect", () => {
const onSelect = vi.fn();
renderMenu([{ id: "sync", label: "Sync", onSelect, disabled: true }]);
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
const item = screen.getByText("Sync").closest("button")!;
expect((item as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(item);
expect(onSelect).not.toHaveBeenCalled();
});
it("skips a disabled item in roving keyboard nav", () => {
// items[0] disabled, items[1] + items[2] enabled. Roving keyboard focus (the `tabindex=0` item,
// which tracks useListNavigation's activeIndex) must NEVER land on the disabled index-0 — arrowing
// skips straight over it. The skip comes for free: PopoverItem renders a native `disabled` button
// and useListNavigation reads that DOM state (no explicit disabledIndices needed). Without it,
// ArrowUp/Down would strand roving focus on the inert index-0 button. (floating-ui wires this on
// real key nav, not on open, in jsdom — so we drive it with arrow keys.)
renderMenu([
{ id: "sync", label: "Sync", onSelect: vi.fn(), disabled: true },
{ id: "backfill", label: "Backfill", onSelect: vi.fn() },
{ id: "reset", label: "Reset", onSelect: vi.fn() },
]);
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
const [disabled, backfill, reset] = screen.getAllByRole("menuitem", { hidden: true }) as [
HTMLElement,
HTMLElement,
HTMLElement,
];
const menu = screen.getByRole("menu", { hidden: true });
const rover = () =>
[disabled, backfill, reset].find((el) => el.getAttribute("tabindex") === "0")?.textContent;
// The disabled item never becomes the roving target, whichever way we arrow.
fireEvent.keyDown(menu, { key: "ArrowDown" }); // skips disabled[0] -> first enabled
expect(rover()).toBe("Backfill");
expect(disabled.getAttribute("tabindex")).toBe("-1");
fireEvent.keyDown(menu, { key: "ArrowDown" });
expect(rover()).toBe("Reset");
fireEvent.keyDown(menu, { key: "ArrowUp" }); // back to Backfill
expect(rover()).toBe("Backfill");
fireEvent.keyDown(menu, { key: "ArrowUp" }); // wraps over disabled[0] to the last item
expect(rover()).toBe("Reset");
expect(disabled.getAttribute("tabindex")).toBe("-1"); // still never the disabled item
});
it("honours a custom trigger node", () => {
renderMenu([{ id: "sync", label: "Sync", onSelect: vi.fn() }]);
cleanup();
render(
<FloatingTree>
<ActionMenu
ariaLabel="More"
items={[{ id: "x", label: "X", onSelect: vi.fn() }]}
trigger={<span data-testid="custom-trigger">•••</span>}
/>
</FloatingTree>,
);
expect(screen.getByTestId("custom-trigger")).toBeTruthy();
});
});
+78
View File
@@ -0,0 +1,78 @@
import { useState, type ReactNode } from "react";
import { MoreHorizontal } from "lucide-react";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions.
// Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher
// and LanguageSwitcher. Use it to keep occasional actions out of a crowded toolbar. The items are
// fire-and-close (`onSelect`); a `disabled` item (e.g. a mutation already in flight) is inert.
export interface ActionMenuItem {
id: string;
label: string;
icon?: ReactNode; // an element (so a caller can swap in a spinning icon while pending)
onSelect: () => void;
disabled?: boolean;
title?: string; // optional native tooltip (carries a longer hint the label alone can't)
}
export default function ActionMenu({
items,
ariaLabel,
className,
trigger,
}: {
items: readonly ActionMenuItem[];
/** Accessible name for the trigger (e.g. "Catalog actions"). */
ariaLabel: string;
/** Trigger className override; omit for the default glass ⋯ icon button. */
className?: string;
/** Custom trigger content; omit for the default ⋯ icon. */
trigger?: ReactNode;
}) {
const [open, setOpen] = useState(false);
// A disabled item (a mutation already in flight) is skipped by roving keyboard nav automatically:
// PopoverItem renders it as a native `disabled` button, which useListNavigation reads and never
// focuses. No explicit disabledIndices needed. See ActionMenu.dom.test.tsx for the guard.
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement: "bottom-end" });
return (
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={ariaLabel}
title={ariaLabel}
className={
className ??
"glass-card glass-hover flex items-center justify-center rounded-xl p-2 text-sm transition"
}
>
{trigger ?? <MoreHorizontal className="h-5 w-5" aria-hidden="true" />}
</button>
<PopoverSurface
popover={pop}
ariaLabel={ariaLabel}
className="glass-menu min-w-52 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
{items.map((it) => (
<PopoverItem
key={it.id}
role="menuitem"
disabled={it.disabled}
label={it.label}
title={it.title}
onSelect={() => {
it.onSelect();
setOpen(false);
}}
className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm outline-hidden transition disabled:opacity-50"
>
{it.icon && <span className="shrink-0 text-muted">{it.icon}</span>}
<span className="flex-1 truncate">{it.label}</span>
</PopoverItem>
))}
</PopoverSurface>
</>
);
}
@@ -175,6 +175,10 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
setHoverIndex(null);
},
selectedIndex: opts.selectedIndex ?? null,
// Disabled items are skipped automatically: with no `disabledIndices`, useListNavigation reads
// each item's DOM `disabled`/`aria-disabled` (PopoverItem always renders native `disabled`), so a
// pending/inert item is never a roving target. (Verified against @floating-ui/react 0.27
// `isListIndexDisabled`.) No explicit index list needed.
loop: true,
// Focus the first item on open for BOTH click and keyboard (the default 'auto' skips pointer-opens,
// which strands focus on the menu container — arrows then can't enter the roving list at all).
+5 -10
View File
@@ -1,12 +1,13 @@
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { UserPlus } from "lucide-react";
import { api, type DiscoveredChannel } from "../lib/api";
import { accountKey, LS, readAccount, writeAccount } from "../lib/storage";
import { accountKey, LS } from "../lib/storage";
import { formatCountOrDash } from "../lib/format";
import { parseSortState, sortRows, type SortState } from "../lib/columnSort";
import { sortRows } from "../lib/columnSort";
import { useSortState } from "../lib/useSortState";
import { useCardPager } from "../lib/useCardPager";
import { subsColumn } from "./channelColumns";
import { notify } from "../lib/notifications";
@@ -44,13 +45,7 @@ export default function ChannelDiscovery({
// The table/cards density is shared with the Subscriptions tab (ChannelsProvider). The sort is
// this tab's own (its columns differ), lifted out of the table so the card layout shares it.
const layout = useChannelLayout();
const [sort, setSortState] = useState<SortState>(() =>
parseSortState(readAccount(LS.channelDiscoverySort, null)),
);
const setSort = (s: SortState) => {
setSortState(s);
writeAccount(LS.channelDiscoverySort, s);
};
const [sort, setSort] = useSortState(LS.channelDiscoverySort);
const query = useQuery({
queryKey: qk.discoveredChannels(),
+12 -13
View File
@@ -17,6 +17,7 @@ import {
SlidersHorizontal,
} from "lucide-react";
import Avatar from "./Avatar";
import Tabs from "./Tabs";
import Feed from "./Feed";
import ChannelAboutContent from "./ChannelAbout";
import { PageToolbar, PageToolbarSlot } from "./PageShell";
@@ -265,11 +266,6 @@ export default function ChannelPage({
: null,
joined ? t("channel.joined", { date: joined }) : null,
].filter(Boolean) as string[];
const tabClass = (active: boolean) =>
active
? "pb-2 text-sm font-medium border-b-2 border-fg text-fg"
: "pb-2 text-sm text-muted hover:text-fg border-b-2 border-transparent";
// The channel page owns no scroller of its own (PageShell provides the one app scroller). Its
// whole chrome is fixed — wrapped in <PageToolbar> it portals into the shell's fixed band, so the
// banner/identity/tabs/toolbar stay put and only the video grid scrolls.
@@ -370,14 +366,17 @@ export default function ChannelPage({
{/* Tabs — the channel actions ride along here rather than off in the header's top-right
corner: there are only ever two tabs, so there's room, and it keeps the things you click
within a few pixels of each other instead of across the page. */}
<div className="flex items-center gap-4 mt-3 border-b border-border">
<button onClick={() => setTab("videos")} className={tabClass(tab === "videos")}>
{t("channel.tabVideos")}
</button>
<button onClick={() => setTab("about")} className={tabClass(tab === "about")}>
{t("channel.tabAbout")}
</button>
<div className="ml-2 flex items-center gap-2 pb-1.5">
<div className="flex items-center gap-2 mt-3 border-b border-border pb-2">
<Tabs
className="flex gap-1"
active={tab}
onChange={(id) => setTab(id as "videos" | "about")}
tabs={[
{ id: "videos", label: t("channel.tabVideos") },
{ id: "about", label: t("channel.tabAbout") },
]}
/>
<div className="ml-2 flex items-center gap-2">
<a
href={ytUrl}
target="_blank"
@@ -0,0 +1,98 @@
// @vitest-environment jsdom
//
// Contract of the catalog-stats hover chip: the compact trigger, the rows it reveals on open (value
// formatting + the conditional "left" row), and — the point of the R13-S3 review fix — that each row
// still carries its per-stat explanation as a `title`, so the guidance the old inline stats showed is
// not lost. i18n is mocked to a key-passthrough so assertions read the stable keys, not translations.
// The AnchoredPopover platform is floating-ui based (hover mode), so we stub the observers autoUpdate
// wires and wrap in a FloatingTree, and open via focus (hover mode enables useFocus).
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
// Key-passthrough t so assertions read stable keys — but keep the rest of react-i18next intact
// (i18n/index.ts calls i18n.use(initReactI18next) at import time).
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
import ChannelStatsPopover from "./ChannelStatsPopover";
import { type MyStatus } from "../lib/api";
import { formatEta } from "../lib/format";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(cleanup);
const status = (over: Partial<MyStatus> = {}): MyStatus =>
({
channels_total: 10,
channels_recent_synced: 3,
channels_deep_done: 1,
channels_deep_requested: 4,
deep_pending_count: 0,
deep_eta_seconds: 120,
my_videos: 1234,
quota_remaining_today: 5000,
...over,
}) as MyStatus;
const open = (s: MyStatus) => {
render(
<FloatingTree>
<ChannelStatsPopover status={s} />
</FloatingTree>,
);
fireEvent.focus(screen.getByRole("button", { name: "channels.stats.label" }));
};
describe("ChannelStatsPopover", () => {
it("renders a compact Stats trigger, panel closed until opened", () => {
render(
<FloatingTree>
<ChannelStatsPopover status={status()} />
</FloatingTree>,
);
expect(screen.getByRole("button", { name: "channels.stats.label" })).toBeTruthy();
expect(screen.queryByText("channels.stats.channels")).toBeNull();
});
it("reveals one row per stat with formatted values", () => {
open(status());
expect(screen.getByText("channels.stats.recentSynced")).toBeTruthy();
expect(screen.getByText("3/10")).toBeTruthy(); // recent-synced ratio
expect(screen.getByText("1/4")).toBeTruthy(); // full-history ratio
expect(screen.getByText((1234).toLocaleString())).toBeTruthy(); // my_videos, locale-agnostic
});
it("shows the 'left' ETA row only while a backfill is pending, with the formatted ETA value", () => {
open(status({ deep_pending_count: 0 }));
expect(screen.queryByText("channels.stats.left")).toBeNull();
cleanup();
open(status({ deep_pending_count: 2, deep_eta_seconds: 120 }));
expect(screen.getByText("channels.stats.left")).toBeTruthy();
// The value is the FORMATTED ETA (via formatEta), not raw seconds — asserting it guards the
// formatEta wiring, which is untested anywhere else. Using the same formatEta keeps this
// locale/impl-agnostic (expected === what the component renders).
expect(screen.getByText(formatEta(120))).toBeTruthy();
expect(screen.queryByText("120")).toBeNull(); // never the raw seconds
});
it("keeps each stat's explanation as a per-row title (hint not lost)", () => {
open(status());
const row = screen.getByText("channels.stats.channels").closest("div");
expect(row?.getAttribute("title")).toBe("channels.stats.channelsHint");
});
});
@@ -0,0 +1,107 @@
import { useState } from "react";
import {
BarChart3,
Clock,
Film,
History,
RefreshCw,
Users,
Zap,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { type MyStatus } from "../lib/api";
import { formatEta } from "../lib/format";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// The channel-manager catalog stats, collapsed from a crowded inline row into a compact "Stats" chip
// that reveals the numbers on hover (reuses the AnchoredPopover hover mode). One row per stat, each
// with a leading icon. Kept in the manager's own toolbar (the header's SyncStatus is global; these
// are channel-catalog figures).
export default function ChannelStatsPopover({ status }: { status: MyStatus }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "hover",
placement: "bottom-end",
offset: 8,
});
// Each row keeps the per-stat explanation the old inline stats carried (as a native `title` on the
// row), so hovering a figure still tells the user what it counts — the guidance is not lost.
const rows: { icon: LucideIcon; label: string; value: string | number; hint: string }[] = [
{
icon: Users,
label: t("channels.stats.channels"),
value: status.channels_total,
hint: t("channels.stats.channelsHint"),
},
{
icon: RefreshCw,
label: t("channels.stats.recentSynced"),
value: `${status.channels_recent_synced}/${status.channels_total}`,
hint: t("channels.stats.recentSyncedHint"),
},
{
icon: History,
label: t("channels.stats.fullHistory"),
value: `${status.channels_deep_done}/${status.channels_deep_requested}`,
hint: t("channels.stats.fullHistoryHint"),
},
...(status.deep_pending_count > 0
? [
{
icon: Clock,
label: t("channels.stats.left"),
value: formatEta(status.deep_eta_seconds),
hint: t("channels.stats.leftHint", { count: status.deep_pending_count }),
},
]
: []),
{
icon: Film,
label: t("channels.stats.myVideos"),
value: status.my_videos.toLocaleString(),
hint: t("channels.stats.myVideosHint"),
},
{
icon: Zap,
label: t("channels.stats.quotaLeft"),
value: status.quota_remaining_today.toLocaleString(),
hint: t("channels.stats.quotaLeftHint"),
},
];
return (
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-muted transition"
>
<BarChart3 className="h-4 w-4" aria-hidden="true" />
{t("channels.stats.label")}
</button>
<PopoverSurface
popover={pop}
ariaLabel={t("channels.stats.label")}
className="glass-menu w-60 rounded-xl p-2 text-xs animate-[popIn_0.16s_ease]"
>
{rows.map((r) => (
<div
key={r.label}
title={r.hint}
className="flex cursor-help items-center gap-2.5 px-1.5 py-1.5"
>
<r.icon className="h-4 w-4 shrink-0 text-muted" aria-hidden="true" />
<span className="flex-1 text-muted">{r.label}</span>
<span className="tabular-nums font-medium text-fg">{r.value}</span>
</div>
))}
</PopoverSurface>
</>
);
}
+2 -1
View File
@@ -22,7 +22,8 @@ interface TagChipProps {
}
function TagChip({ tag, onClick, title }: TagChipProps) {
const cls = "chip-solid shrink-0 text-3xs px-1.5 py-0.5 rounded-full max-w-36 truncate";
const cls =
"chip-solid shrink-0 text-2xs font-medium px-1.5 py-0.5 rounded-full max-w-36 truncate";
return onClick ? (
<button
type="button"
+143 -196
View File
@@ -1,25 +1,30 @@
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 { qk } from "../lib/queryKeys";
import { Ban, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react";
import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react";
import ExcludedChannelList from "./ExcludedChannelList";
import { api, type ManagedChannel } from "../lib/api";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
import { formatEta } from "../lib/format";
import { buildChannelColumns } from "./channelColumns";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import DataTable from "./DataTable";
import Tabs, { type TabDef } from "./Tabs";
import ChannelLayoutGrid from "./ChannelLayoutGrid";
import { LoadingState, StateMessage } from "./QueryState";
import { useChannelTagActions } from "../lib/useChannelTagActions";
import { useChannelPriority } from "../lib/useChannelPriority";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
import ColumnSortControl from "./ColumnSortControl";
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
import ActionMenu, { type ActionMenuItem } from "./ActionMenu";
import ChannelStatsPopover from "./ChannelStatsPopover";
import Pager from "./Pager";
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
import { sortRows } from "../lib/columnSort";
import { filterRows } from "./tableHeader";
import { useCardPager } from "../lib/useCardPager";
import { PageToolbar } from "./PageShell";
import ChannelDiscovery from "./ChannelDiscovery";
@@ -31,15 +36,16 @@ import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useWizard } from "./WizardProvider";
import { useMe } from "../lib/useMe";
export type ChannelsView = "subscribed" | "discovery" | "blocked";
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
{ id: "all", labelKey: "channels.filters.all" },
{ id: "needs_full", labelKey: "channels.filters.needsFull" },
{ id: "fully_synced", labelKey: "channels.filters.fullySynced" },
{ id: "hidden", labelKey: "channels.filters.hidden" },
// The top sync chips are a controlled view over the subscribed table's `sync` column filter (shared
// via ChannelsProvider): each chip writes `filters.sync`, using the SAME vocab + predicate as the
// Sync column funnel (channelColumns.tsx) so the two can't drift. "" = all (isActive treats it as
// off). fullySynced = recent_synced && backfill_done (matches the cell's own "fully synced" badge).
const SYNC_CHIPS: { value: string; labelKey: string }[] = [
{ value: "", labelKey: "channels.filters.all" },
{ value: "needsFull", labelKey: "channels.filters.needsFull" },
{ value: "fully", labelKey: "channels.filters.fullySynced" },
];
const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
@@ -58,7 +64,7 @@ export default function Channels() {
// callers); this page is their primary editor.
const {
search,
statusFilter,
filters,
view,
sort,
filtersResetToken,
@@ -68,7 +74,7 @@ export default function Channels() {
const layout = useChannelLayout();
const {
setSearch,
setStatusFilter,
setFilters,
setView,
setLayout,
setSort,
@@ -206,41 +212,41 @@ export default function Channels() {
notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard),
});
// Channel-name search (from the shared top SearchBar, via props) + tag-chip filtering, applied
// client-side over the status-filtered list.
const [tagFilter, setTagFilter] = useState<number[]>([]);
// The tag-chip row is a controlled view over the Tags COLUMN filter (`filters.tags`, a string[] of
// tag ids), the same way the sync chips drive `filters.sync` — so the top chips and the Tags column
// funnel share ONE state and stay in sync (bidirectional). Toggling a chip rewrites `filters.tags`;
// filterRows / DataTable then applies it. Channel-name search stays separate (the shared top box).
const tagFilter = Array.isArray(filters.tags) ? filters.tags : [];
const toggleTagFilter = (tagId: number) => {
const id = String(tagId);
const next = tagFilter.includes(id) ? tagFilter.filter((x) => x !== id) : [...tagFilter, id];
setFilters({ ...filters, tags: next });
};
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
useEffect(() => {
if (focusChannelName) setSearch(focusChannelName);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focusChannelToken]);
// The header's reset intent clears the search + tag chips (skip the initial mount).
// The header's reset intent clears the search box (skip the initial mount); the tag chips ride in
// `filters.tags`, which goToFullHistory already clears by setting a fresh map.
const resetRef = useRef(filtersResetToken);
useEffect(() => {
if (filtersResetToken === resetRef.current) return;
resetRef.current = filtersResetToken;
setSearch("");
setTagFilter([]);
}, [filtersResetToken]);
// The Sync-status filter stays a top-level chip set (not a column filter) because the
// header's "go to full history" deep-link drives it; search/tag filtering + sort + pagination
// run over whatever the status chip leaves.
const channels = (channelsQuery.data ?? []).filter((c) =>
statusFilter === "needs_full"
? !c.backfill_done
: statusFilter === "fully_synced"
? c.backfill_done
: statusFilter === "hidden"
? c.hidden
: true,
);
// The sync-status AND tag filters are now controlled column filters (`filters.sync` / `filters.tags`)
// shared by the top chips, the column funnels, and the card layout — no separate pre-filter. Hidden
// channels have their own tab, so they're excluded from the subscribed table (no more dimmed rows).
// Only the name search filters here; the column filters (sync/tags/priority/text funnels) are applied
// by DataTable in table mode and by filterRows below in card mode, over the same `columns`.
const hiddenChannels = (channelsQuery.data ?? []).filter((c) => c.hidden);
const channels = (channelsQuery.data ?? []).filter((c) => !c.hidden);
const q = search.trim().toLowerCase();
const visibleChannels = channels.filter((c) => {
if (q && !`${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q)) return false;
if (tagFilter.length && !tagFilter.some((id) => c.tag_ids.includes(id))) return false;
return true;
});
const visibleChannels = channels.filter(
(c) => !q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q),
);
const s = statusQuery.data;
const onView = (c: ManagedChannel) =>
@@ -288,88 +294,43 @@ export default function Channels() {
// Status chips sit in the table's controls row (next to paging) — compact and close to
// where they act.
const syncFilter = typeof filters.sync === "string" ? filters.sync : "";
const statusChips = (
<div className="flex flex-wrap items-center gap-1.5">
{STATUS_FILTERS.map((f) => (
<button
key={f.id}
onClick={() => setStatusFilter(f.id)}
aria-pressed={statusFilter === f.id}
className="pill text-xs px-2.5 py-1 rounded-full border"
>
{t(f.labelKey)}
</button>
))}
</div>
<FilterChipGroup
ariaLabel={t("channels.filters.syncLabel")}
className="flex flex-wrap items-center gap-1.5"
options={SYNC_CHIPS.map((c) => ({ value: c.value, label: t(c.labelKey) }))}
value={syncFilter}
onChange={(sync) => setFilters({ ...filters, sync })}
/>
);
// The catalog-wide stats ride on the right of the tab row (subscribed only), leaving room for
// future tabs on the left.
const statsBlock = (
<div className="flex flex-wrap items-center justify-end gap-x-5 gap-y-1 text-sm text-muted">
{s && (
<>
<Stat
label={t("channels.stats.channels")}
value={s.channels_total}
hint={t("channels.stats.channelsHint")}
/>
<Stat
label={t("channels.stats.recentSynced")}
value={`${s.channels_recent_synced}/${s.channels_total}`}
hint={t("channels.stats.recentSyncedHint")}
/>
<Stat
label={t("channels.stats.fullHistory")}
value={`${s.channels_deep_done}/${s.channels_deep_requested}`}
hint={t("channels.stats.fullHistoryHint")}
/>
{s.deep_pending_count > 0 && (
<Stat
label={t("channels.stats.left")}
value={formatEta(s.deep_eta_seconds)}
hint={t("channels.stats.leftHint", { count: s.deep_pending_count })}
/>
)}
<Stat
label={t("channels.stats.myVideos")}
value={s.my_videos.toLocaleString()}
hint={t("channels.stats.myVideosHint")}
/>
<Stat
label={t("channels.stats.quotaLeft")}
value={s.quota_remaining_today.toLocaleString()}
hint={t("channels.stats.quotaLeftHint")}
/>
</>
)}
</div>
);
// The catalog-wide stats collapse into a compact "Stats" hover chip (right of the tab row, subscribed
// only) instead of a crowded inline row.
const statsBlock = s ? <ChannelStatsPopover status={s} /> : null;
// Sync + backfill — now share the tags row (right side).
// Catalog-wide, occasional actions — tucked into a shared ⋯ overflow menu so they stop crowding the
// tags row. Pending mutations keep the item visible but disabled, with a spinning icon.
const catalogActions: ActionMenuItem[] = [
{
id: "sync",
label: t("channels.syncSubscriptions"),
title: t("channels.syncSubscriptionsHint"),
icon: <RefreshCw className={`h-4 w-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />,
onSelect: () => syncSubs.mutate(),
disabled: syncSubs.isPending,
},
{
id: "backfill",
label: t("channels.backfillEverything"),
title: t("channels.backfillEverythingHint"),
icon: <History className={`h-4 w-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />,
onSelect: () => deepAll.mutate(),
disabled: deepAll.isPending,
},
];
const actionButtons = (
<div className="flex items-center gap-2 shrink-0">
<Tooltip side="bottom" hint={t("channels.syncSubscriptionsHint")}>
<button
onClick={() => syncSubs.mutate()}
disabled={syncSubs.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
{t("channels.syncSubscriptions")}
</button>
</Tooltip>
<Tooltip side="bottom" hint={t("channels.backfillEverythingHint")}>
<button
onClick={() => deepAll.mutate()}
disabled={deepAll.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
{t("channels.backfillEverything")}
</button>
</Tooltip>
</div>
<ActionMenu ariaLabel={t("channels.actionsLabel")} items={catalogActions} />
);
// Table / cards density — shared by the subscribed + discovery tables (blocked is a chip list, no
@@ -407,7 +368,11 @@ export default function Channels() {
// Only computed for cards: in table mode DataTable sorts and slices internally, so doing it here
// too would copy+sort the whole ~300-row array on every render for a result nothing reads.
const isCards = layout === "cards";
const sortedChannels = isCards ? sortRows(visibleChannels, columns, sort) : visibleChannels;
// Cards have no column headers, so apply the shared column filters (sync + any funnel set in table
// mode) here via the exported predicate — the same filterRows DataTable runs internally — then sort.
const sortedChannels = isCards
? sortRows(filterRows(visibleChannels, columns, filters), columns, sort)
: visibleChannels;
const {
paged: pagedCards,
pagerProps,
@@ -420,23 +385,25 @@ export default function Channels() {
// handled by the hook's clamp instead.
useEffect(() => {
resetPage();
}, [statusFilter, search, tagFilter, sort, resetPage]);
}, [filters, search, sort, resetPage]);
const cardPager = <Pager {...pagerProps} />;
const viewTabs: TabDef[] = useMemo(
() =>
(["subscribed", "discovery", "hidden", "blocked"] as ChannelsView[]).map((v) => ({
id: v,
label: t(`channels.tabs.${v}`),
})),
[t],
);
const tabs = (
<div className="px-4 pt-4 max-w-384 mx-auto flex items-center justify-between gap-4 flex-wrap">
<div className="flex flex-wrap items-center gap-1.5">
{(["subscribed", "discovery", "blocked"] as ChannelsView[]).map((v) => (
<button
key={v}
onClick={() => setView(v)}
aria-pressed={view === v}
className="pill text-sm px-3 py-1.5 rounded-full border"
>
{t(`channels.tabs.${v}`)}
</button>
))}
</div>
<div className="px-4 pt-2 max-w-384 mx-auto flex items-center justify-between gap-4 flex-wrap">
<Tabs
tabs={viewTabs}
active={view}
onChange={(id) => setView(id as ChannelsView)}
className="flex flex-wrap items-center gap-1"
/>
<div className="flex items-center gap-3">
{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. */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex flex-wrap items-center gap-1.5">
{/* 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. */}
<Tooltip hint={t("channels.tags.yourTagsHint")}>
<span className="text-xs uppercase tracking-wide text-muted mr-1 cursor-help underline decoration-dotted decoration-muted/40 underline-offset-4">
{t("channels.tags.yourTags")}
</span>
<button
onClick={() => 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"
>
<Pencil className="h-3 w-3" />
{t("channels.tags.label")}
</button>
</Tooltip>
{userTags.map((tg) => {
const active = tagFilter.includes(tg.id);
return (
<button
key={tg.id}
onClick={() =>
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}
</button>
);
})}
{userTags.map((tg) => (
<button
key={tg.id}
onClick={() => toggleTagFilter(tg.id)}
aria-pressed={tagFilter.includes(String(tg.id))}
className={filterChipClass}
>
{tg.name}
</button>
))}
{tagFilter.length > 0 && (
<button
onClick={() => setTagFilter([])}
onClick={() => setFilters({ ...filters, tags: [] })}
className="text-xs px-2 py-1 text-muted hover:text-accent transition"
>
{t("datatable.clear")}
</button>
)}
<button
onClick={() => 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"
>
<Pencil className="w-3 h-3" />
{t("channels.tags.manage")}
</button>
</div>
{actionButtons}
</div>
@@ -521,37 +482,33 @@ export default function Channels() {
) : view === "blocked" ? (
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
{(blockedQuery.data?.length ?? 0) === 0 ? (
<p className="text-sm text-muted py-8">{t("channels.blocked.empty")}</p>
) : (
<>
<div className="flex items-center gap-2 mb-3">
<Ban className="w-4 h-4 text-muted" />
<span className="text-xs uppercase tracking-wide text-muted">
{t("channels.blocked.title")}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{blockedQuery.data!.map((c) => (
<span
key={c.id}
className="inline-flex items-center gap-1.5 text-xs pl-2.5 pr-1 py-1 rounded-full bg-card border border-border"
>
{c.title ?? c.id}
<button
onClick={() => 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"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<p className="text-2xs text-muted mt-2">{t("channels.blocked.hint")}</p>
</>
)}
<ExcludedChannelList
channels={blockedQuery.data ?? []}
icon={<Ban className="w-4 h-4 text-muted" />}
title={t("channels.blocked.title")}
hint={t("channels.blocked.hint")}
emptyText={t("channels.blocked.empty")}
actionLabel={t("channel.unblock")}
actionIcon={<X className="w-3.5 h-3.5" />}
onAction={(id) => unblock.mutate(id)}
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
/>
</div>
) : 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). */
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
<ExcludedChannelList
channels={hiddenChannels}
icon={<EyeOff className="w-4 h-4 text-muted" />}
title={t("channels.hidden.title")}
hint={t("channels.hidden.hint")}
emptyText={t("channels.hidden.empty")}
actionLabel={t("channels.hidden.unhide")}
actionIcon={<Eye className="w-3.5 h-3.5" />}
onAction={(id) => patch.mutate({ id, body: { hidden: false } })}
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
/>
</div>
) : (
/* 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 (
<Tooltip hint={hint ?? ""}>
<span className={hint ? "cursor-help" : ""}>
<span className="text-fg font-semibold">{value}</span> {label}
</span>
</Tooltip>
);
}
@@ -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 }) => (
<ChannelsProvider>{children}</ChannelsProvider>
);
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
});
});
+70 -73
View File
@@ -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 <App/>).
//
// 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<ChannelsState>({
search: "",
statusFilter: "all",
filters: {},
view: "subscribed",
sort: null,
filtersResetToken: 0,
@@ -83,7 +89,7 @@ const StateContext = createContext<ChannelsState>({
const LayoutContext = createContext<ChannelLayout>("table");
const ActionsContext = createContext<ChannelsActions>({
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<ChannelStatusFilter>(() =>
clampStatus(getAccountRaw(LS.channelFilter)),
);
const [view, setViewState] = useState<ChannelsView>(() =>
clampView(getAccountRaw(LS.channelsView)),
);
const [layout, setLayoutState] = useState<ChannelLayout>(() =>
parseChannelLayout(getAccountRaw(LS.channelLayout)),
);
const [sort, setSortState] = useState<SortState>(() =>
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
// <App>, 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<string | null>(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<ChannelsState>(
() => ({ 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<ChannelsActions>(
() => ({
setSearch,
setStatusFilter,
setFilters,
setView,
setLayout,
setSort,
focusChannel,
goToFullHistory,
}),
[setSearch, setStatusFilter, setView, setLayout, setSort, focusChannel, goToFullHistory],
[setSearch, setFilters, setView, setLayout, setSort, focusChannel, goToFullHistory],
);
return (
+15 -43
View File
@@ -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<T>({
columns,
sort,
@@ -25,44 +26,15 @@ export default function ColumnSortControl<T>({
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 (
<div className="flex items-center gap-1">
<select
value={active?.key ?? ""}
onChange={(e) => {
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"
>
<option value="">{defaultLabel}</option>
{sortable.map((c) => (
<option key={c.key} value={c.key}>
{c.header}
</option>
))}
</select>
{active && (
<button
onClick={() => 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" ? (
<ArrowUp className="w-4 h-4" />
) : (
<ArrowDown className="w-4 h-4" />
)}
</button>
)}
</div>
<SortControl
options={optionsFromColumns(columns)}
sort={sort}
onChange={onChange}
label={label}
defaultLabel={defaultLabel}
ascLabel={ascLabel}
descLabel={descLabel}
/>
);
}
+15
View File
@@ -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 (
<span
className={`tabular-nums text-2xs leading-none px-1.5 py-0.5 rounded-full ${
active ? "bg-accent-fg/20" : "bg-accent/20 text-accent"
}`}
>
{count}
</span>
);
}
+47 -7
View File
@@ -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<T>({
resetFiltersToken,
sort: sortProp,
onSortChange,
filters: filtersProp,
onFiltersChange,
}: {
rows: T[];
columns: Column<T>[];
@@ -82,6 +89,13 @@ export default function DataTable<T>({
// 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<T>({
const controlledSort = onSortChange !== undefined;
const [internalSort, setInternalSort] = useState<SortState>(initial.sort);
const sort = controlledSort ? (sortProp ?? null) : internalSort;
const [filters, setFilters] = useState<FilterMap>(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<FilterMap>(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<T>({
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<T>({
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<T>({
{paged.map((row) => (
<tr
key={rowKey(row)}
className={`border-b border-border/50 hover:bg-card/40 transition ${
className={`border-b border-border/50 hover:bg-(--hover) transition ${
rowClassName?.(row) ?? ""
}`}
>
@@ -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 <p className="text-sm text-muted py-8">{emptyText}</p>;
return (
<>
<div className="flex items-center gap-2 mb-3">
{icon}
<span className="text-xs uppercase tracking-wide text-muted">{title}</span>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{channels.map((c) => (
<div key={c.id} className="glass-card flex items-center gap-2 rounded-xl px-3 py-2">
<div className="min-w-0 flex-1">
<ChannelLink
id={c.id}
title={c.title}
handle={c.handle}
thumbnailUrl={c.thumbnail_url}
onView={onView ? () => onView(c) : undefined}
/>
</div>
<button
onClick={() => 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}
</button>
</div>
))}
</div>
<p className="text-2xs text-muted mt-3">{hint}</p>
</>
);
}
+66 -75
View File
@@ -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<SortKey, "shuffle" | "relevance">],
}));
const toolbar = (
<div className="pb-3">
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
{SHOW_IDS.map((id) => (
<button
key={id}
data-testid={`feed-show-${id}`}
onClick={() => 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)}
</button>
))}
<FilterChipGroup
ariaLabel={t("feed.showLabel")}
options={SHOW_IDS.map((id) => ({
value: id,
label: t("sidebar.show." + id),
testId: `feed-show-${id}`,
}))}
value={filters.show}
onChange={(show) => setFilters({ ...filters, show })}
/>
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
{CONTENT.map((c) => {
const on = filters[c.key];
return (
<button
key={c.key}
data-testid={`feed-content-${c.key}`}
onClick={() => setFilters({ ...filters, [c.key]: !on })}
aria-pressed={on}
className="pill text-xs px-3 py-1.5 rounded-full border"
>
{t(c.label)}
</button>
);
})}
{/* Content-type toggles are MULTI-select (independent booleans) — same chip look via
filterChipClass, but not a single-select group. */}
<div className="flex flex-wrap gap-1.5" role="group" aria-label={t("feed.contentLabel")}>
{CONTENT.map((c) => {
const on = filters[c.key];
return (
<button
key={c.key}
data-testid={`feed-content-${c.key}`}
onClick={() => setFilters({ ...filters, [c.key]: !on })}
aria-pressed={on}
className={filterChipClass}
>
{t(c.label)}
</button>
);
})}
</div>
{/* 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({
</span>
<div className="ml-auto flex items-center gap-2">
<span className="text-xs text-muted">{t("feed.sortLabel")}</span>
<select
value={sortKey}
onChange={(e) => {
const key = e.target.value as SortKey;
const dir = DIRECTIONLESS.includes(key)
? "desc"
: SORT_DEFAULT_DIR[key as Exclude<SortKey, "shuffle" | "relevance">];
<SortControl
options={sortOptions}
sort={{ key: sortKey, dir: sortDir }}
onChange={(next) => {
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) => (
<option key={k} value={k}>
{t("feed.sortKey." + k)}
</option>
))}
</select>
{!DIRECTIONLESS.includes(sortKey) && (
<button
onClick={() =>
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" ? (
<ArrowUp className="w-4 h-4" />
) : (
<ArrowDown className="w-4 h-4" />
)}
</button>
)}
{sortKey === "shuffle" && (
<button
onClick={() => 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"
>
<RefreshCw className="w-4 h-4" />
</button>
)}
label={t("feed.sortLabel")}
ascLabel={t("feed.dirAsc")}
descLabel={t("feed.dirDesc")}
trailing={
sortKey === "shuffle" ? (
<button
onClick={() => 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"
>
<RefreshCw className="w-4 h-4" />
</button>
) : undefined
}
/>
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
<ViewSwitcher
value={view}
@@ -0,0 +1,63 @@
// @vitest-environment jsdom
//
// Rendering integration of the shared single-select FilterChipGroup. Labels are plain props; active
// paint is CSS (`.pill[aria-pressed]`), so we assert the aria-pressed / onChange / testId contract.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
const OPTS = [
{ value: "all", label: "All", testId: "chip-all" },
{ value: "needsFull", label: "Needs full" },
{ value: "fully", label: "Fully synced", count: 3 },
] as const;
afterEach(cleanup);
describe("FilterChipGroup", () => {
it("renders one chip per option with labels", () => {
render(<FilterChipGroup options={OPTS} value="all" onChange={vi.fn()} ariaLabel="Sync" />);
expect(screen.getAllByRole("button").map((b) => b.textContent)).toEqual([
"All",
"Needs full",
"Fully synced3",
]);
});
it("marks exactly the active chip aria-pressed", () => {
render(
<FilterChipGroup options={OPTS} value="needsFull" onChange={vi.fn()} ariaLabel="Sync" />,
);
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(<FilterChipGroup options={OPTS} value="all" onChange={onChange} ariaLabel="Sync" />);
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(<FilterChipGroup options={OPTS} value="all" onChange={onChange} ariaLabel="Sync" />);
fireEvent.click(screen.getByRole("button", { name: "All" }));
expect(onChange).toHaveBeenCalledWith("all");
});
it("preserves per-option testId and exposes the group aria-label", () => {
render(
<FilterChipGroup options={OPTS} value="all" onChange={vi.fn()} ariaLabel="Sync state" />,
);
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");
});
});
@@ -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<T extends string> {
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<T extends string>({
options,
value,
onChange,
ariaLabel,
className = "flex flex-wrap gap-1.5",
}: {
options: readonly FilterChipOption<T>[];
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 (
<div className={className} role="group" aria-label={ariaLabel}>
{options.map((o) => {
const on = value === o.value;
return (
<button
key={o.value}
data-testid={o.testId}
onClick={() => onChange(o.value)}
title={o.title}
aria-pressed={on}
className={filterChipClass}
>
{o.icon}
<span>{o.label}</span>
{o.count != null && o.count > 0 && <CountBadge count={o.count} active={on} />}
</button>
);
})}
</div>
);
}
+37 -12
View File
@@ -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 <App>, 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.
+19 -8
View File
@@ -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<number | null>(() => {
const s = getAccountRaw(LS.playlist);
return s ? Number(s) : null;
});
const [selectedId, setSelectedIdState] = useState<number | null>(readSelectedId);
// Re-read the selected playlist once the account is pinned after mount (first load on a fresh tab):
// this provider mounts above <App>, 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<PlaylistsState>(() => ({ search, selectedId }), [search, selectedId]);
const actions = useMemo<PlaylistsActions>(
+29 -38
View File
@@ -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<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]);
@@ -149,43 +157,26 @@ export default function PlaylistsRail({
{t("playlists.syncYoutube")}
</button>
{playlists.length > 1 && (
<div className="flex items-center gap-1">
<select
value={plSort.key}
onChange={(e) => 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"
>
<option value="custom">{t("playlists.railSortCustom")}</option>
<option value="name">{t("playlists.railSortName")}</option>
<option value="count">{t("playlists.railSortCount")}</option>
<option value="duration">{t("playlists.railSortDuration")}</option>
</select>
<button
onClick={() => 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" ? (
<ArrowUp className="w-3.5 h-3.5" />
) : (
<ArrowDown className="w-3.5 h-3.5" />
)}
</button>
<button
onClick={() => 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"
}`}
>
<Pin className="w-3.5 h-3.5" />
</button>
</div>
<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) => {
+14 -2
View File
@@ -9,6 +9,7 @@ import {
type ReactNode,
} from "react";
import { qk } from "../lib/queryKeys";
import { healPlexSort } from "../lib/plexSort";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
@@ -58,6 +59,8 @@ import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
import PlexCollectionEditor from "./PlexCollectionEditor";
import { LoadingState, StateMessage } from "./QueryState";
import { PageToolbar } from "./PageShell";
import ActiveFilterChips from "./ActiveFilterChips";
import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters";
import { usePlex, usePlexActions } from "./PlexProvider";
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
@@ -90,6 +93,13 @@ export default function PlexBrowse() {
const { q, scope, show, sort, filters, playlistOpen: openPlaylist } = usePlex();
const { setScope, setFilters, setPlaylistOpen } = usePlexActions();
const dq = useDebounced(q.trim(), 350);
// Plex's active-filter chip row (above the grid), the shared feed idiom: one removable chip per
// narrowing, plus a Clear-all (shared with the sidebar) that resets filters + watch-state + sort.
const plexChips = usePlexActiveFilters();
const clearAllFilters = usePlexClearFilters();
// The provider holds sort scope-agnostically; heal it against THIS scope's options so a movie-only
// key (e.g. duration) left over from before a switch to shows doesn't ask for an unavailable sort.
const sortState = healPlexSort(sort, scope);
const sub = useHistorySubview<Sub>({ kind: "grid" });
// Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then
@@ -141,14 +151,14 @@ export default function PlexBrowse() {
}, [sub]);
const browseQ = useInfiniteQuery({
queryKey: qk.plexLibrary(scope, dq, sort, show, filters),
queryKey: qk.plexLibrary(scope, dq, sortState, show, filters),
enabled: !!scope && sub.view.kind === "grid",
initialPageParam: 0,
queryFn: ({ pageParam }) =>
api.plexLibrary({
scope,
q: dq || undefined,
sort,
sort: sortState,
show,
filters,
offset: pageParam as number,
@@ -329,6 +339,8 @@ export default function PlexBrowse() {
</div>
</PageToolbar>
<ActiveFilterChips filters={plexChips} onClearAll={clearAllFilters} />
{personCards.length > 0 && (
<div className="mb-4 flex flex-wrap gap-3">
{personCards.map((p) => (
+13 -26
View File
@@ -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 }) => (
<button
onClick={() => 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}
</button>
);
const layoutOptions: ViewOption<Layout>[] = [
{ id: "accordion", label: t("plex.playlist.layoutAccordion"), icon: Rows3 },
{ id: "tree", label: t("plex.playlist.layoutTree"), icon: ListTree },
];
return (
<div className="mx-auto w-[90%] max-w-[1100px] p-4">
@@ -536,19 +529,13 @@ export default function PlexPlaylistView({
) : (
<h1 className="min-w-0 flex-1 truncate text-2xl font-bold">{q.data?.title ?? " "}</h1>
)}
{/* Layout preference: Accordion (A) or Tree (C). */}
<div className="flex items-center gap-1">
<LayoutBtn
v="accordion"
icon={<Rows3 className="h-4 w-4" />}
label={t("plex.playlist.layoutAccordion")}
/>
<LayoutBtn
v="tree"
icon={<ListTree className="h-4 w-4" />}
label={t("plex.playlist.layoutTree")}
/>
</div>
{/* Layout preference: Accordion or Tree — the shared list-view switcher. */}
<ViewSwitcher
value={layout}
options={layoutOptions}
onChange={setLayout}
label={t("plex.playlist.layoutLabel")}
/>
<button
onClick={() => {
setName(q.data?.title ?? "");
+106 -35
View File
@@ -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 <App/>).
//
// 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
// <App>, 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<PlexState>({
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<string>(() => {
const v = getAccountRaw(LS.plexLibrary) ?? "both";
return ["movie", "show", "both"].includes(v) ? v : "both";
});
const [show, setShowState] = useState<string>(() => getAccountRaw(LS.plexShow) ?? "all");
const [sort, setSortState] = useState<string>(() => getAccountRaw(LS.plexSort) ?? "title");
const [scope, setScopeState] = useState<string>(readPlexScope);
const [show, setShowState] = useState<string>(readPlexShow);
const [sort, setSortState] = useState<SortState>(readPlexSort);
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
const [filtersRaw, setFiltersRaw] = useState<string>(() => getAccountRaw(LS.plexFilters) ?? "{}");
const [filtersRaw, setFiltersRaw] = useState<string>(readPlexFiltersRaw);
const filters = useMemo<PlexFilters>(() => {
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<string>(() => getAccountRaw(LS.plexQ) ?? "");
const [q, setQState] = useState<string>(readPlexQ);
const [playlistOpen, setPlaylistOpen] = useState<number | null>(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<PlexState>(
() => ({ scope, show, sort, filters, q, playlistOpen }),
+109 -155
View File
@@ -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 (
<div className="mb-1.5 flex items-center justify-between">
<span className="text-3xs uppercase tracking-wide text-muted">
{t("plex.filter.matchLabel")}
</span>
<div
className="flex items-center rounded-full border border-border bg-card p-0.5 text-3xs"
role="group"
aria-label={t("plex.filter.matchLabel")}
>
{(["any", "all"] as const).map((m) => (
<button
key={m}
onClick={() => onChange(m)}
aria-pressed={mode === m}
className="pill rounded-full px-2 py-0.5"
>
{t(`plex.filter.match.${m}`)}
</button>
))}
</div>
</div>
<SegmentedToggle
label={t("plex.filter.matchLabel")}
ariaLabel={t("plex.filter.matchLabel")}
options={[
{ value: "any", label: t("plex.filter.match.any") },
{ value: "all", label: t("plex.filter.match.all") },
]}
value={mode}
onChange={onChange}
/>
);
}
@@ -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<PlexFilters>) => 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: (
<div className="grid grid-cols-3 gap-1">
{(["both", "movie", "show"] as const).map((s) => (
<button
key={s}
onClick={() => 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}`)}
</button>
))}
</div>
<SegmentedToggle
ariaLabel={t("plex.filter.scope")}
fullWidth
size="md"
options={(["both", "movie", "show"] as const).map((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: (
<ChipRow>
{SHOW_OPTS.map((s) => (
<Chip key={s} active={show === s} onClick={() => setShow(s)}>
{t(`plex.filter.showOpt.${s}`)}
</Chip>
))}
</ChipRow>
<FilterChipGroup
ariaLabel={t("plex.filter.show")}
options={SHOW_OPTS.map((s) => ({ value: s, label: t(`plex.filter.showOpt.${s}`) }))}
value={show}
onChange={setShow}
/>
),
},
{
id: "sort",
title: t("plex.filter.sort"),
body: (
<>
<ChipRow>
{sorts.map((s) => (
<Chip key={s} active={sort === s} onClick={() => setSort(s)}>
{t(`plex.sort.${s}`)}
</Chip>
))}
</ChipRow>
<div className="mt-1.5 flex gap-1">
{(["asc", "desc"] as const).map((d) => (
<Chip
key={d}
active={(filters.sortDir ?? "asc") === d}
onClick={() => patch({ sortDir: d })}
>
{t(`plex.filter.dir.${d}`)}
</Chip>
))}
</div>
</>
<SortControl
options={sortOptions}
sort={healedSort}
onChange={(next) => 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: (
<ChipRow>
<Chip active={filters.ratingMin == null} onClick={() => patch({ ratingMin: null })}>
{t("plex.filter.any")}
</Chip>
{RATING_STEPS.map((r) => (
<Chip key={r} active={filters.ratingMin === r} onClick={() => patch({ ratingMin: r })}>
{r}+
</Chip>
))}
</ChipRow>
<FilterChipGroup
ariaLabel={t("plex.filter.rating")}
value={filters.ratingMin == null ? "" : String(filters.ratingMin)}
options={[
{ value: "", label: t("plex.filter.any") },
...RATING_STEPS.map((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 })}
/>
)}
<ChipRow>
<div
className="flex flex-wrap gap-1.5"
role="group"
aria-label={t("plex.filter.genre")}
>
{facets.genres.map((g) => (
<Chip
<button
key={g.value}
active={filters.genres.includes(g.value)}
onClick={() => patch({ genres: toggleIn(filters.genres, g.value) })}
aria-pressed={filters.genres.includes(g.value)}
className={filterChipClass}
>
{g.value}
</Chip>
</button>
))}
</ChipRow>
</div>
</>
),
},
@@ -394,25 +371,26 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
id: "duration",
title: t("plex.filter.duration"),
body: (
<ChipRow>
<Chip
active={
!durBucketKey && filters.durationMin == null && filters.durationMax == null
}
onClick={() => patch({ durationMin: null, durationMax: null })}
>
{t("plex.filter.any")}
</Chip>
{DURATION_BUCKETS.map((b) => (
<Chip
key={b.key}
active={durBucketKey === b.key}
onClick={() => patch({ durationMin: b.min, durationMax: b.max })}
>
{t(`plex.filter.durationOpt.${b.key}`)}
</Chip>
))}
</ChipRow>
<FilterChipGroup
ariaLabel={t("plex.filter.duration")}
// "Any" is selected only when there is genuinely no duration filter; a set-but-off-bucket
// duration (e.g. stale state after a bucket-boundary change) selects nothing, not "Any".
value={
durBucketKey ??
(filters.durationMin == null && filters.durationMax == null ? "" : "")
}
options={[
{ value: "", label: t("plex.filter.any") },
...DURATION_BUCKETS.map((b) => ({
value: b.key,
label: t(`plex.filter.durationOpt.${b.key}`),
})),
]}
onChange={(key) => {
const b = DURATION_BUCKETS.find((x) => x.key === key);
patch({ durationMin: b?.min ?? null, durationMax: b?.max ?? null });
}}
/>
),
},
]
@@ -421,20 +399,15 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
id: "added",
title: t("plex.filter.added"),
body: (
<ChipRow>
<Chip active={!filters.addedWithin} onClick={() => patch({ addedWithin: "" })}>
{t("plex.filter.any")}
</Chip>
{ADDED_OPTS.map((a) => (
<Chip
key={a}
active={filters.addedWithin === a}
onClick={() => patch({ addedWithin: a })}
>
{t(`plex.filter.addedOpt.${a}`)}
</Chip>
))}
</ChipRow>
<FilterChipGroup
ariaLabel={t("plex.filter.added")}
value={filters.addedWithin || ""}
options={[
{ value: "", label: t("plex.filter.any") },
...ADDED_OPTS.map((a) => ({ value: a, label: t(`plex.filter.addedOpt.${a}`) })),
]}
onChange={(v) => patch({ addedWithin: v })}
/>
),
},
...(facets && facets.content_ratings.length > 0
@@ -443,19 +416,24 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
id: "contentrating",
title: t("plex.filter.contentRating"),
body: (
<ChipRow>
<div
className="flex flex-wrap gap-1.5"
role="group"
aria-label={t("plex.filter.contentRating")}
>
{facets.content_ratings.map((c) => (
<Chip
<button
key={c.value}
active={filters.contentRatings.includes(c.value)}
onClick={() =>
patch({ contentRatings: toggleIn(filters.contentRatings, c.value) })
}
aria-pressed={filters.contentRatings.includes(c.value)}
className={filterChipClass}
>
{c.value}
</Chip>
</button>
))}
</ChipRow>
</div>
),
},
]
@@ -490,30 +468,6 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
);
}
function ChipRow({ children }: { children: ReactNode }) {
return <div className="flex flex-wrap gap-1">{children}</div>;
}
function Chip({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
onClick={onClick}
aria-pressed={active}
className={`pill px-2.5 py-1 rounded-full text-xs ${active ? "" : "glass-card glass-hover"}`}
>
{children}
</button>
);
}
function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) {
return (
<span className="chip px-2 py-1 text-xs">
@@ -0,0 +1,72 @@
// @vitest-environment jsdom
//
// Rendering integration of the shared SegmentedToggle. Labels are plain props (no i18n); the active
// paint is CSS (`.pill[aria-pressed]`), so we assert the aria-pressed contract, not styling.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import SegmentedToggle from "./SegmentedToggle";
const OPTS = [
{ value: "or", label: "Any" },
{ value: "and", label: "All" },
] as const;
afterEach(cleanup);
describe("SegmentedToggle", () => {
it("renders one button per option with the given labels", () => {
render(<SegmentedToggle options={OPTS} value="or" onChange={vi.fn()} ariaLabel="Match" />);
expect(screen.getAllByRole("button").map((b) => b.textContent)).toEqual(["Any", "All"]);
});
it("marks exactly the active option aria-pressed", () => {
render(<SegmentedToggle options={OPTS} value="and" onChange={vi.fn()} ariaLabel="Match" />);
expect(screen.getByRole("button", { name: "Any" }).getAttribute("aria-pressed")).toBe("false");
expect(screen.getByRole("button", { name: "All" }).getAttribute("aria-pressed")).toBe("true");
});
it("clicking an option fires onChange with its value", () => {
const onChange = vi.fn();
render(<SegmentedToggle options={OPTS} value="or" onChange={onChange} ariaLabel="Match" />);
fireEvent.click(screen.getByRole("button", { name: "All" }));
expect(onChange).toHaveBeenCalledWith("and");
});
it("exposes the group aria-label", () => {
render(<SegmentedToggle options={OPTS} value="or" onChange={vi.fn()} ariaLabel="Match mode" />);
expect(screen.getByRole("group", { name: "Match mode" })).toBeTruthy();
});
it("renders the caption when label is set", () => {
render(
<SegmentedToggle
options={OPTS}
value="or"
onChange={vi.fn()}
ariaLabel="Match"
label="Match"
/>,
);
// caption text plus the two option labels
expect(screen.getByText("Match")).toBeTruthy();
expect(screen.getAllByRole("button")).toHaveLength(2);
});
it("renders per-option icons and tooltips (scope-bar shape)", () => {
render(
<SegmentedToggle
ariaLabel="Scope"
fullWidth
size="md"
options={[
{ value: "my", label: "Mine", icon: <svg data-testid="ic-my" />, title: "Your subs" },
{ value: "all", label: "Library", icon: <svg data-testid="ic-all" /> },
]}
value="my"
onChange={vi.fn()}
/>,
);
expect(screen.getByTestId("ic-my")).toBeTruthy();
expect(screen.getByRole("button", { name: "Mine" }).getAttribute("title")).toBe("Your subs");
});
});
@@ -0,0 +1,75 @@
import { type ReactNode } from "react";
// Shared MATCH / segmented primitive — a rounded-full pill track where exactly ONE option is active
// (radio-like). Backs the feed any/all + or/and "Match" toggles and the feed Mine/Library scope bar,
// and the Plex any/all combine toggles. The active paint comes entirely from the
// `.pill[aria-pressed="true"]` recipe (index.css) — do NOT add bg/text/border-colour utilities on the
// buttons here or a utility-layer class would beat the pressed fill.
export interface SegmentedOption<T extends string> {
value: T;
label: string;
icon?: ReactNode;
title?: string; // optional per-option tooltip
}
export default function SegmentedToggle<T extends string>({
options,
value,
onChange,
ariaLabel,
label,
title,
fullWidth = false,
size = "sm",
className,
}: {
options: readonly SegmentedOption<T>[];
value: T;
onChange: (v: T) => void;
ariaLabel: string;
// Optional left-hand caption; when set the control renders as a justify-between row (the feed/Plex
// "Match" toggles). Omit for a standalone bar (the Mine/Library scope switch).
label?: ReactNode;
title?: string; // tooltip on the whole row (caption mode only)
// Full-width track with flex-1 buttons (the scope bar); default is intrinsic width.
fullWidth?: boolean;
// sm = text-3xs, compact padding (the captioned match toggles); md = text-xs (the scope bar).
size?: "sm" | "md";
className?: string;
}) {
const track = (
<div
className={`inline-flex items-center rounded-full border border-border bg-card p-0.5 ${
size === "md" ? "text-xs" : "text-3xs"
}${fullWidth ? " w-full" : ""}`}
role="group"
aria-label={ariaLabel}
>
{options.map((o) => (
<button
key={o.value}
onClick={() => onChange(o.value)}
title={o.title}
aria-pressed={value === o.value}
className={`pill inline-flex items-center justify-center gap-1 rounded-full ${
size === "md" ? "px-2.5 py-1" : "px-2 py-0.5"
}${fullWidth ? " flex-1" : ""}`}
>
{o.icon}
<span>{o.label}</span>
</button>
))}
</div>
);
if (label == null) {
return className ? <div className={className}>{track}</div> : track;
}
return (
<div className={`flex items-center justify-between ${className ?? "mb-1.5"}`} title={title}>
<span className="text-3xs uppercase tracking-wide text-muted">{label}</span>
{track}
</div>
);
}
+13 -19
View File
@@ -14,6 +14,7 @@ import { type SaveState } from "./ui/DraftSaveBar";
import { useConfirm } from "./ConfirmProvider";
import { useWizard } from "./WizardProvider";
import { usePrefs } from "./PrefsProvider";
import Tabs from "./Tabs";
import { useMe } from "../lib/useMe";
// The Settings page edits server-persisted preferences. Each change applies locally for instant
@@ -61,25 +62,18 @@ export default function SettingsPanel() {
<div className="glass rounded-2xl overflow-hidden">
<div className="flex">
{/* Vertical tab rail — never wraps; active is a clear accent pill. */}
<nav className="w-32 sm:w-36 shrink-0 border-r border-border/60 p-2 flex flex-col gap-1">
{TABS.map((tabItem) => {
const active = tab === tabItem.id;
return (
<button
key={tabItem.id}
data-testid={`settings-tab-${tabItem.id}`}
onClick={() => setTab(tabItem.id)}
aria-pressed={active}
className={`pill flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left ${
active ? "font-medium shadow-md shadow-accent/20" : "hover:bg-card/60"
}`}
>
<tabItem.icon className="w-4 h-4 shrink-0" />
{t(`settings.tabs.${tabItem.id}`)}
</button>
);
})}
</nav>
<Tabs
orientation="vertical"
className="w-32 sm:w-36 shrink-0 border-r border-border/60 p-2 flex flex-col gap-1"
active={tab}
onChange={setTab}
tabs={TABS.map((tabItem) => ({
id: tabItem.id,
label: t(`settings.tabs.${tabItem.id}`),
icon: tabItem.icon,
testId: `settings-tab-${tabItem.id}`,
}))}
/>
{/* Render only the active tab so the panel sizes to its actual content. (Stacking
every tab in one grid cell made the whole panel as tall as the tallest tab —
+55 -76
View File
@@ -10,11 +10,14 @@ import { copyText } from "../lib/clipboard";
import { notify } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
import { applyDateChip, deriveDateKey } from "../lib/dateFilter";
import TagManager from "./TagManager";
import SavedViewsWidget from "./SavedViewsWidget";
import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups";
import ChannelPicker from "./ChannelPicker";
import SegmentedToggle from "./SegmentedToggle";
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useChannelsActions } from "./ChannelsProvider";
import { useMe } from "../lib/useMe";
@@ -47,7 +50,7 @@ function TagChip({
onClick={onClick}
title={t("sidebar.channelCount", { count })}
aria-pressed={active}
className="pill inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border shadow-xs hover:shadow-sm active:translate-y-px"
className={filterChipClass}
>
<span>{tag.name}</span>
<span
@@ -149,42 +152,27 @@ export default function Sidebar({
return <SavedViewsWidget filters={filters} onApply={setFilters} />;
case "channel":
return <ChannelPicker filters={filters} setFilters={setFilters} />;
case "date":
case "date": {
// Single-select across the presets + Custom; pure logic lives in lib/dateFilter.
const dateKey = deriveDateKey(filters, customDates, DATE_PRESETS);
return (
<>
<div className="flex flex-wrap gap-1.5">
{DATE_PRESETS.map((p) => {
const activePreset = filters.maxAgeDays === p.days && !customDates;
return (
<button
key={p.days}
onClick={() =>
setFilters({
...filters,
maxAgeDays: activePreset ? undefined : p.days,
dateFrom: undefined,
dateTo: undefined,
})
}
aria-pressed={activePreset}
className="pill text-xs px-2.5 py-1 rounded-full border shadow-xs active:translate-y-px"
>
{t("sidebar.datePreset." + p.key)}
</button>
);
})}
<button
onClick={() => {
const next = !customDates;
setCustomDates(next);
if (next) setFilters({ ...filters, maxAgeDays: undefined });
}}
aria-pressed={!!(customDates || filters.dateFrom || filters.dateTo)}
className="pill text-xs px-2.5 py-1 rounded-full border shadow-xs active:translate-y-px"
>
{t("sidebar.custom")}
</button>
</div>
<FilterChipGroup
ariaLabel={t("sidebar.datePresetLabel")}
value={dateKey}
options={[
...DATE_PRESETS.map((p) => ({
value: String(p.days),
label: t("sidebar.datePreset." + p.key),
})),
{ value: "custom", label: t("sidebar.custom") },
]}
onChange={(key) => {
const change = applyDateChip(key, filters, customDates);
if (change.customDates !== undefined) setCustomDates(change.customDates);
if (change.filters) setFilters(change.filters);
}}
/>
{(customDates || filters.dateFrom || filters.dateTo) && (
<div className="flex flex-col gap-1.5 mt-2">
@@ -232,6 +220,7 @@ export default function Sidebar({
)}
</>
);
}
case "language": {
const chips = visibleChips(languages);
return (
@@ -255,30 +244,17 @@ export default function Sidebar({
const chips = visibleChips(topics);
return (
<>
<div
className="flex items-center justify-between mb-1.5"
<SegmentedToggle
label={t("sidebar.match")}
title={t("sidebar.tagModeTooltip")}
>
<span className="text-3xs uppercase tracking-wide text-muted">
{t("sidebar.match")}
</span>
<div
className="flex items-center rounded-full border border-border bg-card p-0.5 text-3xs"
role="group"
aria-label={t("sidebar.match")}
>
{(["or", "and"] as const).map((m) => (
<button
key={m}
onClick={() => setFilters({ ...filters, tagMode: m })}
aria-pressed={filters.tagMode === m}
className="pill px-2 py-0.5 rounded-full"
>
{m === "or" ? t("sidebar.any") : t("sidebar.all")}
</button>
))}
</div>
</div>
ariaLabel={t("sidebar.match")}
options={[
{ value: "or", label: t("sidebar.any") },
{ value: "and", label: t("sidebar.all") },
]}
value={filters.tagMode}
onChange={(tagMode) => setFilters({ ...filters, tagMode })}
/>
<div className="flex flex-wrap gap-1.5">
{chips.map((tg) => (
<TagChip
@@ -368,24 +344,27 @@ export default function Sidebar({
actions={actions}
>
{/* Scope: your own subscriptions (Mine) vs the shared Library. */}
<div
className="flex items-center rounded-full border border-border bg-card p-0.5 text-xs"
role="group"
aria-label={t("header.scope.label")}
>
{(["my", "all"] as const).map((s) => (
<button
key={s}
onClick={() => setFilters({ ...filters, scope: s })}
title={t("header.scope." + s + "Tip")}
aria-pressed={filters.scope === s}
className="pill flex-1 inline-flex items-center justify-center gap-1 px-2.5 py-1 rounded-full"
>
{s === "my" ? <User className="w-3.5 h-3.5" /> : <Library className="w-3.5 h-3.5" />}
<span>{t("header.scope." + s)}</span>
</button>
))}
</div>
<SegmentedToggle
ariaLabel={t("header.scope.label")}
fullWidth
size="md"
options={[
{
value: "my",
label: t("header.scope.my"),
icon: <User className="w-3.5 h-3.5" />,
title: t("header.scope.myTip"),
},
{
value: "all",
label: t("header.scope.all"),
icon: <Library className="w-3.5 h-3.5" />,
title: t("header.scope.allTip"),
},
]}
value={filters.scope}
onChange={(scope) => setFilters({ ...filters, scope })}
/>
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
@@ -0,0 +1,82 @@
// @vitest-environment jsdom
//
// The rendering integration of SortControl (its pickSort/heal decision logic is covered in
// columnSort.test.ts). Labels are plain props here — no i18n needed.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import SortControl from "./SortControl";
import type { SortOption, SortState } from "../lib/columnSort";
const OPTS: SortOption[] = [
{ key: "date", label: "Date", defaultDir: "desc" },
{ key: "title", label: "Title" },
{ key: "shuffle", label: "Shuffle", directionless: true },
];
function renderControl(sort: SortState, extra: Record<string, unknown> = {}) {
const onChange = vi.fn();
render(
<SortControl
options={OPTS}
sort={sort}
onChange={onChange}
label="Sort"
ascLabel="Ascending"
descLabel="Descending"
{...extra}
/>,
);
return { onChange };
}
afterEach(cleanup);
describe("SortControl", () => {
it("renders one option per SortOption, no empty option without defaultLabel", () => {
renderControl({ key: "title", dir: "asc" });
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual([
"Date",
"Title",
"Shuffle",
]);
});
it("adds a leading no-sort option when defaultLabel is set (tables)", () => {
renderControl(null, { defaultLabel: "None" });
const none = screen.getByRole("option", { name: "None" }) as HTMLOptionElement;
expect(none.value).toBe("");
});
it("falls back to the first option (not a blank select) for an unavailable key without defaultLabel", () => {
renderControl({ key: "ghost", dir: "asc" });
expect((screen.getByRole("combobox") as HTMLSelectElement).value).toBe("date");
});
it("picking a new option fires onChange at that option's defaultDir", () => {
const { onChange } = renderControl({ key: "title", dir: "asc" });
fireEvent.change(screen.getByRole("combobox"), { target: { value: "date" } });
expect(onChange).toHaveBeenCalledWith({ key: "date", dir: "desc" });
});
it("shows the direction toggle for a directional sort and flips it on click", () => {
const { onChange } = renderControl({ key: "date", dir: "desc" });
const toggle = screen.getByRole("button", { name: "Descending" });
fireEvent.click(toggle);
expect(onChange).toHaveBeenCalledWith({ key: "date", dir: "asc" });
});
it("hides the direction toggle for a directionless sort", () => {
renderControl({ key: "shuffle", dir: "desc" });
expect(screen.queryByRole("button")).toBeNull();
});
it("renders the trailing adornment", () => {
renderControl(
{ key: "shuffle", dir: "desc" },
{
trailing: <button>Reshuffle</button>,
},
);
expect(screen.getByRole("button", { name: "Reshuffle" })).toBeTruthy();
});
});
+75
View File
@@ -0,0 +1,75 @@
import { type ReactNode } from "react";
import { ArrowDown, ArrowUp } from "lucide-react";
import { pickSort, type SortOption, type SortState } from "../lib/columnSort";
// The one sort control for every content surface (YT feed, Plex library, channel manager + its card
// view, channel discovery): a dropdown of `SortOption`s + an asc/desc toggle, driven by the shared
// `SortState`. Presentation-agnostic to its container — a table feeds column-derived options via
// `ColumnSortControl`, the feed/Plex declare their own vocabulary. `defaultLabel` opts a view into a
// "no sort" choice (tables); omit it for always-sorted views (feed/Plex). `trailing` is an adornment
// slot (the feed's reshuffle button) so the shuffle affordance stays out of the generic control.
export default function SortControl({
options,
sort,
onChange,
label,
ascLabel,
descLabel,
defaultLabel,
trailing,
}: {
options: SortOption[];
sort: SortState;
onChange: (next: SortState) => void;
/** Accessible name for the dropdown (e.g. "Sort"). */
label: string;
ascLabel: string;
descLabel: string;
/** When set, a leading "no sort / original order" option is offered (tables). Omit for
* always-sorted views (feed/Plex), which never surface a null sort. */
defaultLabel?: string;
/** Rendered after the direction toggle (e.g. the feed's reshuffle button). */
trailing?: ReactNode;
}) {
// A stored sort can name an option this surface no longer offers; show it as unsorted rather than
// letting an arrow claim a sort that `sortRows`/the server won't actually apply.
const active = sort && options.some((o) => o.key === sort.key) ? sort : null;
const activeOpt = active ? options.find((o) => o.key === active.key) : undefined;
const showArrow = !!activeOpt && !activeOpt.directionless;
// An always-sorted view (no `defaultLabel`) must still show a real option when the current key isn't
// offered (a stale/out-of-scope sort) — fall back to the first option rather than a blank select
// (which also trips React's controlled-value warning). Views with a "no sort" option keep it.
const selectValue = active?.key ?? (defaultLabel === undefined ? (options[0]?.key ?? "") : "");
return (
<div className="flex items-center gap-1">
<select
value={selectValue}
onChange={(e) => onChange(pickSort(options, active, e.target.value))}
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 !== undefined && <option value="">{defaultLabel}</option>}
{options.map((o) => (
<option key={o.key} value={o.key}>
{o.label}
</option>
))}
</select>
{showArrow && active && (
<button
onClick={() => 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" ? (
<ArrowUp className="w-4 h-4" />
) : (
<ArrowDown className="w-4 h-4" />
)}
</button>
)}
{trailing}
</div>
);
}
+20 -24
View File
@@ -9,6 +9,8 @@ import { notify } from "../lib/notifications";
import { LS, useAccountPersistedState } from "../lib/storage";
import Tooltip from "./Tooltip";
import { LoadingState, StateMessage } from "./QueryState";
import FilterChipGroup from "./FilterChipGroup";
import Tabs from "./Tabs";
import { Section, SettingRow } from "./ui/form";
import { useMe } from "../lib/useMe";
@@ -30,18 +32,14 @@ export default function Stats() {
<div className="p-4 max-w-4xl mx-auto">
{/* Only admins have a second (System) tab; everyone else just sees the Overview. */}
{isAdmin && (
<div className="flex items-center gap-1 mb-4">
{(["overview", "system"] as StatsTab[]).map((id) => (
<button
key={id}
onClick={() => setTab(id)}
aria-pressed={tab === id}
className="pill px-3 py-1.5 rounded-full text-sm border"
>
{t(`stats.tabs.${id}`)}
</button>
))}
</div>
<Tabs
active={tab}
onChange={setTab}
tabs={(["overview", "system"] as StatsTab[]).map((id) => ({
id,
label: t(`stats.tabs.${id}`),
}))}
/>
)}
{tab === "system" && isAdmin ? <SystemStats /> : <Overview me={me} />}
@@ -228,18 +226,16 @@ function SystemStats() {
<div className="flex items-center justify-between gap-3 mb-4">
<h2 className="text-lg font-semibold">{t("stats.title")}</h2>
<div className="flex items-center gap-1">
{RANGES.map((d) => (
<button
key={d}
onClick={() => setDays(d)}
aria-pressed={days === d}
className="pill px-3 py-1.5 rounded-full text-sm border"
>
{t("stats.rangeDays", { count: d })}
</button>
))}
</div>
<FilterChipGroup
ariaLabel={t("stats.rangeLabel")}
className="flex items-center gap-1.5"
options={RANGES.map((d) => ({
value: String(d),
label: t("stats.rangeDays", { count: d }),
}))}
value={String(days)}
onChange={(v) => setDays(Number(v))}
/>
</div>
<p className="text-xs text-muted mb-4 leading-relaxed">
+27 -16
View File
@@ -1,13 +1,18 @@
import { type LucideIcon } from "lucide-react";
import { useAccountPersistedState } from "../lib/storage";
import CountBadge from "./CountBadge";
// Reusable horizontal pill tab-rail for in-page sub-navigation (Configuration, Users & roles…).
// The active tab is persisted to localStorage so a reload (F5) keeps the user where they were,
// per the app's "persist UI state across reload" convention.
// Reusable pill tab-rail for in-page sub-navigation (Configuration, Users & roles, the Settings
// sections, the Stats overview/system split…). Horizontal by default; `orientation="vertical"` gives
// the Settings-style side rail (full-width, left-aligned, icon + label). The active tab is persisted
// to localStorage so a reload keeps the user where they were.
export interface TabDef {
id: string;
label: string;
badge?: number; // optional count pill (e.g. pending requests); hidden when 0/undefined
icon?: LucideIcon; // optional leading icon (the vertical settings rail)
testId?: string; // optional data-testid, to preserve existing e2e hooks
}
/** Persisted active-tab state, scoped to the current account (so one account's last tab doesn't
@@ -18,34 +23,40 @@ export default function Tabs({
tabs,
active,
onChange,
orientation = "horizontal",
className,
}: {
tabs: TabDef[];
active: string;
onChange: (id: string) => void;
// "vertical" gives the Settings-style side rail; "horizontal" (default) is the standard row.
orientation?: "horizontal" | "vertical";
// Override the row/rail wrapper — e.g. the channel manager embeds the rail in a justify-between
// header, or the settings rail adds a width + right border. Omit for the orientation default.
className?: string;
}) {
const vertical = orientation === "vertical";
const wrapCls = className ?? (vertical ? "flex flex-col gap-1" : "flex flex-wrap gap-1 mb-4");
return (
<div className="flex flex-wrap gap-1 mb-4">
<div className={wrapCls}>
{tabs.map((tb) => {
const on = tb.id === active;
const Icon = tb.icon;
return (
<button
key={tb.id}
data-testid={tb.testId}
onClick={() => onChange(tb.id)}
aria-pressed={on}
className={`pill inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm ${
on ? "font-medium shadow-md shadow-accent/20" : "hover:bg-card/60"
}`}
className={`pill items-center rounded-lg px-3 text-sm ${
vertical
? "flex w-full justify-start gap-2 py-2 text-left"
: "inline-flex gap-1.5 py-1.5"
} ${on ? "font-medium shadow-md shadow-accent/20" : "hover:bg-card/60"}`}
>
{Icon && <Icon className="h-4 w-4 shrink-0" />}
{tb.label}
{tb.badge != null && tb.badge > 0 && (
<span
className={`text-2xs leading-none px-1.5 py-0.5 rounded-full ${
on ? "bg-accent-fg/20" : "bg-accent/20 text-accent"
}`}
>
{tb.badge}
</span>
)}
{tb.badge != null && tb.badge > 0 && <CountBadge count={tb.badge} active={on} />}
</button>
);
})}
@@ -0,0 +1,131 @@
import { useTranslation } from "react-i18next";
import { type ActiveFilter } from "../lib/useActiveFilters";
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
import { healPlexSort, PLEX_SORT_FALLBACK } from "../lib/plexSort";
import { usePlex, usePlexActions } from "./PlexProvider";
// Plex's analogue of useActiveFilters: turns the shared Plex state (expanded filters + watch-state +
// sort) into the source-agnostic ActiveFilter[] the shared ActiveFilterChips renders above the grid.
// Plex thereby gets the same at-a-glance "what's narrowing this" row — with per-chip removal — that
// the feed has, instead of the narrowing being visible only inside the collapsed sidebar. Labels reuse
// the existing plex.* i18n keys; multi-value dimensions (genres, people, ratings) emit one chip each so
// removing one keeps the rest (same shape as the feed's tag/channel chips). Lives here, not in lib,
// because it reads the Plex provider (lib stays provider-free, like useActiveFilters).
export function usePlexActiveFilters(): ActiveFilter[] {
const { scope, show, sort, filters } = usePlex();
const { setShow, setSort, setFilters } = usePlexActions();
const { t } = useTranslation();
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
const out: ActiveFilter[] = [];
const has = (x: number | null | undefined) => x != null;
for (const g of filters.genres) {
out.push({
key: `genre:${g}`,
label: g,
remove: () => patch({ genres: filters.genres.filter((x) => x !== g) }),
});
}
for (const r of filters.contentRatings) {
out.push({
key: `contentRating:${r}`,
label: r,
remove: () => patch({ contentRatings: filters.contentRatings.filter((x) => x !== r) }),
});
}
if (has(filters.yearMin) || has(filters.yearMax)) {
const range =
has(filters.yearMin) && has(filters.yearMax)
? `${filters.yearMin}–${filters.yearMax}`
: has(filters.yearMin)
? `≥${filters.yearMin}`
: `≤${filters.yearMax}`;
out.push({
key: "year",
label: `${t("plex.filter.year")}: ${range}`,
remove: () => patch({ yearMin: null, yearMax: null }),
});
}
if (has(filters.ratingMin)) {
out.push({
key: "ratingMin",
label: `${t("plex.filter.rating")}: ≥${filters.ratingMin}`,
remove: () => patch({ ratingMin: null }),
});
}
if (has(filters.durationMin) || has(filters.durationMax)) {
const lo = has(filters.durationMin) ? Math.round(filters.durationMin! / 60) : null;
const hi = has(filters.durationMax) ? Math.round(filters.durationMax! / 60) : null;
const u = t("plex.filter.durationUnit");
const range =
lo != null && hi != null ? `${lo}–${hi} ${u}` : lo != null ? `≥${lo} ${u}` : `≤${hi} ${u}`;
out.push({
key: "duration",
label: `${t("plex.filter.duration")}: ${range}`,
remove: () => patch({ durationMin: null, durationMax: null }),
});
}
if (filters.addedWithin) {
out.push({
key: "added",
label: `${t("plex.filter.added")}: ${t("plex.filter.addedOpt." + filters.addedWithin)}`,
remove: () => patch({ addedWithin: "" }),
});
}
for (const d of filters.directors) {
out.push({
key: `director:${d}`,
label: d,
remove: () => patch({ directors: filters.directors.filter((x) => x !== d) }),
});
}
for (const a of filters.actors) {
out.push({
key: `actor:${a}`,
label: a,
remove: () => patch({ actors: filters.actors.filter((x) => x !== a) }),
});
}
for (const s of filters.studios) {
out.push({
key: `studio:${s}`,
label: s,
remove: () => patch({ studios: filters.studios.filter((x) => x !== s) }),
});
}
if (filters.collection) {
out.push({
key: "collection",
label: filters.collectionTitle ?? t("plex.filter.collection"),
remove: () => patch({ collection: null, collectionTitle: null }),
});
}
if (show !== "all") {
out.push({
key: "show",
label: t("plex.filter.showOpt." + show),
remove: () => setShow("all"),
});
}
const healed = healPlexSort(sort, scope);
if (healed.key !== "title") {
out.push({
key: "sort",
label: t("plex.sort." + healed.key),
remove: () => setSort(PLEX_SORT_FALLBACK),
});
}
return out;
}
// The one "reset every Plex narrowing" callback, shared by the sidebar's Clear-filters and the chip
// row's Clear-all so the two can't drift: expanded filters + watch-state + sort all go back to default.
export function usePlexClearFilters(): () => void {
const { setShow, setSort, setFilters } = usePlexActions();
return () => {
setFilters(EMPTY_PLEX_FILTERS);
setShow("all");
setSort(PLEX_SORT_FALLBACK);
};
}
+12 -4
View File
@@ -1,4 +1,5 @@
{
"actionsLabel": "Catalog actions",
"syncSubscriptions": "Read subscriptions from YouTube",
"syncSubscriptionsHint": "Re-import your subscription list from YouTube — adds channels you've newly followed and drops ones you've unfollowed. The videos themselves keep syncing automatically in the background; this does not re-fetch them.",
"backfillEverything": "Backfill everything",
@@ -6,6 +7,7 @@
"tabs": {
"subscribed": "Subscriptions",
"discovery": "Discover from playlists",
"hidden": "Hidden",
"blocked": "Blocked"
},
"layoutLabel": "Layout",
@@ -38,14 +40,13 @@
}
},
"filters": {
"syncLabel": "Sync status",
"all": "All",
"needsFull": "Needs full history",
"fullySynced": "Fully synced",
"hidden": "Hidden"
"fullySynced": "Fully synced"
},
"tags": {
"yourTags": "Your tags",
"manage": "Manage tags",
"label": "Tags",
"yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)",
"noneYet": "No tags yet — create one below.",
"create": "Create tag",
@@ -74,6 +75,7 @@
"actions": "Actions"
},
"stats": {
"label": "Stats",
"channels": "Channels",
"channelsHint": "Channels you're subscribed to.",
"recentSynced": "Recent synced",
@@ -139,5 +141,11 @@
"title": "Blocked channels",
"hint": "Their videos are hidden from your feed, Library, and live search.",
"empty": "No blocked channels. Block one from its channel page to hide all its videos without unsubscribing."
},
"hidden": {
"title": "Hidden channels",
"hint": "Their videos are kept out of your personal feed, but still appear in the shared Library.",
"empty": "No hidden channels. Hide a subscription (the eye icon on its row) to keep its uploads out of your personal feed without unsubscribing.",
"unhide": "Unhide"
}
}
+2
View File
@@ -20,6 +20,8 @@
"videoCount_other": "{{formattedCount}} videos",
"sortLabel": "Sort",
"viewLabel": "View",
"showLabel": "Watch state",
"contentLabel": "Content types",
"view": {
"cards": "Large cards",
"cardsSmall": "Small cards",
+2
View File
@@ -33,6 +33,7 @@
"genre": "Genre",
"year": "Year",
"duration": "Length",
"durationUnit": "min",
"durationOpt": {
"short": "Under 90m",
"mid": "90–120m",
@@ -217,6 +218,7 @@
"playAll": "Play all",
"delete": "Delete playlist",
"empty": "This playlist is empty. Add titles from their info page.",
"layoutLabel": "Layout",
"layoutAccordion": "Accordion view",
"layoutTree": "Tree view",
"removeShow": "Remove whole show",
@@ -66,6 +66,7 @@
"shorts": "Shorts",
"live": "Live / Upcoming"
},
"datePresetLabel": "Upload date",
"datePreset": {
"24h": "24h",
"1week": "1 week",
+1
View File
@@ -5,6 +5,7 @@
},
"title": "API quota usage",
"rangeDays": "{{count}}d",
"rangeLabel": "Time range",
"introBefore": "YouTube Data API units attributed by who triggered the spend. ",
"introSystem": "System",
"introAfter": " is shared background work (scheduled backfill, enrichment, resync) that isn't tied to one person.",
+12 -4
View File
@@ -1,4 +1,5 @@
{
"actionsLabel": "Katalógus-műveletek",
"syncSubscriptions": "Feliratkozások beolvasása a YouTube-ról",
"syncSubscriptionsHint": "Újraimportálja a feliratkozási listádat a YouTube-ról — hozzáadja az újonnan követett csatornákat, és eltávolítja azokat, amelyekről leiratkoztál. Maguk a videók a háttérben automatikusan tovább szinkronizálódnak; ez nem tölti le őket újra.",
"backfillEverything": "Minden letöltése",
@@ -6,6 +7,7 @@
"tabs": {
"subscribed": "Feliratkozások",
"discovery": "Felfedezés a lejátszási listákból",
"hidden": "Elrejtett",
"blocked": "Letiltott"
},
"layoutLabel": "Nézet",
@@ -38,14 +40,13 @@
}
},
"filters": {
"syncLabel": "Szinkron állapot",
"all": "Mind",
"needsFull": "Hiányos előzmény",
"fullySynced": "Teljesen szinkronizálva",
"hidden": "Elrejtett"
"fullySynced": "Teljesen szinkronizálva"
},
"tags": {
"yourTags": "Címkéid",
"manage": "Címkék kezelése",
"label": "Címkék",
"yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)",
"noneYet": "Még nincs címke — hozz létre egyet lent.",
"create": "Címke létrehozása",
@@ -74,6 +75,7 @@
"actions": "Műveletek"
},
"stats": {
"label": "Statisztika",
"channels": "Csatorna",
"channelsHint": "Csatornák, amelyekre feliratkoztál.",
"recentSynced": "Legutóbbi szinkronizálva",
@@ -139,5 +141,11 @@
"title": "Tiltott csatornák",
"hint": "A videóik el vannak rejtve a feededből, a Library-ből és a YouTube-keresésből.",
"empty": "Nincs tiltott csatorna. Egy csatorna oldaláról tilthatsz le egyet, hogy minden videóját elrejtsd leiratkozás nélkül."
},
"hidden": {
"title": "Elrejtett csatornák",
"hint": "A videóik kimaradnak a személyes feededből, de a megosztott Library-ben továbbra is megjelennek.",
"empty": "Nincs elrejtett csatorna. Egy feliratkozást a sorában (a szem ikonnal) rejthetsz el, hogy kimaradjon a személyes feededből leiratkozás nélkül.",
"unhide": "Felfedés"
}
}
+2
View File
@@ -20,6 +20,8 @@
"videoCount_other": "{{formattedCount}} videó",
"sortLabel": "Rendezés",
"viewLabel": "Nézet",
"showLabel": "Megtekintési állapot",
"contentLabel": "Tartalomtípusok",
"view": {
"cards": "Nagy kártya",
"cardsSmall": "Kis kártya",
+2
View File
@@ -33,6 +33,7 @@
"genre": "Műfaj",
"year": "Év",
"duration": "Hossz",
"durationUnit": "perc",
"durationOpt": {
"short": "90 perc alatt",
"mid": "90–120 perc",
@@ -217,6 +218,7 @@
"playAll": "Összes lejátszása",
"delete": "Lista törlése",
"empty": "Ez a lista üres. Adj hozzá címeket az info-oldalukról.",
"layoutLabel": "Elrendezés",
"layoutAccordion": "Harmonika nézet",
"layoutTree": "Fa nézet",
"removeShow": "Egész sorozat eltávolítása",
@@ -66,6 +66,7 @@
"shorts": "Shorts",
"live": "Élő / Hamarosan"
},
"datePresetLabel": "Feltöltés dátuma",
"datePreset": {
"24h": "24 óra",
"1week": "1 hét",
+1
View File
@@ -5,6 +5,7 @@
},
"title": "API-kvóta használat",
"rangeDays": "{{count}} nap",
"rangeLabel": "Időtartomány",
"introBefore": "A YouTube Data API-egységek aszerint, hogy ki váltotta ki a felhasználást. A ",
"introSystem": "Rendszer",
"introAfter": " a megosztott háttérmunka (ütemezett letöltés, gazdagítás, újraszinkronizálás), amely nem köthető egy személyhez.",
+11 -2
View File
@@ -288,8 +288,17 @@ html[data-theme="light"] {
}
html {
font-size: calc(16px * var(--font-scale));
/* Snap the scaled root font-size to a whole pixel. The base is rem-derived app-wide, so a fractional
root (e.g. 16px * 1.02 = 16.32px) makes every box height fractional — and when a reflow changes a
section's row count, the sub-pixel remainder shifts everything below it by ~0.5px, a visible 1px
jitter at DPR=1. round() keeps the font-scale feature but quantises to crisp integer metrics. */
font-size: round(calc(16px * var(--font-scale)), 1px);
background: var(--bg);
/* Shared interactive-row hover tint. `--card` reads faint in dark schemes (it sits close to `--bg`),
so hover was barely visible; a foreground-mix reads clearly in every scheme + theme (it lightens
on dark, darkens on light) and adapts automatically since --fg is per-scheme. One source for every
hoverable row (the .selectable recipe + DataTable rows). */
--hover: color-mix(in srgb, var(--fg) 9%, transparent);
}
body {
@@ -410,7 +419,7 @@ body {
color 0.15s ease;
}
.selectable:hover {
background-color: var(--card);
background-color: var(--hover);
}
.selectable:focus-visible {
/* outline (not a box-shadow ring) so it never fights a glass surface's box-shadow and never gets
+39
View File
@@ -0,0 +1,39 @@
import { useSyncExternalStore } from "react";
// The account a browser TAB is acting as (per-tab, in sessionStorage — see api.ts), plus its reactive
// wrapper. Its OWN module (imports nothing internal) so it can be shared by both `storage.ts` (which
// keys per-account localStorage on it) and `useAccountRehydrate.ts` without a circular import — the
// account id is a lower-level primitive than the storage helpers that consume it.
//
// ACTIVE_ACCOUNT_KEY is duplicated here rather than imported from api.ts so this stays dependency-free.
export const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount";
export function activeAccountId(): number | null {
try {
const raw = sessionStorage.getItem(ACTIVE_ACCOUNT_KEY);
if (!raw) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
} catch {
return null;
}
}
// Reactive active-account. setActiveAccount/clearActiveAccount write sessionStorage — a silent op that
// triggers no React render — so this tab's per-account state (sort/filter/view/layout, all keyed by the
// account) would never re-read once the account is finally known. That bites exactly once, on first
// load: the provider tree above <App> mounts with NO account, then <App> pins it after /api/me resolves.
// The account setters call notifyActiveAccountChange() so useActiveAccountId subscribers can re-hydrate.
const accountListeners = new Set<() => void>();
function subscribeActiveAccount(cb: () => void): () => void {
accountListeners.add(cb);
return () => accountListeners.delete(cb);
}
export function notifyActiveAccountChange(): void {
for (const cb of [...accountListeners]) cb();
}
/** The current tab's active account id, as reactive state — re-renders when it is (re)pinned. The
* snapshot is a primitive (number | null), so useSyncExternalStore's Object.is check is stable. */
export function useActiveAccountId(): number | null {
return useSyncExternalStore(subscribeActiveAccount, activeAccountId, activeAccountId);
}
+4 -1
View File
@@ -5,7 +5,7 @@
import { notify, remove } from "../notifications";
import i18n from "../../i18n";
import { reportError } from "../errorDialog";
import { activeAccountId, ACTIVE_ACCOUNT_KEY } from "../storage";
import { activeAccountId, ACTIVE_ACCOUNT_KEY, notifyActiveAccountChange } from "../activeAccount";
export class HttpError extends Error {
status: number;
@@ -136,6 +136,8 @@ export function setActiveAccount(id: number): void {
} catch {
/* ignore */
}
// Let per-account UI state re-hydrate now the account is known (see notifyActiveAccountChange).
notifyActiveAccountChange();
}
export function clearActiveAccount(): void {
try {
@@ -143,6 +145,7 @@ export function clearActiveAccount(): void {
} catch {
/* ignore */
}
notifyActiveAccountChange();
}
export async function req<T = unknown>(
+7 -8
View File
@@ -3,6 +3,7 @@
// two-way watch-sync link. Self-contained — its types reference only each other, and its methods
// touch only the shared `req`/`beacon` from core plus the local `appendPlexFilters` helper.
import { req, beacon } from "./core";
import { type SortState } from "../columnSort";
// Admin: result of testing the Plex server connection (also drives the library-picker).
interface PlexSection {
@@ -196,7 +197,6 @@ export interface PlexFilters {
studios: string[]; // OR — from any selected studio
collection?: string | null; // a single Plex collection (rating_key)
collectionTitle?: string | null; // its title, for the active-filter chip
sortDir?: "asc" | "desc"; // sort direction (default desc)
}
export const EMPTY_PLEX_FILTERS: PlexFilters = {
genres: [],
@@ -275,7 +275,7 @@ export const plexApi = {
plexLibrary: (p: {
scope: string; // movie | show | both
q?: string;
sort?: string;
sort?: SortState;
show?: string;
offset?: number;
limit?: number;
@@ -283,15 +283,14 @@ export const plexApi = {
}): Promise<PlexUnifiedResult> => {
const u = new URLSearchParams({ scope: p.scope });
if (p.q) u.set("q", p.q);
if (p.sort) u.set("sort", p.sort);
if (p.sort) {
u.set("sort", p.sort.key);
u.set("sort_dir", p.sort.dir);
}
if (p.show && p.show !== "all") u.set("show", p.show);
if (p.offset) u.set("offset", String(p.offset));
if (p.limit) u.set("limit", String(p.limit));
const f = p.filters;
if (f) {
appendPlexFilters(u, f);
u.set("sort_dir", f.sortDir ?? "asc"); // default ascending
}
if (p.filters) appendPlexFilters(u, p.filters);
return req(`/api/plex/library?${u.toString()}`);
},
// Facets ADAPT to the active filters + watch-state (faceted search) — pass them so each group
+97 -1
View File
@@ -1,5 +1,14 @@
import { describe, expect, it } from "vitest";
import { sortRows, parseSortState, cycleSort, type SortState } from "./columnSort";
import {
sortRows,
parseSortState,
cycleSort,
pickSort,
healSort,
hydrateSort,
type SortOption,
type SortState,
} from "./columnSort";
interface Row {
name: string;
@@ -84,3 +93,90 @@ describe("cycleSort", () => {
expect(cycleSort({ key: "name", dir: "desc" }, "views")).toEqual({ key: "views", dir: "asc" });
});
});
const OPTIONS: SortOption[] = [
{ key: "date", label: "Date", defaultDir: "desc" },
{ key: "title", label: "Title" }, // defaultDir omitted → asc
{ key: "shuffle", label: "Shuffle", directionless: true },
];
describe("pickSort", () => {
it("starts a freshly-picked option at its defaultDir (asc fallback)", () => {
expect(pickSort(OPTIONS, null, "date")).toEqual({ key: "date", dir: "desc" });
expect(pickSort(OPTIONS, null, "title")).toEqual({ key: "title", dir: "asc" });
});
it("keeps the current direction when re-picking the same key", () => {
expect(pickSort(OPTIONS, { key: "date", dir: "asc" }, "date")).toEqual({
key: "date",
dir: "asc",
});
});
it("carries a stable dir for a directionless option and never shows a flip", () => {
expect(pickSort(OPTIONS, { key: "date", dir: "asc" }, "shuffle")).toEqual({
key: "shuffle",
dir: "desc",
});
});
it("clears to null on an empty key or an unknown option", () => {
expect(pickSort(OPTIONS, { key: "date", dir: "desc" }, "")).toBeNull();
expect(pickSort(OPTIONS, { key: "date", dir: "desc" }, "ghost")).toBeNull();
});
});
describe("healSort", () => {
it("keeps a sort whose key is still offered", () => {
const s: SortState = { key: "title", dir: "asc" };
expect(healSort(s, OPTIONS)).toBe(s);
});
it("collapses a sort naming an absent option to the fallback", () => {
expect(healSort({ key: "gone", dir: "asc" }, OPTIONS)).toBeNull();
expect(healSort({ key: "gone", dir: "asc" }, OPTIONS, { key: "date", dir: "desc" })).toEqual({
key: "date",
dir: "desc",
});
});
});
describe("hydrateSort", () => {
it("returns the fallback for missing storage", () => {
expect(hydrateSort(null, OPTIONS)).toBeNull();
expect(hydrateSort(null, OPTIONS, { key: "date", dir: "desc" })).toEqual({
key: "date",
dir: "desc",
});
});
it("parses and heals a stored JSON state", () => {
expect(hydrateSort(JSON.stringify({ key: "title", dir: "asc" }), OPTIONS)).toEqual({
key: "title",
dir: "asc",
});
expect(hydrateSort(JSON.stringify({ key: "gone", dir: "asc" }), OPTIONS)).toBeNull();
});
it("tolerates a legacy bare-key string, applying the option's defaultDir", () => {
expect(hydrateSort("date", OPTIONS)).toEqual({ key: "date", dir: "desc" });
expect(hydrateSort("title", OPTIONS)).toEqual({ key: "title", dir: "asc" });
});
it("falls back when a legacy bare-key names an unknown option", () => {
expect(hydrateSort("ghost", OPTIONS, { key: "date", dir: "desc" })).toEqual({
key: "date",
dir: "desc",
});
});
it("parses without healing when no options are supplied (caller heals for display)", () => {
// A table/provider that owns no vocabulary keeps whatever was stored, even an unknown key.
expect(hydrateSort(JSON.stringify({ key: "anything", dir: "desc" }), [])).toEqual({
key: "anything",
dir: "desc",
});
// A malformed stored value still coerces to the fallback.
expect(hydrateSort(JSON.stringify({ key: 3, dir: "up" }), [], null)).toBeNull();
});
});
+57
View File
@@ -40,3 +40,60 @@ export function cycleSort(current: SortState, key: string): SortState {
if (current.dir === "asc") return { key, dir: "desc" };
return null;
}
// --- the source-agnostic sort vocabulary -------------------------------------------------
// A `SortOption[]` is the one shape every content surface (YT feed, Plex library, channel
// manager, discovery) hands to the shared `SortControl`. A table derives it from its columns;
// the feed and Plex declare it explicitly. Direction lives in `SortState`, never encoded into
// the key — the feed's wire string (`feedSort.ts`) is a codec at the server boundary only.
export interface SortOption {
key: string;
label: string;
/** No meaningful asc/desc flip (the feed's `shuffle`/`relevance`) — hides the direction toggle. */
directionless?: boolean;
/** The natural first direction when this option is freshly picked (defaults to `"asc"`). */
defaultDir?: "asc" | "desc";
}
/** Decide the next `SortState` when the user PICKS `key` from a dropdown/chip (NOT the header-click
* cycle — that's `cycleSort`). Re-picking the same key keeps its direction; a new key starts at its
* `defaultDir` (asc fallback); a directionless option carries a stable dir; an empty key clears sort. */
export function pickSort(options: SortOption[], prev: SortState, key: string): SortState {
if (!key) return null;
const opt = options.find((o) => o.key === key);
if (!opt) return null;
if (opt.directionless) return { key, dir: "desc" };
if (prev?.key === key) return prev;
return { key, dir: opt.defaultDir ?? "asc" };
}
/** Collapse a sort that names an option no longer offered (a renamed column, or Plex's movie/show
* scope swap dropping a key) to `fallback` — otherwise a stale key claims a sort nothing applies. */
export function healSort(
sort: SortState,
options: SortOption[],
fallback: SortState = null,
): SortState {
return sort && options.some((o) => o.key === sort.key) ? sort : fallback;
}
/** Hydrate a persisted raw string into a `SortState`. Accepts the JSON `{key,dir}` shape and tolerates
* a legacy bare-key string (older builds stored just the key). When `options` is empty, the value is
* parsed but NOT healed — for a table/provider that owns no vocabulary itself and heals for display
* elsewhere (e.g. `ColumnSortControl`); a non-empty `options` heals an absent key to `fallback`. */
export function hydrateSort(
raw: string | null,
options: SortOption[],
fallback: SortState = null,
): SortState {
if (!raw) return fallback;
const heal = (s: SortState): SortState =>
options.length === 0 ? (s ?? fallback) : healSort(s, options, fallback);
try {
return heal(parseSortState(JSON.parse(raw)));
} catch {
const opt = options.find((o) => o.key === raw);
return opt ? { key: raw, dir: opt.defaultDir ?? "asc" } : fallback;
}
}
+88
View File
@@ -0,0 +1,88 @@
import { describe, it, expect } from "vitest";
import { applyDateChip, deriveDateKey, type DatePreset } from "./dateFilter";
import { type FeedFilters } from "./api";
const PRESETS: DatePreset[] = [
{ days: 1, key: "24h" },
{ days: 7, key: "1week" },
{ days: 30, key: "1month" },
{ days: 180, key: "6months" },
{ days: 365, key: "1year" },
];
const base = (over: Partial<FeedFilters> = {}): FeedFilters => ({
tags: [],
tagMode: "or",
q: "",
sort: "date",
scope: "my",
includeNormal: true,
includeShorts: true,
includeLive: true,
show: "unwatched",
channelIds: [],
...over,
});
describe("deriveDateKey", () => {
it("is empty when no date filter is set", () => {
expect(deriveDateKey(base(), false, PRESETS)).toBe("");
});
it("is the preset day-count when maxAgeDays matches a preset", () => {
expect(deriveDateKey(base({ maxAgeDays: 7 }), false, PRESETS)).toBe("7");
});
it("is empty when maxAgeDays matches no preset (e.g. a shared-link value)", () => {
expect(deriveDateKey(base({ maxAgeDays: 45 }), false, PRESETS)).toBe("");
});
it("is 'custom' when the custom editor is open", () => {
expect(deriveDateKey(base(), true, PRESETS)).toBe("custom");
});
it("is 'custom' when a from/to range is set, even over a stale maxAgeDays", () => {
expect(deriveDateKey(base({ dateFrom: "2026-01-01", maxAgeDays: 7 }), false, PRESETS)).toBe(
"custom",
);
});
});
describe("applyDateChip", () => {
it("picking a preset sets maxAgeDays and clears any custom range", () => {
const change = applyDateChip(
"7",
base({ dateFrom: "2026-01-01", dateTo: "2026-02-01" }),
false,
);
expect(change.customDates).toBeUndefined();
expect(change.filters).toMatchObject({ maxAgeDays: 7, dateFrom: undefined, dateTo: undefined });
});
it("re-picking the active preset toggles it off", () => {
const change = applyDateChip("7", base({ maxAgeDays: 7 }), false);
expect(change.filters?.maxAgeDays).toBeUndefined();
});
it("a preset is not 'active' while a custom range is open, so it sets rather than toggling off", () => {
const change = applyDateChip("7", base({ maxAgeDays: 7 }), true);
expect(change.filters?.maxAgeDays).toBe(7);
});
it("opening Custom sets the flag and clears a preset's maxAgeDays", () => {
const change = applyDateChip("custom", base({ maxAgeDays: 7 }), false);
expect(change.customDates).toBe(true);
expect(change.filters).toMatchObject({ maxAgeDays: undefined });
});
it("closing Custom clears the flag and leaves filters untouched", () => {
const change = applyDateChip("custom", base({ maxAgeDays: 7 }), true);
expect(change.customDates).toBe(false);
expect(change.filters).toBeUndefined();
});
it("preserves unrelated filter fields when picking a preset", () => {
const change = applyDateChip("30", base({ q: "hello", tags: [1, 2] }), false);
expect(change.filters).toMatchObject({ q: "hello", tags: [1, 2], maxAgeDays: 30 });
});
});
+51
View File
@@ -0,0 +1,51 @@
import { type FeedFilters } from "./api";
// Pure date-filter selection logic for the feed sidebar's "Upload date" chip row, lifted out of the
// component so the single-select derivation + the preset/custom dispatch can be unit-tested without
// mounting Sidebar (same split as columnSort.ts for sort). The row is single-select across the day
// presets plus a "Custom" range chip.
export interface DatePreset {
days: number;
key: string;
}
/** Which date chip is active: a preset's day-count (as a string), "custom", or "" (none). A custom
* range (the editor is open, or a from/to is set) owns the selection; otherwise a preset matches only
* when no custom range is active; a maxAge that matches no preset (e.g. a shared-link value) is none. */
export function deriveDateKey(
filters: Pick<FeedFilters, "maxAgeDays" | "dateFrom" | "dateTo">,
customDates: boolean,
presets: readonly DatePreset[],
): string {
if (customDates || filters.dateFrom || filters.dateTo) return "custom";
const preset = presets.find((p) => filters.maxAgeDays === p.days);
return preset ? String(preset.days) : "";
}
/** The state change when a date chip is picked. A preset is single-select with toggle-off (re-picking
* the active preset clears it) and clears any custom range; "custom" toggles the range editor and,
* when opened, clears a preset. Returns the next `filters` (only when they change) and/or the next
* `customDates` flag (only when it changes) so the caller applies exactly what moved. */
export function applyDateChip(
key: string,
filters: FeedFilters,
customDates: boolean,
): { filters?: FeedFilters; customDates?: boolean } {
if (key === "custom") {
const next = !customDates;
return next
? { customDates: true, filters: { ...filters, maxAgeDays: undefined } }
: { customDates: false };
}
const days = Number(key);
const activePreset = filters.maxAgeDays === days && !customDates;
return {
filters: {
...filters,
maxAgeDays: activePreset ? undefined : days,
dateFrom: undefined,
dateTo: undefined,
},
};
}
+37
View File
@@ -0,0 +1,37 @@
import { healSort, type SortOption, type SortState } from "./columnSort";
// Plex's sort vocabulary, source-agnostic like the feed's — a `SortOption[]` fed to the shared
// SortControl. Direction lives in the SortState (no more sibling `sortDir` on PlexFilters); the wire
// call (`plexLibrary`) still splits it into `sort` + `sort_dir` at the server boundary.
const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] as const;
const SHOW_SORTS = ["added", "release", "year", "rating", "title"] as const;
/** The sort keys a scope offers — movie libraries add `duration`; shows/both don't. */
function plexSortKeys(scope: string): string[] {
return [...(scope === "movie" ? MOVIE_SORTS : SHOW_SORTS)];
}
/** The historical default: `sort=title`, `sort_dir=asc`. Frozen: it is handed out by reference (from
* healSort/healPlexSort and as the provider's default state), so an accidental in-place mutation of a
* SortState would otherwise corrupt the shared fallback for every caller. */
export const PLEX_SORT_FALLBACK: SortState = Object.freeze({ key: "title", dir: "asc" as const });
/** Labelled, alpha-ordered options for the SortControl. `label` resolves each key (i18n
* `plex.sort.<key>`). */
export function plexSortOptions(scope: string, label: (key: string) => string): SortOption[] {
return plexSortKeys(scope)
.map((key) => ({ key, label: label(key) }))
.sort((a, b) => a.label.localeCompare(b.label));
}
/** Heal a stored sort against a scope's available keys, collapsing an out-of-scope key to the default.
* The single heal both the sidebar control and the browse query use, so they never diverge. Always
* non-null (the fallback is), so callers can read `.key` without a guard. */
export function healPlexSort(sort: SortState, scope: string): NonNullable<SortState> {
return healSort(
sort,
plexSortKeys(scope).map((key) => ({ key, label: key })),
PLEX_SORT_FALLBACK,
)!;
}
+17
View File
@@ -14,6 +14,23 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.64.0",
date: "2026-08-15",
summary:
"A consistency pass across the app: sorting and filtering now look and work the same everywhere, and the channel manager toolbar is tidier.",
features: [
"Hidden channels now have their own “Hidden” tab in the channel manager, separate from Blocked — so you can see and un-hide them at a glance.",
"Sorting and filtering work the same way everywhere now — the feed, Plex, the channel manager and discovery all share one sort control and one style of filter chips, with a row of active-filter chips you can clear one at a time.",
"Tidier channel-manager toolbar: the catalog actions (read subscriptions, backfill) tuck into a “⋯” menu, the catalog stats collapse into a “Stats” chip you hover for the numbers, and the tags editor opens from the “✎ Tags” label.",
],
fixes: [
"Fixed a faint one-pixel jitter where the page could shift slightly when filters changed at certain zoom or text-size settings.",
],
chores: [
"Large internal refactor unifying the shared UI controls (sort, filter chips, tabs, overflow menus) behind single reusable components, with new automated tests. No change to how anything looks beyond the consistency improvements above.",
],
},
{
version: "0.63.3",
date: "2026-08-13",
+10
View File
@@ -0,0 +1,10 @@
import { type Column } from "../components/tableHeader";
import { type SortOption } from "./columnSort";
/** The `SortOption[]` a table exposes for sorting — its sortable columns, labelled by header. Bridges
* the `Column<T>` contract to the source-agnostic sort vocabulary the shared `SortControl` consumes. */
export function optionsFromColumns<T>(columns: Column<T>[]): SortOption[] {
return columns
.filter((c) => c.sortable && c.sortValue)
.map((c) => ({ key: c.key, label: c.header }));
}
+28 -23
View File
@@ -1,4 +1,6 @@
import { useCallback, useState } from "react";
import { activeAccountId } from "./activeAccount";
import { useAccountRehydrate } from "./useAccountRehydrate";
// Central registry of every localStorage key the app uses. One place to see them all, rename
// safely, and avoid collisions — instead of `"siftlode.x"` string literals scattered across
@@ -22,7 +24,13 @@ export const LS = {
// until /api/me lands: the account isn't pinned yet (App pins it once meQuery resolves), so
// the per-account key can't be read.
feedView: "siftlode.feedView",
channelFilter: "siftlode.channelFilter",
// The subscribed table's column filters (a FilterMap), lifted out of the table so the top status
// chips, the Sync column funnel, and the card layout all share ONE state (per-account). Supersedes
// the old single-string `channelFilter` sync chip.
channelsFilterMap: "siftlode.channelsFilterMap",
// DEPRECATED — the retired single-value sync chip; read once to migrate a pinned value into
// `channelsFilterMap`, then inert. Do not write.
legacyChannelFilter: "siftlode.channelFilter",
channelsView: "siftlode.channelsView",
// The channel manager's layout (table / cards — see lib/channelLayout.ts), per-account.
channelLayout: "siftlode.channelLayout",
@@ -61,21 +69,8 @@ export const LS = {
} as const;
// --- per-account scoping -----------------------------------------------------------------
// The account a browser TAB is acting as (per-tab, in sessionStorage — see api.ts). Duplicated
// here (rather than imported from api.ts) so storage.ts stays dependency-free and usable by the
// low-level helpers below.
export const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount";
export function activeAccountId(): number | null {
try {
const raw = sessionStorage.getItem(ACTIVE_ACCOUNT_KEY);
if (!raw) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
} catch {
return null;
}
}
// The active-account primitive (activeAccountId / useActiveAccountId / notify) lives in ./activeAccount
// so it can be shared without a cycle; the helpers below key localStorage on it.
/** Suffix a base localStorage key with an account id so per-account UI state (filters, selected
* playlist, notifications, tab positions, cached prefs…) is NOT shared across the accounts signed
@@ -155,17 +150,25 @@ export function writeJSON(key: string, value: unknown): void {
/** A `useState` whose string value is mirrored to localStorage, scoped to the current account (see
* accountKey), so one account's persisted tab/view position doesn't carry over to another in the
* same browser. Survives reload (F5); validation is deferred to the caller (clamp at render) so it
* can be used before an async-loaded option list is known, keeping hook order stable. These hooks
* run in components that only mount once signed in, so the account is known. */
* can be used before an async-loaded option list is known, keeping hook order stable. Consumers
* usually mount only once signed in (account known), but re-read on the account-pin transition too so
* one that mounts before the pin (a fresh tab's first paint) still restores. */
export function useAccountPersistedState(
base: string,
fallback = "",
): [string, (value: string) => void] {
const [value, setValue] = useState<string>(() => getAccountRaw(base) ?? fallback);
const set = (next: string) => {
setValue(next);
setAccountRaw(base, next);
};
const markDirty = useAccountRehydrate(() => setValue(getAccountRaw(base) ?? fallback));
// Stable setter (base + markDirty are both stable) so a provider can build a stable action on top
// of it — e.g. ChannelsProvider's setView, which must not change identity on every tab switch.
const set = useCallback(
(next: string) => {
markDirty();
setValue(next);
setAccountRaw(base, next);
},
[base, markDirty],
);
return [value, set];
}
@@ -177,15 +180,17 @@ export function useAccountPersistedObject<T extends object>(
defaults: T,
): [T, (patch: Partial<T>) => void] {
const [value, setValue] = useState<T>(() => readAccountMerged(base, defaults));
const markDirty = useAccountRehydrate(() => setValue(readAccountMerged(base, defaults)));
const patch = useCallback(
(p: Partial<T>) => {
markDirty();
setValue((prev) => {
const next = { ...prev, ...p };
writeAccount(base, next);
return next;
});
},
[base],
[base, markDirty],
);
return [value, patch];
}
@@ -0,0 +1,45 @@
// @vitest-environment jsdom
//
// useAccountRehydrate re-reads per-account state when the tab's account is pinned after mount (first
// load). useActiveAccountId is mocked with a plain variable so the transition can be driven by hand.
import { describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
let account: number | null = null;
vi.mock("./activeAccount", () => ({
useActiveAccountId: () => account,
}));
import { useAccountRehydrate } from "./useAccountRehydrate";
describe("useAccountRehydrate", () => {
beforeEach(() => {
account = null;
});
it("runs rehydrate when the account transitions from null to a value", () => {
const rehydrate = vi.fn();
const { rerender } = renderHook(() => useAccountRehydrate(rehydrate));
expect(rehydrate).not.toHaveBeenCalled(); // mount with no account: no transition
account = 1;
act(() => rerender());
expect(rehydrate).toHaveBeenCalledTimes(1);
});
it("does not run when the account id is already known and stable", () => {
account = 1;
const rehydrate = vi.fn();
const { rerender } = renderHook(() => useAccountRehydrate(rehydrate));
act(() => rerender());
expect(rehydrate).not.toHaveBeenCalled();
});
it("skips rehydrate when marked dirty before the account pins (no clobber of a user edit)", () => {
const rehydrate = vi.fn();
const { result, rerender } = renderHook(() => useAccountRehydrate(rehydrate));
act(() => result.current()); // markDirty — the owning setter ran pre-pin
account = 1;
act(() => rerender());
expect(rehydrate).not.toHaveBeenCalled();
});
});
+39
View File
@@ -0,0 +1,39 @@
import { useCallback, useEffect, useRef } from "react";
import { useActiveAccountId } from "./activeAccount";
// Re-read per-account persisted state when this tab's active account becomes known (or changes). The
// provider tree above <App> mounts before the account is pinned, so its useState initializers seed from
// an unknown account (empty defaults); once <App> pins it, `rehydrate` runs to load the real stored
// value. Guarded so it never clobbers a value the user set in the brief pre-pin window: the returned
// `markDirty` is called by the owning setter, and a dirty state skips the re-hydrate.
//
// Keyed on the account id ONLY — the user's own writes go through setAccountRaw, which does not change
// the account id, so this effect can't loop on them.
//
// This is the localStorage-only counterpart of the `useEffect([me?.id])` server-prefs adopt pattern in
// FeedFiltersProvider / FeedViewProvider / PrefsProvider (and App's shell prefs): those re-load from the
// SERVER when the account query resolves; this re-loads from localStorage when the account is pinned.
// A provider needs one or the other — not both — to survive a fresh tab's first paint.
export function useAccountRehydrate(rehydrate: () => void): () => void {
const accountId = useActiveAccountId();
const dirty = useRef(false);
const prev = useRef(accountId);
// Latest closure in a ref so the effect depends on the account id alone (a fresh `rehydrate` each
// render must not re-fire it), while still calling the current logic.
const fn = useRef(rehydrate);
fn.current = rehydrate;
useEffect(() => {
if (accountId === prev.current) return; // mount, and renders with no real transition
prev.current = accountId;
if (!dirty.current) fn.current();
// Reset per account: `dirty` means "the user edited under THIS account". Today a real account
// switch full-reloads (fresh instance), but clearing it here keeps the guard correct even if a
// future switch stops reloading — the next account starts clean rather than inheriting dirtiness.
dirty.current = false;
}, [accountId]);
return useCallback(() => {
dirty.current = true;
}, []);
}
+118
View File
@@ -0,0 +1,118 @@
// @vitest-environment jsdom
//
// The persistence/hydration wiring of useFilterState. storage is mocked with an in-memory map so the
// hook can be exercised without a logged-in account (getAccountRaw/setAccountRaw are account-keyed in
// the real module) — mirrors useSortState.dom.test.ts.
import { describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
// `ctl.account` models the tab's pinned account: getAccountRaw returns null while it's null (mirrors the
// real accountKey → no key → null), so a fresh-tab mount reads defaults until the account is pinned.
const { store, ctl } = vi.hoisted(() => ({
store: new Map<string, string>(),
ctl: { account: 1 as number | null },
}));
vi.mock("./storage", () => ({
getAccountRaw: (k: string) => (ctl.account == null ? null : (store.get(k) ?? null)),
setAccountRaw: (k: string, v: string) => {
if (ctl.account != null) store.set(k, v);
},
}));
vi.mock("./activeAccount", () => ({ useActiveAccountId: () => ctl.account }));
import { useFilterState } from "./useFilterState";
describe("useFilterState", () => {
beforeEach(() => {
store.clear();
ctl.account = 1;
});
it("hydrates to an empty map when storage is empty", () => {
const { result } = renderHook(() => useFilterState("k"));
expect(result.current[0]).toEqual({});
});
it("hydrates a stored map on mount, keeping string and string[] entries", () => {
store.set("k", JSON.stringify({ sync: "needsFull", tags: ["1", "2"] }));
const { result } = renderHook(() => useFilterState("k"));
expect(result.current[0]).toEqual({ sync: "needsFull", tags: ["1", "2"] });
});
it("persists on set, and a fresh mount reads it back (write → reload → hydrate)", () => {
const first = renderHook(() => useFilterState("k"));
act(() => first.result.current[1]({ sync: "fully" }));
expect(first.result.current[0]).toEqual({ sync: "fully" });
expect(store.get("k")).toBe(JSON.stringify({ sync: "fully" }));
const second = renderHook(() => useFilterState("k"));
expect(second.result.current[0]).toEqual({ sync: "fully" });
});
it("heals a non-object or malformed stored value to an empty map", () => {
store.set("k", "not json");
expect(renderHook(() => useFilterState("k")).result.current[0]).toEqual({});
store.set("k2", JSON.stringify(["a", "b"]));
expect(renderHook(() => useFilterState("k2")).result.current[0]).toEqual({});
store.set("k3", JSON.stringify(null));
expect(renderHook(() => useFilterState("k3")).result.current[0]).toEqual({});
});
it("drops entries whose value is neither a string nor a string[] of strings", () => {
store.set("k", JSON.stringify({ sync: "needsFull", n: 3, mixed: ["a", 2], obj: {} }));
const { result } = renderHook(() => useFilterState("k"));
expect(result.current[0]).toEqual({ sync: "needsFull" });
});
it("uses the migrate seed only when storage is empty", () => {
const migrate = () => ({ sync: "needsFull" });
// Empty storage → seed applies.
expect(renderHook(() => useFilterState("k", migrate)).result.current[0]).toEqual({
sync: "needsFull",
});
// Stored value wins over the seed.
store.set("k2", JSON.stringify({ sync: "fully" }));
expect(renderHook(() => useFilterState("k2", migrate)).result.current[0]).toEqual({
sync: "fully",
});
// A seed that returns null falls back to an empty map.
expect(renderHook(() => useFilterState("k3", () => null)).result.current[0]).toEqual({});
});
// The account-pin re-hydrate (via useAccountRehydrate) integrated into useFilterState.
it("re-hydrates the stored map once the account is pinned after mount", () => {
ctl.account = null; // fresh tab: no account at mount
store.set("k", JSON.stringify({ sync: "fully", tags: ["7"] }));
const { result, rerender } = renderHook(() => useFilterState("k"));
expect(result.current[0]).toEqual({}); // mount read with no account → empty
act(() => {
ctl.account = 1; // <App> pins the account
rerender();
});
expect(result.current[0]).toEqual({ sync: "fully", tags: ["7"] });
});
it("applies the migrate seed on the account-pin re-hydrate when the key is still empty", () => {
ctl.account = null;
// Mirrors migrateSyncChip: reads an account-scoped legacy key, so it yields null until pinned.
const migrate = () => (ctl.account == null ? null : { sync: "needsFull" });
const { result, rerender } = renderHook(() => useFilterState("k", migrate));
expect(result.current[0]).toEqual({}); // migrate can't read the legacy key with no account yet
act(() => {
ctl.account = 1;
rerender();
});
expect(result.current[0]).toEqual({ sync: "needsFull" }); // migrate carries the legacy value forward
});
it("does NOT clobber a pre-pin user edit when the account is then pinned (markDirty guard)", () => {
ctl.account = null;
store.set("k", JSON.stringify({ sync: "fully" })); // the stored value
const { result, rerender } = renderHook(() => useFilterState("k"));
act(() => result.current[1]({ sync: "needsFull" })); // user edits before the pin
act(() => {
ctl.account = 1;
rerender();
});
expect(result.current[0]).toEqual({ sync: "needsFull" }); // the edit survives, not "fully"
});
});
+53
View File
@@ -0,0 +1,53 @@
import { useCallback, useState } from "react";
import { getAccountRaw, setAccountRaw } from "./storage";
import { useAccountRehydrate } from "./useAccountRehydrate";
import type { FilterMap } from "../components/tableHeader";
// The per-account persisted column-filter map for a DataTable whose filters are CONTROLLED by the
// caller (so a second view of the same rows — the channel manager's card grid, the top status chips
// — shares one filter state). The table analogue of useSortState: one home so a controlled-filter
// surface gets persistence for free, seeded from and saved to `storageBase`.
export function useFilterState(
storageBase: string,
// Optional one-time seed used ONLY when `storageBase` has nothing stored yet — e.g. to carry a
// retired single-value key forward into the new map. Runs synchronously at init (no first-render
// flash); it stays idempotent because the seed persists as soon as the user touches the filters.
migrate?: () => FilterMap | null,
): [FilterMap, (next: FilterMap) => void] {
// Read the account's stored map, or the migrate seed when its key is still empty. Shared by the
// initial mount AND the account-pin re-hydrate, so first-load migration runs on whichever fires with
// the account known (on a fresh tab the account is null at mount → the re-hydrate does the real read).
const read = useCallback((): FilterMap => {
const raw = getAccountRaw(storageBase);
return raw ? hydrate(raw) : (migrate?.() ?? {});
}, [storageBase, migrate]);
const [filters, setFilters] = useState<FilterMap>(read);
const markDirty = useAccountRehydrate(() => setFilters(read()));
const set = useCallback(
(next: FilterMap) => {
markDirty();
setFilters(next);
setAccountRaw(storageBase, JSON.stringify(next));
},
[storageBase, markDirty],
);
return [filters, set];
}
// A stored map is only trusted when it parses to a plain object of string / string[] entries; any
// other shape (a legacy value, hand-edited storage) heals to empty rather than throwing downstream.
function hydrate(raw: string | null): FilterMap {
if (!raw) return {};
try {
const v = JSON.parse(raw);
if (!v || typeof v !== "object" || Array.isArray(v)) return {};
const out: FilterMap = {};
for (const [key, val] of Object.entries(v)) {
if (typeof val === "string") out[key] = val;
else if (Array.isArray(val) && val.every((x) => typeof x === "string")) out[key] = val;
}
return out;
} catch {
return {};
}
}
+90
View File
@@ -0,0 +1,90 @@
// @vitest-environment jsdom
//
// The persistence/hydration wiring of useSortState (the pure hydrateSort/healSort logic it leans on is
// covered in columnSort.test.ts). storage is mocked with an in-memory map so the hook can be exercised
// without a logged-in account (getAccountRaw/setAccountRaw are account-keyed in the real module).
import { describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
// `ctl.account` models the tab's pinned account: getAccountRaw returns null while it's null (mirrors the
// real accountKey → no key → null), so a fresh-tab mount reads defaults until the account is pinned.
const { store, ctl } = vi.hoisted(() => ({
store: new Map<string, string>(),
ctl: { account: 1 as number | null },
}));
vi.mock("./storage", () => ({
getAccountRaw: (k: string) => (ctl.account == null ? null : (store.get(k) ?? null)),
setAccountRaw: (k: string, v: string) => {
if (ctl.account != null) store.set(k, v);
},
}));
vi.mock("./activeAccount", () => ({ useActiveAccountId: () => ctl.account }));
import { useSortState } from "./useSortState";
describe("useSortState", () => {
beforeEach(() => {
store.clear();
ctl.account = 1;
});
it("hydrates to the fallback when storage is empty", () => {
const { result } = renderHook(() => useSortState("k"));
expect(result.current[0]).toBeNull();
const withFallback = renderHook(() => useSortState("k2", [], { key: "date", dir: "desc" }));
expect(withFallback.result.current[0]).toEqual({ key: "date", dir: "desc" });
});
it("hydrates a stored SortState on mount", () => {
store.set("k", JSON.stringify({ key: "views", dir: "desc" }));
const { result } = renderHook(() => useSortState("k"));
expect(result.current[0]).toEqual({ key: "views", dir: "desc" });
});
it("persists on set, and a fresh mount reads it back (write → reload → hydrate)", () => {
const first = renderHook(() => useSortState("k"));
act(() => first.result.current[1]({ key: "name", dir: "asc" }));
expect(first.result.current[0]).toEqual({ key: "name", dir: "asc" });
expect(store.get("k")).toBe(JSON.stringify({ key: "name", dir: "asc" }));
const second = renderHook(() => useSortState("k"));
expect(second.result.current[0]).toEqual({ key: "name", dir: "asc" });
});
it("heals a stored key that the supplied options no longer offer", () => {
store.set("k", JSON.stringify({ key: "gone", dir: "asc" }));
const opts = [{ key: "name", label: "Name" }];
const { result } = renderHook(() => useSortState("k", opts, { key: "name", dir: "asc" }));
expect(result.current[0]).toEqual({ key: "name", dir: "asc" });
});
it("keeps a stored key un-healed when no options are supplied (caller heals for display)", () => {
store.set("k", JSON.stringify({ key: "anything", dir: "desc" }));
const { result } = renderHook(() => useSortState("k"));
expect(result.current[0]).toEqual({ key: "anything", dir: "desc" });
});
// The account-pin re-hydrate (via useAccountRehydrate) integrated into useSortState.
it("re-hydrates the stored sort once the account is pinned after mount", () => {
ctl.account = null; // fresh tab: no account at mount
store.set("k", JSON.stringify({ key: "views", dir: "desc" }));
const { result, rerender } = renderHook(() => useSortState("k"));
expect(result.current[0]).toBeNull(); // mount read with no account → default
act(() => {
ctl.account = 1; // <App> pins the account
rerender();
});
expect(result.current[0]).toEqual({ key: "views", dir: "desc" });
});
it("does NOT clobber a pre-pin user edit when the account is then pinned (markDirty guard)", () => {
ctl.account = null;
store.set("k", JSON.stringify({ key: "views", dir: "desc" })); // the stored value
const { result, rerender } = renderHook(() => useSortState("k"));
act(() => result.current[1]({ key: "name", dir: "asc" })); // user edits before the pin
act(() => {
ctl.account = 1;
rerender();
});
expect(result.current[0]).toEqual({ key: "name", dir: "asc" }); // the edit survives, not "views"
});
});
+30
View File
@@ -0,0 +1,30 @@
import { useCallback, useState } from "react";
import { getAccountRaw, setAccountRaw } from "./storage";
import { useAccountRehydrate } from "./useAccountRehydrate";
import { hydrateSort, type SortOption, type SortState } from "./columnSort";
// The per-account persisted sort state, hydrated + healed against the current options — the plumbing
// the channel manager and channel discovery each hand-rolled (useState + parseSortState + a persist
// key). One home so a new sortable surface (Plex, a future table) gets persistence for free.
export function useSortState(
storageBase: string,
options: SortOption[] = [],
fallback: SortState = null,
): [SortState, (next: SortState) => void] {
const [sort, setSort] = useState<SortState>(() =>
hydrateSort(getAccountRaw(storageBase), options, fallback),
);
// When the account is pinned after mount (first load), re-read the stored sort for that account.
const markDirty = useAccountRehydrate(() =>
setSort(hydrateSort(getAccountRaw(storageBase), options, fallback)),
);
const set = useCallback(
(next: SortState) => {
markDirty();
setSort(next);
setAccountRaw(storageBase, JSON.stringify(next));
},
[storageBase, markDirty],
);
return [sort, set];
}