siftlode/frontend/src/App.tsx

354 lines
13 KiB
TypeScript
Raw Normal View History

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<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>(loadInitialPage);
const [wizardOpen, setWizardOpen] = useState(false);
const CHANNEL_FILTER_KEY = "siftlode.channelFilter";
const [channelFilter, setChannelFilterState] = useState<ChannelStatusFilter>(() => {
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<string | undefined>(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<string | null>(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 (
<div className="min-h-screen grid place-items-center text-muted">{t("common.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">
{t("common.somethingWrong")}
</div>
);
return (
<div className="h-screen flex bg-bg text-fg">
<NavSidebar
me={meQuery.data!}
page={page}
setPage={setPage}
onOpenAbout={() => setAboutOpen(true)}
onChangeLanguage={changeLanguage}
language={i18n.language as LangCode}
filters={filters}
setFilters={setFilters}
/>
<div className="relative flex-1 min-w-0 flex flex-col">
<Header
me={meQuery.data!}
filters={filters}
setFilters={setFilters}
page={page}
onGoToFullHistory={() => {
setChannelFilter("needs_full");
setPage("channels");
}}
/>
{meQuery.data!.is_demo && <DemoBanner />}
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
<div className="flex flex-1 min-h-0">
{page === "feed" && (
<Sidebar
filters={filters}
setFilters={setFilters}
layout={sidebarLayout}
setLayout={setSidebarLayout}
onFocusChannel={focusChannel}
/>
)}
<main className="flex-1 min-w-0 overflow-y-auto">
{page === "channels" ? (
<Channels
canWrite={meQuery.data!.can_write}
isAdmin={meQuery.data!.role === "admin"}
focusChannelName={focusChannelName}
onFocusChannel={focusChannel}
statusFilter={channelFilter}
setStatusFilter={setChannelFilter}
onOpenWizard={() => 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" ? (
<Stats />
) : page === "scheduler" && meQuery.data!.role === "admin" ? (
<Scheduler />
) : page === "playlists" ? (
<Playlists canWrite={meQuery.data!.can_write} />
) : page === "notifications" ? (
<NotificationsPanel />
) : page === "settings" ? (
<SettingsPanel
me={meQuery.data!}
theme={theme}
setTheme={setTheme}
view={view}
setView={changeView}
onOpenWizard={() => setWizardOpen(true)}
/>
) : (
<Feed
filters={filters}
setFilters={setFilters}
view={view}
canRead={meQuery.data!.can_read}
isDemo={meQuery.data!.is_demo}
onOpenWizard={() => setWizardOpen(true)}
/>
)}
</main>
</div>
{/* 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. */}
<Toaster />
</div>
{wizardOpen && meQuery.data && !meQuery.data.is_demo && (
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
)}
{aboutOpen && (
<About
onClose={() => setAboutOpen(false)}
onOpenReleaseNotes={() => {
setAboutOpen(false);
openReleaseNotes();
}}
/>
)}
{notesOpen && (
<ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} />
)}
<ErrorDialog />
</div>
);
}