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
+88 -6
View File
@@ -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,
};
}
+73
View File
@@ -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]);
}