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

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