201 lines
7.8 KiB
TypeScript
201 lines
7.8 KiB
TypeScript
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';
|
|||
|
|
import type { HeroItem, HeroSchedule } from '../api/types';
|
|||
|
|
|
|||
|
|
const MAX_PINS = 4;
|
|||
|
|
|
|||
|
|
export function HeroPage() {
|
|||
|
|
const { status, error, loading, reload } = useGateway();
|
|||
|
|
const { wrap, show } = useToast();
|
|||
|
|
const { busy, run } = useAction();
|
|||
|
|
const [pins, setPins] = useState<HeroItem[]>([]);
|
|||
|
|
const [subtitle, setSubtitle] = useState('');
|
|||
|
|
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;
|
|||
|
|
setPins((policy.pinnedItems ?? []).slice(0, MAX_PINS));
|
|||
|
|
setSubtitle(policy.primeSubtitle ?? '');
|
|||
|
|
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 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;
|
|||
|
|
}
|
|||
|
|
setPins((current) => [...current, item]);
|
|||
|
|
setDirty(true);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const save = () =>
|
|||
|
|
run('save', async () => {
|
|||
|
|
await wrap(
|
|||
|
|
() =>
|
|||
|
|
api.post('/admin/api/hero-policy', {
|
|||
|
|
pinnedItemIds: pins.map((item) => item.id),
|
|||
|
|
primeSubtitle: subtitle.trim(),
|
|||
|
|
schedules,
|
|||
|
|
}),
|
|||
|
|
'Hero saved.',
|
|||
|
|
);
|
|||
|
|
setDirty(false);
|
|||
|
|
await reload();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<>
|
|||
|
|
<PageHead
|
|||
|
|
title="Home hero"
|
|||
|
|
intro="Choose films or television shows for the launcher spotlight while recent releases fill the remaining places."
|
|||
|
|
/>
|
|||
|
|
<Banner message={error} />
|
|||
|
|
|
|||
|
|
{loading ? (
|
|||
|
|
<Loading rows={2} />
|
|||
|
|
) : (
|
|||
|
|
<>
|
|||
|
|
<Card
|
|||
|
|
title="Pinned titles"
|
|||
|
|
intro="Pinned films and series lead the four-card launcher grid in this order. Empty places are filled by Memby's existing mix of recent digital releases, premieres and highly rated library titles. Pinning changes placement only; labels and reasons remain natural."
|
|||
|
|
icon="star"
|
|||
|
|
tone="note"
|
|||
|
|
footer={
|
|||
|
|
<>
|
|||
|
|
<Button variant="primary" busy={busy === 'save'} onClick={() => void save()}>
|
|||
|
|
Save hero
|
|||
|
|
</Button>
|
|||
|
|
<Button
|
|||
|
|
onClick={() => {
|
|||
|
|
setPins([]);
|
|||
|
|
setDirty(true);
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
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={() => {
|
|||
|
|
setPins((current) => current.filter((pin) => pin.id !== item.id));
|
|||
|
|
setDirty(true);
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
{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}
|
|||
|
|
value={subtitle}
|
|||
|
|
placeholder="Leave blank for the automatic reason"
|
|||
|
|
onChange={(event) => {
|
|||
|
|
setSubtitle(event.target.value);
|
|||
|
|
setDirty(true);
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
</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) => {
|
|||
|
|
const item = [...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}</span><Button size="sm" variant="quiet" onClick={() => { setSchedules((current) => current.filter((entry) => entry.id !== schedule.id)); setDirty(true); }}>Remove</Button></div>;
|
|||
|
|
})}</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);
|
|||
|
|
setSchedules((current) => [...current, { id: crypto.randomUUID(), itemId: first.id, startAt: start.toISOString(), endAt: end.toISOString(), priority: 0, enabled: true }]); setDirty(true);
|
|||
|
|
}}>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"
|
|||
|
|
intro="Search the imported Emby catalogue. Up to four films or series can be pinned."
|
|||
|
|
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"
|
|||
|
|
disabled={pins.some((pin) => pin.id === item.id)}
|
|||
|
|
onClick={() => add(item)}
|
|||
|
|
>
|
|||
|
|
{pins.some((pin) => pin.id === item.id) ? 'Pinned' : 'Add to hero'}
|
|||
|
|
</Button>
|
|||
|
|
</Card>
|
|||
|
|
))}
|
|||
|
|
</Grid>
|
|||
|
|
)}
|
|||
|
|
</Card>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</>
|
|||
|
|
);
|
|||
|
|
}
|