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
+7 -1
View File
@@ -8,6 +8,7 @@ import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { api, type FeedFilters, type Me } from "../lib/api";
import type { FeedView } from "../lib/feedView";
import { channelYouTubeUrl, formatViews } from "../lib/format";
// --- About-tab metadata helpers ---
@@ -54,6 +55,7 @@ export default function ChannelPage({
initialName,
me,
view,
setView,
onBack,
onOpenChannel,
onShowInFeed,
@@ -61,7 +63,10 @@ export default function ChannelPage({
channelId: string;
initialName?: string;
me: Me;
view: "grid" | "list";
// The feed's view pref, passed straight through to the Feed this page renders — so its
// toolbar carries the same switcher and a change here is the same account-wide choice.
view: FeedView;
setView: (v: FeedView) => void;
onBack: () => void;
// Narrow the real feed to this channel and go there. Offered only for a channel you're
// subscribed to: the feed defaults to scope "my", so filtering it to a channel you don't
@@ -371,6 +376,7 @@ export default function ChannelPage({
filters={filters}
setFilters={setFilters}
view={view}
setView={setView}
canRead={me.can_read}
isDemo={me.is_demo}
onOpenWizard={() => {}}
+32 -2
View File
@@ -1,7 +1,18 @@
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowDown, ArrowUp, ArrowLeft, Info, RefreshCw, Trash2, Youtube } from "lucide-react";
import {
ArrowDown,
ArrowUp,
ArrowLeft,
Info,
LayoutGrid,
RefreshCw,
Rows3,
Trash2,
Youtube,
type LucideIcon,
} from "lucide-react";
import { api, HttpError, type FeedFilters, type Video } from "../lib/api";
import i18n from "../i18n";
import { notify, resolveVideo } from "../lib/notifications";
@@ -14,8 +25,10 @@ import {
SORT_KEYS,
type SortKey,
} from "../lib/feedSort";
import { FEED_VIEWS, type FeedView } from "../lib/feedView";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
import ActiveFilterChips from "./ActiveFilterChips";
import ViewSwitcher from "./ViewSwitcher";
import VirtualFeed from "./VirtualFeed";
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
const PlayerModal = lazy(() => import("./PlayerModal"));
@@ -32,6 +45,12 @@ const CONTENT = [
] as const;
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,
rows: Rows3,
};
// Ordering = a key + direction (like the Playlists page), mapped to the backend sort strings
// (which encode both). One entry per concept; a single arrow flips the direction.
// "relevance" (full-text search ranking) and "shuffle" are directionless single-string sorts.
@@ -56,6 +75,7 @@ export default function Feed({
filters,
setFilters,
view,
setView,
canRead,
isDemo = false,
onOpenWizard,
@@ -67,7 +87,10 @@ export default function Feed({
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
view: "grid" | "list";
// The view mode is an account pref, not a filter: App owns it and it persists on pick, so it
// isn't part of FeedFilters (no share-link param, no saved view, no active-filter chip).
view: FeedView;
setView: (v: FeedView) => void;
canRead: boolean;
isDemo?: boolean;
onOpenWizard: () => void;
@@ -621,6 +644,13 @@ export default function Feed({
<RefreshCw className="w-4 h-4" />
</button>
)}
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
<ViewSwitcher
value={view}
options={FEED_VIEWS.map((id) => ({ id, label: t("feed.view." + id), icon: VIEW_ICON[id] }))}
onChange={setView}
label={t("feed.viewLabel")}
/>
</div>
</div>
</div>
+1 -10
View File
@@ -20,8 +20,6 @@ export type PrefsSaveState = SaveState;
export interface PrefsController {
theme: ThemePrefs;
setTheme: (t: ThemePrefs) => void;
view: "grid" | "list";
setView: (v: "grid" | "list") => void;
perf: boolean;
setPerf: (v: boolean) => void;
hints: boolean;
@@ -128,7 +126,7 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
const { t } = useTranslation();
// Controlled by App's prefs draft: each change applies locally for preview but is only
// persisted on an explicit Save (handled by the panel's Save/Discard bar).
const { theme, setTheme, view, setView, perf, setPerf, hints, setHints } = prefs;
const { theme, setTheme, perf, setPerf, hints, setHints } = prefs;
return (
<>
@@ -186,13 +184,6 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
/>
</div>
)}
<SettingRow label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}>
<Switch
label={t("settings.appearance.listView")}
checked={view === "list"}
onChange={(v) => setView(v ? "list" : "grid")}
/>
</SettingRow>
<SettingRow
label={t("settings.appearance.performanceMode")}
hint={t("settings.appearance.performanceModeHint")}
+3 -2
View File
@@ -14,6 +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 { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
function Actions({
@@ -237,7 +238,7 @@ function VideoCard({
onOpen,
}: {
video: Video;
view: "grid" | "list";
view: FeedView;
onState: (id: string, status: string) => void;
onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
@@ -303,7 +304,7 @@ function VideoCard({
</>
);
if (view === "list") {
if (view === "rows") {
return (
<div
className={clsx(
+80
View File
@@ -0,0 +1,80 @@
import { useRef, useState } from "react";
import { Check, ChevronDown, type LucideIcon } from "lucide-react";
import { useDismiss } from "../lib/useDismiss";
export interface ViewOption<T extends string> {
id: T;
label: string;
icon: LucideIcon;
}
// "How should this list look" — the current mode's icon in the toolbar, the named modes in a
// menu behind it. Generic over the mode id so any module's list can adopt it (the feed's
// density modes; the managers' layouts) without re-deriving the menu behaviour.
export default function ViewSwitcher<T extends string>({
value,
options,
onChange,
label,
}: {
value: T;
options: readonly ViewOption<T>[];
onChange: (v: T) => void;
label: string;
}) {
const [open, setOpen] = useState(false);
const btnRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
useDismiss(open, () => setOpen(false), [btnRef, menuRef]);
const current = options.find((o) => o.id === value) ?? options[0];
if (!current) return null;
const CurrentIcon = current.icon;
return (
<div className="relative">
<button
ref={btnRef}
onClick={() => setOpen((o) => !o)}
aria-haspopup="menu"
aria-expanded={open}
aria-label={`${label}: ${current.label}`}
title={`${label}: ${current.label}`}
className="shrink-0 inline-flex items-center gap-1 pl-1.5 pr-1 py-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
>
<CurrentIcon className="w-4 h-4" />
<ChevronDown className={`w-3 h-3 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div
ref={menuRef}
role="menu"
className="glass-menu absolute right-0 top-full mt-1 z-30 min-w-44 p-1.5 rounded-xl animate-[popIn_0.16s_ease]"
>
{options.map((o) => {
const Icon = o.icon;
const on = o.id === value;
return (
<button
key={o.id}
role="menuitemradio"
aria-checked={on}
onClick={() => {
onChange(o.id);
setOpen(false);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-left transition ${
on ? "text-accent" : "text-fg hover:bg-card"
}`}
>
<Icon className="w-4 h-4 shrink-0" />
<span className="flex-1 truncate">{o.label}</span>
{on && <Check className="w-3.5 h-3.5 shrink-0" />}
</button>
);
})}
</div>
)}
</div>
);
}
+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}