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:
npeter83 2026-06-11 02:19:47 +02:00
parent 9a377b7e7e
commit e56502789f
20 changed files with 1194 additions and 4 deletions

28
Dockerfile Normal file
View file

@ -0,0 +1,28 @@
# Stage 1: build the frontend SPA
FROM node:20-alpine AS frontend
WORKDIR /fe
COPY frontend/package.json ./
RUN npm install
COPY frontend/ ./
RUN npm run build
# Stage 2: backend runtime, serving the built SPA
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
COPY backend/requirements.txt .
RUN pip install -r requirements.txt
COPY backend/ .
COPY --from=frontend /fe/dist ./app/static_spa
RUN adduser --disabled-password --gecos "" appuser && chown -R appuser /app
USER appuser
EXPOSE 8000
CMD ["sh", "entrypoint.sh"]

View file

@ -1,7 +1,7 @@
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
@ -47,10 +47,23 @@ app.include_router(tags.router)
app.include_router(feed.router)
app.include_router(me.router)
STATIC_DIR = Path(__file__).parent / "static"
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
# The built SPA (populated by the Docker frontend build stage).
STATIC_DIR = Path(__file__).parent / "static_spa"
app.mount(
"/assets",
StaticFiles(directory=STATIC_DIR / "assets", check_dir=False),
name="assets",
)
@app.get("/")
async def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html")
@app.get("/{full_path:path}")
async def spa_fallback(full_path: str) -> FileResponse:
# Client-side routes fall back to index.html; real API/asset paths are matched above.
if full_path.startswith(("api/", "auth/", "healthz", "assets/")):
raise HTTPException(status_code=404)
return FileResponse(STATIC_DIR / "index.html")

View file

@ -16,7 +16,8 @@ services:
api:
build:
context: ./backend
context: .
dockerfile: Dockerfile
env_file: .env
environment:
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-subfeed}:${POSTGRES_PASSWORD:-subfeed}@db:5432/${POSTGRES_DB:-subfeed}

12
frontend/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" data-theme="dark" data-scheme="midnight">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Subfeed</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

27
frontend/package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "subfeed-frontend",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.51.0",
"clsx": "^2.1.1",
"lucide-react": "^0.408.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
"typescript": "^5.5.3",
"vite": "^5.3.4"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

88
frontend/src/App.tsx Normal file
View 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>
);
}

View file

@ -0,0 +1,79 @@
import { useEffect, useRef, useState } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { api, type FeedFilters, type Video } from "../lib/api";
import VideoCard from "./VideoCard";
const PAGE = 60;
export default function Feed({
filters,
view,
}: {
filters: FeedFilters;
view: "grid" | "list";
}) {
const [overrides, setOverrides] = useState<Record<string, string>>({});
const query = useInfiniteQuery({
queryKey: ["feed", filters],
queryFn: ({ pageParam }) => api.feed(filters, pageParam as number, PAGE),
initialPageParam: 0,
getNextPageParam: (last) => (last.has_more ? last.offset + last.limit : undefined),
});
useEffect(() => setOverrides({}), [filters]);
const sentinel = useRef<HTMLDivElement | null>(null);
const { hasNextPage, isFetchingNextPage, fetchNextPage } = query;
useEffect(() => {
const el = sentinel.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ rootMargin: "800px" }
);
io.observe(el);
return () => io.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
function onState(id: string, status: string) {
setOverrides((o) => ({ ...o, [id]: status }));
api.setState(id, status).catch(() => {});
}
const items: Video[] = (query.data?.pages ?? [])
.flatMap((p) => p.items)
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
.filter((v) => overrides[v.id] !== "hidden");
if (query.isLoading) return <div className="p-8 text-muted">Loading feed</div>;
if (query.isError) return <div className="p-8 text-muted">Couldn't load the feed.</div>;
if (items.length === 0)
return <div className="p-8 text-muted">No videos match these filters.</div>;
return (
<div className="p-4">
{view === "grid" ? (
<div className="grid gap-x-4 gap-y-6 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
{items.map((v) => (
<VideoCard key={v.id} video={v} view="grid" onState={onState} />
))}
</div>
) : (
<div className="max-w-4xl mx-auto flex flex-col gap-1">
{items.map((v) => (
<VideoCard key={v.id} video={v} view="list" onState={onState} />
))}
</div>
)}
<div ref={sentinel} className="h-10" />
{isFetchingNextPage && (
<div className="text-center text-muted py-4">Loading more</div>
)}
</div>
);
}

View 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>
);
}

View file

