mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 01:55:51 -07:00
feat(stats): add library entry merge and episode move (#190)
This commit is contained in:
@@ -5,22 +5,45 @@ import type { AnimeLibraryItem } from '../../types/stats';
|
||||
interface AnimeCardProps {
|
||||
anime: AnimeLibraryItem;
|
||||
onClick: () => void;
|
||||
/** While selecting, clicking the card toggles it instead of opening it. */
|
||||
selectable?: boolean;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
export function AnimeCard({ anime, onClick }: AnimeCardProps) {
|
||||
export function AnimeCard({
|
||||
anime,
|
||||
onClick,
|
||||
selectable = false,
|
||||
selected = false,
|
||||
}: AnimeCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="group bg-ctp-surface0 border border-ctp-surface1 rounded-lg overflow-hidden hover:border-ctp-blue/50 hover:shadow-lg hover:shadow-ctp-blue/10 transition-all duration-200 hover:-translate-y-1 text-left w-full"
|
||||
aria-pressed={selectable ? selected : undefined}
|
||||
className={`group bg-ctp-surface0 border rounded-lg overflow-hidden hover:shadow-lg hover:shadow-ctp-blue/10 transition-all duration-200 hover:-translate-y-1 text-left w-full ${
|
||||
selected ? 'border-ctp-blue' : 'border-ctp-surface1 hover:border-ctp-blue/50'
|
||||
}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="overflow-hidden relative">
|
||||
<AnimeCoverImage
|
||||
animeId={anime.animeId}
|
||||
title={anime.canonicalTitle}
|
||||
coverRetryToken={anime.anilistId ?? 0}
|
||||
className="w-full aspect-[3/4] rounded-t-lg transition-transform duration-200 group-hover:scale-105"
|
||||
/>
|
||||
{selectable && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute top-2 left-2 w-5 h-5 rounded border flex items-center justify-center text-xs ${
|
||||
selected
|
||||
? 'bg-ctp-blue border-ctp-blue text-ctp-base'
|
||||
: 'bg-ctp-crust/70 border-ctp-surface2 text-transparent'
|
||||
}`}
|
||||
>
|
||||
{'✓'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="text-sm font-medium text-ctp-text truncate">{anime.canonicalTitle}</div>
|
||||
|
||||
@@ -25,6 +25,8 @@ interface AnimeDetailViewProps {
|
||||
* keeps showing the previous title's art.
|
||||
*/
|
||||
onAnilistRelinked?: () => void;
|
||||
/** Called after an episode is reassigned to another entry. */
|
||||
onEpisodeMoved?: () => void;
|
||||
}
|
||||
|
||||
type Range = 14 | 30 | 90;
|
||||
@@ -150,6 +152,7 @@ export function AnimeDetailView({
|
||||
onOpenEpisodeDetail,
|
||||
onAnimeDeleted,
|
||||
onAnilistRelinked,
|
||||
onEpisodeMoved,
|
||||
}: AnimeDetailViewProps) {
|
||||
const { data, loading, error, reload } = useAnimeDetail(animeId);
|
||||
const [showAnilistSelector, setShowAnilistSelector] = useState(false);
|
||||
@@ -223,6 +226,13 @@ export function AnimeDetailView({
|
||||
<AnimeOverviewStats detail={detail} knownWordsSummary={knownWordsSummary} />
|
||||
<EpisodeList
|
||||
episodes={episodes}
|
||||
animeId={animeId}
|
||||
onEpisodeMoved={(removedPreviousAnime) => {
|
||||
onEpisodeMoved?.();
|
||||
// The last episode taking the entry with it leaves nothing to show.
|
||||
if (removedPreviousAnime) onBack();
|
||||
else reload();
|
||||
}}
|
||||
onOpenDetail={onOpenEpisodeDetail ? (videoId) => onOpenEpisodeDetail(videoId) : undefined}
|
||||
/>
|
||||
<AnimeWatchChart animeId={animeId} />
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Window } from 'happy-dom';
|
||||
import { act, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import type { AnimeLibraryItem } from '../../types/stats';
|
||||
import { AnimeMergeDialog } from './AnimeMergeDialog';
|
||||
import { LibraryEntryPicker } from './LibraryEntryPicker';
|
||||
|
||||
interface TestWindow extends Window {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
|
||||
function installDom(): () => void {
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
const previousHTMLElement = globalThis.HTMLElement;
|
||||
const previousIsReactActEnvironment = (
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT;
|
||||
const window = new Window() as TestWindow;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', { value: window, configurable: true });
|
||||
Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true });
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
value: window.HTMLElement,
|
||||
configurable: true,
|
||||
});
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
return () => {
|
||||
Object.defineProperty(globalThis, 'window', { value: previousWindow, configurable: true });
|
||||
Object.defineProperty(globalThis, 'document', { value: previousDocument, configurable: true });
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
value: previousHTMLElement,
|
||||
configurable: true,
|
||||
});
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = previousIsReactActEnvironment;
|
||||
};
|
||||
}
|
||||
|
||||
function libraryItem(animeId: number, title: string): AnimeLibraryItem {
|
||||
return {
|
||||
animeId,
|
||||
canonicalTitle: title,
|
||||
anilistId: null,
|
||||
totalSessions: 1,
|
||||
totalActiveMs: 1000,
|
||||
totalCards: 0,
|
||||
totalTokensSeen: 0,
|
||||
episodeCount: 1,
|
||||
episodesTotal: null,
|
||||
lastWatchedMs: 1,
|
||||
};
|
||||
}
|
||||
|
||||
test('AnimeMergeDialog focuses its close control, closes on Escape, and restores focus', async () => {
|
||||
const uninstallDom = installDom();
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
function Harness() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
Review merge
|
||||
</button>
|
||||
{open ? (
|
||||
<AnimeMergeDialog
|
||||
entries={[libraryItem(1, 'Show'), libraryItem(2, 'Show Season 1')]}
|
||||
onClose={() => setOpen(false)}
|
||||
onMerged={() => undefined}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
await act(async () => root.render(<Harness />));
|
||||
const trigger = container.querySelector('button') as HTMLButtonElement;
|
||||
trigger.focus();
|
||||
await act(async () => trigger.click());
|
||||
|
||||
assert.equal(document.activeElement?.getAttribute('aria-label'), 'Close');
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
});
|
||||
assert.equal(container.querySelector('[role="dialog"]'), null);
|
||||
assert.equal(document.activeElement, trigger);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
} finally {
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
|
||||
test('AnimeMergeDialog keeps keyboard focus inside the modal', async () => {
|
||||
const uninstallDom = installDom();
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AnimeMergeDialog
|
||||
entries={[libraryItem(1, 'Show'), libraryItem(2, 'Show Season 1')]}
|
||||
onClose={() => undefined}
|
||||
onMerged={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const dialog = container.querySelector('[role="dialog"]') as HTMLElement;
|
||||
const focusable = [...dialog.querySelectorAll('button:not([disabled])')] as HTMLButtonElement[];
|
||||
const first = focusable[0];
|
||||
const last = focusable.at(-1);
|
||||
assert.ok(first);
|
||||
assert.ok(last);
|
||||
|
||||
last.focus();
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Tab' }));
|
||||
});
|
||||
assert.equal(document.activeElement, first);
|
||||
|
||||
first.focus();
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Tab', shiftKey: true }));
|
||||
});
|
||||
assert.equal(document.activeElement, last);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
} finally {
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
|
||||
test('LibraryEntryPicker focuses search, closes on Escape, and restores focus', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = apiClient.getAnimeLibrary;
|
||||
apiClient.getAnimeLibrary = (async () => [
|
||||
libraryItem(1, 'Show'),
|
||||
]) as typeof apiClient.getAnimeLibrary;
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
function Harness() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
Move
|
||||
</button>
|
||||
{open ? (
|
||||
<LibraryEntryPicker
|
||||
heading="Move episode"
|
||||
onSelect={() => undefined}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
await act(async () => root.render(<Harness />));
|
||||
const trigger = container.querySelector('button') as HTMLButtonElement;
|
||||
trigger.focus();
|
||||
await act(async () => trigger.click());
|
||||
|
||||
assert.equal(document.activeElement?.getAttribute('placeholder'), 'Search library...');
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
});
|
||||
assert.equal(container.querySelector('[role="dialog"]'), null);
|
||||
assert.equal(document.activeElement, trigger);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
} finally {
|
||||
apiClient.getAnimeLibrary = original;
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
|
||||
test('LibraryEntryPicker cannot be dismissed while a move is in flight', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = apiClient.getAnimeLibrary;
|
||||
apiClient.getAnimeLibrary = (async () => [
|
||||
libraryItem(1, 'Show'),
|
||||
]) as typeof apiClient.getAnimeLibrary;
|
||||
let closeCalls = 0;
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LibraryEntryPicker
|
||||
heading="Move episode"
|
||||
busyAnimeId={1}
|
||||
onSelect={() => undefined}
|
||||
onClose={() => {
|
||||
closeCalls += 1;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const closeButton = container.querySelector('button[aria-label="Close"]') as HTMLButtonElement;
|
||||
assert.equal(closeButton.disabled, true);
|
||||
await act(async () => {
|
||||
closeButton.click();
|
||||
container.firstElementChild?.dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
|
||||
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
});
|
||||
assert.equal(closeCalls, 0);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
} finally {
|
||||
apiClient.getAnimeLibrary = original;
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useId, useRef, useState } from 'react';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { formatDuration, formatNumber } from '../../lib/formatters';
|
||||
import { useModalFocus } from '../../hooks/useModalFocus';
|
||||
import { AnimeCoverImage } from './AnimeCoverImage';
|
||||
import type { AnimeLibraryItem } from '../../types/stats';
|
||||
|
||||
interface AnimeMergeDialogProps {
|
||||
entries: AnimeLibraryItem[];
|
||||
onClose: () => void;
|
||||
onMerged: (survivingAnimeId: number) => void;
|
||||
}
|
||||
|
||||
/** Biggest entry first: the one most likely to carry the right title and art. */
|
||||
function pickDefaultKeeper(entries: AnimeLibraryItem[]): number {
|
||||
const best = [...entries].sort(
|
||||
(a, b) => b.episodeCount - a.episodeCount || b.totalActiveMs - a.totalActiveMs,
|
||||
)[0];
|
||||
return best?.animeId ?? 0;
|
||||
}
|
||||
|
||||
export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialogProps) {
|
||||
const headingId = useId();
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [keeperId, setKeeperId] = useState(() => pickDefaultKeeper(entries));
|
||||
const [merging, setMerging] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const totalEpisodes = entries.reduce((sum, entry) => sum + entry.episodeCount, 0);
|
||||
const totalCards = entries.reduce((sum, entry) => sum + entry.totalCards, 0);
|
||||
const totalActiveMs = entries.reduce((sum, entry) => sum + entry.totalActiveMs, 0);
|
||||
|
||||
useModalFocus({
|
||||
dialogRef,
|
||||
initialFocusRef: closeButtonRef,
|
||||
dismissDisabled: merging,
|
||||
onDismiss: onClose,
|
||||
});
|
||||
|
||||
const handleMerge = async () => {
|
||||
const sourceAnimeIds = entries
|
||||
.map((entry) => entry.animeId)
|
||||
.filter((animeId) => animeId !== keeperId);
|
||||
if (sourceAnimeIds.length === 0) return;
|
||||
setMerging(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await apiClient.mergeAnime(keeperId, sourceAnimeIds);
|
||||
onMerged(result.animeId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to merge these entries.');
|
||||
setMerging(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Dismissing mid-request would leave the caller unaware of a merge that is
|
||||
// still going to land, so the backdrop and close button are inert until it
|
||||
// resolves.
|
||||
const handleDismiss = () => {
|
||||
if (!merging) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]"
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
<div className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]" />
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={headingId}
|
||||
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">
|
||||
<h3 id={headingId} className="text-sm font-semibold text-ctp-text">
|
||||
Merge {entries.length} Library Entries
|
||||
</h3>
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
disabled={merging}
|
||||
aria-label="Close"
|
||||
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none disabled:opacity-50"
|
||||
>
|
||||
{'✕'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-ctp-overlay2 mt-2">
|
||||
Pick the entry to keep. Every episode moves onto it and the others are removed; no
|
||||
sessions or mined cards are deleted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{entries.map((entry) => (
|
||||
<button
|
||||
key={entry.animeId}
|
||||
type="button"
|
||||
disabled={merging}
|
||||
aria-pressed={keeperId === entry.animeId}
|
||||
onClick={() => setKeeperId(entry.animeId)}
|
||||
className={`w-full flex items-center gap-3 p-2.5 rounded-lg transition-colors text-left disabled:opacity-50 ${
|
||||
keeperId === entry.animeId ? 'bg-ctp-surface1' : 'hover:bg-ctp-surface0'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`w-4 h-4 rounded-full border shrink-0 ${
|
||||
keeperId === entry.animeId
|
||||
? 'border-ctp-blue bg-ctp-blue'
|
||||
: 'border-ctp-surface2 bg-transparent'
|
||||
}`}
|
||||
/>
|
||||
<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)} · {formatNumber(entry.totalCards)} cards
|
||||
</div>
|
||||
</div>
|
||||
{keeperId === entry.animeId ? (
|
||||
<span className="text-xs text-ctp-blue shrink-0">Keep</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-ctp-surface1 space-y-2">
|
||||
{error ? (
|
||||
<div role="alert" className="text-xs text-ctp-red">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
Result: {totalEpisodes} episode{totalEpisodes !== 1 ? 's' : ''} ·{' '}
|
||||
{formatDuration(totalActiveMs)} · {formatNumber(totalCards)} cards
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={merging}
|
||||
onClick={() => void handleMerge()}
|
||||
className="px-3 py-1.5 rounded-lg bg-ctp-blue/15 border border-ctp-blue/40 text-xs text-ctp-blue hover:bg-ctp-blue/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{merging ? 'Merging…' : 'Merge Entries'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Window } from 'happy-dom';
|
||||
import { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import type { AnimeLibraryItem, StatsMergeAnimeResponse } from '../../types/stats';
|
||||
import { AnimeTab } from './AnimeTab';
|
||||
|
||||
interface TestWindow extends Window {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
|
||||
function installDom(): () => void {
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
const previousHTMLElement = globalThis.HTMLElement;
|
||||
const previousIsReactActEnvironment = (
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT;
|
||||
const window = new Window() as TestWindow;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', { value: window, configurable: true });
|
||||
Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true });
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
value: window.HTMLElement,
|
||||
configurable: true,
|
||||
});
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
return () => {
|
||||
Object.defineProperty(globalThis, 'window', { value: previousWindow, configurable: true });
|
||||
Object.defineProperty(globalThis, 'document', { value: previousDocument, configurable: true });
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
value: previousHTMLElement,
|
||||
configurable: true,
|
||||
});
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = previousIsReactActEnvironment;
|
||||
};
|
||||
}
|
||||
|
||||
function libraryItem(animeId: number, title: string, episodeCount: number): AnimeLibraryItem {
|
||||
return {
|
||||
animeId,
|
||||
canonicalTitle: title,
|
||||
anilistId: null,
|
||||
totalSessions: 1,
|
||||
totalActiveMs: 1000,
|
||||
totalCards: 1,
|
||||
totalTokensSeen: 0,
|
||||
episodeCount,
|
||||
episodesTotal: null,
|
||||
lastWatchedMs: animeId,
|
||||
};
|
||||
}
|
||||
|
||||
function findButton(container: Element, label: string): HTMLElement {
|
||||
const match = [...container.querySelectorAll('button')].find((button) =>
|
||||
(button.textContent ?? '').includes(label),
|
||||
);
|
||||
assert.ok(match, `expected a "${label}" button`);
|
||||
return match as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
/** Library cards only expose aria-pressed while selection mode is on. */
|
||||
function cardButtons(container: Element): HTMLButtonElement[] {
|
||||
return [...container.querySelectorAll('button[aria-pressed]')] as unknown as HTMLButtonElement[];
|
||||
}
|
||||
|
||||
function mergeButton(container: Element): HTMLButtonElement {
|
||||
const match = [...container.querySelectorAll('button')].find(
|
||||
(button) => (button.textContent ?? '').trim() === 'Merge Selected',
|
||||
);
|
||||
assert.ok(match, 'expected a "Merge Selected" button');
|
||||
return match as unknown as HTMLButtonElement;
|
||||
}
|
||||
|
||||
test('AnimeTab merges the selected duplicate entries into the chosen keeper', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = {
|
||||
getAnimeLibrary: apiClient.getAnimeLibrary,
|
||||
mergeAnime: apiClient.mergeAnime,
|
||||
};
|
||||
|
||||
// Two cards for one show, the split this feature exists to undo.
|
||||
let entries = [libraryItem(1, 'Show', 2), libraryItem(2, 'Show Season 1', 1)];
|
||||
let libraryFetches = 0;
|
||||
let mergeCall: { targetAnimeId: number; sourceAnimeIds: number[] } | null = null;
|
||||
|
||||
apiClient.getAnimeLibrary = (async () => {
|
||||
libraryFetches += 1;
|
||||
return entries;
|
||||
}) as typeof apiClient.getAnimeLibrary;
|
||||
apiClient.mergeAnime = (async (targetAnimeId: number, sourceAnimeIds: number[]) => {
|
||||
mergeCall = { targetAnimeId, sourceAnimeIds };
|
||||
entries = [libraryItem(1, 'Show', 3)];
|
||||
return {
|
||||
ok: true,
|
||||
animeId: targetAnimeId,
|
||||
mergedAnimeIds: sourceAnimeIds,
|
||||
movedVideos: 1,
|
||||
} satisfies StatsMergeAnimeResponse;
|
||||
}) as typeof apiClient.mergeAnime;
|
||||
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<AnimeTab />);
|
||||
});
|
||||
assert.equal(libraryFetches, 1);
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, 'Select').click();
|
||||
});
|
||||
// Nothing to merge until at least two entries are picked.
|
||||
assert.equal(mergeButton(container).disabled, true);
|
||||
|
||||
// Sorted by last watched, so the season-tagged duplicate comes first.
|
||||
const cards = cardButtons(container);
|
||||
assert.equal(cards.length, 2);
|
||||
assert.match(cards[0]?.textContent ?? '', /Show Season 1/);
|
||||
|
||||
await act(async () => {
|
||||
cards[0]?.click();
|
||||
});
|
||||
assert.equal(mergeButton(container).disabled, true);
|
||||
|
||||
await act(async () => {
|
||||
cardButtons(container)[1]?.click();
|
||||
});
|
||||
assert.equal(mergeButton(container).disabled, false);
|
||||
|
||||
await act(async () => {
|
||||
mergeButton(container).click();
|
||||
});
|
||||
// The dialog defaults to the entry with the most episodes.
|
||||
assert.match(container.textContent ?? '', /Merge 2 Library Entries/);
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, 'Merge Entries').click();
|
||||
});
|
||||
|
||||
assert.deepEqual(mergeCall, { targetAnimeId: 1, sourceAnimeIds: [2] });
|
||||
assert.equal(libraryFetches, 2);
|
||||
// Selection mode closes and the grid is back to a single card.
|
||||
assert.doesNotMatch(container.textContent ?? '', /Merge 2 Library Entries/);
|
||||
assert.doesNotMatch(container.textContent ?? '', /Show Season 1/);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
} finally {
|
||||
Object.assign(apiClient, original);
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
|
||||
test('AnimeTab keeps a suggested duplicate visible until it is reviewed and merged', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = {
|
||||
getAnimeLibrary: apiClient.getAnimeLibrary,
|
||||
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
|
||||
dismissAnimeMergeRecommendation: apiClient.dismissAnimeMergeRecommendation,
|
||||
mergeAnime: apiClient.mergeAnime,
|
||||
};
|
||||
|
||||
let entries = [libraryItem(1, 'Show', 2), libraryItem(2, 'Show Season 1', 1)];
|
||||
let recommendations = [{ recommendationId: 41, animeIds: [1, 2] }];
|
||||
let mergeCall: { targetAnimeId: number; sourceAnimeIds: number[] } | null = null;
|
||||
|
||||
apiClient.getAnimeLibrary = (async () => entries) as typeof apiClient.getAnimeLibrary;
|
||||
apiClient.getAnimeMergeRecommendations = (async () => ({
|
||||
recommendations,
|
||||
})) as typeof apiClient.getAnimeMergeRecommendations;
|
||||
apiClient.dismissAnimeMergeRecommendation = (async () =>
|
||||
undefined) as typeof apiClient.dismissAnimeMergeRecommendation;
|
||||
apiClient.mergeAnime = (async (targetAnimeId: number, sourceAnimeIds: number[]) => {
|
||||
mergeCall = { targetAnimeId, sourceAnimeIds };
|
||||
entries = [libraryItem(targetAnimeId, 'Show', 3)];
|
||||
recommendations = [];
|
||||
return {
|
||||
ok: true,
|
||||
animeId: targetAnimeId,
|
||||
mergedAnimeIds: sourceAnimeIds,
|
||||
movedVideos: 1,
|
||||
} satisfies StatsMergeAnimeResponse;
|
||||
}) as typeof apiClient.mergeAnime;
|
||||
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<AnimeTab />);
|
||||
});
|
||||
|
||||
assert.match(container.textContent ?? '', /Possible duplicate/);
|
||||
assert.match(container.textContent ?? '', /Show/);
|
||||
assert.match(container.textContent ?? '', /Show Season 1/);
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, 'Review merge').click();
|
||||
});
|
||||
assert.match(container.textContent ?? '', /Merge 2 Library Entries/);
|
||||
|
||||
const keeper = [...container.querySelectorAll('button[aria-pressed]')].find((button) =>
|
||||
(button.textContent ?? '').includes('Show Season 1'),
|
||||
) as HTMLButtonElement | undefined;
|
||||
assert.ok(keeper);
|
||||
await act(async () => {
|
||||
keeper.click();
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(container, 'Merge Entries').click();
|
||||
});
|
||||
|
||||
assert.deepEqual(mergeCall, { targetAnimeId: 2, sourceAnimeIds: [1] });
|
||||
assert.doesNotMatch(container.textContent ?? '', /Possible duplicate/);
|
||||
assert.doesNotMatch(container.textContent ?? '', /Show Season 1/);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
} finally {
|
||||
Object.assign(apiClient, original);
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
|
||||
test('AnimeTab dismisses a false-positive duplicate recommendation', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = {
|
||||
getAnimeLibrary: apiClient.getAnimeLibrary,
|
||||
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
|
||||
dismissAnimeMergeRecommendation: apiClient.dismissAnimeMergeRecommendation,
|
||||
};
|
||||
let dismissedId: number | null = null;
|
||||
|
||||
apiClient.getAnimeLibrary = (async () => [
|
||||
libraryItem(1, 'Show', 2),
|
||||
libraryItem(2, 'Different Show', 1),
|
||||
]) as typeof apiClient.getAnimeLibrary;
|
||||
apiClient.getAnimeMergeRecommendations = (async () => ({
|
||||
recommendations: [{ recommendationId: 73, animeIds: [1, 2] }],
|
||||
})) as typeof apiClient.getAnimeMergeRecommendations;
|
||||
apiClient.dismissAnimeMergeRecommendation = (async (recommendationId: number) => {
|
||||
dismissedId = recommendationId;
|
||||
}) as typeof apiClient.dismissAnimeMergeRecommendation;
|
||||
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => root.render(<AnimeTab />));
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, 'Not duplicates').click();
|
||||
});
|
||||
|
||||
assert.equal(dismissedId, 73);
|
||||
assert.doesNotMatch(container.textContent ?? '', /Possible duplicate/);
|
||||
await act(async () => root.unmount());
|
||||
} finally {
|
||||
Object.assign(apiClient, original);
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
|
||||
test('AnimeTab refreshes the library and recommendations when the window regains focus', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = {
|
||||
getAnimeLibrary: apiClient.getAnimeLibrary,
|
||||
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
|
||||
};
|
||||
let entries = [libraryItem(1, 'Show', 2), libraryItem(2, 'Show Season 1', 1)];
|
||||
let libraryFetches = 0;
|
||||
let recommendationFetches = 0;
|
||||
apiClient.getAnimeLibrary = (async () => {
|
||||
libraryFetches += 1;
|
||||
return entries;
|
||||
}) as typeof apiClient.getAnimeLibrary;
|
||||
apiClient.getAnimeMergeRecommendations = (async () => {
|
||||
recommendationFetches += 1;
|
||||
return { recommendations: [] };
|
||||
}) as typeof apiClient.getAnimeMergeRecommendations;
|
||||
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => root.render(<AnimeTab />));
|
||||
|
||||
entries = [libraryItem(1, 'Show', 3)];
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new window.Event('focus'));
|
||||
});
|
||||
|
||||
assert.equal(libraryFetches, 2);
|
||||
assert.equal(recommendationFetches, 2);
|
||||
assert.doesNotMatch(container.textContent ?? '', /Show Season 1/);
|
||||
await act(async () => root.unmount());
|
||||
} finally {
|
||||
Object.assign(apiClient, original);
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
|
||||
test('AnimeTab keeps a recommendation visible through a transient refresh failure', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = {
|
||||
getAnimeLibrary: apiClient.getAnimeLibrary,
|
||||
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
|
||||
};
|
||||
let failRecommendations = false;
|
||||
apiClient.getAnimeLibrary = (async () => [
|
||||
libraryItem(1, 'Show', 2),
|
||||
libraryItem(2, 'Show Season 1', 1),
|
||||
]) as typeof apiClient.getAnimeLibrary;
|
||||
apiClient.getAnimeMergeRecommendations = (async () => {
|
||||
if (failRecommendations) throw new Error('temporary failure');
|
||||
return { recommendations: [{ recommendationId: 41, animeIds: [1, 2] }] };
|
||||
}) as typeof apiClient.getAnimeMergeRecommendations;
|
||||
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => root.render(<AnimeTab />));
|
||||
assert.match(container.textContent ?? '', /Possible duplicate/);
|
||||
|
||||
failRecommendations = true;
|
||||
await act(async () => window.dispatchEvent(new window.Event('focus')));
|
||||
|
||||
assert.match(container.textContent ?? '', /Possible duplicate/);
|
||||
await act(async () => root.unmount());
|
||||
} finally {
|
||||
Object.assign(apiClient, original);
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
|
||||
test('AnimeTab retains a recommendation and reports a failed dismissal', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = {
|
||||
getAnimeLibrary: apiClient.getAnimeLibrary,
|
||||
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
|
||||
dismissAnimeMergeRecommendation: apiClient.dismissAnimeMergeRecommendation,
|
||||
};
|
||||
apiClient.getAnimeLibrary = (async () => [
|
||||
libraryItem(1, 'Show', 2),
|
||||
libraryItem(2, 'Show Season 1', 1),
|
||||
]) as typeof apiClient.getAnimeLibrary;
|
||||
apiClient.getAnimeMergeRecommendations = (async () => ({
|
||||
recommendations: [{ recommendationId: 41, animeIds: [1, 2] }],
|
||||
})) as typeof apiClient.getAnimeMergeRecommendations;
|
||||
apiClient.dismissAnimeMergeRecommendation = (async () => {
|
||||
throw new Error('offline');
|
||||
}) as typeof apiClient.dismissAnimeMergeRecommendation;
|
||||
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => root.render(<AnimeTab />));
|
||||
|
||||
await act(async () => findButton(container, 'Not duplicates').click());
|
||||
|
||||
assert.match(container.textContent ?? '', /Possible duplicate/);
|
||||
assert.match(container.textContent ?? '', /Could not dismiss this suggestion/);
|
||||
await act(async () => root.unmount());
|
||||
} finally {
|
||||
Object.assign(apiClient, original);
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
|
||||
test('AnimeTab keeps an open recommendation review stable during background refresh', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = {
|
||||
getAnimeLibrary: apiClient.getAnimeLibrary,
|
||||
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
|
||||
};
|
||||
let recommendations = [{ recommendationId: 41, animeIds: [1, 2] as [number, number] }];
|
||||
apiClient.getAnimeLibrary = (async () => [
|
||||
libraryItem(1, 'Show', 2),
|
||||
libraryItem(2, 'Show Season 1', 1),
|
||||
]) as typeof apiClient.getAnimeLibrary;
|
||||
apiClient.getAnimeMergeRecommendations = (async () => ({
|
||||
recommendations,
|
||||
})) as typeof apiClient.getAnimeMergeRecommendations;
|
||||
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => root.render(<AnimeTab />));
|
||||
await act(async () => findButton(container, 'Review merge').click());
|
||||
assert.match(container.textContent ?? '', /Merge 2 Library Entries/);
|
||||
|
||||
recommendations = [];
|
||||
await act(async () => window.dispatchEvent(new window.Event('focus')));
|
||||
|
||||
assert.match(container.textContent ?? '', /Merge 2 Library Entries/);
|
||||
assert.match(container.textContent ?? '', /Show Season 1/);
|
||||
await act(async () => root.unmount());
|
||||
} finally {
|
||||
Object.assign(apiClient, original);
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
|
||||
test('AnimeTab keeps the library visible through a transient background refresh failure', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = {
|
||||
getAnimeLibrary: apiClient.getAnimeLibrary,
|
||||
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
|
||||
};
|
||||
let failLibrary = false;
|
||||
apiClient.getAnimeLibrary = (async () => {
|
||||
if (failLibrary) throw new Error('temporary failure');
|
||||
return [libraryItem(1, 'Show', 2), libraryItem(2, 'Show Season 1', 1)];
|
||||
}) as typeof apiClient.getAnimeLibrary;
|
||||
apiClient.getAnimeMergeRecommendations = (async () => ({
|
||||
recommendations: [],
|
||||
})) as typeof apiClient.getAnimeMergeRecommendations;
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => root.render(<AnimeTab />));
|
||||
assert.match(container.textContent ?? '', /Show Season 1/);
|
||||
|
||||
failLibrary = true;
|
||||
await act(async () => window.dispatchEvent(new window.Event('focus')));
|
||||
|
||||
// A failed poll keeps the last confirmed library instead of replacing the
|
||||
// whole tab (and any open dialog) with the error screen.
|
||||
assert.match(container.textContent ?? '', /Show Season 1/);
|
||||
assert.doesNotMatch(container.textContent ?? '', /Error:/);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
Object.assign(apiClient, original);
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
|
||||
test('AnimeTab does not resurrect a dismissed recommendation from a stale in-flight refresh', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = {
|
||||
getAnimeLibrary: apiClient.getAnimeLibrary,
|
||||
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
|
||||
dismissAnimeMergeRecommendation: apiClient.dismissAnimeMergeRecommendation,
|
||||
};
|
||||
const payload = {
|
||||
recommendations: [{ recommendationId: 41, animeIds: [1, 2] as [number, number] }],
|
||||
};
|
||||
let holdNextFetch = false;
|
||||
let releaseHeldFetch: (() => void) | null = null;
|
||||
apiClient.getAnimeLibrary = (async () => [
|
||||
libraryItem(1, 'Show', 2),
|
||||
libraryItem(2, 'Show Season 1', 1),
|
||||
]) as typeof apiClient.getAnimeLibrary;
|
||||
apiClient.getAnimeMergeRecommendations = ((): Promise<typeof payload> => {
|
||||
if (!holdNextFetch) return Promise.resolve(payload);
|
||||
holdNextFetch = false;
|
||||
return new Promise((resolve) => {
|
||||
releaseHeldFetch = () => resolve(payload);
|
||||
});
|
||||
}) as typeof apiClient.getAnimeMergeRecommendations;
|
||||
apiClient.dismissAnimeMergeRecommendation =
|
||||
(async () => {}) as typeof apiClient.dismissAnimeMergeRecommendation;
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => root.render(<AnimeTab />));
|
||||
assert.match(container.textContent ?? '', /Possible duplicate/);
|
||||
|
||||
// Focus starts a refresh whose response is still in flight when the user
|
||||
// dismisses; the stale payload must not bring the strip back.
|
||||
holdNextFetch = true;
|
||||
await act(async () => window.dispatchEvent(new window.Event('focus')));
|
||||
await act(async () => findButton(container, 'Not duplicates').click());
|
||||
assert.doesNotMatch(container.textContent ?? '', /Possible duplicate/);
|
||||
|
||||
await act(async () => {
|
||||
releaseHeldFetch?.();
|
||||
});
|
||||
|
||||
assert.doesNotMatch(container.textContent ?? '', /Possible duplicate/);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
Object.assign(apiClient, original);
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from '../../lib/library-card-size';
|
||||
import { AnimeCard } from './AnimeCard';
|
||||
import { AnimeDetailView } from './AnimeDetailView';
|
||||
import { AnimeMergeDialog } from './AnimeMergeDialog';
|
||||
import { DuplicateReviewStrip } from './DuplicateReviewStrip';
|
||||
|
||||
type SortKey = 'lastWatched' | 'watchTime' | 'cards' | 'episodes';
|
||||
|
||||
@@ -53,7 +55,17 @@ export function AnimeTab({
|
||||
onNavigateToWord,
|
||||
onOpenEpisodeDetail,
|
||||
}: AnimeTabProps) {
|
||||
const { anime, loading, error, reload } = useAnimeLibrary();
|
||||
const {
|
||||
anime,
|
||||
loading,
|
||||
error,
|
||||
reload,
|
||||
recommendations,
|
||||
dismissRecommendation,
|
||||
dismissingRecommendationId,
|
||||
recommendationActionError,
|
||||
clearRecommendation,
|
||||
} = useAnimeLibrary();
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
|
||||
const [cardSize, setCardSize] = useState<LibraryCardSize>(() =>
|
||||
@@ -62,6 +74,23 @@ export function AnimeTab({
|
||||
),
|
||||
);
|
||||
const [selectedAnimeId, setSelectedAnimeId] = useState<number | null>(null);
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [checkedAnimeIds, setCheckedAnimeIds] = useState<number[]>([]);
|
||||
const [showMergeDialog, setShowMergeDialog] = useState(false);
|
||||
const [reviewRecommendationId, setReviewRecommendationId] = useState<number | null>(null);
|
||||
const [reviewAnimeIds, setReviewAnimeIds] = useState<[number, number] | null>(null);
|
||||
|
||||
function toggleChecked(animeId: number): void {
|
||||
setCheckedAnimeIds((ids) =>
|
||||
ids.includes(animeId) ? ids.filter((id) => id !== animeId) : [...ids, animeId],
|
||||
);
|
||||
}
|
||||
|
||||
function exitSelectionMode(): void {
|
||||
setSelectionMode(false);
|
||||
setCheckedAnimeIds([]);
|
||||
setShowMergeDialog(false);
|
||||
}
|
||||
|
||||
function handleCardSizeChange(size: LibraryCardSize): void {
|
||||
setCardSize(size);
|
||||
@@ -86,6 +115,22 @@ export function AnimeTab({
|
||||
}, [anime, search, sortKey]);
|
||||
|
||||
const totalMs = anime.reduce((sum, a) => sum + a.totalActiveMs, 0);
|
||||
const checkedEntries = checkedAnimeIds
|
||||
.map((animeId) => anime.find((entry) => entry.animeId === animeId))
|
||||
.filter((entry): entry is (typeof anime)[number] => entry !== undefined);
|
||||
const hydratedRecommendations = recommendations
|
||||
.map((recommendation) => ({
|
||||
...recommendation,
|
||||
entries: recommendation.animeIds
|
||||
.map((animeId) => anime.find((entry) => entry.animeId === animeId))
|
||||
.filter((entry): entry is (typeof anime)[number] => entry !== undefined),
|
||||
}))
|
||||
.filter((recommendation) => recommendation.entries.length >= 2);
|
||||
const activeRecommendation = hydratedRecommendations[0] ?? null;
|
||||
const reviewEntries = (reviewAnimeIds ?? [])
|
||||
.map((animeId) => anime.find((entry) => entry.animeId === animeId))
|
||||
.filter((entry): entry is (typeof anime)[number] => entry !== undefined);
|
||||
const mergeEntries = reviewRecommendationId !== null ? reviewEntries : checkedEntries;
|
||||
|
||||
if (selectedAnimeId !== null) {
|
||||
return (
|
||||
@@ -100,6 +145,7 @@ export function AnimeTab({
|
||||
}
|
||||
onAnimeDeleted={reload}
|
||||
onAnilistRelinked={reload}
|
||||
onEpisodeMoved={reload}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -143,11 +189,57 @@ export function AnimeTab({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (selectionMode ? exitSelectionMode() : setSelectionMode(true))}
|
||||
title="Select several entries to merge them into one"
|
||||
className={`px-2 py-2 rounded-lg border text-xs shrink-0 transition-colors ${
|
||||
selectionMode
|
||||
? 'bg-ctp-blue/15 border-ctp-blue/40 text-ctp-blue'
|
||||
: 'bg-ctp-surface0 border-ctp-surface1 text-ctp-overlay2 hover:text-ctp-subtext0'
|
||||
}`}
|
||||
>
|
||||
{selectionMode ? 'Cancel' : 'Select'}
|
||||
</button>
|
||||
<div className="text-xs text-ctp-overlay2 shrink-0">
|
||||
{filtered.length} titles · {formatDuration(totalMs)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeRecommendation ? (
|
||||
<DuplicateReviewStrip
|
||||
entries={activeRecommendation.entries}
|
||||
current={1}
|
||||
total={hydratedRecommendations.length}
|
||||
dismissing={dismissingRecommendationId === activeRecommendation.recommendationId}
|
||||
error={recommendationActionError}
|
||||
onReview={() => {
|
||||
setReviewRecommendationId(activeRecommendation.recommendationId);
|
||||
setReviewAnimeIds(activeRecommendation.animeIds);
|
||||
setShowMergeDialog(true);
|
||||
}}
|
||||
onDismiss={() => void dismissRecommendation(activeRecommendation.recommendationId)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{selectionMode && (
|
||||
<div className="flex items-center justify-between gap-3 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2">
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{checkedEntries.length === 0
|
||||
? 'Pick the duplicate entries to combine'
|
||||
: `${checkedEntries.length} selected`}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={checkedEntries.length < 2}
|
||||
onClick={() => setShowMergeDialog(true)}
|
||||
className="px-3 py-1.5 rounded-lg bg-ctp-blue/15 border border-ctp-blue/40 text-xs text-ctp-blue hover:bg-ctp-blue/25 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Merge Selected
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-sm text-ctp-overlay2 p-4">No titles found</div>
|
||||
) : (
|
||||
@@ -156,11 +248,35 @@ export function AnimeTab({
|
||||
<AnimeCard
|
||||
key={item.animeId}
|
||||
anime={item}
|
||||
onClick={() => setSelectedAnimeId(item.animeId)}
|
||||
selectable={selectionMode}
|
||||
selected={checkedAnimeIds.includes(item.animeId)}
|
||||
onClick={() =>
|
||||
selectionMode ? toggleChecked(item.animeId) : setSelectedAnimeId(item.animeId)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showMergeDialog && mergeEntries.length >= 2 && (
|
||||
<AnimeMergeDialog
|
||||
entries={mergeEntries}
|
||||
onClose={() => {
|
||||
setShowMergeDialog(false);
|
||||
setReviewRecommendationId(null);
|
||||
setReviewAnimeIds(null);
|
||||
}}
|
||||
onMerged={() => {
|
||||
if (reviewRecommendationId !== null) {
|
||||
clearRecommendation(reviewRecommendationId);
|
||||
}
|
||||
exitSelectionMode();
|
||||
setReviewRecommendationId(null);
|
||||
setReviewAnimeIds(null);
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { AnimeLibraryItem } from '../../types/stats';
|
||||
|
||||
interface DuplicateReviewStripProps {
|
||||
entries: AnimeLibraryItem[];
|
||||
current: number;
|
||||
total: number;
|
||||
dismissing: boolean;
|
||||
error?: string | null;
|
||||
onReview: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function DuplicateReviewStrip({
|
||||
entries,
|
||||
current,
|
||||
total,
|
||||
dismissing,
|
||||
error = null,
|
||||
onReview,
|
||||
onDismiss,
|
||||
}: DuplicateReviewStripProps) {
|
||||
return (
|
||||
<aside
|
||||
aria-label="Possible duplicate library entries"
|
||||
className="relative overflow-hidden rounded-lg border border-ctp-yellow/25 bg-ctp-yellow/[0.06] px-3 py-2.5"
|
||||
>
|
||||
<div className="absolute inset-y-0 left-0 w-0.5 bg-ctp-yellow/70" aria-hidden="true" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-ctp-yellow">Possible duplicate</span>
|
||||
{total > 1 ? (
|
||||
<span className="text-[10px] tabular-nums text-ctp-overlay1">
|
||||
{current} of {total}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-ctp-subtext0">
|
||||
{entries.map((entry) => entry.canonicalTitle).join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={dismissing}
|
||||
onClick={onDismiss}
|
||||
className="shrink-0 rounded-md px-2.5 py-1.5 text-xs text-ctp-overlay2 transition-colors hover:bg-ctp-surface0 hover:text-ctp-text disabled:opacity-50"
|
||||
>
|
||||
{dismissing ? 'Dismissing…' : 'Not duplicates'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={dismissing}
|
||||
onClick={onReview}
|
||||
className="shrink-0 rounded-md border border-ctp-yellow/35 bg-ctp-yellow/10 px-2.5 py-1.5 text-xs font-medium text-ctp-yellow transition-colors hover:bg-ctp-yellow/20 disabled:opacity-50"
|
||||
>
|
||||
Review merge
|
||||
</button>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="mt-1.5 text-xs text-ctp-red">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Window } from 'happy-dom';
|
||||
import { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import type { AnimeEpisode, AnimeLibraryItem, StatsMoveVideoResponse } from '../../types/stats';
|
||||
import { EpisodeList } from './EpisodeList';
|
||||
|
||||
interface TestWindow extends Window {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
|
||||
function installDom(): () => void {
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
const previousHTMLElement = globalThis.HTMLElement;
|
||||
const previousIsReactActEnvironment = (
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT;
|
||||
const window = new Window() as TestWindow;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', { value: window, configurable: true });
|
||||
Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true });
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
value: window.HTMLElement,
|
||||
configurable: true,
|
||||
});
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
return () => {
|
||||
Object.defineProperty(globalThis, 'window', { value: previousWindow, configurable: true });
|
||||
Object.defineProperty(globalThis, 'document', { value: previousDocument, configurable: true });
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
value: previousHTMLElement,
|
||||
configurable: true,
|
||||
});
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = previousIsReactActEnvironment;
|
||||
};
|
||||
}
|
||||
|
||||
function episode(videoId: number, title: string): AnimeEpisode {
|
||||
return {
|
||||
videoId,
|
||||
episode: videoId,
|
||||
season: null,
|
||||
durationMs: 1_440_000,
|
||||
endedMediaMs: null,
|
||||
watched: 0,
|
||||
canonicalTitle: title,
|
||||
totalSessions: 1,
|
||||
totalActiveMs: 1000,
|
||||
totalCards: 0,
|
||||
totalTokensSeen: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
lastWatchedMs: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function libraryItem(animeId: number, title: string): AnimeLibraryItem {
|
||||
return {
|
||||
animeId,
|
||||
canonicalTitle: title,
|
||||
anilistId: null,
|
||||
totalSessions: 1,
|
||||
totalActiveMs: 1000,
|
||||
totalCards: 0,
|
||||
totalTokensSeen: 0,
|
||||
episodeCount: 1,
|
||||
episodesTotal: null,
|
||||
lastWatchedMs: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function findButtonByTitle(container: Element, title: string): HTMLElement {
|
||||
const match = [...container.querySelectorAll('button')].find(
|
||||
(button) => button.getAttribute('title') === title,
|
||||
);
|
||||
assert.ok(match, `expected a button titled "${title}"`);
|
||||
return match as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
function findButtonByText(container: Element, text: string): HTMLElement {
|
||||
const match = [...container.querySelectorAll('button')].find((button) =>
|
||||
(button.textContent ?? '').includes(text),
|
||||
);
|
||||
assert.ok(match, `expected a "${text}" button`);
|
||||
return match as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
test('EpisodeList moves an episode to the library entry picked in the dialog', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = {
|
||||
getAnimeLibrary: apiClient.getAnimeLibrary,
|
||||
moveVideoToAnime: apiClient.moveVideoToAnime,
|
||||
};
|
||||
|
||||
let moveCall: { videoId: number; animeId: number } | null = null;
|
||||
let movedResult: boolean | null = null;
|
||||
|
||||
apiClient.getAnimeLibrary = (async () => [
|
||||
libraryItem(1, 'Current Entry'),
|
||||
libraryItem(2, 'Real Series'),
|
||||
]) as typeof apiClient.getAnimeLibrary;
|
||||
apiClient.moveVideoToAnime = (async (videoId: number, animeId: number) => {
|
||||
moveCall = { videoId, animeId };
|
||||
return {
|
||||
ok: true,
|
||||
animeId,
|
||||
previousAnimeId: 1,
|
||||
removedPreviousAnime: true,
|
||||
} satisfies StatsMoveVideoResponse;
|
||||
}) as typeof apiClient.moveVideoToAnime;
|
||||
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EpisodeList
|
||||
episodes={[episode(5, 'Stray Episode')]}
|
||||
animeId={1}
|
||||
onEpisodeMoved={(removedPreviousAnime) => {
|
||||
movedResult = removedPreviousAnime;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButtonByTitle(container, 'Move to another library entry').click();
|
||||
});
|
||||
assert.match(container.textContent ?? '', /Move "Stray Episode" To/);
|
||||
// The entry the episode already belongs to is not offered as a target.
|
||||
assert.doesNotMatch(container.textContent ?? '', /Current Entry/);
|
||||
|
||||
await act(async () => {
|
||||
findButtonByText(container, 'Real Series').click();
|
||||
});
|
||||
|
||||
assert.deepEqual(moveCall, { videoId: 5, animeId: 2 });
|
||||
assert.equal(movedResult, true);
|
||||
// The row leaves this entry's list and the picker closes.
|
||||
assert.doesNotMatch(container.textContent ?? '', /Stray Episode/);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
} finally {
|
||||
Object.assign(apiClient, original);
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { formatDuration } from '../../lib/formatters';
|
||||
import { useModalFocus } from '../../hooks/useModalFocus';
|
||||
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 [loadFailed, setLoadFailed] = useState(false);
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const headingId = useId();
|
||||
const searchId = useId();
|
||||
const busy = busyAnimeId !== null;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
apiClient
|
||||
.getAnimeLibrary()
|
||||
.then((data) => {
|
||||
if (!cancelled) setEntries(data);
|
||||
})
|
||||
.catch(() => {
|
||||
// Distinct from an empty library: telling the user "no other titles"
|
||||
// when the request failed hides a retryable error.
|
||||
if (cancelled) return;
|
||||
setEntries([]);
|
||||
setLoadFailed(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useModalFocus({
|
||||
dialogRef,
|
||||
initialFocusRef: inputRef,
|
||||
dismissDisabled: busy,
|
||||
onDismiss: onClose,
|
||||
});
|
||||
|
||||
const handleDismiss = () => {
|
||||
if (!busy) onClose();
|
||||
};
|
||||
|
||||
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={handleDismiss}
|
||||
>
|
||||
<div className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]" />
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={headingId}
|
||||
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 id={headingId} className="text-sm font-semibold text-ctp-text">
|
||||
{heading}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
disabled={busy}
|
||||
aria-label="Close"
|
||||
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none disabled:opacity-50"
|
||||
>
|
||||
{'✕'}
|
||||
</button>
|
||||
</div>
|
||||
<label htmlFor={searchId} className="sr-only">
|
||||
Search library
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={searchId}
|
||||
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 role="alert" 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>}
|
||||
{loadFailed && (
|
||||
<div role="alert" className="text-xs text-ctp-red p-3">
|
||||
Could not load the library. Close this dialog and try again.
|
||||
</div>
|
||||
)}
|
||||
{!loadFailed && entries !== null && visible.length === 0 && (
|
||||
<div className="text-xs text-ctp-overlay2 p-3">
|
||||
{query.trim() ? 'No matches' : 'No other titles'}
|
||||
</div>
|
||||
)}
|
||||
{visible.map((entry) => (
|
||||
<button
|
||||
key={entry.animeId}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,25 @@
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
import { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type { AnimeLibraryItem } from '../types/stats';
|
||||
import type { AnimeLibraryItem, StatsAnimeMergeRecommendation } from '../types/stats';
|
||||
|
||||
const BACKGROUND_REFRESH_MS = 30_000;
|
||||
|
||||
export function useAnimeLibrary() {
|
||||
const [anime, setAnime] = useState<AnimeLibraryItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [recommendations, setRecommendations] = useState<StatsAnimeMergeRecommendation[]>([]);
|
||||
const [dismissingRecommendationId, setDismissingRecommendationId] = useState<number | null>(null);
|
||||
const [recommendationActionError, setRecommendationActionError] = useState<string | null>(null);
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
// Once the library has rendered, a failed background poll must not replace
|
||||
// it with the error screen (and unmount any open dialog with it).
|
||||
const hasLoadedRef = useRef(false);
|
||||
// Ids dismissed or merged away locally. A recommendations response that was
|
||||
// already in flight when the user acted would otherwise resurrect them.
|
||||
// Dismissed and merged rows never return to pending server-side, so the set
|
||||
// only ever suppresses genuinely stale data.
|
||||
const removedRecommendationIdsRef = useRef<Set<number>>(new Set());
|
||||
|
||||
const reload = useCallback(() => {
|
||||
setReloadToken((token) => token + 1);
|
||||
@@ -14,21 +27,90 @@ export function useAnimeLibrary() {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getStatsClient()
|
||||
const client = getStatsClient();
|
||||
client
|
||||
.getAnimeLibrary()
|
||||
.then((data) => {
|
||||
if (!cancelled) setAnime(data);
|
||||
if (!cancelled) {
|
||||
hasLoadedRef.current = true;
|
||||
setAnime(data);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (!cancelled) setError(err.message);
|
||||
if (!cancelled && !hasLoadedRef.current) setError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
// Recommendation support is deliberately non-blocking. An older backend
|
||||
// should still be able to display its library even when this endpoint is
|
||||
// unavailable.
|
||||
client
|
||||
.getAnimeMergeRecommendations()
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setRecommendations(
|
||||
data.recommendations.filter(
|
||||
(item) => !removedRecommendationIdsRef.current.has(item.recommendationId),
|
||||
),
|
||||
);
|
||||
setRecommendationActionError(null);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Preserve the last confirmed set. A transient polling failure should
|
||||
// not make a pending review silently disappear.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [reloadToken]);
|
||||
|
||||
return { anime, loading, error, reload };
|
||||
useEffect(() => {
|
||||
const refreshOnFocus = () => reload();
|
||||
const interval = window.setInterval(reload, BACKGROUND_REFRESH_MS);
|
||||
window.addEventListener('focus', refreshOnFocus);
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener('focus', refreshOnFocus);
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
const dismissRecommendation = useCallback(async (recommendationId: number) => {
|
||||
setDismissingRecommendationId(recommendationId);
|
||||
setRecommendationActionError(null);
|
||||
try {
|
||||
await getStatsClient().dismissAnimeMergeRecommendation(recommendationId);
|
||||
removedRecommendationIdsRef.current.add(recommendationId);
|
||||
setRecommendations((current) =>
|
||||
current.filter((item) => item.recommendationId !== recommendationId),
|
||||
);
|
||||
} catch {
|
||||
setRecommendationActionError('Could not dismiss this suggestion. Try again.');
|
||||
} finally {
|
||||
setDismissingRecommendationId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearRecommendation = useCallback((recommendationId: number) => {
|
||||
removedRecommendationIdsRef.current.add(recommendationId);
|
||||
setRecommendations((current) =>
|
||||
current.filter((item) => item.recommendationId !== recommendationId),
|
||||
);
|
||||
setRecommendationActionError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
anime,
|
||||
loading,
|
||||
error,
|
||||
reload,
|
||||
recommendations,
|
||||
dismissRecommendation,
|
||||
dismissingRecommendationId,
|
||||
recommendationActionError,
|
||||
clearRecommendation,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react';
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'a[href]',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
|
||||
interface UseModalFocusOptions {
|
||||
dialogRef: RefObject<HTMLElement | null>;
|
||||
initialFocusRef: RefObject<HTMLElement | null>;
|
||||
dismissDisabled?: boolean;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function useModalFocus({
|
||||
dialogRef,
|
||||
initialFocusRef,
|
||||
dismissDisabled = false,
|
||||
onDismiss,
|
||||
}: UseModalFocusOptions): void {
|
||||
const dismissDisabledRef = useRef(dismissDisabled);
|
||||
const onDismissRef = useRef(onDismiss);
|
||||
dismissDisabledRef.current = dismissDisabled;
|
||||
onDismissRef.current = onDismiss;
|
||||
|
||||
useEffect(() => {
|
||||
const previouslyFocused =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
initialFocusRef.current?.focus();
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
if (!dismissDisabledRef.current) {
|
||||
event.preventDefault();
|
||||
onDismissRef.current();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
const focusable = [...dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)];
|
||||
const first = focusable[0];
|
||||
const last = focusable.at(-1);
|
||||
if (!first || !last) return;
|
||||
|
||||
if (
|
||||
event.shiftKey &&
|
||||
(document.activeElement === first || !dialog.contains(document.activeElement))
|
||||
) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (
|
||||
!event.shiftKey &&
|
||||
(document.activeElement === last || !dialog.contains(document.activeElement))
|
||||
) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
previouslyFocused?.focus();
|
||||
};
|
||||
}, [dialogRef, initialFocusRef]);
|
||||
}
|
||||
@@ -44,6 +44,45 @@ test('getAnimeCoverUrl appends retry tokens for late cover refreshes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('getAnimeMergeRecommendations loads pending duplicate pairs', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let seenUrl = '';
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
seenUrl = String(input);
|
||||
return new Response(
|
||||
JSON.stringify({ recommendations: [{ recommendationId: 4, animeIds: [7, 8] }] }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
const result = await apiClient.getAnimeMergeRecommendations();
|
||||
assert.equal(seenUrl, `${BASE_URL}/api/stats/anime/merge-recommendations`);
|
||||
assert.deepEqual(result.recommendations, [{ recommendationId: 4, animeIds: [7, 8] }]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('dismissAnimeMergeRecommendation sends DELETE for the selected suggestion', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let seenUrl = '';
|
||||
let seenMethod = '';
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
seenUrl = String(input);
|
||||
seenMethod = init?.method ?? 'GET';
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
await apiClient.dismissAnimeMergeRecommendation(4);
|
||||
assert.equal(seenUrl, `${BASE_URL}/api/stats/anime/merge-recommendations/4`);
|
||||
assert.equal(seenMethod, 'DELETE');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('getCoverImages batches anime and media cover requests', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let seenUrl = '';
|
||||
|
||||
@@ -11,6 +11,10 @@ import type {
|
||||
StatsExcludedWordsRequest,
|
||||
StatsHttpClient,
|
||||
StatsJsonResponseMap,
|
||||
StatsMergeAnimeRequest,
|
||||
StatsMergeAnimeResponse,
|
||||
StatsMoveVideoRequest,
|
||||
StatsMoveVideoResponse,
|
||||
StatsTrendGroupBy,
|
||||
StatsTrendRange,
|
||||
StatsVideoWatchedRequest,
|
||||
@@ -140,6 +144,8 @@ export const apiClient = {
|
||||
getMediaLibrary: () => fetchJson('mediaLibrary', '/api/stats/media'),
|
||||
getMediaDetail: (videoId: number) => fetchJson('mediaDetail', `/api/stats/media/${videoId}`),
|
||||
getAnimeLibrary: () => fetchJson('animeLibrary', '/api/stats/anime'),
|
||||
getAnimeMergeRecommendations: () =>
|
||||
fetchJson('animeMergeRecommendations', '/api/stats/anime/merge-recommendations'),
|
||||
getAnimeDetail: (animeId: number) => fetchJson('animeDetail', `/api/stats/anime/${animeId}`),
|
||||
getAnimeWords: (animeId: number, limit = 50) =>
|
||||
fetchJson('animeWords', `/api/stats/anime/${animeId}/words?limit=${limit}`),
|
||||
@@ -209,6 +215,30 @@ export const apiClient = {
|
||||
fetchResponse(`/api/stats/anime/${animeId}`, { method: 'DELETE' }),
|
||||
);
|
||||
},
|
||||
mergeAnime: async (
|
||||
targetAnimeId: number,
|
||||
sourceAnimeIds: number[],
|
||||
): Promise<StatsMergeAnimeResponse> => {
|
||||
const res = await fetchResponse(`/api/stats/anime/${targetAnimeId}/merge`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sourceAnimeIds } satisfies StatsMergeAnimeRequest),
|
||||
});
|
||||
return res.json() as Promise<StatsMergeAnimeResponse>;
|
||||
},
|
||||
moveVideoToAnime: async (videoId: number, animeId: number): Promise<StatsMoveVideoResponse> => {
|
||||
const res = await fetchResponse(`/api/stats/media/${videoId}/anime`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ animeId } satisfies StatsMoveVideoRequest),
|
||||
});
|
||||
return res.json() as Promise<StatsMoveVideoResponse>;
|
||||
},
|
||||
dismissAnimeMergeRecommendation: async (recommendationId: number): Promise<void> => {
|
||||
await fetchResponse(`/api/stats/anime/merge-recommendations/${recommendationId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
getKnownWords: () => fetchJson('knownWords', '/api/stats/known-words'),
|
||||
getKnownWordsSummary: () => fetchJson('knownWordsSummary', '/api/stats/known-words-summary'),
|
||||
getAnimeKnownWordsSummary: (animeId: number) =>
|
||||
|
||||
Reference in New Issue
Block a user