@protobase/ui 0.12.9 → 0.12.11

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 (38) hide show
  1. package/dist/app/action-icon.js +3 -7
  2. package/dist/app/app.d.ts +4 -1
  3. package/dist/app/app.js +26 -18
  4. package/dist/app/create/create-page.js +32 -6
  5. package/dist/app/create/create-values.js +16 -3
  6. package/dist/app/current-address.d.ts +1 -0
  7. package/dist/app/displays/cards-display.js +58 -10
  8. package/dist/app/displays/list-displays.js +7 -3
  9. package/dist/app/field-editor.js +4 -0
  10. package/dist/app/named-icon.js +13 -0
  11. package/dist/app/pages/field-value.js +5 -5
  12. package/dist/app/pages/use-relation-labels.js +1 -1
  13. package/dist/app/record-page.js +4 -3
  14. package/dist/app/value-badge.js +14 -0
  15. package/dist/app-shell/app-shell.js +2 -1
  16. package/dist/app-shell/sidebar.js +12 -2
  17. package/dist/auth/auth-gate.js +17 -11
  18. package/dist/auth/auth-provider.js +10 -3
  19. package/dist/auth/sign-in-form.js +7 -1
  20. package/dist/auth/sign-in-page.js +2 -1
  21. package/dist/data-grid/cells.js +9 -6
  22. package/dist/lib/readable-text.js +23 -0
  23. package/dist/live/columns-from-model.js +3 -0
  24. package/dist/live/display-value.js +6 -3
  25. package/dist/live/use-filter-config.js +5 -3
  26. package/dist/live/value-color.js +8 -0
  27. package/dist/package.js +1 -1
  28. package/dist/primitives/badge.js +12 -3
  29. package/dist/roles/use-active-role.js +8 -3
  30. package/dist/shell/assets/app-B2rZN97s.js +65 -0
  31. package/dist/shell/assets/{index-CGPHR8-j.js → index-B7XSEKuk.js} +1 -1
  32. package/dist/shell/assets/index-otHceRXj.css +2 -0
  33. package/dist/shell/assets/{shared-protobase-ui-oolw3GIu.js → shared-protobase-ui-DhQ8wFhe.js} +1 -1
  34. package/dist/shell/index.html +4 -4
  35. package/dist/shell/protobase.shell.json +1 -1
  36. package/package.json +4 -4
  37. package/dist/shell/assets/app-Ds2CYFLS.js +0 -65
  38. package/dist/shell/assets/index-BMIEmNDF.css +0 -2
@@ -1,10 +1,6 @@
1
- import { FileText, Send, Wallet, Zap } from "lucide-react";
1
+ import { namedIcon } from "./named-icon.js";
2
+ import { Zap } from "lucide-react";
2
3
  //#region src/app/action-icon.ts
3
- const byName = {
4
- wallet: Wallet,
5
- "file-text": FileText,
6
- send: Send
7
- };
8
- const actionIcon = (name) => name ? byName[name] ?? Zap : Zap;
4
+ const actionIcon = (name) => namedIcon(name) ?? Zap;
9
5
  //#endregion
10
6
  export { actionIcon };
package/dist/app/app.d.ts CHANGED
@@ -28,6 +28,9 @@ export type AppProps = {
28
28
  /** How the plugins `/meta` names are loaded, for stories and tests; the page's `fetch` and `import()` by default. */
29
29
  pluginLoader?: PluginLoader;
30
30
  };
31
- /** The admin: sign-in first, then a sidebar from `/meta`, `/:page` composed pages, `/:resource` lists and `/:resource/:key` records. Fills its parent. */
31
+ /**
32
+ * The admin: sign-in first, or browsing as a visitor when the project has an anonymous role, then a sidebar from
33
+ * `/meta`, `/:page` composed pages, `/:resource` lists and `/:resource/:key` records. Fills its parent.
34
+ */
32
35
  export declare const App: ({ auth, workspace, ...rest }: AppProps) => import("react").JSX.Element;
33
36
  //#endregion
package/dist/app/app.js CHANGED
@@ -48,9 +48,10 @@ const placeOf = (address) => address?.kind === "page" ? address.page : address?.
48
48
  const recordOf = (address) => address?.kind === "record" ? address : void 0;
49
49
  const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
50
50
  const meta = useAdminMeta();
51
- const { state, session, signOut, stopStaffSession, organizations: organizationSettings } = useAuth();
52
- if (state.kind !== "signed-in") throw new Error("The shell is only rendered for a signed-in user");
53
- const { user, staff } = state;
51
+ const { state, session, signOut, showSignIn, stopStaffSession, organizations: organizationSettings } = useAuth();
52
+ if (state.kind !== "signed-in" && state.kind !== "visitor") throw new Error("The shell is only rendered for a signed-in user or a visitor");
53
+ const user = state.kind === "signed-in" ? state.user : void 0;
54
+ const staff = state.kind === "signed-in" ? state.staff : void 0;
54
55
  const { path, params, basePath } = useRouter();
