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 contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@ -47,10 +47,23 @@ app.include_router(tags.router)
app.include_router(feed.router) app.include_router(feed.router)
app.include_router(me.router) app.include_router(me.router)
STATIC_DIR = Path(__file__).parent / "static" # The built SPA (populated by the Docker frontend build stage).
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets") STATIC_DIR = Path(__file__).parent / "static_spa"
app.mount(
"/assets",
StaticFiles(directory=STATIC_DIR / "assets", check_dir=False),
name="assets",
)
@app.get("/") @app.get("/")
async def index() -> FileResponse: async def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html") 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: api:
build: build:
context: ./backend context: .
dockerfile: Dockerfile
env_file: .env env_file: .env
environment: environment:
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-subfeed}:${POSTGRES_PASSWORD:-subfeed}@db:5432/${POSTGRES_DB:-subfeed} 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" },
});