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
+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>
);
}