@protobase/ui 0.3.4 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app/app.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { SidebarMode } from "../app-shell/sidebar-mode.js";
2
2
  import "../app-shell/index.js";
3
3
  import { ProjectUi } from "./pages/project-ui.js";
4
- import { AssistantClient, AuthSession, Client } from "@protobase/client";
4
+ import { AssistantClient, AuthSession, Client, RuntimeClient } from "@protobase/client";
5
5
  //#region src/app/app.d.ts
6
6
  export type AppProps = {
7
7
  /** Where `/api/v1` lives. Defaults to the current origin. */
@@ -20,6 +20,8 @@ export type AppProps = {
20
20
  ui?: ProjectUi;
21
21
  /** A ready-made client of the assistant backend, for stories and tests; used only when `/meta` names an assistant. */
22
22
  assistant?: AssistantClient;
23
+ /** A ready-made client of the runtime endpoint, for stories and tests; used only when `/meta` names one. */
24
+ runtime?: RuntimeClient;
23
25
  };
24
26
  /** The admin: sign-in first, then a sidebar from `/meta`, `/:page` composed pages, `/:resource` lists and `/:resource/:key` records. Fills its parent. */
25
27
  export declare const App: ({ auth, workspace, ...rest }: AppProps) => import("react").JSX.Element;
package/dist/app/app.js CHANGED
@@ -19,11 +19,12 @@ import { ProjectUiProvider, useProjectUi } from "./pages/project-ui.js";
19
19
  import { ComposedPage } from "./pages/composed-page.js";
20
20
  import { RecordPage } from "./record-page.js";
21
21
  import { useGlobalSearch } from "./search/use-global-search.js";
22
+ import { RuntimeUpdateButton } from "./runtime/runtime-update.js";
22
23
  import { useNavRecent } from "./use-nav-recent.js";
23
24
  import { useEffect, useState } from "react";
24
25
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
25
26
  //#region src/app/app.tsx
26
- const Routes = ({ workspace, sidebarMode, assistant }) => {
27
+ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
27
28
  const meta = useAdminMeta();
28
29
  const { state, signOut } = useAuth();
29
30
  if (state.kind !== "signed-in") throw new Error("The shell is only rendered for a signed-in user");
@@ -63,6 +64,11 @@ const Routes = ({ workspace, sidebarMode, assistant }) => {
63
64
  page: params.size > 0 ? `${path}?${params}` : path,
64
65
  onClose: () => setAssistantOpen(false)
65
66
  });
67
+ const runtimeUrl = meta.runtime?.url;
68
+ const runtimeButton = runtimeUrl && /* @__PURE__ */ jsx(RuntimeUpdateButton, {
69
+ url: runtimeUrl,
70
+ client: runtime
71
+ });
66
72
  const breadcrumb = breadcrumbFor({
67
73
  basePath,
68
74
  route,
@@ -90,7 +96,11 @@ const Routes = ({ workspace, sidebarMode, assistant }) => {
90
96
  groups: search.groups,
91
97
  onSelect: open
92
98
  } : void 0,
93
- actions: (Actions || assistantButton) && /* @__PURE__ */ jsxs(Fragment$1, { children: [Actions && /* @__PURE__ */ jsx(Actions, {}), assistantButton] }),
99
+ actions: (Actions || runtimeButton || assistantButton) && /* @__PURE__ */ jsxs(Fragment$1, { children: [
100
+ Actions && /* @__PURE__ */ jsx(Actions, {}),
101
+ runtimeButton,
102
+ assistantButton
103
+ ] }),
94
104
  rightPanel: (RightPanel || assistantPanel) && /* @__PURE__ */ jsxs(Fragment$1, { children: [RightPanel && /* @__PURE__ */ jsx(RightPanel, {}), assistantPanel] }),
95
105
  children: !route.resource ? /* @__PURE__ */ jsx(Notice, {
96
106
  title: "Choose a resource",
@@ -101,7 +111,7 @@ const Routes = ({ workspace, sidebarMode, assistant }) => {
101
111
  }, `${route.resource}/${route.key}`) : /* @__PURE__ */ jsx(ListPage, { resource: route.resource }, route.resource)
102
112
  });
103
113
  };
104
- const Authenticated = ({ baseUrl, client, basePath, initialUrl, workspace, sidebarMode, ui, assistant }) => {
114
+ const Authenticated = ({ baseUrl, client, basePath, initialUrl, workspace, sidebarMode, ui, assistant, runtime }) => {
105
115
  const { session, markSignedOut } = useAuth();
106
116
  return /* @__PURE__ */ jsx(ApiProvider, {
107
117
  baseUrl,
@@ -116,7 +126,8 @@ const Authenticated = ({ baseUrl, client, basePath, initialUrl, workspace, sideb
116
126
  children: /* @__PURE__ */ jsx(Routes, {
117
127
  workspace,
118
128
  sidebarMode,
119
- assistant
129
+ assistant,
130
+ runtime
120
131
  })
121
132
  }) }) })
122
133
  })
