feat(channels): shareable manager view in the URL — tab+sort+column-filters (S8)
The Channels-manager content state now round-trips through the address bar,
so a manager view is bookmarkable/shareable — the S4 pattern applied to the
last content page the audit flagged.
- New lib/channelsUrlState.ts (channelsParamUpdates / channelsParamsToState /
hasChannelsParams) under NAMESPACED keys ctab/csort/cf — channels-unique
like ?v=/?pl/pscope, so no other provider reads or clears them and the
feed's URL-stripping can't delete them. The mixed scalar/array FilterMap
(sync/priority scalars + tags multi) rides in `cf` as a JSON blob so both
shapes round-trip exactly. Only non-default values emit.
- ChannelsProvider mirrors {view, sort, filters} via useUrlMirror, page-gated
on channels (off-page the keys clear; localStorage still holds the state and
it re-mirrors on return). A shared link wins over stored state: readChannels
UrlSeed captured once, applied before paint through the slice setters (so the
account-pin re-hydrate sees them dirty and won't reload storage over the
shared view), then persisted to the account once pinned so it survives a
later plain reload — mirrors PlexProvider's seed/rehydrate. The name search
stays ephemeral (out of the shared contract).
- NavigationProvider's page-switch nulls ctab/csort/cf alongside the plex keys
so switching modules resets to the manager root.
Tests: channelsUrlState.dom.test.ts (9 round-trip) + ChannelsProvider.dom
seed/mirror (2); the existing tab-history test's beforeEach now resets the
query string too (the mirror writes it). FE vitest 503→514. FE-only.
This commit is contained in:
@@ -27,7 +27,10 @@ const VIEW_KEY = "siftlode.channelsView.1"; // account-scoped (accountKey suffix
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
sessionStorage.setItem("siftlode.activeAccount", "1"); // pin the tab's account
|
sessionStorage.setItem("siftlode.activeAccount", "1"); // pin the tab's account
|
||||||
window.history.replaceState({ sfPage: "channels" }, ""); // setView only owns an entry on this page
|
// Reset BOTH the history state and the query string: setView only owns an entry on the channels page,
|
||||||
|
// and the S8 URL mirror writes ctab/csort/cf — a stale key left by a prior test would be read back by
|
||||||
|
// this provider's mount-time URL seed and change the starting tab.
|
||||||
|
window.history.replaceState({ sfPage: "channels" }, "", "/");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ChannelsProvider tab history", () => {
|
describe("ChannelsProvider tab history", () => {
|
||||||
@@ -71,3 +74,29 @@ describe("ChannelsProvider tab history", () => {
|
|||||||
expect(result.current.view).toBe("hidden"); // unchanged
|
expect(result.current.view).toBe("hidden"); // unchanged
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ChannelsProvider — S8 shareable URL", () => {
|
||||||
|
it("seeds tab + sort + filters from a shared manager link at mount (URL wins over stored)", () => {
|
||||||
|
// Stored view says "blocked"; the shared link says "hidden" + a sort + a filter → the URL wins.
|
||||||
|
localStorage.setItem(VIEW_KEY, "blocked");
|
||||||
|
const cf = encodeURIComponent(JSON.stringify({ sync: "needsFull" }));
|
||||||
|
window.history.replaceState(
|
||||||
|
{ sfPage: "channels" },
|
||||||
|
"",
|
||||||
|
`/?ctab=hidden&csort=priority:desc&cf=${cf}`,
|
||||||
|
);
|
||||||
|
const { result } = render();
|
||||||
|
expect(result.current.view).toBe("hidden");
|
||||||
|
expect(result.current.sort).toEqual({ key: "priority", dir: "desc" });
|
||||||
|
expect(result.current.filters).toEqual({ sync: "needsFull" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mirrors the tab into the address bar and clears it back to the default", () => {
|
||||||
|
const { result } = render();
|
||||||
|
act(() => result.current.setView("blocked"));
|
||||||
|
expect(new URLSearchParams(window.location.search).get("ctab")).toBe("blocked");
|
||||||
|
// Back to the default tab clears the key (only non-defaults are emitted).
|
||||||
|
act(() => result.current.setView("subscribed"));
|
||||||
|
expect(new URLSearchParams(window.location.search).has("ctab")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,16 +3,25 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { getAccountRaw, LS } from "../lib/storage";
|
import { getAccountRaw, setAccountRaw, LS } from "../lib/storage";
|
||||||
import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout";
|
import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout";
|
||||||
import { type SortState } from "../lib/columnSort";
|
import { hydrateSort, type SortState } from "../lib/columnSort";
|
||||||
import { useSortState } from "../lib/useSortState";
|
import { useSortState } from "../lib/useSortState";
|
||||||
import { useFilterState } from "../lib/useFilterState";
|
import { useFilterState } from "../lib/useFilterState";
|
||||||
|
import { useActiveAccountId } from "../lib/activeAccount";
|
||||||
|
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||||
|
import {
|
||||||
|
channelsParamsToState,
|
||||||
|
channelsParamUpdates,
|
||||||
|
hasChannelsParams,
|
||||||
|
type ChannelsUrlState,
|
||||||
|
} from "../lib/channelsUrlState";
|
||||||
import { usePersistedTab } from "./Tabs";
|
import { usePersistedTab } from "./Tabs";
|
||||||
import type { FilterMap } from "./tableHeader";
|
import type { FilterMap } from "./tableHeader";
|
||||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||||
@@ -44,6 +53,36 @@ function migrateSyncChip(): FilterMap | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The manager's stored column-filter map, healed to a plain string/string[] object (same discipline as
|
||||||
|
// useFilterState's hydrate). Used only to build the seed's base — the URL keeps whatever the stored map
|
||||||
|
// has for any dimension a partial shared link omits.
|
||||||
|
function readStoredFilters(): FilterMap {
|
||||||
|
const raw = getAccountRaw(LS.channelsFilterMap);
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(raw);
|
||||||
|
if (v && typeof v === "object" && !Array.isArray(v)) return v as FilterMap;
|
||||||
|
} catch {
|
||||||
|
/* fall through to the migrate seed */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return migrateSyncChip() ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// S8: a shared manager link carries the content state in namespaced URL keys (ctab/csort/cf). When
|
||||||
|
// present, the URL wins over this account's stored view — read once at mount and applied over the slice
|
||||||
|
// hooks (see the provider). The keys are channels-unique, so their mere presence identifies a shared
|
||||||
|
// manager view; no ?page check is needed (the mirror below is page-gated so it never pollutes others).
|
||||||
|
function readChannelsUrlSeed(): ChannelsUrlState | null {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (!hasChannelsParams(params)) return null;
|
||||||
|
return channelsParamsToState(params, {
|
||||||
|
view: clampView(getAccountRaw(LS.channelsView)),
|
||||||
|
sort: hydrateSort(getAccountRaw(LS.channelSort), [], null),
|
||||||
|
filters: readStoredFilters(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
type ChannelsState = {
|
type ChannelsState = {
|
||||||
search: string;
|
search: string;
|
||||||
// The subscribed table's column filters, lifted out of DataTable so the top status chips, the Sync
|
// The subscribed table's column filters, lifted out of DataTable so the top status chips, the Sync
|
||||||
@@ -132,6 +171,38 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
|||||||
const [focusName, setFocusName] = useState<string | null>(null);
|
const [focusName, setFocusName] = useState<string | null>(null);
|
||||||
const [focusToken, setFocusToken] = useState(0);
|
const [focusToken, setFocusToken] = useState(0);
|
||||||
|
|
||||||
|
// S8 — shareable manager view. Captured once at mount (or null when no manager URL keys are present).
|
||||||
|
const [urlSeed] = useState(readChannelsUrlSeed);
|
||||||
|
const accountId = useActiveAccountId();
|
||||||
|
const seededRef = useRef(false);
|
||||||
|
const persistedRef = useRef(false);
|
||||||
|
|
||||||
|
// Apply the shared-link seed once, BEFORE paint, through the slice setters. Going through the setters
|
||||||
|
// (not raw state) is deliberate: each one calls its hook's markDirty, so the account-pin re-hydrate
|
||||||
|
// (which fires later on a fresh tab) sees the slice as user-edited and won't reload the stored view
|
||||||
|
// over the shared one. setViewRaw (not setView) restores the tab without pushing a phantom history
|
||||||
|
// entry. A normal load has urlSeed === null and this is a no-op.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!urlSeed || seededRef.current) return;
|
||||||
|
seededRef.current = true;
|
||||||
|
if (urlSeed.view !== view) setViewRaw(urlSeed.view);
|
||||||
|
setFilters(urlSeed.filters);
|
||||||
|
setSort(urlSeed.sort);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// On a fresh tab the account pins AFTER mount, so the seed's setter writes above no-op'd (no account
|
||||||
|
// yet). Persist the seed to the account once it's known, so the shared view also survives a later
|
||||||
|
// PLAIN reload (no URL) — mirrors PlexProvider's rehydrate-persist. The slice hooks' own re-hydrate
|
||||||
|
// already skips (marked dirty above), so this only touches storage, not state.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!urlSeed || accountId == null || persistedRef.current) return;
|
||||||
|
persistedRef.current = true;
|
||||||
|
setAccountRaw(LS.channelsView, urlSeed.view);
|
||||||
|
setAccountRaw(LS.channelsFilterMap, JSON.stringify(urlSeed.filters));
|
||||||
|
setAccountRaw(LS.channelSort, JSON.stringify(urlSeed.sort));
|
||||||
|
}, [urlSeed, accountId]);
|
||||||
|
|
||||||
// Latest-value ref so the STABLE setView can compare against the current tab without depending on
|
// Latest-value ref so the STABLE setView can compare against the current tab without depending on
|
||||||
// it (a dep would recreate the action on every switch and re-render every actions consumer).
|
// it (a dep would recreate the action on every switch and re-render every actions consumer).
|
||||||
// Written during render — the sanctioned pattern NavigationProvider uses for `pageRef`.
|
// Written during render — the sanctioned pattern NavigationProvider uses for `pageRef`.
|
||||||
@@ -218,6 +289,12 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
|||||||
[setSearch, setFilters, setView, setLayout, setSort, focusChannel, goToFullHistory],
|
[setSearch, setFilters, setView, setLayout, setSort, focusChannel, goToFullHistory],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// S8: mirror the content state (tab + sort + column filters) into the address bar under the namespaced
|
||||||
|
// keys so a manager view is shareable. Gated on page===channels: off the manager the keys are cleared
|
||||||
|
// (localStorage still holds the state; it re-mirrors on return). The name `search` stays OUT — it's a
|
||||||
|
// deliberately transient rail-search, like the feed's and plex's.
|
||||||
|
useUrlMirror(channelsParamUpdates(page === "channels" ? { view, sort, filters } : null));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionsContext.Provider value={actions}>
|
<ActionsContext.Provider value={actions}>
|
||||||
<LayoutContext.Provider value={layout}>
|
<LayoutContext.Provider value={layout}>
|
||||||
|
|||||||
@@ -127,8 +127,8 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
// app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its root, and
|
// app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its root, and
|
||||||
// the address bar reflects the page (feed is implicit) while clearing the view-specific params
|
// the address bar reflects the page (feed is implicit) while clearing the view-specific params
|
||||||
// (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page switch resets to that
|
// (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page switch resets to that
|
||||||
// module's root. Feed filters (owned by FeedFiltersProvider) are left intact; the plex keys
|
// module's root. Feed filters (owned by FeedFiltersProvider) are left intact; the plex/channels
|
||||||
// re-mirror from PlexProvider on return to the plex page.
|
// keys re-mirror from PlexProvider / ChannelsProvider on return to their page.
|
||||||
window.history.pushState(
|
window.history.pushState(
|
||||||
{ sfPage: next, sfScroll: nextNavId() },
|
{ sfPage: next, sfScroll: nextNavId() },
|
||||||
"",
|
"",
|
||||||
@@ -141,6 +141,9 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
psort: null,
|
psort: null,
|
||||||
pshow: null,
|
pshow: null,
|
||||||
pf: null,
|
pf: null,
|
||||||
|
ctab: null,
|
||||||
|
csort: null,
|
||||||
|
cf: null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
notifyNav();
|
notifyNav();
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// S8 — the Channels-manager content state (active tab + column sort + the column-filter map) round-trips
|
||||||
|
// through namespaced URL keys (ctab/csort/cf) so a manager view is shareable, the S4/plexUrlState
|
||||||
|
// analogue. `cf` carries the mixed scalar/array FilterMap as a JSON blob.
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
channelsParamsToState,
|
||||||
|
channelsParamUpdates,
|
||||||
|
hasChannelsParams,
|
||||||
|
type ChannelsUrlState,
|
||||||
|
} from "./channelsUrlState";
|
||||||
|
import type { FilterMap } from "../components/tableHeader";
|
||||||
|
|
||||||
|
// A concrete non-default filter map exercising both wire shapes: scalars (the sync chip, a select) and
|
||||||
|
// an array (a multi column).
|
||||||
|
const RICH_FILTERS: FilterMap = {
|
||||||
|
sync: "needsFull",
|
||||||
|
priority: "3",
|
||||||
|
tags: ["12", "34"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const BASE: ChannelsUrlState = { view: "subscribed", sort: null, filters: {} };
|
||||||
|
|
||||||
|
// Apply an updates delta onto a base URLSearchParams the way urlWithParams would (null deletes).
|
||||||
|
function applyUpdates(updates: Record<string, string | null>): URLSearchParams {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(updates)) {
|
||||||
|
if (v != null) p.set(k, v);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("channelsParamUpdates", () => {
|
||||||
|
it("emits nothing for the all-default state", () => {
|
||||||
|
expect(channelsParamUpdates(BASE)).toEqual({ ctab: null, csort: null, cf: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears every key when passed null (off-manager page)", () => {
|
||||||
|
expect(channelsParamUpdates(null)).toEqual({ ctab: null, csort: null, cf: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits only non-default keys", () => {
|
||||||
|
const u = channelsParamUpdates({
|
||||||
|
view: "hidden",
|
||||||
|
sort: { key: "subscribers", dir: "desc" },
|
||||||
|
filters: { sync: "fully" },
|
||||||
|
});
|
||||||
|
expect(u.ctab).toBe("hidden");
|
||||||
|
expect(u.csort).toBe("subscribers:desc");
|
||||||
|
expect(u.cf).toBe(JSON.stringify({ sync: "fully" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops empty filter entries so a cleared funnel emits no cf", () => {
|
||||||
|
expect(
|
||||||
|
channelsParamUpdates({ view: "subscribed", sort: null, filters: { tags: [], sync: "" } }).cf,
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("round-trip", () => {
|
||||||
|
it("restores a rich state (scalar + array filters) from the emitted URL", () => {
|
||||||
|
const state: ChannelsUrlState = {
|
||||||
|
view: "blocked",
|
||||||
|
sort: { key: "priority", dir: "asc" },
|
||||||
|
filters: RICH_FILTERS,
|
||||||
|
};
|
||||||
|
const params = applyUpdates(channelsParamUpdates(state));
|
||||||
|
const back = channelsParamsToState(params, BASE);
|
||||||
|
expect(back.view).toBe("blocked");
|
||||||
|
expect(back.sort).toEqual({ key: "priority", dir: "asc" });
|
||||||
|
expect(back.filters).toEqual(RICH_FILTERS);
|
||||||
|
// the array shape survives the JSON round-trip, not collapsed to a scalar
|
||||||
|
expect(Array.isArray(back.filters.tags)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps base values for absent keys (partial link)", () => {
|
||||||
|
const params = new URLSearchParams("csort=title:asc");
|
||||||
|
const back = channelsParamsToState(params, {
|
||||||
|
view: "discovery",
|
||||||
|
sort: null,
|
||||||
|
filters: { sync: "needsFull" },
|
||||||
|
});
|
||||||
|
expect(back.view).toBe("discovery"); // from base, no ctab
|
||||||
|
expect(back.filters).toEqual({ sync: "needsFull" }); // from base, no cf
|
||||||
|
expect(back.sort).toEqual({ key: "title", dir: "asc" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores an out-of-vocabulary tab / bad sort / corrupt cf and falls back to base", () => {
|
||||||
|
const params = new URLSearchParams("ctab=bogus&csort=key:sideways&cf=notjson");
|
||||||
|
const back = channelsParamsToState(params, { view: "subscribed", sort: null, filters: {} });
|
||||||
|
expect(back.view).toBe("subscribed"); // bogus tab → base
|
||||||
|
expect(back.sort).toBeNull(); // bad dir → parseSortState null → base (null)
|
||||||
|
expect(back.filters).toEqual({}); // corrupt cf → decodeFilters heals to empty
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasChannelsParams", () => {
|
||||||
|
it("detects any namespaced key", () => {
|
||||||
|
expect(hasChannelsParams(new URLSearchParams("ctab=hidden"))).toBe(true);
|
||||||
|
expect(hasChannelsParams(new URLSearchParams("cf=%7B%22sync%22%3A%22fully%22%7D"))).toBe(true);
|
||||||
|
});
|
||||||
|
it("ignores the feed's and plex's keys", () => {
|
||||||
|
expect(hasChannelsParams(new URLSearchParams("scope=all&pscope=movie&sort=oldest"))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Serialize the Channels-manager content state (active tab + column sort + the column-filter map) to/
|
||||||
|
// from the address bar so a manager view is bookmarkable/shareable — the S8 analogue of the feed's
|
||||||
|
// urlState and Plex's plexUrlState (S4). Keys are NAMESPACED (`ctab`/`csort`/`cf`) so they're
|
||||||
|
// channels-unique (like `?v=`/`?pl`/`pscope`): no other provider ever reads or clears them, and the
|
||||||
|
// feed's URL-stripping can't delete them out from under the manager. Only non-default values are
|
||||||
|
// emitted, so URLs stay clean. The manager's name search is deliberately EPHEMERAL (like the feed/plex
|
||||||
|
// rail search) and stays OUT of the shared contract.
|
||||||
|
import { parseSortState, type SortState } from "./columnSort";
|
||||||
|
import { type FilterMap } from "../components/tableHeader";
|
||||||
|
|
||||||
|
// The content state S8 mirrors. `view` is the raw tab string (the provider clamps it); `sort` is the
|
||||||
|
// raw provider SortState (healed against live columns only at the point of use), so the user's real
|
||||||
|
// preference round-trips through the URL.
|
||||||
|
export interface ChannelsUrlState {
|
||||||
|
view: string;
|
||||||
|
sort: SortState;
|
||||||
|
filters: FilterMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEYS = ["ctab", "csort", "cf"] as const;
|
||||||
|
const VIEWS = ["subscribed", "discovery", "hidden", "blocked"];
|
||||||
|
|
||||||
|
// Sort ⇄ `key:dir` (e.g. `subscribers:desc`). Column sort keys are simple words (no colon), so a plain
|
||||||
|
// split is safe. There is no default sort (fallback null), so any sort emits.
|
||||||
|
function encodeSort(s: SortState): string | null {
|
||||||
|
return s ? `${s.key}:${s.dir}` : null;
|
||||||
|
}
|
||||||
|
function decodeSort(raw: string): SortState {
|
||||||
|
const i = raw.indexOf(":");
|
||||||
|
const key = i === -1 ? raw : raw.slice(0, i);
|
||||||
|
const dir = i === -1 ? "asc" : raw.slice(i + 1);
|
||||||
|
return parseSortState({ key, dir });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The FilterMap mixes scalar column filters (priority=select, name=text, the sync chip) with array ones
|
||||||
|
// (tags=multi), so it's carried as a compact JSON blob rather than a nested query string — JSON round-
|
||||||
|
// trips both shapes exactly (and future multi columns for free), and the values are opaque enum/id
|
||||||
|
// tokens where a readable querystring buys little. Empty entries are dropped so a cleared funnel emits
|
||||||
|
// no `cf`.
|
||||||
|
function encodeFilters(map: FilterMap): string | null {
|
||||||
|
const clean: FilterMap = {};
|
||||||
|
for (const [k, v] of Object.entries(map)) {
|
||||||
|
if (Array.isArray(v) ? v.length : v) clean[k] = v;
|
||||||
|
}
|
||||||
|
return Object.keys(clean).length ? JSON.stringify(clean) : null;
|
||||||
|
}
|
||||||
|
// A URL blob is untrusted input: accept only a plain object of string / string[] entries, anything else
|
||||||
|
// heals to empty (same discipline as useFilterState's storage hydrate).
|
||||||
|
function decodeFilters(raw: string): FilterMap {
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(raw);
|
||||||
|
if (!v || typeof v !== "object" || Array.isArray(v)) return {};
|
||||||
|
const out: FilterMap = {};
|
||||||
|
for (const [k, val] of Object.entries(v)) {
|
||||||
|
if (typeof val === "string") out[k] = val;
|
||||||
|
else if (Array.isArray(val) && val.every((x) => typeof x === "string"))
|
||||||
|
out[k] = val as string[];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The address-bar delta for the manager content state: each namespaced key mapped to its emitted
|
||||||
|
* value, or null to delete it (so a control back at its default clears the key). Pass `null` to clear
|
||||||
|
* ALL manager keys — used when the app is off the Channels page (localStorage still holds the state; it
|
||||||
|
* re-mirrors on return). Feed the result into urlWithParams(). */
|
||||||
|
export function channelsParamUpdates(
|
||||||
|
state: ChannelsUrlState | null,
|
||||||
|
): Record<string, string | null> {
|
||||||
|
if (!state) return { ctab: null, csort: null, cf: null };
|
||||||
|
return {
|
||||||
|
ctab: state.view && state.view !== "subscribed" ? state.view : null,
|
||||||
|
csort: encodeSort(state.sort),
|
||||||
|
cf: encodeFilters(state.filters),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the query string carries any manager content key we recognize (a shared manager link). */
|
||||||
|
export function hasChannelsParams(params: URLSearchParams): boolean {
|
||||||
|
return KEYS.some((k) => params.has(k));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge the manager content keys from the URL over a base state (the localStorage-seeded values); an
|
||||||
|
* absent or invalid key keeps the base value, so a partial link (only `?ctab=`) is honoured. */
|
||||||
|
export function channelsParamsToState(
|
||||||
|
params: URLSearchParams,
|
||||||
|
base: ChannelsUrlState,
|
||||||
|
): ChannelsUrlState {
|
||||||
|
const tab = params.get("ctab");
|
||||||
|
const csort = params.get("csort");
|
||||||
|
return {
|
||||||
|
view: tab && VIEWS.includes(tab) ? tab : base.view,
|
||||||
|
sort: csort != null ? (decodeSort(csort) ?? base.sort) : base.sort,
|
||||||
|
filters: params.has("cf") ? decodeFilters(params.get("cf") ?? "") : base.filters,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user