From 54bf8bbe25337e810cd468a184cf2916f8d22077 Mon Sep 17 00:00:00 2001 From: Christian Benincasa Date: Tue, 12 Mar 2024 16:36:44 -0400 Subject: [PATCH] Separate out Plex specifics from ProgrammingSelector component + more --- web/src/components/Breadcrumbs.tsx | 2 +- .../CustomShowProgrammingSelector.tsx | 3 + .../PlexProgrammingSelector.tsx | 350 ++++++++++++++ .../channel_config/ProgrammingSelector.tsx | 448 ++++-------------- web/src/hooks/useCustomShows.ts | 28 +- web/src/hooks/useQueryHelpers.ts | 3 +- web/src/hooks/useRouteName.ts | 35 +- web/src/pages/library/EditCustomShowPage.tsx | 14 +- web/src/preloaders/customShowLoaders.ts | 31 +- web/src/store/customShowEditor/store.ts | 0 web/src/store/programmingSelector/actions.ts | 4 +- web/src/store/programmingSelector/store.ts | 18 +- web/src/types/index.ts | 1 + 13 files changed, 549 insertions(+), 388 deletions(-) create mode 100644 web/src/components/channel_config/CustomShowProgrammingSelector.tsx create mode 100644 web/src/components/channel_config/PlexProgrammingSelector.tsx create mode 100644 web/src/store/customShowEditor/store.ts diff --git a/web/src/components/Breadcrumbs.tsx b/web/src/components/Breadcrumbs.tsx index 3e18f03e8..d0388aacf 100644 --- a/web/src/components/Breadcrumbs.tsx +++ b/web/src/components/Breadcrumbs.tsx @@ -31,7 +31,7 @@ export default function Breadcrumbs(props: BreadcrumbsProps) { // Don't display crumbs for pages that aren't excplicely defined in useRouteNames hook return isLast ? ( - {getRouteName(to) ?? 'null'} + {getRouteName(to) ?? ''} ) : getRouteName(to) ? ( diff --git a/web/src/components/channel_config/CustomShowProgrammingSelector.tsx b/web/src/components/channel_config/CustomShowProgrammingSelector.tsx new file mode 100644 index 000000000..bb3ca6de9 --- /dev/null +++ b/web/src/components/channel_config/CustomShowProgrammingSelector.tsx @@ -0,0 +1,3 @@ +export function CustomShowProgrammingSelector() { + return
; +} diff --git a/web/src/components/channel_config/PlexProgrammingSelector.tsx b/web/src/components/channel_config/PlexProgrammingSelector.tsx new file mode 100644 index 000000000..705d94b26 --- /dev/null +++ b/web/src/components/channel_config/PlexProgrammingSelector.tsx @@ -0,0 +1,350 @@ +import { ExpandLess, ExpandMore } from '@mui/icons-material'; +import Clear from '@mui/icons-material/Clear'; +import GridView from '@mui/icons-material/GridView'; +import Search from '@mui/icons-material/Search'; +import ViewList from '@mui/icons-material/ViewList'; +import { + Box, + Collapse, + Divider, + Grow, + IconButton, + InputAdornment, + LinearProgress, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Stack, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@mui/material'; +import { DataTag, useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { + PlexLibraryCollections, + PlexLibraryMovies, + PlexLibraryShows, + PlexMovie, + PlexTvShow, +} from '@tunarr/types/plex'; +import { chain, first, isEmpty, isNil, isUndefined, map } from 'lodash-es'; +import { Fragment, useCallback, useEffect, useState } from 'react'; +import { useIntersectionObserver } from 'usehooks-ts'; +import { toggle } from '../../helpers/util'; +import { + EnrichedPlexMedia, + fetchPlexPath, + usePlex, +} from '../../hooks/plexHooks'; +import { usePlexServerSettings } from '../../hooks/settingsHooks'; +import useDebouncedState from '../../hooks/useDebouncedState'; +import useStore from '../../store'; +import { addKnownMediaForServer } from '../../store/programmingSelector/actions'; +import { setProgrammingSelectorViewState } from '../../store/themeEditor/actions'; +import ConnectPlex from '../settings/ConnectPlex'; +import { PlexGridItem } from './PlexGridItem'; +import { PlexListItem } from './PlexListItem'; +import SelectedProgrammingList from './SelectedProgrammingList'; + +type ViewType = 'list' | 'grid'; + +type Props = { + onAddSelectedMedia: (items: EnrichedPlexMedia[]) => void; +}; + +export default function PlexProgrammingSelector({ onAddSelectedMedia }: Props) { + const { data: plexServers } = usePlexServerSettings(); + const selectedServer = useStore((s) => s.currentServer); + const selectedLibrary = useStore((s) => + s.currentLibrary?.type === 'plex' ? s.currentLibrary : null, + ); + const viewType = useStore((state) => state.theme.programmingSelectorView); + + const [searchBarOpen, setSearchBarOpen] = useState(false); + const [search, debounceSearch, setSearch] = useDebouncedState('', 300); + const [collectionsOpen, setCollectionsOpen] = useState(false); + const [scrollParams, setScrollParams] = useState({ limit: 0, max: -1 }); + + const { data: directoryChildren } = usePlex( + selectedServer?.name ?? '', + '/library/sections', + !isUndefined(selectedServer), + ); + + const clearSearchInput = useCallback(() => { + setSearch(''); + setSearchBarOpen(false); + }, [setSearch]); + + const setViewType = (view: ViewType) => { + setProgrammingSelectorViewState(view); + }; + + const handleFormat = ( + _event: React.MouseEvent, + newFormats: ViewType, + ) => { + setViewType(newFormats); + }; + + const { data: collectionsData } = useQuery({ + queryKey: [ + 'plex', + selectedServer?.name, + selectedLibrary?.library.key, + 'collections', + ], + queryFn: () => { + return fetchPlexPath( + selectedServer!.name, + `/library/sections/${selectedLibrary?.library.key}/collections?`, + )(); + }, + enabled: !isNil(selectedServer) && !isNil(selectedLibrary), + }); + + const { isLoading: searchLoading, data: searchData } = useInfiniteQuery({ + queryKey: [ + 'plex-search', + selectedServer?.name, + selectedLibrary?.library.key, + debounceSearch, + ] as DataTag< + ['plex-search', string, string, string], + PlexLibraryMovies | PlexLibraryShows + >, + enabled: !isNil(selectedServer) && !isNil(selectedLibrary), + initialPageParam: 0, + queryFn: ({ pageParam }) => { + const plexQuery = new URLSearchParams({ + 'Plex-Container-Start': pageParam.toString(), + 'Plex-Container-Size': '10', + }); + + if (!isNil(debounceSearch) && !isEmpty(debounceSearch)) { + plexQuery.set('title<', debounceSearch); + } + + return fetchPlexPath( + selectedServer!.name, + `/library/sections/${ + selectedLibrary!.library.key + }/all?${plexQuery.toString()}`, + )(); + }, + getNextPageParam: (res, _, last) => { + if (res.size < 10) { + return null; + } + + return last + 10; + }, + }); + + useEffect(() => { + if (searchData) { + // We're using this as an analogue for detecting the start of a new 'query' + if (searchData.pages.length === 1) { + setScrollParams({ + limit: 16, + max: first(searchData.pages)!.size, + }); + } + + // We probably wouldn't have made it this far if we didnt have a server, but + // putting this here to prevent crashes + if (selectedServer) { + const allMedia = chain(searchData.pages) + .reject((page) => page.size === 0) + .map((page) => page.Metadata) + .flatten() + .value(); + addKnownMediaForServer(selectedServer.name, allMedia); + } + } + }, [selectedServer, searchData, setScrollParams]); + + const { ref } = useIntersectionObserver({ + onChange: (_, entry) => { + if (entry.isIntersecting && scrollParams.limit < scrollParams.max) { + setScrollParams(({ limit: prevLimit, max }) => ({ + max, + limit: prevLimit + 10, + })); + } + }, + threshold: 0.5, + }); + + const renderListItems = () => { + const elements: JSX.Element[] = []; + + if (collectionsData && collectionsData.size > 0) { + elements.push( + + setCollectionsOpen(toggle)} + dense + sx={ + viewType === 'grid' ? { display: 'block', width: '100%' } : null + } + > + + {collectionsOpen ? : } + + + + + {map(collectionsData.Metadata, (item) => + viewType === 'list' ? ( + + ) : ( + + ), + )} + + , + ); + } + + if (searchData) { + const items = chain(searchData.pages) + .reject((page) => page.size === 0) + .map((page) => page.Metadata) + .flatten() + .take(scrollParams.limit) + .value(); + elements.push( + ...map(items, (item: PlexMovie | PlexTvShow) => { + return viewType === 'list' ? ( + + ) : ( + + ); + }), + ); + } + return elements; + }; + + return ( + <> + {!isNil(directoryChildren) && + directoryChildren.size > 0 && + selectedLibrary && ( + <> + + + setSearch(e.target.value)} + key={'searchbar'} + sx={{ m: 0 }} + InputProps={{ + endAdornment: ( + + e.preventDefault()} + edge="end" + > + + + + ), + sx: { height: '48px' }, + }} + /> + + {!searchBarOpen && ( + setSearchBarOpen(toggle)} + > + + + )} + + + + + + + + + + + )} + {plexServers?.length === 0 ? ( + + + + ) : ( + <> + + + {renderListItems()} +
+
+ + + Selected Items + + + )} + + ); +} diff --git a/web/src/components/channel_config/ProgrammingSelector.tsx b/web/src/components/channel_config/ProgrammingSelector.tsx index 137aa7640..12e96a60b 100644 --- a/web/src/components/channel_config/ProgrammingSelector.tsx +++ b/web/src/components/channel_config/ProgrammingSelector.tsx @@ -1,65 +1,24 @@ import { - Clear, - ExpandLess, - ExpandMore, - GridView, - Search, - ViewList, -} from '@mui/icons-material'; -import { - Box, - Collapse, - Divider, FormControl, - Grow, - IconButton, - InputAdornment, InputLabel, - LinearProgress, - List, - ListItemButton, - ListItemIcon, - ListItemText, MenuItem, Select, Stack, - TextField, - ToggleButton, - ToggleButtonGroup, - Typography, } from '@mui/material'; -import { DataTag, useInfiniteQuery, useQuery } from '@tanstack/react-query'; -import { - PlexLibraryCollections, - PlexLibraryMovies, - PlexLibraryShows, - PlexMedia, - PlexMovie, - PlexTvShow, - isPlexDirectory, -} from '@tunarr/types/plex'; -import { chain, first, isEmpty, isNil, isUndefined, map } from 'lodash-es'; -import React, { Fragment, useCallback, useEffect, useState } from 'react'; -import { useIntersectionObserver } from 'usehooks-ts'; -import { toggle } from '../../helpers/util.ts'; -import { - EnrichedPlexMedia, - fetchPlexPath, - usePlex, -} from '../../hooks/plexHooks.ts'; +import { PlexMedia, isPlexDirectory } from '@tunarr/types/plex'; +import { find, isEmpty, isNil, isUndefined } from 'lodash-es'; +import React, { useCallback, useEffect, useState } from 'react'; +import { EnrichedPlexMedia, usePlex } from '../../hooks/plexHooks.ts'; import { usePlexServerSettings } from '../../hooks/settingsHooks.ts'; -import useDebouncedState from '../../hooks/useDebouncedState.ts'; +import { useCustomShows } from '../../hooks/useCustomShows.ts'; import useStore from '../../store/index.ts'; import { addKnownMediaForServer, setProgrammingListLibrary, setProgrammingListingServer, } from '../../store/programmingSelector/actions.ts'; -import { setProgrammingSelectorViewState } from '../../store/themeEditor/actions.ts'; -import ConnectPlex from '../settings/ConnectPlex.tsx'; -import { PlexGridItem } from './PlexGridItem.tsx'; -import { PlexListItem } from './PlexListItem.tsx'; -import SelectedProgrammingList from './SelectedProgrammingList.tsx'; +import { CustomShowProgrammingSelector } from './CustomShowProgrammingSelector.tsx'; +import PlexProgrammingSelector from './PlexProgrammingSelector.tsx'; export interface PlexListItemProps { item: T; @@ -69,8 +28,6 @@ export interface PlexListItemProps { parent?: string; } -type ViewType = 'list' | 'grid'; - type Props = { onAddSelectedMedia: (items: EnrichedPlexMedia[]) => void; }; @@ -80,24 +37,26 @@ export default function ProgrammingSelector({ onAddSelectedMedia }: Props) { const selectedServer = useStore((s) => s.currentServer); const selectedLibrary = useStore((s) => s.currentLibrary); const knownMedia = useStore((s) => s.knownMediaByServer); - const [collectionsOpen, setCollectionsOpen] = useState(false); - const viewType = useStore((state) => state.theme.programmingSelectorView); - const [searchBarOpen, setSearchBarOpen] = useState(false); - - const setViewType = (view: ViewType) => { - setProgrammingSelectorViewState(view); - }; - - const handleFormat = ( - _event: React.MouseEvent, - newFormats: ViewType, - ) => { - setViewType(newFormats); - }; - - const handleSearchOpen = () => { - setSearchBarOpen(!searchBarOpen); - }; + const [mediaSource, setMediaSource] = useState(selectedServer?.name); + + // Convenience sub-selectors for specific library types + const selectedPlexLibrary = + selectedLibrary?.type === 'plex' ? selectedLibrary.library : undefined; + const selectedCustomShow = + selectedLibrary?.type === 'custom-show' + ? selectedLibrary.library + : undefined; + + const viewingCustomShows = mediaSource === 'custom-shows'; + + /** + * Load Plex libraries + */ + const { data: plexLibraryChildren } = usePlex( + selectedServer?.name ?? '', + '/library/sections', + !isUndefined(selectedServer), + ); useEffect(() => { const server = @@ -108,107 +67,56 @@ export default function ProgrammingSelector({ onAddSelectedMedia }: Props) { setProgrammingListingServer(server); }, [plexServers]); - const [scrollParams, setScrollParams] = useState({ limit: 0, max: -1 }); - const [search, debounceSearch, setSearch] = useDebouncedState('', 300); - - const { data: directoryChildren } = usePlex( - selectedServer?.name ?? '', - '/library/sections', - !isUndefined(selectedServer), - ); - useEffect(() => { - if (directoryChildren) { - if (directoryChildren.size > 0) { - setProgrammingListLibrary(directoryChildren.Directory[0]); + if (selectedServer && plexLibraryChildren) { + if (plexLibraryChildren.size > 0) { + setProgrammingListLibrary({ + type: 'plex', + library: plexLibraryChildren.Directory[0], + }); } addKnownMediaForServer(selectedServer!.name, [ - ...directoryChildren.Directory, + ...plexLibraryChildren.Directory, ]); } - setCollectionsOpen(false); - }, [selectedServer, directoryChildren]); - - const { isLoading: searchLoading, data: searchData } = useInfiniteQuery({ - queryKey: [ - 'plex-search', - selectedServer?.name, - selectedLibrary?.key, - debounceSearch, - ] as DataTag< - ['plex-search', string, string, string], - PlexLibraryMovies | PlexLibraryShows - >, - enabled: !isNil(selectedServer) && !isNil(selectedLibrary), - initialPageParam: 0, - queryFn: ({ pageParam }) => { - const plexQuery = new URLSearchParams({ - 'Plex-Container-Start': pageParam.toString(), - 'Plex-Container-Size': '10', - }); - - if (!isNil(debounceSearch) && !isEmpty(debounceSearch)) { - plexQuery.set('title<', debounceSearch); - } - - return fetchPlexPath( - selectedServer!.name, - `/library/sections/${selectedLibrary!.key}/all?${plexQuery.toString()}`, - )(); - }, - getNextPageParam: (res, _, last) => { - if (res.size < 10) { - return null; - } + }, [selectedServer, plexLibraryChildren]); - return last + 10; - }, - }); - - const { data: collectionsData } = useQuery({ - queryKey: [ - 'plex', - selectedServer?.name, - selectedLibrary?.key, - 'collections', - ], - queryFn: () => { - return fetchPlexPath( - selectedServer!.name, - `/library/sections/${selectedLibrary!.key}/collections?`, - )(); - }, - enabled: !isNil(selectedServer) && !isNil(selectedLibrary), + /** + * Load custom shows + */ + const { data: customShows } = useCustomShows([], { + enabled: viewingCustomShows, }); useEffect(() => { - if (searchData) { - // We're using this as an analogue for detecting the start of a new 'query' - if (searchData.pages.length === 1) { - setScrollParams({ - limit: 16, - max: first(searchData.pages)!.size, - }); - } - - // We probably wouldn't have made it this far if we didnt have a server, but - // putting this here to prevent crashes - if (selectedServer) { - const allMedia = chain(searchData.pages) - .reject((page) => page.size === 0) - .map((page) => page.Metadata) - .flatten() - .value(); - addKnownMediaForServer(selectedServer.name, allMedia); - } - } - }, [selectedServer, searchData, setScrollParams]); - - useEffect(() => { - if (selectedServer?.name && collectionsData && collectionsData.Metadata) { - addKnownMediaForServer(selectedServer.name, collectionsData.Metadata); + if ( + mediaSource === 'custom-shows' && + customShows && + customShows.length > 0 + ) { + setProgrammingListLibrary({ + type: 'custom-show', + library: customShows[0], + }); } - }, [selectedServer?.name, collectionsData]); + }, [mediaSource, customShows]); + + const onMediaSourceChange = useCallback( + (mediaSource: string) => { + if (mediaSource === 'custom-shows') { + // Not dealing with a server + setProgrammingListingServer(undefined); + setMediaSource(mediaSource); + } else { + const server = find(plexServers, { name: mediaSource }); + if (server) { + setProgrammingListingServer(server); + setMediaSource(server.name); + } + } + }, + [plexServers], + ); const onLibraryChange = useCallback( (libraryUuid: string) => { @@ -217,88 +125,23 @@ export default function ProgrammingSelector({ onAddSelectedMedia }: Props) { const known = knownMedia[selectedServer.name] ?? {}; const library = known[libraryUuid]; if (library && isPlexDirectory(library)) { - setProgrammingListLibrary(library); + setProgrammingListLibrary({ type: 'plex', library }); } } }, [knownMedia, selectedServer], ); - const { ref } = useIntersectionObserver({ - onChange: (_, entry) => { - if (entry.isIntersecting && scrollParams.limit < scrollParams.max) { - setScrollParams(({ limit: prevLimit, max }) => ({ - max, - limit: prevLimit + 10, - })); - } - }, - threshold: 0.5, - }); - - const clearSearchInput = useCallback(() => { - setSearch(''); - setSearchBarOpen(false); - }, [setSearch]); - - const renderListItems = () => { - const elements: JSX.Element[] = []; - - if (collectionsData && collectionsData.size > 0) { - elements.push( - - setCollectionsOpen(toggle)} - dense - sx={ - viewType === 'grid' ? { display: 'block', width: '100%' } : null - } - > - - {collectionsOpen ? : } - - - - - {map(collectionsData.Metadata, (item) => - viewType === 'list' ? ( - - ) : ( - - ), - )} - - , + const renderMediaSourcePrograms = () => { + if (selectedLibrary?.type === 'custom-show') { + return ; + } else if (selectedLibrary?.type === 'plex') { + return ( + ); } - if (searchData) { - const items = chain(searchData.pages) - .reject((page) => page.size === 0) - .map((page) => page.Metadata) - .flatten() - .take(scrollParams.limit) - .value(); - elements.push( - ...map(items, (item: PlexMovie | PlexTvShow) => { - return viewType === 'list' ? ( - - ) : ( - - ); - }), - ); - } - return elements; + return null; }; return ( @@ -312,10 +155,16 @@ export default function ProgrammingSelector({ onAddSelectedMedia }: Props) { flexGrow: 1, }} > - {selectedServer && ( + {plexServers && ( Media Source - onMediaSourceChange(e.target.value)} + > {plexServers?.map((server) => ( Plex: {server.name} @@ -326,132 +175,43 @@ export default function ProgrammingSelector({ onAddSelectedMedia }: Props) { )} - {!isNil(directoryChildren) && - directoryChildren.size > 0 && - selectedLibrary && ( + {!isNil(plexLibraryChildren) && + plexLibraryChildren.size > 0 && + selectedPlexLibrary && ( Library )} - - {!isNil(directoryChildren) && - directoryChildren.size > 0 && - selectedLibrary && ( - <> - + Custom Show + + )} - {plexServers?.length === 0 ? ( - - - - ) : ( - <> - - - {renderListItems()} -
-
- - - Selected Items - - - )} +
+ {renderMediaSourcePrograms()} ); } diff --git a/web/src/hooks/useCustomShows.ts b/web/src/hooks/useCustomShows.ts index 6cbe4407c..59c35f357 100644 --- a/web/src/hooks/useCustomShows.ts +++ b/web/src/hooks/useCustomShows.ts @@ -1,18 +1,38 @@ -import { DataTag, useQuery } from '@tanstack/react-query'; +import { + DataTag, + DefinedInitialDataOptions, + useQuery, +} from '@tanstack/react-query'; import { CustomShow } from '@tunarr/types'; import { apiClient } from '../external/api.ts'; import { ZodiosAliasReturnType } from '../types/index.ts'; import { makeQueryOptionsInitialData } from './useQueryHelpers.ts'; -export const customShowsQuery = (initialData: CustomShow[] = []) => +export type CustomShowsQueryOpts = Omit< + DefinedInitialDataOptions< + CustomShow[], + Error, + CustomShow[], + DataTag<['custom-shows'], CustomShow[]> + >, + 'queryKey' | 'queryFn' | 'initialData' +>; + +export const customShowsQuery = ( + initialData: CustomShow[] = [], + opts?: CustomShowsQueryOpts, +) => makeQueryOptionsInitialData( ['custom-shows'], () => apiClient.getCustomShows(), initialData, + opts ?? {}, ); -export const useCustomShows = (initialData: CustomShow[] = []) => - useQuery(customShowsQuery(initialData)); +export const useCustomShows = ( + initialData: CustomShow[] = [], + opts?: CustomShowsQueryOpts, +) => useQuery(customShowsQuery(initialData, opts ?? {})); export const customShowQuery = (id: string) => ({ queryKey: ['custom-shows', id] as DataTag< diff --git a/web/src/hooks/useQueryHelpers.ts b/web/src/hooks/useQueryHelpers.ts index f0156373f..c515dbc0e 100644 --- a/web/src/hooks/useQueryHelpers.ts +++ b/web/src/hooks/useQueryHelpers.ts @@ -4,6 +4,7 @@ import { QueryFunction, UseQueryOptions, } from '@tanstack/react-query'; +import { NonUndefinedGuard } from '../types'; export function makeQueryOptions< K extends readonly unknown[], @@ -25,8 +26,6 @@ export function makeQueryOptions< }; } -type NonUndefinedGuard = T extends undefined ? never : T; - export function makeQueryOptionsInitialData< K extends readonly unknown[], Fn extends QueryFunction, diff --git a/web/src/hooks/useRouteName.ts b/web/src/hooks/useRouteName.ts index 902da2cda..204f03fb1 100644 --- a/web/src/hooks/useRouteName.ts +++ b/web/src/hooks/useRouteName.ts @@ -2,6 +2,18 @@ import { find, memoize } from 'lodash-es'; type Route = { matcher: RegExp; name: string }; +const uuidRegexPattern = + '[0-9a-fA-F]{8}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{12}'; + +const entityPageMatcher = (entity: string, path: string) => + new RegExp(`^\/${entity}\/${uuidRegexPattern}\/${path}\/?$`); + +const channelsPageMatcher = (path: string) => + entityPageMatcher('channels', path); + +const customShowsPageMatcher = (path: string) => + entityPageMatcher('library/custom-shows', path); + const namedRoutes: Route[] = [ { matcher: /^\/channels$/g, @@ -12,33 +24,31 @@ const namedRoutes: Route[] = [ name: 'New', }, { - matcher: - /^\/channels\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}\/watch$/g, + matcher: channelsPageMatcher('watch'), name: 'Watch', }, { - matcher: - /^\/channels\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}\/edit$/g, + matcher: channelsPageMatcher('edit'), + name: 'Edit', + }, + { + matcher: customShowsPageMatcher('edit'), name: 'Edit', }, { - matcher: - /^\/channels\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}\/programming$/g, + matcher: channelsPageMatcher('programming'), name: 'Programming', }, { - matcher: - /^\/channels\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}\/programming\/add$/g, + matcher: channelsPageMatcher('programming/add'), name: 'Add', }, { - matcher: - /^\/channels\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}\/programming\/time-slot-editor$/g, + matcher: channelsPageMatcher('time-slot-editor'), name: 'Time Slot Editor', }, { - matcher: - /^\/channels\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}\/programming\/random-slot-editor$/g, + matcher: channelsPageMatcher('random-slot-editor'), name: 'Random Slot Editor', }, { @@ -64,7 +74,6 @@ const namedRoutes: Route[] = [ ]; const getRouteName = memoize((path: string) => { - console.log(path); return find(namedRoutes, ({ matcher }) => { return matcher.test(path); })?.name; diff --git a/web/src/pages/library/EditCustomShowPage.tsx b/web/src/pages/library/EditCustomShowPage.tsx index 653b3897d..a09a77e56 100644 --- a/web/src/pages/library/EditCustomShowPage.tsx +++ b/web/src/pages/library/EditCustomShowPage.tsx @@ -25,7 +25,10 @@ import AddSelectedMediaButton from '../../components/channel_config/AddSelectedM import ProgrammingSelector from '../../components/channel_config/ProgrammingSelector.tsx'; import { apiClient } from '../../external/api.ts'; import { usePreloadedData } from '../../hooks/preloadedDataHook.ts'; -import { customShowLoader } from '../../preloaders/customShowLoaders.ts'; +import { + existingCustomShowLoader, + newCustomShowLoader, +} from '../../preloaders/customShowLoaders.ts'; import { addPlexMediaToCurrentCustomShow, removeCustomShowProgram, @@ -40,7 +43,9 @@ type CustomShowForm = { }; export default function EditCustomShowPage({ isNew }: Props) { - const customShow = usePreloadedData(customShowLoader(isNew)); + const { show: customShow } = usePreloadedData( + isNew ? existingCustomShowLoader : newCustomShowLoader, + ); const customShowPrograms = useStore((s) => s.customShowEditor.programList); const queryClient = useQueryClient(); const navigate = useNavigate(); @@ -57,10 +62,11 @@ export default function EditCustomShowPage({ isNew }: Props) { }); useEffect(() => { + console.log(customShow, 'reset'); reset({ name: customShow.name, }); - }, [customShow.name, reset]); + }, [customShow, reset]); const saveShowMutation = useMutation({ mutationKey: ['custom-shows', isNew ? 'new' : customShow.id], @@ -147,7 +153,7 @@ export default function EditCustomShowPage({ isNew }: Props) { - New Custom Show + {isNew ? 'New' : 'Edit'} Custom Show diff --git a/web/src/preloaders/customShowLoaders.ts b/web/src/preloaders/customShowLoaders.ts index f08e567e2..bfab57e43 100644 --- a/web/src/preloaders/customShowLoaders.ts +++ b/web/src/preloaders/customShowLoaders.ts @@ -10,7 +10,12 @@ import { import { setCurrentCustomShow } from '../store/channelEditor/actions.ts'; import { Preloader } from '../types/index.ts'; -export const customShowLoader = (isNew: boolean) => { +export type CustomShowPreload = { + show: CustomShow; + programs: CustomShowProgramming; +}; + +export const customShowLoader = (isNew: boolean): Preloader => { if (!isNew) { return createPreloader( ({ params }) => customShowQuery(params.id!), @@ -29,20 +34,17 @@ export const customShowLoader = (isNew: boolean) => { } }; -export const newCustomShowLoader: Preloader<{ - show: CustomShow; - programs: CustomShowProgramming; -}> = (queryClient: QueryClient) => (args: LoaderFunctionArgs) => { - return customShowLoader(true)(queryClient)(args).then((show) => ({ - show, - programs: [], - })); -}; +export const newCustomShowLoader: Preloader = + (queryClient: QueryClient) => (args: LoaderFunctionArgs) => { + return customShowLoader(true)(queryClient)(args).then((show) => ({ + show, + programs: [], + })); + }; -export const existingCustomShowLoader: Preloader<{ - show: CustomShow; - programs: CustomShowProgramming; -}> = (queryClient: QueryClient) => { +export const existingCustomShowLoader: Preloader = ( + queryClient: QueryClient, +) => { const showLoader = customShowLoader(false)(queryClient); return async (args: LoaderFunctionArgs) => { @@ -53,7 +55,6 @@ export const existingCustomShowLoader: Preloader<{ return await Promise.all([showLoaderPromise, programsPromise]).then( ([show, programs]) => { - console.log(show, programs); setCurrentCustomShow(show, programs); return { show, diff --git a/web/src/store/customShowEditor/store.ts b/web/src/store/customShowEditor/store.ts new file mode 100644 index 000000000..e69de29bb diff --git a/web/src/store/programmingSelector/actions.ts b/web/src/store/programmingSelector/actions.ts index d776ba6bb..6578341d3 100644 --- a/web/src/store/programmingSelector/actions.ts +++ b/web/src/store/programmingSelector/actions.ts @@ -7,7 +7,7 @@ import { } from '@tunarr/types/plex'; import { map, reject } from 'lodash-es'; import useStore from '..'; -import { SelectedMedia } from './store'; +import { SelectedLibrary, SelectedMedia } from './store'; export const setProgrammingListingServer = ( server: PlexServerSettings | undefined, @@ -16,7 +16,7 @@ export const setProgrammingListingServer = ( state.currentServer = server; }); -export const setProgrammingListLibrary = (library: PlexLibrarySection) => +export const setProgrammingListLibrary = (library: SelectedLibrary) => useStore.setState((state) => { state.currentLibrary = library; }); diff --git a/web/src/store/programmingSelector/store.ts b/web/src/store/programmingSelector/store.ts index 8ca715e63..8723f6d39 100644 --- a/web/src/store/programmingSelector/store.ts +++ b/web/src/store/programmingSelector/store.ts @@ -1,5 +1,5 @@ -import { PlexServerSettings } from '@tunarr/types'; -import { PlexMedia, PlexLibrarySection } from '@tunarr/types/plex'; +import { CustomShow, PlexServerSettings } from '@tunarr/types'; +import { PlexLibrarySection, PlexMedia } from '@tunarr/types/plex'; import { StateCreator } from 'zustand'; type ServerName = string; @@ -10,9 +10,21 @@ export interface SelectedMedia { guid: PlexItemGuid; } +export type PlexLibrary = { + type: 'plex'; + library: PlexLibrarySection; +}; + +export type CustomShowLibrary = { + type: 'custom-show'; + library: CustomShow; +}; + +export type SelectedLibrary = PlexLibrary | CustomShowLibrary; + export interface ProgrammingListingsState { currentServer?: PlexServerSettings; - currentLibrary?: PlexLibrarySection; + currentLibrary?: SelectedLibrary; // Tracks the parent-child mappings of library items contentHierarchyByServer: Record< ServerName, diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 584dbc928..cd718e246 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -105,3 +105,4 @@ export const isUIRedirectProgram = ( export type UIFillerListProgram = (ContentProgram | CustomProgram) & UIIndex; export type UICustomShowProgram = (ContentProgram | CustomProgram) & UIIndex; +export type NonUndefinedGuard = T extends undefined ? never : T;