diff --git a/VERSION b/VERSION index 8ee93b94..1dd1c715 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.63.3 \ No newline at end of file +0.64.0 \ No newline at end of file diff --git a/frontend/src/components/ActionMenu.dom.test.tsx b/frontend/src/components/ActionMenu.dom.test.tsx new file mode 100644 index 00000000..72090311 --- /dev/null +++ b/frontend/src/components/ActionMenu.dom.test.tsx @@ -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( + + + , + ); + +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( + + •••} + /> + , + ); + expect(screen.getByTestId("custom-trigger")).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/ActionMenu.tsx b/frontend/src/components/ActionMenu.tsx new file mode 100644 index 00000000..b14bb497 --- /dev/null +++ b/frontend/src/components/ActionMenu.tsx @@ -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 ( + <> + + + {items.map((it) => ( + { + 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 && {it.icon}} + {it.label} + + ))} + + + ); +} diff --git a/frontend/src/components/AnchoredPopover.tsx b/frontend/src/components/AnchoredPopover.tsx index 21287f54..7e70d586 100644 --- a/frontend/src/components/AnchoredPopover.tsx +++ b/frontend/src/components/AnchoredPopover.tsx @@ -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). diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index 6ea311cf..4334425c 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -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(() => - 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(), diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 318518b7..8cca3732 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -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 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. */} -
- - -
+
+ setTab(id as "videos" | "about")} + tabs={[ + { id: "videos", label: t("channel.tabVideos") }, + { id: "about", label: t("channel.tabAbout") }, + ]} + /> +
{ + const actual = await importOriginal(); + 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 => + ({ + 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( + + + , + ); + fireEvent.focus(screen.getByRole("button", { name: "channels.stats.label" })); +}; + +describe("ChannelStatsPopover", () => { + it("renders a compact Stats trigger, panel closed until opened", () => { + render( + + + , + ); + 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"); + }); +}); diff --git a/frontend/src/components/ChannelStatsPopover.tsx b/frontend/src/components/ChannelStatsPopover.tsx new file mode 100644 index 00000000..5c8a3b13 --- /dev/null +++ b/frontend/src/components/ChannelStatsPopover.tsx @@ -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 ( + <> + + + {rows.map((r) => ( +
+
+ ))} +
+ + ); +} diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx index ee985a8d..99ad3c95 100644 --- a/frontend/src/components/ChannelTags.tsx +++ b/frontend/src/components/ChannelTags.tsx @@ -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 ? ( - ))} -
+ ({ 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 = ( -
- {s && ( - <> - - - - {s.deep_pending_count > 0 && ( - - )} - - - - )} -
- ); + // 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 ? : 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: , + onSelect: () => syncSubs.mutate(), + disabled: syncSubs.isPending, + }, + { + id: "backfill", + label: t("channels.backfillEverything"), + title: t("channels.backfillEverythingHint"), + icon: , + onSelect: () => deepAll.mutate(), + disabled: deepAll.isPending, + }, + ]; const actionButtons = ( -
- - - - - - -
+ ); // 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 = ; + const viewTabs: TabDef[] = useMemo( + () => + (["subscribed", "discovery", "hidden", "blocked"] as ChannelsView[]).map((v) => ({ + id: v, + label: t(`channels.tabs.${v}`), + })), + [t], + ); const tabs = ( -
-
- {(["subscribed", "discovery", "blocked"] as ChannelsView[]).map((v) => ( - - ))} -
+
+ setView(id as ChannelsView)} + className="flex flex-wrap items-center gap-1" + />
{view === "subscribed" && statsBlock} {view === "subscribed" && layout === "cards" && sortControl} @@ -458,41 +425,35 @@ export default function Channels() { search lives in the shared top SearchBar, tag create/delete in the manager. */}
+ {/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate + "Manage tags" button; the tooltip explains what tags are + that it's clickable. */} - - {t("channels.tags.yourTags")} - + - {userTags.map((tg) => { - const active = tagFilter.includes(tg.id); - return ( - - ); - })} + {userTags.map((tg) => ( + + ))} {tagFilter.length > 0 && ( )} -
{actionButtons}
@@ -521,37 +482,33 @@ export default function Channels() { ) : view === "blocked" ? ( /* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
- {(blockedQuery.data?.length ?? 0) === 0 ? ( -

{t("channels.blocked.empty")}

- ) : ( - <> -
- - - {t("channels.blocked.title")} - -
-
- {blockedQuery.data!.map((c) => ( - - {c.title ?? c.id} - - - ))} -
-

{t("channels.blocked.hint")}

- - )} + } + title={t("channels.blocked.title")} + hint={t("channels.blocked.hint")} + emptyText={t("channels.blocked.empty")} + actionLabel={t("channel.unblock")} + actionIcon={} + onAction={(id) => unblock.mutate(id)} + onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))} + /> +
+ ) : view === "hidden" ? ( + /* Hidden channels — kept out of the Mine feed but still in the shared Library; managed here + instead of dimmed in the subscribed table. Same card grid as Blocked (ExcludedChannelList). */ +
+ } + title={t("channels.hidden.title")} + hint={t("channels.hidden.hint")} + emptyText={t("channels.hidden.empty")} + actionLabel={t("channels.hidden.unhide")} + actionIcon={} + onAction={(id) => patch.mutate({ id, body: { hidden: false } })} + onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))} + />
) : ( /* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when @@ -580,7 +537,8 @@ export default function Channels() { controlsBandClassName="px-4 max-w-384 mx-auto" sort={sort} onSortChange={setSort} - rowClassName={(c) => (c.hidden ? "opacity-60" : "")} + filters={filters} + onFiltersChange={setFilters} emptyText={t("channels.empty")} /> ) : ( @@ -591,7 +549,6 @@ export default function Channels() { leadKey="priority" aboutKey="about" actionsKey="actions" - rowClassName={(c) => (c.hidden ? "opacity-60" : "")} emptyText={t("channels.empty")} /> )} @@ -600,13 +557,3 @@ export default function Channels() { ); } - -function Stat({ label, value, hint }: { label: string; value: string | number; hint?: string }) { - return ( - - - {value} {label} - - - ); -} diff --git a/frontend/src/components/ChannelsProvider.dom.test.tsx b/frontend/src/components/ChannelsProvider.dom.test.tsx new file mode 100644 index 00000000..5012e794 --- /dev/null +++ b/frontend/src/components/ChannelsProvider.dom.test.tsx @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +// +// ChannelsProvider's tab-history behaviour: setView pushes a `_chTab` history entry + persists the tab, +// and a genuine browser Back (popstate carrying `_chTab`) restores + re-persists it. This wiring was +// re-routed through usePersistedTab in R13 S2b, so it's exercised here rather than only by hand E2E. +// +// Uses REAL storage (jsdom localStorage/sessionStorage) with the tab's account pinned to 1, so +// usePersistedTab / useSortState / useFilterState all resolve their account-scoped keys; only +// NavigationProvider (the sole runtime dependency) is mocked, pinned to the channels page. +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import type { ReactNode } from "react"; + +vi.mock("./NavigationProvider", () => ({ + useNavigation: () => ({ page: "channels" }), + useNavigationActions: () => ({ setPage: vi.fn() }), +})); + +import { ChannelsProvider, useChannels, useChannelsActions } from "./ChannelsProvider"; + +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); +const render = () => renderHook(() => ({ ...useChannels(), ...useChannelsActions() }), { wrapper }); +const VIEW_KEY = "siftlode.channelsView.1"; // account-scoped (accountKey suffixes the pinned id) + +beforeEach(() => { + localStorage.clear(); + sessionStorage.setItem("siftlode.activeAccount", "1"); // pin the tab's account + window.history.replaceState({ sfPage: "channels" }, ""); // setView only owns an entry on this page +}); + +describe("ChannelsProvider tab history", () => { + it("setView pushes a _chTab history entry and persists the tab", () => { + const { result } = render(); + expect(result.current.view).toBe("subscribed"); // default + + act(() => result.current.setView("hidden")); + + expect(result.current.view).toBe("hidden"); + expect(window.history.state).toMatchObject({ sfPage: "channels", _chTab: "hidden" }); + expect(localStorage.getItem(VIEW_KEY)).toBe("hidden"); + }); + + it("does NOT push a new entry when re-selecting the current tab", () => { + const { result } = render(); + const spy = vi.spyOn(window.history, "pushState"); + act(() => result.current.setView("subscribed")); // already the current tab + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); + + it("a genuine Back (popstate carrying _chTab) restores and re-persists that tab", () => { + const { result } = render(); + act(() => result.current.setView("blocked")); + expect(result.current.view).toBe("blocked"); + + // Browser Back to an entry stamped with a different tab. + act(() => + window.dispatchEvent(new PopStateEvent("popstate", { state: { _chTab: "discovery" } })), + ); + + expect(result.current.view).toBe("discovery"); + expect(localStorage.getItem(VIEW_KEY)).toBe("discovery"); + }); + + it("ignores a popstate for an entry outside the manager (no _chTab)", () => { + const { result } = render(); + act(() => result.current.setView("hidden")); + act(() => window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "feed" } }))); + expect(result.current.view).toBe("hidden"); // unchanged + }); +}); diff --git a/frontend/src/components/ChannelsProvider.tsx b/frontend/src/components/ChannelsProvider.tsx index 6b6680ce..c4d5b26f 100644 --- a/frontend/src/components/ChannelsProvider.tsx +++ b/frontend/src/components/ChannelsProvider.tsx @@ -8,41 +8,47 @@ import { useState, type ReactNode, } from "react"; -import { getAccountRaw, LS, readAccount, setAccountRaw, writeAccount } from "../lib/storage"; +import { getAccountRaw, LS } from "../lib/storage"; import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout"; -import { parseSortState, type SortState } from "../lib/columnSort"; +import { type SortState } from "../lib/columnSort"; +import { useSortState } from "../lib/useSortState"; +import { useFilterState } from "../lib/useFilterState"; +import { usePersistedTab } from "./Tabs"; +import type { FilterMap } from "./tableHeader"; import { useNavigation, useNavigationActions } from "./NavigationProvider"; -import type { ChannelStatusFilter, ChannelsView } from "./Channels"; +import type { ChannelsView } from "./Channels"; -// Owns the Channels module's shared local state — the rail-less manager's name filter, status chip, -// active tab, the "drop a stale column filter" token, and the focus-a-channel intent — lifted out of +// Owns the Channels module's shared local state — the rail-less manager's name filter, column-filter +// map, active tab, the "drop a stale column filter" token, and the focus-a-channel intent — lifted out of // App so the Channels page, the header search box, and the cross-module callers (the feed filter // sidebar and the notifications inbox both "focus this channel in the manager") read it via hooks // instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts, // stable useCallback setters, one-line hooks; composed in main.tsx around ). // // Nested INSIDE NavigationProvider so its actions can drive navigation: focusChannel and -// goToFullHistory both jump to the Channels page (and force the subscriptions tab). status / view are +// goToFullHistory both jump to the Channels page (and force the subscriptions tab). filters / view are // per-account persisted (localStorage); search / focus / the reset token are ephemeral. An account // switch does a full reload, so init-from-storage is all the hydration the persisted ones need. -const STATUS_FILTERS: readonly ChannelStatusFilter[] = [ - "needs_full", - "fully_synced", - "hidden", - "all", -]; - -function clampStatus(v: string | null): ChannelStatusFilter { - return STATUS_FILTERS.includes(v as ChannelStatusFilter) ? (v as ChannelStatusFilter) : "all"; -} function clampView(v: string | null): ChannelsView { - return v === "discovery" ? "discovery" : v === "blocked" ? "blocked" : "subscribed"; + return v === "discovery" || v === "blocked" || v === "hidden" ? v : "subscribed"; +} + +// One-time carry-forward of the retired single-value sync chip (LS.legacyChannelFilter, vocab +// all/needs_full/fully_synced) into the new filter map's `sync` key (vocab ""/needsFull/fully), so a +// user who had a chip pinned keeps it. Only consulted when channelsFilterMap is still empty. +function migrateSyncChip(): FilterMap | null { + const old = getAccountRaw(LS.legacyChannelFilter); + if (old === "needs_full") return { sync: "needsFull" }; + if (old === "fully_synced") return { sync: "fully" }; + return null; } type ChannelsState = { search: string; - statusFilter: ChannelStatusFilter; + // The subscribed table's column filters, lifted out of DataTable so the top status chips, the Sync + // column funnel, and the card layout share ONE filter state (the sync chip lives at `filters.sync`). + filters: FilterMap; view: ChannelsView; // The subscribed table's sort, lifted out of DataTable so the card layout shares it. sort: SortState; @@ -57,7 +63,7 @@ type ChannelsState = { interface ChannelsActions { setSearch: (q: string) => void; - setStatusFilter: (f: ChannelStatusFilter) => void; + setFilters: (f: FilterMap) => void; setView: (v: ChannelsView) => void; setLayout: (l: ChannelLayout) => void; setSort: (s: SortState) => void; @@ -70,7 +76,7 @@ interface ChannelsActions { const StateContext = createContext({ search: "", - statusFilter: "all", + filters: {}, view: "subscribed", sort: null, filtersResetToken: 0, @@ -83,7 +89,7 @@ const StateContext = createContext({ const LayoutContext = createContext("table"); const ActionsContext = createContext({ setSearch: () => {}, - setStatusFilter: () => {}, + setFilters: () => {}, setView: () => {}, setLayout: () => {}, setSort: () => {}, @@ -108,18 +114,20 @@ export function ChannelsProvider({ children }: { children: ReactNode }) { const { setPage } = useNavigationActions(); const [search, setSearchState] = useState(""); - const [statusFilter, setStatusFilterState] = useState(() => - clampStatus(getAccountRaw(LS.channelFilter)), - ); - const [view, setViewState] = useState(() => - clampView(getAccountRaw(LS.channelsView)), - ); - const [layout, setLayoutState] = useState(() => - parseChannelLayout(getAccountRaw(LS.channelLayout)), - ); - const [sort, setSortState] = useState(() => - parseSortState(readAccount(LS.channelSort, null)), - ); + // The subscribed table's column-filter map, owned here (lifted out of DataTable) so the top status + // chips, the Sync column funnel, and the card layout share one state; useFilterState persists it. + const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip); + // view + layout are persisted tabs (usePersistedTab = the shared account-scoped tab-state hook), so + // they get init-from-storage AND the account-pin re-hydrate for free (this provider mounts above + // , which pins the account only after /api/me). Stored un-clamped; clampView/parseChannelLayout + // heal them for display. setView layers the history logic (below) on top of the raw setter. + const [viewRaw, setViewRaw] = usePersistedTab(LS.channelsView, "subscribed"); + const view = clampView(viewRaw); + const [layoutRaw, setLayout] = usePersistedTab(LS.channelLayout, "table"); + const layout = parseChannelLayout(layoutRaw); + // Sort is owned here (lifted out of DataTable) so the card layout shares it; ColumnSortControl heals + // it against the live columns for display, so the provider stores it un-healed (no options passed). + const [sort, setSort] = useSortState(LS.channelSort); const [filtersResetToken, setFiltersResetToken] = useState(0); const [focusName, setFocusName] = useState(null); const [focusToken, setFocusToken] = useState(0); @@ -131,37 +139,26 @@ export function ChannelsProvider({ children }: { children: ReactNode }) { viewRef.current = view; const setSearch = useCallback((q: string) => setSearchState(q), []); - const setStatusFilter = useCallback((f: ChannelStatusFilter) => { - setStatusFilterState(f); - setAccountRaw(LS.channelFilter, f); - }, []); // The active tab rides in history.state (`_chTab`), the same way the channel page (`_chan`) and the // live YouTube search (`_yt`) do — so Back steps back through the tabs you actually visited instead // of jumping straight out to the previous module. - const setView = useCallback((v: ChannelsView) => { - // Re-selecting the tab you are already on is not a navigation — bail before pushing, or clicking - // the active tab (and goToFullHistory forcing "subscribed") stacks Back entries that do nothing. - if (v === viewRef.current) return; - setViewState(v); - setAccountRaw(LS.channelsView, v); - // Only own an entry while the manager is already on screen. Arriving from elsewhere - // (focusChannel / goToFullHistory) lets setPage push the entry — the stamp effect labels it — so - // one navigation never costs two Back presses. - if (window.history.state?.sfPage === "channels") { - // A FRESH state, like setPage's: carrying the old entry's `_chan`/`_yt` markers over would make - // Back re-open the channel overlay or a spent search on top of the tab. - window.history.pushState({ sfPage: "channels", _chTab: v }, ""); - } - }, []); - const setLayout = useCallback((l: ChannelLayout) => { - setLayoutState(l); - setAccountRaw(LS.channelLayout, l); - }, []); - const setSort = useCallback((s: SortState) => { - setSortState(s); - writeAccount(LS.channelSort, s); - }, []); - + const setView = useCallback( + (v: ChannelsView) => { + // Re-selecting the tab you are already on is not a navigation — bail before pushing, or clicking + // the active tab (and goToFullHistory forcing "subscribed") stacks Back entries that do nothing. + if (v === viewRef.current) return; + setViewRaw(v); // persists + guards the account-pin re-hydrate (via usePersistedTab) + // Only own an entry while the manager is already on screen. Arriving from elsewhere + // (focusChannel / goToFullHistory) lets setPage push the entry — the stamp effect labels it — so + // one navigation never costs two Back presses. + if (window.history.state?.sfPage === "channels") { + // A FRESH state, like setPage's: carrying the old entry's `_chan`/`_yt` markers over would make + // Back re-open the channel overlay or a spent search on top of the tab. + window.history.pushState({ sfPage: "channels", _chTab: v }, ""); + } + }, + [setViewRaw], + ); const focusChannel = useCallback( (name: string) => { setFocusName(name); @@ -172,11 +169,13 @@ export function ChannelsProvider({ children }: { children: ReactNode }) { [setView, setPage], ); const goToFullHistory = useCallback(() => { - setStatusFilter("needs_full"); - setView("subscribed"); // the status filter applies to the subscriptions tab - setFiltersResetToken((n) => n + 1); // drop any stale column filter hiding the rows + // One controlled map replaces the old status chip: sync=needsFull, and dropping every other + // column filter (the whole map is set fresh) so no stale funnel hides the rows we're sent to see. + setFilters({ sync: "needsFull" }); + setView("subscribed"); // the sync filter applies to the subscriptions tab + setFiltersResetToken((n) => n + 1); // also clear the ephemeral name search + tag chips setPage("channels"); - }, [setStatusFilter, setView, setPage]); + }, [setFilters, setView, setPage]); // Clear the manager focus when leaving the Channels page so it doesn't re-apply on a later visit. useEffect(() => { @@ -196,29 +195,27 @@ export function ChannelsProvider({ children }: { children: ReactNode }) { function onPop(e: PopStateEvent) { const tab = e.state?._chTab; if (!tab) return; // an entry outside the manager — leave the tab alone - const next = clampView(tab as string); - setViewState(next); - setAccountRaw(LS.channelsView, next); + setViewRaw(clampView(tab as string)); // persists the restored tab (setViewRaw is stable) } window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); - }, []); + }, [setViewRaw]); const state = useMemo( - () => ({ search, statusFilter, view, sort, filtersResetToken, focusName, focusToken }), - [search, statusFilter, view, sort, filtersResetToken, focusName, focusToken], + () => ({ search, filters, view, sort, filtersResetToken, focusName, focusToken }), + [search, filters, view, sort, filtersResetToken, focusName, focusToken], ); const actions = useMemo( () => ({ setSearch, - setStatusFilter, + setFilters, setView, setLayout, setSort, focusChannel, goToFullHistory, }), - [setSearch, setStatusFilter, setView, setLayout, setSort, focusChannel, goToFullHistory], + [setSearch, setFilters, setView, setLayout, setSort, focusChannel, goToFullHistory], ); return ( diff --git a/frontend/src/components/ColumnSortControl.tsx b/frontend/src/components/ColumnSortControl.tsx index d38d4d13..2599ac1c 100644 --- a/frontend/src/components/ColumnSortControl.tsx +++ b/frontend/src/components/ColumnSortControl.tsx @@ -1,11 +1,12 @@ -import { ArrowDown, ArrowUp } from "lucide-react"; import { type Column } from "./DataTable"; import { type SortState } from "../lib/columnSort"; +import { optionsFromColumns } from "../lib/sortOptions"; +import SortControl from "./SortControl"; -// A sort control for views without clickable column headers (the channel manager's card layout): -// a dropdown of the sortable columns + an asc/desc toggle, mirroring the feed toolbar's sort. It's -// driven by the SAME `SortState` the DataTable header uses, so table and cards stay in sync. The -// dropdown's options come straight from the columns' `sortable`/`sortValue` metadata — no new list. +// The headerless sort control for views without clickable column headers (the channel manager's and +// discovery's card layouts). Now a thin adapter over the generic `SortControl`, feeding it the +// column-derived options and a "no sort" default — kept as its own component so the existing call +// sites stay untouched and table/cards keep sharing one `SortState`. export default function ColumnSortControl({ columns, sort, @@ -25,44 +26,15 @@ export default function ColumnSortControl({ ascLabel: string; descLabel: string; }) { - const sortable = columns.filter((c) => c.sortable && c.sortValue); - // A stored sort can name a column this table no longer offers (renamed, or `sortable` dropped). - // `sortRows` already leaves the rows unsorted then, so treat it as no sort here too — otherwise the - // select falls back to showing "default" while a direction arrow claims a sort that isn't applied. - const active = sortable.some((c) => c.key === sort?.key) ? sort : null; return ( -
- - {active && ( - - )} -
+ ); } diff --git a/frontend/src/components/CountBadge.tsx b/frontend/src/components/CountBadge.tsx new file mode 100644 index 00000000..a99265bb --- /dev/null +++ b/frontend/src/components/CountBadge.tsx @@ -0,0 +1,15 @@ +// A small count pill shown beside a tab label or filter chip (pending requests, item counts). One +// source for the count-badge token so Tabs and FilterChipGroup can't drift. The `active` variant sits +// on an accent-filled parent (a pressed tab/chip), so it tints against accent-fg; the resting variant +// carries its own accent tint. Callers guard on `count > 0` — this just renders the pill. +export default function CountBadge({ count, active }: { count: number; active?: boolean }) { + return ( + + {count} + + ); +} diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx index a1750074..3c781b9b 100644 --- a/frontend/src/components/DataTable.tsx +++ b/frontend/src/components/DataTable.tsx @@ -25,6 +25,11 @@ interface Persisted { size?: number; } +// Stable empty-map identity for the controlled-but-value-omitted fallback, so `filters` keeps a +// constant reference across renders (a fresh `{}` would churn the persist effect + re-filter every +// render). Mirrors controlled sort's stable `null` fallback. +const EMPTY_FILTERS: FilterMap = {}; + function loadPersist(key?: string): Persisted { const empty: Persisted = { sort: null, filters: {}, page: 0 }; if (!key) return empty; @@ -53,6 +58,8 @@ export default function DataTable({ resetFiltersToken, sort: sortProp, onSortChange, + filters: filtersProp, + onFiltersChange, }: { rows: T[]; columns: Column[]; @@ -82,6 +89,13 @@ export default function DataTable({ // persistKey-backed sort. sort?: SortState; onSortChange?: (next: SortState) => void; + // CONTROLLED filters (optional): symmetric to controlled sort. When `onFiltersChange` is given the + // caller owns the column-filter map (and its persistence) — so the channel manager's top status + // chips, the Sync column funnel, and the card layout all share ONE filter state — and the table + // reflects `filters` and reports funnel changes instead of keeping its own. Omit both for the + // default internal, persistKey-backed filters. + filters?: FilterMap; + onFiltersChange?: (next: FilterMap) => void; }) { const { t } = useTranslation(); const initial = loadPersist(persistKey); @@ -90,17 +104,42 @@ export default function DataTable({ const controlledSort = onSortChange !== undefined; const [internalSort, setInternalSort] = useState(initial.sort); const sort = controlledSort ? (sortProp ?? null) : internalSort; - const [filters, setFilters] = useState(initial.filters); + // Filters are controlled when the caller passes onFiltersChange (it then owns/persists the map); + // otherwise the table keeps its own, seeded from and saved to persistKey — symmetric to sort. + const controlledFilters = onFiltersChange !== undefined; + const [internalFilters, setInternalFilters] = useState(initial.filters); + const filters = controlledFilters ? (filtersProp ?? EMPTY_FILTERS) : internalFilters; const [page, setPage] = useState(initial.page); const [size, setSize] = useState(initial.size ?? pageSize); + // Commit a whole new filter map through the owner (controlled) or internal state. A ref holds the + // latest resolved map so the externalFilter/reset effects can derive from it without taking + // `filters` as a dep (which would re-run them on every filter change). + const filtersRef = useRef(filters); + filtersRef.current = filters; + const commitFilters = (next: FilterMap) => { + if (controlledFilters) onFiltersChange!(next); + else setInternalFilters(next); + }; + + // Persist page/size always, but only the sort/filter slices this table actually OWNS — a controlled + // caller persists those itself, so writing them here would just leave a stale, never-read duplicate. useEffect(() => { - if (persistKey) localStorage.setItem(persistKey, JSON.stringify({ sort, filters, page, size })); - }, [persistKey, sort, filters, page, size]); + if (!persistKey) return; + localStorage.setItem( + persistKey, + JSON.stringify({ + sort: controlledSort ? null : sort, + filters: controlledFilters ? {} : filters, + page, + size, + }), + ); + }, [persistKey, controlledSort, controlledFilters, sort, filters, page, size]); useEffect(() => { if (!externalFilter) return; - setFilters((f) => ({ ...f, [externalFilter.key]: externalFilter.value })); + commitFilters({ ...filtersRef.current, [externalFilter.key]: externalFilter.value }); setPage(0); // eslint-disable-next-line react-hooks/exhaustive-deps }, [externalFilter?.key, externalFilter?.value]); @@ -113,8 +152,9 @@ export default function DataTable({ useEffect(() => { if (resetFiltersToken === undefined || resetFiltersToken === resetRef.current) return; resetRef.current = resetFiltersToken; - setFilters({}); + commitFilters({}); setPage(0); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [resetFiltersToken]); const sorted = sortRows(filterRows(rows, columns, filters), columns, sort); @@ -132,7 +172,7 @@ export default function DataTable({ setPage(0); } function applyFilter(key: string, value: string | string[]) { - setFilters((f) => ({ ...f, [key]: value })); + commitFilters({ ...filters, [key]: value }); setPage(0); } @@ -196,7 +236,7 @@ export default function DataTable({ {paged.map((row) => ( diff --git a/frontend/src/components/ExcludedChannelList.tsx b/frontend/src/components/ExcludedChannelList.tsx new file mode 100644 index 00000000..5caf33dc --- /dev/null +++ b/frontend/src/components/ExcludedChannelList.tsx @@ -0,0 +1,70 @@ +import { type ReactNode } from "react"; +import ChannelLink from "./ChannelLink"; + +// The presentation shared by the channel manager's "excluded channel" tabs — Hidden and Blocked. +// A responsive grid of proper channel cards (avatar + name + open-on-YouTube, via ChannelLink) each +// with one reversing action, instead of a bare row of name chips. Both tabs pass the same shape; only +// the header icon/label, the action, and the copy differ. +export interface ExcludedChannel { + id: string; + title: string | null; + handle?: string | null; + thumbnail_url: string | null; +} + +export default function ExcludedChannelList({ + channels, + icon, + title, + hint, + emptyText, + actionLabel, + actionIcon, + onAction, + onView, +}: { + channels: ExcludedChannel[]; + icon: ReactNode; + title: string; + hint: string; + emptyText: string; + actionLabel: string; + actionIcon: ReactNode; + onAction: (id: string) => void; + onView?: (c: ExcludedChannel) => void; +}) { + if (channels.length === 0) return

{emptyText}

; + return ( + <> +
+ {icon} + {title} +
+
+ {channels.map((c) => ( +
+
+ onView(c) : undefined} + /> +
+ +
+ ))} +
+

{hint}

+ + ); +} diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index f932e961..8440a519 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -8,8 +8,6 @@ import { } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { - ArrowDown, - ArrowUp, ArrowLeft, Columns2, Grid3x3, @@ -34,12 +32,15 @@ import { SORT_KEYS, type SortKey, } from "../lib/feedSort"; +import { type SortOption } from "../lib/columnSort"; import { FEED_VIEWS, type FeedView } from "../lib/feedView"; import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters"; import ActiveFilterChips from "./ActiveFilterChips"; import { LoadingState, StateMessage } from "./QueryState"; import { PageToolbar } from "./PageShell"; import { useWizard } from "./WizardProvider"; +import SortControl from "./SortControl"; +import FilterChipGroup, { filterChipClass } from "./FilterChipGroup"; import ViewSwitcher from "./ViewSwitcher"; import VirtualFeed from "./VirtualFeed"; // Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened. @@ -547,36 +548,51 @@ export default function Feed({ ); const { key: sortKey, dir: sortDir } = parseSort(filters.sort); + // The feed's sort vocabulary as source-agnostic options for the shared SortControl. Relevance only + // ranks meaningfully with a search term (offered then); shuffle/relevance are directionless. + const sortOptions: SortOption[] = ( + filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys + ).map((k) => ({ + key: k, + label: t("feed.sortKey." + k), + directionless: DIRECTIONLESS.includes(k), + defaultDir: DIRECTIONLESS.includes(k) + ? undefined + : SORT_DEFAULT_DIR[k as Exclude], + })); const toolbar = (
- {SHOW_IDS.map((id) => ( - - ))} + ({ + value: id, + label: t("sidebar.show." + id), + testId: `feed-show-${id}`, + }))} + value={filters.show} + onChange={(show) => setFilters({ ...filters, show })} + />
{t("feed.sortLabel")} - - {!DIRECTIONLESS.includes(sortKey) && ( - - )} - {sortKey === "shuffle" && ( - - )} + label={t("feed.sortLabel")} + ascLabel={t("feed.dirAsc")} + descLabel={t("feed.dirDesc")} + trailing={ + sortKey === "shuffle" ? ( + + ) : undefined + } + />
+ + {personCards.length > 0 && (
{personCards.map((p) => ( diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index 60ca7dac..f77e9606 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useState } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -35,6 +35,7 @@ import { api, type PlexCard } from "../lib/api"; import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; import { useConfirm } from "./ConfirmProvider"; import { LoadingState, StateMessage } from "./QueryState"; +import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; // A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the // same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat @@ -499,18 +500,10 @@ export default function PlexPlaylistView({ onRemove, }; - const LayoutBtn = ({ v, icon, label }: { v: Layout; icon: ReactNode; label: string }) => ( - - ); + const layoutOptions: ViewOption[] = [ + { id: "accordion", label: t("plex.playlist.layoutAccordion"), icon: Rows3 }, + { id: "tree", label: t("plex.playlist.layoutTree"), icon: ListTree }, + ]; return (
@@ -536,19 +529,13 @@ export default function PlexPlaylistView({ ) : (

{q.data?.title ?? " "}

)} - {/* Layout preference: Accordion (A) or Tree (C). */} -
- } - label={t("plex.playlist.layoutAccordion")} - /> - } - label={t("plex.playlist.layoutTree")} - /> -
+ {/* Layout preference: Accordion or Tree — the shared list-view switcher. */} + - ))} -
-
+ ); } @@ -95,7 +87,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl qc.invalidateQueries({ queryKey: qk.plexPlaylists() }); onOpenPlaylist(pl.id); } - const { sortDir: _sd, collectionTitle: _ct, ...facetKey } = filters; + const { collectionTitle: _ct, ...facetKey } = filters; const facetsQ = useQuery({ queryKey: qk.plexFacets(scope, show, facetKey), queryFn: () => api.plexFacets(scope, filters, show), @@ -114,21 +106,18 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl const collections = collectionsQ.data?.collections ?? []; const fCount = plexFilterCount(filters); - const activeCount = fCount + (show !== "all" ? 1 : 0) + (sort !== "title" ? 1 : 0); + // Heal against the current scope so a stale out-of-scope key (e.g. movie "duration" after switching + // to shows) doesn't inflate the active-filter count while the control + grid show the default. + const healedSort = healPlexSort(sort, scope); + const activeCount = fCount + (show !== "all" ? 1 : 0) + (healedSort.key !== "title" ? 1 : 0); const anyActive = activeCount > 0; const patch = (p: Partial) => setFilters({ ...filters, ...p }); const toggleIn = (arr: string[], v: string) => arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v]; - const clearAll = () => { - setFilters(EMPTY_PLEX_FILTERS); - setShow("all"); - setSort("title"); - }; + const clearAll = usePlexClearFilters(); - const sorts = [...(scope === "movie" ? MOVIE_SORTS : SHOW_SORTS)].sort((a, b) => - t(`plex.sort.${a}`).localeCompare(t(`plex.sort.${b}`)), - ); + const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`)); const durBucketKey = DURATION_BUCKETS.find( (b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max, )?.key; @@ -143,20 +132,17 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl id: "scope", title: t("plex.filter.scope"), body: ( -
- {(["both", "movie", "show"] as const).map((s) => ( - - ))} -
+ ({ + value: s, + label: t(`plex.filter.scopeOpt.${s}`), + }))} + value={scope} + onChange={setScope} + /> ), }, { @@ -205,39 +191,26 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl id: "watch", title: t("plex.filter.show"), body: ( - - {SHOW_OPTS.map((s) => ( - setShow(s)}> - {t(`plex.filter.showOpt.${s}`)} - - ))} - + ({ value: s, label: t(`plex.filter.showOpt.${s}`) }))} + value={show} + onChange={setShow} + /> ), }, { id: "sort", title: t("plex.filter.sort"), body: ( - <> - - {sorts.map((s) => ( - setSort(s)}> - {t(`plex.sort.${s}`)} - - ))} - -
- {(["asc", "desc"] as const).map((d) => ( - patch({ sortDir: d })} - > - {t(`plex.filter.dir.${d}`)} - - ))} -
- + setSort(next ?? PLEX_SORT_FALLBACK)} + label={t("plex.filter.sort")} + ascLabel={t("plex.filter.dir.asc")} + descLabel={t("plex.filter.dir.desc")} + /> ), }, ...(peopleActive @@ -328,16 +301,15 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl id: "rating", title: t("plex.filter.rating"), body: ( - - patch({ ratingMin: null })}> - {t("plex.filter.any")} - - {RATING_STEPS.map((r) => ( - patch({ ratingMin: r })}> - {r}+ - - ))} - + ({ value: String(r), label: `${r}+` })), + ]} + onChange={(v) => patch({ ratingMin: v === "" ? null : Number(v) })} + /> ), }, ...(facets && facets.genres.length > 0 @@ -353,17 +325,22 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl onChange={(m) => patch({ genreMode: m })} /> )} - +
{facets.genres.map((g) => ( - patch({ genres: toggleIn(filters.genres, g.value) })} + aria-pressed={filters.genres.includes(g.value)} + className={filterChipClass} > {g.value} - + ))} - +
), }, @@ -394,25 +371,26 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl id: "duration", title: t("plex.filter.duration"), body: ( - - patch({ durationMin: null, durationMax: null })} - > - {t("plex.filter.any")} - - {DURATION_BUCKETS.map((b) => ( - patch({ durationMin: b.min, durationMax: b.max })} - > - {t(`plex.filter.durationOpt.${b.key}`)} - - ))} - + ({ + 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: ( - - patch({ addedWithin: "" })}> - {t("plex.filter.any")} - - {ADDED_OPTS.map((a) => ( - patch({ addedWithin: a })} - > - {t(`plex.filter.addedOpt.${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: ( - +
{facets.content_ratings.map((c) => ( - patch({ contentRatings: toggleIn(filters.contentRatings, c.value) }) } + aria-pressed={filters.contentRatings.includes(c.value)} + className={filterChipClass} > {c.value} - + ))} - +
), }, ] @@ -490,30 +468,6 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl ); } -function ChipRow({ children }: { children: ReactNode }) { - return
{children}
; -} - -function Chip({ - active, - onClick, - children, -}: { - active: boolean; - onClick: () => void; - children: ReactNode; -}) { - return ( - - ); -} - function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) { return ( diff --git a/frontend/src/components/SegmentedToggle.dom.test.tsx b/frontend/src/components/SegmentedToggle.dom.test.tsx new file mode 100644 index 00000000..18394f89 --- /dev/null +++ b/frontend/src/components/SegmentedToggle.dom.test.tsx @@ -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(); + expect(screen.getAllByRole("button").map((b) => b.textContent)).toEqual(["Any", "All"]); + }); + + it("marks exactly the active option aria-pressed", () => { + render(); + 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(); + fireEvent.click(screen.getByRole("button", { name: "All" })); + expect(onChange).toHaveBeenCalledWith("and"); + }); + + it("exposes the group aria-label", () => { + render(); + expect(screen.getByRole("group", { name: "Match mode" })).toBeTruthy(); + }); + + it("renders the caption when label is set", () => { + render( + , + ); + // 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( + , title: "Your subs" }, + { value: "all", label: "Library", icon: }, + ]} + value="my" + onChange={vi.fn()} + />, + ); + expect(screen.getByTestId("ic-my")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Mine" }).getAttribute("title")).toBe("Your subs"); + }); +}); diff --git a/frontend/src/components/SegmentedToggle.tsx b/frontend/src/components/SegmentedToggle.tsx new file mode 100644 index 00000000..728c94d6 --- /dev/null +++ b/frontend/src/components/SegmentedToggle.tsx @@ -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 { + value: T; + label: string; + icon?: ReactNode; + title?: string; // optional per-option tooltip +} + +export default function SegmentedToggle({ + options, + value, + onChange, + ariaLabel, + label, + title, + fullWidth = false, + size = "sm", + className, +}: { + options: readonly SegmentedOption[]; + 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 = ( +
+ {options.map((o) => ( + + ))} +
+ ); + + if (label == null) { + return className ?
{track}
: track; + } + return ( +
+ {label} + {track} +
+ ); +} diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index eab9b47b..f8eaa900 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -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() {
{/* Vertical tab rail — never wraps; active is a clear accent pill. */} - + ({ + 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 — diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 0eda45a9..dd9d2467 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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} > {tag.name} ; case "channel": return ; - case "date": + case "date": { + // Single-select across the presets + Custom; pure logic lives in lib/dateFilter. + const dateKey = deriveDateKey(filters, customDates, DATE_PRESETS); return ( <> -
- {DATE_PRESETS.map((p) => { - const activePreset = filters.maxAgeDays === p.days && !customDates; - return ( - - ); - })} - -
+ ({ + 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) && (
@@ -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 ( <> -
- - {t("sidebar.match")} - -
- {(["or", "and"] as const).map((m) => ( - - ))} -
-
+ 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 })} + />
{chips.map((tg) => ( {/* Scope: your own subscriptions (Mine) vs the shared Library. */} -
- {(["my", "all"] as const).map((s) => ( - - ))} -
+ , + title: t("header.scope.myTip"), + }, + { + value: "all", + label: t("header.scope.all"), + icon: , + title: t("header.scope.allTip"), + }, + ]} + value={filters.scope} + onChange={(scope) => setFilters({ ...filters, scope })} + /> diff --git a/frontend/src/components/SortControl.dom.test.tsx b/frontend/src/components/SortControl.dom.test.tsx new file mode 100644 index 00000000..2ff69e73 --- /dev/null +++ b/frontend/src/components/SortControl.dom.test.tsx @@ -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 = {}) { + const onChange = vi.fn(); + render( + , + ); + 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: , + }, + ); + expect(screen.getByRole("button", { name: "Reshuffle" })).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/SortControl.tsx b/frontend/src/components/SortControl.tsx new file mode 100644 index 00000000..cf3be5c6 --- /dev/null +++ b/frontend/src/components/SortControl.tsx @@ -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 ( +
+ + {showArrow && active && ( + + )} + {trailing} +
+ ); +} diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index 59c4602e..9bb4c9d0 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -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() {
{/* Only admins have a second (System) tab; everyone else just sees the Overview. */} {isAdmin && ( -
- {(["overview", "system"] as StatsTab[]).map((id) => ( - - ))} -
+ ({ + id, + label: t(`stats.tabs.${id}`), + }))} + /> )} {tab === "system" && isAdmin ? : } @@ -228,18 +226,16 @@ function SystemStats() {

{t("stats.title")}

-
- {RANGES.map((d) => ( - - ))} -
+ ({ + value: String(d), + label: t("stats.rangeDays", { count: d }), + }))} + value={String(days)} + onChange={(v) => setDays(Number(v))} + />

diff --git a/frontend/src/components/Tabs.tsx b/frontend/src/components/Tabs.tsx index 3154827b..1a2b4841 100644 --- a/frontend/src/components/Tabs.tsx +++ b/frontend/src/components/Tabs.tsx @@ -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 ( -

+
{tabs.map((tb) => { const on = tb.id === active; + const Icon = tb.icon; return ( ); })} diff --git a/frontend/src/components/usePlexActiveFilters.ts b/frontend/src/components/usePlexActiveFilters.ts new file mode 100644 index 00000000..4b12e019 --- /dev/null +++ b/frontend/src/components/usePlexActiveFilters.ts @@ -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) => 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); + }; +} diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index 7ec4d8ca..57cdcfda 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -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" } } diff --git a/frontend/src/i18n/locales/en/feed.json b/frontend/src/i18n/locales/en/feed.json index 7374ec5a..74ee3954 100644 --- a/frontend/src/i18n/locales/en/feed.json +++ b/frontend/src/i18n/locales/en/feed.json @@ -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", diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 301c0998..8f941a83 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -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", diff --git a/frontend/src/i18n/locales/en/sidebar.json b/frontend/src/i18n/locales/en/sidebar.json index ffcc83d9..ea21751c 100644 --- a/frontend/src/i18n/locales/en/sidebar.json +++ b/frontend/src/i18n/locales/en/sidebar.json @@ -66,6 +66,7 @@ "shorts": "Shorts", "live": "Live / Upcoming" }, + "datePresetLabel": "Upload date", "datePreset": { "24h": "24h", "1week": "1 week", diff --git a/frontend/src/i18n/locales/en/stats.json b/frontend/src/i18n/locales/en/stats.json index 1131e73e..5de6eaec 100644 --- a/frontend/src/i18n/locales/en/stats.json +++ b/frontend/src/i18n/locales/en/stats.json @@ -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.", diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index e61d737c..29df7265 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -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" } } diff --git a/frontend/src/i18n/locales/hu/feed.json b/frontend/src/i18n/locales/hu/feed.json index 7e0625b6..08fa0f85 100644 --- a/frontend/src/i18n/locales/hu/feed.json +++ b/frontend/src/i18n/locales/hu/feed.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 78c40a67..04d50378 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/sidebar.json b/frontend/src/i18n/locales/hu/sidebar.json index 0049936a..6b8c01fa 100644 --- a/frontend/src/i18n/locales/hu/sidebar.json +++ b/frontend/src/i18n/locales/hu/sidebar.json @@ -66,6 +66,7 @@ "shorts": "Shorts", "live": "Élő / Hamarosan" }, + "datePresetLabel": "Feltöltés dátuma", "datePreset": { "24h": "24 óra", "1week": "1 hét", diff --git a/frontend/src/i18n/locales/hu/stats.json b/frontend/src/i18n/locales/hu/stats.json index d85adb08..c7692360 100644 --- a/frontend/src/i18n/locales/hu/stats.json +++ b/frontend/src/i18n/locales/hu/stats.json @@ -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.", diff --git a/frontend/src/index.css b/frontend/src/index.css index 4f69c0c5..ce4b33c9 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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 diff --git a/frontend/src/lib/activeAccount.ts b/frontend/src/lib/activeAccount.ts new file mode 100644 index 00000000..e9813435 --- /dev/null +++ b/frontend/src/lib/activeAccount.ts @@ -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 mounts with NO account, then 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); +} diff --git a/frontend/src/lib/api/core.ts b/frontend/src/lib/api/core.ts index 815dae6c..9fa40cb2 100644 --- a/frontend/src/lib/api/core.ts +++ b/frontend/src/lib/api/core.ts @@ -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( diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index 0a9eab46..2fea4492 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -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 => { 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 diff --git a/frontend/src/lib/columnSort.test.ts b/frontend/src/lib/columnSort.test.ts index 4219c176..6263493c 100644 --- a/frontend/src/lib/columnSort.test.ts +++ b/frontend/src/lib/columnSort.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/lib/columnSort.ts b/frontend/src/lib/columnSort.ts index 7704685e..e9ae944f 100644 --- a/frontend/src/lib/columnSort.ts +++ b/frontend/src/lib/columnSort.ts @@ -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; + } +} diff --git a/frontend/src/lib/dateFilter.test.ts b/frontend/src/lib/dateFilter.test.ts new file mode 100644 index 00000000..b793dad8 --- /dev/null +++ b/frontend/src/lib/dateFilter.test.ts @@ -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 => ({ + 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 }); + }); +}); diff --git a/frontend/src/lib/dateFilter.ts b/frontend/src/lib/dateFilter.ts new file mode 100644 index 00000000..bbb806ff --- /dev/null +++ b/frontend/src/lib/dateFilter.ts @@ -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, + 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, + }, + }; +} diff --git a/frontend/src/lib/plexSort.ts b/frontend/src/lib/plexSort.ts new file mode 100644 index 00000000..4b8617a4 --- /dev/null +++ b/frontend/src/lib/plexSort.ts @@ -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.`). */ +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 { + return healSort( + sort, + plexSortKeys(scope).map((key) => ({ key, label: key })), + PLEX_SORT_FALLBACK, + )!; +} diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index a911a54a..46090461 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -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", diff --git a/frontend/src/lib/sortOptions.ts b/frontend/src/lib/sortOptions.ts new file mode 100644 index 00000000..7d079d05 --- /dev/null +++ b/frontend/src/lib/sortOptions.ts @@ -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` contract to the source-agnostic sort vocabulary the shared `SortControl` consumes. */ +export function optionsFromColumns(columns: Column[]): SortOption[] { + return columns + .filter((c) => c.sortable && c.sortValue) + .map((c) => ({ key: c.key, label: c.header })); +} diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 393e7462..5fdb2cac 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -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(() => 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( defaults: T, ): [T, (patch: Partial) => void] { const [value, setValue] = useState(() => readAccountMerged(base, defaults)); + const markDirty = useAccountRehydrate(() => setValue(readAccountMerged(base, defaults))); const patch = useCallback( (p: Partial) => { + markDirty(); setValue((prev) => { const next = { ...prev, ...p }; writeAccount(base, next); return next; }); }, - [base], + [base, markDirty], ); return [value, patch]; } diff --git a/frontend/src/lib/useAccountRehydrate.dom.test.ts b/frontend/src/lib/useAccountRehydrate.dom.test.ts new file mode 100644 index 00000000..8ba5cacd --- /dev/null +++ b/frontend/src/lib/useAccountRehydrate.dom.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/lib/useAccountRehydrate.ts b/frontend/src/lib/useAccountRehydrate.ts new file mode 100644 index 00000000..51f7cdac --- /dev/null +++ b/frontend/src/lib/useAccountRehydrate.ts @@ -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 mounts before the account is pinned, so its useState initializers seed from +// an unknown account (empty defaults); once 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; + }, []); +} diff --git a/frontend/src/lib/useFilterState.dom.test.ts b/frontend/src/lib/useFilterState.dom.test.ts new file mode 100644 index 00000000..c6d5655a --- /dev/null +++ b/frontend/src/lib/useFilterState.dom.test.ts @@ -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(), + 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; // 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" + }); +}); diff --git a/frontend/src/lib/useFilterState.ts b/frontend/src/lib/useFilterState.ts new file mode 100644 index 00000000..eea933a8 --- /dev/null +++ b/frontend/src/lib/useFilterState.ts @@ -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(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 {}; + } +} diff --git a/frontend/src/lib/useSortState.dom.test.ts b/frontend/src/lib/useSortState.dom.test.ts new file mode 100644 index 00000000..758e4249 --- /dev/null +++ b/frontend/src/lib/useSortState.dom.test.ts @@ -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(), + 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; // 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" + }); +}); diff --git a/frontend/src/lib/useSortState.ts b/frontend/src/lib/useSortState.ts new file mode 100644 index 00000000..b9ad1c66 --- /dev/null +++ b/frontend/src/lib/useSortState.ts @@ -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(() => + 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]; +}