siftlode/frontend/src/App.tsx
npeter83 2e79074fca fix(settings): size panel to the active tab + drop the native unload prompt
- Render only the active settings tab instead of stacking all four in one grid
  cell. The stack forced the whole panel to the tallest tab's (Account) height,
  leaving the short tabs (Appearance/Notifications) with dead space and a
  pointless scrollbar. Now the panel sizes to its actual content.
- Remove the beforeunload guard: it could only raise the browser's own native
  'Reload site?' prompt (no in-app dialog possible there), which we avoid.
  Unsaved prefs are local-only and revert to the saved server baseline on the
  next load, so a reload/close loses nothing. The in-app confirm still guards
  in-app navigation and browser Back.
2026-06-19 00:04:19 +02:00

496 lines
19 KiB
TypeScript

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 } 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<Page>("feed");
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
const [filters, setFiltersState] = useState<FeedFilters>(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<NotifSettings>(() => getNotifSettings());
const [savedPrefs, setSavedPrefs] = useState<EditablePrefs>(() => ({
theme: loadLocalTheme(),
view: "grid",
performanceMode: localStorage.getItem(PERF_KEY) === "1",
hints: hintsEnabled(),
notifications: getNotifSettings(),
}));
const [prefsSaveState, setPrefsSaveState] = useState<PrefsController["saveState"]>("idle");
const saveMsgTimer = useRef<number | undefined>(undefined);
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;
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 (
<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}
/>
<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 filters={filters} setFilters={setFilters} setPage={setPage} />
) : page === "settings" ? (
<SettingsPanel
me={meQuery.data!}
prefs={prefsCtl}
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>
);
}