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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue