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:
+2
-23
@@ -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 ? (
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user