2026-08-14 09:40:03 +12:00
import { useEffect , useState } from 'react' ;
import { api } from '../api/client' ;
import { useAction } from '../lib/hooks' ;
import { useGateway } from '../lib/gateway' ;
import { useToast } from '../lib/toast' ;
2026-08-15 09:23:26 +12:00
import { Banner , Button , Card , Empty , Field , Grid , Loading , PageHead , Tag , Toggle } from '../components/ui' ;
2026-08-14 13:32:14 +12:00
import type { HeroItem , HeroPlacement , HeroPlacementPolicy , HeroSchedule } from '../api/types' ;
2026-08-14 09:40:03 +12:00
const MAX_PINS = 4 ;
2026-08-14 13:32:14 +12:00
const PLACEMENTS : Array < { id : HeroPlacement ; label : string ; type : string } > = [
{ id : 'home' , label : 'Home' , type : 'films and television shows' },
{ id : 'movies' , label : 'Movies' , type : 'films' },
{ id : 'tv_shows' , label : 'TV Shows' , type : 'television shows' },
];
const emptyPlacement = () : HeroPlacementPolicy => ({ pinnedItems : [], primeSubtitle : '' });
2026-08-15 09:23:26 +12:00
const WEEKDAYS = [
{ value : 1 , short : 'Mon' , label : 'Monday' },
{ value : 2 , short : 'Tue' , label : 'Tuesday' },
{ value : 3 , short : 'Wed' , label : 'Wednesday' },
{ value : 4 , short : 'Thu' , label : 'Thursday' },
{ value : 5 , short : 'Fri' , label : 'Friday' },
{ value : 6 , short : 'Sat' , label : 'Saturday' },
{ value : 0 , short : 'Sun' , label : 'Sunday' },
];
type ScheduleFrequency = 'once' | 'daily' | 'weekly' ;
function localDateTime ( value : Date ) : string {
const offset = value . getTimezoneOffset () * 60 _000 ;
return new Date ( value . getTime () - offset ). toISOString (). slice ( 0 , 16 );
}
function usableAbsoluteTime ( value : string | undefined ) : boolean {
return Boolean ( value && Number . isFinite ( new Date ( value ). getTime ()) && new Date ( value ). getFullYear () >= 2000 );
}
function scheduleFrequency ( schedule : HeroSchedule ) : ScheduleFrequency {
return schedule . frequency ?? 'once' ;
}
function scheduleSummary ( schedule : HeroSchedule ) : string {
if ( schedule . frequency === 'daily' ) {
return `Every day · ${ schedule . startTime } – ${ schedule . endTime } ` ;
}
if ( schedule . frequency === 'weekly' ) {
const selected = WEEKDAYS . filter (( day ) => ( schedule . weekdays ?? []). includes ( day . value ));
const days = selected . length === 7 ? 'Every day' : selected . map (( day ) => day . short ). join ( ', ' );
return ` ${ days || 'No days selected' } · ${ schedule . startTime } – ${ schedule . endTime } ` ;
}
return ` ${ schedule . startAt ? new Date ( schedule . startAt ). toLocaleString () : 'Start missing' } → ${ schedule . endAt ? new Date ( schedule . endAt ). toLocaleString () : 'End missing' } ` ;
}
function newSchedule ( item : HeroItem , placement : HeroPlacement ) : HeroSchedule {
const start = new Date ();
start . setMinutes ( Math . ceil ( start . getMinutes () / 30 ) * 30 , 0 , 0 );
const end = new Date ( start . getTime () + 2 * 60 * 60 * 1000 );
const validPlacement = placement === 'home' ||
( placement === 'movies' && item . type === 'Movie' ) ||
( placement === 'tv_shows' && item . type === 'Series' );
return {
id : crypto.randomUUID (), itemId : item.id ,
startAt : start.toISOString (), endAt : end.toISOString (),
priority : 0 , enabled : true , placements : [ validPlacement ? placement : 'home' ],
};
}
2026-08-14 09:40:03 +12:00
export function HeroPage() {
const { status , error , loading , reload } = useGateway ();
const { wrap , show } = useToast ();
const { busy , run } = useAction ();
2026-08-14 13:32:14 +12:00
const [ placement , setPlacement ] = useState < HeroPlacement >( 'home' );
const [ placements , setPlacements ] = useState < Record < HeroPlacement , HeroPlacementPolicy >>({
home : emptyPlacement (), movies : emptyPlacement (), tv_shows : emptyPlacement (),
});
2026-08-14 09:40:03 +12:00
const [ dirty , setDirty ] = useState ( false );
const [ queryText , setQueryText ] = useState ( '' );
const [ results , setResults ] = useState < HeroItem [] | null >( null );
const [ schedules , setSchedules ] = useState < HeroSchedule [] >([]);
2026-08-15 09:23:26 +12:00
const [ scheduleDraft , setScheduleDraft ] = useState < HeroSchedule | null >( null );
2026-08-14 09:40:03 +12:00
const policy = status ? . heroPolicy ;
useEffect (() => {
// The poll must not take an unsaved arrangement away, which is what `dirty` guards.
if ( dirty || ! policy ) return ;
2026-08-14 13:32:14 +12:00
setPlacements ({
home : policy.placements?.home ?? { pinnedItems : policy.pinnedItems ?? [], primeSubtitle : policy.primeSubtitle ?? '' },
movies : policy.placements?.movies ?? emptyPlacement (),
tv_shows : policy.placements?.tv_shows ?? emptyPlacement (),
});
2026-08-14 09:40:03 +12:00
setSchedules ( policy . schedules ?? []);
}, [ policy , dirty ]);
const search = () =>
run ( 'search' , async () => {
const needle = queryText . trim ();
if ( ! needle ) return ;
const payload = await wrap (() =>
api . get < { items : HeroItem [] | null } > ( `/admin/api/hero/search?q= ${ encodeURIComponent ( needle ) } ` ),
);
if ( payload ) setResults ( payload . items ?? []);
});
2026-08-14 13:32:14 +12:00
const current = placements [ placement ];
const pins = current . pinnedItems ?? [];
const setCurrent = ( next : Partial < HeroPlacementPolicy >) => {
setPlacements (( value ) => ({ ... value , [ placement ] : { ... value [ placement ], ... next } }));
setDirty ( true );
};
2026-08-14 09:40:03 +12:00
const add = ( item : HeroItem ) => {
if ( pins . some (( pin ) => pin . id === item . id )) return ;
if ( pins . length >= MAX_PINS ) {
show ( 'Remove a pinned title before adding another.' , 'bad' );
return ;
}
2026-08-14 13:32:14 +12:00
setCurrent ({ pinnedItems : [... pins , item ] });
2026-08-14 09:40:03 +12:00
};
const save = () =>
run ( 'save' , async () => {
await wrap (
() =>
api . post ( '/admin/api/hero-policy' , {
2026-08-14 13:32:14 +12:00
placements : Object.fromEntries ( Object . entries ( placements ). map (([ name , value ]) => [ name , {
pinnedItemIds : ( value . pinnedItems ?? []). map (( item ) => item . id ),
primeSubtitle : value.primeSubtitle.trim (),
}])),
2026-08-14 09:40:03 +12:00
schedules ,
}),
'Hero saved.' ,
);
setDirty ( false );
await reload ();
});
return (
<>
< PageHead
2026-08-14 13:32:14 +12:00
title = "Featured content"
intro = "Manage an independent, backend-resolved hero for Home, Movies and TV Shows."
2026-08-14 09:40:03 +12:00
/>
< Banner message = { error } />
{ loading ? (
< Loading rows = { 2 } />
) : (
<>
2026-08-14 13:32:14 +12:00
< Grid >
{ PLACEMENTS . map (( option ) => {
const configured = placements [ option . id ];
const activeSchedule = schedules
2026-08-15 09:23:26 +12:00
. filter (( entry ) => entry . enabled && ( entry . placements ?? [ 'home' ]). includes ( option . id ))
2026-08-14 13:32:14 +12:00
. sort (( left , right ) => right . priority - left . priority )[ 0 ];
2026-08-15 09:23:26 +12:00
const source = ( configured . pinnedItems ?? []). length ? 'Manual' : activeSchedule ? 'Schedule ready' : 'Automatic' ;
2026-08-14 13:32:14 +12:00
const scheduledItem = policy ? . items ? . find (( item ) => item . id === activeSchedule ? . itemId );
const preview = ( configured . pinnedItems ?? []). map (( item ) => item . name ). join ( ', ' ) || scheduledItem ? . name || activeSchedule ? . itemId || 'Resolved for each viewer' ;
return < Card key = { option . id } title = { option . label } intro = { ` ${ source } · ${ preview } ` } tone = { option . id === placement ? 'info' : undefined }>
< Button size = "sm" variant = "quiet" onClick = {() => setPlacement ( option . id )}> Manage { option . label }</ Button >
</ Card >;
})}
</ Grid >
< div className = "tabs" role = "tablist" aria-label = "Hero placement" >
{ PLACEMENTS . map (( option ) => (
< Button key = { option . id } variant = { placement === option . id ? 'primary' : 'quiet' } onClick = {() => setPlacement ( option . id )}>
{ option . label }
</ Button >
))}
</ div >
2026-08-14 09:40:03 +12:00
< Card
2026-08-14 13:32:14 +12:00
title = { ` ${ PLACEMENTS . find (( entry ) => entry . id === placement ) ? . label } hero` }
intro = { `Pinned ${ PLACEMENTS . find (( entry ) => entry . id === placement ) ? . type } lead this section only. Empty places use this placement’ s automatic selection.` }
2026-08-14 09:40:03 +12:00
icon = "star"
tone = "note"
footer = {
<>
< Button variant = "primary" busy = { busy === 'save' } onClick = {() => void save ()}>
Save hero
</ Button >
< Button
onClick = {() => {
2026-08-14 13:32:14 +12:00
setCurrent ({ pinnedItems : [] });
2026-08-14 09:40:03 +12:00
}}
>
Clear pins
</ Button >
{ dirty ? < span className = "hint" > Unsaved changes .</ span > : null }
</>
}
>
{ pins . length === 0 ? (
< Empty > No titles are pinned . The hero is entirely release - aware and automatic .</ Empty >
) : (
2026-08-15 09:23:26 +12:00
< div className = "hero-pins" >
2026-08-14 09:40:03 +12:00
{ pins . map (( item , index ) => (
2026-08-15 09:23:26 +12:00
< div className = "hero-pin" key = { item . id }>
< span className = "hero-pin-order" >{ index + 1 }</ span >
< span >< b >{ item . name }</ b >< small >{ item . type }{ item . year ? ` · ${ item . year } ` : '' }</ small ></ span >
< Button size = "sm" icon = "clock" onClick = {() => setScheduleDraft ( newSchedule ( item , placement ))}>
Schedule
</ Button >
< Button
variant = "quiet"
size = "sm"
icon = "close"
title = { `Remove ${ item . name } ` }
onClick = {() => setCurrent ({ pinnedItems : pins.filter (( pin ) => pin . id !== item . id ) })}
/>
</ div >
2026-08-14 09:40:03 +12:00
))}
</ div >
)}
< Field
label = "Prime-card subtitle"
hint = "Optional wording under the large first card. Leave blank to use Memby's natural release or rating reason."
>
< input
type = "text"
maxLength = { 160 }
2026-08-14 13:32:14 +12:00
value = { current . primeSubtitle }
2026-08-14 09:40:03 +12:00
placeholder = "Leave blank for the automatic reason"
onChange = {( event ) => {
2026-08-14 13:32:14 +12:00
setCurrent ({ primeSubtitle : event.target.value });
2026-08-14 09:40:03 +12:00
}}
/>
</ Field >
</ Card >
2026-08-15 09:23:26 +12:00
< Card
title = "Hero schedule"
intro = "The gateway applies these rules in server time. Manual pins win first; otherwise the highest-priority active schedule wins, followed by Memby’ s automatic hero."
icon = "clock"
tone = "info"
actions = {< Tag tone = "info" >{ policy ? . timeZone || 'server local time' }</ Tag >}
footer = {
<>
< Button variant = "primary" busy = { busy === 'save' } onClick = {() => void save ()}>
Save schedule
</ Button >
< span className = "hint" > Daily and weekly rules repeat until you switch them off .</ span >
</>
}
>
{ schedules . length === 0 ? (
< Empty > No scheduled heroes yet . Use Schedule beside a pinned or searched title .</ Empty >
) : (
< div className = "hero-schedule-list" >
{[... schedules ]
. sort (( left , right ) => Number ( right . enabled ) - Number ( left . enabled ) || right . priority - left . priority )
. map (( schedule ) => {
const item = [...( policy ? . items ?? []), ... pins , ...( results ?? [])]
. find (( candidate ) => candidate . id === schedule . itemId );
return (
< article className = "hero-schedule" data-enabled = { schedule . enabled || undefined } key = { schedule . id }>
< div className = "hero-schedule-time" >
< b >{ schedule . frequency === 'weekly' ? 'Weekly' : schedule . frequency === 'daily' ? 'Daily' : 'Once' }</ b >
< span >{ schedule . frequency ? schedule.startTime : schedule.startAt ? new Date ( schedule . startAt ). toLocaleDateString () : '—' }</ span >
</ div >
< div className = "hero-schedule-main" >
< div className = "hero-schedule-title" >
< h3 >{ item ? . name ?? schedule . itemId }</ h3 >
< Tag tone = { schedule . enabled ? 'ok' : undefined }>{ schedule . enabled ? 'enabled' : 'paused' }</ Tag >
</ div >
< p >{ scheduleSummary ( schedule )}</ p >
< div className = "chips" >
{( schedule . placements ?? [ 'home' ]). map (( value ) => (
< span className = "chip" key = { value }>{ PLACEMENTS . find (( entry ) => entry . id === value ) ? . label }</ span >
))}
{ schedule . priority !== 0 ? < span className = "chip" > Priority { schedule . priority }</ span > : null }
</ div >
</ div >
< div className = "hero-schedule-actions" >
< Button size = "sm" onClick = {() => setScheduleDraft ({ ... schedule })}> Edit </ Button >
< Button
size = "sm"
variant = "quiet"
onClick = {() => {
setSchedules (( current ) => current . map (( entry ) => entry . id === schedule . id ? { ... entry , enabled : ! entry . enabled } : entry ));
setDirty ( true );
}}
>
{ schedule . enabled ? 'Pause' : 'Enable' }
</ Button >
< Button
size = "sm"
variant = "quiet"
onClick = {() => {
setSchedules (( current ) => current . filter (( entry ) => entry . id !== schedule . id ));
setDirty ( true );
}}
>
Remove
</ Button >
</ div >
</ article >
);
})}
</ div >
2026-08-14 09:40:03 +12:00
)}
</ Card >
< Card
title = "Find a title"
2026-08-14 13:32:14 +12:00
intro = { `Search the imported Emby catalogue. Add a result to the selected ${ PLACEMENTS . find (( entry ) => entry . id === placement ) ? . label } placement; switch tabs to show it in more than one section.` }
2026-08-14 09:40:03 +12:00
icon = "search"
tone = "info"
>
< div className = "field-row" >
< Field label = "Title" grow >
< input
type = "search"
value = { queryText }
placeholder = "Search films and television shows"
onChange = {( event ) => setQueryText ( event . target . value )}
onKeyDown = {( event ) => {
if ( event . key === 'Enter' ) void search ();
}}
/>
</ Field >
< Button busy = { busy === 'search' } icon = "search" onClick = {() => void search ()}>
Search
</ Button >
</ div >
{ results === null ? null : results . length === 0 ? (
< Empty > No playable films or series matched that search .</ Empty >
) : (
< Grid >
{ results . map (( item ) => (
< Card key = { item . id } title = { item . name } intro = { ` ${ item . type || 'Title' } · ${ item . year || 'Year unknown' } ` }>
2026-08-15 09:23:26 +12:00
< div className = "row" >
< Button
size = "sm"
icon = "plus"
disabled = { pins . some (( pin ) => pin . id === item . id ) || ( placement === 'movies' && item . type !== 'Movie' ) || ( placement === 'tv_shows' && item . type !== 'Series' )}
onClick = {() => add ( item )}
>
{ pins . some (( pin ) => pin . id === item . id ) ? 'Pinned' : 'Add to hero' }
</ Button >
< Button size = "sm" icon = "clock" onClick = {() => setScheduleDraft ( newSchedule ( item , placement ))}>
Schedule
</ Button >
</ div >
2026-08-14 09:40:03 +12:00
</ Card >
))}
</ Grid >
)}
</ Card >
</>
)}
2026-08-15 09:23:26 +12:00
{ scheduleDraft ? (
< ScheduleEditor
schedule = { scheduleDraft }
item = {[...( policy ? . items ?? []), ... pins , ...( results ?? [])]. find (( candidate ) => candidate . id === scheduleDraft . itemId )}
timeZone = { policy ? . timeZone || 'server local time' }
isNew = { ! schedules . some (( entry ) => entry . id === scheduleDraft . id )}
onCancel = {() => setScheduleDraft ( null )}
onSave = {( next ) => {
setSchedules (( current ) => current . some (( entry ) => entry . id === next . id )
? current . map (( entry ) => entry . id === next . id ? next : entry )
: [... current , next ]);
setScheduleDraft ( null );
setDirty ( true );
}}
/>
) : null }
2026-08-14 09:40:03 +12:00
</>
);
}
2026-08-15 09:23:26 +12:00
function ScheduleEditor ({
schedule ,
item ,
timeZone ,
isNew ,
onSave ,
onCancel ,
} : {
schedule : HeroSchedule ;
item? : HeroItem ;
timeZone : string ;
isNew : boolean ;
onSave : ( schedule : HeroSchedule ) => void ;
onCancel : () => void ;
}) {
const [ draft , setDraft ] = useState < HeroSchedule >({ ... schedule , weekdays : [...( schedule . weekdays ?? [])] });
const frequency = scheduleFrequency ( draft );
const setFrequency = ( next : ScheduleFrequency ) => {
const now = new Date ();
const later = new Date ( now . getTime () + 2 * 60 * 60 * 1000 );
setDraft (( current ) => next === 'once'
? {
... current ,
frequency : undefined ,
startAt : usableAbsoluteTime ( current . startAt ) ? current.startAt : now.toISOString (),
endAt : usableAbsoluteTime ( current . endAt ) ? current.endAt : later.toISOString (),
}
: {
... current ,
frequency : next ,
startTime : current.startTime || '18:00' ,
endTime : current.endTime || '22:00' ,
weekdays : next === 'weekly' ? ( current . weekdays ? . length ? current . weekdays : [ 1 , 2 , 3 , 4 , 5 ]) : [],
});
};
const selectedPlacements = draft . placements ?? [ 'home' ];
const validPlacement = ( value : HeroPlacement ) => value === 'home' ||
( value === 'movies' && item ? . type === 'Movie' ) ||
( value === 'tv_shows' && item ? . type === 'Series' );
const onceValid = Boolean ( draft . startAt && draft . endAt && new Date ( draft . endAt ) > new Date ( draft . startAt ));
const recurringValid = Boolean ( draft . startTime && draft . endTime && draft . startTime !== draft . endTime &&
( frequency !== 'weekly' || ( draft . weekdays ?? []). length > 0 ));
const valid = frequency === 'once' ? onceValid : recurringValid ;
return (
< div className = "scrim" onPointerDown = {( event ) => event . target === event . currentTarget && onCancel ()}>
< div className = "dialog hero-schedule-dialog" role = "dialog" aria-modal = "true" aria-labelledby = "hero-schedule-title" >
< div className = "hero-schedule-dialog-head" >
< span className = "hero-schedule-kicker" > Hero schedule </ span >
< h2 id = "hero-schedule-title" >{ item ? . name ?? draft . itemId }</ h2 >
< p > Choose exactly when this title can lead the selected sections . Times use { timeZone }.</ p >
</ div >
< div className = "schedule-frequency" role = "group" aria-label = "Schedule frequency" >
{([ 'once' , 'daily' , 'weekly' ] as ScheduleFrequency []). map (( value ) => (
< button key = { value } type = "button" aria-pressed = { frequency === value } onClick = {() => setFrequency ( value )}>
< b >{ value === 'once' ? 'One time' : value === 'daily' ? 'Every day' : 'Weekly' }</ b >
< span >{ value === 'once' ? 'A date range' : value === 'daily' ? 'Same time daily' : 'Choose days' }</ span >
</ button >
))}
</ div >
{ frequency === 'once' ? (
< div className = "fields" >
< Field label = "Starts" >
< input
type = "datetime-local"
value = { draft . startAt ? localDateTime ( new Date ( draft . startAt )) : '' }
onChange = {( event ) => setDraft (( current ) => ({ ... current , startAt : event.target.value ? new Date ( event . target . value ). toISOString () : undefined }))}
/>
</ Field >
< Field label = "Ends" >
< input
type = "datetime-local"
value = { draft . endAt ? localDateTime ( new Date ( draft . endAt )) : '' }
onChange = {( event ) => setDraft (( current ) => ({ ... current , endAt : event.target.value ? new Date ( event . target . value ). toISOString () : undefined }))}
/>
</ Field >
</ div >
) : (
<>
< div className = "fields" >
< Field label = "Starts each time" >
< input type = "time" value = { draft . startTime ?? '' } onChange = {( event ) => setDraft (( current ) => ({ ... current , startTime : event.target.value }))} />
</ Field >
< Field label = "Ends each time" hint = "An earlier end time continues into the following day." >
< input type = "time" value = { draft . endTime ?? '' } onChange = {( event ) => setDraft (( current ) => ({ ... current , endTime : event.target.value }))} />
</ Field >
</ div >
{ frequency === 'weekly' ? (
< div className = "schedule-days" >
< div className = "schedule-days-head" >
< b > Days </ b >
< div >
< button type = "button" onClick = {() => setDraft (( current ) => ({ ... current , weekdays : [ 1 , 2 , 3 , 4 , 5 ] }))}> Weekdays </ button >
< button type = "button" onClick = {() => setDraft (( current ) => ({ ... current , weekdays : [ 6 , 0 ] }))}> Weekend </ button >
< button type = "button" onClick = {() => setDraft (( current ) => ({ ... current , weekdays : WEEKDAYS.map (( day ) => day . value ) }))}> Every day </ button >
</ div >
</ div >
< div className = "schedule-day-grid" >
{ WEEKDAYS . map (( day ) => {
const selected = ( draft . weekdays ?? []). includes ( day . value );
return (
< button
type = "button"
key = { day . value }
aria-pressed = { selected }
title = { day . label }
onClick = {() => setDraft (( current ) => ({
... current ,
weekdays : selected
? ( current . weekdays ?? []). filter (( value ) => value !== day . value )
: [...( current . weekdays ?? []), day . value ],
}))}
>
{ day . short }
</ button >
);
})}
</ div >
</ div >
) : null }
</>
)}
< div className = "schedule-options" >
< div >
< span className = "schedule-option-label" > Show in </ span >
< div className = "schedule-placement-grid" >
{ PLACEMENTS . map (( option ) => (
< Toggle
key = { option . id }
label = { option . label }
checked = { selectedPlacements . includes ( option . id )}
disabled = { ! validPlacement ( option . id )}
onChange = {( enabled ) => setDraft (( current ) => {
const selected = current . placements ?? [ 'home' ];
const next = enabled ? [... selected , option . id ] : selected . filter (( value ) => value !== option . id );
return { ... current , placements : next.length ? [... new Set ( next )] : selected };
})}
/>
))}
</ div >
</ div >
< Field label = "Priority" hint = "Higher rules win when schedules overlap." >
< input
type = "number"
min = { - 1000 }
max = { 1000 }
step = { 10 }
value = { draft . priority }
onChange = {( event ) => setDraft (( current ) => ({ ... current , priority : Number ( event . target . value ) }))}
/>
</ Field >
</ div >
< Toggle
label = "Schedule enabled"
hint = "Pause it without losing its days and times."
checked = { draft . enabled }
onChange = {( enabled ) => setDraft (( current ) => ({ ... current , enabled }))}
/>
< div className = "dialog-actions" >
< Button variant = "quiet" onClick = { onCancel }> Cancel </ Button >
< Button variant = "primary" disabled = { ! valid } onClick = {() => onSave ( draft )}>
{ isNew ? 'Add rule' : 'Save rule' }
</ Button >
</ div >
</ div >
</ div >
);
}