mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-13 13:55:57 -07:00
fix(stats): review fuzzy AniList duplicates before merging
- Preserve merged title aliases for future episodes - Fail closed when queued writes cannot drain
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
import { useCallback, useState, useEffect } 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);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
@@ -14,10 +19,14 @@ export function useAnimeLibrary() {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getStatsClient()
|
||||
const client = getStatsClient();
|
||||
client
|
||||
.getAnimeLibrary()
|
||||
.then((data) => {
|
||||
if (!cancelled) setAnime(data);
|
||||
if (!cancelled) {
|
||||
setAnime(data);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (!cancelled) setError(err.message);
|
||||
@@ -25,10 +34,68 @@ export function useAnimeLibrary() {
|
||||
.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);
|
||||
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);
|
||||
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) => {
|
||||
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]);
|
||||
}
|
||||
Reference in New Issue
Block a user