import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler, type FeedFilters, } 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 boundary below. const Feed = lazy(() => import("./components/Feed")); const PlexBrowse = lazy(() => import("./components/PlexBrowse")); 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(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("feed"); const [theme, setThemeState] = useState(() => loadLocalTheme()); const [filters, setFiltersState] = useState(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(() => getNotifSettings()); const [savedPrefs, setSavedPrefs] = useState(() => ({ theme: loadLocalTheme(), view: "grid", performanceMode: getAccountRaw(PERF_KEY) === "1", hints: hintsEnabled(), notifications: getNotifSettings(), })); const [prefsSaveState, setPrefsSaveState] = useState("idle"); const saveMsgTimer = useRef(undefined); const [sidebarLayout, setSidebarLayoutState] = useState(loadLayout); const [page, setPageState] = useState(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(() => { // 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(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"; // 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(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(readAccount(LS.navCollapsed, false)) ); const [filterCollapsed, setFilterCollapsedState] = useState(() => 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 (
{t("common.loading")}
); if (needsSetup) return ; if (meQuery.isLoading) return (
{t("common.loading")}
); // /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 (
{t("common.somethingWrong")}
); if (!meQuery.data) return ; // Shown briefly while a lazy page/module chunk loads (first visit to that page only). const pageFallback = (
{t("common.loading")}
); return (
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 && ( setFilterCollapsed(!filterCollapsed)} /> )}
{channelView ? ( ) : ( <>
{meQuery.data!.is_demo && } openReleaseNotes(CURRENT_VERSION)} />
{page === "channels" ? ( 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" ? ( ) : page === "scheduler" && meQuery.data!.role === "admin" ? ( ) : page === "config" && meQuery.data!.role === "admin" ? ( ) : page === "users" && meQuery.data!.role === "admin" ? ( ) : page === "playlists" ? ( ) : page === "notifications" ? ( ) : page === "messages" && !meQuery.data!.is_demo ? (
) : page === "downloads" && !meQuery.data!.is_demo ? ( ) : page === "settings" ? ( setWizardOpen(true)} /> ) : meQuery.data!.plex_enabled && filters.librarySource === "plex" ? ( ) : ( setWizardOpen(true)} ytSearch={ytSearch} onYtSearch={enterYtSearch} onExitYtSearch={exitYtSearch} onOpenChannel={openChannel} /> )}
)} {/* 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. */}
{meQuery.data && !meQuery.data.is_demo && } {wizardOpen && meQuery.data && !meQuery.data.is_demo && ( setWizardOpen(false)} /> )} {aboutOpen && ( setAboutOpen(false)} onOpenReleaseNotes={() => { setAboutOpen(false); openReleaseNotes(); }} /> )} {notesOpen && ( setNotesOpen(false)} highlight={notesHighlight} /> )} {/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
); }