@columenlabs/lumen-ui 0.1.92 → 0.1.94
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/assistant/client.d.ts +5 -2
- package/dist/assistant/client.js +2 -1
- package/dist/assistant/settings.js +99 -73
- package/dist/chatgpt-signin.d.ts +4 -1
- package/dist/chatgpt-signin.js +4 -1
- package/dist/connection.d.ts +117 -0
- package/dist/connection.js +229 -0
- package/dist/expand-list.d.ts +3 -1
- package/dist/expand-list.js +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/people/email-settings.d.ts +4 -3
- package/dist/people/email-settings.js +64 -19
- package/dist/ui/radio-group.d.ts +5 -0
- package/dist/ui/radio-group.js +10 -0
- package/package.json +2 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AllowanceRequest, AssistantChat, AssistantChatRow, AssistantEvent, AssistantMessage, AssistantProvider, AssistantSettingsPatch, AssistantStatus, DraftItem, KeyState, StepItem } from '@columenlabs/lumen-ui-types/assistant';
|
|
1
|
+
import type { AllowanceRequest, AssistantChat, AssistantChatRow, AssistantEvent, AssistantMessage, AssistantOwnWay, AssistantProvider, AssistantSettingsPatch, AssistantStatus, DraftItem, KeyState, StepItem } from '@columenlabs/lumen-ui-types/assistant';
|
|
2
2
|
type Test = NonNullable<KeyState['test']>;
|
|
3
3
|
export interface AssistantAdapter {
|
|
4
4
|
/** Names the cache its reads keep (two assistants on one page need two keys). */
|
|
@@ -14,7 +14,8 @@ export interface AssistantAdapter {
|
|
|
14
14
|
ownKey(provider: AssistantProvider, key: string | null): Promise<AssistantStatus>;
|
|
15
15
|
/** What the person may spend a month on their own keys. */
|
|
16
16
|
ownCap(usd: number): Promise<AssistantStatus>;
|
|
17
|
-
|
|
17
|
+
/** Tests the person's key or plan (`way`; by default the one in use). */
|
|
18
|
+
testOwnKey(provider: AssistantProvider, way?: AssistantOwnWay): Promise<Test>;
|
|
18
19
|
/** Starts a plan sign-in (ChatGPT): the address to open and the one it ends on. */
|
|
19
20
|
startSignin(provider: AssistantProvider): Promise<{
|
|
20
21
|
url: string;
|
|
@@ -22,6 +23,8 @@ export interface AssistantAdapter {
|
|
|
22
23
|
}>;
|
|
23
24
|
/** Finishes it from the address the sign-in tab ended on. */
|
|
24
25
|
finishSignin(provider: AssistantProvider, url: string): Promise<AssistantStatus>;
|
|
26
|
+
/** Signs out of the plan; the person's key, if set, runs on. */
|
|
27
|
+
signOut(provider: AssistantProvider): Promise<AssistantStatus>;
|
|
25
28
|
/** Admin: ScrapeQL's Access service token; null removes it. */
|
|
26
29
|
scrapeql(token: {
|
|
27
30
|
client_id: string;
|
package/dist/assistant/client.js
CHANGED
|
@@ -49,9 +49,10 @@ export function assistantClient(base) {
|
|
|
49
49
|
? send('PUT', `/my-key?provider=${p}`, { api_key: key })
|
|
50
50
|
: send('DELETE', `/my-key?provider=${p}`),
|
|
51
51
|
ownCap: (usd) => send('PUT', '/my-key/cap', { cap_usd: usd }),
|
|
52
|
-
testOwnKey: (p) => send('POST', `/my-key/test?provider=${p}`),
|
|
52
|
+
testOwnKey: (p, way) => send('POST', `/my-key/test?provider=${p}${way ? `&way=${way}` : ''}`),
|
|
53
53
|
startSignin: (p) => send('POST', `/my-signin?provider=${p}`),
|
|
54
54
|
finishSignin: (p, url) => send('POST', `/my-signin/finish?provider=${p}`, { url }),
|
|
55
|
+
signOut: (p) => send('DELETE', `/my-signin?provider=${p}`),
|
|
55
56
|
scrapeql: (token) => token ? send('PUT', '/web/scrapeql', token) : send('DELETE', '/web/scrapeql'),
|
|
56
57
|
testScrapeql: () => send('POST', '/web/test'),
|
|
57
58
|
chats: () => get('/chats'),
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
3
|
-
import {
|
|
3
|
+
import { CoinsIcon, GlobeIcon, InboxIcon, KeyRoundIcon, LayersIcon, LogInIcon, ReceiptIcon, SparklesIcon, WrenchIcon, } from 'lucide-react';
|
|
4
4
|
import { useId, useState } from 'react';
|
|
5
|
-
import {
|
|
5
|
+
import { ConnectionLogo, LumenConnection, } from '../connection.js';
|
|
6
6
|
import { LumenExpandList, LumenExpandRow } from '../expand-list.js';
|
|
7
7
|
import { SaveButton } from '../form-issues.js';
|
|
8
|
-
import { IconButton } from '../icon-button.js';
|
|
9
8
|
import { InfoLabel } from '../info-tip.js';
|
|
10
|
-
import {
|
|
9
|
+
import { errorText } from '../integrations.js';
|
|
11
10
|
import { LumenSelector } from '../lumen-selector.js';
|
|
12
11
|
import { lumenToast } from '../lumen-toast.js';
|
|
13
12
|
import { ShareMeter } from '../share-meter.js';
|
|
@@ -70,14 +69,6 @@ function WithStatus({ client, children, }) {
|
|
|
70
69
|
function Row({ label, info, children }) {
|
|
71
70
|
return (_jsxs("div", { "data-slot": "setting-row", className: "flex flex-wrap items-center gap-x-4 gap-y-2 border-b py-3 last:border-b-0", children: [_jsx(Label, { className: "min-w-0 flex-1", children: _jsx(InfoLabel, { info: info, children: label }) }), _jsx("div", { className: "shrink-0", children: children })] }));
|
|
72
71
|
}
|
|
73
|
-
const testOf = (k) => k.test
|
|
74
|
-
? {
|
|
75
|
-
at: new Date(k.test.at).toISOString(),
|
|
76
|
-
ok: k.test.ok,
|
|
77
|
-
detail: k.test.detail,
|
|
78
|
-
error: k.test.ok ? null : k.test.detail,
|
|
79
|
-
}
|
|
80
|
-
: null;
|
|
81
72
|
// ---------------------------------------------------------------- the assistant
|
|
82
73
|
function General({ client }) {
|
|
83
74
|
const { apply } = useApply(client);
|
|
@@ -98,27 +89,6 @@ function General({ client }) {
|
|
|
98
89
|
} }));
|
|
99
90
|
}
|
|
100
91
|
// ---------------------------------------------------------------- model providers
|
|
101
|
-
/** Paste a key, Save tests it first; then its hint, Test and Remove. */
|
|
102
|
-
function KeyBlock({ label, info, prefix, provider, state, canEdit, onSave, onRemove, onTest, keysUrl, }) {
|
|
103
|
-
const id = useId();
|
|
104
|
-
const [key, setKey] = useState('');
|
|
105
|
-
const [replacing, setReplacing] = useState(false);
|
|
106
|
-
const [busy, setBusy] = useState(false);
|
|
107
|
-
const editing = canEdit && (!state.set || replacing);
|
|
108
|
-
const issues = [];
|
|
109
|
-
if (editing && key.trim() && !key.trim().startsWith(prefix))
|
|
110
|
-
issues.push({ where: label, message: `An ${provider} key starts with ${prefix}`, target: id });
|
|
111
|
-
const save = async () => {
|
|
112
|
-
setBusy(true);
|
|
113
|
-
if (await onSave(key.trim())) {
|
|
114
|
-
setKey('');
|
|
115
|
-
setReplacing(false);
|
|
116
|
-
}
|
|
117
|
-
setBusy(false);
|
|
118
|
-
};
|
|
119
|
-
return (_jsxs("div", { className: "flex flex-col gap-3 border-b py-3 last:border-b-0", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(InfoLabel, { className: "flex-1 font-medium text-sm", info: info, children: label }), state.set ? (state.hint ? (_jsx("span", { className: "font-code text-muted-foreground text-xs", children: state.hint })) : (_jsx(Badge, { variant: "secondary", children: "Set" }))) : state.from_env ? (_jsx(Badge, { variant: "secondary", children: "From the server" })) : (_jsx(Badge, { variant: "outline", children: "Not set" })), state.set && state.test && _jsx(TestBadge, { test: state.test })] }), editing ? (_jsxs("div", { className: "flex gap-2", children: [_jsx(Input, { id: id, type: "password", autoComplete: "off", placeholder: `${prefix}…`, className: "font-code text-xs", value: key, onChange: (e) => setKey(e.target.value) }), keysUrl && (_jsx(IconButton, { icon: ExternalLinkIcon, label: `Get an ${provider} key`, href: keysUrl, variant: "outline" })), replacing && (_jsx(IconButton, { icon: Trash2Icon, label: "Keep the saved key", onClick: () => setReplacing(false) })), _jsx(SaveButton, { issues: issues, busy: busy, disabled: !key.trim(), label: "Test and save", onSave: save })] })) : (state.set &&
|
|
120
|
-
canEdit && (_jsxs(_Fragment, { children: [_jsx(KeyTestLine, { test: testOf(state), run: onTest }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(IconButton, { icon: Trash2Icon, label: `Remove ${label.toLowerCase()}`, variant: "outline", onClick: () => void onRemove() }), _jsx(SaveButton, { issues: [], icon: KeyRoundIcon, label: "Replace", onSave: () => setReplacing(true) })] })] })))] }));
|
|
121
|
-
}
|
|
122
92
|
/** A dollar cap a month, saved on Enter or leaving the field. */
|
|
123
93
|
function CapInput({ label, value, onSave, }) {
|
|
124
94
|
const [text, setText] = useState(String(value));
|
|
@@ -131,31 +101,87 @@ function CapInput({ label, value, onSave, }) {
|
|
|
131
101
|
};
|
|
132
102
|
return (_jsxs("div", { className: "flex items-center gap-1 text-sm", children: [_jsx("span", { className: "text-muted-foreground", children: "$" }), _jsx(Input, { "aria-label": label, inputMode: "decimal", className: "h-8 w-20 text-right tabular-nums", value: text, onChange: (e) => setText(e.target.value), onBlur: commit, onKeyDown: (e) => e.key === 'Enter' && commit() }), _jsx("span", { className: "text-muted-foreground", children: "/ month" })] }));
|
|
133
103
|
}
|
|
134
|
-
/** A
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
104
|
+
/** A key's last test as a connection way shows it; a plan that failed asks for a new sign-in. */
|
|
105
|
+
const wayTest = (k, plan = false) => k?.test
|
|
106
|
+
? {
|
|
107
|
+
at: k.test.at,
|
|
108
|
+
ok: k.test.ok,
|
|
109
|
+
detail: k.test.detail,
|
|
110
|
+
error: k.test.ok ? null : k.test.detail,
|
|
111
|
+
expired: plan && !k.test.ok,
|
|
112
|
+
}
|
|
113
|
+
: null;
|
|
114
|
+
/** A provider's mark: its initial on a tile. */
|
|
115
|
+
const mark = (label) => _jsx(ConnectionLogo, { text: label.charAt(0) });
|
|
116
|
+
/** One provider, collapsed to a row: the person's own key and, where it has one, their plan sign-in. */
|
|
142
117
|
function OwnProviderKey({ client, p }) {
|
|
143
118
|
const { apply, refresh } = useApply(client);
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
119
|
+
const ways = [];
|
|
120
|
+
if (p.signin === 'chatgpt')
|
|
121
|
+
ways.push({
|
|
122
|
+
id: 'plan',
|
|
123
|
+
label: 'ChatGPT plan',
|
|
124
|
+
icon: LogInIcon,
|
|
125
|
+
info: 'Run OpenAI models on your own ChatGPT plan instead of an API key.',
|
|
126
|
+
set: !!p.plan?.set,
|
|
127
|
+
ident: p.plan?.hint,
|
|
128
|
+
test: wayTest(p.plan, true),
|
|
129
|
+
signin: { start: () => client.startSignin(p.id), provider: 'ChatGPT' },
|
|
130
|
+
onFinish: (url) => apply(`signin:${p.id}`, () => client.finishSignin(p.id, url), 'Signed in'),
|
|
131
|
+
onTest: () => client.testOwnKey(p.id, 'plan').finally(refresh),
|
|
132
|
+
onRemove: () => apply(`plan:${p.id}`, () => client.signOut(p.id), 'Signed out'),
|
|
133
|
+
removeTitle: 'Sign out of your ChatGPT plan?',
|
|
134
|
+
removeText: 'Your API key, if you set one, runs from then on.',
|
|
135
|
+
removeLabel: 'Sign out',
|
|
136
|
+
});
|
|
137
|
+
ways.push({
|
|
138
|
+
id: 'key',
|
|
139
|
+
label: 'API key',
|
|
140
|
+
icon: KeyRoundIcon,
|
|
141
|
+
info: `Your own ${p.label} key, billed by ${p.label}.`,
|
|
142
|
+
set: p.own.set,
|
|
143
|
+
ident: p.own.hint,
|
|
144
|
+
test: wayTest(p.own),
|
|
145
|
+
fields: [{ id: 'key', label: `${p.label} API key`, secret: true, prefix: p.prefix }],
|
|
146
|
+
getUrl: p.keys_url,
|
|
147
|
+
onSave: (v) => apply(`own:${p.id}`, () => client.ownKey(p.id, v.key ?? ''), 'Key saved'),
|
|
148
|
+
onTest: () => client.testOwnKey(p.id, 'key').finally(refresh),
|
|
149
|
+
onRemove: () => apply(`own:${p.id}`, () => client.ownKey(p.id, null), 'Key removed'),
|
|
150
|
+
removeTitle: `Remove your ${p.label} key?`,
|
|
151
|
+
});
|
|
152
|
+
return (_jsx(LumenConnection, { id: p.id, name: p.label, logo: mark(p.label), ways: ways, use: p.use, onUse: (use) => apply(`use:${p.id}`, () => client.settings({ own_use: { [p.id]: use } })) }));
|
|
147
153
|
}
|
|
148
154
|
/** A person's own keys, one row a provider, and what they may spend on them. */
|
|
149
155
|
function Providers({ client }) {
|
|
150
156
|
const { apply } = useApply(client);
|
|
151
|
-
return (_jsx(WithStatus, { client: client, children: (s) => (_jsxs("div", { className: "flex flex-col", children: [_jsx(LumenExpandList, { children: s.providers.map((p) => (_jsx(OwnProviderKey, { client: client, p: p }, p.id))) }), s.providers.some((p) => p.own.set) && (_jsx(Row, { label: "Your keys may spend", info: "On your keys, every provider together.", children: _jsx(CapInput, { label: "Your monthly cap", value: s.caps.own, onSave: (n) => void apply('own', () => client.ownCap(n)) }) }))] })) }));
|
|
157
|
+
return (_jsx(WithStatus, { client: client, children: (s) => (_jsxs("div", { className: "flex flex-col", children: [_jsx(LumenExpandList, { children: s.providers.map((p) => (_jsx(OwnProviderKey, { client: client, p: p }, p.id))) }), s.providers.some((p) => p.own.set || p.plan?.set) && (_jsx(Row, { label: "Your keys may spend", info: "On your keys, every provider together.", children: _jsx(CapInput, { label: "Your monthly cap", value: s.caps.own, onSave: (n) => void apply('own', () => client.ownCap(n)) }) }))] })) }));
|
|
152
158
|
}
|
|
153
159
|
/** One provider's shared key, which an admin sets for everyone. */
|
|
154
160
|
function SharedProviderKey({ client, p }) {
|
|
155
161
|
const { apply, refresh } = useApply(client);
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
162
|
+
const env = !p.app.set && !!p.app.from_env;
|
|
163
|
+
return (_jsx(LumenConnection, { id: p.id, name: p.label, logo: mark(p.label), ways: [
|
|
164
|
+
{
|
|
165
|
+
id: 'key',
|
|
166
|
+
label: 'Shared key',
|
|
167
|
+
icon: KeyRoundIcon,
|
|
168
|
+
info: "Everyone's answers run on it, within the allowance below.",
|
|
169
|
+
set: p.app.set || env,
|
|
170
|
+
ident: env ? 'From the server' : p.app.hint,
|
|
171
|
+
test: wayTest(p.app),
|
|
172
|
+
fields: [{ id: 'key', label: `${p.label} API key`, secret: true, prefix: p.prefix }],
|
|
173
|
+
getUrl: p.keys_url,
|
|
174
|
+
onSave: (v) => apply(`app:${p.id}`, () => client.appKey(p.id, v.key ?? ''), 'Key saved'),
|
|
175
|
+
onTest: () => client.testAppKey(p.id).finally(refresh),
|
|
176
|
+
...(p.app.set
|
|
177
|
+
? {
|
|
178
|
+
onRemove: () => apply(`app:${p.id}`, () => client.appKey(p.id, null), 'Key removed'),
|
|
179
|
+
removeTitle: `Remove the shared ${p.label} key?`,
|
|
180
|
+
removeText: 'Answers on it stop until a key is set again.',
|
|
181
|
+
}
|
|
182
|
+
: {}),
|
|
183
|
+
},
|
|
184
|
+
] }));
|
|
159
185
|
}
|
|
160
186
|
/** Admins only: the app's shared keys and what each person may spend on them. */
|
|
161
187
|
function SharedKeys({ client }) {
|
|
@@ -171,7 +197,7 @@ function Models({ client }) {
|
|
|
171
197
|
const tiers = s.tiers.map((t) => ({ value: t.id, label: t.label }));
|
|
172
198
|
return (_jsxs("div", { className: "flex flex-col", children: [s.tiers.map((t) => {
|
|
173
199
|
const p = s.providers.find((x) => x.id === t.provider);
|
|
174
|
-
const keyless = p && !p.app.set && !p.app.from_env && !p.own.set;
|
|
200
|
+
const keyless = p && !p.app.set && !p.app.from_env && !p.own.set && !p.plan?.set;
|
|
175
201
|
return (_jsx(Row, { label: t.label, info: t.about, children: _jsxs("div", { className: "flex flex-wrap items-center justify-end gap-2", children: [keyless && _jsxs(Badge, { variant: "outline", children: ["No ", p.label, " key"] }), _jsx(LumenSelector, { label: `${t.label} model`, size: "sm", className: "w-36 sm:w-44", value: t.model, options: models, onChange: (model) => set({ tiers: { [t.id]: { model } } }) }), _jsx(LumenSelector, { label: `${t.label} effort`, size: "sm", className: "w-24 sm:w-32", value: t.effort, options: EFFORTS, onChange: (effort) => set({ tiers: { [t.id]: { effort } } }) })] }) }, t.id));
|
|
176
202
|
}), s.tasks.length > 1 &&
|
|
177
203
|
s.tasks.map((task) => (_jsx(Row, { label: `${task.label} starts on`, children: _jsx(LumenSelector, { label: `${task.label} starts on`, size: "sm", className: "w-44", value: task.tier, options: tiers, onChange: (tier) => set({ tasks: { [task.id]: tier } }) }) }, task.id)))] }));
|
|
@@ -186,33 +212,33 @@ function Tools({ client }) {
|
|
|
186
212
|
// ---------------------------------------------------------------- web
|
|
187
213
|
function ScrapeqlBlock({ client, s }) {
|
|
188
214
|
const { apply, refresh } = useApply(client);
|
|
189
|
-
const ids = useId();
|
|
190
|
-
const [id, setId] = useState('');
|
|
191
|
-
const [secret, setSecret] = useState('');
|
|
192
|
-
const [replacing, setReplacing] = useState(false);
|
|
193
|
-
const [busy, setBusy] = useState(false);
|
|
194
215
|
const state = s.web.scrapeql;
|
|
195
216
|
if (!state)
|
|
196
217
|
return null;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
218
|
+
return (_jsx(LumenExpandList, { children: _jsx(LumenConnection, { id: "scrapeql", name: "ScrapeQL", logo: _jsx(ConnectionLogo, { icon: GlobeIcon }), meta: state.set ? undefined : 'Search and pages that need a real browser', ways: [
|
|
219
|
+
{
|
|
220
|
+
id: 'token',
|
|
221
|
+
label: 'Service token',
|
|
222
|
+
icon: KeyRoundIcon,
|
|
223
|
+
info: 'A Cloudflare Access service token that ScrapeQL lets in.',
|
|
224
|
+
set: state.set,
|
|
225
|
+
ident: state.hint,
|
|
226
|
+
test: wayTest(state),
|
|
227
|
+
readOnly: !s.admin,
|
|
228
|
+
fields: [
|
|
229
|
+
{ id: 'client_id', label: 'Client ID' },
|
|
230
|
+
{ id: 'client_secret', label: 'Client Secret', secret: true },
|
|
231
|
+
],
|
|
232
|
+
onSave: (v) => apply('scrapeql', () => client.scrapeql({
|
|
233
|
+
client_id: v.client_id ?? '',
|
|
234
|
+
client_secret: v.client_secret ?? '',
|
|
235
|
+
}), 'Token saved'),
|
|
236
|
+
...(s.admin ? { onTest: () => client.testScrapeql().finally(refresh) } : {}),
|
|
237
|
+
onRemove: () => apply('scrapeql', () => client.scrapeql(null), 'Token removed'),
|
|
238
|
+
removeTitle: 'Remove the ScrapeQL token?',
|
|
239
|
+
removeText: 'Search and browser pages stop until a token is set again.',
|
|
240
|
+
},
|
|
241
|
+
] }) }));
|
|
216
242
|
}
|
|
217
243
|
function Web({ client }) {
|
|
218
244
|
const { apply } = useApply(client);
|
package/dist/chatgpt-signin.d.ts
CHANGED
|
@@ -14,5 +14,8 @@ export interface LumenChatgptSigninProps {
|
|
|
14
14
|
/** One free call that says whether the sign-in still works; the app refreshes its data after. */
|
|
15
15
|
test: () => Promise<unknown>;
|
|
16
16
|
}
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Sign in with ChatGPT: opens in a new tab, finished by pasting back where it ended.
|
|
19
|
+
* @deprecated Use `LumenConnection` with a `signin` way; the assistant keeps the plan apart from the key.
|
|
20
|
+
*/
|
|
18
21
|
export declare function LumenChatgptSignin({ state, start, finish, signOut, test, }: LumenChatgptSigninProps): import("react").JSX.Element;
|
package/dist/chatgpt-signin.js
CHANGED
|
@@ -17,7 +17,10 @@ const testOf = (k) => k.test
|
|
|
17
17
|
error: k.test.ok ? null : k.test.detail,
|
|
18
18
|
}
|
|
19
19
|
: null;
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Sign in with ChatGPT: opens in a new tab, finished by pasting back where it ended.
|
|
22
|
+
* @deprecated Use `LumenConnection` with a `signin` way; the assistant keeps the plan apart from the key.
|
|
23
|
+
*/
|
|
21
24
|
export function LumenChatgptSignin({ state, start, finish, signOut, test, }) {
|
|
22
25
|
const id = useId();
|
|
23
26
|
const [started, setStarted] = useState(null);
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { type ComponentType, type ReactNode } from 'react';
|
|
2
|
+
/** A way's last test. `at` is an ISO time or epoch ms; `expired` asks for a new key or sign-in. */
|
|
3
|
+
export interface ConnectionTest {
|
|
4
|
+
at: string | number;
|
|
5
|
+
ok: boolean;
|
|
6
|
+
/** What it read when it worked (`3 models`, the account). */
|
|
7
|
+
detail?: string | null;
|
|
8
|
+
/** Why it failed, in the person's words. */
|
|
9
|
+
error?: string | null;
|
|
10
|
+
expired?: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** One value a way is set up with: a key, a client id, a secret. */
|
|
13
|
+
export interface ConnectionField {
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
/** Typed as a password and never shown again. */
|
|
17
|
+
secret?: boolean;
|
|
18
|
+
/** What it starts with (`sk-`); anything else is an issue before Save. */
|
|
19
|
+
prefix?: string;
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
info?: ReactNode;
|
|
22
|
+
}
|
|
23
|
+
/** A sign-in that opens in a new tab and ends on an address the person pastes back. */
|
|
24
|
+
export interface ConnectionSignin {
|
|
25
|
+
/** Starts it on the server: the address to open and, when it varies, the one it ends on. */
|
|
26
|
+
start: () => Promise<{
|
|
27
|
+
url: string;
|
|
28
|
+
redirect?: string;
|
|
29
|
+
}>;
|
|
30
|
+
/** Where it ends when `start` does not say (`http://localhost:8765/`). */
|
|
31
|
+
redirect?: string;
|
|
32
|
+
/** Whose sign-in, for the button: `Sign in with ChatGPT`. */
|
|
33
|
+
provider?: string;
|
|
34
|
+
}
|
|
35
|
+
/** One way to connect. */
|
|
36
|
+
export interface ConnectionWay {
|
|
37
|
+
id: string;
|
|
38
|
+
/** `API key`, `ChatGPT plan`, `Service token`. */
|
|
39
|
+
label: string;
|
|
40
|
+
icon?: ComponentType<{
|
|
41
|
+
className?: string;
|
|
42
|
+
}>;
|
|
43
|
+
info?: ReactNode;
|
|
44
|
+
/** Set up now. */
|
|
45
|
+
set: boolean;
|
|
46
|
+
/** Who or what it is once set: an email, a key's last characters. */
|
|
47
|
+
ident?: ReactNode;
|
|
48
|
+
test?: ConnectionTest | null;
|
|
49
|
+
/** A short line in place of the actions (`From the server`, `Only an admin can change it`). */
|
|
50
|
+
note?: ReactNode;
|
|
51
|
+
/** Pasted values: the built-in form, saved by `onSave`. */
|
|
52
|
+
fields?: ConnectionField[];
|
|
53
|
+
/** Where a key is made, beside the empty fields. */
|
|
54
|
+
getUrl?: string;
|
|
55
|
+
signin?: ConnectionSignin;
|
|
56
|
+
/** Tests and keeps the values; false (the app said why) or a throw keeps the form open. */
|
|
57
|
+
onSave?: (values: Record<string, string>) => Promise<unknown>;
|
|
58
|
+
/** Finishes a sign-in from the pasted address; false or a throw keeps it open. */
|
|
59
|
+
onFinish?: (pasted: string) => Promise<unknown>;
|
|
60
|
+
/** One cheap call that says whether it still works; the app refreshes its data after. */
|
|
61
|
+
onTest?: () => Promise<unknown>;
|
|
62
|
+
/** Removes it (asks first). */
|
|
63
|
+
onRemove?: () => Promise<unknown>;
|
|
64
|
+
/** The confirm's question and action: `Sign out of ChatGPT?`, `Sign out`. */
|
|
65
|
+
removeTitle?: string;
|
|
66
|
+
removeText?: ReactNode;
|
|
67
|
+
removeLabel?: string;
|
|
68
|
+
/** The button that sets it up: `Add api key`, `Sign in with …` by default. */
|
|
69
|
+
addLabel?: string;
|
|
70
|
+
/** The app's own editor in place of `fields` and `signin`; `close` once saved or cancelled. */
|
|
71
|
+
form?: (close: () => void) => ReactNode;
|
|
72
|
+
/** Shown but not changed here (a person who is not an admin). */
|
|
73
|
+
readOnly?: boolean;
|
|
74
|
+
}
|
|
75
|
+
/** A label and value under the ways: `Server`, `Sent from`. */
|
|
76
|
+
export interface ConnectionLine {
|
|
77
|
+
label: string;
|
|
78
|
+
value: ReactNode;
|
|
79
|
+
info?: ReactNode;
|
|
80
|
+
mono?: boolean;
|
|
81
|
+
}
|
|
82
|
+
export interface LumenConnectionProps {
|
|
83
|
+
/** Its id inside a LumenExpandList. */
|
|
84
|
+
id?: string;
|
|
85
|
+
name: ReactNode;
|
|
86
|
+
/** The collapsed row's line; by default the way in use and who it is. */
|
|
87
|
+
meta?: ReactNode;
|
|
88
|
+
/** A logo or icon before the name. */
|
|
89
|
+
logo?: ReactNode;
|
|
90
|
+
ways: ConnectionWay[];
|
|
91
|
+
/** The way in use when several are set up; by default the first set up. */
|
|
92
|
+
use?: string;
|
|
93
|
+
/** Picks the way in use; with it and two ways or more the ways show radios. */
|
|
94
|
+
onUse?: (way: string) => unknown;
|
|
95
|
+
lines?: ConnectionLine[];
|
|
96
|
+
defaultOpen?: boolean;
|
|
97
|
+
children?: ReactNode;
|
|
98
|
+
}
|
|
99
|
+
export type ConnectionStatus = 'connected' | 'untested' | 'expired' | 'failed' | 'off';
|
|
100
|
+
/** Where a way stands: the row's pill. */
|
|
101
|
+
export declare function connectionStatus(way: ConnectionWay | undefined): ConnectionStatus;
|
|
102
|
+
/** The way in use: the picked one when it is set up, else the first set up. */
|
|
103
|
+
export declare function activeWay(ways: ConnectionWay[], use?: string): ConnectionWay | undefined;
|
|
104
|
+
/** The row's pill for a status. */
|
|
105
|
+
export declare function ConnectionBadge({ status, signin, }: {
|
|
106
|
+
status: ConnectionStatus;
|
|
107
|
+
signin?: boolean;
|
|
108
|
+
}): import("react").JSX.Element;
|
|
109
|
+
/** A connection's mark: an icon or a letter on a tile. */
|
|
110
|
+
export declare function ConnectionLogo({ icon: Icon, text, }: {
|
|
111
|
+
icon?: ComponentType<{
|
|
112
|
+
className?: string;
|
|
113
|
+
}>;
|
|
114
|
+
text?: string;
|
|
115
|
+
}): import("react").JSX.Element;
|
|
116
|
+
/** One connection: a row with its status, the ways to connect inside. */
|
|
117
|
+
export declare function LumenConnection({ id, name, meta, logo, ways, use, onUse, lines, defaultOpen, children, }: LumenConnectionProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// One connection, one look on every app: an AI provider, an email relay, a Google account, a
|
|
3
|
+
// connector. A row that expands in place (LumenExpandRow) with one status pill; inside, each way to
|
|
4
|
+
// connect (a pasted key or token, a sign-in that ends on an address the person pastes back, or the
|
|
5
|
+
// app's own form). Several ways show radios and an In use tag, so a person who has both an API key and
|
|
6
|
+
// a plan sign-in picks which one runs. A way that is set up shows who or what it is and its last test,
|
|
7
|
+
// then Test, Remove (asks first) and Replace or Sign in again, all on the right. `lines` and
|
|
8
|
+
// `children` hold the app's own settings below the ways (a server, a sender, folders).
|
|
9
|
+
import { AlertTriangleIcon, CheckIcon, ExternalLinkIcon, KeyRoundIcon, LogInIcon, PlusIcon, RefreshCwIcon, Trash2Icon, XIcon, } from 'lucide-react';
|
|
10
|
+
import { useId, useState } from 'react';
|
|
11
|
+
import { LumenConfirm } from './confirm.js';
|
|
12
|
+
import { LumenExpandRow } from './expand-list.js';
|
|
13
|
+
import { SaveButton } from './form-issues.js';
|
|
14
|
+
import { IconButton } from './icon-button.js';
|
|
15
|
+
import { InfoLabel } from './info-tip.js';
|
|
16
|
+
import { ErrorLine, errorText, useClock } from './integrations.js';
|
|
17
|
+
import { cn } from './lib/utils.js';
|
|
18
|
+
import { lumenToast } from './lumen-toast.js';
|
|
19
|
+
import { Badge } from './ui/badge.js';
|
|
20
|
+
import { Button } from './ui/button.js';
|
|
21
|
+
import { Input } from './ui/input.js';
|
|
22
|
+
import { Label } from './ui/label.js';
|
|
23
|
+
import { RadioGroup, RadioGroupItem } from './ui/radio-group.js';
|
|
24
|
+
/** Where a way stands: the row's pill. */
|
|
25
|
+
export function connectionStatus(way) {
|
|
26
|
+
if (!way?.set)
|
|
27
|
+
return 'off';
|
|
28
|
+
if (!way.test)
|
|
29
|
+
return 'untested';
|
|
30
|
+
if (way.test.ok)
|
|
31
|
+
return 'connected';
|
|
32
|
+
return way.test.expired ? 'expired' : 'failed';
|
|
33
|
+
}
|
|
34
|
+
/** The way in use: the picked one when it is set up, else the first set up. */
|
|
35
|
+
export function activeWay(ways, use) {
|
|
36
|
+
return ways.find((w) => w.id === use && w.set) ?? ways.find((w) => w.set);
|
|
37
|
+
}
|
|
38
|
+
/** The row's pill for a status. */
|
|
39
|
+
export function ConnectionBadge({ status, signin, }) {
|
|
40
|
+
switch (status) {
|
|
41
|
+
case 'connected':
|
|
42
|
+
return (_jsxs(Badge, { variant: "outline", className: "text-gain", children: [_jsx(CheckIcon, {}), "Connected"] }));
|
|
43
|
+
case 'untested':
|
|
44
|
+
return _jsx(Badge, { variant: "outline", children: "Not tested" });
|
|
45
|
+
case 'expired':
|
|
46
|
+
return (_jsxs(Badge, { variant: "outline", className: "text-warning", children: [_jsx(AlertTriangleIcon, {}), signin ? 'Sign in again' : 'Key expired'] }));
|
|
47
|
+
case 'failed':
|
|
48
|
+
return (_jsxs(Badge, { variant: "outline", className: "text-loss", children: [_jsx(AlertTriangleIcon, {}), "Failed"] }));
|
|
49
|
+
default:
|
|
50
|
+
return (_jsx(Badge, { variant: "outline", className: "border-dashed text-muted-foreground", children: "Not connected" }));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** A connection's mark: an icon or a letter on a tile. */
|
|
54
|
+
export function ConnectionLogo({ icon: Icon, text, }) {
|
|
55
|
+
return (_jsx("span", { "aria-hidden": true, className: "flex size-8 shrink-0 items-center justify-center rounded-md border bg-muted font-semibold text-muted-foreground text-sm", children: Icon ? _jsx(Icon, { className: "size-4" }) : text }));
|
|
56
|
+
}
|
|
57
|
+
/** `just now`, `5 min ago`, `3 h ago`, `2 d ago`. */
|
|
58
|
+
function ago(ms) {
|
|
59
|
+
const s = Math.max(0, (Date.now() - ms) / 1000);
|
|
60
|
+
if (s < 60)
|
|
61
|
+
return 'just now';
|
|
62
|
+
if (s < 3600)
|
|
63
|
+
return `${Math.floor(s / 60)} min ago`;
|
|
64
|
+
if (s < 86400)
|
|
65
|
+
return `${Math.floor(s / 3600)} h ago`;
|
|
66
|
+
return `${Math.floor(s / 86400)} d ago`;
|
|
67
|
+
}
|
|
68
|
+
/** `Service token` → `service token`; `API key` stays. */
|
|
69
|
+
const lower = (label) => /^[A-Z][a-z]/.test(label) ? label.charAt(0).toLowerCase() + label.slice(1) : label;
|
|
70
|
+
const toMs = (at) => (typeof at === 'number' ? at : Date.parse(at));
|
|
71
|
+
/** One connection: a row with its status, the ways to connect inside. */
|
|
72
|
+
export function LumenConnection({ id, name, meta, logo, ways, use, onUse, lines, defaultOpen, children, }) {
|
|
73
|
+
const active = activeWay(ways, use);
|
|
74
|
+
const status = connectionStatus(active);
|
|
75
|
+
const pick = ways.length > 1 && !!onUse;
|
|
76
|
+
const [picking, setPicking] = useState(false);
|
|
77
|
+
const choose = async (way) => {
|
|
78
|
+
if (!onUse || way === active?.id)
|
|
79
|
+
return;
|
|
80
|
+
setPicking(true);
|
|
81
|
+
try {
|
|
82
|
+
await onUse(way);
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
lumenToast.error(errorText(e));
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
setPicking(false);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const line = meta ??
|
|
92
|
+
(active ? (_jsxs(_Fragment, { children: [ways.length > 1 && active.label, ways.length > 1 && active.ident ? ' · ' : '', active.ident] })) : ('Not set up'));
|
|
93
|
+
const list = ways.map((w) => (_jsx(WayRow, { way: w, pick: pick, inUse: w.id === active?.id && ways.length > 1 }, w.id)));
|
|
94
|
+
return (_jsxs(LumenExpandRow, { id: id, name: name, meta: line, icon: logo, defaultOpen: defaultOpen, badge: _jsx(ConnectionBadge, { status: status, signin: !!active?.signin }), children: [_jsx("div", { className: "grid grid-cols-1 gap-2", children: pick ? (_jsx(RadioGroup, { value: active?.id ?? '', onValueChange: (v) => void choose(v), disabled: picking, "aria-label": `Which way ${typeof name === 'string' ? name : ''} connects`, className: "gap-2", children: list })) : (list) }), lines?.length ? (_jsx("dl", { className: "grid grid-cols-1 text-sm", children: lines.map((l) => (_jsxs("div", { className: "flex items-center gap-3 border-b py-2 last:border-b-0", children: [_jsx("dt", { className: "shrink-0 text-muted-foreground", children: _jsx(InfoLabel, { info: l.info, children: l.label }) }), _jsx("dd", { className: cn('min-w-0 flex-1 truncate text-right', l.mono && 'font-code text-xs'), title: typeof l.value === 'string' ? l.value : undefined, children: l.value })] }, l.label))) })) : null, children] }));
|
|
95
|
+
}
|
|
96
|
+
/** One way: its name and state, its actions on the right, its form when open. */
|
|
97
|
+
function WayRow({ way, pick, inUse }) {
|
|
98
|
+
const radioId = useId();
|
|
99
|
+
const clock = useClock();
|
|
100
|
+
const [editing, setEditing] = useState(false);
|
|
101
|
+
const [testing, setTesting] = useState(false);
|
|
102
|
+
const [error, setError] = useState(null);
|
|
103
|
+
const Icon = way.icon;
|
|
104
|
+
const status = connectionStatus(way);
|
|
105
|
+
const failing = status === 'failed' || status === 'expired';
|
|
106
|
+
const editable = !way.readOnly && !way.note;
|
|
107
|
+
const hasForm = !!(way.form || way.fields?.length || way.signin);
|
|
108
|
+
const test = async () => {
|
|
109
|
+
if (!way.onTest)
|
|
110
|
+
return;
|
|
111
|
+
setTesting(true);
|
|
112
|
+
setError(null);
|
|
113
|
+
try {
|
|
114
|
+
await way.onTest();
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
setError(e);
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
setTesting(false);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const close = () => setEditing(false);
|
|
124
|
+
const fixLabel = way.signin ? 'Sign in again' : 'Replace';
|
|
125
|
+
const addLabel = way.addLabel ??
|
|
126
|
+
(way.signin
|
|
127
|
+
? way.signin.provider
|
|
128
|
+
? `Sign in with ${way.signin.provider}`
|
|
129
|
+
: 'Sign in'
|
|
130
|
+
: `Add ${lower(way.label)}`);
|
|
131
|
+
const t = way.test;
|
|
132
|
+
return (_jsxs("div", { className: cn('grid grid-cols-1 gap-2 rounded-md border px-3 py-2.5', inUse && 'border-primary/60 bg-primary/5'), children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [pick && (_jsx(RadioGroupItem, { id: radioId, value: way.id, disabled: !way.set, "aria-label": `Use ${way.label}` })), Icon && _jsx(Icon, { className: "size-4 shrink-0 text-muted-foreground" }), _jsxs("span", { className: "min-w-0 flex-1 basis-40", children: [_jsxs("span", { className: "flex min-w-0 items-center gap-2", children: [pick ? (_jsx(Label, { htmlFor: radioId, className: "whitespace-nowrap font-medium", children: _jsx(InfoLabel, { info: way.info, children: way.label }) })) : (_jsx(InfoLabel, { className: "whitespace-nowrap font-medium text-sm", info: way.info, children: way.label })), inUse && (_jsx(Badge, { variant: "outline", className: "h-5 shrink-0 border-primary/40 text-primary", children: "In use" }))] }), _jsx("span", { className: "flex min-w-0 items-center gap-1 text-muted-foreground text-xs", children: way.set ? (_jsxs(_Fragment, { children: [way.ident && (_jsx("span", { className: "truncate", title: typeof way.ident === 'string' ? way.ident : undefined, children: way.ident })), way.ident && _jsx("span", { "aria-hidden": true, children: "\u00B7" }), t ? (_jsxs("span", { className: cn('flex shrink-0 items-center gap-1', t.ok ? 'text-gain' : 'text-loss'), title: `Tested ${clock(new Date(toMs(t.at)).toISOString())}${t.ok && t.detail ? ` · ${t.detail}` : ''}`, children: [t.ok ? (_jsx(CheckIcon, { className: "size-3" })) : (_jsx(AlertTriangleIcon, { className: "size-3" })), t.ok ? 'Works' : t.expired ? 'Expired' : 'Failed', " ", ago(toMs(t.at))] })) : (_jsx("span", { className: "shrink-0", children: "Not tested" }))] })) : (_jsx("span", { children: "Not set up" })) })] }), way.note ? (_jsx("span", { className: "ml-auto shrink-0 text-muted-foreground text-xs", children: way.note })) : way.set ? (_jsxs("span", { className: "ml-auto flex shrink-0 items-center gap-1", children: [way.onTest && (_jsx(IconButton, { icon: RefreshCwIcon, label: testing ? 'Testing…' : `Test ${way.label}`, busy: testing, disabled: testing, onClick: () => void test() })), editable && way.onRemove && (_jsx(LumenConfirm, { title: way.removeTitle ?? `Remove ${lower(way.label)}?`, description: way.removeText ?? 'You can add it again any time.', action: way.removeLabel ?? 'Remove', onConfirm: () => way.onRemove?.(), problem: errorText, children: _jsx(IconButton, { icon: Trash2Icon, label: way.removeLabel ?? `Remove ${way.label}` }) })), editable && hasForm && !editing && (_jsxs(Button, { size: "sm", variant: failing ? 'default' : 'outline', onClick: () => setEditing(true), children: [way.signin ? _jsx(LogInIcon, {}) : _jsx(KeyRoundIcon, {}), fixLabel] }))] })) : (editable &&
|
|
133
|
+
hasForm &&
|
|
134
|
+
!editing && (_jsxs(Button, { size: "sm", className: "ml-auto shrink-0", onClick: () => setEditing(true), children: [way.signin ? _jsx(LogInIcon, {}) : _jsx(PlusIcon, {}), addLabel] })))] }), t && !t.ok && t.error && (_jsx("p", { role: "alert", className: "text-xs text-loss", children: t.error })), _jsx(ErrorLine, { error: error }), editing &&
|
|
135
|
+
(way.form ? (way.form(close)) : way.signin ? (_jsx(SigninForm, { way: way, close: close })) : (_jsx(FieldsForm, { way: way, close: close })))] }));
|
|
136
|
+
}
|
|
137
|
+
/** Runs a save; false or a throw keeps the form open (a throw says why). */
|
|
138
|
+
async function keep(run, setError) {
|
|
139
|
+
setError(null);
|
|
140
|
+
try {
|
|
141
|
+
return (await run()) !== false;
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
setError(e);
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** The way's fields, checked before Test and save. */
|
|
149
|
+
function FieldsForm({ way, close }) {
|
|
150
|
+
const ids = useId();
|
|
151
|
+
const fields = way.fields ?? [];
|
|
152
|
+
const [values, setValues] = useState({});
|
|
153
|
+
const [busy, setBusy] = useState(false);
|
|
154
|
+
const [error, setError] = useState(null);
|
|
155
|
+
const val = (f) => (values[f.id] ?? '').trim();
|
|
156
|
+
const issues = [];
|
|
157
|
+
for (const f of fields) {
|
|
158
|
+
const v = val(f);
|
|
159
|
+
if (v && f.prefix && !v.startsWith(f.prefix))
|
|
160
|
+
issues.push({
|
|
161
|
+
where: f.label,
|
|
162
|
+
message: `It starts with ${f.prefix}`,
|
|
163
|
+
target: `${ids}-${f.id}`,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const empty = fields.some((f) => !val(f));
|
|
167
|
+
const save = async () => {
|
|
168
|
+
if (!way.onSave)
|
|
169
|
+
return;
|
|
170
|
+
setBusy(true);
|
|
171
|
+
const trimmed = Object.fromEntries(fields.map((f) => [f.id, val(f)]));
|
|
172
|
+
if (await keep(() => Promise.resolve(way.onSave?.(trimmed)), setError))
|
|
173
|
+
close();
|
|
174
|
+
setBusy(false);
|
|
175
|
+
};
|
|
176
|
+
return (_jsxs("div", { className: "grid grid-cols-1 gap-2 border-t pt-2", children: [fields.map((f) => (_jsxs("div", { className: "grid grid-cols-1 gap-1", children: [fields.length > 1 && (_jsx(Label, { htmlFor: `${ids}-${f.id}`, className: "text-xs", children: _jsx(InfoLabel, { info: f.info, children: f.label }) })), _jsx(Input, { id: `${ids}-${f.id}`, "aria-label": f.label, type: f.secret ? 'password' : 'text', autoComplete: "off", placeholder: f.placeholder ?? (f.prefix ? `${f.prefix}…` : f.label), className: "font-code text-xs", value: values[f.id] ?? '', onChange: (e) => setValues((s) => ({ ...s, [f.id]: e.target.value })), onKeyDown: (e) => e.key === 'Enter' && !empty && !issues.length && void save() })] }, f.id))), _jsx(ErrorLine, { error: error }), _jsxs("div", { className: "flex items-center justify-end gap-1", children: [way.getUrl && (_jsx(IconButton, { icon: ExternalLinkIcon, label: `Get ${/^[AEIOU]/i.test(way.label) ? 'an' : 'a'} ${lower(way.label)}`, href: way.getUrl })), _jsx(IconButton, { icon: XIcon, label: "Cancel", onClick: close }), _jsx(SaveButton, { issues: issues, busy: busy, disabled: empty, label: "Test and save", onSave: () => void save() })] })] }));
|
|
177
|
+
}
|
|
178
|
+
/** Open the sign-in in a new tab, then paste back the address it ended on. */
|
|
179
|
+
function SigninForm({ way, close }) {
|
|
180
|
+
const id = useId();
|
|
181
|
+
const signin = way.signin;
|
|
182
|
+
const [redirect, setRedirect] = useState(null);
|
|
183
|
+
const [pasted, setPasted] = useState('');
|
|
184
|
+
const [busy, setBusy] = useState(false);
|
|
185
|
+
const [error, setError] = useState(null);
|
|
186
|
+
const open = async () => {
|
|
187
|
+
// Open the tab inside the click, or the browser blocks it; point it once the address is known.
|
|
188
|
+
const tab = window.open('about:blank', '_blank');
|
|
189
|
+
if (tab)
|
|
190
|
+
tab.opener = null;
|
|
191
|
+
setError(null);
|
|
192
|
+
try {
|
|
193
|
+
const r = await signin.start();
|
|
194
|
+
if (tab)
|
|
195
|
+
tab.location.href = r.url;
|
|
196
|
+
else
|
|
197
|
+
window.open(r.url, '_blank', 'noopener');
|
|
198
|
+
setRedirect(r.redirect ?? signin.redirect ?? '');
|
|
199
|
+
}
|
|
200
|
+
catch (e) {
|
|
201
|
+
tab?.close();
|
|
202
|
+
setError(e);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
const origin = (() => {
|
|
206
|
+
try {
|
|
207
|
+
return redirect ? new URL(redirect).origin : '';
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return redirect ?? '';
|
|
211
|
+
}
|
|
212
|
+
})();
|
|
213
|
+
const issues = [];
|
|
214
|
+
const p = pasted.trim();
|
|
215
|
+
if (p && origin && !p.startsWith(origin))
|
|
216
|
+
issues.push({ where: 'Address', message: `It starts with ${redirect}`, target: id });
|
|
217
|
+
const finish = async () => {
|
|
218
|
+
if (!way.onFinish)
|
|
219
|
+
return;
|
|
220
|
+
setBusy(true);
|
|
221
|
+
if (await keep(() => Promise.resolve(way.onFinish?.(p)), setError))
|
|
222
|
+
close();
|
|
223
|
+
setBusy(false);
|
|
224
|
+
};
|
|
225
|
+
return (_jsxs("ol", { className: "grid grid-cols-1 gap-3 border-t pt-2 text-sm", children: [_jsxs("li", { className: "flex items-center gap-2", children: [_jsx(Step, { n: 1 }), _jsx("span", { className: "flex-1", children: "Sign in in a new tab" }), _jsxs(Button, { size: "sm", variant: redirect === null ? 'default' : 'outline', onClick: () => void open(), children: [_jsx(ExternalLinkIcon, {}), redirect === null ? 'Open sign-in' : 'Open again'] })] }), _jsxs("li", { className: "grid grid-cols-1 gap-2", children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(Step, { n: 2 }), _jsx(Label, { htmlFor: id, className: cn(redirect === null && 'text-muted-foreground'), children: _jsx(InfoLabel, { info: `The sign-in ends on ${redirect || signin.redirect || 'an address'} that does not load. That is expected: copy the whole address from that tab's address bar.`, children: "Paste the address it ended on" }) })] }), _jsx("span", { className: "flex gap-2 pl-7", children: _jsx(Input, { id: id, autoComplete: "off", disabled: redirect === null, placeholder: `${redirect || signin.redirect || 'http://localhost'}…`, className: "font-code text-xs", value: pasted, onChange: (e) => setPasted(e.target.value) }) })] }), _jsx(ErrorLine, { error: error }), _jsxs("li", { className: "flex items-center justify-end gap-1", children: [_jsx(IconButton, { icon: XIcon, label: "Cancel", onClick: close }), _jsx(SaveButton, { issues: issues, busy: busy, disabled: !p, label: "Finish", onSave: () => void finish() })] })] }));
|
|
226
|
+
}
|
|
227
|
+
function Step({ n }) {
|
|
228
|
+
return (_jsx("span", { className: "flex size-5 shrink-0 items-center justify-center rounded-full bg-muted font-medium text-xs tabular-nums", children: n }));
|
|
229
|
+
}
|
package/dist/expand-list.d.ts
CHANGED
|
@@ -9,11 +9,13 @@ 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, open: openProp, onOpenChange, defaultOpen, children, }: {
|
|
12
|
+
export declare function LumenExpandRow({ id, name, meta, badge, icon, open: openProp, onOpenChange, defaultOpen, children, }: {
|
|
13
13
|
id?: string;
|
|
14
14
|
name: ReactNode;
|
|
15
15
|
meta?: ReactNode;
|
|
16
16
|
badge?: ReactNode;
|
|
17
|
+
/** A logo or icon before the name. */
|
|
18
|
+
icon?: ReactNode;
|
|
17
19
|
open?: boolean;
|
|
18
20
|
onOpenChange?: (open: boolean) => void;
|
|
19
21
|
defaultOpen?: boolean;
|
package/dist/expand-list.js
CHANGED
|
@@ -16,7 +16,7 @@ export function LumenExpandList({ defaultOpen = null, className, children, }) {
|
|
|
16
16
|
* One row that expands in place. Inside a LumenExpandList it opens by `id`; `open` and
|
|
17
17
|
* `onOpenChange` make it controlled; alone it keeps its own state (`defaultOpen`).
|
|
18
18
|
*/
|
|
19
|
-
export function LumenExpandRow({ id, name, meta, badge, open: openProp, onOpenChange, defaultOpen = false, children, }) {
|
|
19
|
+
export function LumenExpandRow({ id, name, meta, badge, icon, open: openProp, onOpenChange, defaultOpen = false, children, }) {
|
|
20
20
|
const list = useContext(ListCtx);
|
|
21
21
|
const [own, setOwn] = useState(defaultOpen);
|
|
22
22
|
const inList = list && id !== undefined;
|
|
@@ -29,5 +29,5 @@ export function LumenExpandRow({ id, name, meta, badge, open: openProp, onOpenCh
|
|
|
29
29
|
else
|
|
30
30
|
setOwn(o);
|
|
31
31
|
};
|
|
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: [_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 })] }));
|
|
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 })] }));
|
|
33
33
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -50,6 +50,7 @@ export { type Crumb, IconButton, type IconButtonProps, Page, PageBody, PageHeade
|
|
|
50
50
|
export { DEFAULT_PREFS, deviceTimeZone, type Prefs, TIME_ZONES, type Theme, type TimeZoneId, getPrefs, resolvedTheme, setPref, timeZoneLabel, usePrefs, ASSISTANT_WIDTH, } from './prefs.js';
|
|
51
51
|
export { KIT, type KitConfig, configureLumen } from './kit-config.js';
|
|
52
52
|
export { ConnectionBlock, ErrorLine, IntegrationRow, KeyTestLine, type KeyTestResult, TestBadge, errorText, useClock, } from './integrations.js';
|
|
53
|
+
export { ConnectionBadge, type ConnectionField, type ConnectionLine, ConnectionLogo, type ConnectionSignin, type ConnectionStatus, type ConnectionTest, type ConnectionWay, LumenConnection, type LumenConnectionProps, activeWay, connectionStatus, } from './connection.js';
|
|
53
54
|
export { LumenChatgptSignin, type LumenChatgptSigninProps } from './chatgpt-signin.js';
|
|
54
55
|
export { LumenExpandList, LumenExpandRow } from './expand-list.js';
|
|
55
56
|
export { type Fact, FactList, type FactListProps } from './fact-list.js';
|
package/dist/index.js
CHANGED
|
@@ -57,6 +57,7 @@ export { IconButton, Page, PageBody, PageHeader, } from './page.js';
|
|
|
57
57
|
export { DEFAULT_PREFS, deviceTimeZone, TIME_ZONES, getPrefs, resolvedTheme, setPref, timeZoneLabel, usePrefs, ASSISTANT_WIDTH, } from './prefs.js';
|
|
58
58
|
export { KIT, configureLumen } from './kit-config.js';
|
|
59
59
|
export { ConnectionBlock, ErrorLine, IntegrationRow, KeyTestLine, TestBadge, errorText, useClock, } from './integrations.js';
|
|
60
|
+
export { ConnectionBadge, ConnectionLogo, LumenConnection, activeWay, connectionStatus, } from './connection.js';
|
|
60
61
|
export { LumenChatgptSignin } from './chatgpt-signin.js';
|
|
61
62
|
export { LumenExpandList, LumenExpandRow } from './expand-list.js';
|
|
62
63
|
export { FactList } from './fact-list.js';
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { PeopleAdapter } from './client.js';
|
|
2
|
-
/** The Email section's body
|
|
3
|
-
*
|
|
4
|
-
export declare function LumenEmailSettings({ adapter,
|
|
2
|
+
/** The Email section's body: the relay as one connection (its key, its last check, Test, Remove),
|
|
3
|
+
* set up or changed in place through the four steps. */
|
|
4
|
+
export declare function LumenEmailSettings({ adapter, }: {
|
|
5
5
|
adapter: PeopleAdapter;
|
|
6
|
+
/** Kept for apps that pass it; the steps always sit in the row now. */
|
|
6
7
|
inline?: boolean;
|
|
7
8
|
}): import("react").JSX.Element;
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
3
|
-
import { CableIcon, CheckIcon, CircleHelpIcon, GlobeIcon, MailIcon, PlugZapIcon, XIcon, } from 'lucide-react';
|
|
3
|
+
import { CableIcon, CheckIcon, CircleHelpIcon, GlobeIcon, KeyRoundIcon, MailIcon, PlugZapIcon, XIcon, } from 'lucide-react';
|
|
4
4
|
import { useEffect, useId, useState } from 'react';
|
|
5
|
+
import { ConnectionLogo, LumenConnection } from '../connection.js';
|
|
6
|
+
import { LumenExpandList } from '../expand-list.js';
|
|
5
7
|
import { SaveButton } from '../form-issues.js';
|
|
8
|
+
import { IconButton } from '../icon-button.js';
|
|
6
9
|
import { InfoLabel } from '../info-tip.js';
|
|
7
10
|
import { errorText } from '../integrations.js';
|
|
8
11
|
import { LumenChoice } from '../lumen-choice.js';
|
|
9
12
|
import { lumenToast } from '../lumen-toast.js';
|
|
10
|
-
import { SettingsActions, SettingsFooter } from '../settings.js';
|
|
11
|
-
import { Badge } from '../ui/badge.js';
|
|
12
13
|
import { Button } from '../ui/button.js';
|
|
13
14
|
import { Input } from '../ui/input.js';
|
|
14
15
|
import { Spinner } from '../ui/spinner.js';
|
|
@@ -28,19 +29,68 @@ const same = (a, b) => a.route === b.route &&
|
|
|
28
29
|
(a.apiKey ?? '') === (b.apiKey ?? '') &&
|
|
29
30
|
a.from === b.from &&
|
|
30
31
|
a.fromName === b.fromName;
|
|
31
|
-
/** The Email section's body
|
|
32
|
-
*
|
|
33
|
-
export function LumenEmailSettings({ adapter,
|
|
32
|
+
/** The Email section's body: the relay as one connection (its key, its last check, Test, Remove),
|
|
33
|
+
* set up or changed in place through the four steps. */
|
|
34
|
+
export function LumenEmailSettings({ adapter, }) {
|
|
34
35
|
const api = adapter.email;
|
|
36
|
+
const qc = useQueryClient();
|
|
37
|
+
const key = ['lumen-people', adapter.key, 'email'];
|
|
35
38
|
const q = useQuery({
|
|
36
|
-
queryKey:
|
|
39
|
+
queryKey: key,
|
|
37
40
|
queryFn: () => api?.view() ?? Promise.reject(new Error('Email settings are not set up here.')),
|
|
38
41
|
});
|
|
39
|
-
if (!q.data)
|
|
42
|
+
if (!q.data || !api)
|
|
40
43
|
return (_jsx("p", { className: "text-muted-foreground text-sm", children: q.isError ? errorText(q.error) : 'Loading…' }));
|
|
41
|
-
|
|
44
|
+
const view = q.data;
|
|
45
|
+
const cur = view.current;
|
|
46
|
+
const last = view.lastTest;
|
|
47
|
+
const refresh = () => qc.invalidateQueries({ queryKey: ['lumen-people', adapter.key] });
|
|
48
|
+
const failed = last?.steps.find((x) => x.ok === false);
|
|
49
|
+
return (_jsx(LumenExpandList, { defaultOpen: cur ? null : 'email', children: _jsx(LumenConnection, { id: "email", name: "Email relay", logo: _jsx(ConnectionLogo, { icon: MailIcon }), meta: cur
|
|
50
|
+
? `${cur.fromName ? `${cur.fromName} · ` : ''}${cur.from}`
|
|
51
|
+
: 'Invites and alerts are not emailed', ways: [
|
|
52
|
+
{
|
|
53
|
+
id: 'api',
|
|
54
|
+
label: 'Email API',
|
|
55
|
+
icon: KeyRoundIcon,
|
|
56
|
+
info: 'A Resend-compatible email API: Resend itself or a self-hosted one.',
|
|
57
|
+
set: !!cur,
|
|
58
|
+
ident: cur?.keyHint,
|
|
59
|
+
test: last
|
|
60
|
+
? {
|
|
61
|
+
at: last.at,
|
|
62
|
+
ok: last.ok,
|
|
63
|
+
detail: last.kind === 'test' ? 'Test email sent' : 'Connection checked',
|
|
64
|
+
error: last.ok ? null : (failed?.text ?? 'The last check failed.'),
|
|
65
|
+
}
|
|
66
|
+
: null,
|
|
67
|
+
addLabel: 'Set up',
|
|
68
|
+
onTest: async () => {
|
|
69
|
+
const check = await api.check(blank(view));
|
|
70
|
+
await refresh();
|
|
71
|
+
if (!check.ok)
|
|
72
|
+
throw new Error(check.steps.find((x) => x.ok === false)?.text ?? 'The check failed.');
|
|
73
|
+
},
|
|
74
|
+
onRemove: async () => {
|
|
75
|
+
await api.clear();
|
|
76
|
+
await refresh();
|
|
77
|
+
},
|
|
78
|
+
removeTitle: 'Remove the email relay?',
|
|
79
|
+
removeText: 'Nothing is emailed until it is set up again.',
|
|
80
|
+
form: (close) => _jsx(EmailForm, { adapter: adapter, view: view, onDone: close }),
|
|
81
|
+
},
|
|
82
|
+
], lines: cur
|
|
83
|
+
? [
|
|
84
|
+
{
|
|
85
|
+
label: 'Reached through',
|
|
86
|
+
value: view.routes.find((r) => r.id === cur.route)?.label ?? cur.route,
|
|
87
|
+
},
|
|
88
|
+
{ label: 'Server', value: cur.url, mono: true },
|
|
89
|
+
{ label: 'Saved', value: `${cur.updatedBy ?? '—'} ${when(cur.updatedAt)}` },
|
|
90
|
+
]
|
|
91
|
+
: undefined }) }));
|
|
42
92
|
}
|
|
43
|
-
function EmailForm({ adapter, view,
|
|
93
|
+
function EmailForm({ adapter, view, onDone, }) {
|
|
44
94
|
const api = adapter.email;
|
|
45
95
|
const qc = useQueryClient();
|
|
46
96
|
const ids = useId();
|
|
@@ -89,6 +139,7 @@ function EmailForm({ adapter, view, inline, }) {
|
|
|
89
139
|
setReplacing(false);
|
|
90
140
|
setResult(null);
|
|
91
141
|
lumenToast.success('Email saved. Invites now go out by email.');
|
|
142
|
+
onDone();
|
|
92
143
|
}
|
|
93
144
|
catch (e) {
|
|
94
145
|
lumenToast.error(errorText(e));
|
|
@@ -111,18 +162,12 @@ function EmailForm({ adapter, view, inline, }) {
|
|
|
111
162
|
const ready = issues.length === 0;
|
|
112
163
|
if (ready && changed && !passed)
|
|
113
164
|
issues.push({ where: 'Test it', message: 'Check the connection or send a test first' });
|
|
114
|
-
const badge = passed ? (_jsx(Badge, { variant: "secondary", children: "Ready to save" })) : view.current ? (view.lastTest && !view.lastTest.ok ? (_jsx(Badge, { variant: "destructive", children: "Last test failed" })) : (_jsx(Badge, { variant: "secondary", className: "text-gain", children: "Working" }))) : (_jsx(Badge, { variant: "outline", children: "Not set up" }));
|
|
115
165
|
const footer = (_jsxs("div", { className: "flex w-full items-center gap-2", children: [_jsx("span", { className: "min-w-0 flex-1 truncate text-muted-foreground text-xs", children: passed
|
|
116
166
|
? 'Passed'
|
|
117
167
|
: view.current
|
|
118
168
|
? `Saved by ${view.current.updatedBy ?? '—'} ${when(view.current.updatedAt)}`
|
|
119
|
-
: 'Test to save' }),
|
|
120
|
-
|
|
121
|
-
setReplacing(!view.current);
|
|
122
|
-
setPerRoute({});
|
|
123
|
-
setResult(null);
|
|
124
|
-
}, children: "Revert" })), _jsx(SaveButton, { issues: issues, busy: busy === 'save', disabled: !changed, onSave: save })] }));
|
|
125
|
-
return (_jsxs("div", { className: "flex flex-col", children: [inline ? (_jsx("div", { className: "flex justify-end pb-2", children: badge })) : (_jsx(SettingsActions, { children: badge })), _jsx(Step, { n: 1, title: "Reach the server through", children: _jsx(LumenChoice, { label: "Reach the server through", value: draft.route, options: view.routes.map((r) => ({
|
|
169
|
+
: 'Test to save' }), _jsx(IconButton, { icon: XIcon, label: "Cancel", onClick: onDone }), _jsx(SaveButton, { issues: issues, busy: busy === 'save', disabled: !changed, onSave: save })] }));
|
|
170
|
+
return (_jsxs("div", { className: "flex flex-col border-t", children: [_jsx(Step, { n: 1, title: "Reach the server through", children: _jsx(LumenChoice, { label: "Reach the server through", value: draft.route, options: view.routes.map((r) => ({
|
|
126
171
|
value: r.id,
|
|
127
172
|
icon: r.id === 'public' ? GlobeIcon : CableIcon,
|
|
128
173
|
title: (_jsxs("span", { className: "flex w-full items-center gap-2", children: [r.label, r.state && (_jsx("span", { className: "ml-auto font-normal text-gain text-xs", children: r.state }))] })),
|
|
@@ -143,7 +188,7 @@ function EmailForm({ adapter, view, inline, }) {
|
|
|
143
188
|
} }) }), _jsxs(Step, { n: 2, title: "Email server", children: [_jsx(Labelled, { id: `${ids}-url`, label: _jsx(InfoLabel, { info: route?.urlInfo, children: "Server address" }), children: _jsx(Input, { id: `${ids}-url`, className: "font-code text-xs", value: draft.url, placeholder: route?.url ?? 'https://api.resend.com', onChange: (e) => set({ url: e.target.value }) }) }), _jsx(Labelled, { id: `${ids}-key`, label: "API key", children: replacing ? (_jsxs("div", { className: "flex gap-2", children: [_jsx(Input, { id: `${ids}-key`, type: "password", autoComplete: "off", className: "font-code text-xs", value: draft.apiKey ?? '', onChange: (e) => set({ apiKey: e.target.value }) }), view.current && (_jsx(Button, { size: "sm", variant: "ghost", onClick: () => {
|
|
144
189
|
setReplacing(false);
|
|
145
190
|
set({ apiKey: '' });
|
|
146
|
-
}, children: "Keep" }))] })) : (_jsxs("div", { className: "flex h-9 items-center gap-3 rounded-md border px-3 text-sm", children: [_jsx("span", { className: "font-code text-muted-foreground text-xs", children: view.current?.keyHint }), _jsx("span", { className: "ml-auto text-gain text-xs", children: "sealed" }), _jsx(Button, { size: "xs", variant: "link", onClick: () => setReplacing(true), children: "Replace" })] })) })] }), _jsx(Step, { n: 3, title: "Sent from", children: _jsxs("div", { className: "flex flex-col gap-3 sm:flex-row", children: [_jsx(Labelled, { id: `${ids}-name`, label: "Name", className: "sm:flex-1", children: _jsx(Input, { id: `${ids}-name`, value: draft.fromName, onChange: (e) => set({ fromName: e.target.value }) }) }), _jsx(Labelled, { id: `${ids}-from`, label: "Address", className: "sm:flex-[1.4]", children: _jsx(Input, { id: `${ids}-from`, type: "email", value: draft.from, placeholder: "hello@example.com", onChange: (e) => set({ from: e.target.value }) }) })] }) }), _jsxs(Step, { n: 4, title: "Test it", children: [_jsx("div", { className: "flex flex-wrap gap-2", children: _jsxs(Button, { size: "sm", variant: "outline", disabled: !ready || !!busy, onClick: () => run('check'), children: [busy === 'check' ? _jsx(Spinner, {}) : _jsx(PlugZapIcon, {}), " Check connection"] }) }), _jsx(Labelled, { id: `${ids}-test-to`, label: "Send a test email to", children: _jsxs("div", { className: "flex gap-2", children: [_jsx(Input, { id: `${ids}-test-to`, type: "email", className: "text-sm", value: testTo, placeholder: view.me, onChange: (e) => setTestTo(e.target.value) }), _jsxs(Button, { size: "sm", variant: "outline", disabled: !ready || !!busy || !testTo.trim(), onClick: () => run('test'), children: [busy === 'test' ? _jsx(Spinner, {}) : _jsx(MailIcon, {}), " Send test"] })] }) }), result && same(result.for, draft) && _jsx(Steps, { check: result.check }), !result && view.lastTest && (_jsxs("p", { className: "text-muted-foreground text-xs", children: ["Last ", view.lastTest.kind === 'test' ? 'test' : 'check', " ", when(view.lastTest.at), ":", ' ', view.lastTest.ok ? 'passed' : 'failed', "."] }))] }),
|
|
191
|
+
}, children: "Keep" }))] })) : (_jsxs("div", { className: "flex h-9 items-center gap-3 rounded-md border px-3 text-sm", children: [_jsx("span", { className: "font-code text-muted-foreground text-xs", children: view.current?.keyHint }), _jsx("span", { className: "ml-auto text-gain text-xs", children: "sealed" }), _jsx(Button, { size: "xs", variant: "link", onClick: () => setReplacing(true), children: "Replace" })] })) })] }), _jsx(Step, { n: 3, title: "Sent from", children: _jsxs("div", { className: "flex flex-col gap-3 sm:flex-row", children: [_jsx(Labelled, { id: `${ids}-name`, label: "Name", className: "sm:flex-1", children: _jsx(Input, { id: `${ids}-name`, value: draft.fromName, onChange: (e) => set({ fromName: e.target.value }) }) }), _jsx(Labelled, { id: `${ids}-from`, label: "Address", className: "sm:flex-[1.4]", children: _jsx(Input, { id: `${ids}-from`, type: "email", value: draft.from, placeholder: "hello@example.com", onChange: (e) => set({ from: e.target.value }) }) })] }) }), _jsxs(Step, { n: 4, title: "Test it", children: [_jsx("div", { className: "flex flex-wrap gap-2", children: _jsxs(Button, { size: "sm", variant: "outline", disabled: !ready || !!busy, onClick: () => run('check'), children: [busy === 'check' ? _jsx(Spinner, {}) : _jsx(PlugZapIcon, {}), " Check connection"] }) }), _jsx(Labelled, { id: `${ids}-test-to`, label: "Send a test email to", children: _jsxs("div", { className: "flex gap-2", children: [_jsx(Input, { id: `${ids}-test-to`, type: "email", className: "text-sm", value: testTo, placeholder: view.me, onChange: (e) => setTestTo(e.target.value) }), _jsxs(Button, { size: "sm", variant: "outline", disabled: !ready || !!busy || !testTo.trim(), onClick: () => run('test'), children: [busy === 'test' ? _jsx(Spinner, {}) : _jsx(MailIcon, {}), " Send test"] })] }) }), result && same(result.for, draft) && _jsx(Steps, { check: result.check }), !result && view.lastTest && (_jsxs("p", { className: "text-muted-foreground text-xs", children: ["Last ", view.lastTest.kind === 'test' ? 'test' : 'check', " ", when(view.lastTest.at), ":", ' ', view.lastTest.ok ? 'passed' : 'failed', "."] }))] }), _jsx("div", { className: "border-t pt-3", children: footer })] }));
|
|
147
192
|
}
|
|
148
193
|
function Step({ n, title, children }) {
|
|
149
194
|
return (_jsxs("section", { className: "flex gap-4 border-t py-5 first-of-type:border-t-0", "aria-label": title, children: [_jsx("span", { className: "num flex size-6 shrink-0 items-center justify-center rounded-full bg-muted font-semibold text-xs", children: n }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-3", children: [_jsx("h3", { className: "font-medium text-sm", children: title }), children] })] }));
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { RadioGroup as RadioGroupPrimitive } from 'radix-ui';
|
|
2
|
+
import type * as React from 'react';
|
|
3
|
+
declare function RadioGroup({ className, ...props }: React.ComponentProps<typeof RadioGroupPrimitive.Root>): React.JSX.Element;
|
|
4
|
+
declare function RadioGroupItem({ className, ...props }: React.ComponentProps<typeof RadioGroupPrimitive.Item>): React.JSX.Element;
|
|
5
|
+
export { RadioGroup, RadioGroupItem };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from '../lib/utils.js';
|
|
3
|
+
import { RadioGroup as RadioGroupPrimitive } from 'radix-ui';
|
|
4
|
+
function RadioGroup({ className, ...props }) {
|
|
5
|
+
return (_jsx(RadioGroupPrimitive.Root, { "data-slot": "radio-group", className: cn('grid gap-2', className), ...props }));
|
|
6
|
+
}
|
|
7
|
+
function RadioGroupItem({ className, ...props }) {
|
|
8
|
+
return (_jsx(RadioGroupPrimitive.Item, { "data-slot": "radio-group-item", className: cn('border-input dark:bg-input/30 data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive peer relative flex size-4 shrink-0 items-center justify-center rounded-full border outline-none transition-colors after:absolute after:-inset-2 focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50', className), ...props, children: _jsx(RadioGroupPrimitive.Indicator, { "data-slot": "radio-group-indicator", className: "flex items-center justify-center", children: _jsx("span", { className: "size-2 rounded-full bg-primary" }) }) }));
|
|
9
|
+
}
|
|
10
|
+
export { RadioGroup, RadioGroupItem };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@columenlabs/lumen-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.94",
|
|
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.94",
|
|
53
53
|
"@dnd-kit/core": "6.3.1",
|
|
54
54
|
"@dnd-kit/sortable": "10.0.0",
|
|
55
55
|
"@dnd-kit/utilities": "3.2.2",
|