Merge remote-tracking branch 'origin/main' into feat/141-media-browse
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m37s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m5s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

# Conflicts:
#	ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs
#	web/src/App.tsx
#	web/src/screens/SettingsScreen.tsx
This commit is contained in:
2026-07-07 15:50:03 +02:00
17 changed files with 1909 additions and 2 deletions
+30 -1
View File
@@ -33,6 +33,7 @@ import {
LayoutDashboard,
LayoutGrid,
Library,
Link2,
ListVideo,
MonitorPlay,
Music,
@@ -65,6 +66,7 @@ import { LogsScreen } from './screens/LogsScreen';
import { MediaBrowseScreen } from './screens/MediaBrowseScreen';
import { SearchScreen } from './screens/SearchScreen';
import { SettingsScreen } from './screens/SettingsScreen';
import { TraktListsScreen } from './screens/TraktListsScreen';
import { TrashScreen } from './screens/TrashScreen';
import { TroubleshootingScreen } from './screens/TroubleshootingScreen';
import { WatermarksScreen } from './screens/WatermarksScreen';
@@ -147,6 +149,7 @@ type ScreenId =
| 'collections'
| 'fillerPresets'
| 'libraries'
| 'traktLists'
| 'ffmpegProfiles'
| 'watermarks'
| 'settings'
@@ -321,6 +324,20 @@ const routes: ScreenRoute[] = [
primaryAction: 'Scan',
placeholder: 'Libraries workspace'
},
{
// The editor lives at a sub-path (/app/trakt-lists/{id}); the screen owns parsing the
// {id} suffix itself (see TraktListsScreen), same pattern as editChannel/settings.
id: 'traktLists',
path: '/app/trakt-lists',
label: 'Trakt Lists',
title: 'Trakt Lists',
kicker: 'Media',
description: 'Add, match, and manage Trakt list imports.',
icon: <Link2 aria-hidden="true" size={16} />,
primaryAction: 'Add Trakt List',
placeholder: 'Trakt lists workspace',
allowSubPaths: true
},
{
id: 'ffmpegProfiles',
path: '/app/ffmpeg-profiles',
@@ -391,7 +408,15 @@ const primaryNavIds: ScreenId[] = [
'schedules',
'playouts'
];
const mediaNavIds: ScreenId[] = ['media', 'search', 'trash', 'collections', 'fillerPresets', 'libraries'];
const mediaNavIds: ScreenId[] = [
'media',
'search',
'trash',
'collections',
'fillerPresets',
'libraries',
'traktLists'
];
const systemNavIds: ScreenId[] = [
'settings',
'logs',
@@ -3034,6 +3059,10 @@ function ScreenContent({
return <CollectionsScreen />;
}
if (route.id === 'traktLists') {
return <TraktListsScreen key={window.location.pathname} />;
}
if (route.id === 'fillerPresets') {
return <FillerPresetsScreen />;
}
+24
View File
@@ -14,6 +14,9 @@ export interface components {
"songIds": null | Array<number>;
"imageIds": null | Array<number>;
"remoteStreamIds": null | Array<number>;
};
"AddTraktListRequest": {
"url": null | string;
};
"ArtworkContentTypeModel": {
"path": null | string;
@@ -598,6 +601,10 @@ export interface components {
"PagedPlayoutsResponseModel": {
"totalCount": number;
"page": null | Array<components["schemas"]["PlayoutListItemResponseModel"]>;
};
"PagedTraktListsResponseModel": {
"totalCount": number;
"page": Array<components["schemas"]["TraktListResponseModel"]>;
};
"PlaybackOrder": "None" | "Chronological" | "Random" | "Shuffle" | "ShuffleInOrder" | "MultiEpisodeShuffle" | "SeasonEpisode" | "RandomRotation" | "Marathon";
"PlaylistViewModel": {
@@ -808,6 +815,19 @@ export interface components {
"StartType": "Dynamic" | "Fixed";
"StreamingMode": "TransportStream" | "HttpLiveStreamingDirect" | "HttpLiveStreamingSegmenter" | "TransportStreamHybrid";
"TailMode": "None" | "Offline" | "Slate" | "Filler";
"TraktListResponseModel": {
"id": number;
"traktId": number;
"slug": string;
"name": string;
"itemCount": number;
"matchCount": number;
"autoRefresh": boolean;
"generatePlaylist": boolean;
};
"TraktStatusResponseModel": {
"busy": boolean;
};
"TroubleshootingInfoResponseModel": {
"generalJson": string;
"nvidiaCapabilities": null | string;
@@ -975,6 +995,10 @@ export interface components {
"UpdateSmartCollectionRequest": {
"name": null | string;
"query": null | string;
};
"UpdateTraktListRequest": {
"autoRefresh": boolean;
"generatePlaylist": boolean;
};
"UpdateUiSettingsRequest": {
"isDarkMode": boolean;
+1
View File
@@ -18,6 +18,7 @@ export * from './playouts';
export * from './schedules';
export * from './search';
export * from './settings';
export * from './trakt';
export * from './troubleshoot';
export * from './useChannelsQuery';
export * from './watermarks';
+129
View File
@@ -0,0 +1,129 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
addTraktList,
deleteTraktList,
getTraktListById,
getTraktLists,
getTraktStatus,
matchTraktList,
updateTraktList
} from './trakt';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
status
});
}
function accepted(): Response {
return new Response(null, { headers: { 'Content-Length': '0' }, status: 202 });
}
describe('trakt api client', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('getTraktLists fetches the default page without query params', async () => {
const fetchMock = vi
.spyOn(window, 'fetch')
.mockResolvedValue(jsonResponse({ page: [], totalCount: 0 }));
await getTraktLists();
expect(fetchMock).toHaveBeenCalledWith('/api/trakt/lists', expect.objectContaining({ method: 'GET' }));
});
it('getTraktLists forwards pageNum/pageSize as query params', async () => {
const fetchMock = vi
.spyOn(window, 'fetch')
.mockResolvedValue(jsonResponse({ page: [], totalCount: 0 }));
await getTraktLists({ pageNum: 2, pageSize: 25 });
const url = new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost');
expect(url.pathname).toBe('/api/trakt/lists');
expect(url.searchParams.get('pageNum')).toBe('2');
expect(url.searchParams.get('pageSize')).toBe('25');
});
it('getTraktListById fetches a single list by id', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(
jsonResponse({
autoRefresh: true,
generatePlaylist: false,
id: 1,
itemCount: 10,
matchCount: 8,
name: 'My List',
slug: 'my-list',
traktId: 100
})
);
await expect(getTraktListById(1)).resolves.toMatchObject({ id: 1, slug: 'my-list' });
expect(fetchMock).toHaveBeenCalledWith('/api/trakt/lists/1', expect.objectContaining({ method: 'GET' }));
});
it('addTraktList POSTs the url and resolves on 202', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(accepted());
await expect(addTraktList('https://trakt.tv/users/someuser/lists/some-list')).resolves.toBeUndefined();
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/trakt/lists');
expect(init).toMatchObject({ method: 'POST' });
expect(JSON.parse(String(init?.body))).toEqual({ url: 'https://trakt.tv/users/someuser/lists/some-list' });
});
it('addTraktList rethrows a 422 for an invalid url', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ detail: 'Invalid Trakt list url', status: 422 }, 422));
await expect(addTraktList('not-a-url')).rejects.toMatchObject({ status: 422 });
});
it('matchTraktList POSTs to the match sub-route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(accepted());
await expect(matchTraktList(5)).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith('/api/trakt/lists/5/match', expect.objectContaining({ method: 'POST' }));
});
it('deleteTraktList issues a DELETE and resolves on 202', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(accepted());
await expect(deleteTraktList(9)).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith('/api/trakt/lists/9', expect.objectContaining({ method: 'DELETE' }));
});
it('updateTraktList PUTs autoRefresh/generatePlaylist', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(
jsonResponse({
autoRefresh: true,
generatePlaylist: true,
id: 3,
itemCount: 10,
matchCount: 8,
name: 'My List',
slug: 'my-list',
traktId: 100
})
);
await updateTraktList(3, { autoRefresh: true, generatePlaylist: true });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/trakt/lists/3');
expect(init).toMatchObject({ method: 'PUT' });
expect(JSON.parse(String(init?.body))).toEqual({ autoRefresh: true, generatePlaylist: true });
});
it('getTraktStatus fetches the busy flag', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ busy: true }));
await expect(getTraktStatus()).resolves.toEqual({ busy: true });
expect(fetchMock).toHaveBeenCalledWith('/api/trakt/status', expect.objectContaining({ method: 'GET' }));
});
});
+70
View File
@@ -0,0 +1,70 @@
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type TraktList = components['schemas']['TraktListResponseModel'];
export type PagedTraktLists = components['schemas']['PagedTraktListsResponseModel'];
export type TraktStatus = components['schemas']['TraktStatusResponseModel'];
export type AddTraktListRequest = components['schemas']['AddTraktListRequest'];
export type UpdateTraktListRequest = components['schemas']['UpdateTraktListRequest'];
export interface GetTraktListsParams {
pageNum?: number;
pageSize?: number;
}
export function getTraktLists(params: GetTraktListsParams = {}): Promise<PagedTraktLists> {
const searchParams = new URLSearchParams();
if (params.pageNum != null) {
searchParams.set('pageNum', String(params.pageNum));
}
if (params.pageSize != null) {
searchParams.set('pageSize', String(params.pageSize));
}
const queryString = searchParams.toString();
return request<PagedTraktLists>(`/api/trakt/lists${queryString ? `?${queryString}` : ''}`);
}
export function getTraktListById(id: number): Promise<TraktList> {
return request<TraktList>(`/api/trakt/lists/${id}`);
}
// 202 Accepted: the server dispatches to the same background worker channel the classic
// UI's "Add Trakt List" dialog uses. Fetch/save/match all happen asynchronously — poll
// getTraktStatus() and reload the list once it goes idle.
export function addTraktList(url: string): Promise<void> {
return request<void>('/api/trakt/lists', { body: { url } satisfies AddTraktListRequest, method: 'POST' });
}
// 202 Accepted; see addTraktList for the async/poll pattern.
export function matchTraktList(id: number): Promise<void> {
return request<void>(`/api/trakt/lists/${id}/match`, { method: 'POST' });
}
// 202 Accepted; see addTraktList for the async/poll pattern.
export function deleteTraktList(id: number): Promise<void> {
return request<void>(`/api/trakt/lists/${id}`, { method: 'DELETE' });
}
export function updateTraktList(id: number, body: UpdateTraktListRequest): Promise<TraktList> {
return request<TraktList>(`/api/trakt/lists/${id}`, { body, method: 'PUT' });
}
export function getTraktStatus(): Promise<TraktStatus> {
return request<TraktStatus>('/api/trakt/status');
}
export function messageFromTraktError(error: unknown, fallback = 'Unable to load Trakt lists'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+1 -1
View File
@@ -861,7 +861,7 @@ function SystemPane({
Open Dashboard
</Button>
</Row>
<Row control={220} help="Trakt, blocks/decos/templates and playout editors still live here." label="Classic UI">
<Row control={220} help="Blocks/decos/templates and playout editors still live here." label="Classic UI">
<a className="ctv-button ctv-button-secondary ctv-button-sm" href="/system/health">
<span>Open Classic UI</span>
<ExternalLink aria-hidden="true" size={13} />
+542
View File
@@ -0,0 +1,542 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ArrowLeft, ExternalLink, Pencil, Plus, RefreshCw, Search, Trash2, TriangleAlert } from 'lucide-react';
import { navigateToPath } from '../routing';
import { Badge, Button, Card, ConfirmDialog, Dialog, IconButton, Input, Spinner, Switch } from '../components';
import {
addTraktList,
deleteTraktList,
getTraktListById,
getTraktLists,
getTraktStatus,
matchTraktList,
messageFromTraktError,
updateTraktList,
type TraktList
} from '../api';
const TRAKT_BASE_PATH = '/app/trakt-lists';
// The SPA has no search screen yet (#161) — "view matched items" opens the classic Blazor
// search page, same interim deep-link pattern used elsewhere for un-migrated screens.
function classicSearchUrl(traktId: number): string {
const params = new URLSearchParams({ query: `trakt_list:${traktId}` });
return `/search?${params.toString()}`;
}
function traktListIdFromPathname(pathname: string): number | null {
const normalized = pathname.replace(/\/+$/, '');
if (!normalized.startsWith(`${TRAKT_BASE_PATH}/`)) {
return null;
}
const id = Number(normalized.slice(TRAKT_BASE_PATH.length + 1).split('/')[0]);
return Number.isInteger(id) && id > 0 ? id : null;
}
// The status endpoint is a poll-only substitute for the Blazor page's live
// IEntityLocker.OnTraktChanged event (no push channel exists for the REST API). Poll while
// busy; stop once idle; the caller re-fetches its own data on the busy -> idle transition.
function useTraktBusyPoll(onIdleTransition: () => void) {
const [busy, setBusy] = useState(false);
const wasBusyRef = useRef(false);
const timerRef = useRef<number | undefined>(undefined);
const onIdleTransitionRef = useRef(onIdleTransition);
// Indirection so the recursive setTimeout call always reaches the latest poll closure
// without the callback needing to reference its own (not-yet-assigned) binding.
const pollRef = useRef<() => void>(() => {});
useEffect(() => {
onIdleTransitionRef.current = onIdleTransition;
});
useEffect(() => {
pollRef.current = () => {
getTraktStatus()
.then((status) => {
setBusy(status.busy);
if (wasBusyRef.current && !status.busy) {
onIdleTransitionRef.current();
}
wasBusyRef.current = status.busy;
if (status.busy) {
timerRef.current = window.setTimeout(() => pollRef.current(), 2500);
}
})
.catch(() => {
// Give up silently; the next dispatched action restarts polling via markBusy().
});
};
});
useEffect(() => {
pollRef.current();
return () => window.clearTimeout(timerRef.current);
}, []);
const markBusy = useCallback(() => {
setBusy(true);
wasBusyRef.current = true;
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => pollRef.current(), 2500);
}, []);
return { busy, markBusy };
}
/* ---------- add dialog ---------- */
function AddTraktListDialog({
busy,
error,
onCancel,
onSubmit,
open
}: {
busy: boolean;
error: string | null;
onCancel: () => void;
onSubmit: (url: string) => void;
open: boolean;
}) {
const [url, setUrl] = useState('');
const trimmed = url.trim();
return (
<Dialog
footer={
<>
<Button disabled={busy} onClick={onCancel} variant="secondary">
Cancel
</Button>
<Button disabled={busy || trimmed.length === 0} loading={busy} onClick={() => onSubmit(trimmed)} variant="primary">
Add
</Button>
</>
}
onClose={onCancel}
open={open}
title="Add Trakt list"
width={480}
>
<Input
label="Trakt list URL"
onChange={(event) => setUrl(event.target.value)}
placeholder="https://trakt.tv/users/username/lists/list-name"
value={url}
/>
<p className="ctv-collections-picker-note">
Fetching, saving and matching happen in the background this dialog closes right away and the list appears
once the server finishes.
</p>
{error && (
<span className="ctv-field-error" role="alert">
{error}
</span>
)}
</Dialog>
);
}
/* ---------- sub-path editor ---------- */
function TraktListEditor({
id,
onBack,
onBackgroundMatch,
}: {
id: number;
onBack: () => void;
onBackgroundMatch: () => void;
}) {
const [list, setList] = useState<TraktList | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [autoRefresh, setAutoRefresh] = useState(false);
const [generatePlaylist, setGeneratePlaylist] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
useEffect(() => {
let active = true;
getTraktListById(id)
.then((fetched) => {
if (!active) {
return;
}
setList(fetched);
setAutoRefresh(fetched.autoRefresh);
setGeneratePlaylist(fetched.generatePlaylist);
})
.catch((fetchError: unknown) => {
if (active) {
setError(messageFromTraktError(fetchError, 'Unable to load Trakt list'));
}
})
.finally(() => {
if (active) {
setLoading(false);
}
});
return () => {
active = false;
};
}, [id]);
const save = async () => {
setSaving(true);
setSaveError(null);
setSaved(false);
try {
const updated = await updateTraktList(id, { autoRefresh, generatePlaylist });
setList(updated);
setSaved(true);
if (generatePlaylist) {
// saving with generatePlaylist enqueues a background match server-side
onBackgroundMatch();
}
} catch (updateError) {
setSaveError(messageFromTraktError(updateError, 'Unable to save Trakt list'));
} finally {
setSaving(false);
}
};
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
<Button onClick={onBack} size="sm" startIcon={<ArrowLeft aria-hidden="true" size={14} />} variant="ghost">
All Trakt lists
</Button>
<span className="ctv-collections-detail-title">{list?.name ?? `Trakt list ${id}`}</span>
</div>
{error && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{error}</span>
</div>
)}
<Card>
{loading ? (
<div className="ctv-collections-loading">
<Spinner size={18} />
<span>Loading Trakt list</span>
</div>
) : list ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Input disabled label="Slug" value={list.slug} />
<label className="ctv-collections-order-toggle" title="Automatically refresh this list's items">
<Switch checked={autoRefresh} onChange={setAutoRefresh} size="sm" />
<span>Auto refresh</span>
</label>
<label className="ctv-collections-order-toggle" title="Generate a system playlist from this list">
<Switch checked={generatePlaylist} onChange={setGeneratePlaylist} size="sm" />
<span>Generate playlist</span>
</label>
{saveError && (
<span className="ctv-field-error" role="alert">
{saveError}
</span>
)}
{saved && !saveError && <Badge tone="ok">Saved</Badge>}
<div>
<Button disabled={saving} loading={saving} onClick={() => void save()} variant="primary">
Save changes
</Button>
</div>
</div>
) : null}
</Card>
</div>
);
}
/* ---------- screen ---------- */
export function TraktListsScreen() {
const editingId = traktListIdFromPathname(window.location.pathname);
const [lists, setLists] = useState<TraktList[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [addOpen, setAddOpen] = useState(false);
const [addBusy, setAddBusy] = useState(false);
const [addError, setAddError] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<TraktList | null>(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [rowError, setRowError] = useState<string | null>(null);
const activeRef = useRef(true);
// Fetch only; state updates happen in the async callbacks (never synchronously in the
// effect body). `loading` starts true and flips false in `finally`, so the mount effect
// shows the spinner; later quiet reloads (e.g. after a busy -> idle transition) never
// touch `loading`, so the table stays visible instead of flashing back to a spinner.
const load = useCallback(() => {
getTraktLists({ pageSize: 100 })
.then((paged) => {
if (activeRef.current) {
setLists(paged.page ?? []);
setTotalCount(paged.totalCount ?? 0);
setError(null);
}
})
.catch((loadError: unknown) => {
if (activeRef.current) {
setError(messageFromTraktError(loadError));
}
})
.finally(() => {
if (activeRef.current) {
setLoading(false);
}
});
}, []);
const refresh = useCallback(() => {
setLoading(true);
load();
}, [load]);
useEffect(() => {
activeRef.current = true;
load();
return () => {
activeRef.current = false;
};
}, [load]);
const { busy, markBusy } = useTraktBusyPoll(load);
if (editingId !== null) {
return (
<TraktListEditor
id={editingId}
onBack={() => navigateToPath(TRAKT_BASE_PATH)}
onBackgroundMatch={markBusy}
/>
);
}
const submitAdd = async (url: string) => {
setAddBusy(true);
setAddError(null);
try {
await addTraktList(url);
markBusy();
setAddOpen(false);
} catch (submitError) {
setAddError(messageFromTraktError(submitError, 'Unable to add Trakt list'));
} finally {
setAddBusy(false);
}
};
const match = async (list: TraktList) => {
setRowError(null);
try {
await matchTraktList(list.id);
markBusy();
} catch (matchError) {
setRowError(messageFromTraktError(matchError, 'Unable to match Trakt list items'));
}
};
const confirmDelete = async () => {
if (!deleteTarget) {
return;
}
setDeleteBusy(true);
setDeleteError(null);
try {
await deleteTraktList(deleteTarget.id);
markBusy();
setDeleteTarget(null);
} catch (removeError) {
setDeleteError(messageFromTraktError(removeError, 'Unable to delete Trakt list'));
} finally {
setDeleteBusy(false);
}
};
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
<span className="ctv-collections-detail-title">Trakt Lists</span>
{busy && (
<Badge tone="accent">
<Spinner size={12} /> Busy
</Badge>
)}
<span className="ctv-channels-spacer" />
<Button
disabled={busy}
onClick={() => {
setAddError(null);
setAddOpen(true);
}}
size="sm"
startIcon={<Plus aria-hidden="true" size={14} />}
>
Add Trakt list
</Button>
</div>
{error && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{error}</span>
<span className="ctv-channels-spacer" />
<Button onClick={refresh} size="sm" variant="secondary">
Retry
</Button>
</div>
)}
{rowError && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{rowError}</span>
</div>
)}
<Card padded={false}>
{loading ? (
<div className="ctv-collections-loading" role="status">
<Spinner size={18} />
<span>Loading Trakt lists</span>
</div>
) : lists.length === 0 ? (
<div className="ctv-collections-empty">No Trakt lists yet.</div>
) : (
<div className="ctv-channels-table-frame">
<div className="ctv-channels-table-scroll">
<table aria-label="Trakt lists" className="ctv-channels-table">
<thead>
<tr>
<th>Slug</th>
<th>Name</th>
<th>Match status</th>
<th />
</tr>
</thead>
<tbody>
{lists.map((list) => (
<tr key={list.id}>
<td style={{ fontFamily: 'var(--font-mono)' }}>{list.slug}</td>
<td>{list.name}</td>
<td>
{list.matchCount} of {list.itemCount}
</td>
<td>
<div style={{ alignItems: 'center', display: 'flex', gap: 4 }}>
<IconButton
disabled={busy}
onClick={() => navigateToPath(`${TRAKT_BASE_PATH}/${list.id}`)}
size="sm"
title="Edit Trakt list properties"
>
<Pencil aria-hidden="true" size={14} />
</IconButton>
<IconButton
disabled={busy}
onClick={() => void match(list)}
size="sm"
title="Match Trakt list items"
>
<RefreshCw aria-hidden="true" size={14} />
</IconButton>
<a
className="ctv-icon-button ctv-icon-button-sm"
href={classicSearchUrl(list.traktId)}
rel="noreferrer"
target="_blank"
title="View matched items (opens the Classic UI search page)"
>
<ExternalLink aria-hidden="true" size={14} />
</a>
<IconButton
disabled={busy}
onClick={() => setDeleteTarget(list)}
size="sm"
title="Delete Trakt list"
>
<Trash2 aria-hidden="true" size={14} />
</IconButton>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="ctv-channels-footer">
<span>
{totalCount} list{totalCount === 1 ? '' : 's'}
</span>
</div>
</div>
)}
</Card>
<div className="ctv-settings-callout">
<Search aria-hidden="true" size={14} />
<span>
"View matched items" opens the Classic UI search page the SPA doesn't have a search screen yet.
</span>
</div>
<AddTraktListDialog
busy={addBusy}
error={addError}
key={`add-${addOpen}`}
onCancel={() => setAddOpen(false)}
onSubmit={(url) => void submitAdd(url)}
open={addOpen}
/>
<ConfirmDialog
busy={deleteBusy}
confirmLabel="Delete"
message={
deleteTarget ? (
<>
<span>{`Delete "${deleteTarget.name}"? This cannot be undone.`}</span>
{deleteError && (
<span className="ctv-field-error" role="alert">
{deleteError}
</span>
)}
</>
) : (
''
)
}
onCancel={() => {
setDeleteTarget(null);
setDeleteError(null);
}}
onConfirm={() => void confirmDelete()}
open={deleteTarget !== null}
title="Delete Trakt list"
tone="danger"
/>
</div>
);
}