Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions src/components/Collection/Anilist/AnilistLinkSelectPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { useEffect, useMemo, useState } from 'react';
import { useParams, useSearchParams } from 'react-router';
import { mdiLoading, mdiMagnify, mdiOpenInNew, mdiRefresh } from '@mdi/js';
import { Icon } from '@mdi/react';
import cx from 'classnames';
import { toNumber } from 'lodash';
import { useDebounceValue } from 'usehooks-ts';

import Button from '@/components/Input/Button';
import Input from '@/components/Input/Input';
import { getAnilistUnavailableMessage, getAnilistUnavailableState } from '@/core/react-query/anilist/helpers';
import { useAnilistRefreshMutation } from '@/core/react-query/anilist/mutations';
import { useAnilistAutoSearchQuery, useAnilistSearchQuery } from '@/core/react-query/anilist/queries';
import { useSettingsQuery } from '@/core/react-query/settings/queries';
import toast from '@/core/toast';
import { getAnilistAnimeLink } from '@/core/util';

import type { AnilistSearchResultType } from '@/core/types/api/anilist';

type SearchResultRowProps = {
result: AnilistSearchResultType;
selectLink: (anilistId: number) => void;
};

const SearchResultRow = ({ result, selectLink }: SearchResultRowProps) => {
const handleClick = () => {
selectLink(result.ID);
};

return (
<div className="flex items-center gap-x-4">
<a
className="flex w-24 cursor-pointer items-center justify-between font-semibold text-panel-text-primary"
href={getAnilistAnimeLink(result.ID)}
target="_blank"
rel="noopener noreferrer"
>
{result.ID}
<Icon path={mdiOpenInNew} size={0.9} />
</a>
<div
className="line-clamp-1 flex cursor-pointer items-center gap-x-4 hover:text-panel-text-primary"
onClick={handleClick}
>
<span>|</span>
{result.Title}
</div>
</div>
);
};

const AnilistLinkSelectPanel = () => {
const { seriesId } = useParams();

const [, setSearchParams] = useSearchParams();

const [selectedId, setSelectedId] = useState(0);
const [searchText, setSearchText] = useState('');
const [debouncedSearch] = useDebounceValue(searchText, 200);

const { includeRestricted } = useSettingsQuery().data.WebUI_Settings.collection.anilist;

const autoSearchQuery = useAnilistAutoSearchQuery(toNumber(seriesId), debouncedSearch === '' && !!seriesId);
const autoSearchResults = useMemo(
() => autoSearchQuery.data?.map(result => result.Anime) ?? [],
[autoSearchQuery.data],
);

const searchQuery = useAnilistSearchQuery(debouncedSearch, {
includeRestricted,
pageSize: 25,
});
const { isPending: refreshPending, mutate: refreshData } = useAnilistRefreshMutation();

const activeQuery = debouncedSearch === '' ? autoSearchQuery : searchQuery;
const unavailableState = useMemo(() => getAnilistUnavailableState(activeQuery.error), [activeQuery.error]);

const noResults = useMemo(() => {
if (unavailableState) return false;
if (debouncedSearch === '') return autoSearchResults.length === 0;
return searchQuery.data?.length === 0;
}, [autoSearchResults, debouncedSearch, searchQuery.data, unavailableState]);

const handleRetry = () => {
activeQuery.refetch().catch(console.error);
};

const isPending = useMemo(
() => autoSearchQuery.isLoading || searchQuery.isLoading || refreshPending,
[autoSearchQuery.isLoading, refreshPending, searchQuery.isLoading],
);

const selectLink = (anilistId: number) => {
setSelectedId(anilistId);
};

useEffect(() => {
if (selectedId === 0) return;

refreshData(
{
anilistId: selectedId,
Immediate: true,
QuickRefresh: true,
},
{
onSuccess: () => setSearchParams({ id: selectedId.toString() }),
onError: (error) => {
const state = getAnilistUnavailableState(error);
toast.error(state ? getAnilistUnavailableMessage(state) : 'Failed to refresh data!');
setSelectedId(0);
},
},
);
}, [refreshData, selectedId, setSearchParams]);

return (
<div className="row-span-2 flex flex-col gap-y-2">
<div className="flex items-center justify-between rounded-lg border border-panel-border bg-panel-background-alt p-4 font-semibold">
<div className="flex items-center">
AniList |&nbsp;
<div>
Not linked
</div>
</div>
</div>

<Input
id="link-search"
type="text"
value={searchText}
onChange={event => setSearchText(event.target.value)}
placeholder="Enter Title or AniList ID..."
inputClassName="!p-4"
startIcon={mdiMagnify}
autoFocus
/>

<div className="relative h-96 rounded-lg border border-panel-border bg-panel-input p-4">
{isPending && (
<div className="absolute inset-0 flex items-center justify-center text-panel-text-primary">
<Icon path={mdiLoading} size={4} spin />
</div>
)}

{!isPending && (
<div
className={cx(
'flex h-full flex-col gap-y-2 overflow-y-auto',
refreshPending && 'pointer-events-none opacity-65',
)}
>
{debouncedSearch === '' && autoSearchResults.map(result => (
<SearchResultRow
key={result.ID}
result={result}
selectLink={selectLink}
/>
))}

{debouncedSearch && searchQuery.data?.map(result => (
<SearchResultRow
key={result.ID}
result={result}
selectLink={selectLink}
/>
))}

{noResults && (
<div className="flex grow items-center justify-center">
No results found!
</div>
)}

{unavailableState && (
<div className="flex grow flex-col items-center justify-center gap-y-4 text-center">
{getAnilistUnavailableMessage(unavailableState)}
<Button
buttonType="secondary"
buttonSize="normal"
className="flex items-center gap-x-2"
onClick={handleRetry}
loading={activeQuery.isFetching}
>
<Icon path={mdiRefresh} size={1} />
Retry
</Button>
</div>
)}
</div>
)}
</div>
</div>
);
};

