import { lazy, Suspense, useEffect, useRef, useState } from "react"; import { qk } from "./lib/queryKeys"; import { saveLanguage } from "./lib/saveLanguage"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { api, setUnauthorizedHandler, type Me } from "./lib/api"; import { notifyActionError } from "./lib/actionErrors"; import { setLanguage, type LangCode } from "./i18n"; import { clearParams } from "./lib/urlState"; import { reloadAfterRefusal } from "./lib/entryUrl"; import { useAccountSwitchNotice } from "./lib/useAccountSwitchNotice"; import { PREF_KEY, saveLayoutLocal, seedLayout, type PanelId, type PanelLayout, } from "./lib/panelLayout"; import { notify, removeByMetaKind } from "./lib/notifications"; import { LS } from "./lib/storage"; import { usePref } from "./lib/usePref"; import { useVerifyEntry } from "./lib/useVerifyEntry"; import { useNavigation, useNavigationActions } from "./components/navigationContext"; import { useFeedFilters, useFeedFiltersActions } from "./components/feedFiltersContext"; import { useFeedView, useFeedViewActions } from "./components/feedViewContext"; import { moduleOrder } from "./lib/modules"; import { PAGE_CONTENT, PAGE_RAIL, preloadPages } from "./components/moduleRegistry"; import { lazyWithPreload } from "./lib/lazyWithPreload"; import { LoadingState } from "./components/ui/QueryState"; // Persistent shell — always mounted, so eagerly imported. import Welcome from "./components/Welcome"; import SetupWizard from "./components/SetupWizard"; import Header from "./components/Header"; import NavSidebar from "./components/NavSidebar"; import BackToTop from "./components/ui/BackToTop"; import PageShell from "./components/ui/PageShell"; import { SkipLink } from "./components/ui/SkipLink"; import { pageTitleKey } from "./lib/pageMeta"; import DebugRecorder from "./components/DebugRecorder"; import Toaster from "./components/ui/Toaster"; import KeyboardHelp from "./components/ui/KeyboardHelp"; import AppCommandPalette from "./components/AppCommandPalette"; import DownloadIntake from "./components/DownloadIntake"; import DownloadTray from "./components/DownloadTray"; import { useGlobalKeymap } from "./lib/keymap"; import ErrorDialog from "./components/ErrorDialog"; import VersionBanner from "./components/VersionBanner"; import VerifyBanner from "./components/VerifyBanner"; import { focusAccessRequestsTab } from "./lib/adminUsersTab"; import { focusSettingsTab } from "./lib/settingsTab"; // The built app version, NOT imported from releaseNotes — importing anything from that module // pulls the release-notes loader into the eager App chunk and defeats the lazy ReleaseNotes // split. FRONTEND_VERSION equals the newest release note's version by release convention (both bump together). import { FRONTEND_VERSION } from "./lib/version"; import { applyDiagnostics } from "./lib/diagnostics"; // The page modules (and their side rails) are code-split per route via the module registry // (components/moduleRegistry). App keeps only the persistent-shell chunks + the channel page and the // two modals, which sit outside the registry-driven content column. const ChannelPage = lazyWithPreload(() => import("./components/ChannelPage")); const About = lazy(() => import("./components/About")); const ReleaseNotes = lazy(() => import("./components/ReleaseNotes")); export default function App() { const { t, i18n } = useTranslation(); const { page, channelView, ytSearch } = useNavigation(); const { setPage, enterYtSearch } = useNavigationActions(); // An emailed verification link is confirmed HERE, above the signed-in/signed-out branch below, so // it works in a browser that already holds a session. It used to be handled inside Welcome, which // that branch never renders when signed in — the token was silently dropped. Signed out, the // outcome goes to the auth card's banners; signed in, to the top-of-app bar. const verifyEntry = useVerifyEntry(); // The global feed filters live in FeedFiltersProvider (per-account persistence + the account-load // effect + share-link capture all moved there). App reads them to hand to and to the // handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey). const filters = useFeedFilters(); const { setFilters } = useFeedFiltersActions(); // The feed's view mode (card/row/tile density) lives in FeedViewProvider — server-synced, shared // by the feed page and the channel page. App reads it only to hand to /. const view = useFeedView(); const { setView } = useFeedViewActions(); const queryClient = useQueryClient(); // Per-panel "customize" layouts (order/collapsed/hidden of each side panel's group cards), // persisted per-account. One record so feed/plex/playlists share the same mechanism. Seeded from // the account's server preferences — BootGate mounts App only once `me` is cached — so a layout // changed on another device is right on the first paint; the local copy covers a key the server // has never stored. const [panelLayouts, setPanelLayouts] = useState>(() => { const prefs = queryClient.getQueryData(qk.me())?.preferences; return { feed: seedLayout("feed", prefs), plex: seedLayout("plex", prefs), playlists: seedLayout("playlists", prefs), files: seedLayout("files", prefs), }; }); const [aboutOpen, setAboutOpen] = useState(false); const [notesOpen, setNotesOpen] = useState(false); const [notesHighlight, setNotesHighlight] = useState(undefined); function openReleaseNotes(highlight?: string) { setNotesHighlight(highlight); setNotesOpen(true); } function setPanelLayout(panel: PanelId, next: PanelLayout) { setPanelLayouts((prev) => ({ ...prev, [panel]: next })); saveLayoutLocal(panel, next); api .savePrefs({ [PREF_KEY[panel]]: next }) .catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.prefs"))); } // Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the // user's preferences (via usePref) so it follows the account across devices, seeded from the `me` // cache at mount; the localStorage mirror covers an account whose server prefs never stored it. // Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice. const [filterCollapsed, setFilterCollapsed] = usePref("filterCollapsed", true, { localKey: LS.filterCollapsed, parse: Boolean, }); // The Playlists rail has its own collapse flag (it's a list/navigator, not filters). const [playlistsCollapsed, setPlaylistsCollapsed] = usePref("playlistsCollapsed", true, { localKey: LS.playlistsCollapsed, parse: Boolean, }); // The Files rail is the library's folder list — how you move around it, not a filter you set once — // so it starts pinned open (the owner's mockup), and has its own flag. const [filesCollapsed, setFilesCollapsed] = usePref("filesCollapsed", false, { localKey: LS.filesCollapsed, parse: Boolean, }); const railCollapse = { filter: [filterCollapsed, setFilterCollapsed], playlists: [playlistsCollapsed, setPlaylistsCollapsed], files: [filesCollapsed, setFilesCollapsed], } as const; // (The feed filters + page/channel now LIVE in the address bar — FeedFiltersProvider and // NavigationProvider read them on load and keep them mirrored — so there's no query to strip here.) // Passive observers (enabled:false) of the setup-status and `me` queries BootGate fetches, polls // and waits for: App mounts only once both have answered, so their caches are always populated here. const setupQuery = useQuery({ queryKey: qk.setupStatus(), queryFn: api.setupStatus, enabled: false, }); const needsSetup = setupQuery.data?.configured === false; const meQuery = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false }); // The signed-in account, or null/undefined while signed out. Hoisted here because half a dozen // effects below depend on it, and a dep list is only honest if it names the value the body reads. const me = meQuery.data; // The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette, Shift+W jumps to // Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted // (bottom of this tree) and where the modules exist at all — see useGlobalKeymap on why binding it // above the signed-out branch is worse than not binding it. // A global key can fire while an overlay is open; `setPage` closes every overlay before it pushes, // so Shift+W over the player lands on Home with the player closed — also when Home is already the // page underneath (owner decision 2026-09-09: the key always ends with the answer on screen). // The `page` scope rides the same binding: the `g` chords jump to a module, but only to one this // account can reach (the nav rail's own list, plus Settings, which is not in the rail's order). useGlobalKeymap(!!me, setPage, me ? [...moduleOrder(me), "settings"] : []); // The account's consented diagnostics level, applied to this tab's log: raised, the log is // kept in more detail and streamed to the server until the level lapses. A debug session that is // recording raises it for its own run and tags what it keeps. A lapse (its own timer, or the // server saying it is off) refetches `me`, so Settings and the recorder show what is in force. // Re-running this on every `me` refetch is FREE and therefore the deps are the honest ones: // applyDiagnostics computes an effective-level key and returns early when it has not moved // (lib/diagnostics.ts), so an unchanged level is a no-op. It does refresh the `lapsed` callback // before that early return, which is the half we WANT re-run. The old dep list picked out four // primitive fields to dodge the fresh object identity a refetch produces; that is the checker's // job to verify, not a comment's to assert, and it silently missed any other field the module // may come to read. const diagnostics = me?.diagnostics; const debugSession = me?.debug_session; useEffect(() => { applyDiagnostics( diagnostics, debugSession, () => void queryClient.invalidateQueries({ queryKey: qk.me() }), ); }, [diagnostics, debugSession, queryClient]); // Fetch the code of every module this account can open — and the channel page — once the first page // is on screen, so a module's first visit renders at once instead of flashing the chunk fallback. // Idle-scheduled so it never competes with the first page's own requests (Safari has no // requestIdleCallback, hence the timer). // Depending on the whole `me` rather than its id is both honest and MORE correct: the module set // is derived from `me` through each module's own gate (lib/modules moduleOrder), so a permission // change — an admin grant, a Plex server appearing — alters it without the id moving, and the old // `[me.id]` dep never re-preloaded for those. Re-running costs nothing: `preload()` is idempotent // (lazyWithPreload memoises the pending promise), so a refetch that changes nothing fetches // nothing; only the idle callback is re-scheduled. useEffect(() => { if (!me) return; const run = () => { preloadPages([...moduleOrder(me), "settings"]); ChannelPage.preload().catch(() => {}); }; const idle = window.requestIdleCallback as typeof window.requestIdleCallback | undefined; if (idle) { const id = idle(run, { timeout: 3000 }); return () => window.cancelIdleCallback(id); } const id = window.setTimeout(run, 1000); return () => window.clearTimeout(id); }, [me]); // Measure the scrollbar's gutter width once and publish it as --sbw, so the floating header and the // toolbar band can pad their right edge to line up with the scrolled cards (the app scroller reserves // that gutter via scrollbar-gutter: stable). It's a device constant, so a one-shot mount measure is enough. useEffect(() => { const probe = document.createElement("div"); probe.style.cssText = "position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll;scrollbar-gutter:stable"; document.body.appendChild(probe); const w = probe.offsetWidth - probe.clientWidth; document.body.removeChild(probe); document.documentElement.style.setProperty("--sbw", `${w}px`); }, []); // Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify, // ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the // URL. Gated on auth so the logged-out Welcome page still reads its params first. // Keyed on "is there a session at all", which is the whole condition the body tests. Re-running // on an account switch was never the point — by then the params are already gone and a second // clearParams is a no-op. const signedIn = !!me; useEffect(() => { if (signedIn) clearParams("access", "verify", "reset"); }, [signedIn]); // A sign-in as someone else that the server refused, or a reload as another account: say so, once. useAccountSwitchNotice(signedIn); // Any request that 401s means the session ended server-side. If we were signed in (cached // `me` is a user object), reload to the login page at once (also covers any click that hits // the API). Guarded on a cached user so a stray 401 while logged out (cached `me` is null) // can't loop the public landing. A tab showing another account than the one signed in now // reloads as that one instead, and says why (`reloadAfterRefusal`). useEffect(() => { setUnauthorizedHandler((err) => { if (!queryClient.getQueryData(qk.me())) return; queryClient.clear(); reloadAfterRefusal(err); }); return () => setUnauthorizedHandler(null); }, [queryClient]); // Returning from an in-app Google link/upgrade round-trip (auth.py redirects to ?link=…). // Surface the outcome, refresh `me` (can_read/can_write/has_google may have flipped), land on // Settings → Account so the new state is visible, then strip the param for a clean URL. // "Exactly once per mount" is now ENFORCED by the ref rather than asserted by an empty dep list: // `t` changes identity on a language switch, so the honest dep list would otherwise re-fire the // toast when the user changes language. (The old `[]` got away with it only because clearParams // had already removed the param — an invariant held by a side effect two lines further down.) const linkAnswered = useRef(false); const { refetch: refetchMe } = meQuery; useEffect(() => { if (linkAnswered.current) return; const result = new URLSearchParams(window.location.search).get("link"); if (!result) return; linkAnswered.current = true; if (result === "ok") { notify({ level: "success", message: t("settings.account.googleLink.linked") }); void refetchMe(); focusSettingsTab("account"); setPage("settings"); } else { const key = result === "conflict" || result === "mismatch" ? result : "error"; notify({ level: "error", message: t(`settings.account.googleLink.${key}`) }); } clearParams("link"); }, [t, setPage, refetchMe]); // A Google OAuth flow started while already signed in (signing in as someone else, or a Google // connect) was cancelled at Google's consent screen. The backend only emits this for a live session, so a // neutral toast is the right feedback — the pre-auth login banner would never render here. // Same one-shot shape as the `?link=` handler above, same reason for the ref. const oauthAnswered = useRef(false); useEffect(() => { if (oauthAnswered.current) return; if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return; oauthAnswered.current = true; notify({ level: "info", message: t("settings.account.googleCancelled") }); clearParams("oauth"); }, [t]); // Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to // the admin Users → Access requests view. Snapshot the param up front (read once at mount); act once // `me` is known, and only for an admin (others just land on their default page). const [adminDeepLink] = useState(() => new URLSearchParams(window.location.search).get("admin")); // Ref-guarded like the two above, and here it closes a real hole rather than only satisfying the // checker: the snapshot lives for the whole session, so with honest deps a later role change — // or any `me` refetch, under the old id-only key — would yank the user to Users long after the // deep link was answered. const adminDeepLinkAnswered = useRef(false); useEffect(() => { if (adminDeepLinkAnswered.current) return; if (adminDeepLink !== "access-requests" || !me) return; adminDeepLinkAnswered.current = true; if (me.role === "admin") { focusAccessRequestsTab(); setPage("users"); } clearParams("admin"); }, [adminDeepLink, me, setPage]); // Nudge admins when access requests are waiting (in lieu of a server-push bell). Keyed on the // pending COUNT so it re-resolves the moment an approval/denial changes it — not only on a // fresh load (which is why the notice used to linger until F5 after an approval). // Honest deps, and `t` among them is a fix rather than a concession: the nudge is a persisted // toast whose text is translated at issue time, so a language switch must re-issue it. It did not // before — the notice stayed in the old language until the count changed. const role = me?.role; const pendingInvites = me?.pending_invites ?? 0; const pendingActivations = me?.pending_activations ?? 0; useEffect(() => { if (role !== "admin") return; // Keep a single, current nudge: drop any prior one (persisted copies reload as dismissed // history, so they'd otherwise pile up one-per-reload) before re-issuing the live notice. removeByMetaKind("access-requests"); // Both kinds of "someone is waiting on you": a request needing a decision, and a sign-up whose // address was approved before they registered. The second used to produce NO signal at all — // nothing lands in the queue for it — which is how the 2026-09-03 registrant went unnoticed. const pending = pendingInvites + pendingActivations; if (pending > 0) { notify({ level: "warning", title: t("common.accessRequestsTitle"), message: t("common.accessRequestsMessage", { count: pending }), // Durable inbox link (survives reload, unlike a live action callback); the toast also // gets a click via the action. meta: { kind: "access-requests" }, action: { label: t("common.accessRequestsReview"), onClick: () => { focusAccessRequestsTab(); // Clicked inside whatever overlay hosts the toast; setPage closes it before pushing. setPage("users"); }, }, }); } }, [role, pendingInvites, pendingActivations, t, setPage]); // Language is edited outside the Settings page (sidebar/login), so it stays instant. function changeLanguage(code: LangCode) { setLanguage(code); // Also from the signed-out login page, where the save 401s — `req` marks that as handled, so // the reporter stays quiet there. saveLanguage(queryClient, code).catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.prefs")), ); } // First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard. if (needsSetup) return ; // /api/me answers 200 always (null body = logged out), and BootGate has already handled a failed // probe — so no data here means "not signed in" → the public landing. if (!me) return ; // Shown while a page's chunk is still on its way: only if its visit beats the preload above (or the // preload failed), and like every loading state it stays invisible for a load that is quick anyway. const pageFallback = ; // Scroll restore/reset key for the shared normal-page scroller (see PageScroller): the same // page/feed-filters returns you to where you were (bug 1); a changed feed filter is a new key, so // the fresh result set starts at the top (bug 3). `q` is normalised out so typing in the search // box doesn't reset the scroll on every keystroke; the live-YouTube sub-view gets its own key. const normalScrollKey = page !== "feed" ? `page:${page}` : ytSearch != null ? `yt:${ytSearch}` : `feed:${JSON.stringify({ ...filters, q: "" })}`; // The module registry drives the render from `page` alone — no per-page chrome conditionals. A page // the account can't reach never gets here: the navigation state already moved it to the feed // (`reachablePage`). A channel page overlays the content column, so no side rail shows behind it. const { Component: PageContent, maxWidth } = PAGE_CONTENT[page]; const railDef = channelView ? undefined : PAGE_RAIL[page]; return (
setAboutOpen(true)} onChangeLanguage={changeLanguage} language={i18n.language as LangCode} /> {/* The active page's side rail (feed filters / plex filters / playlists list), if it has one. A floating collapsible column beside the nav rail; hidden while a channel page overlays the content. feed + plex share the filter-rail collapse flag, playlists and files have their own. */} {railDef && ( setPanelLayout(railDef.panelId, l)} collapsed={railCollapse[railDef.collapse][0]} onToggleCollapse={() => { const [collapsed, setCollapsed] = railCollapse[railDef.collapse]; setCollapsed(!collapsed); }} /> )} {/* `@container/appmain`: the floating header + its search box adapt to THIS column's width (which shrinks when the filter sidebar is open), not the viewport — a viewport breakpoint can't see the sidebar-driven narrowing. NB: `container-type: inline-size` implies `contain: layout`, making this column a containing block for `position: fixed` descendants too — safe today because floating-ui menus render via FloatingPortal (to document.body) and the toasts below are absolute; keep any new fixed overlay inside here body-portalled. */}
{channelView ? ( { // Keep the rest of the feed's filters — the point is "this channel, the way I // normally read": unwatched, my tags, my date window. // ADD to the picked set, don't replace it: channels OR together now, so arriving // from a second channel page should widen the feed, not silently drop the first. // Deduped — clicking it again for a channel already picked is a no-op. setFilters({ ...filters, channelIds: [...new Set([...filters.channelIds, channelView.id])], }); // setPage already leaves an open channel page and pushes a clean history entry. // Calling closeChannel() first ran history.back(), whose popstate landed AFTER this // and restored whatever page the channel had been opened from. setPage("feed"); }} /> ) : ( } banners={ <> openReleaseNotes(FRONTEND_VERSION)} /> } > )} {/* Toasts rise from the bottom-left, by the notification bell in the nav rail. Anchored inside the content column so they clear the sidebar automatically (collapsed or expanded) without tracking its width. */} {/* The running downloads, bottom-right in the same column (the toasts keep the left). */}
{/* The two app-wide overlays: the keyboard cheat sheet (`h`) and the jump-to palette (Mod+K). Mounted once, outside the page area, so every surface answers "what can I press here" and "take me to X" the same way. Their keys are bound by useGlobalKeymap above. */} {/* The one download dialog: the Files link box, a link pasted onto any page, the palette. */} {/* The debug recorder: its start dialog, and its pill docked under the toasts. */} {me && } {aboutOpen && ( setAboutOpen(false)} onOpenReleaseNotes={() => { setAboutOpen(false); openReleaseNotes(); }} /> )} {notesOpen && ( setNotesOpen(false)} highlight={notesHighlight} /> )} {/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
); }