feat(stats): add library entry merge and episode move (#190)

This commit is contained in:
2026-08-14 21:54:17 -07:00
committed by GitHub
parent c5a77ac067
commit 6fb3d2eb13
42 changed files with 4850 additions and 89 deletions
+62 -1
View File
@@ -4,21 +4,39 @@ import { apiClient } from '../../lib/api-client';
import { confirmEpisodeDelete } from '../../lib/delete-confirm';
import { buildLookupRateDisplay } from '../../lib/yomitan-lookup';
import { EpisodeDetail } from './EpisodeDetail';
import { LibraryEntryPicker } from './LibraryEntryPicker';
import type { AnimeEpisode } from '../../types/stats';
/**
* Row actions that only appear on hover. Keyboard focus and pointers with no
* hover (touch) reveal them too, otherwise those users cannot reach the button
* at all.
*/
const HOVER_REVEALED =
'opacity-0 group-hover:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100';
interface EpisodeListProps {
episodes: AnimeEpisode[];
/** Entry these episodes currently belong to; excluded from the move picker. */
animeId?: number;
onEpisodeDeleted?: () => void;
/** Fires after an episode is reassigned, so the caller can refetch. */
onEpisodeMoved?: (removedPreviousAnime: boolean) => void;
onOpenDetail?: (videoId: number) => void;
}
export function EpisodeList({
episodes: initialEpisodes,
animeId,
onEpisodeDeleted,
onEpisodeMoved,
onOpenDetail,
}: EpisodeListProps) {
const [expandedVideoId, setExpandedVideoId] = useState<number | null>(null);
const [episodes, setEpisodes] = useState(initialEpisodes);
const [movingEpisode, setMovingEpisode] = useState<AnimeEpisode | null>(null);
const [moveTargetId, setMoveTargetId] = useState<number | null>(null);
const [moveError, setMoveError] = useState<string | null>(null);
if (episodes.length === 0) return null;
@@ -51,6 +69,22 @@ export function EpisodeList({
onEpisodeDeleted?.();
};
const handleMoveEpisode = async (videoId: number, targetAnimeId: number) => {
setMoveTargetId(targetAnimeId);
setMoveError(null);
try {
const result = await apiClient.moveVideoToAnime(videoId, targetAnimeId);
setEpisodes((prev) => prev.filter((ep) => ep.videoId !== videoId));
if (expandedVideoId === videoId) setExpandedVideoId(null);
setMovingEpisode(null);
onEpisodeMoved?.(result.removedPreviousAnime);
} catch (err) {
setMoveError(err instanceof Error ? err.message : 'Failed to move this episode.');
} finally {
setMoveTargetId(null);
}
};
const watchedCount = episodes.filter((ep) => ep.watched).length;
return (
@@ -164,14 +198,28 @@ export function EpisodeList({
>
{'\u2713'}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setMoveError(null);
setMovingEpisode(ep);
}}
className={`w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-blue/50 hover:text-ctp-blue focus-visible:text-ctp-blue hover:bg-ctp-blue/10 transition-colors text-xs flex items-center justify-center ${HOVER_REVEALED}`}
title="Move to another library entry"
aria-label="Move to another library entry"
>
{'\u2192'}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void handleDeleteEpisode(ep.videoId, ep.canonicalTitle);
}}
className="w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover:opacity-100 text-xs flex items-center justify-center"
className={`w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red focus-visible:text-ctp-red hover:bg-ctp-red/10 transition-colors text-xs flex items-center justify-center ${HOVER_REVEALED}`}
title="Delete episode"
aria-label="Delete episode"
>
{'\u2715'}
</button>
@@ -191,6 +239,19 @@ export function EpisodeList({
</tbody>
</table>
</div>
{movingEpisode && (
<LibraryEntryPicker
heading={`Move "${movingEpisode.canonicalTitle}" To`}
excludeAnimeIds={animeId != null ? [animeId] : []}
busyAnimeId={moveTargetId}
error={moveError}
onSelect={(entry) => void handleMoveEpisode(movingEpisode.videoId, entry.animeId)}
onClose={() => {
setMovingEpisode(null);
setMoveError(null);
}}
/>
)}
</div>
);
}