@miadi/stateloom-web 0.1.6 → 0.1.8
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/package.json +1 -1
- package/server/.next/BUILD_ID +1 -1
- package/server/.next/build-manifest.json +2 -2
- package/server/.next/server/app/_global-error.html +2 -2
- package/server/.next/server/app/_global-error.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/server/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/server/.next/server/app/_not-found.html +1 -1
- package/server/.next/server/app/_not-found.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/server/.next/server/app/index.html +1 -1
- package/server/.next/server/app/index.rsc +3 -3
- package/server/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/server/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/server/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/server/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/server/.next/server/app/page.js.nft.json +1 -1
- package/server/.next/server/app/page_client-reference-manifest.js +1 -1
- package/server/.next/server/chunks/ssr/{[root-of-the-server]__df915e74._.js → [root-of-the-server]__46d45ef8._.js} +1 -1
- package/server/.next/server/chunks/ssr/[root-of-the-server]__edbb995c._.js +3 -3
- package/server/.next/server/pages/404.html +1 -1
- package/server/.next/server/pages/500.html +2 -2
- package/server/.next/static/chunks/8f6bd51bbcce6314.css +2 -0
- package/server/.next/static/chunks/a9e7748193d11ed7.js +10 -0
- package/server/package-lock.json +2 -2
- package/server/package.json +1 -1
- package/server/src/components/erd/ErdWorkspace.tsx +476 -167
- package/server/stateloom-web.json +2 -2
- package/server/tsconfig.tsbuildinfo +1 -1
- package/server/.next/static/chunks/3dc2436b75e43174.css +0 -2
- package/server/.next/static/chunks/e7adb06692216348.js +0 -10
- /package/server/.next/static/{6GrzDGxArqw_jZoajUQwk → XCT3lnw_1F5LD0GG4F94-}/_buildManifest.js +0 -0
- /package/server/.next/static/{6GrzDGxArqw_jZoajUQwk → XCT3lnw_1F5LD0GG4F94-}/_clientMiddlewareManifest.json +0 -0
- /package/server/.next/static/{6GrzDGxArqw_jZoajUQwk → XCT3lnw_1F5LD0GG4F94-}/_ssgManifest.js +0 -0
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
* What it shares is everything underneath — the same `?doc=` parameter and file
|
|
9
9
|
* API, the same hub room keyed by the resolved path, the same canvas package.
|
|
10
10
|
*
|
|
11
|
+
* Layout. The board is the point, so it gets the screen: the header is one
|
|
12
|
+
* row, the view controls (zoom, fit) float on the board, and status is a line
|
|
13
|
+
* laid over its corner rather than a row of its own. On a phone the panel is a
|
|
14
|
+
* bottom sheet that starts closed, behind a three-tab dock — the same shape the
|
|
15
|
+
* state designer uses — and on a desktop it is the right column, which can be
|
|
16
|
+
* folded away.
|
|
17
|
+
*
|
|
11
18
|
* Every edit is written to disk and then pushed to the room whole. Disk first,
|
|
12
19
|
* because the agent's MCP tools read the file before each of their own edits:
|
|
13
20
|
* a change that lived only in the browser would be overwritten by the next
|
|
@@ -20,6 +27,8 @@ import { createBridgeSession, type BridgeSession } from "@miadi/stateloom-react"
|
|
|
20
27
|
import {
|
|
21
28
|
IDENTITY_VIEWPORT,
|
|
22
29
|
ERD_CARDINALITIES,
|
|
30
|
+
fitToBoxes,
|
|
31
|
+
zoomAt,
|
|
23
32
|
addAttribute,
|
|
24
33
|
addEntity,
|
|
25
34
|
addRelationship,
|
|
@@ -32,9 +41,11 @@ import {
|
|
|
32
41
|
removeEntity,
|
|
33
42
|
removeRelationship,
|
|
34
43
|
renderMermaidEr,
|
|
44
|
+
updateEntity,
|
|
35
45
|
validateErd,
|
|
36
46
|
type EntityRelationshipDefinition,
|
|
37
47
|
type ErdCardinality,
|
|
48
|
+
type ErdNotation,
|
|
38
49
|
type ErdValidationError,
|
|
39
50
|
type LayoutBox,
|
|
40
51
|
type StateMachineDefinition,
|
|
@@ -45,18 +56,33 @@ import { docQuery, navigateToDoc, useRequestedDoc } from "@/lib/docParam";
|
|
|
45
56
|
import { loadRuntimeConfig } from "@/lib/runtimeConfig";
|
|
46
57
|
|
|
47
58
|
type Moved = Record<string, { x: number; y: number }>;
|
|
59
|
+
type Tab = "entities" | "relations" | "problems";
|
|
48
60
|
type Edit = (def: EntityRelationshipDefinition) => EntityRelationshipDefinition;
|
|
49
61
|
|
|
50
|
-
|
|
62
|
+
// The two notations give an entity different footprints, so each keeps its own
|
|
63
|
+
// dragged arrangement. Crow's foot keeps the key it has always had.
|
|
64
|
+
const layoutKey = (docPath: string, notation: ErdNotation): string =>
|
|
65
|
+
notation === "chen" ? `stateloom:erd-layout:chen:${docPath}` : `stateloom:erd-layout:${docPath}`;
|
|
66
|
+
|
|
67
|
+
const NOTATION_KEY = "stateloom:erd-notation";
|
|
51
68
|
|
|
52
|
-
function readMoved(docPath: string): Moved {
|
|
69
|
+
function readMoved(docPath: string, notation: ErdNotation): Moved {
|
|
53
70
|
try {
|
|
54
|
-
return JSON.parse(localStorage.getItem(layoutKey(docPath)) ?? "{}") as Moved;
|
|
71
|
+
return JSON.parse(localStorage.getItem(layoutKey(docPath, notation)) ?? "{}") as Moved;
|
|
55
72
|
} catch {
|
|
56
73
|
return {};
|
|
57
74
|
}
|
|
58
75
|
}
|
|
59
76
|
|
|
77
|
+
/** The viewer's own preference — kept in this browser, never in the document. */
|
|
78
|
+
function readNotation(): ErdNotation {
|
|
79
|
+
try {
|
|
80
|
+
return localStorage.getItem(NOTATION_KEY) === "chen" ? "chen" : "crowsfoot";
|
|
81
|
+
} catch {
|
|
82
|
+
return "crowsfoot";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
60
86
|
interface Machine {
|
|
61
87
|
path: string;
|
|
62
88
|
def: StateMachineDefinition;
|
|
@@ -93,12 +119,32 @@ export default function ErdWorkspace() {
|
|
|
93
119
|
const [docPath, setDocPath] = useState("");
|
|
94
120
|
const [def, setDef] = useState<EntityRelationshipDefinition | null>(null);
|
|
95
121
|
const [moved, setMoved] = useState<Moved>({});
|
|
122
|
+
const [notation, setNotation] = useState<ErdNotation>("crowsfoot");
|
|
96
123
|
const [viewport, setViewport] = useState<Viewport>({ ...IDENTITY_VIEWPORT });
|
|
97
124
|
const [fitKey, setFitKey] = useState(0);
|
|
98
125
|
const [selection, setSelection] = useState<string | null>(null);
|
|
99
|
-
const [status,
|
|
126
|
+
const [status, setStatusState] = useState<{ text: string; tone: "ok" | "warn" }>({ text: "loading…", tone: "ok" });
|
|
127
|
+
const setStatus = useCallback((text: string, tone: "ok" | "warn" = "ok") => setStatusState({ text, tone }), []);
|
|
128
|
+
// Routine news ("saved", "updated live") steps back to its dot after a moment,
|
|
129
|
+
// so it is never what covers a corner of the diagram. A warning stays.
|
|
130
|
+
const [statusQuiet, setStatusQuiet] = useState(false);
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
const timer = setTimeout(() => setStatusQuiet(true), 4000);
|
|
133
|
+
return () => {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
setStatusQuiet(false);
|
|
136
|
+
};
|
|
137
|
+
}, [status]);
|
|
138
|
+
const [tab, setTab] = useState<Tab>("entities");
|
|
139
|
+
// Phone only: the sheet starts closed so the board owns the screen.
|
|
140
|
+
const [sheetOpen, setSheetOpen] = useState(false);
|
|
141
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
142
|
+
// Desktop only: the right column can be folded away.
|
|
143
|
+
const [panelHidden, setPanelHidden] = useState(false);
|
|
144
|
+
const boardRef = useRef<HTMLDivElement | null>(null);
|
|
100
145
|
const [peers, setPeers] = useState(0);
|
|
101
|
-
|
|
146
|
+
// null: not checked. "none": checked, and no state machine sits beside the document.
|
|
147
|
+
const [linkReport, setLinkReport] = useState<ErdValidationError[] | "none" | null>(null);
|
|
102
148
|
const sessionRef = useRef<BridgeSession | null>(null);
|
|
103
149
|
|
|
104
150
|
// Load the document, then join its room.
|
|
@@ -108,7 +154,7 @@ export default function ErdWorkspace() {
|
|
|
108
154
|
(async () => {
|
|
109
155
|
const body = await (await fetch(`/api/file${docQuery(requested)}`, { cache: "no-store" })).json();
|
|
110
156
|
if (cancelled) return;
|
|
111
|
-
if (body.error) return setStatus(body.error);
|
|
157
|
+
if (body.error) return setStatus(body.error, "warn");
|
|
112
158
|
const path: string = body.path ?? "";
|
|
113
159
|
let loaded: EntityRelationshipDefinition | null = null;
|
|
114
160
|
try {
|
|
@@ -117,11 +163,13 @@ export default function ErdWorkspace() {
|
|
|
117
163
|
} catch {
|
|
118
164
|
// Falls through to the unreadable-document message below.
|
|
119
165
|
}
|
|
120
|
-
if (body.exists && !loaded) return setStatus(`${path} is not a readable ERD definition
|
|
166
|
+
if (body.exists && !loaded) return setStatus(`${path} is not a readable ERD definition`, "warn");
|
|
121
167
|
const name = (path.split("/").pop() ?? "Untitled").replace(/\.erdf\.json$/i, "");
|
|
168
|
+
const preferred = readNotation();
|
|
122
169
|
setDocPath(path);
|
|
123
170
|
setDef(loaded ?? emptyErd("Default", name));
|
|
124
|
-
|
|
171
|
+
setNotation(preferred);
|
|
172
|
+
setMoved(readMoved(path, preferred));
|
|
125
173
|
setSelection(null);
|
|
126
174
|
setLinkReport(null);
|
|
127
175
|
setFitKey((k) => k + 1);
|
|
@@ -146,22 +194,22 @@ export default function ErdWorkspace() {
|
|
|
146
194
|
});
|
|
147
195
|
sessionRef.current = session;
|
|
148
196
|
session.connect();
|
|
149
|
-
})().catch((e) => !cancelled && setStatus(String(e)));
|
|
197
|
+
})().catch((e) => !cancelled && setStatus(String(e), "warn"));
|
|
150
198
|
return () => {
|
|
151
199
|
cancelled = true;
|
|
152
200
|
sessionRef.current = null;
|
|
153
201
|
session?.disconnect();
|
|
154
202
|
};
|
|
155
|
-
}, [requested]);
|
|
203
|
+
}, [requested, setStatus]);
|
|
156
204
|
|
|
157
205
|
const positions = useMemo<Record<string, LayoutBox>>(() => {
|
|
158
206
|
if (!def) return {};
|
|
159
|
-
const derived = erdAutoLayout(def);
|
|
207
|
+
const derived = erdAutoLayout(def, { notation });
|
|
160
208
|
for (const [name, at] of Object.entries(moved)) {
|
|
161
209
|
if (derived[name]) derived[name] = { ...derived[name], ...at };
|
|
162
210
|
}
|
|
163
211
|
return derived;
|
|
164
|
-
}, [def, moved]);
|
|
212
|
+
}, [def, moved, notation]);
|
|
165
213
|
|
|
166
214
|
const problems = useMemo(() => (def ? validateErd(def) : []), [def]);
|
|
167
215
|
const errorEntities = useMemo(
|
|
@@ -176,7 +224,7 @@ export default function ErdWorkspace() {
|
|
|
176
224
|
try {
|
|
177
225
|
next = edit(def);
|
|
178
226
|
} catch (e) {
|
|
179
|
-
return setStatus(e instanceof Error ? e.message : String(e));
|
|
227
|
+
return setStatus(e instanceof Error ? e.message : String(e), "warn");
|
|
180
228
|
}
|
|
181
229
|
setDef(next);
|
|
182
230
|
setLinkReport(null);
|
|
@@ -190,10 +238,10 @@ export default function ErdWorkspace() {
|
|
|
190
238
|
sessionRef.current?.emitFull(next as unknown as StateMachineDefinition);
|
|
191
239
|
setStatus("saved");
|
|
192
240
|
} catch (e) {
|
|
193
|
-
setStatus(`not saved: ${e instanceof Error ? e.message : String(e)}
|
|
241
|
+
setStatus(`not saved: ${e instanceof Error ? e.message : String(e)}`, "warn");
|
|
194
242
|
}
|
|
195
243
|
},
|
|
196
|
-
[def, docPath],
|
|
244
|
+
[def, docPath, setStatus],
|
|
197
245
|
);
|
|
198
246
|
|
|
199
247
|
const moveEntity = useCallback(
|
|
@@ -201,20 +249,32 @@ export default function ErdWorkspace() {
|
|
|
201
249
|
setMoved((prev) => {
|
|
202
250
|
const next = { ...prev, [name]: { x: Math.round(box.x), y: Math.round(box.y) } };
|
|
203
251
|
try {
|
|
204
|
-
localStorage.setItem(layoutKey(docPath), JSON.stringify(next));
|
|
252
|
+
localStorage.setItem(layoutKey(docPath, notation), JSON.stringify(next));
|
|
205
253
|
} catch {
|
|
206
254
|
// A full or disabled store costs the arrangement, not the document.
|
|
207
255
|
}
|
|
208
256
|
return next;
|
|
209
257
|
});
|
|
210
258
|
},
|
|
211
|
-
[docPath],
|
|
259
|
+
[docPath, notation],
|
|
212
260
|
);
|
|
213
261
|
|
|
262
|
+
const chooseNotation = (next: ErdNotation): void => {
|
|
263
|
+
if (next === notation) return;
|
|
264
|
+
try {
|
|
265
|
+
localStorage.setItem(NOTATION_KEY, next);
|
|
266
|
+
} catch {
|
|
267
|
+
// The choice then lasts for this visit only.
|
|
268
|
+
}
|
|
269
|
+
setNotation(next);
|
|
270
|
+
setMoved(readMoved(docPath, next));
|
|
271
|
+
setFitKey((k) => k + 1);
|
|
272
|
+
};
|
|
273
|
+
|
|
214
274
|
const arrange = (): void => {
|
|
215
275
|
setMoved({});
|
|
216
276
|
try {
|
|
217
|
-
localStorage.removeItem(layoutKey(docPath));
|
|
277
|
+
localStorage.removeItem(layoutKey(docPath, notation));
|
|
218
278
|
} catch {
|
|
219
279
|
/* as above */
|
|
220
280
|
}
|
|
@@ -226,60 +286,171 @@ export default function ErdWorkspace() {
|
|
|
226
286
|
setStatus("checking links…");
|
|
227
287
|
const machines = await siblingMachines(docPath);
|
|
228
288
|
if (machines.length === 0) {
|
|
229
|
-
setLinkReport(
|
|
230
|
-
|
|
289
|
+
setLinkReport("none");
|
|
290
|
+
setTab("problems");
|
|
291
|
+
setSheetOpen(true);
|
|
292
|
+
return setStatus("no .smdf.json beside this document to check against", "warn");
|
|
231
293
|
}
|
|
232
294
|
const found = machines.flatMap((m) =>
|
|
233
295
|
checkLinks(m.def, def).map((e) => ({ ...e, message: `${m.def.settings?.name ?? m.path}: ${e.message}` })),
|
|
234
296
|
);
|
|
235
297
|
found.push(...checkStateOf(def, machines.map((m) => m.def.settings?.name ?? "")));
|
|
236
298
|
setLinkReport(found);
|
|
237
|
-
|
|
299
|
+
// The answer lives in the Problems tab, so go there — on a phone it is otherwise behind a closed sheet.
|
|
300
|
+
setTab("problems");
|
|
301
|
+
setSheetOpen(true);
|
|
302
|
+
setPanelHidden(false);
|
|
303
|
+
setStatus(`checked ${machines.length} machine(s): ${found.length} problem(s)`, found.length ? "warn" : "ok");
|
|
238
304
|
};
|
|
239
305
|
|
|
240
306
|
const openMachine = async (machine: string): Promise<void> => {
|
|
241
307
|
const hit = (await siblingMachines(docPath)).find((m) => m.def.settings?.name === machine);
|
|
242
308
|
if (hit) navigateToDoc(hit.path);
|
|
243
|
-
else setStatus(`no .smdf.json beside this document defines '${machine}'
|
|
309
|
+
else setStatus(`no .smdf.json beside this document defines '${machine}'`, "warn");
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const boardSize = (): { width: number; height: number } => {
|
|
313
|
+
const rect = boardRef.current?.getBoundingClientRect();
|
|
314
|
+
return { width: rect?.width ?? 0, height: rect?.height ?? 0 };
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const zoomBy = (factor: number): void => {
|
|
318
|
+
const { width, height } = boardSize();
|
|
319
|
+
setViewport((vp) => zoomAt(vp, factor, { x: width / 2, y: height / 2 }));
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
/** Bring one entity to the middle of the board at a size its rows can be read at. */
|
|
323
|
+
const showOnBoard = (name: string): void => {
|
|
324
|
+
const box = positions[name];
|
|
325
|
+
const { width, height } = boardSize();
|
|
326
|
+
if (!box || !width) return;
|
|
327
|
+
setViewport(fitToBoxes([box], width, height, { padding: 28, maxScale: 1.25 }));
|
|
328
|
+
setSelection(name);
|
|
329
|
+
setSheetOpen(false);
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const openTab = (next: Tab): void => {
|
|
333
|
+
// Tapping the tab you are already reading closes the sheet, so the dock is
|
|
334
|
+
// both switcher and dismiss control.
|
|
335
|
+
setSheetOpen((wasOpen) => !(wasOpen && tab === next));
|
|
336
|
+
setTab(next);
|
|
244
337
|
};
|
|
245
338
|
|
|
246
339
|
const selected = def?.entities.find((e) => e.name === selection) ?? null;
|
|
247
340
|
const fileName = docPath.split("/").pop() || "…";
|
|
341
|
+
const linkProblems = Array.isArray(linkReport) ? linkReport : [];
|
|
342
|
+
const problemCount = problems.length + linkProblems.length;
|
|
343
|
+
const tabs: { id: Tab; label: string; icon: string; badge?: number }[] = [
|
|
344
|
+
{ id: "entities", label: "Entities", icon: "▭" },
|
|
345
|
+
{ id: "relations", label: "Relations", icon: "⟷" },
|
|
346
|
+
{ id: "problems", label: "Problems", icon: "⚠", badge: problemCount || undefined },
|
|
347
|
+
];
|
|
348
|
+
const shown = (id: Tab): string => (tab === id ? "" : "hidden md:block");
|
|
349
|
+
|
|
350
|
+
const copyMermaid = (): void => {
|
|
351
|
+
if (def) navigator.clipboard?.writeText(renderMermaidEr(def)).then(() => setStatus("mermaid copied"));
|
|
352
|
+
};
|
|
248
353
|
|
|
249
354
|
return (
|
|
250
|
-
<div
|
|
251
|
-
|
|
355
|
+
<div
|
|
356
|
+
className="app-shell dock-offset flex flex-col overflow-hidden bg-gray-950 text-gray-200"
|
|
357
|
+
onClick={() => menuOpen && setMenuOpen(false)}
|
|
358
|
+
>
|
|
359
|
+
{/* One row at every width. What does not fit a phone goes behind ⋯, never onto a second line. */}
|
|
360
|
+
<header className="relative z-40 flex items-center gap-2 border-b border-gray-800 bg-gray-900 px-2 py-1.5 text-xs md:px-3 md:py-2">
|
|
252
361
|
<span className="font-semibold text-blue-300">ERD</span>
|
|
253
362
|
<DocSwitcher />
|
|
254
|
-
<span className="text-gray-400" title={docPath}>
|
|
255
|
-
{def?.settings.name ??
|
|
363
|
+
<span className="min-w-0 flex-1 truncate text-gray-400" title={docPath}>
|
|
364
|
+
{def?.settings.name ?? fileName}
|
|
365
|
+
<span className="hidden text-gray-600 lg:inline"> · {fileName}</span>
|
|
256
366
|
</span>
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
367
|
+
|
|
368
|
+
{/* Same document, two drawings. The choice is this browser's; it is never written to the file. */}
|
|
369
|
+
<span className="inline-flex shrink-0 overflow-hidden rounded border border-gray-700" role="group" aria-label="Notation">
|
|
370
|
+
{(
|
|
371
|
+
[
|
|
372
|
+
["crowsfoot", "▤", "Crow's foot — attributes listed inside the entity box"],
|
|
373
|
+
["chen", "◇", "Chen — attributes as ovals, relationships as diamonds"],
|
|
374
|
+
] as const
|
|
375
|
+
).map(([id, glyph, title]) => (
|
|
376
|
+
<button
|
|
377
|
+
key={id}
|
|
378
|
+
title={title}
|
|
379
|
+
aria-label={title}
|
|
380
|
+
aria-pressed={notation === id}
|
|
381
|
+
onClick={() => chooseNotation(id)}
|
|
382
|
+
className={`px-2.5 py-1.5 text-sm leading-none md:px-2 md:py-1 ${
|
|
383
|
+
notation === id ? "bg-blue-900/60 text-blue-200" : "bg-gray-800 text-gray-400 hover:bg-gray-700"
|
|
384
|
+
}`}
|
|
385
|
+
>
|
|
386
|
+
{glyph}
|
|
387
|
+
</button>
|
|
388
|
+
))}
|
|
260
389
|
</span>
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
390
|
+
|
|
391
|
+
<div className="hidden shrink-0 items-center gap-2 md:flex">
|
|
392
|
+
<button className={button} onClick={arrange} title="Forget the dragged positions and lay the board out again">
|
|
393
|
+
⤢ Arrange
|
|
394
|
+
</button>
|
|
395
|
+
<button className={button} onClick={runLinkCheck} title="Check the state machines beside this document against it">
|
|
396
|
+
Check links
|
|
397
|
+
</button>
|
|
398
|
+
<button className={button} onClick={copyMermaid}>
|
|
399
|
+
Copy mermaid
|
|
400
|
+
</button>
|
|
401
|
+
<button
|
|
402
|
+
className={button}
|
|
403
|
+
onClick={() => setPanelHidden((h) => !h)}
|
|
404
|
+
aria-pressed={!panelHidden}
|
|
405
|
+
title={panelHidden ? "Show the panel" : "Hide the panel — the board takes the full width"}
|
|
406
|
+
>
|
|
407
|
+
{panelHidden ? "⟨ Panel" : "Panel ⟩"}
|
|
408
|
+
</button>
|
|
409
|
+
</div>
|
|
410
|
+
|
|
411
|
+
<div className="relative shrink-0 md:hidden">
|
|
412
|
+
<button
|
|
413
|
+
className="rounded border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm leading-none text-gray-200"
|
|
414
|
+
aria-label="More actions"
|
|
415
|
+
aria-expanded={menuOpen}
|
|
416
|
+
onClick={(e) => {
|
|
417
|
+
e.stopPropagation();
|
|
418
|
+
setMenuOpen((o) => !o);
|
|
419
|
+
}}
|
|
420
|
+
>
|
|
421
|
+
⋯
|
|
422
|
+
</button>
|
|
423
|
+
{menuOpen && (
|
|
424
|
+
<div className="absolute right-0 top-full z-50 mt-1 w-56 overflow-hidden rounded-lg border border-gray-700 bg-gray-900 py-1 shadow-xl">
|
|
425
|
+
{(
|
|
426
|
+
[
|
|
427
|
+
["⤢ Arrange the board", arrange],
|
|
428
|
+
["Check links to the machines", runLinkCheck],
|
|
429
|
+
["Copy as mermaid", copyMermaid],
|
|
430
|
+
] as const
|
|
431
|
+
).map(([label, run]) => (
|
|
432
|
+
<button
|
|
433
|
+
key={label}
|
|
434
|
+
className="block w-full px-4 py-3 text-left text-sm text-gray-200 hover:bg-gray-800"
|
|
435
|
+
onClick={() => {
|
|
436
|
+
setMenuOpen(false);
|
|
437
|
+
run();
|
|
438
|
+
}}
|
|
439
|
+
>
|
|
440
|
+
{label}
|
|
441
|
+
</button>
|
|
442
|
+
))}
|
|
443
|
+
</div>
|
|
444
|
+
)}
|
|
445
|
+
</div>
|
|
276
446
|
</header>
|
|
277
447
|
|
|
278
|
-
<div className="flex min-h-0 flex-1
|
|
279
|
-
<div className="min-
|
|
448
|
+
<div className="relative flex min-h-0 flex-1">
|
|
449
|
+
<div ref={boardRef} className="relative min-w-0 flex-1 overflow-hidden">
|
|
280
450
|
{def && (
|
|
281
451
|
<EntityRelationshipCanvas
|
|
282
452
|
definition={def}
|
|
453
|
+
notation={notation}
|
|
283
454
|
positions={positions}
|
|
284
455
|
viewport={viewport}
|
|
285
456
|
onViewportChange={setViewport}
|
|
@@ -293,143 +464,281 @@ export default function ErdWorkspace() {
|
|
|
293
464
|
emptyHint="No entities yet — add one in the panel, or let an agent call add_entity."
|
|
294
465
|
/>
|
|
295
466
|
)}
|
|
467
|
+
|
|
468
|
+
{/* Status costs no layout: it is a line over the board's corner. */}
|
|
469
|
+
<div
|
|
470
|
+
className={`pointer-events-none absolute left-2 top-2 flex max-w-[75%] items-center gap-1.5 rounded bg-gray-950/70 px-1.5 py-0.5 text-[11px] ${
|
|
471
|
+
status.tone === "warn" ? "text-amber-300" : "text-gray-500"
|
|
472
|
+
}`}
|
|
473
|
+
role="status"
|
|
474
|
+
>
|
|
475
|
+
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${status.tone === "warn" ? "bg-amber-400" : "bg-emerald-500"}`} />
|
|
476
|
+
<span
|
|
477
|
+
className={`truncate transition-opacity duration-500 ${
|
|
478
|
+
statusQuiet && status.tone === "ok" ? "opacity-0" : "opacity-100"
|
|
479
|
+
}`}
|
|
480
|
+
>
|
|
481
|
+
{status.text}
|
|
482
|
+
{peers > 1 ? ` · ${peers} in the room` : ""}
|
|
483
|
+
</span>
|
|
484
|
+
</div>
|
|
485
|
+
|
|
486
|
+
{/* View controls live on the board they act on. */}
|
|
487
|
+
<div className="absolute bottom-3 right-3 flex flex-col overflow-hidden rounded-lg border border-gray-700 bg-gray-900/90 shadow-lg backdrop-blur">
|
|
488
|
+
{(
|
|
489
|
+
[
|
|
490
|
+
["+", "Zoom in", () => zoomBy(1.25)],
|
|
491
|
+
["−", "Zoom out", () => zoomBy(0.8)],
|
|
492
|
+
["⛶", "Fit the whole diagram", () => setFitKey((k) => k + 1)],
|
|
493
|
+
] as const
|
|
494
|
+
).map(([glyph, title, run]) => (
|
|
495
|
+
<button
|
|
496
|
+
key={title}
|
|
497
|
+
title={title}
|
|
498
|
+
aria-label={title}
|
|
499
|
+
onClick={run}
|
|
500
|
+
className="h-10 w-10 border-b border-gray-800 text-base leading-none text-gray-200 last:border-b-0 hover:bg-gray-800 md:h-8 md:w-8 md:text-sm"
|
|
501
|
+
>
|
|
502
|
+
{glyph}
|
|
503
|
+
</button>
|
|
504
|
+
))}
|
|
505
|
+
</div>
|
|
506
|
+
|
|
507
|
+
{/* Phone: the way from a tapped entity to its details, without the sheet opening by itself over the board. */}
|
|
508
|
+
{selected && !sheetOpen && (
|
|
509
|
+
<button
|
|
510
|
+
className="absolute bottom-3 left-1/2 max-w-[60%] -translate-x-1/2 truncate rounded-full border border-blue-500/60 bg-gray-900/95 px-4 py-2 text-sm text-blue-200 shadow-lg md:hidden"
|
|
511
|
+
onClick={() => {
|
|
512
|
+
setTab("entities");
|
|
513
|
+
setSheetOpen(true);
|
|
514
|
+
}}
|
|
515
|
+
>
|
|
516
|
+
✎ {selected.name}
|
|
517
|
+
</button>
|
|
518
|
+
)}
|
|
296
519
|
</div>
|
|
297
520
|
|
|
521
|
+
{sheetOpen && (
|
|
522
|
+
<div className="fixed inset-0 z-20 bg-black/50 md:hidden" onClick={() => setSheetOpen(false)} aria-hidden="true" />
|
|
523
|
+
)}
|
|
524
|
+
|
|
298
525
|
{def && (
|
|
299
|
-
<aside
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
526
|
+
<aside
|
|
527
|
+
className={`safe-x fixed inset-x-0 bottom-0 z-30 flex h-[62dvh] flex-col overflow-hidden rounded-t-2xl border-t border-gray-800 bg-gray-900 shadow-2xl transition-transform duration-200 ease-out ${
|
|
528
|
+
sheetOpen ? "translate-y-0" : "pointer-events-none translate-y-full"
|
|
529
|
+
} md:pointer-events-auto md:static md:z-auto md:h-auto md:w-80 md:translate-y-0 md:rounded-none md:border-l md:border-t-0 md:shadow-none md:transition-none ${
|
|
530
|
+
panelHidden ? "md:hidden" : ""
|
|
531
|
+
}`}
|
|
532
|
+
>
|
|
533
|
+
<div className="md:hidden">
|
|
534
|
+
<div className="flex justify-center pb-1 pt-2">
|
|
535
|
+
<span className="h-1 w-10 rounded-full bg-gray-700" />
|
|
536
|
+
</div>
|
|
537
|
+
<div className="flex items-center justify-between border-b border-gray-800 px-4 pb-2">
|
|
538
|
+
<h2 className="text-sm font-semibold text-gray-300">{tabs.find((t) => t.id === tab)?.label}</h2>
|
|
539
|
+
<button onClick={() => setSheetOpen(false)} className="-mr-2 px-3 py-2 text-lg text-gray-400" aria-label="Close panel">
|
|
540
|
+
✕
|
|
311
541
|
</button>
|
|
312
|
-
|
|
542
|
+
</div>
|
|
313
543
|
</div>
|
|
314
|
-
<InlineForm
|
|
315
|
-
fields={[{ name: "name", placeholder: "new entity" }]}
|
|
316
|
-
submit="+ Entity"
|
|
317
|
-
onSubmit={(v) => {
|
|
318
|
-
apply((d) => addEntity(d, { name: v.name.trim() }));
|
|
319
|
-
setSelection(v.name.trim());
|
|
320
|
-
}}
|
|
321
|
-
/>
|
|
322
544
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
<div className={heading}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
545
|
+
<div className="touch-targets dock-offset flex-1 overflow-y-auto overscroll-contain p-3">
|
|
546
|
+
<section className={shown("entities")}>
|
|
547
|
+
<div className={heading} style={{ marginTop: 0 }}>
|
|
548
|
+
Entities
|
|
549
|
+
</div>
|
|
550
|
+
<div className="flex flex-wrap gap-1">
|
|
551
|
+
{def.entities.map((e) => (
|
|
552
|
+
<button
|
|
553
|
+
key={e.name}
|
|
554
|
+
className={`${button} ${selection === e.name ? "border-blue-500 text-blue-300" : ""}`}
|
|
555
|
+
onClick={() => setSelection(e.name)}
|
|
556
|
+
>
|
|
557
|
+
{e.name}
|
|
558
|
+
</button>
|
|
559
|
+
))}
|
|
560
|
+
</div>
|
|
561
|
+
<InlineForm
|
|
562
|
+
fields={[{ name: "name", placeholder: "new entity" }]}
|
|
563
|
+
submit="+ Entity"
|
|
564
|
+
onSubmit={(v) => {
|
|
565
|
+
apply((d) => addEntity(d, { name: v.name.trim() }));
|
|
566
|
+
setSelection(v.name.trim());
|
|
567
|
+
}}
|
|
568
|
+
/>
|
|
569
|
+
|
|
570
|
+
{selected && (
|
|
571
|
+
<>
|
|
572
|
+
<div className={`${heading} flex items-center justify-between gap-2`}>
|
|
573
|
+
<span className="truncate text-sm normal-case tracking-normal text-gray-200">{selected.name}</span>
|
|
574
|
+
<button
|
|
575
|
+
className={`${button} normal-case tracking-normal`}
|
|
576
|
+
onClick={() => showOnBoard(selected.name)}
|
|
577
|
+
title="Bring this entity to the middle of the board at a readable size"
|
|
578
|
+
>
|
|
579
|
+
◎ Show on board
|
|
580
|
+
</button>
|
|
581
|
+
</div>
|
|
582
|
+
<label
|
|
583
|
+
className="mb-1 flex items-center gap-2 text-[11px] text-gray-400"
|
|
584
|
+
title="Exists only through another entity (an order line, without its order). Chen draws it as a double rectangle."
|
|
585
|
+
>
|
|
586
|
+
<input
|
|
587
|
+
type="checkbox"
|
|
588
|
+
checked={!!selected.weak}
|
|
589
|
+
onChange={(e) => apply((d) => updateEntity(d, selected.name, { weak: e.target.checked }))}
|
|
590
|
+
/>
|
|
591
|
+
weak entity
|
|
592
|
+
</label>
|
|
593
|
+
{(selected.attributes ?? []).map((a) => (
|
|
594
|
+
<div key={a.name} className="flex items-center justify-between gap-2 py-0.5 font-mono text-[11px]">
|
|
595
|
+
<span className={a.stateOf ? "text-blue-300" : "text-gray-300"}>
|
|
596
|
+
{a.name} : {a.type}
|
|
597
|
+
<span className="text-gray-500">
|
|
598
|
+
{a.key ? ` ${a.key.toUpperCase()}` : ""}
|
|
599
|
+
{a.references ? ` → ${a.references}` : ""}
|
|
600
|
+
{a.stateOf ? ` ◉ ${[a.stateOf].flat().join(", ")}` : ""}
|
|
601
|
+
</span>
|
|
602
|
+
</span>
|
|
603
|
+
<button
|
|
604
|
+
className="text-gray-500 hover:text-red-400"
|
|
605
|
+
aria-label={`Remove ${a.name}`}
|
|
606
|
+
onClick={() => apply((d) => removeAttribute(d, selected.name, a.name))}
|
|
607
|
+
>
|
|
608
|
+
✕
|
|
609
|
+
</button>
|
|
610
|
+
</div>
|
|
611
|
+
))}
|
|
612
|
+
<InlineForm
|
|
613
|
+
fields={[
|
|
614
|
+
{ name: "name", placeholder: "attribute" },
|
|
615
|
+
{ name: "type", placeholder: "type", initial: "string" },
|
|
616
|
+
{ name: "key", options: ["", "pk", "uk"] },
|
|
617
|
+
{ name: "references", placeholder: "references Entity.attr" },
|
|
618
|
+
{ name: "stateOf", placeholder: "stateOf Machine" },
|
|
619
|
+
]}
|
|
620
|
+
submit="+ Attribute"
|
|
621
|
+
onSubmit={(v) =>
|
|
622
|
+
apply((d) =>
|
|
623
|
+
addAttribute(d, selected.name, {
|
|
624
|
+
name: v.name.trim(),
|
|
625
|
+
type: v.type.trim() || "string",
|
|
626
|
+
key: v.key === "pk" || v.key === "uk" ? v.key : undefined,
|
|
627
|
+
references: v.references.trim() || undefined,
|
|
628
|
+
stateOf: v.stateOf.trim() || undefined,
|
|
629
|
+
}),
|
|
630
|
+
)
|
|
631
|
+
}
|
|
632
|
+
/>
|
|
633
|
+
<button
|
|
634
|
+
className={`${button} mt-2 text-red-300`}
|
|
635
|
+
onClick={() => {
|
|
636
|
+
apply((d) => removeEntity(d, selected.name));
|
|
637
|
+
setSelection(null);
|
|
638
|
+
}}
|
|
639
|
+
>
|
|
640
|
+
🗑 Delete {selected.name}
|
|
641
|
+
</button>
|
|
642
|
+
</>
|
|
643
|
+
)}
|
|
644
|
+
</section>
|
|
645
|
+
|
|
646
|
+
<section className={shown("relations")}>
|
|
647
|
+
<div className={`${heading} max-md:mt-0`}>Relationships</div>
|
|
648
|
+
{def.relationships.length === 0 && <div className="py-0.5 text-[11px] text-gray-600">None yet.</div>}
|
|
649
|
+
{def.relationships.map((r, i) => (
|
|
650
|
+
<div key={i} className="flex items-center justify-between gap-2 py-0.5 text-[11px] text-gray-300">
|
|
651
|
+
<span>
|
|
652
|
+
{r.from} <span className="text-gray-500">{r.cardinality}</span> {r.to}
|
|
653
|
+
{r.label ? <span className="text-gray-500"> : {r.label}</span> : null}
|
|
335
654
|
</span>
|
|
336
655
|
<button
|
|
337
656
|
className="text-gray-500 hover:text-red-400"
|
|
338
|
-
aria-label=
|
|
339
|
-
onClick={() => apply((d) =>
|
|
657
|
+
aria-label="Remove relationship"
|
|
658
|
+
onClick={() => apply((d) => removeRelationship(d, r.from, r.to, r.label))}
|
|
340
659
|
>
|
|
341
660
|
✕
|
|
342
661
|
</button>
|
|
343
662
|
</div>
|
|
344
663
|
))}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
{
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
}}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
className="text-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
</
|
|
393
|
-
|
|
394
|
-
{def.entities.length > 0 && (
|
|
395
|
-
<InlineForm
|
|
396
|
-
fields={[
|
|
397
|
-
{ name: "from", options: def.entities.map((e) => e.name) },
|
|
398
|
-
{ name: "cardinality", options: [...ERD_CARDINALITIES], initial: "1:N" },
|
|
399
|
-
{ name: "to", options: def.entities.map((e) => e.name) },
|
|
400
|
-
{ name: "label", placeholder: "label (a verb)" },
|
|
401
|
-
]}
|
|
402
|
-
submit="+ Relationship"
|
|
403
|
-
onSubmit={(v) =>
|
|
404
|
-
apply((d) =>
|
|
405
|
-
addRelationship(d, {
|
|
406
|
-
from: v.from,
|
|
407
|
-
to: v.to,
|
|
408
|
-
cardinality: v.cardinality as ErdCardinality,
|
|
409
|
-
label: v.label.trim() || undefined,
|
|
410
|
-
}),
|
|
411
|
-
)
|
|
412
|
-
}
|
|
413
|
-
/>
|
|
414
|
-
)}
|
|
415
|
-
|
|
416
|
-
{(problems.length > 0 || linkReport) && <div className={heading}>Problems</div>}
|
|
417
|
-
{problems.map((p, i) => (
|
|
418
|
-
<div key={`p${i}`} className="py-0.5 text-[11px] text-red-300">
|
|
419
|
-
[{p.ruleId}] {p.message}
|
|
420
|
-
</div>
|
|
421
|
-
))}
|
|
422
|
-
{linkReport?.length === 0 && problems.length === 0 && (
|
|
423
|
-
<div className="py-0.5 text-[11px] text-green-400">Every name the machines use resolves.</div>
|
|
424
|
-
)}
|
|
425
|
-
{linkReport?.map((p, i) => (
|
|
426
|
-
<div key={`l${i}`} className="py-0.5 text-[11px] text-amber-300">
|
|
427
|
-
[{p.ruleId}] {p.message}
|
|
428
|
-
</div>
|
|
429
|
-
))}
|
|
664
|
+
{def.entities.length > 0 && (
|
|
665
|
+
<InlineForm
|
|
666
|
+
fields={[
|
|
667
|
+
{ name: "from", options: def.entities.map((e) => e.name) },
|
|
668
|
+
{ name: "cardinality", options: [...ERD_CARDINALITIES], initial: "1:N" },
|
|
669
|
+
{ name: "to", options: def.entities.map((e) => e.name) },
|
|
670
|
+
{ name: "label", placeholder: "label (a verb)" },
|
|
671
|
+
]}
|
|
672
|
+
submit="+ Relationship"
|
|
673
|
+
onSubmit={(v) =>
|
|
674
|
+
apply((d) =>
|
|
675
|
+
addRelationship(d, {
|
|
676
|
+
from: v.from,
|
|
677
|
+
to: v.to,
|
|
678
|
+
cardinality: v.cardinality as ErdCardinality,
|
|
679
|
+
label: v.label.trim() || undefined,
|
|
680
|
+
}),
|
|
681
|
+
)
|
|
682
|
+
}
|
|
683
|
+
/>
|
|
684
|
+
)}
|
|
685
|
+
</section>
|
|
686
|
+
|
|
687
|
+
<section className={shown("problems")}>
|
|
688
|
+
<div className={`${heading} max-md:mt-0 ${problemCount === 0 && !linkReport ? "md:hidden" : ""}`}>Problems</div>
|
|
689
|
+
{problems.map((p, i) => (
|
|
690
|
+
<div key={`p${i}`} className="py-0.5 text-[11px] text-red-300">
|
|
691
|
+
[{p.ruleId}] {p.message}
|
|
692
|
+
</div>
|
|
693
|
+
))}
|
|
694
|
+
{linkProblems.map((p, i) => (
|
|
695
|
+
<div key={`l${i}`} className="py-0.5 text-[11px] text-amber-300">
|
|
696
|
+
[{p.ruleId}] {p.message}
|
|
697
|
+
</div>
|
|
698
|
+
))}
|
|
699
|
+
{linkReport === "none" && (
|
|
700
|
+
<div className="py-0.5 text-[11px] text-amber-300">
|
|
701
|
+
No state machine (.smdf.json) sits beside this document, so there was nothing to check it against.
|
|
702
|
+
</div>
|
|
703
|
+
)}
|
|
704
|
+
{problemCount === 0 && linkReport !== "none" && (
|
|
705
|
+
<div className={`py-0.5 text-[11px] ${linkReport ? "text-green-400" : "text-gray-600 md:hidden"}`}>
|
|
706
|
+
{linkReport
|
|
707
|
+
? "Every name the machines use resolves."
|
|
708
|
+
: "The document is valid. “Check links” (⋯) holds the state machines beside it against it."}
|
|
709
|
+
</div>
|
|
710
|
+
)}
|
|
711
|
+
</section>
|
|
712
|
+
</div>
|
|
430
713
|
</aside>
|
|
431
714
|
)}
|
|
432
715
|
</div>
|
|
716
|
+
|
|
717
|
+
{/* Tab dock (phone). Three tabs one thumb-tap away, and the problem count is on screen at all times. */}
|
|
718
|
+
<nav className="dock-safe safe-x fixed inset-x-0 bottom-0 z-40 flex border-t border-gray-800 bg-gray-900/95 backdrop-blur-md md:hidden">
|
|
719
|
+
{tabs.map((t) => {
|
|
720
|
+
const current = sheetOpen && tab === t.id;
|
|
721
|
+
return (
|
|
722
|
+
<button
|
|
723
|
+
key={t.id}
|
|
724
|
+
onClick={() => openTab(t.id)}
|
|
725
|
+
aria-pressed={current}
|
|
726
|
+
className={`relative flex h-14 flex-1 flex-col items-center justify-center gap-0.5 text-[11px] transition-colors ${
|
|
727
|
+
current ? "text-blue-400" : "text-gray-500"
|
|
728
|
+
}`}
|
|
729
|
+
>
|
|
730
|
+
<span className="text-base leading-none">{t.icon}</span>
|
|
731
|
+
<span>{t.label}</span>
|
|
732
|
+
{t.badge !== undefined && (
|
|
733
|
+
<span className="absolute right-1/2 top-1 min-w-[16px] translate-x-5 rounded-full bg-red-600 px-1 text-[10px] leading-4 text-white">
|
|
734
|
+
{t.badge}
|
|
735
|
+
</span>
|
|
736
|
+
)}
|
|
737
|
+
{current && <span className="absolute inset-x-4 top-0 h-0.5 rounded-full bg-blue-400" />}
|
|
738
|
+
</button>
|
|
739
|
+
);
|
|
740
|
+
})}
|
|
741
|
+
</nav>
|
|
433
742
|
</div>
|
|
434
743
|
);
|
|
435
744
|
}
|
|
@@ -494,7 +803,7 @@ function InlineForm({
|
|
|
494
803
|
/>
|
|
495
804
|
),
|
|
496
805
|
)}
|
|
497
|
-
<button type="submit" className={`${button} col-span-2`}>
|
|
806
|
+
<button type="submit" className={`${button} ${fields.length === 1 ? "" : "col-span-2"}`}>
|
|
498
807
|
{submit}
|
|
499
808
|
</button>
|
|
500
809
|
</form>
|