@groveback/ui 0.1.0 → 0.2.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
+
4
+ // src/MarkdownField.tsx
5
+ import { useState } from "react";
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
+ }
3
127
 
4
128
  // src/RelationSelect.tsx
5
- import { useEffect, useMemo, useRef, useState } from "react";
6
- import { jsxDEV } from "react/jsx-dev-runtime";
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__ */ jsxDEV("p", {
182
+ return /* @__PURE__ */ jsx2("p", {
59
183
  className: "text-xs text-red-600",
60
184
  children: error
61
- }, undefined, false, undefined, this);
185
+ });
62
186
  }
63
187
  const buttonLabel = selected ? labelOf(selected, displayField) : value !== "" ? value : placeholder ?? "Select…";
64
- return /* @__PURE__ */ jsxDEV("div", {
188
+ return /* @__PURE__ */ jsxs2("div", {
65
189
  ref: boxRef,
66
190
  className: "relative",
67
191
  children: [
68
- /* @__PURE__ */ jsxDEV("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__ */ jsxDEV("span", {
197
+ /* @__PURE__ */ jsx2("span", {
74
198
  className: selected || value ? "" : "text-neutral-400",
75
199
  children: docs === null ? "Loading…" : buttonLabel
76
- }, undefined, false, undefined, this),
77
- /* @__PURE__ */ jsxDEV("span", {
200
+ }),
201
+ /* @__PURE__ */ jsx2("span", {
78
202
  "aria-hidden": true,
79
203
  className: "ml-2 text-neutral-400",
80
204
  children: "▾"
81
- }, undefined, false, undefined, this)
205
+ })
82
206
  ]
83
- }, undefined, true, undefined, this),
84
- open ? /* @__PURE__ */ jsxDEV("div", {
207
+ }),
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__ */ jsxDEV("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
- }, undefined, false, undefined, this),
94
- /* @__PURE__ */ jsxDEV("ul", {
217
+ }),
218
+ /* @__PURE__ */ jsxs2("ul", {
95
219
  className: "max-h-56 overflow-auto py-1",
96
220
  children: [
97
- !required ? /* @__PURE__ */ jsxDEV("li", {
98
- children: /* @__PURE__ */ jsxDEV("button", {
221
+ !required ? /* @__PURE__ */ jsx2("li", {
222
+ children: /* @__PURE__ */ jsx2("button", {
99
223
  type: "button",
100
224
  onClick: () => {
101
225
  onChange("");
@@ -104,15 +228,15 @@ function RelationSelect(props) {
104
228
  },
105
229
  className: "w-full px-3 py-1.5 text-left text-sm text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-900",
106
230
  children: "None"
107
- }, undefined, false, undefined, this)
108
- }, undefined, false, undefined, this) : null,
109
- matches.length === 0 ? /* @__PURE__ */ jsxDEV("li", {
231
+ })
232
+ }) : null,
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
- }, undefined, false, undefined, this) : matches.map((doc) => {
236
+ }) : matches.map((doc) => {
113
237
  const id = typeof doc["id"] === "string" ? doc["id"] : "";
114
- return /* @__PURE__ */ jsxDEV("li", {
115
- children: /* @__PURE__ */ jsxDEV("button", {
238
+ return /* @__PURE__ */ jsx2("li", {
239
+ children: /* @__PURE__ */ jsx2("button", {
116
240
  type: "button",
117
241
  onClick: () => {
118
242
  onChange(id);
@@ -121,34 +245,34 @@ function RelationSelect(props) {
121
245
  },
122
246
  className: `w-full px-3 py-1.5 text-left text-sm hover:bg-neutral-100 dark:hover:bg-neutral-900 ${id === value ? "font-medium" : ""}`,
123
247
  children: labelOf(doc, displayField)
124
- }, undefined, false, undefined, this)
125
- }, id, false, undefined, this);
248
+ })
249
+ }, id);
126
250
  })
127
251
  ]
128
- }, undefined, true, undefined, this),
129
- truncated ? /* @__PURE__ */ jsxDEV("p", {
252
+ }),
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 ",
133
257
  PAGE,
134
258
  ". Type to filter within them."
135
259
  ]
136
- }, undefined, true, undefined, this) : null
260
+ }) : null
137
261
  ]
