Files
memby/admin-ui/src/pages/Hero.tsx
T

238 lines
11 KiB
TypeScript
Raw Normal View History

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 placements 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 Membys 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>
</>
)}
</>
);
}