@volter/world-ui 0.1.37 → 1.0.0
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/client/payment.tsx +1 -0
- package/dist/client/payment.tsx +1 -0
- package/dist/src/index.d.ts +1 -19
- package/dist/src/index.js +1 -41
- package/dist/src/session.d.ts +13 -0
- package/dist/src/session.js +33 -0
- package/package.json +5 -6
- package/src/index.ts +4 -54
- package/src/session.ts +38 -0
- package/client/kit.css +0 -55
- package/client/kit.tsx +0 -312
- package/dist/client/kit.bundle.js +0 -235
- package/dist/client/kit.css +0 -55
- package/dist/client/kit.d.ts +0 -101
- package/dist/client/kit.js +0 -160
- package/dist/client/kit.tsx +0 -312
package/client/kit.tsx
DELETED
|
@@ -1,312 +0,0 @@
|
|
|
1
|
-
// THE TWIN UI KIT — shared pieces for a derived pack's screens (docs/contributing/architecture.md,
|
|
2
|
-
// "Screens"): the shell and navigation, lists and details, references, state pills, the moves a machine
|
|
3
|
-
// allows from a row's state, and create forms, all through the vendor's own API. A pack builds the
|
|
4
|
-
// screens people work in from these under its skin. It is not a vendor's UI on its own: TwinUi's
|
|
5
|
-
// generic resource browser renders any declared resource and so answers what a World holds, which a
|
|
6
|
-
// vendor's screens never pose as.
|
|
7
|
-
import React, { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
8
|
-
import { createRoot } from 'react-dom/client';
|
|
9
|
-
|
|
10
|
-
export type Row = Record<string, any>;
|
|
11
|
-
|
|
12
|
-
export type UiAction = { operation: string; method: string; path: string; field: string; from: string[] | '*'; to?: string };
|
|
13
|
-
export type UiResource = {
|
|
14
|
-
list?: string;
|
|
15
|
-
retrieve?: string;
|
|
16
|
-
create?: { path: string; encoding: string; body: Array<{ name: string; type: string; required: boolean }> };
|
|
17
|
-
actions: UiAction[];
|
|
18
|
-
states: Record<string, string[]>;
|
|
19
|
-
embeds: Record<string, string>;
|
|
20
|
-
};
|
|
21
|
-
export type UiData = { resources: Record<string, UiResource> };
|
|
22
|
-
|
|
23
|
-
/** One entry of the navigation: a resource's list, titled the vendor's way. */
|
|
24
|
-
export type Section = {
|
|
25
|
-
resource: string;
|
|
26
|
-
label: string;
|
|
27
|
-
/** where it is listed, when not the resource's own list (a sub-collection) */
|
|
28
|
-
list?: string;
|
|
29
|
-
title?: (row: Row) => string;
|
|
30
|
-
subtitle?: (row: Row) => string;
|
|
31
|
-
/** what the vendor's detail page shows beyond the fields (a balance panel, an error banner) */
|
|
32
|
-
extras?: (row: Row) => ReactNode;
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
/** A screen of the vendor's UI that is not a resource view (a chat, a board, a canvas). */
|
|
36
|
-
export type Screen = { key: string; label: string; render: (api: Api) => ReactNode };
|
|
37
|
-
|
|
38
|
-
export type TwinUiConfig = {
|
|
39
|
-
skin: { name: string; product?: string };
|
|
40
|
-
ui: UiData;
|
|
41
|
-
sections: Section[];
|
|
42
|
-
screens?: Screen[];
|
|
43
|
-
/** headers every call carries (the fake credential the vendor's API asks for) */
|
|
44
|
-
headers?: Record<string, string>;
|
|
45
|
-
/** how the vendor's lists hold their rows: `data` (Stripe, OpenAI), the whole body (GitHub), or a key */
|
|
46
|
-
rowsAt?: (body: any, resource: string) => Row[];
|
|
47
|
-
/** a value's tone for its status pill */
|
|
48
|
-
tone?: (value: string) => 'ok' | 'warn' | 'bad' | 'muted';
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
// ── talking to the vendor's API ────────────────────────────────────────────────────────────
|
|
52
|
-
|
|
53
|
-
const BASE = typeof document === 'undefined' || document.querySelector('base[href]') === null ? '' : new URL('.', document.baseURI).pathname.replace(/\/$/, '');
|
|
54
|
-
|
|
55
|
-
export type Api = {
|
|
56
|
-
get: (path: string) => Promise<any>;
|
|
57
|
-
send: (method: string, path: string, body?: Record<string, unknown>, encoding?: string) => Promise<{ status: number; body: any }>;
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
function formEncode(value: unknown, prefix: string, out: URLSearchParams): void {
|
|
61
|
-
if (value === undefined || value === '') return;
|
|
62
|
-
if (value && typeof value === 'object') {
|
|
63
|
-
for (const [k, v] of Object.entries(value as Record<string, unknown>)) formEncode(v, prefix ? `${prefix}[${k}]` : k, out);
|
|
64
|
-
} else out.append(prefix, String(value));
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function makeApi(headers: Record<string, string> = {}): Api {
|
|
68
|
-
return {
|
|
69
|
-
get: (path) => fetch(`${BASE}${path}`, { headers }).then((r) => r.json()),
|
|
70
|
-
send: async (method, path, body, encoding = 'json') => {
|
|
71
|
-
const init: RequestInit = { method, headers: { ...headers } };
|
|
72
|
-
if (body && Object.keys(body).length) {
|
|
73
|
-
if (encoding === 'form') {
|
|
74
|
-
const p = new URLSearchParams();
|
|
75
|
-
formEncode(body, '', p);
|
|
76
|
-
init.body = p.toString();
|
|
77
|
-
(init.headers as Record<string, string>)['content-type'] = 'application/x-www-form-urlencoded';
|
|
78
|
-
} else {
|
|
79
|
-
init.body = JSON.stringify(body);
|
|
80
|
-
(init.headers as Record<string, string>)['content-type'] = 'application/json';
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
const r = await fetch(`${BASE}${path}`, init);
|
|
84
|
-
const text = await r.text();
|
|
85
|
-
let parsed: any = text;
|
|
86
|
-
try { parsed = text ? JSON.parse(text) : null; } catch { /* a non-JSON answer stays text */ }
|
|
87
|
-
return { status: r.status, body: parsed };
|
|
88
|
-
},
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const fill = (path: string, id: string): string => path.replace(/\{[^}]+\}/, encodeURIComponent(id));
|
|
93
|
-
const defaultRows = (body: any): Row[] => (Array.isArray(body) ? body : Array.isArray(body?.data) ? body.data : []);
|
|
94
|
-
|
|
95
|
-
// ── pieces ───────────────────────────────────────────────────────────────────────────────
|
|
96
|
-
|
|
97
|
-
export function StatusPill({ value, tone }: { value: unknown; tone?: TwinUiConfig['tone'] }) {
|
|
98
|
-
if (value === undefined || value === null || value === '') return null;
|
|
99
|
-
const t = tone?.(String(value)) ?? (/(succeeded|paid|active|open|completed|ok|true)/.test(String(value)) ? 'ok' : /(fail|canceled|cancelled|void|closed|expired|error|false)/.test(String(value)) ? 'bad' : 'muted');
|
|
100
|
-
return <span className={`tw-pill tw-pill-${t}`}>{String(value)}</span>;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export function RefChip({ label, onJump }: { label: string; onJump: () => void }) {
|
|
104
|
-
return (
|
|
105
|
-
<button type="button" className="tw-ref" onClick={onJump}>
|
|
106
|
-
{label}
|
|
107
|
-
</button>
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** An object as the vendor's detail page lists it: one line per field, nested objects indented, references as chips. */
|
|
112
|
-
export function Fields({ row, embeds, onJump }: { row: Row; embeds: Record<string, string>; onJump: (resource: string, id: string) => void }) {
|
|
113
|
-
const lines: Array<{ key: string; depth: number; value: ReactNode }> = [];
|
|
114
|
-
const walk = (obj: Row, depth: number, prefix: string) => {
|
|
115
|
-
for (const [k, v] of Object.entries(obj)) {
|
|
116
|
-
const key = prefix ? `${prefix}.${k}` : k;
|
|
117
|
-
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
118
|
-
lines.push({ key, depth, value: '' });
|
|
119
|
-
walk(v, depth + 1, key);
|
|
120
|
-
} else if (depth === 0 && embeds[k] && typeof v === 'string') {
|
|
121
|
-
lines.push({ key, depth, value: <RefChip label={v} onJump={() => onJump(embeds[k]!, v)} /> });
|
|
122
|
-
} else lines.push({ key, depth, value: Array.isArray(v) ? JSON.stringify(v) : String(v) });
|
|
123
|
-
}
|
|
124
|
-
};
|
|
125
|
-
walk(row, 0, '');
|
|
126
|
-
return (
|
|
127
|
-
<dl className="tw-fields">
|
|
128
|
-
{lines.map((l) => (
|
|
129
|
-
<div key={l.key} className="tw-field" style={{ paddingLeft: `${l.depth * 16}px` }}>
|
|
130
|
-
<dt>{l.key.split('.').at(-1)}</dt>
|
|
131
|
-
<dd>{l.value}</dd>
|
|
132
|
-
</div>
|
|
133
|
-
))}
|
|
134
|
-
</dl>
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** The moves the resource's machine allows from where this row stands, as buttons. */
|
|
139
|
-
export function Actions({ resource, row, api, onDone }: { resource: UiResource; row: Row; api: Api; onDone: () => void }) {
|
|
140
|
-
const [error, setError] = useState<string | null>(null);
|
|
141
|
-
const legal = resource.actions.filter((a) => a.from === '*' || a.from.includes(String(row[a.field])));
|
|
142
|
-
const seen = new Set<string>();
|
|
143
|
-
const unique = legal.filter((a) => (seen.has(a.operation) ? false : (seen.add(a.operation), true)));
|
|
144
|
-
if (!unique.length) return null;
|
|
145
|
-
return (
|
|
146
|
-
<div className="tw-actions">
|
|
147
|
-
{unique.map((a) => (
|
|
148
|
-
<button
|
|
149
|
-
key={a.operation}
|
|
150
|
-
type="button"
|
|
151
|
-
onClick={async () => {
|
|
152
|
-
setError(null);
|
|
153
|
-
const r = await api.send(a.method, fill(a.path, String(row.id)), undefined, resource.create?.encoding);
|
|
154
|
-
if (r.status >= 400) setError(r.body?.error?.message ?? r.body?.message ?? r.body?.error ?? `HTTP ${r.status}`);
|
|
155
|
-
onDone();
|
|
156
|
-
}}
|
|
157
|
-
>
|
|
158
|
-
{a.path.split('/').at(-1)!.replace(/[_-]/g, ' ')}
|
|
159
|
-
</button>
|
|
160
|
-
))}
|
|
161
|
-
{error ? <p className="tw-error">{String(error)}</p> : null}
|
|
162
|
-
</div>
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/** A form for the resource's create operation: its required parameters, and any others the caller fills. */
|
|
167
|
-
export function CreateForm({ resource, api, onCreated }: { resource: UiResource; api: Api; onCreated: (row: Row) => void }) {
|
|
168
|
-
const create = resource.create;
|
|
169
|
-
const [values, setValues] = useState<Record<string, string>>({});
|
|
170
|
-
const [error, setError] = useState<string | null>(null);
|
|
171
|
-
if (!create) return null;
|
|
172
|
-
const fields = create.body.filter((b) => b.required || ['name', 'email', 'description', 'title', 'amount', 'currency'].includes(b.name));
|
|
173
|
-
return (
|
|
174
|
-
<form
|
|
175
|
-
className="tw-create"
|
|
176
|
-
onSubmit={async (e) => {
|
|
177
|
-
e.preventDefault();
|
|
178
|
-
setError(null);
|
|
179
|
-
const r = await api.send('POST', create.path, values, create.encoding);
|
|
180
|
-
if (r.status >= 400) setError(r.body?.error?.message ?? r.body?.message ?? `HTTP ${r.status}`);
|
|
181
|
-
else onCreated(r.body);
|
|
182
|
-
}}
|
|
183
|
-
>
|
|
184
|
-
{fields.map((f) => (
|
|
185
|
-
<label key={f.name}>
|
|
186
|
-
{f.name}
|
|
187
|
-
{f.required ? ' *' : ''}
|
|
188
|
-
<input value={values[f.name] ?? ''} onChange={(e) => setValues({ ...values, [f.name]: e.target.value })} />
|
|
189
|
-
</label>
|
|
190
|
-
))}
|
|
191
|
-
<button type="submit">Create</button>
|
|
192
|
-
{error ? <p className="tw-error">{String(error)}</p> : null}
|
|
193
|
-
</form>
|
|
194
|
-
);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// ── the app ──────────────────────────────────────────────────────────────────────────────
|
|
198
|
-
|
|
199
|
-
type Route = { kind: 'home' } | { kind: 'list'; section: Section } | { kind: 'detail'; section: Section; id: string } | { kind: 'screen'; screen: Screen };
|
|
200
|
-
|
|
201
|
-
export function TwinUi({ config }: { config: TwinUiConfig }) {
|
|
202
|
-
const api = useMemo(() => makeApi(config.headers), [config.headers]);
|
|
203
|
-
const [route, setRoute] = useState<Route>({ kind: 'home' });
|
|
204
|
-
const [rows, setRows] = useState<Row[]>([]);
|
|
205
|
-
const [detail, setDetail] = useState<Row | null>(null);
|
|
206
|
-
const [counts, setCounts] = useState<Record<string, number>>({});
|
|
207
|
-
const [tick, setTick] = useState(0);
|
|
208
|
-
const rowsOf = config.rowsAt ?? defaultRows;
|
|
209
|
-
const listPath = (s: Section) => s.list ?? config.ui.resources[s.resource]?.list;
|
|
210
|
-
const bySection = (resource: string) => config.sections.find((s) => s.resource === resource);
|
|
211
|
-
|
|
212
|
-
useEffect(() => {
|
|
213
|
-
for (const s of config.sections) {
|
|
214
|
-
const p = listPath(s);
|
|
215
|
-
if (p && !p.includes('{')) api.get(p).then((b) => setCounts((c) => ({ ...c, [s.label]: rowsOf(b, s.resource).length }))).catch(() => undefined);
|
|
216
|
-
}
|
|
217
|
-
}, [tick]);
|
|
218
|
-
|
|
219
|
-
useEffect(() => {
|
|
220
|
-
if (route.kind === 'list') {
|
|
221
|
-
const p = listPath(route.section);
|
|
222
|
-
if (p) api.get(p).then((b) => setRows(rowsOf(b, route.section.resource))).catch(() => setRows([]));
|
|
223
|
-
}
|
|
224
|
-
if (route.kind === 'detail') {
|
|
225
|
-
const r = config.ui.resources[route.section.resource];
|
|
226
|
-
const fromList = rows.find((x) => String(x.id) === route.id) ?? null;
|
|
227
|
-
if (r?.retrieve && (r.retrieve.match(/\{/g) ?? []).length === 1) api.get(fill(r.retrieve, route.id)).then(setDetail).catch(() => setDetail(fromList));
|
|
228
|
-
else setDetail(fromList);
|
|
229
|
-
}
|
|
230
|
-
}, [route, tick]);
|
|
231
|
-
|
|
232
|
-
const jump = useCallback((resource: string, id: string) => {
|
|
233
|
-
const s = bySection(resource);
|
|
234
|
-
if (s) setRoute({ kind: 'detail', section: s, id });
|
|
235
|
-
}, []);
|
|
236
|
-
|
|
237
|
-
return (
|
|
238
|
-
<div className="tw-app">
|
|
239
|
-
<nav className="tw-nav">
|
|
240
|
-
<button type="button" className="tw-brand" onClick={() => setRoute({ kind: 'home' })}>
|
|
241
|
-
{config.skin.name}
|
|
242
|
-
{config.skin.product ? <small>{config.skin.product}</small> : null}
|
|
243
|
-
</button>
|
|
244
|
-
{(config.screens ?? []).map((s) => (
|
|
245
|
-
<button key={s.key} type="button" className={route.kind === 'screen' && route.screen.key === s.key ? 'tw-on' : ''} onClick={() => setRoute({ kind: 'screen', screen: s })}>
|
|
246
|
-
{s.label}
|
|
247
|
-
</button>
|
|
248
|
-
))}
|
|
249
|
-
{config.sections.map((s) => (
|
|
250
|
-
<button key={s.label} type="button" className={route.kind !== 'home' && route.kind !== 'screen' && route.section.label === s.label ? 'tw-on' : ''} onClick={() => setRoute({ kind: 'list', section: s })}>
|
|
251
|
-
{s.label}
|
|
252
|
-
{counts[s.label] !== undefined ? <span className="tw-count">{counts[s.label]}</span> : null}
|
|
253
|
-
</button>
|
|
254
|
-
))}
|
|
255
|
-
</nav>
|
|
256
|
-
<main className="tw-main">
|
|
257
|
-
{route.kind === 'home' ? (
|
|
258
|
-
<section className="tw-home">
|
|
259
|
-
<h1>{config.skin.name}</h1>
|
|
260
|
-
<ul>
|
|
261
|
-
{config.sections.map((s) => (
|
|
262
|
-
<li key={s.label}>
|
|
263
|
-
<button type="button" onClick={() => setRoute({ kind: 'list', section: s })}>
|
|
264
|
-
{s.label} <span className="tw-count">{counts[s.label] ?? '…'}</span>
|
|
265
|
-
</button>
|
|
266
|
-
</li>
|
|
267
|
-
))}
|
|
268
|
-
</ul>
|
|
269
|
-
</section>
|
|
270
|
-
) : null}
|
|
271
|
-
{route.kind === 'screen' ? route.screen.render(api) : null}
|
|
272
|
-
{route.kind === 'list' ? (
|
|
273
|
-
<section>
|
|
274
|
-
<h1>{route.section.label}</h1>
|
|
275
|
-
{config.ui.resources[route.section.resource] ? <CreateForm resource={config.ui.resources[route.section.resource]!} api={api} onCreated={() => setTick((t) => t + 1)} /> : null}
|
|
276
|
-
<ul className="tw-list">
|
|
277
|
-
{rows.map((r) => (
|
|
278
|
-
<li key={String(r.id)}>
|
|
279
|
-
<button type="button" onClick={() => setRoute({ kind: 'detail', section: route.section, id: String(r.id) })}>
|
|
280
|
-
<strong>{route.section.title?.(r) ?? String(r.name ?? r.title ?? r.id)}</strong>
|
|
281
|
-
<span>{route.section.subtitle?.(r) ?? String(r.id)}</span>
|
|
282
|
-
<StatusPill value={r.status ?? r.state} tone={config.tone} />
|
|
283
|
-
</button>
|
|
284
|
-
</li>
|
|
285
|
-
))}
|
|
286
|
-
{rows.length === 0 ? <li className="tw-empty">None yet.</li> : null}
|
|
287
|
-
</ul>
|
|
288
|
-
</section>
|
|
289
|
-
) : null}
|
|
290
|
-
{route.kind === 'detail' && detail ? (
|
|
291
|
-
<section>
|
|
292
|
-
<button type="button" className="tw-back" onClick={() => setRoute({ kind: 'list', section: route.section })}>
|
|
293
|
-
← {route.section.label}
|
|
294
|
-
</button>
|
|
295
|
-
<h1>
|
|
296
|
-
{route.section.title?.(detail) ?? String(detail.name ?? detail.title ?? detail.id)} <StatusPill value={detail.status ?? detail.state} tone={config.tone} />
|
|
297
|
-
</h1>
|
|
298
|
-
{route.section.extras?.(detail)}
|
|
299
|
-
{config.ui.resources[route.section.resource] ? <Actions resource={config.ui.resources[route.section.resource]!} row={detail} api={api} onDone={() => setTick((t) => t + 1)} /> : null}
|
|
300
|
-
<Fields row={detail} embeds={config.ui.resources[route.section.resource]?.embeds ?? {}} onJump={jump} />
|
|
301
|
-
</section>
|
|
302
|
-
) : null}
|
|
303
|
-
</main>
|
|
304
|
-
</div>
|
|
305
|
-
);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/** Mount a pack's UI into the shell's #root. */
|
|
309
|
-
export function mountTwinUi(config: TwinUiConfig): void {
|
|
310
|
-
if (typeof document === 'undefined') return;
|
|
311
|
-
createRoot(document.getElementById('root')!).render(<TwinUi config={config} />);
|
|
312
|
-
}
|