@@ -0,0 +1,25 @@
1
+ import { formatDateTime } from "../../format/date.js";
2
+ import { isApiError } from "@protobase/client";
3
+ //#region src/app/runtime/runtime-status.ts
4
+ /** The top bar shows the update button only when there is an update and the endpoint wants it shown. */
5
+ const showsUpdateButton = (status) => Boolean(status?.updateAvailable && status.indicator);
6
+ const policyLabels = {
7
+ immediate: "Updates as soon as a release is out",
8
+ weekly: "Updates at a set time every week",
9
+ scheduled: "Updates at the time set for this release",
10
+ manual: "Updates only when asked to"
11
+ };
12
+ /** When the latest version will be applied on its own, in words. */
13
+ const nextUpdateText = (status) => {
14
+ if (status.updating) return "Updating now";
15
+ if (status.nextUpdateAt) return `${policyLabels[status.policy]}: next on ${formatDateTime(Date.parse(status.nextUpdateAt))} UTC`;
16
+ return policyLabels[status.policy];
17
+ };
18
+ /** Why "Update now" failed, in words. */
19
+ const updateErrorText = (error) => {
20
+ if (isApiError(error) && error.status === 409) return "Already on the latest version, or an update is running.";
21
+ if (isApiError(error) && error.status === 403) return "Only an admin can update the runtime.";
22
+ return "The update could not be started. Try again later.";
23
+ };
24
+ //#endregion
25
+ export { nextUpdateText, showsUpdateButton, updateErrorText };
@@ -0,0 +1,77 @@
1
+ import { Button } from "../../primitives/button.js";
2
+ import { Dialog } from "../../primitives/dialog.js";
3
+ import { nextUpdateText, showsUpdateButton, updateErrorText } from "./runtime-status.js";
4
+ import { useRuntimeClient, useRuntimeStatus } from "./use-runtime-status.js";
5
+ import { useState } from "react";
6
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
7
+ import { ArrowUpCircle } from "lucide-react";
8
+ //#region src/app/runtime/runtime-update.tsx
9
+ const Row = ({ label, value }) => /* @__PURE__ */ jsxs("div", {
10
+ className: "flex justify-between gap-4 py-1 text-[13px]",
11
+ children: [/* @__PURE__ */ jsx("span", {
12
+ className: "text-muted-foreground",
13
+ children: label
14
+ }), /* @__PURE__ */ jsx("span", {
15
+ className: "font-medium tabular-nums",
16
+ children: value
17
+ })]
18
+ });
19
+ /**
20
+ * The top bar's runtime update button, shown while the endpoint reports an update and wants it shown, and the dialog
21
+ * it opens: the versions, when the update policy applies the latest one, and "Update now". Below `md` only the icon shows.
22
+ */
23
+ const RuntimeUpdate = ({ client }) => {
24
+ const { status, update, requesting, error, checkError } = useRuntimeStatus(client);
25
+ const [open, setOpen] = useState(false);
26
+ if (!status || !open && !showsUpdateButton(status)) return null;
27
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs(Button, {
28
+ size: "sm",
29
+ variant: "ghost",
30
+ "aria-label": "Update available",
31
+ title: "Update available",
32
+ onClick: () => setOpen(true),
33
+ className: "max-md:size-9 max-md:p-0",
34
+ children: [/* @__PURE__ */ jsx(ArrowUpCircle, { className: "size-3.5 text-primary" }), /* @__PURE__ */ jsx("span", {
35
+ className: "max-md:sr-only",
36
+ children: "Update"
37
+ })]
38
+ }), open && /* @__PURE__ */ jsxs(Dialog, {
39
+ title: "Runtime update",
40
+ description: nextUpdateText(status),
41
+ onClose: () => setOpen(false),
42
+ actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
43
+ onClick: () => setOpen(false),
44
+ children: "Close"
45
+ }), /* @__PURE__ */ jsx(Button, {
46
+ variant: "primary",
47
+ loading: requesting,
48
+ disabled: status.updating || !status.updateAvailable,
49
+ onClick: () => void update(),
50
+ children: status.updating ? "Updating…" : "Update now"
51
+ })] }),
52
+ children: [
53
+ /* @__PURE__ */ jsx(Row, {
54
+ label: "Current version",
55
+ value: status.version
56
+ }),
57
+ /* @__PURE__ */ jsx(Row, {
58
+ label: "Latest version",
59
+ value: status.latest
60
+ }),
61
+ error !== void 0 && /* @__PURE__ */ jsx("p", {
62
+ role: "alert",
63
+ className: "mt-3 text-[13px] text-danger-text",
64
+ children: updateErrorText(error)
65
+ }),
66
+ checkError !== void 0 && /* @__PURE__ */ jsx("p", {
67
+ className: "mt-3 text-[13px] text-muted-foreground",
68
+ children: "The status could not be checked again; this is the last one read."
69
+ })
70
+ ]
71
+ })] });
72
+ };
73
+ const OwnRuntimeUpdate = ({ url }) => /* @__PURE__ */ jsx(RuntimeUpdate, { client: useRuntimeClient(url) });
74
+ /** The update button for the endpoint `/meta` names, signed in as the user; `client` replaces it in stories and tests. */
75
+ const RuntimeUpdateButton = ({ url, client }) => client ? /* @__PURE__ */ jsx(RuntimeUpdate, { client }) : /* @__PURE__ */ jsx(OwnRuntimeUpdate, { url });
76
+ //#endregion
77
+ export { RuntimeUpdateButton };
@@ -0,0 +1,59 @@
1
+ import { useApiToken } from "../../auth/use-api-token.js";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { createRuntimeClient } from "@protobase/client";
4
+ //#region src/app/runtime/use-runtime-status.ts
5
+ /** How often the status is read again: rarely, and more often while an update runs so the button goes away when it is done. */
6
+ const refreshMs = 18e5;
7
+ const updatingRefreshMs = 3e4;
8
+ /** A client of the runtime endpoint at `url`, authenticated as the signed-in user; `undefined` until their token is there. */
9
+ const useRuntimeClient = (url) => {
10
+ const token = useApiToken();
11
+ const latest = useRef(token);
12
+ latest.current = token;
13
+ const ready = token !== void 0;
14
+ return useMemo(() => ready ? createRuntimeClient({
15
+ url,
16
+ token: () => latest.current
17
+ }) : void 0, [url, ready]);
18
+ };
19
+ /**
20
+ * The runtime's status, read on load and every half hour after, and `update` to apply the latest version now.
21
+ * `status` keeps the last answer when a later read fails, which `checkError` then holds; `error` is why "Update now" failed.
22
+ */
23
+ const useRuntimeStatus = (client) => {
24
+ const [status, setStatus] = useState();
25
+ const [requesting, setRequesting] = useState(false);
26
+ const [error, setError] = useState();
27
+ const [checkError, setCheckError] = useState();
28
+ const updating = status?.updating ?? false;
29
+ useEffect(() => {
30
+ if (!client) return;
31
+ let active = true;
32
+ const read = () => client.status().then((next) => {
33
+ if (!active) return;
34
+ setStatus(next);
35
+ setCheckError(void 0);
36
+ }, (reason) => active && setCheckError(reason));
37
+ read();
38
+ const timer = setInterval(() => void read(), updating ? updatingRefreshMs : refreshMs);
39
+ return () => {
40
+ active = false;
41
+ clearInterval(timer);
42
+ };
43
+ }, [client, updating]);
44
+ return {
45
+ status,
46
+ update: useCallback(async () => {
47
+ if (!client) return;
48
+ setRequesting(true);
49
+ setError(void 0);
50
+ await client.update().then(setStatus, setError);
51
+ setRequesting(false);
52
+ }, [client]),
53
+ requesting,
54
+ error,
55
+ checkError
56
+ };
57
+ };
58
+ //#endregion
59
+ export { useRuntimeClient, useRuntimeStatus };
@@ -14,5 +14,9 @@ export type MetaData = {
14
14
  assistant?: {
15
15
  url: string;
16
16
  };
17
+ /** The runtime updates endpoint, when the app has one and the user has the admin role. */
18
+ runtime?: {
19
+ url: string;
20
+ };
17
21
  };
