@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,94 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { navOpenStorageKey, readNavOpen, writeNavOpen } from "./nav-open-storage.js";
3
+ import { SidebarItem } from "./sidebar-item.js";
4
+ import { StatusDot } from "./status-dot.js";
5
+ import { useId, useState } from "react";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ import { ChevronRight } from "lucide-react";
8
+ //#region src/app-shell/sidebar-group.tsx
9
+ const row = {
10
+ "text-large": "h-8 gap-2.5 px-2.5 text-[13px]",
11
+ "text-small": "h-7 gap-2 px-2 text-xs"
12
+ };
13
+ /** Lines the records up under the entry's icon. */
14
+ const indent = {
15
+ "text-large": "ml-[20px]",
16
+ "text-small": "ml-[17px]"
17
+ };
18
+ /**
19
+ * A sidebar entry with a few of its records under it. The label opens the list, the chevron shows or hides the
20
+ * records (remembered in this browser), and "View all" at the end opens the list too.
21
+ */
22
+ const SidebarGroup = ({ item, recent, mode, active, onNavigate }) => {
23
+ const storageKey = navOpenStorageKey(item.id);
24
+ const [open, setOpen] = useState(() => readNavOpen(localStorage, storageKey));
25
+ const listId = useId();
26
+ const href = item.href ?? `#${item.id}`;
27
+ const toggle = () => {
28
+ writeNavOpen(localStorage, storageKey, !open);
29
+ setOpen(!open);
30
+ };
31
+ const link = (to) => ({
32
+ href: to,
33
+ onClick: onNavigate && ((event) => {
34
+ event.preventDefault();
35
+ onNavigate(to);
36
+ })
37
+ });
38
+ return /* @__PURE__ */ jsxs("div", {
39
+ className: "flex flex-col gap-0.5",
40
+ children: [/* @__PURE__ */ jsxs("div", {
41
+ className: "relative",
42
+ children: [/* @__PURE__ */ jsx(SidebarItem, {
43
+ item,
44
+ mode,
45
+ active,
46
+ onNavigate,
47
+ className: "pr-8"
48
+ }), /* @__PURE__ */ jsx("button", {
49
+ type: "button",
50
+ "aria-expanded": open,
51
+ "aria-controls": listId,
52
+ "aria-label": `Recent ${item.label.toLowerCase()}`,
53
+ onClick: toggle,
54
+ 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
+ children: /* @__PURE__ */ jsx(ChevronRight, { className: cn("size-3.5 transition-transform", open && "rotate-90") })
56
+ })]
57
+ }), open && /* @__PURE__ */ jsxs("ul", {
58
+ id: listId,
59
+ "aria-label": `Recent ${item.label.toLowerCase()}`,
60
+ className: cn("flex flex-col gap-0.5 border-l pl-1.5", indent[mode]),
61
+ children: [
62
+ recent.state === "loading" && /* @__PURE__ */ jsx("li", {
63
+ className: cn("flex items-center text-faint-foreground", row[mode]),
64
+ children: "Loading..."
65
+ }),
66
+ recent.state === "error" && /* @__PURE__ */ jsx("li", {
67
+ className: cn("flex items-center text-faint-foreground", row[mode]),
68
+ children: "Could not load"
69
+ }),
70
+ recent.state === "ready" && recent.records.map((record) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("a", {
71
+ ...link(record.href),
72
+ "aria-label": `${record.label}, ${record.status}`,
73
+ title: `${record.label} (${record.status})`,
74
+ "aria-current": record.active ? "page" : void 0,
75
+ className: cn("flex min-w-0 items-center rounded-md font-medium transition-colors", row[mode], record.active ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground"),
76
+ children: [/* @__PURE__ */ jsx(StatusDot, {
77
+ tone: record.tone,
78
+ pulse: record.pulse
79
+ }), /* @__PURE__ */ jsx("span", {
80
+ className: "truncate",
81
+ children: record.label
82
+ })]
83
+ }) }, record.id)),
84
+ /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("a", {
85
+ ...link(href),
86
+ 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
+ }) })
89
+ ]
90
+ })]
91
+ });
92
+ };
93
+ //#endregion
94
+ export { SidebarGroup };
@@ -0,0 +1,46 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { Tooltip } from "../primitives/tooltip.js";
3
+ import { isTextMode, showsLabelInline } from "./sidebar-mode.js";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ //#region src/app-shell/sidebar-item.tsx
6
+ const layout = {
7
+ "text-large": "h-9 gap-3 px-3 text-sm",
8
+ "text-small": "h-7 gap-2.5 px-2.5 text-xs",
9
+ icon: "size-9 justify-center",
10
+ "icon-label": "w-full flex-col gap-0.5 py-1.5 text-[10px]",
11
+ "icon-tooltip": "size-9 justify-center",
12
+ "icon-expand": "h-8 gap-3 px-3 text-[13px]"
13
+ };
14
+ const SidebarItem = ({ item, mode, active, onNavigate, className }) => {
15
+ const Icon = item.icon;
16
+ const button = /* @__PURE__ */ jsxs("a", {
17
+ onClick: onNavigate && ((event) => {
18
+ event.preventDefault();
19
+ onNavigate(item.href ?? `#${item.id}`);
20
+ }),
21
+ href: item.href ?? `#${item.id}`,
22
+ "aria-label": item.label,
23
+ "aria-current": active ? "page" : void 0,
24
+ className: cn("flex shrink-0 items-center rounded-md font-medium transition-colors", layout[mode], active ? "bg-primary-soft text-primary-text" : "text-muted-foreground hover:bg-muted hover:text-foreground", className),
25
+ children: [
26
+ /* @__PURE__ */ jsx(Icon, {
27
+ className: cn("shrink-0", mode === "text-small" ? "size-4" : "size-[18px]"),
28
+ strokeWidth: active ? 2.2 : 1.9
29
+ }),
30
+ (showsLabelInline(mode) || mode === "icon-label") && /* @__PURE__ */ jsx("span", {
31
+ className: "whitespace-nowrap",
32
+ children: item.label
33
+ }),
34
+ isTextMode(mode) && item.count && /* @__PURE__ */ jsx("span", {
35
+ className: "ml-auto rounded bg-muted px-1.5 text-[11px] tabular-nums text-muted-foreground",
36
+ children: item.count
37
+ })
38
+ ]
39
+ });
40
+ return mode === "icon-tooltip" ? /* @__PURE__ */ jsx(Tooltip, {
41
+ label: item.label,
42
+ children: button
43
+ }) : button;
44
+ };
45
+ //#endregion
46
+ export { SidebarItem };
@@ -0,0 +1,4 @@
1
+ //#region src/app-shell/sidebar-mode.d.ts
2
+ export declare const sidebarModes: readonly ["text-large", "text-small", "icon", "icon-label", "icon-tooltip", "icon-expand"];
3
+ export type SidebarMode = (typeof sidebarModes)[number];
4
+ //#endregion
@@ -0,0 +1,13 @@
1
+ //#region src/app-shell/sidebar-mode.ts
2
+ const showsLabelInline = (mode) => mode === "text-large" || mode === "text-small" || mode === "icon-expand";
3
+ const isTextMode = (mode) => mode === "text-large" || mode === "text-small";
4
+ const sidebarWidth = {
5
+ "text-large": "w-60",
6
+ "text-small": "w-52",
7
+ icon: "w-14",
8
+ "icon-label": "w-[76px]",
9
+ "icon-tooltip": "w-14",
10
+ "icon-expand": "w-14"
11
+ };
12
+ //#endregion
13
+ export { isTextMode, showsLabelInline, sidebarWidth };
@@ -0,0 +1,2 @@
1
+ import "./nav.js";
2
+ import "./profile-menu.js";
@@ -0,0 +1,64 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { Logo } from "./logo.js";
3
+ import { navGroups } from "./nav.js";
4
+ import { ProfileMenu } from "./profile-menu.js";
5
+ import { isTextMode, sidebarWidth } from "./sidebar-mode.js";
6
+ import { SidebarItem } from "./sidebar-item.js";
7
+ import { SidebarGroup } from "./sidebar-group.js";
8
+ import { jsx, jsxs } from "react/jsx-runtime";
9
+ //#region src/app-shell/sidebar.tsx
10
+ const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenuOpen, groups = navGroups, userMenu, onNavigate, onSignOut }) => {
11
+ const expanding = mode === "icon-expand";
12
+ const expanded = expanding && forceExpanded;
13
+ const inline = isTextMode(mode) || expanding;
14
+ return /* @__PURE__ */ jsx("nav", {
15
+ "aria-label": "Resources",
16
+ className: cn("relative z-30 shrink-0", sidebarWidth[mode]),
17
+ children: /* @__PURE__ */ jsxs("div", {
18
+ 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"),
19
+ children: [
20
+ /* @__PURE__ */ jsx("div", {
21
+ className: cn("flex h-12 shrink-0 items-center px-3.5", mode === "icon-label" && "justify-center px-0"),
22
+ children: /* @__PURE__ */ jsx(Logo, {
23
+ showName: isTextMode(mode) || expanding,
24
+ workspace
25
+ })
26
+ }),
27
+ /* @__PURE__ */ jsx("div", {
28
+ className: cn("flex-1 overflow-y-auto overflow-x-hidden pb-2", mode === "icon-label" ? "px-2" : "px-2"),
29
+ children: groups.map((group, index) => /* @__PURE__ */ jsxs("div", {
30
+ className: cn("flex flex-col gap-0.5", index > 0 && "mt-3"),
31
+ children: [inline ? /* @__PURE__ */ jsx("div", {
32
+ className: cn("px-3 pb-1 pt-1 text-[11px] font-medium uppercase tracking-wide text-faint-foreground", expanding && "h-4 whitespace-nowrap py-0 opacity-0 transition-opacity group-hover/side:opacity-100 group-focus-within/side:opacity-100", expanded && "opacity-100"),
33
+ children: group.label
34
+ }) : index > 0 && /* @__PURE__ */ jsx("div", { className: "mx-auto mb-1 h-px w-6 bg-border" }), group.items.map((item) => item.recent && isTextMode(mode) ? /* @__PURE__ */ jsx(SidebarGroup, {
35
+ item,
36
+ recent: item.recent,
37
+ mode,
38
+ active: item.id === activeItem,
39
+ onNavigate
40
+ }, item.id) : /* @__PURE__ */ jsx(SidebarItem, {
41
+ item,
42
+ mode,
43
+ active: item.id === activeItem,
44
+ onNavigate
45
+ }, item.id))]
46
+ }, group.label))
47
+ }),
48
+ /* @__PURE__ */ jsx("div", {
49
+ className: cn("shrink-0 border-t p-2", mode !== "text-large" && mode !== "text-small" && !expanding && "flex justify-center"),
50
+ children: /* @__PURE__ */ jsx(ProfileMenu, {
51
+ user,
52
+ compact: !inline,
53
+ open: profileMenuOpen,
54
+ items: userMenu,
55
+ onNavigate,
56
+ onSignOut
57
+ })
58
+ })
59
+ ]
60
+ })
61
+ });
62
+ };
63
+ //#endregion
64
+ export { Sidebar };
@@ -0,0 +1 @@
1
+ import "@protobase/schema";
@@ -0,0 +1,20 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ //#region src/app-shell/status-dot.tsx
4
+ const colour = {
5
+ neutral: "bg-faint-foreground",
6
+ info: "bg-primary",
7
+ success: "bg-success",
8
+ warning: "bg-warning",
9
+ danger: "bg-danger"
10
+ };
11
+ /** A record's status as a coloured dot; `pulse` animates a ring around it for states in progress (still under reduced motion). */
12
+ const StatusDot = ({ tone, pulse = false, className }) => /* @__PURE__ */ jsxs("span", {
13
+ "aria-hidden": true,
14
+ className: cn("relative flex size-2 shrink-0", className),
15
+ "data-tone": tone,
16
+ "data-pulse": pulse || void 0,
17
+ children: [pulse && /* @__PURE__ */ jsx("span", { className: cn("absolute inset-0 animate-ping rounded-full opacity-60 motion-reduce:animate-none", colour[tone]) }), /* @__PURE__ */ jsx("span", { className: cn("relative size-2 rounded-full", colour[tone]) })]
18
+ });
19
+ //#endregion
20
+ export { StatusDot };
@@ -0,0 +1,82 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { Breadcrumb } from "./breadcrumb.js";
3
+ import { GlobalSearch } from "./global-search.js";
4
+ import { useEffect, useRef, useState } from "react";
5
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
6
+ import { Menu, Search, X } from "lucide-react";
7
+ //#region src/app-shell/top-bar.tsx
8
+ const iconButton = "inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted md:hidden";
9
+ const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControls, search }) => {
10
+ const [searchOpen, setSearchOpen] = useState(false);
11
+ const desktopSearch = useRef(null);
12
+ useEffect(() => {
13
+ if (!search) return;
14
+ const onKey = (event) => {
15
+ if (event.key.toLowerCase() !== "k" || !(event.metaKey || event.ctrlKey)) return;
16
+ event.preventDefault();
17
+ const input = desktopSearch.current;
18
+ if (input && input.offsetParent !== null) input.focus();
19
+ else setSearchOpen(true);
20
+ };
21
+ window.addEventListener("keydown", onKey);
22
+ return () => window.removeEventListener("keydown", onKey);
23
+ }, [search]);
24
+ return /* @__PURE__ */ jsxs("header", {
25
+ className: "relative flex h-12 shrink-0 items-center gap-1 border-b bg-background px-2 md:gap-4 md:px-5",
26
+ children: [
27
+ onMenu && /* @__PURE__ */ jsx("button", {
28
+ type: "button",
29
+ "aria-label": "Open navigation",
30
+ "aria-expanded": Boolean(menuOpen),
31
+ "aria-controls": menuControls,
32
+ onClick: onMenu,
33
+ className: cn(iconButton, "-ml-0.5"),
34
+ children: /* @__PURE__ */ jsx(Menu, { className: "size-5" })
35
+ }),
36
+ /* @__PURE__ */ jsx(Breadcrumb, {
37
+ parts: breadcrumb,
38
+ onNavigate
39
+ }),
40
+ /* @__PURE__ */ jsxs("div", {
41
+ className: "ml-auto flex items-center gap-1 md:gap-3",
42
+ children: [search && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("div", {
43
+ className: "hidden md:block",
44
+ children: /* @__PURE__ */ jsx(GlobalSearch, {
45
+ ref: desktopSearch,
46
+ search
47
+ })
48
+ }), /* @__PURE__ */ jsx("button", {
49
+ type: "button",
50
+ "aria-label": "Search",
51
+ "aria-expanded": searchOpen,
52
+ onClick: () => setSearchOpen(true),
53
+ className: iconButton,
54
+ children: /* @__PURE__ */ jsx(Search, { className: "size-5" })
55
+ })] }), actions]
56
+ }),
57
+ search && searchOpen && /* @__PURE__ */ jsxs("div", {
58
+ className: "absolute inset-0 z-20 flex items-center gap-1 bg-background px-3 md:hidden",
59
+ children: [/* @__PURE__ */ jsx(GlobalSearch, {
60
+ className: "min-w-0 flex-1",
61
+ autoFocus: true,
62
+ showShortcut: false,
63
+ search: {
64
+ ...search,
65
+ onSelect: (href) => {
66
+ search.onSelect(href);
67
+ setSearchOpen(false);
68
+ }
69
+ }
70
+ }), /* @__PURE__ */ jsx("button", {
71
+ type: "button",
72
+ "aria-label": "Close search",
73
+ onClick: () => setSearchOpen(false),
74
+ className: iconButton,
75
+ children: /* @__PURE__ */ jsx(X, { className: "size-5" })
76
+ })]
77
+ })
78
+ ]
79
+ });
80
+ };
81
+ //#endregion
82
+ export { TopBar };
@@ -0,0 +1,70 @@
1
+ import { Spinner } from "../primitives/spinner.js";
2
+ import { useAuth } from "./auth-provider.js";
3
+ import { Button } from "../primitives/button.js";
4
+ import { FirstRunPage } from "./first-run-page.js";
5
+ import { readResetLink, withoutResetLink } from "./reset-link.js";
6
+ import { ForgotPasswordPage } from "./forgot-password-page.js";
7
+ import { ResetPasswordPage } from "./reset-password-page.js";
8
+ import { SignInPage } from "./sign-in-page.js";
9
+ import { useEffect, useState } from "react";
10
+ import { jsx, jsxs } from "react/jsx-runtime";
11
+ //#region src/auth/auth-gate.tsx
12
+ const firstPage = () => {
13
+ const link = readResetLink(window.location.href);
14
+ return link ? {
15
+ kind: "reset-password",
16
+ token: link.token
17
+ } : { kind: "sign-in" };
18
+ };
19
+ const forgetResetLink = () => window.history.replaceState(window.history.state, "", withoutResetLink(window.location.href));
20
+ /** Shows the app when the user is signed in (or the server has no login), and the right page otherwise. */
21
+ const AuthGate = ({ workspace, children }) => {
22
+ const { state, recheck, passwordReset, signOut } = useAuth();
23
+ const [page, setPage] = useState(firstPage);
24
+ useEffect(() => {
25
+ if (state.kind === "signed-in") setPage((current) => current.kind === "sign-in" && current.notice ? { kind: "sign-in" } : current);
26
+ }, [state.kind]);
27
+ const leaveResetPage = (next) => {
28
+ forgetResetLink();
29
+ setPage(next);
30
+ };
31
+ const passwordChanged = async () => {
32
+ if (state.kind === "signed-in") await signOut();
33
+ leaveResetPage({
34
+ kind: "sign-in",
35
+ notice: "Your password is changed. Sign in with the new one."
36
+ });
37
+ };
38
+ switch (state.kind) {
39
+ case "loading": return /* @__PURE__ */ jsxs("div", {
40
+ className: "flex h-full min-h-48 items-center justify-center gap-2 text-[13px] text-muted-foreground",
41
+ children: [/* @__PURE__ */ jsx(Spinner, {}), " Loading"]
42
+ });
43
+ case "error": return /* @__PURE__ */ jsxs("div", {
44
+ className: "flex h-full min-h-48 flex-col items-center justify-center gap-3 p-8 text-center",
45
+ children: [/* @__PURE__ */ jsxs("p", {
46
+ className: "text-[13px] text-muted-foreground",
47
+ children: ["Cannot reach the server: ", state.message]
48
+ }), /* @__PURE__ */ jsx(Button, {
49
+ onClick: recheck,
50
+ children: "Try again"
51
+ })]
52
+ });
53
+ case "needs-admin": return /* @__PURE__ */ jsx(FirstRunPage, { onCheckAgain: recheck });
54
+ }
55
+ if (page.kind === "reset-password") return /* @__PURE__ */ jsx(ResetPasswordPage, {
56
+ token: page.token,
57
+ onDone: () => void passwordChanged(),
58
+ onRequestNewLink: passwordReset ? () => leaveResetPage({ kind: "forgot-password" }) : void 0,
59
+ onBack: () => leaveResetPage({ kind: "sign-in" })
60
+ });
61
+ if (state.kind === "signed-in") return children;
62
+ if (page.kind === "forgot-password") return /* @__PURE__ */ jsx(ForgotPasswordPage, { onBack: () => setPage({ kind: "sign-in" }) });
63
+ return /* @__PURE__ */ jsx(SignInPage, {
64
+ workspace,
65
+ notice: page.notice,
66
+ onForgotPassword: passwordReset ? () => setPage({ kind: "forgot-password" }) : void 0
67
+ });
68
+ };
69
+ //#endregion
70
+ export { AuthGate };
@@ -0,0 +1,29 @@
1
+ import { LogoMark } from "../app-shell/logo.js";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ //#region src/auth/auth-layout.tsx
4
+ /** The frame of the sign-in and first-run pages: logo, title and a card. */
5
+ const AuthLayout = ({ title, description, children }) => /* @__PURE__ */ jsx("div", {
6
+ className: "flex min-h-full w-full items-center justify-center bg-surface px-4 py-10",
7
+ children: /* @__PURE__ */ jsxs("div", {
8
+ className: "w-full max-w-sm",
9
+ children: [/* @__PURE__ */ jsxs("div", {
10
+ className: "mb-6 flex flex-col items-center text-center",
11
+ children: [
12
+ /* @__PURE__ */ jsx(LogoMark, { className: "size-10" }),
13
+ /* @__PURE__ */ jsx("h1", {
14
+ className: "mt-4 text-xl font-semibold tracking-tight",
15
+ children: title
16
+ }),
17
+ description && /* @__PURE__ */ jsx("p", {
18
+ className: "mt-1 text-[13px] text-muted-foreground",
19
+ children: description
20
+ })
21
+ ]
22
+ }), /* @__PURE__ */ jsx("div", {
23
+ className: "rounded-lg border bg-background p-5 shadow-sm",
24
+ children
25
+ })]
26
+ })
27
+ });
28
+ //#endregion
29
+ export { AuthLayout };
@@ -0,0 +1,64 @@
1
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
2
+ import { jsx } from "react/jsx-runtime";
3
+ import { createAuthSession } from "@protobase/client";
4
+ //#region src/auth/auth-provider.tsx
5
+ const AuthContext = createContext(null);
6
+ const useAuth = () => {
7
+ const api = useContext(AuthContext);
8
+ if (!api) throw new Error("useAuth must be used inside AuthProvider");
9
+ return api;
10
+ };
11
+ /** Finds out whether the server has a login and who is signed in, and keeps that state for the app. */
12
+ const AuthProvider = ({ session: given, children }) => {
13
+ const [state, setState] = useState({ kind: "loading" });
14
+ const [passwordReset, setPasswordReset] = useState(false);
15
+ const markSignedOut = useCallback(() => setState((current) => current.kind === "signed-in" ? { kind: "signed-out" } : current), []);
16
+ const signedOut = useRef(markSignedOut);
17
+ signedOut.current = markSignedOut;
18
+ const session = useMemo(() => given ?? createAuthSession({ onSignedOut: () => signedOut.current() }), [given]);
19
+ const recheck = useCallback(() => {
20
+ setState({ kind: "loading" });
21
+ session.status().then(async (status) => {
22
+ setPasswordReset(status.passwordReset);
23
+ if (status.needsAdmin) return setState({ kind: "needs-admin" });
24
+ const user = await session.session();
25
+ setState(user ? {
26
+ kind: "signed-in",
27
+ user
28
+ } : { kind: "signed-out" });
29
+ }, (error) => setState({
30
+ kind: "error",
31
+ message: error instanceof Error ? error.message : "The server did not answer"
32
+ }));
33
+ }, [session]);
34
+ useEffect(recheck, [recheck]);
35
+ const api = useMemo(() => ({
36
+ state,
37
+ session,
38
+ passwordReset,
39
+ markSignedOut,
40
+ recheck,
41
+ signIn: async (email, password) => {
42
+ setState({
43
+ kind: "signed-in",
44
+ user: await session.signIn(email, password)
45
+ });
46
+ },
47
+ signOut: async () => {
48
+ await session.signOut();
49
+ setState({ kind: "signed-out" });
50
+ }
51
+ }), [
52
+ state,
53
+ session,
54
+ passwordReset,
55
+ markSignedOut,
56
+ recheck
57
+ ]);
58
+ return /* @__PURE__ */ jsx(AuthContext.Provider, {
59
+ value: api,
60
+ children
61
+ });
62
+ };
63
+ //#endregion
64
+ export { AuthProvider, useAuth };
@@ -0,0 +1,51 @@
1
+ import { Button } from "../primitives/button.js";
2
+ import { AuthLayout } from "./auth-layout.js";
3
+ import { useState } from "react";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ import { Check, Copy } from "lucide-react";
6
+ //#region src/auth/first-run-page.tsx
7
+ const createAdminCommand = "protobase users create you@example.com";
8
+ /**
9
+ * Shown while the admin store has no user. Accounts are not created over the web (a fresh URL must not be claimable
10
+ * by whoever reaches it first), so this says how to create the first admin on the host.
11
+ */
12
+ const FirstRunPage = ({ command = createAdminCommand, onCheckAgain, busy }) => {
13
+ const [copied, setCopied] = useState(false);
14
+ return /* @__PURE__ */ jsxs(AuthLayout, {
15
+ title: "No admin yet",
16
+ description: "This Protobase has no users to sign in with.",
17
+ children: [
18
+ /* @__PURE__ */ jsx("p", {
19
+ className: "text-[13px] text-muted-foreground",
20
+ children: "Run this in the terminal where Protobase runs, then reload. The first admin is created on the server, not in the browser, so nobody else can claim a fresh URL."
21
+ }),
22
+ /* @__PURE__ */ jsxs("div", {
23
+ className: "mt-4 flex items-center gap-2 rounded-md border bg-surface py-1.5 pl-3 pr-1.5",
24
+ children: [/* @__PURE__ */ jsx("code", {
25
+ className: "min-w-0 flex-1 break-all font-mono text-xs",
26
+ children: command
27
+ }), /* @__PURE__ */ jsx("button", {
28
+ type: "button",
29
+ "aria-label": "Copy command",
30
+ onClick: () => {
31
+ navigator.clipboard.writeText(command).then(() => {
32
+ setCopied(true);
33
+ setTimeout(() => setCopied(false), 2e3);
34
+ });
35
+ },
36
+ className: "inline-flex size-8 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted pointer-coarse:size-11",
37
+ children: copied ? /* @__PURE__ */ jsx(Check, { className: "size-4 text-success" }) : /* @__PURE__ */ jsx(Copy, { className: "size-4" })
38
+ })]
39
+ }),
40
+ /* @__PURE__ */ jsx(Button, {
41
+ variant: "primary",
42
+ className: "mt-4 min-h-10 w-full",
43
+ loading: busy,
44
+ onClick: onCheckAgain,
45
+ children: "Reload"
46
+ })
47
+ ]
48
+ });
49
+ };
50
+ //#endregion
51
+ export { FirstRunPage, createAdminCommand };
@@ -0,0 +1,75 @@
1
+ import { Input } from "../primitives/input.js";
2
+ import { Button } from "../primitives/button.js";
3
+ import { AuthLayout } from "./auth-layout.js";
4
+ import { useState } from "react";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ //#region src/auth/forgot-password-form.tsx
7
+ const ForgotPasswordForm = ({ onSubmit, onBack, busy, error, sentTo }) => {
8
+ const [email, setEmail] = useState("");
9
+ if (sentTo) return /* @__PURE__ */ jsxs(AuthLayout, {
10
+ title: "Check your email",
11
+ children: [/* @__PURE__ */ jsxs("p", {
12
+ className: "text-[13px] text-muted-foreground",
13
+ children: [
14
+ "If ",
15
+ /* @__PURE__ */ jsx("span", {
16
+ className: "font-medium text-foreground",
17
+ children: sentTo
18
+ }),
19
+ " has an account, an email with a link to choose a new password is on its way. The link works once, for a limited time."
20
+ ]
21
+ }), /* @__PURE__ */ jsx(Button, {
22
+ variant: "primary",
23
+ className: "mt-4 min-h-10 w-full",
24
+ onClick: onBack,
25
+ children: "Back to sign in"
26
+ })]
27
+ });
28
+ return /* @__PURE__ */ jsx(AuthLayout, {
29
+ title: "Reset your password",
30
+ description: "We email you a link to choose a new one.",
31
+ children: /* @__PURE__ */ jsxs("form", {
32
+ className: "flex flex-col gap-4",
33
+ onSubmit: (event) => {
34
+ event.preventDefault();
35
+ onSubmit(email.trim());
36
+ },
37
+ children: [
38
+ /* @__PURE__ */ jsxs("label", {
39
+ className: "flex flex-col gap-1.5 text-[13px] font-medium",
40
+ children: ["Email", /* @__PURE__ */ jsx(Input, {
41
+ type: "email",
42
+ name: "email",
43
+ autoComplete: "username",
44
+ required: true,
45
+ autoFocus: true,
46
+ value: email,
47
+ invalid: Boolean(error),
48
+ onChange: (event) => setEmail(event.target.value),
49
+ className: "min-h-6"
50
+ })]
51
+ }),
52
+ error && /* @__PURE__ */ jsx("p", {
53
+ role: "alert",
54
+ className: "text-xs font-medium text-danger-text",
55
+ children: error
56
+ }),
57
+ /* @__PURE__ */ jsx(Button, {
58
+ variant: "primary",
59
+ type: "submit",
60
+ loading: busy,
61
+ className: "min-h-10",
62
+ children: "Send reset link"
63
+ }),
64
+ /* @__PURE__ */ jsx(Button, {
65
+ variant: "ghost",
66
+ onClick: onBack,
67
+ className: "min-h-10",
68
+ children: "Back to sign in"
69
+ })
70
+ ]
71
+ })
72
+ });
73
+ };
74
+ //#endregion
75
+ export { ForgotPasswordForm };
@@ -0,0 +1,35 @@
1
+ import { useAuth } from "./auth-provider.js";
2
+ import { ForgotPasswordForm } from "./forgot-password-form.js";
3
+ import { resetLinkTarget } from "./reset-link.js";
4
+ import { useState } from "react";
5
+ import { jsx } from "react/jsx-runtime";
6
+ import { AuthError } from "@protobase/client";
7
+ //#region src/auth/forgot-password-page.tsx
8
+ /** Asks the server to email a reset link that leads back to this page. */
9
+ const ForgotPasswordPage = ({ onBack }) => {
10
+ const { session } = useAuth();
11
+ const [busy, setBusy] = useState(false);
12
+ const [error, setError] = useState();
13
+ const [sentTo, setSentTo] = useState();
14
+ const submit = async (email) => {
15
+ setBusy(true);
16
+ setError(void 0);
17
+ try {
18
+ await session.requestPasswordReset(email, resetLinkTarget(window.location.href));
19
+ setSentTo(email);
20
+ } catch (failure) {
21
+ if (!(failure instanceof AuthError)) throw failure;
22
+ setError(failure.status === 429 ? "Too many requests. Wait a minute and try again." : failure.message);
23
+ }
24
+ setBusy(false);
25
+ };
26
+ return /* @__PURE__ */ jsx(ForgotPasswordForm, {
27
+ busy,
28
+ error,
29
+ sentTo,
30
+ onBack,
31
+ onSubmit: (email) => void submit(email)
32
+ });
33
+ };
34
+ //#endregion
35
+ export { ForgotPasswordPage };