feat(feed): pick the feed's view from its own toolbar

The view mode moves out of Settings into a switcher next to sort, on the
feed and channel pages alike. It leaves the Settings prefs draft for its
own eager save: a toolbar click must not mark that page dirty and trip
its leave-page guard.

lib/feedView.ts owns the vocabulary and maps the old grid/list prefs
forward on read, so nobody's saved choice is lost.
This commit is contained in:
2026-07-16 02:29:32 +02:00
parent 303c896d87
commit eae65456ac
13 changed files with 185 additions and 38 deletions
+9 -8
View File
@@ -1,6 +1,7 @@
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 VideoCard from "./VideoCard";
// Grid column sizing mirrors the CSS the feed used before virtualization
@@ -37,7 +38,7 @@ function getScrollParent(node: HTMLElement | null): HTMLElement {
export interface VirtualFeedProps {
items: Video[];
view: "grid" | "list";
view: FeedView;
onState: (id: string, status: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
onOpenChannel: (channelId: string, channelName: string) => void;
@@ -62,7 +63,7 @@ export default function VirtualFeed({
}: VirtualFeedProps) {
const listRef = useRef<HTMLDivElement>(null);
const [scrollEl, setScrollEl] = useState<HTMLElement | null>(null);
const [colCount, setColCount] = useState(view === "list" ? 1 : 4);
const [colCount, setColCount] = useState(view === "rows" ? 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,7 +85,7 @@ export default function VirtualFeed({
setScrollMargin(m);
}
setColCount(
view === "list"
view === "rows"
? 1
: Math.max(1, Math.floor((node.clientWidth + GRID_GAP) / (GRID_MIN_COL + GRID_GAP)))
);
@@ -101,9 +102,9 @@ export default function VirtualFeed({
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollEl,
estimateSize: () => (view === "grid" ? GRID_ROW_EST : LIST_ROW_EST),
estimateSize: () => (view === "cards" ? GRID_ROW_EST : LIST_ROW_EST),
overscan: 4,
gap: view === "grid" ? GRID_GAP : LIST_GAP,
gap: view === "cards" ? GRID_GAP : LIST_GAP,
scrollMargin,
});
@@ -137,7 +138,7 @@ export default function VirtualFeed({
transform: `translateY(${vr.start - virtualizer.options.scrollMargin}px)`,
}}
>
{view === "grid" ? (
{view === "cards" ? (
<div
className="grid gap-4"
style={{ gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }}
@@ -146,7 +147,7 @@ export default function VirtualFeed({
<VideoCard
key={v.id}
video={v}
view="grid"
view={view}
onState={onState}
onToggleSave={onToggleSave}
onOpenChannel={onOpenChannel}
@@ -159,7 +160,7 @@ export default function VirtualFeed({
<div className="max-w-4xl mx-auto pb-1">
<VideoCard
video={row[0]}
view="list"
view={view}
onState={onState}
onToggleSave={onToggleSave}
onOpenChannel={onOpenChannel}