2026-08-22 08:26:10 +12:00
|
|
|
import { useEffect, useState } from 'react';
|
|
|
|
|
import { api } from '../api/client';
|
|
|
|
|
import type { MetadataHeroSettingsResponse } from '../api/types';
|
2026-08-22 11:32:50 +12:00
|
|
|
import { Banner, Button, Card, Loading, PageHead, Segments, Toggle } from '../components/ui';
|
2026-08-22 08:26:10 +12:00
|
|
|
import { useAction, useQuery } from '../lib/hooks';
|
|
|
|
|
import { useToast } from '../lib/toast';
|
|
|
|
|
|
|
|
|
|
const DEFAULT_ORDER = ['title', 'ratings', 'facts', 'summary'];
|
|
|
|
|
|
|
|
|
|
export function MetadataHeroPage() {
|
|
|
|
|
const query = useQuery<MetadataHeroSettingsResponse>('/admin/api/metadata-hero');
|
|
|
|
|
const { busy, run } = useAction();
|
|
|
|
|
const { wrap } = useToast();
|
|
|
|
|
const [order, setOrder] = useState<string[] | null>(null);
|
2026-08-22 11:32:50 +12:00
|
|
|
const [timeRemainingColour, setTimeRemainingColour] = useState<'green' | 'white' | null>(null);
|
2026-08-22 08:26:10 +12:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (order === null && query.data) setOrder(query.data.settings.contentOrder);
|
2026-08-22 11:32:50 +12:00
|
|
|
if (timeRemainingColour === null && query.data) {
|
|
|
|
|
setTimeRemainingColour(query.data.settings.timeRemainingColour ?? 'green');
|
|
|
|
|
}
|
|
|
|
|
}, [order, query.data, timeRemainingColour]);
|
2026-08-22 08:26:10 +12:00
|
|
|
|
|
|
|
|
const selected = order ?? DEFAULT_ORDER;
|
2026-08-22 11:32:50 +12:00
|
|
|
const selectedTimeRemainingColour = timeRemainingColour ?? 'green';
|
2026-08-22 08:26:10 +12:00
|
|
|
const options = query.data?.options ?? [];
|
|
|
|
|
const displayOptions = [
|
|
|
|
|
...selected,
|
|
|
|
|
...options.map((option) => option.value).filter((value) => !selected.includes(value)),
|
|
|
|
|
];
|
|
|
|
|
const move = (value: string, offset: number) => {
|
|
|
|
|
const from = selected.indexOf(value);
|
|
|
|
|
const to = from + offset;
|
|
|
|
|
if (from < 0 || to < 0 || to >= selected.length) return;
|
|
|
|
|
const next = [...selected];
|
|
|
|
|
const moved = next[from];
|
|
|
|
|
next[from] = next[to]!;
|
|
|
|
|
next[to] = moved!;
|
|
|
|
|
setOrder(next);
|
|
|
|
|
};
|
|
|
|
|
const save = () => run('save', async () => {
|
|
|
|
|
const response = await wrap(
|
2026-08-22 11:32:50 +12:00
|
|
|
() => api.post<MetadataHeroSettingsResponse>('/admin/api/metadata-hero', {
|
|
|
|
|
contentOrder: selected,
|
|
|
|
|
timeRemainingColour: selectedTimeRemainingColour,
|
|
|
|
|
}),
|
2026-08-22 08:26:10 +12:00
|
|
|
'Metadata hero saved for every television.',
|
|
|
|
|
);
|
|
|
|
|
if (response) {
|
|
|
|
|
query.set(response);
|
|
|
|
|
setOrder(response.settings.contentOrder);
|
2026-08-22 11:32:50 +12:00
|
|
|
setTimeRemainingColour(response.settings.timeRemainingColour);
|
2026-08-22 08:26:10 +12:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<PageHead
|
|
|
|
|
title="Metadata hero"
|
|
|
|
|
intro="Choose which content appears beside focused browse cards, and the order every television uses."
|
|
|
|
|
/>
|
|
|
|
|
<Banner message={query.error} />
|
|
|
|
|
{query.loading ? <Loading rows={2} /> : (
|
|
|
|
|
<div className="metadata-hero-layout">
|
|
|
|
|
<Card
|
|
|
|
|
title="Content order"
|
|
|
|
|
intro="Enabled blocks are drawn from top to bottom. Changes apply to the whole household."
|
|
|
|
|
icon="sliders"
|
|
|
|
|
footer={
|
|
|
|
|
<>
|
|
|
|
|
<Button variant="primary" busy={busy === 'save'} onClick={() => void save()}>
|
|
|
|
|
Save metadata hero
|
|
|
|
|
</Button>
|
2026-08-22 11:32:50 +12:00
|
|
|
<Button onClick={() => {
|
|
|
|
|
setOrder(DEFAULT_ORDER);
|
|
|
|
|
setTimeRemainingColour('green');
|
|
|
|
|
}}>Restore defaults</Button>
|
2026-08-22 08:26:10 +12:00
|
|
|
</>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<div className="metadata-hero-order">
|
|
|
|
|
{displayOptions.map((value) => {
|
|
|
|
|
const option = options.find((entry) => entry.value === value);
|
|
|
|
|
if (!option) return null;
|
|
|
|
|
const index = selected.indexOf(value);
|
|
|
|
|
const enabled = index >= 0;
|
|
|
|
|
return (
|
|
|
|
|
<div className="metadata-hero-order-row" data-enabled={enabled || undefined} key={value}>
|
|
|
|
|
<span className="metadata-hero-order-number">{enabled ? index + 1 : '—'}</span>
|
|
|
|
|
<Toggle
|
|
|
|
|
label={option.label}
|
|
|
|
|
hint={option.description}
|
|
|
|
|
checked={enabled}
|
|
|
|
|
disabled={enabled && selected.length === 1}
|
|
|
|
|
onChange={(on) => setOrder(on
|
|
|
|
|
? [...selected, value]
|
|
|
|
|
: selected.filter((entry) => entry !== value))}
|
|
|
|
|
/>
|
|
|
|
|
<div className="metadata-hero-order-actions">
|
|
|
|
|
<Button size="sm" variant="quiet" disabled={!enabled || index === 0}
|
|
|
|
|
title={`Move ${option.label} up`} onClick={() => move(value, -1)}>↑</Button>
|
|
|
|
|
<Button size="sm" variant="quiet" disabled={!enabled || index === selected.length - 1}
|
|
|
|
|
title={`Move ${option.label} down`} onClick={() => move(value, 1)}>↓</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
2026-08-22 11:32:50 +12:00
|
|
|
<div className="metadata-hero-colour">
|
|
|
|
|
<b>Time-remaining text colour</b>
|
|
|
|
|
<p>Choose the caption colour used beside the resume progress bar.</p>
|
|
|
|
|
<Segments
|
|
|
|
|
value={selectedTimeRemainingColour}
|
|
|
|
|
options={[
|
|
|
|
|
{ value: 'green', label: 'Emby green' },
|
|
|
|
|
{ value: 'white', label: 'White' },
|
|
|
|
|
]}
|
|
|
|
|
onChange={setTimeRemainingColour}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-08-22 08:26:10 +12:00
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
<Card
|
|
|
|
|
title="Television preview"
|
|
|
|
|
intro="A representative focused film at launcher scale and in the current order."
|
|
|
|
|
icon="tv"
|
|
|
|
|
>
|
|
|
|
|
<div className="metadata-hero-preview" aria-label="Metadata hero preview">
|
|
|
|
|
<div className="metadata-hero-preview-copy">
|
|
|
|
|
{selected.map((section) => {
|
|
|
|
|
if (section === 'title') return <div className="metadata-preview-title" key={section}>THE SOUTHERN LIGHT</div>;
|
|
|
|
|
if (section === 'ratings') return <div className="metadata-preview-ratings" key={section}><b>IMDb 8.2</b><b>TMDb 81%</b></div>;
|
|
|
|
|
if (section === 'facts') return <div className="metadata-preview-facts" key={section}>
|
|
|
|
|
<span>2026 • 1h 54m • M • Drama, Mystery</span>
|
|
|
|
|
<b>4K</b><b>5.1</b>
|
|
|
|
|
</div>;
|
|
|
|
|
if (section === 'recommendation_reason') return <div className="metadata-preview-reason" key={section}>Because you watched Harbour Lights</div>;
|
2026-08-22 11:32:50 +12:00
|
|
|
if (section === 'time_remaining') return <div className="metadata-preview-progress" data-colour={selectedTimeRemainingColour} key={section}>
|
|
|
|
|
<span><i /></span><b>47 minutes remaining</b>
|
2026-08-22 08:26:10 +12:00
|
|
|
</div>;
|
|
|
|
|
if (section === 'summary') return <p className="metadata-preview-summary" key={section}>A quiet coastal town follows an unexpected signal across the winter sky.</p>;
|
|
|
|
|
return null;
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="metadata-hero-preview-art" aria-hidden="true" />
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|