0.3.22 - PINs

This commit is contained in:
ponzischeme89
2026-08-25 11:39:55 +12:00
parent 396d35e2f5
commit 0fcc02f57e
2697 changed files with 5360 additions and 50 deletions
+391
View File
@@ -0,0 +1,391 @@
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';
}
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: '',
},
})}
>
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.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 ?? '');
// 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));
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,
});
};
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}
{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>
);
}