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.
88 lines
3.7 KiB
TypeScript
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>
|
|
);
|
|
}
|