The player names what it plays in its own history entry (watch, wrel, wtab): a reload or a
bookmark reopens it on the page that hosts it, with that page's list as the queue, or alone
from the catalog (GET /api/videos/{id}/row); a page that hosts none gets the shell's. Back
lands on the page's address without it (useBackToClose's url; ui/Modal passes it on). The
Files viewer and player do the same (fview, fplay). The Plex and channel-manager views are
always written in full, as their fallback is remembered state.
216 lines
11 KiB
TypeScript
216 lines
11 KiB
TypeScript
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<string | undefined>(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 <SetupWizard />;
|
|
// /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 <Welcome verify={verifyEntry} />;
|
|
|
|
// 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 = <LoadingState className="flex-1 justify-center" />;
|
|
|
|
// 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 (
|
|
<div className="h-dvh flex text-fg">
|
|
<SkipLink label={t("nav.skipToContent")} />
|
|
<NavSidebar
|
|
me={me!}
|
|
onOpenAbout={() => 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 && (
|
|
<Suspense fallback={null}>
|
|
<PageRail />
|
|
</Suspense>
|
|
)}
|
|
{/* `@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. */}
|
|
<div className="@container/appmain relative flex-1 min-w-0 flex flex-col">
|
|
{channelView ? (
|
|
<PageShell scrollKey={`channel:${channelView.id}`}>
|
|
<Suspense fallback={pageFallback}>
|
|
<ChannelPage
|
|
key={channelView.id}
|
|
channelId={channelView.id}
|
|
initialName={channelView.name}
|
|
me={me!}
|
|
/>
|
|
</Suspense>
|
|
</PageShell>
|
|
) : (
|
|
<PageShell
|
|
title={t(pageTitleKey(page))}
|
|
scrollKey={normalScrollKey}
|
|
maxWidth={maxWidth}
|
|
header={<Header />}
|
|
banners={
|
|
<>
|
|
<VerifyBanner entry={verifyEntry} />
|
|
<VersionBanner onOpen={() => openReleaseNotes(FRONTEND_VERSION)} />
|
|
</>
|
|
}
|
|
>
|
|
<Suspense fallback={pageFallback}>
|
|
<PageContent />
|
|
<PageHeaderDefault>
|
|
<PaletteLauncher />
|
|
</PageHeaderDefault>
|
|
</Suspense>
|
|
</PageShell>
|
|
)}
|
|
{/* 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. */}
|
|
<Toaster />
|
|
{/* The running downloads, bottom-right in the same column (the toasts keep the left). */}
|
|
<DownloadTray />
|
|
</div>
|
|
{/* 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. */}
|
|
<KeyboardHelp />
|
|
<AppCommandPalette />
|
|
{/* The one download dialog: the Files link box, a link pasted onto any page, the palette. */}
|
|
<DownloadIntake />
|
|
{/* 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. */}
|
|
<LinkedPlayer hosted={!!channelView || !!moduleDef(page).playsVideos} />
|
|
{/* The debug recorder: its start dialog, and its pill docked under the toasts. */}
|
|
{me && <DebugRecorder />}
|
|
<Suspense fallback={null}>
|
|
{aboutOpen && (
|
|
<About
|
|
onClose={() => setAboutOpen(false)}
|
|
onOpenReleaseNotes={() => {
|
|
setAboutOpen(false);
|
|
openReleaseNotes();
|
|
}}
|
|
/>
|
|
)}
|
|
{notesOpen && (
|
|
<ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} />
|
|
)}
|
|
</Suspense>
|
|
<ErrorDialog />
|
|
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
|
|
<BackToTop dep={channelView ? `chan:${channelView.id}` : page} />
|
|
</div>
|
|
);
|
|
}
|