@protobase/ui 0.23.4 → 0.23.6

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.
Files changed (81) hide show
  1. package/dist/app/app.d.ts +4 -1
  2. package/dist/app/app.js +22 -18
  3. package/dist/app/charts/chart-groups.js +3 -2
  4. package/dist/app/charts/period-chart.js +3 -2
  5. package/dist/app/create/create-values.js +1 -1
  6. package/dist/app/custom-section.js +1 -1
  7. package/dist/app/displays/board-column.js +2 -1
  8. package/dist/app/displays/custom-display.js +1 -1
  9. package/dist/app/displays/display-frame.js +3 -2
  10. package/dist/app/displays/map-display.js +4 -3
  11. package/dist/app/embedded-list/cut-off-note.js +2 -1
  12. package/dist/app/hand-order/hand-sorted-list.js +2 -1
  13. package/dist/app/hand-order/use-hand-order.js +1 -1
  14. package/dist/app/import/import-check-step.js +3 -2
  15. package/dist/app/import/import-counts.js +2 -1
  16. package/dist/app/import/import-dialog.js +2 -1
  17. package/dist/app/import/import-result-step.js +2 -1
  18. package/dist/app/incoming-relations.js +1 -1
  19. package/dist/app/list-edit/use-list-edits.js +1 -1
  20. package/dist/app/list-page.js +1 -1
  21. package/dist/app/main.js +3 -1
  22. package/dist/app/map/records-map.js +4 -3
  23. package/dist/app/meta-gate-messages.js +16 -0
  24. package/dist/app/meta-gate.js +13 -4
  25. package/dist/app/nav-recent.js +1 -1
  26. package/dist/app/pages/blocks/link.js +1 -1
  27. package/dist/app/pages/blocks/progress.js +3 -2
  28. package/dist/app/pages/blocks/stat.js +2 -1
  29. package/dist/app/pages/render-node.js +1 -1
  30. package/dist/app/pages/use-layout-data.js +1 -1
  31. package/dist/app/record-preview.js +1 -1
  32. package/dist/app/record-row/record-row-body.js +2 -1
  33. package/dist/app/saved/saved-records.js +1 -1
  34. package/dist/app/search/messages.js +18 -0
  35. package/dist/app/search/search-model.js +6 -5
  36. package/dist/app/searches/saved-search-rows.js +1 -1
  37. package/dist/app/searches/saved-searches.js +1 -1
  38. package/dist/app/searches/update-search-dialog.js +1 -1
  39. package/dist/app/searches/use-save-search.js +1 -1
  40. package/dist/app-shell/bottom-bar.js +3 -2
  41. package/dist/app-shell/breadcrumb.js +2 -1
  42. package/dist/app-shell/drawer.js +2 -1
  43. package/dist/app-shell/global-search.js +5 -4
  44. package/dist/app-shell/messages.js +64 -0
  45. package/dist/app-shell/profile-menu.js +5 -4
  46. package/dist/app-shell/sidebar-group.js +6 -5
  47. package/dist/app-shell/sidebar.js +4 -3
  48. package/dist/app-shell/theme-switch.js +8 -7
  49. package/dist/app-shell/top-bar.js +5 -4
  50. package/dist/data/meta-data.d.ts +3 -0
  51. package/dist/data/use-meta.js +1 -0
  52. package/dist/data/use-record.js +1 -1
  53. package/dist/data/use-update-record.js +1 -1
  54. package/dist/format/date.js +42 -50
  55. package/dist/format/intl-cache.js +12 -0
  56. package/dist/format/messages.js +24 -0
  57. package/dist/format/money.js +9 -16
  58. package/dist/format/number-text.js +3 -2
  59. package/dist/format/number.js +7 -5
  60. package/dist/format/zone.js +3 -5
  61. package/dist/invitations/pending-invitation-row.js +2 -1
  62. package/dist/live/display-value.js +2 -1
  63. package/dist/locale/active-locale.js +24 -0
  64. package/dist/locale/app-locale.js +18 -0
  65. package/dist/locale/messages.js +12 -0
  66. package/dist/organizations/organizations-page.js +2 -1
  67. package/dist/package.js +1 -1
  68. package/dist/shell/assets/app-ByxJ0Flo.js +69 -0
  69. package/dist/shell/assets/export-worker-O1HF9R4j.js +2 -0
  70. package/dist/shell/assets/{index-Dxk5GdSQ.js → index-CY5B9PIg.js} +2 -2
  71. package/dist/shell/assets/{maplibre-CzKsADHj.js → maplibre-D4ace4wn.js} +2 -2
  72. package/dist/shell/assets/{markdown-content-pV-6NJbB.js → markdown-content-JU2jw1Kr.js} +1 -1
  73. package/dist/shell/assets/{record-map-CVhzR4LM.js → record-map-B8GB--Ku.js} +1 -1
  74. package/dist/shell/assets/records-map-BUfPiD0T.js +1 -0
  75. package/dist/shell/assets/{shared-protobase-ui-BxPtBpOH.js → shared-protobase-ui-C0Ld98uO.js} +1 -1
  76. package/dist/shell/index.html +3 -3
  77. package/dist/shell/protobase.shell.json +1 -1
  78. package/package.json +4 -4
  79. package/dist/shell/assets/app-CZR9_391.js +0 -69
  80. package/dist/shell/assets/export-worker-C1djGgjD.js +0 -2
  81. package/dist/shell/assets/records-map-CJLKyY62.js +0 -1