138
- }, undefined, true, undefined, this) : null
262
+ }) : null
139
263
  ]
140
- }, undefined, true, undefined, this);
264
+ });
141
265
  }
142
266
 
143
267
  // src/runtime.tsx
144
268
  import { createContext, useContext, useCallback } from "react";
145
- import { jsxDEV as jsxDEV2 } from "react/jsx-dev-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__ */ jsxDEV2(Ctx.Provider, {
272
+ return /* @__PURE__ */ jsx3(Ctx.Provider, {
149
273
  value,
150
274
  children
151
- }, undefined, false, undefined, this);
275
+ });
152
276
  }
153
277
  var DEFAULT_CONTEXT = {
154
278
  navigate: (href) => {
@@ -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,86 +344,257 @@ 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 { jsxDEV as jsxDEV3 } from "react/jsx-dev-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__ */ jsxDEV3("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
- }, undefined, false, undefined, this);
419
+ });
243
420
  }
244
421
  function Grid(props) {
245
422
  const { columns, gap = 4, width, children } = props;
246
- return /* @__PURE__ */ jsxDEV3("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
- }, undefined, false, undefined, this);
426
+ });
250
427
  }
251
428
  function Divider() {
252
- return /* @__PURE__ */ jsxDEV3("hr", {
429
+ return /* @__PURE__ */ jsx4("hr", {
253
430
  className: "border-t border-neutral-200 dark:border-neutral-800"
254
- }, undefined, false, undefined, this);
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__ */ jsxDEV3(Tag, {
436
+ return /* @__PURE__ */ jsx4(Tag, {
260
437
  className: HEADING[level],
261
438
  children
262
- }, undefined, false, undefined, this);
439
+ });
263
440
  }
264
441
  function Text({ muted, children }) {
265
- return /* @__PURE__ */ jsxDEV3("p", {
442
+ return /* @__PURE__ */ jsx4("p", {
266
443
  className: muted ? "text-sm text-neutral-500" : "text-sm",
267
444
  children
268
- }, undefined, false, undefined, this);
445
+ });
446
+ }
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
+ });
269
569
  }
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";
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__ */ jsxDEV3("button", {
585
+ return /* @__PURE__ */ jsx4("button", {
287
586
  type: "button",
288
587
  disabled: busy,
289
588
  onClick,
290
589
  className: cx(BUTTON_BASE, VARIANT[variant]),
291
590
  children
292
- }, undefined, false, undefined, this);
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__ */ jsxDEV3("p", {
322
- className: cls,
619
+ return /* @__PURE__ */ jsx4("p", {
620
+ className: TONE[tone],
621
+ ...tone === "success" ? { role: "status" } : {},
323
622
  children
324
- }, undefined, false, undefined, this);
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,69 +666,69 @@ 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__ */ jsxDEV3(Message, {
669
+ return /* @__PURE__ */ jsx4(Message, {
371
670
  tone: "muted",
372
671
  children: "Loading…"
373
- }, undefined, false, undefined, this);
672
+ });
374
673
  if (error)
375
- return /* @__PURE__ */ jsxDEV3(Message, {
674
+ return /* @__PURE__ */ jsx4(Message, {
376
675
  tone: "error",
377
676
  children: error
378
- }, undefined, false, undefined, this);
677
+ });
379
678
  if (!data || data.length === 0)
380
- return /* @__PURE__ */ jsxDEV3(Message, {
679
+ return /* @__PURE__ */ jsx4(Message, {
381
680
  tone: "muted",
382
681
  children: emptyText ?? "Nothing here yet."
383
- }, undefined, false, undefined, this);
384
- return /* @__PURE__ */ jsxDEV3("div", {
682
+ });
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__ */ jsxDEV3("table", {
685
+ children: /* @__PURE__ */ jsxs3("table", {
387
686
  className: "w-full text-sm",
388
687
  children: [
389
- /* @__PURE__ */ jsxDEV3("thead", {
390
- className: "bg-neutral-50 dark:bg-neutral-900",
391
- children: /* @__PURE__ */ jsxDEV3("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__ */ jsxDEV3("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
- }, c.field, false, undefined, this)),
397
- rowActions && rowActions.length > 0 ? /* @__PURE__ */ jsxDEV3("th", {
695
+ }, c.field)),
696
+ rowActions && rowActions.length > 0 ? /* @__PURE__ */ jsx4("th", {
398
697
  className: "px-3 py-2"
399
- }, undefined, false, undefined, this) : null
698
+ }) : null
400
699
  ]
401
- }, undefined, true, undefined, this)
402
- }, undefined, false, undefined, this),
403
- /* @__PURE__ */ jsxDEV3("tbody", {
404
- children: data.map((doc, i) => /* @__PURE__ */ jsxDEV3("tr", {
700
+ })
701
+ }),
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__ */ jsxDEV3("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
- }, c.field, false, undefined, this)),
412
- rowActions && rowActions.length > 0 ? /* @__PURE__ */ jsxDEV3("td", {
710
+ }, c.field)),
711
+ rowActions && rowActions.length > 0 ? /* @__PURE__ */ jsx4("td", {
413
712
  className: "px-3 py-2",
414
- children: /* @__PURE__ */ jsxDEV3("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__ */ jsxDEV3(Button, {
716
+ children: rowActions.map((a) => /* @__PURE__ */ jsx4(Button, {
418
717
  action: a.action,
419
718
  variant: a.variant,
420
719
  doc,
421
720
  collection,
422
721
  onDone: reload,
423
722
  children: a.label
424
- }, a.label, false, undefined, this))
425
- }, undefined, false, undefined, this)
426
- }, undefined, false, undefined, this) : null
723
+ }, a.label))
724
+ })
725
+ }) : null
427
726
  ]
