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, Tag, Toggle } from '../components/ui'; import type { HeroItem, HeroPlacement, HeroPlacementPolicy, HeroSchedule } from '../api/types'; const MAX_PINS = 4; 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: '' }); 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'], }; } export function HeroPage() { const { status, error, loading, reload } = useGateway(); const { wrap, show } = useToast(); const { busy, run } = useAction(); const [placement, setPlacement] = useState('home'); const [placements, setPlacements] = useState>({ home: emptyPlacement(), movies: emptyPlacement(), tv_shows: emptyPlacement(), }); const [dirty, setDirty] = useState(false); const [queryText, setQueryText] = useState(''); const [results, setResults] = useState(null); const [schedules, setSchedules] = useState([]); const [scheduleDraft, setScheduleDraft] = useState(null); const policy = status?.heroPolicy; useEffect(() => { // The poll must not take an unsaved arrangement away, which is what `dirty` guards. if (dirty || !policy) return; setPlacements({ home: policy.placements?.home ?? { pinnedItems: policy.pinnedItems ?? [], primeSubtitle: policy.primeSubtitle ?? '' }, movies: policy.placements?.movies ?? emptyPlacement(), tv_shows: policy.placements?.tv_shows ?? emptyPlacement(), }); 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 ?? []); }); const current = placements[placement]; const pins = current.pinnedItems ?? []; const setCurrent = (next: Partial) => { setPlacements((value) => ({ ...value, [placement]: { ...value[placement], ...next } })); setDirty(true); }; 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; } setCurrent({ pinnedItems: [...pins, item] }); }; const save = () => run('save', async () => { await wrap( () => api.post('/admin/api/hero-policy', { placements: Object.fromEntries(Object.entries(placements).map(([name, value]) => [name, { pinnedItemIds: (value.pinnedItems ?? []).map((item) => item.id), primeSubtitle: value.primeSubtitle.trim(), }])), schedules, }), 'Hero saved.', ); setDirty(false); await reload(); }); return ( <> {loading ? ( ) : ( <> {PLACEMENTS.map((option) => { const configured = placements[option.id]; const activeSchedule = schedules .filter((entry) => entry.enabled && (entry.placements ?? ['home']).includes(option.id)) .sort((left, right) => right.priority - left.priority)[0]; const source = (configured.pinnedItems ?? []).length ? 'Manual' : activeSchedule ? 'Schedule ready' : '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 ; })}
{PLACEMENTS.map((option) => ( ))}
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.`} icon="star" tone="note" footer={ <> {dirty ? Unsaved changes. : null} } > {pins.length === 0 ? ( No titles are pinned. The hero is entirely release-aware and automatic. ) : (
{pins.map((item, index) => (
{index + 1} {item.name}{item.type}{item.year ? ` · ${item.year}` : ''}
))}
)} { setCurrent({ primeSubtitle: event.target.value }); }} />
{policy?.timeZone || 'server local time'}} footer={ <> Daily and weekly rules repeat until you switch them off. } > {schedules.length === 0 ? ( No scheduled heroes yet. Use Schedule beside a pinned or searched title. ) : (
{[...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 (
{schedule.frequency === 'weekly' ? 'Weekly' : schedule.frequency === 'daily' ? 'Daily' : 'Once'} {schedule.frequency ? schedule.startTime : schedule.startAt ? new Date(schedule.startAt).toLocaleDateString() : '—'}

{item?.name ?? schedule.itemId}

{schedule.enabled ? 'enabled' : 'paused'}

{scheduleSummary(schedule)}

{(schedule.placements ?? ['home']).map((value) => ( {PLACEMENTS.find((entry) => entry.id === value)?.label} ))} {schedule.priority !== 0 ? Priority {schedule.priority} : null}
); })}
)}
entry.id === placement)?.label} placement; switch tabs to show it in more than one section.`} icon="search" tone="info" >
setQueryText(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') void search(); }} />
{results === null ? null : results.length === 0 ? ( No playable films or series matched that search. ) : ( {results.map((item) => (
))}
)}
)} {scheduleDraft ? ( 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} ); } 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({ ...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 (
event.target === event.currentTarget && onCancel()}>
Hero schedule

{item?.name ?? draft.itemId}

Choose exactly when this title can lead the selected sections. Times use {timeZone}.

{(['once', 'daily', 'weekly'] as ScheduleFrequency[]).map((value) => ( ))}
{frequency === 'once' ? (
setDraft((current) => ({ ...current, startAt: event.target.value ? new Date(event.target.value).toISOString() : undefined }))} /> setDraft((current) => ({ ...current, endAt: event.target.value ? new Date(event.target.value).toISOString() : undefined }))} />
) : ( <>
setDraft((current) => ({ ...current, startTime: event.target.value }))} /> setDraft((current) => ({ ...current, endTime: event.target.value }))} />
{frequency === 'weekly' ? (
Days
{WEEKDAYS.map((day) => { const selected = (draft.weekdays ?? []).includes(day.value); return ( ); })}
) : null} )}
Show in
{PLACEMENTS.map((option) => ( 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 }; })} /> ))}
setDraft((current) => ({ ...current, priority: Number(event.target.value) }))} />
setDraft((current) => ({ ...current, enabled }))} />
); }