@groveback/ui 0.1.1 → 0.3.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/dist/index.js CHANGED
@@ -1,9 +1,133 @@
1
1
  // src/components.tsx
2
- import { useCallback as useCallback2, useEffect as useEffect2, useState as useState2 } from "react";
2
+ import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef2, useState as useState3 } from "react";
3
3
 
4
- // src/RelationSelect.tsx
5
- import { useEffect, useMemo, useRef, useState } from "react";
4
+ // src/MarkdownField.tsx
5
+ import { useState } from "react";
6
6
  import { jsx, jsxs } from "react/jsx-runtime";
7
+ function escapeHtml(s) {
8
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
9
+ }
10
+ function renderInline(s) {
11
+ return escapeHtml(s).replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>").replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
12
+ }
13
+ function renderMarkdown(src) {
14
+ const lines = src.split(`
15
+ `);
16
+ const html = [];
17
+ let inList = false;
18
+ const closeList = () => {
19
+ if (inList) {
20
+ html.push("</ul>");
21
+ inList = false;
22
+ }
23
+ };
24
+ for (const line of lines) {
25
+ const heading = /^(#{1,3})\s+(.*)$/.exec(line);
26
+ const item = /^[-*]\s+(.*)$/.exec(line);
27
+ if (heading) {
28
+ closeList();
29
+ const level = heading[1].length;
30
+ html.push(`<h${level}>${renderInline(heading[2])}</h${level}>`);
31
+ } else if (item) {
32
+ if (!inList) {
33
+ html.push("<ul>");
34
+ inList = true;
35
+ }
36
+ html.push(`<li>${renderInline(item[1])}</li>`);
37
+ } else if (line.trim() === "") {
38
+ closeList();
39
+ } else {
40
+ closeList();
41
+ html.push(`<p>${renderInline(line)}</p>`);
42
+ }
43
+ }
44
+ closeList();
45
+ return html.join(`
46
+ `);
47
+ }
48
+ var TOOLS = [
49
+ { key: "b", label: "Bold", glyph: "B", wrap: { before: "**", after: "**", placeholder: "bold" } },
50
+ { key: "i", label: "Italic", glyph: "I", wrap: { before: "*", after: "*", placeholder: "italic" } },
51
+ { key: "h", label: "Heading", glyph: "H", wrap: { before: "## ", after: "", placeholder: "Heading" } },
52
+ { key: "ul", label: "List item", glyph: "•", wrap: { before: "- ", after: "", placeholder: "item" } },
53
+ { key: "code", label: "Code", glyph: "</>", wrap: { before: "`", after: "`", placeholder: "code" } },
54
+ { key: "a", label: "Link", glyph: "\uD83D\uDD17", wrap: { before: "[", after: "](https://)", placeholder: "text" } }
55
+ ];
56
+ var TOOL_BUTTON = "inline-flex h-7 min-w-7 items-center justify-center rounded px-1.5 text-xs text-neutral-600 " + "hover:bg-neutral-100 hover:text-neutral-900 disabled:opacity-40 " + "dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100";
57
+ var PREVIEW_TYPOGRAPHY = "min-h-40 px-3 py-2 text-sm [&_a]:underline [&_code]:rounded [&_code]:bg-neutral-100 [&_code]:px-1 " + "[&_h1]:text-lg [&_h1]:font-semibold [&_h2]:text-base [&_h2]:font-semibold [&_h3]:font-semibold " + "[&_p]:my-2 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 dark:[&_code]:bg-neutral-800";
58
+ function MarkdownField({
59
+ value,
60
+ onChange
61
+ }) {
62
+ const [mode, setMode] = useState("write");
63
+ const [el, setEl] = useState(null);
64
+ function apply(wrap) {
65
+ if (!el)
66
+ return;
67
+ const start = el.selectionStart;
68
+ const end = el.selectionEnd;
69
+ const selected = value.slice(start, end) || wrap.placeholder;
70
+ onChange(value.slice(0, start) + wrap.before + selected + wrap.after + value.slice(end));
71
+ requestAnimationFrame(() => {
72
+ el.focus();
73
+ const caret = start + wrap.before.length;
74
+ el.setSelectionRange(caret, caret + selected.length);
75
+ });
76
+ }
77
+ const tab = (active) => `inline-flex h-7 items-center rounded px-2 text-xs ${active ? "bg-neutral-100 font-medium text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100" : "text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100"}`;
78
+ return /* @__PURE__ */ jsxs("div", {
79
+ className: "rounded-md border border-neutral-300 bg-white text-neutral-900 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-100",
80
+ children: [
81
+ /* @__PURE__ */ jsxs("div", {
82
+ className: "flex items-center gap-0.5 border-b border-neutral-200 px-1 py-1 dark:border-neutral-800",
83
+ children: [
84
+ TOOLS.map((t) => /* @__PURE__ */ jsx("button", {
85
+ type: "button",
86
+ title: t.label,
87
+ "aria-label": t.label,
88
+ disabled: mode === "preview",
89
+ onClick: () => apply(t.wrap),
90
+ className: TOOL_BUTTON,
91
+ children: t.glyph
92
+ }, t.key)),
93
+ /* @__PURE__ */ jsxs("div", {
94
+ className: "ml-auto flex gap-0.5",
95
+ children: [
96
+ /* @__PURE__ */ jsx("button", {
97
+ type: "button",
98
+ onClick: () => setMode("write"),
99
+ className: tab(mode === "write"),
100
+ children: "Write"
101
+ }),
102
+ /* @__PURE__ */ jsx("button", {
103
+ type: "button",
104
+ onClick: () => setMode("preview"),
105
+ className: tab(mode === "preview"),
106
+ children: "Preview"
107
+ })
108
+ ]
109
+ })
110
+ ]
111
+ }),
112
+ mode === "write" ? /* @__PURE__ */ jsx("textarea", {
113
+ ref: setEl,
114
+ rows: 8,
115
+ value,
116
+ spellCheck: false,
117
+ placeholder: "Write Markdown…",
118
+ onChange: (e) => onChange(e.target.value),
119
+ className: "w-full resize-y rounded-b-md border-0 bg-transparent px-3 py-2 font-mono text-sm text-neutral-900 outline-none placeholder:text-neutral-400 dark:text-neutral-100 dark:placeholder:text-neutral-500"
120
+ }) : /* @__PURE__ */ jsx("div", {
121
+ className: PREVIEW_TYPOGRAPHY,
122
+ dangerouslySetInnerHTML: { __html: renderMarkdown(value || "_Nothing to preview_") }
123
+ })
124
+ ]
125
+ });
126
+ }
127
+
128
+ // src/RelationSelect.tsx
129
+ import { useEffect, useMemo, useRef, useState as useState2 } from "react";
130
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
7
131
  var PAGE = 200;
8
132
  function labelOf(doc, field) {
9
133
  const raw = doc[field];
@@ -15,11 +139,11 @@ function labelOf(doc, field) {
15
139
  }
16
140
  function RelationSelect(props) {
17
141
  const { collection, displayField, value, onChange, placeholder, required } = props;
18
- const [docs, setDocs] = useState(null);
19
- const [error, setError] = useState(null);
20
- const [query, setQuery] = useState("");
21
- const [open, setOpen] = useState(false);
22
- const [truncated, setTruncated] = useState(false);
142
+ const [docs, setDocs] = useState2(null);
143
+ const [error, setError] = useState2(null);
144
+ const [query, setQuery] = useState2("");
145
+ const [open, setOpen] = useState2(false);
146
+ const [truncated, setTruncated] = useState2(false);
23
147
  const boxRef = useRef(null);
24
148
  useEffect(() => {
25
149
  let live = true;
@@ -55,47 +179,47 @@ function RelationSelect(props) {
55
179
  return all.filter((d) => labelOf(d, displayField).toLowerCase().includes(q));
56
180
  }, [docs, displayField, query]);
57
181
  if (error) {
58
- return /* @__PURE__ */ jsx("p", {
182
+ return /* @__PURE__ */ jsx2("p", {
59
183
  className: "text-xs text-red-600",
60
184
  children: error
61
185
  });
62
186
  }
63
187
  const buttonLabel = selected ? labelOf(selected, displayField) : value !== "" ? value : placeholder ?? "Select…";
64
- return /* @__PURE__ */ jsxs("div", {
188
+ return /* @__PURE__ */ jsxs2("div", {
65
189
  ref: boxRef,
66
190
  className: "relative",
67
191
  children: [
68
- /* @__PURE__ */ jsxs("button", {
192
+ /* @__PURE__ */ jsxs2("button", {
69
193
  type: "button",
70
194
  onClick: () => setOpen((v) => !v),
71
195
  className: "flex w-full items-center justify-between rounded-md border border-neutral-300 px-3 py-1.5 text-left text-sm dark:border-neutral-700 dark:bg-neutral-950",
72
196
  children: [
73
- /* @__PURE__ */ jsx("span", {
197
+ /* @__PURE__ */ jsx2("span", {
74
198
  className: selected || value ? "" : "text-neutral-400",
75
199
  children: docs === null ? "Loading…" : buttonLabel
76
200
  }),
77
- /* @__PURE__ */ jsx("span", {
201
+ /* @__PURE__ */ jsx2("span", {
78
202
  "aria-hidden": true,
79
203
  className: "ml-2 text-neutral-400",
80
204
  children: "▾"
81
205
  })
82
206
  ]
83
207
  }),
84
- open ? /* @__PURE__ */ jsxs("div", {
208
+ open ? /* @__PURE__ */ jsxs2("div", {
85
209
  className: "absolute z-20 mt-1 w-full rounded-md border border-neutral-200 bg-white shadow-lg dark:border-neutral-800 dark:bg-neutral-950",
86
210
  children: [
87
- /* @__PURE__ */ jsx("input", {
211
+ /* @__PURE__ */ jsx2("input", {
88
212
  autoFocus: true,
89
213
  value: query,
90
214
  onChange: (e) => setQuery(e.target.value),
91
215
  placeholder: "Search…",
92
216
  className: "w-full rounded-t-md border-b border-neutral-200 px-3 py-1.5 text-sm outline-none dark:border-neutral-800 dark:bg-neutral-950"
93
217
  }),
94
- /* @__PURE__ */ jsxs("ul", {
218
+ /* @__PURE__ */ jsxs2("ul", {
95
219
  className: "max-h-56 overflow-auto py-1",
96
220
  children: [
97
- !required ? /* @__PURE__ */ jsx("li", {
98
- children: /* @__PURE__ */ jsx("button", {
221
+ !required ? /* @__PURE__ */ jsx2("li", {
222
+ children: /* @__PURE__ */ jsx2("button", {
99
223
  type: "button",
100
224
  onClick: () => {
101
225
  onChange("");
@@ -106,13 +230,13 @@ function RelationSelect(props) {
106
230
  children: "None"
107
231
  })
108
232
  }) : null,
109
- matches.length === 0 ? /* @__PURE__ */ jsx("li", {
233
+ matches.length === 0 ? /* @__PURE__ */ jsx2("li", {
110
234
  className: "px-3 py-2 text-xs text-neutral-500",
111
235
  children: "No matches."
112
236
  }) : matches.map((doc) => {
113
237
  const id = typeof doc["id"] === "string" ? doc["id"] : "";
114
- return /* @__PURE__ */ jsx("li", {
115
- children: /* @__PURE__ */ jsx("button", {
238
+ return /* @__PURE__ */ jsx2("li", {
239
+ children: /* @__PURE__ */ jsx2("button", {
116
240
  type: "button",
117
241
  onClick: () => {
118
242
  onChange(id);
@@ -126,7 +250,7 @@ function RelationSelect(props) {
126
250
  })
127
251
  ]
128
252
  }),
129
- truncated ? /* @__PURE__ */ jsxs("p", {
253
+ truncated ? /* @__PURE__ */ jsxs2("p", {
130
254
  className: "border-t border-neutral-200 px-3 py-1.5 text-[11px] text-neutral-500 dark:border-neutral-800",
131
255
  children: [
132
256
  "Showing the first ",
@@ -142,10 +266,10 @@ function RelationSelect(props) {
142
266
 
143
267
  // src/runtime.tsx
144
268
  import { createContext, useContext, useCallback } from "react";
145
- import { jsx as jsx2 } from "react/jsx-runtime";
269
+ import { jsx as jsx3 } from "react/jsx-runtime";
146
270
  var Ctx = createContext(null);
147
271
  function GroveUiProvider({ value, children }) {
148
- return /* @__PURE__ */ jsx2(Ctx.Provider, {
272
+ return /* @__PURE__ */ jsx3(Ctx.Provider, {
149
273
  value,
150
274
  children
151
275
  });
@@ -187,15 +311,19 @@ function useAction() {
187
311
  return;
188
312
  }
189
313
  case "endpoint": {
190
- const base = ctx.endpointBase ?? "";
191
314
  const href = resolveHref(action.path, opts.doc);
192
- await fetch(`${base}${href}`, { method: action.method ?? "POST" });
315
+ const method = action.method ?? "POST";
316
+ if (ctx.callEndpoint)
317
+ await ctx.callEndpoint(href, method);
318
+ else
319
+ await fetch(`${ctx.endpointBase ?? ""}${href}`, { method });
193
320
  opts.onDone?.();
194
321
  return;
195
322
  }
196
323
  }
197
324
  }, [ctx]);
198
325
  }
326
+ var MARKDOWN_CELL_MAX = 80;
199
327
  function formatValue(value, format, opts = {}) {
200
328
  if (value === null || value === undefined)
201
329
  return "";
@@ -216,74 +344,245 @@ function formatValue(value, format, opts = {}) {
216
344
  return Number.isFinite(n) ? new Intl.NumberFormat(opts.locale).format(n) : String(value);
217
345
  }
218
346
  case "date": {
219
- const d = new Date(String(value));
220
- return Number.isNaN(d.getTime()) ? String(value) : d.toLocaleDateString(opts.locale);
347
+ const text = String(value);
348
+ const day = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
349
+ const d = day ? new Date(Number(day[1]), Number(day[2]) - 1, Number(day[3])) : new Date(text);
350
+ return Number.isNaN(d.getTime()) ? text : d.toLocaleDateString(opts.locale);
351
+ }
352
+ case "markdown": {
353
+ const text = String(value).replace(/\s+/g, " ").trim();
354
+ if (text.length <= MARKDOWN_CELL_MAX)
355
+ return text;
356
+ const cut = text.slice(0, MARKDOWN_CELL_MAX);
357
+ const lastSpace = cut.lastIndexOf(" ");
358
+ return `${(lastSpace > MARKDOWN_CELL_MAX / 2 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
221
359
  }
222
360
  default:
223
361
  return typeof value === "object" ? JSON.stringify(value) : String(value);
224
362
  }
225
363
  }
364
+ function useMessages(messages, fallback) {
365
+ const { locale } = useGroveUi();
366
+ return useCallback((key) => {
367
+ const entry = messages[key];
368
+ if (!entry)
369
+ return key;
370
+ return (locale !== undefined ? entry[locale] : undefined) ?? entry[fallback] ?? key;
371
+ }, [messages, locale, fallback]);
372
+ }
373
+ function detectLocale(locales, preferred = typeof navigator !== "undefined" ? navigator.languages : []) {
374
+ const first = locales[0] ?? "en";
375
+ for (const want of preferred) {
376
+ const exact = locales.find((l) => l.toLowerCase() === want.toLowerCase());
377
+ if (exact)
378
+ return exact;
379
+ const lang = want.split("-")[0]?.toLowerCase();
380
+ const loose = locales.find((l) => l.split("-")[0]?.toLowerCase() === lang);
381
+ if (loose)
382
+ return loose;
383
+ }
384
+ return first;
385
+ }
386
+
387
+ // src/styles.ts
388
+ function cx(...parts) {
389
+ return parts.filter(Boolean).join(" ");
390
+ }
391
+ var INPUT_CLASS = "rounded-md border border-neutral-300 bg-white px-3 py-1.5 text-sm text-neutral-900 " + "dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-100";
392
+ var BUTTON_BASE = "inline-flex w-fit self-start items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium " + "transition-colors disabled:opacity-50 disabled:pointer-events-none";
393
+ var VARIANT = {
394
+ default: "bg-neutral-900 text-white hover:bg-neutral-800 dark:bg-white dark:text-neutral-900",
395
+ secondary: "bg-neutral-100 text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-100",
396
+ destructive: "bg-red-600 text-white hover:bg-red-700",
397
+ outline: "border border-neutral-300 text-neutral-900 hover:bg-neutral-50 " + "dark:border-neutral-700 dark:text-neutral-100 dark:hover:bg-neutral-900",
398
+ ghost: "text-neutral-900 hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-800"
399
+ };
400
+ var TONE = {
401
+ error: "text-sm text-red-600",
402
+ success: "text-sm text-green-700 dark:text-green-500",
403
+ muted: "text-sm text-neutral-500"
404
+ };
226
405
 
227
406
  // src/components.tsx
228
- import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
407
+ import { jsx as jsx4, jsxs as jsxs3, Fragment } from "react/jsx-runtime";
408
+ var WIDTH_CLASS = { narrow: "w-[12%]", normal: "", wide: "w-[45%]" };
229
409
  var GAP = ["gap-0", "gap-1", "gap-2", "gap-3", "gap-4", "gap-5", "gap-6", "gap-7", "gap-8", "gap-9", "gap-10", "gap-11", "gap-12"];
230
410
  var COLUMNS = ["", "md:grid-cols-1", "md:grid-cols-2", "md:grid-cols-3", "md:grid-cols-4", "md:grid-cols-5", "md:grid-cols-6"];
231
411
  var WIDTH = { full: "w-full", container: "w-full max-w-5xl mx-auto", narrow: "w-full max-w-xl mx-auto" };
232
412
  var ALIGN = { start: "items-start", center: "items-center", end: "items-end", stretch: "items-stretch" };
233
413
  var JUSTIFY = { start: "justify-start", center: "justify-center", end: "justify-end", between: "justify-between" };
234
- function cx(...parts) {
235
- return parts.filter(Boolean).join(" ");
236
- }
237
414
  function Stack(props) {
238
415
  const { direction = "vertical", gap = 4, align, justify, width, wrap, children } = props;
239
- return /* @__PURE__ */ jsx3("div", {
416
+ return /* @__PURE__ */ jsx4("div", {
240
417
  className: cx("flex", direction === "vertical" ? "flex-col" : "flex-row", GAP[Math.min(Math.max(gap, 0), 12)], align && ALIGN[align], justify && JUSTIFY[justify], width && WIDTH[width], wrap && "flex-wrap"),
241
418
  children
242
419
  });
243
420
  }
244
421
  function Grid(props) {
245
422
  const { columns, gap = 4, width, children } = props;
246
- return /* @__PURE__ */ jsx3("div", {
423
+ return /* @__PURE__ */ jsx4("div", {
247
424
  className: cx("grid grid-cols-1", COLUMNS[Math.min(Math.max(columns, 1), 6)], GAP[Math.min(Math.max(gap, 0), 12)], width && WIDTH[width]),
248
425
  children
249
426
  });
250
427
  }
251
428
  function Divider() {
252
- return /* @__PURE__ */ jsx3("hr", {
429
+ return /* @__PURE__ */ jsx4("hr", {
253
430
  className: "border-t border-neutral-200 dark:border-neutral-800"
254
431
  });
255
432
  }
256
433
  var HEADING = { 1: "text-3xl font-semibold tracking-tight", 2: "text-2xl font-semibold", 3: "text-lg font-medium" };
257
434
  function Heading({ level = 1, children }) {
258
435
  const Tag = ["h1", "h2", "h3"][level - 1] ?? "h1";
259
- return /* @__PURE__ */ jsx3(Tag, {
436
+ return /* @__PURE__ */ jsx4(Tag, {
260
437
  className: HEADING[level],
261
438
  children
262
439
  });
263
440
  }
264
441
  function Text({ muted, children }) {
265
- return /* @__PURE__ */ jsx3("p", {
442
+ return /* @__PURE__ */ jsx4("p", {
266
443
  className: muted ? "text-sm text-neutral-500" : "text-sm",
267
444
  children
268
445
  });
269
446
  }
270
- var VARIANT = {
271
- default: "bg-neutral-900 text-white hover:bg-neutral-800 dark:bg-white dark:text-neutral-900",
272
- secondary: "bg-neutral-100 text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-100",
273
- destructive: "bg-red-600 text-white hover:bg-red-700",
274
- outline: "border border-neutral-300 hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-900",
275
- ghost: "hover:bg-neutral-100 dark:hover:bg-neutral-800"
276
- };
277
- var BUTTON_BASE = "inline-flex w-fit self-start items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium " + "transition-colors disabled:opacity-50 disabled:pointer-events-none";
447
+ function Header(props) {
448
+ const { brand, brandHref = "/", links, actions, sticky } = props;
449
+ const dispatch = useAction();
450
+ const [open, setOpen] = useState3(false);
451
+ const go = useCallback2((href) => (e) => {
452
+ if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0)
453
+ return;
454
+ e.preventDefault();
455
+ setOpen(false);
456
+ dispatch({ kind: "navigate", href }, {});
457
+ }, [dispatch]);
458
+ const hasNav = links && links.length > 0 || actions && actions.length > 0;
459
+ return /* @__PURE__ */ jsxs3("header", {
460
+ className: cx("border-b border-neutral-200 bg-white px-4 py-3 text-neutral-900", "dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-100", sticky ? "sticky top-0 z-40" : ""),
461
+ children: [
462
+ /* @__PURE__ */ jsxs3("div", {
463
+ className: "flex items-center gap-6",
464
+ children: [
465
+ brand !== undefined && brand !== null && brand !== "" ? /* @__PURE__ */ jsx4("a", {
466
+ href: brandHref,
467
+ onClick: go(brandHref),
468
+ className: "text-sm font-semibold tracking-tight",
469
+ children: brand
470
+ }) : null,
471
+ links && links.length > 0 ? /* @__PURE__ */ jsx4("nav", {
472
+ "data-grove-nav": true,
473
+ className: "hidden items-center gap-4 md:flex",
474
+ children: links.map((link, i) => /* @__PURE__ */ jsx4("a", {
475
+ href: link.href,
476
+ onClick: go(link.href),
477
+ className: "text-sm text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100",
478
+ children: link.label
479
+ }, i))
480
+ }) : null,
481
+ actions && actions.length > 0 ? /* @__PURE__ */ jsx4("div", {
482
+ "data-grove-nav-actions": true,
483
+ className: "ml-auto hidden items-center gap-2 md:flex",
484
+ children: actions.map((a, i) => /* @__PURE__ */ jsx4(Button, {
485
+ action: a.action,
486
+ variant: a.variant,
487
+ children: a.label
488
+ }, i))
489
+ }) : null,
490
+ hasNav ? /* @__PURE__ */ jsx4("button", {
491
+ type: "button",
492
+ "data-grove-nav-toggle": true,
493
+ "aria-label": open ? "Close menu" : "Open menu",
494
+ "aria-expanded": open,
495
+ onClick: () => setOpen((v) => !v),
496
+ className: "ml-auto inline-flex items-center justify-center rounded-md p-2 text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900 md:hidden dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100",
497
+ children: /* @__PURE__ */ jsx4("svg", {
498
+ width: "20",
499
+ height: "20",
500
+ viewBox: "0 0 24 24",
501
+ fill: "none",
502
+ stroke: "currentColor",
503
+ strokeWidth: "2",
504
+ strokeLinecap: "round",
505
+ "aria-hidden": "true",
506
+ children: open ? /* @__PURE__ */ jsxs3(Fragment, {
507
+ children: [
508
+ /* @__PURE__ */ jsx4("line", {
509
+ x1: "18",
510
+ y1: "6",
511
+ x2: "6",
512
+ y2: "18"
513
+ }),
514
+ /* @__PURE__ */ jsx4("line", {
515
+ x1: "6",
516
+ y1: "6",
517
+ x2: "18",
518
+ y2: "18"
519
+ })
520
+ ]
521
+ }) : /* @__PURE__ */ jsxs3(Fragment, {
522
+ children: [
523
+ /* @__PURE__ */ jsx4("line", {
524
+ x1: "3",
525
+ y1: "6",
526
+ x2: "21",
527
+ y2: "6"
528
+ }),
529
+ /* @__PURE__ */ jsx4("line", {
530
+ x1: "3",
531
+ y1: "12",
532
+ x2: "21",
533
+ y2: "12"
534
+ }),
535
+ /* @__PURE__ */ jsx4("line", {
536
+ x1: "3",
537
+ y1: "18",
538
+ x2: "21",
539
+ y2: "18"
540
+ })
541
+ ]
542
+ })
543
+ })
544
+ }) : null
545
+ ]
546
+ }),
547
+ hasNav && open ? /* @__PURE__ */ jsxs3("div", {
548
+ "data-grove-nav-drawer": true,
549
+ className: "mt-3 flex flex-col gap-1 border-t border-neutral-200 pt-3 md:hidden dark:border-neutral-800",
550
+ children: [
551
+ links?.map((link, i) => /* @__PURE__ */ jsx4("a", {
552
+ href: link.href,
553
+ onClick: go(link.href),
554
+ className: "rounded-md px-2 py-2 text-sm text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100",
555
+ children: link.label
556
+ }, i)),
557
+ actions && actions.length > 0 ? /* @__PURE__ */ jsx4("div", {
558
+ className: "mt-2 flex flex-col gap-2",
559
+ children: actions.map((a, i) => /* @__PURE__ */ jsx4(Button, {
560
+ action: a.action,
561
+ variant: a.variant,
562
+ children: a.label
563
+ }, i))
564
+ }) : null
565
+ ]
566
+ }) : null
567
+ ]
568
+ });
569
+ }
278
570
  function Button(props) {
279
571
  const { action, variant = "default", doc, collection, onDone, children } = props;
280
572
  const dispatch = useAction();
281
- const [busy, setBusy] = useState2(false);
573
+ const [busy, setBusy] = useState3(false);
574
+ const running = useRef2(false);
282
575
  const onClick = useCallback2(() => {
576
+ if (running.current)
577
+ return;
578
+ running.current = true;
283
579
  setBusy(true);
284
- dispatch(action, { doc, collection, onDone }).finally(() => setBusy(false));
580
+ dispatch(action, { doc, collection, onDone }).finally(() => {
581
+ running.current = false;
582
+ setBusy(false);
583
+ });
285
584
  }, [action, dispatch, doc, collection, onDone]);
286
- return /* @__PURE__ */ jsx3("button", {
585
+ return /* @__PURE__ */ jsx4("button", {
287
586
  type: "button",
288
587
  disabled: busy,
289
588
  onClick,
@@ -292,10 +591,10 @@ function Button(props) {
292
591
  });
293
592
  }
294
593
  function useAsync(load, deps) {
295
- const [data, setData] = useState2(null);
296
- const [error, setError] = useState2(null);
297
- const [loading, setLoading] = useState2(true);
298
- const [nonce, setNonce] = useState2(0);
594
+ const [data, setData] = useState3(null);
595
+ const [error, setError] = useState3(null);
596
+ const [loading, setLoading] = useState3(true);
597
+ const [nonce, setNonce] = useState3(0);
299
598
  useEffect2(() => {
300
599
  let live = true;
301
600
  setLoading(true);
@@ -317,14 +616,14 @@ function useAsync(load, deps) {
317
616
  return { data, error, loading, reload: () => setNonce((n) => n + 1) };
318
617
  }
319
618
  function Message({ tone, children }) {
320
- const cls = tone === "error" ? "text-sm text-red-600" : "text-sm text-neutral-500";
321
- return /* @__PURE__ */ jsx3("p", {
322
- className: cls,
619
+ return /* @__PURE__ */ jsx4("p", {
620
+ className: TONE[tone],
621
+ ...tone === "success" ? { role: "status" } : {},
323
622
  children
324
623
  });
325
624
  }
326
625
  function useRelations(fields, docs, resolveCollection) {
327
- const [map, setMap] = useState2({});
626
+ const [map, setMap] = useState3({});
328
627
  const relations = fields.filter((f) => f.display);
329
628
  const key = JSON.stringify(relations.map((f) => f.field)) + (docs?.length ?? 0);
330
629
  useEffect2(() => {
@@ -367,54 +666,54 @@ function DataTable(props) {
367
666
  const { data, error, loading, reload } = useAsync(() => collection.find(options), [JSON.stringify(options)]);
368
667
  const relations = useRelations(columns, data, resolveRelation);
369
668
  if (loading)
370
- return /* @__PURE__ */ jsx3(Message, {
669
+ return /* @__PURE__ */ jsx4(Message, {
371
670
  tone: "muted",
372
671
  children: "Loading…"
373
672
  });
374
673
  if (error)
375
- return /* @__PURE__ */ jsx3(Message, {
674
+ return /* @__PURE__ */ jsx4(Message, {
376
675
  tone: "error",
377
676
  children: error
378
677
  });
379
678
  if (!data || data.length === 0)
380
- return /* @__PURE__ */ jsx3(Message, {
679
+ return /* @__PURE__ */ jsx4(Message, {
381
680
  tone: "muted",
382
681
  children: emptyText ?? "Nothing here yet."
383
682
  });
384
- return /* @__PURE__ */ jsx3("div", {
683
+ return /* @__PURE__ */ jsx4("div", {
385
684
  className: "w-full overflow-x-auto rounded-md border border-neutral-200 dark:border-neutral-800",
386
- children: /* @__PURE__ */ jsxs2("table", {
685
+ children: /* @__PURE__ */ jsxs3("table", {
387
686
  className: "w-full text-sm",
388
687
  children: [
389
- /* @__PURE__ */ jsx3("thead", {
390
- className: "bg-neutral-50 dark:bg-neutral-900",
391
- children: /* @__PURE__ */ jsxs2("tr", {
688
+ /* @__PURE__ */ jsx4("thead", {
689
+ className: "bg-neutral-50 text-neutral-900 dark:bg-neutral-900 dark:text-neutral-100",
690
+ children: /* @__PURE__ */ jsxs3("tr", {
392
691
  children: [
393
- columns.map((c) => /* @__PURE__ */ jsx3("th", {
394
- className: "px-3 py-2 text-left font-medium text-neutral-600 dark:text-neutral-400",
692
+ columns.map((c) => /* @__PURE__ */ jsx4("th", {
693
+ className: `px-3 py-2 text-left font-medium text-neutral-600 dark:text-neutral-400 ${WIDTH_CLASS[c.width ?? "normal"]}`,
395
694
  children: c.label
396
695
  }, c.field)),
397
- rowActions && rowActions.length > 0 ? /* @__PURE__ */ jsx3("th", {
696
+ rowActions && rowActions.length > 0 ? /* @__PURE__ */ jsx4("th", {
398
697
  className: "px-3 py-2"
399
698
  }) : null
400
699
  ]
401
700
  })
402
701
  }),
403
- /* @__PURE__ */ jsx3("tbody", {
404
- children: data.map((doc, i) => /* @__PURE__ */ jsxs2("tr", {
702
+ /* @__PURE__ */ jsx4("tbody", {
703
+ children: data.map((doc, i) => /* @__PURE__ */ jsxs3("tr", {
405
704
  className: cx("border-t border-neutral-200 dark:border-neutral-800", rowHref && "cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-900"),
406
705
  onClick: rowHref ? () => ctx.navigate(resolveHref(rowHref, doc)) : undefined,
407
706
  children: [
408
- columns.map((c) => /* @__PURE__ */ jsx3("td", {
707
+ columns.map((c) => /* @__PURE__ */ jsx4("td", {
409
708
  className: "px-3 py-2",
410
709
  children: cellValue(doc, c, relations, ctx.locale, ctx.currency)
411
710
  }, c.field)),
412
- rowActions && rowActions.length > 0 ? /* @__PURE__ */ jsx3("td", {
711
+ rowActions && rowActions.length > 0 ? /* @__PURE__ */ jsx4("td", {
413
712
  className: "px-3 py-2",
414
- children: /* @__PURE__ */ jsx3("div", {
713
+ children: /* @__PURE__ */ jsx4("div", {
415
714
  className: "flex justify-end gap-2",
416
715
  onClick: (e) => e.stopPropagation(),
417
- children: rowActions.map((a) => /* @__PURE__ */ jsx3(Button, {
716
+ children: rowActions.map((a) => /* @__PURE__ */ jsx4(Button, {
418
717
  action: a.action,
419
718
  variant: a.variant,
420
719
  doc,
@@ -437,30 +736,33 @@ function Detail(props) {
437
736
  const { data, error, loading } = useAsync(() => collection.get(id), [id]);
438
737
  const relations = useRelations(fields, data ? [data] : null, resolveRelation);
439
738
  if (loading)
440
- return /* @__PURE__ */ jsx3(Message, {
739
+ return /* @__PURE__ */ jsx4(Message, {
441
740
  tone: "muted",
442
741
  children: "Loading…"
443
742
  });
444
743
  if (error)
445
- return /* @__PURE__ */ jsx3(Message, {
744
+ return /* @__PURE__ */ jsx4(Message, {
446
745
  tone: "error",
447
746
  children: error
448
747
  });
449
748
  if (!data)
450
- return /* @__PURE__ */ jsx3(Message, {
749
+ return /* @__PURE__ */ jsx4(Message, {
451
750
  tone: "muted",
452
751
  children: "Not found."
453
752
  });
454
- return /* @__PURE__ */ jsx3("dl", {
753
+ return /* @__PURE__ */ jsx4("dl", {
455
754
  className: "divide-y divide-neutral-200 rounded-md border border-neutral-200 dark:divide-neutral-800 dark:border-neutral-800",
456
- children: fields.map((f) => /* @__PURE__ */ jsxs2("div", {
755
+ children: fields.map((f) => /* @__PURE__ */ jsxs3("div", {
457
756
  className: "grid grid-cols-3 gap-4 px-3 py-2",
458
757
  children: [
459
- /* @__PURE__ */ jsx3("dt", {
758
+ /* @__PURE__ */ jsx4("dt", {
460
759
  className: "text-sm text-neutral-500",
461
760
  children: f.label
462
761
  }),
463
- /* @__PURE__ */ jsx3("dd", {
762
+ f.format === "markdown" && !f.display ? /* @__PURE__ */ jsx4("dd", {
763
+ className: "col-span-2 whitespace-pre-wrap text-sm",
764
+ children: String(data[f.field] ?? "")
765
+ }) : /* @__PURE__ */ jsx4("dd", {
464
766
  className: "col-span-2 text-sm",
465
767
  children: cellValue(data, f, relations, ctx.locale, ctx.currency)
466
768
  })
@@ -493,10 +795,12 @@ function coerce(raw, format) {
493
795
  function Form(props) {
494
796
  const { collection, mode, id, fields, submitLabel, onSuccess, resolveRelation } = props;
495
797
  const dispatch = useAction();
496
- const [values, setValues] = useState2({});
497
- const [error, setError] = useState2(null);
498
- const [busy, setBusy] = useState2(false);
499
- const [loading, setLoading] = useState2(mode === "update");
798
+ const [values, setValues] = useState3({});
799
+ const [error, setError] = useState3(null);
800
+ const [busy, setBusy] = useState3(false);
801
+ const [done, setDone] = useState3(false);
802
+ const submitting = useRef2(false);
803
+ const [loading, setLoading] = useState3(mode === "update");
500
804
  useEffect2(() => {
501
805
  if (mode !== "update" || !id)
502
806
  return;
@@ -523,10 +827,18 @@ function Form(props) {
523
827
  live = false;
524
828
  };
525
829
  }, [id, mode]);
830
+ const setField = useCallback2((field, value) => {
831
+ setDone(false);
832
+ setValues((v) => ({ ...v, [field]: value }));
833
+ }, []);
526
834
  const submit = useCallback2(async (e) => {
527
835
  e.preventDefault();
836
+ if (submitting.current)
837
+ return;
838
+ submitting.current = true;
528
839
  setBusy(true);
529
840
  setError(null);
841
+ setDone(false);
530
842
  try {
531
843
  const payload = {};
532
844
  for (const f of fields) {
@@ -538,53 +850,79 @@ function Form(props) {
538
850
  if (onSuccess) {
539
851
  const doc = mode === "create" ? saved : { id, ...payload };
540
852
  await dispatch(onSuccess, { doc });
853
+ } else {
854
+ setDone(true);
855
+ if (mode === "create")
856
+ setValues({});
541
857
  }
542
858
  } catch (err) {
543
859
  setError(err instanceof Error ? err.message : String(err));
544
860
  } finally {
861
+ submitting.current = false;
545
862
  setBusy(false);
546
863
  }
547
864
  }, [collection, dispatch, fields, id, mode, onSuccess, values]);
548
865
  if (loading)
549
- return /* @__PURE__ */ jsx3(Message, {
866
+ return /* @__PURE__ */ jsx4(Message, {
550
867
  tone: "muted",
551
868
  children: "Loading…"
552
869
  });
553
- return /* @__PURE__ */ jsxs2("form", {
870
+ return /* @__PURE__ */ jsxs3("form", {
554
871
  onSubmit: submit,
555
872
  className: "flex w-full max-w-xl flex-col gap-3",
556
873
  children: [
557
- fields.map((f) => /* @__PURE__ */ jsxs2("label", {
874
+ fields.map((f) => /* @__PURE__ */ jsxs3("label", {
558
875
  className: "flex flex-col gap-1",
559
876
  children: [
560
- /* @__PURE__ */ jsx3("span", {
877
+ /* @__PURE__ */ jsx4("span", {
561
878
  className: "text-sm text-neutral-600 dark:text-neutral-400",
562
879
  children: f.label
563
880
  }),
564
- f.display && resolveRelation ? /* @__PURE__ */ jsx3(RelationSelect, {
881
+ f.display && resolveRelation ? /* @__PURE__ */ jsx4(RelationSelect, {
565
882
  collection: resolveRelation(f.field),
566
883
  displayField: f.display.field,
567
884
  value: String(values[f.field] ?? ""),
568
- onChange: (next) => setValues((v) => ({ ...v, [f.field]: next }))
569
- }) : f.format === "boolean" ? /* @__PURE__ */ jsx3("input", {
885
+ onChange: (next) => setField(f.field, next)
886
+ }) : f.format === "boolean" ? /* @__PURE__ */ jsx4("input", {
570
887
  type: "checkbox",
571
888
  className: "h-4 w-4",
572
889
  checked: Boolean(values[f.field]),
573
- onChange: (e) => setValues((v) => ({ ...v, [f.field]: e.target.checked }))
574
- }) : /* @__PURE__ */ jsx3("input", {
890
+ onChange: (e) => setField(f.field, e.target.checked)
891
+ }) : f.options && f.options.length > 0 ? /* @__PURE__ */ jsxs3("select", {
892
+ className: INPUT_CLASS,
893
+ value: String(values[f.field] ?? ""),
894
+ onChange: (e) => setField(f.field, e.target.value),
895
+ children: [
896
+ /* @__PURE__ */ jsx4("option", {
897
+ value: "",
898
+ children: "—"
899
+ }),
900
+ f.options.map((opt) => /* @__PURE__ */ jsx4("option", {
901
+ value: opt,
902
+ children: opt
903
+ }, opt))
904
+ ]
905
+ }) : f.format === "markdown" ? /* @__PURE__ */ jsx4(MarkdownField, {
906
+ value: String(values[f.field] ?? ""),
907
+ onChange: (next) => setField(f.field, next)
908
+ }) : /* @__PURE__ */ jsx4("input", {
575
909
  type: inputType(f.format),
576
- className: "rounded-md border border-neutral-300 px-3 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-950",
910
+ className: INPUT_CLASS,
577
911
  value: String(values[f.field] ?? ""),
578
- onChange: (e) => setValues((v) => ({ ...v, [f.field]: e.target.value }))
912
+ onChange: (e) => setField(f.field, e.target.value)
579
913
  })
580
914
  ]
581
915
  }, f.field)),
582
- error ? /* @__PURE__ */ jsx3(Message, {
916
+ error ? /* @__PURE__ */ jsx4(Message, {
583
917
  tone: "error",
584
918
  children: error
585
919
  }) : null,
586
- /* @__PURE__ */ jsx3("div", {
587
- children: /* @__PURE__ */ jsx3("button", {
920
+ done && !error ? /* @__PURE__ */ jsx4(Message, {
921
+ tone: "success",
922
+ children: mode === "create" ? "Saved." : "Changes saved."
923
+ }) : null,
924
+ /* @__PURE__ */ jsx4("div", {
925
+ children: /* @__PURE__ */ jsx4("button", {
588
926
  type: "submit",
589
927
  disabled: busy,
590
928
  className: cx(BUTTON_BASE, VARIANT.default),
@@ -594,6 +932,291 @@ function Form(props) {
594
932
  ]
595
933
  });
596
934
  }
935
+
936
+ // src/routes.tsx
937
+ import { useCallback as useCallback3, useEffect as useEffect3, useMemo as useMemo2, useState as useState4 } from "react";
938
+ import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
939
+ function matchRoute(path, pattern) {
940
+ const clean = (path.split(/[?#]/)[0] ?? "").replace(/\/+$/, "") || "/";
941
+ const given = clean.split("/");
942
+ const parts = pattern.split("/");
943
+ if (given.length !== parts.length)
944
+ return null;
945
+ const params = {};
946
+ for (let i = 0;i < parts.length; i++) {
947
+ const seg = parts[i];
948
+ const value = given[i];
949
+ if (seg.startsWith(":")) {
950
+ if (value.length === 0)
951
+ return null;
952
+ params[seg.slice(1)] = value;
953
+ } else if (seg !== value) {
954
+ return null;
955
+ }
956
+ }
957
+ return params;
958
+ }
959
+ function paramCount(pattern) {
960
+ return pattern.split("/").filter((s) => s.startsWith(":")).length;
961
+ }
962
+ function resolveRoute(routes, path) {
963
+ const ordered = [...routes].sort((a, b) => paramCount(a.path) - paramCount(b.path));
964
+ for (const route of ordered) {
965
+ const params = matchRoute(path, route.path);
966
+ if (params !== null)
967
+ return { route, params };
968
+ }
969
+ return null;
970
+ }
971
+ function GroveRoutes(props) {
972
+ const { routes, context, fallback, children, gate } = props;
973
+ const [path, setPath] = useState4(() => window.location.pathname);
974
+ useEffect3(() => {
975
+ const onPop = () => setPath(window.location.pathname);
976
+ window.addEventListener("popstate", onPop);
977
+ return () => window.removeEventListener("popstate", onPop);
978
+ }, []);
979
+ const navigate = useCallback3((href) => {
980
+ window.history.pushState({}, "", href);
981
+ setPath(href.split(/[?#]/)[0] ?? href);
982
+ }, []);
983
+ const value = useMemo2(() => ({ ...context, navigate }), [context, navigate]);
984
+ const hit = resolveRoute(routes, path);
985
+ const isRoot = (path.replace(/\/+$/, "") || "/") === "/";
986
+ const matched = hit ? hit.route.render(hit.params) : fallback ?? (isRoot ? /* @__PURE__ */ jsx5(RouteIndex, {
987
+ routes
988
+ }) : /* @__PURE__ */ jsx5(NotFound, {}));
989
+ const screen = gate && hit && hit.route.requiresAuth !== false ? gate(matched) : matched;
990
+ return /* @__PURE__ */ jsxs4(GroveUiProvider, {
991
+ value,
992
+ children: [
993
+ children,
994
+ screen
995
+ ]
996
+ });
997
+ }
998
+ function NotFound() {
999
+ const { navigate } = useGroveUi();
1000
+ return /* @__PURE__ */ jsxs4("div", {
1001
+ className: "flex flex-col gap-2 p-6",
1002
+ children: [
1003
+ /* @__PURE__ */ jsx5("p", {
1004
+ className: "text-sm text-neutral-500",
1005
+ children: "Not found."
1006
+ }),
1007
+ /* @__PURE__ */ jsx5("a", {
1008
+ href: "/",
1009
+ className: "w-fit text-sm underline",
1010
+ onClick: (e) => {
1011
+ e.preventDefault();
1012
+ navigate("/");
1013
+ },
1014
+ children: "Home"
1015
+ })
1016
+ ]
1017
+ });
1018
+ }
1019
+ function RouteIndex({ routes }) {
1020
+ const { navigate } = useGroveUi();
1021
+ const pages = routes.filter((r) => !r.path.split("/").some((seg) => seg.startsWith(":")));
1022
+ return /* @__PURE__ */ jsxs4("nav", {
1023
+ className: "flex flex-col gap-3 p-6",
1024
+ "aria-label": "Pages",
1025
+ children: [
1026
+ /* @__PURE__ */ jsx5("h1", {
1027
+ className: "text-2xl font-semibold",
1028
+ children: "Pages"
1029
+ }),
1030
+ /* @__PURE__ */ jsx5("ul", {
1031
+ className: "flex flex-col gap-1",
1032
+ children: pages.map((r) => /* @__PURE__ */ jsxs4("li", {
1033
+ children: [
1034
+ /* @__PURE__ */ jsx5("a", {
1035
+ href: r.path,
1036
+ className: "text-sm underline",
1037
+ onClick: (e) => {
1038
+ e.preventDefault();
1039
+ navigate(r.path);
1040
+ },
1041
+ children: r.title ?? r.path
1042
+ }),
1043
+ /* @__PURE__ */ jsx5("span", {
1044
+ className: "ml-2 text-xs text-neutral-500",
1045
+ children: r.path
1046
+ })
1047
+ ]
1048
+ }, r.path))
1049
+ })
1050
+ ]
1051
+ });
1052
+ }
1053
+
1054
+ // src/SignIn.tsx
1055
+ import { useCallback as useCallback4, useRef as useRef3, useState as useState5 } from "react";
1056
+ import { jsx as jsx6, jsxs as jsxs5, Fragment as Fragment2 } from "react/jsx-runtime";
1057
+ function SignIn(props) {
1058
+ const { auth, title, allowRegister = typeof auth.register === "function", onSignedIn, children } = props;
1059
+ const [signedIn, setSignedIn] = useState5(() => auth.getTokens() !== null);
1060
+ const [mode, setMode] = useState5("login");
1061
+ const [email, setEmail] = useState5("");
1062
+ const [password, setPassword] = useState5("");
1063
+ const [name, setName] = useState5("");
1064
+ const [code, setCode] = useState5("");
1065
+ const [error, setError] = useState5(null);
1066
+ const [busy, setBusy] = useState5(false);
1067
+ const submitting = useRef3(false);
1068
+ const finish = useCallback4((user) => {
1069
+ setSignedIn(true);
1070
+ onSignedIn?.(user);
1071
+ }, [onSignedIn]);
1072
+ const submit = useCallback4(async (e) => {
1073
+ e.preventDefault();
1074
+ if (submitting.current)
1075
+ return;
1076
+ submitting.current = true;
1077
+ setBusy(true);
1078
+ setError(null);
1079
+ try {
1080
+ if (mode === "mfa") {
1081
+ if (!auth.verifyMfa)
1082
+ throw new Error("This account requires a second factor, which this app does not support.");
1083
+ finish(await auth.verifyMfa(code));
1084
+ return;
1085
+ }
1086
+ if (mode === "register") {
1087
+ if (!auth.register)
1088
+ throw new Error("Registration is not available.");
1089
+ await auth.register(email, password, name.trim().length > 0 ? name.trim() : undefined);
1090
+ }
1091
+ const user = await auth.login(email, password);
1092
+ if (user["mfaRequired"] === true) {
1093
+ setMode("mfa");
1094
+ return;
1095
+ }
1096
+ finish(user);
1097
+ } catch (err) {
1098
+ setError(err instanceof Error ? err.message : String(err));
1099
+ } finally {
1100
+ submitting.current = false;
1101
+ setBusy(false);
1102
+ }
1103
+ }, [auth, code, email, finish, mode, name, password]);
1104
+ if (signedIn)
1105
+ return /* @__PURE__ */ jsx6(Fragment2, {
1106
+ children
1107
+ });
1108
+ const heading = mode === "register" ? "Create account" : mode === "mfa" ? "Two-factor code" : title ?? "Sign in";
1109
+ const label = "text-sm text-neutral-600 dark:text-neutral-400";
1110
+ return /* @__PURE__ */ jsx6("div", {
1111
+ className: "flex min-h-screen items-center justify-center p-6",
1112
+ children: /* @__PURE__ */ jsxs5("form", {
1113
+ onSubmit: submit,
1114
+ className: "flex w-full max-w-sm flex-col gap-3",
1115
+ "aria-busy": busy,
1116
+ children: [
1117
+ /* @__PURE__ */ jsx6("h1", {
1118
+ className: "text-2xl font-semibold",
1119
+ children: heading
1120
+ }),
1121
+ mode === "mfa" ? /* @__PURE__ */ jsxs5("label", {
1122
+ className: "flex flex-col gap-1",
1123
+ children: [
1124
+ /* @__PURE__ */ jsx6("span", {
1125
+ className: label,
1126
+ children: "Code from your authenticator app, or a backup code"
1127
+ }),
1128
+ /* @__PURE__ */ jsx6("input", {
1129
+ className: INPUT_CLASS,
1130
+ autoComplete: "one-time-code",
1131
+ inputMode: "numeric",
1132
+ required: true,
1133
+ autoFocus: true,
1134
+ value: code,
1135
+ onChange: (e) => setCode(e.target.value)
1136
+ })
1137
+ ]
1138
+ }) : /* @__PURE__ */ jsxs5(Fragment2, {
1139
+ children: [
1140
+ mode === "register" ? /* @__PURE__ */ jsxs5("label", {
1141
+ className: "flex flex-col gap-1",
1142
+ children: [
1143
+ /* @__PURE__ */ jsx6("span", {
1144
+ className: label,
1145
+ children: "Name"
1146
+ }),
1147
+ /* @__PURE__ */ jsx6("input", {
1148
+ className: INPUT_CLASS,
1149
+ autoComplete: "name",
1150
+ value: name,
1151
+ onChange: (e) => setName(e.target.value)
1152
+ })
1153
+ ]
1154
+ }) : null,
1155
+ /* @__PURE__ */ jsxs5("label", {
1156
+ className: "flex flex-col gap-1",
1157
+ children: [
1158
+ /* @__PURE__ */ jsx6("span", {
1159
+ className: label,
1160
+ children: "Email"
1161
+ }),
1162
+ /* @__PURE__ */ jsx6("input", {
1163
+ className: INPUT_CLASS,
1164
+ type: "email",
1165
+ autoComplete: "email",
1166
+ required: true,
1167
+ value: email,
1168
+ onChange: (e) => setEmail(e.target.value)
1169
+ })
1170
+ ]
1171
+ }),
1172
+ /* @__PURE__ */ jsxs5("label", {
1173
+ className: "flex flex-col gap-1",
1174
+ children: [
1175
+ /* @__PURE__ */ jsx6("span", {
1176
+ className: label,
1177
+ children: "Password"
1178
+ }),
1179
+ /* @__PURE__ */ jsx6("input", {
1180
+ className: INPUT_CLASS,
1181
+ type: "password",
1182
+ autoComplete: mode === "register" ? "new-password" : "current-password",
1183
+ required: true,
1184
+ value: password,
1185
+ onChange: (e) => setPassword(e.target.value)
1186
+ })
1187
+ ]
1188
+ })
1189
+ ]
1190
+ }),
1191
+ error ? /* @__PURE__ */ jsx6("p", {
1192
+ className: TONE.error,
1193
+ role: "alert",
1194
+ children: error
1195
+ }) : null,
1196
+ /* @__PURE__ */ jsxs5("div", {
1197
+ className: "flex items-center justify-between gap-3 pt-1",
1198
+ children: [
1199
+ /* @__PURE__ */ jsx6("button", {
1200
+ type: "submit",
1201
+ disabled: busy,
1202
+ className: cx(BUTTON_BASE, VARIANT.default),
1203
+ children: busy ? "Please wait…" : mode === "register" ? "Create account" : mode === "mfa" ? "Verify" : "Sign in"
1204
+ }),
1205
+ allowRegister && mode !== "mfa" ? /* @__PURE__ */ jsx6("button", {
1206
+ type: "button",
1207
+ className: cx(BUTTON_BASE, VARIANT.ghost),
1208
+ onClick: () => {
1209
+ setError(null);
1210
+ setMode(mode === "register" ? "login" : "register");
1211
+ },
1212
+ children: mode === "register" ? "Have an account? Sign in" : "Create an account"
1213
+ }) : null
1214
+ ]
1215
+ })
1216
+ ]
1217
+ })
1218
+ });
1219
+ }
597
1220
  export {
598
1221
  Button,
599
1222
  DataTable,
@@ -601,13 +1224,23 @@ export {
601
1224
  Divider,
602
1225
  Form,
603
1226
  Grid,
1227
+ GroveRoutes,
604
1228
  GroveUiProvider,
1229
+ Header,
605
1230
  Heading,
1231
+ MarkdownField,
606
1232
  RelationSelect,
1233
+ RouteIndex,
1234
+ SignIn,
607
1235
  Stack,
608
1236
  Text,
1237
+ detectLocale,
609
1238
  formatValue,
1239
+ matchRoute,
1240
+ renderMarkdown,
610
1241
  resolveHref,
1242
+ resolveRoute,
611
1243
  useAction,
612
- useGroveUi
1244
+ useGroveUi,
1245
+ useMessages
613
1246
  };