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))` }
|
||||
}
|
||||
>
|
||||
|
||||
@@ -19,6 +19,9 @@ export interface FeedViewSpec {
|
||||
/** Narrowest grid column in px, or null for ONE full-width column. VirtualFeed derives the
|
||||
* column count from this and the container width, so every multi-column view reflows for free. */
|
||||
minCol: number | null;
|
||||
/** Single-column views only: the reading-width cap in px (null = fill). A long line is hard to
|
||||
* scan, so the stacked row keeps a cap; the one-line row doesn't need as tight a one. */
|
||||
maxCol: number | null;
|
||||
/** Show the video thumbnail. False only for the bare row — whose badges (duration, live state,
|
||||
* saved, resume bar) then move into the meta line, since Thumb is what draws them. */
|
||||
thumb: boolean;
|
||||
@@ -31,12 +34,16 @@ export interface FeedViewSpec {
|
||||
|
||||
// The whole matrix in one place. Adding a view here makes TS point at every switch that needs it.
|
||||
export const FEED_VIEW_SPEC: Record<FeedView, FeedViewSpec> = {
|
||||
cards: { family: "card", minCol: 260, thumb: true, dense: false, actionsBelow: false },
|
||||
cardsSmall: { family: "card", minCol: 180, thumb: true, dense: true, actionsBelow: false },
|
||||
cards: { family: "card", minCol: 260, maxCol: null, thumb: true, dense: false, actionsBelow: false },
|
||||
cardsSmall: { family: "card", minCol: 180, maxCol: null, thumb: true, dense: true, actionsBelow: false },
|
||||
// Single column on purpose: the point of the list is that your eye runs down one edge with the
|
||||
// titles stacked. Density must not cost that — `tiles` is the multi-column option instead.
|
||||
rows: { family: "row", minCol: null, thumb: true, dense: false, actionsBelow: false },
|
||||
rowsCompact: { family: "row", minCol: null, thumb: false, dense: false, actionsBelow: false },
|
||||
// Keeps the 896px cap: its actions dock at the right edge, so a wider row would only stretch the
|
||||
// dead space between them and the meta.
|
||||
rows: { family: "row", minCol: null, maxCol: 896, thumb: true, dense: false, actionsBelow: false },
|
||||
// Wider (1280): everything sits on one line with the actions right-aligned, so there's no gap to
|
||||
// grow — the extra width just buys more title before it truncates.
|
||||
rowsCompact: { family: "row", minCol: null, maxCol: 1280, thumb: false, dense: false, actionsBelow: false },
|
||||
// 380 = thumbnail (160) + gap (12) + ~192 for the text + the block's own padding (16). The text
|
||||
// floor is the action row's measured 156px plus enough for a title to be worth reading.
|
||||
// Tuned by measuring the real layouts, not guessed: 3 columns need 3n+32 px, so 380 turns the
|
||||
@@ -44,7 +51,7 @@ export const FEED_VIEW_SPEC: Record<FeedView, FeedViewSpec> = {
|
||||
// (~955px) still gets 2. 470 was tried first and needed 956px for two — the common layout fell
|
||||
// back to ONE column and the mode looked broken; 400 then left 596px tiles whose titles ended
|
||||
// halfway, which is the very waste this mode exists to kill.
|
||||
tiles: { family: "row", minCol: 380, thumb: true, dense: false, actionsBelow: true },
|
||||
tiles: { family: "row", minCol: 380, maxCol: null, thumb: true, dense: false, actionsBelow: true },
|
||||
};
|
||||
|
||||
function isFeedView(v: unknown): v is FeedView {
|
||||
|
||||
Reference in New Issue
Block a user