428
- }, typeof doc["id"] === "string" ? doc["id"] : i, true, undefined, this))
429
- }, undefined, false, undefined, this)
727
+ }, typeof doc["id"] === "string" ? doc["id"] : i))
728
+ })
430
729
  ]
431
- }, undefined, true, undefined, this)
432
- }, undefined, false, undefined, this);
730
+ })
731
+ });
433
732
  }
434
733
  function Detail(props) {
435
734
  const { collection, id, fields, resolveRelation } = props;
@@ -437,36 +736,39 @@ 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__ */ jsxDEV3(Message, {
739
+ return /* @__PURE__ */ jsx4(Message, {
441
740
  tone: "muted",
442
741
  children: "Loading…"
443
- }, undefined, false, undefined, this);
742
+ });
444
743
  if (error)
445
- return /* @__PURE__ */ jsxDEV3(Message, {
744
+ return /* @__PURE__ */ jsx4(Message, {
446
745
  tone: "error",
447
746
  children: error
448
- }, undefined, false, undefined, this);
747
+ });
449
748
  if (!data)
450
- return /* @__PURE__ */ jsxDEV3(Message, {
749
+ return /* @__PURE__ */ jsx4(Message, {
451
750
  tone: "muted",
452
751
  children: "Not found."
453
- }, undefined, false, undefined, this);
454
- return /* @__PURE__ */ jsxDEV3("dl", {
752
+ });
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__ */ jsxDEV3("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__ */ jsxDEV3("dt", {
758
+ /* @__PURE__ */ jsx4("dt", {
460
759
  className: "text-sm text-neutral-500",
461
760
  children: f.label
462
- }, undefined, false, undefined, this),
463
- /* @__PURE__ */ jsxDEV3("dd", {
761
+ }),
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
- }, undefined, false, undefined, this)
768
+ })
467
769
  ]
468
- }, f.field, true, undefined, this))
469
- }, undefined, false, undefined, this);
770
+ }, f.field))
771
+ });
470
772
  }
