Files
ersatztv/web/src/screens/TrashScreen.tsx
T
timothyandClaude Fable 5 0a8c7b691b
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
feat(spa): logs sort + page-size persistence, trash see-all paging (#213)
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>
2026-07-11 00:10:20 +02:00

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>
);
}