0.2.64 update
This commit is contained in:
@@ -101,6 +101,18 @@ export interface HeroPolicy {
|
||||
primeSubtitle: string;
|
||||
placements?: Record<HeroPlacement, HeroPlacementPolicy>;
|
||||
schedules?: HeroSchedule[] | null;
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
export interface ReleaseBuilderStatus {
|
||||
state: 'idle' | 'running' | 'succeeded' | 'failed';
|
||||
tag?: string;
|
||||
mandatory: boolean;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
message?: string;
|
||||
logs: string[] | null;
|
||||
fallback: string;
|
||||
}
|
||||
|
||||
export type HeroPlacement = 'home' | 'movies' | 'tv_shows';
|
||||
@@ -136,9 +148,12 @@ export interface RequestUsage {
|
||||
export interface HeroSchedule {
|
||||
id: string;
|
||||
itemId: string;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
startAt?: string;
|
||||
endAt?: string;
|
||||
weekdays?: number[];
|
||||
frequency?: 'daily' | 'weekly';
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
priority: number;
|
||||
userId?: string;
|
||||
enabled: boolean;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { matchPath, NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { Icon } from './Icon';
|
||||
import { OmniSearch } from './OmniSearch';
|
||||
import { NotificationBell } from './NotificationBell';
|
||||
@@ -29,15 +29,40 @@ function Rail({ open, onNavigate }: { open: boolean; onNavigate: () => void }) {
|
||||
}
|
||||
});
|
||||
|
||||
const toggle = (id: string) => {
|
||||
const storeCollapsed = (next: Record<string, boolean>) => {
|
||||
try {
|
||||
localStorage.setItem('memby-admin-nav', JSON.stringify(next));
|
||||
} catch {
|
||||
// Private-mode storage refusals cost the memory of which group is open, and
|
||||
// nothing else.
|
||||
}
|
||||
};
|
||||
|
||||
// The labelled groups are an accordion. Following a destination opens its group and
|
||||
// folds the one the operator has just left, including navigation from search or Back.
|
||||
useEffect(() => {
|
||||
const activeGroup = nav.find((group) =>
|
||||
group.items.some((item) => matchPath({ path: item.path, end: true }, location.pathname)),
|
||||
);
|
||||
if (!activeGroup) return;
|
||||
setCollapsed((current) => {
|
||||
const next = { ...current, [id]: !current[id] };
|
||||
try {
|
||||
localStorage.setItem('memby-admin-nav', JSON.stringify(next));
|
||||
} catch {
|
||||
// Private-mode storage refusals cost the memory of which groups were open, and
|
||||
// nothing else.
|
||||
}
|
||||
const next = { ...current };
|
||||
nav.forEach((group) => {
|
||||
if (group.collapsible !== false) next[group.id] = group.id !== activeGroup.id;
|
||||
});
|
||||
storeCollapsed(next);
|
||||
return next;
|
||||
});
|
||||
}, [location.pathname]);
|
||||
|
||||
const toggle = (id: string, defaultCollapsed = false) => {
|
||||
setCollapsed((current) => {
|
||||
const next = { ...current };
|
||||
const collapseSelected = !(current[id] ?? defaultCollapsed);
|
||||
nav.forEach((group) => {
|
||||
if (group.collapsible !== false) next[group.id] = group.id === id ? collapseSelected : true;
|
||||
});
|
||||
storeCollapsed(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
@@ -47,20 +72,25 @@ function Rail({ open, onNavigate }: { open: boolean; onNavigate: () => void }) {
|
||||
{nav.map((group) => {
|
||||
const items = group.items.filter((item) => !item.hidden);
|
||||
if (items.length === 0) return null;
|
||||
const holdsCurrent = items.some((item) => location.pathname === item.path);
|
||||
const expanded = holdsCurrent || !collapsed[group.id];
|
||||
const holdsCurrent = items.some((item) =>
|
||||
matchPath({ path: item.path, end: true }, location.pathname),
|
||||
);
|
||||
const collapsible = group.collapsible !== false;
|
||||
const expanded = holdsCurrent || !collapsible || !(collapsed[group.id] ?? group.defaultCollapsed ?? false);
|
||||
return (
|
||||
<div className="rail-group" key={group.id}>
|
||||
{group.label ? (
|
||||
{group.label && collapsible ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rail-head"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => toggle(group.id)}
|
||||
onClick={() => toggle(group.id, group.defaultCollapsed)}
|
||||
>
|
||||
{group.label}
|
||||
<Icon name="caret" className="ico caret" />
|
||||
</button>
|
||||
) : group.label ? (
|
||||
<div className="rail-head rail-head-static">{group.label}</div>
|
||||
) : null}
|
||||
{expanded
|
||||
? items.map((item) => (
|
||||
@@ -124,6 +154,23 @@ export function Layout() {
|
||||
// it sits over the page that was just opened.
|
||||
useEffect(() => setRailOpen(false), [location.pathname]);
|
||||
|
||||
// An open tablet drawer is modal navigation: keep the page underneath still and let
|
||||
// Escape close it. The media query decides whether the rail is overlaid; applying this
|
||||
// on desktop is harmless because the desktop rail cannot be opened by a hidden button.
|
||||
useEffect(() => {
|
||||
if (!railOpen) return;
|
||||
const previous = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setRailOpen(false);
|
||||
};
|
||||
window.addEventListener('keydown', closeOnEscape);
|
||||
return () => {
|
||||
document.body.style.overflow = previous;
|
||||
window.removeEventListener('keydown', closeOnEscape);
|
||||
};
|
||||
}, [railOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<a className="skip" href="#main">
|
||||
@@ -178,6 +225,14 @@ export function Layout() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{railOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rail-scrim"
|
||||
aria-label="Close sections"
|
||||
onClick={() => setRailOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
<Rail open={railOpen} onNavigate={() => setRailOpen(false)} />
|
||||
|
||||
<main className="page" id="main">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type ReactNode, useEffect, useId, useRef, useState } from 'react';
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { Glyph, Icon, type IconName } from './Icon';
|
||||
import type { Tone } from '../lib/format';
|
||||
import { allNavItems } from '../nav';
|
||||
|
||||
/* The component vocabulary. Every page is built from what is below and adds nothing of its
|
||||
own: a screen that needs a look it cannot get from here is a missing component, not a
|
||||
@@ -13,19 +15,28 @@ export function PageHead({
|
||||
intro,
|
||||
actions,
|
||||
crumbs,
|
||||
icon,
|
||||
}: {
|
||||
title: string;
|
||||
intro?: string;
|
||||
actions?: ReactNode;
|
||||
crumbs?: ReactNode;
|
||||
icon?: IconName;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const pageIcon = icon ?? allNavItems.find((item) =>
|
||||
matchPath({ path: item.path, end: true }, location.pathname),
|
||||
)?.icon;
|
||||
return (
|
||||
<header className="page-head">
|
||||
{crumbs ? <nav className="crumbs">{crumbs}</nav> : null}
|
||||
<div className="page-head-row">
|
||||
<div>
|
||||
<h1>{title}</h1>
|
||||
{intro ? <p>{intro}</p> : null}
|
||||
<div className="page-head-title">
|
||||
{pageIcon ? <span className="page-head-icon" aria-hidden="true"><Icon name={pageIcon} /></span> : null}
|
||||
<div className="page-head-text">
|
||||
<h1>{title}</h1>
|
||||
{intro ? <p>{intro}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
{actions ? <div className="page-head-actions">{actions}</div> : null}
|
||||
</div>
|
||||
|
||||
+95
-84
@@ -9,7 +9,9 @@ import type { IconName } from './components/Icon';
|
||||
* console for any /admin path and the routing is entirely here.
|
||||
*
|
||||
* `hidden` marks a destination that is reachable and titled but not in the rail: a page
|
||||
* about one person or one television belongs to the thing it is about, not to a menu. */
|
||||
* about one person or one television belongs to the thing it is about, not to a menu.
|
||||
* Everyday is deliberately frequency-based; the remaining sections are organised by the
|
||||
* operator's question and begin folded so the common routes never scroll out of reach. */
|
||||
|
||||
export interface NavItem {
|
||||
id: string;
|
||||
@@ -27,12 +29,19 @@ export interface NavItem {
|
||||
export interface NavGroup {
|
||||
id: string;
|
||||
label?: string;
|
||||
/** Less-frequent sections begin folded, but still open whenever they hold the current
|
||||
* page. An operator's explicit choice is remembered and takes precedence. */
|
||||
defaultCollapsed?: boolean;
|
||||
/** A group may opt out of the accordion when its links must remain visible. */
|
||||
collapsible?: boolean;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
export const nav: NavGroup[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
id: 'everyday',
|
||||
label: 'Everyday',
|
||||
defaultCollapsed: false,
|
||||
items: [
|
||||
{
|
||||
id: 'overview',
|
||||
@@ -51,12 +60,6 @@ export const nav: NavGroup[] = [
|
||||
icon: 'bell',
|
||||
badge: 'notifications',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'people',
|
||||
label: 'People',
|
||||
items: [
|
||||
{
|
||||
id: 'accounts',
|
||||
path: '/admin/accounts',
|
||||
@@ -65,12 +68,52 @@ export const nav: NavGroup[] = [
|
||||
intro: 'Who uses Memby, and the devices they are signed in on.',
|
||||
icon: 'people',
|
||||
},
|
||||
{
|
||||
id: 'requests',
|
||||
path: '/admin/requests',
|
||||
label: 'Media requests',
|
||||
title: 'Media requests',
|
||||
intro: 'Who can ask for something the library does not have.',
|
||||
icon: 'inbox',
|
||||
},
|
||||
{
|
||||
id: 'media-reports',
|
||||
path: '/admin/media-reports',
|
||||
label: 'Media reports',
|
||||
title: 'Media reports',
|
||||
intro: 'Problems viewers reported with a film or episode.',
|
||||
icon: 'alert',
|
||||
},
|
||||
{
|
||||
id: 'updates',
|
||||
path: '/admin/updates',
|
||||
label: 'App updates',
|
||||
title: 'App updates',
|
||||
intro: 'Publish an optional or a required client update.',
|
||||
icon: 'upload',
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
path: '/admin/logs',
|
||||
label: 'Server logs',
|
||||
title: 'Server logs',
|
||||
intro: 'Structured gateway events as they happen.',
|
||||
icon: 'list',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'people',
|
||||
label: 'Devices & access',
|
||||
defaultCollapsed: true,
|
||||
items: [
|
||||
{
|
||||
id: 'account',
|
||||
path: '/admin/accounts/:userId',
|
||||
label: 'User',
|
||||
title: 'User',
|
||||
intro: 'Devices, recommendation setup and synced settings for one person.',
|
||||
icon: 'person',
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
@@ -79,6 +122,7 @@ export const nav: NavGroup[] = [
|
||||
label: 'Settings history',
|
||||
title: 'Settings history',
|
||||
intro: "Every change to one person's synced settings, and which devices took it.",
|
||||
icon: 'sliders',
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
@@ -103,13 +147,15 @@ export const nav: NavGroup[] = [
|
||||
label: 'Device',
|
||||
title: 'Device',
|
||||
intro: 'One television: how often it connects, at what times, and from which addresses.',
|
||||
icon: 'tv',
|
||||
hidden: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
label: 'Content',
|
||||
label: 'Content & discovery',
|
||||
defaultCollapsed: true,
|
||||
items: [
|
||||
{
|
||||
id: 'library',
|
||||
@@ -120,27 +166,14 @@ export const nav: NavGroup[] = [
|
||||
icon: 'library',
|
||||
},
|
||||
{
|
||||
id: 'ratings',
|
||||
path: '/admin/ratings',
|
||||
label: 'Movie ratings',
|
||||
title: 'Movie ratings',
|
||||
intro: 'Optional MDBList scores on films and shows.',
|
||||
id: 'hero',
|
||||
path: '/admin/hero',
|
||||
label: 'Home hero',
|
||||
title: 'Home hero',
|
||||
intro:
|
||||
'Choose films or television shows for the launcher spotlight while recent releases fill the remaining places.',
|
||||
icon: 'star',
|
||||
},
|
||||
{
|
||||
id: 'requests',
|
||||
path: '/admin/requests',
|
||||
label: 'Media requests',
|
||||
title: 'Media requests',
|
||||
intro: 'Who can ask for something the library does not have.',
|
||||
icon: 'inbox',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'personalisation',
|
||||
label: 'Personalisation',
|
||||
items: [
|
||||
{
|
||||
id: 'recommendations',
|
||||
path: '/admin/recommendations',
|
||||
@@ -149,6 +182,14 @@ export const nav: NavGroup[] = [
|
||||
intro: 'The prepared pools personalised rows are drawn from.',
|
||||
icon: 'sparkle',
|
||||
},
|
||||
{
|
||||
id: 'ratings',
|
||||
path: '/admin/ratings',
|
||||
label: 'Movie ratings',
|
||||
title: 'Movie ratings',
|
||||
intro: 'Optional MDBList scores on films and shows.',
|
||||
icon: 'star',
|
||||
},
|
||||
{
|
||||
id: 'inspector',
|
||||
path: '/admin/inspector',
|
||||
@@ -161,17 +202,9 @@ export const nav: NavGroup[] = [
|
||||
},
|
||||
{
|
||||
id: 'experience',
|
||||
label: 'Experience',
|
||||
label: 'Viewing experience',
|
||||
defaultCollapsed: true,
|
||||
items: [
|
||||
{
|
||||
id: 'hero',
|
||||
path: '/admin/hero',
|
||||
label: 'Home hero',
|
||||
title: 'Home hero',
|
||||
intro:
|
||||
'Choose films or television shows for the launcher spotlight while recent releases fill the remaining places.',
|
||||
icon: 'star',
|
||||
},
|
||||
{
|
||||
id: 'features',
|
||||
path: '/admin/features',
|
||||
@@ -196,19 +229,12 @@ export const nav: NavGroup[] = [
|
||||
intro: 'Which providers a viewer may fetch a missing subtitle from.',
|
||||
icon: 'captions',
|
||||
},
|
||||
{
|
||||
id: 'updates',
|
||||
path: '/admin/updates',
|
||||
label: 'App updates',
|
||||
title: 'App updates',
|
||||
intro: 'Publish an optional or a required client update.',
|
||||
icon: 'upload',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'operations',
|
||||
label: 'Operations',
|
||||
defaultCollapsed: true,
|
||||
items: [
|
||||
{
|
||||
id: 'tasks',
|
||||
@@ -219,12 +245,12 @@ export const nav: NavGroup[] = [
|
||||
icon: 'clock',
|
||||
},
|
||||
{
|
||||
id: 'integrations',
|
||||
path: '/admin/integrations',
|
||||
label: 'Integrations',
|
||||
title: 'Integrations',
|
||||
intro: 'Send administrative events to Discord and, in time, elsewhere.',
|
||||
icon: 'plug',
|
||||
id: 'imports',
|
||||
path: '/admin/imports',
|
||||
label: 'Imports',
|
||||
title: 'Imports',
|
||||
intro: 'Catalogue synchronisation history.',
|
||||
icon: 'database',
|
||||
},
|
||||
{
|
||||
id: 'maintenance',
|
||||
@@ -235,35 +261,20 @@ export const nav: NavGroup[] = [
|
||||
icon: 'wrench',
|
||||
},
|
||||
{
|
||||
id: 'imports',
|
||||
path: '/admin/imports',
|
||||
label: 'Imports',
|
||||
title: 'Imports',
|
||||
intro: 'Catalogue synchronisation history.',
|
||||
icon: 'database',
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
path: '/admin/logs',
|
||||
label: 'Server logs',
|
||||
title: 'Server logs',
|
||||
intro: 'Structured gateway events as they happen.',
|
||||
icon: 'list',
|
||||
id: 'integrations',
|
||||
path: '/admin/integrations',
|
||||
label: 'Integrations',
|
||||
title: 'Integrations',
|
||||
intro: 'Send administrative events to Discord and, in time, elsewhere.',
|
||||
icon: 'plug',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'reporting',
|
||||
label: 'Reporting',
|
||||
id: 'insights',
|
||||
label: 'Insights',
|
||||
defaultCollapsed: true,
|
||||
items: [
|
||||
{
|
||||
id: 'media-reports',
|
||||
path: '/admin/media-reports',
|
||||
label: 'Media reports',
|
||||
title: 'Media reports',
|
||||
intro: 'Problems viewers reported with a film or episode.',
|
||||
icon: 'inbox',
|
||||
},
|
||||
{
|
||||
id: 'views',
|
||||
path: '/admin/views',
|
||||
@@ -272,6 +283,14 @@ export const nav: NavGroup[] = [
|
||||
intro: 'Home-screen visits, viewers and the times Memby is used.',
|
||||
icon: 'overview',
|
||||
},
|
||||
{
|
||||
id: 'searches',
|
||||
path: '/admin/searches',
|
||||
label: 'Searches',
|
||||
title: 'Searches',
|
||||
intro: 'What the household has been looking for, and what it searched just now.',
|
||||
icon: 'search',
|
||||
},
|
||||
{
|
||||
id: 'journeys',
|
||||
path: '/admin/journeys',
|
||||
@@ -288,14 +307,6 @@ export const nav: NavGroup[] = [
|
||||
intro: 'Impressions, focus, dwell and selections per launcher row.',
|
||||
icon: 'chart',
|
||||
},
|
||||
{
|
||||
id: 'searches',
|
||||
path: '/admin/searches',
|
||||
label: 'Searches',
|
||||
title: 'Searches',
|
||||
intro: 'What the household has been looking for, and what it searched just now.',
|
||||
icon: 'search',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -68,6 +68,17 @@ interface RecommendationState {
|
||||
contentTypes?: string[];
|
||||
}
|
||||
|
||||
interface NotificationPreferences {
|
||||
enabled: boolean;
|
||||
showReturnAlerts: boolean;
|
||||
sonarrAlerts: boolean;
|
||||
radarrAlerts: boolean;
|
||||
updateAlerts: boolean;
|
||||
libraryAlerts: boolean;
|
||||
systemAlerts: boolean;
|
||||
leadDays: number;
|
||||
}
|
||||
|
||||
interface AccountDetail {
|
||||
id: string;
|
||||
username: string;
|
||||
@@ -82,6 +93,7 @@ interface AccountDetail {
|
||||
updatedAt?: string;
|
||||
preferences?: Record<string, unknown>;
|
||||
};
|
||||
notifications: NotificationPreferences;
|
||||
}
|
||||
|
||||
interface AccountsPayload {
|
||||
@@ -124,6 +136,7 @@ export function AccountPage() {
|
||||
saving one does not discard an unsaved edit to the other. */
|
||||
const [prefs, setPrefs] = useState<Record<string, unknown> | null>(null);
|
||||
const [themes, setThemes] = useState<string[] | null>(null);
|
||||
const [notifications, setNotifications] = useState<NotificationPreferences | null>(null);
|
||||
const [pending, setPending] = useState<Pending | null>(null);
|
||||
const [renaming, setRenaming] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
@@ -135,6 +148,10 @@ export function AccountPage() {
|
||||
if (prefs === null && account) setPrefs({ ...(account.settings?.preferences ?? {}) });
|
||||
}, [account, prefs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (notifications === null && account) setNotifications({ ...account.notifications });
|
||||
}, [account, notifications]);
|
||||
|
||||
useEffect(() => {
|
||||
if (themes !== null || !account) return;
|
||||
// An empty list from the server means unrestricted, so it draws as every box ticked.
|
||||
@@ -318,6 +335,105 @@ export function AccountPage() {
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Card
|
||||
title="Notifications"
|
||||
intro="Choose what this person sees across every television. Changes apply through the gateway within a few seconds and do not require an app release."
|
||||
icon="bell"
|
||||
tone="note"
|
||||
actions={notifications?.enabled ? <Tag tone="ok">enabled</Tag> : <Tag>muted</Tag>}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
busy={busy === 'notifications'}
|
||||
onClick={() =>
|
||||
void act(
|
||||
'notifications',
|
||||
() => api.put(`${base}/notifications`, notifications),
|
||||
'Notification settings saved.',
|
||||
() => setNotifications(null),
|
||||
)
|
||||
}
|
||||
>
|
||||
Save notifications
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setNotifications(null);
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
Discard changes
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{notifications ? (
|
||||
<div className="checks columns">
|
||||
<Toggle
|
||||
label="All notifications"
|
||||
hint="The master switch. Turning this off hides every optional notification below."
|
||||
checked={notifications.enabled}
|
||||
onChange={(enabled) => setNotifications((current) => current && { ...current, enabled })}
|
||||
/>
|
||||
<Toggle
|
||||
label="My Shows return dates"
|
||||
hint="Remind this person when a followed show is about to return."
|
||||
checked={notifications.showReturnAlerts}
|
||||
disabled={!notifications.enabled}
|
||||
onChange={(showReturnAlerts) =>
|
||||
setNotifications((current) => current && { ...current, showReturnAlerts })
|
||||
}
|
||||
/>
|
||||
<Toggle
|
||||
label="Sonarr television alerts"
|
||||
hint="New episodes, additions and cancellation news supplied by Sonarr."
|
||||
checked={notifications.sonarrAlerts}
|
||||
disabled={!notifications.enabled}
|
||||
onChange={(sonarrAlerts) =>
|
||||
setNotifications((current) => current && { ...current, sonarrAlerts })
|
||||
}
|
||||
/>
|
||||
<Toggle
|
||||
label="Radarr film alerts"
|
||||
hint="Notify this person when Radarr imports a new film."
|
||||
checked={notifications.radarrAlerts}
|
||||
disabled={!notifications.enabled}
|
||||
onChange={(radarrAlerts) =>
|
||||
setNotifications((current) => current && { ...current, radarrAlerts })
|
||||
}
|
||||
/>
|
||||
<Toggle
|
||||
label="Optional app updates"
|
||||
hint="Offer new app versions to this person. Mandatory compatibility updates are always enforced."
|
||||
checked={notifications.updateAlerts}
|
||||
disabled={!notifications.enabled}
|
||||
onChange={(updateAlerts) =>
|
||||
setNotifications((current) => current && { ...current, updateAlerts })
|
||||
}
|
||||
/>
|
||||
<Toggle
|
||||
label="Library activity"
|
||||
hint="Show alerts after the Memby library catalogue is refreshed."
|
||||
checked={notifications.libraryAlerts}
|
||||
disabled={!notifications.enabled}
|
||||
onChange={(libraryAlerts) =>
|
||||
setNotifications((current) => current && { ...current, libraryAlerts })
|
||||
}
|
||||
/>
|
||||
<Toggle
|
||||
label="Service status"
|
||||
hint="Memby deployment and Emby outage or recovery notices. Maintenance mode itself still applies."
|
||||
checked={notifications.systemAlerts}
|
||||
disabled={!notifications.enabled}
|
||||
onChange={(systemAlerts) =>
|
||||
setNotifications((current) => current && { ...current, systemAlerts })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Settings"
|
||||
intro="These live on the server and follow the person, so a change here reaches every television they use — usually within a few seconds, and on the next launch for a set that is switched off."
|
||||
|
||||
+343
-35
@@ -3,7 +3,7 @@ import { api } from '../api/client';
|
||||
import { useAction } from '../lib/hooks';
|
||||
import { useGateway } from '../lib/gateway';
|
||||
import { useToast } from '../lib/toast';
|
||||
import { Banner, Button, Card, Empty, Field, Grid, Loading, PageHead } from '../components/ui';
|
||||
import { Banner, Button, Card, Empty, Field, Grid, Loading, PageHead, Tag, Toggle } from '../components/ui';
|
||||
import type { HeroItem, HeroPlacement, HeroPlacementPolicy, HeroSchedule } from '../api/types';
|
||||
|
||||
const MAX_PINS = 4;
|
||||
@@ -13,6 +13,56 @@ const PLACEMENTS: Array<{ id: HeroPlacement; label: string; type: string }> = [
|
||||
{ id: 'tv_shows', label: 'TV Shows', type: 'television shows' },
|
||||
];
|
||||
const emptyPlacement = (): HeroPlacementPolicy => ({ pinnedItems: [], primeSubtitle: '' });
|
||||
const WEEKDAYS = [
|
||||
{ value: 1, short: 'Mon', label: 'Monday' },
|
||||
{ value: 2, short: 'Tue', label: 'Tuesday' },
|
||||
{ value: 3, short: 'Wed', label: 'Wednesday' },
|
||||
{ value: 4, short: 'Thu', label: 'Thursday' },
|
||||
{ value: 5, short: 'Fri', label: 'Friday' },
|
||||
{ value: 6, short: 'Sat', label: 'Saturday' },
|
||||
{ value: 0, short: 'Sun', label: 'Sunday' },
|
||||
];
|
||||
|
||||
type ScheduleFrequency = 'once' | 'daily' | 'weekly';
|
||||
|
||||
function localDateTime(value: Date): string {
|
||||
const offset = value.getTimezoneOffset() * 60_000;
|
||||
return new Date(value.getTime() - offset).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function usableAbsoluteTime(value: string | undefined): boolean {
|
||||
return Boolean(value && Number.isFinite(new Date(value).getTime()) && new Date(value).getFullYear() >= 2000);
|
||||
}
|
||||
|
||||
function scheduleFrequency(schedule: HeroSchedule): ScheduleFrequency {
|
||||
return schedule.frequency ?? 'once';
|
||||
}
|
||||
|
||||
function scheduleSummary(schedule: HeroSchedule): string {
|
||||
if (schedule.frequency === 'daily') {
|
||||
return `Every day · ${schedule.startTime}–${schedule.endTime}`;
|
||||
}
|
||||
if (schedule.frequency === 'weekly') {
|
||||
const selected = WEEKDAYS.filter((day) => (schedule.weekdays ?? []).includes(day.value));
|
||||
const days = selected.length === 7 ? 'Every day' : selected.map((day) => day.short).join(', ');
|
||||
return `${days || 'No days selected'} · ${schedule.startTime}–${schedule.endTime}`;
|
||||
}
|
||||
return `${schedule.startAt ? new Date(schedule.startAt).toLocaleString() : 'Start missing'} → ${schedule.endAt ? new Date(schedule.endAt).toLocaleString() : 'End missing'}`;
|
||||
}
|
||||
|
||||
function newSchedule(item: HeroItem, placement: HeroPlacement): HeroSchedule {
|
||||
const start = new Date();
|
||||
start.setMinutes(Math.ceil(start.getMinutes() / 30) * 30, 0, 0);
|
||||
const end = new Date(start.getTime() + 2 * 60 * 60 * 1000);
|
||||
const validPlacement = placement === 'home' ||
|
||||
(placement === 'movies' && item.type === 'Movie') ||
|
||||
(placement === 'tv_shows' && item.type === 'Series');
|
||||
return {
|
||||
id: crypto.randomUUID(), itemId: item.id,
|
||||
startAt: start.toISOString(), endAt: end.toISOString(),
|
||||
priority: 0, enabled: true, placements: [validPlacement ? placement : 'home'],
|
||||
};
|
||||
}
|
||||
|
||||
export function HeroPage() {
|
||||
const { status, error, loading, reload } = useGateway();
|
||||
@@ -26,6 +76,7 @@ export function HeroPage() {
|
||||
const [queryText, setQueryText] = useState('');
|
||||
const [results, setResults] = useState<HeroItem[] | null>(null);
|
||||
const [schedules, setSchedules] = useState<HeroSchedule[]>([]);
|
||||
const [scheduleDraft, setScheduleDraft] = useState<HeroSchedule | null>(null);
|
||||
|
||||
const policy = status?.heroPolicy;
|
||||
|
||||
@@ -99,9 +150,9 @@ export function HeroPage() {
|
||||
{PLACEMENTS.map((option) => {
|
||||
const configured = placements[option.id];
|
||||
const activeSchedule = schedules
|
||||
.filter((entry) => entry.enabled && (entry.placements ?? ['home']).includes(option.id) && new Date(entry.startAt) <= new Date() && new Date(entry.endAt) > new Date())
|
||||
.filter((entry) => entry.enabled && (entry.placements ?? ['home']).includes(option.id))
|
||||
.sort((left, right) => right.priority - left.priority)[0];
|
||||
const source = (configured.pinnedItems ?? []).length ? 'Manual' : activeSchedule ? 'Scheduled' : 'Automatic';
|
||||
const source = (configured.pinnedItems ?? []).length ? 'Manual' : activeSchedule ? 'Schedule ready' : 'Automatic';
|
||||
const scheduledItem = policy?.items?.find((item) => item.id === activeSchedule?.itemId);
|
||||
const preview = (configured.pinnedItems ?? []).map((item) => item.name).join(', ') || scheduledItem?.name || activeSchedule?.itemId || 'Resolved for each viewer';
|
||||
return <Card key={option.id} title={option.label} intro={`${source} · ${preview}`} tone={option.id === placement ? 'info' : undefined}>
|
||||
@@ -140,20 +191,22 @@ export function HeroPage() {
|
||||
{pins.length === 0 ? (
|
||||
<Empty>No titles are pinned. The hero is entirely release-aware and automatic.</Empty>
|
||||
) : (
|
||||
<div className="chips">
|
||||
<div className="hero-pins">
|
||||
{pins.map((item, index) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant="quiet"
|
||||
size="sm"
|
||||
icon="close"
|
||||
onClick={() => {
|
||||
setCurrent({ pinnedItems: pins.filter((pin) => pin.id !== item.id) });
|
||||
}}
|
||||
>
|
||||
{index + 1}. {item.name}
|
||||
{item.year ? ` (${item.year})` : ''}
|
||||
</Button>
|
||||
<div className="hero-pin" key={item.id}>
|
||||
<span className="hero-pin-order">{index + 1}</span>
|
||||
<span><b>{item.name}</b><small>{item.type}{item.year ? ` · ${item.year}` : ''}</small></span>
|
||||
<Button size="sm" icon="clock" onClick={() => setScheduleDraft(newSchedule(item, placement))}>
|
||||
Schedule
|
||||
</Button>
|
||||
<Button
|
||||
variant="quiet"
|
||||
size="sm"
|
||||
icon="close"
|
||||
title={`Remove ${item.name}`}
|
||||
onClick={() => setCurrent({ pinnedItems: pins.filter((pin) => pin.id !== item.id) })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -174,18 +227,77 @@ export function HeroPage() {
|
||||
</Field>
|
||||
</Card>
|
||||
|
||||
<Card title="Scheduled heroes" intro="Schedules are resolved by the gateway: manual pins still win, then the highest-priority eligible schedule, then Memby’s automatic hero." icon="clock" tone="info">
|
||||
{schedules.length === 0 ? <Empty>No scheduled heroes yet.</Empty> : (
|
||||
<div className="stack">{schedules.map((schedule) => {
|
||||
const item = [...(policy?.items ?? []), ...pins, ...(results ?? [])].find((candidate) => candidate.id === schedule.itemId);
|
||||
return <div className="row" key={schedule.id}><b>{item?.name ?? schedule.itemId}</b><span className="muted">{new Date(schedule.startAt).toLocaleString()} → {new Date(schedule.endAt).toLocaleString()} · priority {schedule.priority} · {(schedule.placements ?? ['home']).map((value) => PLACEMENTS.find((entry) => entry.id === value)?.label).join(', ')}</span><div className="chips">{PLACEMENTS.map((option) => <Button key={option.id} size="sm" variant={(schedule.placements ?? ['home']).includes(option.id) ? 'primary' : 'quiet'} onClick={() => { setSchedules((all) => all.map((entry) => { if (entry.id !== schedule.id) return entry; const selected = entry.placements ?? ['home']; const next = selected.includes(option.id) ? selected.filter((value) => value !== option.id) : [...selected, option.id]; return { ...entry, placements: next.length ? next : [option.id] }; })); setDirty(true); }}>{option.label}</Button>)}</div><Button size="sm" variant="quiet" onClick={() => { setSchedules((current) => current.filter((entry) => entry.id !== schedule.id)); setDirty(true); }}>Remove</Button></div>;
|
||||
})}</div>
|
||||
<Card
|
||||
title="Hero schedule"
|
||||
intro="The gateway applies these rules in server time. Manual pins win first; otherwise the highest-priority active schedule wins, followed by Memby’s automatic hero."
|
||||
icon="clock"
|
||||
tone="info"
|
||||
actions={<Tag tone="info">{policy?.timeZone || 'server local time'}</Tag>}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="primary" busy={busy === 'save'} onClick={() => void save()}>
|
||||
Save schedule
|
||||
</Button>
|
||||
<span className="hint">Daily and weekly rules repeat until you switch them off.</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{schedules.length === 0 ? (
|
||||
<Empty>No scheduled heroes yet. Use Schedule beside a pinned or searched title.</Empty>
|
||||
) : (
|
||||
<div className="hero-schedule-list">
|
||||
{[...schedules]
|
||||
.sort((left, right) => Number(right.enabled) - Number(left.enabled) || right.priority - left.priority)
|
||||
.map((schedule) => {
|
||||
const item = [...(policy?.items ?? []), ...pins, ...(results ?? [])]
|
||||
.find((candidate) => candidate.id === schedule.itemId);
|
||||
return (
|
||||
<article className="hero-schedule" data-enabled={schedule.enabled || undefined} key={schedule.id}>
|
||||
<div className="hero-schedule-time">
|
||||
<b>{schedule.frequency === 'weekly' ? 'Weekly' : schedule.frequency === 'daily' ? 'Daily' : 'Once'}</b>
|
||||
<span>{schedule.frequency ? schedule.startTime : schedule.startAt ? new Date(schedule.startAt).toLocaleDateString() : '—'}</span>
|
||||
</div>
|
||||
<div className="hero-schedule-main">
|
||||
<div className="hero-schedule-title">
|
||||
<h3>{item?.name ?? schedule.itemId}</h3>
|
||||
<Tag tone={schedule.enabled ? 'ok' : undefined}>{schedule.enabled ? 'enabled' : 'paused'}</Tag>
|
||||
</div>
|
||||
<p>{scheduleSummary(schedule)}</p>
|
||||
<div className="chips">
|
||||
{(schedule.placements ?? ['home']).map((value) => (
|
||||
<span className="chip" key={value}>{PLACEMENTS.find((entry) => entry.id === value)?.label}</span>
|
||||
))}
|
||||
{schedule.priority !== 0 ? <span className="chip">Priority {schedule.priority}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-schedule-actions">
|
||||
<Button size="sm" onClick={() => setScheduleDraft({ ...schedule })}>Edit</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="quiet"
|
||||
onClick={() => {
|
||||
setSchedules((current) => current.map((entry) => entry.id === schedule.id ? { ...entry, enabled: !entry.enabled } : entry));
|
||||
setDirty(true);
|
||||
}}
|
||||
>
|
||||
{schedule.enabled ? 'Pause' : 'Enable'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="quiet"
|
||||
onClick={() => {
|
||||
setSchedules((current) => current.filter((entry) => entry.id !== schedule.id));
|
||||
setDirty(true);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{pins.length > 0 ? <Button size="sm" icon="plus" onClick={() => {
|
||||
const first = pins[0]; if (!first) return;
|
||||
const start = new Date(); const end = new Date(start.getTime() + 2 * 60 * 60 * 1000);
|
||||
setSchedules((current) => [...current, { id: crypto.randomUUID(), itemId: first.id, startAt: start.toISOString(), endAt: end.toISOString(), priority: 0, enabled: true, placements: [placement] }]); setDirty(true);
|
||||
}}>Schedule first pinned title for two hours</Button> : <p className="hint">Pin or search for a title first, then add it to a schedule.</p>}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
@@ -217,14 +329,19 @@ export function HeroPage() {
|
||||
<Grid>
|
||||
{results.map((item) => (
|
||||
<Card key={item.id} title={item.name} intro={`${item.type || 'Title'} · ${item.year || 'Year unknown'}`}>
|
||||
<Button
|
||||
size="sm"
|
||||
icon="plus"
|
||||
disabled={pins.some((pin) => pin.id === item.id) || (placement === 'movies' && item.type !== 'Movie') || (placement === 'tv_shows' && item.type !== 'Series')}
|
||||
onClick={() => add(item)}
|
||||
>
|
||||
{pins.some((pin) => pin.id === item.id) ? 'Pinned' : 'Add to hero'}
|
||||
</Button>
|
||||
<div className="row">
|
||||
<Button
|
||||
size="sm"
|
||||
icon="plus"
|
||||
disabled={pins.some((pin) => pin.id === item.id) || (placement === 'movies' && item.type !== 'Movie') || (placement === 'tv_shows' && item.type !== 'Series')}
|
||||
onClick={() => add(item)}
|
||||
>
|
||||
{pins.some((pin) => pin.id === item.id) ? 'Pinned' : 'Add to hero'}
|
||||
</Button>
|
||||
<Button size="sm" icon="clock" onClick={() => setScheduleDraft(newSchedule(item, placement))}>
|
||||
Schedule
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</Grid>
|
||||
@@ -232,6 +349,197 @@ export function HeroPage() {
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{scheduleDraft ? (
|
||||
<ScheduleEditor
|
||||
schedule={scheduleDraft}
|
||||
item={[...(policy?.items ?? []), ...pins, ...(results ?? [])].find((candidate) => candidate.id === scheduleDraft.itemId)}
|
||||
timeZone={policy?.timeZone || 'server local time'}
|
||||
isNew={!schedules.some((entry) => entry.id === scheduleDraft.id)}
|
||||
onCancel={() => setScheduleDraft(null)}
|
||||
onSave={(next) => {
|
||||
setSchedules((current) => current.some((entry) => entry.id === next.id)
|
||||
? current.map((entry) => entry.id === next.id ? next : entry)
|
||||
: [...current, next]);
|
||||
setScheduleDraft(null);
|
||||
setDirty(true);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleEditor({
|
||||
schedule,
|
||||
item,
|
||||
timeZone,
|
||||
isNew,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
schedule: HeroSchedule;
|
||||
item?: HeroItem;
|
||||
timeZone: string;
|
||||
isNew: boolean;
|
||||
onSave: (schedule: HeroSchedule) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<HeroSchedule>({ ...schedule, weekdays: [...(schedule.weekdays ?? [])] });
|
||||
const frequency = scheduleFrequency(draft);
|
||||
const setFrequency = (next: ScheduleFrequency) => {
|
||||
const now = new Date();
|
||||
const later = new Date(now.getTime() + 2 * 60 * 60 * 1000);
|
||||
setDraft((current) => next === 'once'
|
||||
? {
|
||||
...current,
|
||||
frequency: undefined,
|
||||
startAt: usableAbsoluteTime(current.startAt) ? current.startAt : now.toISOString(),
|
||||
endAt: usableAbsoluteTime(current.endAt) ? current.endAt : later.toISOString(),
|
||||
}
|
||||
: {
|
||||
...current,
|
||||
frequency: next,
|
||||
startTime: current.startTime || '18:00',
|
||||
endTime: current.endTime || '22:00',
|
||||
weekdays: next === 'weekly' ? (current.weekdays?.length ? current.weekdays : [1, 2, 3, 4, 5]) : [],
|
||||
});
|
||||
};
|
||||
const selectedPlacements = draft.placements ?? ['home'];
|
||||
const validPlacement = (value: HeroPlacement) => value === 'home' ||
|
||||
(value === 'movies' && item?.type === 'Movie') ||
|
||||
(value === 'tv_shows' && item?.type === 'Series');
|
||||
const onceValid = Boolean(draft.startAt && draft.endAt && new Date(draft.endAt) > new Date(draft.startAt));
|
||||
const recurringValid = Boolean(draft.startTime && draft.endTime && draft.startTime !== draft.endTime &&
|
||||
(frequency !== 'weekly' || (draft.weekdays ?? []).length > 0));
|
||||
const valid = frequency === 'once' ? onceValid : recurringValid;
|
||||
|
||||
return (
|
||||
<div className="scrim" onPointerDown={(event) => event.target === event.currentTarget && onCancel()}>
|
||||
<div className="dialog hero-schedule-dialog" role="dialog" aria-modal="true" aria-labelledby="hero-schedule-title">
|
||||
<div className="hero-schedule-dialog-head">
|
||||
<span className="hero-schedule-kicker">Hero schedule</span>
|
||||
<h2 id="hero-schedule-title">{item?.name ?? draft.itemId}</h2>
|
||||
<p>Choose exactly when this title can lead the selected sections. Times use {timeZone}.</p>
|
||||
</div>
|
||||
|
||||
<div className="schedule-frequency" role="group" aria-label="Schedule frequency">
|
||||
{(['once', 'daily', 'weekly'] as ScheduleFrequency[]).map((value) => (
|
||||
<button key={value} type="button" aria-pressed={frequency === value} onClick={() => setFrequency(value)}>
|
||||
<b>{value === 'once' ? 'One time' : value === 'daily' ? 'Every day' : 'Weekly'}</b>
|
||||
<span>{value === 'once' ? 'A date range' : value === 'daily' ? 'Same time daily' : 'Choose days'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{frequency === 'once' ? (
|
||||
<div className="fields">
|
||||
<Field label="Starts">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={draft.startAt ? localDateTime(new Date(draft.startAt)) : ''}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, startAt: event.target.value ? new Date(event.target.value).toISOString() : undefined }))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Ends">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={draft.endAt ? localDateTime(new Date(draft.endAt)) : ''}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, endAt: event.target.value ? new Date(event.target.value).toISOString() : undefined }))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="fields">
|
||||
<Field label="Starts each time">
|
||||
<input type="time" value={draft.startTime ?? ''} onChange={(event) => setDraft((current) => ({ ...current, startTime: event.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Ends each time" hint="An earlier end time continues into the following day.">
|
||||
<input type="time" value={draft.endTime ?? ''} onChange={(event) => setDraft((current) => ({ ...current, endTime: event.target.value }))} />
|
||||
</Field>
|
||||
</div>
|
||||
{frequency === 'weekly' ? (
|
||||
<div className="schedule-days">
|
||||
<div className="schedule-days-head">
|
||||
<b>Days</b>
|
||||
<div>
|
||||
<button type="button" onClick={() => setDraft((current) => ({ ...current, weekdays: [1, 2, 3, 4, 5] }))}>Weekdays</button>
|
||||
<button type="button" onClick={() => setDraft((current) => ({ ...current, weekdays: [6, 0] }))}>Weekend</button>
|
||||
<button type="button" onClick={() => setDraft((current) => ({ ...current, weekdays: WEEKDAYS.map((day) => day.value) }))}>Every day</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="schedule-day-grid">
|
||||
{WEEKDAYS.map((day) => {
|
||||
const selected = (draft.weekdays ?? []).includes(day.value);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={day.value}
|
||||
aria-pressed={selected}
|
||||
title={day.label}
|
||||
onClick={() => setDraft((current) => ({
|
||||
...current,
|
||||
weekdays: selected
|
||||
? (current.weekdays ?? []).filter((value) => value !== day.value)
|
||||
: [...(current.weekdays ?? []), day.value],
|
||||
}))}
|
||||
>
|
||||
{day.short}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="schedule-options">
|
||||
<div>
|
||||
<span className="schedule-option-label">Show in</span>
|
||||
<div className="schedule-placement-grid">
|
||||
{PLACEMENTS.map((option) => (
|
||||
<Toggle
|
||||
key={option.id}
|
||||
label={option.label}
|
||||
checked={selectedPlacements.includes(option.id)}
|
||||
disabled={!validPlacement(option.id)}
|
||||
onChange={(enabled) => setDraft((current) => {
|
||||
const selected = current.placements ?? ['home'];
|
||||
const next = enabled ? [...selected, option.id] : selected.filter((value) => value !== option.id);
|
||||
return { ...current, placements: next.length ? [...new Set(next)] : selected };
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Field label="Priority" hint="Higher rules win when schedules overlap.">
|
||||
<input
|
||||
type="number"
|
||||
min={-1000}
|
||||
max={1000}
|
||||
step={10}
|
||||
value={draft.priority}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, priority: Number(event.target.value) }))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
label="Schedule enabled"
|
||||
hint="Pause it without losing its days and times."
|
||||
checked={draft.enabled}
|
||||
onChange={(enabled) => setDraft((current) => ({ ...current, enabled }))}
|
||||
/>
|
||||
|
||||
<div className="dialog-actions">
|
||||
<Button variant="quiet" onClick={onCancel}>Cancel</Button>
|
||||
<Button variant="primary" disabled={!valid} onClick={() => onSave(draft)}>
|
||||
{isNew ? 'Add rule' : 'Save rule'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ export function JourneyViewerPage() {
|
||||
const journeys = sessions.flatMap((session) => splitViewingJourneys(session).map((events, index) => ({ events, key: `${session[0]?.journeyId}:${index}` })));
|
||||
|
||||
return <>
|
||||
<PageHead title={`${username}'s journeys`} intro="Each app session is shown as the viewing journeys it contains: entry, selection, playback outcome." crumbs={<Link className="crumb" to="/admin/journeys">Journeys</Link>} />
|
||||
<PageHead title={`${username}'s journeys`} intro="Each app session is shown as the viewing journeys it contains: entry, selection, playback outcome." icon="journey" crumbs={<Link className="crumb" to="/admin/journeys">Journeys</Link>} />
|
||||
<Banner message={error} />
|
||||
{loading ? <Loading /> : <Card title="Viewing journeys" intro={`${sessions.length} app session${sessions.length === 1 ? '' : 's'} · ${journeys.length} viewing journey${journeys.length === 1 ? '' : 's'} in the last 90 days.`} icon="journey" tone="info">
|
||||
<div className="visits">
|
||||
|
||||
+205
-72
@@ -1,41 +1,114 @@
|
||||
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Fragment,
|
||||
memo,
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { num, when } from '../lib/format';
|
||||
import { Banner, Button, Card, Field, PageHead } from '../components/ui';
|
||||
import { num } from '../lib/format';
|
||||
import { Banner, Button, Card, Field, Note, PageHead } from '../components/ui';
|
||||
import type { LogEvent, LogResponse } from '../api/types';
|
||||
|
||||
/* The live server log.
|
||||
*
|
||||
* The ring buffer is drained in pages until it is caught up, so a console opened *after*
|
||||
* an incident sees what happened rather than only what happens next. Everything the page
|
||||
* has drained is held for filtering and export; only the visible tail is drawn, because
|
||||
* rendering twenty thousand lines during an incident is how a browser tab stops
|
||||
* responding at exactly the wrong moment. */
|
||||
* Network delivery is already cursor based: each server record crosses the wire once.
|
||||
* Rendering is virtualised as well, so retaining and filtering thousands of records does
|
||||
* not mean mounting thousands of details trees. Only the rows around the viewport exist
|
||||
* in the DOM; selecting one opens its complete structured data below the window. */
|
||||
|
||||
const RANKS: Record<string, number> = { DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
|
||||
const RANKS: Record<string, number> = { TRACE: 5, DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
|
||||
const RETAIN = 20_000;
|
||||
const DRAW = 2_500;
|
||||
const POLL_MS = 5_000;
|
||||
const ROW_HEIGHT = 48;
|
||||
const HEADER_HEIGHT = 31;
|
||||
const OVERSCAN = 8;
|
||||
|
||||
/* The same order the server's own console lines use — who and where first, the reason for
|
||||
the line last — so a log read here and a log read over SSH look alike. The gateway
|
||||
version deliberately remains visible: a server-log export is often read away from the
|
||||
console whose top bar would otherwise supply it. */
|
||||
const FIELD_ORDER = ['component', 'user', 'device', 'client', 'protocol', 'method', 'path', 'status', 'duration', 'version', 'gateway_version'];
|
||||
const FIELD_ORDER = [
|
||||
'component',
|
||||
'user',
|
||||
'device',
|
||||
'client',
|
||||
'protocol',
|
||||
'method',
|
||||
'path',
|
||||
'status',
|
||||
'duration',
|
||||
'version',
|
||||
'gateway_version',
|
||||
];
|
||||
const FIELD_RANK = new Map(FIELD_ORDER.map((key, index) => [key, index]));
|
||||
const dateTime = new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'medium' });
|
||||
|
||||
function orderedFields(attributes: Record<string, unknown>): [string, unknown][] {
|
||||
const rank = (key: string) => {
|
||||
const at = FIELD_ORDER.indexOf(key);
|
||||
if (at >= 0) return at;
|
||||
return key === 'error' ? 1000 : 100;
|
||||
interface CachedEvent {
|
||||
fields: [string, unknown][];
|
||||
summary: string;
|
||||
haystack: string;
|
||||
occurred: string;
|
||||
}
|
||||
|
||||
// API event objects remain stable for their retained lifetime. A WeakMap gives formatting
|
||||
// and search indexing the same lifetime without adding private fields to JSON exports.
|
||||
const eventCache = new WeakMap<LogEvent, CachedEvent>();
|
||||
|
||||
function cached(event: LogEvent): CachedEvent {
|
||||
const existing = eventCache.get(event);
|
||||
if (existing) return existing;
|
||||
const fields = Object.entries(event.attributes ?? {}).sort((left, right) => {
|
||||
const leftRank = FIELD_RANK.get(left[0]) ?? (left[0] === 'error' ? 1000 : 100);
|
||||
const rightRank = FIELD_RANK.get(right[0]) ?? (right[0] === 'error' ? 1000 : 100);
|
||||
return leftRank - rightRank || left[0].localeCompare(right[0]);
|
||||
});
|
||||
const value = {
|
||||
fields,
|
||||
summary: fields.map(([key, fieldValue]) => `${key}=${String(fieldValue)}`).join(' '),
|
||||
haystack: [event.message, ...fields.flat()].join(' ').toLowerCase(),
|
||||
occurred: dateTime.format(new Date(event.occurredAt)),
|
||||
};
|
||||
return Object.entries(attributes).sort((a, b) => rank(a[0]) - rank(b[0]));
|
||||
eventCache.set(event, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
const readableKey = (key: string) => key.replace(/_/g, ' ');
|
||||
|
||||
const haystack = (event: LogEvent) =>
|
||||
[event.message, ...Object.entries(event.attributes ?? {}).flat()].join(' ').toLowerCase();
|
||||
const LogLine = memo(function LogLine({
|
||||
event,
|
||||
index,
|
||||
onInspect,
|
||||
}: {
|
||||
event: LogEvent;
|
||||
index: number;
|
||||
onInspect: (sequence: number) => void;
|
||||
}) {
|
||||
const display = cached(event);
|
||||
return (
|
||||
<div
|
||||
className="logline"
|
||||
data-level={event.level}
|
||||
data-virtual="true"
|
||||
style={{ transform: `translateY(${index * ROW_HEIGHT}px)` }}
|
||||
>
|
||||
<time title={event.occurredAt}>
|
||||
{display.occurred}
|
||||
<small>#{event.sequence}</small>
|
||||
</time>
|
||||
<span className="lvl">{event.level}</span>
|
||||
<span className="msg" title={event.message}>{event.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="logattrs-button"
|
||||
title={display.summary || 'No structured details'}
|
||||
onClick={() => onInspect(event.sequence)}
|
||||
>
|
||||
{display.summary || 'View record'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export function LogsPage() {
|
||||
const [records, setRecords] = useState<LogEvent[]>([]);
|
||||
@@ -44,67 +117,114 @@ export function LogsPage() {
|
||||
const [level, setLevel] = useState('INFO');
|
||||
const [search, setSearch] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [viewport, setViewport] = useState({ top: 0, height: 600 });
|
||||
const [selectedSequence, setSelectedSequence] = useState<number | null>(null);
|
||||
|
||||
const deferredSearch = useDeferredValue(search.trim().toLowerCase());
|
||||
const cursor = useRef(0);
|
||||
const fetching = useRef(false);
|
||||
const viewGeneration = useRef(0);
|
||||
const view = useRef<HTMLDivElement>(null);
|
||||
// Whether the reader is at the bottom is decided *before* the new lines are drawn: after
|
||||
// they are, the measurement always says "not at the bottom" and the log would never
|
||||
// follow. Hence the layout effect below rather than a check inside the fetch.
|
||||
const pinned = useRef(true);
|
||||
const scrollFrame = useRef<number | undefined>(undefined);
|
||||
|
||||
const drain = useCallback(async () => {
|
||||
if (paused || fetching.current) return;
|
||||
if (paused || fetching.current || document.hidden) return;
|
||||
fetching.current = true;
|
||||
const generation = viewGeneration.current;
|
||||
const batch: LogEvent[] = [];
|
||||
let droppedInDrain = 0;
|
||||
try {
|
||||
let pages = 0;
|
||||
let page: LogResponse;
|
||||
do {
|
||||
page = await api.get<LogResponse>(`/admin/api/events?after=${cursor.current}&limit=1000`);
|
||||
cursor.current = page.next || cursor.current;
|
||||
if (page.dropped) setDropped((current) => current + page.dropped);
|
||||
const events = page.events ?? [];
|
||||
if (events.length > 0) {
|
||||
setRecords((current) => {
|
||||
const next = [...current, ...events];
|
||||
return next.length > RETAIN ? next.slice(next.length - RETAIN) : next;
|
||||
});
|
||||
}
|
||||
droppedInDrain += page.dropped || 0;
|
||||
batch.push(...(page.events ?? []));
|
||||
pages += 1;
|
||||
} while (page.hasMore && pages < 20);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
// One React update for a complete catch-up prevents the initial 5,000-record drain
|
||||
// from redrawing the page once per network page.
|
||||
if (batch.length > 0 && generation === viewGeneration.current) {
|
||||
setRecords((current) => {
|
||||
const next = current.concat(batch);
|
||||
return next.length > RETAIN ? next.slice(next.length - RETAIN) : next;
|
||||
});
|
||||
}
|
||||
if (droppedInDrain > 0 && generation === viewGeneration.current) {
|
||||
setDropped((current) => current + droppedInDrain);
|
||||
}
|
||||
fetching.current = false;
|
||||
}
|
||||
}, [paused]);
|
||||
|
||||
useEffect(() => {
|
||||
void drain();
|
||||
if (paused) return;
|
||||
const timer = window.setInterval(() => void drain(), POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
let timer: number | undefined;
|
||||
const schedule = () => {
|
||||
window.clearInterval(timer);
|
||||
timer = document.hidden ? undefined : window.setInterval(() => void drain(), POLL_MS);
|
||||
};
|
||||
const visibilityChanged = () => {
|
||||
schedule();
|
||||
if (!document.hidden) void drain();
|
||||
};
|
||||
void drain();
|
||||
schedule();
|
||||
document.addEventListener('visibilitychange', visibilityChanged);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
document.removeEventListener('visibilitychange', visibilityChanged);
|
||||
};
|
||||
}, [drain, paused]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const minimum = RANKS[level] ?? 20;
|
||||
const needle = search.trim().toLowerCase();
|
||||
return records.filter(
|
||||
(event) => (RANKS[event.level] ?? 0) >= minimum && (!needle || haystack(event).includes(needle)),
|
||||
(event) =>
|
||||
(RANKS[event.level] ?? 0) >= minimum &&
|
||||
(!deferredSearch || cached(event).haystack.includes(deferredSearch)),
|
||||
);
|
||||
}, [records, level, search]);
|
||||
}, [records, level, deferredSearch]);
|
||||
|
||||
const visible = filtered.slice(-DRAW);
|
||||
const lastSequence = filtered.at(-1)?.sequence ?? 0;
|
||||
const bodyTop = Math.max(0, viewport.top - HEADER_HEIGHT);
|
||||
const count = Math.ceil(viewport.height / ROW_HEIGHT) + OVERSCAN * 2;
|
||||
// A restrictive filter can make the old scroll offset larger than the new body before
|
||||
// the browser dispatches its compensating scroll event. Clamp immediately so that
|
||||
// transition never paints an apparently empty log.
|
||||
const first = Math.min(
|
||||
Math.max(0, Math.floor(bodyTop / ROW_HEIGHT) - OVERSCAN),
|
||||
Math.max(0, filtered.length - count),
|
||||
);
|
||||
const windowed = filtered.slice(first, first + count);
|
||||
const selected = useMemo(
|
||||
() => records.find((event) => event.sequence === selectedSequence),
|
||||
[records, selectedSequence],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = view.current;
|
||||
if (node && pinned.current) node.scrollTop = node.scrollHeight;
|
||||
}, [visible.length]);
|
||||
if (!node || !pinned.current) return;
|
||||
node.scrollTop = node.scrollHeight;
|
||||
setViewport({ top: node.scrollTop, height: node.clientHeight });
|
||||
}, [lastSequence, deferredSearch, level]);
|
||||
|
||||
useEffect(() => () => window.cancelAnimationFrame(scrollFrame.current ?? 0), []);
|
||||
|
||||
const onScroll = () => {
|
||||
const node = view.current;
|
||||
if (node) pinned.current = node.scrollHeight - node.scrollTop - node.clientHeight < 50;
|
||||
if (!node) return;
|
||||
pinned.current = node.scrollHeight - node.scrollTop - node.clientHeight < ROW_HEIGHT;
|
||||
window.cancelAnimationFrame(scrollFrame.current ?? 0);
|
||||
scrollFrame.current = window.requestAnimationFrame(() => {
|
||||
setViewport({ top: node.scrollTop, height: node.clientHeight });
|
||||
});
|
||||
};
|
||||
|
||||
const exportJson = () => {
|
||||
@@ -145,56 +265,69 @@ export function LogsPage() {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
// A page already in flight may finish after this click. Advancing the
|
||||
// generation makes that batch part of the cleared past, not a flash of
|
||||
// old lines reappearing after the view was emptied.
|
||||
viewGeneration.current += 1;
|
||||
setRecords([]);
|
||||
setDropped(0);
|
||||
setSelectedSequence(null);
|
||||
}}
|
||||
>
|
||||
Clear view
|
||||
</Button>
|
||||
<Button onClick={exportJson} icon="download">
|
||||
Export JSON
|
||||
</Button>
|
||||
<Button onClick={exportJson} icon="download">Export JSON</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="logview" ref={view} onScroll={onScroll} role="log" aria-live="polite">
|
||||
<div className="logview" ref={view} onScroll={onScroll} role="log" aria-label="Server events">
|
||||
<div className="loghead" aria-hidden="true">
|
||||
<span>Time</span>
|
||||
<span>Level</span>
|
||||
<span>Event</span>
|
||||
<span>Details</span>
|
||||
</div>
|
||||
{visible.length === 0 ? (
|
||||
{filtered.length === 0 ? (
|
||||
<p className="empty">{records.length === 0 ? 'Waiting for server events…' : 'No events match this filter.'}</p>
|
||||
) : (
|
||||
visible.map((event, index) => (
|
||||
<div className="logline" key={`${event.occurredAt}:${index}`} data-level={event.level}>
|
||||
<time title={`Log record ${event.sequence}`}>{when(event.occurredAt)}<small>#{event.sequence}</small></time>
|
||||
{/* The level is a class on the line as well as its own column: scrolling a
|
||||
log is looking for the one line that is not INFO, and a coloured word
|
||||
four columns in is easy to scroll past. */}
|
||||
<span className="lvl">{event.level}</span>
|
||||
<span className="msg">{event.message}</span>
|
||||
<span className="attrs">
|
||||
<details className="logdetails">
|
||||
<summary>{orderedFields(event.attributes ?? {}).map(([key, value]) => <span key={key}><b>{key}=</b>{String(value)} </span>)}</summary>
|
||||
<dl>
|
||||
<dt>Log record</dt><dd>{event.sequence}</dd>
|
||||
{orderedFields(event.attributes ?? {}).map(([key, value]) => <Fragment key={key}><dt>{readableKey(key)}</dt><dd>{String(value)}</dd></Fragment>)}
|
||||
</dl>
|
||||
</details>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
<div className="logbody" style={{ height: `${filtered.length * ROW_HEIGHT}px` }}>
|
||||
{windowed.map((event, offset) => (
|
||||
<LogLine key={event.sequence} event={event} index={first + offset} onInspect={setSelectedSequence} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="hint">
|
||||
{num(records.length)} retained · {num(filtered.length)} matching
|
||||
{visible.length < filtered.length ? ` · showing the latest ${num(visible.length)}` : ''}
|
||||
{filtered.length ? ` · ${num(windowed.length)} rows mounted` : ''}
|
||||
{dropped ? ` · ${num(dropped)} overwritten before delivery` : ''}
|
||||
{paused ? ' · paused' : ''}
|
||||
</p>
|
||||
|
||||
{selected ? (
|
||||
<section className="log-inspector" aria-label={`Log record ${selected.sequence}`}>
|
||||
<div className="log-inspector-head">
|
||||
<div>
|
||||
<b>{selected.message}</b>
|
||||
<span>{cached(selected).occurred} · {selected.level} · record #{selected.sequence}</span>
|
||||
</div>
|
||||
<Button size="sm" variant="quiet" onClick={() => setSelectedSequence(null)}>Close</Button>
|
||||
</div>
|
||||
{cached(selected).fields.length ? (
|
||||
<dl>
|
||||
{cached(selected).fields.map(([key, fieldValue]) => (
|
||||
<Fragment key={key}>
|
||||
<dt>{readableKey(key)}</dt>
|
||||
<dd>{String(fieldValue)}</dd>
|
||||
</Fragment>
|
||||
))}
|
||||
</dl>
|
||||
) : (
|
||||
<Note>No structured details were attached to this record.</Note>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { ReleaseBuilderStatus } from '../api/types';
|
||||
import { useAction } from '../lib/hooks';
|
||||
import { useGateway } from '../lib/gateway';
|
||||
import { useToast } from '../lib/toast';
|
||||
import { Banner, Button, Card, Confirm, Field, Loading, PageHead, Tag, Toggle } from '../components/ui';
|
||||
import { Banner, Button, Card, Confirm, Field, Loading, Note, PageHead, Tag, Toggle } from '../components/ui';
|
||||
|
||||
interface Draft {
|
||||
version: string;
|
||||
@@ -20,6 +21,13 @@ export function UpdatesPage() {
|
||||
const { busy, run } = useAction();
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [releaseConfirming, setReleaseConfirming] = useState(false);
|
||||
const [releaseTag, setReleaseTag] = useState('');
|
||||
const [releaseNotes, setReleaseNotes] = useState('');
|
||||
const [releaseMandatory, setReleaseMandatory] = useState(false);
|
||||
const [builder, setBuilder] = useState<ReleaseBuilderStatus | null>(null);
|
||||
const [builderError, setBuilderError] = useState('');
|
||||
const completedAt = useRef('');
|
||||
|
||||
const policy = status?.updatePolicy;
|
||||
|
||||
@@ -39,6 +47,32 @@ export function UpdatesPage() {
|
||||
});
|
||||
}, [policy, draft]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const next = await api.get<ReleaseBuilderStatus>('/admin/api/release-builder');
|
||||
if (!active) return;
|
||||
setBuilder(next);
|
||||
setBuilderError('');
|
||||
if (next.state === 'succeeded' && next.finishedAt && completedAt.current !== next.finishedAt) {
|
||||
completedAt.current = next.finishedAt;
|
||||
setDraft(null);
|
||||
await reload();
|
||||
}
|
||||
} catch (cause) {
|
||||
if (!active) return;
|
||||
setBuilderError(cause instanceof Error ? cause.message : 'The release builder is unavailable.');
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
const timer = window.setInterval(() => void poll(), 3000);
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
const save = (enabled: boolean) =>
|
||||
run(enabled ? 'save' : 'off', async () => {
|
||||
if (!draft) return;
|
||||
@@ -66,10 +100,92 @@ export function UpdatesPage() {
|
||||
|
||||
const patch = (next: Partial<Draft>) => setDraft((current) => (current ? { ...current, ...next } : current));
|
||||
|
||||
const startRelease = () =>
|
||||
run('release', async () => {
|
||||
const next = await wrap(
|
||||
() =>
|
||||
api.post<ReleaseBuilderStatus>('/admin/api/release-builder', {
|
||||
tag: releaseTag.trim(),
|
||||
notes: releaseNotes.trim(),
|
||||
mandatory: releaseMandatory,
|
||||
}),
|
||||
'Memby release started.',
|
||||
);
|
||||
if (next) {
|
||||
setBuilder(next);
|
||||
setBuilderError('');
|
||||
setReleaseConfirming(false);
|
||||
}
|
||||
});
|
||||
|
||||
const builderTone =
|
||||
builder?.state === 'succeeded'
|
||||
? 'ok'
|
||||
: builder?.state === 'failed'
|
||||
? 'bad'
|
||||
: builder?.state === 'running'
|
||||
? 'warn'
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="App updates" intro="Publish an optional or a required client update." />
|
||||
<Banner message={error} />
|
||||
<Banner message={builderError} />
|
||||
|
||||
<Card
|
||||
title="Build and publish"
|
||||
intro="Build the latest tagged Android app in Docker, sign it with Memby's existing certificate, verify it, and publish it to televisions. The builder runs separately from the gateway."
|
||||
icon="download"
|
||||
tone="data"
|
||||
actions={<Tag tone={builderTone}>{builder?.state ?? 'checking'}</Tag>}
|
||||
footer={
|
||||
<Button
|
||||
variant="primary"
|
||||
busy={busy === 'release' || builder?.state === 'running'}
|
||||
disabled={Boolean(builderError)}
|
||||
onClick={() => (releaseMandatory ? setReleaseConfirming(true) : void startRelease())}
|
||||
>
|
||||
{releaseTag.trim() ? `Build ${releaseTag.trim()}` : 'Build latest release'}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="fields">
|
||||
<Field
|
||||
label="GitHub tag override"
|
||||
hint="Leave blank for the latest semantic tag. An exact tag bypasses discovery or repeats that tag; the gateway still refuses downgrades."
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={releaseTag}
|
||||
disabled={builder?.state === 'running'}
|
||||
placeholder="v0.2.64 (blank uses latest)"
|
||||
onChange={(event) => setReleaseTag(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Release notes" hint="Leave blank to use this version's CHANGELOG entry.">
|
||||
<input
|
||||
type="text"
|
||||
value={releaseNotes}
|
||||
disabled={builder?.state === 'running'}
|
||||
placeholder="What's new on the television"
|
||||
onChange={(event) => setReleaseNotes(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Toggle
|
||||
label="Make this update required"
|
||||
hint="Older televisions cannot dismiss the update prompt. You will confirm before the build starts."
|
||||
checked={releaseMandatory}
|
||||
disabled={builder?.state === 'running'}
|
||||
onChange={setReleaseMandatory}
|
||||
/>
|
||||
{builder?.message ? <Note tone={builderTone}>{builder.message}</Note> : null}
|
||||
{builder?.logs?.length ? <pre className="code">{builder.logs.join('\n')}</pre> : null}
|
||||
<Note>
|
||||
Command-line fallback: <code>{builder?.fallback ?? 'docker compose run --rm --build memby-builder release'}</code>
|
||||
</Note>
|
||||
</Card>
|
||||
|
||||
{loading || !draft ? (
|
||||
<Loading rows={1} />
|
||||
@@ -187,6 +303,17 @@ export function UpdatesPage() {
|
||||
onCancel={() => setConfirming(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{releaseConfirming ? (
|
||||
<Confirm
|
||||
title="Build a required update?"
|
||||
body="When this signed APK is published, every older television will be blocked until it installs the update."
|
||||
confirmLabel="Build and publish"
|
||||
busy={busy === 'release'}
|
||||
onConfirm={() => void startRelease()}
|
||||
onCancel={() => setReleaseConfirming(false)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+818
-32
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user