55
56
  const { address, go, open } = useAppAddress();
56
57
  const place = placeOf(address);
@@ -112,15 +113,15 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
112
113
  url: runtimeUrl,
113
114
  client: runtime
114
115
  });
115
- const role = useActiveRole(useCallback(() => reopenIn(basePath), [basePath]));
116
- const acting = actingUser(user, role.current);
117
- const admin = isAdmin(acting);
116
+ const role = useActiveRole(useCallback(() => reopenIn(basePath), [basePath]), Boolean(user));
117
+ const acting = user && actingUser(user, role.current);
118
+ const admin = acting ? isAdmin(acting) : false;
118
119
  const pluginsOff = usePluginsOff();
119
- const organizations = useOrganizations(Boolean(organizationSettings));
120
+ const organizations = useOrganizations(Boolean(organizationSettings && user));
120
121
  const current = organizations.current;
121
122
  const outsideOrganizations = Boolean(organizationSettings) && current !== void 0 && !current.organization;
122
- const organizationPage = organizationSettings ? organizationPageTitles[path] : void 0;
123
- const accountPage = path === "/-/account" ? "Sign-in & security" : path === "/-/sign-in-policy" ? "Sign-in policy" : organizationPage;
123
+ const organizationPage = organizationSettings && user ? organizationPageTitles[path] : void 0;
124
+ const accountPage = !user ? void 0 : path === "/-/account" ? "Sign-in & security" : path === "/-/sign-in-policy" ? "Sign-in policy" : organizationPage;
124
125
  const breadcrumb = accountPage ? [accountPage] : breadcrumbFor({
125
126
  basePath,
126
127
  address,
@@ -130,7 +131,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
130
131
  recordTitle: title,
131
132
  recordLoading: titleLoading
132
133
  });
