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
144
frontend/src/components/Header.tsx
Normal file
144
frontend/src/components/Header.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
LayoutGrid,
|
||||
List,
|
||||
LogOut,
|
||||
Moon,
|
||||
Palette,
|
||||
Search,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||
import type { FeedFilters, Me } from "../lib/api";
|
||||
|
||||
function IconBtn(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const { className = "", ...rest } = props;
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
className={`p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeMenu({
|
||||
theme,
|
||||
setTheme,
|
||||
}: {
|
||||
theme: ThemePrefs;
|
||||
setTheme: (t: ThemePrefs) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute right-0 mt-2 w-60 rounded-xl border border-border bg-surface shadow-2xl p-3 z-30">
|
||||
<div className="text-xs uppercase tracking-wide text-muted mb-2">Color scheme</div>
|
||||
<div className="grid grid-cols-4 gap-2 mb-3">
|
||||
{SCHEMES.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setTheme({ ...theme, scheme: s.id as Scheme })}
|
||||
title={s.name}
|
||||
className={`h-9 rounded-lg border-2 transition ${
|
||||
theme.scheme === s.id ? "border-fg" : "border-transparent"
|
||||
}`}
|
||||
style={{ background: s.swatch }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs uppercase tracking-wide text-muted mb-2">Text size</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.9}
|
||||
max={1.3}
|
||||
step={0.02}
|
||||
value={theme.fontScale}
|
||||
onChange={(e) => setTheme({ ...theme, fontScale: Number(e.target.value) })}
|
||||
className="w-full accent-accent"
|
||||
/>
|
||||
<div className="text-right text-xs text-muted">
|
||||
{Math.round(theme.fontScale * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Header({
|
||||
me,
|
||||
theme,
|
||||
setTheme,
|
||||
filters,
|
||||
setFilters,
|
||||
view,
|
||||
setView,
|
||||
}: {
|
||||
me: Me;
|
||||
theme: ThemePrefs;
|
||||
setTheme: (t: ThemePrefs) => void;
|
||||
filters: FeedFilters;
|
||||
setFilters: (f: FeedFilters) => void;
|
||||
view: "grid" | "list";
|
||||
setView: (v: "grid" | "list") => void;
|
||||
}) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
async function logout() {
|
||||
await fetch("/auth/logout", { method: "POST", credentials: "include" });
|
||||
location.reload();
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="h-14 shrink-0 border-b border-border bg-surface/80 backdrop-blur flex items-center gap-3 px-4 z-20">
|
||||
<div className="text-xl font-bold tracking-tight select-none">
|
||||
Sub<span className="text-accent">feed</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 max-w-xl mx-auto relative">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
||||
<input
|
||||
value={filters.q}
|
||||
onChange={(e) => setFilters({ ...filters, q: e.target.value })}
|
||||
placeholder="Search your subscriptions…"
|
||||
className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<IconBtn
|
||||
onClick={() => setView(view === "grid" ? "list" : "grid")}
|
||||
title={view === "grid" ? "List view" : "Grid view"}
|
||||
>
|
||||
{view === "grid" ? <List className="w-5 h-5" /> : <LayoutGrid className="w-5 h-5" />}
|
||||
</IconBtn>
|
||||
<IconBtn
|
||||
onClick={() => setTheme({ ...theme, mode: theme.mode === "dark" ? "light" : "dark" })}
|
||||
title="Toggle dark / light"
|
||||
>
|
||||
{theme.mode === "dark" ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||
</IconBtn>
|
||||
<div className="relative">
|
||||
<IconBtn onClick={() => setMenuOpen((o) => !o)} title="Theme">
|
||||
<Palette className="w-5 h-5" />
|
||||
</IconBtn>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-20" onClick={() => setMenuOpen(false)} />
|
||||
<ThemeMenu theme={theme} setTheme={setTheme} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
{me.avatar_url ? (
|
||||
<img src={me.avatar_url} alt="" className="w-8 h-8 rounded-full" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-card grid place-items-center text-xs">
|
||||
{me.email[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<IconBtn onClick={logout} title="Sign out">
|
||||
<LogOut className="w-5 h-5" />
|
||||
</IconBtn>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue