Files
siftlode/frontend/src/shell/App.tsx
T
peter fa893c73a3 feat(player): the YouTube player and the file viewer are named in the address
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.
2026-10-27 05:29:49 +01:00

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>
);
}