siftlode/frontend/src/components/VideoCard.tsx

181 lines
5.1 KiB
TypeScript
Raw Normal View History

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 shadow-md group-hover:shadow-2xl transition-shadow",
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>
)}
{video.status === "saved" && (
<span className="absolute top-1.5 right-1.5 bg-accent text-accent-fg rounded-full p-1">
<Bookmark className="w-3.5 h-3.5" />
</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 hover:shadow-lg transition",
watched && "opacity-55"
)}
>
<Thumb video={video} className="w-44 aspect-video shrink-0" />
<div className="min-w-0 flex-1">
{title}
<a
href={video.channel_url}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full hover:text-fg"
>
{video.channel_title}
</a>
<div className="text-xs text-muted mt-0.5">{meta}</div>
</div>
<Actions video={video} onState={onState} />
</div>
);
}
return (
<div
className={clsx(
"group transition-transform duration-150 hover:-translate-y-1",
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}
<a
href={video.channel_url}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full hover:text-fg"
>
{video.channel_title}
</a>
<div className="text-xs text-muted mt-0.5">{meta}</div>
<Actions video={video} onState={onState} />
</div>
</div>
</div>
);
}