The remaining E4 deep-review deferred gaps, in one sprint: - readSelectedId split into playlistsInit.ts (react-refresh-clean, testable) — ?pl accepted only as a positive integer; empty/0/negative/fractional/non-numeric fall back to the stored selection; null when neither is present. - useUrlMirror: mounts write the param, a same-serialized rerender does NOT re-fire (stable dep), a null value clears the key, other params preserved. - usePref: a pending debounced write is FLUSHED on unmount (not dropped); an unmount with nothing pending stays silent. 20 new test cases; full FE gate green (tsc/eslint/prettier/knip, 309 vitest).
78 lines
3.2 KiB
TypeScript
78 lines
3.2 KiB
TypeScript
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
|
import { LS, setAccountRaw } from "../lib/storage";
|
|
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
|
import { useUrlMirror } from "../lib/useUrlMirror";
|
|
import { useNavigation } from "./NavigationProvider";
|
|
import { readSelectedId } from "./playlistsInit";
|
|
|
|
// 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);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|