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
154
frontend/src/components/VideoCard.tsx
Normal file
154
frontend/src/components/VideoCard.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
|
||||
<button
|
||||
onClick={act("watched")}
|
||||
title="Mark watched"
|
||||
className={clsx(
|
||||
"p-1.5 rounded-md hover:bg-card text-muted hover:text-fg",
|
||||
video.status === "watched" && "text-accent"
|
||||
)}
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={act("saved")}
|
||||
title="Save for later"
|
||||
className={clsx(
|
||||
"p-1.5 rounded-md hover:bg-card text-muted hover:text-fg",
|
||||
video.status === "saved" && "text-accent"
|
||||
)}
|
||||
>
|
||||
<Bookmark className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={act("hidden")}
|
||||
title="Hide"
|
||||
className="p-1.5 rounded-md hover:bg-card text-muted hover:text-fg"
|
||||
>
|
||||
<EyeOff className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Thumb({ video, className }: { video: Video; className?: string }) {
|
||||
return (
|
||||
<a
|
||||
href={video.watch_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={() => video.status === "new" && undefined}
|
||||
className={clsx(
|
||||
"block relative rounded-xl overflow-hidden bg-surface border border-border",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{video.thumbnail_url ? (
|
||||
<img
|
||||
src={video.thumbnail_url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full" />
|
||||
)}
|
||||
{video.duration_seconds != null && (
|
||||
<span className="absolute bottom-1.5 right-1.5 bg-black/80 text-white text-[11px] font-medium px-1.5 py-0.5 rounded">
|
||||
{formatDuration(video.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
{video.live_status === "was_live" && (
|
||||
<span className="absolute top-1.5 left-1.5 bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded">
|
||||
stream
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
<a
|
||||
href={video.watch_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium leading-snug line-clamp-2 hover:text-accent"
|
||||
>
|
||||
{video.title}
|
||||
</a>
|
||||
);
|
||||
|
||||
if (view === "list") {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"group flex gap-3 p-2 rounded-xl hover:bg-card transition",
|
||||
watched && "opacity-55"
|
||||
)}
|
||||
>
|
||||
<Thumb video={video} className="w-44 aspect-video shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
{title}
|
||||
<div className="text-sm text-muted truncate mt-0.5">{video.channel_title}</div>
|
||||
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
||||
</div>
|
||||
<Actions video={video} onState={onState} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx("group", watched && "opacity-55")}>
|
||||
<Thumb video={video} className="aspect-video" />
|
||||
<div className="flex gap-3 mt-2">
|
||||
{video.channel_thumbnail && (
|
||||
<img
|
||||
src={video.channel_thumbnail}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-9 h-9 rounded-full shrink-0 mt-0.5"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{title}
|
||||
<div className="text-sm text-muted truncate mt-0.5">{video.channel_title}</div>
|
||||
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
||||
<Actions video={video} onState={onState} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue