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, paramsToFilters, readPage, syncUrl, 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 Sidebar from "./components/Sidebar"; import Feed from "./components/Feed"; import Channels, { type ChannelStatusFilter } from "./components/Channels"; import Stats from "./components/Stats"; import SettingsPanel from "./components/SettingsPanel"; import OnboardingWizard from "./components/OnboardingWizard"; import { shouldAutoOpenOnboarding } from "./lib/onboarding"; import Toaster from "./components/Toaster"; import About from "./components/About"; import ReleaseNotes from "./components/ReleaseNotes"; import VersionBanner from "./components/VersionBanner"; 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"; 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 } = 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(readPage); const [settingsOpen, setSettingsOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false); const [channelFilter, setChannelFilter] = useState("all"); const [aboutOpen, setAboutOpen] = useState(false); const [notesOpen, setNotesOpen] = useState(false); const [notesHighlight, setNotesHighlight] = useState(undefined); function openReleaseNotes(highlight?: string) { setNotesHighlight(highlight); setNotesOpen(true); } function setFilters(next: FeedFilters) { setFiltersState(next); localStorage.setItem(FILTERS_KEY, JSON.stringify(next)); syncUrl(next, page); } function setPage(next: Page) { setPageState(next); syncUrl(filters, next); } function setSidebarLayout(next: SidebarLayout) { setSidebarLayoutState(next); saveLayoutLocal(next); api.savePrefs({ sidebarLayout: next }).catch(() => {}); } useEffect(() => applyTheme(theme), [theme]); // Reflect the initial filters + page into the address bar so the URL is always shareable. useEffect(() => syncUrl(filters, page), []); // 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 }), }); } // 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 (
setSettingsOpen(true)} onOpenAbout={() => setAboutOpen(true)} onChangeLanguage={changeLanguage} onGoToFullHistory={() => { setChannelFilter("needs_full"); setPage("channels"); }} /> openReleaseNotes(CURRENT_VERSION)} />
{page === "feed" && ( )}
{page === "channels" ? ( setWizardOpen(true)} onViewChannel={(id, name) => { setFilters({ ...filters, channelId: id, channelName: name, show: "all" }); setPage("feed"); }} /> ) : page === "stats" && meQuery.data!.role === "admin" ? ( ) : ( setWizardOpen(true)} /> )}
{settingsOpen && ( setSettingsOpen(false)} onOpenWizard={() => { setSettingsOpen(false); setWizardOpen(true); }} /> )} {wizardOpen && meQuery.data && ( setWizardOpen(false)} /> )} {aboutOpen && ( setAboutOpen(false)} onOpenReleaseNotes={() => { setAboutOpen(false); openReleaseNotes(); }} /> )} {notesOpen && ( setNotesOpen(false)} highlight={notesHighlight} /> )}
); }