The channel page rendered its own PageScroller as a separate App branch — the source of the app's two <main>s. Make it a headerless/rail-less PageShell config (its banner scrolls from the top) so there is exactly ONE page scroller. Verified: single <main>, banner scrolls, in-page and browser Back both return to the feed; E2E 11/11.
861 lines
39 KiB
TypeScript
861 lines
39 KiB
TypeScript
import { lazy, Suspense, 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, paramsToFilters, stripUrlParams } from "./lib/urlState";
|
|
import {
|
|
loadLayout,
|
|
normalizeLayout,
|
|
PREF_KEY,
|
|
saveLayoutLocal,
|
|
type PanelId,
|
|
type PanelLayout,
|
|
} from "./lib/panelLayout";
|
|
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
|
|
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
|
|
import { parseFeedView, type FeedView } from "./lib/feedView";
|
|
import {
|
|
accountKey,
|
|
getAccountRaw,
|
|
LS,
|
|
readAccount,
|
|
readJSON,
|
|
readMerged,
|
|
setAccountRaw,
|
|
useAccountPersistedState,
|
|
writeAccount,
|
|
writeJSON,
|
|
} from "./lib/storage";
|
|
import type { PrefsController } from "./components/SettingsPanel";
|
|
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
|
|
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 PlaylistsRail from "./components/PlaylistsRail";
|
|
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 ErrorDialog from "./components/ErrorDialog";
|
|
import VersionBanner from "./components/VersionBanner";
|
|
import DemoBanner from "./components/DemoBanner";
|
|
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 AuditLog = lazy(() => import("./components/AuditLog"));
|
|
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",
|
|
channelIds: [],
|
|
q: "",
|
|
sort: "newest",
|
|
scope: "my",
|
|
includeNormal: true,
|
|
includeShorts: false,
|
|
includeLive: false,
|
|
show: "unwatched",
|
|
};
|
|
|
|
const FILTERS_KEY = LS.filters;
|
|
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;
|
|
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.
|
|
// 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 { page, channelView, ytSearch } = useNavigation();
|
|
const { setPage, openChannel, enterYtSearch, exitYtSearch } = useNavigationActions();
|
|
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))
|
|
);
|
|
// The feed's view mode is picked from the feed's own toolbar; like every Settings pref it
|
|
// persists the moment you choose it.
|
|
const [view, setViewState] = useState<FeedView>(() => parseFeedView(readAccount(LS.feedView, null)));
|
|
// Settings-page prefs: applied live by the effects below, and auto-saved (debounced) on change
|
|
// — no draft, no Save button (see the auto-save effect).
|
|
const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1");
|
|
const [hints, setHints] = useState(() => hintsEnabled());
|
|
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
|
|
// The last server-known prefs. The same states are ALSO written when we adopt server prefs on
|
|
// login/account-switch, so the auto-save effect compares against this baseline to avoid echoing
|
|
// a server-adopted value straight back. A ref (drives no UI); updated on adopt + after each save.
|
|
const baselineRef = useRef<EditablePrefs>({
|
|
theme: loadLocalTheme(),
|
|
performanceMode: getAccountRaw(PERF_KEY) === "1",
|
|
hints: hintsEnabled(),
|
|
notifications: getNotifSettings(),
|
|
});
|
|
const [prefsSaveState, setPrefsSaveState] = useState<PrefsController["saveState"]>("idle");
|
|
const saveMsgTimer = useRef<number | undefined>(undefined);
|
|
const saveTimer = useRef<number | undefined>(undefined);
|
|
// 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"),
|
|
}));
|
|
// Client-side name filters for the Channels + Playlists lists — lifted here so the shared top
|
|
// SearchBar can drive them (the modules read/seed them via props).
|
|
const [channelsSearch, setChannelsSearch] = useState("");
|
|
const [playlistsSearch, setPlaylistsSearch] = useState("");
|
|
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.
|
|
// The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
|
|
// (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".)
|
|
const [plexScopeRaw, setPlexScope] = useAccountPersistedState(LS.plexLibrary, "both");
|
|
const plexScope = ["movie", "show", "both"].includes(plexScopeRaw) ? plexScopeRaw : "both";
|
|
const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all");
|
|
const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "title");
|
|
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] = useAccountPersistedState(LS.plexQ, ""); // survives F5, unlike the feed search
|
|
// 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 [focusChannelToken, setFocusChannelToken] = useState(0);
|
|
const focusChannel = (name: string) => {
|
|
setFocusChannelName(name);
|
|
setFocusChannelToken((n) => n + 1); // re-seed even when the same channel is re-focused
|
|
setChannelsView("subscribed"); // the name filter applies to the subscriptions table
|
|
setPage("channels");
|
|
};
|
|
useEffect(() => {
|
|
// Clear the channel-manager focus when leaving the Channels page so it doesn't re-apply later.
|
|
// (Leaving the feed clearing the live search is now the NavigationProvider's job.)
|
|
if (page !== "channels") setFocusChannelName(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 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 setView(next: FeedView) {
|
|
setViewState(next);
|
|
writeAccount(LS.feedView, next);
|
|
api.savePrefs({ view: 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(() => {});
|
|
}
|
|
// The selected playlist — App state so the App-level PlaylistsRail and the module's detail pane
|
|
// (Playlists) stay in sync. Persisted so a reload keeps it (it's not in the URL).
|
|
const [selectedPlaylistId, setSelectedPlaylistIdState] = useState<number | null>(() => {
|
|
const s = getAccountRaw(LS.playlist);
|
|
return s ? Number(s) : null;
|
|
});
|
|
const setSelectedPlaylistId = (id: number | null) => {
|
|
setSelectedPlaylistIdState(id);
|
|
if (id != null) setAccountRaw(LS.playlist, String(id));
|
|
};
|
|
|
|
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]);
|
|
|
|
// The per-scheme background image (the "glass over image" look) is on when the user hasn't opted
|
|
// out and we're not in perf mode. Drives `html[data-backdrop]` (see index.css), which paints the
|
|
// theme-appropriate image on <body> (dark or /light/ set) and switches the glass to translucent.
|
|
useEffect(() => {
|
|
const on = theme.bgImage && !perf;
|
|
document.documentElement.dataset.backdrop = on ? "on" : "off";
|
|
}, [theme.bgImage, perf]);
|
|
useEffect(() => setHintsEnabled(hints), [hints]);
|
|
useEffect(() => configureNotifications(notif), [notif]);
|
|
|
|
// Auto-save the Settings prefs on change (debounced ~500ms so a slider drag coalesces into one
|
|
// PUT). Live-apply is handled by the effects above; this is the only persistence path — there is
|
|
// no draft or Save button. `baselineRef` suppresses the echo when the change came from adopting
|
|
// server prefs on login (`/me` merges, so a full payload is fine).
|
|
const editablePrefs = useMemo<EditablePrefs>(
|
|
() => ({ theme, performanceMode: perf, hints, notifications: notif }),
|
|
[theme, perf, hints, notif],
|
|
);
|
|
useEffect(() => {
|
|
window.clearTimeout(saveTimer.current);
|
|
if (JSON.stringify(editablePrefs) === JSON.stringify(baselineRef.current)) {
|
|
// Back at the last-saved value (initial load, server adopt, or an edit undone) — nothing to
|
|
// save; clear any "Saving…" left from a change that was reverted before it flushed.
|
|
setPrefsSaveState((s) => (s === "saving" ? "idle" : s));
|
|
return;
|
|
}
|
|
setPrefsSaveState("saving");
|
|
window.clearTimeout(saveMsgTimer.current);
|
|
const payload = editablePrefs;
|
|
saveTimer.current = window.setTimeout(() => {
|
|
api
|
|
.savePrefs(payload)
|
|
.then(() => {
|
|
baselineRef.current = payload;
|
|
setPrefsSaveState("saved");
|
|
saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 2500);
|
|
})
|
|
.catch(() => {
|
|
// api.req() already surfaces the failure; flag the indicator so the user sees it didn't take.
|
|
setPrefsSaveState("error");
|
|
saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 4000);
|
|
});
|
|
}, 500);
|
|
return () => window.clearTimeout(saveTimer.current);
|
|
}, [editablePrefs]);
|
|
// Clear pending pref-save timers on unmount so a debounced PUT or a fade timer never fires
|
|
// setState on a dead component.
|
|
useEffect(
|
|
() => () => {
|
|
window.clearTimeout(saveTimer.current);
|
|
window.clearTimeout(saveMsgTimer.current);
|
|
},
|
|
[],
|
|
);
|
|
|
|
// 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
|
|
|
|
|
|
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
|
|
|
|
// 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
|
|
|
|
// 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 into the live states (the runtime effects above apply them for
|
|
// display) AND into the auto-save baseline, so adopting them doesn't echo a save back.
|
|
const adopted: EditablePrefs = {
|
|
theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
|
|
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);
|
|
setPerf(adopted.performanceMode);
|
|
setHints(adopted.hints);
|
|
setNotif(adopted.notifications);
|
|
baselineRef.current = adopted;
|
|
// Out-of-scope prefs stay instant (edited outside the Settings page).
|
|
const prefsRec = prefs as Record<string, unknown>;
|
|
// parseFeedView also maps the pre-0.45 "grid" / "list" values forward.
|
|
const adoptedView = parseFeedView(prefs.view);
|
|
setViewState(adoptedView);
|
|
writeAccount(LS.feedView, adoptedView);
|
|
(["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]);
|
|
|
|
// Theme applies live (and caches to localStorage); the auto-save effect persists it to the server.
|
|
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 prefsCtl: PrefsController = {
|
|
theme,
|
|
setTheme,
|
|
perf,
|
|
setPerf,
|
|
hints,
|
|
setHints,
|
|
notif,
|
|
setNotif,
|
|
saveState: prefsSaveState,
|
|
};
|
|
|
|
|
|
// 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 text-fg">
|
|
<NavSidebar
|
|
me={meQuery.data!}
|
|
onOpenAbout={() => setAboutOpen(true)}
|
|
onChangeLanguage={changeLanguage}
|
|
language={i18n.language as LangCode}
|
|
collapsed={navCollapsed}
|
|
onToggleCollapse={() => setNavCollapsed(!navCollapsed)}
|
|
/>
|
|
{/* 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={panelLayouts.feed}
|
|
setLayout={(l) => setPanelLayout("feed", l)}
|
|
onFocusChannel={focusChannel}
|
|
isDemo={meQuery.data!.is_demo}
|
|
collapsed={filterCollapsed}
|
|
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
|
|
/>
|
|
)}
|
|
{page === "plex" && meQuery.data!.plex_enabled && (
|
|
<Suspense fallback={null}>
|
|
<PlexSidebar
|
|
scope={plexScope}
|
|
setScope={setPlexScope}
|
|
show={plexShowFilter}
|
|
setShow={setPlexShowFilter}
|
|
sort={plexSort}
|
|
setSort={setPlexSort}
|
|
filters={plexFilters}
|
|
setFilters={setPlexFilters}
|
|
onOpenPlaylist={setPlexPlaylistOpen}
|
|
layout={panelLayouts.plex}
|
|
setLayout={(l) => setPanelLayout("plex", l)}
|
|
collapsed={filterCollapsed}
|
|
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
{page === "playlists" && !channelView && (
|
|
<PlaylistsRail
|
|
canWrite={meQuery.data!.can_write}
|
|
search={playlistsSearch}
|
|
selectedId={selectedPlaylistId}
|
|
setSelectedId={setSelectedPlaylistId}
|
|
layout={panelLayouts.playlists}
|
|
setLayout={(l) => setPanelLayout("playlists", l)}
|
|
collapsed={playlistsCollapsed}
|
|
onToggleCollapse={() => setPlaylistsCollapsed(!playlistsCollapsed)}
|
|
/>
|
|
)}
|
|
<div className="relative flex-1 min-w-0 flex flex-col">
|
|
{channelView ? (
|
|
<PageShell>
|
|
<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
|
|
header={
|
|
<Header
|
|
me={meQuery.data!}
|
|
filters={filters}
|
|
setFilters={setFilters}
|
|
plexQ={plexQ}
|
|
setPlexQ={setPlexQ}
|
|
channelsSearch={channelsSearch}
|
|
setChannelsSearch={setChannelsSearch}
|
|
playlistsSearch={playlistsSearch}
|
|
setPlaylistsSearch={setPlaylistsSearch}
|
|
onYtSearch={enterYtSearch}
|
|
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");
|
|
}}
|
|
/>
|
|
}
|
|
banners={
|
|
<>
|
|
{meQuery.data!.is_demo && <DemoBanner />}
|
|
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
|
|
</>
|
|
}
|
|
>
|
|
<Suspense fallback={pageFallback}>
|
|
{page === "channels" ? (
|
|
<Channels
|
|
canWrite={meQuery.data!.can_write}
|
|
isAdmin={meQuery.data!.role === "admin"}
|
|
search={channelsSearch}
|
|
setSearch={setChannelsSearch}
|
|
focusChannelName={focusChannelName}
|
|
focusChannelToken={focusChannelToken}
|
|
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], channelIds: [] });
|
|
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 === "audit" && meQuery.data!.role === "admin" ? (
|
|
<AuditLog />
|
|
) : page === "playlists" ? (
|
|
<Playlists
|
|
canWrite={meQuery.data!.can_write}
|
|
selectedId={selectedPlaylistId}
|
|
setSelectedId={setSelectedPlaylistId}
|
|
/>
|
|
) : page === "notifications" ? (
|
|
<NotificationsPanel filters={filters} setFilters={setFilters} 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}
|
|
scope={plexScope}
|
|
setScope={setPlexScope}
|
|
show={plexShowFilter}
|
|
sort={plexSort}
|
|
filters={plexFilters}
|
|
setFilters={setPlexFilters}
|
|
openPlaylist={plexPlaylistOpen}
|
|
onPlaylistOpened={() => setPlexPlaylistOpen(null)}
|
|
/>
|
|
) : (
|
|
<Feed
|
|
filters={filters}
|
|
setFilters={setFilters}
|
|
view={view}
|
|
setView={setView}
|
|
canRead={meQuery.data!.can_read}
|
|
isDemo={meQuery.data!.is_demo}
|
|
onOpenWizard={() => setWizardOpen(true)}
|
|
ytSearch={ytSearch}
|
|
onYtSearch={enterYtSearch}
|
|
onExitYtSearch={exitYtSearch}
|
|
/>
|
|
)}
|
|
</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}>
|
|
{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} />
|
|
{/* Opt-in live appearance tuner (Settings → Appearance; off by default). */}
|
|
<GlassTuner enabled={theme.showTuner} />
|
|
</div>
|
|
);
|
|
}
|