@groveback/ui 0.3.0 → 0.5.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/SignIn.d.ts +15 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +353 -197
- package/dist/messages.d.ts +74 -0
- package/dist/runtime.d.ts +51 -0
- package/package.json +1 -1
package/dist/SignIn.d.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* through the same endpoints, and the same rate limits, as a hand-written form would.
|
|
11
11
|
*/
|
|
12
12
|
import type { ReactNode } from 'react';
|
|
13
|
+
import type { UiMessages } from './messages';
|
|
13
14
|
import type { Doc } from './types';
|
|
14
15
|
/**
|
|
15
16
|
* The slice of the SDK's `client.auth` the gate uses. `login` may resolve with
|
|
@@ -32,6 +33,20 @@ export interface SignInProps {
|
|
|
32
33
|
allowRegister?: boolean | undefined;
|
|
33
34
|
/** Called with the user after a successful sign-in or registration. */
|
|
34
35
|
onSignedIn?: ((user: Doc) => void) | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* Language for the form's own words. Optional, and normally unnecessary: under
|
|
38
|
+
* `<GroveRoutes>` the gate reads the shell's locale like every other primitive.
|
|
39
|
+
*
|
|
40
|
+
* It exists for the mount that wraps the WHOLE router — `<SignIn><GroveRoutes /></SignIn>`,
|
|
41
|
+
* the way an app puts every page behind a session. That places the form above the provider
|
|
42
|
+
* carrying the locale, so a Spanish app came up with an English sign-in screen in front of
|
|
43
|
+
* translated pages. Absent, the gate falls back to the provider when there is one and then
|
|
44
|
+
* to the viewer's own languages, so that mount is now translated with no prop at all; pass
|
|
45
|
+
* one to drive it from the app's own switcher.
|
|
46
|
+
*/
|
|
47
|
+
locale?: string | undefined;
|
|
48
|
+
/** Overrides for the form's words, merged over the shipped catalogue (see `messages.ts`). */
|
|
49
|
+
messages?: Partial<UiMessages> | undefined;
|
|
35
50
|
/** What a signed-in viewer sees — normally the app's routes. */
|
|
36
51
|
children: ReactNode;
|
|
37
52
|
}
|
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, useStandaloneUiText, 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, useStandaloneUiText, 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,263 @@
|
|
|
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 useStandaloneUiText(locale, overrides) {
|
|
142
|
+
const ctx = useGroveUi();
|
|
143
|
+
const active = locale ?? ctx.locale ?? detectLocale(Object.keys(UI_LOCALES));
|
|
144
|
+
const messages = { ...ctx.messages, ...overrides };
|
|
145
|
+
return useCallback((key) => messages[key] ?? uiMessagesFor(active)[key], [active, ctx.messages, overrides]);
|
|
146
|
+
}
|
|
147
|
+
function resolveHref(href, doc) {
|
|
148
|
+
if (!doc)
|
|
149
|
+
return href;
|
|
150
|
+
return href.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, (whole, name) => {
|
|
151
|
+
const value = doc[name];
|
|
152
|
+
return value === undefined || value === null ? whole : String(value);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
function useAction() {
|
|
156
|
+
const ctx = useGroveUi();
|
|
157
|
+
return useCallback(async (action, opts = {}) => {
|
|
158
|
+
switch (action.kind) {
|
|
159
|
+
case "navigate":
|
|
160
|
+
ctx.navigate(resolveHref(action.href, opts.doc));
|
|
161
|
+
return;
|
|
162
|
+
case "delete": {
|
|
163
|
+
const id = opts.doc?.["id"];
|
|
164
|
+
if (typeof id !== "string" || !opts.collection)
|
|
165
|
+
return;
|
|
166
|
+
const ask = ctx.confirm ?? ((m) => window.confirm(m));
|
|
167
|
+
const message = action.confirm ?? uiText(ctx, "action.confirmDelete");
|
|
168
|
+
if (!await ask(message))
|
|
169
|
+
return;
|
|
170
|
+
await opts.collection.delete(id);
|
|
171
|
+
opts.onDone?.();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
case "endpoint": {
|
|
175
|
+
const href = resolveHref(action.path, opts.doc);
|
|
176
|
+
const method = action.method ?? "POST";
|
|
177
|
+
if (ctx.callEndpoint)
|
|
178
|
+
await ctx.callEndpoint(href, method);
|
|
179
|
+
else
|
|
180
|
+
await fetch(`${ctx.endpointBase ?? ""}${href}`, { method });
|
|
181
|
+
opts.onDone?.();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}, [ctx]);
|
|
186
|
+
}
|
|
187
|
+
var MARKDOWN_CELL_MAX = 80;
|
|
188
|
+
function formatValue(value, format, opts = {}) {
|
|
189
|
+
if (value === null || value === undefined)
|
|
190
|
+
return "";
|
|
191
|
+
switch (format) {
|
|
192
|
+
case "boolean":
|
|
193
|
+
return value ? opts.messages?.["common.yes"] ?? "Yes" : opts.messages?.["common.no"] ?? "No";
|
|
194
|
+
case "currency": {
|
|
195
|
+
const n = Number(value);
|
|
196
|
+
if (!Number.isFinite(n))
|
|
197
|
+
return String(value);
|
|
198
|
+
return new Intl.NumberFormat(opts.locale, {
|
|
199
|
+
style: "currency",
|
|
200
|
+
currency: opts.currency ?? "USD"
|
|
201
|
+
}).format(n);
|
|
202
|
+
}
|
|
203
|
+
case "number": {
|
|
204
|
+
const n = Number(value);
|
|
205
|
+
return Number.isFinite(n) ? new Intl.NumberFormat(opts.locale).format(n) : String(value);
|
|
206
|
+
}
|
|
207
|
+
case "date": {
|
|
208
|
+
const text = String(value);
|
|
209
|
+
const day = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
|
|
210
|
+
const d = day ? new Date(Number(day[1]), Number(day[2]) - 1, Number(day[3])) : new Date(text);
|
|
211
|
+
return Number.isNaN(d.getTime()) ? text : d.toLocaleDateString(opts.locale);
|
|
212
|
+
}
|
|
213
|
+
case "markdown": {
|
|
214
|
+
const text = String(value).replace(/\s+/g, " ").trim();
|
|
215
|
+
if (text.length <= MARKDOWN_CELL_MAX)
|
|
216
|
+
return text;
|
|
217
|
+
const cut = text.slice(0, MARKDOWN_CELL_MAX);
|
|
218
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
219
|
+
return `${(lastSpace > MARKDOWN_CELL_MAX / 2 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
|
|
220
|
+
}
|
|
221
|
+
default:
|
|
222
|
+
return typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
function useMessages(messages, fallback) {
|
|
226
|
+
const { locale } = useGroveUi();
|
|
227
|
+
return useCallback((key) => {
|
|
228
|
+
const entry = messages[key];
|
|
229
|
+
if (!entry)
|
|
230
|
+
return key;
|
|
231
|
+
return (locale !== undefined ? entry[locale] : undefined) ?? entry[fallback] ?? key;
|
|
232
|
+
}, [messages, locale, fallback]);
|
|
233
|
+
}
|
|
234
|
+
function usePageTitle(title) {
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
if (typeof document === "undefined" || !title)
|
|
237
|
+
return;
|
|
238
|
+
const previous = document.title;
|
|
239
|
+
document.title = title;
|
|
240
|
+
return () => {
|
|
241
|
+
document.title = previous;
|
|
242
|
+
};
|
|
243
|
+
}, [title]);
|
|
244
|
+
}
|
|
245
|
+
function detectLocale(locales, preferred = typeof navigator !== "undefined" ? navigator.languages : []) {
|
|
246
|
+
const first = locales[0] ?? "en";
|
|
247
|
+
for (const want of preferred) {
|
|
248
|
+
const exact = locales.find((l) => l.toLowerCase() === want.toLowerCase());
|
|
249
|
+
if (exact)
|
|
250
|
+
return exact;
|
|
251
|
+
const lang = want.split("-")[0]?.toLowerCase();
|
|
252
|
+
const loose = locales.find((l) => l.split("-")[0]?.toLowerCase() === lang);
|
|
253
|
+
if (loose)
|
|
254
|
+
return loose;
|
|
255
|
+
}
|
|
256
|
+
return first;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// src/MarkdownField.tsx
|
|
260
|
+
import { jsx as jsx2, jsxs } from "react/jsx-runtime";
|
|
7
261
|
function escapeHtml(s) {
|
|
8
262
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
9
263
|
}
|
|
@@ -46,12 +300,12 @@ function renderMarkdown(src) {
|
|
|
46
300
|
`);
|
|
47
301
|
}
|
|
48
302
|
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: "
|
|
303
|
+
{ key: "b", label: "markdown.bold", glyph: "B", wrap: { before: "**", after: "**", placeholder: "bold" } },
|
|
304
|
+
{ key: "i", label: "markdown.italic", glyph: "I", wrap: { before: "*", after: "*", placeholder: "italic" } },
|
|
305
|
+
{ key: "h", label: "markdown.heading", glyph: "H", wrap: { before: "## ", after: "", placeholder: "Heading" } },
|
|
306
|
+
{ key: "ul", label: "markdown.listItem", glyph: "•", wrap: { before: "- ", after: "", placeholder: "item" } },
|
|
307
|
+
{ key: "code", label: "markdown.code", glyph: "</>", wrap: { before: "`", after: "`", placeholder: "code" } },
|
|
308
|
+
{ key: "a", label: "markdown.link", glyph: "\uD83D\uDD17", wrap: { before: "[", after: "](https://)", placeholder: "text" } }
|
|
55
309
|
];
|
|
56
310
|
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
311
|
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 +313,7 @@ function MarkdownField({
|
|
|
59
313
|
value,
|
|
60
314
|
onChange
|
|
61
315
|
}) {
|
|
316
|
+
const t = useUiText();
|
|
62
317
|
const [mode, setMode] = useState("write");
|
|
63
318
|
const [el, setEl] = useState(null);
|
|
64
319
|
function apply(wrap) {
|
|
@@ -81,53 +336,53 @@ function MarkdownField({
|
|
|
81
336
|
/* @__PURE__ */ jsxs("div", {
|
|
82
337
|
className: "flex items-center gap-0.5 border-b border-neutral-200 px-1 py-1 dark:border-neutral-800",
|
|
83
338
|
children: [
|
|
84
|
-
TOOLS.map((
|
|
339
|
+
TOOLS.map((tool) => /* @__PURE__ */ jsx2("button", {
|
|
85
340
|
type: "button",
|
|
86
|
-
title: t.label,
|
|
87
|
-
"aria-label": t.label,
|
|
341
|
+
title: t(tool.label),
|
|
342
|
+
"aria-label": t(tool.label),
|
|
88
343
|
disabled: mode === "preview",
|
|
89
|
-
onClick: () => apply(
|
|
344
|
+
onClick: () => apply(tool.wrap),
|
|
90
345
|
className: TOOL_BUTTON,
|
|
91
|
-
children:
|
|
92
|
-
},
|
|
346
|
+
children: tool.glyph
|
|
347
|
+
}, tool.key)),
|
|
93
348
|
/* @__PURE__ */ jsxs("div", {
|
|
94
349
|
className: "ml-auto flex gap-0.5",
|
|
95
350
|
children: [
|
|
96
|
-
/* @__PURE__ */
|
|
351
|
+
/* @__PURE__ */ jsx2("button", {
|
|
97
352
|
type: "button",
|
|
98
353
|
onClick: () => setMode("write"),
|
|
99
354
|
className: tab(mode === "write"),
|
|
100
|
-
children: "
|
|
355
|
+
children: t("markdown.write")
|
|
101
356
|
}),
|
|
102
|
-
/* @__PURE__ */
|
|
357
|
+
/* @__PURE__ */ jsx2("button", {
|
|
103
358
|
type: "button",
|
|
104
359
|
onClick: () => setMode("preview"),
|
|
105
360
|
className: tab(mode === "preview"),
|
|
106
|
-
children: "
|
|
361
|
+
children: t("markdown.preview")
|
|
107
362
|
})
|
|
108
363
|
]
|
|
109
364
|
})
|
|
110
365
|
]
|
|
111
366
|
}),
|
|
112
|
-
mode === "write" ? /* @__PURE__ */
|
|
367
|
+
mode === "write" ? /* @__PURE__ */ jsx2("textarea", {
|
|
113
368
|
ref: setEl,
|
|
114
369
|
rows: 8,
|
|
115
370
|
value,
|
|
116
371
|
spellCheck: false,
|
|
117
|
-
placeholder: "
|
|
372
|
+
placeholder: t("markdown.placeholder"),
|
|
118
373
|
onChange: (e) => onChange(e.target.value),
|
|
119
374
|
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__ */
|
|
375
|
+
}) : /* @__PURE__ */ jsx2("div", {
|
|
121
376
|
className: PREVIEW_TYPOGRAPHY,
|
|
122
|
-
dangerouslySetInnerHTML: { __html: renderMarkdown(value || "
|
|
377
|
+
dangerouslySetInnerHTML: { __html: renderMarkdown(value || `_${t("markdown.nothingToPreview")}_`) }
|
|
123
378
|
})
|
|
124
379
|
]
|
|
125
380
|
});
|
|
126
381
|
}
|
|
127
382
|
|
|
128
383
|
// src/RelationSelect.tsx
|
|
129
|
-
import { useEffect, useMemo, useRef, useState as useState2 } from "react";
|
|
130
|
-
import { jsx as
|
|
384
|
+
import { useEffect as useEffect2, useMemo as useMemo2, useRef, useState as useState2 } from "react";
|
|
385
|
+
import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
131
386
|
var PAGE = 200;
|
|
132
387
|
function labelOf(doc, field) {
|
|
133
388
|
const raw = doc[field];
|
|
@@ -139,13 +394,14 @@ function labelOf(doc, field) {
|
|
|
139
394
|
}
|
|
140
395
|
function RelationSelect(props) {
|
|
141
396
|
const { collection, displayField, value, onChange, placeholder, required } = props;
|
|
397
|
+
const t = useUiText();
|
|
142
398
|
const [docs, setDocs] = useState2(null);
|
|
143
399
|
const [error, setError] = useState2(null);
|
|
144
400
|
const [query, setQuery] = useState2("");
|
|
145
401
|
const [open, setOpen] = useState2(false);
|
|
146
402
|
const [truncated, setTruncated] = useState2(false);
|
|
147
403
|
const boxRef = useRef(null);
|
|
148
|
-
|
|
404
|
+
useEffect2(() => {
|
|
149
405
|
let live = true;
|
|
150
406
|
collection.find({ limit: PAGE }).then((found) => {
|
|
151
407
|
if (!live)
|
|
@@ -160,7 +416,7 @@ function RelationSelect(props) {
|
|
|
160
416
|
live = false;
|
|
161
417
|
};
|
|
162
418
|
}, [collection]);
|
|
163
|
-
|
|
419
|
+
useEffect2(() => {
|
|
164
420
|
if (!open)
|
|
165
421
|
return;
|
|
166
422
|
const onDown = (e) => {
|
|
@@ -170,8 +426,8 @@ function RelationSelect(props) {
|
|
|
170
426
|
document.addEventListener("mousedown", onDown);
|
|
171
427
|
return () => document.removeEventListener("mousedown", onDown);
|
|
172
428
|
}, [open]);
|
|
173
|
-
const selected =
|
|
174
|
-
const matches =
|
|
429
|
+
const selected = useMemo2(() => (docs ?? []).find((d) => d["id"] === value) ?? null, [docs, value]);
|
|
430
|
+
const matches = useMemo2(() => {
|
|
175
431
|
const all = docs ?? [];
|
|
176
432
|
const q = query.trim().toLowerCase();
|
|
177
433
|
if (q === "")
|
|
@@ -179,12 +435,12 @@ function RelationSelect(props) {
|
|
|
179
435
|
return all.filter((d) => labelOf(d, displayField).toLowerCase().includes(q));
|
|
180
436
|
}, [docs, displayField, query]);
|
|
181
437
|
if (error) {
|
|
182
|
-
return /* @__PURE__ */
|
|
438
|
+
return /* @__PURE__ */ jsx3("p", {
|
|
183
439
|
className: "text-xs text-red-600",
|
|
184
440
|
children: error
|
|
185
441
|
});
|
|
186
442
|
}
|
|
187
|
-
const buttonLabel = selected ? labelOf(selected, displayField) : value !== "" ? value : placeholder ?? "
|
|
443
|
+
const buttonLabel = selected ? labelOf(selected, displayField) : value !== "" ? value : placeholder ?? t("relation.select");
|
|
188
444
|
return /* @__PURE__ */ jsxs2("div", {
|
|
189
445
|
ref: boxRef,
|
|
190
446
|
className: "relative",
|
|
@@ -194,11 +450,11 @@ function RelationSelect(props) {
|
|
|
194
450
|
onClick: () => setOpen((v) => !v),
|
|
195
451
|
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
452
|
children: [
|
|
197
|
-
/* @__PURE__ */
|
|
453
|
+
/* @__PURE__ */ jsx3("span", {
|
|
198
454
|
className: selected || value ? "" : "text-neutral-400",
|
|
199
|
-
children: docs === null ? "
|
|
455
|
+
children: docs === null ? t("common.loading") : buttonLabel
|
|
200
456
|
}),
|
|
201
|
-
/* @__PURE__ */
|
|
457
|
+
/* @__PURE__ */ jsx3("span", {
|
|
202
458
|
"aria-hidden": true,
|
|
203
459
|
className: "ml-2 text-neutral-400",
|
|
204
460
|
children: "▾"
|
|
@@ -208,18 +464,18 @@ function RelationSelect(props) {
|
|
|
208
464
|
open ? /* @__PURE__ */ jsxs2("div", {
|
|
209
465
|
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
466
|
children: [
|
|
211
|
-
/* @__PURE__ */
|
|
467
|
+
/* @__PURE__ */ jsx3("input", {
|
|
212
468
|
autoFocus: true,
|
|
213
469
|
value: query,
|
|
214
470
|
onChange: (e) => setQuery(e.target.value),
|
|
215
|
-
placeholder: "
|
|
471
|
+
placeholder: t("relation.search"),
|
|
216
472
|
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
473
|
}),
|
|
218
474
|
/* @__PURE__ */ jsxs2("ul", {
|
|
219
475
|
className: "max-h-56 overflow-auto py-1",
|
|
220
476
|
children: [
|
|
221
|
-
!required ? /* @__PURE__ */
|
|
222
|
-
children: /* @__PURE__ */
|
|
477
|
+
!required ? /* @__PURE__ */ jsx3("li", {
|
|
478
|
+
children: /* @__PURE__ */ jsx3("button", {
|
|
223
479
|
type: "button",
|
|
224
480
|
onClick: () => {
|
|
225
481
|
onChange("");
|
|
@@ -227,16 +483,16 @@ function RelationSelect(props) {
|
|
|
227
483
|
setQuery("");
|
|
228
484
|
},
|
|
229
485
|
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: "
|
|
486
|
+
children: t("relation.none")
|
|
231
487
|
})
|
|
232
488
|
}) : null,
|
|
233
|
-
matches.length === 0 ? /* @__PURE__ */
|
|
489
|
+
matches.length === 0 ? /* @__PURE__ */ jsx3("li", {
|
|
234
490
|
className: "px-3 py-2 text-xs text-neutral-500",
|
|
235
|
-
children: "
|
|
491
|
+
children: t("relation.noMatches")
|
|
236
492
|
}) : matches.map((doc) => {
|
|
237
493
|
const id = typeof doc["id"] === "string" ? doc["id"] : "";
|
|
238
|
-
return /* @__PURE__ */
|
|
239
|
-
children: /* @__PURE__ */
|
|
494
|
+
return /* @__PURE__ */ jsx3("li", {
|
|
495
|
+
children: /* @__PURE__ */ jsx3("button", {
|
|
240
496
|
type: "button",
|
|
241
497
|
onClick: () => {
|
|
242
498
|
onChange(id);
|
|
@@ -264,126 +520,6 @@ function RelationSelect(props) {
|
|
|
264
520
|
});
|
|
265
521
|
}
|
|
266
522
|
|
|
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
523
|
// src/styles.ts
|
|
388
524
|
function cx(...parts) {
|
|
389
525
|
return parts.filter(Boolean).join(" ");
|
|
@@ -447,6 +583,7 @@ function Text({ muted, children }) {
|
|
|
447
583
|
function Header(props) {
|
|
448
584
|
const { brand, brandHref = "/", links, actions, sticky } = props;
|
|
449
585
|
const dispatch = useAction();
|
|
586
|
+
const t = useUiText();
|
|
450
587
|
const [open, setOpen] = useState3(false);
|
|
451
588
|
const go = useCallback2((href) => (e) => {
|
|
452
589
|
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0)
|
|
@@ -490,7 +627,7 @@ function Header(props) {
|
|
|
490
627
|
hasNav ? /* @__PURE__ */ jsx4("button", {
|
|
491
628
|
type: "button",
|
|
492
629
|
"data-grove-nav-toggle": true,
|
|
493
|
-
"aria-label": open ? "
|
|
630
|
+
"aria-label": t(open ? "header.closeMenu" : "header.openMenu"),
|
|
494
631
|
"aria-expanded": open,
|
|
495
632
|
onClick: () => setOpen((v) => !v),
|
|
496
633
|
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 +732,7 @@ function useAsync(load, deps) {
|
|
|
595
732
|
const [error, setError] = useState3(null);
|
|
596
733
|
const [loading, setLoading] = useState3(true);
|
|
597
734
|
const [nonce, setNonce] = useState3(0);
|
|
598
|
-
|
|
735
|
+
useEffect3(() => {
|
|
599
736
|
let live = true;
|
|
600
737
|
setLoading(true);
|
|
601
738
|
setError(null);
|
|
@@ -626,7 +763,7 @@ function useRelations(fields, docs, resolveCollection) {
|
|
|
626
763
|
const [map, setMap] = useState3({});
|
|
627
764
|
const relations = fields.filter((f) => f.display);
|
|
628
765
|
const key = JSON.stringify(relations.map((f) => f.field)) + (docs?.length ?? 0);
|
|
629
|
-
|
|
766
|
+
useEffect3(() => {
|
|
630
767
|
if (!docs || relations.length === 0 || !resolveCollection)
|
|
631
768
|
return;
|
|
632
769
|
let live = true;
|
|
@@ -650,25 +787,26 @@ function useRelations(fields, docs, resolveCollection) {
|
|
|
650
787
|
}, [key, resolveCollection]);
|
|
651
788
|
return map;
|
|
652
789
|
}
|
|
653
|
-
function cellValue(doc, field, relations, locale, currency) {
|
|
790
|
+
function cellValue(doc, field, relations, locale, currency, messages) {
|
|
654
791
|
const raw = doc[field.field];
|
|
655
792
|
if (field.display) {
|
|
656
793
|
const id = typeof raw === "string" ? raw : "";
|
|
657
794
|
const referenced = relations[field.field]?.[id];
|
|
658
795
|
if (referenced)
|
|
659
|
-
return formatValue(referenced[field.display.field], "text", { locale, currency });
|
|
796
|
+
return formatValue(referenced[field.display.field], "text", { locale, currency, messages });
|
|
660
797
|
}
|
|
661
|
-
return formatValue(raw, field.format, { locale, currency });
|
|
798
|
+
return formatValue(raw, field.format, { locale, currency, messages });
|
|
662
799
|
}
|
|
663
800
|
function DataTable(props) {
|
|
664
801
|
const { collection, options, columns, rowHref, rowActions, emptyText, resolveRelation } = props;
|
|
665
802
|
const ctx = useGroveUi();
|
|
803
|
+
const messages = useUiMessages();
|
|
666
804
|
const { data, error, loading, reload } = useAsync(() => collection.find(options), [JSON.stringify(options)]);
|
|
667
805
|
const relations = useRelations(columns, data, resolveRelation);
|
|
668
806
|
if (loading)
|
|
669
807
|
return /* @__PURE__ */ jsx4(Message, {
|
|
670
808
|
tone: "muted",
|
|
671
|
-
children: "
|
|
809
|
+
children: messages["common.loading"]
|
|
672
810
|
});
|
|
673
811
|
if (error)
|
|
674
812
|
return /* @__PURE__ */ jsx4(Message, {
|
|
@@ -678,7 +816,7 @@ function DataTable(props) {
|
|
|
678
816
|
if (!data || data.length === 0)
|
|
679
817
|
return /* @__PURE__ */ jsx4(Message, {
|
|
680
818
|
tone: "muted",
|
|
681
|
-
children: emptyText ?? "
|
|
819
|
+
children: emptyText ?? messages["table.empty"]
|
|
682
820
|
});
|
|
683
821
|
return /* @__PURE__ */ jsx4("div", {
|
|
684
822
|
className: "w-full overflow-x-auto rounded-md border border-neutral-200 dark:border-neutral-800",
|
|
@@ -706,7 +844,7 @@ function DataTable(props) {
|
|
|
706
844
|
children: [
|
|
707
845
|
columns.map((c) => /* @__PURE__ */ jsx4("td", {
|
|
708
846
|
className: "px-3 py-2",
|
|
709
|
-
children: cellValue(doc, c, relations, ctx.locale, ctx.currency)
|
|
847
|
+
children: cellValue(doc, c, relations, ctx.locale, ctx.currency, messages)
|
|
710
848
|
}, c.field)),
|
|
711
849
|
rowActions && rowActions.length > 0 ? /* @__PURE__ */ jsx4("td", {
|
|
712
850
|
className: "px-3 py-2",
|
|
@@ -733,12 +871,13 @@ function DataTable(props) {
|
|
|
733
871
|
function Detail(props) {
|
|
734
872
|
const { collection, id, fields, resolveRelation } = props;
|
|
735
873
|
const ctx = useGroveUi();
|
|
874
|
+
const messages = useUiMessages();
|
|
736
875
|
const { data, error, loading } = useAsync(() => collection.get(id), [id]);
|
|
737
876
|
const relations = useRelations(fields, data ? [data] : null, resolveRelation);
|
|
738
877
|
if (loading)
|
|
739
878
|
return /* @__PURE__ */ jsx4(Message, {
|
|
740
879
|
tone: "muted",
|
|
741
|
-
children: "
|
|
880
|
+
children: messages["common.loading"]
|
|
742
881
|
});
|
|
743
882
|
if (error)
|
|
744
883
|
return /* @__PURE__ */ jsx4(Message, {
|
|
@@ -748,7 +887,7 @@ function Detail(props) {
|
|
|
748
887
|
if (!data)
|
|
749
888
|
return /* @__PURE__ */ jsx4(Message, {
|
|
750
889
|
tone: "muted",
|
|
751
|
-
children: "
|
|
890
|
+
children: messages["common.notFound"]
|
|
752
891
|
});
|
|
753
892
|
return /* @__PURE__ */ jsx4("dl", {
|
|
754
893
|
className: "divide-y divide-neutral-200 rounded-md border border-neutral-200 dark:divide-neutral-800 dark:border-neutral-800",
|
|
@@ -764,7 +903,7 @@ function Detail(props) {
|
|
|
764
903
|
children: String(data[f.field] ?? "")
|
|
765
904
|
}) : /* @__PURE__ */ jsx4("dd", {
|
|
766
905
|
className: "col-span-2 text-sm",
|
|
767
|
-
children: cellValue(data, f, relations, ctx.locale, ctx.currency)
|
|
906
|
+
children: cellValue(data, f, relations, ctx.locale, ctx.currency, messages)
|
|
768
907
|
})
|
|
769
908
|
]
|
|
770
909
|
}, f.field))
|
|
@@ -795,13 +934,14 @@ function coerce(raw, format) {
|
|
|
795
934
|
function Form(props) {
|
|
796
935
|
const { collection, mode, id, fields, submitLabel, onSuccess, resolveRelation } = props;
|
|
797
936
|
const dispatch = useAction();
|
|
937
|
+
const t = useUiText();
|
|
798
938
|
const [values, setValues] = useState3({});
|
|
799
939
|
const [error, setError] = useState3(null);
|
|
800
940
|
const [busy, setBusy] = useState3(false);
|
|
801
941
|
const [done, setDone] = useState3(false);
|
|
802
942
|
const submitting = useRef2(false);
|
|
803
943
|
const [loading, setLoading] = useState3(mode === "update");
|
|
804
|
-
|
|
944
|
+
useEffect3(() => {
|
|
805
945
|
if (mode !== "update" || !id)
|
|
806
946
|
return;
|
|
807
947
|
let live = true;
|
|
@@ -865,7 +1005,7 @@ function Form(props) {
|
|
|
865
1005
|
if (loading)
|
|
866
1006
|
return /* @__PURE__ */ jsx4(Message, {
|
|
867
1007
|
tone: "muted",
|
|
868
|
-
children: "
|
|
1008
|
+
children: t("common.loading")
|
|
869
1009
|
});
|
|
870
1010
|
return /* @__PURE__ */ jsxs3("form", {
|
|
871
1011
|
onSubmit: submit,
|
|
@@ -919,14 +1059,14 @@ function Form(props) {
|
|
|
919
1059
|
}) : null,
|
|
920
1060
|
done && !error ? /* @__PURE__ */ jsx4(Message, {
|
|
921
1061
|
tone: "success",
|
|
922
|
-
children: mode === "create" ? "
|
|
1062
|
+
children: t(mode === "create" ? "form.created" : "form.updated")
|
|
923
1063
|
}) : null,
|
|
924
1064
|
/* @__PURE__ */ jsx4("div", {
|
|
925
1065
|
children: /* @__PURE__ */ jsx4("button", {
|
|
926
1066
|
type: "submit",
|
|
927
1067
|
disabled: busy,
|
|
928
1068
|
className: cx(BUTTON_BASE, VARIANT.default),
|
|
929
|
-
children: busy ? "
|
|
1069
|
+
children: busy ? t("form.saving") : submitLabel ?? t("form.save")
|
|
930
1070
|
})
|
|
931
1071
|
})
|
|
932
1072
|
]
|
|
@@ -934,7 +1074,7 @@ function Form(props) {
|
|
|
934
1074
|
}
|
|
935
1075
|
|
|
936
1076
|
// src/routes.tsx
|
|
937
|
-
import { useCallback as useCallback3, useEffect as
|
|
1077
|
+
import { useCallback as useCallback3, useEffect as useEffect4, useMemo as useMemo3, useState as useState4 } from "react";
|
|
938
1078
|
import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
939
1079
|
function matchRoute(path, pattern) {
|
|
940
1080
|
const clean = (path.split(/[?#]/)[0] ?? "").replace(/\/+$/, "") || "/";
|
|
@@ -971,7 +1111,7 @@ function resolveRoute(routes, path) {
|
|
|
971
1111
|
function GroveRoutes(props) {
|
|
972
1112
|
const { routes, context, fallback, children, gate } = props;
|
|
973
1113
|
const [path, setPath] = useState4(() => window.location.pathname);
|
|
974
|
-
|
|
1114
|
+
useEffect4(() => {
|
|
975
1115
|
const onPop = () => setPath(window.location.pathname);
|
|
976
1116
|
window.addEventListener("popstate", onPop);
|
|
977
1117
|
return () => window.removeEventListener("popstate", onPop);
|
|
@@ -980,7 +1120,7 @@ function GroveRoutes(props) {
|
|
|
980
1120
|
window.history.pushState({}, "", href);
|
|
981
1121
|
setPath(href.split(/[?#]/)[0] ?? href);
|
|
982
1122
|
}, []);
|
|
983
|
-
const value =
|
|
1123
|
+
const value = useMemo3(() => ({ ...context, navigate }), [context, navigate]);
|
|
984
1124
|
const hit = resolveRoute(routes, path);
|
|
985
1125
|
const isRoot = (path.replace(/\/+$/, "") || "/") === "/";
|
|
986
1126
|
const matched = hit ? hit.route.render(hit.params) : fallback ?? (isRoot ? /* @__PURE__ */ jsx5(RouteIndex, {
|
|
@@ -997,12 +1137,13 @@ function GroveRoutes(props) {
|
|
|
997
1137
|
}
|
|
998
1138
|
function NotFound() {
|
|
999
1139
|
const { navigate } = useGroveUi();
|
|
1140
|
+
const t = useUiText();
|
|
1000
1141
|
return /* @__PURE__ */ jsxs4("div", {
|
|
1001
1142
|
className: "flex flex-col gap-2 p-6",
|
|
1002
1143
|
children: [
|
|
1003
1144
|
/* @__PURE__ */ jsx5("p", {
|
|
1004
1145
|
className: "text-sm text-neutral-500",
|
|
1005
|
-
children: "
|
|
1146
|
+
children: t("common.notFound")
|
|
1006
1147
|
}),
|
|
1007
1148
|
/* @__PURE__ */ jsx5("a", {
|
|
1008
1149
|
href: "/",
|
|
@@ -1011,21 +1152,22 @@ function NotFound() {
|
|
|
1011
1152
|
e.preventDefault();
|
|
1012
1153
|
navigate("/");
|
|
1013
1154
|
},
|
|
1014
|
-
children: "
|
|
1155
|
+
children: t("common.home")
|
|
1015
1156
|
})
|
|
1016
1157
|
]
|
|
1017
1158
|
});
|
|
1018
1159
|
}
|
|
1019
1160
|
function RouteIndex({ routes }) {
|
|
1020
1161
|
const { navigate } = useGroveUi();
|
|
1162
|
+
const t = useUiText();
|
|
1021
1163
|
const pages = routes.filter((r) => !r.path.split("/").some((seg) => seg.startsWith(":")));
|
|
1022
1164
|
return /* @__PURE__ */ jsxs4("nav", {
|
|
1023
1165
|
className: "flex flex-col gap-3 p-6",
|
|
1024
|
-
"aria-label": "
|
|
1166
|
+
"aria-label": t("common.pages"),
|
|
1025
1167
|
children: [
|
|
1026
1168
|
/* @__PURE__ */ jsx5("h1", {
|
|
1027
1169
|
className: "text-2xl font-semibold",
|
|
1028
|
-
children: "
|
|
1170
|
+
children: t("common.pages")
|
|
1029
1171
|
}),
|
|
1030
1172
|
/* @__PURE__ */ jsx5("ul", {
|
|
1031
1173
|
className: "flex flex-col gap-1",
|
|
@@ -1055,7 +1197,16 @@ function RouteIndex({ routes }) {
|
|
|
1055
1197
|
import { useCallback as useCallback4, useRef as useRef3, useState as useState5 } from "react";
|
|
1056
1198
|
import { jsx as jsx6, jsxs as jsxs5, Fragment as Fragment2 } from "react/jsx-runtime";
|
|
1057
1199
|
function SignIn(props) {
|
|
1058
|
-
const {
|
|
1200
|
+
const {
|
|
1201
|
+
auth,
|
|
1202
|
+
title,
|
|
1203
|
+
allowRegister = typeof auth.register === "function",
|
|
1204
|
+
onSignedIn,
|
|
1205
|
+
locale,
|
|
1206
|
+
messages,
|
|
1207
|
+
children
|
|
1208
|
+
} = props;
|
|
1209
|
+
const t = useStandaloneUiText(locale, messages);
|
|
1059
1210
|
const [signedIn, setSignedIn] = useState5(() => auth.getTokens() !== null);
|
|
1060
1211
|
const [mode, setMode] = useState5("login");
|
|
1061
1212
|
const [email, setEmail] = useState5("");
|
|
@@ -1079,13 +1230,13 @@ function SignIn(props) {
|
|
|
1079
1230
|
try {
|
|
1080
1231
|
if (mode === "mfa") {
|
|
1081
1232
|
if (!auth.verifyMfa)
|
|
1082
|
-
throw new Error("
|
|
1233
|
+
throw new Error(t("signIn.mfaUnsupported"));
|
|
1083
1234
|
finish(await auth.verifyMfa(code));
|
|
1084
1235
|
return;
|
|
1085
1236
|
}
|
|
1086
1237
|
if (mode === "register") {
|
|
1087
1238
|
if (!auth.register)
|
|
1088
|
-
throw new Error("
|
|
1239
|
+
throw new Error(t("signIn.registerUnavailable"));
|
|
1089
1240
|
await auth.register(email, password, name.trim().length > 0 ? name.trim() : undefined);
|
|
1090
1241
|
}
|
|
1091
1242
|
const user = await auth.login(email, password);
|
|
@@ -1100,12 +1251,12 @@ function SignIn(props) {
|
|
|
1100
1251
|
submitting.current = false;
|
|
1101
1252
|
setBusy(false);
|
|
1102
1253
|
}
|
|
1103
|
-
}, [auth, code, email, finish, mode, name, password]);
|
|
1254
|
+
}, [auth, code, email, finish, mode, name, password, t]);
|
|
1104
1255
|
if (signedIn)
|
|
1105
1256
|
return /* @__PURE__ */ jsx6(Fragment2, {
|
|
1106
1257
|
children
|
|
1107
1258
|
});
|
|
1108
|
-
const heading = mode === "register" ? "
|
|
1259
|
+
const heading = mode === "register" ? t("signIn.register") : mode === "mfa" ? t("signIn.mfa") : title ?? t("signIn.title");
|
|
1109
1260
|
const label = "text-sm text-neutral-600 dark:text-neutral-400";
|
|
1110
1261
|
return /* @__PURE__ */ jsx6("div", {
|
|
1111
1262
|
className: "flex min-h-screen items-center justify-center p-6",
|
|
@@ -1123,7 +1274,7 @@ function SignIn(props) {
|
|
|
1123
1274
|
children: [
|
|
1124
1275
|
/* @__PURE__ */ jsx6("span", {
|
|
1125
1276
|
className: label,
|
|
1126
|
-
children: "
|
|
1277
|
+
children: t("signIn.code")
|
|
1127
1278
|
}),
|
|
1128
1279
|
/* @__PURE__ */ jsx6("input", {
|
|
1129
1280
|
className: INPUT_CLASS,
|
|
@@ -1142,7 +1293,7 @@ function SignIn(props) {
|
|
|
1142
1293
|
children: [
|
|
1143
1294
|
/* @__PURE__ */ jsx6("span", {
|
|
1144
1295
|
className: label,
|
|
1145
|
-
children: "
|
|
1296
|
+
children: t("signIn.name")
|
|
1146
1297
|
}),
|
|
1147
1298
|
/* @__PURE__ */ jsx6("input", {
|
|
1148
1299
|
className: INPUT_CLASS,
|
|
@@ -1157,7 +1308,7 @@ function SignIn(props) {
|
|
|
1157
1308
|
children: [
|
|
1158
1309
|
/* @__PURE__ */ jsx6("span", {
|
|
1159
1310
|
className: label,
|
|
1160
|
-
children: "
|
|
1311
|
+
children: t("signIn.email")
|
|
1161
1312
|
}),
|
|
1162
1313
|
/* @__PURE__ */ jsx6("input", {
|
|
1163
1314
|
className: INPUT_CLASS,
|
|
@@ -1174,7 +1325,7 @@ function SignIn(props) {
|
|
|
1174
1325
|
children: [
|
|
1175
1326
|
/* @__PURE__ */ jsx6("span", {
|
|
1176
1327
|
className: label,
|
|
1177
|
-
children: "
|
|
1328
|
+
children: t("signIn.password")
|
|
1178
1329
|
}),
|
|
1179
1330
|
/* @__PURE__ */ jsx6("input", {
|
|
1180
1331
|
className: INPUT_CLASS,
|
|
@@ -1200,7 +1351,7 @@ function SignIn(props) {
|
|
|
1200
1351
|
type: "submit",
|
|
1201
1352
|
disabled: busy,
|
|
1202
1353
|
className: cx(BUTTON_BASE, VARIANT.default),
|
|
1203
|
-
children: busy ? "
|
|
1354
|
+
children: busy ? t("signIn.wait") : mode === "register" ? t("signIn.submitRegister") : mode === "mfa" ? t("signIn.submitMfa") : t("signIn.submit")
|
|
1204
1355
|
}),
|
|
1205
1356
|
allowRegister && mode !== "mfa" ? /* @__PURE__ */ jsx6("button", {
|
|
1206
1357
|
type: "button",
|
|
@@ -1209,7 +1360,7 @@ function SignIn(props) {
|
|
|
1209
1360
|
setError(null);
|
|
1210
1361
|
setMode(mode === "register" ? "login" : "register");
|
|
1211
1362
|
},
|
|
1212
|
-
children: mode === "register" ? "
|
|
1363
|
+
children: t(mode === "register" ? "signIn.toSignIn" : "signIn.toRegister")
|
|
1213
1364
|
}) : null
|
|
1214
1365
|
]
|
|
1215
1366
|
})
|
|
@@ -1240,7 +1391,12 @@ export {
|
|
|
1240
1391
|
renderMarkdown,
|
|
1241
1392
|
resolveHref,
|
|
1242
1393
|
resolveRoute,
|
|
1394
|
+
uiMessagesFor,
|
|
1243
1395
|
useAction,
|
|
1244
1396
|
useGroveUi,
|
|
1245
|
-
useMessages
|
|
1397
|
+
useMessages,
|
|
1398
|
+
usePageTitle,
|
|
1399
|
+
useStandaloneUiText,
|
|
1400
|
+
useUiMessages,
|
|
1401
|
+
useUiText
|
|
1246
1402
|
};
|
|
@@ -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,49 @@ 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;
|
|
54
|
+
/**
|
|
55
|
+
* The chrome translator for a component that may render OUTSIDE the provider.
|
|
56
|
+
*
|
|
57
|
+
* `SignIn` is the case this exists for: the generated app can mount it around the whole
|
|
58
|
+
* router (`<SignIn><GroveRoutes /></SignIn>`), which puts the form ABOVE the provider that
|
|
59
|
+
* carries the locale — so the sign-in screen of a Spanish app came up in English while every
|
|
60
|
+
* page behind it was translated. Rather than make the app thread a locale through by hand,
|
|
61
|
+
* such a component resolves one itself: an explicit prop, else the provider when there is
|
|
62
|
+
* one, else the viewer's own languages.
|
|
63
|
+
*
|
|
64
|
+
* `available` is the set to match the viewer against — the locales the catalogue ships, since
|
|
65
|
+
* a component outside the provider has no document to ask.
|
|
66
|
+
*/
|
|
67
|
+
export declare function useStandaloneUiText(locale: string | undefined, overrides: Partial<UiMessages> | undefined): (key: UiMessageKey) => string;
|
|
30
68
|
/** Substitute `:param` segments in an href with a document's values. */
|
|
31
69
|
export declare function resolveHref(href: string, doc?: Doc): string;
|
|
32
70
|
export interface DispatchOptions {
|
|
@@ -52,6 +90,8 @@ export declare function useAction(): (action: Action, opts?: DispatchOptions) =>
|
|
|
52
90
|
export declare function formatValue(value: unknown, format: Format | undefined, opts?: {
|
|
53
91
|
locale?: string | undefined;
|
|
54
92
|
currency?: string | undefined;
|
|
93
|
+
/** The chrome catalogue, for the one format that renders words rather than digits. */
|
|
94
|
+
messages?: Pick<UiMessages, 'common.yes' | 'common.no'> | undefined;
|
|
55
95
|
}): string;
|
|
56
96
|
/**
|
|
57
97
|
* A translation lookup over the catalogue a multilingual screen carries, reading the active
|
|
@@ -60,6 +100,17 @@ export declare function formatValue(value: unknown, format: Format | undefined,
|
|
|
60
100
|
* set for all of them at once.
|
|
61
101
|
*/
|
|
62
102
|
export declare function useMessages(messages: Record<string, Record<string, string>>, fallback: string): (key: string) => string;
|
|
103
|
+
/**
|
|
104
|
+
* Sets the browser tab's title while a screen is mounted, restoring the previous one when it
|
|
105
|
+
* unmounts (a screen with no title of its own must not inherit the last one's).
|
|
106
|
+
*
|
|
107
|
+
* A generated screen calls this with its OWN translated title, which is why the title is not
|
|
108
|
+
* taken from the `ROUTES` table: the table is module data, evaluated before any locale is
|
|
109
|
+
* known, so a title read from there is frozen in the document's default language. The route's
|
|
110
|
+
* `title` stays as the index's label and as what an external router reads; the tab follows
|
|
111
|
+
* the viewer's language.
|
|
112
|
+
*/
|
|
113
|
+
export declare function usePageTitle(title: string | undefined): void;
|
|
63
114
|
/**
|
|
64
115
|
* The locale to start in: the first of the viewer's preferred languages the document has,
|
|
65
116
|
* 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.5.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",
|