2026-06-12 18:17:03 +02:00
|
|
|
import { memo } from "react";
|
2026-06-14 18:40:12 +02:00
|
|
|
import {
|
|
|
|
|
Bookmark,
|
|
|
|
|
Check,
|
|
|
|
|
CheckCheck,
|
|
|
|
|
Eye,
|
|
|
|
|
EyeOff,
|
|
|
|
|
ListFilter,
|
|
|
|
|
Play,
|
|
|
|
|
RotateCcw,
|
|
|
|
|
} from "lucide-react";
|
2026-06-12 18:01:43 +02:00
|
|
|
import Avatar from "./Avatar";
|
2026-06-11 02:19:47 +02:00
|
|
|
import clsx from "clsx";
|
|
|
|
|
import type { Video } from "../lib/api";
|
|
|
|
|
import { formatDuration, formatViews, relativeTime } from "../lib/format";
|
|
|
|
|
|
|
|
|
|
function Actions({
|
|
|
|
|
video,
|
|
|
|
|
onState,
|
2026-06-11 03:47:51 +02:00
|
|
|
onChannelFilter,
|
2026-06-11 02:19:47 +02:00
|
|
|
}: {
|
|
|
|
|
video: Video;
|
|
|
|
|
onState: (id: string, status: string) => void;
|
2026-06-11 03:47:51 +02:00
|
|
|
onChannelFilter?: (channelId: string, channelName: string) => void;
|
2026-06-11 02:19:47 +02:00
|
|
|
}) {
|
|
|
|
|
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")}
|
2026-06-12 17:39:01 +02:00
|
|
|
title={video.status === "watched" ? "Watched — click to unmark" : "Mark watched"}
|
2026-06-11 02:19:47 +02:00
|
|
|
className={clsx(
|
2026-06-11 03:47:51 +02:00
|
|
|
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
|
2026-06-11 02:19:47 +02:00
|
|
|
video.status === "watched" && "text-accent"
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-06-12 17:39:01 +02:00
|
|
|
{video.status === "watched" ? (
|
|
|
|
|
<CheckCheck className="w-4 h-4" />
|
|
|
|
|
) : (
|
|
|
|
|
<Check className="w-4 h-4" />
|
|
|
|
|
)}
|
2026-06-11 02:19:47 +02:00
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={act("saved")}
|
2026-06-12 17:39:01 +02:00
|
|
|
title={video.status === "saved" ? "Saved — click to remove" : "Save for later"}
|
2026-06-11 02:19:47 +02:00
|
|
|
className={clsx(
|
2026-06-11 03:47:51 +02:00
|
|
|
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
|
2026-06-12 17:39:01 +02:00
|
|
|
video.status === "saved" && "fill-current text-accent"
|
2026-06-11 02:19:47 +02:00
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<Bookmark className="w-4 h-4" />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={act("hidden")}
|
2026-06-11 04:00:17 +02:00
|
|
|
title={video.status === "hidden" ? "Unhide" : "Hide"}
|
|
|
|
|
className={clsx(
|
|
|
|
|
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
|
|
|
|
|
video.status === "hidden" && "text-accent"
|
|
|
|
|
)}
|
2026-06-11 02:19:47 +02:00
|
|
|
>
|
2026-06-11 04:00:17 +02:00
|
|
|
{video.status === "hidden" ? (
|
|
|
|
|
<Eye className="w-4 h-4" />
|
|
|
|
|
) : (
|
|
|
|
|
<EyeOff className="w-4 h-4" />
|
|
|
|
|
)}
|
2026-06-11 02:19:47 +02:00
|
|
|
</button>
|
2026-06-11 03:47:51 +02:00
|
|
|
{onChannelFilter && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
onChannelFilter(video.channel_id, video.channel_title ?? "This channel");
|
|
|
|
|
}}
|
|
|
|
|
title="Only this channel"
|
|
|
|
|
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"
|
|
|
|
|
>
|
|
|
|
|
<ListFilter className="w-4 h-4" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
2026-06-11 02:19:47 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 17:38:45 +02:00
|
|
|
// 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,
|
2026-06-14 18:40:12 +02:00
|
|
|
onOpen?: (v: Video, startAt?: number | null) => void
|
2026-06-12 17:38:45 +02:00
|
|
|
): 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;
|
2026-06-14 18:40:12 +02:00
|
|
|
onOpen?: (v: Video, startAt?: number | null) => void;
|
2026-06-12 17:38:45 +02:00
|
|
|
}) {
|
2026-06-14 18:40:12 +02:00
|
|
|
// 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);
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
return (
|
|
|
|
|
<a
|
|
|
|
|
href={video.watch_url}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noreferrer"
|
2026-06-12 17:38:45 +02:00
|
|
|
onClick={(e) => openInApp(e, video, onOpen)}
|
2026-06-11 02:19:47 +02:00
|
|
|
className={clsx(
|
2026-06-11 03:28:45 +02:00
|
|
|
"block relative rounded-xl overflow-hidden bg-surface border border-border shadow-md group-hover:shadow-2xl transition-shadow",
|
2026-06-11 02:19:47 +02:00
|
|
|
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>
|
|
|
|
|
)}
|
fix: address reader-UI feedback (shorts, search, tags, login, UX)
- Shorts: confirm via youtube.com/shorts/<id> probe (SOCS cookie bypasses the
consent redirect) instead of a 60s heuristic; concurrent probing, shorts_probed
flag, scheduled refinement (migration 0005)
- Search: match title + channel name only (descriptions caused noisy results)
- Faceted tag filtering: AND across categories (language AND topic narrows),
OR within a category; any/all toggle applies to topics
- Language detection: majority vote over individual titles (fixes misdetections
like multipoleguy -> English; drops bogus Polish/Romanian)
- Login: drop forced consent so returning sign-in is quick (select_account)
- Feed cards: clickable channel name (opens channel), persistent saved badge,
undo toast on hide, Hidden view to restore; tag chips show counts in tooltip
2026-06-11 03:07:49 +02:00
|
|
|
{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>
|
|
|
|
|
)}
|
2026-06-14 18:40:12 +02:00
|
|
|
{/* 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="Continue where you left off"
|
|
|
|
|
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" />
|
|
|
|
|
Continue
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={open(0)}
|
|
|
|
|
title="Play from the beginning"
|
|
|
|
|
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" />
|
|
|
|
|
Restart
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<button
|
|
|
|
|
onClick={open(null)}
|
|
|
|
|
title="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>
|
|
|
|
|
)}
|
2026-06-11 02:19:47 +02:00
|
|
|
</a>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 18:17:03 +02:00
|
|
|
function VideoCard({
|
2026-06-11 02:19:47 +02:00
|
|
|
video,
|
|
|
|
|
view,
|
|
|
|
|
onState,
|
2026-06-11 03:47:51 +02:00
|
|
|
onChannelFilter,
|
2026-06-12 17:38:45 +02:00
|
|
|
onOpen,
|
2026-06-11 02:19:47 +02:00
|
|
|
}: {
|
|
|
|
|
video: Video;
|
|
|
|
|
view: "grid" | "list";
|
|
|
|
|
onState: (id: string, status: string) => void;
|
2026-06-11 03:47:51 +02:00
|
|
|
onChannelFilter?: (channelId: string, channelName: string) => void;
|
2026-06-14 18:40:12 +02:00
|
|
|
onOpen?: (v: Video, startAt?: number | null) => void;
|
2026-06-11 02:19:47 +02:00
|
|
|
}) {
|
|
|
|
|
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"
|
2026-06-12 17:38:45 +02:00
|
|
|
onClick={(e) => openInApp(e, video, onOpen)}
|
2026-06-11 02:19:47 +02:00
|
|
|
className="font-medium leading-snug line-clamp-2 hover:text-accent"
|
|
|
|
|
>
|
|
|
|
|
{video.title}
|
|
|
|
|
</a>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (view === "list") {
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className={clsx(
|
2026-06-12 18:17:03 +02:00
|
|
|
"cv-row group flex gap-3 p-2 rounded-xl hover:bg-card hover:shadow-lg transition",
|
2026-06-11 02:19:47 +02:00
|
|
|
watched && "opacity-55"
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-06-12 17:38:45 +02:00
|
|
|
<Thumb video={video} className="w-44 aspect-video shrink-0" onOpen={onOpen} />
|
2026-06-11 02:19:47 +02:00
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
{title}
|
fix: address reader-UI feedback (shorts, search, tags, login, UX)
- Shorts: confirm via youtube.com/shorts/<id> probe (SOCS cookie bypasses the
consent redirect) instead of a 60s heuristic; concurrent probing, shorts_probed
flag, scheduled refinement (migration 0005)
- Search: match title + channel name only (descriptions caused noisy results)
- Faceted tag filtering: AND across categories (language AND topic narrows),
OR within a category; any/all toggle applies to topics
- Language detection: majority vote over individual titles (fixes misdetections
like multipoleguy -> English; drops bogus Polish/Romanian)
- Login: drop forced consent so returning sign-in is quick (select_account)
- Feed cards: clickable channel name (opens channel), persistent saved badge,
undo toast on hide, Hidden view to restore; tag chips show counts in tooltip
2026-06-11 03:07:49 +02:00
|
|
|
<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>
|
2026-06-11 02:19:47 +02:00
|
|
|
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
|
|
|
|
</div>
|
2026-06-11 03:47:51 +02:00
|
|
|
<Actions video={video} onState={onState} onChannelFilter={onChannelFilter} />
|
2026-06-11 02:19:47 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-06-11 03:28:45 +02:00
|
|
|
<div
|
|
|
|
|
className={clsx(
|
2026-06-12 18:17:03 +02:00
|
|
|
"cv-card group glass-card glass-hover rounded-2xl p-2.5 transition-all duration-150 hover:-translate-y-1",
|
2026-06-11 03:28:45 +02:00
|
|
|
watched && "opacity-55"
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-06-12 17:38:45 +02:00
|
|
|
<Thumb video={video} className="aspect-video" onOpen={onOpen} />
|
2026-06-11 03:47:51 +02:00
|
|
|
<div className="flex gap-3 mt-2.5 px-0.5 pb-0.5">
|
2026-06-12 18:01:43 +02:00
|
|
|
<Avatar
|
|
|
|
|
src={video.channel_thumbnail}
|
|
|
|
|
fallback={video.channel_title ?? ""}
|
|
|
|
|
className="w-9 h-9 rounded-full shrink-0 mt-0.5"
|
|
|
|
|
/>
|
2026-06-11 02:19:47 +02:00
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
{title}
|
fix: address reader-UI feedback (shorts, search, tags, login, UX)
- Shorts: confirm via youtube.com/shorts/<id> probe (SOCS cookie bypasses the
consent redirect) instead of a 60s heuristic; concurrent probing, shorts_probed
flag, scheduled refinement (migration 0005)
- Search: match title + channel name only (descriptions caused noisy results)
- Faceted tag filtering: AND across categories (language AND topic narrows),
OR within a category; any/all toggle applies to topics
- Language detection: majority vote over individual titles (fixes misdetections
like multipoleguy -> English; drops bogus Polish/Romanian)
- Login: drop forced consent so returning sign-in is quick (select_account)
- Feed cards: clickable channel name (opens channel), persistent saved badge,
undo toast on hide, Hidden view to restore; tag chips show counts in tooltip
2026-06-11 03:07:49 +02:00
|
|
|
<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>
|
2026-06-11 02:19:47 +02:00
|
|
|
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
2026-06-11 03:47:51 +02:00
|
|
|
<Actions video={video} onState={onState} onChannelFilter={onChannelFilter} />
|
2026-06-11 02:19:47 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-06-12 18:17:03 +02:00
|
|
|
|
|
|
|
|
// 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);
|