-
Notifications
You must be signed in to change notification settings - Fork 27
/
Copy pathSeriesOverview.tsx
243 lines (225 loc) · 9.21 KB
/
SeriesOverview.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
import React, { useMemo, useState } from 'react';
import { useParams } from 'react-router';
import { mdiEarth, mdiOpenInNew } from '@mdi/js';
import { Icon } from '@mdi/react';
import cx from 'classnames';
import { flatMap, get, map, round, toNumber } from 'lodash';
import CharacterImage from '@/components/CharacterImage';
import EpisodeSummary from '@/components/Collection/Episode/EpisodeSummary';
import SeriesMetadata from '@/components/Collection/SeriesMetadata';
import MultiStateButton from '@/components/Input/MultiStateButton';
import ShokoPanel from '@/components/Panels/ShokoPanel';
import SeriesPoster from '@/components/SeriesPoster';
import {
useRelatedAnimeQuery,
useSeriesCastQuery,
useSeriesNextUpQuery,
useSeriesQuery,
useSimilarAnimeQuery,
} from '@/core/react-query/series/queries';
import useEventCallback from '@/hooks/useEventCallback';
import type { ImageType } from '@/core/types/api/common';
import type { SeriesCast } from '@/core/types/api/series';
// Links
const MetadataLinks = ['AniDB', 'TMDB', 'TraktTv'] as const;
const SeriesOverview = () => {
const { seriesId } = useParams();
const { data: series, ...seriesQuery } = useSeriesQuery(
toNumber(seriesId!),
{ includeDataFrom: ['AniDB', 'TMDB'] },
!!seriesId,
);
const nextUpEpisodeQuery = useSeriesNextUpQuery(toNumber(seriesId!), {
includeDataFrom: ['AniDB'],
includeMissing: false,
onlyUnwatched: false,
}, !!seriesId);
const relatedAnimeQuery = useRelatedAnimeQuery(toNumber(seriesId!), !!seriesId);
const similarAnimeQuery = useSimilarAnimeQuery(toNumber(seriesId!), !!seriesId);
const tabStates = [
{ label: 'Metadata Sites', value: 'metadata' },
{ label: 'Series Links', value: 'links' },
];
const [currentTab, setCurrentTab] = useState<string>(tabStates[0].value);
const handleTabStateChange = useEventCallback((newState: string) => {
setCurrentTab(newState);
});
const relatedAnime = useMemo(() => relatedAnimeQuery?.data ?? [], [relatedAnimeQuery.data]);
const similarAnime = useMemo(() => similarAnimeQuery?.data ?? [], [similarAnimeQuery.data]);
const cast = useSeriesCastQuery(toNumber(seriesId!), !!seriesId).data;
const getThumbnailUrl = (item: SeriesCast, mode: string) => {
const thumbnail = get<SeriesCast, string, ImageType | null>(item, `${mode}.Image`, null);
if (thumbnail === null) return null;
return `/api/v3/Image/${thumbnail.Source}/${thumbnail.Type}/${thumbnail.ID}`;
};
return (
<>
<div className="flex gap-x-6">
<div className="flex w-full gap-x-6">
<ShokoPanel
title="Metadata Sites"
className="flex w-full max-w-[37.5rem]"
transparent
disableOverflow
options={
<MultiStateButton states={tabStates} activeState={currentTab} onStateChange={handleTabStateChange} />
}
isFetching={seriesQuery.isFetching}
>
{series && currentTab === 'metadata' && (
<div
className={cx(
'flex h-[15.625rem] flex-col gap-3 overflow-y-auto lg:gap-x-4 2xl:flex-nowrap 2xl:gap-x-6',
// TODO: The below needs to check for how many links are rendered, not how many types of links can exist
MetadataLinks.length > 4 ? 'pr-4' : '',
)}
>
{MetadataLinks.map((site) => {
if (site === 'TMDB') {
const tmdbIds = series.IDs.TMDB;
if (tmdbIds.Movie.length + tmdbIds.Show.length === 0) {
return <SeriesMetadata key={site} site={site} seriesId={series.IDs.ID} />;
}
return [
...flatMap(tmdbIds, (ids, type: 'Movie' | 'Show') =>
ids.map(id => (
<SeriesMetadata
key={`${site}-${type}-${id}`}
site={site}
id={id}
seriesId={series.IDs.ID}
type={type}
/>
))),
/* Show row to add new TMDB links */
<SeriesMetadata key="TMDB-add-new" site="TMDB" seriesId={series.IDs.ID} />,
];
}
// Site is not TMDB, so it's either a single ID or an array of IDs
const idOrIds = series?.IDs[site] ?? [0];
const linkIds = typeof idOrIds === 'number' ? [idOrIds] : idOrIds;
if (linkIds.length === 0) linkIds.push(0);
return linkIds.map(id => (
<SeriesMetadata key={`${site}-${id}`} site={site} id={id} seriesId={series.IDs.ID} />
));
})}
</div>
)}
{series && currentTab === 'links' && (
<div
className={cx(
'flex h-[15.625rem] flex-col gap-3 overflow-y-auto',
series.Links.length > 4 ? 'pr-4' : '',
)}
>
{series.Links.map(link => (
<a
className="flex w-full gap-x-2 rounded-lg border border-panel-border bg-panel-background px-4 py-3 text-left !text-base !font-normal text-panel-icon-action hover:bg-panel-toggle-background-hover"
key={link.URL}
href={link.URL}
rel="noopener noreferrer"
target="_blank"
>
<Icon
className="text-panel-icon"
path={mdiEarth}
size={1}
/>
{link.Name}
<Icon
className="text-panel-icon-action"
path={mdiOpenInNew}
size={1}
/>
</a>
))}
</div>
)}
</ShokoPanel>
<ShokoPanel
title="Episode On Deck"
className="flex w-full grow overflow-visible"
transparent
isFetching={nextUpEpisodeQuery.isFetching}
>
{nextUpEpisodeQuery.isSuccess && nextUpEpisodeQuery.data
? <EpisodeSummary seriesId={toNumber(seriesId)} episode={nextUpEpisodeQuery.data} nextUp />
: (
<div className="flex grow items-center justify-center font-semibold">
All available episodes have already been watched
</div>
)}
</ShokoPanel>
</div>
</div>
{relatedAnime.length > 0 && (
<ShokoPanel
title="Related Anime"
className="w-full"
transparent
contentClassName={cx('!flex-row gap-x-6', relatedAnime.length > 7 && 'pb-4')}
>
{map(relatedAnime, item => (
<SeriesPoster
key={item.ID}
image={item.Poster}
title={item.Title}
subtitle={item.Relation.replace(/([a-z])([A-Z])/g, '$1 $2')}
shokoId={item.ShokoID}
anidbSeriesId={item.ID}
inCollection={!!item.ShokoID}
/>
))}
</ShokoPanel>
)}
{similarAnime.length > 0 && (
<ShokoPanel
title="Similar Anime"
className="w-full"
transparent
contentClassName={cx('!flex-row gap-x-6', similarAnime.length > 7 && 'pb-4')}
>
{map(similarAnime, item => (
<SeriesPoster
key={item.ID}
image={item.Poster}
title={item.Title}
subtitle={`${round(item.UserApproval.Value, 2)}% (${item.UserApproval.Votes} votes)`}
shokoId={item.ShokoID}
anidbSeriesId={item.ID}
inCollection={!!item.ShokoID}
/>
))}
</ShokoPanel>
)}
<ShokoPanel title="Top 20 Seiyuu" className="w-full" transparent>
<div className="z-10 flex w-full gap-x-6">
{cast?.filter(credit => credit.RoleName === 'Seiyuu' && credit.Character).slice(0, 20).map(seiyuu => (
<div
key={`${seiyuu.Character?.Name}-${Math.random() * (cast.length + (seiyuu.Character?.Name.length ?? 0))}`}
className="flex flex-col items-center gap-y-3 pb-3"
>
<div className="flex gap-x-4">
<CharacterImage
imageSrc={getThumbnailUrl(seiyuu, 'Character')}
className="relative h-48 w-36 rounded-lg"
/>
<CharacterImage
imageSrc={getThumbnailUrl(seiyuu, 'Staff')}
className="relative h-48 w-36 rounded-lg"
/>
</div>
<div className="flex flex-col items-center">
<span className="line-clamp-1 text-ellipsis text-xl font-semibold">{seiyuu.Character?.Name}</span>
<span className="line-clamp-1 text-ellipsis text-sm font-semibold opacity-65">
{seiyuu.Staff.Name}
</span>
</div>
</div>
))}
</div>
</ShokoPanel>
</>
);
};
export default SeriesOverview;