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 ` )} @@ -400,3 +390,53 @@ function Field({ ); } + +// 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")} + ) : ( + +
+ ({ value: level, label: levelName(level) }))} + /> +
+
+ )} + + {pending && ( +
+

{t(`settings.diagnostics.consent.title.${pending}`)}

+
    +
  • {t(`settings.diagnostics.consent.what.${pending}`)}
  • +
  • + {t("settings.diagnostics.consent.server", { + count: state?.retention_days ?? 7, + })} +
  • +
  • {t("settings.diagnostics.consent.never")}
  • +
  • + {t("settings.diagnostics.consent.lapse")}{" "} + + {t("settings.diagnostics.consent.policy")} + +
  • +
+
+ + +
+
+ )} + + {current !== "INFO" && !pending && remaining && ( + // Not a live region: the countdown changes every minute, and a screen reader would read + // the whole line out again each time for as long as the page is open. +
+ + + + {t("settings.diagnostics.status", { + level: levelName(current), + time: + remaining.h > 0 + ? t("settings.diagnostics.hoursMinutes", remaining) + : t("settings.diagnostics.minutes", remaining), + })} + + + +
+ )} +
+ +
+ +
+ + ); +} diff --git a/frontend/src/components/DownloadCenter.confirm.dom.test.tsx b/frontend/src/components/DownloadCenter.confirm.dom.test.tsx index f5edb5f2..43e37ab8 100644 --- a/frontend/src/components/DownloadCenter.confirm.dom.test.tsx +++ b/frontend/src/components/DownloadCenter.confirm.dom.test.tsx @@ -17,7 +17,7 @@ vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; }); -vi.mock("./ConfirmProvider", () => ({ useConfirm: () => mocks.confirm })); +vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm })); vi.mock("../lib/useLiveQuery", () => ({ useLiveQuery: (key: readonly unknown[]) => ({ data: key[0] === "downloads" ? mocks.jobs : undefined, diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 08e41265..e314049e 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -30,7 +30,7 @@ const ProfileEditor = lazy(() => import("./ProfileEditor")); const VideoEditor = lazy(() => import("./VideoEditor")); const ShareDialog = lazy(() => import("./ShareDialog")); const LibraryPlayer = lazy(() => import("./LibraryPlayer")); -import { useConfirm } from "./ConfirmProvider"; +import { useConfirm } from "./confirmContext"; import { useLiveQuery } from "../lib/useLiveQuery"; import { useMe } from "../lib/useMe"; import { api, type DownloadJob } from "../lib/api"; diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index 79dfbeca..c30a288e 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -1,6 +1,7 @@ -import { Component, type ReactNode } from "react"; +import { Component, type ErrorInfo, type ReactNode } from "react"; import i18n from "../i18n"; import { notify } from "../lib/notifications"; +import { errorAttrs, log } from "../lib/log"; interface Props { children: ReactNode; @@ -18,7 +19,11 @@ export default class ErrorBoundary extends Component { return { crashed: true }; } - componentDidCatch(error: Error) { + componentDidCatch(error: Error, info: ErrorInfo) { + log.fatal("ui.crash", "the app crashed while rendering", { + ...errorAttrs(error), + ...(info.componentStack ? { component_stack: info.componentStack } : {}), + }); notify({ level: "fatal", title: i18n.t("errors.boundary.notifTitle"), diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 7a113dc2..21f526a3 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -44,7 +44,7 @@ import SelectedViewChip from "./SelectedViewChip"; import { Button } from "./ui/Button"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { PageToolbar } from "./ui/PageShell"; -import { useWizard } from "./WizardProvider"; +import { useWizard } from "./wizardContext"; import SortControl from "./ui/SortControl"; import FilterSelect from "./ui/FilterSelect"; import ItemCount from "./ui/ItemCount"; @@ -230,15 +230,15 @@ export default function Feed({ // rows are unchanged, so clearing overrides there would make a just-hidden card flash back. // Only a real refetch (page count doesn't grow) is authoritative. const pagesLenRef = useRef(0); + const pagesLen = query.data?.pages?.length ?? 0; useEffect(() => { - const len = query.data?.pages?.length ?? 0; - const appended = len > pagesLenRef.current; - pagesLenRef.current = len; + const appended = pagesLen > pagesLenRef.current; + pagesLenRef.current = pagesLen; if (!appended) { setOverrides({}); setSavedOverrides({}); } - }, [query.dataUpdatedAt]); + }, [query.dataUpdatedAt, pagesLen]); // One shared query with the sidebar (same key + filters -> React Query dedupes to a single request): // the feed reads `.count`, the sidebar reads `.facets` (PERF-13, replaces feedCount + facets). diff --git a/frontend/src/components/Feed.ytsort.dom.test.tsx b/frontend/src/components/Feed.ytsort.dom.test.tsx index d3ced48a..e7eae5d2 100644 --- a/frontend/src/components/Feed.ytsort.dom.test.tsx +++ b/frontend/src/components/Feed.ytsort.dom.test.tsx @@ -15,7 +15,7 @@ vi.mock("react-i18next", async (importOriginal) => ({ ...(await importOriginal()), useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), })); -vi.mock("./WizardProvider", () => ({ useWizard: () => ({ openWizard: vi.fn() }) })); +vi.mock("./wizardContext", () => ({ useWizard: () => ({ openWizard: vi.fn() }) })); vi.mock("./VirtualFeed", () => ({ default: ({ items }: { items: { id: string; title: string }[] }) => (
    diff --git a/frontend/src/components/FeedFiltersProvider.dom.test.ts b/frontend/src/components/FeedFiltersProvider.dom.test.ts index b55edddc..95181e4a 100644 --- a/frontend/src/components/FeedFiltersProvider.dom.test.ts +++ b/frontend/src/components/FeedFiltersProvider.dom.test.ts @@ -4,7 +4,7 @@ // legacy global `tagMode` (older builds wrote it) into the per-category `tagModes` — the same coercion // the saved-view and URL paths do — so an upgrading user's topic any/all match isn't silently lost. import { describe, it, expect, beforeEach } from "vitest"; -import { loadAccountFilters } from "./FeedFiltersProvider"; +import { loadAccountFilters } from "./feedFiltersInit"; import { accountKey, LS } from "../lib/storage"; const ID = 7; diff --git a/frontend/src/components/FeedFiltersProvider.seed.dom.test.tsx b/frontend/src/components/FeedFiltersProvider.seed.dom.test.tsx index 4d4c6f2f..b4a7dfe6 100644 --- a/frontend/src/components/FeedFiltersProvider.seed.dom.test.tsx +++ b/frontend/src/components/FeedFiltersProvider.seed.dom.test.tsx @@ -11,7 +11,8 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { cleanup, render } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { NavigationProvider } from "./NavigationProvider"; -import { FeedFiltersProvider, useFeedFilters } from "./FeedFiltersProvider"; +import { FeedFiltersProvider } from "./FeedFiltersProvider"; +import { useFeedFilters } from "./feedFiltersContext"; import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount"; import { qk } from "../lib/queryKeys"; import type { Me } from "../lib/api"; diff --git a/frontend/src/components/FeedFiltersProvider.tsx b/frontend/src/components/FeedFiltersProvider.tsx index ef6d3aeb..e26fc3c7 100644 --- a/frontend/src/components/FeedFiltersProvider.tsx +++ b/frontend/src/components/FeedFiltersProvider.tsx @@ -1,89 +1,31 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, - type ReactNode, -} from "react"; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { qk } from "../lib/queryKeys"; import { useQueryClient } from "@tanstack/react-query"; -import { - getActiveAccount, - coerceTagModes, - coerceUntagged, - DEFAULT_TAG_MODES, - type FeedFilters, - type Me, -} from "../lib/api"; +import { getActiveAccount, type FeedFilters, type Me } from "../lib/api"; import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState"; import { useUrlMirror } from "../lib/useUrlMirror"; -import { accountKey, LS, readJSON, writeJSON } from "../lib/storage"; -import { useNavigation } from "./NavigationProvider"; +import { accountKey, writeJSON } from "../lib/storage"; +import { logFilterChange } from "../lib/filterLog"; +import { useNavigation } from "./navigationContext"; +import { + FeedFiltersActionsContext, + FeedFiltersStateContext, + type FeedFiltersActions, +} from "./feedFiltersContext"; +import { DEFAULT_FILTERS, FILTERS_KEY, loadAccountFilters } from "./feedFiltersInit"; // Owns the GLOBAL feed-filter state (the feed's scope/tags/content/sort/show), lifted out of the App // god component so consumers read it via a hook instead of prop-drilling `filters`/`setFilters` // through the tree. Mirrors NavigationProvider (split contexts + one-line hooks); composed in // main.tsx inside NavigationProvider, around . // -// TWO contexts on purpose (mirrors NavigationProvider): a STABLE actions context (setFilters never -// changes reference) and a CHANGING state context (the filters object). Context consumers re-render -// only when the context they read changes — so a component that dispatches filter changes without -// reading the current filters can subscribe to actions ONLY and skip re-rendering on every tweak. -// // SCOPE NOTE: this owns the FEED's filters only. The channel detail page keeps its OWN isolated // filter state (ChannelPage) — it renders a channel-scoped catalog, not the global feed — and the // reusable is parameterized by a `filters` prop so it can render either. Because the two live // in structurally separate cells, opening a channel can never leak into the global feed (bug 2). - -const DEFAULT_FILTERS: FeedFilters = { - tags: [], - tagModes: DEFAULT_TAG_MODES, - untagged: false, - channelIds: [], - q: "", - sort: "newest", - scope: "my", - includeNormal: true, - includeShorts: false, - includeLive: false, - show: "unwatched", -}; - -const FILTERS_KEY = LS.filters; - -// This account's own persisted filters (per-account keys — never another account's). No account -// (signed out) → defaults. -export function loadAccountFilters(id: number | null): FeedFilters { - // Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you - // picked, or manual tweaks) — setFilters persists them under LS.filters on every change. A starred - // default view seeds a fresh account that has never stored filters, but that is applied ONCE by - // SavedViewsProvider on its views query's first resolve (which then persists here like any edit) — - // not read from a mirror on this synchronous path, which used to race that resolve. So here we only - // ever read the account's own stored filters, else fall back to DEFAULT_FILTERS. - const fKey = accountKey(FILTERS_KEY, id); - let hasStored = false; - try { - hasStored = !!fKey && localStorage.getItem(fKey) != null; - } catch { - /* localStorage may be unavailable */ - } - // Migrate a persisted legacy global `tagMode` into the per-category `tagModes`, matching what the - // saved-view (asFilters) and URL (paramsToFilters) paths already do. coerceTagModes MUST run on the - // RAW stored blob, not the object merged over DEFAULT_FILTERS: the merge injects DEFAULT_TAG_MODES - // (all "or"), which coerceTagModes prefers over the legacy scalar and would mask it. - if (hasStored && fKey) { - const raw = readJSON>(fKey, {}); - return { - ...DEFAULT_FILTERS, - ...raw, - tagModes: coerceTagModes(raw), - untagged: coerceUntagged(raw), - }; - } - return { ...DEFAULT_FILTERS }; -} +// +// The contexts + hooks live in feedFiltersContext.ts; the defaults and the per-account seed in +// feedFiltersInit.ts. // URL wins over localStorage so a pasted link reproduces the exact view. function loadInitialFilters(me: Me | null | undefined): FeedFilters { @@ -94,20 +36,6 @@ function loadInitialFilters(me: Me | null | undefined): FeedFilters { return me?.is_demo ? { ...f, scope: "all" } : f; } -interface FeedFiltersActions { - setFilters: (next: FeedFilters) => void; -} - -const StateContext = createContext(DEFAULT_FILTERS); -const ActionsContext = createContext({ setFilters: () => {} }); - -export function useFeedFilters(): FeedFilters { - return useContext(StateContext); -} -export function useFeedFiltersActions(): FeedFiltersActions { - return useContext(ActionsContext); -} - export function FeedFiltersProvider({ children }: { children: ReactNode }) { const qc = useQueryClient(); // Seeded for the account this tab is — BootGate mounts this provider only once the account is @@ -123,6 +51,7 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) { const setFilters = useCallback((next: FeedFilters) => { setFiltersState(next); + logFilterChange("feed", next, next.q); // Persist under THIS tab's active account so filters don't bleed between accounts. const key = accountKey(FILTERS_KEY, getActiveAccount()); if (key) writeJSON(key, next); @@ -146,8 +75,10 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) { const actions = useMemo(() => ({ setFilters }), [setFilters]); return ( - - {children} - + + + {children} + + ); } diff --git a/frontend/src/components/FeedScopeToggle.tsx b/frontend/src/components/FeedScopeToggle.tsx index e218bb31..9b2b3de3 100644 --- a/frontend/src/components/FeedScopeToggle.tsx +++ b/frontend/src/components/FeedScopeToggle.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; import { Library, User } from "lucide-react"; import SegmentedToggle from "./ui/SegmentedToggle"; -import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; +import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; // The feed's Mine/Library scope, relocated from the filter sidebar to sit just right of the header // search box — mirroring PlexScopeToggle's placement, so the primary "what am I browsing" control is diff --git a/frontend/src/components/FeedViewProvider.tsx b/frontend/src/components/FeedViewProvider.tsx index a07ad2f4..2c864c76 100644 --- a/frontend/src/components/FeedViewProvider.tsx +++ b/frontend/src/components/FeedViewProvider.tsx @@ -1,7 +1,12 @@ -import { createContext, useContext, useMemo, type ReactNode } from "react"; +import { useMemo, type ReactNode } from "react"; import { parseFeedView, type FeedView } from "../lib/feedView"; import { LS } from "../lib/storage"; import { usePref } from "../lib/usePref"; +import { + FeedViewActionsContext, + FeedViewStateContext, + type FeedViewActions, +} from "./feedViewContext"; // Owns the feed's view mode (the card/row/tile density switcher), lifted out of App so both the feed // page and the channel page read it via hooks instead of prop-drilling `view`/`setView` through App. @@ -10,20 +15,8 @@ import { usePref } from "../lib/usePref"; // The view mode is an account PREFERENCE, not a filter: it persists to the server on pick and follows // the account across devices. usePref carries that server-sync (seeded from the server pref, with the // localStorage mirror as the fallback) so this provider is just the two contexts over it. - -interface FeedViewActions { - setView: (v: FeedView) => void; -} - -const StateContext = createContext("cards"); -const ActionsContext = createContext({ setView: () => {} }); - -export function useFeedView(): FeedView { - return useContext(StateContext); -} -export function useFeedViewActions(): FeedViewActions { - return useContext(ActionsContext); -} +// +// The contexts + hooks live in feedViewContext.ts. export function FeedViewProvider({ children }: { children: ReactNode }) { // parseFeedView also maps the pre-0.45 "grid"/"list" values forward (idempotent for current values). @@ -35,8 +28,8 @@ export function FeedViewProvider({ children }: { children: ReactNode }) { const actions = useMemo(() => ({ setView }), [setView]); return ( - - {children} - + + {children} + ); } diff --git a/frontend/src/components/GlassTuner.tsx b/frontend/src/components/GlassTuner.tsx index ffada379..0a132b59 100644 --- a/frontend/src/components/GlassTuner.tsx +++ b/frontend/src/components/GlassTuner.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import Overlay from "./ui/Overlay"; import { copyText } from "../lib/clipboard"; -import { usePrefs } from "./PrefsProvider"; +import { usePrefs } from "./prefsContext"; /** * A floating live appearance tuner. Mounted at the app root so it's reachable on every page; it diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index de2f13aa..e7ec581f 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -8,10 +8,10 @@ import PlexScopeToggle from "./PlexScopeToggle"; import PlexViewToggle from "./PlexViewToggle"; import FeedScopeToggle from "./FeedScopeToggle"; import SyncStatus from "./SyncStatus"; -import { useNavigation } from "./NavigationProvider"; -import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; -import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider"; -import { useChannels, useChannelsActions } from "./ChannelsProvider"; +import { useNavigation } from "./navigationContext"; +import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; +import { usePlaylists, usePlaylistsActions } from "./playlistsContext"; +import { useChannels, useChannelsActions } from "./channelsContext"; // The floating top header: a row of glass pills over the content — the module's SearchBar where it // has one, and the per-user sync chip at the right end. diff --git a/frontend/src/components/Home.dom.test.tsx b/frontend/src/components/Home.dom.test.tsx index 432a7276..50017494 100644 --- a/frontend/src/components/Home.dom.test.tsx +++ b/frontend/src/components/Home.dom.test.tsx @@ -49,6 +49,7 @@ vi.mock("../lib/api", () => ({ setState: () => Promise.resolve(), watchLaterAdd: () => Promise.resolve(), watchLaterRemove: () => Promise.resolve(), + downloadIndex: () => Promise.resolve({}), // the cards' download buttons read it }, DISCOVER_BUDGETS: ["short", "medium", "long", "any"], discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`, @@ -58,10 +59,10 @@ vi.mock("../lib/api", () => ({ // WHAT is handed over is: the jump target decides both what plays and where Back goes. const setPage = vi.fn(); const setJumpTo = vi.fn(); -vi.mock("./NavigationProvider", () => ({ +vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ setPage: (p: string) => setPage(p) }), })); -vi.mock("./PlexProvider", () => ({ +vi.mock("./plexContext", () => ({ usePlexActions: () => ({ setJumpTo: (t: unknown) => setJumpTo(t) }), })); diff --git a/frontend/src/components/Home.pin.dom.test.tsx b/frontend/src/components/Home.pin.dom.test.tsx index 2a91e8ce..9b122160 100644 --- a/frontend/src/components/Home.pin.dom.test.tsx +++ b/frontend/src/components/Home.pin.dom.test.tsx @@ -53,6 +53,7 @@ vi.mock("../lib/api", () => ({ setState: () => Promise.resolve(), watchLaterAdd: () => Promise.resolve(), watchLaterRemove: () => Promise.resolve(), + downloadIndex: () => Promise.resolve({}), // the cards' download buttons read it comfortPins: (...a: unknown[]) => comfortPins(...a), addComfortPin: (...a: unknown[]) => addComfortPin(...a), removeComfortPin: (...a: unknown[]) => removeComfortPin(...a), @@ -63,8 +64,8 @@ vi.mock("../lib/api", () => ({ discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`, })); -vi.mock("./NavigationProvider", () => ({ useNavigationActions: () => ({ setPage: vi.fn() }) })); -vi.mock("./PlexProvider", () => ({ usePlexActions: () => ({ setJumpTo: vi.fn() }) })); +vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ setPage: vi.fn() }) })); +vi.mock("./plexContext", () => ({ usePlexActions: () => ({ setJumpTo: vi.fn() }) })); import Home from "./Home"; diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx index 4331b157..9ab7b13b 100644 --- a/frontend/src/components/Home.tsx +++ b/frontend/src/components/Home.tsx @@ -38,8 +38,8 @@ import SegmentedToggle from "./ui/SegmentedToggle"; import { LoadingState, StateMessage } from "./ui/QueryState"; import VideoCard from "./VideoCard"; import { PlexPosterCard } from "./plexCards"; -import { useNavigationActions } from "./NavigationProvider"; -import { usePlexActions } from "./PlexProvider"; +import { useNavigationActions } from "./navigationContext"; +import { usePlexActions } from "./plexContext"; import { navItemAttrs } from "../lib/listNav"; // The Home surface (E24) — "Mit nézzek most?". diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx index cb33aab5..12dd501f 100644 --- a/frontend/src/components/LanguageSwitcher.tsx +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -1,7 +1,8 @@ import { useState } from "react"; import { Check, Globe } from "lucide-react"; import { LANGUAGES, type LangCode } from "../i18n"; -import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover"; +import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover"; +import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; // Compact language picker (globe + current code). Presentational: the parent decides what // changing the language does (set it; persist server-side when signed in). diff --git a/frontend/src/components/LibraryPlayer.tsx b/frontend/src/components/LibraryPlayer.tsx index 56d12f30..4bdbc3d6 100644 --- a/frontend/src/components/LibraryPlayer.tsx +++ b/frontend/src/components/LibraryPlayer.tsx @@ -1,9 +1,10 @@ -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import Modal from "./ui/Modal"; import { api, type DownloadJob } from "../lib/api"; -import { jobTitle } from "./downloadRow"; +import { jobTitle } from "./downloadTitle"; import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay"; +import { useMediaLogRef } from "../lib/mediaLog"; // E7 S4b — in-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin // YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain @@ -30,6 +31,14 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl // Our self-hosted poster (always same-origin, never expires) is the primary; the source thumbnail is // the fallback. Same precedence as the library-row thumbnail. const poster = job.poster_url ? api.posterImageUrl(job.id) : (asset?.thumbnail_url ?? undefined); + const logMedia = useMediaLogRef("library"); + const videoRef = useCallback( + (el: HTMLVideoElement | null) => { + exposeVideoToBackgroundPlay(el); + logMedia(el); + }, + [logMedia], + ); return ( @@ -42,13 +51,13 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl className="w-40 h-40 rounded-xl object-cover border border-border" /> ) : null} -