siftlode/frontend/src/App.tsx

887 lines
40 KiB
TypeScript
Raw Normal View History

import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
api,
EMPTY_PLEX_FILTERS,
getActiveAccount,
setActiveAccount,
setUnauthorizedHandler,
type FeedFilters,
type PlexFilters,
} from "./lib/api";
import { setLanguage, isSupported, type LangCode } from "./i18n";
import {
applyTheme,
DEFAULT_THEME,
loadLocalTheme,
saveLocalTheme,
type ThemePrefs,
} from "./lib/theme";
import { hasFilterParams, isPage, paramsToFilters, readPage, stripUrlParams, type Page } from "./lib/urlState";
import { pageTitleKey } from "./lib/pageMeta";
import {
loadLayout,
normalizeLayout,
saveLayoutLocal,
type SidebarLayout,
} from "./lib/sidebarLayout";
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
import {
accountKey,
getAccountRaw,
LS,
readAccount,
readJSON,
readMerged,
setAccountRaw,
useAccountPersistedState,
writeAccount,
writeJSON,
} from "./lib/storage";
import { useConfirm } from "./components/ConfirmProvider";
import type { PrefsController } from "./components/SettingsPanel";
import type { ChannelStatusFilter, ChannelsView } from "./components/Channels";
// 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 Sidebar from "./components/Sidebar";
import BackToTop from "./components/BackToTop";
import ChatDock from "./components/ChatDock";
import Toaster from "./components/Toaster";
import ErrorDialog from "./components/ErrorDialog";
import VersionBanner from "./components/VersionBanner";
import DemoBanner from "./components/DemoBanner";
import { setNavigator } from "./lib/nav";
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
import { CURRENT_VERSION } from "./lib/releaseNotes";
// Route-level code splitting: each module page (and the heavier modals) is its own lazy chunk,
// so the initial load — and the logged-out landing — pulls only the shell, and admin-only pages
// never reach non-admins. All are rendered inside a <Suspense> boundary below.
const Feed = lazy(() => import("./components/Feed"));
const PlexBrowse = lazy(() => import("./components/PlexBrowse"));
const PlexSidebar = lazy(() => import("./components/PlexSidebar"));
const ChannelPage = lazy(() => import("./components/ChannelPage"));
const Channels = lazy(() => import("./components/Channels"));
const Playlists = lazy(() => import("./components/Playlists"));
const Stats = lazy(() => import("./components/Stats"));
const Scheduler = lazy(() => import("./components/Scheduler"));
const ConfigPanel = lazy(() => import("./components/ConfigPanel"));
const AdminUsers = lazy(() => import("./components/AdminUsers"));
const SettingsPanel = lazy(() => import("./components/SettingsPanel"));
const NotificationsPanel = lazy(() => import("./components/NotificationsPanel"));
const Messages = lazy(() => import("./components/Messages"));
const DownloadCenter = lazy(() => import("./components/DownloadCenter"));
const OnboardingWizard = lazy(() => import("./components/OnboardingWizard"));
const About = lazy(() => import("./components/About"));
const ReleaseNotes = lazy(() => import("./components/ReleaseNotes"));
const DEFAULT_FILTERS: FeedFilters = {
tags: [],
tagMode: "or",
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
const FILTERS_KEY = LS.filters;
const PAGE_KEY = LS.page;
const PERF_KEY = LS.perfMode;
// The preferences edited on the Settings page. They apply locally for instant preview but
// persist to the server only on an explicit Save — so App holds both the live "draft" (the
// individual states below) and the last-saved "baseline" to compute dirty / revert on discard.
type EditablePrefs = {
theme: ThemePrefs;
view: "grid" | "list";
performanceMode: boolean;
hints: boolean;
notifications: NotifSettings;
};
// Page is navigation state, not a filter, but it's also kept out of the address bar — so
// persist it to localStorage to survive a reload. A share link's ?page= still wins.
function loadInitialPage(): Page {
const params = new URLSearchParams(window.location.search);
if (params.has("page")) return readPage();
const stored = getAccountRaw(PAGE_KEY);
return isPage(stored) ? stored : "feed";
}
// This account's own persisted filters (per-account keys — never another account's). A chosen
// default saved view wins over the last-session filters (SavedViewsWidget mirrors it here). When
// the account isn't known yet (first load, before the tab is pinned), start from defaults; the
// post-login effect loads the real ones once the id arrives.
function loadAccountFilters(id: number | null): FeedFilters {
// Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. The
// starred default view only SEEDS a fresh account that has never stored filters: it drives the
// very first load, after which that state persists like any other. (Re-apply the default view
// from the sidebar to return to it.)
const fKey = accountKey(LS.filters, id);
let hasStored = false;
try {
hasStored = !!fKey && localStorage.getItem(fKey) != null;
} catch {
/* localStorage may be unavailable */
}
if (hasStored && fKey) return readMerged(fKey, DEFAULT_FILTERS);
const dvKey = accountKey(LS.defaultViewFilters, id);
const def = dvKey ? readJSON<FeedFilters | null>(dvKey, null) : null;
if (def) return { ...DEFAULT_FILTERS, ...def };
return { ...DEFAULT_FILTERS };
}
// URL wins over localStorage so a pasted link reproduces the exact view.
function loadInitialFilters(): FeedFilters {
const params = new URLSearchParams(window.location.search);
if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS);
return loadAccountFilters(getActiveAccount());
}
export default function App() {
const { t, i18n } = useTranslation();
const confirm = useConfirm();
// Refs the (stable) navigation handlers read so they always see the latest unsaved-prefs
// state without being recreated; populated by an effect each render (below).
const prefsDirtyRef = useRef(false);
const discardRef = useRef<() => void>(() => {});
const confirmRef = useRef(confirm);
const pageRef = useRef<Page>("feed");
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
// Whether this load arrived via a "Share view" link (captured once, before the URL is stripped)
// — such filters win over the account's stored view and are persisted to the account instead.
const [initialHadUrlFilters] = useState(() =>
hasFilterParams(new URLSearchParams(window.location.search))
);
const [view, setView] = useState<"grid" | "list">("grid");
// Settings-page prefs draft (apply live, persist on Save — see EditablePrefs).
const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1");
const [hints, setHints] = useState(() => hintsEnabled());
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
const [savedPrefs, setSavedPrefs] = useState<EditablePrefs>(() => ({
theme: loadLocalTheme(),
view: "grid",
performanceMode: getAccountRaw(PERF_KEY) === "1",
hints: hintsEnabled(),
notifications: getNotifSettings(),
}));
const [prefsSaveState, setPrefsSaveState] = useState<PrefsController["saveState"]>("idle");
const saveMsgTimer = useRef<number | undefined>(undefined);
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
const [page, setPageState] = useState<Page>(loadInitialPage);
// Live YouTube search term (null = normal feed). Ephemeral: not persisted and not in the URL,
// so a reload returns to the normal feed (a search spends quota, so we never auto-replay it).
// The search is a feed SUB-VIEW that owns a browser-history entry (history.state._yt): the
// popstate handler below derives ytSearch from it, so Back steps player → search → feed in
// that order (a player opened over the results pops first, then the search, then the feed) —
// instead of the search vanishing because it had no history entry of its own.
const [ytSearch, setYtSearch] = useState<string | null>(() => {
// Restore a live search across a reload ONLY when it was served by the zero-quota scrape
// source (re-fetching is free) — an api-source search would re-spend ~100 units, so it's
// dropped to the normal feed as before. The scrape flag rides in history.state (survives F5).
const st = window.history.state;
return st?._yt && st?._ytScrape ? (st._yt as string) : null;
});
const enterYtSearch = useCallback((q: string) => {
setYtSearch(q);
// Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps
// it once the results resolve, so a reload only restores a confirmed scrape-sourced search.
const { _ytScrape: _drop, ...st } = window.history.state || {};
if (st._yt) window.history.replaceState({ ...st, _yt: q }, ""); // refine current search
else window.history.pushState({ ...st, sfPage: "feed", _yt: q }, ""); // new sub-view entry
}, []);
const exitYtSearch = useCallback(() => {
// Pop our search entry (so Forward still works); fall back to a plain clear if we don't own one.
if (window.history.state?._yt) window.history.back();
else setYtSearch(null);
}, []);
// The open channel page (null = no channel page). Like the YouTube-search sub-view it owns a
// browser-history entry (history.state._chan = channel id, _chanName = a display name to show
// before the detail loads), so Back closes the channel page (after any player opened over it)
// and reload returns to the normal app. Channels/videos are reached by id, so it's not in the URL.
const [channelView, setChannelView] = useState<{ id: string; name?: string } | null>(() => {
// Restore the open channel page across a reload (it rides in history.state, which survives F5),
// unlike the YouTube search which is intentionally dropped (it would re-spend quota).
const c = window.history.state?._chan as string | undefined;
return c ? { id: c, name: (window.history.state?._chanName as string) ?? undefined } : null;
});
const openChannel = useCallback((id: string, name?: string) => {
setChannelView({ id, name });
const st = window.history.state || {};
if (st._chan)
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "");
else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "");
}, []);
const closeChannel = useCallback(() => {
if (window.history.state?._chan) window.history.back();
else setChannelView(null);
}, []);
const [wizardOpen, setWizardOpen] = useState(false);
// Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
const [channelFilterRaw, setChannelFilter] = useAccountPersistedState(LS.channelFilter, "all");
const channelFilter: ChannelStatusFilter = (
["needs_full", "fully_synced", "hidden", "all"] as const
).includes(channelFilterRaw as ChannelStatusFilter)
? (channelFilterRaw as ChannelStatusFilter)
: "all";
const [aboutOpen, setAboutOpen] = useState(false);
const [notesOpen, setNotesOpen] = useState(false);
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
// The Channel manager's active tab (subscriptions vs playlist discovery). Lifted here and
// persisted so navigation intents that target the subscriptions table — the header's
// "without full history" link, focus-channel — can force it back to "subscribed" instead
// of dumping the user on whatever tab they last left open.
const [channelsViewRaw, setChannelsView] = useAccountPersistedState(LS.channelsView, "subscribed");
const channelsView: ChannelsView =
channelsViewRaw === "discovery" ? "discovery" : "subscribed";
// Plex module filters (its own left-sidebar filter section) — per-account persisted.
const [plexLib, setPlexLib] = useAccountPersistedState(LS.plexLibrary, "");
const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all");
const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "added");
const [plexPlaylistOpen, setPlexPlaylistOpen] = useState<number | null>(null); // sidebar → open a playlist
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
const [plexFiltersRaw, setPlexFiltersRaw] = useAccountPersistedState(LS.plexFilters, "{}");
const plexFilters = useMemo<PlexFilters>(() => {
try {
return { ...EMPTY_PLEX_FILTERS, ...JSON.parse(plexFiltersRaw) };
} catch {
return EMPTY_PLEX_FILTERS;
}
}, [plexFiltersRaw]);
const setPlexFilters = (f: PlexFilters) => setPlexFiltersRaw(JSON.stringify(f));
// The Plex library search term is its OWN ephemeral state — deliberately NOT the persisted feed
// `filters.q`. Sharing that box meant a Plex search leaked into the feed and, being persisted,
// greeted you with a stale query (colliding with a persisted collection filter → confusing
// "0 matches") after a reload. Kept in App so it survives page switches within a session, but
// resets on reload.
const [plexQ, setPlexQ] = useState("");
// Bumped to tell the channel manager to drop a stale column filter when we send the user
// there to see a specific set (the header's "without full history" link).
const [channelsFilterReset, setChannelsFilterReset] = useState(0);
// "Focus this channel in the manager": jump to the Channels page and seed its name filter
// so the channel is isolated. Cleared when leaving the page so it doesn't re-apply later.
const [focusChannelName, setFocusChannelName] = useState<string | null>(null);
const focusChannel = (name: string) => {
setFocusChannelName(name);
setChannelsView("subscribed"); // the name filter applies to the subscriptions table
setPage("channels");
};
useEffect(() => {
if (page !== "channels") setFocusChannelName(null);
// Leaving the feed ends any live YouTube search, so coming back shows the normal feed.
if (page !== "feed") setYtSearch(null);
}, [page]);
function openReleaseNotes(highlight?: string) {
setNotesHighlight(highlight);
setNotesOpen(true);
}
function setFilters(next: FeedFilters) {
setFiltersState(next);
// Persist under THIS tab's active account so filters don't bleed between accounts.
const key = accountKey(FILTERS_KEY, getActiveAccount());
if (key) writeJSON(key, next);
}
function setPage(next: Page) {
// While a channel page is open it overlays the content column, so a nav click must close it
// even when the underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
if (next === page && !channelView) return;
const go = () => {
setChannelView(null); // leave any open channel page when navigating via the rail
setPageState(next);
setAccountRaw(PAGE_KEY, next);
// Push an in-app history entry so the browser/mouse Back button steps through pages
// instead of leaving the app (e.g. back to the OAuth redirect). The URL stays clean —
// the page rides in history.state, not the query string (filters never go in the URL).
// A fresh { sfPage } (no carried-over _sub/_ov/_chan markers) so each page starts at its root.
window.history.pushState({ sfPage: next }, "");
};
// Guard leaving the Settings page with unsaved preference changes.
if (page === "settings" && prefsDirtyRef.current) {
void confirmRef.current({
title: t("settings.unsaved.title"),
message: t("settings.unsaved.message"),
confirmLabel: t("settings.unsaved.discard"),
danger: true,
}).then((ok) => {
if (!ok) return;
discardRef.current();
go();
});
return;
}
go();
}
// Expose the current setPage to decoupled callers (download toast "View", etc.).
useEffect(() => setNavigator(setPage));
// Reflect the current module in the browser tab title so open tabs are distinguishable and the
// history reads sensibly. Feed = brand only; an open channel page shows the channel name.
useEffect(() => {
const brand = "Siftlode";
const label = channelView
? channelView.name || t("header.channelManager")
: page === "feed"
? null
: t(pageTitleKey(page));
document.title = label ? `${label} · ${brand}` : brand;
}, [page, channelView, i18n.language, t]);
function setSidebarLayout(next: SidebarLayout) {
setSidebarLayoutState(next);
saveLayoutLocal(next);
api.savePrefs({ sidebarLayout: 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.
const [navCollapsed, setNavCollapsedState] = useState<boolean>(() =>
Boolean(readAccount(LS.navCollapsed, false))
);
const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
Boolean(readAccount(LS.filterCollapsed, false))
);
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(() => {});
}
useEffect(() => applyTheme(theme), [theme]);
// Apply the draft prefs locally for instant preview (their localStorage mirrors update via
// the stores too); persistence to the server is deferred to an explicit Save.
useEffect(() => {
document.documentElement.dataset.perf = perf ? "1" : "";
setAccountRaw(PERF_KEY, perf ? "1" : "0");
}, [perf]);
useEffect(() => setHintsEnabled(hints), [hints]);
useEffect(() => configureNotifications(notif), [notif]);
// 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();
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// In-app Back/Forward: stamp the initial entry with the current page, then sync `page`
// from history.state on popstate. (stripUrlParams preserves history.state, so this stamp
// survives a later query-string strip.)
useEffect(() => {
// A reload returns to the normal feed by dropping a stale _yt — EXCEPT when the search was
// scrape-sourced (zero quota): then we keep _yt + _ytScrape so it restores and re-fetches for
// free (ytSearch above already initialised from it). An api-source search is still dropped (it
// would re-spend ~100 units). _chan/_chanName are always kept so a channel page survives F5.
const st = window.history.state || {};
if (st._yt && st._ytScrape) {
window.history.replaceState({ ...st, sfPage: page }, "");
} else {
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
window.history.replaceState({ ...rest, sfPage: page }, "");
}
function onPop(e: PopStateEvent) {
const p = (e.state?.sfPage as Page) ?? "feed";
// Guard a Back step that leaves Settings with unsaved changes: re-assert the Settings
// entry so nothing is silently stranded, then ask; on discard, go where Back headed.
if (pageRef.current === "settings" && p !== "settings" && prefsDirtyRef.current) {
window.history.pushState({ ...window.history.state, sfPage: "settings" }, "");
void confirmRef.current({
title: t("settings.unsaved.title"),
message: t("settings.unsaved.message"),
confirmLabel: t("settings.unsaved.discard"),
danger: true,
}).then((ok) => {
if (!ok) return;
discardRef.current();
setPageState(p);
setAccountRaw(PAGE_KEY, p);
});
return;
}
setPageState(p);
setAccountRaw(PAGE_KEY, p);
// The YouTube-search sub-view rides in history.state._yt, so Back/Forward restores or
// clears it to match the entry we landed on (and a player popped first leaves it intact).
setYtSearch((e.state?._yt as string) ?? null);
// The channel page rides in history.state._chan the same way.
const chan = e.state?._chan as string | undefined;
setChannelView(chan ? { id: chan, name: (e.state?._chanName as string) ?? undefined } : null);
}
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
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: ["setup-status"],
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: ["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]);
// Load THIS account's own filters when the account first resolves or changes (login / switch /
// add). Filters are per-account localStorage state, so another account's last view (incl. its
// default saved view) must never carry over. A share link's filters (captured at mount) win and
// are persisted to the account instead. Runs on id change only, so a 60s background refetch of
// the same account never clobbers the filters you're editing.
useEffect(() => {
const id = meQuery.data?.id;
if (id == null) return;
if (initialHadUrlFilters) {
const key = accountKey(FILTERS_KEY, id);
if (key) writeJSON(key, filters);
return;
}
let f = loadAccountFilters(id);
// The shared demo account has no subscriptions, so default it to the whole library.
if (meQuery.data?.is_demo) f = { ...f, scope: "all" };
setFiltersState(f);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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(["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
// First-login onboarding: prompt the user to connect YouTube (and resume the flow after
// each consent redirect). Derived from granted scopes + storage flags so it's stable
// across the full-page OAuth round-trip; dismissible and reopenable from Settings.
useEffect(() => {
if (meQuery.data && shouldAutoOpenOnboarding(meQuery.data)) setWizardOpen(true);
}, [meQuery.data?.id, meQuery.data?.can_read, meQuery.data?.can_write]);
// On login, adopt server-stored preferences.
useEffect(() => {
const prefs = meQuery.data?.preferences;
if (!prefs) return;
// Adopt the server prefs as both the saved baseline and the live draft. The runtime
// effects above apply the draft (theme/perf/hints/notifications) for display.
const adopted: EditablePrefs = {
theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
view: prefs.view === "grid" || prefs.view === "list" ? prefs.view : "grid",
performanceMode:
typeof prefs.performanceMode === "boolean"
? prefs.performanceMode
: getAccountRaw(PERF_KEY) === "1",
hints: typeof prefs.hints === "boolean" ? prefs.hints : hintsEnabled(),
notifications: prefs.notifications
? { ...getNotifSettings(), ...prefs.notifications }
: getNotifSettings(),
};
setThemeState(adopted.theme);
saveLocalTheme(adopted.theme);
setView(adopted.view);
setPerf(adopted.performanceMode);
setHints(adopted.hints);
setNotif(adopted.notifications);
setSavedPrefs(adopted);
// Out-of-scope prefs stay instant (edited outside the Settings page).
if (prefs.sidebarLayout) {
const l = normalizeLayout(prefs.sidebarLayout);
setSidebarLayoutState(l);
saveLayoutLocal(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 (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]);
// Theme is part of the Settings draft: apply locally for preview, persist on Save.
function setTheme(next: ThemePrefs) {
setThemeState(next);
saveLocalTheme(next);
}
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
function changeLanguage(code: LangCode) {
setLanguage(code);
api.savePrefs({ language: code }).catch(() => {});
}
const prefsDirty =
JSON.stringify({ theme, view, performanceMode: perf, hints, notifications: notif }) !==
JSON.stringify(savedPrefs);
const savePrefs = useCallback(() => {
const payload: EditablePrefs = { theme, view, performanceMode: perf, hints, notifications: notif };
window.clearTimeout(saveMsgTimer.current);
setPrefsSaveState("saving");
api
.savePrefs(payload)
.then(() => {
setSavedPrefs(payload);
setPrefsSaveState("saved");
saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 2500);
})
.catch(() => {
// api.req() already surfaces the failure (connection-lost status / error dialog);
// just flag the bar so the user sees the save didn't take, then clear it.
setPrefsSaveState("error");
saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 4000);
});
}, [theme, view, perf, hints, notif]);
const discardPrefs = useCallback(() => {
setThemeState(savedPrefs.theme);
saveLocalTheme(savedPrefs.theme);
setView(savedPrefs.view);
setPerf(savedPrefs.performanceMode);
setHints(savedPrefs.hints);
setNotif(savedPrefs.notifications);
window.clearTimeout(saveMsgTimer.current);
setPrefsSaveState("idle");
}, [savedPrefs]);
const prefsCtl: PrefsController = {
theme,
setTheme,
view,
setView,
perf,
setPerf,
hints,
setHints,
notif,
setNotif,
dirty: prefsDirty,
save: savePrefs,
discard: discardPrefs,
saveState: prefsSaveState,
};
// Keep the navigation guards (setPage / popstate) reading the latest values.
useEffect(() => {
prefsDirtyRef.current = prefsDirty;
discardRef.current = discardPrefs;
confirmRef.current = confirm;
pageRef.current = page;
});
// No beforeunload guard: a reload/close can only raise the browser's own native prompt
// (we can't show our in-app confirm there), and unsaved prefs are local-only — they
// revert to the saved server baseline on the next load — so there's nothing to lose.
// 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 text-muted">
{t("common.somethingWrong")}
</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>
);
return (
<div className="h-screen flex bg-bg text-fg">
<NavSidebar
me={meQuery.data!}
page={page}
setPage={setPage}
onOpenAbout={() => setAboutOpen(true)}
onChangeLanguage={changeLanguage}
language={i18n.language as LangCode}
collapsed={navCollapsed}
onToggleCollapse={() => setNavCollapsed(!navCollapsed)}
onGoToFullHistory={() => {
setChannelFilter("needs_full");
setChannelsView("subscribed"); // the status filter applies to the subscriptions tab
setChannelsFilterReset((n) => n + 1); // drop any stale column filter hiding the rows
setPage("channels");
}}
/>
{/* Full-height filter sidebar: feed only, and hidden while a channel page overlays the
content column. Sits beside the nav rail as its own collapsible column. */}
{page === "feed" && !channelView && (
<Sidebar
filters={filters}
setFilters={setFilters}
layout={sidebarLayout}
setLayout={setSidebarLayout}
onFocusChannel={focusChannel}
isDemo={meQuery.data!.is_demo}
collapsed={filterCollapsed}
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
/>
)}
{page === "plex" && meQuery.data!.plex_enabled && (
<Suspense fallback={null}>
<PlexSidebar
library={plexLib}
setLibrary={setPlexLib}
show={plexShowFilter}
setShow={setPlexShowFilter}
sort={plexSort}
setSort={setPlexSort}
filters={plexFilters}
setFilters={setPlexFilters}
onOpenPlaylist={setPlexPlaylistOpen}
collapsed={filterCollapsed}
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
/>
</Suspense>
)}
<div className="relative flex-1 min-w-0 flex flex-col">
{channelView ? (
<Suspense fallback={pageFallback}>
<ChannelPage
key={channelView.id}
channelId={channelView.id}
initialName={channelView.name}
me={meQuery.data!}
view={view}
onBack={closeChannel}
onOpenChannel={openChannel}
/>
</Suspense>
) : (
<>
<Header
me={meQuery.data!}
filters={filters}
setFilters={setFilters}
plexQ={plexQ}
setPlexQ={setPlexQ}
page={page}
onYtSearch={enterYtSearch}
/>
{meQuery.data!.is_demo && <DemoBanner />}
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
<main className="flex-1 min-w-0 overflow-y-auto">
<Suspense fallback={pageFallback}>
{page === "channels" ? (
<Channels
canWrite={meQuery.data!.can_write}
isAdmin={meQuery.data!.role === "admin"}
focusChannelName={focusChannelName}
onFocusChannel={focusChannel}
statusFilter={channelFilter}
setStatusFilter={setChannelFilter}
view={channelsView}
setView={setChannelsView}
filtersResetToken={channelsFilterReset}
onOpenWizard={() => setWizardOpen(true)}
onViewChannel={(id, name) => openChannel(id, name)}
onFilterByTag={(tagId, name) => {
setFilters({ ...filters, tags: [tagId], channelId: undefined, channelName: undefined });
setPage("feed");
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
}}
/>
) : page === "stats" ? (
<Stats me={meQuery.data!} />
) : page === "scheduler" && meQuery.data!.role === "admin" ? (
<Scheduler />
) : page === "config" && meQuery.data!.role === "admin" ? (
<ConfigPanel />
) : page === "users" && meQuery.data!.role === "admin" ? (
<AdminUsers me={meQuery.data!} />
) : page === "playlists" ? (
<Playlists canWrite={meQuery.data!.can_write} />
) : page === "notifications" ? (
<NotificationsPanel filters={filters} setFilters={setFilters} setPage={setPage} onFocusChannel={focusChannel} />
) : page === "messages" && !meQuery.data!.is_demo ? (
<div className="p-4 max-w-3xl w-full mx-auto">
<Messages meId={meQuery.data!.id} />
</div>
) : page === "downloads" && !meQuery.data!.is_demo ? (
<DownloadCenter me={meQuery.data!} />
) : page === "settings" ? (
<SettingsPanel
me={meQuery.data!}
prefs={prefsCtl}
onOpenWizard={() => setWizardOpen(true)}
/>
) : page === "plex" && meQuery.data!.plex_enabled ? (
<PlexBrowse
q={plexQ}
onClearSearch={() => setPlexQ("")}
library={plexLib}
show={plexShowFilter}
sort={plexSort}
filters={plexFilters}
setFilters={setPlexFilters}
openPlaylist={plexPlaylistOpen}
onPlaylistOpened={() => setPlexPlaylistOpen(null)}
/>
) : (
<Feed
filters={filters}
setFilters={setFilters}
view={view}
canRead={meQuery.data!.can_read}
isDemo={meQuery.data!.is_demo}
onOpenWizard={() => setWizardOpen(true)}
ytSearch={ytSearch}
onYtSearch={enterYtSearch}
onExitYtSearch={exitYtSearch}
onOpenChannel={openChannel}
/>
)}
</Suspense>
</main>
</>
)}
{/* 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}>
{wizardOpen && meQuery.data && !meQuery.data.is_demo && (
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
)}
{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>
);
}