improvement(feed): rework the row views from UAT feedback
The bare row becomes a single line — title truncating into the space left, then channel, badges, meta and the actions each holding a fixed column — and it drops the 896px reading cap, since with the actions right-aligned there is no gap for the extra width to stretch. The actions get a reserved slot: the reset button only exists for videos with watch state, so without one the meta sat 32px further right on some rows than others. Rows and tiles get a permanent container instead of a hover-only fill. They were transparent at rest and painted an opaque bg on hover, so running the pointer down the list kept covering and re-revealing the ambient backdrop — which reads as the background flickering. Card actions now sit on the bottom edge rather than wherever the title ends, and the small thumbnails carry icon-only controls: the labelled Continue / Restart are sized for a card's image and were clipped at 176px.
This commit is contained in:
@@ -166,10 +166,14 @@ function Thumb({
|
||||
video,
|
||||
className,
|
||||
onOpen,
|
||||
small = false,
|
||||
}: {
|
||||
video: Video;
|
||||
className?: string;
|
||||
onOpen?: (v: Video, startAt?: number | null) => void;
|
||||
/** A row/tile thumbnail (~160-176px): the hover controls go icon-only, since the labelled
|
||||
* Continue / Restart buttons are sized for the card's full-width image and get clipped here. */
|
||||
small?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const inProgress = isInProgress(video);
|
||||
@@ -237,27 +241,39 @@ function Thumb({
|
||||
<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"
|
||||
aria-label={t("card.continue")}
|
||||
className={clsx(
|
||||
"inline-flex items-center justify-center gap-1.5 rounded-lg font-semibold bg-accent text-accent-fg shadow-lg hover:opacity-90 transition",
|
||||
small ? "w-9 h-9 rounded-full" : "px-3 py-1.5 text-sm"
|
||||
)}
|
||||
>
|
||||
<Play className="w-4 h-4 fill-current" />
|
||||
{t("card.continue")}
|
||||
{!small && 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"
|
||||
aria-label={t("card.restart")}
|
||||
className={clsx(
|
||||
"inline-flex items-center justify-center gap-1.5 rounded-lg font-medium bg-white/15 text-white backdrop-blur-sm hover:bg-white/25 transition",
|
||||
small ? "w-9 h-9 rounded-full" : "px-3 py-1.5 text-sm"
|
||||
)}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
{t("card.restart")}
|
||||
{!small && 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"
|
||||
aria-label={t("card.play")}
|
||||
className={clsx(
|
||||
"inline-flex items-center justify-center rounded-full bg-accent text-accent-fg shadow-lg hover:scale-105 transition",
|
||||
small ? "w-9 h-9" : "w-12 h-12"
|
||||
)}
|
||||
>
|
||||
<Play className="w-5 h-5 fill-current translate-x-[1px]" />
|
||||
<Play className={clsx("fill-current translate-x-[1px]", small ? "w-4 h-4" : "w-5 h-5")} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -311,13 +327,15 @@ function VideoCard({
|
||||
{video.published_at && <>{" · "}{formatDate(video.published_at, i18n.language)}</>}
|
||||
</>
|
||||
);
|
||||
// Show the full title in a native tooltip only when it's clamped (overflows its 2 lines).
|
||||
// Show the full title in a native tooltip only when it's actually cut off — vertically where it
|
||||
// clamps to 2 lines, horizontally where the one-line row truncates it.
|
||||
const titleRef = useRef<HTMLAnchorElement>(null);
|
||||
const [titleClamped, setTitleClamped] = useState(false);
|
||||
useEffect(() => {
|
||||
const el = titleRef.current;
|
||||
if (!el) return;
|
||||
const check = () => setTitleClamped(el.scrollHeight > el.clientHeight + 1);
|
||||
const check = () =>
|
||||
setTitleClamped(el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1);
|
||||
check();
|
||||
const ro = new ResizeObserver(check);
|
||||
ro.observe(el);
|
||||
@@ -331,25 +349,31 @@ function VideoCard({
|
||||
rel="noreferrer"
|
||||
onClick={(e) => openInApp(e, video, onOpen)}
|
||||
title={titleClamped ? video.title ?? undefined : undefined}
|
||||
className="font-medium leading-snug line-clamp-2 hover:text-accent"
|
||||
className={clsx(
|
||||
"font-medium leading-snug hover:text-accent",
|
||||
noThumb ? "block truncate" : "line-clamp-2"
|
||||
)}
|
||||
>
|
||||
{video.title}
|
||||
</a>
|
||||
);
|
||||
// Title + channel button + meta — identical in both layouts (only their wrapper / Actions placement
|
||||
// differs). Safe to reuse the same element: exactly one of the two branches below renders.
|
||||
const channelButton = (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel"));
|
||||
}}
|
||||
className="text-sm text-muted truncate block w-fit max-w-full text-left hover:text-fg"
|
||||
>
|
||||
{video.channel_title}
|
||||
</button>
|
||||
);
|
||||
// Title + channel + meta stacked — the card and the thumbnailed row share this; only their
|
||||
// wrapper / Actions placement differs. Safe to reuse: exactly one branch below renders.
|
||||
const textBlock = (
|
||||
<>
|
||||
{title}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel"));
|
||||
}}
|
||||
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full text-left hover:text-fg"
|
||||
>
|
||||
{video.channel_title}
|
||||
</button>
|
||||
<div className="mt-0.5">{channelButton}</div>
|
||||
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
||||
</>
|
||||
);
|
||||
@@ -357,24 +381,46 @@ function VideoCard({
|
||||
const actions = (
|
||||
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
|
||||
);
|
||||
// A permanent container, not a hover-only fill. Rows used to be fully transparent at rest and
|
||||
// paint an opaque bg-card on hover — so running the pointer down the list kept covering and
|
||||
// re-revealing the ambient backdrop behind them, which reads as the background flickering. The
|
||||
// cards never did this because glass-card always paints. No hover lift here: a 900px-wide row
|
||||
// jumping as the pointer crosses it is a lot more restless than a card doing it.
|
||||
const rowShell = "cv-row group glass-card glass-hover relative rounded-xl transition";
|
||||
const resumeBar = (pct: number) =>
|
||||
pct > 0 && (
|
||||
<div className="absolute bottom-0 left-2 right-2 h-1 rounded-full bg-black/40">
|
||||
<div className="h-full rounded-full bg-accent" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (spec.family === "row") {
|
||||
const pct = noThumb ? resumePct(video) : 0;
|
||||
// No thumbnail => everything fits on ONE line: title (truncating into whatever's left), then
|
||||
// channel, badges+meta and the actions all holding a fixed place at the right. How wide the
|
||||
// line is allowed to get is the layout's call — see each view's maxCol in lib/feedView.ts.
|
||||
if (noThumb) {
|
||||
return (
|
||||
<div className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}>
|
||||
<div className="min-w-0 flex-1">{title}</div>
|
||||
<div className="shrink-0 max-w-[14rem]">{channelButton}</div>
|
||||
<div className="shrink-0 text-xs text-muted whitespace-nowrap">{meta}</div>
|
||||
{/* Fixed slot, right-aligned: the reset button only exists for videos that have watch
|
||||
state, so without a reserved width the meta ahead of it would sit 32px further right
|
||||
on some rows than others — visible jitter in a layout whose whole point is that the
|
||||
columns line up. Sized for the full six (6 x 28px + 5 x 4px gap = 188). */}
|
||||
<div className="shrink-0 w-48 flex justify-end">{actions}</div>
|
||||
{resumeBar(resumePct(video))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"cv-row group relative flex gap-3 p-2 rounded-xl hover:bg-card hover:shadow-lg transition",
|
||||
watched && "opacity-55"
|
||||
)}
|
||||
>
|
||||
{/* The bare row drops Thumb; its badges move into the meta line instead (see noThumb). */}
|
||||
{spec.thumb && (
|
||||
<Thumb
|
||||
video={video}
|
||||
className={clsx("aspect-video shrink-0", spec.actionsBelow ? "w-40" : "w-44")}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
)}
|
||||
<div className={clsx(rowShell, "flex gap-3 p-2", watched && "opacity-55")}>
|
||||
<Thumb
|
||||
video={video}
|
||||
className={clsx("aspect-video shrink-0", spec.actionsBelow ? "w-40" : "w-44")}
|
||||
onOpen={onOpen}
|
||||
small
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
{textBlock}
|
||||
{/* Tiles put the actions under the meta, in vertical space the thumbnail already
|
||||
@@ -383,26 +429,28 @@ function VideoCard({
|
||||
{spec.actionsBelow && <div className="mt-1">{actions}</div>}
|
||||
</div>
|
||||
{!spec.actionsBelow && actions}
|
||||
{/* Thumb draws the resume bar over the image; with no image it goes on the row itself. */}
|
||||
{pct > 0 && (
|
||||
<div className="absolute bottom-0 left-2 right-2 h-0.5 rounded-full bg-black/40">
|
||||
<div className="h-full rounded-full bg-accent" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The flex column is what puts the actions on the card's bottom edge: the grid already stretches
|
||||
// every card in a row to the tallest one (align-items: stretch), so the column just needs to push
|
||||
// its last child down. Otherwise the action row floats under a 1-line title on one card and a
|
||||
// line lower on its 2-line neighbour — the same control landing somewhere different on each.
|
||||
// NOTE: do NOT add h-full here. A percentage height against the grid's content-derived height is
|
||||
// circular, and VirtualFeed measures each row with a ResizeObserver — the two together oscillate
|
||||
// by a pixel or two forever, which shows up as the whole page juddering.
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"cv-card group glass-card glass-hover rounded-2xl transition-all duration-150 hover:-translate-y-1",
|
||||
"flex flex-col",
|
||||
spec.dense ? "p-2" : "p-2.5",
|
||||
watched && "opacity-55"
|
||||
)}
|
||||
>
|
||||
<Thumb video={video} className="aspect-video" onOpen={onOpen} />
|
||||
<div className={clsx("flex gap-3 px-0.5 pb-0.5", spec.dense ? "mt-2" : "mt-2.5")}>
|
||||
<div className={clsx("flex gap-3 px-0.5 pb-0.5 flex-1", spec.dense ? "mt-2" : "mt-2.5")}>
|
||||
{/* The small card drops the channel avatar: at its ~180px column the 36px avatar plus the
|
||||
gap would leave the title barely half the width. The channel name is still below it. */}
|
||||
{!spec.dense && (
|
||||
@@ -412,9 +460,9 @@ function VideoCard({
|
||||
className="w-9 h-9 rounded-full shrink-0 mt-0.5"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="min-w-0 flex-1 flex flex-col">
|
||||
{textBlock}
|
||||
{actions}
|
||||
<div className="mt-auto pt-1">{actions}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -149,12 +149,12 @@ export default function VirtualFeed({
|
||||
}}
|
||||
>
|
||||
{/* One branch for both shapes: the single-column views just chunk to colCount 1 and
|
||||
get the reading-width cap instead of grid columns. */}
|
||||
get their reading-width cap instead of grid columns. */}
|
||||
<div
|
||||
className={singleCol ? "max-w-4xl mx-auto pb-1" : "grid gap-4"}
|
||||
className={singleCol ? "mx-auto pb-1" : "grid gap-4"}
|
||||
style={
|
||||
singleCol
|
||||
? undefined
|
||||
? { maxWidth: FEED_VIEW_SPEC[view].maxCol ?? undefined }
|
||||
: { gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }
|
||||
}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user