@columenlabs/lumen-ui 0.1.182 → 0.1.184
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.
- package/dist/expand-list.d.ts +13 -1
- package/dist/expand-list.js +9 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/info-tip.js +1 -1
- package/dist/notifications.js +93 -45
- package/dist/setting-parts.d.ts +62 -0
- package/dist/setting-parts.js +95 -0
- package/dist/settings.d.ts +5 -0
- package/dist/settings.js +3 -2
- package/package.json +2 -2
package/dist/expand-list.d.ts
CHANGED
|
@@ -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;
|
package/dist/expand-list.js
CHANGED
|
@@ -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
|
-
|
|
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, }) {
|
package/dist/notifications.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|
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
|
|
69
|
-
return (_jsxs(
|
|
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 (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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(
|
|
99
|
-
return (_jsxs(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
124
|
-
setUrl('');
|
|
125
|
-
setHeaders([]);
|
|
154
|
+
reset();
|
|
126
155
|
},
|
|
127
156
|
});
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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: [
|
|
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
|
+
}
|
package/dist/settings.d.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.1.184",
|
|
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.
|
|
52
|
+
"@columenlabs/lumen-ui-types": "0.1.184",
|
|
53
53
|
"@dnd-kit/core": "6.3.1",
|
|
54
54
|
"@dnd-kit/sortable": "10.0.0",
|
|
55
55
|
"@dnd-kit/utilities": "3.2.2",
|