Files
ersatztv/web/src/screens/CollectionsScreen.tsx
T
timothyandClaude Fable 5 d05e442605
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m16s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(web): stop season-tile flooding in channel builder and collections (#180)
Part of the #180 library picker fixes (SPA side).

Channel builder: TelevisionSeason is removed from the library grid fan-out, so a
multi-season show renders as one tile instead of one tile per season. Show tiles
gain a "Seasons" drill-in affordance (both grid and compact layouts) that opens a
dialog listing that show's seasons (via the new GET /api/library/browse?parentId=
&mediaType=TelevisionSeason), each with title, artwork and an Add button that
drops the specific season into the lineup.

Collections add-items dialog: the default search fan-out now excludes seasons,
and a media-kind filter row (All / Movies / Shows / Seasons / Artists,
default = All-without-seasons) keeps seasons reachable when explicitly selected.

Client: getLibraryBrowseItems gains an optional parentId param. Tests cover the
param mapping, the builder no longer requesting TelevisionSeason, and the
collections dialog default-excluding vs explicitly-including seasons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:42:18 +02:00

956 lines
30 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import {
ArrowLeft,
Check,
FolderTree,
Info,
ListVideo,
Pencil,
Plus,
Search,
Sparkles,
Trash2,
TriangleAlert
} from 'lucide-react';
import {
Badge,
Button,
Card,
ConfirmDialog,
Dialog,
IconButton,
Input,
Spinner,
Switch
} from '../components';
import {
addItemsToCollection,
createCollection,
createSmartCollection,
deleteCollection,
deleteSmartCollection,
getCollectionItemsPreview,
getCollections,
getLibraryBrowseItems,
getSmartCollections,
messageFromCollectionError,
removeItemFromCollection,
toAddItemsRequest,
updateCollection,
updateSmartCollection,
type LibraryBrowseItem,
type MediaCollection,
type SmartCollection
} from '../api';
import { TYPE_LABEL } from '../media/mediaKinds';
type Tab = 'manual' | 'smart';
// Every kind that can be added to a manual collection from the picker.
const ADDABLE_TYPE_LIST: LibraryBrowseItem['mediaType'][] = ['Movie', 'TelevisionShow', 'TelevisionSeason', 'Artist'];
const ADDABLE_TYPES = new Set<LibraryBrowseItem['mediaType']>(ADDABLE_TYPE_LIST);
// The default fan-out excludes seasons so a multi-season show doesn't flood the
// results with per-season rows (issue #180); seasons stay reachable via the
// explicit media-kind filter below.
const DEFAULT_SEARCH_KINDS: LibraryBrowseItem['mediaType'][] = ['Movie', 'TelevisionShow', 'Artist'];
type MediaKindFilter = 'all' | LibraryBrowseItem['mediaType'];
const MEDIA_KIND_FILTERS: { label: string; value: MediaKindFilter }[] = [
{ label: 'All', value: 'all' },
{ label: 'Movies', value: 'Movie' },
{ label: 'Shows', value: 'TelevisionShow' },
{ label: 'Seasons', value: 'TelevisionSeason' },
{ label: 'Artists', value: 'Artist' }
];
function sortByName<T extends { name?: null | string }>(items: T[]): T[] {
return [...items].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
}
/* ---------- data hook ---------- */
interface CollectionsData {
manual: MediaCollection[];
smart: SmartCollection[];
}
type CollectionsState =
| { data: CollectionsData; error: null; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
function useCollectionsData() {
const [state, setState] = useState<CollectionsState>({ data: null, error: null, status: 'loading' });
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
// Fetch only; state updates happen in the async callbacks (never synchronously in the
// effect body) so the initial 'loading' default stands until data resolves.
const load = useCallback(() => {
Promise.all([getCollections(), getSmartCollections()])
.then(([manual, smart]) => {
if (activeRef.current) {
setState({ data: { manual, smart }, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromCollectionError(error), status: 'error' });
}
});
}, []);
useEffect(() => {
load();
}, [load]);
const refresh = useCallback(
(quiet = false) => {
if (!quiet) {
setState({ data: null, error: null, status: 'loading' });
}
load();
},
[load]
);
return { refresh, state };
}
/* ---------- create / rename dialogs ---------- */
function NameDialog({
busy,
confirmLabel,
error,
initialName = '',
onCancel,
onSubmit,
open,
title
}: {
busy: boolean;
confirmLabel: string;
error: string | null;
initialName?: string;
onCancel: () => void;
onSubmit: (name: string) => void;
open: boolean;
title: string;
}) {
// Parent remounts this via `key` on each open, so `initialName` seeds fresh state
// without a reset effect.
const [name, setName] = useState(initialName);
const trimmed = name.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">
{confirmLabel}
</Button>
</>
}
onClose={onCancel}
open={open}
title={title}
width={440}
>
<Input label="Name" onChange={(event) => setName(event.target.value)} placeholder="Collection name" value={name} />
{error && (
<span className="ctv-field-error" role="alert">
{error}
</span>
)}
</Dialog>
);
}
function SmartDialog({
busy,
error,
initial,
onCancel,
onSubmit,
open
}: {
busy: boolean;
error: string | null;
initial: { name: string; query: string } | null;
onCancel: () => void;
onSubmit: (values: { name: string; query: string }) => void;
open: boolean;
}) {
// Parent remounts this via `key` on each open, so `initial` seeds fresh state.
const [name, setName] = useState(initial?.name ?? '');
const [query, setQuery] = useState(initial?.query ?? '');
const [preview, setPreview] = useState<{ count: number; sample: LibraryBrowseItem[] } | null>(null);
const [previewing, setPreviewing] = useState(false);
const [previewError, setPreviewError] = useState<string | null>(null);
const runPreview = async () => {
const trimmed = query.trim();
if (!trimmed) {
return;
}
setPreviewing(true);
setPreviewError(null);
try {
const result = await getLibraryBrowseItems({ pageSize: 24, query: trimmed });
setPreview({ count: result.totalCount ?? result.page?.length ?? 0, sample: result.page ?? [] });
} catch (error) {
setPreviewError(messageFromCollectionError(error, 'Unable to preview query'));
} finally {
setPreviewing(false);
}
};
const trimmedName = name.trim();
const trimmedQuery = query.trim();
return (
<Dialog
footer={
<>
<Button disabled={busy} onClick={onCancel} variant="secondary">
Cancel
</Button>
<Button
disabled={busy || trimmedName.length === 0 || trimmedQuery.length === 0}
loading={busy}
onClick={() => onSubmit({ name: trimmedName, query: trimmedQuery })}
variant="primary"
>
{initial ? 'Save' : 'Create'}
</Button>
</>
}
onClose={onCancel}
open={open}
title={initial ? 'Edit smart collection' : 'New smart collection'}
width={560}
>
<Input label="Name" onChange={(event) => setName(event.target.value)} placeholder="Smart collection name" value={name} />
<div style={{ marginTop: 12 }}>
<Input
label="Search query"
onChange={(event) => setQuery(event.target.value)}
placeholder='e.g. genre:"action" AND released:2000-2010'
style={{ fontFamily: 'var(--font-mono)' }}
value={query}
/>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<Button
disabled={previewing || trimmedQuery.length === 0}
loading={previewing}
onClick={() => void runPreview()}
size="sm"
startIcon={<Search aria-hidden="true" size={14} />}
variant="secondary"
>
Preview results
</Button>
</div>
{previewError && (
<span className="ctv-field-error" role="alert">
{previewError}
</span>
)}
{preview && (
<div className="ctv-collections-preview">
<div className="ctv-collections-preview-head">
<Badge tone="accent">{preview.count} matches</Badge>
</div>
<ul className="ctv-collections-preview-list">
{preview.sample.slice(0, 12).map((item) => (
<li key={`${item.mediaType}:${item.id}`}>
<span className="ctv-collections-preview-title">{item.title}</span>
<span className="ctv-collections-preview-type">{TYPE_LABEL[item.mediaType]}</span>
</li>
))}
{preview.sample.length === 0 && <li className="ctv-collections-preview-empty">No matches</li>}
</ul>
</div>
)}
{error && (
<span className="ctv-field-error" role="alert">
{error}
</span>
)}
</Dialog>
);
}
/* ---------- add-items picker ---------- */
function AddItemsDialog({
collection,
onAdded,
onClose,
open
}: {
collection: MediaCollection;
onAdded: () => void;
onClose: () => void;
open: boolean;
}) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<LibraryBrowseItem[]>([]);
const [searching, setSearching] = useState(false);
const [selected, setSelected] = useState<Map<string, LibraryBrowseItem>>(() => new Map());
const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [kindFilter, setKindFilter] = useState<MediaKindFilter>('all');
const runSearch = async (filter: MediaKindFilter = kindFilter) => {
setSearching(true);
setError(null);
try {
const trimmed = query.trim();
const kinds = filter === 'all' ? DEFAULT_SEARCH_KINDS : [filter];
const perKind = await Promise.all(
kinds.map((mediaType) => getLibraryBrowseItems({ pageSize: 50, query: trimmed, mediaType }))
);
const merged = perKind
.flatMap((result) => result.page ?? [])
.filter((item) => ADDABLE_TYPES.has(item.mediaType))
.sort((left, right) => left.title.localeCompare(right.title, undefined, { sensitivity: 'base' }));
setResults(merged.slice(0, 50));
} catch (searchError) {
setError(messageFromCollectionError(searchError, 'Unable to search library'));
} finally {
setSearching(false);
}
};
const selectKindFilter = (filter: MediaKindFilter) => {
setKindFilter(filter);
void runSearch(filter);
};
const toggle = (item: LibraryBrowseItem) => {
const key = `${item.mediaType}:${item.id}`;
setSelected((current) => {
const next = new Map(current);
if (next.has(key)) {
next.delete(key);
} else {
next.set(key, item);
}
return next;
});
};
const submit = async () => {
if (selected.size === 0) {
return;
}
setAdding(true);
setError(null);
try {
await addItemsToCollection(collection.id, toAddItemsRequest([...selected.values()]));
onAdded();
onClose();
} catch (addError) {
setError(messageFromCollectionError(addError, 'Unable to add items'));
} finally {
setAdding(false);
}
};
return (
<Dialog
footer={
<>
<Button disabled={adding} onClick={onClose} variant="secondary">
Cancel
</Button>
<Button
disabled={adding || selected.size === 0}
loading={adding}
onClick={() => void submit()}
startIcon={<Plus aria-hidden="true" size={14} />}
variant="primary"
>
Add {selected.size > 0 ? selected.size : ''} item{selected.size === 1 ? '' : 's'}
</Button>
</>
}
onClose={onClose}
open={open}
title={`Add items to ${collection.name ?? 'collection'}`}
width={620}
>
<form
onSubmit={(event) => {
event.preventDefault();
void runSearch();
}}
style={{ display: 'flex', gap: 8 }}
>
<Input
leadingIcon={<Search aria-hidden="true" size={14} />}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search movies, shows, seasons, artists…"
value={query}
/>
<Button loading={searching} size="sm" type="submit" variant="secondary">
Search
</Button>
</form>
<div className="ctv-collections-picker-filters" role="group" aria-label="Filter by media kind">
{MEDIA_KIND_FILTERS.map((filter) => (
<button
aria-pressed={kindFilter === filter.value}
className={`ctv-collections-picker-filter ctv-press${kindFilter === filter.value ? ' ctv-collections-picker-filter-active' : ''}`}
key={filter.value}
onClick={() => selectKindFilter(filter.value)}
type="button"
>
{filter.label}
</button>
))}
</div>
<p className="ctv-collections-picker-note">
Add movies, shows, seasons and artists. &ldquo;All&rdquo; searches movies, shows and artists; pick
&ldquo;Seasons&rdquo; to find a specific season. Episodes, music, images and other item kinds can&rsquo;t be
added from here yet.
</p>
{error && (
<span className="ctv-field-error" role="alert">
{error}
</span>
)}
<div className="ctv-collections-picker-results">
{results.length === 0 && !searching ? (
<div className="ctv-collections-picker-empty">No results try a search above.</div>
) : (
results.map((item) => {
const key = `${item.mediaType}:${item.id}`;
const isSelected = selected.has(key);
return (
<button
aria-pressed={isSelected}
className={`ctv-collections-picker-row ctv-press${isSelected ? ' ctv-collections-picker-row-active' : ''}`}
key={key}
onClick={() => toggle(item)}
type="button"
>
<span className="ctv-collections-picker-check">
{isSelected ? <Check aria-hidden="true" size={14} /> : null}
</span>
<span className="ctv-collections-picker-row-title">{item.title}</span>
<Badge tone="neutral">{TYPE_LABEL[item.mediaType]}</Badge>
</button>
);
})
)}
</div>
</Dialog>
);
}
/* ---------- manual collection items view ---------- */
function ManualItemsView({
collection,
onBack
}: {
collection: MediaCollection;
onBack: () => void;
}) {
const [items, setItems] = useState<LibraryBrowseItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [removing, setRemoving] = useState<number | null>(null);
const activeRef = useRef(true);
// No synchronous setState here: `loading` starts true and flips false in `finally`, so
// this is safe to call from an effect. Reloads (after add/remove) keep the list visible.
const load = useCallback(() => {
getCollectionItemsPreview(collection.name ?? '')
.then((preview) => {
if (activeRef.current) {
setItems(preview);
setError(null);
}
})
.catch((loadError: unknown) => {
if (activeRef.current) {
setError(messageFromCollectionError(loadError, 'Unable to load collection items'));
}
})
.finally(() => {
if (activeRef.current) {
setLoading(false);
}
});
}, [collection.name]);
useEffect(() => {
activeRef.current = true;
load();
return () => {
activeRef.current = false;
};
}, [load]);
const remove = async (item: LibraryBrowseItem) => {
const mediaItemId = item.mediaItemId ?? item.id;
setRemoving(mediaItemId);
try {
await removeItemFromCollection(collection.id, mediaItemId);
load();
} catch (removeError) {
setError(messageFromCollectionError(removeError, 'Unable to remove item'));
} finally {
setRemoving(null);
}
};
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 collections
</Button>
<span className="ctv-collections-detail-title">{collection.name}</span>
<span className="ctv-channels-spacer" />
<Button onClick={() => setPickerOpen(true)} size="sm" startIcon={<Plus aria-hidden="true" size={14} />}>
Add items
</Button>
</div>
<div className="ctv-settings-warn-callout" role="note">
<Info aria-hidden="true" color="var(--status-warn)" size={14} />
<span>
The API has no endpoint to list a manual collection's items. This is a best-effort search preview covering
movies, shows, seasons and artists only — other kinds in this collection won't appear. Adding items works for
all shown kinds.
</span>
</div>
{error && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{error}</span>
</div>
)}
<Card padded={false}>
{loading ? (
<div className="ctv-collections-loading">
<Spinner size={18} />
<span>Loading items</span>
</div>
) : items.length === 0 ? (
<div className="ctv-collections-empty">No previewable items in this collection.</div>
) : (
items.map((item, index) => (
<div
className="ctv-settings-flush-row"
key={`${item.mediaType}:${item.id}`}
style={index === 0 ? { borderTop: 'none' } : undefined}
>
<ListVideo aria-hidden="true" color="var(--text-disabled)" size={15} />
<span className="ctv-settings-flush-row-main ctv-settings-flush-row-title">{item.title}</span>
<Badge tone="neutral">{TYPE_LABEL[item.mediaType]}</Badge>
<IconButton
onClick={() => void remove(item)}
size="sm"
title={`Remove ${item.title}`}
variant="ghost"
>
{removing === (item.mediaItemId ?? item.id) ? (
<Spinner size={14} />
) : (
<Trash2 aria-hidden="true" size={14} />
)}
</IconButton>
</div>
))
)}
</Card>
<AddItemsDialog
collection={collection}
key={`add-${pickerOpen}`}
onAdded={load}
onClose={() => setPickerOpen(false)}
open={pickerOpen}
/>
</div>
);
}
/* ---------- screen ---------- */
export function CollectionsScreen() {
const { refresh, state } = useCollectionsData();
const [tab, setTab] = useState<Tab>('manual');
const [selected, setSelected] = useState<MediaCollection | null>(null);
// dialogs
const [createOpen, setCreateOpen] = useState(false);
const [renameTarget, setRenameTarget] = useState<MediaCollection | null>(null);
const [smartTarget, setSmartTarget] = useState<{ collection: SmartCollection | null; open: boolean }>({
collection: null,
open: false
});
const [deleteTarget, setDeleteTarget] = useState<
{ kind: 'manual'; item: MediaCollection } | { kind: 'smart'; item: SmartCollection } | null
>(null);
const [dialogBusy, setDialogBusy] = useState(false);
const [dialogError, setDialogError] = useState<string | null>(null);
const [rowError, setRowError] = useState<string | null>(null);
const [togglingId, setTogglingId] = useState<number | null>(null);
if (state.status === 'loading') {
return (
<div className="ctv-collections-loading" role="status">
<Spinner size={18} />
<span>Loading collections</span>
</div>
);
}
if (state.status === 'error') {
return (
<div className="ctv-collections">
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{state.error}</span>
<span className="ctv-channels-spacer" />
<Button onClick={() => refresh()} size="sm" variant="secondary">
Retry
</Button>
</div>
</div>
);
}
if (selected) {
// Keep the selected reference fresh across refreshes (e.g. after a rename elsewhere).
const current = state.data.manual.find((collection) => collection.id === selected.id) ?? selected;
return <ManualItemsView collection={current} onBack={() => setSelected(null)} />;
}
const manual = sortByName(state.data.manual);
const smart = sortByName(state.data.smart);
const runDialog = async (operation: () => Promise<void>, onSuccess: () => void) => {
setDialogBusy(true);
setDialogError(null);
try {
await operation();
onSuccess();
refresh(true);
} catch (error) {
setDialogError(messageFromCollectionError(error, 'Operation failed'));
} finally {
setDialogBusy(false);
}
};
const toggleCustomOrder = async (collection: MediaCollection, next: boolean) => {
setRowError(null);
setTogglingId(collection.id);
try {
await updateCollection(collection.id, { name: collection.name, useCustomPlaybackOrder: next });
refresh(true);
} catch (error) {
setRowError(messageFromCollectionError(error, 'Unable to update collection'));
} finally {
setTogglingId(null);
}
};
const confirmDelete = () => {
if (!deleteTarget) {
return;
}
const target = deleteTarget;
void runDialog(
() =>
target.kind === 'manual'
? deleteCollection(target.item.id)
: deleteSmartCollection(target.item.id),
() => setDeleteTarget(null)
);
};
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
<div aria-label="Collection type" className="ctv-segmented" role="group">
<button aria-pressed={tab === 'manual'} onClick={() => setTab('manual')} type="button">
Manual <code>{manual.length}</code>
</button>
<button aria-pressed={tab === 'smart'} onClick={() => setTab('smart')} type="button">
Smart <code>{smart.length}</code>
</button>
</div>
<span className="ctv-channels-spacer" />
{tab === 'manual' ? (
<Button
onClick={() => {
setDialogError(null);
setCreateOpen(true);
}}
size="sm"
startIcon={<Plus aria-hidden="true" size={14} />}
>
New collection
</Button>
) : (
<Button
onClick={() => {
setDialogError(null);
setSmartTarget({ collection: null, open: true });
}}
size="sm"
startIcon={<Plus aria-hidden="true" size={14} />}
>
New smart collection
</Button>
)}
</div>
{rowError && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{rowError}</span>
</div>
)}
{tab === 'manual' && (
<Card padded={false}>
{manual.length === 0 ? (
<div className="ctv-collections-empty">No manual collections yet.</div>
) : (
manual.map((collection, index) => (
<div
className="ctv-settings-flush-row"
key={collection.id}
style={index === 0 ? { borderTop: 'none' } : undefined}
>
<FolderTree aria-hidden="true" color="var(--ctv-accent)" size={15} />
<button
className="ctv-settings-flush-row-main ctv-settings-flush-row-title ctv-collections-linkbtn"
onClick={() => setSelected(collection)}
type="button"
>
{collection.name}
</button>
<label className="ctv-collections-order-toggle" title="Use custom playback order">
<Switch
checked={collection.useCustomPlaybackOrder}
disabled={togglingId === collection.id}
onChange={(next) => void toggleCustomOrder(collection, next)}
size="sm"
/>
<span>Custom order</span>
</label>
<IconButton onClick={() => setSelected(collection)} size="sm" title="Manage items" variant="ghost">
<ListVideo aria-hidden="true" size={14} />
</IconButton>
<IconButton
onClick={() => {
setDialogError(null);
setRenameTarget(collection);
}}
size="sm"
title="Rename"
variant="ghost"
>
<Pencil aria-hidden="true" size={14} />
</IconButton>
<IconButton
onClick={() => setDeleteTarget({ item: collection, kind: 'manual' })}
size="sm"
title="Delete"
variant="ghost"
>
<Trash2 aria-hidden="true" size={14} />
</IconButton>
</div>
))
)}
</Card>
)}
{tab === 'smart' && (
<Card padded={false}>
{smart.length === 0 ? (
<div className="ctv-collections-empty">No smart collections yet.</div>
) : (
smart.map((collection, index) => (
<div
className="ctv-settings-flush-row"
key={collection.id}
style={index === 0 ? { borderTop: 'none' } : undefined}
>
<Sparkles aria-hidden="true" color="var(--ctv-accent)" size={15} />
<div className="ctv-settings-flush-row-main">
<div className="ctv-settings-flush-row-title">{collection.name}</div>
<div className="ctv-collections-query">{collection.query}</div>
</div>
<IconButton
onClick={() => {
setDialogError(null);
setSmartTarget({ collection, open: true });
}}
size="sm"
title="Edit"
variant="ghost"
>
<Pencil aria-hidden="true" size={14} />
</IconButton>
<IconButton
onClick={() => setDeleteTarget({ item: collection, kind: 'smart' })}
size="sm"
title="Delete"
variant="ghost"
>
<Trash2 aria-hidden="true" size={14} />
</IconButton>
</div>
))
)}
</Card>
)}
<ClassicNote />
{/* create manual */}
<NameDialog
busy={dialogBusy}
confirmLabel="Create"
error={createOpen ? dialogError : null}
key={`create-${createOpen}`}
onCancel={() => setCreateOpen(false)}
onSubmit={(name) => void runDialog(() => createCollection({ name }).then(() => undefined), () => setCreateOpen(false))}
open={createOpen}
title="New collection"
/>
{/* rename manual */}
<NameDialog
busy={dialogBusy}
confirmLabel="Save"
error={renameTarget ? dialogError : null}
initialName={renameTarget?.name ?? ''}
key={`rename-${renameTarget?.id ?? 'none'}`}
onCancel={() => setRenameTarget(null)}
onSubmit={(name) =>
renameTarget &&
void runDialog(
() =>
updateCollection(renameTarget.id, {
name,
useCustomPlaybackOrder: renameTarget.useCustomPlaybackOrder
}).then(() => undefined),
() => setRenameTarget(null)
)
}
open={renameTarget !== null}
title="Rename collection"
/>
{/* create / edit smart */}
<SmartDialog
busy={dialogBusy}
error={dialogError}
key={`smart-${smartTarget.open}-${smartTarget.collection?.id ?? 'new'}`}
initial={
smartTarget.collection
? { name: smartTarget.collection.name ?? '', query: smartTarget.collection.query ?? '' }
: null
}
onCancel={() => setSmartTarget({ collection: null, open: false })}
onSubmit={(values) => {
const editing = smartTarget.collection;
void runDialog(
() =>
(editing
? updateSmartCollection(editing.id, values)
: createSmartCollection(values)
).then(() => undefined),
() => setSmartTarget({ collection: null, open: false })
);
}}
open={smartTarget.open}
/>
<ConfirmDialog
busy={dialogBusy}
confirmLabel="Delete"
message={
deleteTarget ? (
<>
<span>{`Delete "${deleteTarget.item.name ?? 'this collection'}"? This cannot be undone.`}</span>
{dialogError && (
<span className="ctv-field-error" role="alert">
{dialogError}
</span>
)}
</>
) : (
''
)
}
onCancel={() => {
setDeleteTarget(null);
setDialogError(null);
}}
onConfirm={confirmDelete}
open={deleteTarget !== null}
title={deleteTarget?.kind === 'smart' ? 'Delete smart collection' : 'Delete collection'}
tone="danger"
/>
</div>
);
}
function ClassicNote() {
return (
<div className="ctv-settings-callout">
<Info aria-hidden="true" size={14} />
<span>
Multi-collections, rerun collections and playlists aren't available here yet manage them in the Classic UI
for now.
</span>
</div>
);
}