mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-20 00:15:27 -07:00
feat(stats): add library entry deletion and app-wide delete progress (#174)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user