@groveback/ui 0.3.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 +337 -196
- package/dist/messages.d.ts +74 -0
- 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(/\/+$/, "") || "/";
|
|
@@ -971,7 +1105,7 @@ function resolveRoute(routes, path) {
|
|
|
971
1105
|
function GroveRoutes(props) {
|
|
972
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,7 +1114,7 @@ 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
1120
|
const matched = hit ? hit.route.render(hit.params) : fallback ?? (isRoot ? /* @__PURE__ */ jsx5(RouteIndex, {
|
|
@@ -997,12 +1131,13 @@ function GroveRoutes(props) {
|
|
|
997
1131
|
}
|
|
998
1132
|
function NotFound() {
|
|
999
1133
|
const { navigate } = useGroveUi();
|
|
1134
|
+
const t = useUiText();
|
|
1000
1135
|
return /* @__PURE__ */ jsxs4("div", {
|
|
1001
1136
|
className: "flex flex-col gap-2 p-6",
|
|
1002
1137
|
children: [
|
|
1003
1138
|
/* @__PURE__ */ jsx5("p", {
|
|
1004
1139
|
className: "text-sm text-neutral-500",
|
|
1005
|
-
children: "
|
|
1140
|
+
children: t("common.notFound")
|
|
1006
1141
|
}),
|
|
1007
1142
|
/* @__PURE__ */ jsx5("a", {
|
|
1008
1143
|
href: "/",
|
|
@@ -1011,21 +1146,22 @@ function NotFound() {
|
|
|
1011
1146
|
e.preventDefault();
|
|
1012
1147
|
navigate("/");
|
|
1013
1148
|
},
|
|
1014
|
-
children: "
|
|
1149
|
+
children: t("common.home")
|
|
1015
1150
|
})
|
|
1016
1151
|
]
|
|
1017
1152
|
});
|
|
1018
1153
|
}
|
|
1019
1154
|
function RouteIndex({ routes }) {
|
|
1020
1155
|
const { navigate } = useGroveUi();
|
|
1156
|
+
const t = useUiText();
|
|
1021
1157
|
const pages = routes.filter((r) => !r.path.split("/").some((seg) => seg.startsWith(":")));
|
|
1022
1158
|
return /* @__PURE__ */ jsxs4("nav", {
|
|
1023
1159
|
className: "flex flex-col gap-3 p-6",
|
|
1024
|
-
"aria-label": "
|
|
1160
|
+
"aria-label": t("common.pages"),
|
|
1025
1161
|
children: [
|
|
1026
1162
|
/* @__PURE__ */ jsx5("h1", {
|
|
1027
1163
|
className: "text-2xl font-semibold",
|
|
1028
|
-
children: "
|
|
1164
|
+
children: t("common.pages")
|
|
1029
1165
|
}),
|
|
1030
1166
|
/* @__PURE__ */ jsx5("ul", {
|
|
1031
1167
|
className: "flex flex-col gap-1",
|
|
@@ -1056,6 +1192,7 @@ import { useCallback as useCallback4, useRef as useRef3, useState as useState5 }
|
|
|
1056
1192
|
import { jsx as jsx6, jsxs as jsxs5, Fragment as Fragment2 } from "react/jsx-runtime";
|
|
1057
1193
|
function SignIn(props) {
|
|
1058
1194
|
const { auth, title, allowRegister = typeof auth.register === "function", onSignedIn, children } = props;
|
|
1195
|
+
const t = useUiText();
|
|
1059
1196
|
const [signedIn, setSignedIn] = useState5(() => auth.getTokens() !== null);
|
|
1060
1197
|
const [mode, setMode] = useState5("login");
|
|
1061
1198
|
const [email, setEmail] = useState5("");
|
|
@@ -1079,13 +1216,13 @@ function SignIn(props) {
|
|
|
1079
1216
|
try {
|
|
1080
1217
|
if (mode === "mfa") {
|
|
1081
1218
|
if (!auth.verifyMfa)
|
|
1082
|
-
throw new Error("
|
|
1219
|
+
throw new Error(t("signIn.mfaUnsupported"));
|
|
1083
1220
|
finish(await auth.verifyMfa(code));
|
|
1084
1221
|
return;
|
|
1085
1222
|
}
|
|
1086
1223
|
if (mode === "register") {
|
|
1087
1224
|
if (!auth.register)
|
|
1088
|
-
throw new Error("
|
|
1225
|
+
throw new Error(t("signIn.registerUnavailable"));
|
|
1089
1226
|
await auth.register(email, password, name.trim().length > 0 ? name.trim() : undefined);
|
|
1090
1227
|
}
|
|
1091
1228
|
const user = await auth.login(email, password);
|
|
@@ -1100,12 +1237,12 @@ function SignIn(props) {
|
|
|
1100
1237
|
submitting.current = false;
|
|
1101
1238
|
setBusy(false);
|
|
1102
1239
|
}
|
|
1103
|
-
}, [auth, code, email, finish, mode, name, password]);
|
|
1240
|
+
}, [auth, code, email, finish, mode, name, password, t]);
|
|
1104
1241
|
if (signedIn)
|
|
1105
1242
|
return /* @__PURE__ */ jsx6(Fragment2, {
|
|
1106
1243
|
children
|
|
1107
1244
|
});
|
|
1108
|
-
const heading = mode === "register" ? "
|
|
1245
|
+
const heading = mode === "register" ? t("signIn.register") : mode === "mfa" ? t("signIn.mfa") : title ?? t("signIn.title");
|
|
1109
1246
|
const label = "text-sm text-neutral-600 dark:text-neutral-400";
|
|
1110
1247
|
return /* @__PURE__ */ jsx6("div", {
|
|
1111
1248
|
className: "flex min-h-screen items-center justify-center p-6",
|
|
@@ -1123,7 +1260,7 @@ function SignIn(props) {
|
|
|
1123
1260
|
children: [
|
|
1124
1261
|
/* @__PURE__ */ jsx6("span", {
|
|
1125
1262
|
className: label,
|
|
1126
|
-
children: "
|
|
1263
|
+
children: t("signIn.code")
|
|
1127
1264
|
}),
|
|
1128
1265
|
/* @__PURE__ */ jsx6("input", {
|
|
1129
1266
|
className: INPUT_CLASS,
|
|
@@ -1142,7 +1279,7 @@ function SignIn(props) {
|
|
|
1142
1279
|
children: [
|
|
1143
1280
|
/* @__PURE__ */ jsx6("span", {
|
|
1144
1281
|
className: label,
|
|
1145
|
-
children: "
|
|
1282
|
+
children: t("signIn.name")
|
|
1146
1283
|
}),
|
|
1147
1284
|
/* @__PURE__ */ jsx6("input", {
|
|
1148
1285
|
className: INPUT_CLASS,
|
|
@@ -1157,7 +1294,7 @@ function SignIn(props) {
|
|
|
1157
1294
|
children: [
|
|
1158
1295
|
/* @__PURE__ */ jsx6("span", {
|
|
1159
1296
|
className: label,
|
|
1160
|
-
children: "
|
|
1297
|
+
children: t("signIn.email")
|
|
1161
1298
|
}),
|
|
1162
1299
|
/* @__PURE__ */ jsx6("input", {
|
|
1163
1300
|
className: INPUT_CLASS,
|
|
@@ -1174,7 +1311,7 @@ function SignIn(props) {
|
|
|
1174
1311
|
children: [
|
|
1175
1312
|
/* @__PURE__ */ jsx6("span", {
|
|
1176
1313
|
className: label,
|
|
1177
|
-
children: "
|
|
1314
|
+
children: t("signIn.password")
|
|
1178
1315
|
}),
|
|
1179
1316
|
/* @__PURE__ */ jsx6("input", {
|
|
1180
1317
|
className: INPUT_CLASS,
|
|
@@ -1200,7 +1337,7 @@ function SignIn(props) {
|
|
|
1200
1337
|
type: "submit",
|
|
1201
1338
|
disabled: busy,
|
|
1202
1339
|
className: cx(BUTTON_BASE, VARIANT.default),
|
|
1203
|
-
children: busy ? "
|
|
1340
|
+
children: busy ? t("signIn.wait") : mode === "register" ? t("signIn.submitRegister") : mode === "mfa" ? t("signIn.submitMfa") : t("signIn.submit")
|
|
1204
1341
|
}),
|
|
1205
1342
|
allowRegister && mode !== "mfa" ? /* @__PURE__ */ jsx6("button", {
|
|
1206
1343
|
type: "button",
|
|
@@ -1209,7 +1346,7 @@ function SignIn(props) {
|
|
|
1209
1346
|
setError(null);
|
|
1210
1347
|
setMode(mode === "register" ? "login" : "register");
|
|
1211
1348
|
},
|
|
1212
|
-
children: mode === "register" ? "
|
|
1349
|
+
children: t(mode === "register" ? "signIn.toSignIn" : "signIn.toRegister")
|
|
1213
1350
|
}) : null
|
|
1214
1351
|
]
|
|
1215
1352
|
})
|
|
@@ -1240,7 +1377,11 @@ export {
|
|
|
1240
1377
|
renderMarkdown,
|
|
1241
1378
|
resolveHref,
|
|
1242
1379
|
resolveRoute,
|
|
1380
|
+
uiMessagesFor,
|
|
1243
1381
|
useAction,
|
|
1244
1382
|
useGroveUi,
|
|
1245
|
-
useMessages
|
|
1383
|
+
useMessages,
|
|
1384
|
+
usePageTitle,
|
|
1385
|
+
useUiMessages,
|
|
1386
|
+
useUiText
|
|
1246
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/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",
|