mirror of
https://github.com/jellyfin/jellyfin-web.git
synced 2026-09-03 03:50:02 +03:00
Add Alpha Picker to Genres view and implement infinite scroll (#8167)
* Add Alpha Picker to Genres view and implement infinite scroll * Fix CI * Keep alphabet picker mounted while genres load * Use Paper background * Apply review suggestions
This commit is contained in:
@@ -1,38 +1,31 @@
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import Box from '@mui/material/Box';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import ToggleButton from '@mui/material/ToggleButton';
|
||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||
|
||||
import type { LibraryViewSettings } from 'types/library';
|
||||
|
||||
import 'components/alphaPicker/style.scss';
|
||||
|
||||
interface AlphabetPickerProps {
|
||||
libraryViewSettings: LibraryViewSettings;
|
||||
setLibraryViewSettings: React.Dispatch<
|
||||
React.SetStateAction<LibraryViewSettings>
|
||||
>;
|
||||
value?: string | null;
|
||||
onChange: (value: string | null | undefined) => void;
|
||||
}
|
||||
|
||||
const LETTER_VALUES = ['#', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'];
|
||||
|
||||
const AlphabetPicker: React.FC<AlphabetPickerProps> = ({
|
||||
libraryViewSettings,
|
||||
setLibraryViewSettings
|
||||
value,
|
||||
onChange
|
||||
}) => {
|
||||
const handleValue = useCallback(
|
||||
(
|
||||
event: React.MouseEvent<HTMLElement>,
|
||||
newValue: string | null | undefined
|
||||
) => {
|
||||
setLibraryViewSettings((prevState) => ({
|
||||
...prevState,
|
||||
StartIndex: 0,
|
||||
Alphabet: newValue
|
||||
}));
|
||||
onChange(newValue);
|
||||
},
|
||||
[setLibraryViewSettings]
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -56,36 +49,44 @@ const AlphabetPicker: React.FC<AlphabetPickerProps> = ({
|
||||
zIndex: theme.zIndex.appBar - 1
|
||||
})}
|
||||
>
|
||||
<ToggleButtonGroup
|
||||
orientation='vertical'
|
||||
value={libraryViewSettings.Alphabet}
|
||||
exclusive
|
||||
color='primary'
|
||||
size='small'
|
||||
onChange={handleValue}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{LETTER_VALUES.map((l) => (
|
||||
<ToggleButton
|
||||
key={l}
|
||||
value={l}
|
||||
sx={{
|
||||
borderWidth: 0,
|
||||
paddingTop: {
|
||||
xs: 0,
|
||||
md: 0.25
|
||||
},
|
||||
paddingBottom: {
|
||||
xs: 0,
|
||||
md: 0.25
|
||||
},
|
||||
paddingLeft: 0.5,
|
||||
paddingRight: 0.5
|
||||
}}
|
||||
>
|
||||
{l}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
<ToggleButtonGroup
|
||||
orientation='vertical'
|
||||
value={value}
|
||||
exclusive
|
||||
color='primary'
|
||||
size='small'
|
||||
onChange={handleValue}
|
||||
>
|
||||
{LETTER_VALUES.map((l) => (
|
||||
<ToggleButton
|
||||
key={l}
|
||||
value={l}
|
||||
sx={{
|
||||
borderWidth: 0,
|
||||
paddingTop: {
|
||||
xs: 0,
|
||||
md: 0.25
|
||||
},
|
||||
paddingBottom: {
|
||||
xs: 0,
|
||||
md: 0.25
|
||||
},
|
||||
paddingLeft: 0.5,
|
||||
paddingRight: 0.5
|
||||
}}
|
||||
>
|
||||
{l}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import type { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||
import type { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type';
|
||||
import React, { FC } from 'react';
|
||||
import { useGetGenres } from 'hooks/useFetchItems';
|
||||
import Box from '@mui/material/Box';
|
||||
import React, { FC, useEffect, useMemo, useState } from 'react';
|
||||
import { useIntersectionObserver } from 'usehooks-ts';
|
||||
|
||||
import NoItemsMessage from 'components/common/NoItemsMessage';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import GenresSectionContainer from './GenresSectionContainer';
|
||||
import type { ParentId } from 'types/library';
|
||||
|
||||
import { useGenres } from '../hooks/api/useGenres';
|
||||
import GenresSectionContainer from './GenresSectionContainer';
|
||||
import AlphabetPicker from './AlphabetPicker';
|
||||
|
||||
interface GenresItemsContainerProps {
|
||||
parentId: ParentId;
|
||||
collectionType: CollectionType | undefined;
|
||||
@@ -17,27 +22,74 @@ const GenresItemsContainer: FC<GenresItemsContainerProps> = ({
|
||||
parentId,
|
||||
collectionType,
|
||||
itemType
|
||||
// eslint-disable-next-line sonarjs/function-return-type
|
||||
}) => {
|
||||
const { isLoading, data: genresResult } = useGetGenres(itemType, parentId);
|
||||
const [alphabet, setAlphabet] = useState<string | null>();
|
||||
const {
|
||||
isLoading,
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage
|
||||
} = useGenres({
|
||||
parentId,
|
||||
includeItemTypes: itemType,
|
||||
alphabet
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
const genres = useMemo(
|
||||
() => data?.pages.flatMap((page) => page?.Items ?? []) ?? [],
|
||||
[data]
|
||||
);
|
||||
|
||||
if (!genresResult?.Items?.length) {
|
||||
const { ref: sentinelRef, isIntersecting } = useIntersectionObserver({
|
||||
rootMargin: '200px'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||
void fetchNextPage();
|
||||
}
|
||||
}, [isIntersecting, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
// No genres at all (no letter filter active) - nothing to pick from
|
||||
if (!isLoading && !genres.length && alphabet == null) {
|
||||
return <NoItemsMessage message='MessageNoGenresAvailable' />;
|
||||
}
|
||||
|
||||
return genresResult.Items.map((genre) => (
|
||||
<GenresSectionContainer
|
||||
key={genre.Id}
|
||||
collectionType={collectionType}
|
||||
parentId={parentId}
|
||||
itemType={itemType}
|
||||
genre={genre}
|
||||
/>
|
||||
));
|
||||
const renderGenres = () => {
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (!genres.length) {
|
||||
return <NoItemsMessage message='MessageNoGenresAvailable' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{genres.map((genre) => (
|
||||
<GenresSectionContainer
|
||||
key={genre.Id}
|
||||
collectionType={collectionType}
|
||||
parentId={parentId}
|
||||
itemType={itemType}
|
||||
genre={genre}
|
||||
/>
|
||||
))}
|
||||
|
||||
{hasNextPage && <Box ref={sentinelRef} sx={{ height: '1px' }} />}
|
||||
{isFetchingNextPage && <Loading />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlphabetPicker value={alphabet} onChange={setAlphabet} />
|
||||
|
||||
{renderGenres()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenresItemsContainer;
|
||||
|
||||
@@ -33,8 +33,11 @@ const ItemsView: FC = () => {
|
||||
} = useLibrary();
|
||||
const viewType = content?.viewType ?? LibraryTab.Movies;
|
||||
const libraryViewSettings = viewSettings ?? getDefaultLibraryViewSettings(viewType);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const setLibraryViewSettings = setViewSettings ?? ((action: SetStateAction<LibraryViewSettings>) => { /* no-op */ });
|
||||
const setLibraryViewSettings = useMemo(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
() => setViewSettings ?? ((action: SetStateAction<LibraryViewSettings>) => { /* no-op */ }),
|
||||
[setViewSettings]
|
||||
);
|
||||
const { isAlphabetPickerEnabled, noItemsMessage } = content ?? {};
|
||||
// Check if the alphabet picker will fit in the current viewport
|
||||
const isAlphabetPickerSupported = useMediaQuery(t => [
|
||||
@@ -180,6 +183,14 @@ const ItemsView: FC = () => {
|
||||
noItemsMessage
|
||||
]);
|
||||
|
||||
const handleAlphabetChange = useCallback((newValue: string | null | undefined) => {
|
||||
setLibraryViewSettings((prevState) => ({
|
||||
...prevState,
|
||||
StartIndex: 0,
|
||||
Alphabet: newValue
|
||||
}));
|
||||
}, [setLibraryViewSettings]);
|
||||
|
||||
const hasSortName = !libraryViewSettings.SortBy.includes(ItemSortBy.Random);
|
||||
|
||||
const itemsContainerClass = classNames(
|
||||
@@ -193,8 +204,8 @@ const ItemsView: FC = () => {
|
||||
<Box className='padded-bottom-page'>
|
||||
{isAlphabetPickerSupported && isAlphabetPickerEnabled && hasSortName && (
|
||||
<AlphabetPicker
|
||||
libraryViewSettings={libraryViewSettings}
|
||||
setLibraryViewSettings={setLibraryViewSettings}
|
||||
value={libraryViewSettings.Alphabet}
|
||||
onChange={handleAlphabetChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Api } from '@jellyfin/sdk/lib/api';
|
||||
import type { GenreApiGetGenresRequest } from '@jellyfin/sdk/lib/generated-client/api/genre-api';
|
||||
import type { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
|
||||
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by';
|
||||
import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order';
|
||||
import { getGenreApi } from '@jellyfin/sdk/lib/utils/api/genre-api';
|
||||
import { infiniteQueryOptions, useInfiniteQuery } from '@tanstack/react-query';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import type { ItemDtoQueryResult } from 'types/base/models/item-dto-query-result';
|
||||
import type { ParentId } from 'types/library';
|
||||
|
||||
export const GENRES_PAGE_SIZE = 10;
|
||||
|
||||
interface GenresParams {
|
||||
parentId?: ParentId;
|
||||
includeItemTypes?: BaseItemKind[];
|
||||
/** Filter by the first letter of the genre name; '#' matches everything sorting before 'A'. */
|
||||
alphabet?: string | null;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
const fetchGenres = async (
|
||||
api: Api,
|
||||
params: GenreApiGetGenresRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
const response = await getGenreApi(api).getGenres(params, options);
|
||||
return response.data as ItemDtoQueryResult;
|
||||
};
|
||||
|
||||
/** Query options for fetching genres. */
|
||||
export const getGenresQuery = (
|
||||
api?: Api,
|
||||
params: GenresParams = {}
|
||||
) => infiniteQueryOptions({
|
||||
queryKey: ['Genres', params.parentId, params.includeItemTypes, params.alphabet],
|
||||
queryFn: ({ pageParam, signal }) => fetchGenres(
|
||||
api!,
|
||||
{
|
||||
userId: params.userId,
|
||||
includeItemTypes: params.includeItemTypes,
|
||||
parentId: params.parentId ?? undefined,
|
||||
sortBy: [ItemSortBy.SortName],
|
||||
sortOrder: [SortOrder.Ascending],
|
||||
nameLessThan: params.alphabet === '#' ? 'A' : undefined,
|
||||
nameStartsWith: params.alphabet === '#' ? undefined : (params.alphabet ?? undefined),
|
||||
enableTotalRecordCount: false,
|
||||
startIndex: pageParam * GENRES_PAGE_SIZE,
|
||||
limit: GENRES_PAGE_SIZE
|
||||
},
|
||||
{ signal }
|
||||
),
|
||||
initialPageParam: 0,
|
||||
// Stop once a page returns fewer items than requested (cheaper than enabling total record count)
|
||||
getNextPageParam: (lastPage, allPages) =>
|
||||
(lastPage?.Items?.length ?? 0) < GENRES_PAGE_SIZE ? undefined : allPages.length,
|
||||
enabled: !!api && !!params.userId && !!params.parentId
|
||||
});
|
||||
|
||||
/** Hook for fetching genres. */
|
||||
export const useGenres = (params?: GenresParams) => {
|
||||
const { api, user } = useApi();
|
||||
return useInfiniteQuery(getGenresQuery(
|
||||
api,
|
||||
{
|
||||
...params,
|
||||
userId: params?.userId || user?.Id
|
||||
}
|
||||
));
|
||||
};
|
||||
@@ -10,7 +10,6 @@ import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-
|
||||
import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order';
|
||||
import { getArtistApi } from '@jellyfin/sdk/lib/utils/api/artist-api';
|
||||
import { getFilterApi } from '@jellyfin/sdk/lib/utils/api/filter-api';
|
||||
import { getGenreApi } from '@jellyfin/sdk/lib/utils/api/genre-api';
|
||||
import { getPersonApi } from '@jellyfin/sdk/lib/utils/api/person-api';
|
||||
import { getStudioApi } from '@jellyfin/sdk/lib/utils/api/studio-api';
|
||||
import { getShowApi } from '@jellyfin/sdk/lib/utils/api/show-api';
|
||||
@@ -72,41 +71,6 @@ export const useGetItems = (parametersOptions: LibraryApiGetItemsRequest) => {
|
||||
});
|
||||
};
|
||||
|
||||
const fetchGetGenres = async (
|
||||
currentApi: JellyfinApiContext,
|
||||
itemType: BaseItemKind[],
|
||||
parentId: ParentId,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
const { api, user } = currentApi;
|
||||
if (api && user?.Id) {
|
||||
const response = await getGenreApi(api).getGenres(
|
||||
{
|
||||
userId: user.Id,
|
||||
sortBy: [ItemSortBy.SortName],
|
||||
sortOrder: [SortOrder.Ascending],
|
||||
includeItemTypes: itemType,
|
||||
enableTotalRecordCount: false,
|
||||
parentId: parentId ?? undefined
|
||||
},
|
||||
{
|
||||
signal: options?.signal
|
||||
}
|
||||
);
|
||||
return response.data as ItemDtoQueryResult;
|
||||
}
|
||||
};
|
||||
|
||||
export const useGetGenres = (itemType: BaseItemKind[], parentId: ParentId) => {
|
||||
const currentApi = useApi();
|
||||
return useQuery({
|
||||
queryKey: ['Genres', parentId],
|
||||
queryFn: ({ signal }) =>
|
||||
fetchGetGenres(currentApi, itemType, parentId, { signal }),
|
||||
enabled: !!currentApi.api && !!currentApi.user?.Id && !!parentId
|
||||
});
|
||||
};
|
||||
|
||||
const fetchGetStudios = async (
|
||||
currentApi: JellyfinApiContext,
|
||||
parentId: ParentId,
|
||||
|
||||
Reference in New Issue
Block a user