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);
|
setHoverIndex(null);
|
||||||
},
|
},
|
||||||
selectedIndex: opts.selectedIndex ?? 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,
|
loop: true,
|
||||||
// Focus the first item on open for BOTH click and keyboard (the default 'auto' skips pointer-opens,
|
// 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).
|
// 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 { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { UserPlus } from "lucide-react";
|
import { UserPlus } from "lucide-react";
|
||||||
import { api, type DiscoveredChannel } from "../lib/api";
|
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 { 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 { useCardPager } from "../lib/useCardPager";
|
||||||
import { subsColumn } from "./channelColumns";
|
import { subsColumn } from "./channelColumns";
|
||||||
import { notify } from "../lib/notifications";
|
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
|
// 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.
|
// this tab's own (its columns differ), lifted out of the table so the card layout shares it.
|
||||||
const layout = useChannelLayout();
|
const layout = useChannelLayout();
|
||||||
const [sort, setSortState] = useState<SortState>(() =>
|
const [sort, setSort] = useSortState(LS.channelDiscoverySort);
|
||||||
parseSortState(readAccount(LS.channelDiscoverySort, null)),
|
|
||||||
);
|
|
||||||
const setSort = (s: SortState) => {
|
|
||||||
setSortState(s);
|
|
||||||
writeAccount(LS.channelDiscoverySort, s);
|
|
||||||
};
|
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: qk.discoveredChannels(),
|
queryKey: qk.discoveredChannels(),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
|
import Tabs from "./Tabs";
|
||||||
import Feed from "./Feed";
|
import Feed from "./Feed";
|
||||||
import ChannelAboutContent from "./ChannelAbout";
|
import ChannelAboutContent from "./ChannelAbout";
|
||||||
import { PageToolbar, PageToolbarSlot } from "./PageShell";
|
import { PageToolbar, PageToolbarSlot } from "./PageShell";
|
||||||
@@ -265,11 +266,6 @@ export default function ChannelPage({
|
|||||||
: null,
|
: null,
|
||||||
joined ? t("channel.joined", { date: joined }) : null,
|
joined ? t("channel.joined", { date: joined }) : null,
|
||||||
].filter(Boolean) as string[];
|
].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
|
// 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
|
// 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.
|
// 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
|
{/* 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
|
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. */}
|
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">
|
<div className="flex items-center gap-2 mt-3 border-b border-border pb-2">
|
||||||
<button onClick={() => setTab("videos")} className={tabClass(tab === "videos")}>
|
<Tabs
|
||||||
{t("channel.tabVideos")}
|
className="flex gap-1"
|
||||||
</button>
|
active={tab}
|
||||||
<button onClick={() => setTab("about")} className={tabClass(tab === "about")}>
|
onChange={(id) => setTab(id as "videos" | "about")}
|
||||||
{t("channel.tabAbout")}
|
tabs={[
|
||||||
</button>
|
{ id: "videos", label: t("channel.tabVideos") },
|
||||||
<div className="ml-2 flex items-center gap-2 pb-1.5">
|
{ id: "about", label: t("channel.tabAbout") },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div className="ml-2 flex items-center gap-2">
|
||||||
<a
|
<a
|
||||||
href={ytUrl}
|
href={ytUrl}
|
||||||
target="_blank"
|
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) {
|
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 ? (
|
return onClick ? (
|
||||||
<button
|
<button
|
||||||
type="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 { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
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 { api, type ManagedChannel } from "../lib/api";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
import { accountKey, LS } from "../lib/storage";
|
import { accountKey, LS } from "../lib/storage";
|
||||||
import { formatEta } from "../lib/format";
|
|
||||||
import { buildChannelColumns } from "./channelColumns";
|
import { buildChannelColumns } from "./channelColumns";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import DataTable from "./DataTable";
|
import DataTable from "./DataTable";
|
||||||
|
import Tabs, { type TabDef } from "./Tabs";
|
||||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||||
import { LoadingState, StateMessage } from "./QueryState";
|
import { LoadingState, StateMessage } from "./QueryState";
|
||||||
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
||||||
import { useChannelPriority } from "../lib/useChannelPriority";
|
import { useChannelPriority } from "../lib/useChannelPriority";
|
||||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||||
import ColumnSortControl from "./ColumnSortControl";
|
import ColumnSortControl from "./ColumnSortControl";
|
||||||
|
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
||||||
|
import ActionMenu, { type ActionMenuItem } from "./ActionMenu";
|
||||||
|
import ChannelStatsPopover from "./ChannelStatsPopover";
|
||||||
import Pager from "./Pager";
|
import Pager from "./Pager";
|
||||||
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
|
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
|
||||||
import { sortRows } from "../lib/columnSort";
|
import { sortRows } from "../lib/columnSort";
|
||||||
|
import { filterRows } from "./tableHeader";
|
||||||
import { useCardPager } from "../lib/useCardPager";
|
import { useCardPager } from "../lib/useCardPager";
|
||||||
import { PageToolbar } from "./PageShell";
|
import { PageToolbar } from "./PageShell";
|
||||||
import ChannelDiscovery from "./ChannelDiscovery";
|
import ChannelDiscovery from "./ChannelDiscovery";
|
||||||
@@ -31,15 +36,16 @@ import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
|||||||
import { useWizard } from "./WizardProvider";
|
import { useWizard } from "./WizardProvider";
|
||||||
import { useMe } from "../lib/useMe";
|
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";
|
// 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
|
||||||
const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
|
// Sync column funnel (channelColumns.tsx) so the two can't drift. "" = all (isActive treats it as
|
||||||
{ id: "all", labelKey: "channels.filters.all" },
|
// off). fullySynced = recent_synced && backfill_done (matches the cell's own "fully synced" badge).
|
||||||
{ id: "needs_full", labelKey: "channels.filters.needsFull" },
|
const SYNC_CHIPS: { value: string; labelKey: string }[] = [
|
||||||
{ id: "fully_synced", labelKey: "channels.filters.fullySynced" },
|
{ value: "", labelKey: "channels.filters.all" },
|
||||||
{ id: "hidden", labelKey: "channels.filters.hidden" },
|
{ value: "needsFull", labelKey: "channels.filters.needsFull" },
|
||||||
|
{ value: "fully", labelKey: "channels.filters.fullySynced" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
|
const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
|
||||||
@@ -58,7 +64,7 @@ export default function Channels() {
|
|||||||
// callers); this page is their primary editor.
|
// callers); this page is their primary editor.
|
||||||
const {
|
const {
|
||||||
search,
|
search,
|
||||||
statusFilter,
|
filters,
|
||||||
view,
|
view,
|
||||||
sort,
|
sort,
|
||||||
filtersResetToken,
|
filtersResetToken,
|
||||||
@@ -68,7 +74,7 @@ export default function Channels() {
|
|||||||
const layout = useChannelLayout();
|
const layout = useChannelLayout();
|
||||||
const {
|
const {
|
||||||
setSearch,
|
setSearch,
|
||||||
setStatusFilter,
|
setFilters,
|
||||||
setView,
|
setView,
|
||||||
setLayout,
|
setLayout,
|
||||||
setSort,
|
setSort,
|
||||||
@@ -206,41 +212,41 @@ export default function Channels() {
|
|||||||
notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard),
|
notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Channel-name search (from the shared top SearchBar, via props) + tag-chip filtering, applied
|
// The tag-chip row is a controlled view over the Tags COLUMN filter (`filters.tags`, a string[] of
|
||||||
// client-side over the status-filtered list.
|
// tag ids), the same way the sync chips drive `filters.sync` — so the top chips and the Tags column
|
||||||
const [tagFilter, setTagFilter] = useState<number[]>([]);
|
// 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.
|
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusChannelName) setSearch(focusChannelName);
|
if (focusChannelName) setSearch(focusChannelName);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [focusChannelToken]);
|
}, [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);
|
const resetRef = useRef(filtersResetToken);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filtersResetToken === resetRef.current) return;
|
if (filtersResetToken === resetRef.current) return;
|
||||||
resetRef.current = filtersResetToken;
|
resetRef.current = filtersResetToken;
|
||||||
setSearch("");
|
setSearch("");
|
||||||
setTagFilter([]);
|
|
||||||
}, [filtersResetToken]);
|
}, [filtersResetToken]);
|
||||||
|
|
||||||
// The Sync-status filter stays a top-level chip set (not a column filter) because the
|
// The sync-status AND tag filters are now controlled column filters (`filters.sync` / `filters.tags`)
|
||||||
// header's "go to full history" deep-link drives it; search/tag filtering + sort + pagination
|
// shared by the top chips, the column funnels, and the card layout — no separate pre-filter. Hidden
|
||||||
// run over whatever the status chip leaves.
|
// channels have their own tab, so they're excluded from the subscribed table (no more dimmed rows).
|
||||||
const channels = (channelsQuery.data ?? []).filter((c) =>
|
// Only the name search filters here; the column filters (sync/tags/priority/text funnels) are applied
|
||||||
statusFilter === "needs_full"
|
// by DataTable in table mode and by filterRows below in card mode, over the same `columns`.
|
||||||
? !c.backfill_done
|
const hiddenChannels = (channelsQuery.data ?? []).filter((c) => c.hidden);
|
||||||
: statusFilter === "fully_synced"
|
const channels = (channelsQuery.data ?? []).filter((c) => !c.hidden);
|
||||||
? c.backfill_done
|
|
||||||
: statusFilter === "hidden"
|
|
||||||
? c.hidden
|
|
||||||
: true,
|
|
||||||
);
|
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
const visibleChannels = channels.filter((c) => {
|
const visibleChannels = channels.filter(
|
||||||
if (q && !`${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q)) return false;
|
(c) => !q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q),
|
||||||
if (tagFilter.length && !tagFilter.some((id) => c.tag_ids.includes(id))) return false;
|
);
|
||||||
return true;
|
|
||||||
});
|
|
||||||
const s = statusQuery.data;
|
const s = statusQuery.data;
|
||||||
|
|
||||||
const onView = (c: ManagedChannel) =>
|
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
|
// Status chips sit in the table's controls row (next to paging) — compact and close to
|
||||||
// where they act.
|
// where they act.
|
||||||
|
const syncFilter = typeof filters.sync === "string" ? filters.sync : "";
|
||||||
const statusChips = (
|
const statusChips = (
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<FilterChipGroup
|
||||||
{STATUS_FILTERS.map((f) => (
|
ariaLabel={t("channels.filters.syncLabel")}
|
||||||
<button
|
className="flex flex-wrap items-center gap-1.5"
|
||||||
key={f.id}
|
options={SYNC_CHIPS.map((c) => ({ value: c.value, label: t(c.labelKey) }))}
|
||||||
onClick={() => setStatusFilter(f.id)}
|
value={syncFilter}
|
||||||
aria-pressed={statusFilter === f.id}
|
onChange={(sync) => setFilters({ ...filters, sync })}
|
||||||
className="pill text-xs px-2.5 py-1 rounded-full border"
|
/>
|
||||||
>
|
|
||||||
{t(f.labelKey)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// The catalog-wide stats ride on the right of the tab row (subscribed only), leaving room for
|
// The catalog-wide stats collapse into a compact "Stats" hover chip (right of the tab row, subscribed
|
||||||
// future tabs on the left.
|
// only) instead of a crowded inline row.
|
||||||
const statsBlock = (
|
const statsBlock = s ? <ChannelStatsPopover status={s} /> : null;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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 = (
|
const actionButtons = (
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<ActionMenu ariaLabel={t("channels.actionsLabel")} items={catalogActions} />
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Table / cards density — shared by the subscribed + discovery tables (blocked is a chip list, no
|
// 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
|
// 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.
|
// too would copy+sort the whole ~300-row array on every render for a result nothing reads.
|
||||||
const isCards = layout === "cards";
|
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 {
|
const {
|
||||||
paged: pagedCards,
|
paged: pagedCards,
|
||||||
pagerProps,
|
pagerProps,
|
||||||
@@ -420,23 +385,25 @@ export default function Channels() {
|
|||||||
// handled by the hook's clamp instead.
|
// handled by the hook's clamp instead.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resetPage();
|
resetPage();
|
||||||
}, [statusFilter, search, tagFilter, sort, resetPage]);
|
}, [filters, search, sort, resetPage]);
|
||||||
const cardPager = <Pager {...pagerProps} />;
|
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 = (
|
const tabs = (
|
||||||
<div className="px-4 pt-4 max-w-384 mx-auto flex items-center justify-between gap-4 flex-wrap">
|
<div className="px-4 pt-2 max-w-384 mx-auto flex items-center justify-between gap-4 flex-wrap">
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<Tabs
|
||||||
{(["subscribed", "discovery", "blocked"] as ChannelsView[]).map((v) => (
|
tabs={viewTabs}
|
||||||
<button
|
active={view}
|
||||||
key={v}
|
onChange={(id) => setView(id as ChannelsView)}
|
||||||
onClick={() => setView(v)}
|
className="flex flex-wrap items-center gap-1"
|
||||||
aria-pressed={view === v}
|
/>
|
||||||
className="pill text-sm px-3 py-1.5 rounded-full border"
|
|
||||||
>
|
|
||||||
{t(`channels.tabs.${v}`)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{view === "subscribed" && statsBlock}
|
{view === "subscribed" && statsBlock}
|
||||||
{view === "subscribed" && layout === "cards" && sortControl}
|
{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. */}
|
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 items-center justify-between gap-4 flex-wrap">
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<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")}>
|
<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">
|
<button
|
||||||
{t("channels.tags.yourTags")}
|
onClick={() => setTagManagerOpen(true)}
|
||||||
</span>
|
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>
|
</Tooltip>
|
||||||
{userTags.map((tg) => {
|
{userTags.map((tg) => (
|
||||||
const active = tagFilter.includes(tg.id);
|
<button
|
||||||
return (
|
key={tg.id}
|
||||||
<button
|
onClick={() => toggleTagFilter(tg.id)}
|
||||||
key={tg.id}
|
aria-pressed={tagFilter.includes(String(tg.id))}
|
||||||
onClick={() =>
|
className={filterChipClass}
|
||||||
setTagFilter((f) => (active ? f.filter((x) => x !== tg.id) : [...f, tg.id]))
|
>
|
||||||
}
|
{tg.name}
|
||||||
aria-pressed={active}
|
</button>
|
||||||
className="pill text-xs px-2 py-1 rounded-full border"
|
))}
|
||||||
>
|
|
||||||
{tg.name}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{tagFilter.length > 0 && (
|
{tagFilter.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setTagFilter([])}
|
onClick={() => setFilters({ ...filters, tags: [] })}
|
||||||
className="text-xs px-2 py-1 text-muted hover:text-accent transition"
|
className="text-xs px-2 py-1 text-muted hover:text-accent transition"
|
||||||
>
|
>
|
||||||
{t("datatable.clear")}
|
{t("datatable.clear")}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
{actionButtons}
|
{actionButtons}
|
||||||
</div>
|
</div>
|
||||||
@@ -521,37 +482,33 @@ export default function Channels() {
|
|||||||
) : view === "blocked" ? (
|
) : view === "blocked" ? (
|
||||||
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
|
/* 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">
|
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
|
||||||
{(blockedQuery.data?.length ?? 0) === 0 ? (
|
<ExcludedChannelList
|
||||||
<p className="text-sm text-muted py-8">{t("channels.blocked.empty")}</p>
|
channels={blockedQuery.data ?? []}
|
||||||
) : (
|
icon={<Ban className="w-4 h-4 text-muted" />}
|
||||||
<>
|
title={t("channels.blocked.title")}
|
||||||
<div className="flex items-center gap-2 mb-3">
|
hint={t("channels.blocked.hint")}
|
||||||
<Ban className="w-4 h-4 text-muted" />
|
emptyText={t("channels.blocked.empty")}
|
||||||
<span className="text-xs uppercase tracking-wide text-muted">
|
actionLabel={t("channel.unblock")}
|
||||||
{t("channels.blocked.title")}
|
actionIcon={<X className="w-3.5 h-3.5" />}
|
||||||
</span>
|
onAction={(id) => unblock.mutate(id)}
|
||||||
</div>
|
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
|
||||||
<div className="flex flex-wrap gap-1.5">
|
/>
|
||||||
{blockedQuery.data!.map((c) => (
|
</div>
|
||||||
<span
|
) : view === "hidden" ? (
|
||||||
key={c.id}
|
/* Hidden channels — kept out of the Mine feed but still in the shared Library; managed here
|
||||||
className="inline-flex items-center gap-1.5 text-xs pl-2.5 pr-1 py-1 rounded-full bg-card border border-border"
|
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">
|
||||||
{c.title ?? c.id}
|
<ExcludedChannelList
|
||||||
<button
|
channels={hiddenChannels}
|
||||||
onClick={() => unblock.mutate(c.id)}
|
icon={<EyeOff className="w-4 h-4 text-muted" />}
|
||||||
title={t("channel.unblock")}
|
title={t("channels.hidden.title")}
|
||||||
aria-label={t("channel.unblock")}
|
hint={t("channels.hidden.hint")}
|
||||||
className="rounded-full p-0.5 text-muted hover:text-danger transition"
|
emptyText={t("channels.hidden.empty")}
|
||||||
>
|
actionLabel={t("channels.hidden.unhide")}
|
||||||
<X className="w-3 h-3" />
|
actionIcon={<Eye className="w-3.5 h-3.5" />}
|
||||||
</button>
|
onAction={(id) => patch.mutate({ id, body: { hidden: false } })}
|
||||||
</span>
|
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
|
||||||
))}
|
/>
|
||||||
</div>
|
|
||||||
<p className="text-2xs text-muted mt-2">{t("channels.blocked.hint")}</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when
|
/* 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"
|
controlsBandClassName="px-4 max-w-384 mx-auto"
|
||||||
sort={sort}
|
sort={sort}
|
||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
filters={filters}
|
||||||
|
onFiltersChange={setFilters}
|
||||||
emptyText={t("channels.empty")}
|
emptyText={t("channels.empty")}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -591,7 +549,6 @@ export default function Channels() {
|
|||||||
leadKey="priority"
|
leadKey="priority"
|
||||||
aboutKey="about"
|
aboutKey="about"
|
||||||
actionsKey="actions"
|
actionsKey="actions"
|
||||||
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
|
||||||
emptyText={t("channels.empty")}
|
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,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} 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 { 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 { 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,
|
// Owns the Channels module's shared local state — the rail-less manager's name filter, column-filter
|
||||||
// active tab, the "drop a stale column filter" token, and the focus-a-channel intent — lifted out of
|
// 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
|
// 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
|
// 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,
|
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
|
||||||
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
|
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
|
||||||
//
|
//
|
||||||
// Nested INSIDE NavigationProvider so its actions can drive navigation: focusChannel and
|
// 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
|
// 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.
|
// 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 {
|
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 = {
|
type ChannelsState = {
|
||||||
search: string;
|
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;
|
view: ChannelsView;
|
||||||
// The subscribed table's sort, lifted out of DataTable so the card layout shares it.
|
// The subscribed table's sort, lifted out of DataTable so the card layout shares it.
|
||||||
sort: SortState;
|
sort: SortState;
|
||||||
@@ -57,7 +63,7 @@ type ChannelsState = {
|
|||||||
|
|
||||||
interface ChannelsActions {
|
interface ChannelsActions {
|
||||||
setSearch: (q: string) => void;
|
setSearch: (q: string) => void;
|
||||||
setStatusFilter: (f: ChannelStatusFilter) => void;
|
setFilters: (f: FilterMap) => void;
|
||||||
setView: (v: ChannelsView) => void;
|
setView: (v: ChannelsView) => void;
|
||||||
setLayout: (l: ChannelLayout) => void;
|
setLayout: (l: ChannelLayout) => void;
|
||||||
setSort: (s: SortState) => void;
|
setSort: (s: SortState) => void;
|
||||||
@@ -70,7 +76,7 @@ interface ChannelsActions {
|
|||||||
|
|
||||||
const StateContext = createContext<ChannelsState>({
|
const StateContext = createContext<ChannelsState>({
|
||||||
search: "",
|
search: "",
|
||||||
statusFilter: "all",
|
filters: {},
|
||||||
view: "subscribed",
|
view: "subscribed",
|
||||||
sort: null,
|
sort: null,
|
||||||
filtersResetToken: 0,
|
filtersResetToken: 0,
|
||||||
@@ -83,7 +89,7 @@ const StateContext = createContext<ChannelsState>({
|
|||||||
const LayoutContext = createContext<ChannelLayout>("table");
|
const LayoutContext = createContext<ChannelLayout>("table");
|
||||||
const ActionsContext = createContext<ChannelsActions>({
|
const ActionsContext = createContext<ChannelsActions>({
|
||||||
setSearch: () => {},
|
setSearch: () => {},
|
||||||
setStatusFilter: () => {},
|
setFilters: () => {},
|
||||||
setView: () => {},
|
setView: () => {},
|
||||||
setLayout: () => {},
|
setLayout: () => {},
|
||||||
setSort: () => {},
|
setSort: () => {},
|
||||||
@@ -108,18 +114,20 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
|||||||
const { setPage } = useNavigationActions();
|
const { setPage } = useNavigationActions();
|
||||||
|
|
||||||
const [search, setSearchState] = useState("");
|
const [search, setSearchState] = useState("");
|
||||||
const [statusFilter, setStatusFilterState] = useState<ChannelStatusFilter>(() =>
|
// The subscribed table's column-filter map, owned here (lifted out of DataTable) so the top status
|
||||||
clampStatus(getAccountRaw(LS.channelFilter)),
|
// chips, the Sync column funnel, and the card layout share one state; useFilterState persists it.
|
||||||
);
|
const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip);
|
||||||
const [view, setViewState] = useState<ChannelsView>(() =>
|
// view + layout are persisted tabs (usePersistedTab = the shared account-scoped tab-state hook), so
|
||||||
clampView(getAccountRaw(LS.channelsView)),
|
// 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
|
||||||
const [layout, setLayoutState] = useState<ChannelLayout>(() =>
|
// heal them for display. setView layers the history logic (below) on top of the raw setter.
|
||||||
parseChannelLayout(getAccountRaw(LS.channelLayout)),
|
const [viewRaw, setViewRaw] = usePersistedTab(LS.channelsView, "subscribed");
|
||||||
);
|
const view = clampView(viewRaw);
|
||||||
const [sort, setSortState] = useState<SortState>(() =>
|
const [layoutRaw, setLayout] = usePersistedTab(LS.channelLayout, "table");
|
||||||
parseSortState(readAccount(LS.channelSort, null)),
|
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 [filtersResetToken, setFiltersResetToken] = useState(0);
|
||||||
const [focusName, setFocusName] = useState<string | null>(null);
|
const [focusName, setFocusName] = useState<string | null>(null);
|
||||||
const [focusToken, setFocusToken] = useState(0);
|
const [focusToken, setFocusToken] = useState(0);
|
||||||
@@ -131,37 +139,26 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
|||||||
viewRef.current = view;
|
viewRef.current = view;
|
||||||
|
|
||||||
const setSearch = useCallback((q: string) => setSearchState(q), []);
|
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
|
// 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
|
// live YouTube search (`_yt`) do — so Back steps back through the tabs you actually visited instead
|
||||||
// of jumping straight out to the previous module.
|
// of jumping straight out to the previous module.
|
||||||
const setView = useCallback((v: ChannelsView) => {
|
const setView = useCallback(
|
||||||
// Re-selecting the tab you are already on is not a navigation — bail before pushing, or clicking
|
(v: ChannelsView) => {
|
||||||
// the active tab (and goToFullHistory forcing "subscribed") stacks Back entries that do nothing.
|
// Re-selecting the tab you are already on is not a navigation — bail before pushing, or clicking
|
||||||
if (v === viewRef.current) return;
|
// the active tab (and goToFullHistory forcing "subscribed") stacks Back entries that do nothing.
|
||||||
setViewState(v);
|
if (v === viewRef.current) return;
|
||||||
setAccountRaw(LS.channelsView, v);
|
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
|
// 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
|
// (focusChannel / goToFullHistory) lets setPage push the entry — the stamp effect labels it — so
|
||||||
// one navigation never costs two Back presses.
|
// one navigation never costs two Back presses.
|
||||||
if (window.history.state?.sfPage === "channels") {
|
if (window.history.state?.sfPage === "channels") {
|
||||||
// A FRESH state, like setPage's: carrying the old entry's `_chan`/`_yt` markers over would make
|
// 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.
|
// Back re-open the channel overlay or a spent search on top of the tab.
|
||||||
window.history.pushState({ sfPage: "channels", _chTab: v }, "");
|
window.history.pushState({ sfPage: "channels", _chTab: v }, "");
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
const setLayout = useCallback((l: ChannelLayout) => {
|
[setViewRaw],
|
||||||
setLayoutState(l);
|
);
|
||||||
setAccountRaw(LS.channelLayout, l);
|
|
||||||
}, []);
|
|
||||||
const setSort = useCallback((s: SortState) => {
|
|
||||||
setSortState(s);
|
|
||||||
writeAccount(LS.channelSort, s);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const focusChannel = useCallback(
|
const focusChannel = useCallback(
|
||||||
(name: string) => {
|
(name: string) => {
|
||||||
setFocusName(name);
|
setFocusName(name);
|
||||||
@@ -172,11 +169,13 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
|||||||
[setView, setPage],
|
[setView, setPage],
|
||||||
);
|
);
|
||||||
const goToFullHistory = useCallback(() => {
|
const goToFullHistory = useCallback(() => {
|
||||||
setStatusFilter("needs_full");
|
// One controlled map replaces the old status chip: sync=needsFull, and dropping every other
|
||||||
setView("subscribed"); // the status filter applies to the subscriptions tab
|
// column filter (the whole map is set fresh) so no stale funnel hides the rows we're sent to see.
|
||||||
setFiltersResetToken((n) => n + 1); // drop any stale column filter hiding the rows
|
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");
|
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.
|
// Clear the manager focus when leaving the Channels page so it doesn't re-apply on a later visit.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -196,29 +195,27 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
|||||||
function onPop(e: PopStateEvent) {
|
function onPop(e: PopStateEvent) {
|
||||||
const tab = e.state?._chTab;
|
const tab = e.state?._chTab;
|
||||||
if (!tab) return; // an entry outside the manager — leave the tab alone
|
if (!tab) return; // an entry outside the manager — leave the tab alone
|
||||||
const next = clampView(tab as string);
|
setViewRaw(clampView(tab as string)); // persists the restored tab (setViewRaw is stable)
|
||||||
setViewState(next);
|
|
||||||
setAccountRaw(LS.channelsView, next);
|
|
||||||
}
|
}
|
||||||
window.addEventListener("popstate", onPop);
|
window.addEventListener("popstate", onPop);
|
||||||
return () => window.removeEventListener("popstate", onPop);
|
return () => window.removeEventListener("popstate", onPop);
|
||||||
}, []);
|
}, [setViewRaw]);
|
||||||
|
|
||||||
const state = useMemo<ChannelsState>(
|
const state = useMemo<ChannelsState>(
|
||||||
() => ({ search, statusFilter, view, sort, filtersResetToken, focusName, focusToken }),
|
() => ({ search, filters, view, sort, filtersResetToken, focusName, focusToken }),
|
||||||
[search, statusFilter, view, sort, filtersResetToken, focusName, focusToken],
|
[search, filters, view, sort, filtersResetToken, focusName, focusToken],
|
||||||
);
|
);
|
||||||
const actions = useMemo<ChannelsActions>(
|
const actions = useMemo<ChannelsActions>(
|
||||||
() => ({
|
() => ({
|
||||||
setSearch,
|
setSearch,
|
||||||
setStatusFilter,
|
setFilters,
|
||||||
setView,
|
setView,
|
||||||
setLayout,
|
setLayout,
|
||||||
setSort,
|
setSort,
|
||||||
focusChannel,
|
focusChannel,
|
||||||
goToFullHistory,
|
goToFullHistory,
|
||||||
}),
|
}),
|
||||||
[setSearch, setStatusFilter, setView, setLayout, setSort, focusChannel, goToFullHistory],
|
[setSearch, setFilters, setView, setLayout, setSort, focusChannel, goToFullHistory],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
|
||||||
import { type Column } from "./DataTable";
|
import { type Column } from "./DataTable";
|
||||||
import { type SortState } from "../lib/columnSort";
|
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):
|
// The headerless sort control for views without clickable column headers (the channel manager's and
|
||||||
// a dropdown of the sortable columns + an asc/desc toggle, mirroring the feed toolbar's sort. It's
|
// discovery's card layouts). Now a thin adapter over the generic `SortControl`, feeding it the
|
||||||
// driven by the SAME `SortState` the DataTable header uses, so table and cards stay in sync. The
|
// column-derived options and a "no sort" default — kept as its own component so the existing call
|
||||||
// dropdown's options come straight from the columns' `sortable`/`sortValue` metadata — no new list.
|
// sites stay untouched and table/cards keep sharing one `SortState`.
|
||||||
export default function ColumnSortControl<T>({
|
export default function ColumnSortControl<T>({
|
||||||
columns,
|
columns,
|
||||||
sort,
|
sort,
|
||||||
@@ -25,44 +26,15 @@ export default function ColumnSortControl<T>({
|
|||||||
ascLabel: string;
|
ascLabel: string;
|
||||||
descLabel: 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 (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<SortControl
|
||||||
<select
|
options={optionsFromColumns(columns)}
|
||||||
value={active?.key ?? ""}
|
sort={sort}
|
||||||
onChange={(e) => {
|
onChange={onChange}
|
||||||
const key = e.target.value;
|
label={label}
|
||||||
// Keep the direction when re-picking the same column; a new column starts ascending.
|
defaultLabel={defaultLabel}
|
||||||
onChange(key ? { key, dir: active?.key === key ? active.dir : "asc" } : null);
|
ascLabel={ascLabel}
|
||||||
}}
|
descLabel={descLabel}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
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 {
|
function loadPersist(key?: string): Persisted {
|
||||||
const empty: Persisted = { sort: null, filters: {}, page: 0 };
|
const empty: Persisted = { sort: null, filters: {}, page: 0 };
|
||||||
if (!key) return empty;
|
if (!key) return empty;
|
||||||
@@ -53,6 +58,8 @@ export default function DataTable<T>({
|
|||||||
resetFiltersToken,
|
resetFiltersToken,
|
||||||
sort: sortProp,
|
sort: sortProp,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
|
filters: filtersProp,
|
||||||
|
onFiltersChange,
|
||||||
}: {
|
}: {
|
||||||
rows: T[];
|
rows: T[];
|
||||||
columns: Column<T>[];
|
columns: Column<T>[];
|
||||||
@@ -82,6 +89,13 @@ export default function DataTable<T>({
|
|||||||
// persistKey-backed sort.
|
// persistKey-backed sort.
|
||||||
sort?: SortState;
|
sort?: SortState;
|
||||||
onSortChange?: (next: SortState) => void;
|
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 { t } = useTranslation();
|
||||||
const initial = loadPersist(persistKey);
|
const initial = loadPersist(persistKey);
|
||||||
@@ -90,17 +104,42 @@ export default function DataTable<T>({
|
|||||||
const controlledSort = onSortChange !== undefined;
|
const controlledSort = onSortChange !== undefined;
|
||||||
const [internalSort, setInternalSort] = useState<SortState>(initial.sort);
|
const [internalSort, setInternalSort] = useState<SortState>(initial.sort);
|
||||||
const sort = controlledSort ? (sortProp ?? null) : internalSort;
|
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 [page, setPage] = useState(initial.page);
|
||||||
const [size, setSize] = useState(initial.size ?? pageSize);
|
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(() => {
|
useEffect(() => {
|
||||||
if (persistKey) localStorage.setItem(persistKey, JSON.stringify({ sort, filters, page, size }));
|
if (!persistKey) return;
|
||||||
}, [persistKey, sort, filters, page, size]);
|
localStorage.setItem(
|
||||||
|
persistKey,
|
||||||
|
JSON.stringify({
|
||||||
|
sort: controlledSort ? null : sort,
|
||||||
|
filters: controlledFilters ? {} : filters,
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [persistKey, controlledSort, controlledFilters, sort, filters, page, size]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!externalFilter) return;
|
if (!externalFilter) return;
|
||||||
setFilters((f) => ({ ...f, [externalFilter.key]: externalFilter.value }));
|
commitFilters({ ...filtersRef.current, [externalFilter.key]: externalFilter.value });
|
||||||
setPage(0);
|
setPage(0);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [externalFilter?.key, externalFilter?.value]);
|
}, [externalFilter?.key, externalFilter?.value]);
|
||||||
@@ -113,8 +152,9 @@ export default function DataTable<T>({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (resetFiltersToken === undefined || resetFiltersToken === resetRef.current) return;
|
if (resetFiltersToken === undefined || resetFiltersToken === resetRef.current) return;
|
||||||
resetRef.current = resetFiltersToken;
|
resetRef.current = resetFiltersToken;
|
||||||
setFilters({});
|
commitFilters({});
|
||||||
setPage(0);
|
setPage(0);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [resetFiltersToken]);
|
}, [resetFiltersToken]);
|
||||||
|
|
||||||
const sorted = sortRows(filterRows(rows, columns, filters), columns, sort);
|
const sorted = sortRows(filterRows(rows, columns, filters), columns, sort);
|
||||||
@@ -132,7 +172,7 @@ export default function DataTable<T>({
|
|||||||
setPage(0);
|
setPage(0);
|
||||||
}
|
}
|
||||||
function applyFilter(key: string, value: string | string[]) {
|
function applyFilter(key: string, value: string | string[]) {
|
||||||
setFilters((f) => ({ ...f, [key]: value }));
|
commitFilters({ ...filters, [key]: value });
|
||||||
setPage(0);
|
setPage(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +236,7 @@ export default function DataTable<T>({
|
|||||||
{paged.map((row) => (
|
{paged.map((row) => (
|
||||||
<tr
|
<tr
|
||||||
key={rowKey(row)}
|
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) ?? ""
|
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";
|
} from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
|
||||||
ArrowUp,
|
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Columns2,
|
Columns2,
|
||||||
Grid3x3,
|
Grid3x3,
|
||||||
@@ -34,12 +32,15 @@ import {
|
|||||||
SORT_KEYS,
|
SORT_KEYS,
|
||||||
type SortKey,
|
type SortKey,
|
||||||
} from "../lib/feedSort";
|
} from "../lib/feedSort";
|
||||||
|
import { type SortOption } from "../lib/columnSort";
|
||||||
import { FEED_VIEWS, type FeedView } from "../lib/feedView";
|
import { FEED_VIEWS, type FeedView } from "../lib/feedView";
|
||||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||||
import ActiveFilterChips from "./ActiveFilterChips";
|
import ActiveFilterChips from "./ActiveFilterChips";
|
||||||
import { LoadingState, StateMessage } from "./QueryState";
|
import { LoadingState, StateMessage } from "./QueryState";
|
||||||
import { PageToolbar } from "./PageShell";
|
import { PageToolbar } from "./PageShell";
|
||||||
import { useWizard } from "./WizardProvider";
|
import { useWizard } from "./WizardProvider";
|
||||||
|
import SortControl from "./SortControl";
|
||||||
|
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
||||||
import ViewSwitcher from "./ViewSwitcher";
|
import ViewSwitcher from "./ViewSwitcher";
|
||||||
import VirtualFeed from "./VirtualFeed";
|
import VirtualFeed from "./VirtualFeed";
|
||||||
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
|
// 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);
|
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 = (
|
const toolbar = (
|
||||||
<div className="pb-3">
|
<div className="pb-3">
|
||||||
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
|
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
|
||||||
{SHOW_IDS.map((id) => (
|
<FilterChipGroup
|
||||||
<button
|
ariaLabel={t("feed.showLabel")}
|
||||||
key={id}
|
options={SHOW_IDS.map((id) => ({
|
||||||
data-testid={`feed-show-${id}`}
|
value: id,
|
||||||
onClick={() => setFilters({ ...filters, show: id })}
|
label: t("sidebar.show." + id),
|
||||||
aria-pressed={filters.show === id}
|
testId: `feed-show-${id}`,
|
||||||
className="pill text-xs px-3 py-1.5 rounded-full border"
|
}))}
|
||||||
>
|
value={filters.show}
|
||||||
{t("sidebar.show." + id)}
|
onChange={(show) => setFilters({ ...filters, show })}
|
||||||
</button>
|
/>
|
||||||
))}
|
|
||||||
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||||
{CONTENT.map((c) => {
|
{/* Content-type toggles are MULTI-select (independent booleans) — same chip look via
|
||||||
const on = filters[c.key];
|
filterChipClass, but not a single-select group. */}
|
||||||
return (
|
<div className="flex flex-wrap gap-1.5" role="group" aria-label={t("feed.contentLabel")}>
|
||||||
<button
|
{CONTENT.map((c) => {
|
||||||
key={c.key}
|
const on = filters[c.key];
|
||||||
data-testid={`feed-content-${c.key}`}
|
return (
|
||||||
onClick={() => setFilters({ ...filters, [c.key]: !on })}
|
<button
|
||||||
aria-pressed={on}
|
key={c.key}
|
||||||
className="pill text-xs px-3 py-1.5 rounded-full border"
|
data-testid={`feed-content-${c.key}`}
|
||||||
>
|
onClick={() => setFilters({ ...filters, [c.key]: !on })}
|
||||||
{t(c.label)}
|
aria-pressed={on}
|
||||||
</button>
|
className={filterChipClass}
|
||||||
);
|
>
|
||||||
})}
|
{t(c.label)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
{/* The Source filter (organic / include-search / search-only) is a global-catalog concept —
|
{/* 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
|
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
|
of this one channel's videos (librarySource is pinned to "all"), so the selector would
|
||||||
@@ -615,58 +631,33 @@ export default function Feed({
|
|||||||
</span>
|
</span>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<span className="text-xs text-muted">{t("feed.sortLabel")}</span>
|
<span className="text-xs text-muted">{t("feed.sortLabel")}</span>
|
||||||
<select
|
<SortControl
|
||||||
value={sortKey}
|
options={sortOptions}
|
||||||
onChange={(e) => {
|
sort={{ key: sortKey, dir: sortDir }}
|
||||||
const key = e.target.value as SortKey;
|
onChange={(next) => {
|
||||||
const dir = DIRECTIONLESS.includes(key)
|
if (!next) return; // the feed is always sorted — no "no sort" option is offered
|
||||||
? "desc"
|
|
||||||
: SORT_DEFAULT_DIR[key as Exclude<SortKey, "shuffle" | "relevance">];
|
|
||||||
setFilters({
|
setFilters({
|
||||||
...filters,
|
...filters,
|
||||||
sort: buildSort(key, dir),
|
sort: buildSort(next.key as SortKey, next.dir),
|
||||||
seed: key === "shuffle" ? rollSeed() : undefined,
|
seed: next.key === "shuffle" ? rollSeed() : undefined,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
aria-label={t("feed.sortLabel")}
|
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"
|
ascLabel={t("feed.dirAsc")}
|
||||||
>
|
descLabel={t("feed.dirDesc")}
|
||||||
{/* Relevance only ranks meaningfully when there's a search term — offer it then. */}
|
trailing={
|
||||||
{(filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys).map((k) => (
|
sortKey === "shuffle" ? (
|
||||||
<option key={k} value={k}>
|
<button
|
||||||
{t("feed.sortKey." + k)}
|
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
|
||||||
</option>
|
title={t("sidebar.reshuffle")}
|
||||||
))}
|
aria-label={t("sidebar.reshuffle")}
|
||||||
</select>
|
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"
|
||||||
{!DIRECTIONLESS.includes(sortKey) && (
|
>
|
||||||
<button
|
<RefreshCw className="w-4 h-4" />
|
||||||
onClick={() =>
|
</button>
|
||||||
setFilters({
|
) : undefined
|
||||||
...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>
|
|
||||||
)}
|
|
||||||
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
|
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
|
||||||
<ViewSwitcher
|
<ViewSwitcher
|
||||||
value={view}
|
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 { isPage, readPage, type Page } from "../lib/urlState";
|
||||||
import { pageTitleKey } from "../lib/pageMeta";
|
import { pageTitleKey } from "../lib/pageMeta";
|
||||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||||
|
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
||||||
import { setNavigator } from "../lib/nav";
|
import { setNavigator } from "../lib/nav";
|
||||||
|
|
||||||
// Owns the app's navigation + browser-history state, lifted out of the App god component so pages
|
// 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);
|
const channelViewRef = useRef(channelView);
|
||||||
channelViewRef.current = channelView;
|
channelViewRef.current = channelView;
|
||||||
|
|
||||||
const setPage = useCallback((next: Page) => {
|
// The landing page is per-account (PAGE_KEY), but this provider mounts above <App>, which pins the
|
||||||
// A channel page overlays the content column, so a nav click must close it even when the
|
// tab's account only after /api/me — so on a fresh tab `loadInitialPage` reads an unknown account and
|
||||||
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
|
// falls back to "feed". Re-read once the account is pinned and correct the CURRENT history entry
|
||||||
if (next === pageRef.current && !channelViewRef.current) return;
|
// (replaceState, not push) so Back isn't given a phantom "feed" step. markDirty (set by setPage)
|
||||||
setChannelView(null); // leave any open channel page when navigating via the rail
|
// skips this if the user already navigated in the pre-pin window.
|
||||||
setPageState(next);
|
const markDirty = useAccountRehydrate(() => {
|
||||||
setAccountRaw(PAGE_KEY, next);
|
const stored = loadInitialPage();
|
||||||
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the
|
if (stored === pageRef.current) return;
|
||||||
// app. The URL stays clean — the page rides in history.state. A fresh { sfPage } (no carried-over
|
setPageState(stored);
|
||||||
// _yt/_chan markers) so each page starts at its root.
|
setAccountRaw(PAGE_KEY, stored); // no-op re-write, keeps storage authoritative
|
||||||
window.history.pushState({ sfPage: next }, "");
|
// 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) => {
|
const openChannel = useCallback((id: string, name?: string) => {
|
||||||
setChannelView({ id, name });
|
setChannelView({ id, name });
|
||||||
const st = window.history.state || {};
|
const st = window.history.state || {};
|
||||||
@@ -129,6 +153,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const enterYtSearch = useCallback((q: string) => {
|
const enterYtSearch = useCallback((q: string) => {
|
||||||
|
// Overlay, not a page change — see openChannel: no markDirty, the re-hydrate preserves _yt.
|
||||||
setYtSearch(q);
|
setYtSearch(q);
|
||||||
// Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps it
|
// 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.
|
// 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 { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
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
|
// 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
|
// — 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);
|
return useContext(ActionsContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readSelectedId(): number | null {
|
||||||
|
const s = getAccountRaw(LS.playlist);
|
||||||
|
return s ? Number(s) : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function PlaylistsProvider({ children }: { children: ReactNode }) {
|
export function PlaylistsProvider({ children }: { children: ReactNode }) {
|
||||||
const [search, setSearchState] = useState("");
|
const [search, setSearchState] = useState("");
|
||||||
const [selectedId, setSelectedIdState] = useState<number | null>(() => {
|
const [selectedId, setSelectedIdState] = useState<number | null>(readSelectedId);
|
||||||
const s = getAccountRaw(LS.playlist);
|
|
||||||
return s ? Number(s) : null;
|
// 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 setSearch = useCallback((q: string) => setSearchState(q), []);
|
||||||
const setSelectedId = useCallback((id: number | null) => {
|
const setSelectedId = useCallback(
|
||||||
setSelectedIdState(id);
|
(id: number | null) => {
|
||||||
if (id != null) setAccountRaw(LS.playlist, String(id));
|
markDirty();
|
||||||
}, []);
|
setSelectedIdState(id);
|
||||||
|
if (id != null) setAccountRaw(LS.playlist, String(id));
|
||||||
|
},
|
||||||
|
[markDirty],
|
||||||
|
);
|
||||||
|
|
||||||
const state = useMemo<PlaylistsState>(() => ({ search, selectedId }), [search, selectedId]);
|
const state = useMemo<PlaylistsState>(() => ({ search, selectedId }), [search, selectedId]);
|
||||||
const actions = useMemo<PlaylistsActions>(
|
const actions = useMemo<PlaylistsActions>(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
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 { api, type Playlist } from "../lib/api";
|
||||||
import { formatDuration } from "../lib/format";
|
import { formatDuration } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
@@ -11,6 +11,8 @@ import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
|||||||
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
||||||
import SidePanel from "./SidePanel";
|
import SidePanel from "./SidePanel";
|
||||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||||
|
import SortControl from "./SortControl";
|
||||||
|
import { type SortOption } from "../lib/columnSort";
|
||||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../lib/useMe";
|
||||||
import { playlistName } from "../lib/playlistName";
|
import { playlistName } from "../lib/playlistName";
|
||||||
@@ -45,6 +47,12 @@ export default function PlaylistsRail({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [plSort, setPlSort] = useState<PlSort>(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT));
|
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(() => {
|
useEffect(() => {
|
||||||
writeAccount(LS.plSort, plSort);
|
writeAccount(LS.plSort, plSort);
|
||||||
}, [plSort]);
|
}, [plSort]);
|
||||||
@@ -149,43 +157,26 @@ export default function PlaylistsRail({
|
|||||||
{t("playlists.syncYoutube")}
|
{t("playlists.syncYoutube")}
|
||||||
</button>
|
</button>
|
||||||
{playlists.length > 1 && (
|
{playlists.length > 1 && (
|
||||||
<div className="flex items-center gap-1">
|
<SortControl
|
||||||
<select
|
label={t("playlists.sortLabel")}
|
||||||
value={plSort.key}
|
ascLabel={t("playlists.dirAsc")}
|
||||||
onChange={(e) => setPlSort({ ...plSort, key: e.target.value as PlSort["key"] })}
|
descLabel={t("playlists.dirDesc")}
|
||||||
aria-label={t("playlists.sortLabel")}
|
options={plSortOptions}
|
||||||
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"
|
sort={{ key: plSort.key, dir: plSort.dir }}
|
||||||
>
|
onChange={(next) =>
|
||||||
<option value="custom">{t("playlists.railSortCustom")}</option>
|
next && setPlSort({ ...plSort, key: next.key as PlSort["key"], dir: next.dir })
|
||||||
<option value="name">{t("playlists.railSortName")}</option>
|
}
|
||||||
<option value="count">{t("playlists.railSortCount")}</option>
|
trailing={
|
||||||
<option value="duration">{t("playlists.railSortDuration")}</option>
|
<button
|
||||||
</select>
|
onClick={() => setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })}
|
||||||
<button
|
title={t("playlists.dirtyFirst")}
|
||||||
onClick={() => setPlSort({ ...plSort, dir: plSort.dir === "asc" ? "desc" : "asc" })}
|
aria-pressed={plSort.dirtyFirst}
|
||||||
disabled={plSort.key === "custom"}
|
className="pill shrink-0 rounded-lg border p-1.5"
|
||||||
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"
|
<Pin className="h-4 w-4" />
|
||||||
>
|
</button>
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import { healPlexSort } from "../lib/plexSort";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -58,6 +59,8 @@ import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
|||||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||||
import { LoadingState, StateMessage } from "./QueryState";
|
import { LoadingState, StateMessage } from "./QueryState";
|
||||||
import { PageToolbar } from "./PageShell";
|
import { PageToolbar } from "./PageShell";
|
||||||
|
import ActiveFilterChips from "./ActiveFilterChips";
|
||||||
|
import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters";
|
||||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||||
|
|
||||||
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
// 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 { q, scope, show, sort, filters, playlistOpen: openPlaylist } = usePlex();
|
||||||
const { setScope, setFilters, setPlaylistOpen } = usePlexActions();
|
const { setScope, setFilters, setPlaylistOpen } = usePlexActions();
|
||||||
const dq = useDebounced(q.trim(), 350);
|
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" });
|
const sub = useHistorySubview<Sub>({ kind: "grid" });
|
||||||
|
|
||||||
// Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then
|
// 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]);
|
}, [sub]);
|
||||||
|
|
||||||
const browseQ = useInfiniteQuery({
|
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",
|
enabled: !!scope && sub.view.kind === "grid",
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
api.plexLibrary({
|
api.plexLibrary({
|
||||||
scope,
|
scope,
|
||||||
q: dq || undefined,
|
q: dq || undefined,
|
||||||
sort,
|
sort: sortState,
|
||||||
show,
|
show,
|
||||||
filters,
|
filters,
|
||||||
offset: pageParam as number,
|
offset: pageParam as number,
|
||||||
@@ -329,6 +339,8 @@ export default function PlexBrowse() {
|
|||||||
</div>
|
</div>
|
||||||
</PageToolbar>
|
</PageToolbar>
|
||||||
|
|
||||||
|
<ActiveFilterChips filters={plexChips} onClearAll={clearAllFilters} />
|
||||||
|
|
||||||
{personCards.length > 0 && (
|
{personCards.length > 0 && (
|
||||||
<div className="mb-4 flex flex-wrap gap-3">
|
<div className="mb-4 flex flex-wrap gap-3">
|
||||||
{personCards.map((p) => (
|
{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 { qk } from "../lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { LoadingState, StateMessage } from "./QueryState";
|
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
|
// 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
|
// 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,
|
onRemove,
|
||||||
};
|
};
|
||||||
|
|
||||||
const LayoutBtn = ({ v, icon, label }: { v: Layout; icon: ReactNode; label: string }) => (
|
const layoutOptions: ViewOption<Layout>[] = [
|
||||||
<button
|
{ id: "accordion", label: t("plex.playlist.layoutAccordion"), icon: Rows3 },
|
||||||
onClick={() => setLayout(v)}
|
{ id: "tree", label: t("plex.playlist.layoutTree"), icon: ListTree },
|
||||||
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>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-[90%] max-w-[1100px] p-4">
|
<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>
|
<h1 className="min-w-0 flex-1 truncate text-2xl font-bold">{q.data?.title ?? " "}</h1>
|
||||||
)}
|
)}
|
||||||
{/* Layout preference: Accordion (A) or Tree (C). */}
|
{/* Layout preference: Accordion or Tree — the shared list-view switcher. */}
|
||||||
<div className="flex items-center gap-1">
|
<ViewSwitcher
|
||||||
<LayoutBtn
|
value={layout}
|
||||||
v="accordion"
|
options={layoutOptions}
|
||||||
icon={<Rows3 className="h-4 w-4" />}
|
onChange={setLayout}
|
||||||
label={t("plex.playlist.layoutAccordion")}
|
label={t("plex.playlist.layoutLabel")}
|
||||||
/>
|
/>
|
||||||
<LayoutBtn
|
|
||||||
v="tree"
|
|
||||||
icon={<ListTree className="h-4 w-4" />}
|
|
||||||
label={t("plex.playlist.layoutTree")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setName(q.data?.title ?? "");
|
setName(q.data?.title ?? "");
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||||
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
|
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 { 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 /
|
// 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
|
// 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,
|
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
|
||||||
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
|
// 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
|
// scope / show / sort / filters / q are per-account persisted (localStorage). init-from-storage seeds
|
||||||
// a full reload, so init-from-storage is all the hydration they need. `playlistOpen` is an ephemeral
|
// 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
|
// 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
|
// 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.
|
// 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 = {
|
type PlexState = {
|
||||||
scope: string; // movie | show | both (unified cross-library scope)
|
scope: string; // movie | show | both (unified cross-library scope)
|
||||||
show: string;
|
show: string;
|
||||||
sort: string;
|
sort: SortState; // {key,dir}; healed against the scope's options at the point of use
|
||||||
filters: PlexFilters;
|
filters: PlexFilters;
|
||||||
q: string;
|
q: string;
|
||||||
playlistOpen: number | null;
|
playlistOpen: number | null;
|
||||||
@@ -26,7 +31,7 @@ type PlexState = {
|
|||||||
interface PlexActions {
|
interface PlexActions {
|
||||||
setScope: (v: string) => void;
|
setScope: (v: string) => void;
|
||||||
setShow: (v: string) => void;
|
setShow: (v: string) => void;
|
||||||
setSort: (v: string) => void;
|
setSort: (v: SortState) => void;
|
||||||
setFilters: (f: PlexFilters) => void;
|
setFilters: (f: PlexFilters) => void;
|
||||||
setQ: (q: string) => void;
|
setQ: (q: string) => void;
|
||||||
setPlaylistOpen: (id: number | null) => void;
|
setPlaylistOpen: (id: number | null) => void;
|
||||||
@@ -35,7 +40,7 @@ interface PlexActions {
|
|||||||
const StateContext = createContext<PlexState>({
|
const StateContext = createContext<PlexState>({
|
||||||
scope: "both",
|
scope: "both",
|
||||||
show: "all",
|
show: "all",
|
||||||
sort: "title",
|
sort: PLEX_SORT_FALLBACK,
|
||||||
filters: EMPTY_PLEX_FILTERS,
|
filters: EMPTY_PLEX_FILTERS,
|
||||||
q: "",
|
q: "",
|
||||||
playlistOpen: null,
|
playlistOpen: null,
|
||||||
@@ -56,49 +61,115 @@ export function usePlexActions(): PlexActions {
|
|||||||
return useContext(ActionsContext);
|
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 }) {
|
export function PlexProvider({ children }: { children: ReactNode }) {
|
||||||
// The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
|
// 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".)
|
// (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".)
|
||||||
const [scope, setScopeState] = useState<string>(() => {
|
const [scope, setScopeState] = useState<string>(readPlexScope);
|
||||||
const v = getAccountRaw(LS.plexLibrary) ?? "both";
|
const [show, setShowState] = useState<string>(readPlexShow);
|
||||||
return ["movie", "show", "both"].includes(v) ? v : "both";
|
const [sort, setSortState] = useState<SortState>(readPlexSort);
|
||||||
});
|
|
||||||
const [show, setShowState] = useState<string>(() => getAccountRaw(LS.plexShow) ?? "all");
|
|
||||||
const [sort, setSortState] = useState<string>(() => getAccountRaw(LS.plexSort) ?? "title");
|
|
||||||
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
|
// 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>(() => {
|
const filters = useMemo<PlexFilters>(() => {
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return EMPTY_PLEX_FILTERS;
|
return EMPTY_PLEX_FILTERS;
|
||||||
}
|
}
|
||||||
}, [filtersRaw]);
|
}, [filtersRaw]);
|
||||||
// Survives F5 (persisted), unlike the feed search — see the scope note above.
|
// 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 [playlistOpen, setPlaylistOpen] = useState<number | null>(null);
|
||||||
|
|
||||||
const setScope = useCallback((v: string) => {
|
// Re-read every per-account slice once the account is pinned after mount (first load on a fresh tab).
|
||||||
setScopeState(v);
|
const markDirty = useAccountRehydrate(() => {
|
||||||
setAccountRaw(LS.plexLibrary, v);
|
setScopeState(readPlexScope());
|
||||||
}, []);
|
setShowState(readPlexShow());
|
||||||
const setShow = useCallback((v: string) => {
|
setSortState(readPlexSort());
|
||||||
setShowState(v);
|
setFiltersRaw(readPlexFiltersRaw());
|
||||||
setAccountRaw(LS.plexShow, v);
|
setQState(readPlexQ());
|
||||||
}, []);
|
});
|
||||||
const setSort = useCallback((v: string) => {
|
|
||||||
setSortState(v);
|
const setScope = useCallback(
|
||||||
setAccountRaw(LS.plexSort, v);
|
(v: string) => {
|
||||||
}, []);
|
markDirty();
|
||||||
const setFilters = useCallback((f: PlexFilters) => {
|
setScopeState(v);
|
||||||
const s = JSON.stringify(f);
|
setAccountRaw(LS.plexLibrary, v);
|
||||||
setFiltersRaw(s);
|
},
|
||||||
setAccountRaw(LS.plexFilters, s);
|
[markDirty],
|
||||||
}, []);
|
);
|
||||||
const setQ = useCallback((v: string) => {
|
const setShow = useCallback(
|
||||||
setQState(v);
|
(v: string) => {
|
||||||
setAccountRaw(LS.plexQ, v);
|
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>(
|
const state = useMemo<PlexState>(
|
||||||
() => ({ scope, show, sort, filters, q, playlistOpen }),
|
() => ({ 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 { qk } from "../lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
|
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 { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
import SidePanel from "./SidePanel";
|
import SidePanel from "./SidePanel";
|
||||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
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";
|
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||||
|
|
||||||
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups
|
// 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 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 RATING_STEPS = [5, 6, 7, 8, 9];
|
||||||
const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const;
|
const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const;
|
||||||
const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[] = [
|
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 },
|
{ 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 any/all combine toggle for a multi-value filter (genres, actors) — a thin binding of the shared
|
||||||
// the feed rail's tag MATCH toggle (rounded-full track + inset active pill), not a boxed button row.
|
// SegmentedToggle to the Plex match vocabulary (same look as the feed rail's tag MATCH toggle).
|
||||||
function MatchToggle({
|
function MatchToggle({
|
||||||
mode,
|
mode,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -45,27 +48,16 @@ function MatchToggle({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="mb-1.5 flex items-center justify-between">
|
<SegmentedToggle
|
||||||
<span className="text-3xs uppercase tracking-wide text-muted">
|
label={t("plex.filter.matchLabel")}
|
||||||
{t("plex.filter.matchLabel")}
|
ariaLabel={t("plex.filter.matchLabel")}
|
||||||
</span>
|
options={[
|
||||||
<div
|
{ value: "any", label: t("plex.filter.match.any") },
|
||||||
className="flex items-center rounded-full border border-border bg-card p-0.5 text-3xs"
|
{ value: "all", label: t("plex.filter.match.all") },
|
||||||
role="group"
|
]}
|
||||||
aria-label={t("plex.filter.matchLabel")}
|
value={mode}
|
||||||
>
|
onChange={onChange}
|
||||||
{(["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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +87,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
||||||
onOpenPlaylist(pl.id);
|
onOpenPlaylist(pl.id);
|
||||||
}
|
}
|
||||||
const { sortDir: _sd, collectionTitle: _ct, ...facetKey } = filters;
|
const { collectionTitle: _ct, ...facetKey } = filters;
|
||||||
const facetsQ = useQuery({
|
const facetsQ = useQuery({
|
||||||
queryKey: qk.plexFacets(scope, show, facetKey),
|
queryKey: qk.plexFacets(scope, show, facetKey),
|
||||||
queryFn: () => api.plexFacets(scope, filters, show),
|
queryFn: () => api.plexFacets(scope, filters, show),
|
||||||
@@ -114,21 +106,18 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
const collections = collectionsQ.data?.collections ?? [];
|
const collections = collectionsQ.data?.collections ?? [];
|
||||||
|
|
||||||
const fCount = plexFilterCount(filters);
|
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 anyActive = activeCount > 0;
|
||||||
|
|
||||||
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
||||||
const toggleIn = (arr: string[], v: string) =>
|
const toggleIn = (arr: string[], v: string) =>
|
||||||
arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v];
|
arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v];
|
||||||
const clearAll = () => {
|
const clearAll = usePlexClearFilters();
|
||||||
setFilters(EMPTY_PLEX_FILTERS);
|
|
||||||
setShow("all");
|
|
||||||
setSort("title");
|
|
||||||
};
|
|
||||||
|
|
||||||
const sorts = [...(scope === "movie" ? MOVIE_SORTS : SHOW_SORTS)].sort((a, b) =>
|
const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`));
|
||||||
t(`plex.sort.${a}`).localeCompare(t(`plex.sort.${b}`)),
|
|
||||||
);
|
|
||||||
const durBucketKey = DURATION_BUCKETS.find(
|
const durBucketKey = DURATION_BUCKETS.find(
|
||||||
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
|
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
|
||||||
)?.key;
|
)?.key;
|
||||||
@@ -143,20 +132,17 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
id: "scope",
|
id: "scope",
|
||||||
title: t("plex.filter.scope"),
|
title: t("plex.filter.scope"),
|
||||||
body: (
|
body: (
|
||||||
<div className="grid grid-cols-3 gap-1">
|
<SegmentedToggle
|
||||||
{(["both", "movie", "show"] as const).map((s) => (
|
ariaLabel={t("plex.filter.scope")}
|
||||||
<button
|
fullWidth
|
||||||
key={s}
|
size="md"
|
||||||
onClick={() => setScope(s)}
|
options={(["both", "movie", "show"] as const).map((s) => ({
|
||||||
aria-pressed={s === scope}
|
value: s,
|
||||||
className={`pill min-w-0 px-1.5 py-1.5 rounded-lg text-xs font-medium truncate ${
|
label: t(`plex.filter.scopeOpt.${s}`),
|
||||||
s === scope ? "" : "glass-card glass-hover"
|
}))}
|
||||||
}`}
|
value={scope}
|
||||||
>
|
onChange={setScope}
|
||||||
{t(`plex.filter.scopeOpt.${s}`)}
|
/>
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -205,39 +191,26 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
id: "watch",
|
id: "watch",
|
||||||
title: t("plex.filter.show"),
|
title: t("plex.filter.show"),
|
||||||
body: (
|
body: (
|
||||||
<ChipRow>
|
<FilterChipGroup
|
||||||
{SHOW_OPTS.map((s) => (
|
ariaLabel={t("plex.filter.show")}
|
||||||
<Chip key={s} active={show === s} onClick={() => setShow(s)}>
|
options={SHOW_OPTS.map((s) => ({ value: s, label: t(`plex.filter.showOpt.${s}`) }))}
|
||||||
{t(`plex.filter.showOpt.${s}`)}
|
value={show}
|
||||||
</Chip>
|
onChange={setShow}
|
||||||
))}
|
/>
|
||||||
</ChipRow>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sort",
|
id: "sort",
|
||||||
title: t("plex.filter.sort"),
|
title: t("plex.filter.sort"),
|
||||||
body: (
|
body: (
|
||||||
<>
|
<SortControl
|
||||||
<ChipRow>
|
options={sortOptions}
|
||||||
{sorts.map((s) => (
|
sort={healedSort}
|
||||||
<Chip key={s} active={sort === s} onClick={() => setSort(s)}>
|
onChange={(next) => setSort(next ?? PLEX_SORT_FALLBACK)}
|
||||||
{t(`plex.sort.${s}`)}
|
label={t("plex.filter.sort")}
|
||||||
</Chip>
|
ascLabel={t("plex.filter.dir.asc")}
|
||||||
))}
|
descLabel={t("plex.filter.dir.desc")}
|
||||||
</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>
|
|
||||||
</>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
...(peopleActive
|
...(peopleActive
|
||||||
@@ -328,16 +301,15 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
id: "rating",
|
id: "rating",
|
||||||
title: t("plex.filter.rating"),
|
title: t("plex.filter.rating"),
|
||||||
body: (
|
body: (
|
||||||
<ChipRow>
|
<FilterChipGroup
|
||||||
<Chip active={filters.ratingMin == null} onClick={() => patch({ ratingMin: null })}>
|
ariaLabel={t("plex.filter.rating")}
|
||||||
{t("plex.filter.any")}
|
value={filters.ratingMin == null ? "" : String(filters.ratingMin)}
|
||||||
</Chip>
|
options={[
|
||||||
{RATING_STEPS.map((r) => (
|
{ value: "", label: t("plex.filter.any") },
|
||||||
<Chip key={r} active={filters.ratingMin === r} onClick={() => patch({ ratingMin: r })}>
|
...RATING_STEPS.map((r) => ({ value: String(r), label: `${r}+` })),
|
||||||
{r}+
|
]}
|
||||||
</Chip>
|
onChange={(v) => patch({ ratingMin: v === "" ? null : Number(v) })}
|
||||||
))}
|
/>
|
||||||
</ChipRow>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
...(facets && facets.genres.length > 0
|
...(facets && facets.genres.length > 0
|
||||||
@@ -353,17 +325,22 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
onChange={(m) => patch({ genreMode: m })}
|
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) => (
|
{facets.genres.map((g) => (
|
||||||
<Chip
|
<button
|
||||||
key={g.value}
|
key={g.value}
|
||||||
active={filters.genres.includes(g.value)}
|
|
||||||
onClick={() => patch({ genres: toggleIn(filters.genres, g.value) })}
|
onClick={() => patch({ genres: toggleIn(filters.genres, g.value) })}
|
||||||
|
aria-pressed={filters.genres.includes(g.value)}
|
||||||
|
className={filterChipClass}
|
||||||
>
|
>
|
||||||
{g.value}
|
{g.value}
|
||||||
</Chip>
|
</button>
|
||||||
))}
|
))}
|
||||||
</ChipRow>
|
</div>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -394,25 +371,26 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
id: "duration",
|
id: "duration",
|
||||||
title: t("plex.filter.duration"),
|
title: t("plex.filter.duration"),
|
||||||
body: (
|
body: (
|
||||||
<ChipRow>
|
<FilterChipGroup
|
||||||
<Chip
|
ariaLabel={t("plex.filter.duration")}
|
||||||
active={
|
// "Any" is selected only when there is genuinely no duration filter; a set-but-off-bucket
|
||||||
!durBucketKey && filters.durationMin == null && filters.durationMax == null
|
// duration (e.g. stale state after a bucket-boundary change) selects nothing, not "Any".
|
||||||
}
|
value={
|
||||||
onClick={() => patch({ durationMin: null, durationMax: null })}
|
durBucketKey ??
|
||||||
>
|
(filters.durationMin == null && filters.durationMax == null ? "" : " | ||||||