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:
+19
-11
@@ -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)}
|
||||
|
||||
@@ -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={() => {}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user