@volter/world-ui 0.1.37 → 1.0.1

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.
@@ -1,55 +0,0 @@
1
- /* The twin UI kit's base: layout and pieces. A pack's own stylesheet is its skin: it sets the
2
- variables below (and anything else of the vendor's look) and is loaded after this one. Unskinned,
3
- the kit wears the Volter brand's roles (company decision 0018): the shell links the brand's tokens
4
- (assets/brand/tokens.css, fetched at build) before this sheet, and the pre-commit hook refuses a
5
- literal colour here. */
6
- :root {
7
- --tw-accent: var(--volter-action-default);
8
- --tw-bg: var(--volter-surface-page);
9
- --tw-nav-bg: var(--volter-surface-subtle);
10
- --tw-text: var(--volter-text-primary);
11
- --tw-muted: var(--volter-text-muted);
12
- --tw-border: var(--volter-border-default);
13
- --tw-ok: var(--volter-status-healthy-text);
14
- --tw-warn: var(--volter-status-attention-text);
15
- --tw-bad: var(--volter-status-danger-text);
16
- --tw-font: var(--volter-font-ui);
17
- }
18
- * { box-sizing: border-box; }
19
- body { margin: 0; font-family: var(--tw-font); color: var(--tw-text); background: var(--tw-bg); }
20
- button { font: inherit; }
21
- .tw-app { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
22
- .tw-nav { background: var(--tw-nav-bg); border-right: 1px solid var(--tw-border); padding: 12px 8px; display: flex; flex-direction: column; gap: 2px; overflow-y: auto; }
23
- .tw-nav button { text-align: left; background: none; border: 0; padding: 6px 10px; border-radius: 6px; color: var(--tw-text); cursor: pointer; display: flex; justify-content: space-between; }
24
- .tw-nav button:hover { background: var(--volter-overlay-hover); }
25
- .tw-nav .tw-on, .tw-nav .tw-on:hover { background: var(--tw-accent); color: var(--volter-text-on-strong); }
26
- .tw-brand { font-weight: 700; font-size: 16px; margin-bottom: 8px; flex-direction: column; }
27
- .tw-brand small { font-weight: 400; color: var(--tw-muted); font-size: 12px; }
28
- .tw-count { color: var(--tw-muted); font-size: 12px; margin-left: 8px; }
29
- .tw-main { padding: 20px 28px; overflow: auto; }
30
- .tw-main h1 { font-size: 20px; display: flex; align-items: center; gap: 8px; }
31
- .tw-list { list-style: none; padding: 0; margin: 0; border: 1px solid var(--tw-border); border-radius: 6px; }
32
- .tw-list li + li { border-top: 1px solid var(--tw-border); }
33
- .tw-list button { width: 100%; background: none; border: 0; padding: 10px 14px; display: grid; grid-template-columns: 1fr 1fr auto; gap: 12px; text-align: left; cursor: pointer; }
34
- .tw-list button:hover { background: var(--tw-nav-bg); }
35
- .tw-list span { color: var(--tw-muted); }
36
- .tw-empty { padding: 12px 14px; color: var(--tw-muted); }
37
- .tw-pill { font-size: 12px; padding: 1px 8px; border-radius: 10px; border: 1px solid currentColor; white-space: nowrap; }
38
- .tw-pill-ok { color: var(--tw-ok); }
39
- .tw-pill-warn { color: var(--tw-warn); }
40
- .tw-pill-bad { color: var(--tw-bad); }
41
- .tw-pill-muted { color: var(--tw-muted); }
42
- .tw-ref { font: inherit; font-size: 13px; background: none; border: 1px solid var(--tw-border); border-radius: 4px; padding: 0 6px; color: var(--tw-accent); cursor: pointer; }
43
- .tw-fields { margin: 12px 0; }
44
- .tw-field { display: grid; grid-template-columns: 220px 1fr; gap: 12px; padding: 3px 0; border-bottom: 1px dashed var(--tw-border); }
45
- .tw-field dt { color: var(--tw-muted); }
46
- .tw-field dd { margin: 0; word-break: break-all; }
47
- .tw-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
48
- .tw-actions button, .tw-create button { background: var(--tw-accent); color: var(--volter-text-on-strong); border: 0; border-radius: 6px; padding: 6px 12px; cursor: pointer; text-transform: capitalize; }
49
- .tw-create { display: flex; gap: 8px; flex-wrap: wrap; align-items: end; margin-bottom: 12px; }
50
- .tw-create label { display: flex; flex-direction: column; font-size: 12px; color: var(--tw-muted); }
51
- .tw-create input { border: 1px solid var(--tw-border); border-radius: 6px; padding: 5px 8px; font: inherit; }
52
- .tw-back { background: none; border: 0; color: var(--tw-accent); cursor: pointer; padding: 0; }
53
- .tw-error { color: var(--tw-bad); width: 100%; }
54
- .tw-home ul { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; }
55
- .tw-home button { width: 100%; text-align: left; background: var(--tw-nav-bg); border: 1px solid var(--tw-border); border-radius: 6px; padding: 12px; cursor: pointer; }
@@ -1,101 +0,0 @@
1
- import React, { type ReactNode } from 'react';
2
- export type Row = Record<string, any>;
3
- export type UiAction = {
4
- operation: string;
5
- method: string;
6
- path: string;
7
- field: string;
8
- from: string[] | '*';
9
- to?: string;
10
- };
11
- export type UiResource = {
12
- list?: string;
13
- retrieve?: string;
14
- create?: {
15
- path: string;
16
- encoding: string;
17
- body: Array<{
18
- name: string;
19
- type: string;
20
- required: boolean;
21
- }>;
22
- };
23
- actions: UiAction[];
24
- states: Record<string, string[]>;
25
- embeds: Record<string, string>;
26
- };
27
- export type UiData = {
28
- resources: Record<string, UiResource>;
29
- };
30
- /** One entry of the navigation: a resource's list, titled the vendor's way. */
31
- export type Section = {
32
- resource: string;
33
- label: string;
34
- /** where it is listed, when not the resource's own list (a sub-collection) */
35
- list?: string;
36
- title?: (row: Row) => string;
37
- subtitle?: (row: Row) => string;
38
- /** what the vendor's detail page shows beyond the fields (a balance panel, an error banner) */
39
- extras?: (row: Row) => ReactNode;
40
- };
41
- /** A screen of the vendor's UI that is not a resource view (a chat, a board, a canvas). */
42
- export type Screen = {
43
- key: string;
44
- label: string;
45
- render: (api: Api) => ReactNode;
46
- };
47
- export type TwinUiConfig = {
48
- skin: {
49
- name: string;
50
- product?: string;
51
- };
52
- ui: UiData;
53
- sections: Section[];
54
- screens?: Screen[];
55
- /** headers every call carries (the fake credential the vendor's API asks for) */
56
- headers?: Record<string, string>;
57
- /** how the vendor's lists hold their rows: `data` (Stripe, OpenAI), the whole body (GitHub), or a key */
58
- rowsAt?: (body: any, resource: string) => Row[];
59
- /** a value's tone for its status pill */
60
- tone?: (value: string) => 'ok' | 'warn' | 'bad' | 'muted';
61
- };
62
- export type Api = {
63
- get: (path: string) => Promise<any>;
64
- send: (method: string, path: string, body?: Record<string, unknown>, encoding?: string) => Promise<{
65
- status: number;
66
- body: any;
67
- }>;
68
- };
69
- export declare function makeApi(headers?: Record<string, string>): Api;
70
- export declare function StatusPill({ value, tone }: {
71
- value: unknown;
72
- tone?: TwinUiConfig['tone'];
73
- }): React.JSX.Element | null;
74
- export declare function RefChip({ label, onJump }: {
75
- label: string;
76
- onJump: () => void;
77
- }): React.JSX.Element;
78
- /** An object as the vendor's detail page lists it: one line per field, nested objects indented, references as chips. */
79
- export declare function Fields({ row, embeds, onJump }: {
80
- row: Row;
81
- embeds: Record<string, string>;
82
- onJump: (resource: string, id: string) => void;
83
- }): React.JSX.Element;
84
- /** The moves the resource's machine allows from where this row stands, as buttons. */
85
- export declare function Actions({ resource, row, api, onDone }: {
86
- resource: UiResource;
87
- row: Row;
88
- api: Api;
89
- onDone: () => void;
90
- }): React.JSX.Element | null;
91
- /** A form for the resource's create operation: its required parameters, and any others the caller fills. */
92
- export declare function CreateForm({ resource, api, onCreated }: {
93
- resource: UiResource;
94
- api: Api;
95
- onCreated: (row: Row) => void;
96
- }): React.JSX.Element | null;
97
- export declare function TwinUi({ config }: {
98
- config: TwinUiConfig;
99
- }): React.JSX.Element;
100
- /** Mount a pack's UI into the shell's #root. */
101
- export declare function mountTwinUi(config: TwinUiConfig): void;
@@ -1,160 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- // THE TWIN UI KIT — shared pieces for a derived pack's screens (docs/contributing/architecture.md,
3
- // "Screens"): the shell and navigation, lists and details, references, state pills, the moves a machine
4
- // allows from a row's state, and create forms, all through the vendor's own API. A pack builds the
5
- // screens people work in from these under its skin. It is not a vendor's UI on its own: TwinUi's
6
- // generic resource browser renders any declared resource and so answers what a World holds, which a
7
- // vendor's screens never pose as.
8
- import { useCallback, useEffect, useMemo, useState } from 'react';
9
- import { createRoot } from 'react-dom/client';
10
- // ── talking to the vendor's API ────────────────────────────────────────────────────────────
11
- const BASE = typeof document === 'undefined' || document.querySelector('base[href]') === null ? '' : new URL('.', document.baseURI).pathname.replace(/\/$/, '');
12
- function formEncode(value, prefix, out) {
13
- if (value === undefined || value === '')
14
- return;
15
- if (value && typeof value === 'object') {
16
- for (const [k, v] of Object.entries(value))
17
- formEncode(v, prefix ? `${prefix}[${k}]` : k, out);
18
- }
19
- else
20
- out.append(prefix, String(value));
21
- }
22
- export function makeApi(headers = {}) {
23
- return {
24
- get: (path) => fetch(`${BASE}${path}`, { headers }).then((r) => r.json()),
25
- send: async (method, path, body, encoding = 'json') => {
26
- const init = { method, headers: { ...headers } };
27
- if (body && Object.keys(body).length) {
28
- if (encoding === 'form') {
29
- const p = new URLSearchParams();
30
- formEncode(body, '', p);
31
- init.body = p.toString();
32
- init.headers['content-type'] = 'application/x-www-form-urlencoded';
33
- }
34
- else {
35
- init.body = JSON.stringify(body);
36
- init.headers['content-type'] = 'application/json';
37
- }
38
- }
39
- const r = await fetch(`${BASE}${path}`, init);
40
- const text = await r.text();
41
- let parsed = text;
42
- try {
43
- parsed = text ? JSON.parse(text) : null;
44
- }
45
- catch { /* a non-JSON answer stays text */ }
46
- return { status: r.status, body: parsed };
47
- },
48
- };
49
- }
50
- const fill = (path, id) => path.replace(/\{[^}]+\}/, encodeURIComponent(id));
51
- const defaultRows = (body) => (Array.isArray(body) ? body : Array.isArray(body?.data) ? body.data : []);
52
- // ── pieces ───────────────────────────────────────────────────────────────────────────────
53
- export function StatusPill({ value, tone }) {
54
- if (value === undefined || value === null || value === '')
55
- return null;
56
- 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');
57
- return _jsx("span", { className: `tw-pill tw-pill-${t}`, children: String(value) });
58
- }
59
- export function RefChip({ label, onJump }) {
60
- return (_jsx("button", { type: "button", className: "tw-ref", onClick: onJump, children: label }));
61
- }
62
- /** An object as the vendor's detail page lists it: one line per field, nested objects indented, references as chips. */
63
- export function Fields({ row, embeds, onJump }) {
64
- const lines = [];
65
- const walk = (obj, depth, prefix) => {
66
- for (const [k, v] of Object.entries(obj)) {
67
- const key = prefix ? `${prefix}.${k}` : k;
68
- if (v && typeof v === 'object' && !Array.isArray(v)) {
69
- lines.push({ key, depth, value: '' });
70
- walk(v, depth + 1, key);
71
- }
72
- else if (depth === 0 && embeds[k] && typeof v === 'string') {
73
- lines.push({ key, depth, value: _jsx(RefChip, { label: v, onJump: () => onJump(embeds[k], v) }) });
74
- }
75
- else
76
- lines.push({ key, depth, value: Array.isArray(v) ? JSON.stringify(v) : String(v) });
77
- }
78
- };
79
- walk(row, 0, '');
80
- return (_jsx("dl", { className: "tw-fields", children: lines.map((l) => (_jsxs("div", { className: "tw-field", style: { paddingLeft: `${l.depth * 16}px` }, children: [_jsx("dt", { children: l.key.split('.').at(-1) }), _jsx("dd", { children: l.value })] }, l.key))) }));
81
- }
82
- /** The moves the resource's machine allows from where this row stands, as buttons. */
83
- export function Actions({ resource, row, api, onDone }) {
84
- const [error, setError] = useState(null);
85
- const legal = resource.actions.filter((a) => a.from === '*' || a.from.includes(String(row[a.field])));
86
- const seen = new Set();
87
- const unique = legal.filter((a) => (seen.has(a.operation) ? false : (seen.add(a.operation), true)));
88
- if (!unique.length)
89
- return null;
90
- return (_jsxs("div", { className: "tw-actions", children: [unique.map((a) => (_jsx("button", { type: "button", onClick: async () => {
91
- setError(null);
92
- const r = await api.send(a.method, fill(a.path, String(row.id)), undefined, resource.create?.encoding);
93
- if (r.status >= 400)
94
- setError(r.body?.error?.message ?? r.body?.message ?? r.body?.error ?? `HTTP ${r.status}`);
95
- onDone();
96
- }, children: a.path.split('/').at(-1).replace(/[_-]/g, ' ') }, a.operation))), error ? _jsx("p", { className: "tw-error", children: String(error) }) : null] }));
97
- }
98
- /** A form for the resource's create operation: its required parameters, and any others the caller fills. */
99
- export function CreateForm({ resource, api, onCreated }) {
100
- const create = resource.create;
101
- const [values, setValues] = useState({});
102
- const [error, setError] = useState(null);
103
- if (!create)
104
- return null;
105
- const fields = create.body.filter((b) => b.required || ['name', 'email', 'description', 'title', 'amount', 'currency'].includes(b.name));
106
- return (_jsxs("form", { className: "tw-create", onSubmit: async (e) => {
107
- e.preventDefault();
108
- setError(null);
109
- const r = await api.send('POST', create.path, values, create.encoding);
110
- if (r.status >= 400)
111
- setError(r.body?.error?.message ?? r.body?.message ?? `HTTP ${r.status}`);
112
- else
113
- onCreated(r.body);
114
- }, children: [fields.map((f) => (_jsxs("label", { children: [f.name, f.required ? ' *' : '', _jsx("input", { value: values[f.name] ?? '', onChange: (e) => setValues({ ...values, [f.name]: e.target.value }) })] }, f.name))), _jsx("button", { type: "submit", children: "Create" }), error ? _jsx("p", { className: "tw-error", children: String(error) }) : null] }));
115
- }
116
- export function TwinUi({ config }) {
117
- const api = useMemo(() => makeApi(config.headers), [config.headers]);
118
- const [route, setRoute] = useState({ kind: 'home' });
119
- const [rows, setRows] = useState([]);
120
- const [detail, setDetail] = useState(null);
121
- const [counts, setCounts] = useState({});
122
- const [tick, setTick] = useState(0);
123
- const rowsOf = config.rowsAt ?? defaultRows;
124
- const listPath = (s) => s.list ?? config.ui.resources[s.resource]?.list;
125
- const bySection = (resource) => config.sections.find((s) => s.resource === resource);
126
- useEffect(() => {
127
- for (const s of config.sections) {
128
- const p = listPath(s);
129
- if (p && !p.includes('{'))
130
- api.get(p).then((b) => setCounts((c) => ({ ...c, [s.label]: rowsOf(b, s.resource).length }))).catch(() => undefined);
131
- }
132
- }, [tick]);
133
- useEffect(() => {
134
- if (route.kind === 'list') {
135
- const p = listPath(route.section);
136
- if (p)
137
- api.get(p).then((b) => setRows(rowsOf(b, route.section.resource))).catch(() => setRows([]));
138
- }
139
- if (route.kind === 'detail') {
140
- const r = config.ui.resources[route.section.resource];
141
- const fromList = rows.find((x) => String(x.id) === route.id) ?? null;
142
- if (r?.retrieve && (r.retrieve.match(/\{/g) ?? []).length === 1)
143
- api.get(fill(r.retrieve, route.id)).then(setDetail).catch(() => setDetail(fromList));
144
- else
145
- setDetail(fromList);
146
- }
147
- }, [route, tick]);
148
- const jump = useCallback((resource, id) => {
149
- const s = bySection(resource);
150
- if (s)
151
- setRoute({ kind: 'detail', section: s, id });
152
- }, []);
153
- return (_jsxs("div", { className: "tw-app", children: [_jsxs("nav", { className: "tw-nav", children: [_jsxs("button", { type: "button", className: "tw-brand", onClick: () => setRoute({ kind: 'home' }), children: [config.skin.name, config.skin.product ? _jsx("small", { children: config.skin.product }) : null] }), (config.screens ?? []).map((s) => (_jsx("button", { type: "button", className: route.kind === 'screen' && route.screen.key === s.key ? 'tw-on' : '', onClick: () => setRoute({ kind: 'screen', screen: s }), children: s.label }, s.key))), config.sections.map((s) => (_jsxs("button", { type: "button", className: route.kind !== 'home' && route.kind !== 'screen' && route.section.label === s.label ? 'tw-on' : '', onClick: () => setRoute({ kind: 'list', section: s }), children: [s.label, counts[s.label] !== undefined ? _jsx("span", { className: "tw-count", children: counts[s.label] }) : null] }, s.label)))] }), _jsxs("main", { className: "tw-main", children: [route.kind === 'home' ? (_jsxs("section", { className: "tw-home", children: [_jsx("h1", { children: config.skin.name }), _jsx("ul", { children: config.sections.map((s) => (_jsx("li", { children: _jsxs("button", { type: "button", onClick: () => setRoute({ kind: 'list', section: s }), children: [s.label, " ", _jsx("span", { className: "tw-count", children: counts[s.label] ?? '…' })] }) }, s.label))) })] })) : null, route.kind === 'screen' ? route.screen.render(api) : null, route.kind === 'list' ? (_jsxs("section", { children: [_jsx("h1", { children: route.section.label }), config.ui.resources[route.section.resource] ? _jsx(CreateForm, { resource: config.ui.resources[route.section.resource], api: api, onCreated: () => setTick((t) => t + 1) }) : null, _jsxs("ul", { className: "tw-list", children: [rows.map((r) => (_jsx("li", { children: _jsxs("button", { type: "button", onClick: () => setRoute({ kind: 'detail', section: route.section, id: String(r.id) }), children: [_jsx("strong", { children: route.section.title?.(r) ?? String(r.name ?? r.title ?? r.id) }), _jsx("span", { children: route.section.subtitle?.(r) ?? String(r.id) }), _jsx(StatusPill, { value: r.status ?? r.state, tone: config.tone })] }) }, String(r.id)))), rows.length === 0 ? _jsx("li", { className: "tw-empty", children: "None yet." }) : null] })] })) : null, route.kind === 'detail' && detail ? (_jsxs("section", { children: [_jsxs("button", { type: "button", className: "tw-back", onClick: () => setRoute({ kind: 'list', section: route.section }), children: ["\u2190 ", route.section.label] }), _jsxs("h1", { children: [route.section.title?.(detail) ?? String(detail.name ?? detail.title ?? detail.id), " ", _jsx(StatusPill, { value: detail.status ?? detail.state, tone: config.tone })] }), route.section.extras?.(detail), config.ui.resources[route.section.resource] ? _jsx(Actions, { resource: config.ui.resources[route.section.resource], row: detail, api: api, onDone: () => setTick((t) => t + 1) }) : null, _jsx(Fields, { row: detail, embeds: config.ui.resources[route.section.resource]?.embeds ?? {}, onJump: jump })] })) : null] })] }));
154
- }
155
- /** Mount a pack's UI into the shell's #root. */
156
- export function mountTwinUi(config) {
157
- if (typeof document === 'undefined')
158
- return;
159
- createRoot(document.getElementById('root')).render(_jsx(TwinUi, { config: config }));
160
- }
@@ -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
- }