0.3.22 - PINs

This commit is contained in:
ponzischeme89
2026-08-25 11:39:55 +12:00
parent 396d35e2f5
commit 0fcc02f57e
2697 changed files with 5360 additions and 50 deletions
+8 -8
View File
@@ -69,14 +69,14 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
...(options.headers ?? {}),
},
});
} catch (error) {
// Fetch reports a refused connection, DNS failure, or broken proxy as TypeError.
// Keep that implementation detail out of the UI and begin the one global recovery loop.
if (error instanceof TypeError) {
reportTemporaryAvailabilityFailure();
throw new ApiError('The server is temporarily unavailable.', 0, true);
}
throw error;
} catch {
// Fetch normally reports a refused connection, DNS failure, or broken proxy as a
// TypeError, but browser implementations and service workers are also allowed to
// reject with other Error subtypes. This catch is deliberately transport-wide: the
// caller cannot usefully recover from any fetch rejection, and must not expose the
// browser's raw network or proxy error while the singleton recovery loop runs.
reportTemporaryAvailabilityFailure();
throw new ApiError('The server is temporarily unavailable.', 0, true);
}
if (response.status === 401) {
throw new ApiError(
+33 -1
View File
@@ -212,13 +212,14 @@ export interface FeaturePolicy {
canRollback: boolean;
features: Feature[] | null;
configuration: ConfigurationValue[] | null;
rowTypes: RowTypeDefinition[] | null;
}
export interface ConfigurationValue {
key: string;
name: string;
description: string;
type: 'boolean' | 'integer' | 'enum' | 'json';
type: 'boolean' | 'integer' | 'enum' | 'json' | 'string';
scopes: string[];
default: unknown;
options?: string[];
@@ -228,6 +229,37 @@ export interface ConfigurationValue {
source: string;
}
/** RowTypeDefinition is the vocabulary behind the visual row editor's "row type" picker —
* what the gateway is willing to compose a Home/Movies/TV row from. Component and data
* source are fixed per type (custom excepted), which is what lets the editor generate them
* rather than asking an operator to know what a "mediaRow" or "emby.resume" is. */
export interface RowTypeDefinition {
type: string;
label: string;
description: string;
component: string;
dataSource: string;
pages: string[];
requiresMatchingDestination: boolean;
custom: boolean;
}
/** RemoteSectionDefinition mirrors the gateway's config.RemoteSectionDefinition. It is the
* shape stored in the "*.sectionDefinitions" configuration values, edited visually by the
* row editor and, when needed, directly as raw JSON. */
export interface RemoteSectionDefinition {
id: string;
type: string;
title: string;
enabled: boolean;
position: number;
dataSource: string;
component: string;
maxItems?: number;
destination?: string;
settings?: Record<string, unknown>;
}
export interface AdminStatus {
serverVersion: string;
currentUser?: string;
+391
View File
@@ -0,0 +1,391 @@
import { useMemo, useState } from 'react';
import type { RemoteSectionDefinition, RowTypeDefinition } from '../api/types';
import { Button, Card, Chip, Confirm, Empty, Field, Note, Tag, Toggle } from './ui';
/* The visual row editor: what an operator sees instead of the JSON section-definition
* array. It is a thin presentation over exactly the same document the gateway already
* stores — every action here ends by calling onSave with a complete, reordered array,
* through the same revisioned "*.sectionDefinitions" configuration value the raw JSON
* view edits directly. Neither view is more authoritative than the other; they are two
* ways of looking at one document, and the gateway validates whichever arrives.
*
* Positions are never exposed. Every mutation here renumbers the whole list to clean
* multiples of ten in its new order, so "duplicate position" is not a state this editor
* can produce and an operator never sees the number at all — only Up/Down and drag order. */
const PAGE_LABEL: Record<string, string> = { home: 'Home', movies: 'Movies', tv: 'TV Shows' };
const DEFAULT_DESTINATION: Record<string, string> = {
continueWatching: 'home',
favorites: 'home',
forYou: 'for-you',
latest: 'home',
};
function renumber(rows: RemoteSectionDefinition[]): RemoteSectionDefinition[] {
return rows.map((row, index) => ({ ...row, position: (index + 1) * 10 }));
}
function slugify(title: string): string {
const base = title
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return base || 'row';
}
function uniqueId(title: string, taken: Set<string>): string {
const base = slugify(title);
if (!taken.has(base)) return base;
let suffix = 2;
while (taken.has(`${base}-${suffix}`)) suffix += 1;
return `${base}-${suffix}`;
}
function rowTypeFor(row: RemoteSectionDefinition, rowTypes: RowTypeDefinition[]): RowTypeDefinition | undefined {
return rowTypes.find((candidate) => candidate.type === row.type);
}
function describeRow(row: RemoteSectionDefinition, rowTypes: RowTypeDefinition[]): string {
const rowType = rowTypeFor(row, rowTypes);
if (rowType && !rowType.custom) return rowType.label;
return row.type ? `Custom · ${row.type}` : 'Custom';
}
export function RowsEditor({
page,
rows,
rowTypes,
busy,
onSave,
}: {
page: 'home' | 'movies' | 'tv';
rows: RemoteSectionDefinition[];
rowTypes: RowTypeDefinition[];
busy: boolean;
onSave: (next: RemoteSectionDefinition[]) => Promise<void>;
}) {
const [editing, setEditing] = useState<{ row: RemoteSectionDefinition; isNew: boolean } | null>(null);
const [deleting, setDeleting] = useState<RemoteSectionDefinition | null>(null);
const [showRaw, setShowRaw] = useState(false);
const [rawDraft, setRawDraft] = useState('');
const [rawError, setRawError] = useState('');
const [busyRow, setBusyRow] = useState<string | null>(null);
const sorted = useMemo(() => [...rows].sort((a, b) => a.position - b.position), [rows]);
const availableTypes = useMemo(() => rowTypes.filter((type) => type.pages.includes(page)), [rowTypes, page]);
const save = async (next: RemoteSectionDefinition[], key: string) => {
setBusyRow(key);
try {
await onSave(renumber(next));
} finally {
setBusyRow(null);
}
};
const move = (row: RemoteSectionDefinition, direction: -1 | 1) => {
const index = sorted.findIndex((candidate) => candidate.id === row.id);
const target = index + direction;
if (index < 0 || target < 0 || target >= sorted.length) return;
const next = [...sorted];
const swapped = next[target];
next[target] = next[index]!;
next[index] = swapped!;
void save(next, `move:${row.id}`);
};
const toggle = (row: RemoteSectionDefinition) => {
void save(sorted.map((candidate) => (candidate.id === row.id ? { ...candidate, enabled: !candidate.enabled } : candidate)), `toggle:${row.id}`);
};
const remove = () => {
if (!deleting) return;
const target = deleting;
void save(sorted.filter((candidate) => candidate.id !== target.id), `delete:${target.id}`).then(() => setDeleting(null));
};
const openRaw = () => {
setRawDraft(JSON.stringify(sorted, null, 2));
setRawError('');
setShowRaw(true);
};
const applyRaw = async () => {
try {
const parsed = JSON.parse(rawDraft);
if (!Array.isArray(parsed)) throw new Error('Must be a JSON array of rows.');
setRawError('');
await save(parsed as RemoteSectionDefinition[], 'raw');
setShowRaw(false);
} catch (error) {
setRawError(error instanceof Error ? error.message : 'Invalid JSON.');
}
};
return (
<Card
title={`${PAGE_LABEL[page]} rows`}
intro="What is on this page and in what order, described the way a person watching would describe it — not the JSON that composes it."
icon="list"
actions={
<div className="row tight">
<Button size="sm" variant="quiet" onClick={openRaw}>
Advanced · raw JSON
</Button>
<Button
size="sm"
variant="primary"
icon="plus"
onClick={() => setEditing({
isNew: true,
row: {
id: '', type: availableTypes[0]?.type ?? 'custom', title: '', enabled: true,
position: 0, dataSource: '', component: '', maxItems: 20, destination: '',
},
})}
>
Add row
</Button>
</div>
}
>
{sorted.length === 0 ? (
<Empty>No rows are configured for this page. Add one to get started.</Empty>
) : (
<div className="rows-editor-list">
{sorted.map((row, index) => (
<div className="rows-editor-item" key={row.id} data-enabled={row.enabled}>
<div className="rows-editor-item-main">
<div className="rows-editor-item-title">
<b>{row.title || row.id}</b>
{!row.enabled ? <Tag>off</Tag> : null}
</div>
<div className="chips">
<Chip>{describeRow(row, rowTypes)}</Chip>
{row.maxItems ? <Chip>{row.maxItems} items</Chip> : null}
{row.destination ? <Chip tone="note">{row.destination}</Chip> : null}
</div>
</div>
<div className="rows-editor-item-actions">
<Button
size="sm"
variant="quiet"
title="Move up"
disabled={index === 0}
busy={busyRow === `move:${row.id}`}
onClick={() => move(row, -1)}
>
</Button>
<Button
size="sm"
variant="quiet"
title="Move down"
disabled={index === sorted.length - 1}
busy={busyRow === `move:${row.id}`}
onClick={() => move(row, 1)}
>
</Button>
<Button
size="sm"
variant="quiet"
busy={busyRow === `toggle:${row.id}`}
onClick={() => toggle(row)}
>
{row.enabled ? 'Disable' : 'Enable'}
</Button>
<Button size="sm" variant="quiet" onClick={() => setEditing({ row, isNew: false })}>
Edit
</Button>
<Button size="sm" variant="quiet" icon="trash" title="Delete row" onClick={() => setDeleting(row)} />
</div>
</div>
))}
</div>
)}
{editing ? (
<RowEditorDialog
page={page}
row={editing.row}
isNew={editing.isNew}
rowTypes={availableTypes}
existingIds={new Set(sorted.map((candidate) => candidate.id).filter((id) => id !== editing.row.id))}
busy={busy}
onCancel={() => setEditing(null)}
onSave={(next) => {
const isNew = editing.isNew;
void save(
isNew ? [...sorted, next] : sorted.map((candidate) => (candidate.id === editing.row.id ? next : candidate)),
isNew ? 'add' : `edit:${editing.row.id}`,
).then(() => setEditing(null));
}}
/>
) : null}
{deleting ? (
<Confirm
title={`Delete "${deleting.title || deleting.id}"?`}
body="This row is removed from the page immediately on every television. Nothing else is affected."
confirmLabel="Delete row"
destructive
busy={busyRow === `delete:${deleting.id}`}
onConfirm={remove}
onCancel={() => setDeleting(null)}
/>
) : null}
{showRaw ? (
<div className="scrim" onPointerDown={(event) => event.target === event.currentTarget && setShowRaw(false)}>
<div className="dialog" role="dialog" aria-modal="true">
<h2>Raw section definitions</h2>
<p>
For development and troubleshooting. The visual editor above covers ordinary
administration; this is the exact document the gateway stores and validates.
</p>
<textarea
rows={16}
className="mono"
value={rawDraft}
onChange={(event) => setRawDraft(event.target.value)}
/>
{rawError ? <Note tone="warn">{rawError}</Note> : null}
<div className="dialog-actions">
<Button variant="quiet" onClick={() => setShowRaw(false)}>Cancel</Button>
<Button variant="primary" busy={busyRow === 'raw'} onClick={() => void applyRaw()}>Apply</Button>
</div>
</div>
</div>
) : null}
</Card>
);
}
function RowEditorDialog({
page,
row,
isNew,
rowTypes,
existingIds,
busy,
onSave,
onCancel,
}: {
page: 'home' | 'movies' | 'tv';
row: RemoteSectionDefinition;
isNew: boolean;
rowTypes: RowTypeDefinition[];
existingIds: Set<string>;
busy: boolean;
onSave: (row: RemoteSectionDefinition) => void;
onCancel: () => void;
}) {
const [title, setTitle] = useState(row.title);
const [type, setType] = useState(row.type || rowTypes[0]?.type || 'custom');
const [maxItems, setMaxItems] = useState(row.maxItems && row.maxItems > 0 ? row.maxItems : 20);
const [enabled, setEnabled] = useState(row.enabled);
const [customDataSource, setCustomDataSource] = useState(row.dataSource);
const [customComponent, setCustomComponent] = useState(row.component || 'mediaRow');
const [customDestination, setCustomDestination] = useState(row.destination ?? '');
// A row saved by an older console, or through the raw JSON view, can carry a type this
// page's catalogue does not offer (moved off this page, or never catalogued at all). It
// still needs an option in the picker, or the select silently shows something else while
// `type` state disagrees — which would rewrite the row to a type nobody chose on save.
const selectableTypes = rowTypes.some((candidate) => candidate.type === row.type) || !row.type
? rowTypes
: [...rowTypes, { type: row.type, label: `Current: ${row.type}`, description: '', component: row.component, dataSource: row.dataSource, pages: [page], requiresMatchingDestination: false, custom: true }];
const rowType = rowTypeFor({ ...row, type }, selectableTypes) ?? selectableTypes.find((candidate) => candidate.type === 'custom');
const isCustom = !rowType || rowType.custom;
const trimmedTitle = title.trim();
const validMaxItems = Number.isFinite(maxItems) && maxItems >= 1 && maxItems <= 100;
const valid = trimmedTitle.length > 0 && validMaxItems &&
(!isCustom || (customDataSource.trim().length > 0 && customComponent.trim().length > 0));
const submit = () => {
if (!valid) return;
const resolvedType = rowType?.type ?? 'custom';
const destination = isCustom
? customDestination.trim()
: rowType?.requiresMatchingDestination
? page
: (row.destination || DEFAULT_DESTINATION[resolvedType] || page);
onSave({
id: isNew ? uniqueId(trimmedTitle, existingIds) : row.id,
type: resolvedType,
title: trimmedTitle,
enabled,
position: row.position,
dataSource: isCustom ? customDataSource.trim() : rowType!.dataSource,
component: isCustom ? customComponent.trim() : rowType!.component,
maxItems,
destination,
});
};
return (
<div className="scrim" onPointerDown={(event) => event.target === event.currentTarget && onCancel()}>
<div className="dialog" role="dialog" aria-modal="true" aria-labelledby="row-editor-title">
<h2 id="row-editor-title">{isNew ? 'Add row' : 'Edit row'}</h2>
<p>{PAGE_LABEL[page]} · what this row shows and how it behaves.</p>
<div className="fields">
<Field label="Row name" grow>
<input type="text" value={title} onChange={(event) => setTitle(event.target.value)} placeholder="e.g. Genres" autoFocus />
</Field>
</div>
<div className="fields">
<Field label="Row type" grow>
<select value={type} onChange={(event) => setType(event.target.value)}>
{selectableTypes.map((option) => (
<option key={option.type} value={option.type}>{option.label}</option>
))}
</select>
</Field>
<Field label="Maximum items">
<input
type="number"
min={1}
max={100}
value={maxItems}
onChange={(event) => setMaxItems(Number(event.target.value))}
/>
</Field>
</div>
{rowType?.description ? <p className="hint">{rowType.description}</p> : null}
{rowType?.requiresMatchingDestination ? (
<Note>Browses the {PAGE_LABEL[page]} catalogue this row can only ever point at the page it lives on.</Note>
) : null}
{isCustom ? (
<div className="fields">
<Field label="Data source" hint="e.g. emby.latest, gateway.recommendations" grow>
<input type="text" value={customDataSource} onChange={(event) => setCustomDataSource(event.target.value)} />
</Field>
<Field label="Component" hint="e.g. mediaRow, mediaGrid, genreBrowser" grow>
<input type="text" value={customComponent} onChange={(event) => setCustomComponent(event.target.value)} />
</Field>
<Field label="Destination" hint="Optional" grow>
<input type="text" value={customDestination} onChange={(event) => setCustomDestination(event.target.value)} />
</Field>
</div>
) : null}
<Toggle label="Row enabled" checked={enabled} onChange={setEnabled} />
<div className="dialog-actions">
<Button variant="quiet" onClick={onCancel}>Cancel</Button>
<Button variant="primary" disabled={!valid} busy={busy} onClick={submit}>
{isNew ? 'Add row' : 'Save row'}
</Button>
</div>
</div>
</div>
);
}
+2 -2
View File
@@ -8,7 +8,7 @@ import {
useState,
type ReactNode,
} from 'react';
import { api } from '../api/client';
import { api, isTemporaryAvailabilityError } from '../api/client';
import type { AdminStatus } from '../api/types';
import { subscribeToRecovery } from './availability';
@@ -60,7 +60,7 @@ export function GatewayProvider({ children }: { children: ReactNode }) {
} catch (err) {
if (mine !== generation.current) return;
setOnline(false);
setError(err instanceof Error ? err.message : String(err));
setError(isTemporaryAvailabilityError(err) ? '' : err instanceof Error ? err.message : String(err));
} finally {
if (mine === generation.current) {
setCheckedAt(new Date().toISOString());
+4 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api/client';
import { api, isTemporaryAvailabilityError } from '../api/client';
import { subscribeToRecovery } from './availability';
/* The two hooks every page is built from.
@@ -57,7 +57,9 @@ export function useQuery<T>(path: string, options: QueryOptions = {}): Loadable<
loaded.current = true;
} catch (err) {
if (mine !== generation.current) return;
setError(err instanceof Error ? err.message : String(err));
// Availability is represented by the global overlay. Keeping the same failure in a
// page banner makes a restart look like dozens of independent broken pages.
setError(isTemporaryAvailabilityError(err) ? '' : err instanceof Error ? err.message : String(err));
} finally {
if (mine === generation.current) {
setLoading(false);
+2 -2
View File
@@ -8,7 +8,7 @@ import {
useState,
type ReactNode,
} from 'react';
import { api } from '../api/client';
import { api, isTemporaryAvailabilityError } from '../api/client';
import type { Tone } from './format';
import type { IconName } from '../components/Icon';
import { subscribeToRecovery } from './availability';
@@ -88,7 +88,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
latestId.current = Math.max(latestId.current, feed.events[0]?.id ?? 0);
setError('');
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setError(isTemporaryAvailabilityError(err) ? '' : err instanceof Error ? err.message : String(err));
}
}, []);
+78 -1
View File
@@ -74,6 +74,7 @@ interface Viewer {
colour?: string;
kind: string;
hasPin?: boolean;
pin?: string;
createdAt?: string;
}
@@ -176,6 +177,7 @@ export function AccountPage() {
/* Renaming or adding a viewer. One piece of state for both, because the dialog is the
same question either way and a null id is what says there is nobody to rename yet. */
const [namingViewer, setNamingViewer] = useState<{ id: string | null; name: string } | null>(null);
const [pinningViewer, setPinningViewer] = useState<Viewer | null>(null);
/* Viewers are their own request rather than a field on the accounts payload: that
response is the whole household and this is a list per person, so folding it in would
@@ -400,7 +402,9 @@ export function AccountPage() {
{viewer.createdAt && viewer.kind !== 'main' ? ` · added ${when(viewer.createdAt)}` : ''}
</p>
</div>
<div className="list-actions">
<div className="list-actions">
<Tag tone={viewer.hasPin ? 'ok' : 'warn'}>{viewer.hasPin ? `PIN ${viewer.pin ?? 'set'}` : 'No PIN'}</Tag>
<Button size="sm" onClick={() => setPinningViewer(viewer)}>{viewer.hasPin ? 'Change PIN' : 'Set PIN'}</Button>
{viewer.kind === 'main' ? (
<Tag tone="info">synced with Emby</Tag>
) : (
@@ -818,6 +822,39 @@ export function AccountPage() {
/>
) : null}
{pinningViewer ? (
<PinDialog
viewer={pinningViewer}
busy={busy === 'viewer-pin'}
onCancel={() => setPinningViewer(null)}
onConfirm={(pin) =>
void act(
'viewer-pin',
() => api.put(`${base}/viewers/${encodeURIComponent(pinningViewer.id)}`, { pin }),
'PIN saved.',
() => {
setPinningViewer(null);
void viewersQuery.reload();
},
)
}
onClear={
pinningViewer.hasPin
? () =>
void act(
'viewer-pin',
() => api.put(`${base}/viewers/${encodeURIComponent(pinningViewer.id)}`, { clearPin: true }),
'PIN cleared.',
() => {
setPinningViewer(null);
void viewersQuery.reload();
},
)
: undefined
}
/>
) : null}
{pending ? (
<PendingDialog
pending={pending}
@@ -1114,6 +1151,46 @@ function ViewerNameDialog({
);
}
function PinDialog({
viewer,
busy,
onConfirm,
onClear,
onCancel,
}: {
viewer: Viewer;
busy: boolean;
onConfirm: (pin: string) => void;
onClear?: () => void;
onCancel: () => void;
}) {
const [pin, setPin] = useState('');
return (
<div className="scrim" onPointerDown={(event) => event.target === event.currentTarget && onCancel()}>
<div className="dialog" role="dialog" aria-modal="true">
<h2>{viewer.hasPin ? 'Change PIN' : 'Set PIN'} for {viewer.name}</h2>
<p>The PIN is checked by the gateway and is never included in ordinary user responses or logs.</p>
<Field label="PIN">
<input
type="password"
inputMode="numeric"
autoFocus
minLength={4}
maxLength={12}
value={pin}
onChange={(event) => setPin(event.target.value.replace(/\D/g, '').slice(0, 12))}
/>
</Field>
<div className="dialog-actions">
<Button variant="quiet" onClick={onCancel}>Cancel</Button>
{onClear ? <Button variant="danger" onClick={onClear}>Reset PIN</Button> : null}
<Button variant="primary" busy={busy} disabled={pin.length < 4} onClick={() => onConfirm(pin)}>Save PIN</Button>
</div>
</div>
</div>
);
}
function PendingDialog({
pending,
busy,
+64 -2
View File
@@ -18,6 +18,18 @@ import {
Tag,
Toggle,
} from '../components/ui';
import { RowsEditor } from '../components/RowsEditor';
import type { RemoteSectionDefinition } from '../api/types';
/* The three configuration keys that hold a page's row composition. They are edited
* visually through RowsEditor rather than through the generic configuration list below,
* which is why they are carved out of `configuration` before that list renders — a
* technical JSON textarea for these three is exactly what the visual editor replaces. */
const ROW_CONFIGURATION_KEYS: Record<'home' | 'movies' | 'tv', string> = {
home: 'home.sectionDefinitions',
movies: 'movies.sectionDefinitions',
tv: 'tv.sectionDefinitions',
};
interface Pending {
action: 'safe-mode' | 'rollback' | 'reset' | 'feature';
@@ -36,7 +48,13 @@ export function FeaturesPage() {
const policy = status?.features;
const features = policy?.features ?? [];
const configuration = policy?.configuration ?? [];
const rowTypes = policy?.rowTypes ?? [];
const rowConfiguration = policy?.configuration ?? [];
// Everything but the three row-composition values, which the visual editors below
// render instead of the generic list.
const rowKeys = new Set(Object.values(ROW_CONFIGURATION_KEYS));
const configuration = rowConfiguration.filter((item) => !rowKeys.has(item.key));
const rowsFor = (key: string) => (rowConfiguration.find((item) => item.key === key)?.value as RemoteSectionDefinition[] | undefined) ?? [];
const clients = status?.clients ?? [];
const revision = policy?.revision ?? 0;
@@ -66,10 +84,26 @@ export function FeaturesPage() {
[key]: enabled,
});
const valuesFor = (key: string, value: unknown) => ({
...Object.fromEntries(configuration.map((item) => [item.key, item.value])),
...Object.fromEntries(rowConfiguration.map((item) => [item.key, item.value])),
[key]: value,
});
const saveRows = (key: string) => async (next: RemoteSectionDefinition[]) => {
await run(key, async () => {
await wrap(
() =>
api.post('/admin/api/features', {
action: 'save',
expectedRevision: revision,
values: valuesFor(key, next),
overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])),
}),
'Rows saved.',
);
await reload();
});
};
return (
<>
<PageHead
@@ -94,6 +128,29 @@ export function FeaturesPage() {
<a className="chip" href="/admin/clients">Client versions and capabilities</a>
</div>
</Card>
<RowsEditor
page="home"
rows={rowsFor(ROW_CONFIGURATION_KEYS.home)}
rowTypes={rowTypes}
busy={busy === ROW_CONFIGURATION_KEYS.home}
onSave={saveRows(ROW_CONFIGURATION_KEYS.home)}
/>
<RowsEditor
page="movies"
rows={rowsFor(ROW_CONFIGURATION_KEYS.movies)}
rowTypes={rowTypes}
busy={busy === ROW_CONFIGURATION_KEYS.movies}
onSave={saveRows(ROW_CONFIGURATION_KEYS.movies)}
/>
<RowsEditor
page="tv"
rows={rowsFor(ROW_CONFIGURATION_KEYS.tv)}
rowTypes={rowTypes}
busy={busy === ROW_CONFIGURATION_KEYS.tv}
onSave={saveRows(ROW_CONFIGURATION_KEYS.tv)}
/>
<Card
title="Control plane"
intro="Every optional feature has a safe default, an explicit override and a remote recovery path. Safe mode turns all of them off at once; sign-in, browsing and playback are never optional."
@@ -186,6 +243,11 @@ export function FeaturesPage() {
} catch { /* wait for valid JSON before publishing */ }
}}
/>
) : item.type === 'string' ? (
<input type="text" value={String(value ?? '')} disabled={busy === item.key} onChange={(event) => void run(item.key, async () => {
await wrap(() => api.post('/admin/api/features', { action: 'save', expectedRevision: revision, values: valuesFor(item.key, event.target.value), overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])) }), 'Configuration saved.');
await reload();
})} />
) : (
<input type="number" value={Number(value)} min={item.min} max={item.max} disabled={busy === item.key} onChange={(event) => void run(item.key, async () => {
await wrap(() => api.post('/admin/api/features', { action: 'save', expectedRevision: revision, values: valuesFor(item.key, Number(event.target.value)), overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])) }), 'Configuration saved.');
+48
View File
@@ -3140,6 +3140,54 @@ details summary {
gap: 6px;
}
.rows-editor-list {
display: grid;
gap: 10px;
}
.rows-editor-item {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 13px 15px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 11px;
background: linear-gradient(135deg, var(--surface-lift), rgba(13, 17, 23, .74));
}
.rows-editor-item::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 3px;
background: var(--quiet);
opacity: .4;
}
.rows-editor-item[data-enabled="true"]::before {
background: var(--info);
opacity: 1;
}
.rows-editor-item-main { min-width: 0; }
.rows-editor-item-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.rows-editor-item-title b {
color: var(--text);
font-size: 14px;
font-weight: 650;
}
.rows-editor-item-actions {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.hero-schedule-dialog {
width: min(760px, 100%);
padding: 0;