This commit is contained in:
ponzischeme89
2026-08-23 13:20:54 +12:00
parent 766cea2199
commit 89ebe21201
35 changed files with 1000 additions and 195 deletions
+2
View File
@@ -40,6 +40,7 @@ import { SearchesPage } from './pages/Searches';
import { ViewsPage } from './pages/Views';
import { MediaReportsPage } from './pages/MediaReports';
import { NotificationsPage } from './pages/Notifications';
import { NotificationSettingsPage } from './pages/NotificationSettings';
import { CreditsPage } from './pages/Credits';
/* The console's routing table.
@@ -109,6 +110,7 @@ export function App() {
<Route path="searches" element={<SearchesPage />} />
<Route path="media-reports" element={<MediaReportsPage />} />
<Route path="notifications" element={<NotificationsPage />} />
<Route path="notification-settings" element={<NotificationSettingsPage />} />
{/* The old console redirected /admin/ to /admin/overview. Anything that
still links there lands on the overview rather than on a 404. */}
+16
View File
@@ -797,6 +797,22 @@ export interface GatewaySettingsResponse {
version: string;
}
/* ---------- television notifications ---------- */
/** NotificationPreferences is the per-person policy the gateway applies before anything
* reaches My Alerts or an informational banner on a television. */
export interface NotificationPreferences {
enabled: boolean;
showReturnAlerts: boolean;
sonarrAlerts: boolean;
radarrAlerts: boolean;
updateAlerts: boolean;
libraryAlerts: boolean;
systemAlerts: boolean;
watchTimeDigest: boolean;
leadDays: number;
}
/* ---------- metadata hero ---------- */
export interface MetadataHeroOption {
+8
View File
@@ -87,6 +87,14 @@ export const nav: NavGroup[] = [
intro: 'Everything Memby sent: who it went to, over which channel, and whether it worked.',
icon: 'send',
},
{
id: 'notification-settings',
path: '/admin/notification-settings',
label: 'TV notifications',
title: 'TV notifications',
intro: 'Choose where notifications appear and who receives them.',
icon: 'bell',
},
{
id: 'media-reports',
path: '/admin/media-reports',
+1 -13
View File
@@ -19,7 +19,7 @@ import {
Tiles,
Toggle,
} from '../components/ui';
import type { DeviceVersion } from '../api/types';
import type { DeviceVersion, NotificationPreferences } from '../api/types';
/* One person: their televisions, their recommendation setup and the settings that follow
them to every set. The identity is in the URL rather than in a query string so the page
@@ -95,18 +95,6 @@ interface RecommendationState {
contentTypes?: string[];
}
interface NotificationPreferences {
enabled: boolean;
showReturnAlerts: boolean;
sonarrAlerts: boolean;
radarrAlerts: boolean;
updateAlerts: boolean;
libraryAlerts: boolean;
systemAlerts: boolean;
watchTimeDigest: boolean;
leadDays: number;
}
/* Tracearr's reading of this person. `matched` separates "no Tracearr, or nobody by this
name in it" from "has watched nothing", which are the same row of zeroes on the wire and
very different things for an operator to be told. */
+223
View File
@@ -0,0 +1,223 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api/client';
import type {
GatewaySettingsResponse,
NotificationPreferences,
} from '../api/types';
import { Banner, Button, Card, Grid, Loading, PageHead, Tag, Toggle } from '../components/ui';
import { useAction, useQuery } from '../lib/hooks';
import { useToast } from '../lib/toast';
interface NotificationAccount {
id: string;
username: string;
shortName?: string;
notifications: NotificationPreferences;
}
interface AccountsResponse {
accounts: NotificationAccount[] | null;
}
const displayChoices = [
{
value: 'everywhere',
label: 'Everywhere',
description: 'Show informational banners over the home screen and active playback.',
},
{
value: 'home_only',
label: 'Home only',
description: 'Show banners on the launcher, without interrupting a film or programme.',
},
{
value: 'off',
label: 'Off',
description: 'Do not show informational banners on any television.',
},
];
/** A dedicated control surface for what televisions show. The notification history keeps
* answering what happened; this page answers what is allowed to happen next. */
export function NotificationSettingsPage() {
const gateway = useQuery<GatewaySettingsResponse>('/admin/api/gateway-settings');
const accounts = useQuery<AccountsResponse>('/admin/api/accounts');
const { busy, run } = useAction();
const { wrap } = useToast();
const [display, setDisplay] = useState<string | null>(null);
const [preferences, setPreferences] = useState<Record<string, NotificationPreferences>>({});
useEffect(() => {
if (display === null && gateway.data) {
setDisplay(gateway.data.settings.notificationDisplay || 'home_only');
}
}, [display, gateway.data]);
useEffect(() => {
const loaded = accounts.data;
if (!loaded) return;
setPreferences((current) => {
const next = { ...current };
for (const account of loaded.accounts ?? []) {
if (!next[account.id]) next[account.id] = { ...account.notifications };
}
return next;
});
}, [accounts.data]);
const saveDisplay = () =>
run('display', async () => {
if (!gateway.data || display === null) return;
const saved = await wrap(
async () => {
// The endpoint stores one server-settings document. Read it again immediately
// before changing this field so a save in another console tab is not replaced
// by the older copy this page originally loaded.
const latest = await api.get<GatewaySettingsResponse>('/admin/api/gateway-settings');
return api.post<GatewaySettingsResponse>('/admin/api/gateway-settings', {
...latest.settings,
notificationDisplay: display,
});
},
'Television notification display saved.',
);
if (saved) {
gateway.set(saved);
setDisplay(saved.settings.notificationDisplay);
}
});
const saveAccount = (account: NotificationAccount) =>
run(`account-${account.id}`, async () => {
const next = preferences[account.id];
if (!next) return;
const saved = await wrap(
() => api.put<NotificationPreferences>(
`/admin/api/accounts/${encodeURIComponent(account.id)}/notifications`,
next,
),
`Notification settings saved for ${account.shortName || account.username}.`,
);
if (saved) {
setPreferences((current) => ({ ...current, [account.id]: saved }));
accounts.set({
accounts: (accounts.data?.accounts ?? []).map((entry) =>
entry.id === account.id ? { ...entry, notifications: saved } : entry,
),
});
}
});
const error = gateway.error || accounts.error;
const rows = accounts.data?.accounts ?? [];
return (
<>
<PageHead
title="TV notifications"
intro="Choose where household notices may appear, then mute or allow them for each person."
actions={<Link className="btn" to="/admin/notifications">View notification history</Link>}
/>
<Banner message={error} />
{gateway.loading || display === null ? (
<Loading rows={2} />
) : (
<Card
title="Where notifications appear"
intro="This household-wide rule applies to every user and television. Maintenance and mandatory update screens still appear because they are not optional notifications."
icon="tv"
tone="info"
actions={<Tag tone={display === 'off' ? undefined : 'ok'}>{displayChoices.find((choice) => choice.value === display)?.label ?? 'Home only'}</Tag>}
footer={
<Button
variant="primary"
icon="check"
busy={busy === 'display'}
onClick={() => void saveDisplay()}
>
Save display rule
</Button>
}
>
<div className="checks columns">
{displayChoices.map((choice) => (
<label className="check" key={choice.value}>
<input
type="radio"
name="notification-display"
checked={display === choice.value}
onChange={() => setDisplay(choice.value)}
/>
<span className="switch" aria-hidden="true" />
<span className="check-body">
<b>{choice.label}</b>
<p>{choice.description}</p>
</span>
</label>
))}
</div>
</Card>
)}
<Card
title="People"
intro="A person's master switch follows them to every television. Open their user page to choose individual notification types."
icon="people"
tone="note"
>
{accounts.loading ? (
<Loading />
) : rows.length === 0 ? (
<p className="empty">No one has signed in to Memby yet.</p>
) : (
<Grid cols="2">
{rows.map((account) => {
const value = preferences[account.id] ?? account.notifications;
const name = account.shortName || account.username || 'Unnamed user';
const changed = value.enabled !== account.notifications.enabled;
return (
<Card
key={account.id}
title={name}
intro={account.shortName ? account.username : 'Memby user'}
icon="person"
tone="note"
actions={<Tag tone={value.enabled ? 'ok' : undefined}>{value.enabled ? 'enabled' : 'muted'}</Tag>}
footer={
<>
<Button
variant="primary"
busy={busy === `account-${account.id}`}
disabled={!changed}
onClick={() => void saveAccount(account)}
>
Save
</Button>
<Link className="btn" to={`/admin/accounts/${encodeURIComponent(account.id)}`}>
Choose notification types
</Link>
</>
}
>
<Toggle
label="All notifications"
hint="Turn off every optional notification for this person."
checked={value.enabled}
onChange={(enabled) =>
setPreferences((current) => ({
...current,
[account.id]: { ...value, enabled },
}))
}
/>
</Card>
);
})}
</Grid>
)}
</Card>
</>
);
}
+1
View File
@@ -158,6 +158,7 @@ export function NotificationsPage() {
<PageHead
title="Notifications"
intro="Everything Memby sent — a viewer's own news, the bar every television draws, and each outbound webhook — with what became of it. Every feature reports through one notification service, so this is the whole trail rather than whichever half a feature remembered to log."
actions={<Link className="btn" to="/admin/notification-settings">TV notification settings</Link>}
/>
<Banner message={log.error} />