471
773
  function inputType(format) {
472
774
  switch (format) {
@@ -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,76 +850,396 @@ 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__ */ jsxDEV3(Message, {
866
+ return /* @__PURE__ */ jsx4(Message, {
550
867
  tone: "muted",
551
868
  children: "Loading…"
552
- }, undefined, false, undefined, this);
553
- return /* @__PURE__ */ jsxDEV3("form", {
869
+ });
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__ */ jsxDEV3("label", {
874
+ fields.map((f) => /* @__PURE__ */ jsxs3("label", {
558
875
  className: "flex flex-col gap-1",
559
876
  children: [
560
- /* @__PURE__ */ jsxDEV3("span", {
877
+ /* @__PURE__ */ jsx4("span", {
561
878
  className: "text-sm text-neutral-600 dark:text-neutral-400",
562
879
  children: f.label
563
- }, undefined, false, undefined, this),
564
- f.display && resolveRelation ? /* @__PURE__ */ jsxDEV3(RelationSelect, {
880
+ }),
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
- }, undefined, false, undefined, this) : f.format === "boolean" ? /* @__PURE__ */ jsxDEV3("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
- }, undefined, false, undefined, this) : /* @__PURE__ */ jsxDEV3("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 }))
579
- }, undefined, false, undefined, this)
912
+ onChange: (e) => setField(f.field, e.target.value)
913
+ })
580
914
  ]
581
- }, f.field, true, undefined, this)),
582
- error ? /* @__PURE__ */ jsxDEV3(Message, {
915
+ }, f.field)),
916
+ error ? /* @__PURE__ */ jsx4(Message, {
583
917
  tone: "error",
584
918
  children: error
585
- }, undefined, false, undefined, this) : null,
586
- /* @__PURE__ */ jsxDEV3("div", {
587
- children: /* @__PURE__ */ jsxDEV3("button", {
919
+ }) : null,
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),
591
929
  children: busy ? "Saving…" : submitLabel ?? "Save"
592
- }, undefined, false, undefined, this)
593
- }, undefined, false, undefined, this)
930
+ })
931
+ })
594
932
  ]
