- /auth/google/connect is a navigation and carries no headers: the tab names the account it was built for in `expect`, checked by the same comparison as the request header (built_for_another_account); a mismatch lands on /?account=changed - the link marker names the OAuth state of its own flow: an older flow coming back no longer spends the marker of the connect still open (which then returned as a plain sign-in) - a 401 account_changed reloads the tab with a one-time flag, and the reloaded tab says it now shows the account signed in - a refused switch to a suspended or not-yet-approved account says so in the signed-in app's words, naming the address a request was recorded for - the prefs merge locks FOR NO KEY UPDATE, so a transaction that only added a row referring to the account does not hold the save up; a test pins the lock - giving up a lost page closes a channel page open over it - e2e README names the chromium-only specs
500 lines
26 KiB
TypeScript
500 lines
26 KiB
TypeScript
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 <Feed> 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 <Feed>/<ChannelPage>.
|
|
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<Record<PanelId, PanelLayout>>(() => {
|
|
const prefs = queryClient.getQueryData<Me | null>(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<string | undefined>(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<boolean>("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<boolean>("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<boolean>("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 <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/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 (
|
|
<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. feed + plex share the filter-rail collapse flag, playlists and files have their own. */}
|
|
{railDef && (
|
|
<Suspense fallback={null}>
|
|
<railDef.Rail
|
|
layout={panelLayouts[railDef.panelId]}
|
|
setLayout={(l) => setPanelLayout(railDef.panelId, l)}
|
|
collapsed={railCollapse[railDef.collapse][0]}
|
|
onToggleCollapse={() => {
|
|
const [collapsed, setCollapsed] = railCollapse[railDef.collapse];
|
|
setCollapsed(!collapsed);
|
|
}}
|
|
/>
|
|
</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!}
|
|
view={view}
|
|
setView={setView}
|
|
onShowInFeed={() => {
|
|
// 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");
|
|
}}
|
|
/>
|
|
</Suspense>
|
|
</PageShell>
|
|
) : (
|
|
<PageShell
|
|
title={t(pageTitleKey(page))}
|
|
scrollKey={normalScrollKey}
|
|
maxWidth={maxWidth}
|
|
header={<Header me={me!} onYtSearch={enterYtSearch} />}
|
|
banners={
|
|
<>
|
|
<VerifyBanner entry={verifyEntry} />
|
|
<VersionBanner onOpen={() => openReleaseNotes(FRONTEND_VERSION)} />
|
|
</>
|
|
}
|
|
>
|
|
<Suspense fallback={pageFallback}>
|
|
<PageContent />
|
|
</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 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>
|
|
);
|
|
}
|