From e56502789f499245e4bf8d4d080a2966a800ebac Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 11 Jun 2026 02:19:47 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20M4=20(part=202)=20=E2=80=94=20React=20r?= =?UTF-8?q?eader=20UI=20with=20theming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Dockerfile | 28 ++++ backend/app/main.py | 19 ++- docker-compose.yml | 3 +- frontend/index.html | 12 ++ frontend/package.json | 27 +++ frontend/postcss.config.js | 6 + frontend/src/App.tsx | 88 ++++++++++ frontend/src/components/Feed.tsx | 79 +++++++++ frontend/src/components/Header.tsx | 144 ++++++++++++++++ frontend/src/components/Login.tsx | 23 +++ frontend/src/components/Sidebar.tsx | 232 ++++++++++++++++++++++++++ frontend/src/components/VideoCard.tsx | 154 +++++++++++++++++ frontend/src/index.css | 121 ++++++++++++++ frontend/src/lib/api.ts | 104 ++++++++++++ frontend/src/lib/format.ts | 43 +++++ frontend/src/lib/theme.ts | 42 +++++ frontend/src/main.tsx | 17 ++ frontend/tailwind.config.js | 22 +++ frontend/tsconfig.json | 19 +++ frontend/vite.config.ts | 15 ++ 20 files changed, 1194 insertions(+), 4 deletions(-) create mode 100644 Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/Feed.tsx create mode 100644 frontend/src/components/Header.tsx create mode 100644 frontend/src/components/Login.tsx create mode 100644 frontend/src/components/Sidebar.tsx create mode 100644 frontend/src/components/VideoCard.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/format.ts create mode 100644 frontend/src/lib/theme.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..37e8237 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/backend/app/main.py b/backend/app/main.py index d129ab4..faa3d58 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/docker-compose.yml b/docker-compose.yml index ddc15d9..5ead8e5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..8e21e5b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Subfeed + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8ef7c07 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..8153139 --- /dev/null +++ b/frontend/src/App.tsx @@ -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(() => loadLocalTheme()); + const [filters, setFilters] = useState(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
Loading…
; + if (meQuery.error instanceof HttpError && meQuery.error.status === 401) + return ; + if (meQuery.error) + return ( +
+ Something went wrong. +
+ ); + + return ( +
+
+
+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx new file mode 100644 index 0000000..8f42b0c --- /dev/null +++ b/frontend/src/components/Feed.tsx @@ -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>({}); + + 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(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
Loading feed…
; + if (query.isError) return
Couldn't load the feed.
; + if (items.length === 0) + return
No videos match these filters.
; + + return ( +
+ {view === "grid" ? ( +
+ {items.map((v) => ( + + ))} +
+ ) : ( +
+ {items.map((v) => ( + + ))} +
+ )} +
+ {isFetchingNextPage && ( +
Loading more…
+ )} +
+ ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx new file mode 100644 index 0000000..49e6b44 --- /dev/null +++ b/frontend/src/components/Header.tsx @@ -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) { + const { className = "", ...rest } = props; + return ( +
+
Text size
+ setTheme({ ...theme, fontScale: Number(e.target.value) })} + className="w-full accent-accent" + /> +
+ {Math.round(theme.fontScale * 100)}% +
+ + ); +} + +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 ( +
+
+ Subfeed +
+ +
+ + 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" + /> +
+ +
+ 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 ( +
+
+
{title}
+ {right} +
+ {children} +
+ ); +} + +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" }, +});