@columenlabs/lumen-ui 0.1.183 → 0.1.185

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,11 +9,23 @@ export declare function LumenExpandList({ defaultOpen, className, children, }: {
9
9
  * One row that expands in place. Inside a LumenExpandList it opens by `id`; `open` and
10
10
  * `onOpenChange` make it controlled; alone it keeps its own state (`defaultOpen`).
11
11
  */
12
- export declare function LumenExpandRow({ id, name, meta, badge, icon, open: openProp, onOpenChange, defaultOpen, children, }: {
12
+ export declare function LumenExpandRow({ id, name, meta, badge, status, toggle, actions, icon, open: openProp, onOpenChange, defaultOpen, children, }: {
13
13
  id?: string;
14
14
  name: ReactNode;
15
15
  meta?: ReactNode;
16
+ /** Old name for `status`. */
16
17
  badge?: ReactNode;
18
+ /** The thing's status pill (`SettingStatus`): Works, Failed, Not tested, Off, or a count. */
19
+ status?: ReactNode;
20
+ /** Turns the whole thing on or off: a switch in the header, outside the expand button. */
21
+ toggle?: {
22
+ checked: boolean;
23
+ onCheckedChange: (on: boolean) => void;
24
+ label: string;
25
+ disabled?: boolean;
26
+ };
27
+ /** The action bar under the rows (`SettingActions`). */
28
+ actions?: ReactNode;
17
29
  /** A logo or icon before the name. */
18
30
  icon?: ReactNode;
19
31
  open?: boolean;
@@ -1,11 +1,14 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // A stack of rows that start collapsed and expand in place: a settings page's providers, channels or
3
3
  // accounts, each row's name, a line about it and a badge, its own settings inside. One row is open
4
- // at a time within a LumenExpandList.
4
+ // at a time within a LumenExpandList. The header holds the thing's `status` pill and the `toggle` that
5
+ // turns the whole thing on or off; the body is `SettingField` rows; `actions` is its action bar
6
+ // (`SettingActions`: last result, Test, Remove). See setting-parts.tsx.
5
7
  import { ChevronRightIcon } from 'lucide-react';
6
8
  import { createContext, useContext, useState } from 'react';
7
9
  import { cn } from './lib/utils.js';
8
10
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible.js';
11
+ import { Switch } from './ui/switch.js';
9
12
  const ListCtx = createContext(null);
10
13
  /** Rows that start collapsed; opening one closes the others. `defaultOpen` names a row open at first. */
11
14
  export function LumenExpandList({ defaultOpen = null, className, children, }) {
@@ -16,7 +19,7 @@ export function LumenExpandList({ defaultOpen = null, className, children, }) {
16
19
  * One row that expands in place. Inside a LumenExpandList it opens by `id`; `open` and
17
20
  * `onOpenChange` make it controlled; alone it keeps its own state (`defaultOpen`).
18
21
  */
19
- export function LumenExpandRow({ id, name, meta, badge, icon, open: openProp, onOpenChange, defaultOpen = false, children, }) {
22
+ export function LumenExpandRow({ id, name, meta, badge, status, toggle, actions, icon, open: openProp, onOpenChange, defaultOpen = false, children, }) {
20
23
  const list = useContext(ListCtx);
21
24
  const [own, setOwn] = useState(defaultOpen);
22
25
  const inList = list && id !== undefined;
@@ -29,5 +32,7 @@ export function LumenExpandRow({ id, name, meta, badge, icon, open: openProp, on
29
32
  else
30
33
  setOwn(o);
31
34
  };
32
- return (_jsxs(Collapsible, { open: open, onOpenChange: change, className: "border-b last:border-b-0", children: [_jsx(CollapsibleTrigger, { asChild: true, children: _jsxs("button", { type: "button", className: "flex w-full items-center gap-3 px-1 py-3 text-left hover:bg-muted/50", children: [icon, _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: "block truncate font-medium", title: typeof name === 'string' ? name : undefined, children: name }), meta && (_jsx("span", { className: "block truncate text-xs text-muted-foreground", title: typeof meta === 'string' ? meta : undefined, children: meta }))] }), badge, _jsx(ChevronRightIcon, { className: cn('size-4 shrink-0 text-muted-foreground transition-transform', open && 'rotate-90') })] }) }), _jsx(CollapsibleContent, { className: "grid grid-cols-1 gap-5 px-1 pt-1 pb-5", children: children })] }));
35
+ const chevron = (_jsx(ChevronRightIcon, { className: cn('size-4 shrink-0 text-muted-foreground transition-transform', open && 'rotate-90') }));
36
+ const label = typeof name === 'string' ? name : undefined;
37
+ return (_jsxs(Collapsible, { open: open, onOpenChange: change, className: "border-b last:border-b-0", children: [_jsxs("div", { className: "flex w-full min-w-0 items-center gap-3 px-1 hover:bg-muted/50", children: [_jsx(CollapsibleTrigger, { asChild: true, children: _jsxs("button", { type: "button", className: "flex min-w-0 flex-1 items-center gap-3 py-3 text-left", children: [icon, _jsxs("span", { className: "flex min-w-0 flex-1 items-baseline gap-2", children: [_jsx("span", { className: "shrink-0 truncate font-medium", title: label, children: name }), meta && (_jsx("span", { className: "min-w-0 truncate text-sm text-muted-foreground", title: typeof meta === 'string' ? meta : undefined, children: meta }))] }), status ?? badge, !toggle && chevron] }) }), toggle && (_jsxs(_Fragment, { children: [_jsx(Switch, { checked: toggle.checked, onCheckedChange: toggle.onCheckedChange, disabled: toggle.disabled, "aria-label": toggle.label }), _jsx(CollapsibleTrigger, { asChild: true, children: _jsx("button", { type: "button", className: "-mr-1 flex min-h-10 min-w-8 items-center justify-center", "aria-label": `${open ? 'Collapse' : 'Expand'}${label ? ` ${label}` : ''}`, children: chevron }) })] }))] }), _jsxs(CollapsibleContent, { "data-slot": "expand-body", className: "grid grid-cols-1 gap-5 px-1 pt-1 pb-5 has-[[data-slot=setting-field]]:gap-0 has-[[data-slot=setting-field]]:pb-3 has-[[data-slot=setting-field]]:pl-8", children: [children, actions] })] }));
33
38
  }
package/dist/index.d.ts CHANGED
@@ -55,6 +55,7 @@ export { ConnectionBlock, ErrorLine, IntegrationRow, KeyTestLine, type KeyTestRe
55
55
  export { ConnectionBadge, type ConnectionField, type ConnectionLine, ConnectionLogo, type ConnectionSignin, type ConnectionStatus, type ConnectionTest, type ConnectionWay, LumenConnection, type LumenConnectionProps, activeWay, connectionStatus, } from './connection.js';
56
56
  export { LumenChatgptSignin, type LumenChatgptSigninProps } from './chatgpt-signin.js';
57
57
  export { LumenExpandList, LumenExpandRow } from './expand-list.js';
58
+ export { SettingActions, SettingField, type SettingHealth, SettingStatus, SettingValue, sectionHealth, settingHealth, } from './setting-parts.js';
58
59
  export { type Fact, FactList, type FactListProps } from './fact-list.js';
59
60
  export { LumenPageFooter, LumenPager, pageList } from './pager.js';
60
61
  export { clearPersistedQueries, createDeskQueryClient, type PersistOptions } from './query-client.js';
package/dist/index.js CHANGED
@@ -62,6 +62,7 @@ export { ConnectionBlock, ErrorLine, IntegrationRow, KeyTestLine, TestBadge, err
62
62
  export { ConnectionBadge, ConnectionLogo, LumenConnection, activeWay, connectionStatus, } from './connection.js';
63
63
  export { LumenChatgptSignin } from './chatgpt-signin.js';
64
64
  export { LumenExpandList, LumenExpandRow } from './expand-list.js';
65
+ export { SettingActions, SettingField, SettingStatus, SettingValue, sectionHealth, settingHealth, } from './setting-parts.js';
65
66
  export { FactList } from './fact-list.js';
66
67
  export { LumenPageFooter, LumenPager, pageList } from './pager.js';
67
68
  export { clearPersistedQueries, createDeskQueryClient } from './query-client.js';
package/dist/info-tip.js CHANGED
@@ -25,7 +25,7 @@ export function InfoTip({ children, label = 'More about this', side = 'top', cla
25
25
  // A 16px icon with a 24px hit area (WCAG 2.2 target size on a phone).
26
26
  "relative inline-flex size-4 shrink-0 items-center justify-center rounded-full align-middle text-muted-foreground outline-none after:absolute after:-inset-1 after:content-[''] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring", className), onFocus: () => later(true, 0), onBlur: () => later(false, 100), ...hover, children: _jsx(InfoIcon, { className: "size-3.5" }) }) }), _jsx(PopoverContent, { side: side, className: "w-72 text-muted-foreground text-xs leading-relaxed",
27
27
  // Focus stays on the (i): the tip is read, not worked in.
28
- onOpenAutoFocus: (e) => e.preventDefault(), onCloseAutoFocus: (e) => e.preventDefault(), ...hover, children: children })] }));
28
+ onOpenAutoFocus: (e) => e.preventDefault(), onCloseAutoFocus: (e) => e.preventDefault(), ...hover, children: _jsx("div", { children: children }) })] }));
29
29
  }
