feat: M4 (part 2) — React reader UI with theming
- Vite + React + TS + Tailwind SPA served by FastAPI (multi-stage Docker build) - Four color schemes (Midnight default, Forest, Slate, YouTube) x dark/light, adjustable text size; persisted to user preferences and localStorage - Header search, grid/list toggle, theme menu; sidebar filters (show state, sort, include Shorts/live, language + topic tag chips with any/all matching) - Infinite-scroll feed of video cards; click opens youtube.com in a new tab; per-video watched / saved / hidden actions with optimistic updates - SPA fallback routing; login screen for unauthenticated users
This commit is contained in:
parent
9a377b7e7e
commit
e56502789f
20 changed files with 1194 additions and 4 deletions
88
frontend/src/App.tsx
Normal file
88
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
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 Login from "./components/Login";
|
||||
import Header from "./components/Header";
|
||||
import Sidebar from "./components/Sidebar";
|
||||
import Feed from "./components/Feed";
|
||||
|
||||
const DEFAULT_FILTERS: FeedFilters = {
|
||||
tags: [],
|
||||
tagMode: "or",
|
||||
q: "",
|
||||
sort: "newest",
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
||||
const [filters, setFilters] = useState<FeedFilters>(DEFAULT_FILTERS);
|
||||
const [view, setView] = useState<"grid" | "list">("grid");
|
||||
|
||||
useEffect(() => applyTheme(theme), [theme]);
|
||||
|
||||
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);
|
||||
// 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} />
|
||||
<main className="flex-1 min-w-0 overflow-y-auto">
|
||||
<Feed filters={filters} view={view} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue