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";
|
} from "./lib/panelLayout";
|
||||||
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
|
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
|
||||||
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
|
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
|
||||||
|
import { parseFeedView, type FeedView } from "./lib/feedView";
|
||||||
import {
|
import {
|
||||||
accountKey,
|
accountKey,
|
||||||
getAccountRaw,
|
getAccountRaw,
|
||||||
@@ -108,7 +109,6 @@ const PERF_KEY = LS.perfMode;
|
|||||||
// individual states below) and the last-saved "baseline" to compute dirty / revert on discard.
|
// individual states below) and the last-saved "baseline" to compute dirty / revert on discard.
|
||||||
type EditablePrefs = {
|
type EditablePrefs = {
|
||||||
theme: ThemePrefs;
|
theme: ThemePrefs;
|
||||||
view: "grid" | "list";
|
|
||||||
performanceMode: boolean;
|
performanceMode: boolean;
|
||||||
hints: boolean;
|
hints: boolean;
|
||||||
notifications: NotifSettings;
|
notifications: NotifSettings;
|
||||||
@@ -170,14 +170,16 @@ export default function App() {
|
|||||||
const [initialHadUrlFilters] = useState(() =>
|
const [initialHadUrlFilters] = useState(() =>
|
||||||
hasFilterParams(new URLSearchParams(window.location.search))
|
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).
|
// Settings-page prefs draft (apply live, persist on Save — see EditablePrefs).
|
||||||
const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1");
|
const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1");
|
||||||
const [hints, setHints] = useState(() => hintsEnabled());
|
const [hints, setHints] = useState(() => hintsEnabled());
|
||||||
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
|
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
|
||||||
const [savedPrefs, setSavedPrefs] = useState<EditablePrefs>(() => ({
|
const [savedPrefs, setSavedPrefs] = useState<EditablePrefs>(() => ({
|
||||||
theme: loadLocalTheme(),
|
theme: loadLocalTheme(),
|
||||||
view: "grid",
|
|
||||||
performanceMode: getAccountRaw(PERF_KEY) === "1",
|
performanceMode: getAccountRaw(PERF_KEY) === "1",
|
||||||
hints: hintsEnabled(),
|
hints: hintsEnabled(),
|
||||||
notifications: getNotifSettings(),
|
notifications: getNotifSettings(),
|
||||||
@@ -387,6 +389,11 @@ export default function App() {
|
|||||||
writeAccount(LS.navCollapsed, next);
|
writeAccount(LS.navCollapsed, next);
|
||||||
api.savePrefs({ navCollapsed: next }).catch(() => {});
|
api.savePrefs({ navCollapsed: next }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
function setView(next: FeedView) {
|
||||||
|
setViewState(next);
|
||||||
|
writeAccount(LS.feedView, next);
|
||||||
|
api.savePrefs({ view: next }).catch(() => {});
|
||||||
|
}
|
||||||
function setFilterCollapsed(next: boolean) {
|
function setFilterCollapsed(next: boolean) {
|
||||||
setFilterCollapsedState(next);
|
setFilterCollapsedState(next);
|
||||||
writeAccount(LS.filterCollapsed, next);
|
writeAccount(LS.filterCollapsed, next);
|
||||||
@@ -603,7 +610,6 @@ export default function App() {
|
|||||||
// effects above apply the draft (theme/perf/hints/notifications) for display.
|
// effects above apply the draft (theme/perf/hints/notifications) for display.
|
||||||
const adopted: EditablePrefs = {
|
const adopted: EditablePrefs = {
|
||||||
theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
|
theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
|
||||||
view: prefs.view === "grid" || prefs.view === "list" ? prefs.view : "grid",
|
|
||||||
performanceMode:
|
performanceMode:
|
||||||
typeof prefs.performanceMode === "boolean"
|
typeof prefs.performanceMode === "boolean"
|
||||||
? prefs.performanceMode
|
? prefs.performanceMode
|
||||||
@@ -615,13 +621,16 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
setThemeState(adopted.theme);
|
setThemeState(adopted.theme);
|
||||||
saveLocalTheme(adopted.theme);
|
saveLocalTheme(adopted.theme);
|
||||||
setView(adopted.view);
|
|
||||||
setPerf(adopted.performanceMode);
|
setPerf(adopted.performanceMode);
|
||||||
setHints(adopted.hints);
|
setHints(adopted.hints);
|
||||||
setNotif(adopted.notifications);
|
setNotif(adopted.notifications);
|
||||||
setSavedPrefs(adopted);
|
setSavedPrefs(adopted);
|
||||||
// Out-of-scope prefs stay instant (edited outside the Settings page).
|
// Out-of-scope prefs stay instant (edited outside the Settings page).
|
||||||
const prefsRec = prefs as Record<string, unknown>;
|
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) => {
|
(["feed", "plex", "playlists"] as PanelId[]).forEach((p) => {
|
||||||
const raw = prefsRec[PREF_KEY[p]];
|
const raw = prefsRec[PREF_KEY[p]];
|
||||||
if (raw) {
|
if (raw) {
|
||||||
@@ -689,11 +698,11 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const prefsDirty =
|
const prefsDirty =
|
||||||
JSON.stringify({ theme, view, performanceMode: perf, hints, notifications: notif }) !==
|
JSON.stringify({ theme, performanceMode: perf, hints, notifications: notif }) !==
|
||||||
JSON.stringify(savedPrefs);
|
JSON.stringify(savedPrefs);
|
||||||
|
|
||||||
const savePrefs = useCallback(() => {
|
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);
|
window.clearTimeout(saveMsgTimer.current);
|
||||||
setPrefsSaveState("saving");
|
setPrefsSaveState("saving");
|
||||||
api
|
api
|
||||||
@@ -709,12 +718,11 @@ export default function App() {
|
|||||||
setPrefsSaveState("error");
|
setPrefsSaveState("error");
|
||||||
saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 4000);
|
saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 4000);
|
||||||
});
|
});
|
||||||
}, [theme, view, perf, hints, notif]);
|
}, [theme, perf, hints, notif]);
|
||||||
|
|
||||||
const discardPrefs = useCallback(() => {
|
const discardPrefs = useCallback(() => {
|
||||||
setThemeState(savedPrefs.theme);
|
setThemeState(savedPrefs.theme);
|
||||||
saveLocalTheme(savedPrefs.theme);
|
saveLocalTheme(savedPrefs.theme);
|
||||||
setView(savedPrefs.view);
|
|
||||||
setPerf(savedPrefs.performanceMode);
|
setPerf(savedPrefs.performanceMode);
|
||||||
setHints(savedPrefs.hints);
|
setHints(savedPrefs.hints);
|
||||||
setNotif(savedPrefs.notifications);
|
setNotif(savedPrefs.notifications);
|
||||||
@@ -725,8 +733,6 @@ export default function App() {
|
|||||||
const prefsCtl: PrefsController = {
|
const prefsCtl: PrefsController = {
|
||||||
theme,
|
theme,
|
||||||
setTheme,
|
setTheme,
|
||||||
view,
|
|
||||||
setView,
|
|
||||||
perf,
|
perf,
|
||||||
setPerf,
|
setPerf,
|
||||||
hints,
|
hints,
|
||||||
@@ -842,6 +848,7 @@ export default function App() {
|
|||||||
initialName={channelView.name}
|
initialName={channelView.name}
|
||||||
me={meQuery.data!}
|
me={meQuery.data!}
|
||||||
view={view}
|
view={view}
|
||||||
|
setView={setView}
|
||||||
onBack={closeChannel}
|
onBack={closeChannel}
|
||||||
onOpenChannel={openChannel}
|
onOpenChannel={openChannel}
|
||||||
onShowInFeed={() => {
|
onShowInFeed={() => {
|
||||||
@@ -962,6 +969,7 @@ export default function App() {
|
|||||||
filters={filters}
|
filters={filters}
|
||||||
setFilters={setFilters}
|
setFilters={setFilters}
|
||||||
view={view}
|
view={view}
|
||||||
|
setView={setView}
|
||||||
canRead={meQuery.data!.can_read}
|
canRead={meQuery.data!.can_read}
|
||||||
isDemo={meQuery.data!.is_demo}
|
isDemo={meQuery.data!.is_demo}
|
||||||
onOpenWizard={() => setWizardOpen(true)}
|
onOpenWizard={() => setWizardOpen(true)}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useConfirm } from "./ConfirmProvider";
|
|||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
import { api, type FeedFilters, type Me } from "../lib/api";
|
import { api, type FeedFilters, type Me } from "../lib/api";
|
||||||
|
import type { FeedView } from "../lib/feedView";
|
||||||
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
||||||
|
|
||||||
// --- About-tab metadata helpers ---
|
// --- About-tab metadata helpers ---
|
||||||
@@ -54,6 +55,7 @@ export default function ChannelPage({
|
|||||||
initialName,
|
initialName,
|
||||||
me,
|
me,
|
||||||
view,
|
view,
|
||||||
|
setView,
|
||||||
onBack,
|
onBack,
|
||||||
onOpenChannel,
|
onOpenChannel,
|
||||||
onShowInFeed,
|
onShowInFeed,
|
||||||
@@ -61,7 +63,10 @@ export default function ChannelPage({
|
|||||||
channelId: string;
|
channelId: string;
|
||||||
initialName?: string;
|
initialName?: string;
|
||||||
me: Me;
|
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;
|
onBack: () => void;
|
||||||
// Narrow the real feed to this channel and go there. Offered only for a channel you're
|
// 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
|
// 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}
|
filters={filters}
|
||||||
setFilters={setFilters}
|
setFilters={setFilters}
|
||||||
view={view}
|
view={view}
|
||||||
|
setView={setView}
|
||||||
canRead={me.can_read}
|
canRead={me.can_read}
|
||||||
isDemo={me.is_demo}
|
isDemo={me.is_demo}
|
||||||
onOpenWizard={() => {}}
|
onOpenWizard={() => {}}
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { api, HttpError, type FeedFilters, type Video } from "../lib/api";
|
||||||
import i18n from "../i18n";
|
import i18n from "../i18n";
|
||||||
import { notify, resolveVideo } from "../lib/notifications";
|
import { notify, resolveVideo } from "../lib/notifications";
|
||||||
@@ -14,8 +25,10 @@ import {
|
|||||||
SORT_KEYS,
|
SORT_KEYS,
|
||||||
type SortKey,
|
type SortKey,
|
||||||
} from "../lib/feedSort";
|
} from "../lib/feedSort";
|
||||||
|
import { FEED_VIEWS, type FeedView } from "../lib/feedView";
|
||||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||||
import ActiveFilterChips from "./ActiveFilterChips";
|
import ActiveFilterChips from "./ActiveFilterChips";
|
||||||
|
import ViewSwitcher from "./ViewSwitcher";
|
||||||
import VirtualFeed from "./VirtualFeed";
|
import VirtualFeed from "./VirtualFeed";
|
||||||
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
|
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
|
||||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||||
@@ -32,6 +45,12 @@ const CONTENT = [
|
|||||||
] as const;
|
] as const;
|
||||||
const rollSeed = () => Math.floor(Math.random() * 1_000_000_000);
|
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
|
// 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.
|
// (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.
|
// "relevance" (full-text search ranking) and "shuffle" are directionless single-string sorts.
|
||||||
@@ -56,6 +75,7 @@ export default function Feed({
|
|||||||
filters,
|
filters,
|
||||||
setFilters,
|
setFilters,
|
||||||
view,
|
view,
|
||||||
|
setView,
|
||||||
canRead,
|
canRead,
|
||||||
isDemo = false,
|
isDemo = false,
|
||||||
onOpenWizard,
|
onOpenWizard,
|
||||||
@@ -67,7 +87,10 @@ export default function Feed({
|
|||||||
}: {
|
}: {
|
||||||
filters: FeedFilters;
|
filters: FeedFilters;
|
||||||
setFilters: (f: FeedFilters) => void;
|
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;
|
canRead: boolean;
|
||||||
isDemo?: boolean;
|
isDemo?: boolean;
|
||||||
onOpenWizard: () => void;
|
onOpenWizard: () => void;
|
||||||
@@ -621,6 +644,13 @@ export default function Feed({
|
|||||||
<RefreshCw className="w-4 h-4" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ export type PrefsSaveState = SaveState;
|
|||||||
export interface PrefsController {
|
export interface PrefsController {
|
||||||
theme: ThemePrefs;
|
theme: ThemePrefs;
|
||||||
setTheme: (t: ThemePrefs) => void;
|
setTheme: (t: ThemePrefs) => void;
|
||||||
view: "grid" | "list";
|
|
||||||
setView: (v: "grid" | "list") => void;
|
|
||||||
perf: boolean;
|
perf: boolean;
|
||||||
setPerf: (v: boolean) => void;
|
setPerf: (v: boolean) => void;
|
||||||
hints: boolean;
|
hints: boolean;
|
||||||
@@ -128,7 +126,7 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Controlled by App's prefs draft: each change applies locally for preview but is only
|
// 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).
|
// 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -186,13 +184,6 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<SettingRow
|
||||||
label={t("settings.appearance.performanceMode")}
|
label={t("settings.appearance.performanceMode")}
|
||||||
hint={t("settings.appearance.performanceModeHint")}
|
hint={t("settings.appearance.performanceModeHint")}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import AddToPlaylist from "./AddToPlaylist";
|
|||||||
import DownloadButton from "./DownloadButton";
|
import DownloadButton from "./DownloadButton";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import type { Video } from "../lib/api";
|
import type { Video } from "../lib/api";
|
||||||
|
import type { FeedView } from "../lib/feedView";
|
||||||
import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
|
import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
|
||||||
|
|
||||||
function Actions({
|
function Actions({
|
||||||
@@ -237,7 +238,7 @@ function VideoCard({
|
|||||||
onOpen,
|
onOpen,
|
||||||
}: {
|
}: {
|
||||||
video: Video;
|
video: Video;
|
||||||
view: "grid" | "list";
|
view: FeedView;
|
||||||
onState: (id: string, status: string) => void;
|
onState: (id: string, status: string) => void;
|
||||||
onResetState?: (id: string) => void;
|
onResetState?: (id: string) => void;
|
||||||
onToggleSave: (id: string, saved: boolean) => void;
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
@@ -303,7 +304,7 @@ function VideoCard({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (view === "list") {
|
if (view === "rows") {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
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 { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import type { Video } from "../lib/api";
|
import type { Video } from "../lib/api";
|
||||||
|
import type { FeedView } from "../lib/feedView";
|
||||||
import VideoCard from "./VideoCard";
|
import VideoCard from "./VideoCard";
|
||||||
|
|
||||||
// Grid column sizing mirrors the CSS the feed used before virtualization
|
// Grid column sizing mirrors the CSS the feed used before virtualization
|
||||||
@@ -37,7 +38,7 @@ function getScrollParent(node: HTMLElement | null): HTMLElement {
|
|||||||
|
|
||||||
export interface VirtualFeedProps {
|
export interface VirtualFeedProps {
|
||||||
items: Video[];
|
items: Video[];
|
||||||
view: "grid" | "list";
|
view: FeedView;
|
||||||
onState: (id: string, status: string) => void;
|
onState: (id: string, status: string) => void;
|
||||||
onToggleSave: (id: string, saved: boolean) => void;
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
onOpenChannel: (channelId: string, channelName: string) => void;
|
onOpenChannel: (channelId: string, channelName: string) => void;
|
||||||
@@ -62,7 +63,7 @@ export default function VirtualFeed({
|
|||||||
}: VirtualFeedProps) {
|
}: VirtualFeedProps) {
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const [scrollEl, setScrollEl] = useState<HTMLElement | null>(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
|
// Distance from the scroll element's content top to where this list starts (the
|
||||||
// feed toolbar sits above it inside the same scroll area).
|
// feed toolbar sits above it inside the same scroll area).
|
||||||
const [scrollMargin, setScrollMargin] = useState(0);
|
const [scrollMargin, setScrollMargin] = useState(0);
|
||||||
@@ -84,7 +85,7 @@ export default function VirtualFeed({
|
|||||||
setScrollMargin(m);
|
setScrollMargin(m);
|
||||||
}
|
}
|
||||||
setColCount(
|
setColCount(
|
||||||
view === "list"
|
view === "rows"
|
||||||
? 1
|
? 1
|
||||||
: Math.max(1, Math.floor((node.clientWidth + GRID_GAP) / (GRID_MIN_COL + GRID_GAP)))
|
: 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({
|
const virtualizer = useVirtualizer({
|
||||||
count: rows.length,
|
count: rows.length,
|
||||||
getScrollElement: () => scrollEl,
|
getScrollElement: () => scrollEl,
|
||||||
estimateSize: () => (view === "grid" ? GRID_ROW_EST : LIST_ROW_EST),
|
estimateSize: () => (view === "cards" ? GRID_ROW_EST : LIST_ROW_EST),
|
||||||
overscan: 4,
|
overscan: 4,
|
||||||
gap: view === "grid" ? GRID_GAP : LIST_GAP,
|
gap: view === "cards" ? GRID_GAP : LIST_GAP,
|
||||||
scrollMargin,
|
scrollMargin,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,7 +138,7 @@ export default function VirtualFeed({
|
|||||||
transform: `translateY(${vr.start - virtualizer.options.scrollMargin}px)`,
|
transform: `translateY(${vr.start - virtualizer.options.scrollMargin}px)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{view === "grid" ? (
|
{view === "cards" ? (
|
||||||
<div
|
<div
|
||||||
className="grid gap-4"
|
className="grid gap-4"
|
||||||
style={{ gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }}
|
style={{ gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }}
|
||||||
@@ -146,7 +147,7 @@ export default function VirtualFeed({
|
|||||||
<VideoCard
|
<VideoCard
|
||||||
key={v.id}
|
key={v.id}
|
||||||
video={v}
|
video={v}
|
||||||
view="grid"
|
view={view}
|
||||||
onState={onState}
|
onState={onState}
|
||||||
onToggleSave={onToggleSave}
|
onToggleSave={onToggleSave}
|
||||||
onOpenChannel={onOpenChannel}
|
onOpenChannel={onOpenChannel}
|
||||||
@@ -159,7 +160,7 @@ export default function VirtualFeed({
|
|||||||
<div className="max-w-4xl mx-auto pb-1">
|
<div className="max-w-4xl mx-auto pb-1">
|
||||||
<VideoCard
|
<VideoCard
|
||||||
video={row[0]}
|
video={row[0]}
|
||||||
view="list"
|
view={view}
|
||||||
onState={onState}
|
onState={onState}
|
||||||
onToggleSave={onToggleSave}
|
onToggleSave={onToggleSave}
|
||||||
onOpenChannel={onOpenChannel}
|
onOpenChannel={onOpenChannel}
|
||||||
|
|||||||
@@ -19,6 +19,11 @@
|
|||||||
"videoCount_one": "{{formattedCount}} video",
|
"videoCount_one": "{{formattedCount}} video",
|
||||||
"videoCount_other": "{{formattedCount}} videos",
|
"videoCount_other": "{{formattedCount}} videos",
|
||||||
"sortLabel": "Sort",
|
"sortLabel": "Sort",
|
||||||
|
"viewLabel": "View",
|
||||||
|
"view": {
|
||||||
|
"cards": "Large cards",
|
||||||
|
"rows": "Rows"
|
||||||
|
},
|
||||||
"dirAsc": "Ascending",
|
"dirAsc": "Ascending",
|
||||||
"dirDesc": "Descending",
|
"dirDesc": "Descending",
|
||||||
"sortKey": {
|
"sortKey": {
|
||||||
|
|||||||
@@ -36,8 +36,6 @@
|
|||||||
"backgroundImage": "Background image",
|
"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.",
|
"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",
|
"backgroundFade": "Image fade",
|
||||||
"listView": "List view",
|
|
||||||
"listViewHint": "Show the feed as a compact list instead of a grid of cards.",
|
|
||||||
"performanceMode": "Performance mode",
|
"performanceMode": "Performance mode",
|
||||||
"performanceModeHint": "Turns off the translucent glass blur and soft shadows for snappier rendering on slower machines.",
|
"performanceModeHint": "Turns off the translucent glass blur and soft shadows for snappier rendering on slower machines.",
|
||||||
"showHints": "Show hints",
|
"showHints": "Show hints",
|
||||||
|
|||||||
@@ -19,6 +19,11 @@
|
|||||||
"videoCount_one": "{{formattedCount}} videó",
|
"videoCount_one": "{{formattedCount}} videó",
|
||||||
"videoCount_other": "{{formattedCount}} videó",
|
"videoCount_other": "{{formattedCount}} videó",
|
||||||
"sortLabel": "Rendezés",
|
"sortLabel": "Rendezés",
|
||||||
|
"viewLabel": "Nézet",
|
||||||
|
"view": {
|
||||||
|
"cards": "Nagy kártya",
|
||||||
|
"rows": "Sor"
|
||||||
|
},
|
||||||
"dirAsc": "Növekvő",
|
"dirAsc": "Növekvő",
|
||||||
"dirDesc": "Csökkenő",
|
"dirDesc": "Csökkenő",
|
||||||
"sortKey": {
|
"sortKey": {
|
||||||
|
|||||||
@@ -36,8 +36,6 @@
|
|||||||
"backgroundImage": "Háttérkép",
|
"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.",
|
"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",
|
"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",
|
"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.",
|
"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",
|
"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",
|
defaultViewFilters: "siftlode.defaultViewFilters",
|
||||||
page: "siftlode.page",
|
page: "siftlode.page",
|
||||||
perfMode: "siftlode.perfMode",
|
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",
|
channelFilter: "siftlode.channelFilter",
|
||||||
channelsView: "siftlode.channelsView",
|
channelsView: "siftlode.channelsView",
|
||||||
channelsTable: "siftlode.channelsTable",
|
channelsTable: "siftlode.channelsTable",
|
||||||
|
|||||||
Reference in New Issue
Block a user