@protobase/ui 0.0.0-stage → 0.1.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.
Files changed (216) hide show
  1. package/LICENSE +202 -0
  2. package/dist/activity-chart/activity-chart.js +125 -0
  3. package/dist/activity-chart/activity-math.js +11 -0
  4. package/dist/activity-chart/activity-tooltip.js +24 -0
  5. package/dist/app/action-icon.js +10 -0
  6. package/dist/app/app.d.ts +24 -0
  7. package/dist/app/app.js +118 -0
  8. package/dist/app/batch-failure.js +21 -0
  9. package/dist/app/breadcrumb-for.js +27 -0
  10. package/dist/app/create/create-page.js +118 -0
  11. package/dist/app/create/create-record-form.js +68 -0
  12. package/dist/app/create/create-values.js +52 -0
  13. package/dist/app/delete/dialogs.js +109 -0
  14. package/dist/app/delete/record-diff.js +17 -0
  15. package/dist/app/delete/use-delete-flow.js +193 -0
  16. package/dist/app/delete/use-restore.js +9 -0
  17. package/dist/app/draft-storage.js +18 -0
  18. package/dist/app/error-banner.js +28 -0
  19. package/dist/app/field-editor.js +88 -0
  20. package/dist/app/icons.js +27 -0
  21. package/dist/app/index.d.ts +2 -0
  22. package/dist/app/index.html +12 -0
  23. package/dist/app/list-chart.js +36 -0
  24. package/dist/app/list-order.js +15 -0
  25. package/dist/app/list-page.js +213 -0
  26. package/dist/app/list-search.js +30 -0
  27. package/dist/app/main.d.ts +1 -0
  28. package/dist/app/main.js +14 -0
  29. package/dist/app/menu-icons.js +21 -0
  30. package/dist/app/meta-gate.js +46 -0
  31. package/dist/app/nav-from-meta.js +65 -0
  32. package/dist/app/nav-recent.js +33 -0
  33. package/dist/app/pages/action-href.js +7 -0
  34. package/dist/app/pages/blocks/action.js +38 -0
  35. package/dist/app/pages/blocks/block-props.js +5 -0
  36. package/dist/app/pages/blocks/card-frame.js +34 -0
  37. package/dist/app/pages/blocks/card-row.js +55 -0
  38. package/dist/app/pages/blocks/card.js +19 -0
  39. package/dist/app/pages/blocks/field.js +45 -0
  40. package/dist/app/pages/blocks/grid.js +34 -0
  41. package/dist/app/pages/blocks/link.js +27 -0
  42. package/dist/app/pages/blocks/modal-form.js +149 -0
  43. package/dist/app/pages/blocks/page-block.js +20 -0
  44. package/dist/app/pages/blocks/progress.js +54 -0
  45. package/dist/app/pages/blocks/record-card.js +56 -0
  46. package/dist/app/pages/blocks/show.js +43 -0
  47. package/dist/app/pages/blocks/stat.js +78 -0
  48. package/dist/app/pages/blocks/table.js +65 -0
  49. package/dist/app/pages/composed-page.js +19 -0
  50. package/dist/app/pages/confirm-action.js +20 -0
  51. package/dist/app/pages/field-value.js +58 -0
  52. package/dist/app/pages/project-ui.d.ts +25 -0
  53. package/dist/app/pages/project-ui.js +13 -0
  54. package/dist/app/pages/record-actions.js +40 -0
  55. package/dist/app/pages/record-scope.d.ts +16 -0
  56. package/dist/app/pages/record-scope.js +12 -0
  57. package/dist/app/pages/record-table.js +110 -0
  58. package/dist/app/pages/render-node.js +84 -0
  59. package/dist/app/pages/use-layout-data.js +73 -0
  60. package/dist/app/pages/use-path-value.js +65 -0
  61. package/dist/app/pages/use-relation-labels.js +30 -0
  62. package/dist/app/pages/use-run-action.js +88 -0
  63. package/dist/app/permissions.js +15 -0
  64. package/dist/app/record-lines.js +39 -0
  65. package/dist/app/record-page.js +318 -0
  66. package/dist/app/related-section.js +126 -0
  67. package/dist/app/relation-picker.js +69 -0
  68. package/dist/app/router.js +155 -0
  69. package/dist/app/search/search-model.js +70 -0
  70. package/dist/app/search/use-global-search.js +68 -0
  71. package/dist/app/sensitive-field.js +65 -0
  72. package/dist/app/use-lines-draft.js +45 -0
  73. package/dist/app/use-lines.js +19 -0
  74. package/dist/app/use-live-save.js +54 -0
  75. package/dist/app/use-nav-recent.js +41 -0
  76. package/dist/app/use-record-editor.js +154 -0
  77. package/dist/app/use-related-records.js +31 -0
  78. package/dist/app/user-menu-from-meta.js +36 -0
  79. package/dist/app-shell/app-shell.d.ts +4 -0
  80. package/dist/app-shell/app-shell.js +57 -0
  81. package/dist/app-shell/breadcrumb.js +37 -0
  82. package/dist/app-shell/drawer.js +30 -0
  83. package/dist/app-shell/global-search.d.ts +1 -0
  84. package/dist/app-shell/global-search.js +145 -0
  85. package/dist/app-shell/index.d.ts +9 -0
  86. package/dist/app-shell/logo.js +46 -0
  87. package/dist/app-shell/nav-open-storage.js +10 -0
  88. package/dist/app-shell/nav.d.ts +2 -0
  89. package/dist/app-shell/nav.js +85 -0
  90. package/dist/app-shell/page-header.d.ts +1 -0
  91. package/dist/app-shell/page-header.js +17 -0
  92. package/dist/app-shell/profile-menu.d.ts +2 -0
  93. package/dist/app-shell/profile-menu.js +115 -0
  94. package/dist/app-shell/sidebar-group.js +94 -0
  95. package/dist/app-shell/sidebar-item.js +46 -0
  96. package/dist/app-shell/sidebar-mode.d.ts +4 -0
  97. package/dist/app-shell/sidebar-mode.js +13 -0
  98. package/dist/app-shell/sidebar.d.ts +2 -0
  99. package/dist/app-shell/sidebar.js +64 -0
  100. package/dist/app-shell/status-dot.d.ts +1 -0
  101. package/dist/app-shell/status-dot.js +20 -0
  102. package/dist/app-shell/top-bar.js +82 -0
  103. package/dist/auth/auth-gate.js +70 -0
  104. package/dist/auth/auth-layout.js +29 -0
  105. package/dist/auth/auth-provider.js +64 -0
  106. package/dist/auth/first-run-page.js +51 -0
  107. package/dist/auth/forgot-password-form.js +75 -0
  108. package/dist/auth/forgot-password-page.js +35 -0
  109. package/dist/auth/reset-link.js +29 -0
  110. package/dist/auth/reset-password-form.js +88 -0
  111. package/dist/auth/reset-password-page.js +43 -0
  112. package/dist/auth/sign-in-form.js +74 -0
  113. package/dist/auth/sign-in-page.js +38 -0
  114. package/dist/auth/user-to-shell.js +18 -0
  115. package/dist/data/api-provider.d.ts +54 -0
  116. package/dist/data/api-provider.js +53 -0
  117. package/dist/data/meta-data.d.ts +14 -0
  118. package/dist/data/meta-data.js +9 -0
  119. package/dist/data/query-keys.js +95 -0
  120. package/dist/data/use-aggregates.d.ts +8 -0
  121. package/dist/data/use-aggregates.js +43 -0
  122. package/dist/data/use-list.d.ts +231 -0
  123. package/dist/data/use-list.js +31 -0
  124. package/dist/data/use-meta.d.ts +8 -0
  125. package/dist/data/use-meta.js +35 -0
  126. package/dist/data/use-record.d.ts +6 -0
  127. package/dist/data/use-record.js +16 -0
  128. package/dist/data/use-update-record.d.ts +15 -0
  129. package/dist/data/use-update-record.js +26 -0
  130. package/dist/data-grid/cells.js +85 -0
  131. package/dist/data-grid/column-spec.js +4 -0
  132. package/dist/data-grid/columns.js +16 -0
  133. package/dist/data-grid/data-grid.js +238 -0
  134. package/dist/data-grid/geometry.js +32 -0
  135. package/dist/data-grid/grid-footer.js +33 -0
  136. package/dist/data-grid/grid-header.js +69 -0
  137. package/dist/data-grid/grid-row.js +83 -0
  138. package/dist/data-grid/grid-template.js +12 -0
  139. package/dist/data-grid/header-menu.js +52 -0
  140. package/dist/data-grid/page-cache.js +27 -0
  141. package/dist/data-grid/pinned-storage.js +13 -0
  142. package/dist/data-grid/row-menu.js +88 -0
  143. package/dist/data-grid/use-grid-scroll.js +80 -0
  144. package/dist/data-grid/use-horizontal-scroll.js +38 -0
  145. package/dist/data-grid/use-paged-rows.js +50 -0
  146. package/dist/data-grid/use-pinned-columns.js +16 -0
  147. package/dist/filter-panel/active-filter-chips.js +34 -0
  148. package/dist/filter-panel/date-presets.js +20 -0
  149. package/dist/filter-panel/facet-group.js +48 -0
  150. package/dist/filter-panel/filter-config.js +18 -0
  151. package/dist/filter-panel/filter-menu.js +41 -0
  152. package/dist/filter-panel/filter-panel.js +112 -0
  153. package/dist/filter-panel/filter-section.js +11 -0
  154. package/dist/filter-panel/filter-state.js +63 -0
  155. package/dist/filter-panel/range-filter.js +54 -0
  156. package/dist/filter-panel/range-math.js +11 -0
  157. package/dist/filter-panel/range-slider.css +32 -0
  158. package/dist/filter-panel/toggle-row.js +13 -0
  159. package/dist/filter-panel/use-filter-state.js +15 -0
  160. package/dist/format/date.js +37 -0
  161. package/dist/format/money.js +15 -0
  162. package/dist/format/number.js +12 -0
  163. package/dist/index.d.ts +12 -0
  164. package/dist/index.js +10 -0
  165. package/dist/inline-child-table/columns.js +4 -0
  166. package/dist/inline-child-table/inline-child-table.js +120 -0
  167. package/dist/inline-child-table/line-menu.js +54 -0
  168. package/dist/inline-child-table/line-row.js +91 -0
  169. package/dist/inline-child-table/number-cell.js +25 -0
  170. package/dist/inline-child-table/reorder.js +10 -0
  171. package/dist/inline-child-table/totals-footer.js +42 -0
  172. package/dist/lib/cn.js +4 -0
  173. package/dist/lib/guarded.js +12 -0
  174. package/dist/lib/use-debounced.js +13 -0
  175. package/dist/lib/use-focus-trap.js +46 -0
  176. package/dist/lib/use-media-query.js +12 -0
  177. package/dist/live/columns-from-model.js +93 -0
  178. package/dist/live/display-value.js +24 -0
  179. package/dist/live/field-values.js +32 -0
  180. package/dist/live/filter-string.js +135 -0
  181. package/dist/live/grid-loader.js +63 -0
  182. package/dist/live/model-helpers.js +30 -0
  183. package/dist/live/naming.js +9 -0
  184. package/dist/live/relation-labels.js +53 -0
  185. package/dist/live/use-filter-config.js +88 -0
  186. package/dist/live/use-live-rows.js +45 -0
  187. package/dist/live/view-fields.js +35 -0
  188. package/dist/mocks/activity.js +24 -0
  189. package/dist/mocks/invoices.js +13 -0
  190. package/dist/mocks/products.js +167 -0
  191. package/dist/mocks/rng.js +20 -0
  192. package/dist/mocks/users.js +63 -0
  193. package/dist/primitives/avatar.js +22 -0
  194. package/dist/primitives/badge.d.ts +1 -0
  195. package/dist/primitives/badge.js +25 -0
  196. package/dist/primitives/button.js +23 -0
  197. package/dist/primitives/checkbox.js +21 -0
  198. package/dist/primitives/dialog.js +41 -0
  199. package/dist/primitives/input.js +16 -0
  200. package/dist/primitives/kbd.js +8 -0
  201. package/dist/primitives/segmented.js +14 -0
  202. package/dist/primitives/sheet.js +44 -0
  203. package/dist/primitives/skeleton.js +6 -0
  204. package/dist/primitives/spinner.js +10 -0
  205. package/dist/primitives/switch.js +14 -0
  206. package/dist/primitives/tooltip.js +13 -0
  207. package/dist/record-view/action-rail.js +28 -0
  208. package/dist/record-view/field.js +62 -0
  209. package/dist/record-view/record-header.js +50 -0
  210. package/dist/record-view/record-sidebar.js +58 -0
  211. package/dist/record-view/section.js +21 -0
  212. package/dist/styles.css +132 -0
  213. package/dist/toasts/toast-provider.js +69 -0
  214. package/dist/toasts/toast.js +45 -0
  215. package/package.json +45 -3
  216. package/README.md +0 -3
