siftlode/frontend/src/components/VideoCard.tsx

318 lines
10 KiB
TypeScript
Raw Normal View History

import { memo } from "react";
import { useTranslation } from "react-i18next";
import {
Bookmark,
Check,
CheckCheck,
Eye,
EyeOff,
ListFilter,
Play,
RotateCcw,
} from "lucide-react";
import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
import clsx from "clsx";
import type { Video } from "../lib/api";
import { formatDuration, formatViews, relativeTime } from "../lib/format";
function Actions({
video,
onState,
onToggleSave,
onChannelFilter,
}: {
video: Video;
onState: (id: string, status: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
onChannelFilter?: (channelId: string, channelName: string) => void;
}) {
const { t } = useTranslation();
const act = (status: string) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onState(video.id, video.status === status ? "new" : status);
};
const toggleSave = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onToggleSave(video.id, !video.saved);
};
return (
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
<button
onClick={act("watched")}
title={video.status === "watched" ? t("card.watchedUnmark") : t("card.markWatched")}
className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
video.status === "watched" && "text-accent"
)}
>
{video.status === "watched" ? (
<CheckCheck className="w-4 h-4" />
) : (
<Check className="w-4 h-4" />
)}
</button>
<button
onClick={toggleSave}
title={video.saved ? t("card.savedRemove") : t("card.saveForLater")}
className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
video.saved && "fill-current text-accent"
)}
>
<Bookmark className="w-4 h-4" />
</button>
<AddToPlaylist videoId={video.id} />
<button
onClick={act("hidden")}
title={video.status === "hidden" ? t("card.unhide") : t("card.hide")}
className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
video.status === "hidden" && "text-accent"
)}
>
{video.status === "hidden" ? (
<Eye className="w-4 h-4" />
) : (
<EyeOff className="w-4 h-4" />
)}
</button>
{onChannelFilter && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onChannelFilter(video.channel_id, video.channel_title ?? t("card.thisChannel"));
}}
title={t("card.onlyThisChannel")}
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"
>
<ListFilter className="w-4 h-4" />
</button>
)}
</div>
);
}
// Plain left-click opens the in-app player (the experiment); Ctrl/Cmd/middle-click
// keep the native "open youtube.com in a new tab" behavior via the underlying href.
function openInApp(
e: React.MouseEvent,
video: Video,
onOpen?: (v: Video, startAt?: number | null) => void
): void {
if (!onOpen || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
e.preventDefault();
onOpen(video);
}
function Thumb({
video,
className,
onOpen,
}: {
video: Video;
className?: string;
onOpen?: (v: Video, startAt?: number | null) => void;
}) {
const { t } = useTranslation();
// A non-zero saved position on an unfinished video = "in progress": show a resume
// progress bar and offer Continue / Restart instead of a plain Play.
const inProgress = video.position_seconds > 0 && video.status !== "watched";
const pct =
inProgress && video.duration_seconds
? Math.min(99, Math.max(2, (video.position_seconds / video.duration_seconds) * 100))
: 0;
// Open in the in-app player at an explicit position (null = resume from saved).
const open = (startAt: number | null) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onOpen?.(video, startAt);
};
return (
<a
href={video.watch_url}
target="_blank"
rel="noreferrer"
onClick={(e) => openInApp(e, video, onOpen)}
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">
{t("card.stream")}
</span>
)}
{video.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>
)}
{/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. */}
{onOpen && (
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 group-hover:opacity-100 transition-opacity">
{inProgress ? (
<>
<button
onClick={open(null)}
title={t("card.continueTitle")}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-semibold bg-accent text-accent-fg shadow-lg hover:opacity-90 transition"
>
<Play className="w-4 h-4 fill-current" />
{t("card.continue")}
</button>
<button
onClick={open(0)}
title={t("card.restartTitle")}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-white/15 text-white backdrop-blur-sm hover:bg-white/25 transition"
>
<RotateCcw className="w-4 h-4" />
{t("card.restart")}
</button>
</>
) : (
<button
onClick={open(null)}
title={t("card.play")}
className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-accent text-accent-fg shadow-lg hover:scale-105 transition"
>
<Play className="w-5 h-5 fill-current translate-x-[1px]" />
</button>
)}
</div>
)}
{/* Resume progress bar (sits at the very bottom, under the duration badge). */}
{pct > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
</div>
)}
</a>
);
}
function VideoCard({
video,
view,
onState,
onToggleSave,
onChannelFilter,
onOpen,
}: {
video: Video;
view: "grid" | "list";
onState: (id: string, status: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
onChannelFilter?: (channelId: string, channelName: string) => void;
onOpen?: (v: Video, startAt?: number | null) => void;
}) {
const { t } = useTranslation();
const watched = video.status === "watched";
const meta = (
<>
{video.view_count != null && (
<>
{formatViews(video.view_count)} {t("card.views")} ·{" "}
</>
)}
{relativeTime(video.published_at)}
</>
);
const title = (
<a
href={video.watch_url}
target="_blank"
rel="noreferrer"
onClick={(e) => openInApp(e, video, onOpen)}
className="font-medium leading-snug line-clamp-2 hover:text-accent"
>
{video.title}
</a>
);
if (view === "list") {
return (
<div
className={clsx(
"cv-row 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" onOpen={onOpen} />
<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} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} />
</div>
);
}
return (
<div
className={clsx(
"cv-card group glass-card glass-hover rounded-2xl p-2.5 transition-all duration-150 hover:-translate-y-1",
watched && "opacity-55"
)}
>
<Thumb video={video} className="aspect-video" onOpen={onOpen} />
<div className="flex gap-3 mt-2.5 px-0.5 pb-0.5">
<Avatar
src={video.channel_thumbnail}
fallback={video.channel_title ?? ""}
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} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} />
</div>
</div>
</div>
);
}
// Memoized so appending a feed page only renders the new cards, not every existing
// one (the callbacks from Feed are stable; non-overridden video objects keep their
// identity across renders).
export default memo(VideoCard);