refactor(playlists): scope module state in PlaylistsProvider

Lift the Playlists module's shared local state (rail name filter + selected
playlist) out of App into a split-context PlaylistsProvider, mirroring
FeedFiltersProvider. Header, PlaylistsRail, and the Playlists detail pane read
it via usePlaylists()/usePlaylistsActions() instead of props drilled through
App; the modules take only a uniform `me` prop now. App loses two useStates and
the selected-playlist persistence helper. (Platform S6b.2)
This commit is contained in:
2026-07-18 23:43:19 +02:00
parent f4a68575c5
commit d0fc061795
6 changed files with 95 additions and 48 deletions
+2 -23
View File
@@ -134,7 +134,6 @@ export default function App() {
// Client-side name filters for the Channels + Playlists lists — lifted here so the shared top // Client-side name filters for the Channels + Playlists lists — lifted here so the shared top
// SearchBar can drive them (the modules read/seed them via props). // SearchBar can drive them (the modules read/seed them via props).
const [channelsSearch, setChannelsSearch] = useState(""); const [channelsSearch, setChannelsSearch] = useState("");
const [playlistsSearch, setPlaylistsSearch] = useState("");
const [wizardOpen, setWizardOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false);
// Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render. // Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
const [channelFilterRaw, setChannelFilter] = useAccountPersistedState(LS.channelFilter, "all"); const [channelFilterRaw, setChannelFilter] = useAccountPersistedState(LS.channelFilter, "all");
@@ -246,17 +245,6 @@ export default function App() {
writeAccount(LS.playlistsCollapsed, next); writeAccount(LS.playlistsCollapsed, next);
api.savePrefs({ playlistsCollapsed: next }).catch(() => {}); api.savePrefs({ playlistsCollapsed: next }).catch(() => {});
} }
// The selected playlist — App state so the App-level PlaylistsRail and the module's detail pane
// (Playlists) stay in sync. Persisted so a reload keeps it (it's not in the URL).
const [selectedPlaylistId, setSelectedPlaylistIdState] = useState<number | null>(() => {
const s = getAccountRaw(LS.playlist);
return s ? Number(s) : null;
});
const setSelectedPlaylistId = (id: number | null) => {
setSelectedPlaylistIdState(id);
if (id != null) setAccountRaw(LS.playlist, String(id));
};
useEffect(() => applyTheme(theme), [theme]); useEffect(() => applyTheme(theme), [theme]);
// Apply the draft prefs locally for instant preview (their localStorage mirrors update via // Apply the draft prefs locally for instant preview (their localStorage mirrors update via
@@ -611,10 +599,7 @@ export default function App() {
)} )}
{page === "playlists" && !channelView && ( {page === "playlists" && !channelView && (
<PlaylistsRail <PlaylistsRail
canWrite={meQuery.data!.can_write} me={meQuery.data!}
search={playlistsSearch}
selectedId={selectedPlaylistId}
setSelectedId={setSelectedPlaylistId}
layout={panelLayouts.playlists} layout={panelLayouts.playlists}
setLayout={(l) => setPanelLayout("playlists", l)} setLayout={(l) => setPanelLayout("playlists", l)}
collapsed={playlistsCollapsed} collapsed={playlistsCollapsed}
@@ -663,8 +648,6 @@ export default function App() {
setPlexQ={setPlexQ} setPlexQ={setPlexQ}
channelsSearch={channelsSearch} channelsSearch={channelsSearch}
setChannelsSearch={setChannelsSearch} setChannelsSearch={setChannelsSearch}
playlistsSearch={playlistsSearch}
setPlaylistsSearch={setPlaylistsSearch}
onYtSearch={enterYtSearch} onYtSearch={enterYtSearch}
onGoToFullHistory={() => { onGoToFullHistory={() => {
setChannelFilter("needs_full"); setChannelFilter("needs_full");
@@ -715,11 +698,7 @@ export default function App() {
) : page === "audit" && meQuery.data!.role === "admin" ? ( ) : page === "audit" && meQuery.data!.role === "admin" ? (
<AuditLog /> <AuditLog />
) : page === "playlists" ? ( ) : page === "playlists" ? (
<Playlists <Playlists me={meQuery.data!} />
canWrite={meQuery.data!.can_write}
selectedId={selectedPlaylistId}
setSelectedId={setSelectedPlaylistId}
/>
) : page === "notifications" ? ( ) : page === "notifications" ? (
<NotificationsPanel onFocusChannel={focusChannel} /> <NotificationsPanel onFocusChannel={focusChannel} />
) : page === "messages" && !meQuery.data!.is_demo ? ( ) : page === "messages" && !meQuery.data!.is_demo ? (
+4 -4
View File
@@ -8,6 +8,7 @@ import SearchBar from "./SearchBar";
import SyncStatus from "./SyncStatus"; import SyncStatus from "./SyncStatus";
import { useNavigation, useNavigationActions } from "./NavigationProvider"; import { useNavigation, useNavigationActions } from "./NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
// The floating top header: a row of glass pills over the content — a ModuleName pill (label + // The floating top header: a row of glass pills over the content — a ModuleName pill (label +
// accent dot + back/forward arrows), the module's SearchBar where it has one, and the per-user // accent dot + back/forward arrows), the module's SearchBar where it has one, and the per-user
@@ -18,8 +19,6 @@ export default function Header({
setPlexQ, setPlexQ,
channelsSearch, channelsSearch,
setChannelsSearch, setChannelsSearch,
playlistsSearch,
setPlaylistsSearch,
onYtSearch, onYtSearch,
onGoToFullHistory, onGoToFullHistory,
}: { }: {
@@ -29,8 +28,6 @@ export default function Header({
setPlexQ: (q: string) => void; setPlexQ: (q: string) => void;
channelsSearch: string; channelsSearch: string;
setChannelsSearch: (q: string) => void; setChannelsSearch: (q: string) => void;
playlistsSearch: string;
setPlaylistsSearch: (q: string) => void;
// Trigger a live YouTube search for the current term (feed only; hidden for the demo account, // Trigger a live YouTube search for the current term (feed only; hidden for the demo account,
// which can't spend the shared quota). // which can't spend the shared quota).
onYtSearch: (q: string) => void; onYtSearch: (q: string) => void;
@@ -42,6 +39,9 @@ export default function Header({
const { setPage } = useNavigationActions(); const { setPage } = useNavigationActions();
const filters = useFeedFilters(); const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions(); const { setFilters } = useFeedFiltersActions();
// The playlists name filter lives in PlaylistsProvider (shared with its rail + detail pane).
const { search: playlistsSearch } = usePlaylists();
const { setSearch: setPlaylistsSearch } = usePlaylistsActions();
// The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so // The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so
// it stays correct as modules are added/gated). Labels of every active module feed ModuleName's // it stays correct as modules are added/gated). Labels of every active module feed ModuleName's
// dynamic width so the pill is sized to the widest reachable title in the current language. // dynamic width so the pill is sized to the widest reachable title in the current language.
+7 -11
View File
@@ -31,7 +31,7 @@ import {
X, X,
Youtube, Youtube,
} from "lucide-react"; } from "lucide-react";
import { api, type Video } from "../lib/api"; import { api, type Me, type Video } from "../lib/api";
import { formatDuration } from "../lib/format"; import { formatDuration } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../lib/youtubeErrors";
@@ -40,6 +40,7 @@ const PlayerModal = lazy(() => import("./PlayerModal"));
import UndoToolbar from "./UndoToolbar"; import UndoToolbar from "./UndoToolbar";
import { PageToolbar } from "./PageShell"; import { PageToolbar } from "./PageShell";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
type SortKey = "manual" | "title" | "duration" | "channel"; type SortKey = "manual" | "title" | "duration" | "channel";
type SortDir = "asc" | "desc"; type SortDir = "asc" | "desc";
@@ -178,17 +179,12 @@ function Row({
); );
} }
export default function Playlists({ export default function Playlists({ me }: { me: Me }) {
canWrite, const canWrite = me.can_write;
selectedId, // The selected playlist lives in PlaylistsProvider, shared with the PlaylistsRail (which owns the
setSelectedId,
}: {
canWrite: boolean;
// The selected playlist is App state, shared with the App-level PlaylistsRail (which owns the
// list + its rail controls). This detail pane reacts to the selection. // list + its rail controls). This detail pane reacts to the selection.
selectedId: number | null; const { selectedId } = usePlaylists();
setSelectedId: (id: number | null) => void; const { setSelectedId } = usePlaylistsActions();
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); const confirm = useConfirm();
@@ -0,0 +1,69 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
// 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>(() => {
const s = getAccountRaw(LS.playlist);
return s ? Number(s) : null;
});
const setSearch = useCallback((q: string) => setSearchState(q), []);
const setSelectedId = useCallback((id: number | null) => {
setSelectedIdState(id);
if (id != null) setAccountRaw(LS.playlist, String(id));
}, []);
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>
);
}
+9 -9
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowDown, ArrowUp, ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react"; import { ArrowDown, ArrowUp, ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api"; import { api, type Me, type Playlist } from "../lib/api";
import { formatDuration } from "../lib/format"; import { formatDuration } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../lib/youtubeErrors";
@@ -10,6 +10,7 @@ import { LS, readAccountMerged, writeAccount } from "../lib/storage";
import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
import SidePanel from "./SidePanel"; import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups"; import PanelGroups, { type PanelItem } from "./PanelGroups";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: "asc" | "desc"; dirtyFirst: boolean }; type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: "asc" | "desc"; dirtyFirst: boolean };
const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false }; const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false };
@@ -19,24 +20,23 @@ const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false }
// controls (sort / new / sync); the selected playlist is App state so the module's detail pane // controls (sort / new / sync); the selected playlist is App state so the module's detail pane
// (Playlists.tsx) reacts to it. Grouped into two islands: "manage" and the playlist list. // (Playlists.tsx) reacts to it. Grouped into two islands: "manage" and the playlist list.
export default function PlaylistsRail({ export default function PlaylistsRail({
canWrite, me,
search,
selectedId,
setSelectedId,
layout, layout,
setLayout, setLayout,
collapsed, collapsed,
onToggleCollapse, onToggleCollapse,
}: { }: {
canWrite: boolean; me: Me;
search: string;
selectedId: number | null;
setSelectedId: (id: number | null) => void;
layout: PanelLayout; layout: PanelLayout;
setLayout: (l: PanelLayout) => void; setLayout: (l: PanelLayout) => void;
collapsed: boolean; collapsed: boolean;
onToggleCollapse: () => void; onToggleCollapse: () => void;
}) { }) {
const canWrite = me.can_write;
// The name filter + the selected playlist live in PlaylistsProvider (shared with the header search
// box and the Playlists detail pane); this rail owns the list query + its own controls.
const { search, selectedId } = usePlaylists();
const { setSelectedId } = usePlaylistsActions();
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const [plSort, setPlSort] = useState<PlSort>(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT)); const [plSort, setPlSort] = useState<PlSort>(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT));
+4 -1
View File
@@ -5,6 +5,7 @@ import ErrorBoundary from "./components/ErrorBoundary";
import { ConfirmProvider } from "./components/ConfirmProvider"; import { ConfirmProvider } from "./components/ConfirmProvider";
import { NavigationProvider } from "./components/NavigationProvider"; import { NavigationProvider } from "./components/NavigationProvider";
import { FeedFiltersProvider } from "./components/FeedFiltersProvider"; import { FeedFiltersProvider } from "./components/FeedFiltersProvider";
import { PlaylistsProvider } from "./components/PlaylistsProvider";
import "./i18n"; import "./i18n";
import "./index.css"; import "./index.css";
@@ -39,7 +40,9 @@ const root =
<ConfirmProvider> <ConfirmProvider>
<NavigationProvider> <NavigationProvider>
<FeedFiltersProvider> <FeedFiltersProvider>
<App /> <PlaylistsProvider>
<App />
</PlaylistsProvider>
</FeedFiltersProvider> </FeedFiltersProvider>
</NavigationProvider> </NavigationProvider>
</ConfirmProvider> </ConfirmProvider>