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 = { home: 'Home', movies: 'Movies', tv: 'TV Shows' }; const DEFAULT_DESTINATION: Record = { 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 { 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 = { 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; }) { const [editing, setEditing] = useState<{ row: RemoteSectionDefinition; isNew: boolean } | null>(null); const [deleting, setDeleting] = useState(null); const [showRaw, setShowRaw] = useState(false); const [rawDraft, setRawDraft] = useState(''); const [rawError, setRawError] = useState(''); const [busyRow, setBusyRow] = useState(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 ( } > {sorted.length === 0 ? ( No rows are configured for this page. Add one to get started. ) : (
{sorted.map((row, index) => (
{row.title || row.id} {!row.enabled ? off : null}
{describeRow(row, rowTypes)} {row.maxItems ? {row.maxItems} items : null} {row.layout && LAYOUT_LABEL[row.layout] ? {LAYOUT_LABEL[row.layout]} : null} {row.destination ? {row.destination} : null}
))}
)} {editing ? ( 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 ? ( setDeleting(null)} /> ) : null} {showRaw ? (
event.target === event.currentTarget && setShowRaw(false)}>

Raw section definitions

For development and troubleshooting. The visual editor above covers ordinary administration; this is the exact document the gateway stores and validates.