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>
956 lines
30 KiB
TypeScript
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. “All” searches movies, shows and artists; pick
|
|
“Seasons” to find a specific season. Episodes, music, images and other item kinds can’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>
|
|
);
|
|
}
|