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,
|
video,
|
||||||
className,
|
className,
|
||||||
onOpen,
|
onOpen,
|
||||||
|
small = false,
|
||||||
}: {
|
}: {
|
||||||
video: Video;
|
video: Video;
|
||||||
className?: string;
|
className?: string;
|
||||||
onOpen?: (v: Video, startAt?: number | null) => void;
|
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 { t } = useTranslation();
|
||||||
const inProgress = isInProgress(video);
|
const inProgress = isInProgress(video);
|
||||||
@@ -237,27 +241,39 @@ function Thumb({
|
|||||||
<button
|
<button
|
||||||
onClick={open(null)}
|
onClick={open(null)}
|
||||||
title={t("card.continueTitle")}
|
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" />
|
<Play className="w-4 h-4 fill-current" />
|
||||||
{t("card.continue")}
|
{!small && t("card.continue")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={open(0)}
|
onClick={open(0)}
|
||||||
title={t("card.restartTitle")}
|
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" />
|
<RotateCcw className="w-4 h-4" />
|
||||||
{t("card.restart")}
|
{!small && t("card.restart")}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={open(null)}
|
onClick={open(null)}
|
||||||
title={t("card.play")}
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -311,13 +327,15 @@ function VideoCard({
|
|||||||
{video.published_at && <>{" · "}{formatDate(video.published_at, i18n.language)}</>}
|
{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 titleRef = useRef<HTMLAnchorElement>(null);
|
||||||
const [titleClamped, setTitleClamped] = useState(false);
|
const [titleClamped, setTitleClamped] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = titleRef.current;
|
const el = titleRef.current;
|
||||||
if (!el) return;
|
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();
|
check();
|
||||||
const ro = new ResizeObserver(check);
|
const ro = new ResizeObserver(check);
|
||||||
ro.observe(el);
|
ro.observe(el);
|
||||||
@@ -331,25 +349,31 @@ function VideoCard({
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
onClick={(e) => openInApp(e, video, onOpen)}
|
onClick={(e) => openInApp(e, video, onOpen)}
|
||||||
title={titleClamped ? video.title ?? undefined : undefined}
|
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}
|
{video.title}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
// Title + channel button + meta — identical in both layouts (only their wrapper / Actions placement
|
const channelButton = (
|
||||||
// differs). Safe to reuse the same element: exactly one of the two branches below renders.
|
<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 = (
|
const textBlock = (
|
||||||
<>
|
<>
|
||||||
{title}
|
{title}
|
||||||
<button
|
<div className="mt-0.5">{channelButton}</div>
|
||||||
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="text-xs text-muted mt-0.5">{meta}</div>
|
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -357,24 +381,46 @@ function VideoCard({
|
|||||||
const actions = (
|
const actions = (
|
||||||
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
|
<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") {
|
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 (
|
return (
|
||||||
<div
|
<div className={clsx(rowShell, "flex gap-3 p-2", watched && "opacity-55")}>
|
||||||
className={clsx(
|
<Thumb
|
||||||
"cv-row group relative flex gap-3 p-2 rounded-xl hover:bg-card hover:shadow-lg transition",
|
video={video}
|
||||||
watched && "opacity-55"
|
className={clsx("aspect-video shrink-0", spec.actionsBelow ? "w-40" : "w-44")}
|
||||||
)}
|
onOpen={onOpen}
|
||||||
>
|
small
|
||||||
{/* 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="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{textBlock}
|
{textBlock}
|
||||||
{/* Tiles put the actions under the meta, in vertical space the thumbnail already
|
{/* 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>}
|
{spec.actionsBelow && <div className="mt-1">{actions}</div>}
|
||||||
</div>
|
</div>
|
||||||
{!spec.actionsBelow && actions}
|
{!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>
|
</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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"cv-card group glass-card glass-hover rounded-2xl transition-all duration-150 hover:-translate-y-1",
|
"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",
|
spec.dense ? "p-2" : "p-2.5",
|
||||||
watched && "opacity-55"
|
watched && "opacity-55"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Thumb video={video} className="aspect-video" onOpen={onOpen} />
|
<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
|
{/* 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. */}
|
gap would leave the title barely half the width. The channel name is still below it. */}
|
||||||
{!spec.dense && (
|
{!spec.dense && (
|
||||||
@@ -412,9 +460,9 @@ function VideoCard({
|
|||||||
className="w-9 h-9 rounded-full shrink-0 mt-0.5"
|
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}
|
{textBlock}
|
||||||
{actions}
|
<div className="mt-auto pt-1">{actions}</div>
|
||||||
</div>
|
</div>
|
||||||
</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
|
{/* 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
|
<div
|
||||||
className={singleCol ? "max-w-4xl mx-auto pb-1" : "grid gap-4"}
|
className={singleCol ? "mx-auto pb-1" : "grid gap-4"}
|
||||||
style={
|
style={
|
||||||
singleCol
|
singleCol
|
||||||
? undefined
|
? { maxWidth: FEED_VIEW_SPEC[view].maxCol ?? undefined }
|
||||||
: { gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }
|
: { 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
|
/** 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. */
|
* column count from this and the container width, so every multi-column view reflows for free. */
|
||||||
minCol: number | null;
|
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,
|
/** 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. */
|
* saved, resume bar) then move into the meta line, since Thumb is what draws them. */
|
||||||
thumb: boolean;
|
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.
|
// 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> = {
|
export const FEED_VIEW_SPEC: Record<FeedView, FeedViewSpec> = {
|
||||||
cards: { family: "card", minCol: 260, thumb: true, dense: false, actionsBelow: false },
|
cards: { family: "card", minCol: 260, maxCol: null, thumb: true, dense: false, actionsBelow: false },
|
||||||
cardsSmall: { family: "card", minCol: 180, thumb: true, dense: true, 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
|
// 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.
|
// 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 },
|
// Keeps the 896px cap: its actions dock at the right edge, so a wider row would only stretch the
|
||||||
rowsCompact: { family: "row", minCol: null, thumb: false, dense: false, actionsBelow: false },
|
// 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
|
// 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.
|
// 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
|
// 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
|
// (~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
|
// 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.
|
// 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 {
|
function isFeedView(v: unknown): v is FeedView {
|
||||||
|
|||||||
Reference in New Issue
Block a user