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 ChannelPage from "./components/ChannelPage"; import BackToTop from "./components/BackToTop"; 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("feed"); const [theme, setThemeState] = useState(() => loadLocalTheme()); const [filters, setFiltersState] = useState(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(() => getNotifSettings()); const [savedPrefs, setSavedPrefs] = useState(() => ({ theme: loadLocalTheme(), view: "grid", performanceMode: localStorage.getItem(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] = 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(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(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) { // 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); 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/_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(); } 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(() => { // 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); 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); // 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), }); // 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 (
{t("common.loading")}
); if (needsSetup) return ; if (meQuery.isLoading) return (
{t("common.loading")}
); if (meQuery.error instanceof HttpError && meQuery.error.status === 401) return ; if (meQuery.error) return (
{t("common.somethingWrong")}
); return (
setAboutOpen(true)} onChangeLanguage={changeLanguage} language={i18n.language as LangCode} />
{channelView ? ( ) : ( <>
{ 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 && } openReleaseNotes(CURRENT_VERSION)} />
{page === "feed" && ( )}
{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 === "settings" ? ( setWizardOpen(true)} /> ) : ( 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). */}
); }