The live-search results view had no browser-history entry of its own, so a player opened over the results sat directly on the feed page entry. Pressing Back (e.g. the mouse back button over the player) could pop past both the player and the search in one step, bouncing from the search results to the normal feed instead of just closing the player. The search is now a feed sub-view that owns a history entry (history.state._yt): entering a search pushes it, the popstate handler derives ytSearch from it, and "Back to feed" pops it. Back now steps player -> search -> feed: the first Back closes only the player (results stay), the second returns to the normal feed. A reload drops any stale _yt so the first Back can't resurrect a gone search.
630 lines
27 KiB
TypeScript
630 lines
27 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { api, HttpError, 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 {
|
|
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 { LS, readMerged, usePersistedState } from "./lib/storage";
|
|
import { useConfirm } from "./components/ConfirmProvider";
|
|
import type { PrefsController } from "./components/SettingsPanel";
|
|
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 Feed from "./components/Feed";
|
|
import Channels, { type ChannelStatusFilter, type ChannelsView } from "./components/Channels";
|
|
import Playlists from "./components/Playlists";
|
|
import Stats from "./components/Stats";
|
|
import Scheduler from "./components/Scheduler";
|
|
import ConfigPanel from "./components/ConfigPanel";
|
|
import AdminUsers, { focusAccessRequestsTab } from "./components/AdminUsers";
|
|
import SettingsPanel from "./components/SettingsPanel";
|
|
import NotificationsPanel from "./components/NotificationsPanel";
|
|
import Messages from "./components/Messages";
|
|
import ChatDock from "./components/ChatDock";
|
|
import OnboardingWizard from "./components/OnboardingWizard";
|
|
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
|
|
import Toaster from "./components/Toaster";
|
|
import ErrorDialog from "./components/ErrorDialog";
|
|
import About from "./components/About";
|
|
import ReleaseNotes from "./components/ReleaseNotes";
|
|
import VersionBanner from "./components/VersionBanner";
|
|
import DemoBanner from "./components/DemoBanner";
|
|
import { CURRENT_VERSION } from "./lib/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 = localStorage.getItem(PAGE_KEY);
|
|
return isPage(stored) ? stored : "feed";
|
|
}
|
|
|
|
function loadStoredFilters(): FeedFilters {
|
|
return readMerged(FILTERS_KEY, 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 loadStoredFilters();
|
|
}
|
|
|
|
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);
|
|
const [view, setView] = useState<"grid" | "list">("grid");
|
|
// Settings-page prefs draft (apply live, persist on Save — see EditablePrefs).
|
|
const [perf, setPerf] = useState(() => localStorage.getItem(PERF_KEY) === "1");
|
|
const [hints, setHints] = useState(() => hintsEnabled());
|
|
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
|
|
const [savedPrefs, setSavedPrefs] = useState<EditablePrefs>(() => ({
|
|
theme: loadLocalTheme(),
|
|
view: "grid",
|
|
performanceMode: localStorage.getItem(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>(null);
|
|
const enterYtSearch = useCallback((q: string) => {
|
|
setYtSearch(q);
|
|
const 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);
|
|
}, []);
|
|
const [wizardOpen, setWizardOpen] = useState(false);
|
|
// Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
|
|
const [channelFilterRaw, setChannelFilter] = usePersistedState(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] = usePersistedState(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<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);
|
|
localStorage.setItem(FILTERS_KEY, JSON.stringify(next));
|
|
}
|
|
|
|
function setPage(next: Page) {
|
|
if (next === page) return;
|
|
const go = () => {
|
|
setPageState(next);
|
|
localStorage.setItem(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 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();
|
|
}
|
|
|
|
function setSidebarLayout(next: SidebarLayout) {
|
|
setSidebarLayoutState(next);
|
|
saveLayoutLocal(next);
|
|
api.savePrefs({ sidebarLayout: 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" : "";
|
|
localStorage.setItem(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")) {
|
|
localStorage.setItem(FILTERS_KEY, JSON.stringify(filters));
|
|
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(() => {
|
|
// Drop any stale _yt from a prior session (a reload starts on the normal feed; ytSearch
|
|
// begins null), so the first Back doesn't resurrect a search we're no longer showing.
|
|
const { _yt: _staleYt, ...rest } = window.history.state || {};
|
|
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);
|
|
localStorage.setItem(PAGE_KEY, p);
|
|
});
|
|
return;
|
|
}
|
|
setPageState(p);
|
|
localStorage.setItem(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);
|
|
}
|
|
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),
|
|
});
|
|
|
|
// 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` exists), reload to the login page at once (option 2 — also covers any click that hits
|
|
// the API). Guarded on cached `me` so the public login page's own /api/me 401 can't loop.
|
|
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();
|
|
localStorage.setItem(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
|
|
: localStorage.getItem(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 (isSupported(prefs.language)) setLanguage(prefs.language);
|
|
// The demo account is shared: there are no subscriptions, so default it to the whole
|
|
// library (the "my" feed would be empty). The communal-state warning is a permanent
|
|
// banner (see DemoBanner below), not a toast that re-pops on every reload.
|
|
if (meQuery.data?.is_demo) {
|
|
setFilters({ ...loadStoredFilters(), scope: "all" });
|
|
}
|
|
// 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>
|
|
);
|
|
if (meQuery.error instanceof HttpError && meQuery.error.status === 401)
|
|
return <Welcome />;
|
|
if (meQuery.error)
|
|
return (
|
|
<div className="min-h-screen grid place-items-center text-muted">
|
|
{t("common.somethingWrong")}
|
|
</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}
|
|
/>
|
|
<div className="relative flex-1 min-w-0 flex flex-col">
|
|
<Header
|
|
me={meQuery.data!}
|
|
filters={filters}
|
|
setFilters={setFilters}
|
|
page={page}
|
|
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");
|
|
}}
|
|
/>
|
|
{meQuery.data!.is_demo && <DemoBanner />}
|
|
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
|
|
<div className="flex flex-1 min-h-0">
|
|
{page === "feed" && (
|
|
<Sidebar
|
|
filters={filters}
|
|
setFilters={setFilters}
|
|
layout={sidebarLayout}
|
|
setLayout={setSidebarLayout}
|
|
onFocusChannel={focusChannel}
|
|
/>
|
|
)}
|
|
<main className="flex-1 min-w-0 overflow-y-auto">
|
|
{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) => {
|
|
setFilters({ ...filters, channelId: id, channelName: name, show: "all" });
|
|
setPage("feed");
|
|
}}
|
|
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 === "settings" ? (
|
|
<SettingsPanel
|
|
me={meQuery.data!}
|
|
prefs={prefsCtl}
|
|
onOpenWizard={() => setWizardOpen(true)}
|
|
/>
|
|
) : (
|
|
<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}
|
|
/>
|
|
)}
|
|
</main>
|
|
</div>
|
|
{/* 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} />}
|
|
{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} />
|
|
)}
|
|
<ErrorDialog />
|
|
</div>
|
|
);
|
|
}
|