From c3d88d9b201c68de0dd04f48f7f885f9fc49a4ba Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 17 Sep 2026 02:59:09 +0200 Subject: [PATCH] refactor: component files export only components; lint fails on a warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/package.json | 2 +- frontend/src/App.tsx | 8 +- frontend/src/components/AboutCell.tsx | 3 +- frontend/src/components/ActiveFilterChips.tsx | 3 +- frontend/src/components/AddToPlaylist.tsx | 3 +- .../src/components/AdminUsers.dom.test.tsx | 2 +- frontend/src/components/AdminUsers.tsx | 7 +- frontend/src/components/AppCommandPalette.tsx | 8 +- frontend/src/components/AuditLog.tsx | 2 +- frontend/src/components/BootGate.dom.test.tsx | 3 +- frontend/src/components/ChannelDiscovery.tsx | 4 +- .../ChannelPage.backfill.dom.test.tsx | 6 +- .../ChannelPage.busyFocus.dom.test.tsx | 6 +- frontend/src/components/ChannelPage.tsx | 9 +- .../src/components/ChannelStatsPopover.tsx | 3 +- frontend/src/components/ChannelTags.tsx | 3 +- frontend/src/components/Channels.tsx | 10 +- .../components/ChannelsProvider.dom.test.tsx | 11 +- frontend/src/components/ChannelsProvider.tsx | 103 ++---- frontend/src/components/ConfigPanel.tsx | 6 +- frontend/src/components/ConfirmProvider.tsx | 61 +--- frontend/src/components/DateFilterSelect.tsx | 3 +- .../DownloadCenter.confirm.dom.test.tsx | 2 +- frontend/src/components/DownloadCenter.tsx | 2 +- frontend/src/components/Feed.tsx | 2 +- .../src/components/Feed.ytsort.dom.test.tsx | 2 +- .../FeedFiltersProvider.dom.test.ts | 2 +- .../FeedFiltersProvider.seed.dom.test.tsx | 3 +- .../src/components/FeedFiltersProvider.tsx | 107 +----- frontend/src/components/FeedScopeToggle.tsx | 2 +- frontend/src/components/FeedViewProvider.tsx | 29 +- frontend/src/components/GlassTuner.tsx | 2 +- frontend/src/components/Header.tsx | 8 +- frontend/src/components/Home.dom.test.tsx | 4 +- frontend/src/components/Home.pin.dom.test.tsx | 4 +- frontend/src/components/Home.tsx | 4 +- frontend/src/components/LanguageSwitcher.tsx | 3 +- frontend/src/components/LibraryPlayer.tsx | 2 +- frontend/src/components/NavSidebar.tsx | 5 +- .../NavigationProvider.dom.test.tsx | 3 +- .../src/components/NavigationProvider.tsx | 69 +--- .../src/components/NotificationsPanel.tsx | 8 +- frontend/src/components/OnboardingWizard.tsx | 3 +- .../PersonalViewsModal.dom.test.tsx | 4 +- .../src/components/PersonalViewsModal.tsx | 2 +- .../src/components/PlayerModal.dom.test.tsx | 5 +- frontend/src/components/PlayerModal.tsx | 8 +- .../PlaylistEditFailures.dom.test.tsx | 2 +- .../src/components/Playlists.dom.test.tsx | 4 +- frontend/src/components/Playlists.tsx | 7 +- frontend/src/components/PlaylistsProvider.tsx | 41 +-- .../PlaylistsRail.rename.dom.test.tsx | 2 +- frontend/src/components/PlaylistsRail.tsx | 2 +- frontend/src/components/PlexBrowse.tsx | 4 +- .../src/components/PlexCollectionEditor.tsx | 2 +- .../PlexCollectionView.dom.test.tsx | 2 +- .../src/components/PlexCollectionView.tsx | 2 +- frontend/src/components/PlexDiscover.tsx | 2 +- frontend/src/components/PlexInfo.tsx | 12 +- frontend/src/components/PlexPlayer.tsx | 3 +- frontend/src/components/PlexPlaylistView.tsx | 2 +- frontend/src/components/PlexProvider.tsx | 94 +----- .../components/PlexScopeToggle.dom.test.tsx | 4 +- frontend/src/components/PlexScopeToggle.tsx | 4 +- .../src/components/PlexSearchBox.dom.test.tsx | 2 +- frontend/src/components/PlexSearchBox.tsx | 2 +- frontend/src/components/PlexSidebar.tsx | 4 +- frontend/src/components/PlexViewBridge.tsx | 26 +- .../components/PlexViewToggle.dom.test.tsx | 2 +- frontend/src/components/PlexViewToggle.tsx | 2 +- .../src/components/PrefsProvider.dom.test.tsx | 3 +- frontend/src/components/PrefsProvider.tsx | 21 +- frontend/src/components/ProfileEditor.tsx | 2 +- .../components/SavedViewsPopover.dom.test.tsx | 2 +- frontend/src/components/SavedViewsPopover.tsx | 10 +- .../SavedViewsProvider.dom.test.tsx | 11 +- .../src/components/SavedViewsProvider.tsx | 75 +---- frontend/src/components/Scheduler.tsx | 2 +- frontend/src/components/SelectedViewChip.tsx | 2 +- .../SettingsPanel.downloadAuth.dom.test.tsx | 2 +- frontend/src/components/SettingsPanel.tsx | 6 +- frontend/src/components/ShareDialog.tsx | 2 +- frontend/src/components/Sidebar.tsx | 6 +- frontend/src/components/Stats.tsx | 2 +- frontend/src/components/SyncStatus.tsx | 3 +- frontend/src/components/TagFilterChips.tsx | 3 +- frontend/src/components/TagManager.tsx | 5 +- .../components/VideoCard.channel.dom.test.tsx | 2 +- .../src/components/VideoCard.nav.dom.test.tsx | 2 +- frontend/src/components/VideoCard.tsx | 2 +- frontend/src/components/VideoInfoCard.tsx | 3 +- frontend/src/components/ViewSwitcher.tsx | 3 +- frontend/src/components/Welcome.tsx | 17 +- frontend/src/components/WizardProvider.tsx | 35 +- .../WriteFailureReporting.dom.test.tsx | 2 +- frontend/src/components/YearFilter.tsx | 3 +- .../src/components/adminInvites.dom.test.tsx | 10 +- .../adminPendingAccess.dom.test.tsx | 10 +- frontend/src/components/channelCells.tsx | 106 ++++++ frontend/src/components/channelColumns.tsx | 111 +------ frontend/src/components/channelSync.ts | 6 + frontend/src/components/channelsContext.ts | 73 +++++ frontend/src/components/confirmContext.ts | 62 ++++ frontend/src/components/downloadAdmin.tsx | 3 +- frontend/src/components/downloadRow.tsx | 5 +- frontend/src/components/downloadTitle.ts | 7 + frontend/src/components/feedFiltersContext.ts | 27 ++ frontend/src/components/feedFiltersInit.ts | 53 +++ frontend/src/components/feedViewContext.ts | 19 ++ frontend/src/components/modulePages.tsx | 54 +++ frontend/src/components/moduleRegistry.tsx | 54 +-- frontend/src/components/navigationContext.ts | 52 +++ frontend/src/components/playlistsContext.ts | 30 ++ frontend/src/components/plexCards.tsx | 19 +- frontend/src/components/plexContext.ts | 75 +++++ frontend/src/components/plexPlayable.ts | 20 ++ frontend/src/components/plexViewContext.ts | 15 + frontend/src/components/plexViews.tsx | 5 +- frontend/src/components/prefsContext.ts | 23 ++ frontend/src/components/savedViewsContext.ts | 50 +++ frontend/src/components/ui/ActionMenu.tsx | 3 +- .../ui/AnchoredPopover.dom.test.tsx | 3 +- .../AnchoredPopover.returnFocus.dom.test.tsx | 3 +- .../src/components/ui/AnchoredPopover.tsx | 309 +---------------- frontend/src/components/ui/Combobox.tsx | 3 +- frontend/src/components/ui/DataTable.tsx | 6 +- frontend/src/components/ui/FilterSelect.tsx | 3 +- frontend/src/components/ui/Modal.tsx | 3 +- frontend/src/components/ui/PageShell.tsx | 8 +- frontend/src/components/ui/Pager.tsx | 12 +- frontend/src/components/ui/README.md | 14 +- frontend/src/components/ui/SortControl.tsx | 3 +- frontend/src/components/ui/Tabs.tsx | 9 +- .../ui/Toaster.strictmode.dom.test.tsx | 3 +- frontend/src/components/ui/Toaster.tsx | 23 +- frontend/src/components/ui/Tooltip.tsx | 3 +- .../src/components/ui/anchoredPopoverHooks.ts | 310 ++++++++++++++++++ frontend/src/components/ui/overlayOrder.ts | 11 + frontend/src/components/ui/pageToolbarSlot.ts | 8 + frontend/src/components/ui/pagerContent.ts | 14 + frontend/src/components/ui/tableAlign.ts | 4 + frontend/src/components/ui/tableHeader.tsx | 7 +- frontend/src/components/ui/toastLevels.ts | 12 + frontend/src/components/ui/useContextMenu.ts | 2 +- .../usePlaylistActions.dom.test.tsx | 4 +- frontend/src/components/usePlaylistActions.ts | 4 +- .../usePlexActiveFilters.dom.test.tsx | 2 +- .../src/components/usePlexActiveFilters.ts | 2 +- .../components/useTagSuggestions.dom.test.tsx | 2 +- frontend/src/components/useTagSuggestions.ts | 2 +- .../src/components/welcomeMode.dom.test.ts | 2 +- frontend/src/components/welcomeMode.ts | 18 + frontend/src/components/wizardContext.ts | 20 ++ frontend/src/lib/adminUsersTab.ts | 2 +- frontend/src/lib/api/tagModes.test.ts | 2 +- frontend/src/lib/plexBackdrop.dom.test.tsx | 2 +- frontend/src/lib/plexDetail.ts | 151 +++++++++ frontend/src/lib/plexDetailUi.tsx | 149 +-------- 158 files changed, 1600 insertions(+), 1431 deletions(-) create mode 100644 frontend/src/components/channelCells.tsx create mode 100644 frontend/src/components/channelSync.ts create mode 100644 frontend/src/components/channelsContext.ts create mode 100644 frontend/src/components/confirmContext.ts create mode 100644 frontend/src/components/downloadTitle.ts create mode 100644 frontend/src/components/feedFiltersContext.ts create mode 100644 frontend/src/components/feedFiltersInit.ts create mode 100644 frontend/src/components/feedViewContext.ts create mode 100644 frontend/src/components/modulePages.tsx create mode 100644 frontend/src/components/navigationContext.ts create mode 100644 frontend/src/components/playlistsContext.ts create mode 100644 frontend/src/components/plexContext.ts create mode 100644 frontend/src/components/plexPlayable.ts create mode 100644 frontend/src/components/plexViewContext.ts create mode 100644 frontend/src/components/prefsContext.ts create mode 100644 frontend/src/components/savedViewsContext.ts create mode 100644 frontend/src/components/ui/anchoredPopoverHooks.ts create mode 100644 frontend/src/components/ui/overlayOrder.ts create mode 100644 frontend/src/components/ui/pageToolbarSlot.ts create mode 100644 frontend/src/components/ui/pagerContent.ts create mode 100644 frontend/src/components/ui/tableAlign.ts create mode 100644 frontend/src/components/ui/toastLevels.ts create mode 100644 frontend/src/components/welcomeMode.ts create mode 100644 frontend/src/components/wizardContext.ts create mode 100644 frontend/src/lib/plexDetail.ts diff --git a/frontend/package.json b/frontend/package.json index 9801d062..bb308fbf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 73212970..8dec8954 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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"; diff --git a/frontend/src/components/AboutCell.tsx b/frontend/src/components/AboutCell.tsx index 3e27a9dc..4ef722ef 100644 --- a/frontend/src/components/AboutCell.tsx +++ b/frontend/src/components/AboutCell.tsx @@ -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" diff --git a/frontend/src/components/ActiveFilterChips.tsx b/frontend/src/components/ActiveFilterChips.tsx index 47253798..6cdce7d4 100644 --- a/frontend/src/components/ActiveFilterChips.tsx +++ b/frontend/src/components/ActiveFilterChips.tsx @@ -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 diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx index e795b38a..6a6d1587 100644 --- a/frontend/src/components/AddToPlaylist.tsx +++ b/frontend/src/components/AddToPlaylist.tsx @@ -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 diff --git a/frontend/src/components/AdminUsers.dom.test.tsx b/frontend/src/components/AdminUsers.dom.test.tsx index 1015326e..590a31a4 100644 --- a/frontend/src/components/AdminUsers.dom.test.tsx +++ b/frontend/src/components/AdminUsers.dom.test.tsx @@ -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), })); diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 28495f6e..27ee9011 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -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 diff --git a/frontend/src/components/AppCommandPalette.tsx b/frontend/src/components/AppCommandPalette.tsx index a4c3f33a..132aa69b 100644 --- a/frontend/src/components/AppCommandPalette.tsx +++ b/frontend/src/components/AppCommandPalette.tsx @@ -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. diff --git a/frontend/src/components/AuditLog.tsx b/frontend/src/components/AuditLog.tsx index 0fa2618e..9aafe88d 100644 --- a/frontend/src/components/AuditLog.tsx +++ b/frontend/src/components/AuditLog.tsx @@ -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"; diff --git a/frontend/src/components/BootGate.dom.test.tsx b/frontend/src/components/BootGate.dom.test.tsx index 958218db..ea11a174 100644 --- a/frontend/src/components/BootGate.dom.test.tsx +++ b/frontend/src/components/BootGate.dom.test.tsx @@ -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"; diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index cb2179bb..13a79555 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -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 diff --git a/frontend/src/components/ChannelPage.backfill.dom.test.tsx b/frontend/src/components/ChannelPage.backfill.dom.test.tsx index bce7e3db..673a714d 100644 --- a/frontend/src/components/ChannelPage.backfill.dom.test.tsx +++ b/frontend/src/components/ChannelPage.backfill.dom.test.tsx @@ -16,11 +16,11 @@ vi.mock("react-i18next", async (importOriginal) => ({ ...(await importOriginal()), 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 })); diff --git a/frontend/src/components/ChannelPage.busyFocus.dom.test.tsx b/frontend/src/components/ChannelPage.busyFocus.dom.test.tsx index afcb0c99..4882f202 100644 --- a/frontend/src/components/ChannelPage.busyFocus.dom.test.tsx +++ b/frontend/src/components/ChannelPage.busyFocus.dom.test.tsx @@ -14,11 +14,11 @@ vi.mock("react-i18next", async (importOriginal) => ({ ...(await importOriginal()), 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 })); diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index a99802cf..1d5ac287 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -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"; diff --git a/frontend/src/components/ChannelStatsPopover.tsx b/frontend/src/components/ChannelStatsPopover.tsx index 7b0b9b8e..c31e03ab 100644 --- a/frontend/src/components/ChannelStatsPopover.tsx +++ b/frontend/src/components/ChannelStatsPopover.tsx @@ -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 diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx index 01050987..1d5de0fc 100644 --- a/frontend/src/components/ChannelTags.tsx +++ b/frontend/src/components/ChannelTags.tsx @@ -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"; diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index a4a008e7..5282300e 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -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"; diff --git a/frontend/src/components/ChannelsProvider.dom.test.tsx b/frontend/src/components/ChannelsProvider.dom.test.tsx index 829ad37d..72f5fa6a 100644 --- a/frontend/src/components/ChannelsProvider.dom.test.tsx +++ b/frontend/src/components/ChannelsProvider.dom.test.tsx @@ -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 }) => ( {children} diff --git a/frontend/src/components/ChannelsProvider.tsx b/frontend/src/components/ChannelsProvider.tsx index 4725541d..6a2aa672 100644 --- a/frontend/src/components/ChannelsProvider.tsx +++ b/frontend/src/components/ChannelsProvider.tsx @@ -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({ - 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("table"); -const ActionsContext = createContext({ - 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 ( - - - {children} - - + + + {children} + + ); } diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index 26b4cea7..5e0e4522 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -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 diff --git a/frontend/src/components/ConfirmProvider.tsx b/frontend/src/components/ConfirmProvider.tsx index 191df296..f8fd2980 100644 --- a/frontend/src/components/ConfirmProvider.tsx +++ b/frontend/src/components/ConfirmProvider.tsx @@ -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 -// 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; -type ConfirmFn = (opts: ConfirmOptions) => Promise; - -const ChoiceContext = createContext(() => 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(); diff --git a/frontend/src/components/DateFilterSelect.tsx b/frontend/src/components/DateFilterSelect.tsx index ff4a5d43..f3f883d5 100644 --- a/frontend/src/components/DateFilterSelect.tsx +++ b/frontend/src/components/DateFilterSelect.tsx @@ -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"; diff --git a/frontend/src/components/DownloadCenter.confirm.dom.test.tsx b/frontend/src/components/DownloadCenter.confirm.dom.test.tsx index f5edb5f2..43e37ab8 100644 --- a/frontend/src/components/DownloadCenter.confirm.dom.test.tsx +++ b/frontend/src/components/DownloadCenter.confirm.dom.test.tsx @@ -17,7 +17,7 @@ vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); 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, diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 08e41265..e314049e 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -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"; diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index c0c1f1c4..21f526a3 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -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"; diff --git a/frontend/src/components/Feed.ytsort.dom.test.tsx b/frontend/src/components/Feed.ytsort.dom.test.tsx index d3ced48a..e7eae5d2 100644 --- a/frontend/src/components/Feed.ytsort.dom.test.tsx +++ b/frontend/src/components/Feed.ytsort.dom.test.tsx @@ -15,7 +15,7 @@ vi.mock("react-i18next", async (importOriginal) => ({ ...(await importOriginal()), 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 }[] }) => (
    diff --git a/frontend/src/components/FeedFiltersProvider.dom.test.ts b/frontend/src/components/FeedFiltersProvider.dom.test.ts index b55edddc..95181e4a 100644 --- a/frontend/src/components/FeedFiltersProvider.dom.test.ts +++ b/frontend/src/components/FeedFiltersProvider.dom.test.ts @@ -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; diff --git a/frontend/src/components/FeedFiltersProvider.seed.dom.test.tsx b/frontend/src/components/FeedFiltersProvider.seed.dom.test.tsx index 4d4c6f2f..b4a7dfe6 100644 --- a/frontend/src/components/FeedFiltersProvider.seed.dom.test.tsx +++ b/frontend/src/components/FeedFiltersProvider.seed.dom.test.tsx @@ -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"; diff --git a/frontend/src/components/FeedFiltersProvider.tsx b/frontend/src/components/FeedFiltersProvider.tsx index d481a381..e26fc3c7 100644 --- a/frontend/src/components/FeedFiltersProvider.tsx +++ b/frontend/src/components/FeedFiltersProvider.tsx @@ -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 . // -// 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 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>(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(DEFAULT_FILTERS); -const ActionsContext = createContext({ 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(() => ({ setFilters }), [setFilters]); return ( - - {children} - + + + {children} + + ); } diff --git a/frontend/src/components/FeedScopeToggle.tsx b/frontend/src/components/FeedScopeToggle.tsx index e218bb31..9b2b3de3 100644 --- a/frontend/src/components/FeedScopeToggle.tsx +++ b/frontend/src/components/FeedScopeToggle.tsx @@ -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 diff --git a/frontend/src/components/FeedViewProvider.tsx b/frontend/src/components/FeedViewProvider.tsx index a07ad2f4..2c864c76 100644 --- a/frontend/src/components/FeedViewProvider.tsx +++ b/frontend/src/components/FeedViewProvider.tsx @@ -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("cards"); -const ActionsContext = createContext({ 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(() => ({ setView }), [setView]); return ( - - {children} - + + {children} + ); } diff --git a/frontend/src/components/GlassTuner.tsx b/frontend/src/components/GlassTuner.tsx index ffada379..0a132b59 100644 --- a/frontend/src/components/GlassTuner.tsx +++ b/frontend/src/components/GlassTuner.tsx @@ -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 diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index de2f13aa..e7ec581f 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -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. diff --git a/frontend/src/components/Home.dom.test.tsx b/frontend/src/components/Home.dom.test.tsx index 432a7276..bd2bce1f 100644 --- a/frontend/src/components/Home.dom.test.tsx +++ b/frontend/src/components/Home.dom.test.tsx @@ -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) }), })); diff --git a/frontend/src/components/Home.pin.dom.test.tsx b/frontend/src/components/Home.pin.dom.test.tsx index 2a91e8ce..e3fc616a 100644 --- a/frontend/src/components/Home.pin.dom.test.tsx +++ b/frontend/src/components/Home.pin.dom.test.tsx @@ -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"; diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx index 4331b157..9ab7b13b 100644 --- a/frontend/src/components/Home.tsx +++ b/frontend/src/components/Home.tsx @@ -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?". diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx index cb33aab5..12dd501f 100644 --- a/frontend/src/components/LanguageSwitcher.tsx +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -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). diff --git a/frontend/src/components/LibraryPlayer.tsx b/frontend/src/components/LibraryPlayer.tsx index dfb81095..4bdbc3d6 100644 --- a/frontend/src/components/LibraryPlayer.tsx +++ b/frontend/src/components/LibraryPlayer.tsx @@ -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"; diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index 42d29d2f..94b816b6 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -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 diff --git a/frontend/src/components/NavigationProvider.dom.test.tsx b/frontend/src/components/NavigationProvider.dom.test.tsx index 34606a34..2a94c602 100644 --- a/frontend/src/components/NavigationProvider.dom.test.tsx +++ b/frontend/src/components/NavigationProvider.dom.test.tsx @@ -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 }) => ( diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx index 885d5975..c9c526a1 100644 --- a/frontend/src/components/NavigationProvider.tsx +++ b/frontend/src/components/NavigationProvider.tsx @@ -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 . // -// 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({ - page: "feed", - channelView: null, - ytSearch: null, -}); -const ActionsContext = createContext({ - 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 ( - - {children} - + + {children} + ); } diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index 6abb4f10..2f828a21 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -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 diff --git a/frontend/src/components/OnboardingWizard.tsx b/frontend/src/components/OnboardingWizard.tsx index 339c9e15..691ae370 100644 --- a/frontend/src/components/OnboardingWizard.tsx +++ b/frontend/src/components/OnboardingWizard.tsx @@ -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"; diff --git a/frontend/src/components/PersonalViewsModal.dom.test.tsx b/frontend/src/components/PersonalViewsModal.dom.test.tsx index 93d7be25..85001847 100644 --- a/frontend/src/components/PersonalViewsModal.dom.test.tsx +++ b/frontend/src/components/PersonalViewsModal.dom.test.tsx @@ -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, diff --git a/frontend/src/components/PersonalViewsModal.tsx b/frontend/src/components/PersonalViewsModal.tsx index ad5ea85e..592edd1a 100644 --- a/frontend/src/components/PersonalViewsModal.tsx +++ b/frontend/src/components/PersonalViewsModal.tsx @@ -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"; diff --git a/frontend/src/components/PlayerModal.dom.test.tsx b/frontend/src/components/PlayerModal.dom.test.tsx index dee9b97c..250b0e6a 100644 --- a/frontend/src/components/PlayerModal.dom.test.tsx +++ b/frontend/src/components/PlayerModal.dom.test.tsx @@ -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"; diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 125449ce..9535ed30 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -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, diff --git a/frontend/src/components/PlaylistEditFailures.dom.test.tsx b/frontend/src/components/PlaylistEditFailures.dom.test.tsx index 625af9a0..09a0187a 100644 --- a/frontend/src/components/PlaylistEditFailures.dom.test.tsx +++ b/frontend/src/components/PlaylistEditFailures.dom.test.tsx @@ -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), })); diff --git a/frontend/src/components/Playlists.dom.test.tsx b/frontend/src/components/Playlists.dom.test.tsx index bd3f67b3..522b5a83 100644 --- a/frontend/src/components/Playlists.dom.test.tsx +++ b/frontend/src/components/Playlists.dom.test.tsx @@ -34,8 +34,8 @@ vi.mock("../lib/notifications", async (importOriginal) => ({ ...(await importOriginal()), 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"); diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 8851d548..0722502b 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -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"; diff --git a/frontend/src/components/PlaylistsProvider.tsx b/frontend/src/components/PlaylistsProvider.tsx index e6ac27b0..16581ff7 100644 --- a/frontend/src/components/PlaylistsProvider.tsx +++ b/frontend/src/components/PlaylistsProvider.tsx @@ -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({ search: "", selectedId: null }); -const ActionsContext = createContext({ - 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 ( - - {children} - + + {children} + ); } diff --git a/frontend/src/components/PlaylistsRail.rename.dom.test.tsx b/frontend/src/components/PlaylistsRail.rename.dom.test.tsx index aa11f2f6..0d1bea41 100644 --- a/frontend/src/components/PlaylistsRail.rename.dom.test.tsx +++ b/frontend/src/components/PlaylistsRail.rename.dom.test.tsx @@ -14,7 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }) }; }); -vi.mock("./PlaylistsProvider", () => ({ +vi.mock("./playlistsContext", () => ({ usePlaylists: () => ({ search: "", selectedId: 1 }), usePlaylistsActions: () => ({ setSelectedId: vi.fn() }), })); diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx index 471c4d08..cf8ad09f 100644 --- a/frontend/src/components/PlaylistsRail.tsx +++ b/frontend/src/components/PlaylistsRail.tsx @@ -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"; diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index a25071c3..ba5ba96a 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -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"; diff --git a/frontend/src/components/PlexCollectionEditor.tsx b/frontend/src/components/PlexCollectionEditor.tsx index f6091804..5f33e584 100644 --- a/frontend/src/components/PlexCollectionEditor.tsx +++ b/frontend/src/components/PlexCollectionEditor.tsx @@ -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 diff --git a/frontend/src/components/PlexCollectionView.dom.test.tsx b/frontend/src/components/PlexCollectionView.dom.test.tsx index b57763ec..f6b525bf 100644 --- a/frontend/src/components/PlexCollectionView.dom.test.tsx +++ b/frontend/src/components/PlexCollectionView.dom.test.tsx @@ -28,7 +28,7 @@ vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); 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() })); diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx index 2608d637..24e38ca1 100644 --- a/frontend/src/components/PlexCollectionView.tsx +++ b/frontend/src/components/PlexCollectionView.tsx @@ -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"; diff --git a/frontend/src/components/PlexDiscover.tsx b/frontend/src/components/PlexDiscover.tsx index 13b9c4a2..b5ef31af 100644 --- a/frontend/src/components/PlexDiscover.tsx +++ b/frontend/src/components/PlexDiscover.tsx @@ -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"; diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 7d07af69..e9141100 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -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, diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 3b628812..48f33ca9 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -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, diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index 6b3890e3..9cae0427 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -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"; diff --git a/frontend/src/components/PlexProvider.tsx b/frontend/src/components/PlexProvider.tsx index 7d14b0d9..8736d53a 100644 --- a/frontend/src/components/PlexProvider.tsx +++ b/frontend/src/components/PlexProvider.tsx @@ -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({ - scope: "both", - show: "all", - sort: PLEX_SORT_FALLBACK, - filters: EMPTY_PLEX_FILTERS, - q: "", - field: "all", - playlistOpen: null, - jumpTo: null, -}); -const ActionsContext = createContext({ - 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 ( - - {children} - + + {children} + ); } diff --git a/frontend/src/components/PlexScopeToggle.dom.test.tsx b/frontend/src/components/PlexScopeToggle.dom.test.tsx index e7ff1324..6861e5b7 100644 --- a/frontend/src/components/PlexScopeToggle.dom.test.tsx +++ b/frontend/src/components/PlexScopeToggle.dom.test.tsx @@ -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, })); diff --git a/frontend/src/components/PlexScopeToggle.tsx b/frontend/src/components/PlexScopeToggle.tsx index d4a2ca15..3eb72121 100644 --- a/frontend/src/components/PlexScopeToggle.tsx +++ b/frontend/src/components/PlexScopeToggle.tsx @@ -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 diff --git a/frontend/src/components/PlexSearchBox.dom.test.tsx b/frontend/src/components/PlexSearchBox.dom.test.tsx index 0a60e702..cefaaa98 100644 --- a/frontend/src/components/PlexSearchBox.dom.test.tsx +++ b/frontend/src/components/PlexSearchBox.dom.test.tsx @@ -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 }), })); diff --git a/frontend/src/components/PlexSearchBox.tsx b/frontend/src/components/PlexSearchBox.tsx index ea1a1d32..3c8834fc 100644 --- a/frontend/src/components/PlexSearchBox.tsx +++ b/frontend/src/components/PlexSearchBox.tsx @@ -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"; diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index b6e742de..f182cb71 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -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 diff --git a/frontend/src/components/PlexViewBridge.tsx b/frontend/src/components/PlexViewBridge.tsx index 99f2cb41..a10a787c 100644 --- a/frontend/src/components/PlexViewBridge.tsx +++ b/frontend/src/components/PlexViewBridge.tsx @@ -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(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(null); return ( - - {children} - + + {children} + ); } - -export const usePlexViewControls = () => useContext(ReadCtx); -export const usePublishPlexView = () => useContext(PublishCtx); diff --git a/frontend/src/components/PlexViewToggle.dom.test.tsx b/frontend/src/components/PlexViewToggle.dom.test.tsx index 41370aea..802fa0ad 100644 --- a/frontend/src/components/PlexViewToggle.dom.test.tsx +++ b/frontend/src/components/PlexViewToggle.dom.test.tsx @@ -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, })); diff --git a/frontend/src/components/PlexViewToggle.tsx b/frontend/src/components/PlexViewToggle.tsx index 59fadede..0743d827 100644 --- a/frontend/src/components/PlexViewToggle.tsx +++ b/frontend/src/components/PlexViewToggle.tsx @@ -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 diff --git a/frontend/src/components/PrefsProvider.dom.test.tsx b/frontend/src/components/PrefsProvider.dom.test.tsx index 76dd9f83..283e84ca 100644 --- a/frontend/src/components/PrefsProvider.dom.test.tsx +++ b/frontend/src/components/PrefsProvider.dom.test.tsx @@ -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"; diff --git a/frontend/src/components/PrefsProvider.tsx b/frontend/src/components/PrefsProvider.tsx index 37689927..7a0f655e 100644 --- a/frontend/src/components/PrefsProvider.tsx +++ b/frontend/src/components/PrefsProvider.tsx @@ -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({ - 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 diff --git a/frontend/src/components/ProfileEditor.tsx b/frontend/src/components/ProfileEditor.tsx index 8e43ba54..8d20d4f9 100644 --- a/frontend/src/components/ProfileEditor.tsx +++ b/frontend/src/components/ProfileEditor.tsx @@ -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"; diff --git a/frontend/src/components/SavedViewsPopover.dom.test.tsx b/frontend/src/components/SavedViewsPopover.dom.test.tsx index ba28aeaa..4de6ad92 100644 --- a/frontend/src/components/SavedViewsPopover.dom.test.tsx +++ b/frontend/src/components/SavedViewsPopover.dom.test.tsx @@ -14,7 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; }); -vi.mock("./SavedViewsProvider", () => ({ +vi.mock("./savedViewsContext", () => ({ useSavedViews: () => ({ views: [], activeViewId: null, apply: vi.fn(), openModal }), })); diff --git a/frontend/src/components/SavedViewsPopover.tsx b/frontend/src/components/SavedViewsPopover.tsx index b51e99bb..e3a576fd 100644 --- a/frontend/src/components/SavedViewsPopover.tsx +++ b/frontend/src/components/SavedViewsPopover.tsx @@ -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 diff --git a/frontend/src/components/SavedViewsProvider.dom.test.tsx b/frontend/src/components/SavedViewsProvider.dom.test.tsx index da28f489..6da9165f 100644 --- a/frontend/src/components/SavedViewsProvider.dom.test.tsx +++ b/frontend/src/components/SavedViewsProvider.dom.test.tsx @@ -20,8 +20,8 @@ vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); 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"; diff --git a/frontend/src/components/SavedViewsProvider.tsx b/frontend/src/components/SavedViewsProvider.tsx index 6b2ab11f..2155b5a9 100644 --- a/frontend/src/components/SavedViewsProvider.tsx +++ b/frontend/src/components/SavedViewsProvider.tsx @@ -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; - rename: (id: number, name: string) => Promise; - /** Overwrite a view's captured filters (used by the modal's removable chips). */ - updateFilters: (id: number, filters: Record) => Promise; - /** 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 | null>; - toggleDefault: (v: SavedView) => Promise; - remove: (v: SavedView) => Promise; - reorder: (nextIds: number[]) => Promise; -} - -const Ctx = createContext(null); - export function SavedViewsProvider({ children }: { children: ReactNode }) { const { t } = useTranslation(); const qc = useQueryClient(); @@ -295,11 +254,5 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) { }, }; - return {children}; -} - -export function useSavedViews(): SavedViewsContext { - const ctx = useContext(Ctx); - if (!ctx) throw new Error("useSavedViews must be used within a SavedViewsProvider"); - return ctx; + return {children}; } diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index a586bd95..4c1df97c 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -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"; diff --git a/frontend/src/components/SelectedViewChip.tsx b/frontend/src/components/SelectedViewChip.tsx index 65aa98f3..8f4edc30 100644 --- a/frontend/src/components/SelectedViewChip.tsx +++ b/frontend/src/components/SelectedViewChip.tsx @@ -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 diff --git a/frontend/src/components/SettingsPanel.downloadAuth.dom.test.tsx b/frontend/src/components/SettingsPanel.downloadAuth.dom.test.tsx index 2d87c068..9de28e61 100644 --- a/frontend/src/components/SettingsPanel.downloadAuth.dom.test.tsx +++ b/frontend/src/components/SettingsPanel.downloadAuth.dom.test.tsx @@ -27,7 +27,7 @@ vi.mock("../lib/notifications", async (importOriginal) => { const actual = await importOriginal(); 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"; diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index d818b5c9..7c7d8dc7 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -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"; diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index 79ad9322..b6a456b5 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -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"; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index c83c0e1c..abdbc506 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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.") etc. diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index db502a3a..2a23cbb8 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -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"; diff --git a/frontend/src/components/SyncStatus.tsx b/frontend/src/components/SyncStatus.tsx index 4a09c159..8e21437f 100644 --- a/frontend/src/components/SyncStatus.tsx +++ b/frontend/src/components/SyncStatus.tsx @@ -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 diff --git a/frontend/src/components/TagFilterChips.tsx b/frontend/src/components/TagFilterChips.tsx index 550c4df9..2d6bb4b2 100644 --- a/frontend/src/components/TagFilterChips.tsx +++ b/frontend/src/components/TagFilterChips.tsx @@ -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 diff --git a/frontend/src/components/TagManager.tsx b/frontend/src/components/TagManager.tsx index 4d0dfbb9..c66fa8d4 100644 --- a/frontend/src/components/TagManager.tsx +++ b/frontend/src/components/TagManager.tsx @@ -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 diff --git a/frontend/src/components/VideoCard.channel.dom.test.tsx b/frontend/src/components/VideoCard.channel.dom.test.tsx index c9e4dbe1..83185d28 100644 --- a/frontend/src/components/VideoCard.channel.dom.test.tsx +++ b/frontend/src/components/VideoCard.channel.dom.test.tsx @@ -14,7 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => ({ ...(await importOriginal()), useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), })); -vi.mock("./NavigationProvider", () => ({ +vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ openChannel: h.openChannel }), })); diff --git a/frontend/src/components/VideoCard.nav.dom.test.tsx b/frontend/src/components/VideoCard.nav.dom.test.tsx index 158b6979..2121cc3c 100644 --- a/frontend/src/components/VideoCard.nav.dom.test.tsx +++ b/frontend/src/components/VideoCard.nav.dom.test.tsx @@ -11,7 +11,7 @@ vi.mock("react-i18next", async (importOriginal) => ({ ...(await importOriginal()), useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), })); -vi.mock("./NavigationProvider", () => ({ +vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ openChannel: vi.fn() }), })); diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index 12bd40b2..5bb5474c 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -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, diff --git a/frontend/src/components/VideoInfoCard.tsx b/frontend/src/components/VideoInfoCard.tsx index 8acaad25..ff779127 100644 --- a/frontend/src/components/VideoInfoCard.tsx +++ b/frontend/src/components/VideoInfoCard.tsx @@ -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 diff --git a/frontend/src/components/ViewSwitcher.tsx b/frontend/src/components/ViewSwitcher.tsx index d1e0d730..f019ecb3 100644 --- a/frontend/src/components/ViewSwitcher.tsx +++ b/frontend/src/components/ViewSwitcher.tsx @@ -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 { id: T; diff --git a/frontend/src/components/Welcome.tsx b/frontend/src/components/Welcome.tsx index 789cca04..05d2cb71 100644 --- a/frontend/src/components/Welcome.tsx +++ b/frontend/src/components/Welcome.tsx @@ -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 = { - 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 = { diff --git a/frontend/src/components/WizardProvider.tsx b/frontend/src/components/WizardProvider.tsx index d55b3cfc..36737c29 100644 --- a/frontend/src/components/WizardProvider.tsx +++ b/frontend/src/components/WizardProvider.tsx @@ -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({ - 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 ( - + {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. */} {open && me && !me.is_demo && } - + ); } diff --git a/frontend/src/components/WriteFailureReporting.dom.test.tsx b/frontend/src/components/WriteFailureReporting.dom.test.tsx index f94b1a9f..c9bc1b2e 100644 --- a/frontend/src/components/WriteFailureReporting.dom.test.tsx +++ b/frontend/src/components/WriteFailureReporting.dom.test.tsx @@ -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), })); diff --git a/frontend/src/components/YearFilter.tsx b/frontend/src/components/YearFilter.tsx index e686f1df..92bc64e8 100644 --- a/frontend/src/components/YearFilter.tsx +++ b/frontend/src/components/YearFilter.tsx @@ -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"; diff --git a/frontend/src/components/adminInvites.dom.test.tsx b/frontend/src/components/adminInvites.dom.test.tsx index 7de481cf..fc3454fe 100644 --- a/frontend/src/components/adminInvites.dom.test.tsx +++ b/frontend/src/components/adminInvites.dom.test.tsx @@ -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()), + useAccountPersistedState: () => ["access", () => {}], })); const listMock = vi.fn(); diff --git a/frontend/src/components/adminPendingAccess.dom.test.tsx b/frontend/src/components/adminPendingAccess.dom.test.tsx index 5ebbe6f7..7a33d17d 100644 --- a/frontend/src/components/adminPendingAccess.dom.test.tsx +++ b/frontend/src/components/adminPendingAccess.dom.test.tsx @@ -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()), + useAccountPersistedState: () => ["access", () => {}], })); const pendingMock = vi.fn(); diff --git a/frontend/src/components/channelCells.tsx b/frontend/src/components/channelCells.tsx new file mode 100644 index 00000000..e5618d23 --- /dev/null +++ b/frontend/src/components/channelCells.tsx @@ -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 ( + + {isFullySynced(c) && ( + + + + )} + {formatNumber(c.stored_videos)} + + ); +} + +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 ( +
    + + + + + + + {canWrite && ( + + + + )} +
    + ); +} diff --git a/frontend/src/components/channelColumns.tsx b/frontend/src/components/channelColumns.tsx index f8b3b0dc..525ba72f 100644 --- a/frontend/src/components/channelColumns.tsx +++ b/frontend/src/components/channelColumns.tsx @@ -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: 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 ( - - {isFullySynced(c) && ( - - - - )} - {formatNumber(c.stored_videos)} - - ); -} - -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 ( -
    - - - - - - - {canWrite && ( - - - - )} -
    - ); -} - /** Everything the Channels-manager column set closes over. The manager owns the state + mutations; * this factory just wires them into `Column` 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; diff --git a/frontend/src/components/channelSync.ts b/frontend/src/components/channelSync.ts new file mode 100644 index 00000000..fc1eb86c --- /dev/null +++ b/frontend/src/components/channelSync.ts @@ -0,0 +1,6 @@ +import type { ManagedChannel } from "../lib/api"; + +// 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. +export const isFullySynced = (c: ManagedChannel) => c.recent_synced && c.backfill_done; diff --git a/frontend/src/components/channelsContext.ts b/frontend/src/components/channelsContext.ts new file mode 100644 index 00000000..5aacab1f --- /dev/null +++ b/frontend/src/components/channelsContext.ts @@ -0,0 +1,73 @@ +import { createContext, useContext } from "react"; +import type { ChannelLayout } from "../lib/channelLayout"; +import type { SortState } from "../lib/columnSort"; +import type { FilterMap } from "../lib/tableColumns"; +import type { ChannelsView } from "./Channels"; + +// The Channels module's contexts + hooks, split out of ChannelsProvider so that file exports only +// components (Fast Refresh). The provider owns the state; this is the read side every consumer imports. + +export 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; +}; + +export 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; +} + +export const ChannelsStateContext = createContext({ + 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. +export const ChannelLayoutContext = createContext("table"); +export const ChannelsActionsContext = createContext({ + setSearch: () => {}, + setFilters: () => {}, + setView: () => {}, + setLayout: () => {}, + setSort: () => {}, + focusChannel: () => {}, + goToFullHistory: () => {}, +}); + +export function useChannels(): ChannelsState { + return useContext(ChannelsStateContext); +} +/** 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(ChannelLayoutContext); +} +export function useChannelsActions(): ChannelsActions { + return useContext(ChannelsActionsContext); +} diff --git a/frontend/src/components/confirmContext.ts b/frontend/src/components/confirmContext.ts new file mode 100644 index 00000000..d8414b78 --- /dev/null +++ b/frontend/src/components/confirmContext.ts @@ -0,0 +1,62 @@ +import { createContext, useCallback, useContext, type ReactNode } from "react"; + +// App-styled, promise-based replacement for window.confirm. Wrap the tree in +// 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). +// +// The context + hooks live here, apart from the dialog in ConfirmProvider, so that file exports only +// components (Fast Refresh). +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[]; +} + +export type ChoiceFn = (opts: ChoiceOptions) => Promise; +type ConfirmFn = (opts: ConfirmOptions) => Promise; + +export const ChoiceContext = createContext(() => 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); +} diff --git a/frontend/src/components/downloadAdmin.tsx b/frontend/src/components/downloadAdmin.tsx index 4b5ee7ad..180ef597 100644 --- a/frontend/src/components/downloadAdmin.tsx +++ b/frontend/src/components/downloadAdmin.tsx @@ -9,7 +9,8 @@ import { api } from "../lib/api"; import { formatBytes } from "../lib/format"; import { inputCls } from "./ui/form"; import { Button } from "./ui/Button"; -import { StatusPill, jobTitle } from "./downloadRow"; +import { StatusPill } from "./downloadRow"; +import { jobTitle } from "./downloadTitle"; // The admin "System" tab — storage overview, per-user footprint, quota editor, and the global job // list. Split out of DownloadCenter (self-contained, admin-only surface). diff --git a/frontend/src/components/downloadRow.tsx b/frontend/src/components/downloadRow.tsx index 60570395..9ae38046 100644 --- a/frontend/src/components/downloadRow.tsx +++ b/frontend/src/components/downloadRow.tsx @@ -17,6 +17,7 @@ import { } from "../lib/format"; import { navItemAttrs } from "../lib/listNav"; import { failCopyKeys } from "../lib/downloadFailure"; +import { jobTitle } from "./downloadTitle"; // Download-row presentation + the small shared primitives (status pill, icon button, usage bar), // split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the queue, @@ -104,10 +105,6 @@ function Thumb({ job }: { job: DownloadJob }) { ); } -export function jobTitle(job: DownloadJob): string { - return job.display_name || job.asset?.title || job.source_ref; -} - // The channel to show under the title: a user override wins over the auto-extracted asset value. // `url` is null for sources without a channel link (e.g. reddit) — then it renders as plain text. function jobChannel(job: DownloadJob): { name: string; url: string | null } { diff --git a/frontend/src/components/downloadTitle.ts b/frontend/src/components/downloadTitle.ts new file mode 100644 index 00000000..e38d4963 --- /dev/null +++ b/frontend/src/components/downloadTitle.ts @@ -0,0 +1,7 @@ +import type { DownloadJob } from "../lib/api"; + +// A download job's display title (a user rename wins over the extracted asset title), shared by the +// row, the admin list and the library player. Its own module so downloadRow exports only components. +export function jobTitle(job: DownloadJob): string { + return job.display_name || job.asset?.title || job.source_ref; +} diff --git a/frontend/src/components/feedFiltersContext.ts b/frontend/src/components/feedFiltersContext.ts new file mode 100644 index 00000000..fafc6bb7 --- /dev/null +++ b/frontend/src/components/feedFiltersContext.ts @@ -0,0 +1,27 @@ +import { createContext, useContext } from "react"; +import type { FeedFilters } from "../lib/api"; +import { DEFAULT_FILTERS } from "./feedFiltersInit"; + +// The feed-filter contexts + hooks, split out of FeedFiltersProvider so that file exports only +// components (Fast Refresh). +// +// 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. + +export interface FeedFiltersActions { + setFilters: (next: FeedFilters) => void; +} + +export const FeedFiltersStateContext = createContext(DEFAULT_FILTERS); +export const FeedFiltersActionsContext = createContext({ + setFilters: () => {}, +}); + +export function useFeedFilters(): FeedFilters { + return useContext(FeedFiltersStateContext); +} +export function useFeedFiltersActions(): FeedFiltersActions { + return useContext(FeedFiltersActionsContext); +} diff --git a/frontend/src/components/feedFiltersInit.ts b/frontend/src/components/feedFiltersInit.ts new file mode 100644 index 00000000..fe0f9594 --- /dev/null +++ b/frontend/src/components/feedFiltersInit.ts @@ -0,0 +1,53 @@ +import { coerceTagModes, coerceUntagged, DEFAULT_TAG_MODES, type FeedFilters } from "../lib/api"; +import { accountKey, LS, readJSON } from "../lib/storage"; + +// The feed filters' defaults and the per-account localStorage seed, split out of FeedFiltersProvider +// (like navigationInit / playlistsInit) so that file exports only components (Fast Refresh). + +export 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", +}; + +export 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>(fKey, {}); + return { + ...DEFAULT_FILTERS, + ...raw, + tagModes: coerceTagModes(raw), + untagged: coerceUntagged(raw), + }; + } + return { ...DEFAULT_FILTERS }; +} diff --git a/frontend/src/components/feedViewContext.ts b/frontend/src/components/feedViewContext.ts new file mode 100644 index 00000000..5a934e38 --- /dev/null +++ b/frontend/src/components/feedViewContext.ts @@ -0,0 +1,19 @@ +import { createContext, useContext } from "react"; +import type { FeedView } from "../lib/feedView"; + +// The feed view-mode contexts + hooks, split out of FeedViewProvider so that file exports only +// components (Fast Refresh). + +export interface FeedViewActions { + setView: (v: FeedView) => void; +} + +export const FeedViewStateContext = createContext("cards"); +export const FeedViewActionsContext = createContext({ setView: () => {} }); + +export function useFeedView(): FeedView { + return useContext(FeedViewStateContext); +} +export function useFeedViewActions(): FeedViewActions { + return useContext(FeedViewActionsContext); +} diff --git a/frontend/src/components/modulePages.tsx b/frontend/src/components/modulePages.tsx new file mode 100644 index 00000000..dd752c74 --- /dev/null +++ b/frontend/src/components/modulePages.tsx @@ -0,0 +1,54 @@ +import { lazyWithPreload } from "../lib/lazyWithPreload"; +import { useMe } from "../lib/useMe"; +import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; +import { useFeedView, useFeedViewActions } from "./feedViewContext"; +import { useNavigation, useNavigationActions } from "./navigationContext"; +import PersonalViewsModal from "./PersonalViewsModal"; +import { useSavedViews } from "./savedViewsContext"; + +// The two module-registry pages that need per-render wiring (see moduleRegistry), split out of it so +// that file exports only data and this one only components (Fast Refresh). Their lazy chunks are +// exported alongside so the registry can attach each chunk's preload to the wrapper that renders it. +export const Feed = lazyWithPreload(() => import("./Feed")); +export const Messages = lazyWithPreload(() => import("./Messages")); + +// The GLOBAL feed page: wires to the app-wide feed filters / view / live-search. (The channel +// page renders itself with its own channel-scoped filters, so those two never share state.) +export function FeedPage() { + const me = useMe(); + const filters = useFeedFilters(); + const { setFilters } = useFeedFiltersActions(); + const view = useFeedView(); + const { setView } = useFeedViewActions(); + const { ytSearch } = useNavigation(); + const { enterYtSearch, exitYtSearch } = useNavigationActions(); + // The Personal Views modal lives at the feed-page level (always mounted while on the feed, unmounts + // on navigation) rather than inside the collapsible filter rail — otherwise opening it from the + // always-visible toolbar view chip while the rail is unpinned would render nothing and latch open. + const { modalOpen } = useSavedViews(); + return ( + <> + + {modalOpen && !me.is_demo && } + + ); +} + +export function MessagesPage() { + const me = useMe(); + return ( +
    + +
    + ); +} diff --git a/frontend/src/components/moduleRegistry.tsx b/frontend/src/components/moduleRegistry.tsx index ac5361fe..da3aff5f 100644 --- a/frontend/src/components/moduleRegistry.tsx +++ b/frontend/src/components/moduleRegistry.tsx @@ -2,14 +2,9 @@ import type { ComponentType } from "react"; import { lazyWithPreload, type PreloadableComponent } from "../lib/lazyWithPreload"; import type { Page } from "../lib/urlState"; import type { PanelId, PanelLayout } from "../lib/panelLayout"; -import { useMe } from "../lib/useMe"; -import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; -import { useFeedView, useFeedViewActions } from "./FeedViewProvider"; -import { useNavigation, useNavigationActions } from "./NavigationProvider"; import Sidebar from "./Sidebar"; import PlaylistsRail from "./PlaylistsRail"; -import PersonalViewsModal from "./PersonalViewsModal"; -import { useSavedViews } from "./SavedViewsProvider"; +import { Feed, FeedPage, Messages, MessagesPage } from "./modulePages"; // The RENDER half of the module registry: which component (and side rail) each page renders. The // metadata half — nav order, label, icon, gate — lives in lib/modules.ts (imported by NavSidebar / @@ -18,21 +13,19 @@ import { useSavedViews } from "./SavedViewsProvider"; // // Every content entry is a PROP-FREE component: it reads `me` from useMe() and its module-local state // from the scoped providers, so App renders `` uniformly. The two that need per-render -// wiring get a thin wrapper here (FeedPage supplies the GLOBAL feed scope; the channel page threads -// its own isolated scope to directly — see the scope note in FeedFiltersProvider). +// wiring get a thin wrapper in modulePages (FeedPage supplies the GLOBAL feed scope; the channel page +// threads its own isolated scope to directly — see the scope note in FeedFiltersProvider). // Route-level code splitting: each page is its own lazy chunk (admin-only pages never reach // non-admins; the logged-out landing pulls only the shell). Rendered inside App's . Each // one is preloadable: App fetches the chunks of every page the account can reach once the first page // is on screen (preloadPages below), so switching modules never shows the chunk fallback. const Home = lazyWithPreload(() => import("./Home")); -const Feed = lazyWithPreload(() => import("./Feed")); const Channels = lazyWithPreload(() => import("./Channels")); const Playlists = lazyWithPreload(() => import("./Playlists")); const PlexBrowse = lazyWithPreload(() => import("./PlexBrowse")); const PlexSidebar = lazyWithPreload(() => import("./PlexSidebar")); const NotificationsPanel = lazyWithPreload(() => import("./NotificationsPanel")); -const Messages = lazyWithPreload(() => import("./Messages")); const DownloadCenter = lazyWithPreload(() => import("./DownloadCenter")); const Stats = lazyWithPreload(() => import("./Stats")); const Scheduler = lazyWithPreload(() => import("./Scheduler")); @@ -41,47 +34,6 @@ const AdminUsers = lazyWithPreload(() => import("./AdminUsers")); const AuditLog = lazyWithPreload(() => import("./AuditLog")); const SettingsPanel = lazyWithPreload(() => import("./SettingsPanel")); -// The GLOBAL feed page: wires to the app-wide feed filters / view / live-search. (The channel -// page renders itself with its own channel-scoped filters, so those two never share state.) -function FeedPage() { - const me = useMe(); - const filters = useFeedFilters(); - const { setFilters } = useFeedFiltersActions(); - const view = useFeedView(); - const { setView } = useFeedViewActions(); - const { ytSearch } = useNavigation(); - const { enterYtSearch, exitYtSearch } = useNavigationActions(); - // The Personal Views modal lives at the feed-page level (always mounted while on the feed, unmounts - // on navigation) rather than inside the collapsible filter rail — otherwise opening it from the - // always-visible toolbar view chip while the rail is unpinned would render nothing and latch open. - const { modalOpen } = useSavedViews(); - return ( - <> - - {modalOpen && !me.is_demo && } - - ); -} - -function MessagesPage() { - const me = useMe(); - return ( -
    - -
    - ); -} - export type PageDef = { // Prop-free by construction: reads `me` + module state from hooks/providers. Preloadable by type, so // a new page cannot silently opt out of preloadPages. diff --git a/frontend/src/components/navigationContext.ts b/frontend/src/components/navigationContext.ts new file mode 100644 index 00000000..b557cefc --- /dev/null +++ b/frontend/src/components/navigationContext.ts @@ -0,0 +1,52 @@ +import { createContext, useContext } from "react"; +import type { Page } from "../lib/urlState"; +import type { ChannelView } from "./navigationInit"; + +// The navigation contexts + their hooks, split out of NavigationProvider so that file exports only +// components (Fast Refresh can then hot-swap the provider without remounting the tree). +// +// 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; +} + +export const NavigationStateContext = createContext({ + page: "feed", + channelView: null, + ytSearch: null, +}); +export const NavigationActionsContext = createContext({ + setPage: () => {}, + openChannel: () => {}, + closeChannel: () => {}, + enterYtSearch: () => {}, + exitYtSearch: () => {}, +}); + +export function useNavigation(): NavigationState { + return useContext(NavigationStateContext); +} +export function useNavigationActions(): NavigationActions { + return useContext(NavigationActionsContext); +} diff --git a/frontend/src/components/playlistsContext.ts b/frontend/src/components/playlistsContext.ts new file mode 100644 index 00000000..89dc9e7a --- /dev/null +++ b/frontend/src/components/playlistsContext.ts @@ -0,0 +1,30 @@ +import { createContext, useContext } from "react"; + +// The Playlists module's contexts + hooks, split out of PlaylistsProvider so that file exports only +// components (Fast Refresh). + +export type PlaylistsState = { + search: string; + selectedId: number | null; +}; + +export interface PlaylistsActions { + setSearch: (q: string) => void; + setSelectedId: (id: number | null) => void; +} + +export const PlaylistsStateContext = createContext({ + search: "", + selectedId: null, +}); +export const PlaylistsActionsContext = createContext({ + setSearch: () => {}, + setSelectedId: () => {}, +}); + +export function usePlaylists(): PlaylistsState { + return useContext(PlaylistsStateContext); +} +export function usePlaylistsActions(): PlaylistsActions { + return useContext(PlaylistsActionsContext); +} diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx index 7f021a4d..c196546e 100644 --- a/frontend/src/components/plexCards.tsx +++ b/frontend/src/components/plexCards.tsx @@ -5,28 +5,11 @@ import { formatRuntime } from "../lib/format"; import { Carousel } from "./ui/Carousel"; import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api"; import { navItemAttrs } from "../lib/listNav"; +import { PLAYABLE_TINT, playableClass } from "./plexPlayable"; // Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared // state). Rendered by the browse grid, the person page, and the series show/season views. -// Playback cost, escalating good→heavy: Direct Play (green) → Direct Stream / remux, a light -// repackage (blue) → Transcode · HW, a hardware re-encode (amber) → Transcode · SW, a software -// re-encode (red). transcode splits HW vs SW by card.transcode_hw. Reuses the semantic tokens as a -// gradient; all four stay visually distinct in light + dark. -export const PLAYABLE_TINT: Record = { - direct: "bg-success", - remux: "bg-info", - transcode_hw: "bg-warn", - transcode_sw: "bg-danger", -}; -// Collapse the wire `playable` (direct|remux|transcode) + transcode_hw flag into one of the 4 display -// classes, so the corner dot's colour + tooltip can't drift from the sidebar filter's option keys. -// Shared with the info-page playback chip (PlexInfo) so both surfaces derive the class identically. -export function playableClass(playable: string, transcodeHw?: boolean | null): string { - if (playable === "transcode") return transcodeHw ? "transcode_hw" : "transcode_sw"; - return playable; // direct | remux -} - // The reveal class for a status pill (watched / in-progress) that shares a corner with a hover-reveal // control (the watched-toggle). When that control is present it takes over on touch, so the pill yields // via `.reveal-idle` (hidden on touch, faded on hover); when there's no such control the pill is the diff --git a/frontend/src/components/plexContext.ts b/frontend/src/components/plexContext.ts new file mode 100644 index 00000000..f99e7852 --- /dev/null +++ b/frontend/src/components/plexContext.ts @@ -0,0 +1,75 @@ +import { createContext, useContext } from "react"; +import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api"; +import type { SortState } from "../lib/columnSort"; +import type { PlexSearchField } from "../lib/plexSearch"; +import { PLEX_SORT_FALLBACK } from "../lib/plexSort"; + +// The Plex module's contexts + hooks, split out of PlexProvider so that file exports only components +// (Fast Refresh). + +// 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; +}; + +export 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") +}; + +export 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; +} + +export const PlexStateContext = createContext({ + scope: "both", + show: "all", + sort: PLEX_SORT_FALLBACK, + filters: EMPTY_PLEX_FILTERS, + q: "", + field: "all", + playlistOpen: null, + jumpTo: null, +}); +export const PlexActionsContext = createContext({ + setScope: () => {}, + setShow: () => {}, + setSort: () => {}, + setFilters: () => {}, + setQ: () => {}, + setField: () => {}, + setPlaylistOpen: () => {}, + setJumpTo: () => {}, +}); + +export function usePlex(): PlexState { + return useContext(PlexStateContext); +} +export function usePlexActions(): PlexActions { + return useContext(PlexActionsContext); +} diff --git a/frontend/src/components/plexPlayable.ts b/frontend/src/components/plexPlayable.ts new file mode 100644 index 00000000..dfec48c3 --- /dev/null +++ b/frontend/src/components/plexPlayable.ts @@ -0,0 +1,20 @@ +// The playback-cost classes and their dot tints, shared by the Plex cards (plexCards) and the info +// page's playback chip (PlexInfo). Its own module so plexCards exports only components (Fast Refresh). + +// Playback cost, escalating good→heavy: Direct Play (green) → Direct Stream / remux, a light +// repackage (blue) → Transcode · HW, a hardware re-encode (amber) → Transcode · SW, a software +// re-encode (red). transcode splits HW vs SW by card.transcode_hw. Reuses the semantic tokens as a +// gradient; all four stay visually distinct in light + dark. +export const PLAYABLE_TINT: Record = { + direct: "bg-success", + remux: "bg-info", + transcode_hw: "bg-warn", + transcode_sw: "bg-danger", +}; +// Collapse the wire `playable` (direct|remux|transcode) + transcode_hw flag into one of the 4 display +// classes, so the corner dot's colour + tooltip can't drift from the sidebar filter's option keys. +// Shared with the info-page playback chip (PlexInfo) so both surfaces derive the class identically. +export function playableClass(playable: string, transcodeHw?: boolean | null): string { + if (playable === "transcode") return transcodeHw ? "transcode_hw" : "transcode_sw"; + return playable; // direct | remux +} diff --git a/frontend/src/components/plexViewContext.ts b/frontend/src/components/plexViewContext.ts new file mode 100644 index 00000000..434e452c --- /dev/null +++ b/frontend/src/components/plexViewContext.ts @@ -0,0 +1,15 @@ +import { createContext, useContext } from "react"; +import { type Sub } from "./plexSubUrl"; + +// The read + publish sides of the Plex view bridge (see PlexViewBridge for why it is a bridge), split +// out of that file so it exports only components (Fast Refresh). +export type PlexViewControls = { + kind: Sub["kind"]; // the current top-level plex view — the header controls derive their state from it + open: (v: Sub) => void; +}; + +export const PlexViewReadContext = createContext(null); +export const PlexViewPublishContext = createContext<(c: PlexViewControls | null) => void>(() => {}); + +export const usePlexViewControls = () => useContext(PlexViewReadContext); +export const usePublishPlexView = () => useContext(PlexViewPublishContext); diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index a1f543fc..3af1e629 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -14,13 +14,10 @@ import { BackBtn, DetailCustomizeMenu, DetailHero, - epLabel, Filterable, - tmdbBackdropUrl, - useCrossfadeBackdrop, - useDetailPrefs, WatchedToggle, } from "../lib/plexDetailUi"; +import { epLabel, tmdbBackdropUrl, useCrossfadeBackdrop, useDetailPrefs } from "../lib/plexDetail"; import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards"; import { PlexPinMenu } from "./ComfortPinMenu"; import { useContextMenu } from "./ui/useContextMenu"; diff --git a/frontend/src/components/prefsContext.ts b/frontend/src/components/prefsContext.ts new file mode 100644 index 00000000..29988413 --- /dev/null +++ b/frontend/src/components/prefsContext.ts @@ -0,0 +1,23 @@ +import { createContext, useContext } from "react"; +import { DEFAULT_THEME } from "../lib/theme"; +import { getNotifSettings } from "../lib/notifications"; +import type { PrefsController } from "./SettingsPanel"; + +// The Settings-page preferences context + hook, split out of PrefsProvider so that file exports only +// components (Fast Refresh). + +export const PrefsContext = createContext({ + theme: DEFAULT_THEME, + setTheme: () => {}, + perf: false, + setPerf: () => {}, + hints: true, + setHints: () => {}, + notif: getNotifSettings(), + setNotif: () => {}, + saveState: "idle", +}); + +export function usePrefs(): PrefsController { + return useContext(PrefsContext); +} diff --git a/frontend/src/components/savedViewsContext.ts b/frontend/src/components/savedViewsContext.ts new file mode 100644 index 00000000..b3fc74d7 --- /dev/null +++ b/frontend/src/components/savedViewsContext.ts @@ -0,0 +1,50 @@ +import { createContext, useContext } from "react"; +import { coerceTagModes, coerceUntagged, type FeedFilters, type SavedView } from "../lib/api"; + +// The saved-views context + hook (and the feed blob coercion its consumers share), split out of +// SavedViewsProvider so that file exports only components (Fast Refresh). + +// 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), +}); + +export type ViewSource = "feed" | "plex"; + +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; + rename: (id: number, name: string) => Promise; + /** Overwrite a view's captured filters (used by the modal's removable chips). */ + updateFilters: (id: number, filters: Record) => Promise; + /** 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 | null>; + toggleDefault: (v: SavedView) => Promise; + remove: (v: SavedView) => Promise; + reorder: (nextIds: number[]) => Promise; +} + +export const SavedViewsCtx = createContext(null); + +export function useSavedViews(): SavedViewsContext { + const ctx = useContext(SavedViewsCtx); + if (!ctx) throw new Error("useSavedViews must be used within a SavedViewsProvider"); + return ctx; +} diff --git a/frontend/src/components/ui/ActionMenu.tsx b/frontend/src/components/ui/ActionMenu.tsx index d643ce33..3e591d48 100644 --- a/frontend/src/components/ui/ActionMenu.tsx +++ b/frontend/src/components/ui/ActionMenu.tsx @@ -1,6 +1,7 @@ import { Fragment, useState, type ReactNode } from "react"; import { ChevronRight, MoreHorizontal } from "lucide-react"; -import { PopoverItem, PopoverSurface, closeThenAct, useAnchoredPopover } from "./AnchoredPopover"; +import { PopoverItem, PopoverSurface } from "./AnchoredPopover"; +import { closeThenAct, useAnchoredPopover } from "./anchoredPopoverHooks"; // A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions. // Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher diff --git a/frontend/src/components/ui/AnchoredPopover.dom.test.tsx b/frontend/src/components/ui/AnchoredPopover.dom.test.tsx index 51afe911..6533bb75 100644 --- a/frontend/src/components/ui/AnchoredPopover.dom.test.tsx +++ b/frontend/src/components/ui/AnchoredPopover.dom.test.tsx @@ -10,7 +10,8 @@ import { describe, it, expect, beforeAll, afterEach } from "vitest"; import { useState } from "react"; import { render, screen, cleanup } from "@testing-library/react"; import { FloatingTree } from "@floating-ui/react"; -import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover"; +import { PopoverSurface, PopoverItem } from "./AnchoredPopover"; +import { useAnchoredPopover } from "./anchoredPopoverHooks"; beforeAll(() => { class Stub { diff --git a/frontend/src/components/ui/AnchoredPopover.returnFocus.dom.test.tsx b/frontend/src/components/ui/AnchoredPopover.returnFocus.dom.test.tsx index 6ad34862..b412056a 100644 --- a/frontend/src/components/ui/AnchoredPopover.returnFocus.dom.test.tsx +++ b/frontend/src/components/ui/AnchoredPopover.returnFocus.dom.test.tsx @@ -7,7 +7,8 @@ import { describe, it, expect, beforeAll, afterEach } from "vitest"; import { render, screen, cleanup, act } from "@testing-library/react"; import { FloatingTree } from "@floating-ui/react"; -import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover"; +import { PopoverSurface } from "./AnchoredPopover"; +import { useAnchoredPopover } from "./anchoredPopoverHooks"; beforeAll(() => { class Stub { diff --git a/frontend/src/components/ui/AnchoredPopover.tsx b/frontend/src/components/ui/AnchoredPopover.tsx index 1b7d2107..d95e4f9a 100644 --- a/frontend/src/components/ui/AnchoredPopover.tsx +++ b/frontend/src/components/ui/AnchoredPopover.tsx @@ -1,45 +1,22 @@ import { createContext, useContext, - useEffect, useId, useLayoutEffect, - useMemo, - useRef, useState, type ButtonHTMLAttributes, type CSSProperties, type ReactNode, } from "react"; import { - useFloating, - autoUpdate, - offset as offsetMiddleware, - flip, - shift, - size, - hide, - useClick, - useHover, - useFocus, - useDismiss, - useRole, - useListNavigation, - useTypeahead, - useInteractions, - useFloatingNodeId, useListItem, - safePolygon, FloatingNode, FloatingList, FloatingPortal, FloatingFocusManager, - type Placement, - type UseRoleProps, } from "@floating-ui/react"; -import { useLayer } from "../../lib/layerStack"; import { useFullscreenPortalHost } from "../../lib/fullscreen"; -import { pageStep, pageTarget, scrollParent } from "../../lib/listPaging"; +import type { useAnchoredPopover } from "./anchoredPopoverHooks"; // R8 popover platform. ONE anchored-popover primitive over @floating-ui/react, replacing the ~9 // hand-rolled positioning engines (measured flip / width clamp / rAF scroll-follow / scroller-relative @@ -79,280 +56,12 @@ import { pageStep, pageTarget, scrollParent } from "../../lib/listPaging"; // target). The caller mounts the INPUT as `refs.setReference` (makes it a "typeable combobox reference") // and the field box as `refs.setPositionReference` (so the panel anchors under the whole chips+input // field). The universal component wraps all of this — call sites use it, not this mode raw. - -// Internal to the platform; call sites pass the string literal via `mode`, they don't name the type. -type PopoverMode = "menu" | "listbox" | "hover" | "dialog" | "combobox"; - -export interface AnchoredPopoverOptions { - open: boolean; - onOpenChange: (open: boolean, event?: Event) => void; - /** "menu"/"listbox"/"dialog" open on click; "hover" opens on hover+focus (tooltips, hover cards); - * "combobox" is a typeable input + listbox where focus stays in the field (use via ). */ - mode?: PopoverMode; - placement?: Placement; - /** Gap between trigger and panel, px. Default 4. */ - offset?: number; - /** Hover mode only: open/close intent delays, ms. */ - hoverDelay?: number | { open?: number; close?: number }; - /** ARIA role for the panel; defaults to "tooltip" in hover mode, else "menu". */ - role?: UseRoleProps["role"]; - /** Cap the panel height to the available space (adds the `size` middleware). Off by default so a - * caller's own max-height class wins; turn on for long lists that should shrink to fit. */ - fitHeight?: boolean; - /** Type-to-focus a matching item by label (menu/listbox only). Default on; turn OFF for menus that - * contain a free-text input, where character keys belong to the text field. */ - typeahead?: boolean; - /** This menu hosts a free-text input (an inline "create" field). Turns off BOTH typeahead AND - * focus-item-on-hover, so neither typed characters nor a pointer passing over a list item steals - * focus/keystrokes from the field. Set it instead of `typeahead:false` for such menus. */ - hostsInput?: boolean; - /** Menu mode only: ALSO open on mouse hover, keeping the click/tap toggle. The submenu idiom — - * a mouse expects a submenu to fall open under the pointer, a finger has no hover at all and - * must tap. `mouseOnly` is forced on so a tap's synthetic mouseenter can't open the panel a - * hair before the click handler toggles it shut again. */ - hoverAlso?: boolean; - /** Index of the item that is currently CHOSEN, for a single-choice menu/listbox. Opening then - * lands the roving focus on it instead of the first item — the behaviour a "pick one of these - * modes" menu needs. Leave unset for action menus and multi-select toggle lists. */ - selectedIndex?: number | null; - /** Close when a scroll container under the panel scrolls. Off by default, because a panel anchored - * to a real trigger element simply MOVES with it. Turn it on when the anchor is a fixed point - * rather than an element — a right-click menu, which otherwise stays parked over whatever row - * scrolls under it while its actions still target the row you opened it on (E10 review round 1). - * This is also what a native context menu does. */ - dismissOnScroll?: boolean; -} +// +// The hook itself (`useAnchoredPopover`) and `closeThenAct` live in anchoredPopoverHooks.ts. // Internal: call sites use the inferred hook return, they don't name the type. type AnchoredPopover = ReturnType; -export function useAnchoredPopover(opts: AnchoredPopoverOptions) { - const mode = opts.mode ?? "menu"; - const isHover = mode === "hover"; - const isCombobox = mode === "combobox"; - // Roving-keyboard list surfaces: a menu of actions or a listbox of options. A "dialog" popover holds - // arbitrary focusable content (its own Tab order), and "hover" is a passive tooltip — neither roves. - const isNav = mode === "menu" || mode === "listbox"; - // Surfaces that host a navigable item list (registry + list-navigation): the roving menu/listbox AND - // the virtual-nav combobox. The combobox navigates WITHOUT moving DOM focus (see below), so it is kept - // out of `isNav` (which also gates the focus trap + typeahead + roving tabindex) and folded in here. - const roves = isNav || isCombobox; - // null when not inside a — the hook still works, just without nested-layer awareness. - const nodeId = useFloatingNodeId(); - - // Item registry for list navigation + typeahead, populated by via useListItem. - const elementsRef = useRef>([]); - const labelsRef = useRef>([]); - const [activeIndex, setActiveIndex] = useState(null); - // Pointer-hover highlight, kept SEPARATE from floating-ui's activeIndex on purpose: useListNavigation - // syncs DOM focus to activeIndex, so setting activeIndex on hover would blur a menu's free-text input - // (the create-tag field) mid-typing. hoverIndex drives only the VISUAL highlight; DOM focus never - // follows it. The highlighted row = hoverIndex ?? activeIndex, so there is exactly one highlight that - // tracks the pointer when hovering and the keyboard otherwise. - const [hoverIndex, setHoverIndex] = useState(null); - - const { refs, floatingStyles, context, middlewareData, isPositioned } = useFloating({ - nodeId, - open: opts.open, - onOpenChange: opts.onOpenChange, - placement: opts.placement ?? "bottom-start", - whileElementsMounted: autoUpdate, - middleware: [ - offsetMiddleware(opts.offset ?? 4), - flip({ padding: 8 }), - shift({ padding: 8 }), - ...(opts.fitHeight - ? [ - size({ - padding: 8, - apply({ availableHeight, elements }) { - // Cap the positioned element, AND publish the available space as a custom property so - // the INNER styled/scroll wrapper (PopoverSurface renders `className` on a child, not on - // the floating element) can cap itself too — e.g. `maxHeight: min(28rem, var(--fit-max-h))` - // keeps a caller's design cap while still shrinking to fit at high zoom / short viewports. - // Capping only the outer element would leave the inner overflow-y-auto box at its fixed - // height, spilling its top off-screen (the whole point of fitHeight). - elements.floating.style.maxHeight = `${availableHeight}px`; - elements.floating.style.setProperty("--fit-max-h", `${availableHeight}px`); - }, - }), - ] - : []), - // A combobox panel matches the field's width (with a sane floor for a very narrow rail); its inner - // scroll box owns the height cap. `rects.reference` is the POSITION reference — the whole chips+input - // field box (the caller mounts it via setPositionReference), not the bare input. - ...(isCombobox - ? [ - size({ - padding: 8, - apply({ rects, elements }) { - elements.floating.style.width = `${Math.max(rects.reference.width, 192)}px`; - }, - }), - ] - : []), - hide({ padding: 8 }), - ], - }); - - // A stale active item would highlight (and steal the next open's initial focus) the second time a - // menu opens; clear it whenever the surface closes. - useEffect(() => { - if (!opts.open) { - setActiveIndex(null); - setHoverIndex(null); - } - }, [opts.open]); - - // A combobox opens from the field's own focus/typing (wired by ), never a click-to-toggle — - // a click on the input must place the caret, not close the just-opened list. - const click = useClick(context, { enabled: !isHover && !isCombobox }); - const hover = useHover(context, { - enabled: isHover || !!opts.hoverAlso, - move: false, - // A hover-card is a pointer affordance either way; a hover-ALSO menu must ignore touch, or the - // synthetic mouseenter a tap emits would race the click toggle (open, then immediately shut). - mouseOnly: !isHover, - // A submenu wants a shorter intent delay than a hover card: the pointer is already inside a - // menu, so the "did they mean it" question is mostly answered. - delay: opts.hoverDelay ?? (isHover ? { open: 120, close: 140 } : { open: 90, close: 120 }), - handleClose: safePolygon(), - }); - const focus = useFocus(context, { enabled: isHover }); - // Escape for EVERY mode (hover included) is dispatched through the shared `layerStack` below, so it - // is strictly topmost-only across popovers, Modals and players — so turn floating-ui's own Escape - // off entirely. (Leaving it on for hover made an open tooltip's document-level handler either - // stopPropagation the Escape away from a Modal/player underneath, or double-close both.) Outside- - // press stays with floating-ui (tree-aware). - const dismiss = useDismiss(context, { - escapeKey: false, - ancestorScroll: opts.dismissOnScroll ?? false, - }); - // `role:"combobox"` makes Floating UI wire the whole typeable-combobox ARIA tree: the reference input - // → role=combobox + aria-expanded/controls/autocomplete + aria-activedescendant (from the virtual nav), - // the surface → role=listbox, the items → role=option + their option ids. (`combobox` maps to the - // `listbox` aria-role internally.) So sets NO aria-* by hand. - const role = useRole(context, { - role: opts.role ?? (isHover ? "tooltip" : isCombobox ? "combobox" : "menu"), - }); - const listNav = useListNavigation(context, { - enabled: roves, - // Virtual navigation for a combobox: DOM focus stays on the text input while ↑/↓ move an - // `aria-activedescendant` pointer over the options (the handler rides on getReferenceProps → the - // input). A menu/listbox uses real roving focus instead. - virtual: isCombobox, - listRef: elementsRef, - activeIndex, - // Keyboard (and pointer, when floating-ui owns it) navigation takes the visual highlight back from - // a stale pointer-hover position. - onNavigate: (i) => { - setActiveIndex(i); - setHoverIndex(null); - }, - selectedIndex: opts.selectedIndex ?? null, - // Disabled items are skipped automatically: with no `disabledIndices`, useListNavigation reads - // each item's DOM `disabled`/`aria-disabled` (PopoverItem always renders native `disabled`), so a - // pending/inert item is never a roving target. (Verified against @floating-ui/react 0.27 - // `isListIndexDisabled`.) No explicit index list needed. - loop: true, - // Focus the first item on open for BOTH click and keyboard (the default 'auto' skips pointer-opens, - // which strands focus on the menu container — arrows then can't enter the roving list at all). A - // combobox opens with NO active item — the caret is in the input, and the first ↓ lands on the first - // option; auto-highlighting item 0 on open would fight the typed query. - focusItemOnOpen: isCombobox ? false : true, - // A menu that hosts a free-text input, and every combobox, must NOT pull focus onto a list item when - // the pointer merely passes over it (the library default) — that blurs the input mid-typing and - // mis-routes the next Enter to the hovered item. The visual hover highlight is driven off hoverIndex - // instead (see PopoverItem), so the pointer still highlights without stealing the caret. - focusItemOnHover: isCombobox ? false : !opts.hostsInput, - }); - const typeahead = useTypeahead(context, { - enabled: isNav && (opts.typeahead ?? true) && !opts.hostsInput, - listRef: labelsRef, - activeIndex, - onMatch: (i) => { - setActiveIndex(i); - setHoverIndex(null); - }, - }); - - // PageUp/PageDown: a page of rows at a time (lib/listPaging, shared with the palette). floating-ui's - // list navigation knows only the arrows and Home/End, and the arrows alone crawl through the sidebar's - // channel filter or a Plex studio list. On BOTH elements: a combobox's keys arrive on its input, a - // menu's on the focused row inside the surface. Last in the list, so floating-ui's own keydown has - // already switched to keyboard modality — which is what makes it scroll the new row into view. A - // nested menu's surface bubbles through its parent's in the React tree; the inner one handles the - // key first and marks it, so the outer one stands down. - const onPageKey = (e: React.KeyboardEvent) => { - if ((e.key !== "PageDown" && e.key !== "PageUp") || e.defaultPrevented) return; - const rows = elementsRef.current; - const live = rows.filter((el): el is HTMLElement => el != null); - // No mounted rows — a closed surface, a hover card, a dialog — and the key keeps its own meaning. - if (!live.length) return; - e.preventDefault(); - const step = pageStep(scrollParent(live[0]!), live); - const to = pageTarget( - activeIndex ?? 0, - e.key === "PageDown" ? step : -step, - rows.length, - (i) => !!rows[i] && !rows[i]!.hasAttribute("disabled"), - ); - if (to === null) return; - setActiveIndex(to); - setHoverIndex(null); - }; - const pageKeyRef = useRef(onPageKey); - pageKeyRef.current = onPageKey; - const paging = useMemo(() => { - const onKeyDown = (e: React.KeyboardEvent) => pageKeyRef.current(e); - return { reference: { onKeyDown }, floating: { onKeyDown } }; - }, []); - - const interactions = useInteractions([ - click, - hover, - focus, - dismiss, - role, - listNav, - typeahead, - paging, - ]); - - // `hide()` flips this true once the trigger is scrolled out of its clipping scroller; the surface is - // then painted invisible (PopoverSurface) but stays open so it can reappear on scroll-back. - const referenceHidden = middlewareData.hide?.referenceHidden ?? false; - - // Register the open popover as a dismissable layer so Escape is topmost-only and a player/modal - // underneath defers to it. Hover popovers register too (an open tooltip/hover-card should take - // Escape before the surface beneath it — and, when it is a player's own overlay card, keep the - // player alive). An invisible (referenceHidden) surface is NOT a layer: it must not silently eat - // an Escape the user aimed at whatever is actually on screen. A hover surface is a PASSIVE layer: - // it does not own the keyboard, so the page's own keys keep working under a resting pointer. - useLayer(opts.open && !referenceHidden, () => opts.onOpenChange(false), { passive: isHover }); - - return { - nodeId, - mode, - isNav, - isCombobox, - roves, - open: opts.open, - selectedIndex: opts.selectedIndex ?? null, - refs, - floatingStyles, - context, - isPositioned, - referenceHidden, - elementsRef, - labelsRef, - activeIndex, - highlightIndex: hoverIndex ?? activeIndex, - setHoverIndex, - ...interactions, - }; -} - // Carries the active index + item-props getter down to . FloatingList (in PopoverSurface) // separately owns the elementsRef/labelsRef registration; this context is the active-state channel. interface PopoverListContextValue { @@ -394,18 +103,6 @@ const HIGHLIGHT_SELECTED = const HIGHLIGHT_SELECTED_RING_ONLY = "data-active:ring-2 data-active:ring-inset data-active:ring-accent/70"; -/** A menu row whose action opens a dialog or moves the focus: close the menu FIRST and act once it - * has handed the focus back. floating-ui returns the focus to the trigger from a microtask queued as - * the menu unmounts, so an action run in the same batch had its focus taken back by it: measured in - * Chrome (E11 S2), a confirm dialog opened from the rail's menu landed its focus on Cancel and lost it - * 7 ms later to the row BEHIND its scrim, where Enter activated the row. A fresh task runs after that - * microtask — and a dialog opened there captures the trigger, not a dying menu item, as the element - * to restore the focus to. Every such row goes through here (`ActionMenu`, "Manage views…"). */ -export function closeThenAct(close: () => void, act: () => void): void { - close(); - setTimeout(act, 0); -} - export function PopoverItem({ children, onSelect, diff --git a/frontend/src/components/ui/Combobox.tsx b/frontend/src/components/ui/Combobox.tsx index 600bf6f2..119540db 100644 --- a/frontend/src/components/ui/Combobox.tsx +++ b/frontend/src/components/ui/Combobox.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Check, ChevronDown, X } from "lucide-react"; -import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover"; +import { PopoverSurface, PopoverItem } from "./AnchoredPopover"; +import { useAnchoredPopover } from "./anchoredPopoverHooks"; import { useScrollFade } from "../../lib/useScrollFade"; // The universal combobox: a react-select / Overseerr-style "combi input" — selected values are diff --git a/frontend/src/components/ui/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx index 04d0fa4a..98273168 100644 --- a/frontend/src/components/ui/DataTable.tsx +++ b/frontend/src/components/ui/DataTable.tsx @@ -1,9 +1,11 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { sortRows, cycleSort, type SortState } from "../../lib/columnSort"; -import Pager, { hasPagerContent } from "./Pager"; +import Pager from "./Pager"; +import { hasPagerContent } from "./pagerContent"; import { PageToolbar } from "./PageShell"; -import { ColumnHead, alignClass } from "./tableHeader"; +import { ColumnHead } from "./tableHeader"; +import { alignClass } from "./tableAlign"; import { filterRows, type Column, type FilterMap } from "../../lib/tableColumns"; import { navItemAttrs, useListNav } from "../../lib/listNav"; diff --git a/frontend/src/components/ui/FilterSelect.tsx b/frontend/src/components/ui/FilterSelect.tsx index bb12deac..53944cd4 100644 --- a/frontend/src/components/ui/FilterSelect.tsx +++ b/frontend/src/components/ui/FilterSelect.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { ChevronDown, Check } from "lucide-react"; -import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; +import { PopoverItem, PopoverSurface } from "./AnchoredPopover"; +import { useAnchoredPopover } from "./anchoredPopoverHooks"; // A compact "labeled pill → dropdown" filter control: the trigger reads `Label: value ▾`, and clicking // it opens a menu of options (facet counts optional). It replaces a wall of individual toolbar chips diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx index 2695089b..9fe28297 100644 --- a/frontend/src/components/ui/Modal.tsx +++ b/frontend/src/components/ui/Modal.tsx @@ -7,7 +7,8 @@ import { anyBlockingLayerAbove, pushLayer, removeLayer, isTopmost } from "../../ import { useFullscreenPortalHost } from "../../lib/fullscreen"; import { trapTab, useDialogChrome } from "./dialogFocus"; import { Button } from "./Button"; -import Toaster, { useOverlayOrder } from "./Toaster"; +import Toaster from "./Toaster"; +import { useOverlayOrder } from "./overlayOrder"; // Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the // shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor diff --git a/frontend/src/components/ui/PageShell.tsx b/frontend/src/components/ui/PageShell.tsx index 419bca41..7ed48449 100644 --- a/frontend/src/components/ui/PageShell.tsx +++ b/frontend/src/components/ui/PageShell.tsx @@ -1,6 +1,5 @@ import { cloneElement, - createContext, isValidElement, useContext, useLayoutEffect, @@ -11,12 +10,7 @@ import { } from "react"; import { createPortal } from "react-dom"; import PageScroller from "./PageScroller"; - -// The DOM node a module's toolbar portals into (see PageToolbar below). PageShell provides its -// fixed band as the default target; a module can override it with its own -// to redirect its toolbar somewhere else — the channel page points it at a sticky sub-header so the -// toolbar rides with the tabs instead of the shell's top band. -export const PageToolbarSlot = createContext(null); +import { PageToolbarSlot } from "./pageToolbarSlot"; // A module renders its per-module fixed band (filter chips / sort / counts) through this. The // content lands in the shell's toolbar band — ABOVE the scroller, so it stays put while the content diff --git a/frontend/src/components/ui/Pager.tsx b/frontend/src/components/ui/Pager.tsx index fcc79b85..702e45e4 100644 --- a/frontend/src/components/ui/Pager.tsx +++ b/frontend/src/components/ui/Pager.tsx @@ -1,23 +1,13 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { ChevronLeft, ChevronRight } from "lucide-react"; +import { hasPagerContent, pageCount } from "./pagerContent"; // The Prev / page-input / Next + "N per page" cluster, lifted out of DataTable so a non-table view // (the channel manager's card layout) can paginate the same way. Controlled: the caller owns page + // size and slices its own rows; this only renders the controls and reports changes. `sizeLabel` lets // the caller say "Rows per page" or "Cards per page". Returns null when there's nothing to page. -/** How many pages `total` rows make at `size` (`size <= 0` = "All" = one page). */ -function pageCount(total: number, size: number): number { - return size <= 0 ? 1 : Math.max(1, Math.ceil(total / size)); -} - -/** Whether a pager would render anything — the ONE owner of that rule. Callers that wrap the pager - * in their own chrome gate on this instead of re-deriving it: a drifted second copy is exactly what - * left empty control bands behind on tables with nothing to page. */ -export function hasPagerContent(total: number, size: number): boolean { - return pageCount(total, size) > 1 || total > 0; -} export default function Pager({ page, size, diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md index 3427f93d..fa92e042 100644 --- a/frontend/src/components/ui/README.md +++ b/frontend/src/components/ui/README.md @@ -33,13 +33,13 @@ domain-free. ## What lives here -| area | components | -| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| overlays & anchoring | `AnchoredPopover` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10), `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster`, `CommandPalette`, `KeyboardHelp` | -| actions & inputs | `Button`, `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` | -| data display | `DataTable`, `tableHeader`, `Pager`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` | -| page & panel structure | `PageShell`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` | -| state & messaging | `QueryState`, `Banner`, `InlineNotice` | +| area | components | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10), `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`), `CommandPalette`, `KeyboardHelp` | +| actions & inputs | `Button`, `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` | +| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` | +| page & panel structure | `PageShell` + `pageToolbarSlot`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` | +| state & messaging | `QueryState`, `Banner`, `InlineNotice` | ## The size contract (E10 S1c) diff --git a/frontend/src/components/ui/SortControl.tsx b/frontend/src/components/ui/SortControl.tsx index 79be2ff4..54ecc952 100644 --- a/frontend/src/components/ui/SortControl.tsx +++ b/frontend/src/components/ui/SortControl.tsx @@ -1,7 +1,8 @@ import { useState, type ReactNode } from "react"; import { ArrowDown, ArrowUp, Check, ChevronDown } from "lucide-react"; import { pickSort, type SortOption, type SortState } from "../../lib/columnSort"; -import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; +import { PopoverItem, PopoverSurface } from "./AnchoredPopover"; +import { useAnchoredPopover } from "./anchoredPopoverHooks"; // The one sort control for every content surface (YT feed, Plex library, channel manager + its card // view, channel discovery, playlists): a labeled dropdown pill matching FilterSelect — `Label: key ↓ ▾` diff --git a/frontend/src/components/ui/Tabs.tsx b/frontend/src/components/ui/Tabs.tsx index f006b2dd..958eb167 100644 --- a/frontend/src/components/ui/Tabs.tsx +++ b/frontend/src/components/ui/Tabs.tsx @@ -1,5 +1,4 @@ import { type LucideIcon } from "lucide-react"; -import { useAccountPersistedState } from "../../lib/storage"; import CountBadge from "./CountBadge"; // Reusable pill tab-rail for in-page sub-navigation (Configuration, Users & roles, the Settings @@ -7,8 +6,8 @@ import CountBadge from "./CountBadge"; // the Settings-style side rail (full-width, left-aligned, icon + label) — from the `sm` breakpoint up. // Below it a side rail leaves a phone too little width for the content beside it (measured at 390 px: // the rail clipped its own labels and Settings → Account pushed its buttons over their text), so there -// it is a horizontal row that scrolls. The active tab is persisted to localStorage so a reload keeps -// the user where they were. +// it is a horizontal row that scrolls. Callers persist the active tab with lib/storage's +// useAccountPersistedState (account-scoped), so a reload keeps the user where they were. export interface TabDef { id: string; @@ -18,10 +17,6 @@ export interface TabDef { testId?: string; // optional data-testid, to preserve existing e2e hooks } -/** Persisted active-tab state, scoped to the current account (so one account's last tab doesn't - * carry into another in the same browser) — re-exported under its original name for call sites. */ -export const usePersistedTab = useAccountPersistedState; - export default function Tabs({ tabs, active, diff --git a/frontend/src/components/ui/Toaster.strictmode.dom.test.tsx b/frontend/src/components/ui/Toaster.strictmode.dom.test.tsx index 877d3b93..4f07ac15 100644 --- a/frontend/src/components/ui/Toaster.strictmode.dom.test.tsx +++ b/frontend/src/components/ui/Toaster.strictmode.dom.test.tsx @@ -21,7 +21,8 @@ vi.mock("react-i18next", async (importOriginal) => { return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; }); -import Toaster, { useOverlayOrder } from "./Toaster"; +import Toaster from "./Toaster"; +import { useOverlayOrder } from "./overlayOrder"; import { clearAll, notify } from "../../lib/notifications"; afterEach(() => { diff --git a/frontend/src/components/ui/Toaster.tsx b/frontend/src/components/ui/Toaster.tsx index 55836cea..20a37717 100644 --- a/frontend/src/components/ui/Toaster.tsx +++ b/frontend/src/components/ui/Toaster.tsx @@ -1,16 +1,10 @@ import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; -import { AlertCircle, AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react"; -import { dismiss, getActiveToasts, subscribe, type NotifLevel } from "../../lib/notifications"; +import { X } from "lucide-react"; +import { dismiss, getActiveToasts, subscribe } from "../../lib/notifications"; import { createStore } from "../../lib/store"; - -export const LEVEL_STYLE: Record = { - info: { icon: Info, color: "text-info", bar: "bg-info" }, - success: { icon: CheckCircle2, color: "text-success", bar: "bg-success" }, - warning: { icon: AlertTriangle, color: "text-warn", bar: "bg-warn" }, - error: { icon: AlertCircle, color: "text-danger", bar: "bg-danger" }, - fatal: { icon: XCircle, color: "text-danger", bar: "bg-danger" }, -}; +import { useOverlayOrder } from "./overlayOrder"; +import { LEVEL_STYLE } from "./toastLevels"; // Overlay hosts. The app's own Toaster sits in the content column, and that column is a stacking // context (its `@container` implies layout containment) — so anything portalled to at the @@ -25,14 +19,7 @@ export const LEVEL_STYLE: Record ++overlaySeq); - return order; -} +// rank once, when it opens (`useOverlayOrder`, in overlayOrder.ts), and hands it to every host it renders. type Host = { id: string; order: number }; const overlayHosts = createStore([]); const topHost = () => { diff --git a/frontend/src/components/ui/Tooltip.tsx b/frontend/src/components/ui/Tooltip.tsx index db03bf19..9815789c 100644 --- a/frontend/src/components/ui/Tooltip.tsx +++ b/frontend/src/components/ui/Tooltip.tsx @@ -1,5 +1,6 @@ import { useState, useSyncExternalStore } from "react"; -import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; +import { PopoverSurface } from "./AnchoredPopover"; +import { useAnchoredPopover } from "./anchoredPopoverHooks"; import { hintsEnabled, subscribeHints } from "../../lib/hints"; type Side = "top" | "bottom" | "left" | "right"; diff --git a/frontend/src/components/ui/anchoredPopoverHooks.ts b/frontend/src/components/ui/anchoredPopoverHooks.ts new file mode 100644 index 00000000..f8dc7692 --- /dev/null +++ b/frontend/src/components/ui/anchoredPopoverHooks.ts @@ -0,0 +1,310 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { + useFloating, + autoUpdate, + offset as offsetMiddleware, + flip, + shift, + size, + hide, + useClick, + useHover, + useFocus, + useDismiss, + useRole, + useListNavigation, + useTypeahead, + useInteractions, + useFloatingNodeId, + safePolygon, + type Placement, + type UseRoleProps, +} from "@floating-ui/react"; +import { useLayer } from "../../lib/layerStack"; +import { pageStep, pageTarget, scrollParent } from "../../lib/listPaging"; + +// The non-component half of the R8 popover platform: the `useAnchoredPopover` engine hook and the +// `closeThenAct` row helper. The surfaces it drives (PopoverSurface / PopoverItem) and the platform's +// design notes live in AnchoredPopover.tsx; the split keeps that file components-only (Fast Refresh). + +// Internal to the platform; call sites pass the string literal via `mode`, they don't name the type. +type PopoverMode = "menu" | "listbox" | "hover" | "dialog" | "combobox"; + +export interface AnchoredPopoverOptions { + open: boolean; + onOpenChange: (open: boolean, event?: Event) => void; + /** "menu"/"listbox"/"dialog" open on click; "hover" opens on hover+focus (tooltips, hover cards); + * "combobox" is a typeable input + listbox where focus stays in the field (use via ). */ + mode?: PopoverMode; + placement?: Placement; + /** Gap between trigger and panel, px. Default 4. */ + offset?: number; + /** Hover mode only: open/close intent delays, ms. */ + hoverDelay?: number | { open?: number; close?: number }; + /** ARIA role for the panel; defaults to "tooltip" in hover mode, else "menu". */ + role?: UseRoleProps["role"]; + /** Cap the panel height to the available space (adds the `size` middleware). Off by default so a + * caller's own max-height class wins; turn on for long lists that should shrink to fit. */ + fitHeight?: boolean; + /** Type-to-focus a matching item by label (menu/listbox only). Default on; turn OFF for menus that + * contain a free-text input, where character keys belong to the text field. */ + typeahead?: boolean; + /** This menu hosts a free-text input (an inline "create" field). Turns off BOTH typeahead AND + * focus-item-on-hover, so neither typed characters nor a pointer passing over a list item steals + * focus/keystrokes from the field. Set it instead of `typeahead:false` for such menus. */ + hostsInput?: boolean; + /** Menu mode only: ALSO open on mouse hover, keeping the click/tap toggle. The submenu idiom — + * a mouse expects a submenu to fall open under the pointer, a finger has no hover at all and + * must tap. `mouseOnly` is forced on so a tap's synthetic mouseenter can't open the panel a + * hair before the click handler toggles it shut again. */ + hoverAlso?: boolean; + /** Index of the item that is currently CHOSEN, for a single-choice menu/listbox. Opening then + * lands the roving focus on it instead of the first item — the behaviour a "pick one of these + * modes" menu needs. Leave unset for action menus and multi-select toggle lists. */ + selectedIndex?: number | null; + /** Close when a scroll container under the panel scrolls. Off by default, because a panel anchored + * to a real trigger element simply MOVES with it. Turn it on when the anchor is a fixed point + * rather than an element — a right-click menu, which otherwise stays parked over whatever row + * scrolls under it while its actions still target the row you opened it on (E10 review round 1). + * This is also what a native context menu does. */ + dismissOnScroll?: boolean; +} + +export function useAnchoredPopover(opts: AnchoredPopoverOptions) { + const mode = opts.mode ?? "menu"; + const isHover = mode === "hover"; + const isCombobox = mode === "combobox"; + // Roving-keyboard list surfaces: a menu of actions or a listbox of options. A "dialog" popover holds + // arbitrary focusable content (its own Tab order), and "hover" is a passive tooltip — neither roves. + const isNav = mode === "menu" || mode === "listbox"; + // Surfaces that host a navigable item list (registry + list-navigation): the roving menu/listbox AND + // the virtual-nav combobox. The combobox navigates WITHOUT moving DOM focus (see below), so it is kept + // out of `isNav` (which also gates the focus trap + typeahead + roving tabindex) and folded in here. + const roves = isNav || isCombobox; + // null when not inside a — the hook still works, just without nested-layer awareness. + const nodeId = useFloatingNodeId(); + + // Item registry for list navigation + typeahead, populated by via useListItem. + const elementsRef = useRef>([]); + const labelsRef = useRef>([]); + const [activeIndex, setActiveIndex] = useState(null); + // Pointer-hover highlight, kept SEPARATE from floating-ui's activeIndex on purpose: useListNavigation + // syncs DOM focus to activeIndex, so setting activeIndex on hover would blur a menu's free-text input + // (the create-tag field) mid-typing. hoverIndex drives only the VISUAL highlight; DOM focus never + // follows it. The highlighted row = hoverIndex ?? activeIndex, so there is exactly one highlight that + // tracks the pointer when hovering and the keyboard otherwise. + const [hoverIndex, setHoverIndex] = useState(null); + + const { refs, floatingStyles, context, middlewareData, isPositioned } = useFloating({ + nodeId, + open: opts.open, + onOpenChange: opts.onOpenChange, + placement: opts.placement ?? "bottom-start", + whileElementsMounted: autoUpdate, + middleware: [ + offsetMiddleware(opts.offset ?? 4), + flip({ padding: 8 }), + shift({ padding: 8 }), + ...(opts.fitHeight + ? [ + size({ + padding: 8, + apply({ availableHeight, elements }) { + // Cap the positioned element, AND publish the available space as a custom property so + // the INNER styled/scroll wrapper (PopoverSurface renders `className` on a child, not on + // the floating element) can cap itself too — e.g. `maxHeight: min(28rem, var(--fit-max-h))` + // keeps a caller's design cap while still shrinking to fit at high zoom / short viewports. + // Capping only the outer element would leave the inner overflow-y-auto box at its fixed + // height, spilling its top off-screen (the whole point of fitHeight). + elements.floating.style.maxHeight = `${availableHeight}px`; + elements.floating.style.setProperty("--fit-max-h", `${availableHeight}px`); + }, + }), + ] + : []), + // A combobox panel matches the field's width (with a sane floor for a very narrow rail); its inner + // scroll box owns the height cap. `rects.reference` is the POSITION reference — the whole chips+input + // field box (the caller mounts it via setPositionReference), not the bare input. + ...(isCombobox + ? [ + size({ + padding: 8, + apply({ rects, elements }) { + elements.floating.style.width = `${Math.max(rects.reference.width, 192)}px`; + }, + }), + ] + : []), + hide({ padding: 8 }), + ], + }); + + // A stale active item would highlight (and steal the next open's initial focus) the second time a + // menu opens; clear it whenever the surface closes. + useEffect(() => { + if (!opts.open) { + setActiveIndex(null); + setHoverIndex(null); + } + }, [opts.open]); + + // A combobox opens from the field's own focus/typing (wired by ), never a click-to-toggle — + // a click on the input must place the caret, not close the just-opened list. + const click = useClick(context, { enabled: !isHover && !isCombobox }); + const hover = useHover(context, { + enabled: isHover || !!opts.hoverAlso, + move: false, + // A hover-card is a pointer affordance either way; a hover-ALSO menu must ignore touch, or the + // synthetic mouseenter a tap emits would race the click toggle (open, then immediately shut). + mouseOnly: !isHover, + // A submenu wants a shorter intent delay than a hover card: the pointer is already inside a + // menu, so the "did they mean it" question is mostly answered. + delay: opts.hoverDelay ?? (isHover ? { open: 120, close: 140 } : { open: 90, close: 120 }), + handleClose: safePolygon(), + }); + const focus = useFocus(context, { enabled: isHover }); + // Escape for EVERY mode (hover included) is dispatched through the shared `layerStack` below, so it + // is strictly topmost-only across popovers, Modals and players — so turn floating-ui's own Escape + // off entirely. (Leaving it on for hover made an open tooltip's document-level handler either + // stopPropagation the Escape away from a Modal/player underneath, or double-close both.) Outside- + // press stays with floating-ui (tree-aware). + const dismiss = useDismiss(context, { + escapeKey: false, + ancestorScroll: opts.dismissOnScroll ?? false, + }); + // `role:"combobox"` makes Floating UI wire the whole typeable-combobox ARIA tree: the reference input + // → role=combobox + aria-expanded/controls/autocomplete + aria-activedescendant (from the virtual nav), + // the surface → role=listbox, the items → role=option + their option ids. (`combobox` maps to the + // `listbox` aria-role internally.) So sets NO aria-* by hand. + const role = useRole(context, { + role: opts.role ?? (isHover ? "tooltip" : isCombobox ? "combobox" : "menu"), + }); + const listNav = useListNavigation(context, { + enabled: roves, + // Virtual navigation for a combobox: DOM focus stays on the text input while ↑/↓ move an + // `aria-activedescendant` pointer over the options (the handler rides on getReferenceProps → the + // input). A menu/listbox uses real roving focus instead. + virtual: isCombobox, + listRef: elementsRef, + activeIndex, + // Keyboard (and pointer, when floating-ui owns it) navigation takes the visual highlight back from + // a stale pointer-hover position. + onNavigate: (i) => { + setActiveIndex(i); + setHoverIndex(null); + }, + selectedIndex: opts.selectedIndex ?? null, + // Disabled items are skipped automatically: with no `disabledIndices`, useListNavigation reads + // each item's DOM `disabled`/`aria-disabled` (PopoverItem always renders native `disabled`), so a + // pending/inert item is never a roving target. (Verified against @floating-ui/react 0.27 + // `isListIndexDisabled`.) No explicit index list needed. + loop: true, + // Focus the first item on open for BOTH click and keyboard (the default 'auto' skips pointer-opens, + // which strands focus on the menu container — arrows then can't enter the roving list at all). A + // combobox opens with NO active item — the caret is in the input, and the first ↓ lands on the first + // option; auto-highlighting item 0 on open would fight the typed query. + focusItemOnOpen: isCombobox ? false : true, + // A menu that hosts a free-text input, and every combobox, must NOT pull focus onto a list item when + // the pointer merely passes over it (the library default) — that blurs the input mid-typing and + // mis-routes the next Enter to the hovered item. The visual hover highlight is driven off hoverIndex + // instead (see PopoverItem), so the pointer still highlights without stealing the caret. + focusItemOnHover: isCombobox ? false : !opts.hostsInput, + }); + const typeahead = useTypeahead(context, { + enabled: isNav && (opts.typeahead ?? true) && !opts.hostsInput, + listRef: labelsRef, + activeIndex, + onMatch: (i) => { + setActiveIndex(i); + setHoverIndex(null); + }, + }); + + // PageUp/PageDown: a page of rows at a time (lib/listPaging, shared with the palette). floating-ui's + // list navigation knows only the arrows and Home/End, and the arrows alone crawl through the sidebar's + // channel filter or a Plex studio list. On BOTH elements: a combobox's keys arrive on its input, a + // menu's on the focused row inside the surface. Last in the list, so floating-ui's own keydown has + // already switched to keyboard modality — which is what makes it scroll the new row into view. A + // nested menu's surface bubbles through its parent's in the React tree; the inner one handles the + // key first and marks it, so the outer one stands down. + const onPageKey = (e: React.KeyboardEvent) => { + if ((e.key !== "PageDown" && e.key !== "PageUp") || e.defaultPrevented) return; + const rows = elementsRef.current; + const live = rows.filter((el): el is HTMLElement => el != null); + // No mounted rows — a closed surface, a hover card, a dialog — and the key keeps its own meaning. + if (!live.length) return; + e.preventDefault(); + const step = pageStep(scrollParent(live[0]!), live); + const to = pageTarget( + activeIndex ?? 0, + e.key === "PageDown" ? step : -step, + rows.length, + (i) => !!rows[i] && !rows[i]!.hasAttribute("disabled"), + ); + if (to === null) return; + setActiveIndex(to); + setHoverIndex(null); + }; + const pageKeyRef = useRef(onPageKey); + pageKeyRef.current = onPageKey; + const paging = useMemo(() => { + const onKeyDown = (e: React.KeyboardEvent) => pageKeyRef.current(e); + return { reference: { onKeyDown }, floating: { onKeyDown } }; + }, []); + + const interactions = useInteractions([ + click, + hover, + focus, + dismiss, + role, + listNav, + typeahead, + paging, + ]); + + // `hide()` flips this true once the trigger is scrolled out of its clipping scroller; the surface is + // then painted invisible (PopoverSurface) but stays open so it can reappear on scroll-back. + const referenceHidden = middlewareData.hide?.referenceHidden ?? false; + + // Register the open popover as a dismissable layer so Escape is topmost-only and a player/modal + // underneath defers to it. Hover popovers register too (an open tooltip/hover-card should take + // Escape before the surface beneath it — and, when it is a player's own overlay card, keep the + // player alive). An invisible (referenceHidden) surface is NOT a layer: it must not silently eat + // an Escape the user aimed at whatever is actually on screen. A hover surface is a PASSIVE layer: + // it does not own the keyboard, so the page's own keys keep working under a resting pointer. + useLayer(opts.open && !referenceHidden, () => opts.onOpenChange(false), { passive: isHover }); + + return { + nodeId, + mode, + isNav, + isCombobox, + roves, + open: opts.open, + selectedIndex: opts.selectedIndex ?? null, + refs, + floatingStyles, + context, + isPositioned, + referenceHidden, + elementsRef, + labelsRef, + activeIndex, + highlightIndex: hoverIndex ?? activeIndex, + setHoverIndex, + ...interactions, + }; +} + +/** A menu row whose action opens a dialog or moves the focus: close the menu FIRST and act once it + * has handed the focus back. floating-ui returns the focus to the trigger from a microtask queued as + * the menu unmounts, so an action run in the same batch had its focus taken back by it: measured in + * Chrome (E11 S2), a confirm dialog opened from the rail's menu landed its focus on Cancel and lost it + * 7 ms later to the row BEHIND its scrim, where Enter activated the row. A fresh task runs after that + * microtask — and a dialog opened there captures the trigger, not a dying menu item, as the element + * to restore the focus to. Every such row goes through here (`ActionMenu`, "Manage views…"). */ +export function closeThenAct(close: () => void, act: () => void): void { + close(); + setTimeout(act, 0); +} diff --git a/frontend/src/components/ui/overlayOrder.ts b/frontend/src/components/ui/overlayOrder.ts new file mode 100644 index 00000000..d04a8df5 --- /dev/null +++ b/frontend/src/components/ui/overlayOrder.ts @@ -0,0 +1,11 @@ +import { useState } from "react"; + +// An overlay's toast-host rank (see the overlay-hosts note in Toaster). Its own module so Toaster exports +// only components (Fast Refresh); the counter is module state, so there is exactly one sequence. +let overlaySeq = 0; +/** This overlay's rank among open overlays — taken once, when it mounts. Call it in the overlay + * itself, never in a component that can remount while the overlay stays open. */ +export function useOverlayOrder(): number { + const [order] = useState(() => ++overlaySeq); + return order; +} diff --git a/frontend/src/components/ui/pageToolbarSlot.ts b/frontend/src/components/ui/pageToolbarSlot.ts new file mode 100644 index 00000000..e035c97d --- /dev/null +++ b/frontend/src/components/ui/pageToolbarSlot.ts @@ -0,0 +1,8 @@ +import { createContext } from "react"; + +// The DOM node a module's toolbar portals into (see PageToolbar below). PageShell provides its +// fixed band as the default target; a module can override it with its own +// to redirect its toolbar somewhere else — the channel page points it at a sticky sub-header so the +// toolbar rides with the tabs instead of the shell's top band. Its own module so PageShell exports only +// components (Fast Refresh). +export const PageToolbarSlot = createContext(null); diff --git a/frontend/src/components/ui/pagerContent.ts b/frontend/src/components/ui/pagerContent.ts new file mode 100644 index 00000000..91b84d18 --- /dev/null +++ b/frontend/src/components/ui/pagerContent.ts @@ -0,0 +1,14 @@ +// The pager's page arithmetic, split out of Pager so that file exports only components (Fast Refresh) +// and table chrome can gate on it without importing the control. + +/** How many pages `total` rows make at `size` (`size <= 0` = "All" = one page). */ +export function pageCount(total: number, size: number): number { + return size <= 0 ? 1 : Math.max(1, Math.ceil(total / size)); +} + +/** Whether a pager would render anything — the ONE owner of that rule. Callers that wrap the pager + * in their own chrome gate on this instead of re-deriving it: a drifted second copy is exactly what + * left empty control bands behind on tables with nothing to page. */ +export function hasPagerContent(total: number, size: number): boolean { + return pageCount(total, size) > 1 || total > 0; +} diff --git a/frontend/src/components/ui/tableAlign.ts b/frontend/src/components/ui/tableAlign.ts new file mode 100644 index 00000000..60d14a39 --- /dev/null +++ b/frontend/src/components/ui/tableAlign.ts @@ -0,0 +1,4 @@ +// A column's `align` as its text-alignment class, shared by the header row (tableHeader) and the table +// bodies that render under it. Its own module so tableHeader exports only components (Fast Refresh). +export const alignClass = (a?: "left" | "right" | "center") => + a === "right" ? "text-right" : a === "center" ? "text-center" : "text-left"; diff --git a/frontend/src/components/ui/tableHeader.tsx b/frontend/src/components/ui/tableHeader.tsx index 87aa8f9d..b08eeffc 100644 --- a/frontend/src/components/ui/tableHeader.tsx +++ b/frontend/src/components/ui/tableHeader.tsx @@ -1,10 +1,12 @@ import { useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { ArrowDown, ArrowUp, ListFilter, X } from "lucide-react"; -import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; +import { PopoverItem, PopoverSurface } from "./AnchoredPopover"; +import { useAnchoredPopover } from "./anchoredPopoverHooks"; import { useScrollFade } from "../../lib/useScrollFade"; import { type SortState } from "../../lib/columnSort"; import { isActive, type Column, type FilterMap } from "../../lib/tableColumns"; +import { alignClass } from "./tableAlign"; // The column-header machinery — the sortable header row with its filter funnel + popover — lifted // OUT of DataTable so a non-DataTable body can render under the same headers. The column contract @@ -17,9 +19,6 @@ import { isActive, type Column, type FilterMap } from "../../lib/tableColumns"; // playlist keep its stored order and still get real column headers. Same move as E4 S3's // ChannelLayoutGrid, which builds a card view straight from the `Column` render closures. -export const alignClass = (a?: "left" | "right" | "center") => - a === "right" ? "text-right" : a === "center" ? "text-center" : "text-left"; - // The multi-select filter's option list, capped and scrollable. Its own component because it owns a // hook — see the FilterPopover note below for why nesting either of these would remount them. function FilterOptionList({ children }: { children: ReactNode }) { diff --git a/frontend/src/components/ui/toastLevels.ts b/frontend/src/components/ui/toastLevels.ts new file mode 100644 index 00000000..3ee2c0be --- /dev/null +++ b/frontend/src/components/ui/toastLevels.ts @@ -0,0 +1,12 @@ +import { AlertCircle, AlertTriangle, CheckCircle2, Info, XCircle } from "lucide-react"; +import type { NotifLevel } from "../../lib/notifications"; + +// Each notification level's icon and colours, shared by the toasts and the notifications inbox. Its own +// module so Toaster exports only components (Fast Refresh). +export const LEVEL_STYLE: Record = { + info: { icon: Info, color: "text-info", bar: "bg-info" }, + success: { icon: CheckCircle2, color: "text-success", bar: "bg-success" }, + warning: { icon: AlertTriangle, color: "text-warn", bar: "bg-warn" }, + error: { icon: AlertCircle, color: "text-danger", bar: "bg-danger" }, + fatal: { icon: XCircle, color: "text-danger", bar: "bg-danger" }, +}; diff --git a/frontend/src/components/ui/useContextMenu.ts b/frontend/src/components/ui/useContextMenu.ts index 1342176c..06ce2886 100644 --- a/frontend/src/components/ui/useContextMenu.ts +++ b/frontend/src/components/ui/useContextMenu.ts @@ -1,7 +1,7 @@ import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; import { matchesShortcut, useKeyScope } from "../../lib/keymap"; -import { useAnchoredPopover } from "./AnchoredPopover"; +import { useAnchoredPopover } from "./anchoredPopoverHooks"; // E10 S2 — the right-click context menu's INPUT half: the gestures that open it and the row they // belong to. The surface itself is `` (ContextMenu.tsx); they are two files only so diff --git a/frontend/src/components/usePlaylistActions.dom.test.tsx b/frontend/src/components/usePlaylistActions.dom.test.tsx index a132d70a..4b9569d6 100644 --- a/frontend/src/components/usePlaylistActions.dom.test.tsx +++ b/frontend/src/components/usePlaylistActions.dom.test.tsx @@ -19,8 +19,8 @@ vi.mock("../lib/notifications", async (importOriginal) => ({ notify: (...a: unknown[]) => h.notify(...a), })); vi.mock("../lib/errorDialog", () => ({ reportError: (...a: unknown[]) => h.reportError(...a) })); -vi.mock("./ConfirmProvider", () => ({ useChoice: () => async () => "both" })); -vi.mock("./PlaylistsProvider", () => ({ +vi.mock("./confirmContext", () => ({ useChoice: () => async () => "both" })); +vi.mock("./playlistsContext", () => ({ usePlaylists: () => ({ selectedId: null }), usePlaylistsActions: () => ({ setSelectedId: () => {} }), })); diff --git a/frontend/src/components/usePlaylistActions.ts b/frontend/src/components/usePlaylistActions.ts index 731a7589..83a66747 100644 --- a/frontend/src/components/usePlaylistActions.ts +++ b/frontend/src/components/usePlaylistActions.ts @@ -5,8 +5,8 @@ import { api, type Playlist } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { playlistName } from "../lib/playlistName"; -import { useChoice } from "./ConfirmProvider"; -import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider"; +import { useChoice } from "./confirmContext"; +import { usePlaylists, usePlaylistsActions } from "./playlistsContext"; import { useMe } from "../lib/useMe"; // The two playlist-level mutations that now have TWO entry points each: the detail header's diff --git a/frontend/src/components/usePlexActiveFilters.dom.test.tsx b/frontend/src/components/usePlexActiveFilters.dom.test.tsx index 8bfd1b09..85436e37 100644 --- a/frontend/src/components/usePlexActiveFilters.dom.test.tsx +++ b/frontend/src/components/usePlexActiveFilters.dom.test.tsx @@ -31,7 +31,7 @@ const plexState = { }; const setShow = vi.fn(); const setFilters = vi.fn(); -vi.mock("./PlexProvider", () => ({ +vi.mock("./plexContext", () => ({ usePlex: () => plexState, usePlexActions: () => ({ setShow, setFilters }), })); diff --git a/frontend/src/components/usePlexActiveFilters.ts b/frontend/src/components/usePlexActiveFilters.ts index 514b1eb5..a0a796dc 100644 --- a/frontend/src/components/usePlexActiveFilters.ts +++ b/frontend/src/components/usePlexActiveFilters.ts @@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next"; import { type ActiveFilter } from "../lib/useActiveFilters"; import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api"; import { PLEX_SORT_FALLBACK } from "../lib/plexSort"; -import { usePlex, usePlexActions } from "./PlexProvider"; +import { usePlex, usePlexActions } from "./plexContext"; import { usePlexCollectionTitles } from "../lib/usePlexCollectionTitles"; // Plex's analogue of useActiveFilters: turns the shared Plex state (expanded filters + watch-state) diff --git a/frontend/src/components/useTagSuggestions.dom.test.tsx b/frontend/src/components/useTagSuggestions.dom.test.tsx index 91bcfe7a..b87a46da 100644 --- a/frontend/src/components/useTagSuggestions.dom.test.tsx +++ b/frontend/src/components/useTagSuggestions.dom.test.tsx @@ -20,7 +20,7 @@ vi.mock("react-i18next", async (importOriginal) => { }); // The confirm dialog's answer is controllable per test (the bulk-accept path gates on it). const confirmState = vi.hoisted(() => ({ result: true })); -vi.mock("./ConfirmProvider", () => ({ +vi.mock("./confirmContext", () => ({ useConfirm: () => () => Promise.resolve(confirmState.result), })); diff --git a/frontend/src/components/useTagSuggestions.ts b/frontend/src/components/useTagSuggestions.ts index 762d1e46..c527a7c3 100644 --- a/frontend/src/components/useTagSuggestions.ts +++ b/frontend/src/components/useTagSuggestions.ts @@ -3,7 +3,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { api, type Tag, type TagSuggestions } from "../lib/api"; import { qk } from "../lib/queryKeys"; -import { useConfirm } from "./ConfirmProvider"; +import { useConfirm } from "./confirmContext"; import type { SuggestedTag } from "./ChannelTags"; // A tag suggestion at/above this score is "high confidence" — the only ones the header's bulk diff --git a/frontend/src/components/welcomeMode.dom.test.ts b/frontend/src/components/welcomeMode.dom.test.ts index fc2240c8..a7bf4a86 100644 --- a/frontend/src/components/welcomeMode.dom.test.ts +++ b/frontend/src/components/welcomeMode.dom.test.ts @@ -10,7 +10,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; import { useHistorySubview } from "../lib/history"; -import { MODE_URL, type Mode } from "./Welcome"; +import { MODE_URL, type Mode } from "./welcomeMode"; const render = (root: Mode = "signin") => renderHook(() => useHistorySubview(root, MODE_URL)); diff --git a/frontend/src/components/welcomeMode.ts b/frontend/src/components/welcomeMode.ts new file mode 100644 index 00000000..e8289e1a --- /dev/null +++ b/frontend/src/components/welcomeMode.ts @@ -0,0 +1,18 @@ +import type { SubviewUrl } from "../lib/history"; + +// The sign-in card's sub-view type and its URL mirror, split out of Welcome so that file exports only +// components (Fast Refresh). + +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 = { + key: "m", + encode: (m: Mode) => (m === "signin" || m === "reset" ? null : m), + decode: (raw: string) => + raw === "register" || raw === "forgot" || raw === "resend" ? (raw as Mode) : null, +}; diff --git a/frontend/src/components/wizardContext.ts b/frontend/src/components/wizardContext.ts new file mode 100644 index 00000000..ca083439 --- /dev/null +++ b/frontend/src/components/wizardContext.ts @@ -0,0 +1,20 @@ +import { createContext, useContext } from "react"; + +// The onboarding wizard's actions context + hook, split out of WizardProvider so that file exports +// only components (Fast Refresh). + +export 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. +export const WizardActionsContext = createContext({ + openWizard: () => {}, + closeWizard: () => {}, +}); + +export function useWizard(): WizardActions { + return useContext(WizardActionsContext); +} diff --git a/frontend/src/lib/adminUsersTab.ts b/frontend/src/lib/adminUsersTab.ts index 09ce140b..dff16f7a 100644 --- a/frontend/src/lib/adminUsersTab.ts +++ b/frontend/src/lib/adminUsersTab.ts @@ -2,7 +2,7 @@ import { LS, setAccountRaw } from "./storage"; // The admin Users page's persisted-tab key + the Access-requests tab id, plus a helper to // pre-select that tab before navigating there (a notification's "Review" link uses it — -// usePersistedTab reads the key on mount, and the page mounts fresh on navigation). +// useAccountPersistedState reads the key on mount, and the page mounts fresh on navigation). // // Kept out of AdminUsers.tsx so callers (App, NotificationsPanel) can pre-select the tab // WITHOUT statically importing the — now lazy-loaded — admin page, which would otherwise pull diff --git a/frontend/src/lib/api/tagModes.test.ts b/frontend/src/lib/api/tagModes.test.ts index 070be2e7..c5dfbf54 100644 --- a/frontend/src/lib/api/tagModes.test.ts +++ b/frontend/src/lib/api/tagModes.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { coerceTagModes, coerceUntagged, DEFAULT_TAG_MODES } from "./tagModes"; -// coerceTagModes runs on every persisted saved-view blob (SavedViewsProvider.asFilters), on the URL +// coerceTagModes runs on every persisted saved-view blob (savedViewsContext.asFilters), on the URL // parse, and on the localStorage seed — the single migration point for the legacy global `tagMode`. describe("coerceTagModes", () => { it("defaults to all-'or' for empty/undefined input", () => { diff --git a/frontend/src/lib/plexBackdrop.dom.test.tsx b/frontend/src/lib/plexBackdrop.dom.test.tsx index c7becf77..c12e0826 100644 --- a/frontend/src/lib/plexBackdrop.dom.test.tsx +++ b/frontend/src/lib/plexBackdrop.dom.test.tsx @@ -7,7 +7,7 @@ // per-test; the hook reads it at effect time. import { describe, it, expect, afterEach, vi } from "vitest"; import { render, cleanup } from "@testing-library/react"; -import { useCrossfadeBackdrop } from "./plexDetailUi"; +import { useCrossfadeBackdrop } from "./plexDetail"; function mockReducedMotion(reduce: boolean) { window.matchMedia = vi.fn().mockImplementation((query: string) => ({ diff --git a/frontend/src/lib/plexDetail.ts b/frontend/src/lib/plexDetail.ts new file mode 100644 index 00000000..20d5872f --- /dev/null +++ b/frontend/src/lib/plexDetail.ts @@ -0,0 +1,151 @@ +import { useLayoutEffect } from "react"; +import { usePref, useSavePref } from "./usePref"; +import type { PlexCard } from "./api"; + +// The non-component half of the shared Plex "detail page" UI (see plexDetailUi): the per-user display +// prefs, the TMDB backdrop URL, the crossfading art backdrop hook and the episode label. Split out so +// plexDetailUi exports only components (Fast Refresh). + +export function useDetailPrefs() { + // Three per-user detail-page toggles, each a server pref adopted on login (usePref). Default ON: + // undefined → true, only an explicit false hides. The detail pages mount after `me` is cached, so + // usePref seeds from the cache (no localStorage mirror). + const [artBg, setArtBg] = usePref("plexInfoArtBg", true); + const [showCast, setShowCast] = usePref("plexInfoCast", true); + const [showRelated, setShowRelated] = usePref("plexInfoRelated", true); + // E5 S8: slowly cross-dissolve the art backdrop through the item's backdrops/related art. Independent + // of prefers-reduced-motion, which hard-disables the rotation regardless of this toggle. + const [crossfade, setCrossfade] = usePref("plexInfoArtCrossfade", true); + const savePref = useSavePref(); + return { + artBg, + showCast, + showRelated, + crossfade, + savePref, // the raw persister for callers with extra per-user prefs (e.g. strip sources) + toggleArtBg: () => setArtBg(!artBg), + toggleCast: () => setShowCast(!showCast), + toggleRelated: () => setShowRelated(!showRelated), + toggleCrossfade: () => setCrossfade(!crossfade), + }; +} + +// A stored TMDB backdrop is a bare file path ("/xyz.jpg"); the CSP allows the TMDB image CDN (only +// frame-ancestors is set), so we render it straight from there — a rich multi-image pool for the +// crossfade even on a lone movie with no collection. +export function tmdbBackdropUrl(path: string): string { + return `https://image.tmdb.org/t/p/w1280${path}`; +} + +// A faint full-viewport art backdrop that slowly cross-dissolves through a pool of images (E5 S8). +// +// Painting layer: the SETTLED image lives on the app root (#root) background — the proven full-viewport +// layer (#root is exactly the viewport, transparent by default so the art shows through the glassy nav +// / header / content that float over it; a fixed layer at negative z-index that is NOT a #root child is +// hidden by the body scheme-background). The incoming image cross-fades in on an OVERLAY layer that IS +// a `z-index:-1` child of #root: it paints ABOVE #root's own background (the settled art) but BELOW the +// content, so the dissolve is visible under the chrome. Once faded, the settled image is committed to +// #root and the overlay reset to transparent — a double buffer, so only ever ONE extra DOM node. +// +// The rotation is HARD-OFF under prefers-reduced-motion (shows only the first image, no movement) and +// when there are fewer than two images or it's disabled; `attachment:fixed` keeps it viewport-anchored +// while the content scrolls. Cleared on unmount / disable / empty pool. +const BACKDROP_FADE_MS = 1600; +const BACKDROP_INTERVAL_MS = 7000; +const BACKDROP_DIM = + "linear-gradient(color-mix(in srgb, var(--bg) 60%, transparent), " + + "color-mix(in srgb, var(--bg) 64%, transparent))"; + +export function useCrossfadeBackdrop( + arts: (string | null | undefined)[], + enabled: boolean, + animate = true, +) { + // Deduped, non-null pool, collapsed to a string dep so the effect only re-runs on a real change. + const key = Array.from(new Set(arts.filter((a): a is string => !!a))).join("|"); + useLayoutEffect(() => { + const root = document.getElementById("root"); + if (!root) return; + const paint = (url: string) => { + root.style.backgroundImage = `${BACKDROP_DIM}, url("${url}")`; + root.style.backgroundSize = "cover"; + root.style.backgroundPosition = "center 20%"; + root.style.backgroundRepeat = "no-repeat"; + root.style.backgroundAttachment = "fixed"; + // #root must be a stacking context for the z-index:-1 crossfade overlay to paint ABOVE #root's + // own background (the settled art) rather than behind it — without this the overlay is invisible. + root.style.isolation = "isolate"; + }; + const clear = () => { + root.style.backgroundImage = ""; + root.style.backgroundSize = ""; + root.style.backgroundPosition = ""; + root.style.backgroundRepeat = ""; + root.style.backgroundAttachment = ""; + root.style.isolation = ""; + }; + + const pool = key ? key.split("|") : []; + if (!enabled || pool.length === 0) { + clear(); + return clear; // and clear again on unmount / dep-change (idempotent) + } + paint(pool[0]!); + + const reduce = + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + // Static case (one image / crossfade off / reduced motion): still MUST clear on unmount — otherwise + // #root keeps the painted backdrop after leaving the page (e.g. switching to another module). + if (!animate || reduce || pool.length < 2) return clear; + + const layer = document.createElement("div"); + Object.assign(layer.style, { + position: "fixed", + inset: "0", + zIndex: "-1", + backgroundSize: "cover", + backgroundPosition: "center 20%", + backgroundRepeat: "no-repeat", + backgroundAttachment: "fixed", + opacity: "0", + pointerEvents: "none", + }); + root.appendChild(layer); + + let idx = 0; + let fadeTimer: ReturnType | undefined; + const tick = () => { + const next = (idx + 1) % pool.length; + layer.style.transition = "none"; + layer.style.opacity = "0"; + layer.style.backgroundImage = `${BACKDROP_DIM}, url("${pool[next]}")`; + requestAnimationFrame(() => { + layer.style.transition = `opacity ${BACKDROP_FADE_MS}ms ease`; + layer.style.opacity = "1"; + }); + fadeTimer = setTimeout(() => { + paint(pool[next]!); // settle the new image on #root, then reset the overlay (no visible change) + layer.style.transition = "none"; + layer.style.opacity = "0"; + idx = next; + }, BACKDROP_FADE_MS); + }; + const interval = setInterval(tick, BACKDROP_INTERVAL_MS); + return () => { + clearInterval(interval); + if (fadeTimer) clearTimeout(fadeTimer); + layer.remove(); + clear(); + }; + }, [key, enabled, animate]); +} + +// Series detail pages (show + season subpage): the S·E label under a resume/next-up episode. +export function epLabel(ep?: PlexCard | null): string | undefined { + if (!ep) return undefined; + if (ep.season_number != null && ep.episode_number != null) + return `S${ep.season_number} · E${ep.episode_number}`; + return ep.title; +} diff --git a/frontend/src/lib/plexDetailUi.tsx b/frontend/src/lib/plexDetailUi.tsx index c1047225..367ae626 100644 --- a/frontend/src/lib/plexDetailUi.tsx +++ b/frontend/src/lib/plexDetailUi.tsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { ArrowLeft, @@ -9,44 +9,11 @@ import { type LucideIcon, } from "lucide-react"; import { useDismiss } from "./useDismiss"; -import { usePref, useSavePref } from "./usePref"; -import type { PlexCard } from "./api"; // Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages, // so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two // per-user display prefs (art background / cast row — same keys as the movie info page), and the -// customize menu that toggles them. - -export function useDetailPrefs() { - // Three per-user detail-page toggles, each a server pref adopted on login (usePref). Default ON: - // undefined → true, only an explicit false hides. The detail pages mount after `me` is cached, so - // usePref seeds from the cache (no localStorage mirror). - const [artBg, setArtBg] = usePref("plexInfoArtBg", true); - const [showCast, setShowCast] = usePref("plexInfoCast", true); - const [showRelated, setShowRelated] = usePref("plexInfoRelated", true); - // E5 S8: slowly cross-dissolve the art backdrop through the item's backdrops/related art. Independent - // of prefers-reduced-motion, which hard-disables the rotation regardless of this toggle. - const [crossfade, setCrossfade] = usePref("plexInfoArtCrossfade", true); - const savePref = useSavePref(); - return { - artBg, - showCast, - showRelated, - crossfade, - savePref, // the raw persister for callers with extra per-user prefs (e.g. strip sources) - toggleArtBg: () => setArtBg(!artBg), - toggleCast: () => setShowCast(!showCast), - toggleRelated: () => setShowRelated(!showRelated), - toggleCrossfade: () => setCrossfade(!crossfade), - }; -} - -// A stored TMDB backdrop is a bare file path ("/xyz.jpg"); the CSP allows the TMDB image CDN (only -// frame-ancestors is set), so we render it straight from there — a rich multi-image pool for the -// crossfade even on a lone movie with no collection. -export function tmdbBackdropUrl(path: string): string { - return `https://image.tmdb.org/t/p/w1280${path}`; -} +// customize menu that toggles them. The prefs hook, backdrop helpers and epLabel live in plexDetail.ts. // A metadata value that filters the grid when clicked (if onClick is wired), else plain text. Shared // by the movie info page (PlexInfo) and the unified grid / show pages (PlexBrowse). The optional @@ -74,111 +41,6 @@ export function Filterable({ ); } -// A faint full-viewport art backdrop that slowly cross-dissolves through a pool of images (E5 S8). -// -// Painting layer: the SETTLED image lives on the app root (#root) background — the proven full-viewport -// layer (#root is exactly the viewport, transparent by default so the art shows through the glassy nav -// / header / content that float over it; a fixed layer at negative z-index that is NOT a #root child is -// hidden by the body scheme-background). The incoming image cross-fades in on an OVERLAY layer that IS -// a `z-index:-1` child of #root: it paints ABOVE #root's own background (the settled art) but BELOW the -// content, so the dissolve is visible under the chrome. Once faded, the settled image is committed to -// #root and the overlay reset to transparent — a double buffer, so only ever ONE extra DOM node. -// -// The rotation is HARD-OFF under prefers-reduced-motion (shows only the first image, no movement) and -// when there are fewer than two images or it's disabled; `attachment:fixed` keeps it viewport-anchored -// while the content scrolls. Cleared on unmount / disable / empty pool. -const BACKDROP_FADE_MS = 1600; -const BACKDROP_INTERVAL_MS = 7000; -const BACKDROP_DIM = - "linear-gradient(color-mix(in srgb, var(--bg) 60%, transparent), " + - "color-mix(in srgb, var(--bg) 64%, transparent))"; - -export function useCrossfadeBackdrop( - arts: (string | null | undefined)[], - enabled: boolean, - animate = true, -) { - // Deduped, non-null pool, collapsed to a string dep so the effect only re-runs on a real change. - const key = Array.from(new Set(arts.filter((a): a is string => !!a))).join("|"); - useLayoutEffect(() => { - const root = document.getElementById("root"); - if (!root) return; - const paint = (url: string) => { - root.style.backgroundImage = `${BACKDROP_DIM}, url("${url}")`; - root.style.backgroundSize = "cover"; - root.style.backgroundPosition = "center 20%"; - root.style.backgroundRepeat = "no-repeat"; - root.style.backgroundAttachment = "fixed"; - // #root must be a stacking context for the z-index:-1 crossfade overlay to paint ABOVE #root's - // own background (the settled art) rather than behind it — without this the overlay is invisible. - root.style.isolation = "isolate"; - }; - const clear = () => { - root.style.backgroundImage = ""; - root.style.backgroundSize = ""; - root.style.backgroundPosition = ""; - root.style.backgroundRepeat = ""; - root.style.backgroundAttachment = ""; - root.style.isolation = ""; - }; - - const pool = key ? key.split("|") : []; - if (!enabled || pool.length === 0) { - clear(); - return clear; // and clear again on unmount / dep-change (idempotent) - } - paint(pool[0]!); - - const reduce = - typeof window !== "undefined" && - typeof window.matchMedia === "function" && - window.matchMedia("(prefers-reduced-motion: reduce)").matches; - // Static case (one image / crossfade off / reduced motion): still MUST clear on unmount — otherwise - // #root keeps the painted backdrop after leaving the page (e.g. switching to another module). - if (!animate || reduce || pool.length < 2) return clear; - - const layer = document.createElement("div"); - Object.assign(layer.style, { - position: "fixed", - inset: "0", - zIndex: "-1", - backgroundSize: "cover", - backgroundPosition: "center 20%", - backgroundRepeat: "no-repeat", - backgroundAttachment: "fixed", - opacity: "0", - pointerEvents: "none", - }); - root.appendChild(layer); - - let idx = 0; - let fadeTimer: ReturnType | undefined; - const tick = () => { - const next = (idx + 1) % pool.length; - layer.style.transition = "none"; - layer.style.opacity = "0"; - layer.style.backgroundImage = `${BACKDROP_DIM}, url("${pool[next]}")`; - requestAnimationFrame(() => { - layer.style.transition = `opacity ${BACKDROP_FADE_MS}ms ease`; - layer.style.opacity = "1"; - }); - fadeTimer = setTimeout(() => { - paint(pool[next]!); // settle the new image on #root, then reset the overlay (no visible change) - layer.style.transition = "none"; - layer.style.opacity = "0"; - idx = next; - }, BACKDROP_FADE_MS); - }; - const interval = setInterval(tick, BACKDROP_INTERVAL_MS); - return () => { - clearInterval(interval); - if (fadeTimer) clearTimeout(fadeTimer); - layer.remove(); - clear(); - }; - }, [key, enabled, animate]); -} - export function DetailCustomizeMenu({ artBg, onToggleArtBg, @@ -291,13 +153,6 @@ export function PrefToggle({ // --- Series detail-page shared UI (the show page + the season subpage share these) ------------------- // Moved out of PlexBrowse — small building blocks the show/season detail views render verbatim. -export function epLabel(ep?: PlexCard | null): string | undefined { - if (!ep) return undefined; - if (ep.season_number != null && ep.episode_number != null) - return `S${ep.season_number} · E${ep.episode_number}`; - return ep.title; -} - export function BackBtn({ onBack, label }: { onBack: () => void; label: string }) { return (