This commit is contained in:
ponzischeme89
2026-08-22 08:26:10 +12:00
parent 3ae8ffad64
commit b131932a4e
34 changed files with 10740 additions and 9190 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -13,9 +13,9 @@
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E"
/>
<script type="module" crossorigin src="/admin/assets/index-CJIn-EsM.js"></script>
<script type="module" crossorigin src="/admin/assets/index-Db7bWHnD.js"></script>
<link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js">
<link rel="stylesheet" crossorigin href="/admin/assets/index-Bx4XLLdG.css">
<link rel="stylesheet" crossorigin href="/admin/assets/index-BS_y3llT.css">
</head>
<body>
<div id="root"></div>
+2
View File
@@ -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 />} />
+19
View File
@@ -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 {
+8
View File
@@ -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',
+128
View File
@@ -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&nbsp;&nbsp; &nbsp;&nbsp;1h 54m&nbsp;&nbsp; &nbsp;&nbsp;M&nbsp;&nbsp; &nbsp;&nbsp;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>
)}
</>
);
}
+151
View File
@@ -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; }
}