18
22
  //#endregion
@@ -27,7 +27,8 @@ const useMeta = () => {
27
27
  pages: Object.fromEntries((result.meta.pages ?? []).map((model) => [model.name, model])),
28
28
  permissions: result.meta.permissions ?? {},
29
29
  ...result.meta.userMenu && { userMenu: result.meta.userMenu },
30
- ...result.meta.assistant && { assistant: result.meta.assistant }
30
+ ...result.meta.assistant && { assistant: result.meta.assistant },
31
+ ...result.meta.runtime && { runtime: result.meta.runtime }
31
32
  };
32
33
  }
33
34
  });
@@ -0,0 +1,27 @@
1
+ import { useEffect, useState } from "react";
2
+ //#region src/lib/use-visual-viewport.ts
3
+ /**
4
+ * While `active`: the part of the page that is on screen, which shrinks when a phone's on-screen keyboard opens;
5
+ * undefined otherwise, or where the browser has no `visualViewport`.
6
+ */
7
+ const useVisualViewport = (active) => {
8
+ const [box, setBox] = useState();
9
+ useEffect(() => {
10
+ const viewport = window.visualViewport;
11
+ if (!active || !viewport) return setBox(void 0);
12
+ const update = () => setBox({
13
+ top: viewport.offsetTop,
14
+ height: viewport.height
15
+ });
16
+ update();
17
+ viewport.addEventListener("resize", update);
18
+ viewport.addEventListener("scroll", update);
19
+ return () => {
20
+ viewport.removeEventListener("resize", update);
21
+ viewport.removeEventListener("scroll", update);
22
+ };
23
+ }, [active]);
24
+ return active ? box : void 0;
25
+ };
26
+ //#endregion
27
+ export { useVisualViewport };
@@ -12,6 +12,10 @@ export type SidePanelProps = {
12
12
  /** The content; it scrolls on its own, so give it `min-h-0 flex-1`. */
13
13
  children: ReactNode;
14
14
  };
