2026-08-25 11:39:55 +12:00
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' ;
}
2026-08-28 23:00:02 +12:00
const LAYOUT_LABEL : Record < string , string > = {
poster : 'Poster cards' ,
thumb : 'Thumbnail cards' ,
};
2026-08-25 11:39:55 +12:00
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 ,
2026-08-28 23:00:02 +12:00
position : 0 , dataSource : '' , component : '' , maxItems : 20 , destination : '' , layout : '' ,
2026-08-25 11:39:55 +12:00
},
})}
>
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 }
2026-08-28 23:00:02 +12:00
{ row . layout && LAYOUT_LABEL [ row . layout ] ? < Chip tone = "note" >{ LAYOUT_LABEL [ row . layout ]}</ Chip > : null }
2026-08-25 11:39:55 +12:00
{ 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 ?? '' );
2026-08-28 23:00:02 +12:00
const [ layout , setLayout ] = useState ( row . layout ?? '' );
2026-08-25 11:39:55 +12:00
// 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 ));
2026-08-28 23:00:02 +12:00
// 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' ;
2026-08-25 11:39:55 +12:00
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 ,
2026-08-28 23:00:02 +12:00
...( layoutApplies && ( layout === 'poster' || layout === 'thumb' ) ? { layout } : {}),
2026-08-25 11:39:55 +12:00
});
};
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 }
2026-08-28 23:00:02 +12:00
{ 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 }
2026-08-25 11:39:55 +12:00
{ 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 >
);
}