595
- }, undefined, true, undefined, this);
933
+ });
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 } = 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 screen = hit ? hit.route.render(hit.params) : fallback ?? (isRoot ? /* @__PURE__ */ jsx5(RouteIndex, {
987
+ routes
988
+ }) : /* @__PURE__ */ jsx5(NotFound, {}));
989
+ return /* @__PURE__ */ jsxs4(GroveUiProvider, {
990
+ value,
991
+ children: [
992
+ children,
993
+ screen
994
+ ]
995
+ });
996
+ }
997
+ function NotFound() {
998
+ const { navigate } = useGroveUi();
999
+ return /* @__PURE__ */ jsxs4("div", {
1000
+ className: "flex flex-col gap-2 p-6",
1001
+ children: [
1002
+ /* @__PURE__ */ jsx5("p", {
1003
+ className: "text-sm text-neutral-500",
1004
+ children: "Not found."
1005
+ }),
1006
+ /* @__PURE__ */ jsx5("a", {
1007
+ href: "/",
1008
+ className: "w-fit text-sm underline",
1009
+ onClick: (e) => {
1010
+ e.preventDefault();
1011
+ navigate("/");
1012
+ },
1013
+ children: "Home"
1014
+ })
1015
+ ]
1016
+ });
1017
+ }
1018
+ function RouteIndex({ routes }) {
1019
+ const { navigate } = useGroveUi();
1020
+ const pages = routes.filter((r) => !r.path.split("/").some((seg) => seg.startsWith(":")));
1021
+ return /* @__PURE__ */ jsxs4("nav", {
1022
+ className: "flex flex-col gap-3 p-6",
1023
+ "aria-label": "Pages",
1024
+ children: [
1025
+ /* @__PURE__ */ jsx5("h1", {
1026
+ className: "text-2xl font-semibold",
1027
+ children: "Pages"
1028
+ }),
1029
+ /* @__PURE__ */ jsx5("ul", {
1030
+ className: "flex flex-col gap-1",
1031
+ children: pages.map((r) => /* @__PURE__ */ jsxs4("li", {
1032
+ children: [
1033
+ /* @__PURE__ */ jsx5("a", {
1034
+ href: r.path,
1035
+ className: "text-sm underline",
1036
+ onClick: (e) => {
1037
+ e.preventDefault();
1038
+ navigate(r.path);
1039
+ },
1040
+ children: r.title ?? r.path
1041
+ }),
1042
+ /* @__PURE__ */ jsx5("span", {
1043
+ className: "ml-2 text-xs text-neutral-500",
1044
+ children: r.path
1045
+ })
1046
+ ]
1047
+ }, r.path))
1048
+ })
1049
+ ]
1050
+ });
1051
+ }
1052
+
1053
+ // src/SignIn.tsx
1054
+ import { useCallback as useCallback4, useRef as useRef3, useState as useState5 } from "react";
1055
+ import { jsx as jsx6, jsxs as jsxs5, Fragment as Fragment2 } from "react/jsx-runtime";
1056
+ function SignIn(props) {
1057
+ const { auth, title, allowRegister = typeof auth.register === "function", onSignedIn, children } = props;
1058
+ const [signedIn, setSignedIn] = useState5(() => auth.getTokens() !== null);
1059
+ const [mode, setMode] = useState5("login");
1060
+ const [email, setEmail] = useState5("");
1061
+ const [password, setPassword] = useState5("");
1062
+ const [name, setName] = useState5("");
1063
+ const [code, setCode] = useState5("");
1064
+ const [error, setError] = useState5(null);
1065
+ const [busy, setBusy] = useState5(false);
1066
+ const submitting = useRef3(false);
1067
+ const finish = useCallback4((user) => {
1068
+ setSignedIn(true);
1069
+ onSignedIn?.(user);
1070
+ }, [onSignedIn]);
1071
+ const submit = useCallback4(async (e) => {
1072
+ e.preventDefault();
1073
+ if (submitting.current)
1074
+ return;
1075
+ submitting.current = true;
1076
+ setBusy(true);
1077
+ setError(null);
1078
+ try {
1079
+ if (mode === "mfa") {
1080
+ if (!auth.verifyMfa)
1081
+ throw new Error("This account requires a second factor, which this app does not support.");
1082
+ finish(await auth.verifyMfa(code));
1083
+ return;
1084
+ }
1085
+ if (mode === "register") {
1086
+ if (!auth.register)
1087
+ throw new Error("Registration is not available.");
1088
+ await auth.register(email, password, name.trim().length > 0 ? name.trim() : undefined);
1089
+ }
1090
+ const user = await auth.login(email, password);
1091
+ if (user["mfaRequired"] === true) {
1092
+ setMode("mfa");
1093
+ return;
1094
+ }
1095
+ finish(user);
1096
+ } catch (err) {
1097
+ setError(err instanceof Error ? err.message : String(err));
1098
+ } finally {
1099
+ submitting.current = false;
1100
+ setBusy(false);
1101
+ }
1102
+ }, [auth, code, email, finish, mode, name, password]);
1103
+ if (signedIn)
1104
+ return /* @__PURE__ */ jsx6(Fragment2, {
1105
+ children
1106
+ });
1107
+ const heading = mode === "register" ? "Create account" : mode === "mfa" ? "Two-factor code" : title ?? "Sign in";
1108
+ const label = "text-sm text-neutral-600 dark:text-neutral-400";
1109
+ return /* @__PURE__ */ jsx6("div", {
1110
+ className: "flex min-h-screen items-center justify-center p-6",
1111
+ children: /* @__PURE__ */ jsxs5("form", {
1112
+ onSubmit: submit,
1113
+ className: "flex w-full max-w-sm flex-col gap-3",
1114
+ "aria-busy": busy,
1115
+ children: [
1116
+ /* @__PURE__ */ jsx6("h1", {
1117
+ className: "text-2xl font-semibold",
1118
+ children: heading
1119
+ }),
1120
+ mode === "mfa" ? /* @__PURE__ */ jsxs5("label", {
1121
+ className: "flex flex-col gap-1",
1122
+ children: [
1123
+ /* @__PURE__ */ jsx6("span", {
1124
+ className: label,
1125
+ children: "Code from your authenticator app, or a backup code"
1126
+ }),
1127
+ /* @__PURE__ */ jsx6("input", {
1128
+ className: INPUT_CLASS,
1129
+ autoComplete: "one-time-code",
1130
+ inputMode: "numeric",
1131
+ required: true,
1132
+ autoFocus: true,
1133
+ value: code,
1134
+ onChange: (e) => setCode(e.target.value)
1135
+ })
1136
+ ]
1137
+ }) : /* @__PURE__ */ jsxs5(Fragment2, {
1138
+ children: [
1139
+ mode === "register" ? /* @__PURE__ */ jsxs5("label", {
1140
+ className: "flex flex-col gap-1",
1141
+ children: [
1142
+ /* @__PURE__ */ jsx6("span", {
1143
+ className: label,
1144
+ children: "Name"
1145
+ }),
1146
+ /* @__PURE__ */ jsx6("input", {
1147
+ className: INPUT_CLASS,
1148
+ autoComplete: "name",
1149
+ value: name,
1150
+ onChange: (e) => setName(e.target.value)
1151
+ })
1152
+ ]
1153
+ }) : null,
1154
+ /* @__PURE__ */ jsxs5("label", {
1155
+ className: "flex flex-col gap-1",
1156
+ children: [
1157
+ /* @__PURE__ */ jsx6("span", {
1158
+ className: label,
1159
+ children: "Email"
1160
+ }),
1161
+ /* @__PURE__ */ jsx6("input", {
1162
+ className: INPUT_CLASS,
1163
+ type: "email",
1164
+ autoComplete: "email",
1165
+ required: true,
1166
+ value: email,
1167
+ onChange: (e) => setEmail(e.target.value)
1168
+ })
1169
+ ]
1170
+ }),
1171
+ /* @__PURE__ */ jsxs5("label", {
1172
+ className: "flex flex-col gap-1",
1173
+ children: [
1174
+ /* @__PURE__ */ jsx6("span", {
1175
+ className: label,
1176
+ children: "Password"
1177
+ }),
1178
+ /* @__PURE__ */ jsx6("input", {
1179
+ className: INPUT_CLASS,
1180
+ type: "password",
1181
+ autoComplete: mode === "register" ? "new-password" : "current-password",
1182
+ required: true,
1183
+ value: password,
1184
+ onChange: (e) => setPassword(e.target.value)
1185
+ })
1186
+ ]
1187
+ })
1188
+ ]
1189
+ }),
1190
+ error ? /* @__PURE__ */ jsx6("p", {
1191
+ className: TONE.error,
1192
+ role: "alert",
1193
+ children: error
1194
+ }) : null,
1195
+ /* @__PURE__ */ jsxs5("div", {
1196
+ className: "flex items-center justify-between gap-3 pt-1",
1197
+ children: [
1198
+ /* @__PURE__ */ jsx6("button", {
1199
+ type: "submit",
1200
+ disabled: busy,
1201
+ className: cx(BUTTON_BASE, VARIANT.default),
1202
+ children: busy ? "Please wait…" : mode === "register" ? "Create account" : mode === "mfa" ? "Verify" : "Sign in"
1203
+ }),
1204
+ allowRegister && mode !== "mfa" ? /* @__PURE__ */ jsx6("button", {
1205
+ type: "button",
1206
+ className: cx(BUTTON_BASE, VARIANT.ghost),
1207
+ onClick: () => {
1208
+ setError(null);
1209
+ setMode(mode === "register" ? "login" : "register");
1210
+ },
1211
+ children: mode === "register" ? "Have an account? Sign in" : "Create an account"
1212
+ }) : null
1213
+ ]
1214
+ })
1215
+ ]
1216
+ })
1217
+ });
596
1218
  }
597
1219
  export {
598
- useGroveUi,
599
- useAction,
600
- resolveHref,
601
- formatValue,
602
- Text,
603
- Stack,
604
- RelationSelect,
605
- Heading,
606
- GroveUiProvider,
607
- Grid,
608
- Form,
609
- Divider,
610
- Detail,
1220
+ Button,
611
1221
  DataTable,
612
- Button
1222
+ Detail,
1223
+ Divider,
1224
+ Form,
1225
+ Grid,
1226
+ GroveRoutes,
1227
+ GroveUiProvider,
1228
+ Header,
1229
+ Heading,
1230
+ MarkdownField,
1231
+ RelationSelect,
1232
+ RouteIndex,
1233
+ SignIn,
1234
+ Stack,
1235
+ Text,
1236
+ detectLocale,
1237
+ formatValue,
1238
+ matchRoute,
1239
+ renderMarkdown,
1240
+ resolveHref,
1241
+ resolveRoute,
1242
+ useAction,
1243
+ useGroveUi,
1244
+ useMessages
613
1245
  };