feat(plex): port the saved-views (personal views) UI to Plex (frontend)
Plex now has the same personal-views system as the feed — the rail header icon opens the quick-pick popover, the rail title opens the manager modal (list / create / rename / delete / reorder / set-default / apply), and the active view leads the chip row with a "modified" dot. One SavedViewsProvider serves both modules: it switches `source` (feed ⟷ plex) by the active page (both filter providers are global, so it reads whichever the page owns) and runs the same logic on per-source wiring — the live filters, apply, a stable signature (feed filtersToParams / plex plexFiltersSignature), coercion (asFilters / coercePlexFilters), and what to strip on save. Moved below PlexProvider in the tree so it can read plex state; resets sticky/modal on a source switch. The shared popover/modal/chip are unchanged; the modal describes a plex view's captured filters via a pure buildPlexActiveFilters (extracted from usePlexActiveFilters — no duplication). api.savedViews/createView take a source; `SavedView.filters` is now an opaque blob. New coercePlexFilters/plexFilters- Signature (also dedups PlexProvider's collection migration). Empty-state copy genericised (no longer says "feed"). A Plex view captures the rail filters (not watch-state/sort/scope). Verified end-to-end on localdev: create/apply/preview/default/delete on plex, feed views stay separate + unregressed (402 vitest), no console errors.
This commit is contained in:
@@ -16,10 +16,11 @@ import {
|
|||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import type { SavedView } from "../lib/api";
|
import { coercePlexFilters, type FeedFilters, type SavedView } from "../lib/api";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
import { useSavedViews, asFilters } from "./SavedViewsProvider";
|
import { useSavedViews, asFilters } from "./SavedViewsProvider";
|
||||||
import { useActiveFilters } from "../lib/useActiveFilters";
|
import { useActiveFilters } from "../lib/useActiveFilters";
|
||||||
|
import { buildPlexActiveFilters } from "./usePlexActiveFilters";
|
||||||
|
|
||||||
// Master–detail manager for saved ("personal") views: the left column lists the views (drag to
|
// Master–detail manager for saved ("personal") views: the left column lists the views (drag to
|
||||||
// reorder) plus a "save the current filters" input; the right column shows the selected view's name,
|
// reorder) plus a "save the current filters" input; the right column shows the selected view's name,
|
||||||
@@ -72,19 +73,38 @@ function ViewRow({
|
|||||||
|
|
||||||
export default function PersonalViewsModal() {
|
export default function PersonalViewsModal() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { views, activeViewId, apply, create, rename, toggleDefault, remove, reorder, closeModal } =
|
const {
|
||||||
useSavedViews();
|
source,
|
||||||
|
views,
|
||||||
|
activeViewId,
|
||||||
|
apply,
|
||||||
|
create,
|
||||||
|
rename,
|
||||||
|
toggleDefault,
|
||||||
|
remove,
|
||||||
|
reorder,
|
||||||
|
closeModal,
|
||||||
|
} = useSavedViews();
|
||||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
|
||||||
|
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(activeViewId ?? views[0]?.id ?? null);
|
const [selectedId, setSelectedId] = useState<number | null>(activeViewId ?? views[0]?.id ?? null);
|
||||||
const selected = views.find((v) => v.id === selectedId) ?? null;
|
const selected = views.find((v) => v.id === selectedId) ?? null;
|
||||||
const [draftName, setDraftName] = useState("");
|
const [draftName, setDraftName] = useState("");
|
||||||
|
|
||||||
// The selected view's captured filters, as the same human labels the toolbar chip row shows.
|
// The selected view's captured filters, as the same human labels its module's chip row shows. The
|
||||||
const captured = useActiveFilters(
|
// feed hook runs unconditionally (fed empty filters off-feed); Plex uses the pure builder (no-op
|
||||||
selected ? asFilters(selected.filters) : ({ tags: [], channelIds: [] } as never),
|
// removes — this is display-only). A plex view captures its rail filters, not the watch-state.
|
||||||
() => {},
|
const noop = () => {};
|
||||||
|
const feedCaptured = useActiveFilters(
|
||||||
|
source === "feed" && selected
|
||||||
|
? asFilters(selected.filters as unknown as FeedFilters)
|
||||||
|
: ({ tags: [], channelIds: [] } as never),
|
||||||
|
noop,
|
||||||
);
|
);
|
||||||
|
const captured =
|
||||||
|
source === "plex" && selected
|
||||||
|
? buildPlexActiveFilters(coercePlexFilters(selected.filters), "all", noop, noop, t)
|
||||||
|
: feedCaptured;
|
||||||
|
|
||||||
async function onDragEnd(e: DragEndEvent) {
|
async function onDragEnd(e: DragEndEvent) {
|
||||||
const { active, over } = e;
|
const { active, over } = e;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import PlexDiscover from "./PlexDiscover";
|
|||||||
import { PlexCollectionsBrowse, PlexPlaylistsBrowse } from "./plexBrowseLists";
|
import { PlexCollectionsBrowse, PlexPlaylistsBrowse } from "./plexBrowseLists";
|
||||||
import { PageToolbar } from "./PageShell";
|
import { PageToolbar } from "./PageShell";
|
||||||
import ActiveFilterChips from "./ActiveFilterChips";
|
import ActiveFilterChips from "./ActiveFilterChips";
|
||||||
|
import SelectedViewChip from "./SelectedViewChip";
|
||||||
import ItemCount from "./ItemCount";
|
import ItemCount from "./ItemCount";
|
||||||
import { StateMessage } from "./QueryState";
|
import { StateMessage } from "./QueryState";
|
||||||
import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters";
|
import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters";
|
||||||
@@ -461,6 +462,8 @@ export default function PlexBrowse() {
|
|||||||
left (overflowing to a "+N"/count pill so they never crowd the controls), while Sort +
|
left (overflowing to a "+N"/count pill so they never crowd the controls), while Sort +
|
||||||
the Discover/Browse toggle stay pinned to the right — the same layout as the Feed. */}
|
the Discover/Browse toggle stay pinned to the right — the same layout as the Feed. */}
|
||||||
<div className="mt-1 flex items-center gap-2">
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
{/* The active personal view (if any) leads the applied-filter row, like the feed. */}
|
||||||
|
<SelectedViewChip />
|
||||||
<ActiveFilterChips
|
<ActiveFilterChips
|
||||||
filters={plexChips}
|
filters={plexChips}
|
||||||
onClearAll={clearAllFilters}
|
onClearAll={clearAllFilters}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||||
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
|
import { coercePlexFilters, EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
|
||||||
import { parseSortState, type SortState } from "../lib/columnSort";
|
import { parseSortState, type SortState } from "../lib/columnSort";
|
||||||
import { parsePlexField, type PlexSearchField } from "../lib/plexSearch";
|
import { parsePlexField, type PlexSearchField } from "../lib/plexSearch";
|
||||||
import { PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
import { PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
||||||
@@ -122,21 +122,11 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
|||||||
const [filtersRaw, setFiltersRaw] = useState<string>(readPlexFiltersRaw);
|
const [filtersRaw, setFiltersRaw] = useState<string>(readPlexFiltersRaw);
|
||||||
const filters = useMemo<PlexFilters>(() => {
|
const filters = useMemo<PlexFilters>(() => {
|
||||||
try {
|
try {
|
||||||
// Drop the legacy `sortDir` — direction now lives in the sort SortState. Left here it would leak
|
// coercePlexFilters fills defaults, drops the legacy `sortDir` (direction now lives in the sort
|
||||||
// into the facets query key and be re-persisted on every filter change. Also migrate the legacy
|
// SortState — left here it would leak into the facets key and be re-persisted), and migrates the
|
||||||
// single `collection`/`collectionTitle` into the multi-select `collections`/`collectionTitles`.
|
// legacy single `collection`/`collectionTitle` into `collections`/`collectionTitles`. Shared with
|
||||||
const {
|
// the saved-views provider so the localStorage seed and a DB view blob coerce identically.
|
||||||
sortDir: _drop,
|
return coercePlexFilters(JSON.parse(filtersRaw));
|
||||||
collection: legacyCollection,
|
|
||||||
collectionTitle: legacyTitle,
|
|
||||||
...rest
|
|
||||||
} = JSON.parse(filtersRaw);
|
|
||||||
const merged: PlexFilters = { ...EMPTY_PLEX_FILTERS, ...rest };
|
|
||||||
if (legacyCollection && !merged.collections.length) {
|
|
||||||
merged.collections = [legacyCollection];
|
|
||||||
if (legacyTitle) merged.collectionTitles = { [legacyCollection]: legacyTitle };
|
|
||||||
}
|
|
||||||
return merged;
|
|
||||||
} catch {
|
} catch {
|
||||||
return EMPTY_PLEX_FILTERS;
|
return EMPTY_PLEX_FILTERS;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ import { type PanelItem } from "./PanelGroups";
|
|||||||
import SegmentedToggle from "./SegmentedToggle";
|
import SegmentedToggle from "./SegmentedToggle";
|
||||||
import FilterChipGroup from "./FilterChipGroup";
|
import FilterChipGroup from "./FilterChipGroup";
|
||||||
import Combobox, { type ComboboxOption } from "./Combobox";
|
import Combobox, { type ComboboxOption } from "./Combobox";
|
||||||
|
import SavedViewsPopover from "./SavedViewsPopover";
|
||||||
|
import PersonalViewsModal from "./PersonalViewsModal";
|
||||||
|
import { useSavedViews } from "./SavedViewsProvider";
|
||||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||||
|
import { useMe } from "../lib/useMe";
|
||||||
|
|
||||||
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups
|
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups
|
||||||
// as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata
|
// as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata
|
||||||
@@ -66,6 +70,10 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
// hand-off to PlexBrowse via the shared `playlistOpen` slot.
|
// hand-off to PlexBrowse via the shared `playlistOpen` slot.
|
||||||
const { scope, show, filters } = usePlex();
|
const { scope, show, filters } = usePlex();
|
||||||
const { setFilters } = usePlexActions();
|
const { setFilters } = usePlexActions();
|
||||||
|
const isDemo = useMe().is_demo;
|
||||||
|
// Personal ("saved") views for the Plex module: the rail header icon opens a quick-pick popover, the
|
||||||
|
// title opens the full manager modal (same shared components as the feed). Hidden for the demo account.
|
||||||
|
const { openModal, modalOpen } = useSavedViews();
|
||||||
// This rail's media source (the Plex library) — names the abstraction the feed and Plex rails
|
// This rail's media source (the Plex library) — names the abstraction the feed and Plex rails
|
||||||
// share; drives the panel layout key. `FilterRail` owns the customize `editing` state now.
|
// share; drives the panel layout key. `FilterRail` owns the customize `editing` state now.
|
||||||
const source: MediaSourceKind = "plex";
|
const source: MediaSourceKind = "plex";
|
||||||
@@ -291,20 +299,33 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FilterRail
|
<>
|
||||||
kind={source}
|
<FilterRail
|
||||||
title={t("sidebar.filters")}
|
kind={source}
|
||||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
title={t("sidebar.filters")}
|
||||||
count={activeCount}
|
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||||
collapsed={collapsed}
|
count={activeCount}
|
||||||
onToggleCollapse={onToggleCollapse}
|
collapsed={collapsed}
|
||||||
layout={layout}
|
onToggleCollapse={onToggleCollapse}
|
||||||
setLayout={setLayout}
|
layout={layout}
|
||||||
items={items}
|
setLayout={setLayout}
|
||||||
// The Plex widgets are simple label + control rows now, not collapsible islands (matches the
|
items={items}
|
||||||
// feed rail). The rail's own "Clear all" is dropped too — the chip row above the grid owns it.
|
// The Plex widgets are simple label + control rows now, not collapsible islands (matches the
|
||||||
collapsible={false}
|
// feed rail). The rail's own "Clear all" is dropped too — the chip row above the grid owns it.
|
||||||
/>
|
collapsible={false}
|
||||||
|
// Saved ("personal") views: header icon → quick-pick popover, title → the manager modal. Hidden
|
||||||
|
// for the demo account. Same shared components as the feed rail (SavedViewsProvider switches source).
|
||||||
|
iconExpanded={
|
||||||
|
isDemo ? undefined : (
|
||||||
|
<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onTitleClick={isDemo ? undefined : openModal}
|
||||||
|
titleActionLabel={isDemo ? undefined : t("views.manageTitle")}
|
||||||
|
/>
|
||||||
|
{/* The manager modal lives here (rail is always mounted on plex); Modal portals to <body>. */}
|
||||||
|
{modalOpen && <PersonalViewsModal />}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
getActiveAccount,
|
coercePlexFilters,
|
||||||
coerceTagModes,
|
coerceTagModes,
|
||||||
|
getActiveAccount,
|
||||||
|
plexFiltersSignature,
|
||||||
type FeedFilters,
|
type FeedFilters,
|
||||||
|
type PlexFilters,
|
||||||
type SavedView,
|
type SavedView,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
import { filtersToParams } from "../lib/urlState";
|
import { filtersToParams } from "../lib/urlState";
|
||||||
@@ -14,12 +17,17 @@ import { preferSticky } from "../lib/savedViews";
|
|||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { accountKey, LS, writeJSON } from "../lib/storage";
|
import { accountKey, LS, writeJSON } from "../lib/storage";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
import { useNavigation } from "./NavigationProvider";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||||
|
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||||
|
|
||||||
// A saved view's `filters` is an opaque JSON blob from the DB — the FeedFilters type says nothing about
|
// Saved "smart views" now serve TWO modules — the feed and Plex — keyed by `source`. This one provider
|
||||||
// what an older build wrote there, so coerce the array/shape fields (and migrate the legacy global
|
// switches which module it operates on by the active page (both filter providers are global, so it can
|
||||||
// tagMode) before the blob reaches app state or a serializer. NOT a migration: a view saved against a
|
// read either), so the shared UI (popover / modal / chip) works on the feed page against feed views and
|
||||||
// dropped field just loses that filter.
|
// on the plex page against plex views without a second provider or an App-layout change.
|
||||||
|
|
||||||
|
// A saved view's `filters` is an opaque JSON blob from the DB; coerce it to the module's shape before it
|
||||||
|
// reaches app state or a serializer (an older build's dropped field just falls away — NOT a migration).
|
||||||
export const asFilters = (raw: FeedFilters): FeedFilters => ({
|
export const asFilters = (raw: FeedFilters): FeedFilters => ({
|
||||||
...raw,
|
...raw,
|
||||||
tags: raw.tags ?? [],
|
tags: raw.tags ?? [],
|
||||||
@@ -27,13 +35,27 @@ export const asFilters = (raw: FeedFilters): FeedFilters => ({
|
|||||||
tagModes: coerceTagModes(raw),
|
tagModes: coerceTagModes(raw),
|
||||||
});
|
});
|
||||||
|
|
||||||
// A stable signature of a filter set (only non-default keys, shuffle seed ignored) so a view can be
|
type ViewSource = "feed" | "plex";
|
||||||
// matched against the live filters regardless of key order or transient fields.
|
|
||||||
const keyOf = (f: FeedFilters) => filtersToParams(asFilters(f)).toString();
|
|
||||||
|
|
||||||
// Mirror the default view's filters to localStorage so the app can seed them on next load before the
|
// The per-source wiring the generic logic runs on: the live filters, how to apply a view's, a stable
|
||||||
// network returns (read by FeedFiltersProvider).
|
// signature for matching, coercion, and what to strip before saving (the feed's transient shuffle seed).
|
||||||
function syncDefaultMirror(views: SavedView[]): void {
|
interface ViewConfig {
|
||||||
|
source: ViewSource;
|
||||||
|
filters: Record<string, unknown>;
|
||||||
|
setFilters: (f: Record<string, unknown>) => void;
|
||||||
|
keyOf: (f: Record<string, unknown>) => string;
|
||||||
|
asFilters: (raw: Record<string, unknown>) => Record<string, unknown>;
|
||||||
|
stripForSave: (f: Record<string, unknown>) => Record<string, unknown>;
|
||||||
|
/** Feed only: mirror the default view's filters to localStorage so it seeds before the network returns. */
|
||||||
|
syncDefaultMirror?: (views: SavedView[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedKeyOf = (f: Record<string, unknown>) =>
|
||||||
|
filtersToParams(asFilters(f as unknown as FeedFilters)).toString();
|
||||||
|
const plexKeyOf = (f: Record<string, unknown>) => plexFiltersSignature(coercePlexFilters(f));
|
||||||
|
|
||||||
|
// Mirror the feed default view's filters to localStorage (read by FeedFiltersProvider on load).
|
||||||
|
function syncFeedDefaultMirror(views: SavedView[]): void {
|
||||||
const key = accountKey(LS.defaultViewFilters, getActiveAccount());
|
const key = accountKey(LS.defaultViewFilters, getActiveAccount());
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
const def = views.find((v) => v.is_default);
|
const def = views.find((v) => v.is_default);
|
||||||
@@ -41,26 +63,21 @@ function syncDefaultMirror(views: SavedView[]): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SavedViewsContext {
|
export interface SavedViewsContext {
|
||||||
|
/** Which module these views belong to — the shared UI branches its filter-describer on it. */
|
||||||
|
source: ViewSource;
|
||||||
views: SavedView[];
|
views: SavedView[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
/** The view whose saved filters EXACTLY match the live filters (null if none) — highlighted as active. */
|
|
||||||
activeViewId: number | null;
|
activeViewId: number | null;
|
||||||
/** The view to name in the chip: the exact match, else the last-applied ("sticky") one it diverged from. */
|
|
||||||
displayView: SavedView | null;
|
displayView: SavedView | null;
|
||||||
/** True when a sticky view is shown but the live filters no longer match it (the chip's "modified" dot). */
|
|
||||||
isDirty: boolean;
|
isDirty: boolean;
|
||||||
modalOpen: boolean;
|
modalOpen: boolean;
|
||||||
openModal: () => void;
|
openModal: () => void;
|
||||||
closeModal: () => void;
|
closeModal: () => void;
|
||||||
/** Apply a view's filters and remember it as the active/sticky one. */
|
|
||||||
apply: (v: SavedView) => void;
|
apply: (v: SavedView) => void;
|
||||||
/** Forget the active/sticky view (the chip disappears) without touching the filters. */
|
|
||||||
clearActive: () => void;
|
clearActive: () => void;
|
||||||
/** Save the CURRENT filters as a new view; returns success so a caller can keep its editor open on fail. */
|
|
||||||
create: (name: string) => Promise<boolean>;
|
create: (name: string) => Promise<boolean>;
|
||||||
rename: (id: number, name: string) => Promise<boolean>;
|
rename: (id: number, name: string) => Promise<boolean>;
|
||||||
toggleDefault: (v: SavedView) => Promise<void>;
|
toggleDefault: (v: SavedView) => Promise<void>;
|
||||||
/** Delete a view after confirming; resolves true only when it was actually deleted (false on cancel/fail). */
|
|
||||||
remove: (v: SavedView) => Promise<boolean>;
|
remove: (v: SavedView) => Promise<boolean>;
|
||||||
reorder: (nextIds: number[]) => Promise<void>;
|
reorder: (nextIds: number[]) => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -71,33 +88,72 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const filters = useFeedFilters();
|
const { page } = useNavigation();
|
||||||
const { setFilters } = useFeedFiltersActions();
|
|
||||||
|
|
||||||
const viewsQuery = useQuery({ queryKey: qk.savedViews(), queryFn: api.savedViews });
|
// Both filter providers are global — read both, then use the one the active page owns.
|
||||||
|
const feedFilters = useFeedFilters();
|
||||||
|
const { setFilters: setFeedFilters } = useFeedFiltersActions();
|
||||||
|
const { filters: plexFilters } = usePlex();
|
||||||
|
const { setFilters: setPlexFilters } = usePlexActions();
|
||||||
|
|
||||||
|
const cfg: ViewConfig = useMemo(() => {
|
||||||
|
if (page === "plex") {
|
||||||
|
return {
|
||||||
|
source: "plex",
|
||||||
|
filters: plexFilters as unknown as Record<string, unknown>,
|
||||||
|
setFilters: (f) => setPlexFilters(f as unknown as PlexFilters),
|
||||||
|
keyOf: plexKeyOf,
|
||||||
|
asFilters: (raw) => coercePlexFilters(raw) as unknown as Record<string, unknown>,
|
||||||
|
stripForSave: (f) => f, // Plex has no transient field to drop
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
source: "feed",
|
||||||
|
filters: feedFilters as unknown as Record<string, unknown>,
|
||||||
|
setFilters: (f) => setFeedFilters(f as unknown as FeedFilters),
|
||||||
|
keyOf: feedKeyOf,
|
||||||
|
asFilters: (raw) =>
|
||||||
|
asFilters(raw as unknown as FeedFilters) as unknown as Record<string, unknown>,
|
||||||
|
// Strip the transient shuffle seed so a saved "shuffle" view re-rolls fresh on apply.
|
||||||
|
stripForSave: (f) => {
|
||||||
|
const { seed: _seed, ...rest } = f;
|
||||||
|
return rest;
|
||||||
|
},
|
||||||
|
syncDefaultMirror: syncFeedDefaultMirror,
|
||||||
|
};
|
||||||
|
}, [page, feedFilters, plexFilters, setFeedFilters, setPlexFilters]);
|
||||||
|
|
||||||
|
const source = cfg.source;
|
||||||
|
const viewsQuery = useQuery({
|
||||||
|
queryKey: qk.savedViews(source),
|
||||||
|
queryFn: () => api.savedViews(source),
|
||||||
|
});
|
||||||
const views = useMemo(() => viewsQuery.data ?? [], [viewsQuery.data]);
|
const views = useMemo(() => viewsQuery.data ?? [], [viewsQuery.data]);
|
||||||
|
|
||||||
const [stickyId, setStickyId] = useState<number | null>(null);
|
const [stickyId, setStickyId] = useState<number | null>(null);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
// Keep the default-view mirror (read on load) in sync with the server state.
|
// Switching modules (feed ⟷ plex) starts fresh: a feed view can't be the sticky/active one on plex,
|
||||||
|
// and a feed modal shouldn't linger open on plex.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewsQuery.data) syncDefaultMirror(viewsQuery.data);
|
setStickyId(null);
|
||||||
}, [viewsQuery.data]);
|
setModalOpen(false);
|
||||||
|
}, [source]);
|
||||||
|
|
||||||
const currentKey = keyOf(filters);
|
// Feed only: keep the default-view mirror (read on load) in sync with the server state.
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewsQuery.data) cfg.syncDefaultMirror?.(viewsQuery.data);
|
||||||
|
}, [viewsQuery.data, cfg]);
|
||||||
|
|
||||||
|
const currentKey = cfg.keyOf(cfg.filters);
|
||||||
const activeView = useMemo(
|
const activeView = useMemo(
|
||||||
// When several views capture identical filters, prefer the one the user explicitly applied
|
|
||||||
// (sticky) so apply(B) isn't silently re-attributed to an earlier duplicate A; else the first.
|
|
||||||
() =>
|
() =>
|
||||||
preferSticky(
|
preferSticky(
|
||||||
views.filter((v) => keyOf(v.filters) === currentKey),
|
views.filter((v) => cfg.keyOf(v.filters) === currentKey),
|
||||||
stickyId,
|
stickyId,
|
||||||
),
|
),
|
||||||
[views, currentKey, stickyId],
|
[views, currentKey, stickyId, cfg],
|
||||||
);
|
);
|
||||||
// An exact match becomes the sticky selection, so filters that happen to equal a view (deep-link,
|
|
||||||
// manual, or an applied view) name that view in the chip and keep naming it after a later tweak.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeView) setStickyId(activeView.id);
|
if (activeView) setStickyId(activeView.id);
|
||||||
}, [activeView]);
|
}, [activeView]);
|
||||||
@@ -109,7 +165,7 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
|||||||
const displayView = activeView ?? stickyView;
|
const displayView = activeView ?? stickyView;
|
||||||
const isDirty = displayView != null && activeView == null;
|
const isDirty = displayView != null && activeView == null;
|
||||||
|
|
||||||
const refresh = () => qc.invalidateQueries({ queryKey: qk.savedViews() });
|
const refresh = () => qc.invalidateQueries({ queryKey: qk.savedViews(source) });
|
||||||
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> {
|
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await fn();
|
await fn();
|
||||||
@@ -123,6 +179,7 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const value: SavedViewsContext = {
|
const value: SavedViewsContext = {
|
||||||
|
source,
|
||||||
views,
|
views,
|
||||||
loading: viewsQuery.isLoading,
|
loading: viewsQuery.isLoading,
|
||||||
activeViewId: activeView?.id ?? null,
|
activeViewId: activeView?.id ?? null,
|
||||||
@@ -132,16 +189,17 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
|||||||
openModal: () => setModalOpen(true),
|
openModal: () => setModalOpen(true),
|
||||||
closeModal: () => setModalOpen(false),
|
closeModal: () => setModalOpen(false),
|
||||||
apply: (v) => {
|
apply: (v) => {
|
||||||
setFilters(asFilters(v.filters));
|
cfg.setFilters(cfg.asFilters(v.filters));
|
||||||
setStickyId(v.id);
|
setStickyId(v.id);
|
||||||
},
|
},
|
||||||
clearActive: () => setStickyId(null),
|
clearActive: () => setStickyId(null),
|
||||||
create: async (name) => {
|
create: async (name) => {
|
||||||
const clean = name.trim();
|
const clean = name.trim();
|
||||||
if (!clean) return false;
|
if (!clean) return false;
|
||||||
// Strip the transient shuffle seed so a saved "shuffle" view re-rolls fresh on apply.
|
return runViewMutation(
|
||||||
const { seed: _seed, ...rest } = filters;
|
() => api.createView(clean, cfg.stripForSave(cfg.filters), source),
|
||||||
return runViewMutation(() => api.createView(clean, rest), t("views.actionFailed"));
|
t("views.actionFailed"),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
rename: async (id, name) => {
|
rename: async (id, name) => {
|
||||||
const next = name.trim();
|
const next = name.trim();
|
||||||
@@ -164,10 +222,8 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
|||||||
return runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
|
return runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
|
||||||
},
|
},
|
||||||
reorder: async (nextIds) => {
|
reorder: async (nextIds) => {
|
||||||
// Optimistic reorder so the drop sticks visually before the round-trip; the refetch reverts to
|
|
||||||
// the server's truth on failure (with a toast, so it doesn't silently snap back).
|
|
||||||
qc.setQueryData<SavedView[]>(
|
qc.setQueryData<SavedView[]>(
|
||||||
qk.savedViews(),
|
qk.savedViews(source),
|
||||||
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean),
|
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean),
|
||||||
);
|
);
|
||||||
await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed"));
|
await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed"));
|
||||||
|
|||||||
@@ -12,11 +12,26 @@ import { usePlex, usePlexActions } from "./PlexProvider";
|
|||||||
// removing one keeps the rest (same shape as the feed's tag/channel chips). Sort is NOT a chip here —
|
// removing one keeps the rest (same shape as the feed's tag/channel chips). Sort is NOT a chip here —
|
||||||
// it's an always-visible control above the grid (S3), like the feed. Lives here, not in lib, because
|
// it's an always-visible control above the grid (S3), like the feed. Lives here, not in lib, because
|
||||||
// it reads the Plex provider (lib stays provider-free, like useActiveFilters).
|
// it reads the Plex provider (lib stays provider-free, like useActiveFilters).
|
||||||
|
type TFn = (key: string, options?: Record<string, unknown>) => string;
|
||||||
|
|
||||||
export function usePlexActiveFilters(): ActiveFilter[] {
|
export function usePlexActiveFilters(): ActiveFilter[] {
|
||||||
const { show, filters } = usePlex();
|
const { show, filters } = usePlex();
|
||||||
const { setShow, setFilters } = usePlexActions();
|
const { setShow, setFilters } = usePlexActions();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
||||||
|
return buildPlexActiveFilters(filters, show, patch, setShow, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pure builder behind the hook: turns a Plex filter set + watch-state into the source-agnostic
|
||||||
|
// ActiveFilter[]. Extracted so the saved-views manager can DESCRIBE an arbitrary view's captured
|
||||||
|
// filters (with no-op removes) — the labels stay in one place, no duplication with the live chip row.
|
||||||
|
export function buildPlexActiveFilters(
|
||||||
|
filters: PlexFilters,
|
||||||
|
show: string,
|
||||||
|
patch: (p: Partial<PlexFilters>) => void,
|
||||||
|
setShow: (s: string) => void,
|
||||||
|
t: TFn,
|
||||||
|
): ActiveFilter[] {
|
||||||
const out: ActiveFilter[] = [];
|
const out: ActiveFilter[] = [];
|
||||||
|
|
||||||
const has = (x: number | null | undefined) => x != null;
|
const has = (x: number | null | undefined) => x != null;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"namePlaceholder": "View name",
|
"namePlaceholder": "View name",
|
||||||
"saveAction": "Save",
|
"saveAction": "Save",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"empty": "No saved views yet. Filter the feed, then save it.",
|
"empty": "No saved views yet. Apply some filters, then save them.",
|
||||||
"apply": "Apply this view",
|
"apply": "Apply this view",
|
||||||
"makeDefault": "Set as default (loads on start)",
|
"makeDefault": "Set as default (loads on start)",
|
||||||
"isDefault": "Default view — loads on start",
|
"isDefault": "Default view — loads on start",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"namePlaceholder": "Nézet neve",
|
"namePlaceholder": "Nézet neve",
|
||||||
"saveAction": "Mentés",
|
"saveAction": "Mentés",
|
||||||
"cancel": "Mégse",
|
"cancel": "Mégse",
|
||||||
"empty": "Még nincs mentett nézet. Szűrd a feedet, majd mentsd el.",
|
"empty": "Még nincs mentett nézet. Állíts be szűrőket, majd mentsd el.",
|
||||||
"apply": "Nézet alkalmazása",
|
"apply": "Nézet alkalmazása",
|
||||||
"makeDefault": "Beállítás alapértelmezettként (indításkor betölt)",
|
"makeDefault": "Beállítás alapértelmezettként (indításkor betölt)",
|
||||||
"isDefault": "Alapértelmezett nézet — indításkor betölt",
|
"isDefault": "Alapértelmezett nézet — indításkor betölt",
|
||||||
|
|||||||
@@ -51,7 +51,12 @@ import { adminApi } from "./api/admin";
|
|||||||
export type * from "./api/admin";
|
export type * from "./api/admin";
|
||||||
import { plexApi } from "./api/plex";
|
import { plexApi } from "./api/plex";
|
||||||
export type * from "./api/plex";
|
export type * from "./api/plex";
|
||||||
export { EMPTY_PLEX_FILTERS, plexFilterCount } from "./api/plex";
|
export {
|
||||||
|
EMPTY_PLEX_FILTERS,
|
||||||
|
plexFilterCount,
|
||||||
|
coercePlexFilters,
|
||||||
|
plexFiltersSignature,
|
||||||
|
} from "./api/plex";
|
||||||
import { messagingApi } from "./api/messaging";
|
import { messagingApi } from "./api/messaging";
|
||||||
export type * from "./api/messaging";
|
export type * from "./api/messaging";
|
||||||
import { downloadsApi } from "./api/downloads";
|
import { downloadsApi } from "./api/downloads";
|
||||||
|
|||||||
@@ -252,6 +252,37 @@ export const EMPTY_PLEX_FILTERS: PlexFilters = {
|
|||||||
keywords: [],
|
keywords: [],
|
||||||
collections: [],
|
collections: [],
|
||||||
};
|
};
|
||||||
|
// Coerce an opaque persisted/saved filter blob to a full PlexFilters: fill defaults for any missing
|
||||||
|
// array/scalar, and migrate the legacy single `collection`/`collectionTitle` into the multi-select
|
||||||
|
// `collections`/`collectionTitles`. Shared by PlexProvider (localStorage seed) and the saved-views
|
||||||
|
// provider (a DB view blob). Not a migration — an unknown field a newer build dropped just falls away.
|
||||||
|
export function coercePlexFilters(raw: Record<string, unknown>): PlexFilters {
|
||||||
|
const {
|
||||||
|
sortDir: _drop,
|
||||||
|
collection,
|
||||||
|
collectionTitle,
|
||||||
|
...rest
|
||||||
|
} = raw as Record<string, unknown> & {
|
||||||
|
collection?: string | null;
|
||||||
|
collectionTitle?: string | null;
|
||||||
|
};
|
||||||
|
const merged: PlexFilters = { ...EMPTY_PLEX_FILTERS, ...(rest as Partial<PlexFilters>) };
|
||||||
|
if (collection && !merged.collections.length) {
|
||||||
|
merged.collections = [collection];
|
||||||
|
if (collectionTitle) merged.collectionTitles = { [collection]: collectionTitle };
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stable signature of a PlexFilters set (only non-default keys; display-only titles excluded), so a
|
||||||
|
// saved view can be matched against the live filters regardless of key order — the Plex analogue of the
|
||||||
|
// feed's filtersToParams. Reuses appendPlexFilters so the signature can't drift from the wire params.
|
||||||
|
export function plexFiltersSignature(f: PlexFilters): string {
|
||||||
|
const u = new URLSearchParams();
|
||||||
|
appendPlexFilters(u, f);
|
||||||
|
return u.toString();
|
||||||
|
}
|
||||||
|
|
||||||
export function plexFilterCount(f: PlexFilters): number {
|
export function plexFilterCount(f: PlexFilters): number {
|
||||||
return (
|
return (
|
||||||
f.genres.length +
|
f.genres.length +
|
||||||
|
|||||||
@@ -1,24 +1,36 @@
|
|||||||
// Saved "smart views": per-user named snapshots of FeedFilters (see SavedViewsWidget).
|
// Saved "smart views": per-user named snapshots of a module's filters, scoped by `source`
|
||||||
// Only touches core's req; the FeedFilters shape is owned by feed.ts.
|
// ("feed" | "plex"). Only touches core's req; `filters` is an opaque JSON blob (each module casts it
|
||||||
|
// to its own filter shape), so this stays independent of feed.ts / plex.ts.
|
||||||
import { req } from "./core";
|
import { req } from "./core";
|
||||||
import type { FeedFilters } from "./feed";
|
|
||||||
|
|
||||||
// A saved "smart view": a per-user named snapshot of FeedFilters (see SavedViewsWidget).
|
// A module's saved filter snapshot. `filters` is opaque here — the feed reads it as FeedFilters, Plex
|
||||||
|
// as PlexFilters; `source` is implicit (the caller queries per source).
|
||||||
export interface SavedView {
|
export interface SavedView {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
filters: FeedFilters;
|
filters: Record<string, unknown>;
|
||||||
position: number;
|
position: number;
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The module a view belongs to. "feed" is the default so existing callers keep working.
|
||||||
|
type ViewSource = "feed" | "plex";
|
||||||
|
|
||||||
export const savedViewsApi = {
|
export const savedViewsApi = {
|
||||||
savedViews: (): Promise<SavedView[]> => req("/api/saved-views"),
|
savedViews: (source: ViewSource = "feed"): Promise<SavedView[]> =>
|
||||||
createView: (name: string, filters: FeedFilters): Promise<SavedView> =>
|
req(`/api/saved-views?source=${source}`),
|
||||||
req("/api/saved-views", { method: "POST", body: JSON.stringify({ name, filters }) }),
|
createView: (
|
||||||
|
name: string,
|
||||||
|
filters: Record<string, unknown>,
|
||||||
|
source: ViewSource = "feed",
|
||||||
|
): Promise<SavedView> =>
|
||||||
|
req("/api/saved-views", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ name, filters, source }),
|
||||||
|
}),
|
||||||
updateView: (
|
updateView: (
|
||||||
id: number,
|
id: number,
|
||||||
patch: Partial<{ name: string; filters: FeedFilters; is_default: boolean }>,
|
patch: Partial<{ name: string; filters: Record<string, unknown>; is_default: boolean }>,
|
||||||
): Promise<SavedView> =>
|
): Promise<SavedView> =>
|
||||||
req(`/api/saved-views/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
req(`/api/saved-views/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
deleteView: (id: number): Promise<{ deleted: number }> =>
|
deleteView: (id: number): Promise<{ deleted: number }> =>
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ export const qk = {
|
|||||||
playlist: (id?: number | null) =>
|
playlist: (id?: number | null) =>
|
||||||
id !== undefined ? (["playlist", id] as const) : (["playlist"] as const),
|
id !== undefined ? (["playlist", id] as const) : (["playlist"] as const),
|
||||||
playlistMembership: (videoId: string) => ["playlist-membership", videoId] as const,
|
playlistMembership: (videoId: string) => ["playlist-membership", videoId] as const,
|
||||||
savedViews: () => ["saved-views"] as const,
|
// Keyed by source ("feed" | "plex") so each module's views cache independently; `savedViews()` (no
|
||||||
|
// arg) stays a prefix for a cross-source invalidation.
|
||||||
|
savedViews: (...rest: unknown[]) => ["saved-views", ...rest],
|
||||||
|
|
||||||
// --- account / misc (R7 S2b) --- (me/myStatus/tags already declared as shared roots above)
|
// --- account / misc (R7 S2b) --- (me/myStatus/tags already declared as shared roots above)
|
||||||
accounts: () => ["accounts"] as const,
|
accounts: () => ["accounts"] as const,
|
||||||
|
|||||||
+10
-8
@@ -59,10 +59,12 @@ const root =
|
|||||||
<ConfirmProvider>
|
<ConfirmProvider>
|
||||||
<NavigationProvider>
|
<NavigationProvider>
|
||||||
<FeedFiltersProvider>
|
<FeedFiltersProvider>
|
||||||
<SavedViewsProvider>
|
<PlaylistsProvider>
|
||||||
<PlaylistsProvider>
|
<PlexProvider>
|
||||||
<PlexProvider>
|
<PlexViewBridge>
|
||||||
<PlexViewBridge>
|
{/* SavedViewsProvider sits below BOTH filter providers so it can switch its
|
||||||
|
source (feed ⟷ plex) by the active page and read the right live filters. */}
|
||||||
|
<SavedViewsProvider>
|
||||||
<ChannelsProvider>
|
<ChannelsProvider>
|
||||||
<FeedViewProvider>
|
<FeedViewProvider>
|
||||||
<PrefsProvider>
|
<PrefsProvider>
|
||||||
@@ -72,10 +74,10 @@ const root =
|
|||||||
</PrefsProvider>
|
</PrefsProvider>
|
||||||
</FeedViewProvider>
|
</FeedViewProvider>
|
||||||
</ChannelsProvider>
|
</ChannelsProvider>
|
||||||
</PlexViewBridge>
|
</SavedViewsProvider>
|
||||||
</PlexProvider>
|
</PlexViewBridge>
|
||||||
</PlaylistsProvider>
|
</PlexProvider>
|
||||||
</SavedViewsProvider>
|
</PlaylistsProvider>
|
||||||
</FeedFiltersProvider>
|
</FeedFiltersProvider>
|
||||||
</NavigationProvider>
|
</NavigationProvider>
|
||||||
</ConfirmProvider>
|
</ConfirmProvider>
|
||||||
|
|||||||
Reference in New Issue
Block a user