- PageShell names the page with a visually hidden h1; a view with its own title renders it as PageHeading and the hidden one steps aside (contract: no hand <h1> inside the shell). - Section captions are SectionLabel headings (h2, h3 in a dialog), ui/form Section included. - "Skip to content" is the first Tab stop and focuses the scroller's <main>. - Below 600px of height the band's filter row folds into "Filters · n" (Feed, Plex, server log), the same row shown in place. - The band holds controls only: Downloads and Notifications lose their title cards, the Config and channel-discovery intros scroll with the content, the Stats tabs and the audit pager join the band, and the server log's toolbar sticks with the fade under it.
484 lines
25 KiB
TypeScript
484 lines
25 KiB
TypeScript
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
|
import { qk } from "./lib/queryKeys";
|
|
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 type { Page } from "./lib/urlState";
|
|
import {
|
|
PREF_KEY,
|
|
saveLayoutLocal,
|
|
seedLayout,
|
|
type PanelId,
|
|
type PanelLayout,
|
|
} from "./lib/panelLayout";
|
|
import { notify, removeByMetaKind } from "./lib/notifications";
|
|
import { LS, setAccountRaw } 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 { moduleDef, 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 ChatDock from "./components/ChatDock";
|
|
import DebugRecorder from "./components/DebugRecorder";
|
|
import Toaster from "./components/ui/Toaster";
|
|
import KeyboardHelp from "./components/ui/KeyboardHelp";
|
|
import AppCommandPalette from "./components/AppCommandPalette";
|
|
import { useGlobalKeymap } from "./lib/keymap";
|
|
import ErrorDialog from "./components/ErrorDialog";
|
|
import VersionBanner from "./components/VersionBanner";
|
|
import DemoBanner from "./components/DemoBanner";
|
|
import VerifyBanner from "./components/VerifyBanner";
|
|
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
|
|
// 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),
|
|
};
|
|
});
|
|
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 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 (E22 S5), 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 (E16) 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]);
|
|
|
|
// 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.
|
|
useEffect(() => {
|
|
setUnauthorizedHandler(() => {
|
|
if (queryClient.getQueryData(qk.me())) {
|
|
queryClient.clear();
|
|
window.location.reload();
|
|
}
|
|
});
|
|
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();
|
|
setAccountRaw(LS.settingsTab, "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 (add-another-account, link, or upgrade) 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.
|
|
api
|
|
.savePrefs({ language: 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 (a gated module via stale nav / a deep link) falls back to the feed,
|
|
// exactly as the old ternary's fallthrough did. A channel page overlays the content column, so no
|
|
// side rail shows behind it.
|
|
const pageGate = moduleDef(page).gate;
|
|
const activePage: Page = !pageGate || pageGate(me!) ? page : "feed";
|
|
const { Component: PageContent, maxWidth } = PAGE_CONTENT[activePage];
|
|
const railDef = channelView ? undefined : PAGE_RAIL[activePage];
|
|
|
|
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 has its own. */}
|
|
{railDef && (
|
|
<Suspense fallback={null}>
|
|
<railDef.Rail
|
|
layout={panelLayouts[railDef.panelId]}
|
|
setLayout={(l) => setPanelLayout(railDef.panelId, l)}
|
|
collapsed={railDef.collapse === "playlists" ? playlistsCollapsed : filterCollapsed}
|
|
onToggleCollapse={() =>
|
|
railDef.collapse === "playlists"
|
|
? setPlaylistsCollapsed(!playlistsCollapsed)
|
|
: setFilterCollapsed(!filterCollapsed)
|
|
}
|
|
/>
|
|
</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 (E5 S7). 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(activePage))}
|
|
scrollKey={normalScrollKey}
|
|
maxWidth={maxWidth}
|
|
header={<Header me={me!} onYtSearch={enterYtSearch} />}
|
|
banners={
|
|
<>
|
|
<VerifyBanner entry={verifyEntry} />
|
|
{me!.is_demo && <DemoBanner />}
|
|
<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 />
|
|
</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 />
|
|
{me && !me.is_demo && <ChatDock meId={me.id} />}
|
|
{/* The debug recorder (E16): its start dialog, and its pill docked under the toasts. */}
|
|
{me && !me.is_demo && <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>
|
|
);
|
|
}
|