0.2.64 update

This commit is contained in:
ponzischeme89
2026-08-15 09:23:26 +12:00
parent a2ca7e8061
commit d5d47473a2
90 changed files with 9188 additions and 451 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -56,4 +56,4 @@ Error generating stack: `+i.message+`
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/function fl(){return fl=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)({}).hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},fl.apply(null,arguments)}function jc(e,t){if(e==null)return{};var n={};for(var r in e)if({}.hasOwnProperty.call(e,r)){if(t.indexOf(r)!==-1)continue;n[r]=e[r]}return n}function nh(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function rh(e,t){return e.button===0&&(!t||t==="_self")&&!nh(e)}const lh=["onClick","relative","reloadDocument","replace","state","target","to","preventScrollReset","viewTransition"],ih=["aria-current","caseSensitive","className","end","style","to","viewTransition","children"],uh="6";try{window.__reactRouterVersion=uh}catch{}const oh=k.createContext({isTransitioning:!1}),ah="startTransition",ma=lf[ah];function Sh(e){let{basename:t,children:n,future:r,window:l}=e,i=k.useRef();i.current==null&&(i.current=cp({window:l,v5Compat:!0}));let u=i.current,[o,a]=k.useState({action:u.action,location:u.location}),{v7_startTransition:s}=r||{},h=k.useCallback(p=>{s&&ma?ma(()=>a(p)):a(p)},[a,s]);return k.useLayoutEffect(()=>u.listen(h),[u,h]),k.useEffect(()=>bp(r),[r]),k.createElement(th,{basename:t,children:n,location:o.location,navigationType:o.action,navigator:u,future:r})}const sh=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u",ch=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,fh=k.forwardRef(function(t,n){let{onClick:r,relative:l,reloadDocument:i,replace:u,state:o,target:a,to:s,preventScrollReset:h,viewTransition:p}=t,m=jc(t,lh),{basename:g}=k.useContext(Je),y,w=!1;if(typeof s=="string"&&ch.test(s)&&(y=s,sh))try{let d=new URL(window.location.href),v=s.startsWith("//")?new URL(d.protocol+s):new URL(s),E=sn(v.pathname,g);v.origin===d.origin&&E!=null?s=E+v.search+v.hash:w=!0}catch{}let C=Fp(s,{relative:l}),f=ph(s,{replace:u,state:o,target:a,preventScrollReset:h,relative:l,viewTransition:p});function c(d){r&&r(d),d.defaultPrevented||f(d)}return k.createElement("a",fl({},m,{href:y||C,onClick:w||i?r:c,ref:n,target:a}))}),kh=k.forwardRef(function(t,n){let{"aria-current":r="page",caseSensitive:l=!1,className:i="",end:u=!1,style:o,to:a,viewTransition:s,children:h}=t,p=jc(t,ih),m=zl(a,{relative:p.relative}),g=mn(),y=k.useContext(Rc),{navigator:w,basename:C}=k.useContext(Je),f=y!=null&&hh(m)&&s===!0,c=w.encodeLocation?w.encodeLocation(m).pathname:m.pathname,d=g.pathname,v=y&&y.navigation&&y.navigation.location?y.navigation.location.pathname:null;l||(d=d.toLowerCase(),v=v?v.toLowerCase():null,c=c.toLowerCase()),v&&C&&(v=sn(v,C)||v);const E=c!=="/"&&c.endsWith("/")?c.length-1:c.length;let _=d===c||!u&&d.startsWith(c)&&d.charAt(E)==="/",N=v!=null&&(v===c||!u&&v.startsWith(c)&&v.charAt(c.length)==="/"),L={isActive:_,isPending:N,isTransitioning:f},$=_?r:void 0,T;typeof i=="function"?T=i(L):T=[i,_?"active":null,N?"pending":null,f?"transitioning":null].filter(Boolean).join(" ");let me=typeof o=="function"?o(L):o;return k.createElement(fh,fl({},p,{"aria-current":$,className:T,ref:n,style:me,to:a,viewTransition:s}),typeof h=="function"?h(L):h)});var tu;(function(e){e.UseScrollRestoration="useScrollRestoration",e.UseSubmit="useSubmit",e.UseSubmitFetcher="useSubmitFetcher",e.UseFetcher="useFetcher",e.useViewTransitionState="useViewTransitionState"})(tu||(tu={}));var va;(function(e){e.UseFetcher="useFetcher",e.UseFetchers="useFetchers",e.UseScrollRestoration="useScrollRestoration"})(va||(va={}));function dh(e){let t=k.useContext(Nl);return t||A(!1),t}function ph(e,t){let{target:n,replace:r,state:l,preventScrollReset:i,relative:u,viewTransition:o}=t===void 0?{}:t,a=Ic(),s=mn(),h=zl(e,{relative:u});return k.useCallback(p=>{if(rh(p,n)){p.preventDefault();let m=r!==void 0?r:cl(s)===cl(h);a(e,{replace:m,state:l,preventScrollReset:i,relative:u,viewTransition:o})}},[s,a,h,r,l,n,e,i,u,o])}function hh(e,t){t===void 0&&(t={});let n=k.useContext(oh);n==null&&A(!1);let{basename:r}=dh(tu.useViewTransitionState),l=zl(e,{relative:t.relative});if(!n.isTransitioning)return!1;let i=sn(n.currentLocation.pathname,r)||n.currentLocation.pathname,u=sn(n.nextLocation.pathname,r)||n.nextLocation.pathname;return bi(l.pathname,u)!=null||bi(l.pathname,i)!=null}export{Sh as B,fh as L,kh as N,gh as O,wh as R,mh as a,mn as b,vh as c,eh as d,yh as e,k as r,Ic as u};
*/function fl(){return fl=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)({}).hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},fl.apply(null,arguments)}function jc(e,t){if(e==null)return{};var n={};for(var r in e)if({}.hasOwnProperty.call(e,r)){if(t.indexOf(r)!==-1)continue;n[r]=e[r]}return n}function nh(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function rh(e,t){return e.button===0&&(!t||t==="_self")&&!nh(e)}const lh=["onClick","relative","reloadDocument","replace","state","target","to","preventScrollReset","viewTransition"],ih=["aria-current","caseSensitive","className","end","style","to","viewTransition","children"],uh="6";try{window.__reactRouterVersion=uh}catch{}const oh=k.createContext({isTransitioning:!1}),ah="startTransition",ma=lf[ah];function Sh(e){let{basename:t,children:n,future:r,window:l}=e,i=k.useRef();i.current==null&&(i.current=cp({window:l,v5Compat:!0}));let u=i.current,[o,a]=k.useState({action:u.action,location:u.location}),{v7_startTransition:s}=r||{},h=k.useCallback(p=>{s&&ma?ma(()=>a(p)):a(p)},[a,s]);return k.useLayoutEffect(()=>u.listen(h),[u,h]),k.useEffect(()=>bp(r),[r]),k.createElement(th,{basename:t,children:n,location:o.location,navigationType:o.action,navigator:u,future:r})}const sh=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u",ch=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,fh=k.forwardRef(function(t,n){let{onClick:r,relative:l,reloadDocument:i,replace:u,state:o,target:a,to:s,preventScrollReset:h,viewTransition:p}=t,m=jc(t,lh),{basename:g}=k.useContext(Je),y,w=!1;if(typeof s=="string"&&ch.test(s)&&(y=s,sh))try{let d=new URL(window.location.href),v=s.startsWith("//")?new URL(d.protocol+s):new URL(s),E=sn(v.pathname,g);v.origin===d.origin&&E!=null?s=E+v.search+v.hash:w=!0}catch{}let C=Fp(s,{relative:l}),f=ph(s,{replace:u,state:o,target:a,preventScrollReset:h,relative:l,viewTransition:p});function c(d){r&&r(d),d.defaultPrevented||f(d)}return k.createElement("a",fl({},m,{href:y||C,onClick:w||i?r:c,ref:n,target:a}))}),kh=k.forwardRef(function(t,n){let{"aria-current":r="page",caseSensitive:l=!1,className:i="",end:u=!1,style:o,to:a,viewTransition:s,children:h}=t,p=jc(t,ih),m=zl(a,{relative:p.relative}),g=mn(),y=k.useContext(Rc),{navigator:w,basename:C}=k.useContext(Je),f=y!=null&&hh(m)&&s===!0,c=w.encodeLocation?w.encodeLocation(m).pathname:m.pathname,d=g.pathname,v=y&&y.navigation&&y.navigation.location?y.navigation.location.pathname:null;l||(d=d.toLowerCase(),v=v?v.toLowerCase():null,c=c.toLowerCase()),v&&C&&(v=sn(v,C)||v);const E=c!=="/"&&c.endsWith("/")?c.length-1:c.length;let _=d===c||!u&&d.startsWith(c)&&d.charAt(E)==="/",N=v!=null&&(v===c||!u&&v.startsWith(c)&&v.charAt(c.length)==="/"),L={isActive:_,isPending:N,isTransitioning:f},$=_?r:void 0,T;typeof i=="function"?T=i(L):T=[i,_?"active":null,N?"pending":null,f?"transitioning":null].filter(Boolean).join(" ");let me=typeof o=="function"?o(L):o;return k.createElement(fh,fl({},p,{"aria-current":$,className:T,ref:n,style:me,to:a,viewTransition:s}),typeof h=="function"?h(L):h)});var tu;(function(e){e.UseScrollRestoration="useScrollRestoration",e.UseSubmit="useSubmit",e.UseSubmitFetcher="useSubmitFetcher",e.UseFetcher="useFetcher",e.useViewTransitionState="useViewTransitionState"})(tu||(tu={}));var va;(function(e){e.UseFetcher="useFetcher",e.UseFetchers="useFetchers",e.UseScrollRestoration="useScrollRestoration"})(va||(va={}));function dh(e){let t=k.useContext(Nl);return t||A(!1),t}function ph(e,t){let{target:n,replace:r,state:l,preventScrollReset:i,relative:u,viewTransition:o}=t===void 0?{}:t,a=Ic(),s=mn(),h=zl(e,{relative:u});return k.useCallback(p=>{if(rh(p,n)){p.preventDefault();let m=r!==void 0?r:cl(s)===cl(h);a(e,{replace:m,state:l,preventScrollReset:i,relative:u,viewTransition:o})}},[s,a,h,r,l,n,e,i,u,o])}function hh(e,t){t===void 0&&(t={});let n=k.useContext(oh);n==null&&A(!1);let{basename:r}=dh(tu.useViewTransitionState),l=zl(e,{relative:t.relative});if(!n.isTransitioning)return!1;let i=sn(n.currentLocation.pathname,r)||n.currentLocation.pathname,u=sn(n.nextLocation.pathname,r)||n.nextLocation.pathname;return bi(l.pathname,u)!=null||bi(l.pathname,i)!=null}export{Sh as B,fh as L,kh as N,gh as O,wh as R,mh as a,mn as b,vh as c,eh as d,yh as e,bi as m,k as r,Ic as u};
-1
View File
@@ -1 +0,0 @@
import"./router-BwjLFE7Y.js";
+1
View File
@@ -0,0 +1 @@
import"./router-D9WH5XEU.js";
+5 -4
View File
@@ -2,19 +2,20 @@
<html lang="en-NZ">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<!-- The console is behind an admin token and is nobody's search result. -->
<meta name="robots" content="noindex, nofollow" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#010409" />
<title>Memby admin</title>
<!-- An SVG favicon, inline, because the console must fetch nothing from anywhere. -->
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E"
/>
<script type="module" crossorigin src="/admin/assets/index-Ol_zP3CI.js"></script>
<link rel="modulepreload" crossorigin href="/admin/assets/router-BwjLFE7Y.js">
<link rel="stylesheet" crossorigin href="/admin/assets/index-CXwJRCVF.css">
<script type="module" crossorigin src="/admin/assets/index-Ax7UuMTz.js"></script>
<link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js">
<link rel="stylesheet" crossorigin href="/admin/assets/index-DSlxKU2t.css">
</head>
<body>
<div id="root"></div>
+2 -1
View File
@@ -2,10 +2,11 @@
<html lang="en-NZ">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<!-- The console is behind an admin token and is nobody's search result. -->
<meta name="robots" content="noindex, nofollow" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#010409" />
<title>Memby admin</title>
<!-- An SVG favicon, inline, because the console must fetch nothing from anywhere. -->
<link
+17 -2
View File
@@ -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;
+68 -13
View File
@@ -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">
+14 -3
View File
@@ -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
View File
@@ -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',
},
],
},
];
+116
View File
@@ -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
View File
@@ -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 Membys 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 Membys 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>
);
}
+1 -1
View File
@@ -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
View File
@@ -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>
</>
);
+129 -2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff