After read access is granted the wizard now imports the user's YouTube subscriptions automatically (with a "Building your feed…" progress state), so a new user lands on a populated feed — channels already in the shared catalog show up instantly, new ones backfill in the background. The empty feed now prompts users without read access to set up via the wizard instead of a bare message.
212 lines
7 KiB
TypeScript
212 lines
7 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { api, HttpError, type FeedFilters } from "./lib/api";
|
|
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 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";
|
|
|
|
const DEFAULT_FILTERS: FeedFilters = {
|
|
tags: [],
|
|
tagMode: "or",
|
|
q: "",
|
|
sort: "newest",
|
|
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 [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
|
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
|
const [view, setView] = useState<"grid" | "list">("grid");
|
|
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
|
|
const [page, setPageState] = useState<Page>(readPage);
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
const [wizardOpen, setWizardOpen] = useState(false);
|
|
|
|
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 (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: "Access requests",
|
|
message: `${pending} pending — review in Settings → Account.`,
|
|
});
|
|
}
|
|
// 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(() => {});
|
|
}
|
|
|
|
if (meQuery.isLoading)
|
|
return <div className="min-h-screen grid place-items-center text-muted">Loading…</div>;
|
|
if (meQuery.error instanceof HttpError && meQuery.error.status === 401)
|
|
return <Login />;
|
|
if (meQuery.error)
|
|
return (
|
|
<div className="min-h-screen grid place-items-center text-muted">
|
|
Something went wrong.
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="h-screen flex flex-col bg-bg text-fg">
|
|
<Header
|
|
me={meQuery.data!}
|
|
filters={filters}
|
|
setFilters={setFilters}
|
|
page={page}
|
|
setPage={setPage}
|
|
onOpenSettings={() => setSettingsOpen(true)}
|
|
/>
|
|
<div className="flex flex-1 min-h-0">
|
|
{page === "feed" && (
|
|
<Sidebar
|
|
filters={filters}
|
|
setFilters={setFilters}
|
|
layout={sidebarLayout}
|
|
setLayout={setSidebarLayout}
|
|
/>
|
|
)}
|
|
<main className="flex-1 min-w-0 overflow-y-auto">
|
|
{page === "channels" ? (
|
|
<Channels
|
|
canWrite={meQuery.data!.can_write}
|
|
onViewChannel={(id, name) => {
|
|
setFilters({ ...filters, channelId: id, channelName: name, show: "all" });
|
|
setPage("feed");
|
|
}}
|
|
/>
|
|
) : page === "stats" && meQuery.data!.role === "admin" ? (
|
|
<Stats />
|
|
) : (
|
|
<Feed
|
|
filters={filters}
|
|
setFilters={setFilters}
|
|
view={view}
|
|
canRead={meQuery.data!.can_read}
|
|
onOpenWizard={() => setWizardOpen(true)}
|
|
/>
|
|
)}
|
|
</main>
|
|
</div>
|
|
{settingsOpen && (
|
|
<SettingsPanel
|
|
me={meQuery.data!}
|
|
theme={theme}
|
|
setTheme={setTheme}
|
|
view={view}
|
|
setView={changeView}
|
|
onClose={() => setSettingsOpen(false)}
|
|
onOpenWizard={() => {
|
|
setSettingsOpen(false);
|
|
setWizardOpen(true);
|
|
}}
|
|
/>
|
|
)}
|
|
{wizardOpen && meQuery.data && (
|
|
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
|
|
)}
|
|
<Toaster />
|
|
</div>
|
|
);
|
|
}
|