@@ -0,0 +1,88 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { Input } from "../primitives/input.js";
3
+ import { Switch } from "../primitives/switch.js";
4
+ import { Link } from "./router.js";
5
+ import { RelationPicker } from "./relation-picker.js";
6
+ import { displayValue } from "../live/display-value.js";
7
+ import { isEditable } from "../live/field-values.js";
8
+ import { SensitiveField } from "./sensitive-field.js";
9
+ import { jsx, jsxs } from "react/jsx-runtime";
10
+ import { enumLabel } from "@protobase/schema";
11
+ //#region src/app/field-editor.tsx
12
+ const numeric = /* @__PURE__ */ new Set([
13
+ "integer",
14
+ "bigint",
15
+ "decimal"
16
+ ]);
17
+ const FieldEditor = ({ field, hints, value, stored, relationLabel, relationHref, relationPicker, invalid, locked, placeholder, reveal, onChange }) => {
18
+ if (field.sensitive) return /* @__PURE__ */ jsx(SensitiveField, {
19
+ field,
20
+ hints,
21
+ value,
22
+ reveal,
23
+ locked: locked || field.readOnly,
24
+ invalid,
25
+ placeholder,
26
+ onChange
27
+ });
28
+ if (field.type === "relation" && relationPicker && !field.readOnly) return /* @__PURE__ */ jsx(RelationPicker, {
29
+ target: relationPicker.target,
30
+ view: relationPicker.view,
31
+ value: String(value),
32
+ label: relationLabel,
33
+ invalid,
34
+ onChange: (next, label) => onChange({
35
+ key: next,
36
+ label
37
+ })
38
+ });
39
+ if (field.type === "boolean" && locked) return /* @__PURE__ */ jsx("div", {
40
+ className: "py-1.5 text-[13px] text-muted-foreground",
41
+ children: displayValue(field, stored, hints)
42
+ });
43
+ if (field.type === "boolean") return /* @__PURE__ */ jsx(Switch, {
44
+ checked: Boolean(value),
45
+ onChange,
46
+ label: field.name
47
+ });
48
+ if (field.type === "relation") {
49
+ const label = relationLabel ?? String(stored ?? "—");
50
+ return /* @__PURE__ */ jsx("div", {
51
+ className: "py-1.5 text-[13px]",
52
+ children: relationHref ? /* @__PURE__ */ jsx(Link, {
53
+ to: relationHref,
54
+ className: "font-medium text-primary-text hover:underline",
55
+ children: label
56
+ }) : label
57
+ });
58
+ }
59
+ if (!isEditable(field) || locked) return /* @__PURE__ */ jsx("div", {
60
+ className: cn("py-1.5 text-[13px] tabular-nums", field.type === "decimal" || field.type === "integer" ? "font-medium" : "text-muted-foreground"),
61
+ children: displayValue(field, stored, hints)
62
+ });
63
+ if (field.type === "enum") return /* @__PURE__ */ jsxs("select", {
64
+ value: String(value),
65
+ onChange: (event) => onChange(event.target.value),
66
+ className: "h-8 w-full rounded-md border border-border-strong bg-background px-2 text-[13px] shadow-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20",
67
+ children: [value === "" && /* @__PURE__ */ jsx("option", {
68
+ value: "",
69
+ children: "Choose..."
70
+ }), (field.enumValues ?? []).map((option) => /* @__PURE__ */ jsx("option", {
71
+ value: option,
72
+ children: enumLabel(option, hints?.valueLabels)
73
+ }, option))]
74
+ });
75
+ return /* @__PURE__ */ jsx(Input, {
76
+ invalid,
77
+ leading: hints?.prefix ? /* @__PURE__ */ jsx("span", {
78
+ className: "text-[13px]",
79
+ children: hints.prefix
80
+ }) : void 0,
81
+ placeholder,
82
+ className: cn(numeric.has(field.type) && "text-right tabular-nums"),
83
+ value: String(value),
84
+ onChange: (event) => onChange(event.target.value)
85
+ });
86
+ };
87
+ //#endregion
88
+ export { FieldEditor };
@@ -0,0 +1,27 @@
1
+ import { Boxes, Building2, CreditCard, FileText, Globe2, Package, Receipt, Settings, ShoppingCart, Table2, Tags, Truck, Users, Warehouse } from "lucide-react";
2
+ //#region src/app/icons.ts
3
+ const byResource = {
4
+ orders: ShoppingCart,
5
+ orderLines: ShoppingCart,
6
+ companies: Building2,
7
+ invoices: FileText,
8
+ invoiceLines: Receipt,
9
+ products: Package,
10
+ categories: Tags,
11
+ tags: Tags,
12
+ companyTags: Tags,
13
+ stockMoves: Boxes,
14
+ stockLevels: Boxes,
15
+ warehouses: Warehouse,
16
+ locations: Warehouse,
17
+ people: Users,
18
+ users: Users,
19
+ empMaster: Users,
20
+ organizations: Settings,
21
+ countries: Globe2,
22
+ currencies: CreditCard,
23
+ shipments: Truck
24
+ };
25
+ const iconFor = (resource) => byResource[resource] ?? Table2;
26
+ //#endregion
27
+ export { iconFor };
@@ -0,0 +1,2 @@
1
+ import { App, AppProps } from "./app.js";
2
+ export { App, type AppProps };
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Protobase</title>
7
+ </head>
8
+ <body>
9
+ <div id="root" class="h-screen"></div>
10
+ <script type="module" src="./main"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,36 @@
1
+ import { useSeries } from "../data/use-aggregates.js";
2
+ import { ActivityChart } from "../activity-chart/activity-chart.js";
3
+ import { useState } from "react";
4
+ import { jsx } from "react/jsx-runtime";
5
+ //#region src/app/list-chart.tsx
6
+ const periods = {
7
+ "7d": "last 7 days",
8
+ "30d": "last 30 days",
9
+ "90d": "last 90 days",
10
+ "1y": "last year",
11
+ all: "all time"
12
+ };
13
+ /** The view's chart, drawn from `:series` with the list's current filter. */
14
+ const ListChart = ({ resource, chart, title, noun, filter }) => {
15
+ const [granularity, setGranularity] = useState(chart.granularity === "week" ? "week" : "day");
16
+ const range = granularity === "week" ? "90d" : chart.range;
17
+ const data = useSeries(resource, {
18
+ field: chart.field,
19
+ range,
20
+ granularity,
21
+ filter
22
+ }).data?.points.map((point) => ({
23
+ at: Date.parse(`${point.bucket}Z`),
24
+ count: point.count
25
+ })) ?? [];
26
+ return /* @__PURE__ */ jsx(ActivityChart, {
27
+ title,
28
+ noun,
29
+ data,
30
+ granularity,
31
+ onGranularityChange: setGranularity,
32
+ periodLabel: granularity === "week" ? "last 12 weeks" : periods[range]
33
+ });
34
+ };
35
+ //#endregion
36
+ export { ListChart };
@@ -0,0 +1,15 @@
1
+ //#region src/app/list-order.ts
2
+ const defaultOrderBy = (model, view) => {
3
+ const sort = view?.list?.sort?.[0];
4
+ return sort ? `${sort[0]} ${sort[1]}` : `${model.primaryKey[0]} asc`;
5
+ };
6
+ const parseSort = (orderBy) => {
7
+ const [columnId = "", direction] = orderBy.trim().split(/\s+/);
8
+ return {
9
+ columnId,
10
+ direction: direction === "desc" ? "desc" : "asc"
11
+ };
12
+ };
13
+ const sortToOrderBy = (sort) => `${sort.columnId} ${sort.direction}`;
14
+ //#endregion
15
+ export { defaultOrderBy, parseSort, sortToOrderBy };
@@ -0,0 +1,213 @@
1
+ import { useIsDesktop } from "../lib/use-media-query.js";
2
+ import { PageHeader } from "../app-shell/page-header.js";
3
+ import { useAuth } from "../auth/auth-provider.js";
4
+ import { Button } from "../primitives/button.js";
5
+ import { recordTitleField } from "../live/model-helpers.js";
6
+ import { humanize } from "../live/naming.js";
7
+ import { useRouter } from "./router.js";
8
+ import { ErrorBanner } from "./error-banner.js";
9
+ import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
10
+ import { can, rowAllows } from "./permissions.js";
11
+ import { pinnedStorageKey } from "../data-grid/pinned-storage.js";
12
+ import { DataGrid } from "../data-grid/data-grid.js";
13
+ import { ActiveFilterChips } from "../filter-panel/active-filter-chips.js";
14
+ import { activeChips, emptyFilterState, removeChip } from "../filter-panel/filter-state.js";
15
+ import { FilterPanel } from "../filter-panel/filter-panel.js";
16
+ import { columnsFromModel } from "../live/columns-from-model.js";
17
+ import { stateToText, textToState } from "../live/filter-string.js";
18
+ import { widgetFields } from "../live/view-fields.js";
19
+ import { useFilterConfig } from "../live/use-filter-config.js";
20
+ import { useLiveRows } from "../live/use-live-rows.js";
21
+ import { Sheet } from "../primitives/sheet.js";
22
+ import { useDeleteFlow } from "./delete/use-delete-flow.js";
23
+ import { useRestore } from "./delete/use-restore.js";
24
+ import { ListChart } from "./list-chart.js";
25
+ import { defaultOrderBy, parseSort, sortToOrderBy } from "./list-order.js";
26
+ import { ListSearch } from "./list-search.js";
27
+ import { useMemo, useState } from "react";
28
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
29
+ import { Plus, SlidersHorizontal } from "lucide-react";
30
+ import { createWhere, encodeKey } from "@protobase/schema";
31
+ //#region src/app/list-page.tsx
32
+ const where = createWhere();
33
+ const searchText = (extras) => extras.find((clause) => clause.kind === "search");
34
+ const ListPage = ({ resource }) => {
35
+ const { resources } = useAdminMeta();
36
+ const model = resources[resource];
37
+ if (!model) return /* @__PURE__ */ jsxs(Notice, {
38
+ title: "Not found",
39
+ children: [
40
+ "There is no resource called ",
41
+ resource,
42
+ "."
43
+ ]
44
+ });
45
+ return /* @__PURE__ */ jsx(LoadedList, { model });
46
+ };
47
+ const LoadedList = ({ model }) => {
48
+ const meta = useAdminMeta();
49
+ const { params, setParams, navigate } = useRouter();
50
+ const resource = model.name;
51
+ const view = meta.views[resource];
52
+ const fields = useMemo(() => widgetFields(view, model), [view, model]);
53
+ const columns = useMemo(() => columnsFromModel(model, view), [model, view]);
54
+ const filterText = params.get("filter") ?? "";
55
+ const parsed = useMemo(() => textToState(fields, filterText), [fields, filterText]);
56
+ const state = parsed.ok ? parsed.state : emptyFilterState;
57
+ const extras = parsed.ok ? parsed.extras : [];
58
+ const orderBy = params.get("order_by") ?? defaultOrderBy(model, view);
59
+ const layout = params.get("layout") === "panel" ? "panel" : "bar";
60
+ const desktop = useIsDesktop();
61
+ const [sheetOpen, setSheetOpen] = useState(false);
62
+ const config = useFilterConfig({
63
+ model,
64
+ resources: meta.resources,
65
+ views: meta.views,
66
+ view,
67
+ state,
68
+ extras
69
+ });
70
+ const rows = useLiveRows({
71
+ resources: meta.resources,
72
+ views: meta.views,
73
+ model,
74
+ columns: columns.map((column) => column.id),
75
+ filter: parsed.ok ? filterText : "",
76
+ orderBy
77
+ });
78
+ const titleField = recordTitleField(model, view);
79
+ const target = (row) => ({
80
+ key: row.id,
81
+ title: String(typeof row[titleField] === "object" ? row[titleField].name : row[titleField] ?? row.id),
82
+ etag: typeof row.etag === "string" ? row.etag : void 0,
83
+ snapshot: row
84
+ });
85
+ const singular = view?.names?.singular ?? humanize(resource);
86
+ const permissions = usePermissions(resource);
87
+ const { state: auth } = useAuth();
88
+ const restore = useRestore(model);
89
+ const remove = useDeleteFlow({
90
+ model,
91
+ view,
92
+ restore,
93
+ onReview: (deleted) => navigate(`/${resource}/${encodeURIComponent(deleted.key)}`)
94
+ });
95
+ const setFilter = (next, nextExtras = extras) => setParams({ filter: stateToText(fields, next, nextExtras) || void 0 });
96
+ const search = searchText(extras);
97
+ const commitSearch = (text) => {
98
+ const others = extras.filter((clause) => clause.kind !== "search");
99
+ setFilter(state, text ? [...others, where.search(text)] : others);
100
+ };
101
+ const plural = view?.names?.plural ?? humanize(resource);
102
+ const panel = /* @__PURE__ */ jsx(FilterPanel, {
103
+ layout,
104
+ config,
105
+ value: state,
106
+ onChange: setFilter
107
+ });
108
+ const chips = activeChips(config, state);
109
+ const phoneFilters = /* @__PURE__ */ jsxs("div", {
110
+ className: "flex flex-col gap-2",
111
+ children: [/* @__PURE__ */ jsxs("div", {
112
+ className: "flex items-center gap-2",
113
+ children: [/* @__PURE__ */ jsxs(Button, {
114
+ className: "min-h-11",
115
+ onClick: () => setSheetOpen(true),
116
+ "aria-haspopup": "dialog",
117
+ children: [
118
+ /* @__PURE__ */ jsx(SlidersHorizontal, { className: "size-3.5" }),
119
+ "Filters",
120
+ chips.length > 0 && ` (${chips.length})`
121
+ ]
122
+ }), /* @__PURE__ */ jsx("div", {
123
+ className: "min-w-0 flex-1",
124
+ children: /* @__PURE__ */ jsx(ActiveFilterChips, {
125
+ scroll: true,
126
+ chips,
127
+ onRemove: (key) => setFilter(removeChip(state, key)),
128
+ onClear: () => setFilter(emptyFilterState)
129
+ })
130
+ })]
131
+ }), /* @__PURE__ */ jsx(Sheet, {
132
+ title: "Filters",
133
+ open: sheetOpen,
134
+ onClose: () => setSheetOpen(false),
135
+ children: /* @__PURE__ */ jsx(FilterPanel, {
136
+ layout: "panel",
137
+ fullWidth: true,
138
+ config,
139
+ value: state,
140
+ onChange: setFilter
141
+ })
142
+ })]
143
+ });
144
+ const grid = rows.source ? /* @__PURE__ */ jsx(DataGrid, {
145
+ columns,
146
+ source: rows.source,
147
+ sort: parseSort(orderBy),
148
+ onSortChange: (sort) => setParams({ order_by: sortToOrderBy(sort) }),
149
+ onOpenRow: (row) => navigate(`/${resource}/${encodeKey(row.id.split(","))}`),
150
+ rowActions: [{
151
+ label: `Open ${singular.toLowerCase()}`,
152
+ onSelect: (row) => navigate(`/${resource}/${encodeKey(row.id.split(","))}`)
153
+ }, ...can(permissions, "delete") ? [{
154
+ label: "Delete",
155
+ destructive: true,
156
+ available: (row) => rowAllows(row, "delete"),
157
+ onSelect: (row) => void remove.start(target(row))
158
+ }] : []],
159
+ bulkActions: can(permissions, "delete") ? [{
160
+ label: (count) => `Delete ${count}`,
161
+ destructive: true,
162
+ onRun: (rows) => remove.startBulk(rows.map(target))
163
+ }] : [],
164
+ rowNumbers: false,
165
+ pinKey: auth.kind === "signed-in" ? pinnedStorageKey(auth.user.id, resource) : void 0,
166
+ className: "min-h-0 flex-1"
167
+ }) : /* @__PURE__ */ jsx("div", { className: "flex-1 rounded-lg border bg-surface" });
168
+ return /* @__PURE__ */ jsxs("div", {
169
+ className: "flex min-h-0 flex-1 flex-col gap-3 px-5 py-4",
170
+ children: [
171
+ remove.dialogs,
172
+ /* @__PURE__ */ jsx(PageHeader, {
173
+ title: plural,
174
+ subtitle: view?.help,
175
+ actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [model.search?.length ? /* @__PURE__ */ jsx(ListSearch, {
176
+ value: search?.kind === "search" ? search.text : "",
177
+ onCommit: commitSearch,
178
+ placeholder: `Search ${plural.toLowerCase()}`
179
+ }) : null, can(permissions, "create") && /* @__PURE__ */ jsxs(Button, {
180
+ variant: "primary",
181
+ onClick: () => navigate(`/${resource}/new`),
182
+ children: [
183
+ /* @__PURE__ */ jsx(Plus, { className: "size-3.5" }),
184
+ "New ",
185
+ singular.toLowerCase()
186
+ ]
187
+ })] })
188
+ }),
189
+ !parsed.ok && /* @__PURE__ */ jsx(ErrorBanner, {
190
+ title: "The filter in the address could not be read",
191
+ error: new Error(parsed.errors.map((error) => error.message).join("; "))
192
+ }),
193
+ rows.error && /* @__PURE__ */ jsx(ErrorBanner, {
194
+ title: "Could not load rows",
195
+ error: rows.error
196
+ }),
197
+ view?.chart && /* @__PURE__ */ jsx(ListChart, {
198
+ resource,
199
+ chart: view.chart,
200
+ title: `${plural} created`,
201
+ noun: plural.toLowerCase(),
202
+ filter: parsed.ok ? filterText : ""
203
+ }),
204
+ !desktop ? phoneFilters : layout === "bar" && panel,
205
+ desktop && layout === "panel" ? /* @__PURE__ */ jsxs("div", {
206
+ className: "flex min-h-0 flex-1 gap-4",
207
+ children: [panel, grid]
208
+ }) : grid
209
+ ]
210
+ });
211
+ };
212
+ //#endregion
213
+ export { ListPage };
@@ -0,0 +1,30 @@
1
+ import { Input } from "../primitives/input.js";
2
+ import { useEffect, useState } from "react";
3
+ import { jsx } from "react/jsx-runtime";
4
+ import { Search } from "lucide-react";
5
+ //#region src/app/list-search.tsx
6
+ /** A search box that commits after a short pause or on Enter. */
7
+ const ListSearch = ({ value, onCommit, placeholder }) => {
8
+ const [text, setText] = useState(value);
9
+ useEffect(() => setText(value), [value]);
10
+ useEffect(() => {
11
+ if (text === value) return;
12
+ const timer = setTimeout(() => onCommit(text), 350);
13
+ return () => clearTimeout(timer);
14
+ }, [
15
+ text,
16
+ value,
17
+ onCommit
18
+ ]);
19
+ return /* @__PURE__ */ jsx(Input, {
20
+ wrapperClassName: "min-w-0 flex-1 sm:w-72 sm:flex-none",
21
+ leading: /* @__PURE__ */ jsx(Search, { className: "size-4" }),
22
+ placeholder,
23
+ "aria-label": "Search",
24
+ value: text,
25
+ onChange: (event) => setText(event.target.value),
26
+ onKeyDown: (event) => event.key === "Enter" && onCommit(text)
27
+ });
28
+ };
29
+ //#endregion
30
+ export { ListSearch };
@@ -0,0 +1 @@
1
+ import "../styles.css";
@@ -0,0 +1,14 @@
1
+ import { App } from "./app.js";
2
+ import { StrictMode } from "react";
3
+ import { jsx } from "react/jsx-runtime";
4
+ import { createRoot } from "react-dom/client";
5
+ import ui from "virtual:protobase-project-ui";
6
+ import "../styles.css";
7
+ //#region src/app/main.tsx
8
+ const root = document.getElementById("root");
9
+ if (!root) throw new Error("index.html needs an element with id \"root\"");
10
+ createRoot(root).render(/* @__PURE__ */ jsx(StrictMode, { children: /* @__PURE__ */ jsx(App, {
11
+ baseUrl: "/api/v1",
12
+ ui
13
+ }) }));
14
+ //#endregion
@@ -0,0 +1,21 @@
1
+ import { BookOpen, Building2, CreditCard, ExternalLink, FileText, KeyRound, LayoutDashboard, LifeBuoy, Mail, Receipt, Settings, ShieldCheck, Users } from "lucide-react";
2
+ //#region src/app/menu-icons.ts
3
+ const byName = {
4
+ "book-open": BookOpen,
5
+ building: Building2,
6
+ "credit-card": CreditCard,
7
+ "external-link": ExternalLink,
8
+ "file-text": FileText,
9
+ key: KeyRound,
10
+ "layout-dashboard": LayoutDashboard,
11
+ "life-buoy": LifeBuoy,
12
+ mail: Mail,
13
+ receipt: Receipt,
14
+ settings: Settings,
15
+ shield: ShieldCheck,
16
+ users: Users
17
+ };
18
+ Object.keys(byName);
19
+ const menuIcon = (name, fallback) => name ? byName[name] ?? fallback : fallback;
20
+ //#endregion
21
+ export { menuIcon };
@@ -0,0 +1,46 @@
1
+ import { Spinner } from "../primitives/spinner.js";
2
+ import { allowEverything } from "../data/meta-data.js";
3
+ import { useMeta } from "../data/use-meta.js";
4
+ import { createContext, useContext } from "react";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ import { isApiError } from "@protobase/client";
7
+ //#region src/app/meta-gate.tsx
8
+ const MetaContext = createContext(null);
9
+ const useAdminMeta = () => {
10
+ const meta = useContext(MetaContext);
11
+ if (!meta) throw new Error("useAdminMeta must be used inside MetaGate");
12
+ return meta;
13
+ };
14
+ const Notice = ({ title, children }) => /* @__PURE__ */ jsx("div", {
15
+ className: "flex h-full min-h-48 items-center justify-center p-8",
16
+ children: /* @__PURE__ */ jsxs("div", {
17
+ className: "max-w-md text-center",
18
+ children: [/* @__PURE__ */ jsx("h1", {
19
+ className: "text-[15px] font-semibold",
20
+ children: title
21
+ }), /* @__PURE__ */ jsx("p", {
22
+ className: "mt-1 text-[13px] text-muted-foreground",
23
+ children
24
+ })]
25
+ })
26
+ });
27
+ /** Renders its children once `/meta` has loaded. */
28
+ const MetaGate = ({ children }) => {
29
+ const meta = useMeta();
30
+ if (meta.isPending) return /* @__PURE__ */ jsxs("div", {
31
+ className: "flex h-full min-h-48 items-center justify-center gap-2 text-[13px] text-muted-foreground",
32
+ children: [/* @__PURE__ */ jsx(Spinner, {}), " Loading"]
33
+ });
34
+ if (meta.error) return /* @__PURE__ */ jsx(Notice, {
35
+ title: "Cannot reach the API",
36
+ children: isApiError(meta.error) ? meta.error.message : "The server did not answer. Is it running?"
37
+ });
38
+ return /* @__PURE__ */ jsx(MetaContext.Provider, {
39
+ value: meta.data,
40
+ children
41
+ });
42
+ };
43
+ /** What the signed-in user may do with a resource; everything when the server does not say. */
44
+ const usePermissions = (resource) => useAdminMeta().permissions[resource] ?? allowEverything;
45
+ //#endregion
46
+ export { MetaGate, Notice, useAdminMeta, usePermissions };
@@ -0,0 +1,65 @@
1
+ import { humanize } from "../live/naming.js";
2
+ import { iconFor } from "./icons.js";
3
+ import { menuIcon } from "./menu-icons.js";
4
+ import { userMenuResources } from "./user-menu-from-meta.js";
5
+ import { LayoutDashboard } from "lucide-react";
6
+ //#region src/app/nav-from-meta.ts
7
+ const groupLabels = {
8
+ sales: "Sales",
9
+ crm: "CRM",
10
+ catalog: "Catalog",
11
+ inventory: "Inventory",
12
+ core: "Admin",
13
+ hr: "HR"
14
+ };
15
+ /**
16
+ * One sidebar group per `nav.group` (default: the database schema, and "Pages" for pages); `nav.hidden` and a place in
17
+ * the user menu leave an entry out, `nav.order` sorts within a group, and `recent` (from `useNavRecent`) makes an entry
18
+ * collapsible. Groups with pages come first, so a dashboard leads the sidebar.
19
+ */
20
+ const navFromMeta = (meta, basePath = "", recent = {}) => {
21
+ const groups = /* @__PURE__ */ new Map();
22
+ const orders = /* @__PURE__ */ new Map();
23
+ const inMenu = userMenuResources(meta);
24
+ for (const page of Object.values(meta.pages)) {
25
+ if (page.nav?.hidden || inMenu.has(page.name)) continue;
26
+ const groupName = page.nav?.group ?? "Pages";
27
+ const group = groups.get(groupName) ?? {
28
+ label: groupName,
29
+ items: []
30
+ };
31
+ group.items.push({
32
+ id: page.name,
33
+ label: page.title,
34
+ icon: menuIcon(page.icon, LayoutDashboard),
35
+ href: `${basePath}/${page.name}`
36
+ });
37
+ groups.set(groupName, group);
38
+ orders.set(page.name, page.nav?.order ?? Number.MAX_SAFE_INTEGER);
39
+ }
40
+ for (const model of Object.values(meta.resources)) {
41
+ const view = meta.views[model.name];
42
+ if (!view || view.nav?.hidden || inMenu.has(model.name)) continue;
43
+ const schema = model.table.schema ?? "public";
44
+ const groupName = view.nav?.group ?? groupLabels[schema] ?? humanize(schema);
45
+ const group = groups.get(groupName) ?? {
46
+ label: groupName,
47
+ items: []
48
+ };
49
+ group.items.push({
50
+ id: model.name,
51
+ label: view.names?.plural ?? humanize(model.name),
52
+ icon: iconFor(model.name),
53
+ href: `${basePath}/${model.name}`,
54
+ ...recent[model.name] && { recent: recent[model.name] }
55
+ });
56
+ groups.set(groupName, group);
57
+ orders.set(model.name, view.nav?.order ?? Number.MAX_SAFE_INTEGER);
58
+ }
59
+ return [...groups.values()].map((group) => ({
60
+ ...group,
61
+ items: [...group.items].sort((a, b) => (orders.get(a.id) ?? 0) - (orders.get(b.id) ?? 0))
62
+ }));
63
+ };
64
+ //#endregion
65
+ export { navFromMeta };
@@ -0,0 +1,33 @@
1
+ import { recordKey, recordTitle, recordTitleField } from "../live/model-helpers.js";
2
+ import { defaultOrderBy } from "./list-order.js";
3
+ import { defaultRecentLimit, encodeKey, enumLabel } from "@protobase/schema";
4
+ //#region src/app/nav-recent.ts
5
+ /** The list request behind a sidebar group: its filter, order (the list's sort when unset), size, and only the fields it shows. */
6
+ const recentRequest = (model, view, recent) => ({
7
+ filter: recent.filter ?? "",
8
+ orderBy: recent.orderBy ?? defaultOrderBy(model, view),
9
+ pageSize: recent.limit ?? defaultRecentLimit,
10
+ fields: [.../* @__PURE__ */ new Set([
11
+ ...model.primaryKey,
12
+ recordTitleField(model, view),
13
+ recent.status
14
+ ])]
15
+ });
16
+ /** List rows as sidebar records: titled like the record page, dotted by their status, active while open. */
17
+ const recentRecords = (model, view, recent, rows, basePath, activeKey) => {
18
+ return rows.map((row) => {
19
+ const key = encodeKey(recordKey(model, row));
20
+ const status = String(row[recent.status] ?? "");
21
+ return {
22
+ id: key,
23
+ label: recordTitle(model, view, row, key),
24
+ href: `${basePath}/${model.name}/${encodeURIComponent(key)}`,
25
+ status: model.fields[recent.status]?.type === "enum" && status ? enumLabel(status, view.fields[recent.status]?.valueLabels) : status,
26
+ tone: recent.tones[status] ?? "neutral",
27
+ pulse: recent.pulse?.includes(status) ?? false,
28
+ active: key === activeKey
29
+ };
30
+ });
31
+ };
32
+ //#endregion
33
+ export { recentRecords, recentRequest };
@@ -0,0 +1,7 @@
1
+ //#region src/app/pages/action-href.ts
2
+ /** `href` with every `{field}` replaced by the record's value, percent-encoded; a missing value becomes empty. */
3
+ const fillHref = (href, record = {}) => href.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => encodeURIComponent(record[name] === null || record[name] === void 0 ? "" : String(record[name])));
4
+ /** Paths in the app start with one `/`; anything else (`https:`, `mailto:`, `//host`) leaves the app. */
5
+ const isAppPath = (href) => href.startsWith("/") && !href.startsWith("//");
6
+ //#endregion
7
+ export { fillHref, isAppPath };
@@ -0,0 +1,38 @@
1
+ import { Button } from "../../../primitives/button.js";
2
+ import { ConfirmAction } from "../confirm-action.js";
3
+ import { useLayoutRecord } from "../record-scope.js";
4
+ import { useRunAction } from "../use-run-action.js";
5
+ import { propsOf } from "./block-props.js";
6
+ import { useState } from "react";
7
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
8
+ //#region src/app/pages/blocks/action.tsx
9
+ /** A button for a named action of the resource's view, asking first when the action has `confirm`. */
10
+ const ActionBlock = ({ node }) => {
11
+ const { name, resource, label, variant } = propsOf(node);
12
+ const scope = useLayoutRecord();
13
+ const action = useRunAction(resource ?? scope?.model.name ?? "", name, resource === void 0 || resource === scope?.model.name ? scope : void 0);
14
+ const [asking, setAsking] = useState(false);
15
+ if (!action.action) return null;
16
+ const text = label ?? action.action.label;
17
+ const start = () => action.action.confirm ? setAsking(true) : void action.run();
18
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
19
+ size: "sm",
20
+ variant: variant ?? "secondary",
21
+ loading: action.running,
22
+ disabled: Boolean(action.blocked),
23
+ title: action.blocked,
24
+ onClick: start,
25
+ children: text
26
+ }), asking && /* @__PURE__ */ jsx(ConfirmAction, {
27
+ action: action.action,
28
+ label: text,
29
+ danger: variant === "danger",
30
+ onCancel: () => setAsking(false),
31
+ onConfirm: () => {
32
+ setAsking(false);
33
+ action.run();
34
+ }
35
+ })] });
36
+ };
37
+ //#endregion
38
+ export { ActionBlock };
@@ -0,0 +1,5 @@
1
+ //#region src/app/pages/blocks/block-props.ts
2
+ /** The element's props in their declared shape; the server checked them against the block's spec. */
3
+ const propsOf = (node) => node.props;
4
+ //#endregion
5
+ export { propsOf };