30
30
  /** A label with its (i): `Link works for (i)`. */
31
31
  export function InfoLabel({ children, info, className, }) {
@@ -65,6 +65,9 @@ export interface ServerPaging {
65
65
  /** The server groups the rows: the Group button offers the domain's groupable fields, and a
66
66
  * group's rows load when it opens. Without it a server's page groups nothing. */
67
67
  groups?: ServerGroups;
68
+ /** Every row for the filter, in the server's order (the server may cap it): Export saves and
69
+ * copies these instead of the one page shown. Left out: Export takes the page. */
70
+ all?: () => Promise<readonly unknown[]>;
68
71
  }
69
72
  /** One group a server counted: its key (the group field's value as the view shows it), its row
70
73
  * count (null: not counted), and, once open, the rows loaded so far. */
@@ -174,7 +177,7 @@ export interface LumenViewProps<T> {
174
177
  * table (the domain's id and `cardId`); left out: `configureLumen({ resizableColumns })`. */
175
178
  resizable?: boolean;
176
179
  /** Every row before the page's filter: Export then offers Filtered or All while a filter hides
177
- * some. Left out (or a server's page): Export takes the rows shown. */
180
+ * some. Left out: Export takes the rows shown, or a server's `paging.all`. */
178
181
  allRows?: T[];
179
182
  }
180
183
  /** What the header's + offers. */
@@ -263,20 +263,33 @@ function useExportScope(key) {
263
263
  * row on every page. On desktop it is an icon button with its menu; on a phone (`items`) its
264
264
  * entries join the card's ⋯ menu.
265
265
  */
266
- function useExport({ name, columns, rows, all, groupBy, sort, hidden, scopeKey, }) {
266
+ function useExport({ name, columns, rows, all, groupBy, sort, hidden, scopeKey, fetchAll, }) {
267
267
  const [picked, setScope] = useExportScope(scopeKey);
268
268
  const split = !!all && all.length !== rows.length;
269
269
  const scope = split ? picked : 'filtered';
270
270
  const chosen = scope === 'all' && all ? all : rows;
271
- const text = () => csvOf({ columns, rows: chosen, groupBy, sort, hidden });
272
- const download = () => text().then((csv) => saveCsv(name, csv));
273
- const n = chosen.length;
274
- const counted = `${n.toLocaleString()} ${n === 1 ? 'row' : 'rows'}`;
271
+ const text = async () => csvOf({
272
+ columns,
273
+ rows: fetchAll ? (await fetchAll()) : chosen,
274
+ groupBy,
275
+ sort,
276
+ hidden,
277
+ });
278
+ const download = () => text().then((csv) => saveCsv(name, csv), () => void toast.error('Could not load every row to export'));
279
+ const n = fetchAll ? null : chosen.length;
280
+ const counted = n == null ? 'Every matching row' : `${n.toLocaleString()} ${n === 1 ? 'row' : 'rows'}`;
275
281
  const copy = async () => {
282
+ let csv;
283
+ try {
284
+ csv = await text();
285
+ }
286
+ catch {
287
+ toast.error('Could not load every row to export');
288
+ return;
289
+ }
276
290
  try {
277
- const csv = await text();
278
291
  await navigator.clipboard.writeText(csv);
279
- toast.success(`Copied ${counted} to the clipboard`);
292
+ toast.success(n == null ? 'Copied every matching row' : `Copied ${counted} to the clipboard`);
280
293
  }
281
294
  catch {
282
295
  toast.error('The browser blocked the copy', {
@@ -337,6 +350,7 @@ export function LumenView({ domain, config, rows, total = rows.length, paging: s
337
350
  sort: config.sort,
338
351
  hidden: amountsHidden,
339
352
  scopeKey: `${domain.id}:${cardId}`,
353
+ fetchAll: server?.all,
340
354
  });
341
355
  const exporter = !pivot && on('download') ? exports : null;
342
356
  const pagination = config.pagination ?? viewPaginationSchema.parse({});
@@ -1,21 +1,22 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
3
- import { AlertTriangleIcon, BellIcon, CheckIcon, CopyIcon, MailIcon, PlusIcon, RefreshCwIcon, Trash2Icon, WebhookIcon, XIcon, } from 'lucide-react';
3
+ import { BellIcon, CheckIcon, MailIcon, PlusIcon, RefreshCwIcon, Trash2Icon, WebhookIcon, XIcon, } from 'lucide-react';
4
4
  import { useState } from 'react';
5
5
  // Settings ▸ Notifications: where the product tells the person something
6
6
  // happened: email to the sign-in address, ntfy (a private ntfy.sh topic per person, sent with their
7
7
  // own paid ntfy.sh token) and webhooks, then which event goes to which target. The app passes its
8
8
  // API base (`/api/notifications`), its product's name and the address email comes from.
9
9
  import { LumenConfirm } from './confirm.js';
10
+ import { LumenExpandRow } from './expand-list.js';
10
11
  import { fetchWithin } from './fetch-within.js';
11
- import { ErrorLine, IntegrationRow, KeyTestLine, TestBadge, } from './integrations.js';
12
+ import { ErrorLine } from './integrations.js';
12
13
  import { IconButton } from './page.js';
14
+ import { SettingActions, SettingField, SettingStatus, SettingValue, settingHealth, } from './setting-parts.js';
13
15
  import { Badge } from './ui/badge.js';
14
16
  import { Button } from './ui/button.js';
15
17
  import { Checkbox } from './ui/checkbox.js';
16
18
  import { Input } from './ui/input.js';
17
19
  import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from './ui/input-group.js';
18
- import { Label } from './ui/label.js';
19
20
  const NTFY_IOS = 'https://apps.apple.com/us/app/ntfy/id1625396347';
20
21
  const NTFY_ANDROID = 'https://play.google.com/store/apps/details?id=io.heckel.ntfy';
21
22
  async function call(url, method = 'GET', body) {
@@ -39,34 +40,32 @@ function useNotifications(api, product, sender) {
39
40
  const send = (method, path, body) => call(`${api}${path}`, method, body ?? (method === 'DELETE' ? undefined : {}));
40
41
  return { q, set, reload, send, product, sender };
41
42
  }
42
- function Field({ id, label, children }) {
43
- return (_jsxs("div", { className: "grid grid-cols-1 gap-1.5", children: [_jsx(Label, { htmlFor: id, className: "text-xs text-muted-foreground", children: label }), children] }));
44
- }
45
- function Section({ title, children }) {
46
- return (_jsxs("section", { className: "grid grid-cols-1 gap-2", children: [_jsx("h4", { className: "text-xs font-semibold tracking-wide text-muted-foreground uppercase", children: title }), children] }));
47
- }
48
- function CopyField({ id, value }) {
49
- const [copied, setCopied] = useState(false);
50
- return (_jsxs(InputGroup, { children: [_jsx(InputGroupInput, { id: id, readOnly: true, className: "num", value: value, onFocus: (e) => e.currentTarget.select() }), _jsx(InputGroupAddon, { align: "inline-end", children: _jsxs(InputGroupButton, { size: "xs", "aria-label": "Copy topic", onClick: () => {
51
- void navigator.clipboard.writeText(value);
52
- setCopied(true);
53
- setTimeout(() => setCopied(false), 1500);
54
- }, children: [copied ? _jsx(CheckIcon, {}) : _jsx(CopyIcon, {}), copied ? 'Copied' : 'Copy'] }) })] }));
55
- }
56
- /** A test that answers refreshes the section. */
57
- function TestLine({ test, run, api }) {
58
- return _jsx(KeyTestLine, { test: test, run: () => api.send('POST', run).then(api.set) });
43
+ const ago = (t) => t ? `${t.ok ? 'Worked' : 'Failed'} ${new Date(t.at).toLocaleString()}` : undefined;
44
+ function useTest(run, api) {
45
+ return () => api.send('POST', run).then(api.set);
59
46
  }
60
47
  // ── Email ──
61
- function Email({ status, api }) {
48
+ function useEmail(status, api) {
62
49
  const create = useMutation({ mutationFn: () => api.send('POST', '/email'), onSuccess: api.set });
63
50
  const remove = useMutation({
64
51
  mutationFn: () => api.send('DELETE', '/email'),
65
52
  onSuccess: () => void api.reload(),
66
53
  });
54
+ return {
55
+ create,
56
+ remove,
57
+ toggle: {
58
+ checked: status.connected,
59
+ disabled: (!status.available && !status.connected) || create.isPending || remove.isPending,
60
+ label: 'Email on',
61
+ onCheckedChange: (on) => (on ? create.mutate() : remove.mutate()),
62
+ },
63
+ };
64
+ }
65
+ function EmailBody({ status, api, errors }) {
67
66
  if (!status.connected)
68
- return status.available ? (_jsxs("div", { className: "grid grid-cols-1 gap-3", children: [_jsxs("p", { className: "text-sm", children: ["Get ", api.product, " alerts by email at the address you sign in with. Nothing to set up."] }), _jsx(ErrorLine, { error: create.error }), _jsx(IconButton, { icon: create.isPending ? RefreshCwIcon : MailIcon, label: "Turn on email", variant: "outline", className: "justify-self-start", disabled: create.isPending, busy: create.isPending, onClick: () => create.mutate() })] })) : (_jsx("p", { className: "text-sm", children: "Email is not set up on this server." }));
69
- return (_jsxs("div", { className: "grid grid-cols-1 gap-5", children: [_jsx(Section, { title: "Sends to", children: _jsxs("p", { className: "text-sm", children: [status.address, ", from ", api.sender] }) }), _jsx(Section, { title: "Test", children: _jsx(TestLine, { test: status.last_test, run: "/email/test", api: api }) }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(ErrorLine, { error: remove.error }), _jsx("span", { className: "flex-1" }), _jsx(LumenConfirm, { title: "Turn off email notifications?", description: `${api.product} stops emailing you. You can turn it on again any time.`, action: "Turn off", onConfirm: () => remove.mutate(), children: _jsx(IconButton, { icon: Trash2Icon, label: "Turn off", variant: "destructive" }) })] })] }));
67
+ return (_jsxs(_Fragment, { children: [_jsx(SettingField, { label: "Sends to", children: _jsx("span", { className: "truncate text-sm text-muted-foreground", children: status.available ? 'Your sign-in address, once on' : 'Not set up on this server' }) }), errors.map((e, i) => (_jsx(ErrorLine, { error: e }, i)))] }));
68
+ return (_jsxs(_Fragment, { children: [_jsx(SettingField, { label: "Sends to", info: `From ${api.sender}.`, children: _jsx(SettingValue, { label: "Address", value: status.address }) }), errors.map((e, i) => (_jsx(ErrorLine, { error: e }, i)))] }));
70
69
  }
71
70
  // ── ntfy ──
72
71
  /**
@@ -74,44 +73,76 @@ function Email({ status, api }) {
74
73
  * address, and the product sends from Cloudflare's shared ones, so sends go out under their paid account.
75
74
  */
76
75
  function NtfyToken({ token, api }) {
76
+ const [editing, setEditing] = useState(!token);
77
77
  const [value, setValue] = useState('');
78
78
  const save = useMutation({
79
79
  mutationFn: () => api.send('PUT', '/ntfy/token', { token: value.trim() }),
80
80
  onSuccess: (data) => {
81
81
  api.set(data);
82
82
  setValue('');
83
+ setEditing(false);
83
84
  },
84
85
  });
85
- return (_jsxs(Section, { title: "Access token", children: [_jsxs("p", { className: "text-sm", children: ["This needs a paid account at", ' ', _jsx("a", { className: "underline", href: "https://ntfy.sh/account", target: "_blank", rel: "noreferrer", children: "ntfy.sh" }), ' ', "(Supporter or above): ntfy.sh limits free accounts by sending address, and ", api.product, ' ', "sends from Cloudflare's shared addresses. Open Account \u25B8 Access tokens, create one and paste it here."] }), token ? (_jsxs("p", { className: "text-sm", children: ["Saved ", _jsx("span", { className: "num", children: token })] })) : null, _jsxs("form", { className: "flex flex-wrap items-end gap-2", onSubmit: (e) => {
86
- e.preventDefault();
87
- if (value.trim())
88
- save.mutate();
89
- }, children: [_jsx("div", { className: "min-w-0 flex-1", children: _jsx(Field, { id: "ntfy-token", label: token ? 'Replace token' : 'Access token', children: _jsx(Input, { id: "ntfy-token", type: "password", className: "num", autoComplete: "off", placeholder: "tk_\u2026", value: value, onChange: (e) => setValue(e.target.value) }) }) }), _jsxs(Button, { type: "submit", size: "sm", disabled: !value.trim() || save.isPending, children: [save.isPending ? _jsx(RefreshCwIcon, { className: "animate-spin" }) : _jsx(CheckIcon, {}), "Save"] })] }), _jsx(ErrorLine, { error: save.error })] }));
86
+ return (_jsx(SettingField, { label: "Token", htmlFor: "ntfy-token", info: _jsxs(_Fragment, { children: ["Needs a paid ntfy.sh account (Supporter or above): ntfy.sh limits free accounts by sending address, and ", api.product, " sends from Cloudflare's shared addresses. Create one at", ' ', _jsx("a", { className: "underline", href: "https://ntfy.sh/account", target: "_blank", rel: "noreferrer", children: "ntfy.sh" }), ' ', "under Account \u25B8 Access tokens."] }), children: token && !editing ? (_jsx(SettingValue, { label: "Access token", value: token, mono: true, onReplace: () => setEditing(true) })) : (_jsxs("form", { className: "min-w-0 flex-1", onSubmit: (e) => {
87
+ e.preventDefault();
88
+ if (value.trim())
89
+ save.mutate();
90
+ }, children: [_jsxs(InputGroup, { className: "h-8", children: [_jsx(InputGroupInput, { id: "ntfy-token", type: "password", className: "num", autoComplete: "off", placeholder: "tk_\u2026", value: value, onChange: (e) => setValue(e.target.value) }), _jsxs(InputGroupAddon, { align: "inline-end", children: [token ? (_jsx(InputGroupButton, { type: "button", size: "icon-xs", "aria-label": "Cancel", title: "Cancel", onClick: () => {
91
+ setValue('');
92
+ setEditing(false);
93
+ }, children: _jsx(XIcon, {}) })) : null, _jsx(InputGroupButton, { type: "submit", size: "icon-xs", "aria-label": "Save token", title: "Save token", disabled: !value.trim() || save.isPending, children: save.isPending ? _jsx(RefreshCwIcon, { className: "animate-spin" }) : _jsx(CheckIcon, {}) })] })] }), _jsx(ErrorLine, { error: save.error })] })) }));
90
94
  }
91
- function Ntfy({ status, api }) {
95
+ function useNtfy(status, api) {
96
+ const [confirm, setConfirm] = useState(false);
92
97
  const create = useMutation({ mutationFn: () => api.send('POST', '/ntfy'), onSuccess: api.set });
93
98
  const remove = useMutation({
94
99
  mutationFn: () => api.send('DELETE', '/ntfy'),
95
100
  onSuccess: () => void api.reload(),
96
101
  });
102
+ return {
103
+ create,
104
+ remove,
105
+ confirm: (_jsx(LumenConfirm, { open: confirm, onOpenChange: setConfirm, title: "Turn off ntfy?", description: `${api.product} stops sending and forgets your topic. Turning on again makes a new topic, which your phone must subscribe to again.`, action: "Turn off", onConfirm: () => remove.mutateAsync() })),
106
+ toggle: {
107
+ checked: status.connected,
108
+ disabled: create.isPending || remove.isPending,
109
+ label: 'ntfy on',
110
+ onCheckedChange: (on) => (on ? create.mutate() : setConfirm(true)),
111
+ },
112
+ };
113
+ }
114
+ function NtfyBody({ status, api, errors }) {
97
115
  if (!status.connected)
98
- return (_jsxs("div", { className: "grid grid-cols-1 gap-3", children: [_jsxs("p", { className: "text-sm", children: ["Get ", api.product, " alerts on your phone through the free ntfy app. ", api.product, " makes you a private topic (a random name only you know) and sends to it with the access token of your own paid ntfy.sh account."] }), _jsx(ErrorLine, { error: create.error }), _jsx(IconButton, { icon: create.isPending ? RefreshCwIcon : BellIcon, label: "Turn on ntfy", variant: "outline", className: "justify-self-start", disabled: create.isPending, busy: create.isPending, onClick: () => create.mutate() })] }));
99
- return (_jsxs("div", { className: "grid grid-cols-1 gap-5", children: [_jsxs(Section, { title: "Set up your phone", children: [_jsxs("ol", { className: "grid list-decimal grid-cols-1 gap-2 pl-5 text-sm", children: [_jsxs("li", { children: ["Install ntfy:", ' ', _jsx("a", { className: "underline", href: NTFY_IOS, target: "_blank", rel: "noreferrer", children: "iPhone" }), ' ', "or", ' ', _jsx("a", { className: "underline", href: NTFY_ANDROID, target: "_blank", rel: "noreferrer", children: "Android" }), "."] }), _jsx("li", { children: "Open it, tap + (Subscribe to topic) and paste your topic. Keep the server as ntfy.sh." }), _jsx("li", { children: "Allow notifications when your phone asks, add your access token below, then tap Test." })] }), _jsx(Field, { id: "ntfy-topic", label: "Your topic (keep it private: anyone with it can read it)", children: _jsx(CopyField, { id: "ntfy-topic", value: status.topic }) })] }), _jsx(NtfyToken, { token: status.token, api: api }), _jsx(Section, { title: "Test", children: _jsx(TestLine, { test: status.last_test, run: "/ntfy/test", api: api }) }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(ErrorLine, { error: remove.error }), _jsx("span", { className: "flex-1" }), _jsx(LumenConfirm, { title: "Turn off ntfy?", description: `${api.product} stops sending and forgets your topic. Turning on again makes a new topic, which your phone must subscribe to again.`, action: "Turn off", onConfirm: () => remove.mutate(), children: _jsx(IconButton, { icon: Trash2Icon, label: "Turn off", variant: "destructive" }) })] })] }));
116
+ return (_jsxs(_Fragment, { children: [_jsx(SettingField, { label: "Topic", info: `${api.product} makes you a private topic (a random name only you know) and sends to it with the access token of your own paid ntfy.sh account.`, children: _jsx("span", { className: "truncate text-sm text-muted-foreground", children: "Made when you turn it on" }) }), errors.map((e, i) => (_jsx(ErrorLine, { error: e }, i)))] }));
117
+ return (_jsxs(_Fragment, { children: [_jsx(SettingField, { label: "Topic", stack: true, info: _jsxs(_Fragment, { children: ["Install ntfy (", _jsx("a", { className: "underline", href: NTFY_IOS, target: "_blank", rel: "noreferrer", children: "iPhone" }), ' ', "or", ' ', _jsx("a", { className: "underline", href: NTFY_ANDROID, target: "_blank", rel: "noreferrer", children: "Android" }), "), tap + and paste this topic, keeping the server as ntfy.sh. Keep it private: anyone with it can read it."] }), children: _jsx(SettingValue, { label: "Topic", value: status.topic, mono: true, copy: true }) }), _jsx(NtfyToken, { token: status.token, api: api }), errors.map((e, i) => (_jsx(ErrorLine, { error: e }, i)))] }));
100
118
  }
101
119
  // ── Webhooks ──
102
120
  function WebhookRow({ hook, api }) {
121
+ const [busy, setBusy] = useState(false);
103
122
  const remove = useMutation({
104
123
  mutationFn: () => api.send('DELETE', `/webhooks/${encodeURIComponent(hook.id)}`),
105
124
  onSuccess: () => void api.reload(),
106
125
  });
107
- return (_jsxs("div", { className: "grid grid-cols-1 gap-2 border-b pb-3 last:border-b-0", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx(WebhookIcon, { className: "size-4 shrink-0 text-muted-foreground" }), _jsx("span", { className: "font-medium", children: hook.name }), _jsx("span", { className: "num min-w-0 flex-1 truncate text-xs text-muted-foreground", children: hook.url }), _jsx(LumenConfirm, { title: `Remove ${hook.name}?`, description: `${api.product} stops sending events to this address.`, action: "Remove", onConfirm: () => remove.mutate(), children: _jsx(IconButton, { icon: Trash2Icon, label: `Remove ${hook.name}`, variant: "destructive" }) })] }), hook.headers.length ? (_jsx("p", { className: "num text-xs text-muted-foreground", children: hook.headers.map((h) => `${h.name}: ${h.value}`).join(' · ') })) : null, _jsx(TestLine, { test: hook.last_test, run: `/webhooks/${encodeURIComponent(hook.id)}/test`, api: api }), _jsx(ErrorLine, { error: remove.error })] }));
126
+ const test = useTest(`/webhooks/${encodeURIComponent(hook.id)}/test`, api);
127
+ const headers = hook.headers.map((h) => `${h.name}: ${h.value}`).join(' · ');
128
+ return (_jsx(SettingField, { label: hook.name, info: headers ? `Headers: ${headers}` : undefined, end: _jsxs("span", { className: "flex items-center gap-1", children: [_jsx(SettingStatus, { health: settingHealth(true, hook.last_test), title: hook.last_test?.error ?? ago(hook.last_test) }), _jsx(IconButton, { icon: RefreshCwIcon, size: "icon-xs", label: `Test ${hook.name}`, busy: busy, disabled: busy, onClick: () => {
129
+ setBusy(true);
130
+ void test().finally(() => setBusy(false));
131
+ } }), _jsx(LumenConfirm, { title: `Remove ${hook.name}?`, description: `${api.product} stops sending events to this address.`, action: "Remove", onConfirm: () => remove.mutateAsync(), children: _jsx(IconButton, { icon: Trash2Icon, size: "icon-xs", label: `Remove ${hook.name}`, className: "text-loss" }) })] }), children: _jsx(SettingValue, { label: "Address", value: hook.url, mono: true }) }));
108
132
  }
109
- function Webhooks({ hooks, api }) {
133
+ function AddWebhook({ api }) {
134
+ const [open, setOpen] = useState(false);
110
135
  const [name, setName] = useState('');
111
136
  const [url, setUrl] = useState('');
112
137
  const [headers, setHeaders] = useState([]);
113
138
  const setHeader = (i, part, v) => setHeaders((hs) => hs.map((h, j) => (j === i ? { ...h, [part]: v } : h)));
114
139
  const filled = headers.filter((h) => h.name.trim() || h.value.trim());
140
+ const reset = () => {
141
+ setName('');
142
+ setUrl('');
143
+ setHeaders([]);
144
+ setOpen(false);
145
+ };
115
146
  const add = useMutation({
116
147
  mutationFn: () => api.send('POST', '/webhooks', {
117
148
  name: name.trim(),
@@ -120,16 +151,23 @@ function Webhooks({ hooks, api }) {
120
151
  }),
121
152
  onSuccess: (data) => {
122
153
  api.set(data);
123
- setName('');
124
- setUrl('');
125
- setHeaders([]);
154
+ reset();
126
155
  },
127
156
  });
128
- return (_jsxs("div", { className: "grid grid-cols-1 gap-5", children: [_jsxs("p", { className: "text-sm", children: [api.product, " posts each event as JSON to your own addresses: Slack, Discord, Home Assistant, n8n or anything that takes a webhook."] }), hooks.length ? (_jsx(Section, { title: "Your webhooks", children: hooks.map((h) => (_jsx(WebhookRow, { hook: h, api: api }, h.id))) })) : null, _jsx(Section, { title: "Add a webhook", children: _jsxs("form", { className: "grid grid-cols-1 gap-3", onSubmit: (e) => {
129
- e.preventDefault();
130
- if (name.trim() && url.trim())
131
- add.mutate();
132
- }, children: [_jsx(Field, { id: "wh-name", label: "Name", children: _jsx(Input, { id: "wh-name", placeholder: "Discord", value: name, onChange: (e) => setName(e.target.value) }) }), _jsx(Field, { id: "wh-url", label: "Address (https)", children: _jsx(Input, { id: "wh-url", className: "num", autoComplete: "off", placeholder: "https://", value: url, onChange: (e) => setUrl(e.target.value) }) }), headers.length ? (_jsx(Field, { id: "wh-header-0", label: "Headers (sent with each post; hidden once saved)", children: _jsx("div", { className: "grid grid-cols-1 gap-2", children: headers.map((h, i) => (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Input, { id: `wh-header-${i}`, className: "num w-2/5", autoComplete: "off", placeholder: "Authorization", "aria-label": "Header name", value: h.name, onChange: (e) => setHeader(i, 'name', e.target.value) }), _jsx(Input, { className: "num min-w-0 flex-1", type: "password", autoComplete: "off", placeholder: "Value", "aria-label": "Header value", value: h.value, onChange: (e) => setHeader(i, 'value', e.target.value) }), _jsx(IconButton, { type: "button", icon: XIcon, label: "Remove header", onClick: () => setHeaders((hs) => hs.filter((_, j) => j !== i)) })] }, i))) }) })) : null, _jsx(ErrorLine, { error: add.error }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [headers.length < 5 ? (_jsx(IconButton, { type: "button", icon: PlusIcon, label: "Add header", variant: "outline", onClick: () => setHeaders((hs) => [...hs, { name: '', value: '' }]) })) : null, _jsx("span", { className: "flex-1" }), _jsxs(Button, { type: "submit", size: "sm", disabled: !name.trim() || !url.trim() || add.isPending, children: [add.isPending ? _jsx(RefreshCwIcon, { className: "animate-spin" }) : _jsx(PlusIcon, {}), "Add"] })] })] }) }), _jsxs("p", { className: "text-xs text-muted-foreground", children: ["Each post is ", _jsx("span", { className: "num", children: '{ event, title, message, click, at }' }), ". Messages name no account, holding or amount. For a Cloudflare Access service token, add the headers CF-Access-Client-Id and CF-Access-Client-Secret."] })] }));
157
+ if (!open)
158
+ return (_jsxs("button", { type: "button", className: "flex min-h-10 items-center gap-2 text-sm font-medium", onClick: () => setOpen(true), children: [_jsx(PlusIcon, { className: "size-4" }), "Add webhook"] }));
159
+ return (_jsxs("form", { className: "grid grid-cols-1", onSubmit: (e) => {
160
+ e.preventDefault();
161
+ if (name.trim() && url.trim())
162
+ add.mutate();
163
+ }, children: [_jsx(SettingField, { label: "Name", htmlFor: "wh-name", children: _jsx(Input, { id: "wh-name", className: "h-8", placeholder: "Discord", value: name, onChange: (e) => setName(e.target.value) }) }), _jsx(SettingField, { label: "Address", htmlFor: "wh-url", info: `${api.product} posts each event as JSON ({ event, title, message, click, at }) to this https address: Slack, Discord, Home Assistant, n8n or anything that takes a webhook. Messages name no account, holding or amount.`, children: _jsx(Input, { id: "wh-url", className: "num h-8", autoComplete: "off", placeholder: "https://", value: url, onChange: (e) => setUrl(e.target.value) }) }), headers.map((h, i) => (_jsxs(SettingField
164
+ // biome-ignore lint/suspicious/noArrayIndexKey: rows have no id until saved
165
+ , { label: `Header ${i + 1}`, htmlFor: `wh-header-${i}`, info: i === 0
166
+ ? 'Sent with each post and hidden once saved. For a Cloudflare Access service token, add CF-Access-Client-Id and CF-Access-Client-Secret.'
167
+ : undefined, end: _jsx(IconButton, { type: "button", icon: XIcon, size: "icon-xs", label: "Remove header", onClick: () => setHeaders((hs) => hs.filter((_, j) => j !== i)) }), children: [_jsx(Input, { id: `wh-header-${i}`, className: "num h-8 w-2/5", autoComplete: "off", placeholder: "Authorization", "aria-label": "Header name", value: h.name, onChange: (e) => setHeader(i, 'name', e.target.value) }), _jsx(Input, { className: "num h-8 min-w-0 flex-1", type: "password", autoComplete: "off", placeholder: "Value", "aria-label": "Header value", value: h.value, onChange: (e) => setHeader(i, 'value', e.target.value) })] }, i))), _jsxs("div", { className: "mt-1 flex min-h-10 items-center gap-2 border-t pt-2", children: [_jsx("span", { className: "min-w-0 flex-1", children: _jsx(ErrorLine, { error: add.error }) }), headers.length < 5 ? (_jsx(IconButton, { type: "button", icon: PlusIcon, label: "Add header", variant: "outline", onClick: () => setHeaders((hs) => [...hs, { name: '', value: '' }]) })) : null, _jsx(Button, { type: "button", size: "sm", variant: "ghost", onClick: reset, children: "Cancel" }), _jsxs(Button, { type: "submit", size: "sm", disabled: !name.trim() || !url.trim() || add.isPending, children: [add.isPending ? _jsx(RefreshCwIcon, { className: "animate-spin" }) : _jsx(CheckIcon, {}), "Add"] })] })] }));
168
+ }
169
+ function Webhooks({ hooks, api }) {
170
+ return (_jsxs(_Fragment, { children: [hooks.map((h) => (_jsx(WebhookRow, { hook: h, api: api }, h.id))), _jsx(AddWebhook, { api: api })] }));
133
171
  }
134
172
  // ── Events: one row per event, one column per target ──
135
173
  function EventCell({ event, target, name, api, }) {
@@ -161,6 +199,18 @@ function Events({ status, api }) {
161
199
  }
162
200
  return (_jsxs("div", { className: "grid grid-cols-1 gap-2", children: [_jsx("div", { className: "overflow-x-auto", children: _jsxs("div", { "aria-label": "Events", className: "grid min-w-max items-center", style: grid, children: [_jsxs("div", { className: "contents", children: [_jsx("span", { className: "pb-1 text-xs font-semibold tracking-wide text-muted-foreground uppercase", children: "Event" }), targets.map((t) => (_jsx("span", { className: "truncate pb-1 text-center text-xs font-semibold tracking-wide text-muted-foreground uppercase", title: t.name, children: t.name }, t.id)))] }), groups.map((g) => (_jsxs("div", { className: "contents", children: [g.label ? (_jsx("span", { className: "border-t pt-3 pb-1 text-xs font-semibold tracking-wide text-muted-foreground uppercase", style: { gridColumn: '1 / -1' }, children: g.label })) : null, g.events.map((e) => (_jsxs("div", { className: "contents", children: [_jsxs("span", { className: "flex min-w-0 items-baseline gap-2 border-t py-2 pr-3", title: `${e.label}: ${e.detail}`, children: [_jsx("span", { className: "truncate text-sm whitespace-nowrap sm:shrink-0", children: e.label }), _jsx("span", { className: "hidden truncate text-xs text-muted-foreground sm:inline", children: e.detail })] }), targets.map((t) => (_jsx("span", { className: "self-stretch border-t py-2", children: _jsx("span", { className: "flex h-full items-center justify-center", children: _jsx(EventCell, { event: e, target: t.id, name: t.name, api: api }) }) }, t.id)))] }, e.key)))] }, g.label ?? '')))] }) }), _jsx("p", { className: "text-xs text-muted-foreground", children: "A new target starts with every event on." })] }));
163
201
  }
202
+ function Channels({ s, api, open, toggle, }) {
203
+ const email = useEmail(s.email, api);
204
+ const ntfy = useNtfy(s.ntfy, api);
205
+ const testEmail = useTest('/email/test', api);
206
+ const testNtfy = useTest('/ntfy/test', api);
207
+ const emailTest = s.email.connected ? s.email.last_test : null;
208
+ const ntfyTest = s.ntfy.connected ? s.ntfy.last_test : null;
209
+ const hooksFailed = s.webhooks.filter((h) => h.last_test?.ok === false);
210
+ return (_jsxs("div", { className: "grid grid-cols-1", children: [ntfy.confirm, _jsx(LumenExpandRow, { icon: _jsx(MailIcon, { className: "size-4 shrink-0 text-muted-foreground" }), name: "Email", meta: s.email.connected ? s.email.address : 'To your sign-in address', open: open === 'email', onOpenChange: toggle('email'), status: _jsx(SettingStatus, { health: settingHealth(s.email.connected, emailTest), title: emailTest?.error ?? ago(emailTest) }), toggle: email.toggle, actions: s.email.connected ? _jsx(SettingActions, { test: emailTest, onTest: testEmail }) : undefined, children: _jsx(EmailBody, { status: s.email, api: api, errors: [email.create.error, email.remove.error] }) }), _jsx(LumenExpandRow, { icon: _jsx(BellIcon, { className: "size-4 shrink-0 text-muted-foreground" }), name: "ntfy", meta: "Phone push through the ntfy app", open: open === 'ntfy', onOpenChange: toggle('ntfy'), status: _jsx(SettingStatus, { health: settingHealth(s.ntfy.connected, ntfyTest), title: ntfyTest?.error ?? ago(ntfyTest) }), toggle: ntfy.toggle, actions: s.ntfy.connected ? _jsx(SettingActions, { test: ntfyTest, onTest: testNtfy }) : undefined, children: _jsx(NtfyBody, { status: s.ntfy, api: api, errors: [ntfy.create.error, ntfy.remove.error] }) }), _jsx(LumenExpandRow, { icon: _jsx(WebhookIcon, { className: "size-4 shrink-0 text-muted-foreground" }), name: "Webhooks", meta: s.webhooks.length
211
+ ? s.webhooks.map((h) => h.name).join(' · ')
212
+ : 'Events posted to your own addresses', open: open === 'webhooks', onOpenChange: toggle('webhooks'), status: hooksFailed.length ? (_jsx(SettingStatus, { health: "failed", title: hooksFailed.map((h) => h.name).join(', ') })) : s.webhooks.length ? (_jsx(SettingStatus, { count: `${s.webhooks.length} webhook${s.webhooks.length === 1 ? '' : 's'}` })) : (_jsx(SettingStatus, { health: "off" })), children: _jsx(Webhooks, { hooks: s.webhooks, api: api }) })] }));
213
+ }
164
214
  /** The Settings ▸ Notifications section's body. */
165
215
  export function NotificationsSettings({ api: base, product, sender, parts = 'all', }) {
166
216
  const api = useNotifications(base, product, sender);
@@ -173,7 +223,5 @@ export function NotificationsSettings({ api: base, product, sender, parts = 'all
173
223
  const s = api.q.data;
174
224
  if (parts === 'events')
175
225
  return _jsx(Events, { status: s, api: api });
176
- return (_jsxs("div", { className: "grid grid-cols-1 gap-6", children: [_jsxs("div", { className: "grid grid-cols-1", children: [_jsx(IntegrationRow, { name: "Email", meta: "To your sign-in address", open: open === 'email', onOpenChange: toggle('email'), badge: s.email.connected ? (_jsx(TestBadge, { test: s.email.last_test })) : (_jsx(Badge, { variant: "outline", children: "Off" })), children: _jsx(Email, { status: s.email, api: api }) }), _jsx(IntegrationRow, { name: "ntfy", meta: "Phone notifications through the ntfy app", open: open === 'ntfy', onOpenChange: toggle('ntfy'), badge: s.ntfy.connected ? (_jsx(TestBadge, { test: s.ntfy.last_test })) : (_jsx(Badge, { variant: "outline", children: "Off" })), children: _jsx(Ntfy, { status: s.ntfy, api: api }) }), _jsx(IntegrationRow, { name: "Webhooks", meta: s.webhooks.length
177
- ? s.webhooks.map((h) => h.name).join(' · ')
178
- : 'Events posted to your own addresses', open: open === 'webhooks', onOpenChange: toggle('webhooks'), badge: s.webhooks.length ? (s.webhooks.some((h) => h.last_test?.ok === false) ? (_jsxs(Badge, { variant: "outline", className: "text-loss", children: [_jsx(AlertTriangleIcon, {}), "Failed"] })) : (_jsxs(Badge, { variant: "outline", children: [s.webhooks.length, " webhook", s.webhooks.length === 1 ? '' : 's'] }))) : (_jsx(Badge, { variant: "outline", children: "None" })), children: _jsx(Webhooks, { hooks: s.webhooks, api: api }) })] }), parts === 'all' ? (_jsxs("section", { className: "grid grid-cols-1 gap-2", children: [_jsx("h4", { className: "font-medium", children: "Events" }), _jsx(Events, { status: s, api: api })] })) : null] }));
226
+ return (_jsxs("div", { className: "grid grid-cols-1 gap-6", children: [_jsx(Channels, { s: s, api: api, open: open, toggle: toggle }), parts === 'all' ? (_jsxs("section", { className: "grid grid-cols-1 gap-2", children: [_jsx("h4", { className: "font-medium", children: "Events" }), _jsx(Events, { status: s, api: api })] })) : null] }));
179
227
  }
@@ -0,0 +1,62 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type KeyTestResult } from './integrations.js';
3
+ /** One setting inside an item: its label, then its control group, then an optional switch at the end. */
4
+ export declare function SettingField({ label, info, htmlFor, end, stack, className, children, }: {
5
+ /** The setting's own name ("Keys", "Webhook"), never the thing's ("Plaid keys"). */
6
+ label: string;
7
+ /** Details behind an (i) beside the label. */
8
+ info?: ReactNode;
9
+ htmlFor?: string;
10
+ /** A switch that turns this one setting on or off, at the row's end. */
11
+ end?: ReactNode;
12
+ /** On a phone, put the label (and `end`) above the control so a long value stays readable. */
13
+ stack?: boolean;
14
+ className?: string;
15
+ /** The control: an input, a picker, a `SettingValue`, a ToggleGroup. Its own buttons sit inside it. */
16
+ children?: ReactNode;
17
+ }): import("react").JSX.Element;
18
+ /** A value read in place: cut to one line with the full text on hover, with Copy and Replace inside. */
19
+ export declare function SettingValue({ value, label, mono, copy, onReplace, replaceLabel, }: {
20
+ value: string;
21
+ /** The value's name for Copy's and Replace's accessible names. */
22
+ label: string;
23
+ mono?: boolean;
24
+ copy?: boolean;
25
+ onReplace?: () => void;
26
+ replaceLabel?: string;
27
+ }): import("react").JSX.Element;
28
+ /** The five words a thing's status takes: Works, Failed, Not tested, Off, or a count. */
29
+ export type SettingHealth = 'works' | 'failed' | 'untested' | 'off';
30
+ /** Where a thing stands from its switch and last test. */
31
+ export declare function settingHealth(on: boolean, test: {
32
+ ok: boolean;
33
+ } | null | undefined): SettingHealth;
34
+ /** A section's health from its things: the worst one wins (Failed, then Not tested, then Works); Off when none is on. */
35
+ export declare function sectionHealth(items: SettingHealth[]): SettingHealth | null;
36
+ /** The status pill in a thing's header (or a section's title). Hover tells when and what. */
37
+ export declare function SettingStatus({ health, count, title, dot, }: {
38
+ health?: SettingHealth | null;
39
+ /** A count instead of a state ("2 hooks"). */
40
+ count?: string;
41
+ /** What hover shows: the last test's time and detail, or which items are failing. */
42
+ title?: string;
43
+ /** Only the dot, for the Settings sidebar. */
44
+ dot?: boolean;
45
+ }): import("react").JSX.Element | null;
46
+ /** The thing's action bar: its last result on the left, then any `children`, Test and Remove. */
47
+ export declare function SettingActions({ test, onTest, remove, children, }: {
48
+ /** The last test; its time shows, and its detail or error on hover. */
49
+ test?: KeyTestResult | null;
50
+ /** Runs the test; the app refreshes its own data when it settles. */
51
+ onTest?: () => Promise<unknown>;
52
+ /** Removes the thing (its keys, its sign-in): a trash icon that confirms first. */
53
+ remove?: {
54
+ label: string;
55
+ title?: ReactNode;
56
+ description?: ReactNode;
57
+ action?: string;
58
+ run: () => unknown;
59
+ };
60
+ /** Other actions on the whole thing (Sync now, Sign in again), icon first. */
61
+ children?: ReactNode;
62
+ }): import("react").JSX.Element;
@@ -0,0 +1,95 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Settings parts: the three slots every thing in a Settings section is built from (docs/parts/settings.md).
3
+ // Header: LumenExpandRow's icon, name, summary, `status` pill, `toggle` for the whole thing, chevron.
4
+ // Rows: `SettingField`, one setting each: a short label right beside its control group, a switch for
5
+ // that one setting at the row's end (`end`). Values a person reads or copies are `SettingValue`.
6
+ // Action bar: `SettingActions`, the last result, Test and Remove (trash, confirms) for the whole thing.
7
+ // A row is never a lone button ("Last test", "Remove keys"), and a label never repeats the thing's name.
8
+ // Built on shadcn's Field and InputGroup parts.
9
+ import { AlertTriangleIcon, CheckIcon, CopyIcon, PencilIcon, RefreshCwIcon, Trash2Icon, } from 'lucide-react';
10
+ import { useState } from 'react';
11
+ import { LumenConfirm } from './confirm.js';
12
+ import { IconButton } from './icon-button.js';
13
+ import { InfoTip } from './info-tip.js';
14
+ import { errorText, useClock } from './integrations.js';
15
+ import { cn } from './lib/utils.js';
16
+ import { Badge } from './ui/badge.js';
17
+ import { InputGroup, InputGroupAddon, InputGroupButton } from './ui/input-group.js';
18
+ import { Label } from './ui/label.js';
19
+ /** One setting inside an item: its label, then its control group, then an optional switch at the end. */
20
+ export function SettingField({ label, info, htmlFor, end, stack = false, className, children, }) {
21
+ return (_jsxs("div", { "data-slot": "setting-field", "data-stack": stack || undefined, className: cn('flex min-h-10 min-w-0 items-center gap-3 border-b border-border/60 py-1.5 last:border-b-0', stack && 'max-sm:flex-wrap max-sm:gap-y-1.5', className), children: [_jsxs("div", { className: cn('flex w-32 shrink-0 items-center gap-1.5 max-sm:w-24', stack && 'max-sm:w-auto max-sm:flex-1'), children: [_jsx(Label, { htmlFor: htmlFor, className: "truncate font-normal text-muted-foreground", title: label, children: label }), info && _jsx(InfoTip, { label: `About ${label}`, children: info })] }), children !== undefined && (_jsx("div", { className: cn('flex min-w-0 max-w-md flex-1 items-center gap-2', stack && 'max-sm:order-2 max-sm:max-w-none max-sm:basis-full'), children: children })), end && _jsx("div", { className: cn('ml-auto shrink-0', stack && 'max-sm:order-1'), children: end })] }));
22
+ }
23
+ /** A value read in place: cut to one line with the full text on hover, with Copy and Replace inside. */
24
+ export function SettingValue({ value, label, mono = false, copy = false, onReplace, replaceLabel = 'Replace', }) {
25
+ const [copied, setCopied] = useState(false);
26
+ const doCopy = async () => {
27
+ try {
28
+ await navigator.clipboard.writeText(value);
29
+ setCopied(true);
30
+ setTimeout(() => setCopied(false), 1500);
31
+ }
32
+ catch {
33
+ setCopied(false);
34
+ }
35
+ };
36
+ return (_jsxs(InputGroup, { className: "h-8 min-w-0", children: [_jsx("span", { className: cn('min-w-0 flex-1 truncate px-2.5 text-sm', mono && 'font-code text-xs'), title: value, children: value }), (copy || onReplace) && (_jsxs(InputGroupAddon, { align: "inline-end", children: [copy && (_jsx(InputGroupButton, { size: "icon-xs", "aria-label": copied ? 'Copied' : `Copy ${label.toLowerCase()}`, title: copied ? 'Copied' : `Copy ${label.toLowerCase()}`, onClick: () => void doCopy(), children: copied ? _jsx(CheckIcon, {}) : _jsx(CopyIcon, {}) })), onReplace && (_jsx(InputGroupButton, { size: "icon-xs", "aria-label": `${replaceLabel} ${label.toLowerCase()}`, title: `${replaceLabel} ${label.toLowerCase()}`, onClick: onReplace, children: _jsx(PencilIcon, {}) }))] }))] }));
37
+ }
38
+ /** Where a thing stands from its switch and last test. */
39
+ export function settingHealth(on, test) {
40
+ if (!on)
41
+ return 'off';
42
+ if (!test)
43
+ return 'untested';
44
+ return test.ok ? 'works' : 'failed';
45
+ }
46
+ /** A section's health from its things: the worst one wins (Failed, then Not tested, then Works); Off when none is on. */
47
+ export function sectionHealth(items) {
48
+ if (!items.length)
49
+ return null;
50
+ for (const h of ['failed', 'untested', 'works'])
51
+ if (items.includes(h))
52
+ return h;
53
+ return 'off';
54
+ }
55
+ const HEALTH = {
56
+ works: { word: 'Works', cls: 'text-gain' },
57
+ failed: { word: 'Failed', cls: 'text-loss' },
58
+ untested: { word: 'Not tested', cls: 'text-muted-foreground' },
59
+ off: { word: 'Off', cls: 'text-muted-foreground' },
60
+ };
61
+ /** The status pill in a thing's header (or a section's title). Hover tells when and what. */
62
+ export function SettingStatus({ health, count, title, dot = false, }) {
63
+ if (count !== undefined)
64
+ return (_jsx(Badge, { variant: "outline", className: "shrink-0", title: title, children: count }));
65
+ if (!health)
66
+ return null;
67
+ const h = HEALTH[health];
68
+ const mark = (_jsx("span", { "aria-hidden": true, className: cn('size-1.5 shrink-0 rounded-full', health === 'works' && 'bg-gain', health === 'failed' && 'bg-loss', (health === 'untested' || health === 'off') && 'border border-muted-foreground') }));
69
+ if (dot)
70
+ return (_jsx("span", { className: "ml-auto flex items-center", title: title ?? h.word, "aria-label": h.word, children: mark }));
71
+ return (_jsxs(Badge, { variant: "outline", className: cn('shrink-0 gap-1.5', h.cls), title: title, children: [mark, h.word] }));
72
+ }
73
+ /** The thing's action bar: its last result on the left, then any `children`, Test and Remove. */
74
+ export function SettingActions({ test, onTest, remove, children, }) {
75
+ const clock = useClock();
76
+ const [pending, setPending] = useState(false);
77
+ const [error, setError] = useState(null);
78
+ const go = async () => {
79
+ if (!onTest)
80
+ return;
81
+ setPending(true);
82
+ setError(null);
83
+ try {
84
+ await onTest();
85
+ }
86
+ catch (e) {
87
+ setError(e);
88
+ }
89
+ finally {
90
+ setPending(false);
91
+ }
92
+ };
93
+ const failed = error ? errorText(error) : test && !test.ok ? (test.error ?? 'Failed') : null;
94
+ return (_jsxs("div", { "data-slot": "setting-actions", className: "mt-1 flex min-h-10 min-w-0 items-center gap-2 border-t pt-2 text-sm", children: [_jsx("span", { className: "flex min-w-0 flex-1 items-center gap-1.5", children: pending ? (_jsx("span", { className: "text-muted-foreground", children: "Testing\u2026" })) : failed ? (_jsxs("span", { role: "alert", className: "flex min-w-0 items-center gap-1.5 text-loss", title: failed, children: [_jsx(AlertTriangleIcon, { className: "size-4 shrink-0" }), _jsx("span", { className: "truncate", children: failed })] })) : test ? (_jsxs("span", { className: "flex min-w-0 items-center gap-1.5 text-gain", title: test.detail ?? undefined, children: [_jsx(CheckIcon, { className: "size-4 shrink-0" }), _jsxs("span", { className: "truncate", children: ["Tested ", clock(test.at)] })] })) : onTest ? (_jsx("span", { className: "text-muted-foreground", children: "Not tested yet" })) : null }), children, onTest && (_jsx(IconButton, { icon: RefreshCwIcon, busy: pending, label: pending ? 'Testing…' : 'Test', variant: "outline", disabled: pending, onClick: () => void go() })), remove && (_jsx(LumenConfirm, { title: remove.title ?? `${remove.label}?`, description: remove.description, action: remove.action ?? 'Remove', onConfirm: remove.run, children: _jsx(IconButton, { icon: Trash2Icon, label: remove.label, variant: "outline", className: "text-loss" }) }))] }));
95
+ }
@@ -1,5 +1,6 @@
1
1
  import { SunIcon } from 'lucide-react';
2
2
  import { type ReactNode } from 'react';
3
+ import { type SettingHealth } from './setting-parts.js';
3
4
  /** A section an app adds to Settings, listed under the app's name. */
4
5
  export interface SettingsSectionDef {
5
6
  id: string;
@@ -12,6 +13,10 @@ export interface SettingsSectionDef {
12
13
  /** What the section's footer holds (a status line, Revert and Save). The footer is pinned under
13
14
  * every section, empty when it has nothing; `SettingsFooter` fills it too. */
14
15
  footer?: ReactNode;
16
+ /** The section's state from its things (`sectionHealth`): a pill by the title, a dot in the list. */
17
+ health?: SettingHealth | null;
18
+ /** What hovering the section's pill says (which thing failed). */
19
+ healthTitle?: string;
15
20
  }
16
21
  /** A labelled group of sections in Settings: one per app when a product shows several apps' settings together. */
17
22
  export interface SettingsGroupDef {
package/dist/settings.js CHANGED
@@ -13,6 +13,7 @@ import { useAssistantMoves } from './assistant-screen.js';
13
13
  import { InfoTip } from './info-tip.js';
14
14
  import { LumenSelector } from './lumen-selector.js';
15
15
  import { FONTS, FONT_SIZES, setPref, usePrefs, } from './prefs.js';
16
+ import { SettingStatus } from './setting-parts.js';
16
17
  import { TimeZoneSelect } from './status.js';
17
18
  import { Dialog, DialogContent, DialogDescription, DialogTitle } from './ui/dialog.js';
18
19
  import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from './ui/input-group.js';
@@ -171,7 +172,7 @@ export function LumenSettings({ open, section, onOpen, onClose, appLabel, profil
171
172
  ...(sections.length ? [{ label: appLabel, items: sections.filter(shown) }] : []),
172
173
  ].filter((g) => g.items.length);
173
174
  const search = (_jsxs(InputGroup, { className: "bg-background", children: [_jsx(InputGroupAddon, { children: _jsx(SearchIcon, {}) }), _jsx(InputGroupInput, { type: "search", className: "[&::-webkit-search-cancel-button]:hidden", "aria-label": "Search settings", placeholder: "Search", value: query, onChange: (e) => setQuery(e.target.value) }), query && (_jsx(InputGroupAddon, { align: "inline-end", children: _jsx(InputGroupButton, { size: "icon-xs", "aria-label": "Clear search", onClick: () => setQuery(''), children: _jsx(XIcon, {}) }) }))] }));
174
- const header = (s, slot) => (_jsxs("div", { className: "flex items-end gap-2", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-0.5", children: [_jsxs("h3", { id: `settings-${s.id}-title`, className: "flex items-center gap-2 text-base font-semibold", children: [_jsx(s.icon, { className: "size-4 text-muted-foreground" }), s.title] }), s.description && (_jsx("p", { className: "truncate text-sm text-muted-foreground", title: typeof s.description === 'string' ? s.description : undefined, children: s.description }))] }), _jsx("div", { ref: slot, className: "flex shrink-0 items-center gap-2 empty:hidden" })] }));
175
+ const header = (s, slot) => (_jsxs("div", { className: "flex items-end gap-2", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-0.5", children: [_jsxs("h3", { id: `settings-${s.id}-title`, className: "flex items-center gap-2 text-base font-semibold", children: [_jsx(s.icon, { className: "size-4 text-muted-foreground" }), s.title, s.health && _jsx(SettingStatus, { health: s.health, title: s.healthTitle })] }), s.description && (_jsx("p", { className: "truncate text-sm text-muted-foreground", title: typeof s.description === 'string' ? s.description : undefined, children: s.description }))] }), _jsx("div", { ref: slot, className: "flex shrink-0 items-center gap-2 empty:hidden" })] }));
175
176
  return (_jsx(Dialog, { open: open, onOpenChange: (o) => {
176
177
  if (o)
177
178
  onOpen(section);
@@ -186,7 +187,7 @@ export function LumenSettings({ open, section, onOpen, onClose, appLabel, profil
186
187
  e.preventDefault();
187
188
  setQuery('');
188
189
  }
189
- }, className: "flex h-[min(720px,calc(100svh-2rem))] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl md:flex-row", children: [_jsx(DialogTitle, { className: "sr-only", children: "Settings" }), _jsx(DialogDescription, { className: "sr-only", children: "Your preferences and this desk's settings." }), _jsxs("nav", { "aria-label": "Settings sections", className: "hidden min-h-0 w-56 shrink-0 flex-col border-r bg-sidebar pt-2 md:flex", children: [_jsx("div", { className: "shrink-0 px-2 pt-1 pb-1", children: search }), _jsx("div", { ref: navList, "data-slot": "settings-nav-list", className: "flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-2 *:shrink-0", children: navGroups.map((g) => (_jsxs(SidebarGroup, { className: "py-1", children: [_jsx(SidebarGroupLabel, { children: g.label }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: g.items.map((s) => (_jsx(SidebarMenuItem, { children: _jsxs(SidebarMenuButton, { isActive: !q && s.id === current.id, className: s.sub ? 'pl-7' : undefined, onClick: () => go(s.id), children: [_jsx(s.icon, {}), _jsx("span", { children: s.title })] }) }, s.id))) }) })] }, g.label))) })] }), _jsxs("div", { className: "flex flex-col gap-2 border-b p-3 pr-12 md:hidden", children: [search, !q && (_jsx(LumenSelector, { label: "Settings section", className: "w-full", groups: navGroups.map((g) => ({
190
+ }, className: "flex h-[min(720px,calc(100svh-2rem))] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl md:flex-row", children: [_jsx(DialogTitle, { className: "sr-only", children: "Settings" }), _jsx(DialogDescription, { className: "sr-only", children: "Your preferences and this desk's settings." }), _jsxs("nav", { "aria-label": "Settings sections", className: "hidden min-h-0 w-56 shrink-0 flex-col border-r bg-sidebar pt-2 md:flex", children: [_jsx("div", { className: "shrink-0 px-2 pt-1 pb-1", children: search }), _jsx("div", { ref: navList, "data-slot": "settings-nav-list", className: "flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-2 *:shrink-0", children: navGroups.map((g) => (_jsxs(SidebarGroup, { className: "py-1", children: [_jsx(SidebarGroupLabel, { children: g.label }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: g.items.map((s) => (_jsx(SidebarMenuItem, { children: _jsxs(SidebarMenuButton, { isActive: !q && s.id === current.id, className: s.sub ? 'pl-7' : undefined, onClick: () => go(s.id), children: [_jsx(s.icon, {}), _jsx("span", { children: s.title }), s.health && s.health !== 'off' && (_jsx(SettingStatus, { health: s.health, title: s.healthTitle, dot: true }))] }) }, s.id))) }) })] }, g.label))) })] }), _jsxs("div", { className: "flex flex-col gap-2 border-b p-3 pr-12 md:hidden", children: [search, !q && (_jsx(LumenSelector, { label: "Settings section", className: "w-full", groups: navGroups.map((g) => ({
190
191
  label: g.label,
191
192
  options: g.items.map((s) => ({
192
193
  value: s.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.183",
3
+ "version": "0.1.185",
4
4
  "description": "Lumen: shared React components (frame, views, widgets, charts, drawers).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,7 +49,7 @@
49
49
  "@codemirror/lang-javascript": "6.2.5",
50
50
  "@codemirror/lang-json": "6.0.2",
51
51
  "@codemirror/lang-sql": "6.10.0",
52
- "@columenlabs/lumen-ui-types": "0.1.183",
52
+ "@columenlabs/lumen-ui-types": "0.1.185",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",