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
+19 -11
View File
@@ -30,6 +30,7 @@ import {
} from "./lib/panelLayout";
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
import { parseFeedView, type FeedView } from "./lib/feedView";
import {
accountKey,
getAccountRaw,
@@ -108,7 +109,6 @@ const PERF_KEY = LS.perfMode;
// individual states below) and the last-saved "baseline" to compute dirty / revert on discard.
type EditablePrefs = {
theme: ThemePrefs;
view: "grid" | "list";
performanceMode: boolean;
hints: boolean;
notifications: NotifSettings;
@@ -170,14 +170,16 @@ export default function App() {
const [initialHadUrlFilters] = useState(() =>
hasFilterParams(new URLSearchParams(window.location.search))
);
const [view, setView] = useState<"grid" | "list">("grid");
// The feed's view mode is picked from the feed's own toolbar, so — unlike the Settings prefs
// below — it persists the moment you choose it. It is NOT part of the EditablePrefs draft:
// that would mark the Settings page dirty from a toolbar click and trip its leave-page guard.
const [view, setViewState] = useState<FeedView>(() => parseFeedView(readAccount(LS.feedView, null)));
// Settings-page prefs draft (apply live, persist on Save — see EditablePrefs).
const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1");
const [hints, setHints] = useState(() => hintsEnabled());
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
const [savedPrefs, setSavedPrefs] = useState<EditablePrefs>(() => ({
theme: loadLocalTheme(),
view: "grid",
performanceMode: getAccountRaw(PERF_KEY) === "1",
hints: hintsEnabled(),
notifications: getNotifSettings(),
@@ -387,6 +389,11 @@ export default function App() {
writeAccount(LS.navCollapsed, next);
api.savePrefs({ navCollapsed: next }).catch(() => {});
}
function setView(next: FeedView) {
setViewState(next);
writeAccount(LS.feedView, next);
api.savePrefs({ view: next }).catch(() => {});
}
function setFilterCollapsed(next: boolean) {
setFilterCollapsedState(next);
writeAccount(LS.filterCollapsed, next);
@@ -603,7 +610,6 @@ export default function App() {
// effects above apply the draft (theme/perf/hints/notifications) for display.
const adopted: EditablePrefs = {
theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
view: prefs.view === "grid" || prefs.view === "list" ? prefs.view : "grid",
performanceMode:
typeof prefs.performanceMode === "boolean"
? prefs.performanceMode
@@ -615,13 +621,16 @@ export default function App() {
};
setThemeState(adopted.theme);
saveLocalTheme(adopted.theme);
setView(adopted.view);
setPerf(adopted.performanceMode);
setHints(adopted.hints);
setNotif(adopted.notifications);
setSavedPrefs(adopted);
// Out-of-scope prefs stay instant (edited outside the Settings page).
const prefsRec = prefs as Record<string, unknown>;
// parseFeedView also maps the pre-0.45 "grid" / "list" values forward.
const adoptedView = parseFeedView(prefs.view);
setViewState(adoptedView);
writeAccount(LS.feedView, adoptedView);
(["feed", "plex", "playlists"] as PanelId[]).forEach((p) => {
const raw = prefsRec[PREF_KEY[p]];
if (raw) {
@@ -689,11 +698,11 @@ export default function App() {
}
const prefsDirty =
JSON.stringify({ theme, view, performanceMode: perf, hints, notifications: notif }) !==
JSON.stringify({ theme, performanceMode: perf, hints, notifications: notif }) !==
JSON.stringify(savedPrefs);
const savePrefs = useCallback(() => {
const payload: EditablePrefs = { theme, view, performanceMode: perf, hints, notifications: notif };
const payload: EditablePrefs = { theme, performanceMode: perf, hints, notifications: notif };
window.clearTimeout(saveMsgTimer.current);
setPrefsSaveState("saving");
api
@@ -709,12 +718,11 @@ export default function App() {
setPrefsSaveState("error");
saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 4000);
});
}, [theme, view, perf, hints, notif]);
}, [theme, perf, hints, notif]);
const discardPrefs = useCallback(() => {
setThemeState(savedPrefs.theme);
saveLocalTheme(savedPrefs.theme);
setView(savedPrefs.view);
setPerf(savedPrefs.performanceMode);
setHints(savedPrefs.hints);
setNotif(savedPrefs.notifications);
@@ -725,8 +733,6 @@ export default function App() {
const prefsCtl: PrefsController = {
theme,
setTheme,
view,
setView,
perf,
setPerf,
hints,
@@ -842,6 +848,7 @@ export default function App() {
initialName={channelView.name}
me={meQuery.data!}
view={view}
setView={setView}
onBack={closeChannel}
onOpenChannel={openChannel}
onShowInFeed={() => {
@@ -962,6 +969,7 @@ export default function App() {
filters={filters}
setFilters={setFilters}
view={view}
setView={setView}
canRead={meQuery.data!.can_read}
isDemo={meQuery.data!.is_demo}
onOpenWizard={() => setWizardOpen(true)}
+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}
+5
View File
@@ -19,6 +19,11 @@
"videoCount_one": "{{formattedCount}} video",
"videoCount_other": "{{formattedCount}} videos",
"sortLabel": "Sort",
"viewLabel": "View",
"view": {
"cards": "Large cards",
"rows": "Rows"
},
"dirAsc": "Ascending",
"dirDesc": "Descending",
"sortKey": {
@@ -36,8 +36,6 @@
"backgroundImage": "Background image",
"backgroundImageHint": "Show a subtle per-scheme backdrop image behind the app so the glass surfaces have something to refract. Off = flat colour.",
"backgroundFade": "Image fade",
"listView": "List view",
"listViewHint": "Show the feed as a compact list instead of a grid of cards.",
"performanceMode": "Performance mode",
"performanceModeHint": "Turns off the translucent glass blur and soft shadows for snappier rendering on slower machines.",
"showHints": "Show hints",
+5
View File
@@ -19,6 +19,11 @@
"videoCount_one": "{{formattedCount}} videó",
"videoCount_other": "{{formattedCount}} videó",
"sortLabel": "Rendezés",
"viewLabel": "Nézet",
"view": {
"cards": "Nagy kártya",
"rows": "Sor"
},
"dirAsc": "Növekvő",
"dirDesc": "Csökkenő",
"sortKey": {
@@ -36,8 +36,6 @@
"backgroundImage": "Háttérkép",
"backgroundImageHint": "Halvány, sémánkénti háttérkép az app mögött, hogy az üveges felületeknek legyen mit megtörniük. Kikapcsolva sima szín.",
"backgroundFade": "Kép elhalványítása",
"listView": "Listanézet",
"listViewHint": "A hírfolyam kompakt listaként jelenik meg a kártyarács helyett.",
"performanceMode": "Teljesítmény mód",
"performanceModeHint": "Kikapcsolja az áttetsző üvegelmosást és a lágy árnyékokat a gyorsabb megjelenítésért lassabb gépeken.",
"showHints": "Tippek megjelenítése",
+21
View File
@@ -0,0 +1,21 @@
// 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 const FEED_VIEWS: readonly FeedView[] = ["cards", "rows"] as const;
export const FEED_VIEW_DEFAULT: FeedView = "cards";
export function isFeedView(v: unknown): v is FeedView {
return typeof v === "string" && (FEED_VIEWS as readonly string[]).includes(v);
}
/** Coerce a stored pref to a view. Up to 0.44 it was "grid" | "list"; those map forward here so
* nobody's saved choice is lost. Anything else (a corrupt value, a future view rolled back) falls
* back to the default rather than rendering nothing. */
export function parseFeedView(raw: unknown): FeedView {
if (isFeedView(raw)) return raw;
if (raw === "grid") return "cards";
if (raw === "list") return "rows";
return FEED_VIEW_DEFAULT;
}
+3
View File
@@ -17,6 +17,9 @@ export const LS = {
defaultViewFilters: "siftlode.defaultViewFilters",
page: "siftlode.page",
perfMode: "siftlode.perfMode",
// The feed's view mode (see lib/feedView.ts). A local cache of the server pref, so the feed
// renders in your chosen view on first paint instead of flashing the default.
feedView: "siftlode.feedView",
channelFilter: "siftlode.channelFilter",
channelsView: "siftlode.channelsView",
channelsTable: "siftlode.channelsTable",