@@ -8,8 +8,8 @@ import { InlineFilterChips } from "./filter-chip-list.js";
8
8
  import { RecordRowBody } from "../record-row/record-row-body.js";
9
9
  import { useSavedSearches } from "./use-saved-searches.js";
10
10
  import { useSearchRow } from "./use-search-row.js";
11
- import { jsx, jsxs } from "react/jsx-runtime";
12
11
  import { encodeKey } from "@protobase/schema/internal";
12
+ import { jsx, jsxs } from "react/jsx-runtime";
13
13
  //#region src/app/searches/saved-search-rows.tsx
14
14
  const SearchRowItem = ({ model, view, target, targetView, row }) => {
15
15
  const key = encodeKey(recordKey(model, row));
@@ -9,8 +9,8 @@ import { can } from "../permissions.js";
9
9
  import { useSavedSearches } from "./use-saved-searches.js";
10
10
  import { useDeleteFlow } from "../delete/use-delete-flow.js";
11
11
  import { SavedSearchSection } from "./saved-search-section.js";
12
- import { jsx, jsxs } from "react/jsx-runtime";
13
12
  import { encodeKey } from "@protobase/schema/internal";
13
+ import { jsx, jsxs } from "react/jsx-runtime";
14
14
  //#region src/app/searches/saved-searches.tsx
15
15
  /**
16
16
  * The list page of a view with `searchesOf`: each of the user's saved searches, with what it finds (`SavedSearchSection`),
@@ -9,9 +9,9 @@ import { useFilterChips } from "./use-filter-chips.js";
9
9
  import { EmbeddedList } from "../embedded-list/embedded-list.js";
10
10
  import { addedFilter, filterChanges, netChange, removedFilter } from "./search-diff.js";
11
11
  import { useMemo } from "react";
12
+ import { tableDisplay } from "@protobase/schema/internal";
12
13
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
13
14
  import { useQuery } from "@tanstack/react-query";
14
- import { tableDisplay } from "@protobase/schema/internal";
15
15
  //#region src/app/searches/update-search-dialog.tsx
16
16
  /** How many records each side of the diff shows; its count says how many there are in all. */
17
17
  const shownLimit = 12;
@@ -8,8 +8,8 @@ import { can } from "../permissions.js";
8
8
  import { useRefreshResource } from "../pages/use-layout-data.js";
9
9
  import { useSavedSearches } from "./use-saved-searches.js";
10
10
  import { useOpenedSearch } from "./use-opened-search.js";
11
- import { useMutation } from "@tanstack/react-query";
12
11
  import { encodeKey } from "@protobase/schema/internal";
12
+ import { useMutation } from "@tanstack/react-query";
13
13
  //#region src/app/searches/use-save-search.ts
14
14
  const message = (error) => error instanceof Error ? error.message : "Something went wrong";
15
15
  /**
@@ -1,4 +1,5 @@
1
1
  import { cn } from "../lib/cn.js";
2
+ import { text } from "./messages.js";
2
3
  import { Ellipsis } from "lucide-react";
3
4
  import { jsx, jsxs } from "react/jsx-runtime";
4
5
  //#region src/app-shell/bottom-bar.tsx
@@ -13,7 +14,7 @@ const BottomBar = ({ tabs, onNavigate, onMore, moreOpen, moreControls, sticky, r
13
14
  const moreCurrent = !tabs.some((item) => item.active);
14
15
  return /* @__PURE__ */ jsxs("nav", {
15
16
  ref,
16
- "aria-label": "Tabs",
17
+ "aria-label": text("tabs"),
17
18
  className: cn("flex shrink-0 border-t bg-background pb-[env(safe-area-inset-bottom)] md:hidden", sticky && "sticky bottom-0 z-30"),
18
19
  children: [tabs.map((item) => {
19
20
  const Icon = item.icon;
@@ -50,7 +51,7 @@ const BottomBar = ({ tabs, onNavigate, onMore, moreOpen, moreControls, sticky, r
50
51
  className: "size-6",
51
52
  strokeWidth: moreCurrent ? 2.2 : 1.9,
52
53
  "aria-hidden": true
53
- }), "More"]
54
+ }), text("more")]
54
55
  })]
55
56
  });
56
57
  };
@@ -1,4 +1,5 @@
1
1
  import { cn } from "../lib/cn.js";
