+
setView(view === "grid" ? "list" : "grid")}
+ title={view === "grid" ? "List view" : "Grid view"}
+ >
+ {view === "grid" ?
: }
+
+
setTheme({ ...theme, mode: theme.mode === "dark" ? "light" : "dark" })}
+ title="Toggle dark / light"
+ >
+ {theme.mode === "dark" ? : }
+
+
+
setMenuOpen((o) => !o)} title="Theme">
+
+
+ {menuOpen && (
+ <>
+
setMenuOpen(false)} />
+
+ >
+ )}
+
+
+
+ {me.avatar_url ? (
+

+ ) : (
+
+ {me.email[0]?.toUpperCase()}
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx
new file mode 100644
index 0000000..a79f418
--- /dev/null
+++ b/frontend/src/components/Login.tsx
@@ -0,0 +1,23 @@
+export default function Login() {
+ return (
+
+
+
+ Subfeed
+
+
+ Your subscriptions, filtered your way.
+
+ Sign in with your Google account.
+
+
+ Sign in with Google
+
+
Invite-only access.
+
+
+ );
+}
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
new file mode 100644
index 0000000..9e73399
--- /dev/null
+++ b/frontend/src/components/Sidebar.tsx
@@ -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 (
+
+ );
+}
+
+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 (
+
+ );
+}
+
+function Section({
+ title,
+ right,
+ children,
+}: {
+ title: string;
+ right?: React.ReactNode;
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
+
+function Toggle({
+ label,
+ checked,
+ onChange,
+}: {
+ label: string;
+ checked: boolean;
+ onChange: (v: boolean) => void;
+}) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx
new file mode 100644
index 0000000..5c8da76
--- /dev/null
+++ b/frontend/src/components/VideoCard.tsx
@@ -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 (
+
+
+
+
+
+ );
+}
+
+function Thumb({ video, className }: { video: Video; className?: string }) {
+ return (
+
video.status === "new" && undefined}
+ className={clsx(
+ "block relative rounded-xl overflow-hidden bg-surface border border-border",
+ className
+ )}
+ >
+ {video.thumbnail_url ? (
+
+ ) : (
+
+ )}
+ {video.duration_seconds != null && (
+
+ {formatDuration(video.duration_seconds)}
+
+ )}
+ {video.live_status === "was_live" && (
+
+ stream
+
+ )}
+
+ );
+}
+
+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 = (
+
+ {video.title}
+
+ );
+
+ if (view === "list") {
+ return (
+
+
+
+ {title}
+
{video.channel_title}
+
{meta}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {video.channel_thumbnail && (
+

+ )}
+
+ {title}
+
{video.channel_title}
+
{meta}
+
+
+
+
+ );
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..62d2685
--- /dev/null
+++ b/frontend/src/index.css
@@ -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;
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
new file mode 100644
index 0000000..7f391d5
--- /dev/null
+++ b/frontend/src/lib/api.ts
@@ -0,0 +1,104 @@
+export interface Me {
+ id: number;
+ email: string;
+ display_name: string | null;
+ avatar_url: string | null;
+ role: string;
+ preferences: Record
;
+}
+
+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 {
+ 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 => req("/api/me"),
+ tags: (): Promise => req("/api/tags"),
+ status: (): Promise => req("/api/sync/status"),
+ feed: (f: FeedFilters, offset: number, limit: number): Promise =>
+ 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) =>
+ req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }),
+};
+
+export { HttpError };
diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts
new file mode 100644
index 0000000..4f7b89f
--- /dev/null
+++ b/frontend/src/lib/format.ts
@@ -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);
+}
diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts
new file mode 100644
index 0000000..436cad4
--- /dev/null
+++ b/frontend/src/lib/theme.ts
@@ -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));
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..17aa397
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -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(
+
+
+
+
+
+);
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
new file mode 100644
index 0000000..7f8b621
--- /dev/null
+++ b/frontend/tailwind.config.js
@@ -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: [],
+};
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..79a2287
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -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"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..77d2204
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -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" },
+});