0.3.07
This commit is contained in:
@@ -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. */}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user