@groveback/ui 0.2.0 → 0.4.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.d.ts +4 -2
- package/dist/index.js +340 -198
- package/dist/messages.d.ts +74 -0
- package/dist/routes.d.ts +15 -2
- package/dist/runtime.d.ts +37 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -15,10 +15,12 @@ import { MarkdownField, renderMarkdown } from './MarkdownField';
|
|
|
15
15
|
import { RelationSelect } from './RelationSelect';
|
|
16
16
|
import { GroveRoutes, RouteIndex, matchRoute, resolveRoute } from './routes';
|
|
17
17
|
import { SignIn } from './SignIn';
|
|
18
|
-
import {
|
|
19
|
-
|
|
18
|
+
import { uiMessagesFor } from './messages';
|
|
19
|
+
import { GroveUiProvider, detectLocale, formatValue, resolveHref, useAction, useGroveUi, useMessages, usePageTitle, useUiMessages, useUiText } from './runtime';
|
|
20
|
+
export { Button, DataTable, Detail, Divider, Form, Grid, GroveRoutes, GroveUiProvider, Header, Heading, MarkdownField, renderMarkdown, RelationSelect, RouteIndex, SignIn, Stack, Text, detectLocale, formatValue, matchRoute, resolveHref, resolveRoute, useAction, useGroveUi, useMessages, usePageTitle, uiMessagesFor, useUiMessages, useUiText, };
|
|
20
21
|
export type { RelationSelectProps } from './RelationSelect';
|
|
21
22
|
export type { GroveUiContext } from './runtime';
|
|
23
|
+
export type { UiMessageKey, UiMessages } from './messages';
|
|
22
24
|
export type { GroveRoute } from './routes';
|
|
23
25
|
export type { AuthLike, SignInProps } from './SignIn';
|
|
24
26
|
export type { Action, AnyCollection, ButtonVariant, CollectionLike, Doc, FieldRef, Format, RowAction } from './types';
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,257 @@
|
|
|
1
1
|
// src/components.tsx
|
|
2
|
-
import { useCallback as useCallback2, useEffect as
|
|
2
|
+
import { useCallback as useCallback2, useEffect as useEffect3, useRef as useRef2, useState as useState3 } from "react";
|
|
3
3
|
|
|
4
4
|
// src/MarkdownField.tsx
|
|
5
5
|
import { useState } from "react";
|
|
6
|
-
|
|
6
|
+
|
|
7
|
+
// src/runtime.tsx
|
|
8
|
+
import { createContext, useContext, useCallback, useEffect, useMemo } from "react";
|
|
9
|
+
|
|
10
|
+
// src/messages.ts
|
|
11
|
+
var EN = {
|
|
12
|
+
"common.loading": "Loading…",
|
|
13
|
+
"common.notFound": "Not found.",
|
|
14
|
+
"common.home": "Home",
|
|
15
|
+
"common.pages": "Pages",
|
|
16
|
+
"common.yes": "Yes",
|
|
17
|
+
"common.no": "No",
|
|
18
|
+
"header.openMenu": "Open menu",
|
|
19
|
+
"header.closeMenu": "Close menu",
|
|
20
|
+
"table.empty": "Nothing here yet.",
|
|
21
|
+
"form.save": "Save",
|
|
22
|
+
"form.saving": "Saving…",
|
|
23
|
+
"form.created": "Saved.",
|
|
24
|
+
"form.updated": "Changes saved.",
|
|
25
|
+
"action.confirmDelete": "Delete this item? This cannot be undone.",
|
|
26
|
+
"relation.select": "Select…",
|
|
27
|
+
"relation.search": "Search…",
|
|
28
|
+
"relation.noMatches": "No matches.",
|
|
29
|
+
"relation.none": "None",
|
|
30
|
+
"signIn.title": "Sign in",
|
|
31
|
+
"signIn.register": "Create account",
|
|
32
|
+
"signIn.mfa": "Two-factor code",
|
|
33
|
+
"signIn.name": "Name",
|
|
34
|
+
"signIn.email": "Email",
|
|
35
|
+
"signIn.password": "Password",
|
|
36
|
+
"signIn.code": "Code from your authenticator app, or a backup code",
|
|
37
|
+
"signIn.mfaUnsupported": "This account requires a second factor, which this app does not support.",
|
|
38
|
+
"signIn.submit": "Sign in",
|
|
39
|
+
"signIn.submitRegister": "Create account",
|
|
40
|
+
"signIn.submitMfa": "Verify",
|
|
41
|
+
"signIn.wait": "Please wait…",
|
|
42
|
+
"signIn.toRegister": "Create an account",
|
|
43
|
+
"signIn.toSignIn": "Have an account? Sign in",
|
|
44
|
+
"signIn.registerUnavailable": "Registration is not available.",
|
|
45
|
+
"markdown.bold": "Bold",
|
|
46
|
+
"markdown.italic": "Italic",
|
|
47
|
+
"markdown.heading": "Heading",
|
|
48
|
+
"markdown.listItem": "List item",
|
|
49
|
+
"markdown.code": "Code",
|
|
50
|
+
"markdown.link": "Link",
|
|
51
|
+
"markdown.placeholder": "Write Markdown…",
|
|
52
|
+
"markdown.write": "Write",
|
|
53
|
+
"markdown.preview": "Preview",
|
|
54
|
+
"markdown.nothingToPreview": "Nothing to preview"
|
|
55
|
+
};
|
|
56
|
+
var ES = {
|
|
57
|
+
"common.loading": "Cargando…",
|
|
58
|
+
"common.notFound": "No encontrado.",
|
|
59
|
+
"common.home": "Inicio",
|
|
60
|
+
"common.pages": "Páginas",
|
|
61
|
+
"common.yes": "Sí",
|
|
62
|
+
"common.no": "No",
|
|
63
|
+
"header.openMenu": "Abrir menú",
|
|
64
|
+
"header.closeMenu": "Cerrar menú",
|
|
65
|
+
"table.empty": "Todavía no hay nada.",
|
|
66
|
+
"form.save": "Guardar",
|
|
67
|
+
"form.saving": "Guardando…",
|
|
68
|
+
"form.created": "Guardado.",
|
|
69
|
+
"form.updated": "Cambios guardados.",
|
|
70
|
+
"action.confirmDelete": "¿Eliminar este elemento? No se puede deshacer.",
|
|
71
|
+
"relation.select": "Selecciona…",
|
|
72
|
+
"relation.search": "Buscar…",
|
|
73
|
+
"relation.noMatches": "Sin resultados.",
|
|
74
|
+
"relation.none": "Ninguno",
|
|
75
|
+
"signIn.title": "Iniciar sesión",
|
|
76
|
+
"signIn.register": "Crear cuenta",
|
|
77
|
+
"signIn.mfa": "Código de dos factores",
|
|
78
|
+
"signIn.name": "Nombre",
|
|
79
|
+
"signIn.email": "Correo",
|
|
80
|
+
"signIn.password": "Contraseña",
|
|
81
|
+
"signIn.code": "Código de tu app de autenticación, o un código de respaldo",
|
|
82
|
+
"signIn.mfaUnsupported": "Esta cuenta requiere un segundo factor, que esta app no admite.",
|
|
83
|
+
"signIn.submit": "Iniciar sesión",
|
|
84
|
+
"signIn.submitRegister": "Crear cuenta",
|
|
85
|
+
"signIn.submitMfa": "Verificar",
|
|
86
|
+
"signIn.wait": "Un momento…",
|
|
87
|
+
"signIn.toRegister": "Crear una cuenta",
|
|
88
|
+
"signIn.toSignIn": "¿Ya tienes cuenta? Inicia sesión",
|
|
89
|
+
"signIn.registerUnavailable": "El registro no está disponible.",
|
|
90
|
+
"markdown.bold": "Negrita",
|
|
91
|
+
"markdown.italic": "Cursiva",
|
|
92
|
+
"markdown.heading": "Encabezado",
|
|
93
|
+
"markdown.listItem": "Elemento de lista",
|
|
94
|
+
"markdown.code": "Código",
|
|
95
|
+
"markdown.link": "Enlace",
|
|
96
|
+
"markdown.placeholder": "Escribe en Markdown…",
|
|
97
|
+
"markdown.write": "Escribir",
|
|
98
|
+
"markdown.preview": "Vista previa",
|
|
99
|
+
"markdown.nothingToPreview": "Nada que previsualizar"
|
|
100
|
+
};
|
|
101
|
+
var UI_LOCALES = { en: EN, es: ES };
|
|
102
|
+
function uiMessagesFor(locale) {
|
|
103
|
+
if (!locale)
|
|
104
|
+
return EN;
|
|
105
|
+
const exact = UI_LOCALES[locale];
|
|
106
|
+
if (exact)
|
|
107
|
+
return exact;
|
|
108
|
+
const lang = locale.split("-")[0]?.toLowerCase();
|
|
109
|
+
return (lang ? UI_LOCALES[lang] : undefined) ?? EN;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// src/runtime.tsx
|
|
113
|
+
import { jsx } from "react/jsx-runtime";
|
|
114
|
+
var Ctx = createContext(null);
|
|
115
|
+
function GroveUiProvider({ value, children }) {
|
|
116
|
+
return /* @__PURE__ */ jsx(Ctx.Provider, {
|
|
117
|
+
value,
|
|
118
|
+
children
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
var DEFAULT_CONTEXT = {
|
|
122
|
+
navigate: (href) => {
|
|
123
|
+
window.history.pushState({}, "", href);
|
|
124
|
+
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
function useGroveUi() {
|
|
128
|
+
return useContext(Ctx) ?? DEFAULT_CONTEXT;
|
|
129
|
+
}
|
|
130
|
+
function uiText(ctx, key) {
|
|
131
|
+
return ctx.messages?.[key] ?? uiMessagesFor(ctx.locale)[key];
|
|
132
|
+
}
|
|
133
|
+
function useUiText() {
|
|
134
|
+
const ctx = useGroveUi();
|
|
135
|
+
return useCallback((key) => uiText(ctx, key), [ctx]);
|
|
136
|
+
}
|
|
137
|
+
function useUiMessages() {
|
|
138
|
+
const ctx = useGroveUi();
|
|
139
|
+
return useMemo(() => ({ ...uiMessagesFor(ctx.locale), ...ctx.messages }), [ctx]);
|
|
140
|
+
}
|
|
141
|
+
function resolveHref(href, doc) {
|
|
142
|
+
if (!doc)
|
|
143
|
+
return href;
|
|
144
|
+
return href.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, (whole, name) => {
|
|
145
|
+
const value = doc[name];
|
|
146
|
+
return value === undefined || value === null ? whole : String(value);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
function useAction() {
|
|
150
|
+
const ctx = useGroveUi();
|
|
151
|
+
return useCallback(async (action, opts = {}) => {
|
|
152
|
+
switch (action.kind) {
|
|
153
|
+
case "navigate":
|
|
154
|
+
ctx.navigate(resolveHref(action.href, opts.doc));
|
|
155
|
+
return;
|
|
156
|
+
case "delete": {
|
|
157
|
+
const id = opts.doc?.["id"];
|
|
158
|
+
if (typeof id !== "string" || !opts.collection)
|
|
159
|
+
return;
|
|
160
|
+
const ask = ctx.confirm ?? ((m) => window.confirm(m));
|
|
161
|
+
const message = action.confirm ?? uiText(ctx, "action.confirmDelete");
|
|
162
|
+
if (!await ask(message))
|
|
163
|
+
return;
|
|
164
|
+
await opts.collection.delete(id);
|
|
165
|
+
opts.onDone?.();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
case "endpoint": {
|
|
169
|
+
const href = resolveHref(action.path, opts.doc);
|
|
170
|
+
const method = action.method ?? "POST";
|
|
171
|
+
if (ctx.callEndpoint)
|
|
172
|
+
await ctx.callEndpoint(href, method);
|
|
173
|
+
else
|
|
174
|
+
await fetch(`${ctx.endpointBase ?? ""}${href}`, { method });
|
|
175
|
+
opts.onDone?.();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}, [ctx]);
|
|
180
|
+
}
|
|
181
|
+
var MARKDOWN_CELL_MAX = 80;
|
|
182
|
+
function formatValue(value, format, opts = {}) {
|
|
183
|
+
if (value === null || value === undefined)
|
|
184
|
+
return "";
|
|
185
|
+
switch (format) {
|
|
186
|
+
case "boolean":
|
|
187
|
+
return value ? opts.messages?.["common.yes"] ?? "Yes" : opts.messages?.["common.no"] ?? "No";
|
|
188
|
+
case "currency": {
|
|
189
|
+
const n = Number(value);
|
|
190
|
+
if (!Number.isFinite(n))
|
|
191
|
+
return String(value);
|
|
192
|
+
return new Intl.NumberFormat(opts.locale, {
|
|
193
|
+
style: "currency",
|
|
194
|
+
currency: opts.currency ?? "USD"
|
|
195
|
+
}).format(n);
|
|
196
|
+
}
|
|
197
|
+
case "number": {
|
|
198
|
+
const n = Number(value);
|
|
199
|
+
return Number.isFinite(n) ? new Intl.NumberFormat(opts.locale).format(n) : String(value);
|
|
200
|
+
}
|
|
201
|
+
case "date": {
|
|
202
|
+
const text = String(value);
|
|
203
|
+
const day = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
|
|
204
|
+
const d = day ? new Date(Number(day[1]), Number(day[2]) - 1, Number(day[3])) : new Date(text);
|
|
205
|
+
return Number.isNaN(d.getTime()) ? text : d.toLocaleDateString(opts.locale);
|
|
206
|
+
}
|
|
207
|
+
case "markdown": {
|
|
208
|
+
const text = String(value).replace(/\s+/g, " ").trim();
|
|
209
|
+
if (text.length <= MARKDOWN_CELL_MAX)
|
|
210
|
+
return text;
|
|
211
|
+
const cut = text.slice(0, MARKDOWN_CELL_MAX);
|
|
212
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
213
|
+
return `${(lastSpace > MARKDOWN_CELL_MAX / 2 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
|
|
214
|
+
}
|
|
215
|
+
default:
|
|
216
|
+
return typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function useMessages(messages, fallback) {
|
|
220
|
+
const { locale } = useGroveUi();
|
|
221
|
+
return useCallback((key) => {
|
|
222
|
+
const entry = messages[key];
|
|
223
|
+
if (!entry)
|
|
224
|
+
return key;
|
|
225
|
+
return (locale !== undefined ? entry[locale] : undefined) ?? entry[fallback] ?? key;
|
|
226
|
+
}, [messages, locale, fallback]);
|
|
227
|
+
}
|
|
228
|
+
function usePageTitle(title) {
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
if (typeof document === "undefined" || !title)
|
|
231
|
+
return;
|
|
232
|
+
const previous = document.title;
|
|
233
|
+
document.title = title;
|
|
234
|
+
return () => {
|
|
235
|
+
document.title = previous;
|
|
236
|
+
};
|
|
237
|
+
}, [title]);
|
|
238
|
+
}
|
|
239
|
+
function detectLocale(locales, preferred = typeof navigator !== "undefined" ? navigator.languages : []) {
|
|
240
|
+
const first = locales[0] ?? "en";
|
|
241
|
+
for (const want of preferred) {
|
|
242
|
+
const exact = locales.find((l) => l.toLowerCase() === want.toLowerCase());
|
|
243
|
+
if (exact)
|
|
244
|
+
return exact;
|
|
245
|
+
const lang = want.split("-")[0]?.toLowerCase();
|
|
246
|
+
const loose = locales.find((l) => l.split("-")[0]?.toLowerCase() === lang);
|
|
247
|
+
if (loose)
|
|
248
|
+
return loose;
|
|
249
|
+
}
|
|
250
|
+
return first;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// src/MarkdownField.tsx
|
|
254
|
+
import { jsx as jsx2, jsxs } from "react/jsx-runtime";
|
|
7
255
|
function escapeHtml(s) {
|
|
8
256
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
9
257
|
}
|
|
@@ -46,12 +294,12 @@ function renderMarkdown(src) {
|
|
|
46
294
|
`);
|
|
47
295
|
}
|
|
48
296
|
var TOOLS = [
|
|
49
|
-
{ key: "b", label: "
|
|
50
|
-
{ key: "i", label: "
|
|
51
|
-
{ key: "h", label: "
|
|
52
|
-
{ key: "ul", label: "
|
|
53
|
-
{ key: "code", label: "
|
|
54
|
-
{ key: "a", label: "
|
|
297
|
+
{ key: "b", label: "markdown.bold", glyph: "B", wrap: { before: "**", after: "**", placeholder: "bold" } },
|
|
298
|
+
{ key: "i", label: "markdown.italic", glyph: "I", wrap: { before: "*", after: "*", placeholder: "italic" } },
|
|
299
|
+
{ key: "h", label: "markdown.heading", glyph: "H", wrap: { before: "## ", after: "", placeholder: "Heading" } },
|
|
300
|
+
{ key: "ul", label: "markdown.listItem", glyph: "•", wrap: { before: "- ", after: "", placeholder: "item" } },
|
|
301
|
+
{ key: "code", label: "markdown.code", glyph: "</>", wrap: { before: "`", after: "`", placeholder: "code" } },
|
|
302
|
+
{ key: "a", label: "markdown.link", glyph: "\uD83D\uDD17", wrap: { before: "[", after: "](https://)", placeholder: "text" } }
|
|
55
303
|
];
|
|
56
304
|
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
305
|
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";
|
|
@@ -59,6 +307,7 @@ function MarkdownField({
|
|
|
59
307
|
value,
|
|
60
308
|
onChange
|
|
61
309
|
}) {
|
|
310
|
+
const t = useUiText();
|
|
62
311
|
const [mode, setMode] = useState("write");
|
|
63
312
|
const [el, setEl] = useState(null);
|
|
64
313
|
function apply(wrap) {
|
|
@@ -81,53 +330,53 @@ function MarkdownField({
|
|
|
81
330
|
/* @__PURE__ */ jsxs("div", {
|
|
82
331
|
className: "flex items-center gap-0.5 border-b border-neutral-200 px-1 py-1 dark:border-neutral-800",
|
|
83
332
|
children: [
|
|
84
|
-
TOOLS.map((
|
|
333
|
+
TOOLS.map((tool) => /* @__PURE__ */ jsx2("button", {
|
|
85
334
|
type: "button",
|
|
86
|
-
title: t.label,
|
|
87
|
-
"aria-label": t.label,
|
|
335
|
+
title: t(tool.label),
|
|
336
|
+
"aria-label": t(tool.label),
|
|
88
337
|
disabled: mode === "preview",
|
|
89
|
-
onClick: () => apply(
|
|
338
|
+
onClick: () => apply(tool.wrap),
|
|
90
339
|
className: TOOL_BUTTON,
|
|
91
|
-
children:
|
|
92
|
-
},
|
|
340
|
+
children: tool.glyph
|
|
341
|
+
}, tool.key)),
|
|
93
342
|
/* @__PURE__ */ jsxs("div", {
|
|
94
343
|
className: "ml-auto flex gap-0.5",
|
|
95
344
|
children: [
|
|
96
|
-
/* @__PURE__ */
|
|
345
|
+
/* @__PURE__ */ jsx2("button", {
|
|
97
346
|
type: "button",
|
|
98
347
|
onClick: () => setMode("write"),
|
|
99
348
|
className: tab(mode === "write"),
|
|
100
|
-
children: "
|
|
349
|
+
children: t("markdown.write")
|
|
101
350
|
}),
|
|
102
|
-
/* @__PURE__ */
|
|
351
|
+
/* @__PURE__ */ jsx2("button", {
|
|
103
352
|
type: "button",
|
|
104
353
|
onClick: () => setMode("preview"),
|
|
105
354
|
className: tab(mode === "preview"),
|
|
106
|
-
children: "
|
|
355
|
+
children: t("markdown.preview")
|
|
107
356
|
})
|
|
108
357
|
]
|
|
109
358
|
})
|
|
110
359
|
]
|
|
111
360
|
}),
|
|
112
|
-
mode === "write" ? /* @__PURE__ */
|
|
361
|
+
mode === "write" ? /* @__PURE__ */ jsx2("textarea", {
|
|
113
362
|
ref: setEl,
|
|
114
363
|
rows: 8,
|
|
115
364
|
value,
|
|
116
365
|
spellCheck: false,
|
|
117
|
-
placeholder: "
|
|
366
|
+
placeholder: t("markdown.placeholder"),
|
|
118
367
|
onChange: (e) => onChange(e.target.value),
|
|
119
368
|
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__ */
|
|
369
|
+
}) : /* @__PURE__ */ jsx2("div", {
|
|
121
370
|
className: PREVIEW_TYPOGRAPHY,
|
|
122
|
-
dangerouslySetInnerHTML: { __html: renderMarkdown(value || "
|
|
371
|
+
dangerouslySetInnerHTML: { __html: renderMarkdown(value || `_${t("markdown.nothingToPreview")}_`) }
|
|
123
372
|
})
|
|
124
373
|
]
|
|
125
374
|
});
|
|
126
375
|
}
|
|
127
376
|
|
|
128
377
|
// src/RelationSelect.tsx
|
|
129
|
-
import { useEffect, useMemo, useRef, useState as useState2 } from "react";
|
|
130
|
-
import { jsx as
|
|
378
|
+
import { useEffect as useEffect2, useMemo as useMemo2, useRef, useState as useState2 } from "react";
|
|
379
|
+
import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
131
380
|
var PAGE = 200;
|
|
132
381
|
function labelOf(doc, field) {
|
|
133
382
|
const raw = doc[field];
|
|
@@ -139,13 +388,14 @@ function labelOf(doc, field) {
|
|
|
139
388
|
}
|
|
140
389
|
function RelationSelect(props) {
|
|
141
390
|
const { collection, displayField, value, onChange, placeholder, required } = props;
|
|
391
|
+
const t = useUiText();
|
|
142
392
|
const [docs, setDocs] = useState2(null);
|
|
143
393
|
const [error, setError] = useState2(null);
|
|
144
394
|
const [query, setQuery] = useState2("");
|
|
145
395
|
const [open, setOpen] = useState2(false);
|
|
146
396
|
const [truncated, setTruncated] = useState2(false);
|
|
147
397
|
const boxRef = useRef(null);
|
|
148
|
-
|
|
398
|
+
useEffect2(() => {
|
|
149
399
|
let live = true;
|
|
150
400
|
collection.find({ limit: PAGE }).then((found) => {
|
|
151
401
|
if (!live)
|
|
@@ -160,7 +410,7 @@ function RelationSelect(props) {
|
|
|
160
410
|
live = false;
|
|
161
411
|
};
|
|
162
412
|
}, [collection]);
|
|
163
|
-
|
|
413
|
+
useEffect2(() => {
|
|
164
414
|
if (!open)
|
|
165
415
|
return;
|
|
166
416
|
const onDown = (e) => {
|
|
@@ -170,8 +420,8 @@ function RelationSelect(props) {
|
|
|
170
420
|
document.addEventListener("mousedown", onDown);
|
|
171
421
|
return () => document.removeEventListener("mousedown", onDown);
|
|
172
422
|
}, [open]);
|
|
173
|
-
const selected =
|
|
174
|
-
const matches =
|
|
423
|
+
const selected = useMemo2(() => (docs ?? []).find((d) => d["id"] === value) ?? null, [docs, value]);
|
|
424
|
+
const matches = useMemo2(() => {
|
|
175
425
|
const all = docs ?? [];
|
|
176
426
|
const q = query.trim().toLowerCase();
|
|
177
427
|
if (q === "")
|
|
@@ -179,12 +429,12 @@ function RelationSelect(props) {
|
|
|
179
429
|
return all.filter((d) => labelOf(d, displayField).toLowerCase().includes(q));
|
|
180
430
|
}, [docs, displayField, query]);
|
|
181
431
|
if (error) {
|
|
182
|
-
return /* @__PURE__ */
|
|
432
|
+
return /* @__PURE__ */ jsx3("p", {
|
|
183
433
|
className: "text-xs text-red-600",
|
|
184
434
|
children: error
|
|
185
435
|
});
|
|
186
436
|
}
|
|
187
|
-
const buttonLabel = selected ? labelOf(selected, displayField) : value !== "" ? value : placeholder ?? "
|
|
437
|
+
const buttonLabel = selected ? labelOf(selected, displayField) : value !== "" ? value : placeholder ?? t("relation.select");
|
|
188
438
|
return /* @__PURE__ */ jsxs2("div", {
|
|
189
439
|
ref: boxRef,
|
|
190
440
|
className: "relative",
|
|
@@ -194,11 +444,11 @@ function RelationSelect(props) {
|
|
|
194
444
|
onClick: () => setOpen((v) => !v),
|
|
195
445
|
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",
|
|
196
446
|
children: [
|
|
197
|
-
/* @__PURE__ */
|
|
447
|
+
/* @__PURE__ */ jsx3("span", {
|
|
198
448
|
className: selected || value ? "" : "text-neutral-400",
|
|
199
|
-
children: docs === null ? "
|
|
449
|
+
children: docs === null ? t("common.loading") : buttonLabel
|
|
200
450
|
}),
|
|
201
|
-
/* @__PURE__ */
|
|
451
|
+
/* @__PURE__ */ jsx3("span", {
|
|
202
452
|
"aria-hidden": true,
|
|
203
453
|
className: "ml-2 text-neutral-400",
|
|
204
454
|
children: "▾"
|
|
@@ -208,18 +458,18 @@ function RelationSelect(props) {
|
|
|
208
458
|
open ? /* @__PURE__ */ jsxs2("div", {
|
|
209
459
|
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",
|
|
210
460
|
children: [
|
|
211
|
-
/* @__PURE__ */
|
|
461
|
+
/* @__PURE__ */ jsx3("input", {
|
|
212
462
|
autoFocus: true,
|
|
213
463
|
value: query,
|
|
214
464
|
onChange: (e) => setQuery(e.target.value),
|
|
215
|
-
placeholder: "
|
|
465
|
+
placeholder: t("relation.search"),
|
|
216
466
|
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"
|
|
217
467
|
}),
|
|
218
468
|
/* @__PURE__ */ jsxs2("ul", {
|
|
219
469
|
className: "max-h-56 overflow-auto py-1",
|
|
220
470
|
children: [
|
|
221
|
-
!required ? /* @__PURE__ */
|
|
222
|
-
children: /* @__PURE__ */
|
|
471
|
+
!required ? /* @__PURE__ */ jsx3("li", {
|
|
472
|
+
children: /* @__PURE__ */ jsx3("button", {
|
|
223
473
|
type: "button",
|
|
224
474
|
onClick: () => {
|
|
225
475
|
onChange("");
|
|
@@ -227,16 +477,16 @@ function RelationSelect(props) {
|
|
|
227
477
|
setQuery("");
|
|
228
478
|
},
|
|
229
479
|
className: "w-full px-3 py-1.5 text-left text-sm text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-900",
|
|
230
|
-
children: "
|
|
480
|
+
children: t("relation.none")
|
|
231
481
|
})
|
|
232
482
|
}) : null,
|
|
233
|
-
matches.length === 0 ? /* @__PURE__ */
|
|
483
|
+
matches.length === 0 ? /* @__PURE__ */ jsx3("li", {
|
|
234
484
|
className: "px-3 py-2 text-xs text-neutral-500",
|
|
235
|
-
children: "
|
|
485
|
+
children: t("relation.noMatches")
|
|
236
486
|
}) : matches.map((doc) => {
|
|
237
487
|
const id = typeof doc["id"] === "string" ? doc["id"] : "";
|
|
238
|
-
return /* @__PURE__ */
|
|
239
|
-
children: /* @__PURE__ */
|
|
488
|
+
return /* @__PURE__ */ jsx3("li", {
|
|
489
|
+
children: /* @__PURE__ */ jsx3("button", {
|
|
240
490
|
type: "button",
|
|
241
491
|
onClick: () => {
|
|
242
492
|
onChange(id);
|
|
@@ -264,126 +514,6 @@ function RelationSelect(props) {
|
|
|
264
514
|
});
|
|
265
515
|
}
|
|
266
516
|
|
|
267
|
-
// src/runtime.tsx
|
|
268
|
-
import { createContext, useContext, useCallback } from "react";
|
|
269
|
-
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
270
|
-
var Ctx = createContext(null);
|
|
271
|
-
function GroveUiProvider({ value, children }) {
|
|
272
|
-
return /* @__PURE__ */ jsx3(Ctx.Provider, {
|
|
273
|
-
value,
|
|
274
|
-
children
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
var DEFAULT_CONTEXT = {
|
|
278
|
-
navigate: (href) => {
|
|
279
|
-
window.history.pushState({}, "", href);
|
|
280
|
-
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
281
|
-
}
|
|
282
|
-
};
|
|
283
|
-
function useGroveUi() {
|
|
284
|
-
return useContext(Ctx) ?? DEFAULT_CONTEXT;
|
|
285
|
-
}
|
|
286
|
-
function resolveHref(href, doc) {
|
|
287
|
-
if (!doc)
|
|
288
|
-
return href;
|
|
289
|
-
return href.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, (whole, name) => {
|
|
290
|
-
const value = doc[name];
|
|
291
|
-
return value === undefined || value === null ? whole : String(value);
|
|
292
|
-
});
|
|
293
|
-
}
|
|
294
|
-
function useAction() {
|
|
295
|
-
const ctx = useGroveUi();
|
|
296
|
-
return useCallback(async (action, opts = {}) => {
|
|
297
|
-
switch (action.kind) {
|
|
298
|
-
case "navigate":
|
|
299
|
-
ctx.navigate(resolveHref(action.href, opts.doc));
|
|
300
|
-
return;
|
|
301
|
-
case "delete": {
|
|
302
|
-
const id = opts.doc?.["id"];
|
|
303
|
-
if (typeof id !== "string" || !opts.collection)
|
|
304
|
-
return;
|
|
305
|
-
const ask = ctx.confirm ?? ((m) => window.confirm(m));
|
|
306
|
-
const message = action.confirm ?? "Delete this item? This cannot be undone.";
|
|
307
|
-
if (!await ask(message))
|
|
308
|
-
return;
|
|
309
|
-
await opts.collection.delete(id);
|
|
310
|
-
opts.onDone?.();
|
|
311
|
-
return;
|
|
312
|
-
}
|
|
313
|
-
case "endpoint": {
|
|
314
|
-
const href = resolveHref(action.path, opts.doc);
|
|
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 });
|
|
320
|
-
opts.onDone?.();
|
|
321
|
-
return;
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
}, [ctx]);
|
|
325
|
-
}
|
|
326
|
-
var MARKDOWN_CELL_MAX = 80;
|
|
327
|
-
function formatValue(value, format, opts = {}) {
|
|
328
|
-
if (value === null || value === undefined)
|
|
329
|
-
return "";
|
|
330
|
-
switch (format) {
|
|
331
|
-
case "boolean":
|
|
332
|
-
return value ? "Yes" : "No";
|
|
333
|
-
case "currency": {
|
|
334
|
-
const n = Number(value);
|
|
335
|
-
if (!Number.isFinite(n))
|
|
336
|
-
return String(value);
|
|
337
|
-
return new Intl.NumberFormat(opts.locale, {
|
|
338
|
-
style: "currency",
|
|
339
|
-
currency: opts.currency ?? "USD"
|
|
340
|
-
}).format(n);
|
|
341
|
-
}
|
|
342
|
-
case "number": {
|
|
343
|
-
const n = Number(value);
|
|
344
|
-
return Number.isFinite(n) ? new Intl.NumberFormat(opts.locale).format(n) : String(value);
|
|
345
|
-
}
|
|
346
|
-
case "date": {
|
|
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()}…`;
|
|
359
|
-
}
|
|
360
|
-
default:
|
|
361
|
-
return typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
362
|
-
}
|
|
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
517
|
// src/styles.ts
|
|
388
518
|
function cx(...parts) {
|
|
389
519
|
return parts.filter(Boolean).join(" ");
|
|
@@ -447,6 +577,7 @@ function Text({ muted, children }) {
|
|
|
447
577
|
function Header(props) {
|
|
448
578
|
const { brand, brandHref = "/", links, actions, sticky } = props;
|
|
449
579
|
const dispatch = useAction();
|
|
580
|
+
const t = useUiText();
|
|
450
581
|
const [open, setOpen] = useState3(false);
|
|
451
582
|
const go = useCallback2((href) => (e) => {
|
|
452
583
|
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0)
|
|
@@ -490,7 +621,7 @@ function Header(props) {
|
|
|
490
621
|
hasNav ? /* @__PURE__ */ jsx4("button", {
|
|
491
622
|
type: "button",
|
|
492
623
|
"data-grove-nav-toggle": true,
|
|
493
|
-
"aria-label": open ? "
|
|
624
|
+
"aria-label": t(open ? "header.closeMenu" : "header.openMenu"),
|
|
494
625
|
"aria-expanded": open,
|
|
495
626
|
onClick: () => setOpen((v) => !v),
|
|
496
627
|
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",
|
|
@@ -595,7 +726,7 @@ function useAsync(load, deps) {
|
|
|
595
726
|
const [error, setError] = useState3(null);
|
|
596
727
|
const [loading, setLoading] = useState3(true);
|
|
597
728
|
const [nonce, setNonce] = useState3(0);
|
|
598
|
-
|
|
729
|
+
useEffect3(() => {
|
|
599
730
|
let live = true;
|
|
600
731
|
setLoading(true);
|
|
601
732
|
setError(null);
|
|
@@ -626,7 +757,7 @@ function useRelations(fields, docs, resolveCollection) {
|
|
|
626
757
|
const [map, setMap] = useState3({});
|
|
627
758
|
const relations = fields.filter((f) => f.display);
|
|
628
759
|
const key = JSON.stringify(relations.map((f) => f.field)) + (docs?.length ?? 0);
|
|
629
|
-
|
|
760
|
+
useEffect3(() => {
|
|
630
761
|
if (!docs || relations.length === 0 || !resolveCollection)
|
|
631
762
|
return;
|
|
632
763
|
let live = true;
|
|
@@ -650,25 +781,26 @@ function useRelations(fields, docs, resolveCollection) {
|
|
|
650
781
|
}, [key, resolveCollection]);
|
|
651
782
|
return map;
|
|
652
783
|
}
|
|
653
|
-
function cellValue(doc, field, relations, locale, currency) {
|
|
784
|
+
function cellValue(doc, field, relations, locale, currency, messages) {
|
|
654
785
|
const raw = doc[field.field];
|
|
655
786
|
if (field.display) {
|
|
656
787
|
const id = typeof raw === "string" ? raw : "";
|
|
657
788
|
const referenced = relations[field.field]?.[id];
|
|
658
789
|
if (referenced)
|
|
659
|
-
return formatValue(referenced[field.display.field], "text", { locale, currency });
|
|
790
|
+
return formatValue(referenced[field.display.field], "text", { locale, currency, messages });
|
|
660
791
|
}
|
|
661
|
-
return formatValue(raw, field.format, { locale, currency });
|
|
792
|
+
return formatValue(raw, field.format, { locale, currency, messages });
|
|
662
793
|
}
|
|
663
794
|
function DataTable(props) {
|
|
664
795
|
const { collection, options, columns, rowHref, rowActions, emptyText, resolveRelation } = props;
|
|
665
796
|
const ctx = useGroveUi();
|
|
797
|
+
const messages = useUiMessages();
|
|
666
798
|
const { data, error, loading, reload } = useAsync(() => collection.find(options), [JSON.stringify(options)]);
|
|
667
799
|
const relations = useRelations(columns, data, resolveRelation);
|
|
668
800
|
if (loading)
|
|
669
801
|
return /* @__PURE__ */ jsx4(Message, {
|
|
670
802
|
tone: "muted",
|
|
671
|
-
children: "
|
|
803
|
+
children: messages["common.loading"]
|
|
672
804
|
});
|
|
673
805
|
if (error)
|
|
674
806
|
return /* @__PURE__ */ jsx4(Message, {
|
|
@@ -678,7 +810,7 @@ function DataTable(props) {
|
|
|
678
810
|
if (!data || data.length === 0)
|
|
679
811
|
return /* @__PURE__ */ jsx4(Message, {
|
|
680
812
|
tone: "muted",
|
|
681
|
-
children: emptyText ?? "
|
|
813
|
+
children: emptyText ?? messages["table.empty"]
|
|
682
814
|
});
|
|
683
815
|
return /* @__PURE__ */ jsx4("div", {
|
|
684
816
|
className: "w-full overflow-x-auto rounded-md border border-neutral-200 dark:border-neutral-800",
|
|
@@ -706,7 +838,7 @@ function DataTable(props) {
|
|
|
706
838
|
children: [
|
|
707
839
|
columns.map((c) => /* @__PURE__ */ jsx4("td", {
|
|
708
840
|
className: "px-3 py-2",
|
|
709
|
-
children: cellValue(doc, c, relations, ctx.locale, ctx.currency)
|
|
841
|
+
children: cellValue(doc, c, relations, ctx.locale, ctx.currency, messages)
|
|
710
842
|
}, c.field)),
|
|
711
843
|
rowActions && rowActions.length > 0 ? /* @__PURE__ */ jsx4("td", {
|
|
712
844
|
className: "px-3 py-2",
|
|
@@ -733,12 +865,13 @@ function DataTable(props) {
|
|
|
733
865
|
function Detail(props) {
|
|
734
866
|
const { collection, id, fields, resolveRelation } = props;
|
|
735
867
|
const ctx = useGroveUi();
|
|
868
|
+
const messages = useUiMessages();
|
|
736
869
|
const { data, error, loading } = useAsync(() => collection.get(id), [id]);
|
|
737
870
|
const relations = useRelations(fields, data ? [data] : null, resolveRelation);
|
|
738
871
|
if (loading)
|
|
739
872
|
return /* @__PURE__ */ jsx4(Message, {
|
|
740
873
|
tone: "muted",
|
|
741
|
-
children: "
|
|
874
|
+
children: messages["common.loading"]
|
|
742
875
|
});
|
|
743
876
|
if (error)
|
|
744
877
|
return /* @__PURE__ */ jsx4(Message, {
|
|
@@ -748,7 +881,7 @@ function Detail(props) {
|
|
|
748
881
|
if (!data)
|
|
749
882
|
return /* @__PURE__ */ jsx4(Message, {
|
|
750
883
|
tone: "muted",
|
|
751
|
-
children: "
|
|
884
|
+
children: messages["common.notFound"]
|
|
752
885
|
});
|
|
753
886
|
return /* @__PURE__ */ jsx4("dl", {
|
|
754
887
|
className: "divide-y divide-neutral-200 rounded-md border border-neutral-200 dark:divide-neutral-800 dark:border-neutral-800",
|
|
@@ -764,7 +897,7 @@ function Detail(props) {
|
|
|
764
897
|
children: String(data[f.field] ?? "")
|
|
765
898
|
}) : /* @__PURE__ */ jsx4("dd", {
|
|
766
899
|
className: "col-span-2 text-sm",
|
|
767
|
-
children: cellValue(data, f, relations, ctx.locale, ctx.currency)
|
|
900
|
+
children: cellValue(data, f, relations, ctx.locale, ctx.currency, messages)
|
|
768
901
|
})
|
|
769
902
|
]
|
|
770
903
|
}, f.field))
|
|
@@ -795,13 +928,14 @@ function coerce(raw, format) {
|
|
|
795
928
|
function Form(props) {
|
|
796
929
|
const { collection, mode, id, fields, submitLabel, onSuccess, resolveRelation } = props;
|
|
797
930
|
const dispatch = useAction();
|
|
931
|
+
const t = useUiText();
|
|
798
932
|
const [values, setValues] = useState3({});
|
|
799
933
|
const [error, setError] = useState3(null);
|
|
800
934
|
const [busy, setBusy] = useState3(false);
|
|
801
935
|
const [done, setDone] = useState3(false);
|
|
802
936
|
const submitting = useRef2(false);
|
|
803
937
|
const [loading, setLoading] = useState3(mode === "update");
|
|
804
|
-
|
|
938
|
+
useEffect3(() => {
|
|
805
939
|
if (mode !== "update" || !id)
|
|
806
940
|
return;
|
|
807
941
|
let live = true;
|
|
@@ -865,7 +999,7 @@ function Form(props) {
|
|
|
865
999
|
if (loading)
|
|
866
1000
|
return /* @__PURE__ */ jsx4(Message, {
|
|
867
1001
|
tone: "muted",
|
|
868
|
-
children: "
|
|
1002
|
+
children: t("common.loading")
|
|
869
1003
|
});
|
|
870
1004
|
return /* @__PURE__ */ jsxs3("form", {
|
|
871
1005
|
onSubmit: submit,
|
|
@@ -919,14 +1053,14 @@ function Form(props) {
|
|
|
919
1053
|
}) : null,
|
|
920
1054
|
done && !error ? /* @__PURE__ */ jsx4(Message, {
|
|
921
1055
|
tone: "success",
|
|
922
|
-
children: mode === "create" ? "
|
|
1056
|
+
children: t(mode === "create" ? "form.created" : "form.updated")
|
|
923
1057
|
}) : null,
|
|
924
1058
|
/* @__PURE__ */ jsx4("div", {
|
|
925
1059
|
children: /* @__PURE__ */ jsx4("button", {
|
|
926
1060
|
type: "submit",
|
|
927
1061
|
disabled: busy,
|
|
928
1062
|
className: cx(BUTTON_BASE, VARIANT.default),
|
|
929
|
-
children: busy ? "
|
|
1063
|
+
children: busy ? t("form.saving") : submitLabel ?? t("form.save")
|
|
930
1064
|
})
|
|
931
1065
|
})
|
|
932
1066
|
]
|
|
@@ -934,7 +1068,7 @@ function Form(props) {
|
|
|
934
1068
|
}
|
|
935
1069
|
|
|
936
1070
|
// src/routes.tsx
|
|
937
|
-
import { useCallback as useCallback3, useEffect as
|
|
1071
|
+
import { useCallback as useCallback3, useEffect as useEffect4, useMemo as useMemo3, useState as useState4 } from "react";
|
|
938
1072
|
import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
939
1073
|
function matchRoute(path, pattern) {
|
|
940
1074
|
const clean = (path.split(/[?#]/)[0] ?? "").replace(/\/+$/, "") || "/";
|
|
@@ -969,9 +1103,9 @@ function resolveRoute(routes, path) {
|
|
|
969
1103
|
return null;
|
|
970
1104
|
}
|
|
971
1105
|
function GroveRoutes(props) {
|
|
972
|
-
const { routes, context, fallback, children } = props;
|
|
1106
|
+
const { routes, context, fallback, children, gate } = props;
|
|
973
1107
|
const [path, setPath] = useState4(() => window.location.pathname);
|
|
974
|
-
|
|
1108
|
+
useEffect4(() => {
|
|
975
1109
|
const onPop = () => setPath(window.location.pathname);
|
|
976
1110
|
window.addEventListener("popstate", onPop);
|
|
977
1111
|
return () => window.removeEventListener("popstate", onPop);
|
|
@@ -980,12 +1114,13 @@ function GroveRoutes(props) {
|
|
|
980
1114
|
window.history.pushState({}, "", href);
|
|
981
1115
|
setPath(href.split(/[?#]/)[0] ?? href);
|
|
982
1116
|
}, []);
|
|
983
|
-
const value =
|
|
1117
|
+
const value = useMemo3(() => ({ ...context, navigate }), [context, navigate]);
|
|
984
1118
|
const hit = resolveRoute(routes, path);
|
|
985
1119
|
const isRoot = (path.replace(/\/+$/, "") || "/") === "/";
|
|
986
|
-
const
|
|
1120
|
+
const matched = hit ? hit.route.render(hit.params) : fallback ?? (isRoot ? /* @__PURE__ */ jsx5(RouteIndex, {
|
|
987
1121
|
routes
|
|
988
1122
|
}) : /* @__PURE__ */ jsx5(NotFound, {}));
|
|
1123
|
+
const screen = gate && hit && hit.route.requiresAuth !== false ? gate(matched) : matched;
|
|
989
1124
|
return /* @__PURE__ */ jsxs4(GroveUiProvider, {
|
|
990
1125
|
value,
|
|
991
1126
|
children: [
|
|
@@ -996,12 +1131,13 @@ function GroveRoutes(props) {
|
|
|
996
1131
|
}
|
|
997
1132
|
function NotFound() {
|
|
998
1133
|
const { navigate } = useGroveUi();
|
|
1134
|
+
const t = useUiText();
|
|
999
1135
|
return /* @__PURE__ */ jsxs4("div", {
|
|
1000
1136
|
className: "flex flex-col gap-2 p-6",
|
|
1001
1137
|
children: [
|
|
1002
1138
|
/* @__PURE__ */ jsx5("p", {
|
|
1003
1139
|
className: "text-sm text-neutral-500",
|
|
1004
|
-
children: "
|
|
1140
|
+
children: t("common.notFound")
|
|
1005
1141
|
}),
|
|
1006
1142
|
/* @__PURE__ */ jsx5("a", {
|
|
1007
1143
|
href: "/",
|
|
@@ -1010,21 +1146,22 @@ function NotFound() {
|
|
|
1010
1146
|
e.preventDefault();
|
|
1011
1147
|
navigate("/");
|
|
1012
1148
|
},
|
|
1013
|
-
children: "
|
|
1149
|
+
children: t("common.home")
|
|
1014
1150
|
})
|
|
1015
1151
|
]
|
|
1016
1152
|
});
|
|
1017
1153
|
}
|
|
1018
1154
|
function RouteIndex({ routes }) {
|
|
1019
1155
|
const { navigate } = useGroveUi();
|
|
1156
|
+
const t = useUiText();
|
|
1020
1157
|
const pages = routes.filter((r) => !r.path.split("/").some((seg) => seg.startsWith(":")));
|
|
1021
1158
|
return /* @__PURE__ */ jsxs4("nav", {
|
|
1022
1159
|
className: "flex flex-col gap-3 p-6",
|
|
1023
|
-
"aria-label": "
|
|
1160
|
+
"aria-label": t("common.pages"),
|
|
1024
1161
|
children: [
|
|
1025
1162
|
/* @__PURE__ */ jsx5("h1", {
|
|
1026
1163
|
className: "text-2xl font-semibold",
|
|
1027
|
-
children: "
|
|
1164
|
+
children: t("common.pages")
|
|
1028
1165
|
}),
|
|
1029
1166
|
/* @__PURE__ */ jsx5("ul", {
|
|
1030
1167
|
className: "flex flex-col gap-1",
|
|
@@ -1055,6 +1192,7 @@ import { useCallback as useCallback4, useRef as useRef3, useState as useState5 }
|
|
|
1055
1192
|
import { jsx as jsx6, jsxs as jsxs5, Fragment as Fragment2 } from "react/jsx-runtime";
|
|
1056
1193
|
function SignIn(props) {
|
|
1057
1194
|
const { auth, title, allowRegister = typeof auth.register === "function", onSignedIn, children } = props;
|
|
1195
|
+
const t = useUiText();
|
|
1058
1196
|
const [signedIn, setSignedIn] = useState5(() => auth.getTokens() !== null);
|
|
1059
1197
|
const [mode, setMode] = useState5("login");
|
|
1060
1198
|
const [email, setEmail] = useState5("");
|
|
@@ -1078,13 +1216,13 @@ function SignIn(props) {
|
|
|
1078
1216
|
try {
|
|
1079
1217
|
if (mode === "mfa") {
|
|
1080
1218
|
if (!auth.verifyMfa)
|
|
1081
|
-
throw new Error("
|
|
1219
|
+
throw new Error(t("signIn.mfaUnsupported"));
|
|
1082
1220
|
finish(await auth.verifyMfa(code));
|
|
1083
1221
|
return;
|
|
1084
1222
|
}
|
|
1085
1223
|
if (mode === "register") {
|
|
1086
1224
|
if (!auth.register)
|
|
1087
|
-
throw new Error("
|
|
1225
|
+
throw new Error(t("signIn.registerUnavailable"));
|
|
1088
1226
|
await auth.register(email, password, name.trim().length > 0 ? name.trim() : undefined);
|
|
1089
1227
|
}
|
|
1090
1228
|
const user = await auth.login(email, password);
|
|
@@ -1099,12 +1237,12 @@ function SignIn(props) {
|
|
|
1099
1237
|
submitting.current = false;
|
|
1100
1238
|
setBusy(false);
|
|
1101
1239
|
}
|
|
1102
|
-
}, [auth, code, email, finish, mode, name, password]);
|
|
1240
|
+
}, [auth, code, email, finish, mode, name, password, t]);
|
|
1103
1241
|
if (signedIn)
|
|
1104
1242
|
return /* @__PURE__ */ jsx6(Fragment2, {
|
|
1105
1243
|
children
|
|
1106
1244
|
});
|
|
1107
|
-
const heading = mode === "register" ? "
|
|
1245
|
+
const heading = mode === "register" ? t("signIn.register") : mode === "mfa" ? t("signIn.mfa") : title ?? t("signIn.title");
|
|
1108
1246
|
const label = "text-sm text-neutral-600 dark:text-neutral-400";
|
|
1109
1247
|
return /* @__PURE__ */ jsx6("div", {
|
|
1110
1248
|
className: "flex min-h-screen items-center justify-center p-6",
|
|
@@ -1122,7 +1260,7 @@ function SignIn(props) {
|
|
|
1122
1260
|
children: [
|
|
1123
1261
|
/* @__PURE__ */ jsx6("span", {
|
|
1124
1262
|
className: label,
|
|
1125
|
-
children: "
|
|
1263
|
+
children: t("signIn.code")
|
|
1126
1264
|
}),
|
|
1127
1265
|
/* @__PURE__ */ jsx6("input", {
|
|
1128
1266
|
className: INPUT_CLASS,
|
|
@@ -1141,7 +1279,7 @@ function SignIn(props) {
|
|
|
1141
1279
|
children: [
|
|
1142
1280
|
/* @__PURE__ */ jsx6("span", {
|
|
1143
1281
|
className: label,
|
|
1144
|
-
children: "
|
|
1282
|
+
children: t("signIn.name")
|
|
1145
1283
|
}),
|
|
1146
1284
|
/* @__PURE__ */ jsx6("input", {
|
|
1147
1285
|
className: INPUT_CLASS,
|
|
@@ -1156,7 +1294,7 @@ function SignIn(props) {
|
|
|
1156
1294
|
children: [
|
|
1157
1295
|
/* @__PURE__ */ jsx6("span", {
|
|
1158
1296
|
className: label,
|
|
1159
|
-
children: "
|
|
1297
|
+
children: t("signIn.email")
|
|
1160
1298
|
}),
|
|
1161
1299
|
/* @__PURE__ */ jsx6("input", {
|
|
1162
1300
|
className: INPUT_CLASS,
|
|
@@ -1173,7 +1311,7 @@ function SignIn(props) {
|
|
|
1173
1311
|
children: [
|
|
1174
1312
|
/* @__PURE__ */ jsx6("span", {
|
|
1175
1313
|
className: label,
|
|
1176
|
-
children: "
|
|
1314
|
+
children: t("signIn.password")
|
|
1177
1315
|
}),
|
|
1178
1316
|
/* @__PURE__ */ jsx6("input", {
|
|
1179
1317
|
className: INPUT_CLASS,
|
|
@@ -1199,7 +1337,7 @@ function SignIn(props) {
|
|
|
1199
1337
|
type: "submit",
|
|
1200
1338
|
disabled: busy,
|
|
1201
1339
|
className: cx(BUTTON_BASE, VARIANT.default),
|
|
1202
|
-
children: busy ? "
|
|
1340
|
+
children: busy ? t("signIn.wait") : mode === "register" ? t("signIn.submitRegister") : mode === "mfa" ? t("signIn.submitMfa") : t("signIn.submit")
|
|
1203
1341
|
}),
|
|
1204
1342
|
allowRegister && mode !== "mfa" ? /* @__PURE__ */ jsx6("button", {
|
|
1205
1343
|
type: "button",
|
|
@@ -1208,7 +1346,7 @@ function SignIn(props) {
|
|
|
1208
1346
|
setError(null);
|
|
1209
1347
|
setMode(mode === "register" ? "login" : "register");
|
|
1210
1348
|
},
|
|
1211
|
-
children: mode === "register" ? "
|
|
1349
|
+
children: t(mode === "register" ? "signIn.toSignIn" : "signIn.toRegister")
|
|
1212
1350
|
}) : null
|
|
1213
1351
|
]
|
|
1214
1352
|
})
|
|
@@ -1239,7 +1377,11 @@ export {
|
|
|
1239
1377
|
renderMarkdown,
|
|
1240
1378
|
resolveHref,
|
|
1241
1379
|
resolveRoute,
|
|
1380
|
+
uiMessagesFor,
|
|
1242
1381
|
useAction,
|
|
1243
1382
|
useGroveUi,
|
|
1244
|
-
useMessages
|
|
1383
|
+
useMessages,
|
|
1384
|
+
usePageTitle,
|
|
1385
|
+
useUiMessages,
|
|
1386
|
+
useUiText
|
|
1245
1387
|
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The primitives' OWN strings — the chrome a generated app never authored.
|
|
3
|
+
*
|
|
4
|
+
* A screen's text is translated by the compiler: it ships a `MESSAGES` catalogue and looks it
|
|
5
|
+
* up with `useMessages`. Nothing did that for the words the primitives supply themselves —
|
|
6
|
+
* "Loading…", "Save", "Sign in", the empty-table line, the sign-in form's labels — so a
|
|
7
|
+
* Spanish app rendered Spanish headings above English chrome. These are that missing half.
|
|
8
|
+
*
|
|
9
|
+
* `en` is the complete catalogue and the fallback; every other locale may be partial, since a
|
|
10
|
+
* missing key falls back to English rather than showing a raw key. Locale matching is the same
|
|
11
|
+
* rule `detectLocale` uses for the document's own locales: exact first, then by language, so a
|
|
12
|
+
* viewer on `es-MX` gets the `es` chrome.
|
|
13
|
+
*
|
|
14
|
+
* An app that wants different wording — or a language not shipped here — passes `messages`
|
|
15
|
+
* on the provider context; those entries win over everything below. That is the extension
|
|
16
|
+
* point, and it is why this catalogue can stay small rather than trying to cover every
|
|
17
|
+
* language a generated app might be published in.
|
|
18
|
+
*/
|
|
19
|
+
/** Every string the primitives render. Keys are dotted by the primitive that owns them. */
|
|
20
|
+
export interface UiMessages {
|
|
21
|
+
'common.loading': string;
|
|
22
|
+
'common.notFound': string;
|
|
23
|
+
'common.home': string;
|
|
24
|
+
'common.pages': string;
|
|
25
|
+
'common.yes': string;
|
|
26
|
+
'common.no': string;
|
|
27
|
+
'header.openMenu': string;
|
|
28
|
+
'header.closeMenu': string;
|
|
29
|
+
'table.empty': string;
|
|
30
|
+
'form.save': string;
|
|
31
|
+
'form.saving': string;
|
|
32
|
+
'form.created': string;
|
|
33
|
+
'form.updated': string;
|
|
34
|
+
'action.confirmDelete': string;
|
|
35
|
+
'relation.select': string;
|
|
36
|
+
'relation.search': string;
|
|
37
|
+
'relation.noMatches': string;
|
|
38
|
+
'relation.none': string;
|
|
39
|
+
'signIn.title': string;
|
|
40
|
+
'signIn.register': string;
|
|
41
|
+
'signIn.mfa': string;
|
|
42
|
+
'signIn.name': string;
|
|
43
|
+
'signIn.email': string;
|
|
44
|
+
'signIn.password': string;
|
|
45
|
+
'signIn.code': string;
|
|
46
|
+
'signIn.mfaUnsupported': string;
|
|
47
|
+
'signIn.submit': string;
|
|
48
|
+
'signIn.submitRegister': string;
|
|
49
|
+
'signIn.submitMfa': string;
|
|
50
|
+
'signIn.wait': string;
|
|
51
|
+
'signIn.toRegister': string;
|
|
52
|
+
'signIn.toSignIn': string;
|
|
53
|
+
'signIn.registerUnavailable': string;
|
|
54
|
+
'markdown.bold': string;
|
|
55
|
+
'markdown.italic': string;
|
|
56
|
+
'markdown.heading': string;
|
|
57
|
+
'markdown.listItem': string;
|
|
58
|
+
'markdown.code': string;
|
|
59
|
+
'markdown.link': string;
|
|
60
|
+
'markdown.placeholder': string;
|
|
61
|
+
'markdown.write': string;
|
|
62
|
+
'markdown.preview': string;
|
|
63
|
+
'markdown.nothingToPreview': string;
|
|
64
|
+
}
|
|
65
|
+
/** A message key — what `useUiText()` accepts. */
|
|
66
|
+
export type UiMessageKey = keyof UiMessages;
|
|
67
|
+
/** The locales this package ships chrome for. Anything else falls back to English. */
|
|
68
|
+
export declare const UI_LOCALES: Record<string, UiMessages>;
|
|
69
|
+
/**
|
|
70
|
+
* The built-in catalogue for a locale: exact match, then by language (`es-MX` → `es`), then
|
|
71
|
+
* English. Exported for the tests and for an app that wants to read the shipped wording
|
|
72
|
+
* before overriding part of it.
|
|
73
|
+
*/
|
|
74
|
+
export declare function uiMessagesFor(locale: string | undefined): UiMessages;
|
package/dist/routes.d.ts
CHANGED
|
@@ -17,6 +17,11 @@ export interface GroveRoute {
|
|
|
17
17
|
path: string;
|
|
18
18
|
/** Title authored in the Studio, when the screen has one. */
|
|
19
19
|
title?: string | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Whether a visitor must be signed in to see this route (D81). Absent means REQUIRED, so a
|
|
22
|
+
* route table written before this existed keeps behaving as it did.
|
|
23
|
+
*/
|
|
24
|
+
requiresAuth?: boolean | undefined;
|
|
20
25
|
render: (params: Record<string, string>) => ReactNode;
|
|
21
26
|
}
|
|
22
27
|
/**
|
|
@@ -58,10 +63,18 @@ export declare function GroveRoutes(props: {
|
|
|
58
63
|
fallback?: ReactNode | undefined;
|
|
59
64
|
/**
|
|
60
65
|
* Rendered inside the provider, ahead of the screen — where an app puts its own chrome.
|
|
61
|
-
* A
|
|
62
|
-
* provider, and wrapping the router is what keeps every route behind it at once.
|
|
66
|
+
* A gate that covers EVERY route goes outside instead (`<SignIn><GroveRoutes …/></SignIn>`).
|
|
63
67
|
*/
|
|
64
68
|
children?: ReactNode | undefined;
|
|
69
|
+
/**
|
|
70
|
+
* Rendered in place of a route whose `requiresAuth` is not false while nobody is signed in
|
|
71
|
+
* (D81) — the generated app passes its `<SignIn>`. Per route rather than around the whole
|
|
72
|
+
* router, so one app can serve a public landing page and a private dashboard.
|
|
73
|
+
*
|
|
74
|
+
* Absent means no gate at all: a route table with no public/private distinction renders as
|
|
75
|
+
* it always did, and an app that wraps the router keeps working unchanged.
|
|
76
|
+
*/
|
|
77
|
+
gate?: ((screen: ReactNode) => ReactNode) | undefined;
|
|
65
78
|
}): import("react").JSX.Element;
|
|
66
79
|
/**
|
|
67
80
|
* The pages of the document, for a root with no screen of its own. Only routes with no
|
package/dist/runtime.d.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* under any of them (Next, React Router, plain history). The default uses the History API.
|
|
6
6
|
*/
|
|
7
7
|
import type { ReactNode } from 'react';
|
|
8
|
+
import { type UiMessageKey, type UiMessages } from './messages';
|
|
8
9
|
import type { Action, CollectionLike, Doc, Format } from './types';
|
|
9
10
|
export interface GroveUiContext {
|
|
10
11
|
/** Navigate to a route. */
|
|
@@ -21,12 +22,35 @@ export interface GroveUiContext {
|
|
|
21
22
|
confirm?: (message: string) => boolean | Promise<boolean>;
|
|
22
23
|
locale?: string;
|
|
23
24
|
currency?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Overrides for the primitives' OWN strings (`messages.ts`) — the chrome an app never
|
|
27
|
+
* authored. Partial: whatever is not given falls back to the shipped catalogue for the
|
|
28
|
+
* active locale, and then to English. This is how an app reaches a language the package
|
|
29
|
+
* does not ship, or changes wording it does ship.
|
|
30
|
+
*/
|
|
31
|
+
messages?: Partial<UiMessages>;
|
|
24
32
|
}
|
|
25
33
|
export declare function GroveUiProvider({ value, children }: {
|
|
26
34
|
value: GroveUiContext;
|
|
27
35
|
children: ReactNode;
|
|
28
36
|
}): import("react").JSX.Element;
|
|
29
37
|
export declare function useGroveUi(): GroveUiContext;
|
|
38
|
+
/**
|
|
39
|
+
* One chrome string, resolved against a context: the app's own override first, then the
|
|
40
|
+
* catalogue shipped for the active locale, then English. Takes the context rather than
|
|
41
|
+
* reading it, so the non-hook call sites (`useAction`'s confirm) share exactly this rule.
|
|
42
|
+
*/
|
|
43
|
+
export declare function uiText(ctx: GroveUiContext, key: UiMessageKey): string;
|
|
44
|
+
/**
|
|
45
|
+
* The primitives' translator — `t` for the words the package supplies itself, as opposed to
|
|
46
|
+
* `useMessages`, which is a screen's translator for the words its author wrote.
|
|
47
|
+
*
|
|
48
|
+
* Returned as a function rather than the whole catalogue so a primitive reads one key at a
|
|
49
|
+
* time and a missing key is a type error, not a blank on the page.
|
|
50
|
+
*/
|
|
51
|
+
export declare function useUiText(): (key: UiMessageKey) => string;
|
|
52
|
+
/** The chrome catalogue in force — for the few places that need several keys at once. */
|
|
53
|
+
export declare function useUiMessages(): UiMessages;
|
|
30
54
|
/** Substitute `:param` segments in an href with a document's values. */
|
|
31
55
|
export declare function resolveHref(href: string, doc?: Doc): string;
|
|
32
56
|
export interface DispatchOptions {
|
|
@@ -52,6 +76,8 @@ export declare function useAction(): (action: Action, opts?: DispatchOptions) =>
|
|
|
52
76
|
export declare function formatValue(value: unknown, format: Format | undefined, opts?: {
|
|
53
77
|
locale?: string | undefined;
|
|
54
78
|
currency?: string | undefined;
|
|
79
|
+
/** The chrome catalogue, for the one format that renders words rather than digits. */
|
|
80
|
+
messages?: Pick<UiMessages, 'common.yes' | 'common.no'> | undefined;
|
|
55
81
|
}): string;
|
|
56
82
|
/**
|
|
57
83
|
* A translation lookup over the catalogue a multilingual screen carries, reading the active
|
|
@@ -60,6 +86,17 @@ export declare function formatValue(value: unknown, format: Format | undefined,
|
|
|
60
86
|
* set for all of them at once.
|
|
61
87
|
*/
|
|
62
88
|
export declare function useMessages(messages: Record<string, Record<string, string>>, fallback: string): (key: string) => string;
|
|
89
|
+
/**
|
|
90
|
+
* Sets the browser tab's title while a screen is mounted, restoring the previous one when it
|
|
91
|
+
* unmounts (a screen with no title of its own must not inherit the last one's).
|
|
92
|
+
*
|
|
93
|
+
* A generated screen calls this with its OWN translated title, which is why the title is not
|
|
94
|
+
* taken from the `ROUTES` table: the table is module data, evaluated before any locale is
|
|
95
|
+
* known, so a title read from there is frozen in the document's default language. The route's
|
|
96
|
+
* `title` stays as the index's label and as what an external router reads; the tab follows
|
|
97
|
+
* the viewer's language.
|
|
98
|
+
*/
|
|
99
|
+
export declare function usePageTitle(title: string | undefined): void;
|
|
63
100
|
/**
|
|
64
101
|
* The locale to start in: the first of the viewer's preferred languages the document has,
|
|
65
102
|
* matched exactly and then by language (`es-MX` finds `es`), else the document's first —
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@groveback/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "React primitives for Groveback Studio screens — tables, forms, detail views and relation pickers that read through the Groveback SDK, so every query passes the policy engine.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|