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:
parent
9a377b7e7e
commit
e56502789f
20 changed files with 1194 additions and 4 deletions
104
frontend/src/lib/api.ts
Normal file
104
frontend/src/lib/api.ts
Normal 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 };
|
||||
43
frontend/src/lib/format.ts
Normal file
43
frontend/src/lib/format.ts
Normal 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
42
frontend/src/lib/theme.ts
Normal 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));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue