diff --git a/.env.example b/.env.example
index 3e4dae17..224dcecc 100644
--- a/.env.example
+++ b/.env.example
@@ -28,6 +28,14 @@ ALLOWED_EMAILS=
# Admin emails (subset of the above) get the admin role.
ADMIN_EMAILS=
+# Logging (E22). LOG_LEVEL is the BOOTSTRAP level — the level a process starts at and the only one
+# the worker/download child read; the API's running level is the admin Configuration page's
+# `log_level` knob, which takes effect without a restart. TRACE | DEBUG | INFO | WARNING | ERROR, in
+# any letter case; any other value stops the boot with an error. LOG_FORMAT is "json" (one object per
+# line — what a log collector ingests) or "text" (a readable line for a local terminal).
+LOG_LEVEL=INFO
+LOG_FORMAT=json
+
# Optional: origin of a separately-served frontend dev server (enables CORS). Leave empty in production.
FRONTEND_ORIGIN=
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4e66f937..51046584 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,34 @@ edit the release notes and re-run the generator.
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
+## 0.88.0 — 2026-09-18
+
+When something goes wrong, you can now turn on detailed logging for yourself for 24 hours — and read your own log on the spot, before any of it is sent anywhere. Siftlode also has a privacy policy and terms at last, in both languages, saying exactly what is recorded and for how long.
+
+### Features
+
+- Settings → Diagnostics: choosing Detailed or Full changes nothing until you confirm a box that tells you what will be recorded, that the server keeps it, for how long, and that it switches itself off after 24 hours. Going back to Basic needs no confirmation and takes effect at once.
+- “My log”, on the same tab, shows what your browser has recorded — newest first, any line openable. Nothing leaves the browser before you have consented.
+- What you search for and filter by is recorded only at Full, and only under your own consent — never an administrator's. Passwords, tokens, cookies and email addresses are recorded at no level, by anyone.
+- Your last sign-in is shown in your account panel, and in the administrator's user list.
+- A privacy policy and terms of service, in Hungarian and English, covering what the server logs and what each diagnostics level adds.
+- Administrators get a Diagnostics section in Configuration: the log level takes effect without a restart, and stored logs are cleaned up on a schedule (7 days by default).
+- Every setting with a fixed set of values is now picked from those values instead of typed in, so a wrong one can no longer be saved.
+
+### Fixes
+
+- Every message arrived twice if you left a conversation and came back quickly: two chat connections stayed open where there should have been one.
+- Settings was unusable on a phone — the tab rail took the width and left almost nothing for the panel beside it.
+- Dates and numbers follow the language you picked in Siftlode, not the one your computer happens to be set to. An English Siftlode on a Hungarian machine no longer shows Hungarian dates.
+- An administrator setting shown as a dropdown opened on its first option instead of the one actually in force.
+- In Hungarian, the audit log and the new log settings had names you could not tell apart.
+- The scheduled feed poll now covers only channels somebody actually follows — on this instance 582 of 979 polled channels had no follower at all. The trade-off is deliberate: new uploads from a channel nobody follows no longer appear in the shared Library.
+
+### Internal
+
+- One logging mechanism replaced four. The API, the worker, the downloader and the migration tool now write the same structured line; secrets are scrubbed where the line is written rather than at each of ~125 places that write one; and every request carries an id that joins a line from your browser to the server line of the same request. The browser's log uses the same shape and the same redaction rules, checked from both sides against one shared set of cases.
+- Two adversarial review rounds over the epic: 56 findings raised, 32 survived three independent attempts to refute each, all fixed. Every regression test was proven to fail with its own fix removed — which is how a test that passed only on leftover state from an earlier test was caught, and how a focus bug that jsdom could not reproduce at all was settled in a real browser.
+
## 0.87.1 — 2026-09-16
A YouTube video that YouTube will only show to a signed-in viewer can now be downloaded: paste your YouTube cookies in Settings the way the failure message already told you to — until now that list had no YouTube in it.
diff --git a/CLAUDE.md b/CLAUDE.md
index c273a7e8..70bc60c6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -58,7 +58,7 @@ how it gets built. Concretely:
`routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`,
`downloads/`, `youtube/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `progress`,
-`security`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
+`security`, `logs`, `logsink`, `diagnostics`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
@@ -95,6 +95,8 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `
{t("settings.invites.requested", {
- time: new Date(inv.requested_at).toLocaleString(),
+ time: formatDateTime(inv.requested_at),
})}
)}
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.dom.test.tsx b/frontend/src/components/ChannelStatsPopover.dom.test.tsx
index f94f6ebb..b477c91f 100644
--- a/frontend/src/components/ChannelStatsPopover.dom.test.tsx
+++ b/frontend/src/components/ChannelStatsPopover.dom.test.tsx
@@ -19,7 +19,7 @@ vi.mock("react-i18next", async (importOriginal) => {
import ChannelStatsPopover from "./ChannelStatsPopover";
import { type MyStatus } from "../lib/api";
-import { formatEta } from "../lib/format";
+import { formatEta, formatNumber } from "../lib/format";
beforeAll(() => {
class Stub {
@@ -74,7 +74,7 @@ describe("ChannelStatsPopover", () => {
expect(screen.getByText("channels.stats.recentSynced")).toBeTruthy();
expect(screen.getByText("3/10")).toBeTruthy(); // recent-synced ratio
expect(screen.getByText("1/4")).toBeTruthy(); // full-history ratio
- expect(screen.getByText((1234).toLocaleString())).toBeTruthy(); // my_videos, locale-agnostic
+ expect(screen.getByText(formatNumber(1234))).toBeTruthy(); // my_videos, locale-agnostic
});
it("shows the 'left' ETA row only while a backfill is pending, with the formatted ETA value", () => {
diff --git a/frontend/src/components/ChannelStatsPopover.tsx b/frontend/src/components/ChannelStatsPopover.tsx
index 3d20d933..c31e03ab 100644
--- a/frontend/src/components/ChannelStatsPopover.tsx
+++ b/frontend/src/components/ChannelStatsPopover.tsx
@@ -11,8 +11,9 @@ import {
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { type MyStatus } from "../lib/api";
-import { formatEta } from "../lib/format";
-import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
+import { formatEta, formatNumber } from "../lib/format";
+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
@@ -63,13 +64,13 @@ export default function ChannelStatsPopover({ status }: { status: MyStatus }) {
{
icon: Film,
label: t("channels.stats.myVideos"),
- value: status.my_videos.toLocaleString(),
+ value: formatNumber(status.my_videos),
hint: t("channels.stats.myVideosHint"),
},
{
icon: Zap,
label: t("channels.stats.quotaLeft"),
- value: status.quota_remaining_today.toLocaleString(),
+ value: formatNumber(status.quota_remaining_today),
hint: t("channels.stats.quotaLeftHint"),
},
];
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 41545499..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";
@@ -292,7 +292,7 @@ export default function Channels() {
if (filtersResetToken === resetRef.current) return;
resetRef.current = filtersResetToken;
setSearch("");
- }, [filtersResetToken]);
+ }, [filtersResetToken, setSearch]);
// Tag/priority/text filters stay controlled COLUMN filters (`filters.tags` etc.) shared by the top
// chips, the column funnels and the cards. The sync-status filter (`filters.sync`) is no longer a
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.choice.dom.test.tsx b/frontend/src/components/ConfigPanel.choice.dom.test.tsx
new file mode 100644
index 00000000..d22dab5f
--- /dev/null
+++ b/frontend/src/components/ConfigPanel.choice.dom.test.tsx
@@ -0,0 +1,140 @@
+// @vitest-environment jsdom
+//
+// A closed-set config key is picked, not typed: a short list renders as a segmented bar, a long one as
+// a dropdown, and the pick is what Save sends. A cleared draft (reset to the default) shows the default
+// as the active option rather than no option at all.
+//
+// i18n is a key-passthrough, except for an option's label: a full key would distort the
+// bar-or-dropdown choice, which weighs the real label length, so a choice id resolves to its value
+// under a one-character marker. The marker is what makes a lost lookup visible — rendering the bare
+// value instead of the id would be indistinguishable from `label: c`. The admin config API is stubbed.
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { ConfigItem } from "../lib/api";
+
+vi.mock("react-i18next", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useTranslation: () => ({
+ // Only an id the fixture really defines resolves: a dropped lookup renders the bare value and
+ // a malformed id renders the key (long enough to turn every bar into a dropdown), so the
+ // assertions below see both.
+ t: (key: string) => CHOICE_LABELS[key] ?? key,
+ i18n: { language: "en" },
+ }),
+ };
+});
+
+const choice = (key: string, choices: string[], value: string, is_set = false): ConfigItem => ({
+ key,
+ type: "choice",
+ group: "g",
+ secret: false,
+ min: null,
+ max: null,
+ is_set,
+ allow_empty: false,
+ choices,
+ value,
+ default: choices[0],
+});
+
+const PRESETS = ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow"];
+const LEVELS = ["TRACE", "DEBUG", "INFO", "WARNING", "ERROR"];
+const CHOICE_LABELS: Record = Object.fromEntries(
+ (
+ [
+ ["plex_transcode_hw", ["auto", "on", "off"]],
+ ["plex_transcode_preset", PRESETS],
+ ["log_level", LEVELS],
+ ] as const
+ ).flatMap(([key, choices]) =>
+ choices.map((c) => [`config.choices.${key}.${c}`, `~${c}`] as const),
+ ),
+);
+const setConfig = vi.fn((_key: string, _value: unknown) => Promise.resolve({}));
+const resetConfig = vi.fn((_key: string) => Promise.resolve({}));
+
+vi.mock("../lib/api", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ api: {
+ ...actual.api,
+ adminConfig: () =>
+ Promise.resolve({
+ groups: {
+ g: [
+ choice("plex_transcode_hw", ["auto", "on", "off"], "on", true),
+ choice("plex_transcode_preset", PRESETS, "veryfast"),
+ choice("log_level", LEVELS, "INFO"),
+ ],
+ },
+ secrets_manageable: true,
+ }),
+ setConfig: (key: string, value: unknown) => setConfig(key, value),
+ resetConfig: (key: string) => resetConfig(key),
+ },
+ };
+});
+vi.mock("../lib/useMe", () => ({ useMe: () => ({ email_configured: true }) }));
+
+import ConfigPanel from "./ConfigPanel";
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+const mount = () =>
+ render(
+
+
+ ,
+ );
+
+describe("ConfigPanel choice keys", () => {
+ it("renders a short list as a segmented bar and saves the pick", async () => {
+ mount();
+ const bar = await screen.findByRole("group", { name: "config.fields.plex_transcode_hw.label" });
+ const pressed = () =>
+ [...bar.querySelectorAll('[aria-pressed="true"]')].map((b) => b.textContent);
+ expect(pressed()).toEqual(["~on"]);
+ expect(screen.queryByRole("textbox")).toBeNull(); // no free-text field for a choice key
+
+ fireEvent.click(screen.getByRole("button", { name: "~off" }));
+ fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
+ await waitFor(() => expect(setConfig).toHaveBeenCalledWith("plex_transcode_hw", "off"));
+ });
+
+ it("shows the default as active once the override is reset, and Save resets it", async () => {
+ mount();
+ await screen.findByRole("group", { name: "config.fields.plex_transcode_hw.label" });
+ fireEvent.click(screen.getByRole("button", { name: /config\.reset$/ }));
+ expect(screen.getByRole("button", { name: "~auto" }).getAttribute("aria-pressed")).toBe("true");
+ fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
+ await waitFor(() => expect(resetConfig).toHaveBeenCalledWith("plex_transcode_hw"));
+ });
+
+ it("renders a long list as a dropdown showing only the value", async () => {
+ mount();
+ const trigger = await screen.findByRole("button", {
+ name: "config.fields.plex_transcode_preset.label: ~veryfast",
+ });
+ expect(trigger.textContent).toBe("~veryfast");
+ fireEvent.click(trigger);
+ // The popover rows are queried as FilterSelect.dom.test does (`hidden: true`, picked by text).
+ const rows = screen.getAllByRole("menuitemradio", { hidden: true });
+ fireEvent.click(rows.find((r) => r.textContent === "~slow")!);
+ fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
+ await waitFor(() => expect(setConfig).toHaveBeenCalledWith("plex_transcode_preset", "slow"));
+ });
+
+ it("a few options with long labels are a dropdown too: a bar that wide does not fit a phone", async () => {
+ mount();
+ await screen.findByRole("button", { name: "config.fields.log_level.label: ~INFO" });
+ expect(screen.queryByRole("group", { name: "config.fields.log_level.label" })).toBeNull();
+ });
+});
diff --git a/frontend/src/components/ConfigPanel.field.dom.test.tsx b/frontend/src/components/ConfigPanel.field.dom.test.tsx
new file mode 100644
index 00000000..38d4ab24
--- /dev/null
+++ b/frontend/src/components/ConfigPanel.field.dom.test.tsx
@@ -0,0 +1,76 @@
+// @vitest-environment jsdom
+//
+// E22 round-2 review: locks the stacking that makes an admin config row usable on a phone. The
+// control column cannot shrink, so beside a wide control it left the label and its hint a sliver of
+// the ~250 px a phone leaves next to the navigation rail. The sibling fix — a long-labelled choice
+// becoming a dropdown — is covered in ConfigPanel.choice.dom.test.tsx, but that one does nothing for
+// a text, number or secret key, whose `w-52` input is the wide control: this is their only fix.
+//
+// jsdom does no layout, so what is asserted is the class contract those rules rest on, the same
+// trade SearchBar.dom.test.tsx already makes for the narrow-header rules.
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, render, screen } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { ConfigItem } from "../lib/api";
+
+vi.mock("react-i18next", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
+ };
+});
+
+const text = (key: string): ConfigItem => ({
+ key,
+ type: "str",
+ group: "g",
+ secret: false,
+ min: null,
+ max: null,
+ is_set: false,
+ allow_empty: true,
+ choices: [],
+ value: "",
+ default: "",
+});
+
+vi.mock("../lib/api", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ api: {
+ ...actual.api,
+ adminConfig: () =>
+ Promise.resolve({ groups: { g: [text("site_name")] }, secrets_manageable: true }),
+ },
+ };
+});
+vi.mock("../lib/useMe", () => ({ useMe: () => ({ email_configured: true }) }));
+
+import ConfigPanel from "./ConfigPanel";
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+describe("ConfigPanel field row (E22 round 2)", () => {
+ it("stacks the label above the control below `sm`, and lets the control fit the screen", async () => {
+ render(
+
+
+ ,
+ );
+ const label = await screen.findByText("config.fields.site_name.label");
+ const row = label.parentElement!.parentElement!; // label → the label/hint column → the row
+ // One column on a phone, the two-column row only from `sm` up:
+ expect(row.className).toContain("flex-col");
+ expect(row.className).toContain("sm:flex-row");
+ // The control column may not out-width the screen, and only stops shrinking from `sm` up:
+ const control = row.lastElementChild as HTMLElement;
+ expect(control.className).toContain("max-w-full");
+ expect(control.className).toContain("sm:shrink-0");
+ expect(control.className).not.toMatch(/(^|\s)shrink-0(\s|$)/);
+ });
+});
diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx
index 92ff29a0..431dce7c 100644
--- a/frontend/src/components/ConfigPanel.tsx
+++ b/frontend/src/components/ConfigPanel.tsx
@@ -8,34 +8,21 @@ 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
// backend — adding a key there makes it appear here automatically). Edits are drafted and
-// applied together via one Save/Discard bar; nothing hits
-// the server until Save. Group order is fixed where known so logically related settings (e.g.
-// Email/SMTP) stay together.
-const GROUP_ORDER = [
- "access",
- "email",
- "youtube",
- "google",
- "quota",
- "backfill",
- "shorts",
- "batch",
- "maintenance",
- "downloads",
- "audit",
- "plex",
- "tmdb",
-];
+// applied together via one Save/Discard bar; nothing hits the server until Save. The tabs follow the
+// registry's own order (`sysconfig.SPECS`), so a new group lands where the backend puts it instead of
+// needing a second, hand-kept order list here.
export default function ConfigPanel() {
const { t } = useTranslation();
@@ -64,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
@@ -171,9 +158,7 @@ export default function ConfigPanel() {
return ;
}
- const groupKeys = Object.keys(data.groups).sort(
- (a, b) => (GROUP_ORDER.indexOf(a) + 1 || 99) - (GROUP_ORDER.indexOf(b) + 1 || 99),
- );
+ const groupKeys = Object.keys(data.groups);
// Clamp the persisted tab to a real group (the registry can change between sessions). The
// tab pill carries a dot when that group has unsaved edits, so a draft in a hidden tab is
// never silently lost behind the global Save bar.
@@ -330,6 +315,7 @@ function Field({
const { t } = useTranslation();
const isNum = item.type === "int";
const isBool = item.type === "bool";
+ const label = t(`config.fields.${item.key}.label`, item.key);
const secretDisabled = item.secret && !secretsManageable;
// Status line: never reveal a secret's value.
@@ -354,22 +340,26 @@ function Field({
const showReset = item.is_set && !isBool;
return (
-
+ // Stacked below `sm`: the control column cannot shrink, and beside a wide control it left the
+ // label and hint a sliver of a phone's width.
+
);
}
+
+// A closed-set key picks from its options instead of taking free text: a segmented bar for a short
+// list, a dropdown past six (the house rule for chip vs. select). A cleared draft ("" = reset to the
+// default on Save) shows the default as the active option, as a text field shows it as placeholder.
+//
+// "Short" is also about the words, not just their number: a bar cannot shrink or wrap, and five level
+// names (TRACE … WARNING, ~300 px) are wider than a phone leaves beside the navigation rail (~250 px).
+// Past MAX_SEGMENT_CHARS of labels, in the language shown, it is a dropdown too.
+const MAX_SEGMENTS = 6;
+const MAX_SEGMENT_CHARS = 22;
+
+function ChoiceField({
+ item,
+ label,
+ value,
+ onChange,
+}: {
+ item: ConfigItem;
+ label: string;
+ value: string;
+ onChange: (v: string) => void;
+}) {
+ const { t } = useTranslation();
+ const options = item.choices.map((c) => ({
+ value: c,
+ label: t(`config.choices.${item.key}.${c}`, c),
+ }));
+ const current = value || String(item.default ?? "");
+ const labelChars = options.reduce((sum, o) => sum + o.label.length, 0);
+ if (options.length <= MAX_SEGMENTS && labelChars <= MAX_SEGMENT_CHARS) {
+ return (
+
+ );
+ }
+ return (
+ next && onChange(next)}
+ />
+ );
+}
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/DiagnosticsSettings.dom.test.tsx b/frontend/src/components/DiagnosticsSettings.dom.test.tsx
new file mode 100644
index 00000000..f4fb8c7b
--- /dev/null
+++ b/frontend/src/components/DiagnosticsSettings.dom.test.tsx
@@ -0,0 +1,216 @@
+// @vitest-environment jsdom
+//
+// E22 S5: Settings → Diagnosztika. Raising the level is a consent, so choosing Részletes or Teljes
+// must change NOTHING until the consent box's own button is pressed; going back to Alap needs no
+// consent. i18n is a key-passthrough (with the count, so the retention days are visible); the level
+// endpoint is stubbed, and `me` lives in a real query cache so a saved level re-renders like in the app.
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import type { DiagnosticsLevel, DiagnosticsState, Me } from "../lib/api";
+import { qk } from "../lib/queryKeys";
+
+vi.mock("react-i18next", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useTranslation: () => ({
+ t: (key: string, opts?: { count?: number }) =>
+ opts?.count != null ? `${key}:${opts.count}` : key,
+ i18n: { language: "en" },
+ }),
+ };
+});
+
+const setLevel = vi.fn(
+ (level: DiagnosticsLevel): Promise<{ ok: true; diagnostics: DiagnosticsState }> =>
+ Promise.resolve({
+ ok: true,
+ diagnostics: {
+ level,
+ until: level === "INFO" ? null : new Date(Date.now() + 86_400_000).toISOString(),
+ retention_days: 7,
+ },
+ }),
+);
+vi.mock("../lib/api", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ api: { ...actual.api, setDiagnosticsLevel: (l: DiagnosticsLevel) => setLevel(l) },
+ };
+});
+
+import DiagnosticsSettings from "./DiagnosticsSettings";
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+function mount(me: Partial) {
+ const qc = new QueryClient();
+ qc.setQueryData(qk.me(), {
+ id: 1,
+ is_demo: false,
+ preferences: {},
+ diagnostics: { level: "INFO", until: null, retention_days: 7 },
+ ...me,
+ });
+ render(
+
+
+ ,
+ );
+ return qc;
+}
+
+const levelButton = (name: string) =>
+ within(screen.getByRole("group", { name: "settings.diagnostics.level" })).getByRole("button", {
+ name,
+ });
+
+describe("DiagnosticsSettings", () => {
+ it("choosing a detailed level asks first and changes nothing until confirmed", async () => {
+ mount({});
+ fireEvent.click(levelButton("settings.diagnostics.levels.DEBUG"));
+ const consent = screen.getByTestId("diagnostics-consent");
+ expect(consent.textContent).toContain("settings.diagnostics.consent.what.DEBUG");
+ expect(consent.textContent).toContain("settings.diagnostics.consent.server:7");
+ expect(setLevel).not.toHaveBeenCalled();
+
+ fireEvent.click(
+ within(consent).getByRole("button", { name: "settings.diagnostics.consent.cancel" }),
+ );
+ expect(screen.queryByTestId("diagnostics-consent")).toBeNull();
+ expect(levelButton("settings.diagnostics.levels.INFO").getAttribute("aria-pressed")).toBe(
+ "true",
+ );
+ expect(setLevel).not.toHaveBeenCalled();
+ });
+
+ it("the consent button raises the level, and the status line offers the way back", async () => {
+ mount({});
+ fireEvent.click(levelButton("settings.diagnostics.levels.TRACE"));
+ fireEvent.click(screen.getByTestId("diagnostics-consent-confirm"));
+ await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy());
+ expect(setLevel).toHaveBeenCalledWith("TRACE");
+ expect(screen.queryByTestId("diagnostics-consent")).toBeNull();
+ expect(levelButton("settings.diagnostics.levels.TRACE").getAttribute("aria-pressed")).toBe(
+ "true",
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "settings.diagnostics.revert" }));
+ await waitFor(() => expect(screen.queryByTestId("diagnostics-status")).toBeNull());
+ expect(setLevel).toHaveBeenLastCalledWith("INFO");
+ });
+
+ it("going back to the base level needs no consent", async () => {
+ const until = new Date(Date.now() + 3_600_000).toISOString();
+ mount({ diagnostics: { level: "DEBUG", until, retention_days: 7 } });
+ fireEvent.click(levelButton("settings.diagnostics.levels.INFO"));
+ expect(screen.queryByTestId("diagnostics-consent")).toBeNull();
+ await waitFor(() => expect(setLevel).toHaveBeenCalledWith("INFO"));
+ });
+
+ it("the focus goes back to the level picker when the box it was in goes away", async () => {
+ mount({});
+ fireEvent.click(levelButton("settings.diagnostics.levels.DEBUG"));
+ const cancel = screen.getByRole("button", { name: "settings.diagnostics.consent.cancel" });
+ cancel.focus();
+ fireEvent.click(cancel);
+ expect(document.activeElement).toBe(levelButton("settings.diagnostics.levels.INFO"));
+
+ fireEvent.click(levelButton("settings.diagnostics.levels.TRACE"));
+ const confirm = screen.getByTestId("diagnostics-consent-confirm");
+ confirm.focus();
+ fireEvent.click(confirm);
+ await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy());
+ expect(document.activeElement).toBe(levelButton("settings.diagnostics.levels.TRACE"));
+
+ const revert = screen.getByRole("button", { name: "settings.diagnostics.revert" });
+ revert.focus();
+ fireEvent.click(revert);
+ await waitFor(() => expect(screen.queryByTestId("diagnostics-status")).toBeNull());
+ expect(document.activeElement).toBe(levelButton("settings.diagnostics.levels.INFO"));
+ });
+
+ it("the watch outlives a render in which the element it is watching is still there", async () => {
+ // The distinguishing property of the restore, and the one the browser found: it keeps looking
+ // until the element is GONE, instead of spending itself on the first render after the change.
+ // Chrome commits a saved level as two renders and jsdom as one, so this drives the general
+ // case directly — the user moved the focus somewhere else while the request was out, and that
+ // somewhere else disappears later, several renders on.
+ vi.useFakeTimers();
+ try {
+ // Raised, so the status line's countdown is mounted and its 30 s tick keeps re-rendering.
+ const until = new Date(Date.now() + 3_600_000).toISOString();
+ mount({ diagnostics: { level: "DEBUG", until, retention_days: 7 } });
+ fireEvent.click(levelButton("settings.diagnostics.levels.TRACE"));
+ const elsewhere = document.createElement("button");
+ document.body.append(elsewhere);
+ elsewhere.focus();
+
+ // Cancel closes the box; the watch is armed on `elsewhere`, which is still in the page.
+ fireEvent.click(screen.getByRole("button", { name: "settings.diagnostics.consent.cancel" }));
+ expect(document.activeElement).toBe(elsewhere); // their place is kept, nothing is stolen
+
+ // Renders keep coming (the countdown ticks) while it is still there, and must not spend it.
+ await vi.advanceTimersByTimeAsync(60_000);
+ expect(document.activeElement).toBe(elsewhere);
+
+ elsewhere.remove(); // now the focus really has fallen to
+ expect(document.activeElement).toBe(document.body);
+ await vi.advanceTimersByTimeAsync(30_000);
+ expect(document.activeElement).toBe(levelButton("settings.diagnostics.levels.DEBUG"));
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("the ticking status line is not a live region", async () => {
+ const until = new Date(Date.now() + 3_600_000).toISOString();
+ mount({ diagnostics: { level: "DEBUG", until, retention_days: 7 } });
+ const status = screen.getByTestId("diagnostics-status");
+ expect(status.closest('[role="status"], [aria-live]')).toBeNull();
+ });
+
+ it("a `me` fetch that left before the change cannot put the old level back", async () => {
+ const qc = mount({});
+ let answerStale: (me: unknown) => void = () => {};
+ const stale = qc
+ .fetchQuery({
+ queryKey: qk.me(),
+ queryFn: () => new Promise((resolve) => (answerStale = resolve)),
+ })
+ .catch(() => null);
+ fireEvent.click(levelButton("settings.diagnostics.levels.TRACE"));
+ fireEvent.click(screen.getByTestId("diagnostics-consent-confirm"));
+ await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy());
+ answerStale({
+ id: 1,
+ is_demo: false,
+ preferences: {},
+ diagnostics: { level: "INFO", until: null, retention_days: 7 },
+ });
+ await stale;
+ expect(qc.getQueryData(qk.me())?.diagnostics.level).toBe("TRACE");
+ expect(screen.getByTestId("diagnostics-status")).toBeTruthy();
+ });
+
+ it("a lapsed level shows as the base level", () => {
+ const until = new Date(Date.now() - 60_000).toISOString();
+ mount({ diagnostics: { level: "TRACE", until, retention_days: 7 } });
+ expect(levelButton("settings.diagnostics.levels.INFO").getAttribute("aria-pressed")).toBe(
+ "true",
+ );
+ expect(screen.queryByTestId("diagnostics-status")).toBeNull();
+ });
+
+ it("the shared demo account gets an explanation instead of the picker", () => {
+ mount({ is_demo: true });
+ expect(screen.queryByRole("group", { name: "settings.diagnostics.level" })).toBeNull();
+ expect(screen.getByText("settings.diagnostics.demo")).toBeTruthy();
+ });
+});
diff --git a/frontend/src/components/DiagnosticsSettings.tsx b/frontend/src/components/DiagnosticsSettings.tsx
new file mode 100644
index 00000000..c1a67440
--- /dev/null
+++ b/frontend/src/components/DiagnosticsSettings.tsx
@@ -0,0 +1,186 @@
+import { useEffect, useRef, useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
+
+import { api, type DiagnosticsLevel } from "../lib/api";
+import { levelInForce } from "../lib/diagnostics";
+import { updateMeCache, useMe } from "../lib/useMe";
+import { Button } from "./ui/Button";
+import InlineNotice from "./ui/InlineNotice";
+import SegmentedToggle from "./ui/SegmentedToggle";
+import { Section, SettingRow } from "./ui/form";
+import MyLogViewer from "./MyLogViewer";
+
+const LEVELS: readonly DiagnosticsLevel[] = ["INFO", "DEBUG", "TRACE"];
+
+/** Hours and minutes until `until`, refreshed every 30 s — the status line counts down. */
+function useRemaining(until: string | null): { h: number; m: number } | null {
+ const [now, setNow] = useState(() => Date.now());
+ useEffect(() => {
+ if (!until) return;
+ const id = window.setInterval(() => setNow(Date.now()), 30_000);
+ return () => window.clearInterval(id);
+ }, [until]);
+ if (!until) return null;
+ const minutes = Math.max(0, Math.ceil((Date.parse(until) - now) / 60_000));
+ return { h: Math.floor(minutes / 60), m: minutes % 60 };
+}
+
+// Settings → Diagnosztika (E22 S5): the consented diagnostics level and the tab's own log.
+//
+// There is no separate on/off switch: Alap IS off. Choosing Részletes or Teljes does not change
+// anything yet — it opens the consent box under the picker, which says what will be recorded, that
+// the server keeps it and for how long, and that it lapses; only its button raises the level.
+// Returning to Alap needs no consent and happens at once.
+export default function DiagnosticsSettings() {
+ const { t } = useTranslation();
+ const me = useMe();
+ const qc = useQueryClient();
+ const state = me.diagnostics;
+ const current = levelInForce(state);
+ const [pending, setPending] = useState(null);
+ const remaining = useRemaining(current === "INFO" ? null : (state?.until ?? null));
+
+ // The consent box and the status line each go away with the button that closed them — the one
+ // holding the focus. Put it back on the level picker (its chosen option) rather than let it fall
+ // to , where the next Tab starts over from the top of the page. Only when it did fall: a
+ // user who moved on while the request was out keeps their place.
+ //
+ // Watched until that button is actually GONE, not for one render: a saved level arrives as two
+ // updates (the `me` cache, then the closed box), and the first render still has the button in the
+ // page — measured in Chrome, where a one-shot check spent itself there and the focus then fell.
+ const pickerRef = useRef(null);
+ const closedFrom = useRef(null);
+ useEffect(() => {
+ const from = closedFrom.current;
+ if (!from || from.isConnected) return;
+ closedFrom.current = null;
+ if (document.activeElement && document.activeElement !== document.body) return;
+ pickerRef.current?.querySelector('[aria-pressed="true"]')?.focus();
+ });
+ const watchFocus = () => {
+ closedFrom.current = document.activeElement;
+ };
+
+ const change = useMutation({
+ mutationFn: api.setDiagnosticsLevel,
+ meta: { failTitle: "settings.diagnostics.failed" },
+ onSuccess: (result) => {
+ watchFocus();
+ updateMeCache(qc, (prev) => ({ ...prev, diagnostics: result.diagnostics }));
+ setPending(null);
+ },
+ });
+
+ const cancel = () => {
+ watchFocus();
+ setPending(null);
+ };
+
+ const choose = (level: DiagnosticsLevel) => {
+ if (level === "INFO") {
+ setPending(null);
+ if (current !== "INFO") change.mutate("INFO");
+ } else {
+ setPending(level === current ? null : level);
+ }
+ };
+
+ const levelName = (level: DiagnosticsLevel) => t(`settings.diagnostics.levels.${level}`);
+
+ return (
+ <>
+
+ {me.is_demo ? (
+ {t("settings.diagnostics.demo")}
+ ) : (
+
+
@@ -540,31 +540,31 @@ export default function Scheduler() {
icon={Database}
label={t("scheduler.queue.recentPending")}
hint={t("scheduler.queue.recentPendingHint")}
- value={data.queue.channels_recent_pending.toLocaleString()}
+ value={formatNumber(data.queue.channels_recent_pending)}
/>
@@ -579,15 +579,15 @@ export default function Scheduler() {
{t("scheduler.quotaUsed", {
- used: data.quota.used_today.toLocaleString(),
- budget: data.quota.daily_budget.toLocaleString(),
+ used: formatNumber(data.quota.used_today),
+ budget: formatNumber(data.quota.daily_budget),
})}
{t("scheduler.quotaRemaining", {
count: data.quota.remaining_today,
- formatted: data.quota.remaining_today.toLocaleString(),
+ formatted: formatNumber(data.quota.remaining_today),
})}
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 86894dcd..7c7d8dc7 100644
--- a/frontend/src/components/SettingsPanel.tsx
+++ b/frontend/src/components/SettingsPanel.tsx
@@ -3,7 +3,8 @@ import { withActiveAccount } from "../lib/activeAccount";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
+import { Activity, Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
+import DiagnosticsSettings from "./DiagnosticsSettings";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, HttpError, type Me } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
@@ -14,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";
@@ -39,11 +40,12 @@ export interface PrefsController {
saveState: PrefsSaveState;
}
-type TabId = "appearance" | "notifications" | "account";
+type TabId = "appearance" | "notifications" | "account" | "diagnostics";
const TABS: { id: TabId; icon: typeof Monitor }[] = [
{ id: "appearance", icon: Monitor },
{ id: "notifications", icon: Bell },
{ id: "account", icon: User },
+ { id: "diagnostics", icon: Activity },
];
// Settings as a page (Design B): rendered in the main content area, not an overlay. It keeps
@@ -65,11 +67,12 @@ export default function SettingsPanel() {
return (
-
- {/* Vertical tab rail — never wraps; active is a clear accent pill. */}
+
+ {/* Tab rail: a scrolling row above the content on a phone, the side rail from `sm` up (see
+ Tabs). Active is a clear accent pill. */}
({
@@ -87,6 +90,7 @@ export default function SettingsPanel() {
{tab === "appearance" && }
{tab === "notifications" && }
{tab === "account" && }
+ {tab === "diagnostics" && }
diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx
index bae70fc6..b6a456b5 100644
--- a/frontend/src/components/ShareDialog.tsx
+++ b/frontend/src/components/ShareDialog.tsx
@@ -5,23 +5,28 @@ 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";
-import { api, type DownloadJob, type ShareLink } from "../lib/api";
+import { formatDate } from "../lib/format";
+import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../lib/api";
import { inputCls } from "./ui/form";
import { Button } from "./ui/Button";
const EXPIRY_DAYS = [null, 1, 7, 30] as const;
// --- share with a registered user (ACL) -----------------------------------------------------
+// One shared empty list while the recipients load: a fresh `[]` per render made `filtered` below
+// recompute on every render.
+const NO_RECIPIENTS: ShareRecipient[] = [];
+
function UserShare({ job }: { job: DownloadJob }) {
const { t } = useTranslation();
const [q, setQ] = useState("");
const [open, setOpen] = useState(false);
const recipients = useQuery({ queryKey: qk.shareRecipients(), queryFn: api.shareRecipients });
- const list = recipients.data ?? [];
+ const list = recipients.data ?? NO_RECIPIENTS;
const filtered = useMemo(() => {
const s = q.trim().toLowerCase();
if (!s) return list.slice(0, 8);
@@ -88,7 +93,7 @@ function UserShare({ job }: { job: DownloadJob }) {
// --- share by public link -------------------------------------------------------------------
function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }) {
- const { t } = useTranslation();
+ const { t, i18n } = useTranslation();
const confirm = useConfirm();
const [copied, setCopied] = useState(false);
const [editPass, setEditPass] = useState(false);
@@ -130,7 +135,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
if (link.allow_download) badges.push(t("downloads.share.downloadable"));
if (link.expires_at)
badges.push(
- t("downloads.share.expiresOn", { date: new Date(link.expires_at).toLocaleDateString() }),
+ t("downloads.share.expiresOn", { date: formatDate(link.expires_at, i18n.language) }),
);
return (
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 21513e9d..2a23cbb8 100644
--- a/frontend/src/components/Stats.tsx
+++ b/frontend/src/components/Stats.tsx
@@ -4,10 +4,10 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { History, Pause, Play, RefreshCw } from "lucide-react";
import { api, type AdminQuotaRow, type Me } from "../lib/api";
-import { formatEta, quotaActionLabel } from "../lib/format";
+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";
@@ -115,10 +115,10 @@ function Overview({ me }: { me: Me }) {
)}
- {s.my_videos.toLocaleString()}
+ {formatNumber(s.my_videos)}
- {s.quota_remaining_today.toLocaleString()}
+ {formatNumber(s.quota_remaining_today)}
) : status.isError ? (
@@ -135,16 +135,16 @@ function Overview({ me }: { me: Me }) {
<>
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 b24f3d89..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,
@@ -22,7 +22,7 @@ import { navItemAttrs } from "../lib/listNav";
import clsx from "clsx";
import type { Video, VideoStatus } from "../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
-import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
+import { formatDate, formatDuration, formatNumber, formatViews, relativeTime } from "../lib/format";
import { isPlainLeftClick } from "../lib/nav";
import ChannelNameLink from "./ChannelNameLink";
@@ -423,7 +423,7 @@ function VideoCard({
// on every line and the tooltip carries the figure regardless.
const viewsCell = (withWord: boolean) =>
video.view_count != null && (
-
+
{formatViews(video.view_count)}
{withWord && ` ${t("card.views")}`}
diff --git a/frontend/src/components/VideoEditor.tsx b/frontend/src/components/VideoEditor.tsx
index 696d4636..77b64591 100644
--- a/frontend/src/components/VideoEditor.tsx
+++ b/frontend/src/components/VideoEditor.tsx
@@ -130,10 +130,13 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const v = videoRef.current;
if (v) setCurrent(v.currentTime);
};
- const seek = (s: number) => {
- const v = videoRef.current;
- if (v) v.currentTime = Math.max(0, Math.min(duration, s));
- };
+ const seek = useCallback(
+ (s: number) => {
+ const v = videoRef.current;
+ if (v) v.currentTime = Math.max(0, Math.min(duration, s));
+ },
+ [duration],
+ );
const playPause = () => {
const v = videoRef.current;
if (!v) return;
@@ -154,18 +157,21 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
return next;
});
};
- const moveBoundary = (bi: number, time: number) =>
- setSegments((segs) => {
- if (creating.current || bi < 0 || bi >= segs.length - 1) return segs;
- // bi is in [0, length-2] here, so bi and bi+1 both index existing segments.
- const lo = segs[bi]!.start + MIN_SEG;
- const hi = segs[bi + 1]!.end - MIN_SEG;
- const tt = Math.max(lo, Math.min(hi, time));
- const copy = [...segs];
- copy[bi] = { ...copy[bi]!, end: tt };
- copy[bi + 1] = { ...copy[bi + 1]!, start: tt };
- return copy;
- });
+ const moveBoundary = useCallback(
+ (bi: number, time: number) =>
+ setSegments((segs) => {
+ if (creating.current || bi < 0 || bi >= segs.length - 1) return segs;
+ // bi is in [0, length-2] here, so bi and bi+1 both index existing segments.
+ const lo = segs[bi]!.start + MIN_SEG;
+ const hi = segs[bi + 1]!.end - MIN_SEG;
+ const tt = Math.max(lo, Math.min(hi, time));
+ const copy = [...segs];
+ copy[bi] = { ...copy[bi]!, end: tt };
+ copy[bi + 1] = { ...copy[bi + 1]!, start: tt };
+ return copy;
+ }),
+ [],
+ );
const deleteBoundary = (bi: number) =>
setSegments((segs) => {
if (creating.current || segs.length < 2) return segs;
@@ -205,7 +211,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
};
- }, [drag, timeAt]);
+ }, [drag, timeAt, seek, moveBoundary]);
// --- crop drag ---
const overlayRef = useRef(null);
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/WatchPage.tsx b/frontend/src/components/WatchPage.tsx
index 325d6ad9..f3ae6f37 100644
--- a/frontend/src/components/WatchPage.tsx
+++ b/frontend/src/components/WatchPage.tsx
@@ -4,6 +4,7 @@ import { Button } from "./ui/Button";
import { formatBytes } from "../lib/format";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { LS, readJSON, writeJSON } from "../lib/storage";
+import { send } from "../lib/http";
// Standalone, login-free video page for a shared /watch/ link. Rendered OUTSIDE the app
// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny
@@ -169,7 +170,7 @@ export default function WatchPage() {
setStatus("gone");
return;
}
- fetch(`/api/public/watch/${encodeURIComponent(token)}`)
+ send(`/api/public/watch/${encodeURIComponent(token)}`)
.then((r) => {
// Only a 404 says the link is gone (unknown, expired, revoked, or its file removed). Anything
// else — a 5xx while the server restarts, a dropped connection — is a failure worth retrying,
@@ -197,7 +198,7 @@ export default function WatchPage() {
setUnlocking(true);
setError(null);
try {
- const r = await fetch(`/api/public/watch/${encodeURIComponent(token)}/unlock`, {
+ const r = await send(`/api/public/watch/${encodeURIComponent(token)}/unlock`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
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 078f22b1..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);
@@ -51,9 +28,19 @@ export function WizardProvider({ children }: { children: ReactNode }) {
// First-login onboarding: prompt the user to connect YouTube (and resume the flow after each
// consent redirect). Re-checked when the account or its grants change.
+ // Keyed on the fields that matter, NOT the `me` object: the 60 s refetch hands out a new object,
+ // and re-running on that would reopen a wizard the user just closed. `can_write` is read by nothing
+ // here but is a trigger: a completed write grant resumes the flow.
+ const meId = me?.id;
+ const canRead = me?.can_read;
+ const canWrite = me?.can_write;
+ const isDemo = me?.is_demo;
+ const googleEnabled = me?.google_enabled;
useEffect(() => {
- if (me && shouldAutoOpenOnboarding(me)) setOpen(true);
- }, [me?.id, me?.can_read, me?.can_write]);
+ if (meId == null || canRead == null) return;
+ const account = { can_read: canRead, is_demo: isDemo, google_enabled: googleEnabled };
+ if (shouldAutoOpenOnboarding(account)) setOpen(true);
+ }, [meId, canRead, canWrite, isDemo, googleEnabled]);
const actions = useMemo(
() => ({ openWizard, closeWizard }),
@@ -61,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 12b8124e..92bc64e8 100644
--- a/frontend/src/components/YearFilter.tsx
+++ b/frontend/src/components/YearFilter.tsx
@@ -3,8 +3,9 @@ import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { ChevronDown } from "lucide-react";
import { qk } from "../lib/queryKeys";
-import { api, type FeedFilters } from "../lib/api";
-import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
+import { api, type FeedFilters, type YearCount } from "../lib/api";
+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";
@@ -21,6 +22,8 @@ import { useDebounced } from "../lib/useDebounced";
// own category.
const QUICK = 6; // recent-year chips shown before the "More…" overflow
+// One shared empty list while the counts load: a fresh `[]` per render made the memos below recompute.
+const NO_YEARS: YearCount[] = [];
export default function YearFilter({
filters,
@@ -51,7 +54,7 @@ export default function YearFilter({
staleTime: 30_000,
placeholderData: keepPreviousData,
});
- const years = yearsQuery.data?.years ?? [];
+ const years = yearsQuery.data?.years ?? NO_YEARS;
const selected = filters.year;
// A year pick and the Upload-date control are mutually exclusive publish-date narrowings.
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 b8b831db..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, 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) && (
-
-
-
- )}
- {c.stored_videos.toLocaleString()}
-
- );
-}
-
-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/clientLogWiring.dom.test.tsx b/frontend/src/components/clientLogWiring.dom.test.tsx
new file mode 100644
index 00000000..4d7daeaa
--- /dev/null
+++ b/frontend/src/components/clientLogWiring.dom.test.tsx
@@ -0,0 +1,82 @@
+// @vitest-environment jsdom
+//
+// E22 S4: what the user was SHOWN — a toast, the blocking error dialog, the crash screen — and what
+// failed behind the scenes (a query, a mutation) all land in the client log. Each producer is its
+// own module, so each is asserted here through the real thing, not a mock of it.
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, render } from "@testing-library/react";
+
+import { clear, events } from "../lib/log";
+import { notify } from "../lib/notifications";
+import { reportError } from "../lib/errorDialog";
+import { createQueryClient } from "../lib/queryClient";
+import ErrorBoundary from "./ErrorBoundary";
+import { HttpError } from "../lib/api/core";
+
+afterEach(() => {
+ cleanup();
+ clear();
+ vi.restoreAllMocks();
+});
+
+const logged = (code: string) => events().filter((e) => e.event === code);
+
+describe("client log wiring", () => {
+ it("records every notification at its level", () => {
+ notify({ level: "success", title: "Saved", message: "Playlist renamed" });
+ notify({ level: "error", message: "Could not reach someone@mail.example" });
+ expect(logged("ui.notify").map((e) => [e.level, e.msg, e.attrs])).toEqual([
+ ["INFO", "Playlist renamed", { level: "success", title: "Saved" }],
+ ["ERROR", "Could not reach [email]", { level: "error" }],
+ ]);
+ });
+
+ it("records the blocking error dialog", () => {
+ reportError("The server refused", "Couldn't save");
+ expect(logged("ui.error_dialog")).toMatchObject([
+ { level: "ERROR", msg: "The server refused", attrs: { title: "Couldn't save" } },
+ ]);
+ });
+
+ it("records a failed query by its resource only — the rest of the key is filter state", async () => {
+ const client = createQueryClient();
+ await client
+ .fetchQuery({
+ queryKey: ["videos", { tags: ["private-tag"] }],
+ queryFn: () => Promise.reject(new HttpError(503, "down")),
+ })
+ .catch(() => {});
+ expect(logged("query.failed")).toMatchObject([
+ { level: "WARNING", attrs: { key: "videos", status: 503, message: "down" } },
+ ]);
+ expect(JSON.stringify(events())).not.toContain("private-tag");
+ });
+
+ it("records a failed mutation", async () => {
+ const client = createQueryClient();
+ const mutation = client.getMutationCache().build(client, {
+ mutationKey: ["playlists", 42],
+ mutationFn: () => Promise.reject(new HttpError(409, "conflict")),
+ meta: { errorShownInline: true },
+ });
+ await mutation.execute(undefined).catch(() => {});
+ expect(logged("mutation.failed")).toMatchObject([
+ { level: "WARNING", attrs: { key: "playlists", status: 409 } },
+ ]);
+ });
+
+ it("records a render crash with its component stack", () => {
+ vi.spyOn(console, "error").mockImplementation(() => {}); // React reports the crash itself too
+ const Boom = () => {
+ throw new Error("render exploded");
+ };
+ render(
+
+
+ ,
+ );
+ const [crash] = logged("ui.crash");
+ expect(crash).toMatchObject({ level: "CRITICAL", attrs: { message: "render exploded" } });
+ expect(String(crash!.attrs!.component_stack)).toContain("Boom");
+ });
+});
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 23699847..9ae38046 100644
--- a/frontend/src/components/downloadRow.tsx
+++ b/frontend/src/components/downloadRow.tsx
@@ -7,9 +7,17 @@ import { useLiveQuery } from "../lib/useLiveQuery";
import { api, type DownloadJob } from "../lib/api";
import { notify } from "../lib/notifications";
import { copyText } from "../lib/clipboard";
-import { formatBytes, formatDuration, formatEta, formatSpeed, relativeTime } from "../lib/format";
+import {
+ formatBytes,
+ formatDateTime,
+ formatDuration,
+ formatEta,
+ formatSpeed,
+ relativeTime,
+} 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,
@@ -97,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 } {
@@ -250,7 +254,7 @@ export function DownloadRow({
{job.status === "done" && job.created_at ? (
· {relativeTime(job.created_at)}
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/legal/LegalLayout.tsx b/frontend/src/components/legal/LegalLayout.tsx
index 2d97dfe0..5b0d30bd 100644
--- a/frontend/src/components/legal/LegalLayout.tsx
+++ b/frontend/src/components/legal/LegalLayout.tsx
@@ -1,17 +1,28 @@
// Shared shell for the public, login-free legal pages (/privacy, /terms). These must be
// reachable unauthenticated — Google's OAuth consent screen links to them and reviewers
// fetch them directly — so they render outside the authenticated App tree (see main.tsx),
-// with no react-query provider: the operator contact is fetched with a plain fetch below.
-import { useEffect, useState } from "react";
+// with no react-query provider: the operator contact is fetched with a bare `send` below.
+//
+// Both pages exist in Hungarian and English, like the rest of the UI. The prose is a document, not
+// a set of UI strings, so each language's body is its own component (see PrivacyPolicy/Terms) and
+// `legalBodies.contract.dom.test.tsx` holds the two to the same sections; the chrome around them is i18n.
+import { useEffect, useState, type ReactNode } from "react";
+import { useTranslation } from "react-i18next";
-const LAST_UPDATED = "June 14, 2026";
+import { setLanguage, type LangCode } from "../../i18n";
+import { formatDate } from "../../lib/format";
+import { send } from "../../lib/http";
+import LanguageSwitcher from "../LanguageSwitcher";
+
+// The day the text last changed materially — bump it with every edit of either body.
+const LAST_UPDATED = "2026-09-17";
// Contact shown on the legal pages: the instance operator's configured admin email, from the
// public /auth/config. One shared fetch across the page; null when the operator set none.
let contactPromise: Promise | null = null;
function fetchOperatorContact(): Promise {
if (!contactPromise) {
- contactPromise = fetch("/auth/config")
+ contactPromise = send("/auth/config")
.then((r) => (r.ok ? r.json() : null))
.then((c) => (c?.operator_contact as string | undefined) ?? null)
.catch(() => null);
@@ -33,8 +44,9 @@ function useOperatorContact(): string | null {
// The operator's contact rendered as a mailto link, or a neutral fallback phrase when unset.
export function ContactEmail() {
+ const { t } = useTranslation();
const email = useOperatorContact();
- if (!email) return <>your instance's administrator>;
+ if (!email) return <>{t("legal.operatorFallback")}>;
return (
{email}
@@ -42,7 +54,7 @@ export function ContactEmail() {
);
}
-export function LegalLink({ href, children }: { href: string; children: React.ReactNode }) {
+export function LegalLink({ href, children }: { href: string; children: ReactNode }) {
return (
{children}
@@ -50,38 +62,45 @@ export function LegalLink({ href, children }: { href: string; children: React.Re
);
}
-export default function LegalLayout({
- title,
- children,
-}: {
- title: string;
- children: React.ReactNode;
-}) {
+export default function LegalLayout({ title, children }: { title: string; children: ReactNode }) {
+ const { t, i18n } = useTranslation();
const contact = useOperatorContact();
+ // A link to a section (`/privacy#diagnostics`): the browser tries to scroll to the fragment before
+ // this lazily loaded page has rendered it, so jump there once the section exists.
+ useEffect(() => {
+ const id = decodeURIComponent(window.location.hash.slice(1));
+ if (id) document.getElementById(id)?.scrollIntoView();
+ }, []);
return (
@@ -90,6 +109,12 @@ export default function LegalLayout({
);
}
-export function H2({ children }: { children: React.ReactNode }) {
- return
{children}
;
+/** A section heading. `id` is required: it is the anchor a link can point at (Settings →
+ * Diagnosztika links `/privacy#diagnostics`), and what holds the two languages to the same sections. */
+export function H2({ id, children }: { id: string; children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
}
diff --git a/frontend/src/components/legal/PrivacyPolicy.tsx b/frontend/src/components/legal/PrivacyPolicy.tsx
index a9ca2d86..8384d6da 100644
--- a/frontend/src/components/legal/PrivacyPolicy.tsx
+++ b/frontend/src/components/legal/PrivacyPolicy.tsx
@@ -1,8 +1,26 @@
+import { useTranslation } from "react-i18next";
+
import LegalLayout, { ContactEmail, H2, LegalLink } from "./LegalLayout";
+import { useHungarian } from "./useHungarian";
+
+// Bold lead-in for a list item.
+const Lead = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
export default function PrivacyPolicy() {
+ const { t } = useTranslation();
+ const hu = useHungarian();
return (
-
+
+ {hu ? : }
+
+ );
+}
+
+export function PrivacyEn() {
+ return (
+ <>
Siftlode is a personal, non-commercial project operated by an individual in Hungary ("we",
"us"). It lets you view and organize your own YouTube subscriptions as a filterable feed.
@@ -10,22 +28,23 @@ export default function PrivacyPolicy() {
By using Siftlode you agree to this policy.
-
What we access
-
Siftlode only accesses data you explicitly authorize, in two separate steps:
+
What we access
+
Siftlode only accesses data you explicitly authorize:
- Sign-in (always): your basic Google profile —
- name, email address, profile picture, and Google account identifier — used solely to
- create and identify your account.
+ Sign-in (always): your email address — and, when you sign in with Google,
+ your basic Google profile (name, profile picture, Google account identifier) — used solely
+ to create and identify your account. Signing in with an email and password stores only a
+ one-way hash of the password.
- YouTube read (optional, opt-in): with the{" "}
+ YouTube read (optional, opt-in): with the{" "}
youtube.readonly scope, your list of YouTube
subscriptions and the public metadata of those channels and their videos (titles,
thumbnails, durations, view counts, publish dates), used to build your feed.
- YouTube manage (optional, opt-in): with the{" "}
+ YouTube manage (optional, opt-in): with the{" "}
youtube scope, the ability to unsubscribe from a channel
on YouTube — performed only when you click to do so.
Your data is used exclusively to provide Siftlode's features to you: building and displaying
your personalized subscription feed, and carrying out actions (such as unsubscribing) that
@@ -43,7 +62,42 @@ export default function PrivacyPolicy() {
app.
-
Limited Use disclosure
+
Server logs
+
+ To keep the service running and to find faults, the server records each request it answers:
+ which part of the app was called (for example /api/feed,
+ never the search terms or other values in the address), whether it succeeded, how long it
+ took, and which account made it. Warnings and errors are recorded too. Your IP address is
+ not stored in these records. They are kept for the retention period the operator sets — 7
+ days by default, at most 90 — and then deleted automatically.
+
+
+
Diagnostic logging (optional, with your consent)
+
+ In Settings → Diagnostics you can see the log your own browser tab keeps. By default it
+ stays in that tab and is gone when the page reloads. If you choose the Detailed{" "}
+ or Full level and confirm, for the next 24 hours:
+
+
+
+ your browser also records which buttons, filters and player controls you use and the
+ values in the address bar — and, at Full, the text you type into search;
+
+
+ that log is sent to the server and stored with your account alongside the server's more
+ detailed record of your requests, for the same retention period as the server logs;
+
+
+ passwords, tokens, email addresses and the content of your messages are never included, at
+ any level.
+
+
+
+ After 24 hours the level returns to the default by itself, and you can return to it earlier
+ at any time. It is used only to diagnose problems with Siftlode.
+
+
+
Limited Use disclosure
Siftlode's use and transfer of information received from Google APIs to any other app will
adhere to the{" "}
@@ -55,32 +109,35 @@ export default function PrivacyPolicy() {
control), and do not use it for advertising.
-
Storage & security
+
Storage & security
Data is stored in a private PostgreSQL database on a server we control, accessible only to
- the operator. Google OAuth refresh tokens are encrypted at rest. Access is restricted to the
- emails explicitly invited to the app. No system is perfectly secure, but we take reasonable
- measures to protect your data.
+ the operator. Google OAuth refresh tokens are encrypted at rest. Private messages between
+ users are end-to-end encrypted in the browser: the server stores only the encrypted text and
+ cannot read it. Access is restricted to the email addresses admitted to the app. No system
+ is perfectly secure, but we take reasonable measures to protect your data.
-
Retention & deletion
+
Retention & deletion
- We keep your data while your account is active. You can revoke Siftlode's access to your
- Google data at any time at{" "}
+ We keep your data while your account is active. You can delete your account and its data
+ yourself at any time in Settings → Account, which also removes the stored log records of
+ your account. You can revoke Siftlode's access to your Google data at{" "}
myaccount.google.com/permissions
- ; once revoked, the stored tokens can no longer be used. To have your account and associated
- data deleted, contact .
+ ; once revoked, the stored tokens can no longer be used. For anything else, contact{" "}
+ .
-
Cookies
+
Cookies & browser storage
- Siftlode sets a single, signed session cookie to keep you logged in. It is not used for
- tracking or advertising, and there are no third-party analytics cookies.
+ Siftlode sets a single, signed session cookie to keep you logged in, and keeps your display
+ preferences in your browser's local storage. Neither is used for tracking or advertising,
+ and there are no third-party analytics cookies.
-
YouTube & Google
+
YouTube & Google
Siftlode uses YouTube API Services. By using Siftlode you also agree to the{" "}
YouTube Terms of Service, and
@@ -88,16 +145,162 @@ export default function PrivacyPolicy() {
Google Privacy Policy.
-
Changes
+
Changes
We may update this policy; material changes will be reflected by the "Last updated" date
above. Continued use after a change constitutes acceptance.
-
Contact
+
Contact
Questions about this policy or your data: contact .
+ A Siftlode egy magánszemély által üzemeltetett, nem kereskedelmi célú személyes projekt
+ Magyarországon („mi”). Lehetővé teszi, hogy a saját YouTube-feliratkozásaidat szűrhető
+ hírfolyamként nézd és rendszerezd. Ez a tájékoztató leírja, milyen adatokhoz fér hozzá a
+ Siftlode, mire használja őket, és milyen lehetőségeid vannak. A Siftlode használatával
+ elfogadod ezt a tájékoztatót.
+
+
+
Mihez férünk hozzá
+
A Siftlode csak azokhoz az adatokhoz fér hozzá, amelyekhez kifejezetten hozzájárulsz:
+
+
+ Bejelentkezés (mindig): az e-mail-címed, Google-bejelentkezésnél pedig az
+ alapvető Google-profilod is (név, profilkép, Google-fiókazonosító), kizárólag a fiókod
+ létrehozásához és azonosításához. E-mail-címmel és jelszóval bejelentkezve csak a jelszó
+ egyirányú lenyomatát tároljuk.
+
+
+ YouTube olvasás (nem kötelező, külön engedélyezed): a{" "}
+ youtube.readonly jogosultsággal a YouTube-feliratkozásaid
+ listája, valamint ezeknek a csatornáknak és videóiknak a nyilvános adatai (cím, bélyegkép,
+ hossz, megtekintésszám, megjelenés dátuma) a hírfolyamod felépítéséhez.
+
+
+ YouTube kezelés (nem kötelező, külön engedélyezed): a{" "}
+ youtube jogosultsággal leiratkozhatsz egy csatornáról a
+ YouTube-on — csak akkor történik meg, ha erre kattintasz.
+
+
+
+ A Siftlode puszta bejelentkezéssel is használható, és a YouTube-jogosultságokat egymástól
+ függetlenül adhatod meg vagy utasíthatod el.
+
+
+
Mire használjuk
+
+ Az adataidat kizárólag a Siftlode funkcióinak nyújtására használjuk: a személyre szabott
+ hírfolyamod felépítésére és megjelenítésére, valamint az általad kezdeményezett műveletek
+ (például leiratkozás) végrehajtására. Nem használjuk hirdetésre, profilalkotásra vagy az
+ alkalmazástól független célra.
+
+
+
Szervernapló
+
+ A szolgáltatás működtetéséhez és a hibák felderítéséhez a szerver minden megválaszolt
+ kérésről feljegyzi: az alkalmazás melyik részét hívták (például{" "}
+ /api/feed, soha nem a keresőszót vagy más értéket a
+ címből), sikerült-e, mennyi ideig tartott, és melyik fiók indította. A figyelmeztetések és a
+ hibák is bekerülnek. Az IP-címedet ezek a feljegyzések nem tárolják. Az üzemeltető által
+ beállított ideig őrizzük őket — alapból 7 napig, legfeljebb 90 napig —, utána automatikusan
+ törlődnek.
+
+
+
Diagnosztikai naplózás (nem kötelező, a beleegyezéseddel)
+
+ A Beállítások → Diagnosztika fülön láthatod a saját böngészőlapod naplóját. Alapból ez csak
+ abban a lapban marad, és az oldal újratöltésekor törlődik. Ha a Részletes vagy
+ a Teljes szintet választod és megerősíted, a következő 24 órában:
+
+
+
+ a böngésződ azt is rögzíti, melyik gombot, szűrőt és lejátszóvezérlőt használod, és a
+ címsor paramétereinek értékét — a Teljes szinten a keresőbe beírt szöveget is;
+
+
+ ezt a naplót elküldi a szervernek, amely a fiókodhoz tárolja, a kéréseid részletesebb
+ szerveroldali naplójával együtt, a szervernapló megőrzési idejéig;
+
+
jelszó, token, e-mail-cím és az üzeneteid tartalma egyik szinten sem kerül bele.
+
+
+ 24 óra múlva a szint magától visszaáll az alapra, és bármikor korábban is visszaállíthatod.
+ Kizárólag a Siftlode hibáinak felderítésére használjuk.
+
+
+
Korlátozott felhasználás (Limited Use)
+
+ A Siftlode a Google API-kból kapott adatok felhasználása és más alkalmazásnak történő
+ átadása során betartja a{" "}
+
+ Google API Services User Data Policy
+ {" "}
+ előírásait, beleértve a Limited Use követelményeit. Nem adjuk el az adataidat, nem adjuk át
+ harmadik félnek (kivéve, ami az alkalmazás működtetéséhez szükséges, például az általunk
+ kezelt tárhely-infrastruktúra), és nem használjuk hirdetésre.
+
+
+
Tárolás és biztonság
+
+ Az adatokat egy általunk kezelt szerveren, privát PostgreSQL-adatbázisban tároljuk, amelyhez
+ csak az üzemeltető fér hozzá. A Google OAuth frissítő tokeneket titkosítva tároljuk. A
+ felhasználók közötti privát üzenetek végponttól végpontig titkosítottak a böngészőben: a
+ szerver csak a titkosított szöveget tárolja, és nem tudja elolvasni. Csak az alkalmazásba
+ beengedett e-mail-címek férnek hozzá. Egyetlen rendszer sem tökéletesen biztonságos, de
+ észszerű intézkedésekkel védjük az adataidat.
+
+
+
Megőrzés és törlés
+
+ Az adataidat addig őrizzük, amíg a fiókod aktív. A fiókodat és az adataidat bármikor magad
+ is törölheted a Beállítások → Fiók fülön; ez a fiókodhoz tárolt naplófeljegyzéseket is
+ törli. A Siftlode hozzáférését a Google-adataidhoz a{" "}
+
+ myaccount.google.com/permissions
+ {" "}
+ oldalon vonhatod vissza; ezután a tárolt tokenek már nem használhatók. Minden más ügyben
+ fordulj ide: .
+
+
+
Sütik és böngészőtár
+
+ A Siftlode egyetlen aláírt munkamenet-sütit használ, hogy bejelentkezve maradj, a
+ megjelenítési beállításaidat pedig a böngésződ helyi tárában tartja. Egyiket sem használjuk
+ követésre vagy hirdetésre, és nincsenek harmadik féltől származó analitikai sütik.
+
+
+
YouTube és Google
+
+ A Siftlode a YouTube API szolgáltatásait használja. A Siftlode használatával elfogadod a{" "}
+
+ YouTube Általános Szerződési Feltételeit
+
+ , a Google-adataid kezelésére pedig a{" "}
+
+ Google Adatvédelmi irányelvei
+ {" "}
+ vonatkoznak.
+
+
+
Változások
+
+ Ezt a tájékoztatót módosíthatjuk; a lényeges változásokat a fenti „Utoljára frissítve” dátum
+ jelzi. Ha a változás után is használod a szolgáltatást, azzal elfogadod.
+
+
+
Kapcsolat
+
+ Kérdés a tájékoztatóval vagy az adataiddal kapcsolatban: .
+
+ >
);
}
diff --git a/frontend/src/components/legal/Terms.tsx b/frontend/src/components/legal/Terms.tsx
index f4875139..1affa646 100644
--- a/frontend/src/components/legal/Terms.tsx
+++ b/frontend/src/components/legal/Terms.tsx
@@ -1,30 +1,41 @@
+import { useTranslation } from "react-i18next";
+
import LegalLayout, { ContactEmail, H2, LegalLink } from "./LegalLayout";
+import { useHungarian } from "./useHungarian";
export default function Terms() {
+ const { t } = useTranslation();
+ const hu = useHungarian();
return (
-
+ {hu ? : }
+ );
+}
+
+export function TermsEn() {
+ return (
+ <>
Siftlode is a personal, non-commercial project operated by an individual in Hungary. It
provides a filterable view of your own YouTube subscriptions. By accessing or using
Siftlode, you agree to these Terms. If you do not agree, do not use the service.
-
The service
+
The service
Siftlode is offered on an invite-only basis, free of charge, and "as is", without warranties
of any kind. It is a hobby project: features may change, and the service may be slowed,
interrupted, or discontinued at any time without notice.
-
Your account & acceptable use
+
Your account & acceptable use
- You sign in with your Google account and may only access Siftlode with an email that has
- been invited. You agree to use the service lawfully, not to abuse, disrupt, probe, or
- overload it, and not to attempt to access other users' data. We may suspend or revoke access
- at our discretion, for example in case of misuse.
+ You sign in with your Google account or with an email address and password, and may only
+ access Siftlode with an email that has been admitted. You agree to use the service lawfully,
+ not to abuse, disrupt, probe, or overload it, and not to attempt to access other users'
+ data. We may suspend or revoke access at our discretion, for example in case of misuse.
-
Third-party services
+
Third-party services
Siftlode uses YouTube API Services. By using Siftlode you agree to be bound by the{" "}
YouTube Terms of Service. Your
@@ -36,7 +47,7 @@ export default function Terms() {
Google Privacy Policy.
-
No warranty & limitation of liability
+
No warranty & limitation of liability
To the maximum extent permitted by law, the service is provided without warranty, and the
operator is not liable for any indirect, incidental, or consequential damages, or for any
@@ -44,22 +55,94 @@ export default function Terms() {
to stop using the service and revoke its access to your account.
-
Governing law
+
Governing law
These Terms are governed by the laws of Hungary and applicable European Union law, without
regard to conflict-of-law rules.
-
Changes
+
Changes
We may update these Terms; the "Last updated" date above reflects the latest version.
Continued use after a change constitutes acceptance.
+ A Siftlode egy magánszemély által üzemeltetett, nem kereskedelmi célú személyes projekt
+ Magyarországon. A saját YouTube-feliratkozásaid szűrhető nézetét nyújtja. A Siftlode
+ elérésével vagy használatával elfogadod ezeket a feltételeket. Ha nem fogadod el őket, ne
+ használd a szolgáltatást.
+
+
+
A szolgáltatás
+
+ A Siftlode csak meghívással, ingyenesen és „ahogy van” alapon érhető el, mindenféle
+ szavatosság nélkül. Hobbiprojekt: a funkciói változhatnak, és a szolgáltatás bármikor,
+ előzetes értesítés nélkül lelassulhat, szünetelhet vagy megszűnhet.
+
+
+
A fiókod és az elfogadható használat
+
+ Google-fiókkal vagy e-mail-címmel és jelszóval jelentkezel be, és csak beengedett
+ e-mail-címmel használhatod a Siftlode-ot. Vállalod, hogy a szolgáltatást jogszerűen
+ használod, nem éled vissza, nem zavarod meg, nem próbálod ki a gyenge pontjait és nem
+ terheled túl, és nem próbálsz más felhasználók adataihoz hozzáférni. A hozzáférést saját
+ belátásunk szerint felfüggeszthetjük vagy visszavonhatjuk, például visszaélés esetén.
+
+
+
Harmadik fél szolgáltatásai
+
+ A Siftlode a YouTube API szolgáltatásait használja. A Siftlode használatával elfogadod a{" "}
+
+ YouTube Általános Szerződési Feltételeit
+
+ . Az adataidat az{" "}
+
+ Adatvédelmi irányelvekben
+ {" "}
+ leírtak szerint kezeljük, a Google-adatokra pedig a{" "}
+
+ Google Adatvédelmi irányelvei
+ {" "}
+ is vonatkoznak.
+
+
+
Szavatosság kizárása és a felelősség korlátozása
+
+ A jogszabályok által megengedett legteljesebb mértékben a szolgáltatás szavatosság nélkül
+ érhető el, és az üzemeltető nem felel a Siftlode használatából eredő közvetett, járulékos
+ vagy következményes károkért, sem adatvesztésért. Ha elégedetlen vagy, az egyetlen
+ lehetőséged, hogy abbahagyod a szolgáltatás használatát, és visszavonod a hozzáférését a
+ fiókodhoz.
+
+
+
Irányadó jog
+
+ Ezekre a feltételekre Magyarország jogszabályai és az alkalmazandó európai uniós jog
+ irányadók, a kollíziós szabályok figyelmen kívül hagyásával.
+
+
+
Változások
+
+ Ezeket a feltételeket módosíthatjuk; a fenti „Utoljára frissítve” dátum a legfrissebb
+ változatot jelzi. Ha a változás után is használod a szolgáltatást, azzal elfogadod.
+
+
+
Kapcsolat
+
+ Kérdés esetén: .
+
+ >
);
}
diff --git a/frontend/src/components/legal/legalBodies.contract.dom.test.tsx b/frontend/src/components/legal/legalBodies.contract.dom.test.tsx
new file mode 100644
index 00000000..0e3b20e7
--- /dev/null
+++ b/frontend/src/components/legal/legalBodies.contract.dom.test.tsx
@@ -0,0 +1,36 @@
+// @vitest-environment jsdom
+//
+// The legal pages are written out once per language (a document, not UI strings — see LegalLayout).
+// What keeps the two from drifting apart is this: both languages must have the SAME sections, in the
+// same order, by anchor — so a section added to one (E22 S5 added the log sections) cannot be
+// missing from the other, and a link to `/privacy#diagnostics` lands in either language.
+import { describe, expect, it, vi } from "vitest";
+import { cleanup, render } from "@testing-library/react";
+import type { ComponentType } from "react";
+
+vi.mock("../../lib/http", () => ({ send: () => Promise.resolve({ ok: false }) }));
+
+import { PrivacyEn, PrivacyHu } from "./PrivacyPolicy";
+import { TermsEn, TermsHu } from "./Terms";
+
+const sections = (Body: ComponentType) => {
+ const { container } = render(
);
+ const ids = [...container.querySelectorAll("h2")].map((h) => h.id);
+ cleanup();
+ return ids;
+};
+
+describe("legal pages", () => {
+ it.each([
+ ["privacy", PrivacyEn, PrivacyHu],
+ ["terms", TermsEn, TermsHu],
+ ])("%s has the same sections in Hungarian and English", (_name, En, Hu) => {
+ const en = sections(En);
+ expect(en.every(Boolean)).toBe(true);
+ expect(sections(Hu)).toEqual(en);
+ });
+
+ it("the privacy policy has the section the diagnostics consent links to", () => {
+ expect(sections(PrivacyEn)).toContain("diagnostics");
+ });
+});
diff --git a/frontend/src/components/legal/useHungarian.ts b/frontend/src/components/legal/useHungarian.ts
new file mode 100644
index 00000000..abd4965b
--- /dev/null
+++ b/frontend/src/components/legal/useHungarian.ts
@@ -0,0 +1,7 @@
+import { useTranslation } from "react-i18next";
+
+/** Whether a legal page renders its Hungarian body (see LegalLayout). */
+export function useHungarian(): boolean {
+ const { i18n } = useTranslation();
+ return i18n.language?.startsWith("hu") ?? false;
+}
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 329e2491..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
@@ -21,6 +22,9 @@ export interface FilterSelectOption {
export interface FilterSelectProps {
/** Visible group name shown as the trigger prefix (`Label: value`) and the accessible name. */
label: string;
+ /** Show only the value on the trigger — for a form row whose own caption already names the field.
+ * The label still names the control and its menu for assistive tech. */
+ hideLabel?: boolean;
options: readonly FilterSelectOption[];
selected: string[];
onChange: (next: string[]) => void;
@@ -39,6 +43,7 @@ export default function FilterSelect({
onChange,
multiple = false,
requireOne = true,
+ hideLabel = false,
testId,
}: FilterSelectProps) {
const [open, setOpen] = useState(false);
@@ -47,6 +52,14 @@ export default function FilterSelect({
onOpenChange: setOpen,
mode: "menu",
placement: "bottom-start",
+ // Single choice: opening lands on the option in use, not the first row. A multi-select toggle
+ // list has no one "current" row, so it keeps the default.
+ selectedIndex: multiple
+ ? null
+ : Math.max(
+ 0,
+ options.findIndex((o) => o.value === selected[0]),
+ ),
});
const labelOf = (v: string) => options.find((o) => o.value === v)?.label ?? v;
@@ -88,7 +101,7 @@ export default function FilterSelect({
open ? "border-accent" : "border-border"
}`}
>
- {label}:
+ {!hideLabel && {label}:}
{summary}
diff --git a/frontend/src/components/ui/ItemCount.dom.test.tsx b/frontend/src/components/ui/ItemCount.dom.test.tsx
index 5295edb4..8fd0c962 100644
--- a/frontend/src/components/ui/ItemCount.dom.test.tsx
+++ b/frontend/src/components/ui/ItemCount.dom.test.tsx
@@ -8,11 +8,15 @@ import { render, screen, cleanup } from "@testing-library/react";
import "../../i18n"; // side-effect: initialise the shared i18next instance
import i18n from "i18next";
import ItemCount from "./ItemCount";
+import { formatNumber } from "../../lib/format";
beforeAll(async () => {
await i18n.changeLanguage("en");
});
-afterEach(cleanup);
+afterEach(async () => {
+ cleanup();
+ await i18n.changeLanguage("en"); // a case that switches language must not leak it to the next
+});
const text = () => screen.getByTestId("item-count").textContent;
@@ -24,17 +28,31 @@ describe("ItemCount", () => {
it("pluralises and thousands-separates a normal count", () => {
render();
- // Separators come from toLocaleString (runtime-locale dependent) — compare against it, not a fixed
- // en-US string, so the test asserts ItemCount's formatting path rather than the CI locale.
- expect(text()).toBe(`${(192045).toLocaleString()} items`);
+ // Separators come from the shared formatNumber — compare against it, not a fixed en-US string,
+ // so the test asserts ItemCount's formatting path rather than the CI locale.
+ expect(text()).toBe(`${formatNumber(192045)} items`);
+ });
+
+ // The pill used `count.toLocaleString()`, which groups in the BROWSER's locale: a Hungarian UI on
+ // an English browser showed "192,045" instead of "192 045". The title carries the pure number (no
+ // plural string), and both languages are asserted — comparing against the runtime's own default
+ // would pass under the old bug on a machine whose locale happens to match the UI language.
+ it("groups in the UI language, not the browser's", async () => {
+ const title = () => screen.getByTestId("item-count").getAttribute("title");
+ render();
+ const asEnglish = title();
+ cleanup();
+ await i18n.changeLanguage("hu");
+ render();
+ expect(asEnglish).toBe(new Intl.NumberFormat("en").format(192045));
+ expect(title()).toBe(new Intl.NumberFormat("hu").format(192045));
+ expect(title()).not.toBe(asEnglish);
});
it("collapses a count of a million or more to 1M+, with the exact figure in the tooltip", () => {
render();
expect(text()).toBe("1M+ items"); // the "1M+" collapse is locale-independent
- expect(screen.getByTestId("item-count").getAttribute("title")).toBe(
- (1_000_000).toLocaleString(),
- );
+ expect(screen.getByTestId("item-count").getAttribute("title")).toBe(formatNumber(1_000_000));
});
it("shows the icon-only fixed box (empty label) while the count is undefined", () => {
diff --git a/frontend/src/components/ui/ItemCount.tsx b/frontend/src/components/ui/ItemCount.tsx
index 58f07710..f185ce5d 100644
--- a/frontend/src/components/ui/ItemCount.tsx
+++ b/frontend/src/components/ui/ItemCount.tsx
@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next";
import { Layers } from "lucide-react";
+import { formatNumber } from "../../lib/format";
// At a million and above we stop printing the exact figure — six digits is the readable ceiling, so
// the pill shows "1M+" and the tooltip carries the precise number.
@@ -20,12 +21,12 @@ export default function ItemCount({ count }: { count?: number }) {
? ""
: t("common.itemCount", {
count,
- formattedCount: count >= OVERFLOW_AT ? "1M+" : count.toLocaleString(),
+ formattedCount: count >= OVERFLOW_AT ? "1M+" : formatNumber(count),
});
return (
diff --git a/frontend/src/components/ui/Modal.fullscreen.dom.test.tsx b/frontend/src/components/ui/Modal.fullscreen.dom.test.tsx
index 816fd154..ac1bb3fe 100644
--- a/frontend/src/components/ui/Modal.fullscreen.dom.test.tsx
+++ b/frontend/src/components/ui/Modal.fullscreen.dom.test.tsx
@@ -31,7 +31,7 @@ describe("a dialog open while fullscreen is left", () => {
it("keeps what the viewer had typed in it, and the focus inside it", async () => {
const stage = document.createElement("div");
document.body.appendChild(stage);
- await setFullscreen(stage);
+ setFullscreen(stage);
render(
{}}>
@@ -42,7 +42,7 @@ describe("a dialog open while fullscreen is left", () => {
field.focus();
expect(stage.contains(field)).toBe(true);
- await setFullscreen(null); // the browser's own Esc leaves fullscreen; the dialog stays open
+ setFullscreen(null); // the browser's own Esc leaves fullscreen; the dialog stays open
const after = screen.getByLabelText("name") as HTMLInputElement;
expect(after.value).toBe("my cut");
@@ -78,7 +78,7 @@ describe("a dialog with an autoFocus child", () => {
const field = screen.getByLabelText("query");
expect(document.activeElement).toBe(field);
- await setFullscreen(stage); // the player goes fullscreen under the open dialog
+ setFullscreen(stage); // the player goes fullscreen under the open dialog
expect(stage.contains(field)).toBe(true);
expect(document.activeElement).toBe(field);
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/QueryState.dom.test.tsx b/frontend/src/components/ui/QueryState.dom.test.tsx
index 089e147b..0b18581c 100644
--- a/frontend/src/components/ui/QueryState.dom.test.tsx
+++ b/frontend/src/components/ui/QueryState.dom.test.tsx
@@ -8,6 +8,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { LoadingState } from "./QueryState";
+// The label is not under test; without an i18n instance react-i18next warns on every render.
+vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
+
let now = 0;
beforeEach(() => {
vi.spyOn(performance, "now").mockImplementation(() => now);
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 68f00abe..958eb167 100644
--- a/frontend/src/components/ui/Tabs.tsx
+++ b/frontend/src/components/ui/Tabs.tsx
@@ -1,11 +1,13 @@
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
// sections, the Stats overview/system split…). Horizontal by default; `orientation="vertical"` gives
-// the Settings-style side rail (full-width, left-aligned, icon + label). The active tab is persisted
-// to localStorage so a reload keeps the user where they were.
+// 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. 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;
@@ -15,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,
@@ -36,7 +34,9 @@ export default function Tabs({
className?: string;
}) {
const vertical = orientation === "vertical";
- const wrapCls = className ?? (vertical ? "flex flex-col gap-1" : "flex flex-wrap gap-1 mb-4");
+ const wrapCls =
+ className ??
+ (vertical ? "flex gap-1 overflow-x-auto sm:flex-col" : "flex flex-wrap gap-1 mb-4");
return (
{tabs.map((tb) => {
@@ -50,7 +50,7 @@ export default function Tabs({
aria-pressed={on}
className={`pill items-center rounded-lg px-3 text-sm ${
vertical
- ? "flex w-full justify-start gap-2 py-2 text-left"
+ ? "inline-flex shrink-0 gap-1.5 py-1.5 sm:flex sm:w-full sm:justify-start sm:gap-2 sm:py-2 sm:text-left"
: "inline-flex gap-1.5 py-1.5"
} ${on ? "font-medium shadow-md shadow-accent/20" : "hover:bg-card/60"}`}
>
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/dialogFocus.ts b/frontend/src/components/ui/dialogFocus.ts
index 56d32440..610c910d 100644
--- a/frontend/src/components/ui/dialogFocus.ts
+++ b/frontend/src/components/ui/dialogFocus.ts
@@ -108,6 +108,11 @@ export function useDialogChrome(
// the scope itself, with `focusScope`), so the dialog opens focused rather than stranded on the
// background.
const scope = scopeRef.current;
+ // The dialog's element as it is while open, for the restore in the CLEANUP — where a closing
+ // dialog's refs are already detached (refs detach in the commit, passive cleanups run after).
+ // Today that reads the same either way (a detached owner and a null one both fail every
+ // `contains` below), but the cleanup should not depend on a ref React has already let go of.
+ const ownerAtOpen = ownerRef?.current ?? scope;
if (scope && !scope.contains(document.activeElement)) {
const back = focusedRef.current;
const target =
@@ -151,7 +156,7 @@ export function useDialogChrome(
// this, a dialog closing on its own (an in-flight mutation's `onSuccess`) under a modal raised
// above it pulled the focus out of that standing dialog onto our own trigger, behind its
// scrim — where Enter still activated it (deep-review round 7).
- const owner = ownerRef?.current ?? scopeRef.current;
+ const owner = ownerAtOpen;
const active = document.activeElement;
const above = active instanceof Element ? active.closest('[aria-modal="true"]') : null;
const standingAbove =
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/i18n/appCopy.dom.test.ts b/frontend/src/i18n/appCopy.dom.test.ts
new file mode 100644
index 00000000..9e65a5b6
--- /dev/null
+++ b/frontend/src/i18n/appCopy.dom.test.ts
@@ -0,0 +1,26 @@
+// @vitest-environment jsdom
+//
+// E22 S5: a clicked control's label is logged only when it is the app's own copy — as its English
+// wording — so nothing a user wrote (a title, a tag, a channel name) can come out of the click log.
+import { describe, expect, it } from "vitest";
+
+import { appCopyOf } from "./appCopy";
+
+describe("appCopyOf", () => {
+ it("finds app copy in either language and reads it in English, whatever the whitespace", () => {
+ expect(appCopyOf("Turn on for 24 hours")).toBe("Turn on for 24 hours");
+ expect(appCopyOf(" Bekapcsolom\n 24 órára ")).toBe("Turn on for 24 hours");
+ });
+
+ it("a wording several keys share still names the wording, not one arbitrary key", () => {
+ // "Diagnosztika" is both the Settings tab and the Configuration group.
+ expect(appCopyOf("Diagnosztika")).toBe("Diagnostics");
+ });
+
+ it("finds nothing in what a user wrote, and never matches an interpolated string", () => {
+ expect(appCopyOf("My holiday video 2024")).toBeNull();
+ expect(appCopyOf("")).toBeNull();
+ expect(appCopyOf(null)).toBeNull();
+ expect(appCopyOf("{{count}} events · newest first")).toBeNull();
+ });
+});
diff --git a/frontend/src/i18n/appCopy.ts b/frontend/src/i18n/appCopy.ts
new file mode 100644
index 00000000..f5246ef9
--- /dev/null
+++ b/frontend/src/i18n/appCopy.ts
@@ -0,0 +1,43 @@
+import i18n from "./index";
+
+// The translation KEY a piece of on-screen text is, or null (E22 S5 click capture).
+//
+// A click is logged by what the control IS, and its visible label is the most telling part — but a
+// label can be the user's own content (a video title, a tag, a channel name), which the log must not
+// carry. Text that is exactly one of the app's own translated strings is app copy, not user content,
+// so it can be logged — as its ENGLISH wording, whatever the UI language: the same click reads the
+// same in every log, and nothing a user typed comes out of it (a user's tag that happens to equal a
+// string of ours is logged as our string, which says nothing about the user). Not as a key: one
+// wording is often several keys ("Diagnostics" is a settings tab AND a config group), so a key would
+// name a control the user may never have touched. Interpolated strings (`{{n}} items`) never match.
+let index: Map | null = null;
+
+const normalize = (text: string) => text.replace(/\s+/g, " ").trim();
+
+/** Every app string (any language) → its English wording. */
+function build(): Map {
+ const map = new Map();
+ const resources = i18n.options.resources ?? {};
+ const english = (path: string[]): string | null => {
+ let node: unknown = resources.en?.translation;
+ for (const key of path) node = (node as Record | undefined)?.[key];
+ return typeof node === "string" ? normalize(node) : null;
+ };
+ const walk = (node: unknown, path: string[]) => {
+ if (typeof node === "string") {
+ const text = normalize(node);
+ const en = english(path);
+ if (en && text.length >= 2 && !text.includes("{{") && !map.has(text)) map.set(text, en);
+ } else if (node && typeof node === "object") {
+ for (const [k, v] of Object.entries(node)) walk(v, [...path, k]);
+ }
+ };
+ for (const lang of Object.keys(resources)) walk(resources[lang]?.translation, []);
+ return map;
+}
+
+export function appCopyOf(text: string | null | undefined): string | null {
+ if (!text) return null;
+ index ??= build();
+ return index.get(normalize(text)) ?? null;
+}
diff --git a/frontend/src/i18n/backendIds.contract.test.ts b/frontend/src/i18n/backendIds.contract.test.ts
index 3b2eb699..867c7658 100644
--- a/frontend/src/i18n/backendIds.contract.test.ts
+++ b/frontend/src/i18n/backendIds.contract.test.ts
@@ -7,7 +7,8 @@ import { describe, expect, it } from "vitest";
// `plex_overlay_map`… as bare identifiers, for months, and the i18n parity test stayed green because EN
// and HU were missing the SAME keys (owner report, E11 S5). The ids live in Python, so the check reads
// the registries straight from the backend sources:
-// - `sysconfig.SPECS` → `config.groups.`, `config.fields..label` + `.hint`
+// - `sysconfig.SPECS` → `config.groups.`, `config.fields..label` + `.hint`, and for a
+// "choice" key `config.choices..