@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,31 @@
1
+ import { keys } from "../data/query-keys.js";
2
+ import { useClient } from "../data/api-provider.js";
3
+ import { humanize } from "../live/naming.js";
4
+ import { fetchLabels } from "../live/relation-labels.js";
5
+ import { useQueries, useQueryClient } from "@tanstack/react-query";
6
+ //#region src/app/use-related-records.ts
7
+ /** One entry per relation the record holds, named by the record it points at. The tenant is left out. */
8
+ const useRelatedRecords = (model, view, resources, views, record, basePath) => {
9
+ const client = useClient();
10
+ const queryClient = useQueryClient();
11
+ const relations = Object.values(model.fields).filter((field) => field.type === "relation" && field.relation && field.name !== model.tenant && record[field.name] != null && resources[field.relation.resource]?.primaryKey.length === 1);
12
+ const labels = useQueries({ queries: relations.map((field) => {
13
+ const target = resources[field.relation.resource];
14
+ const id = String(record[field.name]);
15
+ return {
16
+ queryKey: keys.relatedLabel(target.name, id),
17
+ queryFn: async () => (await fetchLabels(queryClient, client, target, [id], views[target.name])).get(id) ?? id
18
+ };
19
+ }) });
20
+ return {
21
+ hrefs: Object.fromEntries(relations.map((field) => [field.name, `/${field.relation.resource}/${encodeURIComponent(String(record[field.name]))}`])),
22
+ labels: Object.fromEntries(relations.map((field, index) => [field.name, labels[index]?.data])),
23
+ records: relations.map((field, index) => ({
24
+ kind: view?.fields[field.name]?.label ?? humanize(field.name),
25
+ label: labels[index]?.data ?? String(record[field.name]),
26
+ href: `${basePath}/${field.relation.resource}/${encodeURIComponent(String(record[field.name]))}`
27
+ }))
28
+ };
29
+ };
30
+ //#endregion
31
+ export { useRelatedRecords };
@@ -0,0 +1,36 @@
1
+ import { humanize } from "../live/naming.js";
2
+ import { iconFor } from "./icons.js";
3
+ import { menuIcon } from "./menu-icons.js";
4
+ import { ExternalLink, LayoutDashboard } from "lucide-react";
5
+ //#region src/app/user-menu-from-meta.ts
6
+ /** The resources and pages the user menu holds; the sidebar leaves them out. */
7
+ const userMenuResources = (meta) => new Set((meta.userMenu?.items ?? []).flatMap((item) => item.kind === "resource" ? [item.resource] : item.kind === "page" ? [item.page] : []));
8
+ /** The profile menu's items from `/meta`: resources and pages open in the app (marked active while open), links open a new tab. */
9
+ const userMenuFromMeta = (meta, basePath, activeResource) => (meta.userMenu?.items ?? []).map((item, index) => {
10
+ if (item.kind === "link") return {
11
+ id: `link-${index}`,
12
+ label: item.label,
13
+ icon: menuIcon(item.icon, ExternalLink),
14
+ href: item.href,
15
+ external: true
16
+ };
17
+ if (item.kind === "page") {
18
+ const page = meta.pages[item.page];
19
+ return {
20
+ id: item.page,
21
+ label: item.label ?? page?.title ?? humanize(item.page),
22
+ icon: menuIcon(item.icon ?? page?.icon, LayoutDashboard),
23
+ href: `${basePath}/${item.page}`,
24
+ active: item.page === activeResource
25
+ };
26
+ }
27
+ return {
28
+ id: item.resource,
29
+ label: item.label ?? meta.views[item.resource]?.names?.plural ?? humanize(item.resource),
30
+ icon: menuIcon(item.icon, iconFor(item.resource)),
31
+ href: `${basePath}/${item.resource}`,
32
+ active: item.resource === activeResource
33
+ };
34
+ });
35
+ //#endregion
36
+ export { userMenuFromMeta, userMenuResources };
@@ -0,0 +1,4 @@
1
+ import "./nav.js";
2
+ import "./profile-menu.js";
3
+ import "./global-search.js";
4
+ import { ReactNode } from "react";
@@ -0,0 +1,57 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { useIsDesktop } from "../lib/use-media-query.js";
3
+ import { Drawer } from "./drawer.js";
4
+ import { Sidebar } from "./sidebar.js";
5
+ import { TopBar } from "./top-bar.js";
6
+ import { useEffect, useState } from "react";
7
+ import { jsx, jsxs } from "react/jsx-runtime";
8
+ //#region src/app-shell/app-shell.tsx
9
+ const drawerId = "app-drawer";
10
+ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, actions, rightPanel, forceExpanded, profileMenuOpen, navGroups, userMenu, onNavigate, onSignOut, search, className, children }) => {
11
+ const desktop = useIsDesktop();
12
+ const [drawerOpen, setDrawerOpen] = useState(false);
13
+ useEffect(() => {
14
+ if (desktop) setDrawerOpen(false);
15
+ }, [desktop]);
16
+ const sidebar = (mode) => /* @__PURE__ */ jsx(Sidebar, {
17
+ mode,
18
+ activeItem,
19
+ user,
20
+ workspace,
21
+ forceExpanded,
22
+ profileMenuOpen,
23
+ groups: navGroups,
24
+ userMenu,
25
+ onNavigate,
26
+ onSignOut
27
+ });
28
+ return /* @__PURE__ */ jsxs("div", {
29
+ className: cn("flex h-full min-h-0 w-full overflow-hidden bg-background", className),
30
+ children: [
31
+ desktop ? sidebar(sidebarMode) : /* @__PURE__ */ jsx(Drawer, {
32
+ id: drawerId,
33
+ open: drawerOpen,
34
+ onClose: () => setDrawerOpen(false),
35
+ children: sidebar("text-large")
36
+ }),
37
+ /* @__PURE__ */ jsxs("div", {
38
+ className: "flex min-w-0 flex-1 flex-col",
39
+ children: [/* @__PURE__ */ jsx(TopBar, {
40
+ breadcrumb,
41
+ onNavigate,
42
+ actions,
43
+ onMenu: desktop ? void 0 : () => setDrawerOpen(true),
44
+ menuOpen: drawerOpen,
45
+ menuControls: drawerId,
46
+ search
47
+ }), /* @__PURE__ */ jsx("main", {
48
+ className: "flex min-h-0 flex-1 flex-col",
49
+ children
50
+ })]
51
+ }),
52
+ rightPanel
53
+ ]
54
+ });
55
+ };
56
+ //#endregion
57
+ export { AppShell };
@@ -0,0 +1,37 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { ChevronRight } from "lucide-react";
4
+ //#region src/app-shell/breadcrumb.tsx
5
+ const inApp = (event) => event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
6
+ /** The trail of where you are; the last crumb, the current page, shows on phones too. */
7
+ const Breadcrumb = ({ parts, onNavigate }) => /* @__PURE__ */ jsx("nav", {
8
+ "aria-label": "Breadcrumb",
9
+ className: "flex min-w-0 items-center gap-1.5 text-[13px]",
10
+ children: parts.map((part, index) => {
11
+ const last = index === parts.length - 1;
12
+ const { label, href } = typeof part === "string" ? {
13
+ label: part,
14
+ href: void 0
15
+ } : part;
16
+ const text = cn("truncate", last ? "font-semibold" : "text-muted-foreground");
17
+ return /* @__PURE__ */ jsxs("span", {
18
+ className: cn("min-w-0 items-center gap-1.5", last ? "flex" : "hidden md:flex"),
19
+ children: [index > 0 && /* @__PURE__ */ jsx(ChevronRight, { className: "hidden size-3.5 shrink-0 text-faint-foreground md:block" }), href ? /* @__PURE__ */ jsx("a", {
20
+ href,
21
+ "aria-current": last ? "page" : void 0,
22
+ onClick: (event) => {
23
+ if (!onNavigate || !inApp(event)) return;
24
+ event.preventDefault();
25
+ onNavigate(href);
26
+ },
27
+ className: cn(text, "rounded-sm hover:text-foreground hover:underline"),
28
+ children: label
29
+ }) : /* @__PURE__ */ jsx("span", {
30
+ className: text,
31
+ children: label
32
+ })]
33
+ }, `${index}-${label}`);
34
+ })
35
+ });
36
+ //#endregion
37
+ export { Breadcrumb };
@@ -0,0 +1,30 @@
1
+ import { useFocusTrap } from "../lib/use-focus-trap.js";
2
+ import { useRef } from "react";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/app-shell/drawer.tsx
5
+ /** The sidebar on small screens: slides over the page, traps focus, closes on Escape, backdrop tap or navigation. */
6
+ const Drawer = ({ id, open, onClose, children }) => {
7
+ const panel = useRef(null);
8
+ useFocusTrap(panel, open, onClose);
9
+ if (!open) return null;
10
+ return /* @__PURE__ */ jsxs("div", {
11
+ className: "fixed inset-0 z-50 md:hidden",
12
+ children: [/* @__PURE__ */ jsx("div", {
13
+ className: "absolute inset-0 bg-foreground/40",
14
+ onClick: onClose,
15
+ "aria-hidden": true
16
+ }), /* @__PURE__ */ jsx("div", {
17
+ ref: panel,
18
+ id,
19
+ role: "dialog",
20
+ "aria-modal": "true",
21
+ "aria-label": "Navigation",
22
+ tabIndex: -1,
23
+ className: "absolute inset-y-0 left-0 flex w-60 max-w-[85vw] bg-sidebar shadow-pop outline-none",
24
+ onClick: (event) => event.target.closest("a") && onClose(),
25
+ children
26
+ })]
27
+ });
28
+ };
29
+ //#endregion
30
+ export { Drawer };
@@ -0,0 +1 @@
1
+ import "react";
@@ -0,0 +1,145 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { Input } from "../primitives/input.js";
3
+ import { Kbd } from "../primitives/kbd.js";
4
+ import { Spinner } from "../primitives/spinner.js";
5
+ import { useId, useRef, useState } from "react";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ import { Search, X } from "lucide-react";
8
+ //#region src/app-shell/global-search.tsx
9
+ /** Moves the highlighted result by `delta`, wrapping around; from nothing (`-1`) it goes to the first or the last. */
10
+ const moveActive = (active, count, delta) => {
11
+ if (count === 0) return -1;
12
+ if (active < 0) return delta === 1 ? 0 : count - 1;
13
+ return (active + delta + count) % count;
14
+ };
15
+ /** A click the browser should handle itself: a new tab or window, or a download. */
16
+ const opensElsewhere = (event) => event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0;
17
+ /**
18
+ * A search box with grouped results under it, each a link: arrows move through them, Enter or a click opens one and
19
+ * closes the list, keeping the text, so focusing the box again shows the same results. Escape clears, then leaves.
20
+ */
21
+ const GlobalSearch = ({ search, className = "w-96", autoFocus, showShortcut = true, ref }) => {
22
+ const listId = useId();
23
+ const input = useRef(null);
24
+ const [focused, setFocused] = useState(false);
25
+ const [active, setActive] = useState(-1);
26
+ const hits = search.groups.flatMap((group) => group.hits);
27
+ const open = focused && (search.query !== "" || search.loading);
28
+ const optionId = (index) => `${listId}-${index}`;
29
+ const setInput = (node) => {
30
+ input.current = node;
31
+ if (typeof ref === "function") ref(node);
32
+ else if (ref) ref.current = node;
33
+ };
34
+ const choose = (href) => {
35
+ search.onSelect(href);
36
+ setActive(-1);
37
+ input.current?.blur();
38
+ };
39
+ const clear = () => {
40
+ search.onTextChange("");
41
+ setActive(-1);
42
+ input.current?.focus();
43
+ };
44
+ const onKeyDown = (event) => {
45
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
46
+ event.preventDefault();
47
+ setActive((current) => moveActive(current, hits.length, event.key === "ArrowDown" ? 1 : -1));
48
+ } else if (event.key === "Enter") {
49
+ const hit = hits[active] ?? hits[0];
50
+ if (!hit) return;
51
+ event.preventDefault();
52
+ choose(hit.href);
53
+ } else if (event.key === "Escape") {
54
+ if (search.text) search.onTextChange("");
55
+ else event.currentTarget.blur();
56
+ setActive(-1);
57
+ }
58
+ };
59
+ let position = -1;
60
+ return /* @__PURE__ */ jsxs("div", {
61
+ className: cn("relative", className),
62
+ children: [/* @__PURE__ */ jsx(Input, {
63
+ ref: setInput,
64
+ wrapperClassName: "w-full bg-surface shadow-none",
65
+ leading: search.loading ? /* @__PURE__ */ jsx(Spinner, { className: "size-4" }) : /* @__PURE__ */ jsx(Search, { className: "size-4" }),
66
+ trailing: search.text ? /* @__PURE__ */ jsx("button", {
67
+ type: "button",
68
+ "aria-label": "Clear search",
69
+ className: "-mr-1 inline-flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground",
70
+ onMouseDown: (event) => event.preventDefault(),
71
+ onClick: clear,
72
+ children: /* @__PURE__ */ jsx(X, { className: "size-3.5" })
73
+ }) : showShortcut ? /* @__PURE__ */ jsx("span", {
74
+ className: "hidden md:inline-flex",
75
+ children: /* @__PURE__ */ jsx(Kbd, { children: "⌘K" })
76
+ }) : void 0,
77
+ placeholder: search.placeholder,
78
+ "aria-label": "Global search",
79
+ role: "combobox",
80
+ "aria-autocomplete": "list",
81
+ "aria-expanded": open,
82
+ "aria-controls": listId,
83
+ "aria-activedescendant": open && hits[active] ? optionId(active) : void 0,
84
+ autoFocus,
85
+ value: search.text,
86
+ onChange: (event) => {
87
+ search.onTextChange(event.target.value);
88
+ setActive(-1);
89
+ },
90
+ onFocus: () => setFocused(true),
91
+ onBlur: () => setFocused(false),
92
+ onKeyDown
93
+ }), open && /* @__PURE__ */ jsxs("div", {
94
+ id: listId,
95
+ role: "listbox",
96
+ "aria-label": "Search results",
97
+ 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 text-[13px] shadow-pop",
98
+ children: [search.groups.length === 0 && /* @__PURE__ */ jsx("p", {
99
+ className: "px-3 py-2 text-muted-foreground",
100
+ children: search.loading ? "Searching" : `No matches for “${search.query}”`
101
+ }), search.groups.map((group) => /* @__PURE__ */ jsxs("div", {
102
+ role: "group",
103
+ "aria-label": group.label,
104
+ children: [
105
+ /* @__PURE__ */ jsx("div", {
106
+ className: "px-3 pb-1 pt-2 text-[11px] font-medium uppercase tracking-wide text-faint-foreground",
107
+ children: group.label
108
+ }),
109
+ group.failed && /* @__PURE__ */ jsxs("p", {
110
+ className: "px-3 py-1.5 text-danger-text",
111
+ children: ["Could not search ", group.label.toLowerCase()]
112
+ }),
113
+ group.hits.map((hit) => {
114
+ position += 1;
115
+ const index = position;
116
+ return /* @__PURE__ */ jsxs("a", {
117
+ id: optionId(index),
118
+ href: hit.href,
119
+ tabIndex: -1,
120
+ role: "option",
121
+ "aria-selected": index === active,
122
+ className: cn("block px-3 py-1.5", index === active ? "bg-primary-soft text-primary-text" : "hover:bg-muted"),
123
+ onMouseDown: (event) => event.preventDefault(),
124
+ onClick: (event) => {
125
+ if (opensElsewhere(event)) return;
126
+ event.preventDefault();
127
+ choose(hit.href);
128
+ },
129
+ onMouseEnter: () => setActive(index),
130
+ children: [/* @__PURE__ */ jsx("span", {
131
+ className: "block truncate font-semibold",
132
+ children: hit.title
133
+ }), hit.subtitle && /* @__PURE__ */ jsx("span", {
134
+ className: cn("block truncate text-xs", index === active ? "text-primary-text/80" : "text-muted-foreground"),
135
+ children: hit.subtitle
136
+ })]
137
+ }, hit.id);
138
+ })
139
+ ]
140
+ }, group.id))]
141
+ })]
142
+ });
143
+ };
144
+ //#endregion
145
+ export { GlobalSearch, moveActive };
@@ -0,0 +1,9 @@
1
+ import "./nav.js";
2
+ import "./profile-menu.js";
3
+ import { SidebarMode, sidebarModes } from "./sidebar-mode.js";
4
+ import "./global-search.js";
5
+ import "./app-shell.js";
6
+ import "./page-header.js";
7
+ import "./sidebar.js";
8
+ import "./status-dot.js";
9
+ export type { SidebarMode };
@@ -0,0 +1,46 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ //#region src/app-shell/logo.tsx
4
+ const LogoMark = ({ className }) => /* @__PURE__ */ jsxs("svg", {
5
+ viewBox: "0 0 24 24",
6
+ className: cn("size-7 shrink-0", className),
7
+ "aria-hidden": true,
8
+ children: [
9
+ /* @__PURE__ */ jsx("rect", {
10
+ width: "24",
11
+ height: "24",
12
+ rx: "6",
13
+ className: "fill-primary"
14
+ }),
15
+ /* @__PURE__ */ jsx("path", {
16
+ d: "M7 17V7h5.2a3 3 0 0 1 0 6H7",
17
+ fill: "none",
18
+ stroke: "white",
19
+ strokeWidth: "2",
20
+ strokeLinecap: "round",
21
+ strokeLinejoin: "round"
22
+ }),
23
+ /* @__PURE__ */ jsx("path", {
24
+ d: "M12.5 17h4.5",
25
+ stroke: "white",
26
+ strokeOpacity: "0.55",
27
+ strokeWidth: "2",
28
+ strokeLinecap: "round"
29
+ })
30
+ ]
31
+ });
32
+ const Logo = ({ showName, workspace }) => /* @__PURE__ */ jsxs("div", {
33
+ className: "flex items-center gap-2.5 overflow-hidden",
34
+ children: [/* @__PURE__ */ jsx(LogoMark, {}), showName && /* @__PURE__ */ jsxs("div", {
35
+ className: "min-w-0 leading-tight",
36
+ children: [/* @__PURE__ */ jsx("div", {
37
+ className: "text-[13px] font-semibold tracking-tight",
38
+ children: "Protobase"
39
+ }), workspace && /* @__PURE__ */ jsx("div", {
40
+ className: "truncate text-[11px] text-muted-foreground",
41
+ children: workspace
42
+ })]
43
+ })]
44
+ });
45
+ //#endregion
46
+ export { Logo, LogoMark };
@@ -0,0 +1,10 @@
1
+ import { guarded } from "../lib/guarded.js";
2
+ //#region src/app-shell/nav-open-storage.ts
3
+ const prefix = "protobase:nav-open:";
4
+ const navOpenStorageKey = (itemId) => `${prefix}${itemId}`;
5
+ /** Whether a collapsible sidebar entry was left open in this browser; open until someone closes it. */
6
+ const readNavOpen = (storage, key) => guarded(() => storage.getItem(key) !== "false", true);
7
+ /** Remembers only a closed entry, so the default (open) costs no storage. */
8
+ const writeNavOpen = (storage, key, open) => guarded(() => open ? storage.removeItem(key) : storage.setItem(key, "false"), void 0);
9
+ //#endregion
10
+ export { navOpenStorageKey, readNavOpen, writeNavOpen };
@@ -0,0 +1,2 @@
1
+ import "lucide-react";
2
+ import "@protobase/schema";
@@ -0,0 +1,85 @@
1
+ import { BarChart3, Boxes, Building2, CreditCard, FileText, Package, Receipt, Settings, ShoppingCart, Truck, Users, Warehouse } from "lucide-react";
2
+ //#region src/app-shell/nav.ts
3
+ const navGroups = [
4
+ {
5
+ label: "Sales",
6
+ items: [
7
+ {
8
+ id: "orders",
9
+ label: "Orders",
10
+ icon: ShoppingCart,
11
+ count: 12
12
+ },
13
+ {
14
+ id: "customers",
15
+ label: "Customers",
16
+ icon: Building2
17
+ },
18
+ {
19
+ id: "invoices",
20
+ label: "Invoices",
21
+ icon: FileText,
22
+ count: 3
23
+ }
24
+ ]
25
+ },
26
+ {
27
+ label: "Inventory",
28
+ items: [
29
+ {
30
+ id: "products",
31
+ label: "Products",
32
+ icon: Package
33
+ },
34
+ {
35
+ id: "stock-moves",
36
+ label: "Stock moves",
37
+ icon: Boxes
38
+ },
39
+ {
40
+ id: "warehouses",
41
+ label: "Warehouses",
42
+ icon: Warehouse
43
+ },
44
+ {
45
+ id: "shipments",
46
+ label: "Shipments",
47
+ icon: Truck
48
+ }
49
+ ]
50
+ },
51
+ {
52
+ label: "Finance",
53
+ items: [
54
+ {
55
+ id: "payments",
56
+ label: "Payments",
57
+ icon: CreditCard
58
+ },
59
+ {
60
+ id: "credit-notes",
61
+ label: "Credit notes",
62
+ icon: Receipt
63
+ },
64
+ {
65
+ id: "reports",
66
+ label: "Reports",
67
+ icon: BarChart3
68
+ }
69
+ ]
70
+ },
71
+ {
72
+ label: "Admin",
73
+ items: [{
74
+ id: "users",
75
+ label: "Users",
76
+ icon: Users
77
+ }, {
78
+ id: "settings",
79
+ label: "Settings",
80
+ icon: Settings
81
+ }]
82
+ }
83
+ ];
84
+ //#endregion
85
+ export { navGroups };
@@ -0,0 +1 @@
1
+ import { ReactNode } from "react";
@@ -0,0 +1,17 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ //#region src/app-shell/page-header.tsx
3
+ const PageHeader = ({ title, subtitle, actions }) => /* @__PURE__ */ jsxs("div", {
4
+ className: "flex flex-wrap items-end justify-between gap-x-4 gap-y-2",
5
+ children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("h1", {
6
+ className: "text-xl font-semibold tracking-tight",
7
+ children: title
8
+ }), subtitle && /* @__PURE__ */ jsx("p", {
9
+ className: "text-[13px] text-muted-foreground",
10
+ children: subtitle
11
+ })] }), /* @__PURE__ */ jsx("div", {
12
+ className: "flex w-full flex-wrap items-center gap-2 sm:w-auto",
13
+ children: actions
14
+ })]
15
+ });
16
+ //#endregion
17
+ export { PageHeader };
@@ -0,0 +1,2 @@
1
+ import "../primitives/badge.js";
2
+ import "lucide-react";
@@ -0,0 +1,115 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { Avatar } from "../primitives/avatar.js";
3
+ import { Badge } from "../primitives/badge.js";
4
+ import { useEffect, useRef, useState } from "react";
5
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
6
+ import { ChevronsUpDown, LogOut } from "lucide-react";
7
+ //#region src/app-shell/profile-menu.tsx
8
+ const menuRow = "flex min-h-11 w-full items-center gap-2.5 px-3 py-1.5 text-left text-[13px] text-muted-foreground hover:bg-muted md:min-h-0";
9
+ /** The person at the bottom of the sidebar; the menu is fixed to the viewport so a narrow sidebar cannot clip it. */
10
+ const ProfileMenu = ({ user, compact, open: controlled, items = [], onNavigate, onSignOut }) => {
11
+ const [local, setLocal] = useState(false);
12
+ const open = controlled ?? local;
13
+ const root = useRef(null);
14
+ const [anchor, setAnchor] = useState();
15
+ useEffect(() => {
16
+ if (!open || controlled !== void 0) return;
17
+ const close = (event) => {
18
+ if (event instanceof KeyboardEvent ? event.key === "Escape" : !root.current?.contains(event.target)) setLocal(false);
19
+ };
20
+ document.addEventListener("mousedown", close);
21
+ document.addEventListener("keydown", close);
22
+ return () => {
23
+ document.removeEventListener("mousedown", close);
24
+ document.removeEventListener("keydown", close);
25
+ };
26
+ }, [open, controlled]);
27
+ const toggle = () => {
28
+ const rect = root.current?.getBoundingClientRect();
29
+ if (rect) setAnchor({
30
+ left: rect.left,
31
+ bottom: window.innerHeight - rect.top + 8
32
+ });
33
+ setLocal(!local);
34
+ };
35
+ return /* @__PURE__ */ jsxs("div", {
36
+ ref: root,
37
+ className: "relative",
38
+ children: [/* @__PURE__ */ jsxs("button", {
39
+ type: "button",
40
+ 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",
41
+ "aria-label": "Profile menu",
42
+ "aria-haspopup": "menu",
43
+ "aria-expanded": open,
44
+ onClick: toggle,
45
+ children: [/* @__PURE__ */ jsx(Avatar, {
46
+ initials: user.initials,
47
+ hue: user.hue,
48
+ size: "md",
49
+ className: "ring-0"
50
+ }), !compact && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("span", {
51
+ className: "min-w-0 flex-1 leading-tight",
52
+ children: [/* @__PURE__ */ jsx("span", {
53
+ className: "block truncate text-[13px] font-medium",
54
+ children: user.name
55
+ }), /* @__PURE__ */ jsx(Badge, {
56
+ tone: user.roleTone,
57
+ dot: false,
58
+ className: "mt-0.5 h-4 px-1.5 text-[10px]",
59
+ children: user.role
60
+ })]
61
+ }), /* @__PURE__ */ jsx(ChevronsUpDown, { className: "size-3.5 shrink-0 text-faint-foreground" })] })]
62
+ }), open && /* @__PURE__ */ jsxs("div", {
63
+ role: "menu",
64
+ className: controlled === void 0 ? "fixed z-[60] w-56 overflow-hidden rounded-lg border bg-background py-1 shadow-pop" : "absolute bottom-full left-0 z-50 mb-2 w-56 overflow-hidden rounded-lg border bg-background py-1 shadow-pop",
65
+ style: controlled === void 0 && anchor ? {
66
+ left: anchor.left,
67
+ bottom: anchor.bottom
68
+ } : void 0,
69
+ children: [
70
+ /* @__PURE__ */ jsxs("div", {
71
+ className: "border-b px-3 pb-2 pt-1.5",
72
+ children: [/* @__PURE__ */ jsx("div", {
73
+ className: "text-[13px] font-medium",
74
+ children: user.name
75
+ }), /* @__PURE__ */ jsx("div", {
76
+ className: "truncate text-xs text-muted-foreground",
77
+ children: user.email
78
+ })]
79
+ }),
80
+ items.length > 0 && /* @__PURE__ */ jsx("div", {
81
+ className: cn(onSignOut && "border-b"),
82
+ children: items.map((item) => {
83
+ const Icon = item.icon;
84
+ return /* @__PURE__ */ jsxs("a", {
85
+ role: "menuitem",
86
+ href: item.href,
87
+ "aria-current": item.active ? "page" : void 0,
88
+ ...item.external ? {
89
+ target: "_blank",
90
+ rel: "noreferrer"
91
+ } : {},
92
+ onClick: (event) => {
93
+ setLocal(false);
94
+ if (item.external || !onNavigate) return;
95
+ event.preventDefault();
96
+ onNavigate(item.href);
97
+ },
98
+ className: cn(menuRow, item.active && "bg-muted text-foreground"),
99
+ children: [/* @__PURE__ */ jsx(Icon, { className: "size-4" }), item.label]
100
+ }, item.id);
101
+ })
102
+ }),
103
+ onSignOut && /* @__PURE__ */ jsxs("button", {
104
+ type: "button",
105
+ role: "menuitem",
106
+ onClick: onSignOut,
107
+ className: menuRow,
108
+ children: [/* @__PURE__ */ jsx(LogOut, { className: "size-4" }), "Sign out"]
109
+ })
110
+ ]
111
+ })]
112
+ });
113
+ };
114
+ //#endregion
115
+ export { ProfileMenu };