import { lazy, Suspense, 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 } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; import { setLanguage, type LangCode } from "../i18n"; import { useShellSession } from "./useShellSession"; import { useEntryParams } from "./useEntryParams"; import { useAccessRequestsNudge } from "./useAccessRequestsNudge"; import { useVerifyEntry } from "../lib/useVerifyEntry"; import { useNavigation, useNavigationActions } from "../features/navigation/navigationContext"; import { useFeedScrollKey, ChannelPage } from "../modules/feed"; import { moduleDef, pageTitleKey } from "../features/navigation/moduleRegistry"; import { LoadingState } from "../components/ui/QueryState"; // Persistent shell — always mounted, so eagerly imported. import Welcome from "./Welcome"; import SetupWizard from "./SetupWizard"; import Header from "./Header"; import NavSidebar from "./NavSidebar"; import BackToTop from "../components/ui/BackToTop"; import PageShell, { PageHeaderDefault } from "../components/ui/PageShell"; import { PaletteLauncher } from "../features/header/PaletteLauncher"; import { SkipLink } from "../components/ui/SkipLink"; import DebugRecorder from "../features/diagnostics/DebugRecorder"; import Toaster from "../components/ui/Toaster"; import KeyboardHelp from "../components/ui/KeyboardHelp"; import AppCommandPalette from "./AppCommandPalette"; import DownloadIntake from "../features/downloads/DownloadIntake"; import DownloadTray from "../features/downloads/DownloadTray"; import { LinkedPlayer } from "../features/player/LinkedPlayer"; import ErrorDialog from "./ErrorDialog"; import VersionBanner from "./VersionBanner"; import VerifyBanner from "./VerifyBanner"; // 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"; // The page modules (and their side rails) are code-split per route: each module's definition imports // them lazily (features/navigation/moduleRegistry), and so does the feed's for the channel page. App // keeps only the persistent-shell chunks and the two modals. const About = lazy(() => import("./About")); const ReleaseNotes = lazy(() => import("./ReleaseNotes")); export default function App() { const { t, i18n } = useTranslation(); const { page, channelView } = useNavigation(); const { setPage } = 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 feed page restores its scroll by its own key (its filters, the live search); read always, // used only while the feed is the page. const feedScrollKey = useFeedScrollKey(); const queryClient = useQueryClient(); const [aboutOpen, setAboutOpen] = useState(false); const [notesOpen, setNotesOpen] = useState(false); const [notesHighlight, setNotesHighlight] = useState(undefined); function openReleaseNotes(highlight?: string) { setNotesHighlight(highlight); setNotesOpen(true); } // (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; // What runs for the session (keys, diagnostics, preloading, the 401 handler), what the entry URL // asks for once, and the admin's waiting-requests nudge: each its own shell hook, so App is layout. useShellSession(me, setPage); useEntryParams(me, meQuery.refetch, setPage); useAccessRequestsNudge(me, 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 // returns you to where you were; the feed keys it by its filters (useFeedScrollKey). const normalScrollKey = page === "feed" ? feedScrollKey : `page:${page}`; // 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 { Page: PageContent, Rail, maxWidth } = moduleDef(page); const PageRail = channelView ? undefined : Rail; 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. It holds its own layout and collapse flag (lib/useRail). */} {PageRail && ( )} {/* `@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 ? ( ) : ( } 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 player an address names over a page that plays none itself (a channel page plays its own, through its feed); decided once, at boot. */} {/* 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). */}
); }