feat(feed): add the small-card, compact-row and tile views

Tiles answer the row view's wasted space: the actions move under the meta
into vertical slack the thumbnail already reserves, which frees ~168px of
width, so the block survives in a narrow column and the list can go
multi-column. Rows stay single-column on purpose — reading down one edge
is why that view exists.

The column count now comes from a per-view minimum width, so every
multi-column view reflows on the mechanism the card grid already used.
This commit is contained in:
2026-07-16 03:17:30 +02:00
parent 1a94411a03
commit 78f21eb46b
6 changed files with 127 additions and 54 deletions
+6
View File
@@ -5,8 +5,11 @@ import {
ArrowDown,
ArrowUp,
ArrowLeft,
Columns2,
Grid3x3,
Info,
LayoutGrid,
List,
RefreshCw,
Rows3,
Trash2,
@@ -48,7 +51,10 @@ const rollSeed = () => Math.floor(Math.random() * 1_000_000_000);
// Icon per view mode; the labels are i18n'd at render (see feed.view.*).
const VIEW_ICON: Record<FeedView, LucideIcon> = {
cards: LayoutGrid,
cardsSmall: Grid3x3,
rows: Rows3,
rowsCompact: List,
tiles: Columns2,
};
// Ordering = a key + direction (like the Playlists page), mapped to the backend sort strings
+36 -13
View File
@@ -14,7 +14,7 @@ import AddToPlaylist from "./AddToPlaylist";
import DownloadButton from "./DownloadButton";
import clsx from "clsx";
import type { Video } from "../lib/api";
import type { FeedView } from "../lib/feedView";
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
function Actions({
@@ -304,7 +304,12 @@ function VideoCard({
</>
);
if (view === "rows") {
const spec = FEED_VIEW_SPEC[view];
const actions = (
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
);
if (spec.family === "row") {
return (
<div
className={clsx(
@@ -312,9 +317,22 @@ function VideoCard({
watched && "opacity-55"
)}
>
<Thumb video={video} className="w-44 aspect-video shrink-0" onOpen={onOpen} />
<div className="min-w-0 flex-1">{textBlock}</div>
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
{/* `compact` = the thumbnail-less row: everything else (meta, actions) stays put. */}
{!spec.compact && (
<Thumb
video={video}
className={clsx("aspect-video shrink-0", spec.actionsBelow ? "w-40" : "w-44")}
onOpen={onOpen}
/>
)}
<div className="min-w-0 flex-1">
{textBlock}
{/* Tiles put the actions under the meta, in vertical space the thumbnail already
reserves — which frees the ~168px the docked-right block used, so the block still
works in a narrow column. */}
{spec.actionsBelow && <div className="mt-1">{actions}</div>}
</div>
{!spec.actionsBelow && actions}
</div>
);
}
@@ -322,20 +340,25 @@ function VideoCard({
return (
<div
className={clsx(
"cv-card group glass-card glass-hover rounded-2xl p-2.5 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",
spec.compact ? "p-2" : "p-2.5",
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={clsx("flex gap-3 px-0.5 pb-0.5", spec.compact ? "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.compact && (
<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">
{textBlock}
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
{actions}
</div>
</div>
</div>
+38 -37
View File
@@ -1,18 +1,30 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { Video } from "../lib/api";
import type { FeedView } from "../lib/feedView";
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
import VideoCard from "./VideoCard";
// Grid column sizing mirrors the CSS the feed used before virtualization
// (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter).
const GRID_MIN_COL = 260;
// Column sizing mirrors the CSS the feed used before virtualization
// (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter). The per-view
// minimum column width lives with the rest of the view matrix in lib/feedView.ts.
const GRID_GAP = 16;
const LIST_GAP = 4;
// Estimated row heights before measurement; real heights are measured per-row so
// these only affect the very first paint and the scrollbar's initial guess.
const GRID_ROW_EST = 340;
const LIST_ROW_EST = 96;
const ROW_EST: Record<FeedView, number> = {
cards: 340,
cardsSmall: 240,
rows: 115,
rowsCompact: 64,
tiles: 124,
};
/** Columns that fit `width`, or 1 for the single-column views. */
function columnsFor(view: FeedView, width: number): number {
const { minCol } = FEED_VIEW_SPEC[view];
if (minCol == null) return 1;
return Math.max(1, Math.floor((width + GRID_GAP) / (minCol + GRID_GAP)));
}
// Start fetching the next page this many rows before the end scrolls into view
// (keeps the old IntersectionObserver's generous prefetch feel).
const PREFETCH_ROWS = 4;
@@ -63,7 +75,8 @@ export default function VirtualFeed({
}: VirtualFeedProps) {
const listRef = useRef<HTMLDivElement>(null);
const [scrollEl, setScrollEl] = useState<HTMLElement | null>(null);
const [colCount, setColCount] = useState(view === "rows" ? 1 : 4);
const singleCol = FEED_VIEW_SPEC[view].minCol == null;
const [colCount, setColCount] = useState(singleCol ? 1 : 4);
// Distance from the scroll element's content top to where this list starts (the
// feed toolbar sits above it inside the same scroll area).
const [scrollMargin, setScrollMargin] = useState(0);
@@ -84,11 +97,7 @@ export default function VirtualFeed({
scrollEl.scrollTop;
setScrollMargin(m);
}
setColCount(
view === "rows"
? 1
: Math.max(1, Math.floor((node.clientWidth + GRID_GAP) / (GRID_MIN_COL + GRID_GAP)))
);
setColCount(columnsFor(view, node.clientWidth));
};
measure();
const ro = new ResizeObserver(measure);
@@ -102,9 +111,9 @@ export default function VirtualFeed({
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollEl,
estimateSize: () => (view === "cards" ? GRID_ROW_EST : LIST_ROW_EST),
estimateSize: () => ROW_EST[view],
overscan: 4,
gap: view === "cards" ? GRID_GAP : LIST_GAP,
gap: singleCol ? LIST_GAP : GRID_GAP,
scrollMargin,
});
@@ -138,28 +147,20 @@ export default function VirtualFeed({
transform: `translateY(${vr.start - virtualizer.options.scrollMargin}px)`,
}}
>
{view === "cards" ? (
<div
className="grid gap-4"
style={{ gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }}
>
{row.map((v) => (
<VideoCard
key={v.id}
video={v}
view={view}
onState={onState}
onToggleSave={onToggleSave}
onOpenChannel={onOpenChannel}
onResetState={onResetState}
onOpen={onOpen}
/>
))}
</div>
) : (
<div className="max-w-4xl mx-auto pb-1">
{/* One branch for both shapes: the single-column views just chunk to colCount 1 and
get the reading-width cap instead of grid columns. */}
<div
className={singleCol ? "max-w-4xl mx-auto pb-1" : "grid gap-4"}
style={
singleCol
? undefined
: { gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }
}
>
{row.map((v) => (
<VideoCard
video={row[0]}
key={v.id}
video={v}
view={view}
onState={onState}
onToggleSave={onToggleSave}
@@ -167,8 +168,8 @@ export default function VirtualFeed({
onResetState={onResetState}
onOpen={onOpen}
/>
</div>
)}
))}
</div>
</div>
);
})}
+4 -1
View File
@@ -22,7 +22,10 @@
"viewLabel": "View",
"view": {
"cards": "Large cards",
"rows": "Rows"
"cardsSmall": "Small cards",
"rows": "Rows",
"rowsCompact": "Compact rows",
"tiles": "Tiles"
},
"dirAsc": "Ascending",
"dirDesc": "Descending",
+4 -1
View File
@@ -22,7 +22,10 @@
"viewLabel": "Nézet",
"view": {
"cards": "Nagy kártya",
"rows": "Sor"
"cardsSmall": "Kis kártya",
"rows": "Sor",
"rowsCompact": "Tömör sor",
"tiles": "Csempe"
},
"dirAsc": "Növekvő",
"dirDesc": "Csökkenő",
+39 -2
View File
@@ -1,12 +1,49 @@
// The feed's view vocabulary. Lifted out of Feed (like feedSort.ts) because it has three
// consumers that can't share module-private state: App owns the pref, the toolbar's
// ViewSwitcher offers it, and VirtualFeed/VideoCard render it.
export type FeedView = "cards" | "rows";
export type FeedView = "cards" | "cardsSmall" | "rows" | "rowsCompact" | "tiles";
export const FEED_VIEWS: readonly FeedView[] = ["cards", "rows"] as const;
export const FEED_VIEWS: readonly FeedView[] = [
"cards",
"cardsSmall",
"rows",
"rowsCompact",
"tiles",
] as const;
const FEED_VIEW_DEFAULT: FeedView = "cards";
export interface FeedViewSpec {
/** Which shape VideoCard renders: a portrait card (thumbnail on top) or a horizontal block. */
family: "card" | "row";
/** 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;
/** card family: a tighter card. row family: no thumbnail at all. */
compact: boolean;
/** row family: actions sit under the meta rather than docked at the right edge. Frees ~168px of
* width (measured), which is what lets a row block survive in a narrow tile column. */
actionsBelow: boolean;
}
// 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, compact: false, actionsBelow: false },
cardsSmall: { family: "card", minCol: 180, compact: 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, compact: false, actionsBelow: false },
rowsCompact: { family: "row", minCol: null, compact: true, 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
// channel page (1208px) and the unpinned feed into 3 columns while the filter-pinned feed
// (~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, compact: false, actionsBelow: true },
};
function isFeedView(v: unknown): v is FeedView {
return typeof v === "string" && (FEED_VIEWS as readonly string[]).includes(v);
}