import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { api, HttpError, 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, type NotifSettings } from "./lib/notifications"; import { hintsEnabled, setHintsEnabled } from "./lib/hints"; import { useConfirm } from "./components/ConfirmProvider"; import type { PrefsController } from "./components/SettingsPanel"; import Login from "./components/Login"; 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 SettingsPanel from "./components/SettingsPanel"; import NotificationsPanel from "./components/NotificationsPanel"; 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 = "subfeed.filters"; const PAGE_KEY = "siftlode.page"; const PERF_KEY = "subfeed.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 { try { return { ...DEFAULT_FILTERS, ...JSON.parse(localStorage.getItem(FILTERS_KEY) || "{}") }; } catch { 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 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); const [wizardOpen, setWizardOpen] = useState(false); const CHANNEL_FILTER_KEY = "siftlode.channelFilter"; const [channelFilter, setChannelFilterState] = useState(() => { const v = localStorage.getItem(CHANNEL_FILTER_KEY); return v === "needs_full" || v === "fully_synced" || v === "hidden" || v === "all" ? v : "all"; }); // Persist the channel status chip so a reload (F5) keeps it. const setChannelFilter = (f: ChannelStatusFilter) => { setChannelFilterState(f); localStorage.setItem(CHANNEL_FILTER_KEY, f); }; 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 CHANNELS_VIEW_KEY = "siftlode.channelsView"; const [channelsView, setChannelsViewState] = useState(() => localStorage.getItem(CHANNELS_VIEW_KEY) === "discovery" ? "discovery" : "subscribed" ); const setChannelsView = (v: ChannelsView) => { setChannelsViewState(v); localStorage.setItem(CHANNELS_VIEW_KEY, v); }; // 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); }, [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). window.history.pushState({ ...window.history.state, 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. Runs after the strip-params effect above (which resets // history.state to null), so the initial stamp isn't clobbered. useEffect(() => { window.history.replaceState( { ...window.history.state, 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); } window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); }, []); // eslint-disable-line react-hooks/exhaustive-deps const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me }); // 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); // Nudge admins when access requests are waiting (in lieu of a server-push bell). const pending = meQuery.data?.pending_invites ?? 0; if (meQuery.data?.role === "admin" && pending > 0) { notify({ level: "warning", title: t("common.accessRequestsTitle"), message: t("common.accessRequestsMessage", { count: pending }), }); } // 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]); // 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. 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} />
{ 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) => { 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" && meQuery.data!.role === "admin" ? ( ) : page === "scheduler" && meQuery.data!.role === "admin" ? ( ) : page === "playlists" ? ( ) : page === "notifications" ? ( ) : page === "settings" ? ( setWizardOpen(true)} /> ) : ( setWizardOpen(true)} /> )}
{/* 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. */}
{wizardOpen && meQuery.data && !meQuery.data.is_demo && ( setWizardOpen(false)} /> )} {aboutOpen && ( setAboutOpen(false)} onOpenReleaseNotes={() => { setAboutOpen(false); openReleaseNotes(); }} /> )} {notesOpen && ( setNotesOpen(false)} highlight={notesHighlight} /> )}
); }