Files
SubMiner/stats/src/components/anime/LibraryEntryPicker.tsx
T
sudacode 9c503fc67d feat(stats): add library entry merge and episode move
- Add multi-select "Merge Selected" flow to fold duplicate library cards into one, preserving sessions, mined cards, and watch time
- Add per-episode "move to another entry" action for reassigning stray episodes, pruning the source entry when emptied
- Auto-merge library entries that resolve to the same AniList id when their parsed seasons are compatible
- Add mergeAnime/moveVideoToAnime service methods, stats-server routes, and HTTP contract types
2026-08-13 23:10:15 -07:00

123 lines
4.6 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import { apiClient } from '../../lib/api-client';
import { formatDuration } from '../../lib/formatters';
import { AnimeCoverImage } from './AnimeCoverImage';
import type { AnimeLibraryItem } from '../../types/stats';
interface LibraryEntryPickerProps {
heading: string;
/** Entries that cannot be picked, typically the one being moved away from. */
excludeAnimeIds?: number[];
initialQuery?: string;
busyAnimeId?: number | null;
error?: string | null;
onSelect: (entry: AnimeLibraryItem) => void;
onClose: () => void;
}
export function LibraryEntryPicker({
heading,
excludeAnimeIds = [],
initialQuery = '',
busyAnimeId = null,
error = null,
onSelect,
onClose,
}: LibraryEntryPickerProps) {
const [entries, setEntries] = useState<AnimeLibraryItem[] | null>(null);
const [query, setQuery] = useState(initialQuery);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
let cancelled = false;
apiClient
.getAnimeLibrary()
.then((data) => {
if (!cancelled) setEntries(data);
})
.catch(() => {
if (!cancelled) setEntries([]);
});
return () => {
cancelled = true;
};
}, []);
const excluded = useMemo(() => new Set(excludeAnimeIds), [excludeAnimeIds]);
const visible = useMemo(() => {
const term = query.trim().toLowerCase();
return (entries ?? [])
.filter((entry) => !excluded.has(entry.animeId))
.filter((entry) => !term || entry.canonicalTitle.toLowerCase().includes(term))
.sort((a, b) => b.lastWatchedMs - a.lastWatchedMs);
}, [entries, excluded, query]);
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]" onClick={onClose}>
<div className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]" />
<div
className="relative bg-ctp-base border border-ctp-surface1 rounded-xl shadow-2xl w-full max-w-lg max-h-[70vh] flex flex-col animate-fade-in"
onClick={(e) => e.stopPropagation()}
>
<div className="p-4 border-b border-ctp-surface1">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-ctp-text">{heading}</h3>
<button
type="button"
onClick={onClose}
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none"
>
{'✕'}
</button>
</div>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search library..."
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
/>
{error ? <div className="text-xs text-ctp-red mt-2">{error}</div> : null}
</div>
<div className="flex-1 overflow-y-auto p-2">
{entries === null && <div className="text-xs text-ctp-overlay2 p-3">Loading...</div>}
{entries !== null && visible.length === 0 && (
<div className="text-xs text-ctp-overlay2 p-3">No other titles</div>
)}
{visible.map((entry) => (
<button
key={entry.animeId}
type="button"
disabled={busyAnimeId !== null}
onClick={() => onSelect(entry)}
className="w-full flex items-center gap-3 p-2.5 rounded-lg hover:bg-ctp-surface0 transition-colors text-left disabled:opacity-50"
>
<AnimeCoverImage
animeId={entry.animeId}
title={entry.canonicalTitle}
coverRetryToken={entry.anilistId ?? 0}
className="w-10 h-14 rounded shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="text-sm text-ctp-text truncate">{entry.canonicalTitle}</div>
<div className="text-xs text-ctp-overlay2 mt-0.5">
{entry.episodeCount} episode{entry.episodeCount !== 1 ? 's' : ''} ·{' '}
{formatDuration(entry.totalActiveMs)}
</div>
</div>
{busyAnimeId === entry.animeId ? (
<span className="text-xs text-ctp-blue shrink-0">Moving...</span>
) : (
<span className="text-xs text-ctp-overlay2 shrink-0">Select</span>
)}
</button>
))}
</div>
</div>
</div>
);
}