0.3.22 - PINs
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user