15
- /** A full-height panel beside the page, for the shell's `rightPanel`: a header with a close button, content and a footer. */
15
+ /**
16
+ * A full-height panel beside the page, for the shell's `rightPanel`: a header with a close button, content and a footer.
17
+ * Below `md` there is no room beside the page, so it covers the screen instead, shrinking with the on-screen keyboard
18
+ * so the footer stays in view.
19
+ */
16
20
  export declare const SidePanel: ({ title, icon, status, onClose, footer, children }: SidePanelProps) => import("react").JSX.Element;
17
21
  //#endregion
@@ -1,40 +1,54 @@
1
+ import { useIsDesktop } from "../lib/use-media-query.js";
1
2
  import { Button } from "../primitives/button.js";
3
+ import { useVisualViewport } from "../lib/use-visual-viewport.js";
2
4
  import { jsx, jsxs } from "react/jsx-runtime";
3
5
  import { X } from "lucide-react";
4
6
  //#region src/side-panel/side-panel.tsx
5
- /** A full-height panel beside the page, for the shell's `rightPanel`: a header with a close button, content and a footer. */
6
- const SidePanel = ({ title, icon, status, onClose, footer, children }) => /* @__PURE__ */ jsxs("aside", {
7
- "aria-label": title,
8
- className: "flex h-full w-[380px] max-w-full shrink-0 flex-col border-l border-border bg-surface",
9
- children: [
10
- /* @__PURE__ */ jsxs("header", {
11
- className: "flex h-12 shrink-0 items-center gap-2 border-b border-border bg-background px-4",
12
- children: [
13
- icon,
14
- /* @__PURE__ */ jsx("h2", {
15
- className: "flex-1 text-[13px] font-semibold",
16
- children: title
17
- }),
18
- status && /* @__PURE__ */ jsx("span", {
19
- className: "truncate text-xs font-medium text-muted-foreground",
20
- children: status
21
- }),
22
- onClose && /* @__PURE__ */ jsx(Button, {
23
- variant: "ghost",
24
- size: "sm",
25
- className: "w-7 px-0",
26
- "aria-label": "Close",
27
- onClick: onClose,
28
- children: /* @__PURE__ */ jsx(X, { className: "size-4" })
29
- })
30
- ]
31
- }),
32
- children,
33
- footer && /* @__PURE__ */ jsx("footer", {
34
- className: "shrink-0 space-y-2 border-t border-border bg-background p-3",
35
- children: footer
36
- })
37
- ]
38
- });
7
+ /**
8
+ * A full-height panel beside the page, for the shell's `rightPanel`: a header with a close button, content and a footer.
9
+ * Below `md` there is no room beside the page, so it covers the screen instead, shrinking with the on-screen keyboard
10
+ * so the footer stays in view.
11
+ */
12
+ const SidePanel = ({ title, icon, status, onClose, footer, children }) => {
13
+ const screen = useVisualViewport(!useIsDesktop());
14
+ return /* @__PURE__ */ jsxs("aside", {
15
+ "aria-label": title,
16
+ className: "flex h-full w-[380px] max-w-full shrink-0 flex-col border-l border-border bg-surface max-md:fixed max-md:inset-0 max-md:z-40 max-md:w-full max-md:border-l-0",
17
+ style: screen && {
18
+ top: screen.top,
19
+ bottom: "auto",
20
+ height: screen.height
21
+ },
22
+ children: [
23
+ /* @__PURE__ */ jsxs("header", {
24
+ className: "flex h-12 shrink-0 items-center gap-2 border-b border-border bg-background px-4",
25
+ children: [
26
+ icon,
27
+ /* @__PURE__ */ jsx("h2", {
28
+ className: "flex-1 text-[13px] font-semibold",
29
+ children: title
30
+ }),
31
+ status && /* @__PURE__ */ jsx("span", {
32
+ className: "truncate text-xs font-medium text-muted-foreground",
33
+ children: status
34
+ }),
35
+ onClose && /* @__PURE__ */ jsx(Button, {
36
+ variant: "ghost",
37
+ size: "sm",
38
+ className: "w-7 px-0",
39
+ "aria-label": "Close",
40
+ onClick: onClose,
41
+ children: /* @__PURE__ */ jsx(X, { className: "size-4" })
42
+ })
43
+ ]
44
+ }),
45
+ children,
46
+ footer && /* @__PURE__ */ jsx("footer", {
47
+ className: "shrink-0 space-y-2 border-t border-border bg-background p-3",
48
+ children: footer
49
+ })
50
+ ]
51
+ });
52
+ };
39
53
  //#endregion
