release: 0.64.0 (r13-sort-filter-unification)
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the reusable ⋯ overflow menu. Labels/handlers are plain props (no i18n), so we assert
|
||||
// the open/select/close + disabled-item behaviour. The menu content only mounts while open, and the
|
||||
// AnchoredPopover platform is floating-ui based, so we stub the two observers autoUpdate wires and
|
||||
// wrap in a FloatingTree (its real-app parent) so nested-layer dispatch has a node to register.
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import ActionMenu, { type ActionMenuItem } from "./ActionMenu";
|
||||
|
||||
beforeAll(() => {
|
||||
// floating-ui's autoUpdate observes with ResizeObserver + IntersectionObserver (layoutShift); jsdom
|
||||
// ships neither, and the floating element mounts (so they run) the moment the menu opens.
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const renderMenu = (items: ActionMenuItem[]) =>
|
||||
render(
|
||||
<FloatingTree>
|
||||
<ActionMenu ariaLabel="Catalog actions" items={items} />
|
||||
</FloatingTree>,
|
||||
);
|
||||
|
||||
describe("ActionMenu", () => {
|
||||
it("renders a labelled trigger and keeps items unmounted until opened", () => {
|
||||
renderMenu([{ id: "sync", label: "Sync", onSelect: vi.fn() }]);
|
||||
expect(screen.getByRole("button", { name: "Catalog actions" })).toBeTruthy();
|
||||
expect(screen.queryByRole("menuitem")).toBeNull();
|
||||
});
|
||||
|
||||
// floating-ui never computes a position in jsdom, so the surface stays visibility:hidden; the items
|
||||
// are in the DOM but the a11y tree treats them as hidden — hence `{ hidden: true }` on role queries.
|
||||
it("opens on click and shows one menuitem per item", () => {
|
||||
renderMenu([
|
||||
{ id: "sync", label: "Sync", onSelect: vi.fn() },
|
||||
{ id: "backfill", label: "Backfill", onSelect: vi.fn() },
|
||||
]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
|
||||
expect(screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent)).toEqual([
|
||||
"Sync",
|
||||
"Backfill",
|
||||
]);
|
||||
});
|
||||
|
||||
it("selecting an item fires its onSelect and closes the menu", () => {
|
||||
const onSelect = vi.fn();
|
||||
renderMenu([{ id: "sync", label: "Sync", onSelect }]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
|
||||
fireEvent.click(screen.getByText("Sync"));
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("menuitem", { hidden: true })).toBeNull();
|
||||
});
|
||||
|
||||
it("a disabled item is inert — rendered disabled and never fires onSelect", () => {
|
||||
const onSelect = vi.fn();
|
||||
renderMenu([{ id: "sync", label: "Sync", onSelect, disabled: true }]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
|
||||
const item = screen.getByText("Sync").closest("button")!;
|
||||
expect((item as HTMLButtonElement).disabled).toBe(true);
|
||||
fireEvent.click(item);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips a disabled item in roving keyboard nav", () => {
|
||||
// items[0] disabled, items[1] + items[2] enabled. Roving keyboard focus (the `tabindex=0` item,
|
||||
// which tracks useListNavigation's activeIndex) must NEVER land on the disabled index-0 — arrowing
|
||||
// skips straight over it. The skip comes for free: PopoverItem renders a native `disabled` button
|
||||
// and useListNavigation reads that DOM state (no explicit disabledIndices needed). Without it,
|
||||
// ArrowUp/Down would strand roving focus on the inert index-0 button. (floating-ui wires this on
|
||||
// real key nav, not on open, in jsdom — so we drive it with arrow keys.)
|
||||
renderMenu([
|
||||
{ id: "sync", label: "Sync", onSelect: vi.fn(), disabled: true },
|
||||
{ id: "backfill", label: "Backfill", onSelect: vi.fn() },
|
||||
{ id: "reset", label: "Reset", onSelect: vi.fn() },
|
||||
]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
|
||||
const [disabled, backfill, reset] = screen.getAllByRole("menuitem", { hidden: true }) as [
|
||||
HTMLElement,
|
||||
HTMLElement,
|
||||
HTMLElement,
|
||||
];
|
||||
const menu = screen.getByRole("menu", { hidden: true });
|
||||
const rover = () =>
|
||||
[disabled, backfill, reset].find((el) => el.getAttribute("tabindex") === "0")?.textContent;
|
||||
|
||||
// The disabled item never becomes the roving target, whichever way we arrow.
|
||||
fireEvent.keyDown(menu, { key: "ArrowDown" }); // skips disabled[0] -> first enabled
|
||||
expect(rover()).toBe("Backfill");
|
||||
expect(disabled.getAttribute("tabindex")).toBe("-1");
|
||||
|
||||
fireEvent.keyDown(menu, { key: "ArrowDown" });
|
||||
expect(rover()).toBe("Reset");
|
||||
|
||||
fireEvent.keyDown(menu, { key: "ArrowUp" }); // back to Backfill
|
||||
expect(rover()).toBe("Backfill");
|
||||
fireEvent.keyDown(menu, { key: "ArrowUp" }); // wraps over disabled[0] to the last item
|
||||
expect(rover()).toBe("Reset");
|
||||
expect(disabled.getAttribute("tabindex")).toBe("-1"); // still never the disabled item
|
||||
});
|
||||
|
||||
it("honours a custom trigger node", () => {
|
||||
renderMenu([{ id: "sync", label: "Sync", onSelect: vi.fn() }]);
|
||||
cleanup();
|
||||
render(
|
||||
<FloatingTree>
|
||||
<ActionMenu
|
||||
ariaLabel="More"
|
||||
items={[{ id: "x", label: "X", onSelect: vi.fn() }]}
|
||||
trigger={<span data-testid="custom-trigger">•••</span>}
|
||||
/>
|
||||
</FloatingTree>,
|
||||
);
|
||||
expect(screen.getByTestId("custom-trigger")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
// A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions.
|
||||
// Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher
|
||||
// and LanguageSwitcher. Use it to keep occasional actions out of a crowded toolbar. The items are
|
||||
// fire-and-close (`onSelect`); a `disabled` item (e.g. a mutation already in flight) is inert.
|
||||
|
||||
export interface ActionMenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: ReactNode; // an element (so a caller can swap in a spinning icon while pending)
|
||||
onSelect: () => void;
|
||||
disabled?: boolean;
|
||||
title?: string; // optional native tooltip (carries a longer hint the label alone can't)
|
||||
}
|
||||
|
||||
export default function ActionMenu({
|
||||
items,
|
||||
ariaLabel,
|
||||
className,
|
||||
trigger,
|
||||
}: {
|
||||
items: readonly ActionMenuItem[];
|
||||
/** Accessible name for the trigger (e.g. "Catalog actions"). */
|
||||
ariaLabel: string;
|
||||
/** Trigger className override; omit for the default glass ⋯ icon button. */
|
||||
className?: string;
|
||||
/** Custom trigger content; omit for the default ⋯ icon. */
|
||||
trigger?: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
// A disabled item (a mutation already in flight) is skipped by roving keyboard nav automatically:
|
||||
// PopoverItem renders it as a native `disabled` button, which useListNavigation reads and never
|
||||
// focuses. No explicit disabledIndices needed. See ActionMenu.dom.test.tsx for the guard.
|
||||
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement: "bottom-end" });
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
className={
|
||||
className ??
|
||||
"glass-card glass-hover flex items-center justify-center rounded-xl p-2 text-sm transition"
|
||||
}
|
||||
>
|
||||
{trigger ?? <MoreHorizontal className="h-5 w-5" aria-hidden="true" />}
|
||||
</button>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={ariaLabel}
|
||||
className="glass-menu min-w-52 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{items.map((it) => (
|
||||
<PopoverItem
|
||||
key={it.id}
|
||||
role="menuitem"
|
||||
disabled={it.disabled}
|
||||
label={it.label}
|
||||
title={it.title}
|
||||
onSelect={() => {
|
||||
it.onSelect();
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm outline-hidden transition disabled:opacity-50"
|
||||
>
|
||||
{it.icon && <span className="shrink-0 text-muted">{it.icon}</span>}
|
||||
<span className="flex-1 truncate">{it.label}</span>
|
||||
</PopoverItem>
|
||||
))}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -175,6 +175,10 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
setHoverIndex(null);
|
||||
},
|
||||
selectedIndex: opts.selectedIndex ?? null,
|
||||
// Disabled items are skipped automatically: with no `disabledIndices`, useListNavigation reads
|
||||
// each item's DOM `disabled`/`aria-disabled` (PopoverItem always renders native `disabled`), so a
|
||||
// pending/inert item is never a roving target. (Verified against @floating-ui/react 0.27
|
||||
// `isListIndexDisabled`.) No explicit index list needed.
|
||||
loop: true,
|
||||
// Focus the first item on open for BOTH click and keyboard (the default 'auto' skips pointer-opens,
|
||||
// which strands focus on the menu container — arrows then can't enter the roving list at all).
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { UserPlus } from "lucide-react";
|
||||
import { api, type DiscoveredChannel } from "../lib/api";
|
||||
import { accountKey, LS, readAccount, writeAccount } from "../lib/storage";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { formatCountOrDash } from "../lib/format";
|
||||
import { parseSortState, sortRows, type SortState } from "../lib/columnSort";
|
||||
import { sortRows } from "../lib/columnSort";
|
||||
import { useSortState } from "../lib/useSortState";
|
||||
import { useCardPager } from "../lib/useCardPager";
|
||||
import { subsColumn } from "./channelColumns";
|
||||
import { notify } from "../lib/notifications";
|
||||
@@ -44,13 +45,7 @@ export default function ChannelDiscovery({
|
||||
// The table/cards density is shared with the Subscriptions tab (ChannelsProvider). The sort is
|
||||
// this tab's own (its columns differ), lifted out of the table so the card layout shares it.
|
||||
const layout = useChannelLayout();
|
||||
const [sort, setSortState] = useState<SortState>(() =>
|
||||
parseSortState(readAccount(LS.channelDiscoverySort, null)),
|
||||
);
|
||||
const setSort = (s: SortState) => {
|
||||
setSortState(s);
|
||||
writeAccount(LS.channelDiscoverySort, s);
|
||||
};
|
||||
const [sort, setSort] = useSortState(LS.channelDiscoverySort);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: qk.discoveredChannels(),
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
import Avatar from "./Avatar";
|
||||
import Tabs from "./Tabs";
|
||||
import Feed from "./Feed";
|
||||
import ChannelAboutContent from "./ChannelAbout";
|
||||
import { PageToolbar, PageToolbarSlot } from "./PageShell";
|
||||
@@ -265,11 +266,6 @@ export default function ChannelPage({
|
||||
: null,
|
||||
joined ? t("channel.joined", { date: joined }) : null,
|
||||
].filter(Boolean) as string[];
|
||||
const tabClass = (active: boolean) =>
|
||||
active
|
||||
? "pb-2 text-sm font-medium border-b-2 border-fg text-fg"
|
||||
: "pb-2 text-sm text-muted hover:text-fg border-b-2 border-transparent";
|
||||
|
||||
// The channel page owns no scroller of its own (PageShell provides the one app scroller). Its
|
||||
// whole chrome is fixed — wrapped in <PageToolbar> it portals into the shell's fixed band, so the
|
||||
// banner/identity/tabs/toolbar stay put and only the video grid scrolls.
|
||||
@@ -370,14 +366,17 @@ export default function ChannelPage({
|
||||
{/* Tabs — the channel actions ride along here rather than off in the header's top-right
|
||||
corner: there are only ever two tabs, so there's room, and it keeps the things you click
|
||||
within a few pixels of each other instead of across the page. */}
|
||||
<div className="flex items-center gap-4 mt-3 border-b border-border">
|
||||
<button onClick={() => setTab("videos")} className={tabClass(tab === "videos")}>
|
||||
{t("channel.tabVideos")}
|
||||
</button>
|
||||
<button onClick={() => setTab("about")} className={tabClass(tab === "about")}>
|
||||
{t("channel.tabAbout")}
|
||||
</button>
|
||||
<div className="ml-2 flex items-center gap-2 pb-1.5">
|
||||
<div className="flex items-center gap-2 mt-3 border-b border-border pb-2">
|
||||
<Tabs
|
||||
className="flex gap-1"
|
||||
active={tab}
|
||||
onChange={(id) => setTab(id as "videos" | "about")}
|
||||
tabs={[
|
||||
{ id: "videos", label: t("channel.tabVideos") },
|
||||
{ id: "about", label: t("channel.tabAbout") },
|
||||
]}
|
||||
/>
|
||||
<div className="ml-2 flex items-center gap-2">
|
||||
<a
|
||||
href={ytUrl}
|
||||
target="_blank"
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the catalog-stats hover chip: the compact trigger, the rows it reveals on open (value
|
||||
// formatting + the conditional "left" row), and — the point of the R13-S3 review fix — that each row
|
||||
// still carries its per-stat explanation as a `title`, so the guidance the old inline stats showed is
|
||||
// not lost. i18n is mocked to a key-passthrough so assertions read the stable keys, not translations.
|
||||
// The AnchoredPopover platform is floating-ui based (hover mode), so we stub the observers autoUpdate
|
||||
// wires and wrap in a FloatingTree, and open via focus (hover mode enables useFocus).
|
||||
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
|
||||
// Key-passthrough t so assertions read stable keys — but keep the rest of react-i18next intact
|
||||
// (i18n/index.ts calls i18n.use(initReactI18next) at import time).
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
import ChannelStatsPopover from "./ChannelStatsPopover";
|
||||
import { type MyStatus } from "../lib/api";
|
||||
import { formatEta } from "../lib/format";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const status = (over: Partial<MyStatus> = {}): MyStatus =>
|
||||
({
|
||||
channels_total: 10,
|
||||
channels_recent_synced: 3,
|
||||
channels_deep_done: 1,
|
||||
channels_deep_requested: 4,
|
||||
deep_pending_count: 0,
|
||||
deep_eta_seconds: 120,
|
||||
my_videos: 1234,
|
||||
quota_remaining_today: 5000,
|
||||
...over,
|
||||
}) as MyStatus;
|
||||
|
||||
const open = (s: MyStatus) => {
|
||||
render(
|
||||
<FloatingTree>
|
||||
<ChannelStatsPopover status={s} />
|
||||
</FloatingTree>,
|
||||
);
|
||||
fireEvent.focus(screen.getByRole("button", { name: "channels.stats.label" }));
|
||||
};
|
||||
|
||||
describe("ChannelStatsPopover", () => {
|
||||
it("renders a compact Stats trigger, panel closed until opened", () => {
|
||||
render(
|
||||
<FloatingTree>
|
||||
<ChannelStatsPopover status={status()} />
|
||||
</FloatingTree>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "channels.stats.label" })).toBeTruthy();
|
||||
expect(screen.queryByText("channels.stats.channels")).toBeNull();
|
||||
});
|
||||
|
||||
it("reveals one row per stat with formatted values", () => {
|
||||
open(status());
|
||||
expect(screen.getByText("channels.stats.recentSynced")).toBeTruthy();
|
||||
expect(screen.getByText("3/10")).toBeTruthy(); // recent-synced ratio
|
||||
expect(screen.getByText("1/4")).toBeTruthy(); // full-history ratio
|
||||
expect(screen.getByText((1234).toLocaleString())).toBeTruthy(); // my_videos, locale-agnostic
|
||||
});
|
||||
|
||||
it("shows the 'left' ETA row only while a backfill is pending, with the formatted ETA value", () => {
|
||||
open(status({ deep_pending_count: 0 }));
|
||||
expect(screen.queryByText("channels.stats.left")).toBeNull();
|
||||
cleanup();
|
||||
open(status({ deep_pending_count: 2, deep_eta_seconds: 120 }));
|
||||
expect(screen.getByText("channels.stats.left")).toBeTruthy();
|
||||
// The value is the FORMATTED ETA (via formatEta), not raw seconds — asserting it guards the
|
||||
// formatEta wiring, which is untested anywhere else. Using the same formatEta keeps this
|
||||
// locale/impl-agnostic (expected === what the component renders).
|
||||
expect(screen.getByText(formatEta(120))).toBeTruthy();
|
||||
expect(screen.queryByText("120")).toBeNull(); // never the raw seconds
|
||||
});
|
||||
|
||||
it("keeps each stat's explanation as a per-row title (hint not lost)", () => {
|
||||
open(status());
|
||||
const row = screen.getByText("channels.stats.channels").closest("div");
|
||||
expect(row?.getAttribute("title")).toBe("channels.stats.channelsHint");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
Clock,
|
||||
Film,
|
||||
History,
|
||||
RefreshCw,
|
||||
Users,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type MyStatus } from "../lib/api";
|
||||
import { formatEta } from "../lib/format";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
// The channel-manager catalog stats, collapsed from a crowded inline row into a compact "Stats" chip
|
||||
// that reveals the numbers on hover (reuses the AnchoredPopover hover mode). One row per stat, each
|
||||
// with a leading icon. Kept in the manager's own toolbar (the header's SyncStatus is global; these
|
||||
// are channel-catalog figures).
|
||||
export default function ChannelStatsPopover({ status }: { status: MyStatus }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "hover",
|
||||
placement: "bottom-end",
|
||||
offset: 8,
|
||||
});
|
||||
|
||||
// Each row keeps the per-stat explanation the old inline stats carried (as a native `title` on the
|
||||
// row), so hovering a figure still tells the user what it counts — the guidance is not lost.
|
||||
const rows: { icon: LucideIcon; label: string; value: string | number; hint: string }[] = [
|
||||
{
|
||||
icon: Users,
|
||||
label: t("channels.stats.channels"),
|
||||
value: status.channels_total,
|
||||
hint: t("channels.stats.channelsHint"),
|
||||
},
|
||||
{
|
||||
icon: RefreshCw,
|
||||
label: t("channels.stats.recentSynced"),
|
||||
value: `${status.channels_recent_synced}/${status.channels_total}`,
|
||||
hint: t("channels.stats.recentSyncedHint"),
|
||||
},
|
||||
{
|
||||
icon: History,
|
||||
label: t("channels.stats.fullHistory"),
|
||||
value: `${status.channels_deep_done}/${status.channels_deep_requested}`,
|
||||
hint: t("channels.stats.fullHistoryHint"),
|
||||
},
|
||||
...(status.deep_pending_count > 0
|
||||
? [
|
||||
{
|
||||
icon: Clock,
|
||||
label: t("channels.stats.left"),
|
||||
value: formatEta(status.deep_eta_seconds),
|
||||
hint: t("channels.stats.leftHint", { count: status.deep_pending_count }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: Film,
|
||||
label: t("channels.stats.myVideos"),
|
||||
value: status.my_videos.toLocaleString(),
|
||||
hint: t("channels.stats.myVideosHint"),
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
label: t("channels.stats.quotaLeft"),
|
||||
value: status.quota_remaining_today.toLocaleString(),
|
||||
hint: t("channels.stats.quotaLeftHint"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-muted transition"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" aria-hidden="true" />
|
||||
{t("channels.stats.label")}
|
||||
</button>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("channels.stats.label")}
|
||||
className="glass-menu w-60 rounded-xl p-2 text-xs animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{rows.map((r) => (
|
||||
<div
|
||||
key={r.label}
|
||||
title={r.hint}
|
||||
className="flex cursor-help items-center gap-2.5 px-1.5 py-1.5"
|
||||
>
|
||||
<r.icon className="h-4 w-4 shrink-0 text-muted" aria-hidden="true" />
|
||||
<span className="flex-1 text-muted">{r.label}</span>
|
||||
<span className="tabular-nums font-medium text-fg">{r.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,8 @@ interface TagChipProps {
|
||||
}
|
||||
|
||||
function TagChip({ tag, onClick, title }: TagChipProps) {
|
||||
const cls = "chip-solid shrink-0 text-3xs px-1.5 py-0.5 rounded-full max-w-36 truncate";
|
||||
const cls =
|
||||
"chip-solid shrink-0 text-2xs font-medium px-1.5 py-0.5 rounded-full max-w-36 truncate";
|
||||
return onClick ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { Ban, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react";
|
||||
import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react";
|
||||
import ExcludedChannelList from "./ExcludedChannelList";
|
||||
import { api, type ManagedChannel } from "../lib/api";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { formatEta } from "../lib/format";
|
||||
import { buildChannelColumns } from "./channelColumns";
|
||||
import { notify } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
import DataTable from "./DataTable";
|
||||
import Tabs, { type TabDef } from "./Tabs";
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
||||
import { useChannelPriority } from "../lib/useChannelPriority";
|
||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||
import ColumnSortControl from "./ColumnSortControl";
|
||||
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
||||
import ActionMenu, { type ActionMenuItem } from "./ActionMenu";
|
||||
import ChannelStatsPopover from "./ChannelStatsPopover";
|
||||
import Pager from "./Pager";
|
||||
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
|
||||
import { sortRows } from "../lib/columnSort";
|
||||
import { filterRows } from "./tableHeader";
|
||||
import { useCardPager } from "../lib/useCardPager";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import ChannelDiscovery from "./ChannelDiscovery";
|
||||
@@ -31,15 +36,16 @@ import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
import { useWizard } from "./WizardProvider";
|
||||
import { useMe } from "../lib/useMe";
|
||||
|
||||
export type ChannelsView = "subscribed" | "discovery" | "blocked";
|
||||
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
|
||||
|
||||
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
|
||||
|
||||
const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
|
||||
{ id: "all", labelKey: "channels.filters.all" },
|
||||
{ id: "needs_full", labelKey: "channels.filters.needsFull" },
|
||||
{ id: "fully_synced", labelKey: "channels.filters.fullySynced" },
|
||||
{ id: "hidden", labelKey: "channels.filters.hidden" },
|
||||
// The top sync chips are a controlled view over the subscribed table's `sync` column filter (shared
|
||||
// via ChannelsProvider): each chip writes `filters.sync`, using the SAME vocab + predicate as the
|
||||
// Sync column funnel (channelColumns.tsx) so the two can't drift. "" = all (isActive treats it as
|
||||
// off). fullySynced = recent_synced && backfill_done (matches the cell's own "fully synced" badge).
|
||||
const SYNC_CHIPS: { value: string; labelKey: string }[] = [
|
||||
{ value: "", labelKey: "channels.filters.all" },
|
||||
{ value: "needsFull", labelKey: "channels.filters.needsFull" },
|
||||
{ value: "fully", labelKey: "channels.filters.fullySynced" },
|
||||
];
|
||||
|
||||
const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
|
||||
@@ -58,7 +64,7 @@ export default function Channels() {
|
||||
// callers); this page is their primary editor.
|
||||
const {
|
||||
search,
|
||||
statusFilter,
|
||||
filters,
|
||||
view,
|
||||
sort,
|
||||
filtersResetToken,
|
||||
@@ -68,7 +74,7 @@ export default function Channels() {
|
||||
const layout = useChannelLayout();
|
||||
const {
|
||||
setSearch,
|
||||
setStatusFilter,
|
||||
setFilters,
|
||||
setView,
|
||||
setLayout,
|
||||
setSort,
|
||||
@@ -206,41 +212,41 @@ export default function Channels() {
|
||||
notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard),
|
||||
});
|
||||
|
||||
// Channel-name search (from the shared top SearchBar, via props) + tag-chip filtering, applied
|
||||
// client-side over the status-filtered list.
|
||||
const [tagFilter, setTagFilter] = useState<number[]>([]);
|
||||
// The tag-chip row is a controlled view over the Tags COLUMN filter (`filters.tags`, a string[] of
|
||||
// tag ids), the same way the sync chips drive `filters.sync` — so the top chips and the Tags column
|
||||
// funnel share ONE state and stay in sync (bidirectional). Toggling a chip rewrites `filters.tags`;
|
||||
// filterRows / DataTable then applies it. Channel-name search stays separate (the shared top box).
|
||||
const tagFilter = Array.isArray(filters.tags) ? filters.tags : [];
|
||||
const toggleTagFilter = (tagId: number) => {
|
||||
const id = String(tagId);
|
||||
const next = tagFilter.includes(id) ? tagFilter.filter((x) => x !== id) : [...tagFilter, id];
|
||||
setFilters({ ...filters, tags: next });
|
||||
};
|
||||
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
|
||||
useEffect(() => {
|
||||
if (focusChannelName) setSearch(focusChannelName);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [focusChannelToken]);
|
||||
// The header's reset intent clears the search + tag chips (skip the initial mount).
|
||||
// The header's reset intent clears the search box (skip the initial mount); the tag chips ride in
|
||||
// `filters.tags`, which goToFullHistory already clears by setting a fresh map.
|
||||
const resetRef = useRef(filtersResetToken);
|
||||
useEffect(() => {
|
||||
if (filtersResetToken === resetRef.current) return;
|
||||
resetRef.current = filtersResetToken;
|
||||
setSearch("");
|
||||
setTagFilter([]);
|
||||
}, [filtersResetToken]);
|
||||
|
||||
// The Sync-status filter stays a top-level chip set (not a column filter) because the
|
||||
// header's "go to full history" deep-link drives it; search/tag filtering + sort + pagination
|
||||
// run over whatever the status chip leaves.
|
||||
const channels = (channelsQuery.data ?? []).filter((c) =>
|
||||
statusFilter === "needs_full"
|
||||
? !c.backfill_done
|
||||
: statusFilter === "fully_synced"
|
||||
? c.backfill_done
|
||||
: statusFilter === "hidden"
|
||||
? c.hidden
|
||||
: true,
|
||||
);
|
||||
// The sync-status AND tag filters are now controlled column filters (`filters.sync` / `filters.tags`)
|
||||
// shared by the top chips, the column funnels, and the card layout — no separate pre-filter. Hidden
|
||||
// channels have their own tab, so they're excluded from the subscribed table (no more dimmed rows).
|
||||
// Only the name search filters here; the column filters (sync/tags/priority/text funnels) are applied
|
||||
// by DataTable in table mode and by filterRows below in card mode, over the same `columns`.
|
||||
const hiddenChannels = (channelsQuery.data ?? []).filter((c) => c.hidden);
|
||||
const channels = (channelsQuery.data ?? []).filter((c) => !c.hidden);
|
||||
const q = search.trim().toLowerCase();
|
||||
const visibleChannels = channels.filter((c) => {
|
||||
if (q && !`${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q)) return false;
|
||||
if (tagFilter.length && !tagFilter.some((id) => c.tag_ids.includes(id))) return false;
|
||||
return true;
|
||||
});
|
||||
const visibleChannels = channels.filter(
|
||||
(c) => !q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q),
|
||||
);
|
||||
const s = statusQuery.data;
|
||||
|
||||
const onView = (c: ManagedChannel) =>
|
||||
@@ -288,88 +294,43 @@ export default function Channels() {
|
||||
|
||||
// Status chips sit in the table's controls row (next to paging) — compact and close to
|
||||
// where they act.
|
||||
const syncFilter = typeof filters.sync === "string" ? filters.sync : "";
|
||||
const statusChips = (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{STATUS_FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => setStatusFilter(f.id)}
|
||||
aria-pressed={statusFilter === f.id}
|
||||
className="pill text-xs px-2.5 py-1 rounded-full border"
|
||||
>
|
||||
{t(f.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<FilterChipGroup
|
||||
ariaLabel={t("channels.filters.syncLabel")}
|
||||
className="flex flex-wrap items-center gap-1.5"
|
||||
options={SYNC_CHIPS.map((c) => ({ value: c.value, label: t(c.labelKey) }))}
|
||||
value={syncFilter}
|
||||
onChange={(sync) => setFilters({ ...filters, sync })}
|
||||
/>
|
||||
);
|
||||
|
||||
// The catalog-wide stats ride on the right of the tab row (subscribed only), leaving room for
|
||||
// future tabs on the left.
|
||||
const statsBlock = (
|
||||
<div className="flex flex-wrap items-center justify-end gap-x-5 gap-y-1 text-sm text-muted">
|
||||
{s && (
|
||||
<>
|
||||
<Stat
|
||||
label={t("channels.stats.channels")}
|
||||
value={s.channels_total}
|
||||
hint={t("channels.stats.channelsHint")}
|
||||
/>
|
||||
<Stat
|
||||
label={t("channels.stats.recentSynced")}
|
||||
value={`${s.channels_recent_synced}/${s.channels_total}`}
|
||||
hint={t("channels.stats.recentSyncedHint")}
|
||||
/>
|
||||
<Stat
|
||||
label={t("channels.stats.fullHistory")}
|
||||
value={`${s.channels_deep_done}/${s.channels_deep_requested}`}
|
||||
hint={t("channels.stats.fullHistoryHint")}
|
||||
/>
|
||||
{s.deep_pending_count > 0 && (
|
||||
<Stat
|
||||
label={t("channels.stats.left")}
|
||||
value={formatEta(s.deep_eta_seconds)}
|
||||
hint={t("channels.stats.leftHint", { count: s.deep_pending_count })}
|
||||
/>
|
||||
)}
|
||||
<Stat
|
||||
label={t("channels.stats.myVideos")}
|
||||
value={s.my_videos.toLocaleString()}
|
||||
hint={t("channels.stats.myVideosHint")}
|
||||
/>
|
||||
<Stat
|
||||
label={t("channels.stats.quotaLeft")}
|
||||
value={s.quota_remaining_today.toLocaleString()}
|
||||
hint={t("channels.stats.quotaLeftHint")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
// The catalog-wide stats collapse into a compact "Stats" hover chip (right of the tab row, subscribed
|
||||
// only) instead of a crowded inline row.
|
||||
const statsBlock = s ? <ChannelStatsPopover status={s} /> : null;
|
||||
|
||||
// Sync + backfill — now share the tags row (right side).
|
||||
// Catalog-wide, occasional actions — tucked into a shared ⋯ overflow menu so they stop crowding the
|
||||
// tags row. Pending mutations keep the item visible but disabled, with a spinning icon.
|
||||
const catalogActions: ActionMenuItem[] = [
|
||||
{
|
||||
id: "sync",
|
||||
label: t("channels.syncSubscriptions"),
|
||||
title: t("channels.syncSubscriptionsHint"),
|
||||
icon: <RefreshCw className={`h-4 w-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />,
|
||||
onSelect: () => syncSubs.mutate(),
|
||||
disabled: syncSubs.isPending,
|
||||
},
|
||||
{
|
||||
id: "backfill",
|
||||
label: t("channels.backfillEverything"),
|
||||
title: t("channels.backfillEverythingHint"),
|
||||
icon: <History className={`h-4 w-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />,
|
||||
onSelect: () => deepAll.mutate(),
|
||||
disabled: deepAll.isPending,
|
||||
},
|
||||
];
|
||||
const actionButtons = (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Tooltip side="bottom" hint={t("channels.syncSubscriptionsHint")}>
|
||||
<button
|
||||
onClick={() => syncSubs.mutate()}
|
||||
disabled={syncSubs.isPending}
|
||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
|
||||
{t("channels.syncSubscriptions")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip side="bottom" hint={t("channels.backfillEverythingHint")}>
|
||||
<button
|
||||
onClick={() => deepAll.mutate()}
|
||||
disabled={deepAll.isPending}
|
||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
|
||||
{t("channels.backfillEverything")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<ActionMenu ariaLabel={t("channels.actionsLabel")} items={catalogActions} />
|
||||
);
|
||||
|
||||
// Table / cards density — shared by the subscribed + discovery tables (blocked is a chip list, no
|
||||
@@ -407,7 +368,11 @@ export default function Channels() {
|
||||
// Only computed for cards: in table mode DataTable sorts and slices internally, so doing it here
|
||||
// too would copy+sort the whole ~300-row array on every render for a result nothing reads.
|
||||
const isCards = layout === "cards";
|
||||
const sortedChannels = isCards ? sortRows(visibleChannels, columns, sort) : visibleChannels;
|
||||
// Cards have no column headers, so apply the shared column filters (sync + any funnel set in table
|
||||
// mode) here via the exported predicate — the same filterRows DataTable runs internally — then sort.
|
||||
const sortedChannels = isCards
|
||||
? sortRows(filterRows(visibleChannels, columns, filters), columns, sort)
|
||||
: visibleChannels;
|
||||
const {
|
||||
paged: pagedCards,
|
||||
pagerProps,
|
||||
@@ -420,23 +385,25 @@ export default function Channels() {
|
||||
// handled by the hook's clamp instead.
|
||||
useEffect(() => {
|
||||
resetPage();
|
||||
}, [statusFilter, search, tagFilter, sort, resetPage]);
|
||||
}, [filters, search, sort, resetPage]);
|
||||
const cardPager = <Pager {...pagerProps} />;
|
||||
|
||||
const viewTabs: TabDef[] = useMemo(
|
||||
() =>
|
||||
(["subscribed", "discovery", "hidden", "blocked"] as ChannelsView[]).map((v) => ({
|
||||
id: v,
|
||||
label: t(`channels.tabs.${v}`),
|
||||
})),
|
||||
[t],
|
||||
);
|
||||
const tabs = (
|
||||
<div className="px-4 pt-4 max-w-384 mx-auto flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{(["subscribed", "discovery", "blocked"] as ChannelsView[]).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setView(v)}
|
||||
aria-pressed={view === v}
|
||||
className="pill text-sm px-3 py-1.5 rounded-full border"
|
||||
>
|
||||
{t(`channels.tabs.${v}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-4 pt-2 max-w-384 mx-auto flex items-center justify-between gap-4 flex-wrap">
|
||||
<Tabs
|
||||
tabs={viewTabs}
|
||||
active={view}
|
||||
onChange={(id) => setView(id as ChannelsView)}
|
||||
className="flex flex-wrap items-center gap-1"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
{view === "subscribed" && statsBlock}
|
||||
{view === "subscribed" && layout === "cards" && sortControl}
|
||||
@@ -458,41 +425,35 @@ export default function Channels() {
|
||||
search lives in the shared top SearchBar, tag create/delete in the manager. */}
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate
|
||||
"Manage tags" button; the tooltip explains what tags are + that it's clickable. */}
|
||||
<Tooltip hint={t("channels.tags.yourTagsHint")}>
|
||||
<span className="text-xs uppercase tracking-wide text-muted mr-1 cursor-help underline decoration-dotted decoration-muted/40 underline-offset-4">
|
||||
{t("channels.tags.yourTags")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setTagManagerOpen(true)}
|
||||
className="mr-1 inline-flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-xs uppercase tracking-wide text-muted transition hover:border-accent hover:text-accent"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
{t("channels.tags.label")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{userTags.map((tg) => {
|
||||
const active = tagFilter.includes(tg.id);
|
||||
return (
|
||||
<button
|
||||
key={tg.id}
|
||||
onClick={() =>
|
||||
setTagFilter((f) => (active ? f.filter((x) => x !== tg.id) : [...f, tg.id]))
|
||||
}
|
||||
aria-pressed={active}
|
||||
className="pill text-xs px-2 py-1 rounded-full border"
|
||||
>
|
||||
{tg.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{userTags.map((tg) => (
|
||||
<button
|
||||
key={tg.id}
|
||||
onClick={() => toggleTagFilter(tg.id)}
|
||||
aria-pressed={tagFilter.includes(String(tg.id))}
|
||||
className={filterChipClass}
|
||||
>
|
||||
{tg.name}
|
||||
</button>
|
||||
))}
|
||||
{tagFilter.length > 0 && (
|
||||
<button
|
||||
onClick={() => setTagFilter([])}
|
||||
onClick={() => setFilters({ ...filters, tags: [] })}
|
||||
className="text-xs px-2 py-1 text-muted hover:text-accent transition"
|
||||
>
|
||||
{t("datatable.clear")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setTagManagerOpen(true)}
|
||||
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
{t("channels.tags.manage")}
|
||||
</button>
|
||||
</div>
|
||||
{actionButtons}
|
||||
</div>
|
||||
@@ -521,37 +482,33 @@ export default function Channels() {
|
||||
) : view === "blocked" ? (
|
||||
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
|
||||
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
|
||||
{(blockedQuery.data?.length ?? 0) === 0 ? (
|
||||
<p className="text-sm text-muted py-8">{t("channels.blocked.empty")}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Ban className="w-4 h-4 text-muted" />
|
||||
<span className="text-xs uppercase tracking-wide text-muted">
|
||||
{t("channels.blocked.title")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{blockedQuery.data!.map((c) => (
|
||||
<span
|
||||
key={c.id}
|
||||
className="inline-flex items-center gap-1.5 text-xs pl-2.5 pr-1 py-1 rounded-full bg-card border border-border"
|
||||
>
|
||||
{c.title ?? c.id}
|
||||
<button
|
||||
onClick={() => unblock.mutate(c.id)}
|
||||
title={t("channel.unblock")}
|
||||
aria-label={t("channel.unblock")}
|
||||
className="rounded-full p-0.5 text-muted hover:text-danger transition"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-2xs text-muted mt-2">{t("channels.blocked.hint")}</p>
|
||||
</>
|
||||
)}
|
||||
<ExcludedChannelList
|
||||
channels={blockedQuery.data ?? []}
|
||||
icon={<Ban className="w-4 h-4 text-muted" />}
|
||||
title={t("channels.blocked.title")}
|
||||
hint={t("channels.blocked.hint")}
|
||||
emptyText={t("channels.blocked.empty")}
|
||||
actionLabel={t("channel.unblock")}
|
||||
actionIcon={<X className="w-3.5 h-3.5" />}
|
||||
onAction={(id) => unblock.mutate(id)}
|
||||
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
|
||||
/>
|
||||
</div>
|
||||
) : view === "hidden" ? (
|
||||
/* Hidden channels — kept out of the Mine feed but still in the shared Library; managed here
|
||||
instead of dimmed in the subscribed table. Same card grid as Blocked (ExcludedChannelList). */
|
||||
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
|
||||
<ExcludedChannelList
|
||||
channels={hiddenChannels}
|
||||
icon={<EyeOff className="w-4 h-4 text-muted" />}
|
||||
title={t("channels.hidden.title")}
|
||||
hint={t("channels.hidden.hint")}
|
||||
emptyText={t("channels.hidden.empty")}
|
||||
actionLabel={t("channels.hidden.unhide")}
|
||||
actionIcon={<Eye className="w-3.5 h-3.5" />}
|
||||
onAction={(id) => patch.mutate({ id, body: { hidden: false } })}
|
||||
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when
|
||||
@@ -580,7 +537,8 @@ export default function Channels() {
|
||||
controlsBandClassName="px-4 max-w-384 mx-auto"
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
emptyText={t("channels.empty")}
|
||||
/>
|
||||
) : (
|
||||
@@ -591,7 +549,6 @@ export default function Channels() {
|
||||
leadKey="priority"
|
||||
aboutKey="about"
|
||||
actionsKey="actions"
|
||||
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
||||
emptyText={t("channels.empty")}
|
||||
/>
|
||||
)}
|
||||
@@ -600,13 +557,3 @@ export default function Channels() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, hint }: { label: string; value: string | number; hint?: string }) {
|
||||
return (
|
||||
<Tooltip hint={hint ?? ""}>
|
||||
<span className={hint ? "cursor-help" : ""}>
|
||||
<span className="text-fg font-semibold">{value}</span> {label}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// ChannelsProvider's tab-history behaviour: setView pushes a `_chTab` history entry + persists the tab,
|
||||
// and a genuine browser Back (popstate carrying `_chTab`) restores + re-persists it. This wiring was
|
||||
// re-routed through usePersistedTab in R13 S2b, so it's exercised here rather than only by hand E2E.
|
||||
//
|
||||
// Uses REAL storage (jsdom localStorage/sessionStorage) with the tab's account pinned to 1, so
|
||||
// usePersistedTab / useSortState / useFilterState all resolve their account-scoped keys; only
|
||||
// NavigationProvider (the sole runtime dependency) is mocked, pinned to the channels page.
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
vi.mock("./NavigationProvider", () => ({
|
||||
useNavigation: () => ({ page: "channels" }),
|
||||
useNavigationActions: () => ({ setPage: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { ChannelsProvider, useChannels, useChannelsActions } from "./ChannelsProvider";
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ChannelsProvider>{children}</ChannelsProvider>
|
||||
);
|
||||
const render = () => renderHook(() => ({ ...useChannels(), ...useChannelsActions() }), { wrapper });
|
||||
const VIEW_KEY = "siftlode.channelsView.1"; // account-scoped (accountKey suffixes the pinned id)
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.setItem("siftlode.activeAccount", "1"); // pin the tab's account
|
||||
window.history.replaceState({ sfPage: "channels" }, ""); // setView only owns an entry on this page
|
||||
});
|
||||
|
||||
describe("ChannelsProvider tab history", () => {
|
||||
it("setView pushes a _chTab history entry and persists the tab", () => {
|
||||
const { result } = render();
|
||||
expect(result.current.view).toBe("subscribed"); // default
|
||||
|
||||
act(() => result.current.setView("hidden"));
|
||||
|
||||
expect(result.current.view).toBe("hidden");
|
||||
expect(window.history.state).toMatchObject({ sfPage: "channels", _chTab: "hidden" });
|
||||
expect(localStorage.getItem(VIEW_KEY)).toBe("hidden");
|
||||
});
|
||||
|
||||
it("does NOT push a new entry when re-selecting the current tab", () => {
|
||||
const { result } = render();
|
||||
const spy = vi.spyOn(window.history, "pushState");
|
||||
act(() => result.current.setView("subscribed")); // already the current tab
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("a genuine Back (popstate carrying _chTab) restores and re-persists that tab", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.setView("blocked"));
|
||||
expect(result.current.view).toBe("blocked");
|
||||
|
||||
// Browser Back to an entry stamped with a different tab.
|
||||
act(() =>
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { _chTab: "discovery" } })),
|
||||
);
|
||||
|
||||
expect(result.current.view).toBe("discovery");
|
||||
expect(localStorage.getItem(VIEW_KEY)).toBe("discovery");
|
||||
});
|
||||
|
||||
it("ignores a popstate for an entry outside the manager (no _chTab)", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.setView("hidden"));
|
||||
act(() => window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "feed" } })));
|
||||
expect(result.current.view).toBe("hidden"); // unchanged
|
||||
});
|
||||
});
|
||||
@@ -8,41 +8,47 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { getAccountRaw, LS, readAccount, setAccountRaw, writeAccount } from "../lib/storage";
|
||||
import { getAccountRaw, LS } from "../lib/storage";
|
||||
import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout";
|
||||
import { parseSortState, type SortState } from "../lib/columnSort";
|
||||
import { type SortState } from "../lib/columnSort";
|
||||
import { useSortState } from "../lib/useSortState";
|
||||
import { useFilterState } from "../lib/useFilterState";
|
||||
import { usePersistedTab } from "./Tabs";
|
||||
import type { FilterMap } from "./tableHeader";
|
||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||
import type { ChannelStatusFilter, ChannelsView } from "./Channels";
|
||||
import type { ChannelsView } from "./Channels";
|
||||
|
||||
// Owns the Channels module's shared local state — the rail-less manager's name filter, status chip,
|
||||
// active tab, the "drop a stale column filter" token, and the focus-a-channel intent — lifted out of
|
||||
// Owns the Channels module's shared local state — the rail-less manager's name filter, column-filter
|
||||
// map, active tab, the "drop a stale column filter" token, and the focus-a-channel intent — lifted out of
|
||||
// App so the Channels page, the header search box, and the cross-module callers (the feed filter
|
||||
// sidebar and the notifications inbox both "focus this channel in the manager") read it via hooks
|
||||
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
|
||||
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
|
||||
//
|
||||
// Nested INSIDE NavigationProvider so its actions can drive navigation: focusChannel and
|
||||
// goToFullHistory both jump to the Channels page (and force the subscriptions tab). status / view are
|
||||
// goToFullHistory both jump to the Channels page (and force the subscriptions tab). filters / view are
|
||||
// per-account persisted (localStorage); search / focus / the reset token are ephemeral. An account
|
||||
// switch does a full reload, so init-from-storage is all the hydration the persisted ones need.
|
||||
|
||||
const STATUS_FILTERS: readonly ChannelStatusFilter[] = [
|
||||
"needs_full",
|
||||
"fully_synced",
|
||||
"hidden",
|
||||
"all",
|
||||
];
|
||||
|
||||
function clampStatus(v: string | null): ChannelStatusFilter {
|
||||
return STATUS_FILTERS.includes(v as ChannelStatusFilter) ? (v as ChannelStatusFilter) : "all";
|
||||
}
|
||||
function clampView(v: string | null): ChannelsView {
|
||||
return v === "discovery" ? "discovery" : v === "blocked" ? "blocked" : "subscribed";
|
||||
return v === "discovery" || v === "blocked" || v === "hidden" ? v : "subscribed";
|
||||
}
|
||||
|
||||
// One-time carry-forward of the retired single-value sync chip (LS.legacyChannelFilter, vocab
|
||||
// all/needs_full/fully_synced) into the new filter map's `sync` key (vocab ""/needsFull/fully), so a
|
||||
// user who had a chip pinned keeps it. Only consulted when channelsFilterMap is still empty.
|
||||
function migrateSyncChip(): FilterMap | null {
|
||||
const old = getAccountRaw(LS.legacyChannelFilter);
|
||||
if (old === "needs_full") return { sync: "needsFull" };
|
||||
if (old === "fully_synced") return { sync: "fully" };
|
||||
return null;
|
||||
}
|
||||
|
||||
type ChannelsState = {
|
||||
search: string;
|
||||
statusFilter: ChannelStatusFilter;
|
||||
// The subscribed table's column filters, lifted out of DataTable so the top status chips, the Sync
|
||||
// column funnel, and the card layout share ONE filter state (the sync chip lives at `filters.sync`).
|
||||
filters: FilterMap;
|
||||
view: ChannelsView;
|
||||
// The subscribed table's sort, lifted out of DataTable so the card layout shares it.
|
||||
sort: SortState;
|
||||
@@ -57,7 +63,7 @@ type ChannelsState = {
|
||||
|
||||
interface ChannelsActions {
|
||||
setSearch: (q: string) => void;
|
||||
setStatusFilter: (f: ChannelStatusFilter) => void;
|
||||
setFilters: (f: FilterMap) => void;
|
||||
setView: (v: ChannelsView) => void;
|
||||
setLayout: (l: ChannelLayout) => void;
|
||||
setSort: (s: SortState) => void;
|
||||
@@ -70,7 +76,7 @@ interface ChannelsActions {
|
||||
|
||||
const StateContext = createContext<ChannelsState>({
|
||||
search: "",
|
||||
statusFilter: "all",
|
||||
filters: {},
|
||||
view: "subscribed",
|
||||
sort: null,
|
||||
filtersResetToken: 0,
|
||||
@@ -83,7 +89,7 @@ const StateContext = createContext<ChannelsState>({
|
||||
const LayoutContext = createContext<ChannelLayout>("table");
|
||||
const ActionsContext = createContext<ChannelsActions>({
|
||||
setSearch: () => {},
|
||||
setStatusFilter: () => {},
|
||||
setFilters: () => {},
|
||||
setView: () => {},
|
||||
setLayout: () => {},
|
||||
setSort: () => {},
|
||||
@@ -108,18 +114,20 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
const { setPage } = useNavigationActions();
|
||||
|
||||
const [search, setSearchState] = useState("");
|
||||
const [statusFilter, setStatusFilterState] = useState<ChannelStatusFilter>(() =>
|
||||
clampStatus(getAccountRaw(LS.channelFilter)),
|
||||
);
|
||||
const [view, setViewState] = useState<ChannelsView>(() =>
|
||||
clampView(getAccountRaw(LS.channelsView)),
|
||||
);
|
||||
const [layout, setLayoutState] = useState<ChannelLayout>(() =>
|
||||
parseChannelLayout(getAccountRaw(LS.channelLayout)),
|
||||
);
|
||||
const [sort, setSortState] = useState<SortState>(() =>
|
||||
parseSortState(readAccount(LS.channelSort, null)),
|
||||
);
|
||||
// The subscribed table's column-filter map, owned here (lifted out of DataTable) so the top status
|
||||
// chips, the Sync column funnel, and the card layout share one state; useFilterState persists it.
|
||||
const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip);
|
||||
// view + layout are persisted tabs (usePersistedTab = the shared account-scoped tab-state hook), so
|
||||
// they get init-from-storage AND the account-pin re-hydrate for free (this provider mounts above
|
||||
// <App>, which pins the account only after /api/me). Stored un-clamped; clampView/parseChannelLayout
|
||||
// heal them for display. setView layers the history logic (below) on top of the raw setter.
|
||||
const [viewRaw, setViewRaw] = usePersistedTab(LS.channelsView, "subscribed");
|
||||
const view = clampView(viewRaw);
|
||||
const [layoutRaw, setLayout] = usePersistedTab(LS.channelLayout, "table");
|
||||
const layout = parseChannelLayout(layoutRaw);
|
||||
// Sort is owned here (lifted out of DataTable) so the card layout shares it; ColumnSortControl heals
|
||||
// it against the live columns for display, so the provider stores it un-healed (no options passed).
|
||||
const [sort, setSort] = useSortState(LS.channelSort);
|
||||
const [filtersResetToken, setFiltersResetToken] = useState(0);
|
||||
const [focusName, setFocusName] = useState<string | null>(null);
|
||||
const [focusToken, setFocusToken] = useState(0);
|
||||
@@ -131,37 +139,26 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
viewRef.current = view;
|
||||
|
||||
const setSearch = useCallback((q: string) => setSearchState(q), []);
|
||||
const setStatusFilter = useCallback((f: ChannelStatusFilter) => {
|
||||
setStatusFilterState(f);
|
||||
setAccountRaw(LS.channelFilter, f);
|
||||
}, []);
|
||||
// The active tab rides in history.state (`_chTab`), the same way the channel page (`_chan`) and the
|
||||
// live YouTube search (`_yt`) do — so Back steps back through the tabs you actually visited instead
|
||||
// of jumping straight out to the previous module.
|
||||
const setView = useCallback((v: ChannelsView) => {
|
||||
// Re-selecting the tab you are already on is not a navigation — bail before pushing, or clicking
|
||||
// the active tab (and goToFullHistory forcing "subscribed") stacks Back entries that do nothing.
|
||||
if (v === viewRef.current) return;
|
||||
setViewState(v);
|
||||
setAccountRaw(LS.channelsView, v);
|
||||
// Only own an entry while the manager is already on screen. Arriving from elsewhere
|
||||
// (focusChannel / goToFullHistory) lets setPage push the entry — the stamp effect labels it — so
|
||||
// one navigation never costs two Back presses.
|
||||
if (window.history.state?.sfPage === "channels") {
|
||||
// A FRESH state, like setPage's: carrying the old entry's `_chan`/`_yt` markers over would make
|
||||
// Back re-open the channel overlay or a spent search on top of the tab.
|
||||
window.history.pushState({ sfPage: "channels", _chTab: v }, "");
|
||||
}
|
||||
}, []);
|
||||
const setLayout = useCallback((l: ChannelLayout) => {
|
||||
setLayoutState(l);
|
||||
setAccountRaw(LS.channelLayout, l);
|
||||
}, []);
|
||||
const setSort = useCallback((s: SortState) => {
|
||||
setSortState(s);
|
||||
writeAccount(LS.channelSort, s);
|
||||
}, []);
|
||||
|
||||
const setView = useCallback(
|
||||
(v: ChannelsView) => {
|
||||
// Re-selecting the tab you are already on is not a navigation — bail before pushing, or clicking
|
||||
// the active tab (and goToFullHistory forcing "subscribed") stacks Back entries that do nothing.
|
||||
if (v === viewRef.current) return;
|
||||
setViewRaw(v); // persists + guards the account-pin re-hydrate (via usePersistedTab)
|
||||
// Only own an entry while the manager is already on screen. Arriving from elsewhere
|
||||
// (focusChannel / goToFullHistory) lets setPage push the entry — the stamp effect labels it — so
|
||||
// one navigation never costs two Back presses.
|
||||
if (window.history.state?.sfPage === "channels") {
|
||||
// A FRESH state, like setPage's: carrying the old entry's `_chan`/`_yt` markers over would make
|
||||
// Back re-open the channel overlay or a spent search on top of the tab.
|
||||
window.history.pushState({ sfPage: "channels", _chTab: v }, "");
|
||||
}
|
||||
},
|
||||
[setViewRaw],
|
||||
);
|
||||
const focusChannel = useCallback(
|
||||
(name: string) => {
|
||||
setFocusName(name);
|
||||
@@ -172,11 +169,13 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
[setView, setPage],
|
||||
);
|
||||
const goToFullHistory = useCallback(() => {
|
||||
setStatusFilter("needs_full");
|
||||
setView("subscribed"); // the status filter applies to the subscriptions tab
|
||||
setFiltersResetToken((n) => n + 1); // drop any stale column filter hiding the rows
|
||||
// One controlled map replaces the old status chip: sync=needsFull, and dropping every other
|
||||
// column filter (the whole map is set fresh) so no stale funnel hides the rows we're sent to see.
|
||||
setFilters({ sync: "needsFull" });
|
||||
setView("subscribed"); // the sync filter applies to the subscriptions tab
|
||||
setFiltersResetToken((n) => n + 1); // also clear the ephemeral name search + tag chips
|
||||
setPage("channels");
|
||||
}, [setStatusFilter, setView, setPage]);
|
||||
}, [setFilters, setView, setPage]);
|
||||
|
||||
// Clear the manager focus when leaving the Channels page so it doesn't re-apply on a later visit.
|
||||
useEffect(() => {
|
||||
@@ -196,29 +195,27 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
function onPop(e: PopStateEvent) {
|
||||
const tab = e.state?._chTab;
|
||||
if (!tab) return; // an entry outside the manager — leave the tab alone
|
||||
const next = clampView(tab as string);
|
||||
setViewState(next);
|
||||
setAccountRaw(LS.channelsView, next);
|
||||
setViewRaw(clampView(tab as string)); // persists the restored tab (setViewRaw is stable)
|
||||
}
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
}, []);
|
||||
}, [setViewRaw]);
|
||||
|
||||
const state = useMemo<ChannelsState>(
|
||||
() => ({ search, statusFilter, view, sort, filtersResetToken, focusName, focusToken }),
|
||||
[search, statusFilter, view, sort, filtersResetToken, focusName, focusToken],
|
||||
() => ({ search, filters, view, sort, filtersResetToken, focusName, focusToken }),
|
||||
[search, filters, view, sort, filtersResetToken, focusName, focusToken],
|
||||
);
|
||||
const actions = useMemo<ChannelsActions>(
|
||||
() => ({
|
||||
setSearch,
|
||||
setStatusFilter,
|
||||
setFilters,
|
||||
setView,
|
||||
setLayout,
|
||||
setSort,
|
||||
focusChannel,
|
||||
goToFullHistory,
|
||||
}),
|
||||
[setSearch, setStatusFilter, setView, setLayout, setSort, focusChannel, goToFullHistory],
|
||||
[setSearch, setFilters, setView, setLayout, setSort, focusChannel, goToFullHistory],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||
import { type Column } from "./DataTable";
|
||||
import { type SortState } from "../lib/columnSort";
|
||||
import { optionsFromColumns } from "../lib/sortOptions";
|
||||
import SortControl from "./SortControl";
|
||||
|
||||
// A sort control for views without clickable column headers (the channel manager's card layout):
|
||||
// a dropdown of the sortable columns + an asc/desc toggle, mirroring the feed toolbar's sort. It's
|
||||
// driven by the SAME `SortState` the DataTable header uses, so table and cards stay in sync. The
|
||||
// dropdown's options come straight from the columns' `sortable`/`sortValue` metadata — no new list.
|
||||
// The headerless sort control for views without clickable column headers (the channel manager's and
|
||||
// discovery's card layouts). Now a thin adapter over the generic `SortControl`, feeding it the
|
||||
// column-derived options and a "no sort" default — kept as its own component so the existing call
|
||||
// sites stay untouched and table/cards keep sharing one `SortState`.
|
||||
export default function ColumnSortControl<T>({
|
||||
columns,
|
||||
sort,
|
||||
@@ -25,44 +26,15 @@ export default function ColumnSortControl<T>({
|
||||
ascLabel: string;
|
||||
descLabel: string;
|
||||
}) {
|
||||
const sortable = columns.filter((c) => c.sortable && c.sortValue);
|
||||
// A stored sort can name a column this table no longer offers (renamed, or `sortable` dropped).
|
||||
// `sortRows` already leaves the rows unsorted then, so treat it as no sort here too — otherwise the
|
||||
// select falls back to showing "default" while a direction arrow claims a sort that isn't applied.
|
||||
const active = sortable.some((c) => c.key === sort?.key) ? sort : null;
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
value={active?.key ?? ""}
|
||||
onChange={(e) => {
|
||||
const key = e.target.value;
|
||||
// Keep the direction when re-picking the same column; a new column starts ascending.
|
||||
onChange(key ? { key, dir: active?.key === key ? active.dir : "asc" } : null);
|
||||
}}
|
||||
aria-label={label}
|
||||
className="bg-surface border border-border rounded-lg px-2 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
>
|
||||
<option value="">{defaultLabel}</option>
|
||||
{sortable.map((c) => (
|
||||
<option key={c.key} value={c.key}>
|
||||
{c.header}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{active && (
|
||||
<button
|
||||
onClick={() => onChange({ key: active.key, dir: active.dir === "asc" ? "desc" : "asc" })}
|
||||
title={active.dir === "asc" ? ascLabel : descLabel}
|
||||
aria-label={active.dir === "asc" ? ascLabel : descLabel}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
|
||||
>
|
||||
{active.dir === "asc" ? (
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ArrowDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<SortControl
|
||||
options={optionsFromColumns(columns)}
|
||||
sort={sort}
|
||||
onChange={onChange}
|
||||
label={label}
|
||||
defaultLabel={defaultLabel}
|
||||
ascLabel={ascLabel}
|
||||
descLabel={descLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// A small count pill shown beside a tab label or filter chip (pending requests, item counts). One
|
||||
// source for the count-badge token so Tabs and FilterChipGroup can't drift. The `active` variant sits
|
||||
// on an accent-filled parent (a pressed tab/chip), so it tints against accent-fg; the resting variant
|
||||
// carries its own accent tint. Callers guard on `count > 0` — this just renders the pill.
|
||||
export default function CountBadge({ count, active }: { count: number; active?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`tabular-nums text-2xs leading-none px-1.5 py-0.5 rounded-full ${
|
||||
active ? "bg-accent-fg/20" : "bg-accent/20 text-accent"
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,11 @@ interface Persisted {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
// Stable empty-map identity for the controlled-but-value-omitted fallback, so `filters` keeps a
|
||||
// constant reference across renders (a fresh `{}` would churn the persist effect + re-filter every
|
||||
// render). Mirrors controlled sort's stable `null` fallback.
|
||||
const EMPTY_FILTERS: FilterMap = {};
|
||||
|
||||
function loadPersist(key?: string): Persisted {
|
||||
const empty: Persisted = { sort: null, filters: {}, page: 0 };
|
||||
if (!key) return empty;
|
||||
@@ -53,6 +58,8 @@ export default function DataTable<T>({
|
||||
resetFiltersToken,
|
||||
sort: sortProp,
|
||||
onSortChange,
|
||||
filters: filtersProp,
|
||||
onFiltersChange,
|
||||
}: {
|
||||
rows: T[];
|
||||
columns: Column<T>[];
|
||||
@@ -82,6 +89,13 @@ export default function DataTable<T>({
|
||||
// persistKey-backed sort.
|
||||
sort?: SortState;
|
||||
onSortChange?: (next: SortState) => void;
|
||||
// CONTROLLED filters (optional): symmetric to controlled sort. When `onFiltersChange` is given the
|
||||
// caller owns the column-filter map (and its persistence) — so the channel manager's top status
|
||||
// chips, the Sync column funnel, and the card layout all share ONE filter state — and the table
|
||||
// reflects `filters` and reports funnel changes instead of keeping its own. Omit both for the
|
||||
// default internal, persistKey-backed filters.
|
||||
filters?: FilterMap;
|
||||
onFiltersChange?: (next: FilterMap) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const initial = loadPersist(persistKey);
|
||||
@@ -90,17 +104,42 @@ export default function DataTable<T>({
|
||||
const controlledSort = onSortChange !== undefined;
|
||||
const [internalSort, setInternalSort] = useState<SortState>(initial.sort);
|
||||
const sort = controlledSort ? (sortProp ?? null) : internalSort;
|
||||
const [filters, setFilters] = useState<FilterMap>(initial.filters);
|
||||
// Filters are controlled when the caller passes onFiltersChange (it then owns/persists the map);
|
||||
// otherwise the table keeps its own, seeded from and saved to persistKey — symmetric to sort.
|
||||
const controlledFilters = onFiltersChange !== undefined;
|
||||
const [internalFilters, setInternalFilters] = useState<FilterMap>(initial.filters);
|
||||
const filters = controlledFilters ? (filtersProp ?? EMPTY_FILTERS) : internalFilters;
|
||||
const [page, setPage] = useState(initial.page);
|
||||
const [size, setSize] = useState(initial.size ?? pageSize);
|
||||
|
||||
// Commit a whole new filter map through the owner (controlled) or internal state. A ref holds the
|
||||
// latest resolved map so the externalFilter/reset effects can derive from it without taking
|
||||
// `filters` as a dep (which would re-run them on every filter change).
|
||||
const filtersRef = useRef(filters);
|
||||
filtersRef.current = filters;
|
||||
const commitFilters = (next: FilterMap) => {
|
||||
if (controlledFilters) onFiltersChange!(next);
|
||||
else setInternalFilters(next);
|
||||
};
|
||||
|
||||
// Persist page/size always, but only the sort/filter slices this table actually OWNS — a controlled
|
||||
// caller persists those itself, so writing them here would just leave a stale, never-read duplicate.
|
||||
useEffect(() => {
|
||||
if (persistKey) localStorage.setItem(persistKey, JSON.stringify({ sort, filters, page, size }));
|
||||
}, [persistKey, sort, filters, page, size]);
|
||||
if (!persistKey) return;
|
||||
localStorage.setItem(
|
||||
persistKey,
|
||||
JSON.stringify({
|
||||
sort: controlledSort ? null : sort,
|
||||
filters: controlledFilters ? {} : filters,
|
||||
page,
|
||||
size,
|
||||
}),
|
||||
);
|
||||
}, [persistKey, controlledSort, controlledFilters, sort, filters, page, size]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!externalFilter) return;
|
||||
setFilters((f) => ({ ...f, [externalFilter.key]: externalFilter.value }));
|
||||
commitFilters({ ...filtersRef.current, [externalFilter.key]: externalFilter.value });
|
||||
setPage(0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [externalFilter?.key, externalFilter?.value]);
|
||||
@@ -113,8 +152,9 @@ export default function DataTable<T>({
|
||||
useEffect(() => {
|
||||
if (resetFiltersToken === undefined || resetFiltersToken === resetRef.current) return;
|
||||
resetRef.current = resetFiltersToken;
|
||||
setFilters({});
|
||||
commitFilters({});
|
||||
setPage(0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [resetFiltersToken]);
|
||||
|
||||
const sorted = sortRows(filterRows(rows, columns, filters), columns, sort);
|
||||
@@ -132,7 +172,7 @@ export default function DataTable<T>({
|
||||
setPage(0);
|
||||
}
|
||||
function applyFilter(key: string, value: string | string[]) {
|
||||
setFilters((f) => ({ ...f, [key]: value }));
|
||||
commitFilters({ ...filters, [key]: value });
|
||||
setPage(0);
|
||||
}
|
||||
|
||||
@@ -196,7 +236,7 @@ export default function DataTable<T>({
|
||||
{paged.map((row) => (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
className={`border-b border-border/50 hover:bg-card/40 transition ${
|
||||
className={`border-b border-border/50 hover:bg-(--hover) transition ${
|
||||
rowClassName?.(row) ?? ""
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { type ReactNode } from "react";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
|
||||
// The presentation shared by the channel manager's "excluded channel" tabs — Hidden and Blocked.
|
||||
// A responsive grid of proper channel cards (avatar + name + open-on-YouTube, via ChannelLink) each
|
||||
// with one reversing action, instead of a bare row of name chips. Both tabs pass the same shape; only
|
||||
// the header icon/label, the action, and the copy differ.
|
||||
export interface ExcludedChannel {
|
||||
id: string;
|
||||
title: string | null;
|
||||
handle?: string | null;
|
||||
thumbnail_url: string | null;
|
||||
}
|
||||
|
||||
export default function ExcludedChannelList({
|
||||
channels,
|
||||
icon,
|
||||
title,
|
||||
hint,
|
||||
emptyText,
|
||||
actionLabel,
|
||||
actionIcon,
|
||||
onAction,
|
||||
onView,
|
||||
}: {
|
||||
channels: ExcludedChannel[];
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
hint: string;
|
||||
emptyText: string;
|
||||
actionLabel: string;
|
||||
actionIcon: ReactNode;
|
||||
onAction: (id: string) => void;
|
||||
onView?: (c: ExcludedChannel) => void;
|
||||
}) {
|
||||
if (channels.length === 0) return <p className="text-sm text-muted py-8">{emptyText}</p>;
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{icon}
|
||||
<span className="text-xs uppercase tracking-wide text-muted">{title}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{channels.map((c) => (
|
||||
<div key={c.id} className="glass-card flex items-center gap-2 rounded-xl px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<ChannelLink
|
||||
id={c.id}
|
||||
title={c.title}
|
||||
handle={c.handle}
|
||||
thumbnailUrl={c.thumbnail_url}
|
||||
onView={onView ? () => onView(c) : undefined}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onAction(c.id)}
|
||||
title={actionLabel}
|
||||
aria-label={actionLabel}
|
||||
className="shrink-0 inline-flex items-center gap-1 rounded-full border border-border px-2 py-1 text-xs text-muted transition hover:border-accent hover:text-accent"
|
||||
>
|
||||
{actionIcon}
|
||||
{actionLabel}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-2xs text-muted mt-3">{hint}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowLeft,
|
||||
Columns2,
|
||||
Grid3x3,
|
||||
@@ -34,12 +32,15 @@ import {
|
||||
SORT_KEYS,
|
||||
type SortKey,
|
||||
} from "../lib/feedSort";
|
||||
import { type SortOption } from "../lib/columnSort";
|
||||
import { FEED_VIEWS, type FeedView } from "../lib/feedView";
|
||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||
import ActiveFilterChips from "./ActiveFilterChips";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { useWizard } from "./WizardProvider";
|
||||
import SortControl from "./SortControl";
|
||||
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
||||
import ViewSwitcher from "./ViewSwitcher";
|
||||
import VirtualFeed from "./VirtualFeed";
|
||||
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
|
||||
@@ -547,36 +548,51 @@ export default function Feed({
|
||||
);
|
||||
|
||||
const { key: sortKey, dir: sortDir } = parseSort(filters.sort);
|
||||
// The feed's sort vocabulary as source-agnostic options for the shared SortControl. Relevance only
|
||||
// ranks meaningfully with a search term (offered then); shuffle/relevance are directionless.
|
||||
const sortOptions: SortOption[] = (
|
||||
filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys
|
||||
).map((k) => ({
|
||||
key: k,
|
||||
label: t("feed.sortKey." + k),
|
||||
directionless: DIRECTIONLESS.includes(k),
|
||||
defaultDir: DIRECTIONLESS.includes(k)
|
||||
? undefined
|
||||
: SORT_DEFAULT_DIR[k as Exclude<SortKey, "shuffle" | "relevance">],
|
||||
}));
|
||||
|
||||
const toolbar = (
|
||||
<div className="pb-3">
|
||||
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
|
||||
{SHOW_IDS.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
data-testid={`feed-show-${id}`}
|
||||
onClick={() => setFilters({ ...filters, show: id })}
|
||||
aria-pressed={filters.show === id}
|
||||
className="pill text-xs px-3 py-1.5 rounded-full border"
|
||||
>
|
||||
{t("sidebar.show." + id)}
|
||||
</button>
|
||||
))}
|
||||
<FilterChipGroup
|
||||
ariaLabel={t("feed.showLabel")}
|
||||
options={SHOW_IDS.map((id) => ({
|
||||
value: id,
|
||||
label: t("sidebar.show." + id),
|
||||
testId: `feed-show-${id}`,
|
||||
}))}
|
||||
value={filters.show}
|
||||
onChange={(show) => setFilters({ ...filters, show })}
|
||||
/>
|
||||
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||
{CONTENT.map((c) => {
|
||||
const on = filters[c.key];
|
||||
return (
|
||||
<button
|
||||
key={c.key}
|
||||
data-testid={`feed-content-${c.key}`}
|
||||
onClick={() => setFilters({ ...filters, [c.key]: !on })}
|
||||
aria-pressed={on}
|
||||
className="pill text-xs px-3 py-1.5 rounded-full border"
|
||||
>
|
||||
{t(c.label)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* Content-type toggles are MULTI-select (independent booleans) — same chip look via
|
||||
filterChipClass, but not a single-select group. */}
|
||||
<div className="flex flex-wrap gap-1.5" role="group" aria-label={t("feed.contentLabel")}>
|
||||
{CONTENT.map((c) => {
|
||||
const on = filters[c.key];
|
||||
return (
|
||||
<button
|
||||
key={c.key}
|
||||
data-testid={`feed-content-${c.key}`}
|
||||
onClick={() => setFilters({ ...filters, [c.key]: !on })}
|
||||
aria-pressed={on}
|
||||
className={filterChipClass}
|
||||
>
|
||||
{t(c.label)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* The Source filter (organic / include-search / search-only) is a global-catalog concept —
|
||||
it selects how videos ENTERED the library. On a channel-scoped view we already show all
|
||||
of this one channel's videos (librarySource is pinned to "all"), so the selector would
|
||||
@@ -615,58 +631,33 @@ export default function Feed({
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className="text-xs text-muted">{t("feed.sortLabel")}</span>
|
||||
<select
|
||||
value={sortKey}
|
||||
onChange={(e) => {
|
||||
const key = e.target.value as SortKey;
|
||||
const dir = DIRECTIONLESS.includes(key)
|
||||
? "desc"
|
||||
: SORT_DEFAULT_DIR[key as Exclude<SortKey, "shuffle" | "relevance">];
|
||||
<SortControl
|
||||
options={sortOptions}
|
||||
sort={{ key: sortKey, dir: sortDir }}
|
||||
onChange={(next) => {
|
||||
if (!next) return; // the feed is always sorted — no "no sort" option is offered
|
||||
setFilters({
|
||||
...filters,
|
||||
sort: buildSort(key, dir),
|
||||
seed: key === "shuffle" ? rollSeed() : undefined,
|
||||
sort: buildSort(next.key as SortKey, next.dir),
|
||||
seed: next.key === "shuffle" ? rollSeed() : undefined,
|
||||
});
|
||||
}}
|
||||
aria-label={t("feed.sortLabel")}
|
||||
className="bg-surface border border-border rounded-lg px-2 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
>
|
||||
{/* Relevance only ranks meaningfully when there's a search term — offer it then. */}
|
||||
{(filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys).map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{t("feed.sortKey." + k)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{!DIRECTIONLESS.includes(sortKey) && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
...filters,
|
||||
sort: buildSort(sortKey, sortDir === "asc" ? "desc" : "asc"),
|
||||
})
|
||||
}
|
||||
title={sortDir === "asc" ? t("feed.dirAsc") : t("feed.dirDesc")}
|
||||
aria-label={sortDir === "asc" ? t("feed.dirAsc") : t("feed.dirDesc")}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
|
||||
>
|
||||
{sortDir === "asc" ? (
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ArrowDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{sortKey === "shuffle" && (
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
|
||||
title={t("sidebar.reshuffle")}
|
||||
aria-label={t("sidebar.reshuffle")}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
label={t("feed.sortLabel")}
|
||||
ascLabel={t("feed.dirAsc")}
|
||||
descLabel={t("feed.dirDesc")}
|
||||
trailing={
|
||||
sortKey === "shuffle" ? (
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
|
||||
title={t("sidebar.reshuffle")}
|
||||
aria-label={t("sidebar.reshuffle")}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
|
||||
<ViewSwitcher
|
||||
value={view}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Rendering integration of the shared single-select FilterChipGroup. Labels are plain props; active
|
||||
// paint is CSS (`.pill[aria-pressed]`), so we assert the aria-pressed / onChange / testId contract.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
||||
|
||||
const OPTS = [
|
||||
{ value: "all", label: "All", testId: "chip-all" },
|
||||
{ value: "needsFull", label: "Needs full" },
|
||||
{ value: "fully", label: "Fully synced", count: 3 },
|
||||
] as const;
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("FilterChipGroup", () => {
|
||||
it("renders one chip per option with labels", () => {
|
||||
render(<FilterChipGroup options={OPTS} value="all" onChange={vi.fn()} ariaLabel="Sync" />);
|
||||
expect(screen.getAllByRole("button").map((b) => b.textContent)).toEqual([
|
||||
"All",
|
||||
"Needs full",
|
||||
"Fully synced3",
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks exactly the active chip aria-pressed", () => {
|
||||
render(
|
||||
<FilterChipGroup options={OPTS} value="needsFull" onChange={vi.fn()} ariaLabel="Sync" />,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Needs full" }).getAttribute("aria-pressed")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "All" }).getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
|
||||
it("clicking a chip fires onChange with its value", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<FilterChipGroup options={OPTS} value="all" onChange={onChange} ariaLabel="Sync" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Needs full" }));
|
||||
expect(onChange).toHaveBeenCalledWith("needsFull");
|
||||
});
|
||||
|
||||
it("re-clicking the active chip still fires onChange (caller decides toggle-off)", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<FilterChipGroup options={OPTS} value="all" onChange={onChange} ariaLabel="Sync" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "All" }));
|
||||
expect(onChange).toHaveBeenCalledWith("all");
|
||||
});
|
||||
|
||||
it("preserves per-option testId and exposes the group aria-label", () => {
|
||||
render(
|
||||
<FilterChipGroup options={OPTS} value="all" onChange={vi.fn()} ariaLabel="Sync state" />,
|
||||
);
|
||||
expect(screen.getByTestId("chip-all")).toBeTruthy();
|
||||
expect(screen.getByRole("group", { name: "Sync state" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("exports the canonical chip class for multi-select siblings to reuse", () => {
|
||||
expect(filterChipClass).toContain("rounded-full");
|
||||
expect(filterChipClass).toContain("pill");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { type ReactNode } from "react";
|
||||
import CountBadge from "./CountBadge";
|
||||
|
||||
// Canonical filter-chip geometry — the rounded-full "Filters bar" look used app-wide for a selectable
|
||||
// filter chip. Exported so single-select groups (this component) AND standalone/multi-select filter
|
||||
// chips (e.g. the feed content-type toggles) render identically from ONE source of truth. The active
|
||||
// paint comes entirely from the `.pill[aria-pressed="true"]` recipe (index.css) — carry no
|
||||
// bg/text/border-colour utilities here or a utility class would beat the pressed fill.
|
||||
export const filterChipClass =
|
||||
"pill inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border shadow-xs active:translate-y-px";
|
||||
|
||||
export interface FilterChipOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
count?: number; // optional trailing count badge
|
||||
icon?: ReactNode;
|
||||
title?: string; // optional per-chip tooltip
|
||||
testId?: string; // optional data-testid (preserve existing e2e hooks)
|
||||
}
|
||||
|
||||
// A single-select row of filter chips (exactly one active), radio-like. For a multi-select chip row,
|
||||
// render plain buttons with `filterChipClass` instead — same look, different (independent) semantics.
|
||||
export default function FilterChipGroup<T extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
className = "flex flex-wrap gap-1.5",
|
||||
}: {
|
||||
options: readonly FilterChipOption<T>[];
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
ariaLabel: string;
|
||||
// Override the row wrapper (e.g. "contents" to dissolve it into a shared toolbar flex row).
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={className} role="group" aria-label={ariaLabel}>
|
||||
{options.map((o) => {
|
||||
const on = value === o.value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
data-testid={o.testId}
|
||||
onClick={() => onChange(o.value)}
|
||||
title={o.title}
|
||||
aria-pressed={on}
|
||||
className={filterChipClass}
|
||||
>
|
||||
{o.icon}
|
||||
<span>{o.label}</span>
|
||||
{o.count != null && o.count > 0 && <CountBadge count={o.count} active={on} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { isPage, readPage, type Page } from "../lib/urlState";
|
||||
import { pageTitleKey } from "../lib/pageMeta";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
||||
import { setNavigator } from "../lib/nav";
|
||||
|
||||
// Owns the app's navigation + browser-history state, lifted out of the App god component so pages
|
||||
@@ -103,19 +104,42 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
const channelViewRef = useRef(channelView);
|
||||
channelViewRef.current = channelView;
|
||||
|
||||
const setPage = useCallback((next: Page) => {
|
||||
// A channel page overlays the content column, so a nav click must close it even when the
|
||||
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
|
||||
if (next === pageRef.current && !channelViewRef.current) return;
|
||||
setChannelView(null); // leave any open channel page when navigating via the rail
|
||||
setPageState(next);
|
||||
setAccountRaw(PAGE_KEY, next);
|
||||
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the
|
||||
// app. The URL stays clean — the page rides in history.state. A fresh { sfPage } (no carried-over
|
||||
// _yt/_chan markers) so each page starts at its root.
|
||||
window.history.pushState({ sfPage: next }, "");
|
||||
}, []);
|
||||
// The landing page is per-account (PAGE_KEY), but this provider mounts above <App>, which pins the
|
||||
// tab's account only after /api/me — so on a fresh tab `loadInitialPage` reads an unknown account and
|
||||
// falls back to "feed". Re-read once the account is pinned and correct the CURRENT history entry
|
||||
// (replaceState, not push) so Back isn't given a phantom "feed" step. markDirty (set by setPage)
|
||||
// skips this if the user already navigated in the pre-pin window.
|
||||
const markDirty = useAccountRehydrate(() => {
|
||||
const stored = loadInitialPage();
|
||||
if (stored === pageRef.current) return;
|
||||
setPageState(stored);
|
||||
setAccountRaw(PAGE_KEY, stored); // no-op re-write, keeps storage authoritative
|
||||
// Correct the CURRENT entry (replace, not push) and PRESERVE any markers already on it — a channel
|
||||
// (_chan) or YT search (_yt) opened in the pre-pin window survives on top of the restored page,
|
||||
// rather than being orphaned from history so Back can't close it.
|
||||
window.history.replaceState({ ...(window.history.state || {}), sfPage: stored }, "");
|
||||
});
|
||||
|
||||
const setPage = useCallback(
|
||||
(next: Page) => {
|
||||
// A channel page overlays the content column, so a nav click must close it even when the
|
||||
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
|
||||
if (next === pageRef.current && !channelViewRef.current) return;
|
||||
markDirty();
|
||||
setChannelView(null); // leave any open channel page when navigating via the rail
|
||||
setPageState(next);
|
||||
setAccountRaw(PAGE_KEY, next);
|
||||
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the
|
||||
// app. The URL stays clean — the page rides in history.state. A fresh { sfPage } (no carried-over
|
||||
// _yt/_chan markers) so each page starts at its root.
|
||||
window.history.pushState({ sfPage: next }, "");
|
||||
},
|
||||
[markDirty],
|
||||
);
|
||||
|
||||
// A sub-view (channel / YT search) is an OVERLAY, not a page change, so it does NOT markDirty: a
|
||||
// pre-pin account re-hydrate should still restore the stored page underneath — the re-hydrate's
|
||||
// replaceState spreads the _chan/_yt markers, so the overlay survives on top of the restored page.
|
||||
const openChannel = useCallback((id: string, name?: string) => {
|
||||
setChannelView({ id, name });
|
||||
const st = window.history.state || {};
|
||||
@@ -129,6 +153,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const enterYtSearch = useCallback((q: string) => {
|
||||
// Overlay, not a page change — see openChannel: no markDirty, the re-hydrate preserves _yt.
|
||||
setYtSearch(q);
|
||||
// Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps it
|
||||
// once the results resolve, so a reload only restores a confirmed scrape-sourced search.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
||||
|
||||
// Owns the Playlists module's shared local state — the rail's name filter and the selected playlist
|
||||
// — lifted out of the App god component so the header search box, the PlaylistsRail, and the
|
||||
@@ -35,18 +36,28 @@ export function usePlaylistsActions(): PlaylistsActions {
|
||||
return useContext(ActionsContext);
|
||||
}
|
||||
|
||||
function readSelectedId(): number | null {
|
||||
const s = getAccountRaw(LS.playlist);
|
||||
return s ? Number(s) : null;
|
||||
}
|
||||
|
||||
export function PlaylistsProvider({ children }: { children: ReactNode }) {
|
||||
const [search, setSearchState] = useState("");
|
||||
const [selectedId, setSelectedIdState] = useState<number | null>(() => {
|
||||
const s = getAccountRaw(LS.playlist);
|
||||
return s ? Number(s) : null;
|
||||
});
|
||||
const [selectedId, setSelectedIdState] = useState<number | null>(readSelectedId);
|
||||
|
||||
// Re-read the selected playlist once the account is pinned after mount (first load on a fresh tab):
|
||||
// this provider mounts above <App>, which pins the account only after /api/me.
|
||||
const markDirty = useAccountRehydrate(() => setSelectedIdState(readSelectedId()));
|
||||
|
||||
const setSearch = useCallback((q: string) => setSearchState(q), []);
|
||||
const setSelectedId = useCallback((id: number | null) => {
|
||||
setSelectedIdState(id);
|
||||
if (id != null) setAccountRaw(LS.playlist, String(id));
|
||||
}, []);
|
||||
const setSelectedId = useCallback(
|
||||
(id: number | null) => {
|
||||
markDirty();
|
||||
setSelectedIdState(id);
|
||||
if (id != null) setAccountRaw(LS.playlist, String(id));
|
||||
},
|
||||
[markDirty],
|
||||
);
|
||||
|
||||
const state = useMemo<PlaylistsState>(() => ({ search, selectedId }), [search, selectedId]);
|
||||
const actions = useMemo<PlaylistsActions>(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { ArrowDown, ArrowUp, ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
|
||||
import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
|
||||
import { api, type Playlist } from "../lib/api";
|
||||
import { formatDuration } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
@@ -11,6 +11,8 @@ import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
||||
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
||||
import SidePanel from "./SidePanel";
|
||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||
import SortControl from "./SortControl";
|
||||
import { type SortOption } from "../lib/columnSort";
|
||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
@@ -45,6 +47,12 @@ export default function PlaylistsRail({
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [plSort, setPlSort] = useState<PlSort>(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT));
|
||||
const plSortOptions: SortOption[] = [
|
||||
{ key: "custom", label: t("playlists.railSortCustom"), directionless: true },
|
||||
{ key: "name", label: t("playlists.railSortName"), defaultDir: "asc" },
|
||||
{ key: "count", label: t("playlists.railSortCount"), defaultDir: "desc" },
|
||||
{ key: "duration", label: t("playlists.railSortDuration"), defaultDir: "desc" },
|
||||
];
|
||||
useEffect(() => {
|
||||
writeAccount(LS.plSort, plSort);
|
||||
}, [plSort]);
|
||||
@@ -149,43 +157,26 @@ export default function PlaylistsRail({
|
||||
{t("playlists.syncYoutube")}
|
||||
</button>
|
||||
{playlists.length > 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
value={plSort.key}
|
||||
onChange={(e) => setPlSort({ ...plSort, key: e.target.value as PlSort["key"] })}
|
||||
aria-label={t("playlists.sortLabel")}
|
||||
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-1.5 py-1 text-2xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
>
|
||||
<option value="custom">{t("playlists.railSortCustom")}</option>
|
||||
<option value="name">{t("playlists.railSortName")}</option>
|
||||
<option value="count">{t("playlists.railSortCount")}</option>
|
||||
<option value="duration">{t("playlists.railSortDuration")}</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setPlSort({ ...plSort, dir: plSort.dir === "asc" ? "desc" : "asc" })}
|
||||
disabled={plSort.key === "custom"}
|
||||
title={plSort.dir === "asc" ? t("playlists.dirAsc") : t("playlists.dirDesc")}
|
||||
className="p-1 rounded-md border border-border text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-30 transition"
|
||||
>
|
||||
{plSort.dir === "asc" ? (
|
||||
<ArrowUp className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ArrowDown className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })}
|
||||
title={t("playlists.dirtyFirst")}
|
||||
aria-pressed={plSort.dirtyFirst}
|
||||
className={`p-1 rounded-md border transition ${
|
||||
plSort.dirtyFirst
|
||||
? "border-accent text-accent"
|
||||
: "border-border text-muted hover:text-fg hover:border-accent"
|
||||
}`}
|
||||
>
|
||||
<Pin className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<SortControl
|
||||
label={t("playlists.sortLabel")}
|
||||
ascLabel={t("playlists.dirAsc")}
|
||||
descLabel={t("playlists.dirDesc")}
|
||||
options={plSortOptions}
|
||||
sort={{ key: plSort.key, dir: plSort.dir }}
|
||||
onChange={(next) =>
|
||||
next && setPlSort({ ...plSort, key: next.key as PlSort["key"], dir: next.dir })
|
||||
}
|
||||
trailing={
|
||||
<button
|
||||
onClick={() => setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })}
|
||||
title={t("playlists.dirtyFirst")}
|
||||
aria-pressed={plSort.dirtyFirst}
|
||||
className="pill shrink-0 rounded-lg border p-1.5"
|
||||
>
|
||||
<Pin className="h-4 w-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { healPlexSort } from "../lib/plexSort";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -58,6 +59,8 @@ import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import ActiveFilterChips from "./ActiveFilterChips";
|
||||
import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters";
|
||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||
|
||||
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
||||
@@ -90,6 +93,13 @@ export default function PlexBrowse() {
|
||||
const { q, scope, show, sort, filters, playlistOpen: openPlaylist } = usePlex();
|
||||
const { setScope, setFilters, setPlaylistOpen } = usePlexActions();
|
||||
const dq = useDebounced(q.trim(), 350);
|
||||
// Plex's active-filter chip row (above the grid), the shared feed idiom: one removable chip per
|
||||
// narrowing, plus a Clear-all (shared with the sidebar) that resets filters + watch-state + sort.
|
||||
const plexChips = usePlexActiveFilters();
|
||||
const clearAllFilters = usePlexClearFilters();
|
||||
// The provider holds sort scope-agnostically; heal it against THIS scope's options so a movie-only
|
||||
// key (e.g. duration) left over from before a switch to shows doesn't ask for an unavailable sort.
|
||||
const sortState = healPlexSort(sort, scope);
|
||||
const sub = useHistorySubview<Sub>({ kind: "grid" });
|
||||
|
||||
// Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then
|
||||
@@ -141,14 +151,14 @@ export default function PlexBrowse() {
|
||||
}, [sub]);
|
||||
|
||||
const browseQ = useInfiniteQuery({
|
||||
queryKey: qk.plexLibrary(scope, dq, sort, show, filters),
|
||||
queryKey: qk.plexLibrary(scope, dq, sortState, show, filters),
|
||||
enabled: !!scope && sub.view.kind === "grid",
|
||||
initialPageParam: 0,
|
||||
queryFn: ({ pageParam }) =>
|
||||
api.plexLibrary({
|
||||
scope,
|
||||
q: dq || undefined,
|
||||
sort,
|
||||
sort: sortState,
|
||||
show,
|
||||
filters,
|
||||
offset: pageParam as number,
|
||||
@@ -329,6 +339,8 @@ export default function PlexBrowse() {
|
||||
</div>
|
||||
</PageToolbar>
|
||||
|
||||
<ActiveFilterChips filters={plexChips} onClearAll={clearAllFilters} />
|
||||
|
||||
{personCards.length > 0 && (
|
||||
<div className="mb-4 flex flex-wrap gap-3">
|
||||
{personCards.map((p) => (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -35,6 +35,7 @@ import { api, type PlexCard } from "../lib/api";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||
|
||||
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
|
||||
// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
|
||||
@@ -499,18 +500,10 @@ export default function PlexPlaylistView({
|
||||
onRemove,
|
||||
};
|
||||
|
||||
const LayoutBtn = ({ v, icon, label }: { v: Layout; icon: ReactNode; label: string }) => (
|
||||
<button
|
||||
onClick={() => setLayout(v)}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-pressed={layout === v}
|
||||
data-soft="true"
|
||||
className={`pill rounded-lg p-2 text-sm ${layout === v ? "" : "glass-card glass-hover text-muted"}`}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
const layoutOptions: ViewOption<Layout>[] = [
|
||||
{ id: "accordion", label: t("plex.playlist.layoutAccordion"), icon: Rows3 },
|
||||
{ id: "tree", label: t("plex.playlist.layoutTree"), icon: ListTree },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-[90%] max-w-[1100px] p-4">
|
||||
@@ -536,19 +529,13 @@ export default function PlexPlaylistView({
|
||||
) : (
|
||||
<h1 className="min-w-0 flex-1 truncate text-2xl font-bold">{q.data?.title ?? " "}</h1>
|
||||
)}
|
||||
{/* Layout preference: Accordion (A) or Tree (C). */}
|
||||
<div className="flex items-center gap-1">
|
||||
<LayoutBtn
|
||||
v="accordion"
|
||||
icon={<Rows3 className="h-4 w-4" />}
|
||||
label={t("plex.playlist.layoutAccordion")}
|
||||
/>
|
||||
<LayoutBtn
|
||||
v="tree"
|
||||
icon={<ListTree className="h-4 w-4" />}
|
||||
label={t("plex.playlist.layoutTree")}
|
||||
/>
|
||||
</div>
|
||||
{/* Layout preference: Accordion or Tree — the shared list-view switcher. */}
|
||||
<ViewSwitcher
|
||||
value={layout}
|
||||
options={layoutOptions}
|
||||
onChange={setLayout}
|
||||
label={t("plex.playlist.layoutLabel")}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
setName(q.data?.title ?? "");
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
|
||||
import { parseSortState, type SortState } from "../lib/columnSort";
|
||||
import { PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
||||
|
||||
// Owns the Plex module's shared local state — the left-rail library scope / watch-state / sort /
|
||||
// expanded filters, the header search term, and the "open this playlist" hand-off — lifted out of
|
||||
@@ -8,8 +11,10 @@ import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
|
||||
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
|
||||
//
|
||||
// scope / show / sort / filters / q are per-account persisted (localStorage); the account switch does
|
||||
// a full reload, so init-from-storage is all the hydration they need. `playlistOpen` is an ephemeral
|
||||
// scope / show / sort / filters / q are per-account persisted (localStorage). init-from-storage seeds
|
||||
// them, and useAccountRehydrate re-reads once the tab's account is pinned — this provider mounts above
|
||||
// <App>, which pins it only after /api/me, so a fresh tab would otherwise show defaults until a reload
|
||||
// (an account SWITCH does full-reload, but the FIRST load does not). `playlistOpen` is an ephemeral
|
||||
// hand-off (the rail sets it, PlexBrowse consumes it into a history subview then clears it). The Plex
|
||||
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
|
||||
// into (and is never greeted by a stale value from) the feed.
|
||||
@@ -17,7 +22,7 @@ import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
type PlexState = {
|
||||
scope: string; // movie | show | both (unified cross-library scope)
|
||||
show: string;
|
||||
sort: string;
|
||||
sort: SortState; // {key,dir}; healed against the scope's options at the point of use
|
||||
filters: PlexFilters;
|
||||
q: string;
|
||||
playlistOpen: number | null;
|
||||
@@ -26,7 +31,7 @@ type PlexState = {
|
||||
interface PlexActions {
|
||||
setScope: (v: string) => void;
|
||||
setShow: (v: string) => void;
|
||||
setSort: (v: string) => void;
|
||||
setSort: (v: SortState) => void;
|
||||
setFilters: (f: PlexFilters) => void;
|
||||
setQ: (q: string) => void;
|
||||
setPlaylistOpen: (id: number | null) => void;
|
||||
@@ -35,7 +40,7 @@ interface PlexActions {
|
||||
const StateContext = createContext<PlexState>({
|
||||
scope: "both",
|
||||
show: "all",
|
||||
sort: "title",
|
||||
sort: PLEX_SORT_FALLBACK,
|
||||
filters: EMPTY_PLEX_FILTERS,
|
||||
q: "",
|
||||
playlistOpen: null,
|
||||
@@ -56,49 +61,115 @@ export function usePlexActions(): PlexActions {
|
||||
return useContext(ActionsContext);
|
||||
}
|
||||
|
||||
// The per-account init readers, shared by the initial useState seed AND the account-pin re-hydrate.
|
||||
function readPlexScope(): string {
|
||||
const v = getAccountRaw(LS.plexLibrary) ?? "both";
|
||||
return ["movie", "show", "both"].includes(v) ? v : "both";
|
||||
}
|
||||
const readPlexShow = (): string => getAccountRaw(LS.plexShow) ?? "all";
|
||||
const readPlexFiltersRaw = (): string => getAccountRaw(LS.plexFilters) ?? "{}";
|
||||
const readPlexQ = (): string => getAccountRaw(LS.plexQ) ?? "";
|
||||
// Sort is a {key,dir} SortState. Migrate the old shape: a legacy bare-key string in plexSort plus the
|
||||
// direction that used to live in plexFilters.sortDir — so an existing sort preference survives the
|
||||
// upgrade rather than snapping back to the default.
|
||||
function readPlexSort(): SortState {
|
||||
const raw = getAccountRaw(LS.plexSort);
|
||||
if (!raw) return PLEX_SORT_FALLBACK;
|
||||
let wasJson = false;
|
||||
try {
|
||||
const parsed = parseSortState(JSON.parse(raw));
|
||||
wasJson = true;
|
||||
if (parsed) return parsed;
|
||||
} catch {
|
||||
/* not JSON → a legacy bare-key string, handled below */
|
||||
}
|
||||
// Valid JSON that wasn't a SortState is corrupt — fall back rather than treat the JSON text as a
|
||||
// key. Only a non-JSON raw is a genuine legacy bare key.
|
||||
if (wasJson) return PLEX_SORT_FALLBACK;
|
||||
let dir: "asc" | "desc" = "asc";
|
||||
try {
|
||||
if (
|
||||
(JSON.parse(getAccountRaw(LS.plexFilters) ?? "{}") as { sortDir?: string }).sortDir === "desc"
|
||||
)
|
||||
dir = "desc";
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { key: raw, dir };
|
||||
}
|
||||
|
||||
export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
// The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
|
||||
// (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".)
|
||||
const [scope, setScopeState] = useState<string>(() => {
|
||||
const v = getAccountRaw(LS.plexLibrary) ?? "both";
|
||||
return ["movie", "show", "both"].includes(v) ? v : "both";
|
||||
});
|
||||
const [show, setShowState] = useState<string>(() => getAccountRaw(LS.plexShow) ?? "all");
|
||||
const [sort, setSortState] = useState<string>(() => getAccountRaw(LS.plexSort) ?? "title");
|
||||
const [scope, setScopeState] = useState<string>(readPlexScope);
|
||||
const [show, setShowState] = useState<string>(readPlexShow);
|
||||
const [sort, setSortState] = useState<SortState>(readPlexSort);
|
||||
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
|
||||
const [filtersRaw, setFiltersRaw] = useState<string>(() => getAccountRaw(LS.plexFilters) ?? "{}");
|
||||
const [filtersRaw, setFiltersRaw] = useState<string>(readPlexFiltersRaw);
|
||||
const filters = useMemo<PlexFilters>(() => {
|
||||
try {
|
||||
return { ...EMPTY_PLEX_FILTERS, ...JSON.parse(filtersRaw) };
|
||||
// Drop the legacy `sortDir` — direction now lives in the sort SortState. Left here it would leak
|
||||
// into the facets query key and be re-persisted on every filter change.
|
||||
const { sortDir: _drop, ...rest } = JSON.parse(filtersRaw);
|
||||
return { ...EMPTY_PLEX_FILTERS, ...rest };
|
||||
} catch {
|
||||
return EMPTY_PLEX_FILTERS;
|
||||
}
|
||||
}, [filtersRaw]);
|
||||
// Survives F5 (persisted), unlike the feed search — see the scope note above.
|
||||
const [q, setQState] = useState<string>(() => getAccountRaw(LS.plexQ) ?? "");
|
||||
const [q, setQState] = useState<string>(readPlexQ);
|
||||
const [playlistOpen, setPlaylistOpen] = useState<number | null>(null);
|
||||
|
||||
const setScope = useCallback((v: string) => {
|
||||
setScopeState(v);
|
||||
setAccountRaw(LS.plexLibrary, v);
|
||||
}, []);
|
||||
const setShow = useCallback((v: string) => {
|
||||
setShowState(v);
|
||||
setAccountRaw(LS.plexShow, v);
|
||||
}, []);
|
||||
const setSort = useCallback((v: string) => {
|
||||
setSortState(v);
|
||||
setAccountRaw(LS.plexSort, v);
|
||||
}, []);
|
||||
const setFilters = useCallback((f: PlexFilters) => {
|
||||
const s = JSON.stringify(f);
|
||||
setFiltersRaw(s);
|
||||
setAccountRaw(LS.plexFilters, s);
|
||||
}, []);
|
||||
const setQ = useCallback((v: string) => {
|
||||
setQState(v);
|
||||
setAccountRaw(LS.plexQ, v);
|
||||
}, []);
|
||||
// Re-read every per-account slice once the account is pinned after mount (first load on a fresh tab).
|
||||
const markDirty = useAccountRehydrate(() => {
|
||||
setScopeState(readPlexScope());
|
||||
setShowState(readPlexShow());
|
||||
setSortState(readPlexSort());
|
||||
setFiltersRaw(readPlexFiltersRaw());
|
||||
setQState(readPlexQ());
|
||||
});
|
||||
|
||||
const setScope = useCallback(
|
||||
(v: string) => {
|
||||
markDirty();
|
||||
setScopeState(v);
|
||||
setAccountRaw(LS.plexLibrary, v);
|
||||
},
|
||||
[markDirty],
|
||||
);
|
||||
const setShow = useCallback(
|
||||
(v: string) => {
|
||||
markDirty();
|
||||
setShowState(v);
|
||||
setAccountRaw(LS.plexShow, v);
|
||||
},
|
||||
[markDirty],
|
||||
);
|
||||
const setSort = useCallback(
|
||||
(v: SortState) => {
|
||||
markDirty();
|
||||
setSortState(v);
|
||||
setAccountRaw(LS.plexSort, JSON.stringify(v));
|
||||
},
|
||||
[markDirty],
|
||||
);
|
||||
const setFilters = useCallback(
|
||||
(f: PlexFilters) => {
|
||||
markDirty();
|
||||
const s = JSON.stringify(f);
|
||||
setFiltersRaw(s);
|
||||
setAccountRaw(LS.plexFilters, s);
|
||||
},
|
||||
[markDirty],
|
||||
);
|
||||
const setQ = useCallback(
|
||||
(v: string) => {
|
||||
markDirty();
|
||||
setQState(v);
|
||||
setAccountRaw(LS.plexQ, v);
|
||||
},
|
||||
[markDirty],
|
||||
);
|
||||
|
||||
const state = useMemo<PlexState>(
|
||||
() => ({ scope, show, sort, filters, q, playlistOpen }),
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
|
||||
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
|
||||
import { api, plexFilterCount, type PlexFilters } from "../lib/api";
|
||||
import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
||||
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import SidePanel from "./SidePanel";
|
||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||
import SortControl from "./SortControl";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
||||
import { usePlexClearFilters } from "./usePlexActiveFilters";
|
||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||
|
||||
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups
|
||||
@@ -24,8 +29,6 @@ type Props = {
|
||||
};
|
||||
|
||||
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
|
||||
const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] as const;
|
||||
const SHOW_SORTS = ["added", "release", "year", "rating", "title"] as const;
|
||||
const RATING_STEPS = [5, 6, 7, 8, 9];
|
||||
const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const;
|
||||
const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[] = [
|
||||
@@ -34,8 +37,8 @@ const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[
|
||||
{ key: "long", min: 120 * 60, max: null },
|
||||
];
|
||||
|
||||
// The any/all combine toggle for a multi-value filter (genres, actors). Same segmented-pill look as
|
||||
// the feed rail's tag MATCH toggle (rounded-full track + inset active pill), not a boxed button row.
|
||||
// The any/all combine toggle for a multi-value filter (genres, actors) — a thin binding of the shared
|
||||
// SegmentedToggle to the Plex match vocabulary (same look as the feed rail's tag MATCH toggle).
|
||||
function MatchToggle({
|
||||
mode,
|
||||
onChange,
|
||||
@@ -45,27 +48,16 @@ function MatchToggle({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-3xs uppercase tracking-wide text-muted">
|
||||
{t("plex.filter.matchLabel")}
|
||||
</span>
|
||||
<div
|
||||
className="flex items-center rounded-full border border-border bg-card p-0.5 text-3xs"
|
||||
role="group"
|
||||
aria-label={t("plex.filter.matchLabel")}
|
||||
>
|
||||
{(["any", "all"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => onChange(m)}
|
||||
aria-pressed={mode === m}
|
||||
className="pill rounded-full px-2 py-0.5"
|
||||
>
|
||||
{t(`plex.filter.match.${m}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<SegmentedToggle
|
||||
label={t("plex.filter.matchLabel")}
|
||||
ariaLabel={t("plex.filter.matchLabel")}
|
||||
options={[
|
||||
{ value: "any", label: t("plex.filter.match.any") },
|
||||
{ value: "all", label: t("plex.filter.match.all") },
|
||||
]}
|
||||
value={mode}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,7 +87,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
||||
onOpenPlaylist(pl.id);
|
||||
}
|
||||
const { sortDir: _sd, collectionTitle: _ct, ...facetKey } = filters;
|
||||
const { collectionTitle: _ct, ...facetKey } = filters;
|
||||
const facetsQ = useQuery({
|
||||
queryKey: qk.plexFacets(scope, show, facetKey),
|
||||
queryFn: () => api.plexFacets(scope, filters, show),
|
||||
@@ -114,21 +106,18 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
const collections = collectionsQ.data?.collections ?? [];
|
||||
|
||||
const fCount = plexFilterCount(filters);
|
||||
const activeCount = fCount + (show !== "all" ? 1 : 0) + (sort !== "title" ? 1 : 0);
|
||||
// Heal against the current scope so a stale out-of-scope key (e.g. movie "duration" after switching
|
||||
// to shows) doesn't inflate the active-filter count while the control + grid show the default.
|
||||
const healedSort = healPlexSort(sort, scope);
|
||||
const activeCount = fCount + (show !== "all" ? 1 : 0) + (healedSort.key !== "title" ? 1 : 0);
|
||||
const anyActive = activeCount > 0;
|
||||
|
||||
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
||||
const toggleIn = (arr: string[], v: string) =>
|
||||
arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v];
|
||||
const clearAll = () => {
|
||||
setFilters(EMPTY_PLEX_FILTERS);
|
||||
setShow("all");
|
||||
setSort("title");
|
||||
};
|
||||
const clearAll = usePlexClearFilters();
|
||||
|
||||
const sorts = [...(scope === "movie" ? MOVIE_SORTS : SHOW_SORTS)].sort((a, b) =>
|
||||
t(`plex.sort.${a}`).localeCompare(t(`plex.sort.${b}`)),
|
||||
);
|
||||
const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`));
|
||||
const durBucketKey = DURATION_BUCKETS.find(
|
||||
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
|
||||
)?.key;
|
||||
@@ -143,20 +132,17 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
id: "scope",
|
||||
title: t("plex.filter.scope"),
|
||||
body: (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{(["both", "movie", "show"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setScope(s)}
|
||||
aria-pressed={s === scope}
|
||||
className={`pill min-w-0 px-1.5 py-1.5 rounded-lg text-xs font-medium truncate ${
|
||||
s === scope ? "" : "glass-card glass-hover"
|
||||
}`}
|
||||
>
|
||||
{t(`plex.filter.scopeOpt.${s}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SegmentedToggle
|
||||
ariaLabel={t("plex.filter.scope")}
|
||||
fullWidth
|
||||
size="md"
|
||||
options={(["both", "movie", "show"] as const).map((s) => ({
|
||||
value: s,
|
||||
label: t(`plex.filter.scopeOpt.${s}`),
|
||||
}))}
|
||||
value={scope}
|
||||
onChange={setScope}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -205,39 +191,26 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
id: "watch",
|
||||
title: t("plex.filter.show"),
|
||||
body: (
|
||||
<ChipRow>
|
||||
{SHOW_OPTS.map((s) => (
|
||||
<Chip key={s} active={show === s} onClick={() => setShow(s)}>
|
||||
{t(`plex.filter.showOpt.${s}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
<FilterChipGroup
|
||||
ariaLabel={t("plex.filter.show")}
|
||||
options={SHOW_OPTS.map((s) => ({ value: s, label: t(`plex.filter.showOpt.${s}`) }))}
|
||||
value={show}
|
||||
onChange={setShow}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "sort",
|
||||
title: t("plex.filter.sort"),
|
||||
body: (
|
||||
<>
|
||||
<ChipRow>
|
||||
{sorts.map((s) => (
|
||||
<Chip key={s} active={sort === s} onClick={() => setSort(s)}>
|
||||
{t(`plex.sort.${s}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
<div className="mt-1.5 flex gap-1">
|
||||
{(["asc", "desc"] as const).map((d) => (
|
||||
<Chip
|
||||
key={d}
|
||||
active={(filters.sortDir ?? "asc") === d}
|
||||
onClick={() => patch({ sortDir: d })}
|
||||
>
|
||||
{t(`plex.filter.dir.${d}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
<SortControl
|
||||
options={sortOptions}
|
||||
sort={healedSort}
|
||||
onChange={(next) => setSort(next ?? PLEX_SORT_FALLBACK)}
|
||||
label={t("plex.filter.sort")}
|
||||
ascLabel={t("plex.filter.dir.asc")}
|
||||
descLabel={t("plex.filter.dir.desc")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(peopleActive
|
||||
@@ -328,16 +301,15 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
id: "rating",
|
||||
title: t("plex.filter.rating"),
|
||||
body: (
|
||||
<ChipRow>
|
||||
<Chip active={filters.ratingMin == null} onClick={() => patch({ ratingMin: null })}>
|
||||
{t("plex.filter.any")}
|
||||
</Chip>
|
||||
{RATING_STEPS.map((r) => (
|
||||
<Chip key={r} active={filters.ratingMin === r} onClick={() => patch({ ratingMin: r })}>
|
||||
{r}+
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
<FilterChipGroup
|
||||
ariaLabel={t("plex.filter.rating")}
|
||||
value={filters.ratingMin == null ? "" : String(filters.ratingMin)}
|
||||
options={[
|
||||
{ value: "", label: t("plex.filter.any") },
|
||||
...RATING_STEPS.map((r) => ({ value: String(r), label: `${r}+` })),
|
||||
]}
|
||||
onChange={(v) => patch({ ratingMin: v === "" ? null : Number(v) })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(facets && facets.genres.length > 0
|
||||
@@ -353,17 +325,22 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
onChange={(m) => patch({ genreMode: m })}
|
||||
/>
|
||||
)}
|
||||
<ChipRow>
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="group"
|
||||
aria-label={t("plex.filter.genre")}
|
||||
>
|
||||
{facets.genres.map((g) => (
|
||||
<Chip
|
||||
<button
|
||||
key={g.value}
|
||||
active={filters.genres.includes(g.value)}
|
||||
onClick={() => patch({ genres: toggleIn(filters.genres, g.value) })}
|
||||
aria-pressed={filters.genres.includes(g.value)}
|
||||
className={filterChipClass}
|
||||
>
|
||||
{g.value}
|
||||
</Chip>
|
||||
</button>
|
||||
))}
|
||||
</ChipRow>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
@@ -394,25 +371,26 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
id: "duration",
|
||||
title: t("plex.filter.duration"),
|
||||
body: (
|
||||
<ChipRow>
|
||||
<Chip
|
||||
active={
|
||||
!durBucketKey && filters.durationMin == null && filters.durationMax == null
|
||||
}
|
||||
onClick={() => patch({ durationMin: null, durationMax: null })}
|
||||
>
|
||||
{t("plex.filter.any")}
|
||||
</Chip>
|
||||
{DURATION_BUCKETS.map((b) => (
|
||||
<Chip
|
||||
key={b.key}
|
||||
active={durBucketKey === b.key}
|
||||
onClick={() => patch({ durationMin: b.min, durationMax: b.max })}
|
||||
>
|
||||
{t(`plex.filter.durationOpt.${b.key}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
<FilterChipGroup
|
||||
ariaLabel={t("plex.filter.duration")}
|
||||
// "Any" is selected only when there is genuinely no duration filter; a set-but-off-bucket
|
||||
// duration (e.g. stale state after a bucket-boundary change) selects nothing, not "Any".
|
||||
value={
|
||||
durBucketKey ??
|
||||
(filters.durationMin == null && filters.durationMax == null ? "" : " | ||||