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' ;
import { Banner , Button , Card , Empty , Field , Grid , Loading , PageHead } 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-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 [] >([]);
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
. filter (( entry ) => entry . enabled && ( entry . placements ?? [ 'home' ]). includes ( option . id ) && new Date ( entry . startAt ) <= new Date () && new Date ( entry . endAt ) > new Date ())
. sort (( left , right ) => right . priority - left . priority )[ 0 ];
const source = ( configured . pinnedItems ?? []). length ? 'Manual' : activeSchedule ? 'Scheduled' : 'Automatic' ;
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 >
) : (
< div className = "chips" >
{ pins . map (( item , index ) => (
< Button
key = { item . id }
variant = "quiet"
size = "sm"
icon = "close"
onClick = {() => {
2026-08-14 13:32:14 +12:00
setCurrent ({ pinnedItems : pins.filter (( pin ) => pin . id !== item . id ) });
2026-08-14 09:40:03 +12:00
}}
>
{ index + 1 }. { item . name }
{ item . year ? ` ( ${ item . year } )` : '' }
</ Button >
))}
</ 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 >
< Card title = "Scheduled heroes" intro = "Schedules are resolved by the gateway: manual pins still win, then the highest-priority eligible schedule, then Memby’ s automatic hero." icon = "clock" tone = "info" >
{ schedules . length === 0 ? < Empty > No scheduled heroes yet .</ Empty > : (
< div className = "stack" >{ schedules . map (( schedule ) => {
2026-08-14 13:32:14 +12:00
const item = [...( policy ? . items ?? []), ... pins , ...( results ?? [])]. find (( candidate ) => candidate . id === schedule . itemId );
return < div className = "row" key = { schedule . id }>< b >{ item ? . name ?? schedule . itemId }</ b >< span className = "muted" >{ new Date ( schedule . startAt ). toLocaleString ()} → { new Date ( schedule . endAt ). toLocaleString ()} · priority { schedule . priority } · {( schedule . placements ?? [ 'home' ]). map (( value ) => PLACEMENTS . find (( entry ) => entry . id === value ) ? . label ). join ( ', ' )}</ span >< div className = "chips" >{ PLACEMENTS . map (( option ) => < Button key = { option . id } size = "sm" variant = {( schedule . placements ?? [ 'home' ]). includes ( option . id ) ? 'primary' : 'quiet' } onClick = {() => { setSchedules (( all ) => all . map (( entry ) => { if ( entry . id !== schedule . id ) return entry ; const selected = entry . placements ?? [ 'home' ]; const next = selected . includes ( option . id ) ? selected . filter (( value ) => value !== option . id ) : [... selected , option . id ]; return { ... entry , placements : next.length ? next : [ option . id ] }; })); setDirty ( true ); }}>{ option . label }</ Button >)}</ div >< Button size = "sm" variant = "quiet" onClick = {() => { setSchedules (( current ) => current . filter (( entry ) => entry . id !== schedule . id )); setDirty ( true ); }}> Remove </ Button ></ div >;
2026-08-14 09:40:03 +12:00
})}</ div >
)}
{ pins . length > 0 ? < Button size = "sm" icon = "plus" onClick = {() => {
const first = pins [ 0 ]; if ( ! first ) return ;
const start = new Date (); const end = new Date ( start . getTime () + 2 * 60 * 60 * 1000 );
2026-08-14 13:32:14 +12:00
setSchedules (( current ) => [... current , { id : crypto.randomUUID (), itemId : first.id , startAt : start.toISOString (), endAt : end.toISOString (), priority : 0 , enabled : true , placements : [ placement ] }]); setDirty ( true );
2026-08-14 09:40:03 +12:00
}}> Schedule first pinned title for two hours </ Button > : < p className = "hint" > Pin or search for a title first , then add it to a schedule .</ p >}
</ 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' } ` }>
< Button
size = "sm"
icon = "plus"
2026-08-14 13:32:14 +12:00
disabled = { pins . some (( pin ) => pin . id === item . id ) || ( placement === 'movies' && item . type !== 'Movie' ) || ( placement === 'tv_shows' && item . type !== 'Series' )}
2026-08-14 09:40:03 +12:00
onClick = {() => add ( item )}
>
{ pins . some (( pin ) => pin . id === item . id ) ? 'Pinned' : 'Add to hero' }
</ Button >
</ Card >
))}
</ Grid >
)}
</ Card >
</>
)}
</>
);
}