Extend lib/queryKeys.ts to the remaining ~34 key roots and migrate every remaining literal (queryKey: [...] AND the positional useLiveQuery keys) in 36 files. All ~200 React Query keys across the app now route through the factory; zero raw key literals remain outside queryKeys.ts. The heterogeneous composite plex keys (plex-library/facets/collections/ playlists carry a filters object, a ratingKeys array, or a union/group discriminator) take a variadic pass-through — the factory centralizes the ROOT string; single-id keys stay precisely typed. Nullable keys (thread, playlist, ytSearch) accept null so a null segment is preserved. Pure substitution — byte-identical key arrays, so TanStack prefix matching is unchanged. Gate green: typecheck (app+node+e2e), eslint (0 err), prettier, 68 vitest. Runtime-verified on a fresh dev server: Feed + Plex modules load, feed/facets/tags/my-status/plex-library/plex-facets/ plex-collections/plex-playlists all 200, full UI renders.
445 lines
21 KiB
TypeScript
445 lines
21 KiB
TypeScript
import { lazy, Suspense, useEffect, useState } from "react";
|
|
import { qk } from "./lib/queryKeys";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api";
|
|
import { setLanguage, isSupported, type LangCode } from "./i18n";
|
|
import { hasFilterParams, stripUrlParams } from "./lib/urlState";
|
|
import type { Page } from "./lib/urlState";
|
|
import {
|
|
loadLayout,
|
|
normalizeLayout,
|
|
PREF_KEY,
|
|
saveLayoutLocal,
|
|
type PanelId,
|
|
type PanelLayout,
|
|
} from "./lib/panelLayout";
|
|
import { notify, removeByMetaKind } from "./lib/notifications";
|
|
import { LS, readAccount, setAccountRaw, writeAccount } from "./lib/storage";
|
|
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
|
|
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
|
|
import { useChannelLayout } from "./components/ChannelsProvider";
|
|
import { useFeedView, useFeedViewActions } from "./components/FeedViewProvider";
|
|
import { moduleDef } from "./lib/modules";
|
|
import { PAGE_CONTENT, PAGE_RAIL } from "./components/moduleRegistry";
|
|
// 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/BackToTop";
|
|
import PageShell from "./components/PageShell";
|
|
import GlassTuner from "./components/GlassTuner";
|
|
import ChatDock from "./components/ChatDock";
|
|
import Toaster from "./components/Toaster";
|
|
import { StateMessage } from "./components/QueryState";
|
|
import ErrorDialog from "./components/ErrorDialog";
|
|
import VersionBanner from "./components/VersionBanner";
|
|
import DemoBanner from "./components/DemoBanner";
|
|
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
|
|
// The built app version, NOT imported from releaseNotes — importing anything from that module
|
|
// pulls its whole ~900-line changelog into the eager App chunk and defeats the lazy ReleaseNotes
|
|
// split. FRONTEND_VERSION equals RELEASE_NOTES[0].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 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 = lazy(() => 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();
|
|
// 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 channel manager turns its top fade OFF for the table (its sticky header sits at the scroller
|
|
// top) but wants it ON for the card layout (no sticky header) — so the fade depends on the layout.
|
|
// Deliberately the NARROW layout context: subscribing to the manager's whole state here would
|
|
// re-render the app root on every keystroke in its search box.
|
|
const channelLayout = useChannelLayout();
|
|
// 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();
|
|
// 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.
|
|
const [panelLayouts, setPanelLayouts] = useState<Record<PanelId, PanelLayout>>(() => ({
|
|
feed: loadLayout("feed"),
|
|
plex: loadLayout("plex"),
|
|
playlists: loadLayout("playlists"),
|
|
}));
|
|
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(() => {});
|
|
}
|
|
|
|
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
|
|
// user's preferences so it follows the account across devices; a localStorage cache seeds the
|
|
// initial render synchronously so nothing flashes open before the server prefs arrive.
|
|
// Default unpinned (slim + hover-expand overlay); accounts that explicitly pin keep their choice.
|
|
const [navCollapsed, setNavCollapsedState] = useState<boolean>(() =>
|
|
Boolean(readAccount(LS.navCollapsed, true)),
|
|
);
|
|
// Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice.
|
|
const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
|
|
Boolean(readAccount(LS.filterCollapsed, true)),
|
|
);
|
|
// The Playlists rail has its own collapse flag (it's a list/navigator, not filters).
|
|
const [playlistsCollapsed, setPlaylistsCollapsedState] = useState<boolean>(() =>
|
|
Boolean(readAccount(LS.playlistsCollapsed, true)),
|
|
);
|
|
function setNavCollapsed(next: boolean) {
|
|
setNavCollapsedState(next);
|
|
writeAccount(LS.navCollapsed, next);
|
|
api.savePrefs({ navCollapsed: next }).catch(() => {});
|
|
}
|
|
function setFilterCollapsed(next: boolean) {
|
|
setFilterCollapsedState(next);
|
|
writeAccount(LS.filterCollapsed, next);
|
|
api.savePrefs({ filterCollapsed: next }).catch(() => {});
|
|
}
|
|
function setPlaylistsCollapsed(next: boolean) {
|
|
setPlaylistsCollapsedState(next);
|
|
writeAccount(LS.playlistsCollapsed, next);
|
|
api.savePrefs({ playlistsCollapsed: next }).catch(() => {});
|
|
}
|
|
// Filters live in localStorage, not the address bar. If we arrived via a "Share view"
|
|
// link, its params have already hydrated the initial state — persist them and strip the
|
|
// query so the URL stays clean from here on.
|
|
useEffect(() => {
|
|
const params = new URLSearchParams(window.location.search);
|
|
if (hasFilterParams(params) || params.has("page")) {
|
|
// A share link's filters are held in state; they're persisted to the account's key by the
|
|
// post-login effect (which knows the account id). Here we only clean the address bar.
|
|
stripUrlParams();
|
|
}
|
|
}, []);
|
|
|
|
const queryClient = useQueryClient();
|
|
// First-run install gate: a fresh instance reports configured=false and runs in setup mode.
|
|
// We check this before anything else and render the wizard; `me` is held back until we know the
|
|
// instance is set up (otherwise it would 503 against the setup-mode lock).
|
|
const setupQuery = useQuery({
|
|
queryKey: qk.setupStatus(),
|
|
queryFn: api.setupStatus,
|
|
staleTime: Infinity,
|
|
});
|
|
const needsSetup = setupQuery.data?.configured === false;
|
|
// Poll the session so an account suspended/deleted by an admin is noticed even with no
|
|
// interaction (option 1): the refetch 401s → the 401 handler below drops to the login page.
|
|
// Only poll while signed in (data present) — polling on the public login page is pointless and
|
|
// its periodic refetch caused a visible flicker there.
|
|
const meQuery = useQuery({
|
|
queryKey: qk.me(),
|
|
queryFn: api.me,
|
|
enabled: setupQuery.isSuccess && !needsSetup,
|
|
refetchInterval: (query) => (query.state.data ? 60_000 : false),
|
|
});
|
|
|
|
// Pin this tab to whatever account it first loaded as. Without this, a tab that never picked
|
|
// an account (it was riding the session's default) would silently swap identity when ANOTHER
|
|
// tab changes the default — e.g. that other tab adds/switches an account. Once pinned, this
|
|
// tab always sends its own account header, so cross-tab changes can't reach it. An explicit
|
|
// switch on THIS tab sets the override itself (so this no-ops then).
|
|
useEffect(() => {
|
|
if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id);
|
|
}, [meQuery.data?.id]);
|
|
|
|
// Once signed in, drop any leftover query string from the pre-login flow (e.g.
|
|
// ?access=requested, ?verify, ?reset). The logged-in app reads nothing from the URL, so the
|
|
// address bar stays clean. Gated on auth so the logged-out Welcome page still reads its params.
|
|
useEffect(() => {
|
|
if (meQuery.data) stripUrlParams();
|
|
}, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// 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.
|
|
useEffect(() => {
|
|
const result = new URLSearchParams(window.location.search).get("link");
|
|
if (!result) return;
|
|
if (result === "ok") {
|
|
notify({ level: "success", message: t("settings.account.googleLink.linked") });
|
|
void meQuery.refetch();
|
|
setAccountRaw(LS.settingsTab, "account");
|
|
setPage("settings");
|
|
} else {
|
|
const key = result === "conflict" || result === "mismatch" ? result : "error";
|
|
notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
|
|
}
|
|
stripUrlParams();
|
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// 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.
|
|
useEffect(() => {
|
|
if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return;
|
|
notify({ level: "info", message: t("settings.account.googleCancelled") });
|
|
stripUrlParams();
|
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to
|
|
// the admin Users → Access requests view. Snapshot the param so the pre-login URL strip doesn't
|
|
// drop it; 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"));
|
|
useEffect(() => {
|
|
if (adminDeepLink !== "access-requests" || !meQuery.data) return;
|
|
if (meQuery.data.role === "admin") {
|
|
focusAccessRequestsTab();
|
|
setPage("users");
|
|
}
|
|
stripUrlParams();
|
|
}, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// On login, adopt the server-stored SHELL preferences (panel layouts, rail collapse flags,
|
|
// language). The Settings-page prefs (theme/perf/hints/notifications) are adopted by PrefsProvider
|
|
// and the feed view by FeedViewProvider — each via its own passive qk.me() observer.
|
|
useEffect(() => {
|
|
const prefs = meQuery.data?.preferences;
|
|
if (!prefs) return;
|
|
const prefsRec = prefs as Record<string, unknown>;
|
|
(["feed", "plex", "playlists"] as PanelId[]).forEach((p) => {
|
|
const raw = prefsRec[PREF_KEY[p]];
|
|
if (raw) {
|
|
const l = normalizeLayout(p, raw);
|
|
setPanelLayouts((prev) => ({ ...prev, [p]: l }));
|
|
saveLayoutLocal(p, l);
|
|
}
|
|
});
|
|
if (typeof prefs.navCollapsed === "boolean") {
|
|
setNavCollapsedState(prefs.navCollapsed);
|
|
writeAccount(LS.navCollapsed, prefs.navCollapsed);
|
|
}
|
|
if (typeof prefs.filterCollapsed === "boolean") {
|
|
setFilterCollapsedState(prefs.filterCollapsed);
|
|
writeAccount(LS.filterCollapsed, prefs.filterCollapsed);
|
|
}
|
|
if (typeof prefsRec.playlistsCollapsed === "boolean") {
|
|
setPlaylistsCollapsedState(prefsRec.playlistsCollapsed);
|
|
writeAccount(LS.playlistsCollapsed, prefsRec.playlistsCollapsed);
|
|
}
|
|
if (isSupported(prefs.language)) setLanguage(prefs.language);
|
|
// (Per-account filters — incl. the demo account's whole-library default — are loaded by the
|
|
// dedicated effect above, which also covers accounts that have no stored preferences.)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [meQuery.data?.id]);
|
|
|
|
// 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).
|
|
useEffect(() => {
|
|
if (meQuery.data?.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");
|
|
const pending = meQuery.data.pending_invites ?? 0;
|
|
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();
|
|
setPage("users");
|
|
},
|
|
},
|
|
});
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [meQuery.data?.role, meQuery.data?.pending_invites]);
|
|
|
|
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
|
|
function changeLanguage(code: LangCode) {
|
|
setLanguage(code);
|
|
api.savePrefs({ language: code }).catch(() => {});
|
|
}
|
|
|
|
// First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
|
|
if (setupQuery.isLoading)
|
|
return (
|
|
<div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>
|
|
);
|
|
if (needsSetup) return <SetupWizard />;
|
|
|
|
if (meQuery.isLoading)
|
|
return (
|
|
<div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>
|
|
);
|
|
// /api/me answers 200 always now (null body = logged out), so a thrown error here is a real
|
|
// network/5xx failure, and no data means "not signed in" → the public landing.
|
|
if (meQuery.error)
|
|
return (
|
|
<div className="min-h-screen grid place-items-center">
|
|
<StateMessage tone="error" onRetry={() => meQuery.refetch()}>
|
|
{t("common.somethingWrong")}
|
|
</StateMessage>
|
|
</div>
|
|
);
|
|
if (!meQuery.data) return <Welcome />;
|
|
|
|
// Shown briefly while a lazy page/module chunk loads (first visit to that page only).
|
|
const pageFallback = (
|
|
<div className="flex-1 grid place-items-center text-muted">{t("common.loading")}</div>
|
|
);
|
|
|
|
// 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 me = meQuery.data!;
|
|
const pageGate = moduleDef(page).gate;
|
|
const activePage: Page = !pageGate || pageGate(me) ? page : "feed";
|
|
const { Component: PageContent, fadeTop } = PAGE_CONTENT[activePage];
|
|
const railDef = channelView ? undefined : PAGE_RAIL[activePage];
|
|
|
|
return (
|
|
<div className="h-screen flex text-fg">
|
|
<NavSidebar
|
|
me={meQuery.data!}
|
|
onOpenAbout={() => setAboutOpen(true)}
|
|
onChangeLanguage={changeLanguage}
|
|
language={i18n.language as LangCode}
|
|
collapsed={navCollapsed}
|
|
onToggleCollapse={() => setNavCollapsed(!navCollapsed)}
|
|
/>
|
|
{/* 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>
|
|
)}
|
|
<div className="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={meQuery.data!}
|
|
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
|
|
scrollKey={normalScrollKey}
|
|
// Channels: the registry disables the top fade for its sticky-header table, but the card
|
|
// layout has no sticky header, so re-enable it there.
|
|
fadeTop={activePage === "channels" ? channelLayout === "cards" : (fadeTop ?? true)}
|
|
header={<Header me={meQuery.data!} onYtSearch={enterYtSearch} />}
|
|
banners={
|
|
<>
|
|
{meQuery.data!.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>
|
|
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
|
|
<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} />
|
|
{/* Opt-in live appearance tuner (Settings → Appearance; off by default). */}
|
|
<GlassTuner />
|
|
</div>
|
|
);
|
|
}
|