Files
memby/admin-ui/src/pages/Hero.tsx
T
2026-08-15 09:23:26 +12:00

546 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<HeroPlacement>('home');
const [placements, setPlacements] = useState<Record<HeroPlacement, HeroPlacementPolicy>>({
home: emptyPlacement(), movies: emptyPlacement(), tv_shows: emptyPlacement(),
});
const [dirty, setDirty] = useState(false);
const [queryText, setQueryText] = useState('');
const [results, setResults] = useState<HeroItem[] | null>(null);
const [schedules, setSchedules] = useState<HeroSchedule[]>([]);
const [scheduleDraft, setScheduleDraft] = useState<HeroSchedule | null>(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<HeroPlacementPolicy>) => {
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 (
<>
<PageHead
title="Featured content"
intro="Manage an independent, backend-resolved hero for Home, Movies and TV Shows."
/>
<Banner message={error} />
{loading ? (
<Loading rows={2} />
) : (
<>
<Grid>
{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 <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>
<Card
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 placements automatic selection.`}
icon="star"
tone="note"
footer={
<>
<Button variant="primary" busy={busy === 'save'} onClick={() => void save()}>
Save hero
</Button>
<Button
onClick={() => {
setCurrent({ pinnedItems: [] });
}}
>
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="hero-pins">
{pins.map((item, index) => (
<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>
))}
</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}
value={current.primeSubtitle}
placeholder="Leave blank for the automatic reason"
onChange={(event) => {
setCurrent({ primeSubtitle: event.target.value });
}}
/>
</Field>
</Card>
<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 Membys 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>
)}
</Card>
<Card
title="Find a title"
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.`}
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'}`}>
<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>
</Card>
))}
</Grid>
)}
</Card>
</>
)}
{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}
</>
);
}
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>
);
}