422 lines
16 KiB
TypeScript
422 lines
16 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import type { RemoteSectionDefinition, RowTypeDefinition } from '../api/types';
|
|
import { Button, Card, Chip, Confirm, Empty, Field, Note, Tag, Toggle } from './ui';
|
|
|
|
/* The visual row editor: what an operator sees instead of the JSON section-definition
|
|
* array. It is a thin presentation over exactly the same document the gateway already
|
|
* stores — every action here ends by calling onSave with a complete, reordered array,
|
|
* through the same revisioned "*.sectionDefinitions" configuration value the raw JSON
|
|
* view edits directly. Neither view is more authoritative than the other; they are two
|
|
* ways of looking at one document, and the gateway validates whichever arrives.
|
|
*
|
|
* Positions are never exposed. Every mutation here renumbers the whole list to clean
|
|
* multiples of ten in its new order, so "duplicate position" is not a state this editor
|
|
* can produce and an operator never sees the number at all — only Up/Down and drag order. */
|
|
|
|
const PAGE_LABEL: Record<string, string> = { home: 'Home', movies: 'Movies', tv: 'TV Shows' };
|
|
|
|
const DEFAULT_DESTINATION: Record<string, string> = {
|
|
continueWatching: 'home',
|
|
favorites: 'home',
|
|
forYou: 'for-you',
|
|
latest: 'home',
|
|
};
|
|
|
|
function renumber(rows: RemoteSectionDefinition[]): RemoteSectionDefinition[] {
|
|
return rows.map((row, index) => ({ ...row, position: (index + 1) * 10 }));
|
|
}
|
|
|
|
function slugify(title: string): string {
|
|
const base = title
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '');
|
|
return base || 'row';
|
|
}
|
|
|
|
function uniqueId(title: string, taken: Set<string>): string {
|
|
const base = slugify(title);
|
|
if (!taken.has(base)) return base;
|
|
let suffix = 2;
|
|
while (taken.has(`${base}-${suffix}`)) suffix += 1;
|
|
return `${base}-${suffix}`;
|
|
}
|
|
|
|
function rowTypeFor(row: RemoteSectionDefinition, rowTypes: RowTypeDefinition[]): RowTypeDefinition | undefined {
|
|
return rowTypes.find((candidate) => candidate.type === row.type);
|
|
}
|
|
|
|
function describeRow(row: RemoteSectionDefinition, rowTypes: RowTypeDefinition[]): string {
|
|
const rowType = rowTypeFor(row, rowTypes);
|
|
if (rowType && !rowType.custom) return rowType.label;
|
|
return row.type ? `Custom · ${row.type}` : 'Custom';
|
|
}
|
|
|
|
const LAYOUT_LABEL: Record<string, string> = {
|
|
poster: 'Poster cards',
|
|
thumb: 'Thumbnail cards',
|
|
};
|
|
|
|
export function RowsEditor({
|
|
page,
|
|
rows,
|
|
rowTypes,
|
|
busy,
|
|
onSave,
|
|
}: {
|
|
page: 'home' | 'movies' | 'tv';
|
|
rows: RemoteSectionDefinition[];
|
|
rowTypes: RowTypeDefinition[];
|
|
busy: boolean;
|
|
onSave: (next: RemoteSectionDefinition[]) => Promise<void>;
|
|
}) {
|
|
const [editing, setEditing] = useState<{ row: RemoteSectionDefinition; isNew: boolean } | null>(null);
|
|
const [deleting, setDeleting] = useState<RemoteSectionDefinition | null>(null);
|
|
const [showRaw, setShowRaw] = useState(false);
|
|
const [rawDraft, setRawDraft] = useState('');
|
|
const [rawError, setRawError] = useState('');
|
|
const [busyRow, setBusyRow] = useState<string | null>(null);
|
|
|
|
const sorted = useMemo(() => [...rows].sort((a, b) => a.position - b.position), [rows]);
|
|
const availableTypes = useMemo(() => rowTypes.filter((type) => type.pages.includes(page)), [rowTypes, page]);
|
|
|
|
const save = async (next: RemoteSectionDefinition[], key: string) => {
|
|
setBusyRow(key);
|
|
try {
|
|
await onSave(renumber(next));
|
|
} finally {
|
|
setBusyRow(null);
|
|
}
|
|
};
|
|
|
|
const move = (row: RemoteSectionDefinition, direction: -1 | 1) => {
|
|
const index = sorted.findIndex((candidate) => candidate.id === row.id);
|
|
const target = index + direction;
|
|
if (index < 0 || target < 0 || target >= sorted.length) return;
|
|
const next = [...sorted];
|
|
const swapped = next[target];
|
|
next[target] = next[index]!;
|
|
next[index] = swapped!;
|
|
void save(next, `move:${row.id}`);
|
|
};
|
|
|
|
const toggle = (row: RemoteSectionDefinition) => {
|
|
void save(sorted.map((candidate) => (candidate.id === row.id ? { ...candidate, enabled: !candidate.enabled } : candidate)), `toggle:${row.id}`);
|
|
};
|
|
|
|
const remove = () => {
|
|
if (!deleting) return;
|
|
const target = deleting;
|
|
void save(sorted.filter((candidate) => candidate.id !== target.id), `delete:${target.id}`).then(() => setDeleting(null));
|
|
};
|
|
|
|
const openRaw = () => {
|
|
setRawDraft(JSON.stringify(sorted, null, 2));
|
|
setRawError('');
|
|
setShowRaw(true);
|
|
};
|
|
|
|
const applyRaw = async () => {
|
|
try {
|
|
const parsed = JSON.parse(rawDraft);
|
|
if (!Array.isArray(parsed)) throw new Error('Must be a JSON array of rows.');
|
|
setRawError('');
|
|
await save(parsed as RemoteSectionDefinition[], 'raw');
|
|
setShowRaw(false);
|
|
} catch (error) {
|
|
setRawError(error instanceof Error ? error.message : 'Invalid JSON.');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card
|
|
title={`${PAGE_LABEL[page]} rows`}
|
|
intro="What is on this page and in what order, described the way a person watching would describe it — not the JSON that composes it."
|
|
icon="list"
|
|
actions={
|
|
<div className="row tight">
|
|
<Button size="sm" variant="quiet" onClick={openRaw}>
|
|
Advanced · raw JSON
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="primary"
|
|
icon="plus"
|
|
onClick={() => setEditing({
|
|
isNew: true,
|
|
row: {
|
|
id: '', type: availableTypes[0]?.type ?? 'custom', title: '', enabled: true,
|
|
position: 0, dataSource: '', component: '', maxItems: 20, destination: '', layout: '',
|
|
},
|
|
})}
|
|
>
|
|
Add row
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
{sorted.length === 0 ? (
|
|
<Empty>No rows are configured for this page. Add one to get started.</Empty>
|
|
) : (
|
|
<div className="rows-editor-list">
|
|
{sorted.map((row, index) => (
|
|
<div className="rows-editor-item" key={row.id} data-enabled={row.enabled}>
|
|
<div className="rows-editor-item-main">
|
|
<div className="rows-editor-item-title">
|
|
<b>{row.title || row.id}</b>
|
|
{!row.enabled ? <Tag>off</Tag> : null}
|
|
</div>
|
|
<div className="chips">
|
|
<Chip>{describeRow(row, rowTypes)}</Chip>
|
|
{row.maxItems ? <Chip>{row.maxItems} items</Chip> : null}
|
|
{row.layout && LAYOUT_LABEL[row.layout] ? <Chip tone="note">{LAYOUT_LABEL[row.layout]}</Chip> : null}
|
|
{row.destination ? <Chip tone="note">{row.destination}</Chip> : null}
|
|
</div>
|
|
</div>
|
|
<div className="rows-editor-item-actions">
|
|
<Button
|
|
size="sm"
|
|
variant="quiet"
|
|
title="Move up"
|
|
disabled={index === 0}
|
|
busy={busyRow === `move:${row.id}`}
|
|
onClick={() => move(row, -1)}
|
|
>
|
|
↑
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="quiet"
|
|
title="Move down"
|
|
disabled={index === sorted.length - 1}
|
|
busy={busyRow === `move:${row.id}`}
|
|
onClick={() => move(row, 1)}
|
|
>
|
|
↓
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="quiet"
|
|
busy={busyRow === `toggle:${row.id}`}
|
|
onClick={() => toggle(row)}
|
|
>
|
|
{row.enabled ? 'Disable' : 'Enable'}
|
|
</Button>
|
|
<Button size="sm" variant="quiet" onClick={() => setEditing({ row, isNew: false })}>
|
|
Edit
|
|
</Button>
|
|
<Button size="sm" variant="quiet" icon="trash" title="Delete row" onClick={() => setDeleting(row)} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{editing ? (
|
|
<RowEditorDialog
|
|
page={page}
|
|
row={editing.row}
|
|
isNew={editing.isNew}
|
|
rowTypes={availableTypes}
|
|
existingIds={new Set(sorted.map((candidate) => candidate.id).filter((id) => id !== editing.row.id))}
|
|
busy={busy}
|
|
onCancel={() => setEditing(null)}
|
|
onSave={(next) => {
|
|
const isNew = editing.isNew;
|
|
void save(
|
|
isNew ? [...sorted, next] : sorted.map((candidate) => (candidate.id === editing.row.id ? next : candidate)),
|
|
isNew ? 'add' : `edit:${editing.row.id}`,
|
|
).then(() => setEditing(null));
|
|
}}
|
|
/>
|
|
) : null}
|
|
|
|
{deleting ? (
|
|
<Confirm
|
|
title={`Delete "${deleting.title || deleting.id}"?`}
|
|
body="This row is removed from the page immediately on every television. Nothing else is affected."
|
|
confirmLabel="Delete row"
|
|
destructive
|
|
busy={busyRow === `delete:${deleting.id}`}
|
|
onConfirm={remove}
|
|
onCancel={() => setDeleting(null)}
|
|
/>
|
|
) : null}
|
|
|
|
{showRaw ? (
|
|
<div className="scrim" onPointerDown={(event) => event.target === event.currentTarget && setShowRaw(false)}>
|
|
<div className="dialog" role="dialog" aria-modal="true">
|
|
<h2>Raw section definitions</h2>
|
|
<p>
|
|
For development and troubleshooting. The visual editor above covers ordinary
|
|
administration; this is the exact document the gateway stores and validates.
|
|
</p>
|
|
<textarea
|
|
rows={16}
|
|
className="mono"
|
|
value={rawDraft}
|
|
onChange={(event) => setRawDraft(event.target.value)}
|
|
/>
|
|
{rawError ? <Note tone="warn">{rawError}</Note> : null}
|
|
<div className="dialog-actions">
|
|
<Button variant="quiet" onClick={() => setShowRaw(false)}>Cancel</Button>
|
|
<Button variant="primary" busy={busyRow === 'raw'} onClick={() => void applyRaw()}>Apply</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function RowEditorDialog({
|
|
page,
|
|
row,
|
|
isNew,
|
|
rowTypes,
|
|
existingIds,
|
|
busy,
|
|
onSave,
|
|
onCancel,
|
|
}: {
|
|
page: 'home' | 'movies' | 'tv';
|
|
row: RemoteSectionDefinition;
|
|
isNew: boolean;
|
|
rowTypes: RowTypeDefinition[];
|
|
existingIds: Set<string>;
|
|
busy: boolean;
|
|
onSave: (row: RemoteSectionDefinition) => void;
|
|
onCancel: () => void;
|
|
}) {
|
|
const [title, setTitle] = useState(row.title);
|
|
const [type, setType] = useState(row.type || rowTypes[0]?.type || 'custom');
|
|
const [maxItems, setMaxItems] = useState(row.maxItems && row.maxItems > 0 ? row.maxItems : 20);
|
|
const [enabled, setEnabled] = useState(row.enabled);
|
|
const [customDataSource, setCustomDataSource] = useState(row.dataSource);
|
|
const [customComponent, setCustomComponent] = useState(row.component || 'mediaRow');
|
|
const [customDestination, setCustomDestination] = useState(row.destination ?? '');
|
|
const [layout, setLayout] = useState(row.layout ?? '');
|
|
|
|
// A row saved by an older console, or through the raw JSON view, can carry a type this
|
|
// page's catalogue does not offer (moved off this page, or never catalogued at all). It
|
|
// still needs an option in the picker, or the select silently shows something else while
|
|
// `type` state disagrees — which would rewrite the row to a type nobody chose on save.
|
|
const selectableTypes = rowTypes.some((candidate) => candidate.type === row.type) || !row.type
|
|
? rowTypes
|
|
: [...rowTypes, { type: row.type, label: `Current: ${row.type}`, description: '', component: row.component, dataSource: row.dataSource, pages: [page], requiresMatchingDestination: false, custom: true }];
|
|
|
|
const rowType = rowTypeFor({ ...row, type }, selectableTypes) ?? selectableTypes.find((candidate) => candidate.type === 'custom');
|
|
const isCustom = !rowType || rowType.custom;
|
|
const trimmedTitle = title.trim();
|
|
const validMaxItems = Number.isFinite(maxItems) && maxItems >= 1 && maxItems <= 100;
|
|
const valid = trimmedTitle.length > 0 && validMaxItems &&
|
|
(!isCustom || (customDataSource.trim().length > 0 && customComponent.trim().length > 0));
|
|
|
|
// Layout only means something for a horizontal card row. A genre browser or a paged
|
|
// library grid draws neither poster nor thumbnail cards, so the control is hidden and any
|
|
// stray value is dropped on save.
|
|
const resolvedComponent = isCustom ? customComponent.trim() : rowType?.component ?? '';
|
|
const layoutApplies = resolvedComponent === 'mediaRow';
|
|
|
|
const submit = () => {
|
|
if (!valid) return;
|
|
const resolvedType = rowType?.type ?? 'custom';
|
|
const destination = isCustom
|
|
? customDestination.trim()
|
|
: rowType?.requiresMatchingDestination
|
|
? page
|
|
: (row.destination || DEFAULT_DESTINATION[resolvedType] || page);
|
|
onSave({
|
|
id: isNew ? uniqueId(trimmedTitle, existingIds) : row.id,
|
|
type: resolvedType,
|
|
title: trimmedTitle,
|
|
enabled,
|
|
position: row.position,
|
|
dataSource: isCustom ? customDataSource.trim() : rowType!.dataSource,
|
|
component: isCustom ? customComponent.trim() : rowType!.component,
|
|
maxItems,
|
|
destination,
|
|
...(layoutApplies && (layout === 'poster' || layout === 'thumb') ? { layout } : {}),
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="scrim" onPointerDown={(event) => event.target === event.currentTarget && onCancel()}>
|
|
<div className="dialog" role="dialog" aria-modal="true" aria-labelledby="row-editor-title">
|
|
<h2 id="row-editor-title">{isNew ? 'Add row' : 'Edit row'}</h2>
|
|
<p>{PAGE_LABEL[page]} · what this row shows and how it behaves.</p>
|
|
|
|
<div className="fields">
|
|
<Field label="Row name" grow>
|
|
<input type="text" value={title} onChange={(event) => setTitle(event.target.value)} placeholder="e.g. Genres" autoFocus />
|
|
</Field>
|
|
</div>
|
|
|
|
<div className="fields">
|
|
<Field label="Row type" grow>
|
|
<select value={type} onChange={(event) => setType(event.target.value)}>
|
|
{selectableTypes.map((option) => (
|
|
<option key={option.type} value={option.type}>{option.label}</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
<Field label="Maximum items">
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={100}
|
|
value={maxItems}
|
|
onChange={(event) => setMaxItems(Number(event.target.value))}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
{rowType?.description ? <p className="hint">{rowType.description}</p> : null}
|
|
|
|
{layoutApplies ? (
|
|
<div className="fields">
|
|
<Field
|
|
label="Card layout"
|
|
hint="Automatic shows episodes as wide thumbnails and everything else as upright posters. Override to force one shape — thumbnails are the same wide cards Continue Watching uses."
|
|
grow
|
|
>
|
|
<select value={layout} onChange={(event) => setLayout(event.target.value)}>
|
|
<option value="">Automatic</option>
|
|
<option value="poster">Poster cards</option>
|
|
<option value="thumb">Thumbnail cards</option>
|
|
</select>
|
|
</Field>
|
|
</div>
|
|
) : null}
|
|
|
|
{rowType?.requiresMatchingDestination ? (
|
|
<Note>Browses the {PAGE_LABEL[page]} catalogue — this row can only ever point at the page it lives on.</Note>
|
|
) : null}
|
|
|
|
{isCustom ? (
|
|
<div className="fields">
|
|
<Field label="Data source" hint="e.g. emby.latest, gateway.recommendations" grow>
|
|
<input type="text" value={customDataSource} onChange={(event) => setCustomDataSource(event.target.value)} />
|
|
</Field>
|
|
<Field label="Component" hint="e.g. mediaRow, mediaGrid, genreBrowser" grow>
|
|
<input type="text" value={customComponent} onChange={(event) => setCustomComponent(event.target.value)} />
|
|
</Field>
|
|
<Field label="Destination" hint="Optional" grow>
|
|
<input type="text" value={customDestination} onChange={(event) => setCustomDestination(event.target.value)} />
|
|
</Field>
|
|
</div>
|
|
) : null}
|
|
|
|
<Toggle label="Row enabled" checked={enabled} onChange={setEnabled} />
|
|
|
|
<div className="dialog-actions">
|
|
<Button variant="quiet" onClick={onCancel}>Cancel</Button>
|
|
<Button variant="primary" disabled={!valid} busy={busy} onClick={submit}>
|
|
{isNew ? 'Add row' : 'Save row'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|