40
54
  export { SidePanel };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/ui",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "The Protobase admin app and the React hooks and components for custom UI.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -24,12 +24,19 @@
24
24
  "files": [
25
25
  "dist"
26
26
  ],
27
+ "scripts": {
28
+ "build": "tsdown",
29
+ "prepack": "tsdown"
30
+ },
27
31
  "publishConfig": {
28
32
  "access": "public"
29
33
  },
30
34
  "dependencies": {
31
35
  "@dnd-kit/core": "^6.3.1",
32
36
  "@dnd-kit/sortable": "^10.0.0",
37
+ "@protobase/client": "0.3.5",
38
+ "@protobase/layout": "0.3.5",
39
+ "@protobase/schema": "0.3.5",
33
40
  "@tanstack/react-query": "^5.104.1",
34
41
  "@tanstack/react-table": "^9.2.6",
35
42
  "@tanstack/react-virtual": "^3.14.13",
@@ -37,12 +44,6 @@
37
44
  "react": "^19.3.0",
38
45
  "react-dom": "^19.3.0",
39
46
  "recharts": "^3.10.1",
40
- "tailwindcss": "^4.3.3",
41
- "@protobase/client": "0.3.4",
42
- "@protobase/layout": "0.3.4",
43
- "@protobase/schema": "0.3.4"
44
- },
45
- "scripts": {
46
- "build": "tsdown"
47
+ "tailwindcss": "^4.3.3"
47
48
  }
48
- }
49
+ }