diff --git a/src/components/Collection/CardViewItem.tsx b/src/components/Collection/CardViewItem.tsx deleted file mode 100644 index d17a1c769..000000000 --- a/src/components/Collection/CardViewItem.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import React from 'react'; -import { Link } from 'react-router-dom'; -import { - mdiAlertCircleOutline, - mdiCalendarMonthOutline, - mdiEyeCheckOutline, - mdiFileDocumentMultipleOutline, - mdiPencilCircleOutline, - mdiTagTextOutline, - mdiTelevision, - mdiTelevisionAmbientLight, -} from '@mdi/js'; -import { Icon } from '@mdi/react'; -import cx from 'classnames'; -import { forEach } from 'lodash'; -import moment from 'moment/moment'; - -import BackgroundImagePlaceholderDiv from '@/components/BackgroundImagePlaceholderDiv'; -import useMainPoster from '@/hooks/useMainPoster'; - -import AnidbDescription from './AnidbDescription'; - -import type { CollectionGroupType } from '@/core/types/api/collection'; -import type { SeriesSizesFileSourcesType } from '@/core/types/api/series'; -import type { WebuiGroupExtra } from '@/core/types/api/webui'; - -const renderFileSources = (sources: SeriesSizesFileSourcesType): string => { - const output: string[] = []; - forEach(sources, (source, type) => { - if (source !== 0) output.push(type); - }); - return output.join(' / '); -}; - -const SeriesTag = ({ text, type }) => ( -
- - {text} -
-); - -const CardViewItem = ({ item, mainSeries }: { item: CollectionGroupType, mainSeries?: WebuiGroupExtra }) => { - const poster = useMainPoster(item); - const missingEpisodesCount = item.Sizes.Total.Episodes + item.Sizes.Total.Specials - item.Sizes.Local.Episodes - - item.Sizes.Local.Specials; - - const viewRouteLink = () => { - let link = '/webui/collection/'; - - if (item.Size === 1) { - link += `series/${item.IDs.MainSeries}`; - } else { - link += `group/${item.IDs.ID}`; - } - - return link; - }; - - const isSeriesOngoing = () => { - if (!mainSeries?.EndDate) return true; - return moment(mainSeries.EndDate) > moment(); - }; - - return ( -
-
- - -
- - - -
-
- -
-
{item.Name}
- -
-
-
- - {renderFileSources(item.Sizes.FileSources)} -
-
- - - {moment(mainSeries?.AirDate).format('MMMM Do, YYYY')} -  -  - {!mainSeries?.EndDate ? 'Current' : moment(mainSeries?.EndDate).format('MMMM Do, YYYY')} - -
- {isSeriesOngoing() && ( -
- - Ongoing Series -
- )} -
- -
-
- - - Episodes  - {item.Sizes.Local.Episodes} -  /  - {item.Sizes.Total.Episodes} -  | Specials  - {item.Sizes.Local.Specials} -  /  - {item.Sizes.Total.Specials} - -
-
- - - Episodes  - {item.Sizes.Watched.Episodes} -  /  - {item.Sizes.Total.Episodes} -  | Specials  - {item.Sizes.Watched.Specials} -  /  - {item.Sizes.Total.Specials} - -
-
- - - {item.Sizes.Total.Episodes - item.Sizes.Local.Episodes} -  ( - {item.Sizes.Total.Specials - item.Sizes.Local.Specials} - ) - -
-
-
- -
- -
-
-
-
- {mainSeries?.Tags.slice(0, 10).map(tag => ( - - )) ?? ''} -
-
- ); -}; - -export default CardViewItem; diff --git a/src/components/Collection/CollectionView.tsx b/src/components/Collection/CollectionView.tsx index da74ce9fd..75687d7a7 100644 --- a/src/components/Collection/CollectionView.tsx +++ b/src/components/Collection/CollectionView.tsx @@ -8,56 +8,102 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import cx from 'classnames'; import { debounce } from 'lodash'; -import CardViewItem from '@/components/Collection/CardViewItem'; -import GridViewItem from '@/components/Collection/GridViewItem'; -import { useLazyGetGroupsQuery } from '@/core/rtkQuery/splitV3Api/collectionApi'; +import ListViewItem from '@/components/Collection/ListViewItem'; +import PosterViewItem from '@/components/Collection/PosterViewItem'; +import { useLazyGetGroupSeriesQuery, useLazyGetGroupsQuery } from '@/core/rtkQuery/splitV3Api/collectionApi'; +import { useGetSettingsQuery } from '@/core/rtkQuery/splitV3Api/settingsApi'; import { useLazyGetGroupViewQuery } from '@/core/rtkQuery/splitV3Api/webuiApi'; +import { initialSettings } from '@/pages/settings/SettingsPage'; + +import type { SeriesType } from '@/core/types/api/series'; type Props = { mode: string; setGroupTotal: (total: number) => void; + setTimelineSeries: (series: SeriesType[]) => void; + type: 'collection' | 'group'; + isSidebarOpen: boolean; }; -const pageSize = 50; +const defaultPageSize = 50; + +export const posterItemSize = { + width: 209, + height: 363, + gap: 16, +}; + +export const listItemSize = { + width: 907, + height: 328, + widthAlt: 682, + gap: 32, +}; -const CollectionView = (props: Props) => { - const { mode, setGroupTotal } = props; +const CollectionView = ({ isSidebarOpen, mode, setGroupTotal, setTimelineSeries, type }: Props) => { + const { filterId, groupId } = useParams(); - const { filterId } = useParams(); + const settingsQuery = useGetSettingsQuery(); + const settings = useMemo(() => settingsQuery?.data ?? initialSettings, [settingsQuery]); + const { showRandomPoster: showRandomPosterGrid } = settings.WebUI_Settings.collection.poster; + const { showRandomPoster: showRandomPosterList } = settings.WebUI_Settings.collection.list; + const showRandomPoster = useMemo( + () => (mode === 'poster' ? showRandomPosterGrid : showRandomPosterList), + [mode, showRandomPosterGrid, showRandomPosterList], + ); const [itemWidth, itemHeight, itemGap] = useMemo(() => { - // Gaps are intentionally left with + notation to remove the need for calculations if dimensions are being changed - if (mode === 'grid') return [209 + 16, 337 + 16, 16]; // + 16 is to account for gap/margin - return [907 + 32, 328 + 32, 32]; // + 32 is to account for gap/margin - }, [mode]); + if (mode === 'poster') return [posterItemSize.width, posterItemSize.height, posterItemSize.gap]; + return [ + (groupId || isSidebarOpen) ? listItemSize.widthAlt : listItemSize.width, + listItemSize.height, + listItemSize.gap, + ]; + }, [isSidebarOpen, mode, groupId]); const [fetchingPage, setFetchingPage] = useState(false); const [fetchGroups, groupsData] = useLazyGetGroupsQuery(); - const groupPages = groupsData.data?.pages ?? {}; - const groupTotal = groupsData.data?.total ?? 0; + const [fetchSeries, seriesData] = useLazyGetGroupSeriesQuery(); + + const pages = useMemo( + () => (type === 'collection' ? groupsData.data?.pages : seriesData.data?.pages) ?? {}, + [groupsData, seriesData, type], + ); + const total = (type === 'collection' ? groupsData.data?.total : seriesData.data?.total) ?? 0; const [fetchGroupExtras, groupExtrasData] = useLazyGetGroupViewQuery(); const groupExtras = groupExtrasData.data ?? []; useEffect(() => { - setGroupTotal(groupTotal); - }, [groupTotal, setGroupTotal]); + setGroupTotal(total); + if (type === 'group' && pages[1]) { + setTimelineSeries(pages[1] as SeriesType[]); + } + }, [total, setGroupTotal, type, pages, setTimelineSeries]); + + // 999 to make it effectively infinite since Group/{id}/Series is not paginated + const pageSize = useMemo(() => (type === 'collection' ? defaultPageSize : 999), [type]); const fetchPage = useMemo(() => debounce((page: number) => { - fetchGroups({ page, pageSize, filterId: filterId ?? '0' }).then((result) => { - if (!result.data) return; - - const ids = result.data.pages[page].map(group => group.IDs.ID); - fetchGroupExtras({ - GroupIDs: ids, - TagFilter: 128, - TagLimit: 20, - OrderByName: true, - }).then().catch(error => console.error(error)); - }).catch(error => console.error(error)).finally(() => setFetchingPage(false)); - }, 200), [filterId, fetchGroups, fetchGroupExtras]); + if (type === 'collection') { + fetchGroups({ page, pageSize, filterId: filterId ?? '0', randomImages: showRandomPoster }).then( + (result) => { + if (!result.data) return; + + const ids = result.data.pages[page].map(group => group.IDs.ID); + fetchGroupExtras({ + GroupIDs: ids, + TagFilter: 128, + TagLimit: 20, + }).then().catch(error => console.error(error)); + }, + ).catch(error => console.error(error)).finally(() => setFetchingPage(false)); + } else { + fetchSeries({ groupId, randomImages: showRandomPoster }).then().catch(error => console.error(error)); + } + }, 200), [filterId, fetchGroups, fetchGroupExtras, fetchSeries, groupId, pageSize, showRandomPoster, type]); useEffect(() => { fetchPage.cancel(); @@ -72,22 +118,22 @@ const CollectionView = (props: Props) => { const [gridContainerRef, gridContainerBounds] = useMeasure(); - const itemsPerRow = Math.max(1, Math.floor((gridContainerBounds.width + itemGap) / itemWidth)); - const count = useMemo(() => Math.ceil(groupTotal / itemsPerRow), [groupTotal, itemsPerRow]); + const itemsPerRow = Math.max(1, Math.floor(gridContainerBounds.width / itemWidth)); + const count = useMemo(() => Math.ceil(total / itemsPerRow), [total, itemsPerRow]); const virtualizer = useVirtualizer({ count, getScrollElement: () => scrollRef.current, - estimateSize: () => itemHeight, + estimateSize: () => itemHeight + itemGap, overscan: 2, }); - if (groupTotal === 0) { + if (total === 0) { return (
{groupsData.isUninitialized || groupsData.isLoading @@ -101,7 +147,7 @@ const CollectionView = (props: Props) => {
@@ -121,8 +167,8 @@ const CollectionView = (props: Props) => { const neededPage1 = Math.ceil((fromIndex + 1) / pageSize); const neededPage2 = Math.ceil(toIndex / pageSize); - const groupList1 = groupPages[neededPage1]; - const groupList2 = groupPages[neededPage2]; + const groupList1 = pages[neededPage1]; + const groupList2 = pages[neededPage2]; if (groupList1 === undefined && !fetchingPage) { setFetchingPage(true); @@ -138,38 +184,45 @@ const CollectionView = (props: Props) => { for (let i = fromIndex; i < toIndex; i += 1) { const neededPage = Math.ceil((i + 1) / pageSize); const relativeIndex = i % pageSize; - const groupList = groupPages[neededPage]; + const groupList = pages[neededPage]; const item = groupList !== undefined ? groupList[relativeIndex] : undefined; // Placeholder to solve formatting issues. // Used to fill the empty "slots" in the last row - const isPlaceholder = i > groupTotal - 1; + const isPlaceholder = i > total - 1; if (isPlaceholder) { items.push( -
, +
, ); } else if (item) { items.push( - mode === 'grid' - ? + mode === 'poster' + ? : ( - extra.ID === item.IDs.ID)} key={`group-${item.IDs.ID}`} + isSeries={type === 'group'} + isSidebarOpen={isSidebarOpen} /> ), ); } else { items.push(
, @@ -180,12 +233,17 @@ const CollectionView = (props: Props) => { return (
{items}
diff --git a/src/components/Collection/CountIcon.tsx b/src/components/Collection/CountIcon.tsx deleted file mode 100644 index e995aa223..000000000 --- a/src/components/Collection/CountIcon.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import cx from 'classnames'; - -const CountIcon = ({ children, className, show = true }) => ( - show - ? ( -
- {children} -
- ) - : null -); - -export default CountIcon; diff --git a/src/components/Collection/DisplaySettingsModal.tsx b/src/components/Collection/DisplaySettingsModal.tsx new file mode 100644 index 000000000..44501a8f6 --- /dev/null +++ b/src/components/Collection/DisplaySettingsModal.tsx @@ -0,0 +1,153 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { cloneDeep } from 'lodash'; + +import Button from '@/components/Input/Button'; +import Checkbox from '@/components/Input/Checkbox'; +import ModalPanel from '@/components/Panels/ModalPanel'; +import { useGetSettingsQuery, usePatchSettingsMutation } from '@/core/rtkQuery/splitV3Api/settingsApi'; +import { initialSettings } from '@/pages/settings/SettingsPage'; + +type Props = { + show: boolean; + onClose(): void; +}; + +const DisplaySettingsModal = ({ onClose, show }: Props) => { + const dispatch = useDispatch(); + + const settingsQuery = useGetSettingsQuery(); + const settings = useMemo(() => settingsQuery?.data ?? initialSettings, [settingsQuery]); + const [patchSettings] = usePatchSettingsMutation(); + + const [newSettings, setNewSettings] = useState(initialSettings); + + useEffect(() => { + setNewSettings(settings); + }, [dispatch, settings]); + + const updatePosterViewSetting = (key: keyof typeof settings.WebUI_Settings.collection.poster, value: boolean) => { + const tempSettings = cloneDeep(newSettings); + tempSettings.WebUI_Settings.collection.poster[key] = value; + setNewSettings(tempSettings); + }; + + const updateListViewSetting = (key: keyof typeof settings.WebUI_Settings.collection.list, value: boolean) => { + const tempSettings = cloneDeep(newSettings); + tempSettings.WebUI_Settings.collection.list[key] = value; + setNewSettings(tempSettings); + }; + + const { list: listSettings, poster: posterSettings } = newSettings.WebUI_Settings.collection; + + const handleSave = async () => { + try { + await patchSettings({ oldSettings: settings, newSettings }).unwrap(); + onClose(); + } catch (error) { /* empty */ } + }; + + const handleCancel = () => { + setNewSettings(initialSettings); + onClose(); + }; + + return ( + +
+
+
Poster View Options
+
+ updatePosterViewSetting('showEpisodeCount', event.target.checked)} + /> + updatePosterViewSetting('showGroupIndicator', event.target.checked)} + /> + updatePosterViewSetting('showUnwatchedCount', event.target.checked)} + /> + updatePosterViewSetting('showRandomPoster', event.target.checked)} + /> +
+
+ +
+
List View Options
+
+ updateListViewSetting('showItemType', event.target.checked)} + /> + updateListViewSetting('showGroupIndicator', event.target.checked)} + /> + updateListViewSetting('showTopTags', event.target.checked)} + /> + updateListViewSetting('showCustomTags', event.target.checked)} + /> + updateListViewSetting('showRandomPoster', event.target.checked)} + /> +
+
+ +
+ + +
+
+
+ ); +}; + +export default DisplaySettingsModal; diff --git a/src/components/Collection/GridViewItem.tsx b/src/components/Collection/GridViewItem.tsx deleted file mode 100644 index 67a3399cb..000000000 --- a/src/components/Collection/GridViewItem.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from 'react'; -import { Link } from 'react-router-dom'; -import { mdiPencilCircleOutline } from '@mdi/js'; -import { Icon } from '@mdi/react'; - -import BackgroundImagePlaceholderDiv from '@/components/BackgroundImagePlaceholderDiv'; -import CountIcon from '@/components/Collection/CountIcon'; -import useMainPoster from '@/hooks/useMainPoster'; - -import type { CollectionGroupType } from '@/core/types/api/collection'; -import type { SeriesType } from '@/core/types/api/series'; - -type Props = { - item: CollectionGroupType | SeriesType; - isSeries?: boolean; -}; - -const GridViewItem = ({ isSeries = false, item }: Props) => { - const mainPoster = useMainPoster(item); - const unwatchedCount = item.Sizes.Local.Episodes + item.Sizes.Local.Specials - item.Sizes.Watched.Episodes - - item.Sizes.Watched.Specials; - let groupCount = 0; - - if (!isSeries) { - const groupItem = item as CollectionGroupType; - groupCount = groupItem.Sizes.SeriesTypes.Movie + groupItem.Sizes.SeriesTypes.OVA + groupItem.Sizes.SeriesTypes.Other - + groupItem.Sizes.SeriesTypes.TV + groupItem.Sizes.SeriesTypes.TVSpecial + groupItem.Sizes.SeriesTypes.Unknown - + groupItem.Sizes.SeriesTypes.Web; - } - - const viewRouteLink = () => { - let link = '/webui/collection/'; - - if (isSeries) { - link += `series/${item.IDs.ID}`; - } else if (item.Size === 1) { - link += `series/${(item as CollectionGroupType).IDs.MainSeries}`; - } else { - link += `group/${item.IDs.ID}`; - } - - return link; - }; - - return ( - -
- -
- 0} className="bg-overlay-count-episode">{unwatchedCount} - {!isSeries && 1} className="bg-overlay-count-group">{item.Size}} -
-
- - - -
-
-

{item.Name}

-
- - ); -}; - -export default GridViewItem; diff --git a/src/components/Collection/ListViewItem.tsx b/src/components/Collection/ListViewItem.tsx new file mode 100644 index 000000000..c0d940c6c --- /dev/null +++ b/src/components/Collection/ListViewItem.tsx @@ -0,0 +1,231 @@ +import React, { useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { + mdiAlertCircleOutline, + mdiCalendarMonthOutline, + mdiEyeOutline, + mdiFileDocumentMultipleOutline, + mdiPencilCircleOutline, + mdiTagTextOutline, + mdiTelevision, + mdiTelevisionAmbientLight, +} from '@mdi/js'; +import { Icon } from '@mdi/react'; +import cx from 'classnames'; +import { forEach, reduce } from 'lodash'; +import moment from 'moment/moment'; + +import BackgroundImagePlaceholderDiv from '@/components/BackgroundImagePlaceholderDiv'; +import { listItemSize } from '@/components/Collection/CollectionView'; +import { useGetSeriesTagsQuery } from '@/core/rtkQuery/splitV3Api/seriesApi'; +import { useGetSettingsQuery } from '@/core/rtkQuery/splitV3Api/settingsApi'; +import { formatThousand } from '@/core/util'; +import useMainPoster from '@/hooks/useMainPoster'; +import { initialSettings } from '@/pages/settings/SettingsPage'; + +import AnidbDescription from './AnidbDescription'; + +import type { CollectionGroupType } from '@/core/types/api/collection'; +import type { SeriesSizesFileSourcesType, SeriesType } from '@/core/types/api/series'; +import type { WebuiGroupExtra } from '@/core/types/api/webui'; + +const renderFileSources = (sources: SeriesSizesFileSourcesType): string => { + const output: string[] = []; + forEach(sources, (source, type) => { + if (source !== 0) output.push(type); + }); + return output.join(' | '); +}; + +const SeriesTag = ({ text, type }: { text: string, type: 'AniDB' | 'User' }) => ( +
+ + {text} +
+); + +type Props = { + item: CollectionGroupType | SeriesType; + isSeries?: boolean; + mainSeries?: WebuiGroupExtra; + isSidebarOpen: boolean; +}; + +const ListViewItem = ({ isSeries, isSidebarOpen, item, mainSeries }: Props) => { + const settingsQuery = useGetSettingsQuery(undefined, { refetchOnMountOrArgChange: false }); + const settings = useMemo(() => settingsQuery?.data ?? initialSettings, [settingsQuery]); + const { showCustomTags, showGroupIndicator, showItemType, showTopTags } = settings.WebUI_Settings.collection.list; + + const seriesTags = useGetSeriesTagsQuery({ + seriesId: item.IDs.ID.toString(), + filter: 128, + excludeDescriptions: true, + }, { skip: !isSeries }); + + const poster = useMainPoster(item); + const missingEpisodesCount = item.Sizes.Total.Episodes + item.Sizes.Total.Specials - item.Sizes.Local.Episodes + - item.Sizes.Local.Specials; + + const [airDate, description, endDate, groupCount] = useMemo(() => { + if (isSeries) { + const series = (item as SeriesType).AniDB; + return [series?.AirDate, series?.Description, series?.EndDate, 0]; + } + + const group = item as CollectionGroupType; + const tempCount = reduce(group.Sizes.SeriesTypes, (count, value) => count + value, 0); + return [mainSeries?.AirDate, group.Description, mainSeries?.EndDate, tempCount]; + }, [isSeries, item, mainSeries?.AirDate, mainSeries?.EndDate]); + + const viewRouteLink = () => { + let link = '/webui/collection/'; + + if (isSeries) { + link += `series/${item.IDs.ID}`; + } else if (item.Size === 1) { + link += `series/${(item as CollectionGroupType).IDs.MainSeries}`; + } else { + link += `group/${item.IDs.ID}`; + } + + return link; + }; + + const isSeriesOngoing = useMemo(() => { + if (!endDate) return true; + return moment(endDate) > moment(); + }, [endDate]); + + const tags = useMemo( + () => { + let tempTags = (isSeries ? seriesTags?.data : mainSeries?.Tags) ?? []; + if (!showTopTags) tempTags = tempTags.filter(tag => tag.Source !== 'AniDB'); + if (!showCustomTags) tempTags = tempTags.filter(tag => tag.Source !== 'User'); + tempTags = tempTags.toSorted((tagA, tagB) => tagB.Source.localeCompare(tagA.Source)); + return tempTags.slice(0, 10); + }, + [isSeries, mainSeries?.Tags, seriesTags, showCustomTags, showTopTags], + ); + + return ( +
+
+ + +
+ + + +
+ {showGroupIndicator && groupCount > 1 && ( +
+ {groupCount} +  Series +
+ )} +
+ +
+
{item.Name}
+ +
+
+ {showItemType && ( +
+ + {renderFileSources(item.Sizes.FileSources)} +
+ )} +
+ + + {moment(airDate).format('MMMM Do, YYYY')} + {airDate !== endDate && ( + <> +  -  + {!endDate ? 'Current' : moment(endDate).format('MMMM Do, YYYY')} + + )} + +
+ {isSeriesOngoing && ( +
+ + Ongoing Series +
+ )} +
+ +
+
+ + + EP:  + {formatThousand(item.Sizes.Local.Episodes)} +  /  + {formatThousand(item.Sizes.Total.Episodes)} +  | SP:  + {formatThousand(item.Sizes.Local.Specials)} +  /  + {formatThousand(item.Sizes.Total.Specials)} + +
+
+ + + EP:  + {formatThousand(item.Sizes.Watched.Episodes)} +  /  + {formatThousand(item.Sizes.Total.Episodes)} +  | SP:  + {formatThousand(item.Sizes.Watched.Specials)} +  /  + {formatThousand(item.Sizes.Total.Specials)} + +
+
+ + + {formatThousand(item.Sizes.Total.Episodes - item.Sizes.Local.Episodes)} +  ( + {formatThousand(item.Sizes.Total.Specials - item.Sizes.Local.Specials)} + ) + +
+
+
+ +
+ +
+
+
+ {tags.length > 0 && ( +
+ {tags.map(tag => ) ?? ''} +
+ )} +
+ ); +}; + +export default ListViewItem; diff --git a/src/components/Collection/PosterViewItem.tsx b/src/components/Collection/PosterViewItem.tsx new file mode 100644 index 000000000..282d970eb --- /dev/null +++ b/src/components/Collection/PosterViewItem.tsx @@ -0,0 +1,96 @@ +import React, { useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { mdiCheckboxMarkedCircleOutline, mdiPencilCircleOutline } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import { reduce } from 'lodash'; + +import BackgroundImagePlaceholderDiv from '@/components/BackgroundImagePlaceholderDiv'; +import { posterItemSize } from '@/components/Collection/CollectionView'; +import { useGetSettingsQuery } from '@/core/rtkQuery/splitV3Api/settingsApi'; +import useMainPoster from '@/hooks/useMainPoster'; +import { initialSettings } from '@/pages/settings/SettingsPage'; + +import type { CollectionGroupType } from '@/core/types/api/collection'; +import type { SeriesType } from '@/core/types/api/series'; + +type Props = { + item: CollectionGroupType | SeriesType; + isSeries?: boolean; +}; + +const PosterViewItem = ({ isSeries = false, item }: Props) => { + const settingsQuery = useGetSettingsQuery(undefined, { refetchOnMountOrArgChange: false }); + const settings = useMemo(() => settingsQuery?.data ?? initialSettings, [settingsQuery]); + const { showEpisodeCount, showGroupIndicator, showUnwatchedCount } = settings.WebUI_Settings.collection.poster; + + const mainPoster = useMainPoster(item); + const episodeCount = item.Sizes.Local.Episodes + item.Sizes.Local.Specials; + const unwatchedCount = episodeCount - item.Sizes.Watched.Episodes - item.Sizes.Watched.Specials; + let groupCount = 0; + + if (!isSeries) { + groupCount = reduce((item as CollectionGroupType).Sizes.SeriesTypes, (count, value) => count + value, 0); + } + + const viewRouteLink = () => { + let link = '/webui/collection/'; + + if (isSeries) { + link += `series/${item.IDs.ID}`; + } else if (item.Size === 1) { + link += `series/${(item as CollectionGroupType).IDs.MainSeries}`; + } else { + link += `group/${item.IDs.ID}`; + } + + return link; + }; + + return ( + +
+ + {showUnwatchedCount && ( +
+ {unwatchedCount || ( + + )} +
+ )} +
+ + + +
+ {showGroupIndicator && !isSeries && groupCount > 1 && ( +
+ {groupCount} +  Series +
+ )} +
+

{item.Name}

+ {showEpisodeCount && ( +

+ {episodeCount} +  Episodes +

+ )} +
+ + ); +}; + +export default PosterViewItem; diff --git a/src/core/router/index.tsx b/src/core/router/index.tsx index 8ae075366..76da9cffc 100644 --- a/src/core/router/index.tsx +++ b/src/core/router/index.tsx @@ -6,7 +6,6 @@ import { RouterProvider, createBrowserRouter, createRoutesFromElements } from 'r import { useGetSettingsQuery } from '@/core/rtkQuery/splitV3Api/settingsApi'; import Collection from '@/pages/collection/Collection'; -import GroupView from '@/pages/collection/GroupView'; import Series from '@/pages/collection/Series'; import SeriesCredits from '@/pages/collection/series/SeriesCredits'; import SeriesEpisodes from '@/pages/collection/series/SeriesEpisodes'; @@ -86,9 +85,9 @@ const router = createBrowserRouter( } /> - } /> - } /> - } /> + } /> + } /> + } /> }> } /> } /> @@ -115,7 +114,7 @@ const router = createBrowserRouter( const Router = () => { const apikey = useSelector((state: RootState) => state.apiSession.apikey); - const webuiPreviewTheme = useSelector((state: RootState) => state.misc.webuiPreviewTheme) as string; + const webuiPreviewTheme = (useSelector((state: RootState) => state.misc.webuiPreviewTheme) ?? '') as string; const settingsQuery = useGetSettingsQuery(undefined, { skip: apikey === '' }); const { theme } = settingsQuery.data?.WebUI_Settings ?? initialSettings.WebUI_Settings; diff --git a/src/core/rtkQuery/splitV3Api.ts b/src/core/rtkQuery/splitV3Api.ts index 94aaf13f6..4b43fd5a8 100644 --- a/src/core/rtkQuery/splitV3Api.ts +++ b/src/core/rtkQuery/splitV3Api.ts @@ -13,12 +13,14 @@ export const splitV3Api = createApi({ }, }), tagTypes: [ + 'AVDumpEvent', 'EpisodeUpdated', 'FileDeleted', 'FileHashed', 'FileIgnored', 'FileMatched', 'ImportFolder', + 'QueueItems', 'SeriesAniDB', 'SeriesEpisodes', 'SeriesUpdated', @@ -27,8 +29,6 @@ export const splitV3Api = createApi({ 'SeriesSearch', 'UtilitiesRefresh', 'WebUIUpdateCheck', - 'QueueItems', - 'AVDumpEvent', ], refetchOnMountOrArgChange: true, endpoints: () => ({}), diff --git a/src/core/rtkQuery/splitV3Api/collectionApi.ts b/src/core/rtkQuery/splitV3Api/collectionApi.ts index 00e05a38f..d8afe5733 100644 --- a/src/core/rtkQuery/splitV3Api/collectionApi.ts +++ b/src/core/rtkQuery/splitV3Api/collectionApi.ts @@ -9,10 +9,13 @@ import type { SeriesType } from '@/core/types/api/series'; const collectionApi = splitV3Api.injectEndpoints({ endpoints: build => ({ - getGroups: build.query, PaginationType & { filterId: string }>({ - query: ({ filterId, ...params }) => ({ + getGroups: build.query< + InfiniteResultType, + PaginationType & { filterId: string, randomImages?: boolean } + >({ + query: ({ filterId, randomImages, ...params }) => ({ url: `Filter/${filterId}/Group`, - params: { includeEmpty: true, ...params }, + params: { includeEmpty: true, randomImages, ...params }, }), transformResponse: (response: ListResultType, _, args) => ({ pages: { @@ -38,7 +41,7 @@ const collectionApi = splitV3Api.injectEndpoints({ return currentArg !== previousArg; }, }), - getGroup: build.query({ + getGroup: build.query({ query: ({ groupId }) => ({ url: `Group/${groupId}` }), }), getGroupLetters: build.query<{ [index: string]: number }, { includeEmpty: boolean, topLevelOnly: boolean }>({ @@ -47,8 +50,21 @@ const collectionApi = splitV3Api.injectEndpoints({ params: { includeEmpty, topLevelOnly }, }), }), - getGroupSeries: build.query({ - query: ({ groupId }) => ({ url: `Group/${groupId}/Series` }), + getGroupSeries: build.query, { groupId: string, randomImages?: boolean }>({ + query: ({ groupId, randomImages = true }) => ({ + url: `Group/${groupId}/Series`, + params: { + randomImages, + recursive: true, + includeDataFrom: 'AniDB', + }, + }), + transformResponse: (response: SeriesType[]) => ({ + pages: { + 1: response, + }, + total: response.length, + }), }), getTopFilters: build.query, PaginationType>({ query: params => ({ url: 'Filter', params: { page: params.page ?? 1, pageSize: params.pageSize ?? 0 } }), @@ -74,8 +90,8 @@ const collectionApi = splitV3Api.injectEndpoints({ export const { useGetFilterQuery, useGetGroupQuery, - useGetGroupSeriesQuery, useLazyGetFiltersQuery, + useLazyGetGroupSeriesQuery, useLazyGetGroupsQuery, useLazyGetTopFiltersQuery, } = collectionApi; diff --git a/src/core/rtkQuery/splitV3Api/seriesApi.ts b/src/core/rtkQuery/splitV3Api/seriesApi.ts index df1e12465..ffccb3c67 100644 --- a/src/core/rtkQuery/splitV3Api/seriesApi.ts +++ b/src/core/rtkQuery/splitV3Api/seriesApi.ts @@ -162,7 +162,7 @@ const seriesApi = splitV3Api.injectEndpoints({ providesTags: ['SeriesAniDB'], }), - getSeriesTags: build.query({ + getSeriesTags: build.query({ query: ({ seriesId, ...params }) => ({ url: `Series/${seriesId}/Tags`, params, diff --git a/src/core/rtkQuery/splitV3Api/webuiApi.ts b/src/core/rtkQuery/splitV3Api/webuiApi.ts index 9b3da0d0b..8e3dcdeca 100644 --- a/src/core/rtkQuery/splitV3Api/webuiApi.ts +++ b/src/core/rtkQuery/splitV3Api/webuiApi.ts @@ -12,7 +12,7 @@ export type GroupViewApiRequest = { GroupIDs: number[]; TagFilter: number; TagLimit: number; - OrderByName: boolean; + OrderByName?: boolean; }; export type SeriesOverviewApiRequest = { diff --git a/src/core/types/api/series.ts b/src/core/types/api/series.ts index b555fc973..22e36c3ec 100644 --- a/src/core/types/api/series.ts +++ b/src/core/types/api/series.ts @@ -14,6 +14,7 @@ export type SeriesType = { Links: SeriesLinkType[]; Created: string; Updated: string; + AniDB?: SeriesAniDBType; }; export type SeriesRelationType = { diff --git a/src/core/types/api/settings.ts b/src/core/types/api/settings.ts index 3e003f128..fc10483cf 100644 --- a/src/core/types/api/settings.ts +++ b/src/core/types/api/settings.ts @@ -183,6 +183,22 @@ export type WebUISettingsType = { layout: { [key: string]: LayoutType; }; + collection: { + view: 'poster' | 'list'; + poster: { + showEpisodeCount: boolean; + showGroupIndicator: boolean; + showUnwatchedCount: boolean; + showRandomPoster: boolean; + }; + list: { + showItemType: boolean; + showGroupIndicator: boolean; + showTopTags: boolean; + showCustomTags: boolean; + showRandomPoster: boolean; + }; + }; }; export type SettingsType = Omit & { diff --git a/src/core/types/api/webui.ts b/src/core/types/api/webui.ts index 63afb7c97..0c126c843 100644 --- a/src/core/types/api/webui.ts +++ b/src/core/types/api/webui.ts @@ -1,13 +1,7 @@ import type { CollectionFilterType } from './collection'; import type { ImageType, RatingType } from './common'; import type { SeriesTitleType } from './series'; - -export type WebuiGroupExtraTag = { - ID: number; - Name: string; - Weight: number; - Source: 'AniDB' | 'User'; -}; +import type { TagType } from '@/core/types/api/tags'; export type WebuiGroupExtra = { ID: number; @@ -15,7 +9,7 @@ export type WebuiGroupExtra = { Rating: RatingType; AirDate: string | null; EndDate: string | null; - Tags: WebuiGroupExtraTag[]; + Tags: TagType[]; }; export type WebuiSeriesRolePerson = { diff --git a/src/css/theme-shoko-gray.css b/src/css/theme-shoko-gray.css index 52dc681a2..6ac1ac698 100644 --- a/src/css/theme-shoko-gray.css +++ b/src/css/theme-shoko-gray.css @@ -31,10 +31,12 @@ --color-panel-background-alt: #21242b; --color-panel-background-toolbar: #282e38; --color-panel-background-transparent: #2c333ee6; + --color-panel-background-overlay: #1e2027e5; --color-panel-border: #21242b; --color-panel-border-alt: #3f4762; --color-panel-text: #cfd8e3; --color-panel-text-alt: #010f1c; + --color-panel-text-transparent: #cfd8e3a6; --color-panel-primary: #44a3ff; --color-panel-primary-hover: #8ed3ff; --color-panel-important: #10c469; diff --git a/src/pages/collection/Collection.tsx b/src/pages/collection/Collection.tsx index d0000170d..f41596a4c 100644 --- a/src/pages/collection/Collection.tsx +++ b/src/pages/collection/Collection.tsx @@ -1,13 +1,24 @@ -import React, { useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router'; -import { mdiCogOutline, mdiFilterOutline, mdiFormatListText, mdiViewGridOutline } from '@mdi/js'; +import { Link } from 'react-router-dom'; +import { mdiCogOutline, mdiFilterMenuOutline, mdiFilterOutline, mdiFormatListText, mdiViewGridOutline } from '@mdi/js'; import { Icon } from '@mdi/react'; import cx from 'classnames'; +import { cloneDeep } from 'lodash'; +import moment from 'moment/moment'; +import BackgroundImagePlaceholderDiv from '@/components/BackgroundImagePlaceholderDiv'; import CollectionTitle from '@/components/Collection/CollectionTitle'; import CollectionView from '@/components/Collection/CollectionView'; +import DisplaySettingsModal from '@/components/Collection/DisplaySettingsModal'; import FiltersModal from '@/components/Dialogs/FiltersModal'; -import { useGetFilterQuery } from '@/core/rtkQuery/splitV3Api/collectionApi'; +import { useGetFilterQuery, useGetGroupQuery } from '@/core/rtkQuery/splitV3Api/collectionApi'; +import { useGetSettingsQuery, usePatchSettingsMutation } from '@/core/rtkQuery/splitV3Api/settingsApi'; +import { SeriesTypeEnum } from '@/core/types/api/series'; +import useMainPoster from '@/hooks/useMainPoster'; +import { initialSettings } from '@/pages/settings/SettingsPage'; + +import type { SeriesType } from '@/core/types/api/series'; const OptionButton = ({ icon, onClick }) => (
(
); -function Collection() { - const { filterId } = useParams(); +const TimelineItem = ({ series }: { series: SeriesType }) => { + const mainPoster = useMainPoster(series); + const seriesType = series.AniDB?.Type === SeriesTypeEnum.TVSpecial + ? 'TV Special' + : series.AniDB?.Type; + + return ( + +
+ +
+
+ {moment(series.AniDB?.AirDate).year()} +  |  +
{seriesType}
+
+
{series.Name}
+
+
+ + ); +}; + +const TimelineSidebar = ({ series }: { series: SeriesType[] }) => ( +
+
+
Timeline
+
+ {series.map(item => )} +
+
+
+); + +type Props = { + type: 'collection' | 'group'; +}; + +function Collection({ type }: Props) { + const { filterId, groupId } = useParams(); const filterData = useGetFilterQuery({ filterId }, { skip: !filterId }); - const filterName = filterId && filterData?.data?.Name; + const groupData = useGetGroupQuery({ groupId: groupId! }, { skip: !groupId }); + const subsectionName = type === 'collection' ? filterData?.data?.Name : groupData?.data?.Name; + + const settingsQuery = useGetSettingsQuery(); + const settings = useMemo(() => settingsQuery?.data ?? initialSettings, [settingsQuery]); + const viewSetting = settings.WebUI_Settings.collection.view; + const [patchSettings] = usePatchSettingsMutation(); - const [mode, setMode] = useState('grid'); + const [mode, setMode] = useState<'poster' | 'list'>('poster'); const [showFilterSidebar, setShowFilterSidebar] = useState(false); const [showFilterModal, setShowFilterModal] = useState(false); + const [showDisplaySettingsModal, setShowDisplaySettingsModal] = useState(false); const [groupTotal, setGroupTotal] = useState(0); + const [timelineSeries, setTimelineSeries] = useState([]); + + useEffect(() => { + setMode(viewSetting); + }, [viewSetting]); + + const toggleMode = async () => { + const newMode = mode === 'list' ? 'poster' : 'list'; + // Optimistically update view mode to reduce lag without waiting for settings refetch. + setMode(newMode); + const newSettings = cloneDeep(settings); + newSettings.WebUI_Settings.collection.view = newMode; + patchSettings({ oldSettings: settings, newSettings }).catch(console.error); + }; - const toggleMode = () => setMode(mode === 'card' ? 'grid' : 'card'); const toggleFilters = () => { setShowFilterSidebar(!showFilterSidebar); }; @@ -38,28 +110,41 @@ function Collection() { <>
- +
- - - setShowFilterModal(true)} icon={mdiCogOutline} /> + {!groupId && ( + <> + setShowFilterModal(true)} icon={mdiFilterMenuOutline} /> + + + )} + + setShowDisplaySettingsModal(true)} icon={mdiCogOutline} />
- +
-
+
Filter sidebar
+ {groupId && }
setShowFilterModal(false)} /> + setShowDisplaySettingsModal(false)} /> ); } diff --git a/src/pages/collection/GroupView.tsx b/src/pages/collection/GroupView.tsx deleted file mode 100644 index 710595cd0..000000000 --- a/src/pages/collection/GroupView.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React, { useMemo } from 'react'; -import { useParams } from 'react-router'; -import useMeasure from 'react-use-measure'; -import { chunk } from 'lodash'; - -import CollectionTitle from '@/components/Collection/CollectionTitle'; -import GridViewItem from '@/components/Collection/GridViewItem'; -import { useGetGroupQuery, useGetGroupSeriesQuery } from '@/core/rtkQuery/splitV3Api/collectionApi'; - -import type { SeriesType } from '@/core/types/api/series'; - -const itemWidth = 209 + 16; - -const GroupView = () => { - const { groupId } = useParams(); - - const group = useGetGroupQuery({ groupId: Number(groupId) }, { skip: !groupId }); - const series = useGetGroupSeriesQuery({ groupId }, { skip: !groupId }); - const seriesInGroup = useMemo(() => series?.data ?? [] as SeriesType[], [series]); - - const [gridContainerRef, gridContainerBounds] = useMeasure(); - const itemsPerRow = Math.max(1, Math.floor((gridContainerBounds.width - 40) / itemWidth)); - const seriesRows = useMemo(() => chunk(seriesInGroup, itemsPerRow), [itemsPerRow, seriesInGroup]); - - // Placeholder to solve formatting issues, same as CollectionView - const placeholderItems = useMemo(() => { - if (!seriesRows.length) return []; - const placeholderDeficit = itemsPerRow - seriesRows[seriesRows.length - 1].length; - const items = [] as React.ReactNode[]; - for (let i = 0; i < placeholderDeficit; i += 1) { - items.push(
); - } - return items; - }, [itemsPerRow, seriesRows]); - - return ( -
-
- -
-
- {seriesRows.map((row, idx) => ( -
- {row.map(item => )} - {idx === (seriesRows.length - 1) && placeholderItems} -
- ))} -
-
- ); -}; - -export default React.memo(GroupView); diff --git a/src/pages/collection/Series.tsx b/src/pages/collection/Series.tsx index 14717a86a..b62044a2b 100644 --- a/src/pages/collection/Series.tsx +++ b/src/pages/collection/Series.tsx @@ -77,7 +77,9 @@ const Series = () => { const mainPoster = useMainPoster(series); const tagsData = useGetSeriesTagsQuery({ seriesId: seriesId!, excludeDescriptions: true }, { skip: !seriesId }); const tags: TagType[] = tagsData?.data ?? [] as TagType[]; - const groupData = useGetGroupQuery({ groupId: series.IDs?.ParentGroup }, { skip: !series.IDs?.ParentGroup }); + const groupData = useGetGroupQuery({ groupId: series.IDs?.ParentGroup.toString() }, { + skip: !series.IDs?.ParentGroup, + }); const group = groupData?.data ?? {} as CollectionGroupType; useEffect(() => { diff --git a/src/pages/firstrun/FirstRunPage.tsx b/src/pages/firstrun/FirstRunPage.tsx index 662e55632..17066a95d 100644 --- a/src/pages/firstrun/FirstRunPage.tsx +++ b/src/pages/firstrun/FirstRunPage.tsx @@ -75,7 +75,6 @@ function FirstRunPage() { const saveSettings = async () => { try { await patchSettings({ oldSettings: settings, newSettings, skipValidation: true }).unwrap(); - await settingsQuery.refetch(); } catch (error) { console.error(error); } diff --git a/src/pages/settings/SettingsPage.tsx b/src/pages/settings/SettingsPage.tsx index 180b32df1..ab852b340 100644 --- a/src/pages/settings/SettingsPage.tsx +++ b/src/pages/settings/SettingsPage.tsx @@ -410,6 +410,22 @@ export const initialSettings = { toastPosition: 'bottom-right', updateChannel: semver.prerelease(uiVersion()) ? 'Dev' : 'Stable', layout: initialLayout, + collection: { + view: 'poster', + poster: { + showEpisodeCount: true, + showGroupIndicator: true, + showUnwatchedCount: true, + showRandomPoster: false, + }, + list: { + showItemType: true, + showGroupIndicator: true, + showTopTags: true, + showCustomTags: true, + showRandomPoster: false, + }, + }, }, FirstRun: false, Database: { @@ -544,7 +560,6 @@ function SettingsPage() { const saveSettings = async () => { try { await patchSettings({ oldSettings: settings, newSettings }).unwrap(); - await settingsQuery.refetch(); } catch (error) { /* empty */ } }; diff --git a/tailwind.config.js b/tailwind.config.js index 69146f5b7..06fd9e323 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -75,10 +75,12 @@ module.exports = { 'panel-background-alt': 'var(--color-panel-background-alt)', 'panel-background-toolbar': 'var(--color-panel-background-toolbar)', 'panel-background-transparent': 'var(--color-panel-background-transparent)', + 'panel-background-overlay': 'var(--color-panel-background-overlay)', 'panel-border': 'var(--color-panel-border)', 'panel-border-alt': 'var(--color-panel-border-alt)', 'panel-text': 'var(--color-panel-text)', 'panel-text-alt': 'var(--color-panel-text-alt)', + 'panel-text-transparent': 'var(--color-panel-text-transparent)', 'panel-primary': 'var(--color-panel-primary)', 'panel-primary-hover': 'var(--color-panel-primary-hover)', 'panel-important': 'var(--color-panel-important)',