@ -0,0 +1,23 @@
export default function Login() {
return (
<div className="min-h-screen grid place-items-center bg-bg text-fg">
<div className="w-[min(92vw,420px)] rounded-2xl border border-border bg-card/70 backdrop-blur p-10 text-center shadow-2xl">
<div className="text-3xl font-bold tracking-tight">
Sub<span className="text-accent">feed</span>
</div>
<p className="text-muted my-5 leading-relaxed">
Your subscriptions, filtered your way.
<br />
Sign in with your Google account.
</p>
<a
href="/auth/login"
className="inline-flex items-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
Sign in with Google
</a>
<div className="text-xs text-muted mt-6">Invite-only access.</div>
</div>
</div>
);
}

View file

@ -0,0 +1,232 @@
import { useQuery } from "@tanstack/react-query";
import { api, type FeedFilters, type Tag } from "../lib/api";
const SORTS = [
{ id: "newest", label: "Newest" },
{ id: "oldest", label: "Oldest" },
{ id: "views", label: "Most viewed" },
{ id: "duration_desc", label: "Longest" },
{ id: "duration_asc", label: "Shortest" },
{ id: "shuffle", label: "Surprise me" },
];
const SHOWS = [
{ id: "unwatched", label: "Unwatched" },
{ id: "all", label: "All" },
{ id: "watched", label: "Watched" },
{ id: "saved", label: "Saved" },
];
function TagChip({
tag,
active,
onClick,
}: {
tag: Tag;
active: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={`text-xs px-2.5 py-1 rounded-full border transition ${
active
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-fg hover:border-accent"
}`}
>
{tag.name}
<span className={active ? "opacity-80 ml-1" : "text-muted ml-1"}>
{tag.channel_count}
</span>
</button>
);
}
export default function Sidebar({
filters,
setFilters,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
}) {
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const tags = tagsQuery.data ?? [];
const languages = tags.filter((t) => t.category === "language");
const topics = tags.filter((t) => t.category === "topic");
function toggleTag(id: number) {
const has = filters.tags.includes(id);
setFilters({
...filters,
tags: has ? filters.tags.filter((t) => t !== id) : [...filters.tags, id],
});
}
const active =
filters.tags.length > 0 ||
filters.includeShorts ||
filters.includeLive ||
filters.show !== "unwatched" ||
filters.sort !== "newest";
return (
<aside className="w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-5 hidden md:block">
<Section title="Show">
<div className="grid grid-cols-2 gap-1.5">
{SHOWS.map((s) => (
<button
key={s.id}
onClick={() => setFilters({ ...filters, show: s.id })}
className={`text-xs py-1.5 rounded-lg border transition ${
filters.show === s.id
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border hover:border-accent"
}`}
>
{s.label}
</button>
))}
</div>
</Section>
<Section title="Sort">
<select
value={filters.sort}
onChange={(e) => setFilters({ ...filters, sort: e.target.value })}
className="w-full bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
>
{SORTS.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select>
</Section>
<Section title="Content">
<Toggle
label="Include Shorts"
checked={filters.includeShorts}
onChange={(v) => setFilters({ ...filters, includeShorts: v })}
/>
<Toggle
label="Include live / upcoming"
checked={filters.includeLive}
onChange={(v) => setFilters({ ...filters, includeLive: v })}
/>
</Section>
{languages.length > 0 && (
<Section title="Language">
<div className="flex flex-wrap gap-1.5">
{languages.map((t) => (
<TagChip
key={t.id}
tag={t}
active={filters.tags.includes(t.id)}
onClick={() => toggleTag(t.id)}
/>
))}
</div>
</Section>
)}
{topics.length > 0 && (
<Section
title="Topic"
right={
<button
onClick={() =>
setFilters({ ...filters, tagMode: filters.tagMode === "or" ? "and" : "or" })
}
className="text-[10px] uppercase tracking-wide text-muted hover:text-accent"
title="Match any vs all selected tags"
>
{filters.tagMode === "or" ? "Any" : "All"}
</button>
}
>
<div className="flex flex-wrap gap-1.5">
{topics.map((t) => (
<TagChip
key={t.id}
tag={t}
active={filters.tags.includes(t.id)}
onClick={() => toggleTag(t.id)}
/>
))}
</div>
</Section>
)}
{active && (
<button
onClick={() =>
setFilters({
tags: [],
tagMode: "or",
q: filters.q,
sort: "newest",
includeShorts: false,
includeLive: false,
show: "unwatched",
})
}
className="w-full text-xs py-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition"
>
Clear filters
</button>
)}
</aside>
);
}
function Section({
title,
right,
children,
}: {
title: string;
right?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div>
<div className="flex items-center justify-between mb-2">
<div className="text-xs uppercase tracking-wide text-muted">{title}</div>
{right}
</div>
{children}
</div>
);
}
function Toggle({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<label className="flex items-center justify-between py-1 cursor-pointer text-sm">
<span>{label}</span>
<button
type="button"
onClick={() => onChange(!checked)}
className={`w-9 h-5 rounded-full transition relative ${
checked ? "bg-accent" : "bg-border"
}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all ${
checked ? "left-[18px]" : "left-0.5"
}`}
/>
</button>
</label>
);
}

View file

@ -0,0 +1,154 @@
import { Bookmark, Check, EyeOff } from "lucide-react";
import clsx from "clsx";
import type { Video } from "../lib/api";
import { formatDuration, formatViews, relativeTime } from "../lib/format";
function Actions({
video,
onState,
}: {
video: Video;
onState: (id: string, status: string) => void;
}) {
const act = (status: string) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onState(video.id, video.status === status ? "new" : status);
};
return (
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
<button
onClick={act("watched")}
title="Mark watched"
className={clsx(
"p-1.5 rounded-md hover:bg-card text-muted hover:text-fg",
video.status === "watched" && "text-accent"
)}
>
<Check className="w-4 h-4" />
</button>
<button
onClick={act("saved")}
title="Save for later"
className={clsx(
"p-1.5 rounded-md hover:bg-card text-muted hover:text-fg",
video.status === "saved" && "text-accent"
)}
>
<Bookmark className="w-4 h-4" />
</button>
<button
onClick={act("hidden")}
title="Hide"
className="p-1.5 rounded-md hover:bg-card text-muted hover:text-fg"
>
<EyeOff className="w-4 h-4" />
</button>
</div>
);
}
function Thumb({ video, className }: { video: Video; className?: string }) {
return (
<a
href={video.watch_url}
target="_blank"
rel="noreferrer"
onClick={() => video.status === "new" && undefined}
className={clsx(
"block relative rounded-xl overflow-hidden bg-surface border border-border",
className
)}
>
{video.thumbnail_url ? (
<img
src={video.thumbnail_url}
alt=""
loading="lazy"
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full" />
)}
{video.duration_seconds != null && (
<span className="absolute bottom-1.5 right-1.5 bg-black/80 text-white text-[11px] font-medium px-1.5 py-0.5 rounded">
{formatDuration(video.duration_seconds)}
</span>
)}
{video.live_status === "was_live" && (
<span className="absolute top-1.5 left-1.5 bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded">
stream
</span>
)}
</a>
);
}
export default function VideoCard({
video,
view,
onState,
}: {
video: Video;
view: "grid" | "list";
onState: (id: string, status: string) => void;
}) {
const watched = video.status === "watched";
const meta = (
<>
{video.view_count != null && <>{formatViews(video.view_count)} views · </>}
{relativeTime(video.published_at)}
</>
);
const title = (
<a
href={video.watch_url}
target="_blank"
rel="noreferrer"
className="font-medium leading-snug line-clamp-2 hover:text-accent"
>
{video.title}
</a>
);
if (view === "list") {
return (
<div
className={clsx(
"group flex gap-3 p-2 rounded-xl hover:bg-card transition",
watched && "opacity-55"
)}
>
<Thumb video={video} className="w-44 aspect-video shrink-0" />
<div className="min-w-0 flex-1">
{title}
<div className="text-sm text-muted truncate mt-0.5">{video.channel_title}</div>
<div className="text-xs text-muted mt-0.5">{meta}</div>
</div>
<Actions video={video} onState={onState} />
</div>
);
}
return (
<div className={clsx("group", watched && "opacity-55")}>
<Thumb video={video} className="aspect-video" />
<div className="flex gap-3 mt-2">
{video.channel_thumbnail && (
<img
src={video.channel_thumbnail}
alt=""
loading="lazy"
className="w-9 h-9 rounded-full shrink-0 mt-0.5"
/>
)}
<div className="min-w-0 flex-1">
{title}
<div className="text-sm text-muted truncate mt-0.5">{video.channel_title}</div>
<div className="text-xs text-muted mt-0.5">{meta}</div>
<Actions video={video} onState={onState} />
</div>
</div>
</div>
);
}