export default AnilistLinkSelectPanel;
170 changes: 170 additions & 0 deletions src/components/Collection/Anilist/EpisodeRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { mdiLoading } from '@mdi/js';
import { Icon } from '@mdi/react';
import cx from 'classnames';
import { find, map, toNumber } from 'lodash';

import EpisodeSelect from '@/components/Collection/Anilist/EpisodeSelect';
import AniDBEpisode from '@/components/Collection/Tmdb/AniDBEpisode';
import MatchRating from '@/components/Collection/Tmdb/MatchRating';

import type { AnilistEpisodeType, AnilistEpisodeXrefType } from '@/core/types/api/anilist';
import type { EpisodeType } from '@/core/types/api/episode';
import type { Updater } from 'use-immer';

type Props = {
anilistEpisodes?: AnilistEpisodeType[];
anilistEpisodesPending: boolean;
episode: EpisodeType;
existingXrefs?: number[];
isOdd: boolean;
offset: number;
setLinkOverrides: Updater<Record<number, number[]>>;
xrefs?: Record<string, AnilistEpisodeXrefType[]>;
};

const EpisodeRow = (props: Props) => {
const {
anilistEpisodes,
anilistEpisodesPending,
episode,
existingXrefs,
isOdd,
offset,
setLinkOverrides,
xrefs,
} = props;

const [searchParams] = useSearchParams();
const anilistId = toNumber(searchParams.get('id'));

const xref = useMemo(
() => {
if (!xrefs?.[episode.IDs.AniDB]) return undefined;
return xrefs[episode.IDs.AniDB][offset];
},
[episode.IDs.AniDB, offset, xrefs],
);

const anilistEpisode = useMemo(() => {
if (!xref || xref.AnilistEpisodeID === 0) return undefined;
return find(anilistEpisodes, { ID: xref.AnilistEpisodeID });
}, [anilistEpisodes, xref]);

const isDisabled = useMemo(() => {
if (!xref || xref.AnilistEpisodeID === 0) return false;
return xref.AnilistAnimeID !== anilistId;
}, [anilistId, xref]);

const isPending = useMemo(
() => {
// Xrefs are not loaded yet
if (!xrefs) return true;
// Xrefs are loaded but episode doesn't have an xref
if (!xref) return false;
// Episodes for another anime are never loaded, so don't wait for them
if (isDisabled) return false;

return !anilistEpisode && anilistEpisodesPending;
},
[anilistEpisode, anilistEpisodesPending, isDisabled, xref, xrefs],
);

const editExtraEpisodeLink = () => {
const episodeId = episode.IDs.AniDB;
setLinkOverrides((draftState) => {
if (!draftState[episodeId]) {
draftState[episodeId] = map(xrefs?.[episodeId], item => item.AnilistEpisodeID);
}

// If offset is 0, we are adding a link
if (offset === 0) {
draftState[episodeId].push(0);
return;
}

draftState[episodeId].splice(offset, 1);

// When existing xrefs are present and are more than 1,
// we need to keep the first link to "overwrite" others
if (existingXrefs && existingXrefs.length > 1) {
return;
}

// When only one is preset, we can remove the override if existingXref was present
if (draftState[episodeId].length === 1 && existingXrefs) {
delete draftState[episodeId];
}
});
};

const overrideLink = (newAnilistId?: number) => {
const episodeId = episode.IDs.AniDB;
setLinkOverrides((draftState) => {
if (!draftState[episodeId]) {
draftState[episodeId] = map(xrefs?.[episodeId], item => item.AnilistEpisodeID);
}

if (newAnilistId === undefined) {
draftState[episodeId].splice(offset, 1);
return;
}

if (newAnilistId === 0 && !existingXrefs && offset === 0) {
delete draftState[episodeId];
return;
}

draftState[episodeId][offset] = newAnilistId;
});
};

const matchRating = useMemo(() => {
if (isPending) return undefined;
return xref?.Rating;
}, [isPending, xref]);

return (
<>
<AniDBEpisode
episode={episode}
isOdd={isOdd}
extra={offset > 0}
onIconClick={(offset > 0 || (anilistEpisode ?? xref?.AnilistEpisodeID)) ? editExtraEpisodeLink : undefined}
/>

<MatchRating
rating={matchRating}
isOdd={isOdd}
isDisabled={isDisabled}
/>

{!isPending && (
<EpisodeSelect
anilistEpisode={anilistEpisode}
anilistEpisodes={anilistEpisodes}
fallbackEpisodeNumber={xref?.AnilistEpisodeID ? xref.EpisodeNumber : undefined}
isDisabled={isDisabled}
isOdd={isOdd}
override={xref?.AnilistEpisodeID}
overrideLink={overrideLink}
/>
)}

{isPending
&& (
<div
className={cx(
'flex grow basis-0 gap-x-6 rounded-lg border border-panel-border p-4',
isOdd ? 'bg-panel-background-alt' : 'bg-panel-background',
)}
>
<Icon path={mdiLoading} spin size={1} className="m-auto text-panel-text-primary" />
</div>
)}
</>
);
};

export default EpisodeRow;
Loading