refactor(frontend): the module stores mount from the registry, and the feed and Plex each own their saved views
A module's definition names its store (feed: filters, view, saved views; Plex: browse state, saved views; channels, files, playlists their provider), and ModuleStores mounts them at the root in registry order instead of a hand-nested tree in main.tsx. A contract test holds that every store's read side is in reach and that no store's static imports leave its own module, so the order cannot matter. Saved views were one provider that switched between the feed and Plex by the active page and reset on each switch. The logic is now one hook (useSavedViewsStore) with an instance per module; the shared chip, popover and modal read the views their page or rail hands down. A feed view applied and edited now survives a visit to Plex. Plex's views are still asked for only on its page, and a module's manage modal still closes when its page is left. The feed's URL-override check applies only on the feed page, where its filter params live. The Channels store's tab type moves from the page into the store's context.
This commit is contained in:
@@ -58,7 +58,12 @@ import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems";
|
||||
import { optionsFromColumns } from "../../lib/sortOptions";
|
||||
import ChannelDiscovery from "./ChannelDiscovery";
|
||||
import TagManager from "../../features/channel/TagManager";
|
||||
import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext";
|
||||
import {
|
||||
useChannelLayout,
|
||||
useChannels,
|
||||
useChannelsActions,
|
||||
type ChannelsView,
|
||||
} from "./channelsContext";
|
||||
import { useNavigationActions } from "../../features/navigation/navigationContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "../feed";
|
||||
import { useWizard } from "../../features/wizard/wizardContext";
|
||||
@@ -66,8 +71,6 @@ import { useMe } from "../../lib/useMe";
|
||||
import { useTags } from "../../lib/useTags";
|
||||
import ChannelsHeader from "./ChannelsHeader";
|
||||
|
||||
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
|
||||
|
||||
// The tag filter has its own chip row above the list, so the hidden-filter cue does not name it again.
|
||||
const SURFACED_FILTERS = ["tags"];
|
||||
|
||||
|
||||
@@ -24,13 +24,13 @@ import {
|
||||
} from "../../lib/channelsUrlState";
|
||||
import type { FilterMap } from "../../lib/tableColumns";
|
||||
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
|
||||
import type { ChannelsView } from "./Channels";
|
||||
import {
|
||||
ChannelLayoutContext,
|
||||
ChannelsActionsContext,
|
||||
ChannelsStateContext,
|
||||
type ChannelsActions,
|
||||
type ChannelsState,
|
||||
type ChannelsView,
|
||||
} from "./channelsContext";
|
||||
|
||||
// Owns the Channels module's shared local state — the rail-less manager's name filter, column-filter
|
||||
|
||||
@@ -2,11 +2,13 @@ import { createContext, useContext } from "react";
|
||||
import type { ChannelLayout } from "../../lib/channelLayout";
|
||||
import type { SortState } from "../../lib/columnSort";
|
||||
import type { FilterMap } from "../../lib/tableColumns";
|
||||
import type { ChannelsView } from "./Channels";
|
||||
|
||||
// The Channels module's contexts + hooks, split out of ChannelsProvider so that file exports only
|
||||
// components (Fast Refresh). The provider owns the state; this is the read side every consumer imports.
|
||||
|
||||
// The manager's tabs.
|
||||
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
|
||||
|
||||
export type ChannelsState = {
|
||||
search: string;
|
||||
// The subscribed table's column filters, lifted out of DataTable so the top status chips, the Sync
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// The channel manager's public face: its store, mounted at the root and read app-wide, and the sync
|
||||
// chip, which the feed's header shows too.
|
||||
export { ChannelsProvider } from "./ChannelsProvider";
|
||||
export { useChannelsActions } from "./channelsContext";
|
||||
export { default as SyncStatus } from "./SyncStatus";
|
||||
export { channelsModule } from "./module";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Tv } from "lucide-react";
|
||||
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
import { ChannelsProvider } from "./ChannelsProvider";
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily.
|
||||
export const channelsModule: ModuleDef = {
|
||||
@@ -11,4 +12,5 @@ export const channelsModule: ModuleDef = {
|
||||
icon: Tv,
|
||||
Page: lazyWithPreload(() => import("./Channels")),
|
||||
maxWidth: "96rem",
|
||||
Store: ChannelsProvider,
|
||||
};
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
import { useFeedView, useFeedViewActions } from "./feedViewContext";
|
||||
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
|
||||
import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
|
||||
import { useSavedViews } from "../../features/filters/savedViewsContext";
|
||||
import { SavedViewsScope } from "../../features/filters/savedViewsContext";
|
||||
import { useFeedSavedViews } from "./feedSavedViews";
|
||||
import FeedHeader from "./FeedHeader";
|
||||
import Feed from "./Feed";
|
||||
|
||||
@@ -21,9 +22,10 @@ export default function FeedPage() {
|
||||
// The Personal Views modal lives at the feed-page level (always mounted while on the feed, unmounts
|
||||
// on navigation) rather than inside the collapsible filter rail — otherwise opening it from the
|
||||
// always-visible toolbar view chip while the rail is unpinned would render nothing and latch open.
|
||||
const { modalOpen } = useSavedViews();
|
||||
// The feed's saved views reach the shared views UI under this page (the chip, the modal) by the scope.
|
||||
const savedViews = useFeedSavedViews();
|
||||
return (
|
||||
<>
|
||||
<SavedViewsScope value={savedViews}>
|
||||
<FeedHeader />
|
||||
<Feed
|
||||
filters={filters}
|
||||
@@ -35,7 +37,7 @@ export default function FeedPage() {
|
||||
onYtSearch={enterYtSearch}
|
||||
onExitYtSearch={exitYtSearch}
|
||||
/>
|
||||
{modalOpen && <PersonalViewsModal />}
|
||||
</>
|
||||
{savedViews.modalOpen && <PersonalViewsModal />}
|
||||
</SavedViewsScope>
|
||||
);
|
||||
}
|
||||
|
||||
+95
-72
@@ -1,19 +1,34 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The single default-application path. On the saved-views query's FIRST resolve, SavedViewsProvider
|
||||
// seeds the starred default view's filters into a genuinely fresh module (no stored filters), for BOTH
|
||||
// the feed and Plex — but leaves them alone once the account has its own stored filters. This is the
|
||||
// fix for the old feed-only LS mirror that raced FeedFiltersProvider's login effect and never applied a
|
||||
// Plex default at all. The sub-providers are mocked so we can assert exactly which setFilters is called;
|
||||
// api.savedViews is stubbed and driven through a real QueryClient.
|
||||
// The feed's saved views. The single default-application path: on the views query's FIRST resolve, the
|
||||
// starred default view's filters seed a genuinely fresh feed (no stored filters, no URL filter override)
|
||||
// — and are left alone once the account has its own stored filters. This is the fix for the old
|
||||
// feed-only LS mirror that raced FeedFiltersProvider's login effect. And the store is the feed's own: a
|
||||
// visit to another page leaves its active view where it was. The feed filters are mocked so we can
|
||||
// assert exactly which setFilters is called; api.savedViews is stubbed and driven through a real
|
||||
// QueryClient.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const BASE = {
|
||||
tags: [],
|
||||
channelIds: [],
|
||||
tagModes: { language: "or", topic: "or", other: "or" },
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
};
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
page: "feed" as "feed" | "plex",
|
||||
page: "feed" as string,
|
||||
filters: {} as Record<string, unknown>,
|
||||
setFeedFilters: vi.fn(),
|
||||
setPlexFilters: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
@@ -24,31 +39,17 @@ vi.mock("../../features/navigation/navigationContext", () => ({
|
||||
useNavigation: () => ({ page: h.page }),
|
||||
}));
|
||||
vi.mock("./feedFiltersContext", () => ({
|
||||
useFeedFilters: () => ({
|
||||
tags: [],
|
||||
channelIds: [],
|
||||
tagModes: { language: "or", topic: "or", other: "or" },
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
}),
|
||||
useFeedFilters: () => h.filters,
|
||||
useFeedFiltersActions: () => ({ setFilters: h.setFeedFilters }),
|
||||
}));
|
||||
vi.mock("../plex/plexContext", () => ({
|
||||
usePlex: () => ({ filters: {} }),
|
||||
usePlexActions: () => ({ setFilters: h.setPlexFilters }),
|
||||
}));
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
|
||||
import { SavedViewsProvider } from "./SavedViewsProvider";
|
||||
import { useSavedViews } from "../../features/filters/savedViewsContext";
|
||||
import { FeedSavedViewsProvider } from "./FeedSavedViewsProvider";
|
||||
import { useFeedSavedViews } from "./feedSavedViews";
|
||||
import { api, type SavedView } from "../../lib/api";
|
||||
import { claimLocalData, LS } from "../../lib/storage";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import type { SavedViewsContext } from "../../features/filters/savedViewsContext";
|
||||
|
||||
const ID = 7;
|
||||
const view = (over: Partial<SavedView>): SavedView => ({
|
||||
@@ -60,11 +61,17 @@ const view = (over: Partial<SavedView>): SavedView => ({
|
||||
...over,
|
||||
});
|
||||
|
||||
// The store as the last commit left it, for the cases that drive it directly.
|
||||
let latest: SavedViewsContext | null = null;
|
||||
const keep = (store: SavedViewsContext) => {
|
||||
latest = store;
|
||||
};
|
||||
function Probe() {
|
||||
const { loading, views } = useSavedViews();
|
||||
const store = useFeedSavedViews();
|
||||
useEffect(() => keep(store));
|
||||
return (
|
||||
<div data-testid="loading" data-count={views.length}>
|
||||
{String(loading)}
|
||||
<div data-testid="loading" data-count={store.views.length}>
|
||||
{String(store.loading)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -76,41 +83,46 @@ function signedInClient() {
|
||||
return qc;
|
||||
}
|
||||
|
||||
function tree(qc: QueryClient) {
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<FeedSavedViewsProvider>
|
||||
<Probe />
|
||||
</FeedSavedViewsProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
async function mountResolved(views: SavedView[]) {
|
||||
// Fresh array + object copies per call, like a real fetch — so a refetch changes viewsQuery.data's
|
||||
// identity and re-runs the seed effect (which is what makes the per-source guard actually load-bearing
|
||||
// identity and re-runs the seed effect (which is what makes the one-shot guard actually load-bearing
|
||||
// in the re-seed test; a shared reference would skip the effect and hide a missing guard).
|
||||
vi.spyOn(api, "savedViews").mockImplementation(async () => views.map((v) => ({ ...v })));
|
||||
const qc = signedInClient();
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<SavedViewsProvider>
|
||||
<Probe />
|
||||
</SavedViewsProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const r = render(tree(qc));
|
||||
// Wait for the query to resolve so the first-resolve effect has run.
|
||||
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
|
||||
return qc;
|
||||
return { qc, rerender: () => r.rerender(tree(qc)) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
claimLocalData(ID);
|
||||
// Reset the URL: feedCanSeedDefault reads window.location.search, and a stale ?sort= from a prior
|
||||
// case would suppress seeding in the others.
|
||||
// Reset the URL: the seed check reads window.location.search, and a stale ?sort= from a prior case
|
||||
// would suppress seeding in the others.
|
||||
window.history.replaceState({}, "", "/");
|
||||
h.page = "feed";
|
||||
h.filters = { ...BASE };
|
||||
h.setFeedFilters.mockClear();
|
||||
h.setPlexFilters.mockClear();
|
||||
latest = null;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("SavedViewsProvider — default-view seeding on first resolve", () => {
|
||||
it("feed: seeds the starred default into a fresh module (no stored filters)", async () => {
|
||||
describe("the feed's saved views — default-view seeding on first resolve", () => {
|
||||
it("seeds the starred default into a fresh feed (no stored filters)", async () => {
|
||||
await mountResolved([
|
||||
view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }),
|
||||
]);
|
||||
@@ -118,20 +130,20 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => {
|
||||
expect(h.setFeedFilters.mock.calls[0]![0]).toMatchObject({ scope: "all", show: "watched" });
|
||||
});
|
||||
|
||||
it("feed: does NOT seed when the account already has stored filters", async () => {
|
||||
it("does NOT seed when the account already has stored filters", async () => {
|
||||
localStorage.setItem(LS.filters, JSON.stringify({ scope: "my" }));
|
||||
await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]);
|
||||
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("feed: does NOT seed when no view is starred as default", async () => {
|
||||
it("does NOT seed when no view is starred as default", async () => {
|
||||
await mountResolved([view({ id: 2, is_default: false, filters: { scope: "all" } })]);
|
||||
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("feed: does NOT seed when the URL carries filter params (a shared/bookmarked view wins)", async () => {
|
||||
it("does NOT seed when the feed's URL carries filter params (a shared/bookmarked view wins)", async () => {
|
||||
// A link like ?sort=oldest is an explicit filter the user arrived on; the starred default must not
|
||||
// stomp it. feedCanSeedDefault() bails on hasFilterParams — plex ignores the URL, so this is feed-only.
|
||||
// stomp it.
|
||||
window.history.replaceState({}, "", "/?sort=oldest");
|
||||
await mountResolved([
|
||||
view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }),
|
||||
@@ -139,7 +151,16 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => {
|
||||
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT re-seed on a background refetch that returns a changed list (per-source one-shot guard)", async () => {
|
||||
it("seeds when another page's URL carries those params: they are that page's, not a feed link", async () => {
|
||||
// Booting onto Plex with its ?sort= used to hold the feed's default back: the feed's views were
|
||||
// only asked for once you left Plex, by when the URL was the feed's own.
|
||||
h.page = "plex";
|
||||
window.history.replaceState({}, "", "/?page=plex&sort=oldest");
|
||||
await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]);
|
||||
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT re-seed on a background refetch that returns a changed list (one-shot guard)", async () => {
|
||||
// The 60s refetch re-resolves the query; when it returns a CHANGED list (e.g. the user just added a
|
||||
// view elsewhere) react-query hands down a new reference and the seed effect re-runs. seededRef must
|
||||
// stop it re-applying the default over filters the user has since edited. (An identical refetch is
|
||||
@@ -151,13 +172,7 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => {
|
||||
return calls === 1 ? [def] : [def, view({ id: 9, name: "added-later" })];
|
||||
});
|
||||
const qc = signedInClient();
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<SavedViewsProvider>
|
||||
<Probe />
|
||||
</SavedViewsProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
render(tree(qc));
|
||||
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
|
||||
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
|
||||
await act(async () => {
|
||||
@@ -168,34 +183,42 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => {
|
||||
await waitFor(() => expect(screen.getByTestId("loading").dataset.count).toBe("2"));
|
||||
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("plex: seeds the starred default into a fresh module (parity with the feed)", async () => {
|
||||
describe("the feed's saved views — a visit to another page", () => {
|
||||
it("keeps the view you applied, edited since, when you come back from Plex", async () => {
|
||||
const { rerender } = await mountResolved([view({ id: 4, name: "mine", filters: { ...BASE } })]);
|
||||
act(() => latest!.apply(latest!.views[0]!));
|
||||
h.filters = { ...BASE, show: "all" }; // an edit since: the view is now shown as changed
|
||||
rerender();
|
||||
expect(latest!.displayView?.id).toBe(4);
|
||||
expect(latest!.isDirty).toBe(true);
|
||||
h.page = "plex";
|
||||
await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
|
||||
expect(h.setPlexFilters).toHaveBeenCalledTimes(1);
|
||||
expect(h.setPlexFilters.mock.calls[0]![0]).toMatchObject({ genres: ["Action"] });
|
||||
rerender();
|
||||
h.page = "feed";
|
||||
rerender();
|
||||
expect(latest!.displayView?.id).toBe(4);
|
||||
expect(latest!.isDirty).toBe(true);
|
||||
});
|
||||
|
||||
it("plex: does NOT seed when plex filters are already stored", async () => {
|
||||
h.page = "plex";
|
||||
localStorage.setItem(LS.plexFilters, JSON.stringify({ genres: ["Comedy"] }));
|
||||
await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
|
||||
expect(h.setPlexFilters).not.toHaveBeenCalled();
|
||||
it("closes its manage modal when you leave the feed, so it does not reopen on the way back", async () => {
|
||||
const { rerender } = await mountResolved([]);
|
||||
act(() => latest!.openModal());
|
||||
expect(latest!.modalOpen).toBe(true);
|
||||
h.page = "home";
|
||||
rerender();
|
||||
h.page = "feed";
|
||||
rerender();
|
||||
expect(latest!.modalOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SavedViewsProvider — signed-out tab", () => {
|
||||
describe("the feed's saved views — signed-out tab", () => {
|
||||
it("does NOT ask for saved views while no account is signed in (the landing logged a 401)", async () => {
|
||||
const spy = vi.spyOn(api, "savedViews").mockResolvedValue([]);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), null); // BootGate's "nobody"
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<SavedViewsProvider>
|
||||
<Probe />
|
||||
</SavedViewsProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
render(tree(qc));
|
||||
// Let any fetch the mount would start get going before asserting it never happened.
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import type { FeedFilters } from "../../lib/api";
|
||||
import { filtersToParams, hasFilterParams } from "../../lib/urlState";
|
||||
import { LS } from "../../lib/storage";
|
||||
import { useNavigation } from "../../features/navigation/navigationContext";
|
||||
import { asFilters } from "../../features/filters/savedViewsContext";
|
||||
import {
|
||||
storedFiltersPresent,
|
||||
useSavedViewsStore,
|
||||
type SavedViewsConfig,
|
||||
} from "../../features/filters/useSavedViewsStore";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
import { FeedSavedViewsContextProvider } from "./feedSavedViews";
|
||||
|
||||
const keyOf = (f: Record<string, unknown>) =>
|
||||
filtersToParams(asFilters(f as unknown as FeedFilters)).toString();
|
||||
|
||||
// The feed's saved views, over the feed's live filters (useSavedViewsStore holds the logic).
|
||||
export function FeedSavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
const { page } = useNavigation();
|
||||
const filters = useFeedFilters();
|
||||
const { setFilters } = useFeedFiltersActions();
|
||||
const onScreen = page === "feed";
|
||||
const cfg: SavedViewsConfig = useMemo(
|
||||
() => ({
|
||||
source: "feed",
|
||||
filters: filters as unknown as Record<string, unknown>,
|
||||
setFilters: (f) => setFilters(f as unknown as FeedFilters),
|
||||
keyOf,
|
||||
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;
|
||||
},
|
||||
// The feed also yields to a URL that carries filter params (a shared/bookmarked view) — which
|
||||
// only the feed's own page can carry: on any other page those params are that page's.
|
||||
canSeedDefault: () =>
|
||||
!(onScreen && hasFilterParams(new URLSearchParams(window.location.search))) &&
|
||||
!storedFiltersPresent(LS.filters),
|
||||
enabled: true,
|
||||
onScreen,
|
||||
}),
|
||||
[filters, setFilters, onScreen],
|
||||
);
|
||||
const value = useSavedViewsStore(cfg);
|
||||
return <FeedSavedViewsContextProvider value={value}>{children}</FeedSavedViewsContextProvider>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { FeedFiltersProvider } from "./FeedFiltersProvider";
|
||||
import { FeedViewProvider } from "./FeedViewProvider";
|
||||
import { FeedSavedViewsProvider } from "./FeedSavedViewsProvider";
|
||||
|
||||
// The feed module's store, mounted at the root (features/navigation/ModuleStores): its filters, its view
|
||||
// (the card or row layout) and its saved views over those filters.
|
||||
export function FeedStore({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<FeedFiltersProvider>
|
||||
<FeedViewProvider>
|
||||
<FeedSavedViewsProvider>{children}</FeedSavedViewsProvider>
|
||||
</FeedViewProvider>
|
||||
</FeedFiltersProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
import { useEffect, useEffectEvent, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import {
|
||||
api,
|
||||
coercePlexFilters,
|
||||
plexFiltersSignature,
|
||||
type FeedFilters,
|
||||
type PlexFilters,
|
||||
type SavedView,
|
||||
} from "../../lib/api";
|
||||
import { filtersToParams, hasFilterParams } from "../../lib/urlState";
|
||||
import { pickDefaultToSeed, preferSticky } from "../../lib/savedViews";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { LS, ownKey } from "../../lib/storage";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { useNavigation } from "../../features/navigation/navigationContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
import { usePlex, usePlexActions } from "../plex";
|
||||
import {
|
||||
asFilters,
|
||||
SavedViewsCtx,
|
||||
type SavedViewsContext,
|
||||
type ViewSource,
|
||||
} from "../../features/filters/savedViewsContext";
|
||||
|
||||
// Saved "smart views" now serve TWO modules — the feed and Plex — keyed by `source`. This one provider
|
||||
// switches which module it operates on by the active page (both filter providers are global, so it can
|
||||
// read either), so the shared UI (popover / modal / chip) works on the feed page against feed views and
|
||||
// on the plex page against plex views without a second provider or an App-layout change.
|
||||
//
|
||||
// The context, its value type and the hook live in savedViewsContext.ts.
|
||||
|
||||
// The per-source wiring the generic logic runs on: the live filters, how to apply a view's, a stable
|
||||
// signature for matching, coercion, and what to strip before saving (the feed's transient shuffle seed).
|
||||
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>;
|
||||
/** Whether this module is a genuinely fresh start the starred default may seed on first load: no
|
||||
* stored filters for the active account, and — feed only — no URL filter override. */
|
||||
canSeedDefault: () => boolean;
|
||||
}
|
||||
|
||||
const feedKeyOf = (f: Record<string, unknown>) =>
|
||||
filtersToParams(asFilters(f as unknown as FeedFilters)).toString();
|
||||
const plexKeyOf = (f: Record<string, unknown>) => plexFiltersSignature(coercePlexFilters(f));
|
||||
|
||||
// True once this account has its own persisted filters for `lsKey` — the signal that the last
|
||||
// view / manual tweaks (not the default) should win. Account unknown or localStorage unavailable →
|
||||
// report "stored" so a default never seeds blind over an indeterminate state.
|
||||
function storedFiltersPresent(lsKey: string): boolean {
|
||||
const key = ownKey(lsKey);
|
||||
if (!key) return true;
|
||||
try {
|
||||
return localStorage.getItem(key) != null;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Feed also yields to a URL that carries filter params (a shared/bookmarked view).
|
||||
function feedCanSeedDefault(): boolean {
|
||||
if (hasFilterParams(new URLSearchParams(window.location.search))) return false;
|
||||
return !storedFiltersPresent(LS.filters);
|
||||
}
|
||||
// Plex views are facets-only, so LS.plexFilters is the "has the user set this module's filters" key.
|
||||
function plexCanSeedDefault(): boolean {
|
||||
return !storedFiltersPresent(LS.plexFilters);
|
||||
}
|
||||
|
||||
export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const { page } = useNavigation();
|
||||
|
||||
// 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
|
||||
canSeedDefault: plexCanSeedDefault,
|
||||
};
|
||||
}
|
||||
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;
|
||||
},
|
||||
canSeedDefault: feedCanSeedDefault,
|
||||
};
|
||||
}, [page, feedFilters, plexFilters, setFeedFilters, setPlexFilters]);
|
||||
|
||||
const source = cfg.source;
|
||||
// Passive observer of the account BootGate fetches: the providers mount for a signed-out tab too
|
||||
// (the public landing), and saved views are per-account, so asking for "nobody's" views only logs
|
||||
// a 401 on the landing page. Enabled once a signed-in account is cached.
|
||||
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
||||
const viewsQuery = useQuery({
|
||||
queryKey: qk.savedViews(source),
|
||||
queryFn: () => api.savedViews(source),
|
||||
enabled: !!me,
|
||||
});
|
||||
const views = useMemo(() => viewsQuery.data ?? [], [viewsQuery.data]);
|
||||
|
||||
const [stickyId, setStickyId] = useState<number | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
// 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. Adjusted DURING RENDER against the previous
|
||||
// source rather than from an effect, so the first plex render never commits carrying the feed's
|
||||
// sticky id — which is exactly long enough for a stale view chip to paint.
|
||||
const [prevSource, setPrevSource] = useState(source);
|
||||
if (prevSource !== source) {
|
||||
setPrevSource(source);
|
||||
setStickyId(null);
|
||||
setModalOpen(false);
|
||||
}
|
||||
|
||||
// The ONE default-application path for both modules. On the views query's FIRST resolve for a
|
||||
// source, seed the starred default view's filters — but only into a genuinely fresh module (no
|
||||
// stored filters, no URL override; see cfg.canSeedDefault). Applying via cfg.setFilters persists
|
||||
// it, so it happens once and the normal last-view-wins path takes over, exactly as the storage
|
||||
// semantics document. This replaced the feed-only localStorage mirror that raced FeedFiltersProvider's
|
||||
// login effect (on a fresh browser the feed could seed DEFAULT_FILTERS and never re-apply until a
|
||||
// reload) — and it gives Plex the default it previously never applied at all. Per-source guard so a
|
||||
// 60s background refetch never re-seeds over the filters you're editing. canSeedDefault() reads the
|
||||
// account's stored-filters key, which is always readable here: BootGate hands the browser's local
|
||||
// state to the account (claimLocalData) before this provider mounts.
|
||||
const seededRef = useRef<Set<ViewSource>>(new Set());
|
||||
// `cfg` re-memoizes on every filter edit, so it is read through an effect event: the seed must
|
||||
// fire on the views query's first resolve for a source and never again, which `seededRef`
|
||||
// enforces. Listing cfg would re-arm nothing (the guard still holds) but would re-run the effect
|
||||
// on every keystroke in the filter bar.
|
||||
const latestCfg = useEffectEvent(() => cfg);
|
||||
useEffect(() => {
|
||||
const data = viewsQuery.data;
|
||||
if (!data || seededRef.current.has(source)) return;
|
||||
seededRef.current.add(source);
|
||||
const c = latestCfg();
|
||||
const def = pickDefaultToSeed(data, c.canSeedDefault());
|
||||
// The name follows by itself: `preferSticky` picks the starred default among same-filter views.
|
||||
if (def) c.setFilters(c.asFilters(def.filters));
|
||||
}, [viewsQuery.data, source]);
|
||||
|
||||
const currentKey = cfg.keyOf(cfg.filters);
|
||||
const activeView = useMemo(
|
||||
() =>
|
||||
preferSticky(
|
||||
views.filter((v) => cfg.keyOf(v.filters) === currentKey),
|
||||
stickyId,
|
||||
),
|
||||
[views, currentKey, stickyId, cfg],
|
||||
);
|
||||
// The sticky id follows whichever view is active — also adjusted during render. It converges by
|
||||
// construction: preferSticky returns the sticky match when there is one, so the very write below
|
||||
// makes its own condition false on the next pass.
|
||||
if (activeView && activeView.id !== stickyId) setStickyId(activeView.id);
|
||||
|
||||
const stickyView = useMemo(
|
||||
() => (stickyId == null ? null : (views.find((v) => v.id === stickyId) ?? null)),
|
||||
[views, stickyId],
|
||||
);
|
||||
const displayView = activeView ?? stickyView;
|
||||
const isDirty = displayView != null && activeView == null;
|
||||
|
||||
const refresh = () => void qc.invalidateQueries({ queryKey: qk.savedViews(source) });
|
||||
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> {
|
||||
let ok = true;
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
notifyActionError(e, failMsg);
|
||||
ok = false;
|
||||
}
|
||||
refresh();
|
||||
return ok;
|
||||
}
|
||||
|
||||
const value: SavedViewsContext = {
|
||||
source,
|
||||
views,
|
||||
loading: viewsQuery.isLoading,
|
||||
activeViewId: activeView?.id ?? null,
|
||||
displayView,
|
||||
isDirty,
|
||||
modalOpen,
|
||||
openModal: () => setModalOpen(true),
|
||||
closeModal: () => setModalOpen(false),
|
||||
apply: (v) => {
|
||||
cfg.setFilters(cfg.asFilters(v.filters));
|
||||
setStickyId(v.id);
|
||||
},
|
||||
clearActive: () => setStickyId(null),
|
||||
create: async (name) => {
|
||||
const clean = name.trim();
|
||||
if (!clean) return false;
|
||||
return runViewMutation(
|
||||
() => api.createView(clean, cfg.stripForSave(cfg.filters), source),
|
||||
t("views.actionFailed"),
|
||||
);
|
||||
},
|
||||
rename: async (id, name) => {
|
||||
const next = name.trim();
|
||||
if (!next) return false;
|
||||
return runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed"));
|
||||
},
|
||||
updateFilters: (id, filters) =>
|
||||
runViewMutation(() => api.updateView(id, { filters }), t("views.actionFailed")),
|
||||
saveCurrent: async (id) => {
|
||||
const filters = cfg.stripForSave(cfg.filters);
|
||||
const ok = await runViewMutation(
|
||||
() => api.updateView(id, { filters }),
|
||||
t("views.actionFailed"),
|
||||
);
|
||||
return ok ? filters : null;
|
||||
},
|
||||
toggleDefault: (v) =>
|
||||
runViewMutation(
|
||||
() => api.updateView(v.id, { is_default: !v.is_default }),
|
||||
t("views.actionFailed"),
|
||||
).then(() => undefined),
|
||||
remove: async (v) => {
|
||||
const ok = await confirm({
|
||||
message: t("views.deleteConfirm", { name: v.name }),
|
||||
confirmLabel: t("views.delete"),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return false;
|
||||
if (stickyId === v.id) setStickyId(null);
|
||||
return runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
|
||||
},
|
||||
reorder: async (nextIds) => {
|
||||
qc.setQueryData<SavedView[]>(
|
||||
qk.savedViews(source),
|
||||
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean),
|
||||
);
|
||||
await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed"));
|
||||
},
|
||||
};
|
||||
|
||||
return <SavedViewsCtx.Provider value={value}>{children}</SavedViewsCtx.Provider>;
|
||||
}
|
||||
@@ -9,7 +9,8 @@ import { useDebounced } from "../../lib/useDebounced";
|
||||
import { useActiveFilters } from "../../lib/useActiveFilters";
|
||||
import TagManager from "../../features/channel/TagManager";
|
||||
import SavedViewsPopover from "../../features/filters/SavedViewsPopover";
|
||||
import { useSavedViews } from "../../features/filters/savedViewsContext";
|
||||
import { SavedViewsScope } from "../../features/filters/savedViewsContext";
|
||||
import { useFeedSavedViews } from "./feedSavedViews";
|
||||
import FilterRail from "../../components/ui/FilterRail";
|
||||
import { type PanelItem } from "../../components/ui/PanelGroups";
|
||||
import { type MediaSourceKind } from "../../lib/mediaSource";
|
||||
@@ -43,7 +44,8 @@ export default function Sidebar() {
|
||||
const { layout, setLayout, collapsed, onToggleCollapse } = useRail("feed");
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { openModal } = useSavedViews();
|
||||
// The feed's saved views, handed to the shared views UI in this rail through the scope below.
|
||||
const savedViews = useFeedSavedViews();
|
||||
const filters = useFeedFilters();
|
||||
const { setFilters } = useFeedFiltersActions();
|
||||
// "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider.
|
||||
@@ -223,31 +225,33 @@ export default function Sidebar() {
|
||||
}));
|
||||
|
||||
return (
|
||||
<FilterRail
|
||||
kind={source}
|
||||
title={t("sidebar.filters")}
|
||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||
count={activeCount}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
layout={layout}
|
||||
setLayout={setLayout}
|
||||
items={items}
|
||||
// The feed widgets are simple label + control rows, not collapsible islands.
|
||||
collapsible={false}
|
||||
// Saved ("personal") views: the header icon opens a quick-pick popover, the title opens the
|
||||
// full manager modal. Feed rail only.
|
||||
iconExpanded={
|
||||
source === "feed" ? (
|
||||
<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />
|
||||
) : undefined
|
||||
}
|
||||
onTitleClick={source === "feed" ? openModal : undefined}
|
||||
titleActionLabel={source === "feed" ? t("views.manageTitle") : undefined}
|
||||
>
|
||||
{tagManagerOpen && (
|
||||
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
||||
)}
|
||||
</FilterRail>
|
||||
<SavedViewsScope value={savedViews}>
|
||||
<FilterRail
|
||||
kind={source}
|
||||
title={t("sidebar.filters")}
|
||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||
count={activeCount}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
layout={layout}
|
||||
setLayout={setLayout}
|
||||
items={items}
|
||||
// The feed widgets are simple label + control rows, not collapsible islands.
|
||||
collapsible={false}
|
||||
// Saved ("personal") views: the header icon opens a quick-pick popover, the title opens the
|
||||
// full manager modal. Feed rail only.
|
||||
iconExpanded={
|
||||
source === "feed" ? (
|
||||
<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />
|
||||
) : undefined
|
||||
}
|
||||
onTitleClick={source === "feed" ? savedViews.openModal : undefined}
|
||||
titleActionLabel={source === "feed" ? t("views.manageTitle") : undefined}
|
||||
>
|
||||
{tagManagerOpen && (
|
||||
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
||||
)}
|
||||
</FilterRail>
|
||||
</SavedViewsScope>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export function loadAccountFilters(): FeedFilters {
|
||||
// Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you
|
||||
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. A starred
|
||||
// default view seeds a fresh account that has never stored filters, but that is applied ONCE by
|
||||
// SavedViewsProvider on its views query's first resolve (which then persists here like any edit) —
|
||||
// FeedSavedViewsProvider on its views query's first resolve (which then persists here like any edit) —
|
||||
// not read from a mirror on this synchronous path, which used to race that resolve. So here we only
|
||||
// ever read the account's own stored filters, else fall back to DEFAULT_FILTERS.
|
||||
const fKey = ownKey(FILTERS_KEY);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createSavedViewsStore } from "../../features/filters/savedViewsContext";
|
||||
|
||||
// The feed's saved views: filled by FeedSavedViewsProvider, read by the feed's page and rail (which hand
|
||||
// them to the shared views UI) and by the palette.
|
||||
const store = createSavedViewsStore("Feed");
|
||||
export const FeedSavedViewsContextProvider = store.Provider;
|
||||
export const useFeedSavedViews = store.useViews;
|
||||
@@ -1,7 +1,5 @@
|
||||
// The feed's public face: its stores (filters, view, saved views), mounted at the root and read app-wide.
|
||||
export { FeedFiltersProvider } from "./FeedFiltersProvider";
|
||||
export { FeedViewProvider } from "./FeedViewProvider";
|
||||
export { SavedViewsProvider } from "./SavedViewsProvider";
|
||||
export { useFeedSavedViews } from "./feedSavedViews";
|
||||
export { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
export { useFeedView, useFeedViewActions } from "./feedViewContext";
|
||||
export { ChannelPage, feedModule } from "./module";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { YouTubeIcon } from "../../lib/brandIcons";
|
||||
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
import { FeedStore } from "./FeedStore";
|
||||
|
||||
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
|
||||
const Rail = lazyWithPreload(() => import("./Sidebar"));
|
||||
@@ -13,6 +14,7 @@ export const feedModule: ModuleDef = {
|
||||
icon: YouTubeIcon,
|
||||
Rail,
|
||||
Page: lazyWithPreloadAlongside(() => import("./FeedPage"), Rail),
|
||||
Store: FeedStore,
|
||||
};
|
||||
|
||||
// The channel page: a feed scoped to one channel, opened over any page (App renders it in place of the
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// The Files page's public face: its store, mounted at the root, and how a folder is named and pictured.
|
||||
export { FilesProvider } from "./FilesProvider";
|
||||
export { useFilesActions } from "./filesContext";
|
||||
export { folderIcon, folderLabel, sourceLabel, takesUploads } from "./fileFolders";
|
||||
export { filesModule } from "./module";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
import { FilesProvider } from "./FilesProvider";
|
||||
|
||||
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
|
||||
const Rail = lazyWithPreload(() => import("./FilesRail"));
|
||||
@@ -14,4 +15,5 @@ export const filesModule: ModuleDef = {
|
||||
Rail,
|
||||
Page: lazyWithPreloadAlongside(() => import("./Files"), Rail),
|
||||
maxWidth: "80rem",
|
||||
Store: FilesProvider,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// The playlists page's public face: its store, mounted at the root and read app-wide.
|
||||
export { PlaylistsProvider } from "./PlaylistsProvider";
|
||||
export { usePlaylistsActions } from "./playlistsContext";
|
||||
export { playlistsModule } from "./module";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ListVideo } from "lucide-react";
|
||||
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
import { PlaylistsProvider } from "./PlaylistsProvider";
|
||||
|
||||
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
|
||||
const Rail = lazyWithPreload(() => import("./PlaylistsRail"));
|
||||
@@ -14,4 +15,5 @@ export const playlistsModule: ModuleDef = {
|
||||
Rail,
|
||||
Page: lazyWithPreloadAlongside(() => import("./Playlists"), Rail),
|
||||
maxWidth: "64rem",
|
||||
Store: PlaylistsProvider,
|
||||
};
|
||||
|
||||
@@ -24,6 +24,12 @@ vi.mock("./plexViews", async (importOriginal) => ({
|
||||
PlexInfoView: ({ id }: { id: string }) => <div>info page {id}</div>,
|
||||
}));
|
||||
|
||||
// The page hands Plex's saved views to its chip row; none are saved here.
|
||||
vi.mock("./plexSavedViews", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./plexSavedViews")>()),
|
||||
usePlexSavedViews: () => ({ views: [], displayView: null, isDirty: false, activeViewId: null }),
|
||||
}));
|
||||
|
||||
import PlexBrowse from "./PlexBrowse";
|
||||
import { api, type PlexCard } from "../../lib/api";
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ import { menuGroups } from "../../components/ui/menuGroups";
|
||||
import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems";
|
||||
import ActiveFilterChips from "../../features/filters/ActiveFilterChips";
|
||||
import SelectedViewChip from "../../features/filters/SelectedViewChip";
|
||||
import { SavedViewsScope } from "../../features/filters/savedViewsContext";
|
||||
import { usePlexSavedViews } from "./plexSavedViews";
|
||||
import ItemCount from "../../components/ui/ItemCount";
|
||||
import { FilterFoldButton } from "../../components/ui/FilterFold";
|
||||
import { useFilterFold } from "../../lib/useFilterFold";
|
||||
@@ -101,11 +103,13 @@ export default function PlexBrowse() {
|
||||
// history, and entering the plex page mounts this anew, so each entry reseeds it (a page switch
|
||||
// clears ?v and drops _sub: every plex entry starts at the Discover root).
|
||||
const sub = useHistorySubview<Sub>({ kind: "discover" }, PLEX_SUB_URL);
|
||||
// Plex's saved views reach the shared views UI under this page (the chip) by the scope.
|
||||
const savedViews = usePlexSavedViews();
|
||||
return (
|
||||
<>
|
||||
<SavedViewsScope value={savedViews}>
|
||||
<PlexHeader view={{ kind: sub.view.kind, open: sub.open }} />
|
||||
<PlexBrowseView sub={sub} />
|
||||
</>
|
||||
</SavedViewsScope>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// A shared Plex link is persisted to the account, but only the dimensions it CARRIES. The bug it
|
||||
// replaced: a link with only a scope also wrote the (empty) default filters, and a stored Plex filters
|
||||
// value at all is what tells SavedViewsProvider the account has picked filters — so the account's
|
||||
// value at all is what tells PlexSavedViewsProvider the account has picked filters — so the account's
|
||||
// default saved view was never applied again.
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
|
||||
@@ -142,7 +142,7 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
// Persist a shared link's view to the account once, so it wins over the stored view and survives a
|
||||
// later plain reload. Mirrors FeedFiltersProvider's initialHadUrlFilters branch. Only the dimensions
|
||||
// the link CARRIED are written: an omitted one already reads from storage, and writing its default
|
||||
// would not be a no-op — a stored Plex filters value at all is what tells SavedViewsProvider the
|
||||
// would not be a no-op — a stored Plex filters value at all is what tells PlexSavedViewsProvider the
|
||||
// account has picked filters, so the account's default saved view would never be applied again.
|
||||
useEffect(() => {
|
||||
if (!urlSeed) return;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Plex's saved views: the same default-view seed as the feed's (parity — Plex once never applied its
|
||||
// default at all), asked for only while the Plex page is on screen. Plex's filters are mocked so we can
|
||||
// assert exactly which setFilters is called; api.savedViews is stubbed and driven through a real
|
||||
// QueryClient.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const h = vi.hoisted(() => ({ page: "plex" as string, setPlexFilters: vi.fn() }));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
vi.mock("../../features/navigation/navigationContext", () => ({
|
||||
useNavigation: () => ({ page: h.page }),
|
||||
}));
|
||||
vi.mock("./plexContext", () => ({
|
||||
usePlex: () => ({ filters: {} }),
|
||||
usePlexActions: () => ({ setFilters: h.setPlexFilters }),
|
||||
}));
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
|
||||
import { PlexSavedViewsProvider } from "./PlexSavedViewsProvider";
|
||||
import { usePlexSavedViews } from "./plexSavedViews";
|
||||
import { api, type SavedView } from "../../lib/api";
|
||||
import { claimLocalData, LS } from "../../lib/storage";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
const ID = 7;
|
||||
const view = (over: Partial<SavedView>): SavedView => ({
|
||||
id: 1,
|
||||
name: "v",
|
||||
filters: {},
|
||||
position: 0,
|
||||
is_default: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
function Probe() {
|
||||
const { loading } = usePlexSavedViews();
|
||||
return <div data-testid="loading">{String(loading)}</div>;
|
||||
}
|
||||
|
||||
function mount(views: SavedView[]) {
|
||||
const spy = vi
|
||||
.spyOn(api, "savedViews")
|
||||
.mockImplementation(async () => views.map((v) => ({ ...v })));
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: ID, preferences: {} });
|
||||
const tree = () => (
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexSavedViewsProvider>
|
||||
<Probe />
|
||||
</PlexSavedViewsProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const r = render(tree());
|
||||
return { spy, rerender: () => r.rerender(tree()) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
claimLocalData(ID);
|
||||
h.page = "plex";
|
||||
h.setPlexFilters.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Plex's saved views", () => {
|
||||
it("seeds the starred default into a fresh Plex (parity with the feed)", async () => {
|
||||
mount([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
|
||||
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
|
||||
expect(h.setPlexFilters).toHaveBeenCalledTimes(1);
|
||||
expect(h.setPlexFilters.mock.calls[0]![0]).toMatchObject({ genres: ["Action"] });
|
||||
});
|
||||
|
||||
it("does NOT seed when Plex filters are already stored", async () => {
|
||||
localStorage.setItem(LS.plexFilters, JSON.stringify({ genres: ["Comedy"] }));
|
||||
mount([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
|
||||
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
|
||||
expect(h.setPlexFilters).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks for nothing until the Plex page is on screen", async () => {
|
||||
h.page = "feed";
|
||||
const { spy, rerender } = mount([]);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
});
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
h.page = "plex";
|
||||
rerender();
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith("plex"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { coercePlexFilters, plexFiltersSignature, type PlexFilters } from "../../lib/api";
|
||||
import { LS } from "../../lib/storage";
|
||||
import { useNavigation } from "../../features/navigation/navigationContext";
|
||||
import {
|
||||
storedFiltersPresent,
|
||||
useSavedViewsStore,
|
||||
type SavedViewsConfig,
|
||||
} from "../../features/filters/useSavedViewsStore";
|
||||
import { usePlex, usePlexActions } from "./plexContext";
|
||||
import { PlexSavedViewsContextProvider } from "./plexSavedViews";
|
||||
|
||||
const keyOf = (f: Record<string, unknown>) => plexFiltersSignature(coercePlexFilters(f));
|
||||
const asFilters = (raw: Record<string, unknown>) =>
|
||||
coercePlexFilters(raw) as unknown as Record<string, unknown>;
|
||||
// Plex has no transient field to drop before saving.
|
||||
const stripForSave = (f: Record<string, unknown>) => f;
|
||||
// Plex views are facets-only, so LS.plexFilters is the "has the user set this module's filters" key.
|
||||
const canSeedDefault = () => !storedFiltersPresent(LS.plexFilters);
|
||||
|
||||
// Plex's saved views, over Plex's live filters (useSavedViewsStore holds the logic). Asked for only while
|
||||
// the Plex page is on screen, so an account that never opens Plex never fetches them, and the starred
|
||||
// default seeds on the first visit.
|
||||
export function PlexSavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
const { page } = useNavigation();
|
||||
const { filters } = usePlex();
|
||||
const { setFilters } = usePlexActions();
|
||||
const onScreen = page === "plex";
|
||||
const cfg: SavedViewsConfig = useMemo(
|
||||
() => ({
|
||||
source: "plex",
|
||||
filters: filters as unknown as Record<string, unknown>,
|
||||
setFilters: (f) => setFilters(f as unknown as PlexFilters),
|
||||
keyOf,
|
||||
asFilters,
|
||||
stripForSave,
|
||||
canSeedDefault,
|
||||
enabled: onScreen,
|
||||
onScreen,
|
||||
}),
|
||||
[filters, setFilters, onScreen],
|
||||
);
|
||||
const value = useSavedViewsStore(cfg);
|
||||
return <PlexSavedViewsContextProvider value={value}>{children}</PlexSavedViewsContextProvider>;
|
||||
}
|
||||
@@ -17,7 +17,8 @@ import FilterChipGroup from "../../components/ui/FilterChipGroup";
|
||||
import Combobox, { type ComboboxOption } from "../../components/ui/Combobox";
|
||||
import SavedViewsPopover from "../../features/filters/SavedViewsPopover";
|
||||
import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
|
||||
import { useSavedViews } from "../../features/filters/savedViewsContext";
|
||||
import { SavedViewsScope } from "../../features/filters/savedViewsContext";
|
||||
import { usePlexSavedViews } from "./plexSavedViews";
|
||||
import { usePlex, usePlexActions } from "./plexContext";
|
||||
import { DismissibleChip } from "../../components/ui/Chip";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
@@ -47,7 +48,9 @@ export default function PlexSidebar() {
|
||||
const { setFilters } = usePlexActions();
|
||||
// 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).
|
||||
const { openModal, modalOpen } = useSavedViews();
|
||||
// They reach the shared views UI in this rail through the scope below.
|
||||
const savedViews = usePlexSavedViews();
|
||||
const { openModal, modalOpen } = savedViews;
|
||||
// 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.
|
||||
const source: MediaSourceKind = "plex";
|
||||
@@ -293,7 +296,7 @@ export default function PlexSidebar() {
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SavedViewsScope value={savedViews}>
|
||||
<FilterRail
|
||||
kind={source}
|
||||
title={t("sidebar.filters")}
|
||||
@@ -308,14 +311,14 @@ export default function PlexSidebar() {
|
||||
// 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.
|
||||
// Same shared components as the feed rail (SavedViewsProvider switches source).
|
||||
// The same shared components as the feed rail, over Plex's own views.
|
||||
iconExpanded={<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />}
|
||||
onTitleClick={openModal}
|
||||
titleActionLabel={t("views.manageTitle")}
|
||||
/>
|
||||
{/* The manager modal lives here (rail is always mounted on plex); Modal portals to <body>. */}
|
||||
{modalOpen && <PersonalViewsModal />}
|
||||
</>
|
||||
</SavedViewsScope>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { PlexProvider } from "./PlexProvider";
|
||||
import { PlexSavedViewsProvider } from "./PlexSavedViewsProvider";
|
||||
|
||||
// The Plex module's store, mounted at the root (features/navigation/ModuleStores): its browse state, and
|
||||
// its saved views over that state's filters.
|
||||
export function PlexStore({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<PlexProvider>
|
||||
<PlexSavedViewsProvider>{children}</PlexSavedViewsProvider>
|
||||
</PlexProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// The Plex module's public face: its store, mounted at the root and read app-wide, and how its filters read as chips.
|
||||
export { PlexProvider } from "./PlexProvider";
|
||||
export { usePlexSavedViews } from "./plexSavedViews";
|
||||
export { usePlex, usePlexActions } from "./plexContext";
|
||||
export { buildPlexActiveFilters } from "./usePlexActiveFilters";
|
||||
export { plexModule } from "./module";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Clapperboard } from "lucide-react";
|
||||
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
import { PlexStore } from "./PlexStore";
|
||||
|
||||
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
|
||||
const Rail = lazyWithPreload(() => import("./PlexSidebar"));
|
||||
@@ -15,4 +16,5 @@ export const plexModule: ModuleDef = {
|
||||
Rail,
|
||||
Page: lazyWithPreloadAlongside(() => import("./PlexBrowse"), Rail),
|
||||
maxWidth: "100rem",
|
||||
Store: PlexStore,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createSavedViewsStore } from "../../features/filters/savedViewsContext";
|
||||
|
||||
// Plex's saved views: filled by PlexSavedViewsProvider, read by the Plex page and rail (which hand them
|
||||
// to the shared views UI) and by the palette.
|
||||
const store = createSavedViewsStore("Plex");
|
||||
export const PlexSavedViewsContextProvider = store.Provider;
|
||||
export const usePlexSavedViews = store.useViews;
|
||||
Reference in New Issue
Block a user