import { useEffect, 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, notify } from "./lib/notifications"; import { setHintsEnabled } from "./lib/hints"; 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 } 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"; // 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 [theme, setThemeState] = useState(() => loadLocalTheme()); const [filters, setFiltersState] = useState(loadInitialFilters); const [view, setView] = useState<"grid" | "list">("grid"); 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); // "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); 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; 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 }, ""); } function setSidebarLayout(next: SidebarLayout) { setSidebarLayoutState(next); saveLayoutLocal(next); api.savePrefs({ sidebarLayout: next }).catch(() => {}); } useEffect(() => applyTheme(theme), [theme]); // 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"; 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; if (prefs.theme) { const merged = { ...DEFAULT_THEME, ...prefs.theme }; setThemeState(merged); saveLocalTheme(merged); } if (prefs.view === "grid" || prefs.view === "list") setView(prefs.view); if (prefs.sidebarLayout) { const l = normalizeLayout(prefs.sidebarLayout); setSidebarLayoutState(l); saveLayoutLocal(l); } if (isSupported(prefs.language)) setLanguage(prefs.language); if (prefs.notifications) configureNotifications(prefs.notifications); if (typeof prefs.hints === "boolean") setHintsEnabled(prefs.hints); if (typeof prefs.performanceMode === "boolean") document.documentElement.dataset.perf = prefs.performanceMode ? "1" : ""; // 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]); function setTheme(next: ThemePrefs) { setThemeState(next); saveLocalTheme(next); api.savePrefs({ theme: next }).catch(() => {}); } function changeView(v: "grid" | "list") { setView(v); api.savePrefs({ view: v }).catch(() => {}); } function changeLanguage(code: LangCode) { setLanguage(code); api.savePrefs({ language: code }).catch(() => {}); } 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} filters={filters} setFilters={setFilters} />
{ setChannelFilter("needs_full"); 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} /> )}
); }