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} />
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)',