mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 19:21:33 -07:00
feat(stats): add library entry deletion and app-wide delete progress (#174)
This commit is contained in:
+4
-1
@@ -1,8 +1,10 @@
|
||||
import { Suspense, lazy, useCallback, useState } from 'react';
|
||||
import { DeleteConfirmDialog } from './components/layout/DeleteConfirmDialog';
|
||||
import { DeleteProgressToast } from './components/common/DeleteProgressToast';
|
||||
import { TabBar } from './components/layout/TabBar';
|
||||
import { OverviewTab } from './components/overview/OverviewTab';
|
||||
import { useExcludedWords } from './hooks/useExcludedWords';
|
||||
import { assetUrl } from './lib/asset-url';
|
||||
import type { TabId } from './components/layout/TabBar';
|
||||
import {
|
||||
closeMediaDetail,
|
||||
@@ -140,7 +142,7 @@ export function App() {
|
||||
onClick={() => handleTabChange('overview')}
|
||||
className="flex items-center gap-2 mb-2 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<img src="/favicon.png" alt="" className="h-6 object-contain" />
|
||||
<img src={assetUrl('favicon.png')} alt="" className="h-6 object-contain" />
|
||||
<h1 className="text-lg font-semibold text-ctp-text">SubMiner Stats</h1>
|
||||
</button>
|
||||
<TabBar activeTab={activeTab} onTabChange={handleTabChange} />
|
||||
@@ -293,6 +295,7 @@ export function App() {
|
||||
</Suspense>
|
||||
) : null}
|
||||
<DeleteConfirmDialog />
|
||||
<DeleteProgressToast />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useAnimeDetail } from '../../hooks/useAnimeDetail';
|
||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||
import { confirmAnimeDelete } from '../../lib/delete-confirm';
|
||||
import { epochDayToDate } from '../../lib/formatters';
|
||||
import { AnimeHeader } from './AnimeHeader';
|
||||
import { EpisodeList } from './EpisodeList';
|
||||
@@ -16,6 +17,14 @@ interface AnimeDetailViewProps {
|
||||
onBack: () => void;
|
||||
onNavigateToWord?: (wordId: number) => void;
|
||||
onOpenEpisodeDetail?: (videoId: number) => void;
|
||||
/** Called after the whole library entry is deleted, so the caller can refresh. */
|
||||
onAnimeDeleted?: () => void;
|
||||
/**
|
||||
* Called after the AniList link changes. The library list caches the old
|
||||
* anilistId (and with it the cover URL), so it has to refetch or the grid
|
||||
* keeps showing the previous title's art.
|
||||
*/
|
||||
onAnilistRelinked?: () => void;
|
||||
}
|
||||
|
||||
type Range = 14 | 30 | 90;
|
||||
@@ -139,10 +148,15 @@ export function AnimeDetailView({
|
||||
onBack,
|
||||
onNavigateToWord,
|
||||
onOpenEpisodeDetail,
|
||||
onAnimeDeleted,
|
||||
onAnilistRelinked,
|
||||
}: AnimeDetailViewProps) {
|
||||
const { data, loading, error, reload } = useAnimeDetail(animeId);
|
||||
const [showAnilistSelector, setShowAnilistSelector] = useState(false);
|
||||
const [coverRetryToken, setCoverRetryToken] = useState(0);
|
||||
const [isDeletingAnime, setIsDeletingAnime] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const isDeletingAnimeRef = useRef(false);
|
||||
const knownWordsSummary = useAnimeKnownWords(animeId);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -154,6 +168,40 @@ export function AnimeDetailView({
|
||||
if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Anime not found</div>;
|
||||
|
||||
const { detail, episodes, anilistEntries } = data;
|
||||
|
||||
const handleDeleteAnime = async () => {
|
||||
if (isDeletingAnimeRef.current) return;
|
||||
isDeletingAnimeRef.current = true;
|
||||
// Cleared up front so cancelling a retry doesn't leave the previous
|
||||
// attempt's failure on screen.
|
||||
setDeleteError(null);
|
||||
let confirmed = false;
|
||||
try {
|
||||
confirmed = await confirmAnimeDelete(detail.canonicalTitle, detail.episodeCount);
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : 'Failed to confirm delete.');
|
||||
isDeletingAnimeRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!confirmed) {
|
||||
isDeletingAnimeRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteError(null);
|
||||
setIsDeletingAnime(true);
|
||||
try {
|
||||
await getStatsClient().deleteAnime(animeId);
|
||||
onAnimeDeleted?.();
|
||||
onBack();
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : 'Failed to delete this title.');
|
||||
setIsDeletingAnime(false);
|
||||
} finally {
|
||||
isDeletingAnimeRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
@@ -168,7 +216,10 @@ export function AnimeDetailView({
|
||||
anilistEntries={anilistEntries ?? []}
|
||||
coverRetryToken={coverRetryToken}
|
||||
onChangeAnilist={() => setShowAnilistSelector(true)}
|
||||
onDeleteAnime={() => void handleDeleteAnime()}
|
||||
isDeletingAnime={isDeletingAnime}
|
||||
/>
|
||||
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
|
||||
<AnimeOverviewStats detail={detail} knownWordsSummary={knownWordsSummary} />
|
||||
<EpisodeList
|
||||
episodes={episodes}
|
||||
@@ -185,6 +236,7 @@ export function AnimeDetailView({
|
||||
setShowAnilistSelector(false);
|
||||
setCoverRetryToken((value) => value + 1);
|
||||
reload();
|
||||
onAnilistRelinked?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { AnimeHeader } from './AnimeHeader';
|
||||
import { confirmAnimeDelete, setDeleteConfirmPresenter } from '../../lib/delete-confirm';
|
||||
import type { AnimeDetailData } from '../../types/stats';
|
||||
|
||||
const DETAIL: AnimeDetailData['detail'] = {
|
||||
animeId: 3,
|
||||
canonicalTitle: 'Project Radio Noise Season 2',
|
||||
anilistId: 20661,
|
||||
titleRomaji: 'Toaru Kagaku no Railgun S',
|
||||
titleEnglish: 'A Certain Scientific Railgun S',
|
||||
titleNative: null,
|
||||
description: null,
|
||||
totalSessions: 1,
|
||||
totalActiveMs: 960_000,
|
||||
totalCards: 0,
|
||||
totalTokensSeen: 1_655,
|
||||
totalLinesSeen: 300,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
episodeCount: 1,
|
||||
lastWatchedMs: 1_700_000_000_000,
|
||||
};
|
||||
|
||||
test('AnimeHeader offers a delete-entry action alongside the AniList actions', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AnimeHeader detail={DETAIL} anilistEntries={[]} onDeleteAnime={() => {}} />,
|
||||
);
|
||||
|
||||
assert.match(markup, /Delete Entry/);
|
||||
assert.match(markup, /Delete this title and every session and stat recorded for it/);
|
||||
});
|
||||
|
||||
test('AnimeHeader hides the delete action when no handler is wired', () => {
|
||||
const markup = renderToStaticMarkup(<AnimeHeader detail={DETAIL} anilistEntries={[]} />);
|
||||
|
||||
assert.doesNotMatch(markup, /Delete Entry/);
|
||||
});
|
||||
|
||||
test('AnimeHeader shows a pending state while the entry is being deleted', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AnimeHeader detail={DETAIL} anilistEntries={[]} onDeleteAnime={() => {}} isDeletingAnime />,
|
||||
);
|
||||
|
||||
assert.match(markup, /disabled=""/);
|
||||
assert.match(markup, /animate-spin/);
|
||||
assert.doesNotMatch(markup, /Delete Entry/);
|
||||
});
|
||||
|
||||
test('confirmAnimeDelete spells out how much data the entry deletion removes', async () => {
|
||||
const seen: string[] = [];
|
||||
const restore = setDeleteConfirmPresenter((message) => {
|
||||
seen.push(message);
|
||||
return false;
|
||||
});
|
||||
|
||||
try {
|
||||
await confirmAnimeDelete('Railgun Season 2', 1);
|
||||
await confirmAnimeDelete('Railgun Season 2', 3);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
assert.match(seen[0] ?? '', /"Railgun Season 2"/);
|
||||
assert.match(seen[0] ?? '', /1 episode\b/);
|
||||
assert.match(seen[1] ?? '', /3 episodes/);
|
||||
assert.match(seen[0] ?? '', /every session and stat/);
|
||||
});
|
||||
@@ -6,6 +6,8 @@ interface AnimeHeaderProps {
|
||||
anilistEntries: AnilistEntry[];
|
||||
coverRetryToken?: number;
|
||||
onChangeAnilist?: () => void;
|
||||
onDeleteAnime?: () => void;
|
||||
isDeletingAnime?: boolean;
|
||||
}
|
||||
|
||||
function AnilistButton({ entry }: { entry: AnilistEntry }) {
|
||||
@@ -32,6 +34,8 @@ export function AnimeHeader({
|
||||
anilistEntries,
|
||||
coverRetryToken = 0,
|
||||
onChangeAnilist,
|
||||
onDeleteAnime,
|
||||
isDeletingAnime = false,
|
||||
}: AnimeHeaderProps) {
|
||||
const altTitles = [detail.titleRomaji, detail.titleEnglish, detail.titleNative].filter(
|
||||
(t): t is string => t != null && t !== detail.canonicalTitle,
|
||||
@@ -95,6 +99,25 @@ export function AnimeHeader({
|
||||
: 'Link to AniList'}
|
||||
</button>
|
||||
)}
|
||||
{onDeleteAnime && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDeleteAnime}
|
||||
disabled={isDeletingAnime}
|
||||
title="Delete this title and every session and stat recorded for it"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-red/80 hover:bg-ctp-red/15 hover:text-ctp-red transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isDeletingAnime ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-3 w-3 animate-spin rounded-full border-2 border-ctp-surface2 border-t-ctp-red"
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden="true">{'\u2715'}</span>
|
||||
)}
|
||||
{isDeletingAnime ? 'Deleting\u2026' : 'Delete Entry'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{detail.description && (
|
||||
<p className="text-xs text-ctp-subtext0 mt-3 line-clamp-3 leading-relaxed">
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
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 { AnimeDetailData, AnimeLibraryItem } 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(anilistId: number | null): AnimeLibraryItem {
|
||||
return {
|
||||
animeId: 7,
|
||||
canonicalTitle: 'Test Anime Season 2',
|
||||
anilistId,
|
||||
totalSessions: 1,
|
||||
totalActiveMs: 1000,
|
||||
totalCards: 0,
|
||||
totalTokensSeen: 0,
|
||||
episodeCount: 1,
|
||||
episodesTotal: 13,
|
||||
lastWatchedMs: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function detailData(anilistId: number | null): AnimeDetailData {
|
||||
return {
|
||||
detail: {
|
||||
animeId: 7,
|
||||
canonicalTitle: 'Test Anime Season 2',
|
||||
anilistId,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
description: null,
|
||||
totalSessions: 1,
|
||||
totalActiveMs: 1000,
|
||||
totalCards: 0,
|
||||
totalTokensSeen: 0,
|
||||
totalLinesSeen: 0,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
episodeCount: 1,
|
||||
lastWatchedMs: 1,
|
||||
},
|
||||
episodes: [],
|
||||
anilistEntries: [],
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
test('AnimeTab refetches the library after the AniList entry is relinked', async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = {
|
||||
getAnimeLibrary: apiClient.getAnimeLibrary,
|
||||
getAnimeDetail: apiClient.getAnimeDetail,
|
||||
getAnimeWords: apiClient.getAnimeWords,
|
||||
getAnimeRollups: apiClient.getAnimeRollups,
|
||||
getAnimeKnownWordsSummary: apiClient.getAnimeKnownWordsSummary,
|
||||
searchAnilist: apiClient.searchAnilist,
|
||||
reassignAnimeAnilist: apiClient.reassignAnimeAnilist,
|
||||
};
|
||||
|
||||
// The library grid keys its cover URL off anilistId, so a stale list keeps
|
||||
// pointing at the previous entry's art.
|
||||
let anilistId: number | null = 14813;
|
||||
let libraryFetches = 0;
|
||||
|
||||
apiClient.getAnimeLibrary = (async () => {
|
||||
libraryFetches += 1;
|
||||
return [libraryItem(anilistId)];
|
||||
}) as typeof apiClient.getAnimeLibrary;
|
||||
apiClient.getAnimeDetail = (async () => detailData(anilistId)) as typeof apiClient.getAnimeDetail;
|
||||
apiClient.getAnimeWords = (async () => []) as typeof apiClient.getAnimeWords;
|
||||
apiClient.getAnimeRollups = (async () => []) as typeof apiClient.getAnimeRollups;
|
||||
apiClient.getAnimeKnownWordsSummary = (async () => ({
|
||||
totalUniqueWords: 0,
|
||||
knownWordCount: 0,
|
||||
})) as typeof apiClient.getAnimeKnownWordsSummary;
|
||||
apiClient.searchAnilist = (async () => [
|
||||
{
|
||||
id: 108489,
|
||||
episodes: 12,
|
||||
season: null,
|
||||
seasonYear: null,
|
||||
description: null,
|
||||
coverImage: null,
|
||||
title: { romaji: 'Test Anime Kan', english: null, native: null },
|
||||
},
|
||||
]) as typeof apiClient.searchAnilist;
|
||||
apiClient.reassignAnimeAnilist = (async (_animeId: number, info: { anilistId: number }) => {
|
||||
anilistId = info.anilistId;
|
||||
}) as typeof apiClient.reassignAnimeAnilist;
|
||||
|
||||
try {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<AnimeTab />);
|
||||
});
|
||||
assert.equal(libraryFetches, 1);
|
||||
assert.match(
|
||||
container.querySelector('img')?.getAttribute('src') ?? '',
|
||||
/\/api\/stats\/anime\/7\/cover\?coverRetry=14813/,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, 'Test Anime Season 2').click();
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(container, 'Change AniList Entry').click();
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(container, 'Test Anime Kan').click();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(container, 'Back to Library').click();
|
||||
});
|
||||
|
||||
assert.equal(libraryFetches, 2);
|
||||
assert.match(
|
||||
container.querySelector('img')?.getAttribute('src') ?? '',
|
||||
/\/api\/stats\/anime\/7\/cover\?coverRetry=108489/,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
} finally {
|
||||
Object.assign(apiClient, original);
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
@@ -53,7 +53,7 @@ export function AnimeTab({
|
||||
onNavigateToWord,
|
||||
onOpenEpisodeDetail,
|
||||
}: AnimeTabProps) {
|
||||
const { anime, loading, error } = useAnimeLibrary();
|
||||
const { anime, loading, error, reload } = useAnimeLibrary();
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
|
||||
const [cardSize, setCardSize] = useState<LibraryCardSize>(() =>
|
||||
@@ -98,6 +98,8 @@ export function AnimeTab({
|
||||
? (videoId) => onOpenEpisodeDetail(selectedAnimeId, videoId)
|
||||
: undefined
|
||||
}
|
||||
onAnimeDeleted={reload}
|
||||
onAnilistRelinked={reload}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,49 @@
|
||||
interface DeleteProgressToastProps {
|
||||
/** Number of sessions currently being deleted. The toast is hidden when 0. */
|
||||
count: number;
|
||||
}
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { getDeleteProgressSnapshot, subscribeDeleteProgress } from '../../lib/delete-progress';
|
||||
|
||||
/**
|
||||
* Fixed-position toast shown while session deletions are in flight.
|
||||
* Global "deletion in progress" indicator.
|
||||
*
|
||||
* The per-row delete buttons are only visible on hover, so once the confirm
|
||||
* dialog closes the user has no signal that a (potentially slow) batch delete
|
||||
* is still running. This stays on screen, independent of hover, until the work
|
||||
* finishes.
|
||||
* Mounted once at the app root, outside every tab panel, so it shows no matter
|
||||
* which view started the delete — per-tab copies were hidden along with their
|
||||
* `hidden` tab panel, and detail views had no indicator at all. State comes
|
||||
* from the delete-progress store rather than props so it also survives the
|
||||
* initiating component unmounting mid-request.
|
||||
*
|
||||
* Renders two signals: a sweeping bar pinned to the top edge (visible even when
|
||||
* the eye is on the content being deleted) and a bottom-right toast naming the
|
||||
* work.
|
||||
*/
|
||||
export function DeleteProgressToast({ count }: DeleteProgressToastProps) {
|
||||
export function DeleteProgressToast() {
|
||||
const { count, label } = useSyncExternalStore(
|
||||
subscribeDeleteProgress,
|
||||
getDeleteProgressSnapshot,
|
||||
getDeleteProgressSnapshot,
|
||||
);
|
||||
|
||||
if (count <= 0) return null;
|
||||
|
||||
const message = count > 1 ? `Deleting ${count} items` : (label ?? 'Deleting');
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-3 shadow-lg shadow-black/30"
|
||||
>
|
||||
<span
|
||||
<>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-ctp-surface2 border-t-ctp-red"
|
||||
/>
|
||||
<span className="text-sm text-ctp-text">
|
||||
Deleting {count} session{count === 1 ? '' : 's'}…
|
||||
</span>
|
||||
</div>
|
||||
className="fixed inset-x-0 top-0 z-[2147483646] h-0.5 overflow-hidden bg-ctp-surface1"
|
||||
>
|
||||
<div className="h-full w-1/3 animate-indeterminate rounded-full bg-gradient-to-r from-ctp-red via-ctp-peach to-ctp-red" />
|
||||
</div>
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="fixed bottom-4 right-4 z-[2147483646] flex items-center gap-3 rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-3 shadow-lg shadow-black/30"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-ctp-surface2 border-t-ctp-red"
|
||||
/>
|
||||
<span className="text-sm text-ctp-text">{message}…</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { StreakCalendar } from './StreakCalendar';
|
||||
import { RecentSessions } from './RecentSessions';
|
||||
import { TrackingSnapshot } from './TrackingSnapshot';
|
||||
import { TrendChart } from '../trends/TrendChart';
|
||||
import { DeleteProgressToast } from '../common/DeleteProgressToast';
|
||||
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||
@@ -160,8 +159,6 @@ export function OverviewTab({
|
||||
deletingIds={deletingIds}
|
||||
isActive={isActive}
|
||||
/>
|
||||
|
||||
<DeleteProgressToast count={deletingIds.size} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSessions } from '../../hooks/useSessions';
|
||||
import { SessionRow } from './SessionRow';
|
||||
import { SessionDetail } from './SessionDetail';
|
||||
import { DeleteProgressToast } from '../common/DeleteProgressToast';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { confirmBucketDelete, confirmSessionDelete } from '../../lib/delete-confirm';
|
||||
import { formatDuration, formatNumber, formatSessionDayLabel } from '../../lib/formatters';
|
||||
@@ -344,8 +343,6 @@ export function SessionsTab({
|
||||
{search.trim() ? 'No sessions matching your search.' : 'No sessions recorded yet.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DeleteProgressToast count={deletingSessionIds.size} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { useWordDetail } from '../../hooks/useWordDetail';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { assetUrl } from '../../lib/asset-url';
|
||||
import { epochMsFromDbTimestamp, formatNumber, formatRelativeDate } from '../../lib/formatters';
|
||||
import {
|
||||
buildStatsMineCardParams,
|
||||
@@ -164,7 +165,10 @@ export function WordDetailPanel({
|
||||
? data!.detail.headword
|
||||
: occ.text.slice(0, 30);
|
||||
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
||||
new Notification('Anki Card Created', { body: `Mined: ${label}`, icon: '/favicon.png' });
|
||||
new Notification('Anki Card Created', {
|
||||
body: `Mined: ${label}`,
|
||||
icon: assetUrl('favicon.png'),
|
||||
});
|
||||
} else if (typeof Notification !== 'undefined' && Notification.permission !== 'denied') {
|
||||
Notification.requestPermission().then((p) => {
|
||||
if (p === 'granted') new Notification('Anki Card Created', { body: `Mined: ${label}` });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type { AnimeLibraryItem } from '../types/stats';
|
||||
|
||||
@@ -6,6 +6,11 @@ export function useAnimeLibrary() {
|
||||
const [anime, setAnime] = useState<AnimeLibraryItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
setReloadToken((token) => token + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -23,7 +28,7 @@ export function useAnimeLibrary() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [reloadToken]);
|
||||
|
||||
return { anime, loading, error };
|
||||
return { anime, loading, error, reload };
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
} from '../types/stats';
|
||||
import type { StatsMineCardParams, StatsMineCardResponse } from './mining';
|
||||
import { appendCoverRetryToken } from './cover-retry';
|
||||
import { trackDelete } from './delete-progress';
|
||||
|
||||
type StatsLocationLike = Pick<Location, 'protocol' | 'origin' | 'search'>;
|
||||
|
||||
@@ -169,17 +170,29 @@ export const apiClient = {
|
||||
});
|
||||
},
|
||||
deleteSession: async (sessionId: number): Promise<void> => {
|
||||
await fetchResponse(`/api/stats/sessions/${sessionId}`, { method: 'DELETE' });
|
||||
await trackDelete('Deleting session', () =>
|
||||
fetchResponse(`/api/stats/sessions/${sessionId}`, { method: 'DELETE' }),
|
||||
);
|
||||
},
|
||||
deleteSessions: async (sessionIds: number[]): Promise<void> => {
|
||||
await fetchResponse('/api/stats/sessions', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionIds } satisfies StatsDeleteSessionsRequest),
|
||||
});
|
||||
const label = `Deleting ${sessionIds.length} session${sessionIds.length === 1 ? '' : 's'}`;
|
||||
await trackDelete(label, () =>
|
||||
fetchResponse('/api/stats/sessions', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionIds } satisfies StatsDeleteSessionsRequest),
|
||||
}),
|
||||
);
|
||||
},
|
||||
deleteVideo: async (videoId: number): Promise<void> => {
|
||||
await fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' });
|
||||
await trackDelete('Deleting episode', () =>
|
||||
fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' }),
|
||||
);
|
||||
},
|
||||
deleteAnime: async (animeId: number): Promise<void> => {
|
||||
await trackDelete('Deleting library entry', () =>
|
||||
fetchResponse(`/api/stats/anime/${animeId}`, { method: 'DELETE' }),
|
||||
);
|
||||
},
|
||||
getKnownWords: () => fetchJson('knownWords', '/api/stats/known-words'),
|
||||
getKnownWordsSummary: () => fetchJson('knownWordsSummary', '/api/stats/known-words-summary'),
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { assetUrl, resolveAssetUrl } from './asset-url';
|
||||
|
||||
// vite.config.ts sets `base: './'`, so this is what the built bundle sees.
|
||||
const BUILT_BASE = './';
|
||||
|
||||
test('built asset URLs are never root-absolute', () => {
|
||||
assert.equal(resolveAssetUrl('favicon.png', BUILT_BASE).startsWith('/'), false);
|
||||
});
|
||||
|
||||
test('built asset URL resolves next to a file:// index.html', () => {
|
||||
const resolved = new URL(
|
||||
resolveAssetUrl('favicon.png', BUILT_BASE),
|
||||
'file:///opt/SubMiner/stats/dist/index.html',
|
||||
);
|
||||
assert.equal(resolved.href, 'file:///opt/SubMiner/stats/dist/favicon.png');
|
||||
});
|
||||
|
||||
test('built asset URL resolves against the server root when served over http', () => {
|
||||
const resolved = new URL(resolveAssetUrl('favicon.png', BUILT_BASE), 'http://127.0.0.1:8770/');
|
||||
assert.equal(resolved.href, 'http://127.0.0.1:8770/favicon.png');
|
||||
});
|
||||
|
||||
test('dev server base stays root-absolute', () => {
|
||||
assert.equal(resolveAssetUrl('favicon.png', '/'), '/favicon.png');
|
||||
});
|
||||
|
||||
test('a base without a trailing slash still joins cleanly', () => {
|
||||
assert.equal(resolveAssetUrl('favicon.png', '/stats'), '/stats/favicon.png');
|
||||
});
|
||||
|
||||
test('a leading slash in the requested path is tolerated', () => {
|
||||
assert.equal(resolveAssetUrl('/favicon.png', BUILT_BASE), './favicon.png');
|
||||
});
|
||||
|
||||
test('assetUrl falls back to a relative base outside a Vite bundle', () => {
|
||||
assert.equal(assetUrl('favicon.png').startsWith('/'), false);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Resolve a bundled public asset against Vite's base URL.
|
||||
*
|
||||
* The in-player stats window is loaded with `loadFile`, so the document lives on
|
||||
* `file://`. A root-absolute path like `/favicon.png` resolves to the filesystem
|
||||
* root there and 404s, while the HTTP-served web app resolves it fine. Vite
|
||||
* rewrites asset refs in `index.html` but not string literals in JSX, so build
|
||||
* the URL from the configured base instead of hardcoding a leading slash.
|
||||
*/
|
||||
export function resolveAssetUrl(path: string, base: string): string {
|
||||
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
|
||||
return `${normalizedBase}${path.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
function currentBase(): string {
|
||||
// Vite injects BASE_URL at build time ('./' per vite.config.ts) and serves '/'
|
||||
// in dev. Outside a Vite bundle (tests) there is no env, so fall back to './'.
|
||||
const env = (import.meta as { env?: Record<string, string | undefined> }).env;
|
||||
return env?.BASE_URL || './';
|
||||
}
|
||||
|
||||
export function assetUrl(path: string): string {
|
||||
return resolveAssetUrl(path, currentBase());
|
||||
}
|
||||
@@ -60,6 +60,13 @@ export function confirmAnimeGroupDelete(title: string, count: number): Promise<b
|
||||
);
|
||||
}
|
||||
|
||||
export function confirmAnimeDelete(title: string, episodeCount: number): Promise<boolean> {
|
||||
const episodes = `${episodeCount} episode${episodeCount === 1 ? '' : 's'}`;
|
||||
return confirmWithStatsNativeDialogLayer(
|
||||
`Delete "${title}" from your library? This removes ${episodes} plus every session and stat recorded for them.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function confirmEpisodeDelete(title: string): Promise<boolean> {
|
||||
return confirmWithStatsNativeDialogLayer(`Delete "${title}" and all its sessions?`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { DeleteProgressToast } from '../components/common/DeleteProgressToast';
|
||||
import { apiClient } from './api-client';
|
||||
import {
|
||||
beginDeleteTask,
|
||||
getDeleteProgressSnapshot,
|
||||
resetDeleteProgress,
|
||||
subscribeDeleteProgress,
|
||||
trackDelete,
|
||||
} from './delete-progress';
|
||||
|
||||
test('delete progress store reports the oldest label and notifies subscribers', () => {
|
||||
resetDeleteProgress();
|
||||
let notifications = 0;
|
||||
const unsubscribe = subscribeDeleteProgress(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
|
||||
try {
|
||||
assert.deepEqual(getDeleteProgressSnapshot(), { count: 0, label: null });
|
||||
|
||||
const endFirst = beginDeleteTask('Deleting session');
|
||||
const endSecond = beginDeleteTask('Deleting episode');
|
||||
assert.deepEqual(getDeleteProgressSnapshot(), { count: 2, label: 'Deleting session' });
|
||||
assert.equal(notifications, 2);
|
||||
|
||||
endFirst();
|
||||
assert.deepEqual(getDeleteProgressSnapshot(), { count: 1, label: 'Deleting episode' });
|
||||
|
||||
endFirst();
|
||||
assert.deepEqual(
|
||||
getDeleteProgressSnapshot(),
|
||||
{ count: 1, label: 'Deleting episode' },
|
||||
'ending the same task twice must not drop another task',
|
||||
);
|
||||
|
||||
endSecond();
|
||||
assert.deepEqual(getDeleteProgressSnapshot(), { count: 0, label: null });
|
||||
} finally {
|
||||
unsubscribe();
|
||||
resetDeleteProgress();
|
||||
}
|
||||
});
|
||||
|
||||
test('trackDelete clears the indicator even when the request rejects', async () => {
|
||||
resetDeleteProgress();
|
||||
await assert.rejects(
|
||||
trackDelete('Deleting library entry', async () => {
|
||||
assert.equal(getDeleteProgressSnapshot().count, 1);
|
||||
throw new Error('boom');
|
||||
}),
|
||||
/boom/,
|
||||
);
|
||||
assert.deepEqual(getDeleteProgressSnapshot(), { count: 0, label: null });
|
||||
});
|
||||
|
||||
test('DeleteProgressToast stays hidden while idle and reports active deletes', () => {
|
||||
resetDeleteProgress();
|
||||
assert.equal(renderToStaticMarkup(<DeleteProgressToast />), '');
|
||||
|
||||
const end = beginDeleteTask('Deleting library entry');
|
||||
try {
|
||||
const markup = renderToStaticMarkup(<DeleteProgressToast />);
|
||||
assert.match(markup, /role="status"/);
|
||||
assert.match(markup, /Deleting library entry/);
|
||||
assert.match(markup, /animate-indeterminate/);
|
||||
} finally {
|
||||
end();
|
||||
}
|
||||
|
||||
const endFirst = beginDeleteTask('Deleting session');
|
||||
const endSecond = beginDeleteTask('Deleting episode');
|
||||
try {
|
||||
assert.match(renderToStaticMarkup(<DeleteProgressToast />), /Deleting 2 items/);
|
||||
} finally {
|
||||
endFirst();
|
||||
endSecond();
|
||||
resetDeleteProgress();
|
||||
}
|
||||
});
|
||||
|
||||
test('the delete indicator is mounted once at the app root, not inside tab panels', () => {
|
||||
const srcDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const read = (relativePath: string): string =>
|
||||
fs.readFileSync(path.join(srcDir, relativePath), 'utf8');
|
||||
|
||||
const app = read('App.tsx');
|
||||
assert.match(app, /<DeleteProgressToast \/>/);
|
||||
// Sibling of the confirm dialog: outside every `hidden` tab panel, so the
|
||||
// indicator survives tab switches and detail-view navigation.
|
||||
assert.match(app, /<DeleteConfirmDialog \/>\s*<DeleteProgressToast \/>/);
|
||||
|
||||
for (const tab of [
|
||||
'components/overview/OverviewTab.tsx',
|
||||
'components/sessions/SessionsTab.tsx',
|
||||
]) {
|
||||
assert.doesNotMatch(read(tab), /DeleteProgressToast/, `${tab} must not mount its own toast`);
|
||||
}
|
||||
});
|
||||
|
||||
test('every api client delete registers with the global progress indicator', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const seenCounts: number[] = [];
|
||||
globalThis.fetch = (async () => {
|
||||
seenCounts.push(getDeleteProgressSnapshot().count);
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
resetDeleteProgress();
|
||||
await apiClient.deleteSession(1);
|
||||
await apiClient.deleteSessions([1, 2]);
|
||||
await apiClient.deleteVideo(3);
|
||||
await apiClient.deleteAnime(4);
|
||||
|
||||
assert.deepEqual(seenCounts, [1, 1, 1, 1]);
|
||||
assert.deepEqual(getDeleteProgressSnapshot(), { count: 0, label: null });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
resetDeleteProgress();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Tiny external store tracking in-flight delete requests.
|
||||
*
|
||||
* Every delete goes through the API client, which registers here, so a single
|
||||
* globally mounted indicator can report progress no matter which tab, detail
|
||||
* view or overlay window started the work. Keeping the state outside React
|
||||
* means the indicator survives the deleting component unmounting mid-request
|
||||
* (navigating back after deleting a library entry, for example).
|
||||
*/
|
||||
|
||||
export interface DeleteProgressSnapshot {
|
||||
/** Number of delete requests currently in flight. */
|
||||
count: number;
|
||||
/** Label for the oldest in-flight request, or null when idle. */
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
const IDLE_SNAPSHOT: DeleteProgressSnapshot = { count: 0, label: null };
|
||||
|
||||
const activeTasks = new Map<number, string>();
|
||||
const listeners = new Set<() => void>();
|
||||
let nextTaskId = 1;
|
||||
let snapshot: DeleteProgressSnapshot = IDLE_SNAPSHOT;
|
||||
|
||||
function publish(): void {
|
||||
if (activeTasks.size === 0) {
|
||||
snapshot = IDLE_SNAPSHOT;
|
||||
} else {
|
||||
const [oldestLabel] = activeTasks.values();
|
||||
snapshot = { count: activeTasks.size, label: oldestLabel ?? null };
|
||||
}
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function subscribeDeleteProgress(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function getDeleteProgressSnapshot(): DeleteProgressSnapshot {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Register a delete as started. Returns the function that ends it. */
|
||||
export function beginDeleteTask(label: string): () => void {
|
||||
const taskId = nextTaskId++;
|
||||
activeTasks.set(taskId, label);
|
||||
publish();
|
||||
let ended = false;
|
||||
return () => {
|
||||
if (ended) return;
|
||||
ended = true;
|
||||
activeTasks.delete(taskId);
|
||||
publish();
|
||||
};
|
||||
}
|
||||
|
||||
/** Run a delete request with the global progress indicator active. */
|
||||
export async function trackDelete<T>(label: string, run: () => Promise<T>): Promise<T> {
|
||||
const end = beginDeleteTask(label);
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
end();
|
||||
}
|
||||
}
|
||||
|
||||
/** Test helper: drop every in-flight task so cases don't leak into each other. */
|
||||
export function resetDeleteProgress(): void {
|
||||
activeTasks.clear();
|
||||
publish();
|
||||
}
|
||||
@@ -80,7 +80,7 @@ body.overlay-mode #root {
|
||||
}
|
||||
|
||||
/* Tab content entrance animation */
|
||||
@keyframes fadeSlideIn {
|
||||
@keyframes fade-slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
@@ -92,5 +92,39 @@ body.overlay-mode #root {
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeSlideIn 0.25s ease-out;
|
||||
animation: fade-slide-in 0.25s ease-out;
|
||||
}
|
||||
|
||||
/* Indeterminate progress sweep for the global delete indicator */
|
||||
@keyframes indeterminate-sweep {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(400%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-indeterminate {
|
||||
animation: indeterminate-sweep 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-fade-in {
|
||||
animation-duration: 1ms;
|
||||
}
|
||||
|
||||
/* Looping motion can't just be shortened the way a one-shot entrance can —
|
||||
that speeds it up rather than removing it. The delete indicator holds a
|
||||
static busy state instead: the bar fills its track and the spinner stops,
|
||||
leaving the toast text to carry the meaning. */
|
||||
.animate-indeterminate {
|
||||
animation: none;
|
||||
width: 100%;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,3 +14,25 @@ test('stats overlay mode paints an opaque full-viewport background', () => {
|
||||
/body\.overlay-mode #root\s*\{[^}]*background-color:\s*var\(--color-ctp-base\);/s,
|
||||
);
|
||||
});
|
||||
|
||||
test('reduced motion stops the looping delete indicator rather than speeding it up', () => {
|
||||
const reducedMotion = /@media \(prefers-reduced-motion: reduce\)\s*\{(?<body>.*)\n\}/s.exec(css)
|
||||
?.groups?.body;
|
||||
assert.ok(reducedMotion, 'expected a prefers-reduced-motion block');
|
||||
|
||||
// Shortening an infinite animation makes it run faster, so the looping rules
|
||||
// have to switch it off outright and hold a static state instead.
|
||||
assert.match(reducedMotion, /\.animate-indeterminate\s*\{[^}]*animation:\s*none;/s);
|
||||
assert.match(reducedMotion, /\.animate-indeterminate\s*\{[^}]*width:\s*100%;/s);
|
||||
assert.match(reducedMotion, /\.animate-spin\s*\{[^}]*animation:\s*none;/s);
|
||||
assert.doesNotMatch(reducedMotion, /\.animate-indeterminate\s*\{[^}]*animation-duration:/s);
|
||||
assert.doesNotMatch(reducedMotion, /\.animate-spin\s*\{[^}]*animation-duration:/s);
|
||||
});
|
||||
|
||||
test('the looping delete indicator keeps its animation for everyone else', () => {
|
||||
const beforeMediaQuery = css.slice(0, css.indexOf('@media (prefers-reduced-motion'));
|
||||
assert.match(
|
||||
beforeMediaQuery,
|
||||
/\.animate-indeterminate\s*\{\s*animation:\s*indeterminate-sweep/s,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user