2
+ import { text } from "./messages.js";
2
3
  import { Skeleton } from "../primitives/skeleton.js";
3
4
  import { ChevronRight } from "lucide-react";
4
5
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -7,7 +8,7 @@ import { jsx, jsxs } from "react/jsx-runtime";
7
8
  const inApp = (event) => event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
8
9
  /** The trail of where you are; the last crumb, the current page, shows on phones too. */
9
10
  const Breadcrumb = ({ parts, onNavigate }) => /* @__PURE__ */ jsx("nav", {
10
- "aria-label": "Breadcrumb",
11
+ "aria-label": text("breadcrumb"),
11
12
  className: "flex min-w-0 items-center gap-1.5",
12
13
  children: parts.map((part, index) => {
13
14
  const last = index === parts.length - 1;
@@ -1,3 +1,4 @@
1
+ import { text } from "./messages.js";
1
2
  import { useFocusTrap } from "../lib/use-focus-trap.js";
2
3
  import { useRef } from "react";
3
4
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -18,7 +19,7 @@ const Drawer = ({ id, open, onClose, children }) => {
18
19
  id,
19
20
  role: "dialog",
20
21
  "aria-modal": "true",
21
- "aria-label": "Navigation",
22
+ "aria-label": text("navigation"),
22
23
  tabIndex: -1,
23
24
  className: "absolute inset-y-0 left-0 flex w-60 max-w-[85vw] bg-sidebar shadow-pop outline-none",
24
25
  onClick: (event) => event.target.closest("a") && onClose(),
@@ -1,4 +1,5 @@
1
1
  import { cn } from "../lib/cn.js";
2
+ import { text } from "./messages.js";
2
3
  import { Input } from "../primitives/input.js";
3
4
  import { Kbd } from "../primitives/kbd.js";
4
5
  import { Spinner } from "../primitives/spinner.js";
@@ -65,7 +66,7 @@ const GlobalSearch = ({ search, className = "w-96", autoFocus, showShortcut = tr
65
66
  leading: search.loading ? /* @__PURE__ */ jsx(Spinner, { className: "size-4" }) : /* @__PURE__ */ jsx(Search, { className: "size-4" }),
66
67
  trailing: search.text ? /* @__PURE__ */ jsx("button", {
67
68
  type: "button",
68
- "aria-label": "Clear search",
69
+ "aria-label": text("clearSearch"),
69
70
  className: "-mr-1 inline-flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground",
70
71
  onMouseDown: (event) => event.preventDefault(),
71
72
  onClick: clear,
@@ -75,7 +76,7 @@ const GlobalSearch = ({ search, className = "w-96", autoFocus, showShortcut = tr
75
76
  children: /* @__PURE__ */ jsx(Kbd, { children: "⌘K" })
76
77
  }) : void 0,
77
78
  placeholder: search.placeholder,
78
- "aria-label": "Global search",
79
+ "aria-label": text("globalSearch"),
79
80
  role: "combobox",
80
81
  "aria-autocomplete": "list",
81
82
  "aria-expanded": open,
@@ -93,11 +94,11 @@ const GlobalSearch = ({ search, className = "w-96", autoFocus, showShortcut = tr
93
94
  }), open && /* @__PURE__ */ jsxs("div", {
94
95
  id: listId,
95
96
  role: "listbox",
96
- "aria-label": "Search results",
97
+ "aria-label": text("searchResults"),
97
98
  className: "absolute right-0 top-full z-40 mt-1 max-h-96 w-full min-w-72 overflow-y-auto rounded-lg border bg-background py-1 shadow-pop",
98
99
  children: [search.groups.length === 0 && /* @__PURE__ */ jsx("p", {
99
100
  className: "px-3 py-2 text-muted-foreground",
100
- children: search.loading ? "Searching" : `No matches for “${search.query}”`
101
+ children: search.loading ? text("searching") : text("noMatches", search.query)
101
102
  }), search.groups.map((group) => /* @__PURE__ */ jsxs("div", {
102
103
  role: "group",
103
104
  "aria-label": group.label,
@@ -0,0 +1,64 @@
1
+ import { defineMessages } from "../locale/messages.js";
2
+ //#region src/app-shell/messages.ts
3
+ const text = defineMessages({
4
+ en: {
5
+ navigation: "Navigation",
6
+ resources: "Resources",
7
+ breadcrumb: "Breadcrumb",
8
+ tabs: "Tabs",
9
+ more: "More",
10
+ openNavigation: "Open navigation",
11
+ backTo: (label) => `Back to ${label}`,
12
+ search: "Search",
13
+ closeSearch: "Close search",
14
+ clearSearch: "Clear search",
15
+ globalSearch: "Global search",
16
+ searchResults: "Search results",
17
+ searching: "Searching",
18
+ noMatches: (query) => `No matches for “${query}”`,
19
+ profileMenu: "Profile menu",
20
+ organizations: "Organizations",
21
+ roles: "Roles",
22
+ signIn: "Sign in",
23
+ signOut: "Sign out",
24
+ theme: "Theme",
25
+ light: "Light",
26
+ dark: "Dark",
27
+ system: "System",
28
+ recent: (plural) => `Recent ${plural}`,
29
+ loading: "Loading...",
30
+ couldNotLoad: "Could not load",
31
+ viewAll: "View all"
32
+ },
33
+ nl: {
34
+ navigation: "Navigatie",
35
+ resources: "Onderdelen",
36
+ breadcrumb: "Kruimelpad",
37
+ tabs: "Tabbladen",
38
+ more: "Meer",
39
+ openNavigation: "Navigatie openen",
40
+ backTo: (label) => `Terug naar ${label}`,
41
+ search: "Zoeken",
42
+ closeSearch: "Zoeken sluiten",
43
+ clearSearch: "Zoekopdracht wissen",
44
+ globalSearch: "Overal zoeken",
45
+ searchResults: "Zoekresultaten",
46
+ searching: "Zoeken",
47
+ noMatches: (query) => `Niets gevonden voor “${query}”`,
48
+ profileMenu: "Profielmenu",
49
+ organizations: "Organisaties",
50
+ roles: "Rollen",
51
+ signIn: "Inloggen",
52
+ signOut: "Uitloggen",
53
+ theme: "Thema",
54
+ light: "Licht",
55
+ dark: "Donker",
56
+ system: "Systeem",
57
+ recent: (plural) => `Recente ${plural}`,
58
+ loading: "Laden...",
59
+ couldNotLoad: "Kon niet laden",
60
+ viewAll: "Alles bekijken"
61
+ }
62
+ });
63
+ //#endregion
64
+ export { text };
@@ -1,4 +1,5 @@
1
1
  import { cn } from "../lib/cn.js";
2
+ import { text } from "./messages.js";
2
3
  import { Avatar } from "../primitives/avatar.js";
3
4
  import { Badge } from "../primitives/badge.js";
4
5
  import { SwitchGroup } from "./switch-group.js";
@@ -41,7 +42,7 @@ const ProfileMenu = ({ user, compact, open: controlled, items = [], onNavigate,
41
42
  children: [/* @__PURE__ */ jsxs("button", {
42
43
  type: "button",
43
44
  className: "flex min-h-11 w-full items-center gap-2.5 rounded-md p-1 text-left hover:bg-muted md:min-h-0",
44
- "aria-label": "Profile menu",
45
+ "aria-label": text("profileMenu"),
45
46
  "aria-haspopup": "menu",
46
47
  "aria-expanded": open,
47
48
  onClick: toggle,
@@ -81,7 +82,7 @@ const ProfileMenu = ({ user, compact, open: controlled, items = [], onNavigate,
81
82
  })]
82
83
  }),
83
84
  organizations && (organizations.current || organizations.others.length > 0) && /* @__PURE__ */ jsx(SwitchGroup, {
84
- label: "Organizations",
85
+ label: text("organizations"),
85
86
  ...organizations.current && { current: {
86
87
  id: organizations.current.id,
87
88
  label: organizations.current.name
@@ -97,7 +98,7 @@ const ProfileMenu = ({ user, compact, open: controlled, items = [], onNavigate,
97
98
  }
98
99
  }),
99
100
  roles && roles.roles.length > 1 && /* @__PURE__ */ jsx(SwitchGroup, {
100
- label: "Roles",
101
+ label: text("roles"),
101
102
  ...activeRole && { current: {
102
103
  id: activeRole.name,
103
104
  label: activeRole.label
@@ -147,7 +148,7 @@ const ProfileMenu = ({ user, compact, open: controlled, items = [], onNavigate,
147
148
  role: "menuitem",
148
149
  onClick: onSignOut,
149
150
  className: menuRow,
150
- children: [/* @__PURE__ */ jsx(LogOut, { className: "size-4" }), "Sign out"]
151
+ children: [/* @__PURE__ */ jsx(LogOut, { className: "size-4" }), text("signOut")]
151
152
  })
152
153
  ]
153
154
  })]
@@ -1,4 +1,5 @@
1
1
  import { cn } from "../lib/cn.js";
2
+ import { text } from "./messages.js";
2
3
  import { navOpenStorageKey, readNavOpen, writeNavOpen } from "./nav-open-storage.js";
3
4
  import { SidebarItem } from "./sidebar-item.js";
4
5
  import { StatusDot } from "./status-dot.js";
@@ -49,23 +50,23 @@ const SidebarGroup = ({ item, recent, mode, active, onNavigate }) => {
49
50
  type: "button",
50
51
  "aria-expanded": open,
51
52
  "aria-controls": listId,
52
- "aria-label": `Recent ${item.label.toLowerCase()}`,
53
+ "aria-label": text("recent", item.label.toLowerCase()),
53
54
  onClick: toggle,
54
55
  className: "absolute right-1 top-1/2 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded text-faint-foreground hover:bg-muted hover:text-foreground",
55
56
  children: /* @__PURE__ */ jsx(ChevronRight, { className: cn("size-3.5 transition-transform", open && "rotate-90") })
56
57
  })]
57
58
  }), open && /* @__PURE__ */ jsxs("ul", {
58
59
  id: listId,
59
- "aria-label": `Recent ${item.label.toLowerCase()}`,
60
+ "aria-label": text("recent", item.label.toLowerCase()),
60
61
  className: cn("flex flex-col gap-0.5 border-l pl-1.5", indent[mode]),
61
62
  children: [
62
63
  recent.state === "loading" && /* @__PURE__ */ jsx("li", {
63
64
  className: cn("flex items-center text-faint-foreground", row[mode]),
64
- children: "Loading..."
65
+ children: text("loading")
65
66
  }),
66
67
  recent.state === "error" && /* @__PURE__ */ jsx("li", {
67
68
  className: cn("flex items-center text-faint-foreground", row[mode]),
68
- children: "Could not load"
69
+ children: text("couldNotLoad")
69
70
  }),
70
71
  recent.state === "ready" && recent.records.map((record) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("a", {
71
72
  ...link(record.href),
@@ -84,7 +85,7 @@ const SidebarGroup = ({ item, recent, mode, active, onNavigate }) => {
84
85
  /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("a", {
85
86
  ...link(href),
86
87
  className: cn("flex items-center rounded-md text-faint-foreground transition-colors hover:bg-muted hover:text-foreground", row[mode]),
87
- children: "View all"
88
+ children: text("viewAll")
88
89
  }) })
89
90
  ]
90
91
  })]
@@ -1,4 +1,5 @@
1
1
  import { cn } from "../lib/cn.js";
2
+ import { text } from "./messages.js";
2
3
  import { RouteLink } from "../primitives/route-link.js";
3
4
  import { Logo } from "./logo.js";
4
5
  import { navGroups } from "./nav.js";
@@ -15,7 +16,7 @@ const Sidebar = ({ mode, activeItem, user, workspace, appName, forceExpanded, pr
15
16
  const expanded = expanding && forceExpanded;
16
17
  const inline = isTextMode(mode) || expanding;
17
18
  return /* @__PURE__ */ jsx("nav", {
18
- "aria-label": "Resources",
19
+ "aria-label": text("resources"),
19
20
  className: cn(sticky ? "sticky top-0 h-dvh self-start" : "relative", "z-30 shrink-0", sidebarWidth[mode]),
20
21
  children: /* @__PURE__ */ jsxs("div", {
21
22
  className: cn("group/side absolute inset-y-0 left-0 flex flex-col overflow-hidden border-r bg-sidebar", expanding ? "w-14 transition-[width,box-shadow] duration-150 hover:w-60 hover:shadow-pop focus-within:w-60 focus-within:shadow-pop" : "w-full", expanded && "w-60 shadow-pop"),
@@ -66,11 +67,11 @@ const Sidebar = ({ mode, activeItem, user, workspace, appName, forceExpanded, pr
66
67
  className: "flex min-h-11 w-full items-center gap-2.5 whitespace-nowrap p-1.5 text-muted-foreground md:min-h-0"
67
68
  }), signIn && /* @__PURE__ */ jsxs(RouteLink, {
68
69
  to: signIn,
69
- "aria-label": "Sign in",
70
+ "aria-label": text("signIn"),
70
71
  className: "flex min-h-11 w-full items-center gap-2.5 rounded-md p-1.5 text-left font-medium text-muted-foreground hover:bg-muted hover:text-foreground md:min-h-0",
71
72
  children: [/* @__PURE__ */ jsx(LogIn, { className: "size-4 shrink-0" }), inline && /* @__PURE__ */ jsx("span", {
72
73
  className: "whitespace-nowrap",
73
- children: "Sign in"
74
+ children: text("signIn")
74
75
  })]
75
76
  })] })
76
77
  })
@@ -1,21 +1,22 @@
1
+ import { text } from "./messages.js";
1
2
  import { Segmented } from "../primitives/segmented.js";
2
3
  import { Monitor, Moon, Sun, SunMoon } from "lucide-react";
3
4
  import { jsx, jsxs } from "react/jsx-runtime";
4
5
  //#region src/app-shell/theme-switch.tsx
5
- const options = [
6
+ const options = () => [
6
7
  {
7
8
  value: "light",
8
- label: "Light",
9
+ label: text("light"),
9
10
  icon: Sun
10
11
  },
11
12
  {
12
13
  value: "dark",
13
- label: "Dark",
14
+ label: text("dark"),
14
15
  icon: Moon
15
16
  },
16
17
  {
17
18
  value: "system",
18
- label: "System",
19
+ label: text("system"),
19
20
  icon: Monitor
20
21
  }
21
22
  ];
@@ -26,13 +27,13 @@ const ThemeSwitch = ({ theme, className }) => /* @__PURE__ */ jsxs("div", {
26
27
  /* @__PURE__ */ jsx(SunMoon, { className: "size-4" }),
27
28
  /* @__PURE__ */ jsx("span", {
28
29
  className: "flex-1",
29
- children: "Theme"
30
+ children: text("theme")
30
31
  }),
31
32
  /* @__PURE__ */ jsx(Segmented, {
32
- label: "Theme",
33
+ label: text("theme"),
33
34
  iconOnly: true,
34
35
  value: theme.preference,
35
- options,
36
+ options: options(),
36
37
  onChange: theme.onChange
37
38
  })
38
39
  ]
@@ -1,4 +1,5 @@
1
1
  import { cn } from "../lib/cn.js";
2
+ import { text } from "./messages.js";
2
3
  import { Breadcrumb, inApp } from "./breadcrumb.js";
3
4
  import { backTarget } from "./back-target.js";
4
5
  import { GlobalSearch } from "./global-search.js";
@@ -30,7 +31,7 @@ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControl
30
31
  children: [
31
32
  onMenu && /* @__PURE__ */ jsx("button", {
32
33
  type: "button",
33
- "aria-label": "Open navigation",
34
+ "aria-label": text("openNavigation"),
34
35
  "aria-expanded": Boolean(menuOpen),
35
36
  "aria-controls": menuControls,
36
37
  onClick: onMenu,
@@ -39,7 +40,7 @@ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControl
39
40
  }),
40
41
  back && /* @__PURE__ */ jsx("a", {
41
42
  href: back.href,
42
- "aria-label": `Back to ${back.label}`,
43
+ "aria-label": text("backTo", back.label),
43
44
  onClick: (event) => {
44
45
  if (!onNavigate || !inApp(event)) return;
45
46
  event.preventDefault();
@@ -63,7 +64,7 @@ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControl
63
64
  })
64
65
  }), /* @__PURE__ */ jsx("button", {
65
66
  type: "button",
66
- "aria-label": "Search",
67
+ "aria-label": text("search"),
67
68
  "aria-expanded": searchOpen,
68
69
  onClick: () => setSearchOpen(true),
69
70
  className: "inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted md:hidden",
@@ -91,7 +92,7 @@ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControl
91
92
  }
92
93
  }), /* @__PURE__ */ jsx("button", {
93
94
  type: "button",
94
- "aria-label": "Close search",
95
+ "aria-label": text("closeSearch"),
95
96
  onClick: () => setSearchOpen(false),
96
97
  className: "inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted md:hidden",
97
98
  children: /* @__PURE__ */ jsx(X, { className: "size-5" })
@@ -1,4 +1,5 @@
1
1
  import { MapLayerModel, PluginRef, ResourcePermissions, SourceRef } from "@protobase/client";
2
+ import { Locale } from "@protobase/schema/internal";
2
3
  import { BottomBarModel, ResourceModel, SignInViewModel, UserMenuModel, ViewModel } from "@protobase/schema";
3
4
  import { PageModel } from "@protobase/layout";
4
5
  //#region src/data/meta-data.d.ts
@@ -34,6 +35,8 @@ export type MetaData = {
34
35
  style: string;
35
36
  layers?: MapLayerModel[];
36
37
  };
38
+ /** The language the app's screens are in for everyone, when it sets one (see `AppLocale`). */
39
+ locale?: Locale;
37
40
  /** The hosts besides the app's own whose images are drawn with the page's referrer (see `FileImage`). */
38
41
  files?: {
39
42
  trustedHosts: string[];
@@ -38,6 +38,7 @@ const useMeta = () => {
38
38
  ...result.meta.runtime && { runtime: result.meta.runtime },
39
39
  ...result.meta.errorReporting && { errorReporting: result.meta.errorReporting },
40
40
  ...result.meta.maps && { maps: result.meta.maps },
41
+ ...result.meta.locale && { locale: result.meta.locale },
41
42
  ...result.meta.files && { files: result.meta.files },
42
43
  ...result.meta.plugins && { plugins: result.meta.plugins },
43
44
  ...result.meta.sources && { sources: result.meta.sources.map((source) => ({
@@ -1,7 +1,7 @@
1
1
  import { keys } from "./query-keys.js";
2
2
  import { useClient } from "./api-provider.js";
3
- import { useQuery, useQueryClient } from "@tanstack/react-query";
4
3
  import { encodeKey } from "@protobase/schema/internal";
4
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
5
5
  //#region src/data/use-record.ts
6
6
  const useRecord = (resource, key, enabled = true) => {
7
7
  const client = useClient();
@@ -1,8 +1,8 @@
1
1
  import { keys } from "./query-keys.js";
2
2
  import { useClient } from "./api-provider.js";
3
3
  import { PreconditionFailedError } from "@protobase/client";
4
- import { useMutation, useQueryClient } from "@tanstack/react-query";
5
4
  import { encodeKey } from "@protobase/schema/internal";
5
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
6
6
  //#region src/data/use-update-record.ts
7
7
  /**
8
8
  * PATCH with `If-Match`. On success the cached record gets the new ETag and lists refetch;
@@ -1,77 +1,69 @@
1
+ import { cachedFormat } from "./intl-cache.js";
2
+ import { text } from "./messages.js";
1
3
  import { viewerZone } from "./zone.js";
2
4
  //#region src/format/date.ts
3
- const day = new Intl.DateTimeFormat("en-GB", {
5
+ const dateFormat = (name, options) => () => cachedFormat(`date:${name}`, (tag) => new Intl.DateTimeFormat(tag, {
6
+ ...options,
7
+ timeZone: "UTC"
8
+ }));
9
+ const day = dateFormat("day", {
4
10
  day: "numeric",
5
11
  month: "short",
6
- year: "numeric",
7
- timeZone: "UTC"
12
+ year: "numeric"
8
13
  });
9
- const dayShort = new Intl.DateTimeFormat("en-GB", {
14
+ const dayShort = dateFormat("dayShort", {
10
15
  day: "numeric",
11
- month: "short",
12
- timeZone: "UTC"
16
+ month: "short"
13
17
  });
14
- const month = new Intl.DateTimeFormat("en-GB", {
18
+ const month = dateFormat("month", {
15
19
  month: "long",
16
- year: "numeric",
17
- timeZone: "UTC"
20
+ year: "numeric"
18
21
  });
19
- const weekday = new Intl.DateTimeFormat("en-GB", {
20
- weekday: "short",
21
- timeZone: "UTC"
22
- });
23
- const weekdayDate = new Intl.DateTimeFormat("en-GB", {
22
+ const weekday = dateFormat("weekday", { weekday: "short" });
23
+ const weekdayDate = dateFormat("weekdayDate", {
24
24
  weekday: "long",
25
25
  day: "numeric",
26
26
  month: "short",
27
- year: "numeric",
28
- timeZone: "UTC"
27
+ year: "numeric"
29
28
  });
30
- const formatDate = (ms) => day.format(ms);
31
- const formatDayShort = (ms) => dayShort.format(ms);
32
- const zoned = /* @__PURE__ */ new Map();
33
- const zonedFormats = (zone) => {
34
- const known = zoned.get(zone);
35
- if (known) return known;
36
- const formats = {
37
- day: new Intl.DateTimeFormat("en-GB", {
38
- day: "numeric",
39
- month: "short",
40
- year: "numeric",
41
- timeZone: zone
42
- }),
43
- time: new Intl.DateTimeFormat("en-GB", {
44
- hour: "2-digit",
45
- minute: "2-digit",
46
- hour12: false,
47
- timeZone: zone
48
- })
49
- };
50
- zoned.set(zone, formats);
51
- return formats;
52
- };
29
+ const formatDate = (ms) => day().format(ms);
30
+ const formatDayShort = (ms) => dayShort().format(ms);
31
+ const zonedFormats = (zone) => cachedFormat(`zoned:${zone}`, (tag) => ({
32
+ day: new Intl.DateTimeFormat(tag, {
33
+ day: "numeric",
34
+ month: "short",
35
+ year: "numeric",
36
+ timeZone: zone
37
+ }),
38
+ time: new Intl.DateTimeFormat(tag, {
39
+ hour: "2-digit",
40
+ minute: "2-digit",
41
+ hour12: false,
42
+ timeZone: zone
43
+ })
44
+ }));
53
45
  /** "6 Oct 2026, 16:32": an instant on the clock of `zone`, the viewer's unless given. */
54
46
  const formatDateTime = (ms, zone = viewerZone()) => {
55
47
  const formats = zonedFormats(zone);
56
48
  return `${formats.day.format(ms)}, ${formats.time.format(ms)}`;
57
49
  };
58
50
  /** "October 2026". */
59
- const formatMonth = (ms) => month.format(ms);
51
+ const formatMonth = (ms) => month().format(ms);
60
52
  /** "Mon". */
61
- const formatWeekday = (ms) => weekday.format(ms);
53
+ const formatWeekday = (ms) => weekday().format(ms);
62
54
  /** "Monday, 12 Oct 2026". */
63
- const formatWeekdayDate = (ms) => weekdayDate.format(ms);
55
+ const formatWeekdayDate = (ms) => weekdayDate().format(ms);
64
56
  /** "12–18 Oct 2026", "28 Dec 2026 – 3 Jan 2027": the days from `from` to `to`, both included. */
65
- const formatDayRange = (from, to) => day.formatRange(from, to);
57
+ const formatDayRange = (from, to) => day().formatRange(from, to);
66
58
  /** "09:30": an instant's time on the clock of `zone`, the viewer's unless given. */
67
59
  const formatTime = (ms, zone = viewerZone()) => zonedFormats(zone).time.format(ms);
68
60
  const formatAgo = (seconds) => {
69
- if (seconds < 60) return `${seconds} s ago`;
70
- if (seconds < 3600) return `${Math.floor(seconds / 60)} min ago`;
71
- if (seconds < 86400) return `${Math.floor(seconds / 3600)} h ago`;
72
- return `${Math.floor(seconds / 86400)} d ago`;
61
+ if (seconds < 60) return text("secondsAgo", seconds);
62
+ if (seconds < 3600) return text("minutesAgo", Math.floor(seconds / 60));
63
+ if (seconds < 86400) return text("hoursAgo", Math.floor(seconds / 3600));
64
+ return text("daysAgo", Math.floor(seconds / 86400));
73
65
  };
74
- const relative = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
66
+ const relative = () => cachedFormat("relative", (tag) => new Intl.RelativeTimeFormat(tag, { numeric: "auto" }));
75
67
  const units = [
76
68
  ["year", 31536e3],
77
69
  ["month", 2592e3],
@@ -83,8 +75,8 @@ const units = [
83
75
  const formatRelativeTime = (seconds) => {
84
76
  const away = Math.abs(seconds);
85
77
  const [unit, size] = units.find(([, size]) => away >= size) ?? ["second", 1];
86
- if (unit === "second" && away < 45) return "just now";
87
- return relative.format(-Math.sign(seconds) * Math.floor(away / size), unit);
78
+ if (unit === "second" && away < 45) return text("justNow");
79
+ return relative().format(-Math.sign(seconds) * Math.floor(away / size), unit);
88
80
  };
89
81
  //#endregion
90
82
  export { formatAgo, formatDate, formatDateTime, formatDayRange, formatDayShort, formatMonth, formatRelativeTime, formatTime, formatWeekday, formatWeekdayDate };
@@ -0,0 +1,12 @@
1
+ import { formatTag } from "../locale/active-locale.js";
2
+ //#region src/format/intl-cache.ts
3
+ const cache = /* @__PURE__ */ new Map();
4
+ /** One `Intl` formatter per language tag and `key`, made with `make` for the active locale's tag the first time. */
5
+ const cachedFormat = (key, make) => {
6
+ const tag = formatTag();
7
+ const id = `${tag}|${key}`;
8
+ if (!cache.has(id)) cache.set(id, make(tag));
9
+ return cache.get(id);
10
+ };
11
+ //#endregion
12
+ export { cachedFormat };
@@ -0,0 +1,24 @@
1
+ import { defineMessages } from "../locale/messages.js";
2
+ //#region src/format/messages.ts
3
+ const text = defineMessages({
4
+ en: {
5
+ rows: (count) => `${count} rows`,
6
+ aboutRows: (count) => `about ${count} rows`,
7
+ secondsAgo: (count) => `${count} s ago`,
8
+ minutesAgo: (count) => `${count} min ago`,
9
+ hoursAgo: (count) => `${count} h ago`,
10
+ daysAgo: (count) => `${count} d ago`,
11
+ justNow: "just now"
12
+ },
13
+ nl: {
14
+ rows: (count) => `${count} rijen`,
15
+ aboutRows: (count) => `ongeveer ${count} rijen`,
16
+ secondsAgo: (count) => `${count} s geleden`,
17
+ minutesAgo: (count) => `${count} min geleden`,
18
+ hoursAgo: (count) => `${count} u geleden`,
19
+ daysAgo: (count) => `${count} d geleden`,
20
+ justNow: "zojuist"
21
+ }
22
+ });
23
+ //#endregion
24
+ export { text };
@@ -1,20 +1,13 @@
1
+ import { cachedFormat } from "./intl-cache.js";
1
2
  //#region src/format/money.ts
2
- const formatters = /* @__PURE__ */ new Map();
3
- const formatterFor = (currency, decimals) => {
4
- const key = `${currency}:${decimals ?? ""}`;
5
- const cached = formatters.get(key);
6
- if (cached) return cached;
7
- const created = new Intl.NumberFormat("en-IE", {
8
- style: "currency",
9
- currency,
10
- ...decimals !== void 0 && {
11
- minimumFractionDigits: decimals,
12
- maximumFractionDigits: decimals
13
- }
14
- });
15
- formatters.set(key, created);
16
- return created;
17
- };
3
+ const formatterFor = (currency, decimals) => cachedFormat(`money:${currency}:${decimals ?? ""}`, (tag) => new Intl.NumberFormat(tag, {
4
+ style: "currency",
5
+ currency,
6
+ ...decimals !== void 0 && {
7
+ minimumFractionDigits: decimals,
8
+ maximumFractionDigits: decimals
9
+ }
10
+ }));
18
11
  /** An amount in cents in the currency, with its usual decimals or `decimals` of them. */
19
12
  const formatMoney = (cents, currency = "EUR", decimals) => formatterFor(currency, decimals).format(cents / 100);
20
13
  //#endregion