Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m23s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 28s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m14s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Blazor parity for the remaining #213 conveniences: - GET /api/logs gains sortField (timestamp|level) and sortDirection (asc|desc) query params, allow-listed and normalized (unrecognized values fall back to the pre-existing timestamp-desc default) rather than rejected with a 422. LogsScreen.tsx renders clickable, sortable column headers with a chevron direction indicator. - LogsScreen.tsx now persists the chosen page size to localStorage (ctv-logs-page-size) and restores it on mount, following the existing designSystem.ts localStorage-preference pattern. This is a client-local UI preference, not the Blazor ConfigElement-backed server setting — see docs/decisions.md. - TrashScreen.tsx adds a per-kind "See all N ..." affordance that pages past the 100/kind /api/search cap using the already-paginated GET /api/library/browse (mediaType + pageNum), appending results client-side. No new API surface was needed since that endpoint already supports the paging the trash screen needed. docs/decisions.md, docs/blazor-route-parity.md, docs/spa-conventions.md and docs/api-conventions.md updated in this same commit. OpenAPI spec regenerated (v1.d.ts unchanged: query params aren't part of the generated components/schemas surface). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { RefreshCw, Trash2, TriangleAlert } from 'lucide-react';
|
|
import { Button, Card, ConfirmDialog, Spinner } from '../components';
|
|
import {
|
|
deleteMediaItems,
|
|
emptyTrash,
|
|
getLibraryBrowseItems,
|
|
getSearchResults,
|
|
messageFromLibraryBrowseError,
|
|
messageFromSearchError,
|
|
type LibraryBrowseItem,
|
|
type LibraryBrowseMediaType,
|
|
type SearchResults
|
|
} from '../api';
|
|
import { MediaPosterCard } from '../media/MediaPosterCard';
|
|
|
|
// The initial per-kind fetch uses /api/search (GetSearchResults), whose SearchController clamps
|
|
// pageSize to MaxPageSize=100 — see docs/decisions.md ("Trash see all"). "See all" beyond that
|
|
// first page pages through the same underlying data via GET /api/library/browse (mediaType +
|
|
// pageNum), which already supports paging — no new API surface was needed to lift the cap.
|
|
const PAGE_SIZE = 100;
|
|
// Lucene state filter for items whose files have gone missing (matches the legacy Blazor Trash page).
|
|
const TRASH_QUERY = 'state:FileNotFound';
|
|
|
|
interface GroupDef {
|
|
key: keyof SearchResults;
|
|
label: string;
|
|
mediaType: LibraryBrowseMediaType;
|
|
}
|
|
|
|
const GROUPS: GroupDef[] = [
|
|
{ key: 'movies', label: 'Movies', mediaType: 'Movie' },
|
|
{ key: 'shows', label: 'TV Shows', mediaType: 'TelevisionShow' },
|
|
{ key: 'seasons', label: 'Seasons', mediaType: 'TelevisionSeason' },
|
|
{ key: 'episodes', label: 'Episodes', mediaType: 'Episode' },
|
|
{ key: 'artists', label: 'Artists', mediaType: 'Artist' },
|
|
{ key: 'musicVideos', label: 'Music Videos', mediaType: 'MusicVideo' },
|
|
{ key: 'songs', label: 'Songs', mediaType: 'Song' },
|
|
{ key: 'otherVideos', label: 'Other Videos', mediaType: 'OtherVideo' },
|
|
{ key: 'images', label: 'Images', mediaType: 'Image' },
|
|
{ key: 'remoteStreams', label: 'Remote Streams', mediaType: 'RemoteStream' }
|
|
];
|
|
|
|
interface SeeAllState {
|
|
error: string | null;
|
|
items: LibraryBrowseItem[];
|
|
loading: boolean;
|
|
// Next page to request; page 0 was already loaded by the initial /api/search call.
|
|
nextPageNum: number;
|
|
}
|
|
|
|
type TrashState =
|
|
| { results: SearchResults; error: null; status: 'success' }
|
|
| { results: null; error: string; status: 'error' }
|
|
| { results: null; error: null; status: 'loading' };
|
|
|
|
function mediaItemIdOf(item: LibraryBrowseItem): number | null {
|
|
return item.mediaItemId ?? null;
|
|
}
|
|
|
|
export function TrashScreen() {
|
|
const [state, setState] = useState<TrashState>({ results: null, error: null, status: 'loading' });
|
|
const [seeAll, setSeeAll] = useState<Partial<Record<keyof SearchResults, SeeAllState>>>({});
|
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
|
const [confirm, setConfirm] = useState<null | 'selected' | 'all'>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
const activeRef = useRef(true);
|
|
const seqRef = useRef(0);
|
|
|
|
useEffect(() => {
|
|
activeRef.current = true;
|
|
return () => {
|
|
activeRef.current = false;
|
|
};
|
|
}, []);
|
|
|
|
// Fetch only; state updates happen in the async callbacks so the list stays visible during a
|
|
// refetch (the initial 'loading' state and the Refresh button provide the loading affordance).
|
|
const load = useCallback(() => {
|
|
const id = ++seqRef.current;
|
|
getSearchResults({ query: TRASH_QUERY, pageSize: PAGE_SIZE })
|
|
.then((results) => {
|
|
if (activeRef.current && id === seqRef.current) {
|
|
setState({ results, error: null, status: 'success' });
|
|
}
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (activeRef.current && id === seqRef.current) {
|
|
setState({ results: null, error: messageFromSearchError(error), status: 'error' });
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
const refresh = () => {
|
|
setState({ results: null, error: null, status: 'loading' });
|
|
setSeeAll({});
|
|
load();
|
|
};
|
|
|
|
const loadMore = (group: GroupDef) => {
|
|
const current = seeAll[group.key];
|
|
const nextPageNum = current?.nextPageNum ?? 1; // page 0 was already loaded by /api/search above
|
|
setSeeAll((prev) => ({
|
|
...prev,
|
|
[group.key]: { error: null, items: current?.items ?? [], loading: true, nextPageNum }
|
|
}));
|
|
|
|
getLibraryBrowseItems({ mediaType: group.mediaType, pageNum: nextPageNum, pageSize: PAGE_SIZE, query: TRASH_QUERY })
|
|
.then((page) => {
|
|
if (!activeRef.current) {
|
|
return;
|
|
}
|
|
setSeeAll((prev) => {
|
|
const existing = prev[group.key];
|
|
const items = [...(existing?.items ?? []), ...(page.page ?? [])];
|
|
return { ...prev, [group.key]: { error: null, items, loading: false, nextPageNum: nextPageNum + 1 } };
|
|
});
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (!activeRef.current) {
|
|
return;
|
|
}
|
|
setSeeAll((prev) => ({
|
|
...prev,
|
|
[group.key]: {
|
|
error: messageFromLibraryBrowseError(error, 'Unable to load more items'),
|
|
items: current?.items ?? [],
|
|
loading: false,
|
|
nextPageNum
|
|
}
|
|
}));
|
|
});
|
|
};
|
|
|
|
const toggle = (item: LibraryBrowseItem) => {
|
|
const mediaItemId = mediaItemIdOf(item);
|
|
if (mediaItemId == null) {
|
|
return;
|
|
}
|
|
setSelected((current) => {
|
|
const next = new Set(current);
|
|
if (next.has(mediaItemId)) {
|
|
next.delete(mediaItemId);
|
|
} else {
|
|
next.add(mediaItemId);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const totalMatches =
|
|
state.status === 'success' && state.results
|
|
? GROUPS.reduce((sum, group) => sum + state.results![group.key].totalCount, 0)
|
|
: 0;
|
|
|
|
const selectAll = () => {
|
|
if (state.status !== 'success' || !state.results) {
|
|
return;
|
|
}
|
|
const ids = new Set<number>();
|
|
for (const group of GROUPS) {
|
|
const items = [...state.results[group.key].items, ...(seeAll[group.key]?.items ?? [])];
|
|
for (const item of items) {
|
|
const mediaItemId = mediaItemIdOf(item);
|
|
if (mediaItemId != null) {
|
|
ids.add(mediaItemId);
|
|
}
|
|
}
|
|
}
|
|
setSelected(ids);
|
|
};
|
|
|
|
const clearSelection = () => setSelected(new Set());
|
|
|
|
const runDelete = async () => {
|
|
setBusy(true);
|
|
setActionError(null);
|
|
try {
|
|
if (confirm === 'all') {
|
|
await emptyTrash();
|
|
} else {
|
|
await deleteMediaItems([...selected]);
|
|
}
|
|
if (activeRef.current) {
|
|
setSelected(new Set());
|
|
setConfirm(null);
|
|
load();
|
|
}
|
|
} catch (error: unknown) {
|
|
if (activeRef.current) {
|
|
setActionError(messageFromSearchError(error, 'Unable to delete items'));
|
|
}
|
|
} finally {
|
|
if (activeRef.current) {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="ctv-collections">
|
|
<div className="ctv-channels-actionbar">
|
|
<Button disabled={totalMatches === 0} onClick={selectAll} size="sm" variant="secondary">
|
|
Select all
|
|
</Button>
|
|
{selected.size > 0 && (
|
|
<Button onClick={clearSelection} size="sm" variant="secondary">
|
|
Clear selection
|
|
</Button>
|
|
)}
|
|
{selected.size > 0 && (
|
|
<Button
|
|
onClick={() => setConfirm('selected')}
|
|
size="sm"
|
|
startIcon={<Trash2 aria-hidden="true" size={14} />}
|
|
variant="danger"
|
|
>
|
|
Delete {selected.size} selected
|
|
</Button>
|
|
)}
|
|
<span className="ctv-channels-spacer" />
|
|
<Button
|
|
onClick={refresh}
|
|
size="sm"
|
|
startIcon={<RefreshCw aria-hidden="true" size={14} />}
|
|
variant="secondary"
|
|
>
|
|
Refresh
|
|
</Button>
|
|
<Button
|
|
disabled={totalMatches === 0}
|
|
onClick={() => setConfirm('all')}
|
|
size="sm"
|
|
startIcon={<Trash2 aria-hidden="true" size={14} />}
|
|
variant="danger"
|
|
>
|
|
Empty Trash
|
|
</Button>
|
|
</div>
|
|
|
|
{(state.status === 'error' || actionError) && (
|
|
<div className="ctv-channels-error" role="alert">
|
|
<TriangleAlert aria-hidden="true" size={15} />
|
|
<span>{actionError ?? (state.status === 'error' ? state.error : '')}</span>
|
|
<span className="ctv-channels-spacer" />
|
|
<Button onClick={refresh} size="sm" variant="secondary">
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{state.status === 'loading' && (
|
|
<div className="ctv-collections-loading" role="status">
|
|
<Spinner size={18} />
|
|
<span>Loading trash…</span>
|
|
</div>
|
|
)}
|
|
|
|
{state.status === 'success' && state.results && totalMatches === 0 && (
|
|
<Card>
|
|
<div className="ctv-collections-empty">Trash is empty — no missing files detected.</div>
|
|
</Card>
|
|
)}
|
|
|
|
{state.status === 'success' &&
|
|
state.results &&
|
|
GROUPS.map((group) => {
|
|
const data = state.results![group.key];
|
|
const more = seeAll[group.key];
|
|
const items = [...data.items, ...(more?.items ?? [])];
|
|
if (data.totalCount === 0 || items.length === 0) {
|
|
return null;
|
|
}
|
|
const hasMore = items.length < data.totalCount;
|
|
|
|
return (
|
|
<section key={group.key}>
|
|
<div className="ctv-media-section-header">
|
|
<h2>{group.label}</h2>
|
|
<span className="ctv-media-section-count">
|
|
{data.totalCount} missing
|
|
</span>
|
|
</div>
|
|
<div className="ctv-media-grid">
|
|
{items.map((item) => {
|
|
const mediaItemId = mediaItemIdOf(item);
|
|
return (
|
|
<MediaPosterCard
|
|
item={item}
|
|
key={`${item.mediaType}-${item.id}`}
|
|
onToggleSelect={mediaItemId == null ? undefined : toggle}
|
|
selected={mediaItemId != null && selected.has(mediaItemId)}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
{more?.error && (
|
|
<div className="ctv-channels-error" role="alert">
|
|
<TriangleAlert aria-hidden="true" size={15} />
|
|
<span>{more.error}</span>
|
|
</div>
|
|
)}
|
|
{hasMore && (
|
|
<div className="ctv-media-section-footer">
|
|
<Button disabled={more?.loading === true} onClick={() => loadMore(group)} size="sm" variant="secondary">
|
|
{more?.loading ? 'Loading…' : `See all ${data.totalCount} ${group.label.toLowerCase()}`}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
})}
|
|
|
|
<ConfirmDialog
|
|
busy={busy}
|
|
confirmLabel={confirm === 'all' ? 'Empty Trash' : 'Delete'}
|
|
message={
|
|
confirm === 'all'
|
|
? 'Permanently remove every item with a missing file from the database? This cannot be undone.'
|
|
: `Permanently remove ${selected.size} selected item${selected.size === 1 ? '' : 's'} from the database? This cannot be undone.`
|
|
}
|
|
onCancel={() => {
|
|
if (!busy) {
|
|
setConfirm(null);
|
|
}
|
|
}}
|
|
onConfirm={runDelete}
|
|
open={confirm !== null}
|
|
title={confirm === 'all' ? 'Empty Trash' : 'Delete selected items'}
|
|
tone="danger"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|