133
- const content = path === "/-/account" ? /* @__PURE__ */ jsx(AccountSecurityPage, { email: user.email }) : path === "/-/sign-in-policy" ? admin ? /* @__PURE__ */ jsx(SignInPolicyPage, {}) : /* @__PURE__ */ jsx(Notice, {
134
+ const content = user && path === "/-/account" ? /* @__PURE__ */ jsx(AccountSecurityPage, { email: user.email }) : user && path === "/-/sign-in-policy" ? admin ? /* @__PURE__ */ jsx(SignInPolicyPage, {}) : /* @__PURE__ */ jsx(Notice, {
134
135
  title: "Admins only",
135
136
  children: "Only an admin can change how people sign in."
136
137
  }) : organizationPage && path === "/-/organization" ? /* @__PURE__ */ jsx(OrganizationPage, {
@@ -156,7 +157,10 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
156
157
  address.page,
157
158
  "."
158
159
  ]
159
- }) : drawnFrom && drawnFrom.state !== "ready" ? /* @__PURE__ */ jsx(SourceNotice, { status: drawnFrom }) : address.kind === "create" ? /* @__PURE__ */ jsx(CreatePage, { resource: address.resource }, `${address.resource}/new`) : address.kind === "record" ? /* @__PURE__ */ jsx(RecordPage, {
160
+ }) : drawnFrom && drawnFrom.state !== "ready" ? /* @__PURE__ */ jsx(SourceNotice, { status: drawnFrom }) : address.kind === "create" ? /* @__PURE__ */ jsx(CreatePage, {
161
+ resource: address.resource,
162
+ values: address.values
163
+ }, `${address.resource}/new?${new URLSearchParams(address.values).toString()}`) : address.kind === "record" ? /* @__PURE__ */ jsx(RecordPage, {
160
164
  resource: address.resource,
161
165
  recordKey: address.key
162
166
  }, `${address.resource}/${address.key}`) : /* @__PURE__ */ jsx(ListPage, { address }, address.resource);
@@ -164,15 +168,16 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
164
168
  sidebarMode: sidebarMode ?? "text-small",
165
169
  activeItem: place ?? "",
166
170
  breadcrumb,
167
- user: userToShell(acting),
168
- onSignOut: () => void signOut(),
171
+ user: acting && userToShell(acting),
172
+ onSignOut: user && (() => void signOut()),
173
+ onSignIn: user ? void 0 : showSignIn,
169
174
  workspace: current?.organization?.name ?? workspace,
170
175
  navGroups: groups,
171
176
  bottomBar: bottomBarFromMeta(meta, basePath, place),
172
177
  userMenu: [
173
178
  ...userMenuFromMeta(meta, basePath, place),
174
- ...organizationSettings ? organizationMenuItems(basePath, path, current) : [],
175
- ...accountMenuItems(basePath, path, admin)
179
+ ...organizationSettings && user ? organizationMenuItems(basePath, path, current) : [],
180
+ ...user ? accountMenuItems(basePath, path, admin) : []
176
181
  ],
177
182
  organizations: organizationSettings && {
178
183
  ...current?.organization && { current: {
@@ -231,12 +236,12 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
231
236
  });
232
237
  };
233
238
  const Authenticated = ({ baseUrl, client, sources, basePath, initialUrl, workspace, sidebarMode, ui, assistant, runtime, pluginLoader }) => {
234
- const { session, markSignedOut } = useAuth();
239
+ const { state, session, markSignedOut } = useAuth();
235
240
  return /* @__PURE__ */ jsx(ApiProvider, {
236
241
  baseUrl,
237
242
  client,
238
243
  sources,
239
- token: session.token,
244
+ token: state.kind === "visitor" ? void 0 : session.token,
240
245
  onUnauthenticated: markSignedOut,
241
246
  children: /* @__PURE__ */ jsx(Router, {
242
247
  basePath,
@@ -254,7 +259,10 @@ const Authenticated = ({ baseUrl, client, sources, basePath, initialUrl, workspa
254
259
  })
255
260
  });
256
261
  };
257
- /** The admin: sign-in first, then a sidebar from `/meta`, `/:page` composed pages, `/:resource` lists and `/:resource/:key` records. Fills its parent. */
262
+ /**
263
+ * The admin: sign-in first, or browsing as a visitor when the project has an anonymous role, then a sidebar from
264
+ * `/meta`, `/:page` composed pages, `/:resource` lists and `/:resource/:key` records. Fills its parent.
265
+ */
258
266
  const App = ({ auth, workspace, ...rest }) => /* @__PURE__ */ jsx(AuthProvider, {
259
267
  session: auth,
260
268
  children: /* @__PURE__ */ jsx(AuthGate, {
@@ -1,6 +1,7 @@
1
1
  import { useClient } from "../../data/api-provider.js";
2
2
  import { recordKey } from "../../live/model-helpers.js";
3
3
  import { humanize } from "../../live/naming.js";
4
+ import { fetchLabels } from "../../live/relation-labels.js";
4
5
  import { Notice, useAdminMeta, usePermissions } from "../meta-gate.js";
5
6
  import { useAppAddress } from "../use-app-address.js";
6
7
  import { FieldEditor } from "../field-editor.js";
@@ -8,14 +9,14 @@ import { can } from "../permissions.js";
8
9
  import { useLiveSave } from "../use-live-save.js";
9
10
  import { CreateRecordForm } from "./create-record-form.js";
10
11
  import { createBody, createSections, hasDatabaseDefault, initialDraft, isChosen, isRequired } from "./create-values.js";
11
- import { useState } from "react";
12
+ import { useEffect, useState } from "react";
12
13
  import { isApiError } from "@protobase/client";
13
14
  import { jsx, jsxs } from "react/jsx-runtime";
14
15
  import { useQueryClient } from "@tanstack/react-query";
15
16
  //#region src/app/create/create-page.tsx
16
17
  const without = (source, name) => Object.fromEntries(Object.entries(source).filter(([key]) => key !== name));
17
- /** `/:resource/new`: a form from the view's layout; on success it opens the new record. */
18
- const CreatePage = ({ resource }) => {
18
+ /** `/:resource/new`: a form from the view's layout, filled in with the address's `values`; on success it opens the new record. */
19
+ const CreatePage = ({ resource, values }) => {
19
20
  const meta = useAdminMeta();
20
21
  const model = meta.resources[resource];
21
22
  const permissions = usePermissions(resource);
@@ -35,9 +36,12 @@ const CreatePage = ({ resource }) => {
35
36
  "."
36
37
  ]
37
38
  });
38
- return /* @__PURE__ */ jsx(LoadedCreate, { resource });
39
+ return /* @__PURE__ */ jsx(LoadedCreate, {
40
+ resource,
41
+ values
42
+ });
39
43
  };
40
- const LoadedCreate = ({ resource }) => {
44
+ const LoadedCreate = ({ resource, values }) => {
41
45
  const meta = useAdminMeta();
42
46
  const model = meta.resources[resource];
43
47
  const view = meta.views[resource];
@@ -47,8 +51,30 @@ const LoadedCreate = ({ resource }) => {
47
51
  const noun = view?.names?.singular ?? humanize(resource);
48
52
  const feedback = useLiveSave(view?.saveFeedback ?? "toast", `New ${noun.toLowerCase()}`);
49
53
  const sections = createSections(model, view);
50
- const [draft, setDraft] = useState(() => initialDraft(model, sections));
54
+ const [draft, setDraft] = useState(() => initialDraft(model, sections, values));
51
55
  const [errors, setErrors] = useState({});
56
+ useEffect(() => {
57
+ for (const [name, key] of Object.entries(values ?? {})) {
58
+ const target = meta.resources[model.fields[name]?.relation?.resource ?? ""];
59
+ if (!target) continue;
60
+ fetchLabels(queryClient, client, target, [key], meta.views[target.name]).then((labels) => {
61
+ const label = labels.get(key);
62
+ if (label) setDraft((current) => isChosen(current[name]) && current[name].key === key ? {
63
+ ...current,
64
+ [name]: {
65
+ key,
66
+ label
67
+ }
68
+ } : current);
69
+ });
70
+ }
71
+ }, [
72
+ values,
73
+ meta,
74
+ model,
75
+ client,
76
+ queryClient
77
+ ]);
52
78
  const change = (name, value) => {
53
79
  setErrors((current) => without(current, name));
54
80
  setDraft((current) => ({
@@ -42,9 +42,22 @@ const createBody = (model, resources, draft) => Object.fromEntries(Object.entrie
42
42
  }));
43
43
  /** A field is required when it can be neither empty nor filled in by a default. */
44
44
  const isRequired = (field) => !field.nullable && !field.default && field.type !== "boolean";
45
- /** The form's starting values: literal defaults from `/meta`. Database defaults stay empty so the database fills them. */
46
- const initialDraft = (model, sections) => Object.fromEntries(sections.flatMap((section) => section.fields.flatMap((name) => {
47
- const fallback = model.fields[name]?.default;
45
+ const givenValue = (field, value) => {
46
+ if (field.type === "relation") return {
47
+ key: value,
48
+ label: value
49
+ };
50
+ if (field.type === "boolean") return value === "true";
51
+ return value;
52
+ };
53
+ /**
54
+ * The form's starting values: the values the address gives (`/notes/new?listing_id=42`), for the fields the form has,
55
+ * else literal defaults from `/meta`. Database defaults stay empty so the database fills them.
56
+ */
57
+ const initialDraft = (model, sections, given = {}) => Object.fromEntries(sections.flatMap((section) => section.fields.flatMap((name) => {
58
+ const field = model.fields[name];
59
+ if (Object.hasOwn(given, name)) return [[name, givenValue(field, given[name])]];
60
+ const fallback = field.default;
48
61
  if (!fallback || !("value" in fallback) || fallback.value === null || fallback.value === void 0) return [];
49
62
  return [[name, typeof fallback.value === "boolean" ? fallback.value : String(fallback.value)]];
50
63
  })));
@@ -17,6 +17,7 @@ export type CurrentAddress = ListAddress | {
17
17
  } | {
18
18
  kind: 'create';
19
19
  resource: string;
20
+ values?: Readonly<Record<string, string>>;
20
21
  } | {
21
22
  kind: 'page';
22
23
  page: string;
@@ -4,21 +4,47 @@ import { humanize } from "../../live/naming.js";
4
4
  import { useProjectUi } from "../pages/project-ui.js";
5
5
  import { cardFields } from "./list-displays.js";
6
6
  import { AppLink } from "../use-app-address.js";
7
+ import { fileShown, isPreviewable } from "../../live/file-values.js";
7
8
  import { displayValue } from "../../live/display-value.js";
8
9
  import { shownWidget } from "../field-types/shown-widget.js";
9
10
  import { CustomBoundary } from "../pages/custom-boundary.js";
10
11
  import { WidgetPart } from "../field-types/widget-part.js";
11
- import { toneFor } from "../../live/columns-from-model.js";
12
+ import { namedIcon } from "../named-icon.js";
13
+ import { ValueBadge } from "../value-badge.js";
12
14
  import { DisplayFooter, DisplayState } from "./display-frame.js";
13
15
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
14
16
  //#region src/app/displays/cards-display.tsx
15
17
  const grid = "grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-3";
18
+ const CardImage = ({ value }) => {
19
+ const file = fileShown(value);
20
+ const url = file && "url" in file && typeof file.url === "string" && isPreviewable(file.type) ? file.url : void 0;
21
+ return /* @__PURE__ */ jsx("span", {
22
+ className: "-mx-3 -mt-3 mb-1.5 block aspect-[4/3] overflow-hidden rounded-t-lg border-b border-border bg-muted",
23
+ children: url && /* @__PURE__ */ jsx("img", {
24
+ src: url,
25
+ alt: "",
26
+ loading: "lazy",
27
+ className: "size-full object-cover"
28
+ })
29
+ });
30
+ };
31
+ const MetaValue = ({ icon, children }) => {
32
+ const Icon = namedIcon(icon);
33
+ return /* @__PURE__ */ jsxs("dd", {
34
+ className: "flex items-center gap-1 tabular-nums",
35
+ children: [Icon && /* @__PURE__ */ jsx(Icon, {
36
+ className: "size-3.5 shrink-0",
37
+ "aria-hidden": true
38
+ }), children]
39
+ });
40
+ };
16
41
  /**
17
- * A card per record, opening its page: the title over the subtitle, the badge beside it and small meta fields below;
18
- * or, with a `card` component, what it draws of the row, the built-in content again when it is missing or throws.
42
+ * A card per record, opening its page: its image across the top, the title over the subtitle, the badge beside it,
43
+ * small meta fields below and the labels of its flags that are true; or, with a `card` component, what it draws of the
44
+ * row, the built-in content again when it is missing or throws.
19
45
  */
20
46
  const CardsDisplay = ({ model, view, display, list }) => {
21
- const { title, subtitle, badge, meta } = cardFields(model, view, display);
47
+ const { image, title, subtitle, badge, meta, flags } = cardFields(model, view, display);
22
48
  const label = (name) => view?.fields[name]?.label ?? humanize(name);
23
49
  const text = (row, name) => {
24
50
  const field = model.fields[name];
@@ -38,6 +64,17 @@ const CardsDisplay = ({ model, view, display, list }) => {
38
64
  fallback: text(row, name)
39
65
  }) : text(row, name);
40
66
  };
67
+ const metaValue = (row, name) => {
68
+ const field = model.fields[name];
69
+ const hints = view?.fields[name];
70
+ const value = row.record[name];
71
+ if (!field || hints?.format !== "badge" || shownWidget(fieldTypes, field, hints) || value === null || value === void 0 || value === "") return shownValue(row, name);
72
+ return /* @__PURE__ */ jsx(ValueBadge, {
73
+ value,
74
+ hints,
75
+ children: text(row, name)
76
+ });
77
+ };
41
78
  const plural = (view?.names?.plural ?? humanize(model.name)).toLowerCase();
42
79
  return /* @__PURE__ */ jsxs(DisplayState, {
43
80
  list,
@@ -51,14 +88,17 @@ const CardsDisplay = ({ model, view, display, list }) => {
51
88
  "aria-label": view?.names?.plural ?? humanize(model.name),
52
89
  children: list.rows.map((row) => {
53
90
  const status = badge === void 0 ? void 0 : row.record[badge];
91
+ const raised = flags.filter((name) => row.record[name] === true);
54
92
  const builtIn = /* @__PURE__ */ jsxs(Fragment, { children: [
93
+ image !== void 0 && /* @__PURE__ */ jsx(CardImage, { value: row.record[image] }),
55
94
  /* @__PURE__ */ jsxs("span", {
56
95
  className: "flex items-start justify-between gap-2",
57
96
  children: [/* @__PURE__ */ jsx("span", {
58
97
  className: "min-w-0 truncate font-medium",
59
98
  children: shownValue(row, title)
60
- }), badge !== void 0 && status !== null && status !== void 0 && /* @__PURE__ */ jsx(Badge, {
61
- tone: toneFor(String(status)),
99
+ }), badge !== void 0 && status !== null && status !== void 0 && /* @__PURE__ */ jsx(ValueBadge, {
100
+ value: status,
101
+ hints: view?.fields[badge],
62
102
  children: text(row, badge)
63
103
  })]
64
104
  }),
@@ -67,17 +107,25 @@ const CardsDisplay = ({ model, view, display, list }) => {
67
107
  children: shownValue(row, subtitle)
68
108
  }),
69
109
  meta.length > 0 && /* @__PURE__ */ jsx("dl", {
70
- className: "mt-auto flex flex-wrap gap-x-3 gap-y-0.5 pt-1 text-xs text-muted-foreground",
110
+ className: "mt-auto flex flex-wrap items-center gap-x-3 gap-y-0.5 pt-1 text-xs text-muted-foreground",
71
111
  children: meta.map((name) => /* @__PURE__ */ jsxs("div", {
72
112
  className: "flex gap-1",
73
113
  children: [/* @__PURE__ */ jsx("dt", {
74
114
  className: "sr-only",
75
115
  children: label(name)
76
- }), /* @__PURE__ */ jsx("dd", {
77
- className: "tabular-nums",
78
- children: shownValue(row, name)
116
+ }), /* @__PURE__ */ jsx(MetaValue, {
117
+ icon: view?.fields[name]?.icon,
118
+ children: metaValue(row, name)
79
119
  })]
80
120
  }, name))
121
+ }),
122
+ raised.length > 0 && /* @__PURE__ */ jsx("ul", {
123
+ className: "flex flex-wrap gap-1",
124
+ "aria-label": "Features",
125
+ children: raised.map((name) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(Badge, {
126
+ dot: false,
127
+ children: label(name)
128
+ }) }, name))
81
129
  })
82
130
  ] });
83
131
  return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(AppLink, {
@@ -30,20 +30,24 @@ const displayChoices = (view) => [{
30
30
  }))];
31
31
  /** The fields a cards display shows, its title the view's title field when it names none. */
32
32
  const cardFields = (model, view, display) => ({
33
+ image: display.image,
33
34
  title: display.title ?? recordTitleField(model, view),
34
35
  subtitle: display.subtitle,
35
36
  badge: display.badge,
36
- meta: display.meta ?? []
37
+ meta: display.meta ?? [],
38
+ flags: display.flags ?? []
37
39
  });
38
40
  /** Every field a display shows, the fields its rows are loaded and labelled for. */
39
41
  const shownFields = (model, view, display) => {
40
42
  if (display.kind === "custom") return display.fields;
41
- const { title, subtitle, badge, meta } = cardFields(model, view, display);
43
+ const { image, title, subtitle, badge, meta, flags } = cardFields(model, view, display);
42
44
  return [
45
+ image,
43
46
  title,
44
47
  subtitle,
45
48
  badge,
46
- ...meta
49
+ ...meta,
50
+ ...flags
47
51
  ].filter((name) => name !== void 0);
48
52
  };
49
53
  /** The fields to label the list's rows for: the table's columns and every display's, so one load serves them all. */
@@ -90,6 +90,10 @@ const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo,
90
90
  className: "text-[13px]",
91
91
  children: hints.prefix
92
92
  }) : void 0,
93
+ trailing: hints?.suffix ? /* @__PURE__ */ jsx("span", {
94
+ className: "text-[13px]",
95
+ children: hints.suffix.trim()
96
+ }) : void 0,
93
97
  placeholder,
94
98
  className: cn(numeric.has(field.type) && "text-right tabular-nums"),
95
99
  value: String(value),
@@ -0,0 +1,13 @@
1
+ import { FileText, RulerDimensionLine, Send, Wallet } from "lucide-react";
2
+ //#region src/app/named-icon.ts
3
+ /** The icons an action's or a field's `icon` may name. */
4
+ const byName = {
5
+ wallet: Wallet,
6
+ "file-text": FileText,
7
+ send: Send,
8
+ ruler: RulerDimensionLine
9
+ };
10
+ /** The icon a name stands for, or nothing for a name the app does not know. */
11
+ const namedIcon = (name) => name !== void 0 && Object.hasOwn(byName, name) ? byName[name] : void 0;
12
+ //#endregion
13
+ export { namedIcon };
@@ -1,15 +1,14 @@
1
1
  import { cn } from "../../lib/cn.js";
2
- import { Badge } from "../../primitives/badge.js";
3
2
  import { keys } from "../../data/query-keys.js";
4
3
  import { useClient } from "../../data/api-provider.js";
4
+ import { fetchLabels } from "../../live/relation-labels.js";
5
5
  import { useAdminMeta } from "../meta-gate.js";
6
6
  import { AppLink } from "../use-app-address.js";
7
7
  import { fileShown } from "../../live/file-values.js";
8
8
  import { displayValue } from "../../live/display-value.js";
9
9
  import { useShownWidget } from "../field-types/shown-widget.js";
10
10
  import { WidgetPart } from "../field-types/widget-part.js";
11
- import { toneFor } from "../../live/columns-from-model.js";
12
- import { fetchLabels } from "../../live/relation-labels.js";
11
+ import { ValueBadge } from "../value-badge.js";
13
12
  import { jsx } from "react/jsx-runtime";
14
13
  import { useQuery, useQueryClient } from "@tanstack/react-query";
15
14
  //#region src/app/pages/field-value.tsx
@@ -59,8 +58,9 @@ const BuiltInValue = ({ model, field, value, format, relationLabel }) => {
59
58
  children: file.name
60
59
  });
61
60
  const text = displayValue(field, value, hints);
62
- if (field.type === "enum" || hints.format === "badge") return /* @__PURE__ */ jsx(Badge, {
63
- tone: toneFor(String(value)),
61
+ if (field.type === "enum" || hints.format === "badge") return /* @__PURE__ */ jsx(ValueBadge, {
62
+ value,
63
+ hints,
64
64
  children: text
65
65
  });
66
66
  return /* @__PURE__ */ jsx("span", {
@@ -1,7 +1,7 @@
1
1
  import { keys } from "../../data/query-keys.js";
2
2
  import { useClient } from "../../data/api-provider.js";
3
- import { useAdminMeta } from "../meta-gate.js";
4
3
  import { fetchLabels } from "../../live/relation-labels.js";
4
+ import { useAdminMeta } from "../meta-gate.js";
5
5
  import { useQueries, useQueryClient } from "@tanstack/react-query";
6
6
  //#region src/app/pages/use-relation-labels.ts
7
7
  /** Names for the relation columns of the rows on screen, one list call per column, by column then key. */
@@ -1,4 +1,3 @@
1
- import { Badge } from "../primitives/badge.js";
2
1
  import { useAuth } from "../auth/auth-provider.js";
3
2
  import { formatRelativeTime } from "../format/date.js";
4
3
  import { Button } from "../primitives/button.js";
@@ -24,6 +23,7 @@ import { RecordHeader } from "../record-view/record-header.js";
24
23
  import { toneFor } from "../live/columns-from-model.js";
25
24
  import { useDeleteFlow } from "./delete/use-delete-flow.js";
26
25
  import { useRestore } from "./delete/use-restore.js";
26
+ import { ValueBadge } from "./value-badge.js";
27
27
  import { auditField } from "../live/audit-fields.js";
28
28
  import { CustomSection } from "./custom-section.js";
29
29
  import { RecordActions } from "./pages/record-actions.js";
@@ -176,8 +176,9 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
176
176
  const field = model.fields[name];
177
177
  if (!field || !visible(name)) return [];
178
178
  const text = displayValue(field, stored.record[name], hint(name));
179
- const builtIn = field.type === "enum" ? /* @__PURE__ */ jsx(Badge, {
180
- tone: toneFor(String(stored.record[name])),
179
+ const builtIn = field.type === "enum" ? /* @__PURE__ */ jsx(ValueBadge, {
180
+ value: stored.record[name],
181
+ hints: hint(name),
181
182
  children: text
182
183
  }) : /* @__PURE__ */ jsx("strong", { children: text });
183
184
  const shown = shownWidget(fieldTypes, field, hint(name));
@@ -0,0 +1,14 @@
1
+ import { Badge } from "../primitives/badge.js";
2
+ import { valueColor } from "../live/value-color.js";
3
+ import { toneFor } from "../live/columns-from-model.js";
4
+ import { jsx } from "react/jsx-runtime";
5
+ //#region src/app/value-badge.tsx
6
+ /** A stored value as a badge: filled with the colour the view gives it and in its badge shape, else in its tone. */
7
+ const ValueBadge = ({ value, hints, children }) => /* @__PURE__ */ jsx(Badge, {
8
+ tone: toneFor(String(value)),
9
+ color: valueColor(hints?.valueColors, value),
10
+ shape: hints?.badgeShape,
11
+ children
12
+ });
13
+ //#endregion
14
+ export { ValueBadge };
@@ -8,7 +8,7 @@ import { useEffect, useState } from "react";
8
8
  import { jsx, jsxs } from "react/jsx-runtime";
9
9
  //#region src/app-shell/app-shell.tsx
10
10
  const drawerId = "app-drawer";
11
- const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, actions, rightPanel, forceExpanded, profileMenuOpen, navGroups, userMenu, bottomBar, onNavigate, onSignOut, organizations, roles, search, className, children }) => {
11
+ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, actions, rightPanel, forceExpanded, profileMenuOpen, navGroups, userMenu, bottomBar, onNavigate, onSignOut, onSignIn, organizations, roles, search, className, children }) => {
12
12
  const desktop = useIsDesktop();
13
13
  const [drawerOpen, setDrawerOpen] = useState(false);
14
14
  useEffect(() => {
@@ -26,6 +26,7 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, action
26
26
  userMenu,
27
27
  onNavigate,
28
28
  onSignOut,
29
+ onSignIn,
29
30
  organizations,
30
31
  roles
31
32
  });
@@ -5,9 +5,10 @@ import { ProfileMenu } from "./profile-menu.js";
5
5
  import { isTextMode, sidebarWidth } from "./sidebar-mode.js";
6
6
  import { SidebarItem } from "./sidebar-item.js";
7
7
  import { SidebarGroup } from "./sidebar-group.js";
8
+ import { LogIn } from "lucide-react";
8
9
  import { jsx, jsxs } from "react/jsx-runtime";
9
10
  //#region src/app-shell/sidebar.tsx
10
- const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenuOpen, groups = navGroups, userMenu, onNavigate, onSignOut, organizations, roles }) => {
11
+ const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenuOpen, groups = navGroups, userMenu, onNavigate, onSignOut, onSignIn, organizations, roles }) => {
11
12
  const expanding = mode === "icon-expand";
12
13
  const expanded = expanding && forceExpanded;
13
14
  const inline = isTextMode(mode) || expanding;
@@ -47,7 +48,7 @@ const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenu
47
48
  }),
48
49
  /* @__PURE__ */ jsx("div", {
49
50
  className: cn("shrink-0 border-t p-2", mode !== "text-large" && mode !== "text-small" && !expanding && "flex justify-center"),
50
- children: /* @__PURE__ */ jsx(ProfileMenu, {
51
+ children: user ? /* @__PURE__ */ jsx(ProfileMenu, {
51
52
  user,
52
53
  compact: !inline,
53
54
  open: profileMenuOpen,
@@ -56,6 +57,15 @@ const Sidebar = ({ mode, activeItem, user, workspace, forceExpanded, profileMenu
56
57
  onSignOut,
57
58
  organizations,
58
59
  roles
60
+ }) : onSignIn && /* @__PURE__ */ jsxs("button", {
61
+ type: "button",
62
+ onClick: onSignIn,
63
+ "aria-label": "Sign in",
64
+ className: "flex min-h-11 w-full items-center gap-2.5 rounded-md p-1.5 text-left text-[13px] font-medium text-muted-foreground hover:bg-muted hover:text-foreground md:min-h-0",
65
+ children: [/* @__PURE__ */ jsx(LogIn, { className: "size-4 shrink-0" }), inline && /* @__PURE__ */ jsx("span", {
66
+ className: "whitespace-nowrap",
67
+ children: "Sign in"
68
+ })]
59
69
  })
60
70
  })
61
71
  ]
@@ -53,12 +53,12 @@ const forgetStaffLink = () => window.history.replaceState(window.history.state,
53
53
  const forgetSignInLink = () => window.history.replaceState(window.history.state, "", withoutSignInLink(window.location.href));
54
54
  const forgetInvitationLink = () => window.history.replaceState(window.history.state, "", withoutInvitationLink(window.location.href));
55
55
  /**
56
- * Shows the app when the user is signed in (or the server has no login), and the right page otherwise: the sign-in
57
- * steps, or the page an emailed reset link, an invitation link or a staff link opened. An invited person with an
58
- * account signs in first and comes back to the invitation.
56
+ * Shows the app when the user is signed in (or the server has no login), or to a visitor when the project lets them
57
+ * browse, and the right page otherwise: the sign-in steps, or the page an emailed reset link, an invitation link or a
58
+ * staff link opened. An invited person with an account signs in first and comes back to the invitation.
59
59
  */
60
60
  const AuthGate = ({ workspace, children }) => {
61
- const { state, recheck, passwordReset, signOut, signInProviders } = useAuth();
61
+ const { state, recheck, passwordReset, signOut, signInProviders, showSignIn, browse } = useAuth();
62
62
  const [page, setPage] = useState(firstPage);
63
63
  useEffect(() => {
64
64
  if (state.kind === "signed-in" || state.kind === "two-factor" || state.kind === "setup-required") setPage((current) => {
@@ -86,7 +86,7 @@ const AuthGate = ({ workspace, children }) => {
86
86
  };
87
87
  const backToSignIn = () => {
88
88
  setPage({ kind: "sign-in" });
89
- signOut();
89
+ signOut().then(showSignIn);
90
90
  };
91
91
  switch (state.kind) {
92
92
  case "loading": return /* @__PURE__ */ jsxs("div", {
@@ -118,18 +118,23 @@ const AuthGate = ({ workspace, children }) => {
118
118
  setPage({ kind: "sign-in" });
119
119
  }
120
120
  });
121
- if (page.kind === "invitation" && (state.kind === "signed-in" || state.kind === "signed-out" && !page.signingIn)) return /* @__PURE__ */ jsx(InvitationPage, {
121
+ const signedOut = state.kind === "signed-out" || state.kind === "visitor";
122
+ if (page.kind === "invitation" && (state.kind === "signed-in" || signedOut && !page.signingIn)) return /* @__PURE__ */ jsx(InvitationPage, {
122
123
  token: page.token,
123
124
  onDone: () => {
124
125
  forgetInvitationLink();
125
126
  setPage({ kind: "sign-in" });
126
127
  },
127
- onSignIn: () => setPage({
128
- ...page,
129
- signingIn: true
130
- })
128
+ onSignIn: () => {
129
+ setPage({
130
+ ...page,
131
+ signingIn: true
132
+ });
133
+ showSignIn();
134
+ }
131
135
  });
132
136
  if (state.kind === "signed-in") return children;
137
+ if (state.kind === "visitor" && page.kind === "sign-in" && !page.notice && !page.error && !page.providerError) return children;
133
138
  if (state.kind === "two-factor") return /* @__PURE__ */ jsx(TwoFactorPage, {
134
139
  methods: state.methods,
135
140
  onBack: backToSignIn
@@ -156,7 +161,8 @@ const AuthGate = ({ workspace, children }) => {
156
161
  onCodeSent: (email) => setPage({
157
162
  kind: "email-code",
158
163
  email
159
- })
164
+ }),
165
+ onBrowse: browse
160
166
  }, error);
161
167
  };
162
168
  //#endregion