0.3.00
This commit is contained in:
@@ -18,6 +18,7 @@ import { RequestsPage } from './pages/Requests';
|
||||
import { RecommendationsPage } from './pages/Recommendations';
|
||||
import { InspectorPage } from './pages/Inspector';
|
||||
import { HeroPage } from './pages/Hero';
|
||||
import { MetadataHeroPage } from './pages/MetadataHero';
|
||||
import { FeaturesPage } from './pages/Features';
|
||||
import { PlaybackPage } from './pages/Playback';
|
||||
import { SubtitlesPage } from './pages/Subtitles';
|
||||
@@ -81,6 +82,7 @@ export function App() {
|
||||
<Route path="inspector" element={<InspectorPage />} />
|
||||
|
||||
<Route path="hero" element={<HeroPage />} />
|
||||
<Route path="metadata-hero" element={<MetadataHeroPage />} />
|
||||
<Route path="features" element={<FeaturesPage />} />
|
||||
<Route path="playback" element={<PlaybackPage />} />
|
||||
<Route path="subtitles" element={<SubtitlesPage />} />
|
||||
|
||||
@@ -779,6 +779,25 @@ export interface GatewaySettingsResponse {
|
||||
version: string;
|
||||
}
|
||||
|
||||
/* ---------- metadata hero ---------- */
|
||||
|
||||
export interface MetadataHeroOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface MetadataHeroSettings {
|
||||
contentOrder: string[];
|
||||
updatedAt?: string;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface MetadataHeroSettingsResponse {
|
||||
settings: MetadataHeroSettings;
|
||||
options: MetadataHeroOption[];
|
||||
}
|
||||
|
||||
/** IngestJob is one thing Sonarr or Radarr said changed. The key is derived from the file
|
||||
* rather than the delivery, which is what makes a repeated webhook one row. */
|
||||
export interface IngestJob {
|
||||
|
||||
@@ -185,6 +185,14 @@ export const nav: NavGroup[] = [
|
||||
'Choose films or television shows for the launcher spotlight while recent releases fill the remaining places.',
|
||||
icon: 'star',
|
||||
},
|
||||
{
|
||||
id: 'metadata-hero',
|
||||
path: '/admin/metadata-hero',
|
||||
label: 'Metadata hero',
|
||||
title: 'Metadata hero',
|
||||
intro: 'Order the focused-title information shown above browse rows on every television.',
|
||||
icon: 'tv',
|
||||
},
|
||||
{
|
||||
id: 'recommendations',
|
||||
path: '/admin/recommendations',
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { MetadataHeroSettingsResponse } from '../api/types';
|
||||
import { Banner, Button, Card, Loading, PageHead, Toggle } from '../components/ui';
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (order === null && query.data) setOrder(query.data.settings.contentOrder);
|
||||
}, [order, query.data]);
|
||||
|
||||
const selected = order ?? DEFAULT_ORDER;
|
||||
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(
|
||||
() => api.post<MetadataHeroSettingsResponse>('/admin/api/metadata-hero', { contentOrder: selected }),
|
||||
'Metadata hero saved for every television.',
|
||||
);
|
||||
if (response) {
|
||||
query.set(response);
|
||||
setOrder(response.settings.contentOrder);
|
||||
}
|
||||
});
|
||||
|
||||
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>
|
||||
<Button onClick={() => setOrder(DEFAULT_ORDER)}>Restore default order</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</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>;
|
||||
if (section === 'time_remaining') return <div className="metadata-preview-progress" key={section}>
|
||||
<span><i /></span><b>47 MINUTES REMAINING</b>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3660,3 +3660,154 @@ details summary {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ---------- metadata hero ---------- */
|
||||
|
||||
.metadata-hero-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, .82fr) minmax(460px, 1.35fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
.metadata-hero-order {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.metadata-hero-order-row {
|
||||
display: grid;
|
||||
grid-template-columns: 30px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-lift);
|
||||
opacity: .62;
|
||||
}
|
||||
.metadata-hero-order-row[data-enabled] { opacity: 1; }
|
||||
.metadata-hero-order-number {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.metadata-hero-order-actions { display: flex; gap: 5px; }
|
||||
.metadata-hero-order-actions button { min-width: 34px; padding-inline: 8px; }
|
||||
.metadata-hero-preview {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 16 / 7.2;
|
||||
min-height: 280px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #080b0e;
|
||||
}
|
||||
.metadata-hero-preview::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background: linear-gradient(90deg, #080b0e 0 34%, rgba(8,11,14,.9) 48%, rgba(8,11,14,.2) 76%, transparent);
|
||||
}
|
||||
.metadata-hero-preview-art {
|
||||
position: absolute;
|
||||
inset: 0 0 0 36%;
|
||||
z-index: -2;
|
||||
background:
|
||||
radial-gradient(circle at 67% 24%, rgba(211,242,255,.8) 0 2%, transparent 3%),
|
||||
radial-gradient(ellipse at 76% 78%, rgba(71,107,92,.8), transparent 45%),
|
||||
linear-gradient(145deg, #17303d 0%, #456273 42%, #101a1f 100%);
|
||||
}
|
||||
.metadata-hero-preview-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
width: 54%;
|
||||
min-height: 100%;
|
||||
padding: 26px 0 24px 28px;
|
||||
}
|
||||
.metadata-preview-title {
|
||||
max-width: 330px;
|
||||
color: #fff;
|
||||
font-family: Georgia, serif;
|
||||
font-size: clamp(24px, 3vw, 42px);
|
||||
line-height: .95;
|
||||
letter-spacing: .03em;
|
||||
}
|
||||
.metadata-preview-ratings { display: flex; gap: 8px; }
|
||||
.metadata-preview-ratings b {
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255,255,255,.11);
|
||||
color: #e8edf0;
|
||||
font-size: 11px;
|
||||
}
|
||||
.metadata-preview-facts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
color: #bdc7ce;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.metadata-preview-facts span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.metadata-preview-facts b {
|
||||
flex: none;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid rgba(255,255,255,.28);
|
||||
border-radius: 3px;
|
||||
background: rgba(0,0,0,.22);
|
||||
color: #e8edf0;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
.metadata-preview-reason {
|
||||
overflow: hidden;
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.metadata-preview-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #bdc7ce;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.metadata-preview-progress > span {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
width: 112px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255,255,255,.22);
|
||||
}
|
||||
.metadata-preview-progress i {
|
||||
display: block;
|
||||
width: 59%;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
}
|
||||
.metadata-preview-progress b { font-size: inherit; line-height: inherit; }
|
||||
.metadata-preview-summary { margin: 0; color: #cbd2d7; font-size: 12px; line-height: 1.45; }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.metadata-hero-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user