siftlode/frontend/src/App.tsx

143 lines
4.2 KiB
TypeScript
Raw Normal View History

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, syncUrl } from "./lib/urlState";
import {
loadLayout,
normalizeLayout,
saveLayoutLocal,
type SidebarLayout,
} from "./lib/sidebarLayout";
import Login from "./components/Login";
import Header from "./components/Header";
import Sidebar from "./components/Sidebar";
import Feed from "./components/Feed";
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);
function setFilters(next: FeedFilters) {
setFiltersState(next);
localStorage.setItem(FILTERS_KEY, JSON.stringify(next));
syncUrl(next);
}
function setSidebarLayout(next: SidebarLayout) {
setSidebarLayoutState(next);
saveLayoutLocal(next);
api.savePrefs({ sidebarLayout: next }).catch(() => {});
}
useEffect(() => applyTheme(theme), [theme]);
// Reflect the initial filters (from localStorage or URL) into the address bar
// so the URL is always shareable, even before the first filter change.
useEffect(() => syncUrl(filters), []); // eslint-disable-line react-hooks/exhaustive-deps
const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me });
// 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);
}
// 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!}
theme={theme}
setTheme={setTheme}
filters={filters}
setFilters={setFilters}
view={view}
setView={changeView}
/>
<div className="flex flex-1 min-h-0">
<Sidebar
filters={filters}
setFilters={setFilters}
layout={sidebarLayout}
setLayout={setSidebarLayout}
/>
<main className="flex-1 min-w-0 overflow-y-auto">
<Feed filters={filters} setFilters={setFilters} view={view} />
</main>
</div>
<Toaster />
</div>
);
}