Files
siftlode/frontend/src/components/PlaylistsProvider.tsx
T
peter 1579a81395 fix(e4): address code-review findings — usePref flush, ?pl guard, dedup (E4 review)
Applies the /code-review high findings on the E4 branch:

- usePref: flush the pending debounced server write on unmount instead of
  clearing it, so the last change isn't lost (and can't later be overwritten by
  the stale server value via the adopt effect). [correctness]
- PlaylistsProvider.readSelectedId: accept ?pl only when it's a positive integer
  — "?pl=" gave Number("")===0 (finite) and "?pl=1.5" a fractional id, both of
  which selected a non-existent playlist. [correctness]
- usePref: extract mergePrefsCache() so useSavePref and usePref.set share the
  one me-cache merge shape instead of duplicating it. [reuse]
- New lib/useUrlMirror: the shared replaceState(urlWithParams(...)) mirror
  effect, so FeedFiltersProvider and PlaylistsProvider stop hand-rolling the
  identical block (keyed on the serialized delta so it only fires on change).
  [reuse]
- plexCards: swapBadgeReveal() helper for the repeated
  reveal-idle/group-hover:opacity-0 swap-badge ternary. [simplification]

Gate green (tsc, knip, lint 0-err, 247 vitest). Self-E2E (localdev): feed filter
mirrors to ?show and clears on leaving the feed / re-mirrors on return via
useUrlMirror; ?pl= (empty) and ?pl=abc no longer select a bogus playlist (fall
back to the stored selection); Plex reveal-idle badges still display:none on
touch after the helper refactor; 0 console errors.
2026-08-20 02:13:49 +02:00

88 lines
3.7 KiB
TypeScript

import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
import { useUrlMirror } from "../lib/useUrlMirror";
import { useNavigation } from "./NavigationProvider";
// Owns the Playlists module's shared local state — the rail's name filter and the selected playlist
// — lifted out of the App god component so the header search box, the PlaylistsRail, and the
// Playlists detail pane read it via hooks instead of prop-drilling through App. Mirrors
// FeedFiltersProvider / NavigationProvider (split State + Actions contexts, one-line hooks; composed
// in main.tsx around <App/>).
//
// The `search` term is ephemeral (resets on reload, like the other list filters); the selected
// playlist is per-account persisted (LS.playlist) so a reload returns you to it. Neither is
// server-synced, and an account switch does a full reload, so init-from-storage is all the
// hydration these need — no per-account load effect (unlike the server-backed feed filters).
type PlaylistsState = {
search: string;
selectedId: number | null;
};
interface PlaylistsActions {
setSearch: (q: string) => void;
setSelectedId: (id: number | null) => void;
}
const StateContext = createContext<PlaylistsState>({ search: "", selectedId: null });
const ActionsContext = createContext<PlaylistsActions>({
setSearch: () => {},
setSelectedId: () => {},
});
export function usePlaylists(): PlaylistsState {
return useContext(StateContext);
}
export function usePlaylistsActions(): PlaylistsActions {
return useContext(ActionsContext);
}
function readSelectedId(): number | null {
// A ?pl bookmark wins over the stored selection so a pasted link opens that playlist. Accept only a
// positive integer id — a bare "?pl=" gives Number("")===0 (finite!) and "?pl=1.5" a fractional id,
// both of which would select a non-existent playlist.
const fromUrl = new URLSearchParams(window.location.search).get("pl");
const n = fromUrl ? Number(fromUrl) : NaN;
if (Number.isInteger(n) && n > 0) return n;
const s = getAccountRaw(LS.playlist);
return s ? Number(s) : null;
}
export function PlaylistsProvider({ children }: { children: ReactNode }) {
const [search, setSearchState] = useState("");
const [selectedId, setSelectedIdState] = useState<number | null>(readSelectedId);
// Re-read the selected playlist once the account is pinned after mount (first load on a fresh tab):
// this provider mounts above <App>, which pins the account only after /api/me.
const markDirty = useAccountRehydrate(() => setSelectedIdState(readSelectedId()));
const setSearch = useCallback((q: string) => setSearchState(q), []);
const setSelectedId = useCallback(
(id: number | null) => {
markDirty();
setSelectedIdState(id);
if (id != null) setAccountRaw(LS.playlist, String(id));
},
[markDirty],
);
// Mirror the selected playlist to ?pl on the Playlists page so it's bookmarkable; clear it elsewhere
// (the localStorage copy still restores it on return). useUrlMirror keeps ?page and the other nav
// params intact.
const { page } = useNavigation();
useUrlMirror({ pl: page === "playlists" && selectedId != null ? String(selectedId) : null });
const state = useMemo<PlaylistsState>(() => ({ search, selectedId }), [search, selectedId]);
const actions = useMemo<PlaylistsActions>(
() => ({ setSearch, setSelectedId }),
[setSearch, setSelectedId],
);
return (
<ActionsContext.Provider value={actions}>
<StateContext.Provider value={state}>{children}</StateContext.Provider>
</ActionsContext.Provider>
);
}