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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user