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
79
frontend/src/components/Feed.tsx
Normal file
79
frontend/src/components/Feed.tsx
Normal file
|
|
@ -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<Record<string, string>>({});
|
||||
|
||||
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<HTMLDivElement | null>(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 <div className="p-8 text-muted">Loading feed…</div>;
|
||||
if (query.isError) return <div className="p-8 text-muted">Couldn't load the feed.</div>;
|
||||
if (items.length === 0)
|
||||
return <div className="p-8 text-muted">No videos match these filters.</div>;
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
{view === "grid" ? (
|
||||
<div className="grid gap-x-4 gap-y-6 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
|
||||
{items.map((v) => (
|
||||
<VideoCard key={v.id} video={v} view="grid" onState={onState} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-4xl mx-auto flex flex-col gap-1">
|
||||
{items.map((v) => (
|
||||
<VideoCard key={v.id} video={v} view="list" onState={onState} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div ref={sentinel} className="h-10" />
|
||||
{isFetchingNextPage && (
|
||||
<div className="text-center text-muted py-4">Loading more…</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
frontend/src/components/Header.tsx
Normal file
144
frontend/src/components/Header.tsx
Normal file
|
|
@ -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<HTMLButtonElement>) {
|
||||
const { className = "", ...rest } = props;
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
className={`p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeMenu({
|
||||
theme,
|
||||
setTheme,
|
||||
}: {
|
||||
theme: ThemePrefs;
|
||||
setTheme: (t: ThemePrefs) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute right-0 mt-2 w-60 rounded-xl border border-border bg-surface shadow-2xl p-3 z-30">
|
||||
<div className="text-xs uppercase tracking-wide text-muted mb-2">Color scheme</div>
|
||||
<div className="grid grid-cols-4 gap-2 mb-3">
|
||||
{SCHEMES.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setTheme({ ...theme, scheme: s.id as Scheme })}
|
||||
title={s.name}
|
||||
className={`h-9 rounded-lg border-2 transition ${
|
||||
theme.scheme === s.id ? "border-fg" : "border-transparent"
|
||||
}`}
|
||||
style={{ background: s.swatch }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs uppercase tracking-wide text-muted mb-2">Text size</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.9}
|
||||
max={1.3}
|
||||
step={0.02}
|
||||
value={theme.fontScale}
|
||||
onChange={(e) => setTheme({ ...theme, fontScale: Number(e.target.value) })}
|
||||
className="w-full accent-accent"
|
||||
/>
|
||||
<div className="text-right text-xs text-muted">
|
||||
{Math.round(theme.fontScale * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<header className="h-14 shrink-0 border-b border-border bg-surface/80 backdrop-blur flex items-center gap-3 px-4 z-20">
|
||||
<div className="text-xl font-bold tracking-tight select-none">
|
||||
Sub<span className="text-accent">feed</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 max-w-xl mx-auto relative">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
||||
<input
|
||||
value={filters.q}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<IconBtn
|
||||
onClick={() => setView(view === "grid" ? "list" : "grid")}
|
||||
title={view === "grid" ? "List view" : "Grid view"}
|
||||
>
|
||||
{view === "grid" ? <List className="w-5 h-5" /> : <LayoutGrid className="w-5 h-5" />}
|
||||
</IconBtn>
|
||||
<IconBtn
|
||||
onClick={() => setTheme({ ...theme, mode: theme.mode === "dark" ? "light" : "dark" })}
|
||||
title="Toggle dark / light"
|
||||
>
|
||||
{theme.mode === "dark" ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||
</IconBtn>
|
||||
<div className="relative">
|
||||
<IconBtn onClick={() => setMenuOpen((o) => !o)} title="Theme">
|
||||
<Palette className="w-5 h-5" />
|
||||
</IconBtn>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-20" onClick={() => setMenuOpen(false)} />
|
||||
<ThemeMenu theme={theme} setTheme={setTheme} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
{me.avatar_url ? (
|
||||
<img src={me.avatar_url} alt="" className="w-8 h-8 rounded-full" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-card grid place-items-center text-xs">
|
||||
{me.email[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<IconBtn onClick={logout} title="Sign out">
|
||||
<LogOut className="w-5 h-5" />
|
||||
</IconBtn>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/Login.tsx
Normal file
23
frontend/src/components/Login.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export default function Login() {
|
||||
return (
|
||||
<div className="min-h-screen grid place-items-center bg-bg text-fg">
|
||||
<div className="w-[min(92vw,420px)] rounded-2xl border border-border bg-card/70 backdrop-blur p-10 text-center shadow-2xl">
|
||||
<div className="text-3xl font-bold tracking-tight">
|
||||
Sub<span className="text-accent">feed</span>
|
||||
</div>
|
||||
<p className="text-muted my-5 leading-relaxed">
|
||||
Your subscriptions, filtered your way.
|
||||
<br />
|
||||
Sign in with your Google account.
|
||||
</p>
|
||||
<a
|
||||
href="/auth/login"
|
||||
className="inline-flex items-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
|
||||
>
|
||||
Sign in with Google
|
||||
</a>
|
||||
<div className="text-xs text-muted mt-6">Invite-only access.</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
232
frontend/src/components/Sidebar.tsx
Normal file
232
frontend/src/components/Sidebar.tsx
Normal file
|
|
@ -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 (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`text-xs px-2.5 py-1 rounded-full border transition ${
|
||||
active
|
||||
? "bg-accent text-accent-fg border-accent"
|
||||
: "bg-card border-border text-fg hover:border-accent"
|
||||
}`}
|
||||
>
|
||||
{tag.name}
|
||||
<span className={active ? "opacity-80 ml-1" : "text-muted ml-1"}>
|
||||
{tag.channel_count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<aside className="w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-5 hidden md:block">
|
||||
<Section title="Show">
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{SHOWS.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setFilters({ ...filters, show: s.id })}
|
||||
className={`text-xs py-1.5 rounded-lg border transition ${
|
||||
filters.show === s.id
|
||||
? "bg-accent text-accent-fg border-accent"
|
||||
: "bg-card border-border hover:border-accent"
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Sort">
|
||||
<select
|
||||
value={filters.sort}
|
||||
onChange={(e) => setFilters({ ...filters, sort: e.target.value })}
|
||||
className="w-full bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
|
||||
>
|
||||
{SORTS.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Section>
|
||||
|
||||
<Section title="Content">
|
||||
<Toggle
|
||||
label="Include Shorts"
|
||||
checked={filters.includeShorts}
|
||||
onChange={(v) => setFilters({ ...filters, includeShorts: v })}
|
||||
/>
|
||||
<Toggle
|
||||
label="Include live / upcoming"
|
||||
checked={filters.includeLive}
|
||||
onChange={(v) => setFilters({ ...filters, includeLive: v })}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{languages.length > 0 && (
|
||||
<Section title="Language">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{languages.map((t) => (
|
||||
<TagChip
|
||||
key={t.id}
|
||||
tag={t}
|
||||
active={filters.tags.includes(t.id)}
|
||||
onClick={() => toggleTag(t.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{topics.length > 0 && (
|
||||
<Section
|
||||
title="Topic"
|
||||
right={
|
||||
<button
|
||||
onClick={() =>
|
||||
setFilters({ ...filters, tagMode: filters.tagMode === "or" ? "and" : "or" })
|
||||
}
|
||||
className="text-[10px] uppercase tracking-wide text-muted hover:text-accent"
|
||||
title="Match any vs all selected tags"
|
||||
>
|
||||
{filters.tagMode === "or" ? "Any" : "All"}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{topics.map((t) => (
|
||||
<TagChip
|
||||
key={t.id}
|
||||
tag={t}
|
||||
active={filters.tags.includes(t.id)}
|
||||
onClick={() => toggleTag(t.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{active && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
tags: [],
|
||||
tagMode: "or",
|
||||
q: filters.q,
|
||||
sort: "newest",
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
})
|
||||
}
|
||||
className="w-full text-xs py-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
right,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
right?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-xs uppercase tracking-wide text-muted">{title}</div>
|
||||
{right}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center justify-between py-1 cursor-pointer text-sm">
|
||||
<span>{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`w-9 h-5 rounded-full transition relative ${
|
||||
checked ? "bg-accent" : "bg-border"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all ${
|
||||
checked ? "left-[18px]" : "left-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
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