121
frontend/src/index.css Normal file
View file

@ -0,0 +1,121 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--font-scale: 1.06;
}
html {
font-size: calc(16px * var(--font-scale));
background: var(--bg);
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
}
/* ===== Color schemes (accent + neutrals), each with dark + light ===== */
/* Midnight — deep navy/slate with blue accent (default) */
html[data-scheme="midnight"][data-theme="dark"] {
--bg: #0b1020;
--surface: #121a2e;
--card: #161f38;
--border: #243049;
--fg: #e6e9f0;
--muted: #97a3c0;
--accent: #6d8cff;
--accent-fg: #0b1020;
}
html[data-scheme="midnight"][data-theme="light"] {
--bg: #f4f6fc;
--surface: #ffffff;
--card: #ffffff;
--border: #dde3f0;
--fg: #1a2238;
--muted: #5b6685;
--accent: #3b5bdb;
--accent-fg: #ffffff;
}
/* Forest — dark slate with teal/green accent */
html[data-scheme="forest"][data-theme="dark"] {
--bg: #0a1512;
--surface: #0f1f1a;
--card: #12241e;
--border: #1f3a30;
--fg: #e6f0ec;
--muted: #90b1a4;
--accent: #2dd4bf;
--accent-fg: #04110d;
}
html[data-scheme="forest"][data-theme="light"] {
--bg: #f1f7f4;
--surface: #ffffff;
--card: #ffffff;
--border: #d6e7e0;
--fg: #102a22;
--muted: #4d6b60;
--accent: #0d9488;
--accent-fg: #ffffff;
}
/* Slate — neutral grey with warm orange accent (muted in dark) */
html[data-scheme="slate"][data-theme="dark"] {
--bg: #15171c;
--surface: #1b1e25;
--card: #1f232b;
--border: #2c313b;
--fg: #e7e9ee;
--muted: #9aa1ad;
--accent: #e8913c;
--accent-fg: #1a1206;
}
html[data-scheme="slate"][data-theme="light"] {
--bg: #f6f7f9;
--surface: #ffffff;
--card: #ffffff;
--border: #e3e6eb;
--fg: #1c1f26;
--muted: #5d636e;
--accent: #d97706;
--accent-fg: #ffffff;
}
/* YouTube — near-black with red accent */
html[data-scheme="youtube"][data-theme="dark"] {
--bg: #0f0f0f;
--surface: #181818;
--card: #1f1f1f;
--border: #303030;
--fg: #f1f1f1;
--muted: #aaaaaa;
--accent: #ff3b46;
--accent-fg: #ffffff;
}
html[data-scheme="youtube"][data-theme="light"] {
--bg: #ffffff;
--surface: #f9f9f9;
--card: #ffffff;
--border: #e5e5e5;
--fg: #0f0f0f;
--muted: #606060;
--accent: #ff0033;
--accent-fg: #ffffff;
}
/* Thin, theme-aware scrollbars */
* {
scrollbar-color: var(--border) transparent;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 8px;
}

104
frontend/src/lib/api.ts Normal file
View file

@ -0,0 +1,104 @@
export interface Me {
id: number;
email: string;
display_name: string | null;
avatar_url: string | null;
role: string;
preferences: Record<string, any>;
}
export interface Tag {
id: number;
name: string;
color: string | null;
category: string;
system: boolean;
channel_count: number;
}
export interface Video {
id: string;
title: string | null;
channel_id: string;
channel_title: string | null;
channel_thumbnail: string | null;
published_at: string | null;
thumbnail_url: string | null;
duration_seconds: number | null;
view_count: number | null;
is_short: boolean;
live_status: string;
status: string;
watch_url: string;
}
export interface FeedResponse {
items: Video[];
has_more: boolean;
offset: number;
limit: number;
}
export interface FeedFilters {
tags: number[];
tagMode: "or" | "and";
q: string;
sort: string;
includeShorts: boolean;
includeLive: boolean;
show: string;
channelId?: string;
maxAgeDays?: number;
minDuration?: number;
maxDuration?: number;
}
class HttpError extends Error {
status: number;
constructor(status: number) {
super(`HTTP ${status}`);
this.status = status;
}
}
async function req(url: string, opts: RequestInit = {}): Promise<any> {
const r = await fetch(url, {
credentials: "include",
headers: { "Content-Type": "application/json" },
...opts,
});
if (!r.ok) throw new HttpError(r.status);
return r.status === 204 ? null : r.json();
}
function feedQuery(f: FeedFilters, offset: number, limit: number): string {
const p = new URLSearchParams();
f.tags.forEach((t) => p.append("tags", String(t)));
p.set("tag_mode", f.tagMode);
if (f.q) p.set("q", f.q);
p.set("sort", f.sort);
p.set("include_shorts", String(f.includeShorts));
p.set("include_live", String(f.includeLive));
p.set("show", f.show);
if (f.channelId) p.set("channel_id", f.channelId);
if (f.maxAgeDays) p.set("max_age_days", String(f.maxAgeDays));
if (f.minDuration != null) p.set("min_duration", String(f.minDuration));
if (f.maxDuration != null) p.set("max_duration", String(f.maxDuration));
p.set("offset", String(offset));
p.set("limit", String(limit));
return p.toString();
}
export const api = {
me: (): Promise<Me> => req("/api/me"),
tags: (): Promise<Tag[]> => req("/api/tags"),
status: (): Promise<any> => req("/api/sync/status"),
feed: (f: FeedFilters, offset: number, limit: number): Promise<FeedResponse> =>
req(`/api/feed?${feedQuery(f, offset, limit)}`),
setState: (id: string, status: string) =>
req(`/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }),
savePrefs: (p: Record<string, any>) =>
req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }),
};
export { HttpError };

View file

@ -0,0 +1,43 @@
export function relativeTime(iso: string | null): string {
if (!iso) return "";
const then = new Date(iso).getTime();
const secs = Math.max(0, (Date.now() - then) / 1000);
const units: [number, string][] = [
[60, "s"],
[3600, "min"],
[86400, "h"],
[604800, "d"],
[2592000, "wk"],
[31536000, "mo"],
];
if (secs < 60) return "just now";
for (let i = 0; i < units.length - 1; i++) {
const [, label] = units[i];
const next = units[i + 1][0];
if (secs < next) {
const v = Math.floor(secs / units[i][0]);
return `${v} ${label} ago`;
}
}
const years = Math.floor(secs / 31536000);
if (years >= 1) return `${years} yr ago`;
const months = Math.floor(secs / 2592000);
return `${months} mo ago`;
}
export function formatDuration(sec: number | null): string {
if (sec == null) return "";
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = Math.floor(sec % 60);
const pad = (n: number) => String(n).padStart(2, "0");
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
export function formatViews(n: number | null): string {
if (n == null) return "";
if (n >= 1e9) return `${(n / 1e9).toFixed(1).replace(/\.0$/, "")}B`;
if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace(/\.0$/, "")}M`;
if (n >= 1e3) return `${(n / 1e3).toFixed(1).replace(/\.0$/, "")}K`;
return String(n);
}

42
frontend/src/lib/theme.ts Normal file
View file

@ -0,0 +1,42 @@
export type Mode = "dark" | "light";
export type Scheme = "midnight" | "forest" | "slate" | "youtube";
export const SCHEMES: { id: Scheme; name: string; swatch: string }[] = [
{ id: "midnight", name: "Midnight", swatch: "#6d8cff" },
{ id: "forest", name: "Forest", swatch: "#2dd4bf" },
{ id: "slate", name: "Slate", swatch: "#e8913c" },
{ id: "youtube", name: "YouTube", swatch: "#ff3b46" },
];
export interface ThemePrefs {
mode: Mode;
scheme: Scheme;
fontScale: number;
}
export const DEFAULT_THEME: ThemePrefs = {
mode: "dark",
scheme: "midnight",
fontScale: 1.06,
};
export function applyTheme(t: ThemePrefs): void {
const el = document.documentElement;
el.dataset.theme = t.mode;
el.dataset.scheme = t.scheme;
el.style.setProperty("--font-scale", String(t.fontScale));
}
const KEY = "subfeed.theme";
export function loadLocalTheme(): ThemePrefs {
try {
return { ...DEFAULT_THEME, ...JSON.parse(localStorage.getItem(KEY) || "{}") };
} catch {
return DEFAULT_THEME;
}
}
export function saveLocalTheme(t: ThemePrefs): void {
localStorage.setItem(KEY, JSON.stringify(t));
}

17
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,17 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 30_000, refetchOnWindowFocus: false } },
});
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);

View file

@ -0,0 +1,22 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
bg: "var(--bg)",
surface: "var(--surface)",
card: "var(--card)",
border: "var(--border)",
fg: "var(--fg)",
muted: "var(--muted)",
accent: "var(--accent)",
"accent-fg": "var(--accent-fg)",
},
fontFamily: {
sans: ["Inter", "system-ui", "-apple-system", "Segoe UI", "Roboto", "sans-serif"],
},
},
},
plugins: [],
};

19
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src"]
}

15
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// During `vite dev` (host with Node), proxy API calls to the backend container.
const proxy = {
"/api": "http://localhost:8080",
"/auth": "http://localhost:8080",
"/healthz": "http://localhost:8080",
};
export default defineConfig({
plugins: [react()],
server: { port: 5173, proxy },
build: { outDir: "dist" },
});