refactor: component files export only components; lint fails on a warning

The 43 react-refresh/only-export-components warnings the gate let through meant an edit to a
provider (or AnchoredPopover, Toaster, plexDetailUi, …) could not be hot-swapped: Fast Refresh
reloads a module that also exports hooks or helpers.

- Each provider's contexts, the types its hooks return and the hooks themselves live in a sibling
  context module (navigationContext.ts, channelsContext.ts, …); the provider file keeps the
  component. loadAccountFilters moved next to the feed filters' defaults (feedFiltersInit.ts),
  following navigationInit/playlistsInit.
- Helpers that sat in component files have their own modules: welcomeMode, downloadTitle,
  plexPlayable, channelSync, ui/anchoredPopoverHooks, ui/pageToolbarSlot, ui/pagerContent,
  ui/toastLevels, ui/overlayOrder, ui/tableAlign, lib/plexDetail; the cells of the channel columns
  and the registry's page wrappers are components files of their own (channelCells, modulePages).
- usePersistedTab, an alias of useAccountPersistedState, is gone; its callers use the original.

npm run lint is now eslint --max-warnings 0, so a warning fails the gate like an error does.
This commit is contained in:
2026-09-17 02:59:09 +02:00
parent 569190480d
commit c3d88d9b20
158 changed files with 1600 additions and 1431 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"build": "npm run typecheck:app && npm run typecheck:node && vite build",
"preview": "vite preview",
"knip": "knip",
"lint": "eslint .",
"lint": "eslint . --max-warnings 0",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
+4 -4
View File
@@ -18,10 +18,10 @@ import { notify, removeByMetaKind } from "./lib/notifications";
import { LS, setAccountRaw } from "./lib/storage";
import { usePref } from "./lib/usePref";
import { useVerifyEntry } from "./lib/useVerifyEntry";
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
import { useChannelLayout } from "./components/ChannelsProvider";
import { useFeedView, useFeedViewActions } from "./components/FeedViewProvider";
import { useNavigation, useNavigationActions } from "./components/navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "./components/feedFiltersContext";
import { useChannelLayout } from "./components/channelsContext";
import { useFeedView, useFeedViewActions } from "./components/feedViewContext";
import { moduleDef, moduleOrder } from "./lib/modules";
import { PAGE_CONTENT, PAGE_RAIL, preloadPages } from "./components/moduleRegistry";
import { lazyWithPreload } from "./lib/lazyWithPreload";
+2 -1
View File
@@ -3,7 +3,8 @@ import { useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { api } from "../lib/api";
import ChannelAboutContent from "./ChannelAbout";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
/**
* A channel-description cell: one truncated line in the table, revealing the FULL channel "About"
@@ -4,7 +4,8 @@ import { Filter, X } from "lucide-react";
import type { ActiveFilter } from "../lib/useActiveFilters";
import { decideChipLayout } from "../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
+2 -1
View File
@@ -7,7 +7,8 @@ import { api, type Playlist } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { useScrollFade } from "../lib/useScrollFade";
import { playlistName } from "../lib/playlistName";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
// "new playlist" creation. Same shape as the channel tag editor (R8 S1's pilot): radio-style
@@ -24,7 +24,7 @@ vi.mock("../lib/useMe", () => ({
pending_activations: 0,
}),
}));
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
const { notify } = vi.hoisted(() => ({
notify: vi.fn((_n: { level: string; message: string }) => 0),
}));
+4 -3
View File
@@ -35,8 +35,9 @@ import SegmentedToggle from "./ui/SegmentedToggle";
import { LANGUAGES, type LangCode } from "../i18n";
import { LoadingState, StateMessage } from "./ui/QueryState";
import AccountPanel from "./AccountPanel";
import { useConfirm } from "./ConfirmProvider";
import Tabs, { usePersistedTab } from "./ui/Tabs";
import { useConfirm } from "./confirmContext";
import Tabs from "./ui/Tabs";
import { useAccountPersistedState } from "../lib/storage";
import { PageToolbar } from "./ui/PageShell";
import { useMe } from "../lib/useMe";
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
@@ -50,7 +51,7 @@ import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
export default function AdminUsers() {
const me = useMe();
const { t } = useTranslation();
const [tab, setTab] = usePersistedTab(ADMIN_USERS_TAB_KEY, "roles");
const [tab, setTab] = useAccountPersistedState(ADMIN_USERS_TAB_KEY, "roles");
const tabs = [
{ id: "roles", label: t("users.tabs.roles") },
// Both kinds of "waiting on the admin": a request needing a decision, and a sign-up whose
@@ -10,10 +10,10 @@ import { moduleDef, moduleOrder } from "../lib/modules";
import { SCHEMES, type Scheme } from "../lib/theme";
import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap";
import { requestSurprise } from "../lib/homeIntent";
import { useNavigationActions } from "./NavigationProvider";
import { usePlaylistsActions } from "./PlaylistsProvider";
import { usePrefs } from "./PrefsProvider";
import { useSavedViews } from "./SavedViewsProvider";
import { useNavigationActions } from "./navigationContext";
import { usePlaylistsActions } from "./playlistsContext";
import { usePrefs } from "./prefsContext";
import { useSavedViews } from "./savedViewsContext";
// What Ctrl/Cmd+K can reach. The generic list/filter/keyboard shell is `ui/CommandPalette`; this is
// the domain half — the layer boundary says a primitive may not import `lib/api` or a module.
+1 -1
View File
@@ -8,7 +8,7 @@ import { notify } from "../lib/notifications";
import { LS } from "../lib/storage";
import { Button } from "./ui/Button";
import { Section } from "./ui/form";
import { useConfirm } from "./ConfirmProvider";
import { useConfirm } from "./confirmContext";
import DataTable from "./ui/DataTable";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { auditColumns } from "./auditColumns";
@@ -14,7 +14,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import BootGate from "./BootGate";
import { NavigationProvider, useNavigation } from "./NavigationProvider";
import { NavigationProvider } from "./NavigationProvider";
import { useNavigation } from "./navigationContext";
import { api, type Me } from "../lib/api";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
import { qk } from "../lib/queryKeys";
+2 -2
View File
@@ -21,8 +21,8 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
import ColumnSortControl from "./ui/ColumnSortControl";
import Pager from "./ui/Pager";
import { PageToolbar } from "./ui/PageShell";
import { useConfirm } from "./ConfirmProvider";
import { useChannelLayout } from "./ChannelsProvider";
import { useConfirm } from "./confirmContext";
import { useChannelLayout } from "./channelsContext";
// The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but
// that they don't subscribe to. Subscribing here only creates the subscription + enriches
@@ -16,11 +16,11 @@ vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./NavigationProvider", () => ({
vi.mock("./navigationContext", () => ({
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
}));
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
vi.mock("./FeedFiltersProvider", () => ({
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
}));
vi.mock("./Feed", () => ({ default: () => null }));
@@ -14,11 +14,11 @@ vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./NavigationProvider", () => ({
vi.mock("./navigationContext", () => ({
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
}));
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
vi.mock("./FeedFiltersProvider", () => ({
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
}));
vi.mock("./Feed", () => ({ default: () => null }));
+5 -4
View File
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useLiveQuery } from "../lib/useLiveQuery";
import { channelDetailPollMs, useBackfillFeedRefresh } from "../lib/channelDetailPoll";
import { qk } from "../lib/queryKeys";
import { useNavigationActions } from "./NavigationProvider";
import { useNavigationActions } from "./navigationContext";
import { PriorityStepper } from "./ui/PriorityStepper";
import { useChannelPriority } from "../lib/useChannelPriority";
import {
@@ -24,9 +24,10 @@ import Avatar from "./ui/Avatar";
import Tabs from "./ui/Tabs";
import Feed from "./Feed";
import ChannelAboutContent from "./ChannelAbout";
import { PageToolbar, PageToolbarSlot } from "./ui/PageShell";
import { useFeedFilters } from "./FeedFiltersProvider";
import { useConfirm } from "./ConfirmProvider";
import { PageToolbar } from "./ui/PageShell";
import { PageToolbarSlot } from "./ui/pageToolbarSlot";
import { useFeedFilters } from "./feedFiltersContext";
import { useConfirm } from "./confirmContext";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { notifyActionError } from "../lib/actionErrors";
@@ -12,7 +12,8 @@ import {
import { useTranslation } from "react-i18next";
import { type MyStatus } from "../lib/api";
import { formatEta, formatNumber } from "../lib/format";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
// 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
+2 -1
View File
@@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from "react";
import { Check, Plus, Sparkles, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { type Tag } from "../lib/api";
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./ui/AnchoredPopover";
import { PopoverSurface, PopoverItem } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { useScrollFade } from "../lib/useScrollFade";
import { fitChipsWithPill } from "../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
+5 -5
View File
@@ -36,11 +36,11 @@ import { useCardPager } from "../lib/useCardPager";
import { PageToolbar } from "./ui/PageShell";
import ChannelDiscovery from "./ChannelDiscovery";
import TagManager from "./TagManager";
import { useConfirm } from "./ConfirmProvider";
import { useChannelLayout, useChannels, useChannelsActions } from "./ChannelsProvider";
import { useNavigationActions } from "./NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useWizard } from "./WizardProvider";
import { useConfirm } from "./confirmContext";
import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext";
import { useNavigationActions } from "./navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { useWizard } from "./wizardContext";
import { useMe } from "../lib/useMe";
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
@@ -2,21 +2,22 @@
//
// 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.
// re-routed through useAccountPersistedState 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.
// useAccountPersistedState / useSortState / useFilterState all resolve their account-scoped keys; only
// navigationContext (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", () => ({
vi.mock("./navigationContext", () => ({
useNavigation: () => ({ page: "channels" }),
useNavigationActions: () => ({ setPage: vi.fn() }),
}));
import { ChannelsProvider, useChannels, useChannelsActions } from "./ChannelsProvider";
import { ChannelsProvider } from "./ChannelsProvider";
import { useChannels, useChannelsActions } from "./channelsContext";
const wrapper = ({ children }: { children: ReactNode }) => (
<ChannelsProvider>{children}</ChannelsProvider>
+22 -81
View File
@@ -1,7 +1,5 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
@@ -9,9 +7,9 @@ import {
useState,
type ReactNode,
} from "react";
import { getAccountRaw, LS } from "../lib/storage";
import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout";
import { hydrateSort, type SortState } from "../lib/columnSort";
import { getAccountRaw, LS, useAccountPersistedState } from "../lib/storage";
import { parseChannelLayout } from "../lib/channelLayout";
import { hydrateSort } from "../lib/columnSort";
import { useSortState } from "../lib/useSortState";
import { useFilterState } from "../lib/useFilterState";
import { useUrlMirror } from "../lib/useUrlMirror";
@@ -22,10 +20,16 @@ import {
hasChannelsParams,
type ChannelsUrlState,
} from "../lib/channelsUrlState";
import { usePersistedTab } from "./ui/Tabs";
import type { FilterMap } from "../lib/tableColumns";
import { useNavigation, useNavigationActions } from "./NavigationProvider";
import { useNavigation, useNavigationActions } from "./navigationContext";
import type { ChannelsView } from "./Channels";
import {
ChannelLayoutContext,
ChannelsActionsContext,
ChannelsStateContext,
type ChannelsActions,
type ChannelsState,
} from "./channelsContext";
// Owns the Channels module's shared local state — the rail-less manager's name filter, column-filter
// map, active tab, the "drop a stale column filter" token, and the focus-a-channel intent — lifted out of
@@ -38,6 +42,8 @@ import type { ChannelsView } from "./Channels";
// goToFullHistory both jump to the Channels page (and force the subscriptions tab). filters / view are
// per-account persisted (localStorage); search / focus / the reset token are ephemeral. An account
// switch does a full reload, so init-from-storage is all the hydration the persisted ones need.
//
// The contexts, their value types and the hooks live in channelsContext.ts.
function clampView(v: string | null): ChannelsView {
return v === "discovery" || v === "blocked" || v === "hidden" ? v : "subscribed";
@@ -85,71 +91,6 @@ function readChannelsUrlSeed(): ChannelsUrlState | null {
});
}
type ChannelsState = {
search: string;
// The subscribed table's column filters, lifted out of DataTable so the top status chips, the Sync
// column funnel, and the card layout share ONE filter state (the sync chip lives at `filters.sync`).
filters: FilterMap;
view: ChannelsView;
// The subscribed table's sort, lifted out of DataTable so the card layout shares it.
sort: SortState;
// Bumped to tell the manager to drop a stale column filter when an intent sends the user to a
// specific set (the header's "without full history" link).
filtersResetToken: number;
focusName: string | null;
// Bumped every time a focus intent is (re-)issued, so re-focusing the same channel re-seeds the
// manager's name filter even when the name is unchanged.
focusToken: number;
};
interface ChannelsActions {
setSearch: (q: string) => void;
setFilters: (f: FilterMap) => void;
setView: (v: ChannelsView) => void;
setLayout: (l: ChannelLayout) => void;
setSort: (s: SortState) => void;
// "Focus this channel in the manager": jump to Channels (subscriptions tab) and seed its name
// filter so the channel is isolated. Called from the feed sidebar and the notifications inbox.
focusChannel: (name: string) => void;
// Jump to the manager filtered to channels still missing full history (the header sync chip).
goToFullHistory: () => void;
}
const StateContext = createContext<ChannelsState>({
search: "",
filters: {},
view: "subscribed",
sort: null,
filtersResetToken: 0,
focusName: null,
focusToken: 0,
});
// The table/cards density gets its OWN context, deliberately narrow: App reads it (to decide the
// shell's top fade) and would otherwise re-render the whole app on every keystroke in the manager's
// search box, since the state context above changes with it.
const LayoutContext = createContext<ChannelLayout>("table");
const ActionsContext = createContext<ChannelsActions>({
setSearch: () => {},
setFilters: () => {},
setView: () => {},
setLayout: () => {},
setSort: () => {},
focusChannel: () => {},
goToFullHistory: () => {},
});
export function useChannels(): ChannelsState {
return useContext(StateContext);
}
/** The manager's table/cards density on its own — subscribe to this instead of the whole state when
* the layout is all you need (App does, for the shell's top fade). */
export function useChannelLayout(): ChannelLayout {
return useContext(LayoutContext);
}
export function useChannelsActions(): ChannelsActions {
return useContext(ActionsContext);
}
export function ChannelsProvider({ children }: { children: ReactNode }) {
const { page } = useNavigation();
const { setPage } = useNavigationActions();
@@ -158,12 +99,12 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
// The subscribed table's column-filter map, owned here (lifted out of DataTable) so the top status
// chips, the Sync column funnel, and the card layout share one state; useFilterState persists it.
const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip);
// view + layout are persisted tabs (usePersistedTab = the shared account-scoped tab-state hook).
// view + layout are persisted tabs (useAccountPersistedState = the shared account-scoped tab-state hook).
// Stored un-clamped; clampView/parseChannelLayout heal them for display. setView layers the history
// logic (below) on top of the raw setter.
const [viewRaw, setViewRaw] = usePersistedTab(LS.channelsView, "subscribed");
const [viewRaw, setViewRaw] = useAccountPersistedState(LS.channelsView, "subscribed");
const view = clampView(viewRaw);
const [layoutRaw, setLayout] = usePersistedTab(LS.channelLayout, "table");
const [layoutRaw, setLayout] = useAccountPersistedState(LS.channelLayout, "table");
const layout = parseChannelLayout(layoutRaw);
// Sort is owned here (lifted out of DataTable) so the card layout shares it; ColumnSortControl heals
// it against the live columns for display, so the provider stores it un-healed (no options passed).
@@ -203,7 +144,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
// Re-selecting the tab you are already on is not a navigation — bail before pushing, or clicking
// the active tab (and goToFullHistory forcing "subscribed") stacks Back entries that do nothing.
if (v === viewRef.current) return;
setViewRaw(v); // persists (via usePersistedTab)
setViewRaw(v); // persists (via useAccountPersistedState)
// Only own an entry while the manager is already on screen. Arriving from elsewhere
// (focusChannel / goToFullHistory) lets setPage push the entry — the stamp effect labels it — so
// one navigation never costs two Back presses.
@@ -282,10 +223,10 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
useUrlMirror(channelsParamUpdates(page === "channels" ? { view, sort, filters } : null));
return (
<ActionsContext.Provider value={actions}>
<LayoutContext.Provider value={layout}>
<StateContext.Provider value={state}>{children}</StateContext.Provider>
</LayoutContext.Provider>
</ActionsContext.Provider>
<ChannelsActionsContext.Provider value={actions}>
<ChannelLayoutContext.Provider value={layout}>
<ChannelsStateContext.Provider value={state}>{children}</ChannelsStateContext.Provider>
</ChannelLayoutContext.Provider>
</ChannelsActionsContext.Provider>
);
}
+3 -3
View File
@@ -8,14 +8,14 @@ import { notify } from "../lib/notifications";
import Tooltip from "./ui/Tooltip";
import InlineNotice from "./ui/InlineNotice";
import { useMe } from "../lib/useMe";
import Tabs, { usePersistedTab } from "./ui/Tabs";
import Tabs from "./ui/Tabs";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { PageToolbar } from "./ui/PageShell";
import { Switch } from "./ui/form";
import SegmentedToggle from "./ui/SegmentedToggle";
import FilterSelect from "./ui/FilterSelect";
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { LS } from "../lib/storage";
import { LS, useAccountPersistedState } from "../lib/storage";
import { notifyActionError } from "../lib/actionErrors";
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
@@ -51,7 +51,7 @@ export default function ConfigPanel() {
const [reseedToken, setReseedToken] = useState(0);
// One tab per config group (persisted). Called before the loading guard so hook order is
// stable; the active id is clamped to a real group at render time once data has loaded.
const [tab, setTab] = usePersistedTab(LS.configTab);
const [tab, setTab] = useAccountPersistedState(LS.configTab);
// A stable signature of the config SHAPE — changes only when keys are added/removed, not when an
// unchanged refetch produces a new baseline object. Re-seed the draft on a shape change (initial
+3 -58
View File
@@ -1,65 +1,10 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
import { useCallback, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import Modal from "./ui/Modal";
import { Button } from "./ui/Button";
import { ChoiceContext, type ChoiceFn, type ChoiceOptions } from "./confirmContext";
// App-styled, promise-based replacement for window.confirm. Wrap the tree in
// <ConfirmProvider> once, then anywhere: const confirm = useConfirm();
// if (await confirm({ message: "…", danger: true })) { … }
//
// For a decision with more than two outcomes use `useChoice()` instead — never chain two
// confirms, because the second dialog's Cancel/Escape/backdrop is then indistinguishable from
// a deliberate answer (the playlist delete used to read a dismissal as "delete here only" and
// went ahead and deleted).
interface ConfirmOptions {
title?: string;
message: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
}
interface Choice {
id: string;
label: string;
danger?: boolean;
}
export interface ChoiceOptions {
title?: string;
message: ReactNode;
cancelLabel?: string;
/** Rendered left→right after Cancel. Resolves to the clicked choice's `id`. */
choices: Choice[];
}
type ChoiceFn = (opts: ChoiceOptions) => Promise<string | null>;
type ConfirmFn = (opts: ConfirmOptions) => Promise<boolean>;
const ChoiceContext = createContext<ChoiceFn>(() => Promise.resolve(null));
/** Two outcomes: resolves true on confirm, false on cancel/Escape/backdrop. */
export function useConfirm(): ConfirmFn {
const ask = useContext(ChoiceContext);
return useCallback(
async (opts: ConfirmOptions) => {
const picked = await ask({
title: opts.title,
message: opts.message,
cancelLabel: opts.cancelLabel,
choices: [{ id: "confirm", label: opts.confirmLabel ?? "", danger: opts.danger }],
});
return picked === "confirm";
},
[ask],
);
}
/** Three or more outcomes. Resolves to the chosen id, or **null** when the user cancels,
* presses Escape or clicks the backdrop — dismissal is never one of the actions. */
export function useChoice(): ChoiceFn {
return useContext(ChoiceContext);
}
// The dialog behind useConfirm()/useChoice() (see confirmContext.ts for the hooks and their contract).
export function ConfirmProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation();
+2 -1
View File
@@ -1,7 +1,8 @@
import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { CalendarDays, Check, ChevronDown } from "lucide-react";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { type FeedFilters } from "../lib/api";
import { deriveDateKey, selectDateChip, type DatePreset } from "../lib/dateFilter";
@@ -17,7 +17,7 @@ vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => mocks.confirm }));
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
vi.mock("../lib/useLiveQuery", () => ({
useLiveQuery: (key: readonly unknown[]) => ({
data: key[0] === "downloads" ? mocks.jobs : undefined,
+1 -1
View File
@@ -30,7 +30,7 @@ const ProfileEditor = lazy(() => import("./ProfileEditor"));
const VideoEditor = lazy(() => import("./VideoEditor"));
const ShareDialog = lazy(() => import("./ShareDialog"));
const LibraryPlayer = lazy(() => import("./LibraryPlayer"));
import { useConfirm } from "./ConfirmProvider";
import { useConfirm } from "./confirmContext";
import { useLiveQuery } from "../lib/useLiveQuery";
import { useMe } from "../lib/useMe";
import { api, type DownloadJob } from "../lib/api";
+1 -1
View File
@@ -44,7 +44,7 @@ import SelectedViewChip from "./SelectedViewChip";
import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { PageToolbar } from "./ui/PageShell";
import { useWizard } from "./WizardProvider";
import { useWizard } from "./wizardContext";
import SortControl from "./ui/SortControl";
import FilterSelect from "./ui/FilterSelect";
import ItemCount from "./ui/ItemCount";
@@ -15,7 +15,7 @@ vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./WizardProvider", () => ({ useWizard: () => ({ openWizard: vi.fn() }) }));
vi.mock("./wizardContext", () => ({ useWizard: () => ({ openWizard: vi.fn() }) }));
vi.mock("./VirtualFeed", () => ({
default: ({ items }: { items: { id: string; title: string }[] }) => (
<ol aria-label="results">
@@ -4,7 +4,7 @@
// legacy global `tagMode` (older builds wrote it) into the per-category `tagModes` — the same coercion
// the saved-view and URL paths do — so an upgrading user's topic any/all match isn't silently lost.
import { describe, it, expect, beforeEach } from "vitest";
import { loadAccountFilters } from "./FeedFiltersProvider";
import { loadAccountFilters } from "./feedFiltersInit";
import { accountKey, LS } from "../lib/storage";
const ID = 7;
@@ -11,7 +11,8 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { cleanup, render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { NavigationProvider } from "./NavigationProvider";
import { FeedFiltersProvider, useFeedFilters } from "./FeedFiltersProvider";
import { FeedFiltersProvider } from "./FeedFiltersProvider";
import { useFeedFilters } from "./feedFiltersContext";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
import { qk } from "../lib/queryKeys";
import type { Me } from "../lib/api";
+18 -89
View File
@@ -1,90 +1,31 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { qk } from "../lib/queryKeys";
import { useQueryClient } from "@tanstack/react-query";
import {
getActiveAccount,
coerceTagModes,
coerceUntagged,
DEFAULT_TAG_MODES,
type FeedFilters,
type Me,
} from "../lib/api";
import { getActiveAccount, type FeedFilters, type Me } from "../lib/api";
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState";
import { useUrlMirror } from "../lib/useUrlMirror";
import { accountKey, LS, readJSON, writeJSON } from "../lib/storage";
import { accountKey, writeJSON } from "../lib/storage";
import { logFilterChange } from "../lib/filterLog";
import { useNavigation } from "./NavigationProvider";
import { useNavigation } from "./navigationContext";
import {
FeedFiltersActionsContext,
FeedFiltersStateContext,
type FeedFiltersActions,
} from "./feedFiltersContext";
import { DEFAULT_FILTERS, FILTERS_KEY, loadAccountFilters } from "./feedFiltersInit";
// Owns the GLOBAL feed-filter state (the feed's scope/tags/content/sort/show), lifted out of the App
// god component so consumers read it via a hook instead of prop-drilling `filters`/`setFilters`
// through the tree. Mirrors NavigationProvider (split contexts + one-line hooks); composed in
// main.tsx inside NavigationProvider, around <App/>.
//
// TWO contexts on purpose (mirrors NavigationProvider): a STABLE actions context (setFilters never
// changes reference) and a CHANGING state context (the filters object). Context consumers re-render
// only when the context they read changes — so a component that dispatches filter changes without
// reading the current filters can subscribe to actions ONLY and skip re-rendering on every tweak.
//
// SCOPE NOTE: this owns the FEED's filters only. The channel detail page keeps its OWN isolated
// filter state (ChannelPage) — it renders a channel-scoped catalog, not the global feed — and the
// reusable <Feed> is parameterized by a `filters` prop so it can render either. Because the two live
// in structurally separate cells, opening a channel can never leak into the global feed (bug 2).
const DEFAULT_FILTERS: FeedFilters = {
tags: [],
tagModes: DEFAULT_TAG_MODES,
untagged: false,
channelIds: [],
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
const FILTERS_KEY = LS.filters;
// This account's own persisted filters (per-account keys — never another account's). No account
// (signed out) → defaults.
export function loadAccountFilters(id: number | null): FeedFilters {
// Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. A starred
// default view seeds a fresh account that has never stored filters, but that is applied ONCE by
// SavedViewsProvider on its views query's first resolve (which then persists here like any edit) —
// not read from a mirror on this synchronous path, which used to race that resolve. So here we only
// ever read the account's own stored filters, else fall back to DEFAULT_FILTERS.
const fKey = accountKey(FILTERS_KEY, id);
let hasStored = false;
try {
hasStored = !!fKey && localStorage.getItem(fKey) != null;
} catch {
/* localStorage may be unavailable */
}
// Migrate a persisted legacy global `tagMode` into the per-category `tagModes`, matching what the
// saved-view (asFilters) and URL (paramsToFilters) paths already do. coerceTagModes MUST run on the
// RAW stored blob, not the object merged over DEFAULT_FILTERS: the merge injects DEFAULT_TAG_MODES
// (all "or"), which coerceTagModes prefers over the legacy scalar and would mask it.
if (hasStored && fKey) {
const raw = readJSON<Partial<FeedFilters>>(fKey, {});
return {
...DEFAULT_FILTERS,
...raw,
tagModes: coerceTagModes(raw),
untagged: coerceUntagged(raw),
};
}
return { ...DEFAULT_FILTERS };
}
//
// The contexts + hooks live in feedFiltersContext.ts; the defaults and the per-account seed in
// feedFiltersInit.ts.
// URL wins over localStorage so a pasted link reproduces the exact view.
function loadInitialFilters(me: Me | null | undefined): FeedFilters {
@@ -95,20 +36,6 @@ function loadInitialFilters(me: Me | null | undefined): FeedFilters {
return me?.is_demo ? { ...f, scope: "all" } : f;
}
interface FeedFiltersActions {
setFilters: (next: FeedFilters) => void;
}
const StateContext = createContext<FeedFilters>(DEFAULT_FILTERS);
const ActionsContext = createContext<FeedFiltersActions>({ setFilters: () => {} });
export function useFeedFilters(): FeedFilters {
return useContext(StateContext);
}
export function useFeedFiltersActions(): FeedFiltersActions {
return useContext(ActionsContext);
}
export function FeedFiltersProvider({ children }: { children: ReactNode }) {
const qc = useQueryClient();
// Seeded for the account this tab is — BootGate mounts this provider only once the account is
@@ -148,8 +75,10 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
const actions = useMemo<FeedFiltersActions>(() => ({ setFilters }), [setFilters]);
return (
<ActionsContext.Provider value={actions}>
<StateContext.Provider value={filters}>{children}</StateContext.Provider>
</ActionsContext.Provider>
<FeedFiltersActionsContext.Provider value={actions}>
<FeedFiltersStateContext.Provider value={filters}>
{children}
</FeedFiltersStateContext.Provider>
</FeedFiltersActionsContext.Provider>
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { Library, User } from "lucide-react";
import SegmentedToggle from "./ui/SegmentedToggle";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
// The feed's Mine/Library scope, relocated from the filter sidebar to sit just right of the header
// search box — mirroring PlexScopeToggle's placement, so the primary "what am I browsing" control is
+11 -18
View File
@@ -1,7 +1,12 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { useMemo, type ReactNode } from "react";
import { parseFeedView, type FeedView } from "../lib/feedView";
import { LS } from "../lib/storage";
import { usePref } from "../lib/usePref";
import {
FeedViewActionsContext,
FeedViewStateContext,
type FeedViewActions,
} from "./feedViewContext";
// Owns the feed's view mode (the card/row/tile density switcher), lifted out of App so both the feed
// page and the channel page read it via hooks instead of prop-drilling `view`/`setView` through App.
@@ -10,20 +15,8 @@ import { usePref } from "../lib/usePref";
// The view mode is an account PREFERENCE, not a filter: it persists to the server on pick and follows
// the account across devices. usePref carries that server-sync (seeded from the server pref, with the
// localStorage mirror as the fallback) so this provider is just the two contexts over it.
interface FeedViewActions {
setView: (v: FeedView) => void;
}
const StateContext = createContext<FeedView>("cards");
const ActionsContext = createContext<FeedViewActions>({ setView: () => {} });
export function useFeedView(): FeedView {
return useContext(StateContext);
}
export function useFeedViewActions(): FeedViewActions {
return useContext(ActionsContext);
}
//
// The contexts + hooks live in feedViewContext.ts.
export function FeedViewProvider({ children }: { children: ReactNode }) {
// parseFeedView also maps the pre-0.45 "grid"/"list" values forward (idempotent for current values).
@@ -35,8 +28,8 @@ export function FeedViewProvider({ children }: { children: ReactNode }) {
const actions = useMemo<FeedViewActions>(() => ({ setView }), [setView]);
return (
<ActionsContext.Provider value={actions}>
<StateContext.Provider value={view}>{children}</StateContext.Provider>
</ActionsContext.Provider>
<FeedViewActionsContext.Provider value={actions}>
<FeedViewStateContext.Provider value={view}>{children}</FeedViewStateContext.Provider>
</FeedViewActionsContext.Provider>
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import Overlay from "./ui/Overlay";
import { copyText } from "../lib/clipboard";
import { usePrefs } from "./PrefsProvider";
import { usePrefs } from "./prefsContext";
/**
* A floating live appearance tuner. Mounted at the app root so it's reachable on every page; it
+4 -4
View File
@@ -8,10 +8,10 @@ import PlexScopeToggle from "./PlexScopeToggle";
import PlexViewToggle from "./PlexViewToggle";
import FeedScopeToggle from "./FeedScopeToggle";
import SyncStatus from "./SyncStatus";
import { useNavigation } from "./NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { useChannels, useChannelsActions } from "./ChannelsProvider";
import { useNavigation } from "./navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { usePlaylists, usePlaylistsActions } from "./playlistsContext";
import { useChannels, useChannelsActions } from "./channelsContext";
// The floating top header: a row of glass pills over the content — the module's SearchBar where it
// has one, and the per-user sync chip at the right end.
+2 -2
View File
@@ -58,10 +58,10 @@ vi.mock("../lib/api", () => ({
// WHAT is handed over is: the jump target decides both what plays and where Back goes.
const setPage = vi.fn();
const setJumpTo = vi.fn();
vi.mock("./NavigationProvider", () => ({
vi.mock("./navigationContext", () => ({
useNavigationActions: () => ({ setPage: (p: string) => setPage(p) }),
}));
vi.mock("./PlexProvider", () => ({
vi.mock("./plexContext", () => ({
usePlexActions: () => ({ setJumpTo: (t: unknown) => setJumpTo(t) }),
}));
@@ -63,8 +63,8 @@ vi.mock("../lib/api", () => ({
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
}));
vi.mock("./NavigationProvider", () => ({ useNavigationActions: () => ({ setPage: vi.fn() }) }));
vi.mock("./PlexProvider", () => ({ usePlexActions: () => ({ setJumpTo: vi.fn() }) }));
vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ setPage: vi.fn() }) }));
vi.mock("./plexContext", () => ({ usePlexActions: () => ({ setJumpTo: vi.fn() }) }));
import Home from "./Home";
+2 -2
View File
@@ -38,8 +38,8 @@ import SegmentedToggle from "./ui/SegmentedToggle";
import { LoadingState, StateMessage } from "./ui/QueryState";
import VideoCard from "./VideoCard";
import { PlexPosterCard } from "./plexCards";
import { useNavigationActions } from "./NavigationProvider";
import { usePlexActions } from "./PlexProvider";
import { useNavigationActions } from "./navigationContext";
import { usePlexActions } from "./plexContext";
import { navItemAttrs } from "../lib/listNav";
// The Home surface (E24) — "Mit nézzek most?".
+2 -1
View File
@@ -1,7 +1,8 @@
import { useState } from "react";
import { Check, Globe } from "lucide-react";
import { LANGUAGES, type LangCode } from "../i18n";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
// Compact language picker (globe + current code). Presentational: the parent decides what
// changing the language does (set it; persist server-side when signed in).
+1 -1
View File
@@ -2,7 +2,7 @@ import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import Modal from "./ui/Modal";
import { api, type DownloadJob } from "../lib/api";
import { jobTitle } from "./downloadRow";
import { jobTitle } from "./downloadTitle";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { useMediaLogRef } from "../lib/mediaLog";
+3 -2
View File
@@ -2,7 +2,7 @@ import { useState, useSyncExternalStore } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { useNavigation, useNavigationActions } from "./NavigationProvider";
import { useNavigation, useNavigationActions } from "./navigationContext";
import { Info, LogOut, Settings, Shield, UserPlus, type LucideIcon } from "lucide-react";
import { api, type Me } from "../lib/api";
import { pinOnNextPage } from "../lib/activeAccount";
@@ -17,7 +17,8 @@ import { Button } from "./ui/Button";
import AvatarImg from "./ui/Avatar";
import Tooltip from "./ui/Tooltip";
import LanguageSwitcher from "./LanguageSwitcher";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
// Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar
// dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account
@@ -24,7 +24,8 @@ vi.mock("react-i18next", async (importOriginal) => {
};
});
import { NavigationProvider, useNavigation, useNavigationActions } from "./NavigationProvider";
import { NavigationProvider } from "./NavigationProvider";
import { useNavigation, useNavigationActions } from "./navigationContext";
import { useBackToClose } from "../lib/history";
const wrapper = ({ children }: { children: ReactNode }) => (
+11 -58
View File
@@ -1,13 +1,4 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { urlWithParams, type Page } from "../lib/urlState";
import { pageTitleKey } from "../lib/pageMeta";
@@ -20,57 +11,19 @@ import {
rememberNavPosition,
} from "../lib/history";
import { channelViewFrom, loadInitialPage, PAGE_KEY, type ChannelView } from "./navigationInit";
import {
NavigationActionsContext,
NavigationStateContext,
type NavigationActions,
type NavigationState,
} from "./navigationContext";
// Owns the app's navigation + browser-history state, lifted out of the App god component so pages
// consume it via a hook instead of prop-drilling `page`/`setPage`/`onOpenChannel` through the tree.
// Mirrors ConfirmProvider (context + default value + one-line hooks); composed in main.tsx inside
// ConfirmProvider, around <App/>.
//
// TWO contexts on purpose: a STABLE actions context (setPage/openChannel/… never change reference)
// and a CHANGING state context (page/channelView/ytSearch). Context consumers re-render whenever the
// value changes, so deep memo'd leaves (VideoCard, PlayerModal) subscribe to actions ONLY and never
// re-render on a page/channel switch — preserving the memo that keeps feed appends from re-rendering
// every existing card.
export interface NavigationState {
page: Page;
/** The open channel page (null = none). Overlays `page`; rides in history.state._chan. */
channelView: ChannelView | null;
/** Live YouTube search term (null = normal feed). A feed sub-view; rides in history.state._yt. */
ytSearch: string | null;
}
export interface NavigationActions {
/** Switch modules. Closes every open overlay first and pushes only once their history entries are
* back (`lib/history.closeOverlaysThenNavigate`), so over an open player the push lands a moment
* after the call; with nothing open it is synchronous. The requested page being already current
* (with no channel page over it) pushes nothing — but the overlays still close. */
setPage: (next: Page) => void;
openChannel: (id: string, name?: string) => void;
closeChannel: () => void;
enterYtSearch: (q: string) => void;
exitYtSearch: () => void;
}
const StateContext = createContext<NavigationState>({
page: "feed",
channelView: null,
ytSearch: null,
});
const ActionsContext = createContext<NavigationActions>({
setPage: () => {},
openChannel: () => {},
closeChannel: () => {},
enterYtSearch: () => {},
exitYtSearch: () => {},
});
export function useNavigation(): NavigationState {
return useContext(StateContext);
}
export function useNavigationActions(): NavigationActions {
return useContext(ActionsContext);
}
// The contexts, their value types and the hooks live in navigationContext.ts.
export function NavigationProvider({ children }: { children: ReactNode }) {
const { t, i18n } = useTranslation();
@@ -256,8 +209,8 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
);
return (
<ActionsContext.Provider value={actions}>
<StateContext.Provider value={state}>{children}</StateContext.Provider>
</ActionsContext.Provider>
<NavigationActionsContext.Provider value={actions}>
<NavigationStateContext.Provider value={state}>{children}</NavigationStateContext.Provider>
</NavigationActionsContext.Provider>
);
}
@@ -38,12 +38,12 @@ import {
} from "../lib/notifications";
import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format";
import { Button } from "./ui/Button";
import { LEVEL_STYLE } from "./ui/Toaster";
import { LEVEL_STYLE } from "./ui/toastLevels";
import { PageToolbar } from "./ui/PageShell";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { useNavigationActions } from "./NavigationProvider";
import { useChannelsActions } from "./ChannelsProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useNavigationActions } from "./navigationContext";
import { useChannelsActions } from "./channelsContext";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
// The single notification center. "System" = durable, server-backed notifications (inbox
// phase 1). "Activity" = the client-side transient events (action confirmations, errors, with
+2 -1
View File
@@ -7,7 +7,8 @@ import { api, type Me } from "../lib/api";
import { beginGrant, endOnboarding } from "../lib/onboarding";
import { Button } from "./ui/Button";
import Overlay from "./ui/Overlay";
import Toaster, { useOverlayOrder } from "./ui/Toaster";
import Toaster from "./ui/Toaster";
import { useOverlayOrder } from "./ui/overlayOrder";
import { useLayer } from "../lib/layerStack";
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
@@ -3,7 +3,7 @@
// S7 — the Personal-views manager gains an EDITABLE detail pane: captured filters are now removable
// chips that write the pruned set back to the stored view (undoable), an explicit Save-current button
// stores the live filters into the view, and rename has a visible commit affordance. This drives the
// real modal against a mocked SavedViewsProvider so we can assert exactly which mutation fires; the
// real modal against a mocked savedViewsContext so we can assert exactly which mutation fires; the
// captured-filter row uses the REAL useActiveFilters (fed a tag from a seeded react-query cache), so
// the prune snapshots are the genuine ones the live chip row computes.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
@@ -56,7 +56,7 @@ const CTX = {
toggleDefault: vi.fn(async () => {}),
};
vi.mock("./SavedViewsProvider", () => ({
vi.mock("./savedViewsContext", () => ({
useSavedViews: () => ({
...CTX,
activeViewId: h.activeViewId,
@@ -19,7 +19,7 @@ import { CSS } from "@dnd-kit/utilities";
import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api";
import Modal from "./ui/Modal";
import { Button } from "./ui/Button";
import { useSavedViews, asFilters } from "./SavedViewsProvider";
import { useSavedViews, asFilters } from "./savedViewsContext";
import { useActiveFilters } from "../lib/useActiveFilters";
import { buildPlexActiveFilters } from "./usePlexActiveFilters";
import { useUndoable } from "../lib/useUndoable";
@@ -23,13 +23,14 @@ vi.mock("react-i18next", async (importOriginal) => {
}),
};
});
vi.mock("./NavigationProvider", () => ({ useNavigationActions: () => ({ openChannel: vi.fn() }) }));
vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ openChannel: vi.fn() }) }));
// Their own queries and popovers are not what is under test here.
vi.mock("./AddToPlaylist", () => ({ default: () => null }));
vi.mock("./DownloadButton", () => ({ default: () => null }));
import PlayerModal from "./PlayerModal";
import { ConfirmProvider, useConfirm } from "./ConfirmProvider";
import { ConfirmProvider } from "./ConfirmProvider";
import { useConfirm } from "./confirmContext";
import { api, type Video } from "../lib/api";
import { clearAll, notify } from "../lib/notifications";
+5 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { useNavigationActions } from "./NavigationProvider";
import { useNavigationActions } from "./navigationContext";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { usePref } from "../lib/usePref";
@@ -26,7 +26,8 @@ import {
} from "lucide-react";
import Avatar from "./ui/Avatar";
import AddToPlaylist from "./AddToPlaylist";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import {
pushLayer,
removeLayer,
@@ -50,7 +51,8 @@ import { fullscreenElement } from "../lib/fullscreen";
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
import { useBackToClose } from "../lib/history";
import ChannelNameLink from "./ChannelNameLink";
import Toaster, { useOverlayOrder } from "./ui/Toaster";
import Toaster from "./ui/Toaster";
import { useOverlayOrder } from "./ui/overlayOrder";
import { afterRemoval } from "../lib/playerQueue";
import {
useNativeControlsYield,
@@ -16,7 +16,7 @@ vi.mock("react-i18next", async (importOriginal) => {
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
const { notify } = vi.hoisted(() => ({
notify: vi.fn((_n: { level: string; message: string }) => 0),
}));
@@ -34,8 +34,8 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
notify: (...a: unknown[]) => h.notify(...a),
}));
vi.mock("./PlaylistsProvider", () => ({ usePlaylists: () => ({ selectedId: h.selectedId }) }));
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
vi.mock("./playlistsContext", () => ({ usePlaylists: () => ({ selectedId: h.selectedId }) }));
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("./usePlaylistActions", () => ({ usePlaylistActions: () => ({ remove: vi.fn() }) }));
vi.mock("./PlayerModal", async () => {
const { useEffect } = await import("react");
+4 -3
View File
@@ -58,13 +58,14 @@ import { useContextMenu } from "./ui/useContextMenu";
import UndoToolbar from "./ui/UndoToolbar";
import NavGrid from "./ui/NavGrid";
import { PageToolbar } from "./ui/PageShell";
import { useConfirm } from "./ConfirmProvider";
import { useConfirm } from "./confirmContext";
import { usePlaylistActions } from "./usePlaylistActions";
import { usePlaylists } from "./PlaylistsProvider";
import { usePlaylists } from "./playlistsContext";
import { useMe } from "../lib/useMe";
import { usePref } from "../lib/usePref";
import { navItemAttrs } from "../lib/listNav";
import { ColumnHead, alignClass } from "./ui/tableHeader";
import { ColumnHead } from "./ui/tableHeader";
import { alignClass } from "./ui/tableAlign";
import { filterRows, isActive, type Column, type FilterMap } from "../lib/tableColumns";
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
+13 -28
View File
@@ -1,8 +1,14 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { LS, setAccountRaw } from "../lib/storage";
import { useUrlMirror } from "../lib/useUrlMirror";
import { useNavigation } from "./NavigationProvider";
import { useNavigation } from "./navigationContext";
import { readSelectedId } from "./playlistsInit";
import {
PlaylistsActionsContext,
PlaylistsStateContext,
type PlaylistsActions,
type PlaylistsState,
} from "./playlistsContext";
// 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
@@ -14,29 +20,8 @@ import { readSelectedId } from "./playlistsInit";
// playlist is per-account persisted (LS.playlist) so a reload returns you to it. Neither is
// server-synced, and an account switch does a full reload, so init-from-storage is all the
// hydration these need — no per-account load effect (unlike the server-backed feed filters).
type PlaylistsState = {
search: string;
selectedId: number | null;
};
interface PlaylistsActions {
setSearch: (q: string) => void;
setSelectedId: (id: number | null) => void;
}
const StateContext = createContext<PlaylistsState>({ search: "", selectedId: null });
const ActionsContext = createContext<PlaylistsActions>({
setSearch: () => {},
setSelectedId: () => {},
});
export function usePlaylists(): PlaylistsState {
return useContext(StateContext);
}
export function usePlaylistsActions(): PlaylistsActions {
return useContext(ActionsContext);
}
//
// The contexts + hooks live in playlistsContext.ts.
export function PlaylistsProvider({ children }: { children: ReactNode }) {
const [search, setSearchState] = useState("");
@@ -61,8 +46,8 @@ export function PlaylistsProvider({ children }: { children: ReactNode }) {
);
return (
<ActionsContext.Provider value={actions}>
<StateContext.Provider value={state}>{children}</StateContext.Provider>
</ActionsContext.Provider>
<PlaylistsActionsContext.Provider value={actions}>
<PlaylistsStateContext.Provider value={state}>{children}</PlaylistsStateContext.Provider>
</PlaylistsActionsContext.Provider>
);
}
@@ -14,7 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }) };
});
vi.mock("./PlaylistsProvider", () => ({
vi.mock("./playlistsContext", () => ({
usePlaylists: () => ({ search: "", selectedId: 1 }),
usePlaylistsActions: () => ({ setSelectedId: vi.fn() }),
}));
+1 -1
View File
@@ -18,7 +18,7 @@ import { useContextMenu } from "./ui/useContextMenu";
import { type PanelItem } from "./ui/PanelGroups";
import SortControl from "./ui/SortControl";
import { type SortOption } from "../lib/columnSort";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { usePlaylists, usePlaylistsActions } from "./playlistsContext";
import { useMe } from "../lib/useMe";
import { playlistName } from "../lib/playlistName";
import { usePlaylistActions } from "./usePlaylistActions";
+2 -2
View File
@@ -35,8 +35,8 @@ import SelectedViewChip from "./SelectedViewChip";
import ItemCount from "./ui/ItemCount";
import { StateMessage } from "./ui/QueryState";
import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters";
import { usePlex, usePlexActions } from "./PlexProvider";
import { usePublishPlexView } from "./PlexViewBridge";
import { usePlex, usePlexActions } from "./plexContext";
import { usePublishPlexView } from "./plexViewContext";
import { EpisodeCard, PlexPosterCard } from "./plexCards";
import { PlexPinMenu } from "./ComfortPinMenu";
import { useContextMenu } from "./ui/useContextMenu";
@@ -8,7 +8,7 @@ import { notifyActionError } from "../lib/actionErrors";
import { useScrollFade } from "../lib/useScrollFade";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
import { useConfirm } from "./ConfirmProvider";
import { useConfirm } from "./confirmContext";
// Admin-only "manage this movie's collections" dialog (Collections P2). Lists the library's EDITABLE
// collections with an in/out toggle (add/remove the movie, written back to Plex), plus a field to
@@ -28,7 +28,7 @@ vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
// Spy notify without dropping the module's other exports (the render subtree uses them).
const h = vi.hoisted(() => ({ notify: vi.fn() }));
@@ -12,7 +12,7 @@ import { healPlexSort, plexSortOptions } from "../lib/plexSort";
import { usePref } from "../lib/usePref";
import SortControl from "./ui/SortControl";
import { type SortState } from "../lib/columnSort";
import { useConfirm } from "./ConfirmProvider";
import { useConfirm } from "./confirmContext";
import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi";
import { PlexPosterCard } from "./plexCards";
import { PlexPinMenu } from "./ComfortPinMenu";
+1 -1
View File
@@ -7,7 +7,7 @@ import { api, type PlexCard, type PlexDiscover as PlexDiscoverData } from "../li
import PosterTile from "./PosterTile";
import { plexLang } from "../i18n";
import { toggleWatchState } from "../lib/plexWatch";
import { usePlex } from "./PlexProvider";
import { usePlex } from "./plexContext";
import { Carousel } from "./ui/Carousel";
import { PlexPosterCard } from "./plexCards";
import { PlexPinMenu } from "./ComfortPinMenu";
+3 -9
View File
@@ -6,20 +6,14 @@ import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } f
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { notifyActionError } from "../lib/actionErrors";
import {
DetailCustomizeMenu,
Filterable,
PrefToggle,
tmdbBackdropUrl,
useCrossfadeBackdrop,
useDetailPrefs,
} from "../lib/plexDetailUi";
import { DetailCustomizeMenu, Filterable, PrefToggle } from "../lib/plexDetailUi";
import { tmdbBackdropUrl, useCrossfadeBackdrop, useDetailPrefs } from "../lib/plexDetail";
import { usePref } from "../lib/usePref";
import i18n from "../i18n";
import { Carousel } from "./ui/Carousel";
import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
import { PLAYABLE_TINT, playableClass } from "./plexCards";
import { PLAYABLE_TINT, playableClass } from "./plexPlayable";
import { navItemAttrs } from "../lib/listNav";
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
+2 -1
View File
@@ -47,7 +47,8 @@ import { createFragMeter } from "../lib/plexStats";
import { enabled, log } from "../lib/log";
import { logMediaElement } from "../lib/mediaLog";
import { UpNextCard } from "./UpNextCard";
import Toaster, { useOverlayOrder } from "./ui/Toaster";
import Toaster from "./ui/Toaster";
import { useOverlayOrder } from "./ui/overlayOrder";
import {
playhead,
useAutoSkip,
+1 -1
View File
@@ -35,7 +35,7 @@ import { api, HttpError, type PlexCard } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { formatRuntime } from "../lib/format";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { useConfirm } from "./ConfirmProvider";
import { useConfirm } from "./confirmContext";
import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
+14 -80
View File
@@ -1,12 +1,4 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { coercePlexFilters, EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
import { parseSortState, type SortState } from "../lib/columnSort";
import { parsePlexField, type PlexSearchField } from "../lib/plexSearch";
@@ -20,7 +12,14 @@ import {
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { logFilterChange } from "../lib/filterLog";
import { useUrlMirror } from "../lib/useUrlMirror";
import { useNavigation } from "./NavigationProvider";
import { useNavigation } from "./navigationContext";
import {
PlexActionsContext,
PlexStateContext,
type PlexActions,
type PlexJumpTarget,
type PlexState,
} from "./plexContext";
// 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
@@ -33,73 +32,8 @@ import { useNavigation } from "./NavigationProvider";
// hand-off (the rail sets it, PlexBrowse consumes it into a history subview then clears it). The Plex
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
// into (and is never greeted by a stale value from) the feed.
// A "jump straight to this title" hand-off: PlexBrowse opens a movie's info page (like the card's ⓘ),
// a show's drill-down (like clicking a show card), or — for E24's Home hero, whose whole promise is a
// single Play button — the PLAYER for a movie/episode rating_key. `play` exists because the player
// lives inside this module with its lazy hls.js and its provider tree; mounting a second one on the
// landing page to avoid one hand-off would be the expensive way round.
export type PlexJumpTarget = {
id: string;
type: "movie" | "show" | "play";
/** The jump ARRIVED WITH a page change — another module set this and sent the user here in one
* gesture (the Home surface plays a Plex title). The opened sub-view then REPLACES the page entry
* that hand-off just pushed, instead of stacking on top of it, so Back returns to where the user
* actually came from rather than to a module root they never visited. Omitted for a jump made
* from INSIDE the Plex module (the header typeahead), where the entry being replaced would be a
* sub-view the user really was looking at. */
replace?: boolean;
};
type PlexState = {
scope: string; // movie | show | both (unified cross-library scope)
show: string;
sort: SortState; // {key,dir}; healed against the scope's options at the point of use
filters: PlexFilters;
q: string;
field: PlexSearchField; // search scope-pill; healed against `scope` at the point of use
playlistOpen: number | null;
jumpTo: PlexJumpTarget | null; // typeahead → grid hand-off ("open this title")
};
interface PlexActions {
setScope: (v: string) => void;
setShow: (v: string) => void;
setSort: (v: SortState) => void;
setFilters: (f: PlexFilters) => void;
setQ: (q: string) => void;
setField: (f: PlexSearchField) => void;
setPlaylistOpen: (id: number | null) => void;
setJumpTo: (t: PlexJumpTarget | null) => void;
}
const StateContext = createContext<PlexState>({
scope: "both",
show: "all",
sort: PLEX_SORT_FALLBACK,
filters: EMPTY_PLEX_FILTERS,
q: "",
field: "all",
playlistOpen: null,
jumpTo: null,
});
const ActionsContext = createContext<PlexActions>({
setScope: () => {},
setShow: () => {},
setSort: () => {},
setFilters: () => {},
setQ: () => {},
setField: () => {},
setPlaylistOpen: () => {},
setJumpTo: () => {},
});
export function usePlex(): PlexState {
return useContext(StateContext);
}
export function usePlexActions(): PlexActions {
return useContext(ActionsContext);
}
//
// The contexts, their value types and the hooks live in plexContext.ts.
// The per-account init readers, shared by the useState seeds and the shared-link seed's base.
function readPlexScope(): string {
@@ -268,8 +202,8 @@ export function PlexProvider({ children }: { children: ReactNode }) {
useUrlMirror(plexParamUpdates(page === "plex" ? { scope, sort, show, filters } : null));
return (
<ActionsContext.Provider value={actions}>
<StateContext.Provider value={state}>{children}</StateContext.Provider>
</ActionsContext.Provider>
<PlexActionsContext.Provider value={actions}>
<PlexStateContext.Provider value={state}>{children}</PlexStateContext.Provider>
</PlexActionsContext.Provider>
);
}
@@ -15,14 +15,14 @@ vi.mock("react-i18next", async (importOriginal) => {
const plexState = { scope: "both" } as { scope: string };
const setScope = vi.fn();
vi.mock("./PlexProvider", () => ({
vi.mock("./plexContext", () => ({
usePlex: () => plexState,
usePlexActions: () => ({ setScope }),
}));
// The bridge: a controllable current view kind + an open spy.
const controls = { kind: "discover" as string, open: vi.fn() };
vi.mock("./PlexViewBridge", () => ({
vi.mock("./plexViewContext", () => ({
usePlexViewControls: () => controls,
}));
+2 -2
View File
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import SegmentedToggle from "./ui/SegmentedToggle";
import { usePlex, usePlexActions } from "./PlexProvider";
import { usePlexViewControls } from "./PlexViewBridge";
import { usePlex, usePlexActions } from "./plexContext";
import { usePlexViewControls } from "./plexViewContext";
// The Plex "browse target" as a compact header control next to the search box: the three library
// scopes (both | movie | show, which browse TITLES) plus two extra entry points — Playlist and
@@ -32,7 +32,7 @@ const setQ = vi.fn((v: string) => {
});
const setField = vi.fn();
const setJumpTo = vi.fn();
vi.mock("./PlexProvider", () => ({
vi.mock("./plexContext", () => ({
usePlex: () => plexState,
usePlexActions: () => ({ setQ, setField, setJumpTo }),
}));
+1 -1
View File
@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { ChevronDown, Film, Tv } from "lucide-react";
import SearchBar from "./SearchBar";
import ActionMenu from "./ui/ActionMenu";
import { usePlex, usePlexActions } from "./PlexProvider";
import { usePlex, usePlexActions } from "./plexContext";
import { api } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { useDebounced } from "../lib/useDebounced";
+2 -2
View File
@@ -17,8 +17,8 @@ import FilterChipGroup from "./ui/FilterChipGroup";
import Combobox, { type ComboboxOption } from "./ui/Combobox";
import SavedViewsPopover from "./SavedViewsPopover";
import PersonalViewsModal from "./PersonalViewsModal";
import { useSavedViews } from "./SavedViewsProvider";
import { usePlex, usePlexActions } from "./PlexProvider";
import { useSavedViews } from "./savedViewsContext";
import { usePlex, usePlexActions } from "./plexContext";
import { useMe } from "../lib/useMe";
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups
+11 -15
View File
@@ -1,5 +1,9 @@
import { createContext, useContext, useState, type ReactNode } from "react";
import { type Sub } from "./plexSubUrl";
import { useState, type ReactNode } from "react";
import {
PlexViewPublishContext,
PlexViewReadContext,
type PlexViewControls,
} from "./plexViewContext";
// A one-way bridge so the always-mounted Header can host the Plex Discover ⟷ Browse toggle even though
// the sub-view state lives in (and is owned by) the plex-only PlexBrowse. PlexBrowse PUBLISHES its
@@ -11,22 +15,14 @@ import { type Sub } from "./plexSubUrl";
// each plex entry starts at the Discover root). Lifting that hook to an always-mounted provider would
// lose the remount and let the view go stale across navigation. Keeping it in PlexBrowse preserves the
// reseed; the bridge only mirrors the small slice the header needs.
export type PlexViewControls = {
kind: Sub["kind"]; // the current top-level plex view — the header controls derive their state from it
open: (v: Sub) => void;
};
const ReadCtx = createContext<PlexViewControls | null>(null);
const PublishCtx = createContext<(c: PlexViewControls | null) => void>(() => {});
//
// The contexts + hooks live in plexViewContext.ts.
export function PlexViewBridge({ children }: { children: ReactNode }) {
const [controls, setControls] = useState<PlexViewControls | null>(null);
return (
<PublishCtx.Provider value={setControls}>
<ReadCtx.Provider value={controls}>{children}</ReadCtx.Provider>
</PublishCtx.Provider>
<PlexViewPublishContext.Provider value={setControls}>
<PlexViewReadContext.Provider value={controls}>{children}</PlexViewReadContext.Provider>
</PlexViewPublishContext.Provider>
);
}
export const usePlexViewControls = () => useContext(ReadCtx);
export const usePublishPlexView = () => useContext(PublishCtx);
@@ -13,7 +13,7 @@ vi.mock("react-i18next", async (importOriginal) => {
});
const controls = { kind: "discover" as string, open: vi.fn() };
vi.mock("./PlexViewBridge", () => ({
vi.mock("./plexViewContext", () => ({
usePlexViewControls: () => controls,
}));
+1 -1
View File
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import SegmentedToggle from "./ui/SegmentedToggle";
import { usePlexViewControls } from "./PlexViewBridge";
import { usePlexViewControls } from "./plexViewContext";
// The Discover ⟷ Browse switch, hosted in the header next to the library-scope pill (All/Movies/Shows).
// It reflects + drives PlexBrowse's top-level sub-view through the PlexViewBridge, and hides itself on
@@ -7,7 +7,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { PrefsProvider, usePrefs } from "./PrefsProvider";
import { PrefsProvider } from "./PrefsProvider";
import { usePrefs } from "./prefsContext";
import { api } from "../lib/api";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
import { accountKey, LS } from "../lib/storage";
+2 -19
View File
@@ -1,7 +1,5 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
@@ -23,13 +21,14 @@ import { configureNotifications, getNotifSettings, type NotifSettings } from "..
import { hintsEnabled, setHintsEnabled } from "../lib/hints";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import type { PrefsController } from "./SettingsPanel";
import { PrefsContext } from "./prefsContext";
const PERF_KEY = LS.perfMode;
// The four Settings-page preferences. They apply locally for instant preview and auto-save (debounced)
// to the server — there is no draft or Save button. This provider owns them (lifted out of the App god
// component) plus the last-saved "baseline" (the server values it was seeded with, then each successful
// save) used to suppress saving what the server already holds. Consumers (SettingsPanel, GlassTuner) read the PrefsController via usePrefs().
// save) used to suppress saving what the server already holds. Consumers (SettingsPanel, GlassTuner) read the PrefsController via usePrefs() (prefsContext.ts).
type EditablePrefs = {
theme: ThemePrefs;
performanceMode: boolean;
@@ -37,22 +36,6 @@ type EditablePrefs = {
notifications: NotifSettings;
};
const PrefsContext = createContext<PrefsController>({
theme: DEFAULT_THEME,
setTheme: () => {},
perf: false,
setPerf: () => {},
hints: true,
setHints: () => {},
notif: getNotifSettings(),
setNotif: () => {},
saveState: "idle",
});
export function usePrefs(): PrefsController {
return useContext(PrefsContext);
}
export function PrefsProvider({ children }: { children: ReactNode }) {
const qc = useQueryClient();
// The account's prefs, seeded straight from the `me` cache — BootGate mounts this only once /api/me
+1 -1
View File
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Lock, Pencil, Plus, Trash2 } from "lucide-react";
import Modal from "./ui/Modal";
import { useConfirm } from "./ConfirmProvider";
import { useConfirm } from "./confirmContext";
import { api, type DownloadProfile, type DownloadSpec } from "../lib/api";
import { inputCls } from "./ui/form";
import { Button } from "./ui/Button";
@@ -14,7 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
vi.mock("./SavedViewsProvider", () => ({
vi.mock("./savedViewsContext", () => ({
useSavedViews: () => ({ views: [], activeViewId: null, apply: vi.fn(), openModal }),
}));
@@ -1,13 +1,9 @@
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Bookmark, Check, Settings2 } from "lucide-react";
import {
PopoverItem,
PopoverSurface,
closeThenAct,
useAnchoredPopover,
} from "./ui/AnchoredPopover";
import { useSavedViews } from "./SavedViewsProvider";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { closeThenAct, useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { useSavedViews } from "./savedViewsContext";
// The saved-views quick-picker. The filter-panel header's icon (and the toolbar's view chip) use it as
// their trigger: click → a popover list of the user's saved views (apply on click, the active one
@@ -20,8 +20,8 @@ vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
vi.mock("./NavigationProvider", () => ({ useNavigation: () => ({ page: h.page }) }));
vi.mock("./FeedFiltersProvider", () => ({
vi.mock("./navigationContext", () => ({ useNavigation: () => ({ page: h.page }) }));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({
tags: [],
channelIds: [],
@@ -36,13 +36,14 @@ vi.mock("./FeedFiltersProvider", () => ({
}),
useFeedFiltersActions: () => ({ setFilters: h.setFeedFilters }),
}));
vi.mock("./PlexProvider", () => ({
vi.mock("./plexContext", () => ({
usePlex: () => ({ filters: {} }),
usePlexActions: () => ({ setFilters: h.setPlexFilters }),
}));
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
import { SavedViewsProvider, useSavedViews } from "./SavedViewsProvider";
import { SavedViewsProvider } from "./SavedViewsProvider";
import { useSavedViews } from "./savedViewsContext";
import { api, type SavedView } from "../lib/api";
import { LS, accountKey } from "../lib/storage";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
+14 -61
View File
@@ -1,20 +1,10 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import {
api,
coercePlexFilters,
coerceTagModes,
coerceUntagged,
getActiveAccount,
plexFiltersSignature,
type FeedFilters,
@@ -25,27 +15,23 @@ import { filtersToParams, hasFilterParams } from "../lib/urlState";
import { pickDefaultToSeed, preferSticky } from "../lib/savedViews";
import { notifyActionError } from "../lib/actionErrors";
import { accountKey, LS } from "../lib/storage";
import { useConfirm } from "./ConfirmProvider";
import { useNavigation } from "./NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { usePlex, usePlexActions } from "./PlexProvider";
import { useConfirm } from "./confirmContext";
import { useNavigation } from "./navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { usePlex, usePlexActions } from "./plexContext";
import {
asFilters,
SavedViewsCtx,
type SavedViewsContext,
type ViewSource,
} from "./savedViewsContext";
// Saved "smart views" now serve TWO modules — the feed and Plex — keyed by `source`. This one provider
// switches which module it operates on by the active page (both filter providers are global, so it can
// read either), so the shared UI (popover / modal / chip) works on the feed page against feed views and
// on the plex page against plex views without a second provider or an App-layout change.
// A saved view's `filters` is an opaque JSON blob from the DB; coerce it to the module's shape before it
// reaches app state or a serializer (an older build's dropped field just falls away — NOT a migration).
export const asFilters = (raw: FeedFilters): FeedFilters => ({
...raw,
tags: raw.tags ?? [],
channelIds: raw.channelIds ?? [],
tagModes: coerceTagModes(raw),
untagged: coerceUntagged(raw),
});
type ViewSource = "feed" | "plex";
//
// The context, its value type and the hook live in savedViewsContext.ts.
// The per-source wiring the generic logic runs on: the live filters, how to apply a view's, a stable
// signature for matching, coercion, and what to strip before saving (the feed's transient shuffle seed).
@@ -87,33 +73,6 @@ function plexCanSeedDefault(): boolean {
return !storedFiltersPresent(LS.plexFilters);
}
export interface SavedViewsContext {
/** Which module these views belong to — the shared UI branches its filter-describer on it. */
source: ViewSource;
views: SavedView[];
loading: boolean;
activeViewId: number | null;
displayView: SavedView | null;
isDirty: boolean;
modalOpen: boolean;
openModal: () => void;
closeModal: () => void;
apply: (v: SavedView) => void;
clearActive: () => void;
create: (name: string) => Promise<boolean>;
rename: (id: number, name: string) => Promise<boolean>;
/** Overwrite a view's captured filters (used by the modal's removable chips). */
updateFilters: (id: number, filters: Record<string, unknown>) => Promise<boolean>;
/** Save the module's CURRENTLY-applied filters into a view (the live→stored inverse of `apply`);
* resolves to the saved snapshot on success (so the caller can resync its editor) or null. */
saveCurrent: (id: number) => Promise<Record<string, unknown> | null>;
toggleDefault: (v: SavedView) => Promise<void>;
remove: (v: SavedView) => Promise<boolean>;
reorder: (nextIds: number[]) => Promise<void>;
}
const Ctx = createContext<SavedViewsContext | null>(null);
export function SavedViewsProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const qc = useQueryClient();
@@ -295,11 +254,5 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
},
};
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useSavedViews(): SavedViewsContext {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useSavedViews must be used within a SavedViewsProvider");
return ctx;
return <SavedViewsCtx.Provider value={value}>{children}</SavedViewsCtx.Provider>;
}
+1 -1
View File
@@ -21,7 +21,7 @@ import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { useConfirm } from "./ConfirmProvider";
import { useConfirm } from "./confirmContext";
import { formatNumber, relativeTime } from "../lib/format";
import { notify } from "../lib/notifications";
import Tooltip from "./ui/Tooltip";
+1 -1
View File
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { Bookmark, ChevronDown } from "lucide-react";
import SavedViewsPopover from "./SavedViewsPopover";
import { useSavedViews } from "./SavedViewsProvider";
import { useSavedViews } from "./savedViewsContext";
// The active personal view, shown as the leading pill before the active-filter chip row. Styled like
// the toolbar's FilterSelect pills; a warn-coloured dot appears when the live filters have diverged
@@ -27,7 +27,7 @@ vi.mock("../lib/notifications", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/notifications")>();
return { ...actual, notify: vi.fn() };
});
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => vi.fn(async () => true) }));
vi.mock("./confirmContext", () => ({ useConfirm: () => vi.fn(async () => true) }));
import { DownloadAuth } from "./SettingsPanel";
+3 -3
View File
@@ -15,11 +15,11 @@ import Tooltip from "./ui/Tooltip";
import { inputCls, Section, SettingRow, Switch } from "./ui/form";
import { Button } from "./ui/Button";
import { type SaveState } from "./ui/DraftSaveBar";
import { useConfirm } from "./ConfirmProvider";
import { useConfirm } from "./confirmContext";
import { StateMessage } from "./ui/QueryState";
import { useWizard } from "./WizardProvider";
import { useWizard } from "./wizardContext";
import { START_ON_HOME } from "./navigationInit";
import { usePrefs } from "./PrefsProvider";
import { usePrefs } from "./prefsContext";
import Tabs from "./ui/Tabs";
import { useMe } from "../lib/useMe";
+1 -1
View File
@@ -5,7 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
import clsx from "clsx";
import Modal from "./ui/Modal";
import { useConfirm } from "./ConfirmProvider";
import { useConfirm } from "./confirmContext";
import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { copyText } from "../lib/clipboard";
+3 -3
View File
@@ -9,7 +9,7 @@ import { useDebounced } from "../lib/useDebounced";
import { useActiveFilters } from "../lib/useActiveFilters";
import TagManager from "./TagManager";
import SavedViewsPopover from "./SavedViewsPopover";
import { useSavedViews } from "./SavedViewsProvider";
import { useSavedViews } from "./savedViewsContext";
import FilterRail from "./FilterRail";
import { type PanelItem } from "./ui/PanelGroups";
import { type MediaSourceKind } from "../lib/mediaSource";
@@ -17,8 +17,8 @@ import ChannelPicker from "./ChannelPicker";
import DateFilterSelect from "./DateFilterSelect";
import YearFilter from "./YearFilter";
import Combobox, { type ComboboxOption } from "./ui/Combobox";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useChannelsActions } from "./ChannelsProvider";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { useChannelsActions } from "./channelsContext";
import { useMe } from "../lib/useMe";
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
+1 -1
View File
@@ -7,7 +7,7 @@ import { api, type AdminQuotaRow, type Me } from "../lib/api";
import { formatEta, formatNumber, quotaActionLabel } from "../lib/format";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { useWizard } from "./WizardProvider";
import { useWizard } from "./wizardContext";
import { LS, useAccountPersistedState } from "../lib/storage";
import Tooltip from "./ui/Tooltip";
import { LoadingState, StateMessage } from "./ui/QueryState";
+2 -1
View File
@@ -6,7 +6,8 @@ import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lu
import { api, type MyStatus } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { formatViews } from "../lib/format";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
// Per-user status (not the global catalog): shows the number of videos available to *this*
// user, how many of *their* channels are still being fetched, and how many lack full
+2 -1
View File
@@ -3,7 +3,8 @@ import { useTranslation } from "react-i18next";
import { fitChipsWithPill } from "../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
import { filterChipClass } from "./ui/FilterChipGroup";
import { useAnchoredPopover, PopoverSurface } from "./ui/AnchoredPopover";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
// A single-line row of toggle filter chips that collapses its overflow into a trailing "+N" pill whose
// popover holds the rest (still toggleable) — the same compression the feed's applied-filter row uses
+3 -2
View File
@@ -9,8 +9,9 @@ import { notifyActionError } from "../lib/actionErrors";
import { useScrollFade } from "../lib/useScrollFade";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
import { useConfirm } from "./ConfirmProvider";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { useConfirm } from "./confirmContext";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
// One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a
// hover popover on the count listing the tagged channels (each a link that focuses it in the
@@ -14,7 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./NavigationProvider", () => ({
vi.mock("./navigationContext", () => ({
useNavigationActions: () => ({ openChannel: h.openChannel }),
}));
@@ -11,7 +11,7 @@ vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./NavigationProvider", () => ({
vi.mock("./navigationContext", () => ({
useNavigationActions: () => ({ openChannel: vi.fn() }),
}));
+1 -1
View File
@@ -1,6 +1,6 @@
import { memo, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigationActions } from "./NavigationProvider";
import { useNavigationActions } from "./navigationContext";
import {
Bookmark,
Check,
+2 -1
View File
@@ -7,7 +7,8 @@ import { api, type Video } from "../lib/api";
import { formatDate, formatDuration, formatViews } from "../lib/format";
import { linkify } from "../lib/linkify";
import Avatar from "./ui/Avatar";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
// The feed card's "more than the card face" hover infobox (E8·S3, decision D3): full (un-clamped)
// title, channel, views·duration·date, a description snippet, and the channel's tags. It hangs off a
+2 -1
View File
@@ -1,7 +1,8 @@
import { useState } from "react";
import { Check, ChevronDown, type LucideIcon } from "lucide-react";
import clsx from "clsx";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
export interface ViewOption<T extends string> {
id: T;
+2 -15
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { useHistorySubview, type SubviewUrl } from "../lib/history";
import { useHistorySubview } from "../lib/history";
import { useTranslation } from "react-i18next";
import {
ArrowRight,
@@ -34,24 +34,11 @@ import { anyBlockingLayerAbove, isTopmost, pushLayer, removeLayer } from "../lib
import { inputCls } from "./ui/form";
import { entryHash, entrySearch } from "../lib/entryUrl";
import type { VerifyEntry } from "../lib/useVerifyEntry";
import { MODE_URL, type Mode } from "./welcomeMode";
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
const PASSWORD_MIN = 10;
export type Mode = "signin" | "register" | "forgot" | "reset" | "resend";
// The card's sub-views are real history entries, so browser/mouse Back leaves one the way it leaves
// any other page — they used to be plain state, and Back silently changed only the address bar.
// "signin" is the root (no param) and "reset" is deliberately NOT mirrored: that view is only
// reachable with the one-time token from the #reset fragment, so a bookmarked ?m=reset would open a
// form that cannot submit.
export const MODE_URL: SubviewUrl<Mode> = {
key: "m",
encode: (m: Mode) => (m === "signin" || m === "reset" ? null : m),
decode: (raw: string) =>
raw === "register" || raw === "forgot" || raw === "resend" ? (raw as Mode) : null,
};
// Per-mode i18n keys for the card title and the submit button. A flat map (vs nested ternaries)
// keeps adding a new mode a one-line change and makes a missing key a compile error.
const TITLE_KEY: Record<Mode, string> = {
+6 -29
View File
@@ -1,18 +1,9 @@
import {
createContext,
lazy,
Suspense,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { lazy, Suspense, useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { qk } from "../lib/queryKeys";
import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api";
import { shouldAutoOpenOnboarding } from "../lib/onboarding";
import { WizardActionsContext, type WizardActions } from "./wizardContext";
const OnboardingWizard = lazy(() => import("./OnboardingWizard"));
@@ -24,22 +15,8 @@ const OnboardingWizard = lazy(() => import("./OnboardingWizard"));
// granted scopes + storage flags, so it's stable across the OAuth round-trip) and is reopenable from
// Settings / the notify "reconnect" action. It reads `me` as a passive qk.me() observer (BootGate owns
// the fetching query), so it re-runs the auto-open check when the account's grants change.
interface WizardActions {
openWizard: () => void;
closeWizard: () => void;
}
// Only the actions are shared — the open flag stays local to the provider (it renders the wizard
// itself), so no consumer needs to subscribe to it.
const ActionsContext = createContext<WizardActions>({
openWizard: () => {},
closeWizard: () => {},
});
export function useWizard(): WizardActions {
return useContext(ActionsContext);
}
//
// The actions context + hook live in wizardContext.ts.
export function WizardProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
@@ -71,13 +48,13 @@ export function WizardProvider({ children }: { children: ReactNode }) {
);
return (
<ActionsContext.Provider value={actions}>
<WizardActionsContext.Provider value={actions}>
{children}
{/* The wizard is a full-screen overlay; the demo account can't connect YouTube, so it never
opens there. Lazy — its chunk loads only when first opened. */}
<Suspense fallback={null}>
{open && me && !me.is_demo && <OnboardingWizard me={me} onClose={closeWizard} />}
</Suspense>
</ActionsContext.Provider>
</WizardActionsContext.Provider>
);
}
@@ -17,7 +17,7 @@ vi.mock("react-i18next", async (importOriginal) => {
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
const { notify } = vi.hoisted(() => ({
notify: vi.fn((_n: { level: string; title?: string; message: string }) => 0),
}));
+2 -1
View File
@@ -4,7 +4,8 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { ChevronDown } from "lucide-react";
import { qk } from "../lib/queryKeys";
import { api, type FeedFilters, type YearCount } from "../lib/api";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { filterChipClass } from "./ui/FilterChipGroup";
import CountBadge from "./ui/CountBadge";
import { useDebounced } from "../lib/useDebounced";
@@ -31,9 +31,8 @@ type ConfirmOpts = {
danger?: boolean;
};
const confirmMock = vi.fn(async (_opts: ConfirmOpts) => true);
vi.mock("./ConfirmProvider", () => ({
vi.mock("./confirmContext", () => ({
useConfirm: () => confirmMock,
ConfirmProvider: ({ children }: { children: React.ReactNode }) => children,
}));
const notifyMock = vi.fn();
@@ -53,9 +52,10 @@ vi.mock("../lib/useMe", () => ({ useMe: () => meMock }));
// The tab strip is rendered into an app-level toolbar slot that does not exist here, so pin the
// active tab directly — this file is about the Access-requests panel, not the tab chrome.
vi.mock("./ui/Tabs", () => ({
default: () => null,
usePersistedTab: () => ["access", () => {}],
vi.mock("./ui/Tabs", () => ({ default: () => null }));
vi.mock("../lib/storage", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/storage")>()),
useAccountPersistedState: () => ["access", () => {}],
}));
const listMock = vi.fn();
@@ -25,9 +25,8 @@ vi.mock("react-i18next", async (importOriginal) => {
};
});
vi.mock("./ConfirmProvider", () => ({
vi.mock("./confirmContext", () => ({
useConfirm: () => vi.fn(async () => true),
ConfirmProvider: ({ children }: { children: React.ReactNode }) => children,
}));
const notifyMock = vi.fn();
@@ -46,9 +45,10 @@ const meMock = {
vi.mock("../lib/useMe", () => ({ useMe: () => meMock }));
// The tab strip renders into an app-level toolbar slot that does not exist here; pin the tab.
vi.mock("./ui/Tabs", () => ({
default: () => null,
usePersistedTab: () => ["access", () => {}],
vi.mock("./ui/Tabs", () => ({ default: () => null }));
vi.mock("../lib/storage", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/storage")>()),
useAccountPersistedState: () => ["access", () => {}],
}));
const pendingMock = vi.fn();
+106
View File
@@ -0,0 +1,106 @@
import { useTranslation } from "react-i18next";
import { Check, Eye, EyeOff, History, UserMinus } from "lucide-react";
import type { ManagedChannel } from "../lib/api";
import { formatNumber } from "../lib/format";
import Tooltip from "./ui/Tooltip";
import { isFullySynced } from "./channelSync";
// The Channels-manager cells that are components in their own right (they read i18n), split out of
// channelColumns so that file holds only the column factory and its predicates (Fast Refresh).
// The Stored count, with a "fully synced" ✓ charm to the left of the number when recent uploads and
// full history are both in — folding the retired Sync column's at-a-glance status into the value it
// describes. Full detail stays in the hover tooltip.
export function StoredCell({ c }: { c: ManagedChannel }) {
const { t } = useTranslation();
return (
<span className="inline-flex items-center justify-end gap-1 text-muted tabular-nums">
{isFullySynced(c) && (
<Tooltip hint={t("channels.row.fullySyncedHint")}>
<Check
role="img"
className="w-3.5 h-3.5 text-accent shrink-0 cursor-help"
aria-label={t("channels.row.fullySynced")}
/>
</Tooltip>
)}
{formatNumber(c.stored_videos)}
</span>
);
}
export function ActionsCell({
c,
isAdmin,
canWrite,
onHide,
onDeep,
onReset,
onUnsubscribe,
}: {
c: ManagedChannel;
isAdmin: boolean;
canWrite: boolean;
onHide: () => void;
onDeep: () => void;
onReset: () => void;
onUnsubscribe: () => void;
}) {
const { t } = useTranslation();
// Admins get a "reset" trigger that re-fetches the channel from scratch regardless of
// state — always actionable. Regular users get the per-user "request full history" opt-in,
// which only applies until the back-catalog is in (or already coming for everyone).
const done = c.backfill_done;
const requested = !done && c.deep_requested;
const optInActionable = !done && !c.deep_in_queue;
const backfillHint = isAdmin
? t("channels.row.resetHint")
: c.deep_in_queue
? t("channels.row.queuedByOtherHint")
: requested
? t("channels.row.queuedRequestedHint")
: done
? t("channels.row.fullHint")
: t("channels.row.backfillThisHint");
const enabled = isAdmin || optInActionable;
return (
<div className="inline-flex items-center gap-2">
<Tooltip hint={backfillHint}>
<button
onClick={!enabled ? undefined : isAdmin ? onReset : onDeep}
disabled={!enabled}
className={`shrink-0 transition ${
requested
? "text-accent"
: enabled
? "text-muted hover:text-fg"
: "text-muted/40 cursor-default"
}`}
aria-label={isAdmin ? t("channels.row.resetBackfill") : t("channels.row.backfillThis")}
>
<History className="w-4 h-4" />
</button>
</Tooltip>
<Tooltip hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}>
<button
onClick={onHide}
className="text-muted hover:text-fg shrink-0"
aria-label={c.hidden ? t("channels.row.unhide") : t("channels.row.hideFromFeed")}
>
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</Tooltip>
{canWrite && (
<Tooltip hint={t("channels.row.unsubscribeHint")}>
<button
onClick={onUnsubscribe}
className="text-muted hover:text-danger shrink-0"
aria-label={t("channels.row.unsubscribeOnYoutube")}
>
<UserMinus className="w-4 h-4" />
</button>
</Tooltip>
)}
</div>
);
}
+4 -107
View File
@@ -1,15 +1,14 @@
import type { ComponentProps } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Check, Eye, EyeOff, History, UserMinus } from "lucide-react";
import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
import { formatCountOrDash, formatNumber, formatTotalHours, relativeTime } from "../lib/format";
import { formatCountOrDash, formatTotalHours, relativeTime } from "../lib/format";
import type { Column } from "./ui/DataTable";
import Tooltip from "./ui/Tooltip";
import { PriorityStepper } from "./ui/PriorityStepper";
import ChannelLink from "./ChannelLink";
import AboutCell from "./AboutCell";
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
import { ActionsCell, StoredCell } from "./channelCells";
import { isFullySynced } from "./channelSync";
/** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical
* in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different
@@ -28,11 +27,6 @@ export function subsColumn<T extends { subscriber_count: number | null }>(t: TFu
};
}
// A channel is "fully synced" when its recent uploads AND full back-catalogue are both in — the
// predicate the retired Sync column used, now surfaced as a ✓ charm on the Stored count and driven
// by the top sync-filter chips.
const isFullySynced = (c: ManagedChannel) => c.recent_synced && c.backfill_done;
/** Sync-status pre-filter for the subscribed table. The Sync COLUMN is gone (R14) — its status folded
* into a charm on Stored — so the top chips' `filters.sync` value is applied here instead of via a
* column predicate. Same vocab as the old column filter: "fully" | "needsFull" | "" (all). */
@@ -50,106 +44,9 @@ export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean {
return values.some((v) => c.tag_ids.includes(Number(v)));
}
// The Stored count, with a "fully synced" ✓ charm to the left of the number when recent uploads and
// full history are both in — folding the retired Sync column's at-a-glance status into the value it
// describes. Full detail stays in the hover tooltip.
function StoredCell({ c }: { c: ManagedChannel }) {
const { t } = useTranslation();
return (
<span className="inline-flex items-center justify-end gap-1 text-muted tabular-nums">
{isFullySynced(c) && (
<Tooltip hint={t("channels.row.fullySyncedHint")}>
<Check
role="img"
className="w-3.5 h-3.5 text-accent shrink-0 cursor-help"
aria-label={t("channels.row.fullySynced")}
/>
</Tooltip>
)}
{formatNumber(c.stored_videos)}
</span>
);
}
function ActionsCell({
c,
isAdmin,
canWrite,
onHide,
onDeep,
onReset,
onUnsubscribe,
}: {
c: ManagedChannel;
isAdmin: boolean;
canWrite: boolean;
onHide: () => void;
onDeep: () => void;
onReset: () => void;
onUnsubscribe: () => void;
}) {
const { t } = useTranslation();
// Admins get a "reset" trigger that re-fetches the channel from scratch regardless of
// state — always actionable. Regular users get the per-user "request full history" opt-in,
// which only applies until the back-catalog is in (or already coming for everyone).
const done = c.backfill_done;
const requested = !done && c.deep_requested;
const optInActionable = !done && !c.deep_in_queue;
const backfillHint = isAdmin
? t("channels.row.resetHint")
: c.deep_in_queue
? t("channels.row.queuedByOtherHint")
: requested
? t("channels.row.queuedRequestedHint")
: done
? t("channels.row.fullHint")
: t("channels.row.backfillThisHint");
const enabled = isAdmin || optInActionable;
return (
<div className="inline-flex items-center gap-2">
<Tooltip hint={backfillHint}>
<button
onClick={!enabled ? undefined : isAdmin ? onReset : onDeep}
disabled={!enabled}
className={`shrink-0 transition ${
requested
? "text-accent"
: enabled
? "text-muted hover:text-fg"
: "text-muted/40 cursor-default"
}`}
aria-label={isAdmin ? t("channels.row.resetBackfill") : t("channels.row.backfillThis")}
>
<History className="w-4 h-4" />
</button>
</Tooltip>
<Tooltip hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}>
<button
onClick={onHide}
className="text-muted hover:text-fg shrink-0"
aria-label={c.hidden ? t("channels.row.unhide") : t("channels.row.hideFromFeed")}
>
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</Tooltip>
{canWrite && (
<Tooltip hint={t("channels.row.unsubscribeHint")}>
<button
onClick={onUnsubscribe}
className="text-muted hover:text-danger shrink-0"
aria-label={t("channels.row.unsubscribeOnYoutube")}
>
<UserMinus className="w-4 h-4" />
</button>
</Tooltip>
)}
</div>
);
}
/** Everything the Channels-manager column set closes over. The manager owns the state + mutations;
* this factory just wires them into `Column<ManagedChannel>` render/filter/sort defs so the ~150-line
* column block lives next to its cells instead of inside the page component. */
* column block lives outside the page component (its cell components are in channelCells). */
export interface ChannelColumnDeps {
t: TFunction;
isAdmin: boolean;

Some files were not shown because too many files have changed in this diff Show More