feat(stats): add library entry deletion and app-wide delete progress (#174)

This commit is contained in:
2026-07-29 02:00:16 -07:00
committed by GitHub
parent 0d7084c8aa
commit 6d2a72e13b
40 changed files with 2013 additions and 125 deletions
+53 -1
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useAnimeDetail } from '../../hooks/useAnimeDetail';
import { getStatsClient } from '../../hooks/useStatsApi';
import { confirmAnimeDelete } from '../../lib/delete-confirm';
import { epochDayToDate } from '../../lib/formatters';
import { AnimeHeader } from './AnimeHeader';
import { EpisodeList } from './EpisodeList';
@@ -16,6 +17,14 @@ interface AnimeDetailViewProps {
onBack: () => void;
onNavigateToWord?: (wordId: number) => void;
onOpenEpisodeDetail?: (videoId: number) => void;
/** Called after the whole library entry is deleted, so the caller can refresh. */
onAnimeDeleted?: () => void;
/**
* Called after the AniList link changes. The library list caches the old
* anilistId (and with it the cover URL), so it has to refetch or the grid
* keeps showing the previous title's art.
*/
onAnilistRelinked?: () => void;
}
type Range = 14 | 30 | 90;
@@ -139,10 +148,15 @@ export function AnimeDetailView({
onBack,
onNavigateToWord,
onOpenEpisodeDetail,
onAnimeDeleted,
onAnilistRelinked,
}: AnimeDetailViewProps) {
const { data, loading, error, reload } = useAnimeDetail(animeId);
const [showAnilistSelector, setShowAnilistSelector] = useState(false);
const [coverRetryToken, setCoverRetryToken] = useState(0);
const [isDeletingAnime, setIsDeletingAnime] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const isDeletingAnimeRef = useRef(false);
const knownWordsSummary = useAnimeKnownWords(animeId);
useEffect(() => {
@@ -154,6 +168,40 @@ export function AnimeDetailView({
if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Anime not found</div>;
const { detail, episodes, anilistEntries } = data;
const handleDeleteAnime = async () => {
if (isDeletingAnimeRef.current) return;
isDeletingAnimeRef.current = true;
// Cleared up front so cancelling a retry doesn't leave the previous
// attempt's failure on screen.
setDeleteError(null);
let confirmed = false;
try {
confirmed = await confirmAnimeDelete(detail.canonicalTitle, detail.episodeCount);
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to confirm delete.');
isDeletingAnimeRef.current = false;
return;
}
if (!confirmed) {
isDeletingAnimeRef.current = false;
return;
}
setDeleteError(null);
setIsDeletingAnime(true);
try {
await getStatsClient().deleteAnime(animeId);
onAnimeDeleted?.();
onBack();
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to delete this title.');
setIsDeletingAnime(false);
} finally {
isDeletingAnimeRef.current = false;
}
};
return (
<div className="space-y-4">
<button
@@ -168,7 +216,10 @@ export function AnimeDetailView({
anilistEntries={anilistEntries ?? []}
coverRetryToken={coverRetryToken}
onChangeAnilist={() => setShowAnilistSelector(true)}
onDeleteAnime={() => void handleDeleteAnime()}
isDeletingAnime={isDeletingAnime}
/>
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
<AnimeOverviewStats detail={detail} knownWordsSummary={knownWordsSummary} />
<EpisodeList
episodes={episodes}
@@ -185,6 +236,7 @@ export function AnimeDetailView({
setShowAnilistSelector(false);
setCoverRetryToken((value) => value + 1);
reload();
onAnilistRelinked?.();
}}
/>
)}