@protobase/ui 0.12.8 → 0.12.10

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.
@@ -20,7 +20,9 @@ export type ActionCardProps = {
20
20
  actions?: CardAction[];
21
21
  onAction?: (id: string) => void;
22
22
  children?: ReactNode;
23
+ /** Without the bordered card and the title row, for a page that is already framed; the title still labels the section. */
24
+ frameless?: boolean;
23
25
  };
24
26
  /** A card with a toned title row, any content, a note and buttons: the building block of rich chat messages. */
25
- export declare const ActionCard: ({ title, tone, badge, note, actions, onAction, children }: ActionCardProps) => import("react").JSX.Element;
27
+ export declare const ActionCard: ({ title, tone, badge, note, actions, onAction, children, frameless }: ActionCardProps) => import("react").JSX.Element;
26
28
  //#endregion
@@ -27,15 +27,15 @@ const frames = {
27
27
  }
28
28
  };
29
29
  /** A card with a toned title row, any content, a note and buttons: the building block of rich chat messages. */
30
- const ActionCard = ({ title, tone = "neutral", badge, note, actions = [], onAction, children }) => {
30
+ const ActionCard = ({ title, tone = "neutral", badge, note, actions = [], onAction, children, frameless = false }) => {
31
31
  const [confirming, setConfirming] = useState();
32
32
  const hints = actions.flatMap((action) => action.hint ? [action.hint] : []);
33
33
  const click = (action) => action.confirm ? setConfirming(action) : onAction?.(action.id);
34
34
  return /* @__PURE__ */ jsxs("section", {
35
35
  "aria-label": title,
36
- className: cn("shrink-0 overflow-hidden rounded-lg border bg-background whitespace-normal text-foreground shadow-sm", frames[tone].border),
36
+ className: frameless ? "whitespace-normal text-foreground" : cn("shrink-0 overflow-hidden rounded-lg border bg-background whitespace-normal text-foreground shadow-sm", frames[tone].border),
37
37
  children: [
38
- /* @__PURE__ */ jsxs("header", {
38
+ !frameless && /* @__PURE__ */ jsxs("header", {
39
39
  className: cn("flex items-center gap-2 px-3 py-2", frames[tone].header),
40
40
  children: [/* @__PURE__ */ jsx("h3", {
41
41
  className: "min-w-0 flex-1 text-[13px] font-semibold",
@@ -46,7 +46,7 @@ const ActionCard = ({ title, tone = "neutral", badge, note, actions = [], onActi
46
46
  })]
47
47
  }),
48
48
  (children || note || actions.length > 0) && /* @__PURE__ */ jsxs("div", {
49
- className: "space-y-2.5 px-3 py-2.5",
49
+ className: cn("space-y-2.5", !frameless && "px-3 py-2.5"),
50
50
  children: [
51
51
  children,
52
52
  note && /* @__PURE__ */ jsx("p", {
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,6 +4,7 @@ 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";
@@ -13,12 +14,25 @@ import { DisplayFooter, DisplayState } from "./display-frame.js";
13
14
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
14
15
  //#region src/app/displays/cards-display.tsx
15
16
  const grid = "grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-3";
17
+ const CardImage = ({ value }) => {
18
+ const file = fileShown(value);
19
+ const url = file && "url" in file && typeof file.url === "string" && isPreviewable(file.type) ? file.url : void 0;
20
+ return /* @__PURE__ */ jsx("span", {
21
+ className: "-mx-3 -mt-3 mb-1.5 block aspect-[4/3] overflow-hidden rounded-t-lg border-b border-border bg-muted",
22
+ children: url && /* @__PURE__ */ jsx("img", {
23
+ src: url,
24
+ alt: "",
25
+ loading: "lazy",
26
+ className: "size-full object-cover"
27
+ })
28
+ });
29
+ };
16
30
  /**
17
- * A card per record, opening its page: the title over the subtitle, the badge beside it and small meta fields below;
31
+ * A card per record, opening its page: its image across the top, the title over the subtitle, the badge beside it and small meta fields below;
18
32
  * or, with a `card` component, what it draws of the row, the built-in content again when it is missing or throws.
19
33
  */
20
34
  const CardsDisplay = ({ model, view, display, list }) => {
21
- const { title, subtitle, badge, meta } = cardFields(model, view, display);
35
+ const { image, title, subtitle, badge, meta } = cardFields(model, view, display);
22
36
  const label = (name) => view?.fields[name]?.label ?? humanize(name);
23
37
  const text = (row, name) => {
24
38
  const field = model.fields[name];
@@ -52,6 +66,7 @@ const CardsDisplay = ({ model, view, display, list }) => {
52
66
  children: list.rows.map((row) => {
53
67
  const status = badge === void 0 ? void 0 : row.record[badge];
54
68
  const builtIn = /* @__PURE__ */ jsxs(Fragment, { children: [
69
+ image !== void 0 && /* @__PURE__ */ jsx(CardImage, { value: row.record[image] }),
55
70
  /* @__PURE__ */ jsxs("span", {
56
71
  className: "flex items-start justify-between gap-2",
57
72
  children: [/* @__PURE__ */ jsx("span", {
@@ -30,6 +30,7 @@ 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,
@@ -38,8 +39,9 @@ const cardFields = (model, view, display) => ({
38
39
  /** Every field a display shows, the fields its rows are loaded and labelled for. */
39
40
  const shownFields = (model, view, display) => {
40
41
  if (display.kind === "custom") return display.fields;
41
- const { title, subtitle, badge, meta } = cardFields(model, view, display);
42
+ const { image, title, subtitle, badge, meta } = cardFields(model, view, display);
42
43
  return [
44
+ image,
43
45
  title,
44
46
  subtitle,
45
47
  badge,
@@ -2,6 +2,7 @@ import { cn } from "../../lib/cn.js";
2
2
  import { Badge } from "../../primitives/badge.js";
3
3
  import { keys } from "../../data/query-keys.js";
4
4
  import { useClient } from "../../data/api-provider.js";
5
+ import { fetchLabels } from "../../live/relation-labels.js";
5
6
  import { useAdminMeta } from "../meta-gate.js";
6
7
  import { AppLink } from "../use-app-address.js";
7
8
  import { fileShown } from "../../live/file-values.js";
@@ -9,7 +10,6 @@ import { displayValue } from "../../live/display-value.js";
9
10
  import { useShownWidget } from "../field-types/shown-widget.js";
10
11
  import { WidgetPart } from "../field-types/widget-part.js";
11
12
  import { toneFor } from "../../live/columns-from-model.js";
12
- import { fetchLabels } from "../../live/relation-labels.js";
13
13
  import { jsx } from "react/jsx-runtime";
14
14
  import { useQuery, useQueryClient } from "@tanstack/react-query";
15
15
  //#region src/app/pages/field-value.tsx
@@ -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. */
@@ -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
@@ -19,6 +19,8 @@ const AuthProvider = ({ session: given, children }) => {
19
19
  const [platformSignIn, setPlatformSignIn] = useState();
20
20
  const [staffSignIn, setStaffSignIn] = useState();
21
21
  const [organizations, setOrganizations] = useState();
22
+ const [anonymous, setAnonymous] = useState(false);
23
+ const signedOutState = useCallback(() => anonymous ? { kind: "visitor" } : { kind: "signed-out" }, [anonymous]);
22
24
  const markSignedOut = useCallback(() => setState((current) => current.kind === "signed-in" ? { kind: "signed-out" } : current), []);
23
25
  const signedOut = useRef(markSignedOut);
24
26
  signedOut.current = markSignedOut;
@@ -49,9 +51,10 @@ const AuthProvider = ({ session: given, children }) => {
49
51
  setPlatformSignIn(status.platformSignIn);
50
52
  setStaffSignIn(status.staffSignIn);
51
53
  setOrganizations(status.organizations);
54
+ setAnonymous(Boolean(status.anonymous));
52
55
  if (status.needsAdmin) return setState({ kind: "needs-admin" });
53
56
  const user = await session.session();
54
- return user ? enter(user) : setState({ kind: "signed-out" });
57
+ return user ? enter(user) : setState({ kind: status.anonymous ? "visitor" : "signed-out" });
55
58
  }).catch((error) => setState({
56
59
  kind: "error",
57
60
  message: error instanceof Error ? error.message : "The server did not answer"
@@ -73,6 +76,8 @@ const AuthProvider = ({ session: given, children }) => {
73
76
  ...organizations && { organizations },
74
77
  markSignedOut,
75
78
  recheck,
79
+ showSignIn: () => setState((current) => current.kind === "visitor" ? { kind: "signed-out" } : current),
80
+ ...anonymous && { browse: () => setState((current) => current.kind === "signed-out" ? { kind: "visitor" } : current) },
76
81
  signIn: async (email, password) => finish(await session.signIn(email, password)),
77
82
  sendSignInCode: (email) => session.sendSignInCode(email),
78
83
  signInWithCode: async (email, code) => finish(await session.signInWithCode(email, code)),
@@ -86,11 +91,11 @@ const AuthProvider = ({ session: given, children }) => {
86
91
  },
87
92
  stopStaffSession: async () => {
88
93
  await session.staff.stop();
89
- setState({ kind: "signed-out" });
94
+ setState(signedOutState());
90
95
  },
91
96
  signOut: async () => {
92
97
  await session.signOut();
93
- setState({ kind: "signed-out" });
98
+ setState(signedOutState());
94
99
  }
95
100
  }), [
96
101
  state,
@@ -102,6 +107,8 @@ const AuthProvider = ({ session: given, children }) => {
102
107
  platformSignIn,
103
108
  staffSignIn,
104
109
  organizations,
110
+ anonymous,
111
+ signedOutState,
105
112
  markSignedOut,
106
113
  recheck,
107
114
  finish,
@@ -13,7 +13,7 @@ const Divider = () => /* @__PURE__ */ jsxs("div", {
13
13
  /* @__PURE__ */ jsx("span", { className: "h-px flex-1 bg-border" })
14
14
  ]
15
15
  });
16
- const SignInForm = ({ onSubmit, busy, error, workspace, notice, methods = ["password"], onSendCode, onPasskey, passkeyDomains = [], onForgotPassword, providers = [], onContinueWith }) => {
16
+ const SignInForm = ({ onSubmit, busy, error, workspace, notice, methods = ["password"], onSendCode, onPasskey, passkeyDomains = [], onForgotPassword, providers = [], onContinueWith, onBrowse }) => {
17
17
  const [email, setEmail] = useState("");
18
18
  const [password, setPassword] = useState("");
19
19
  const emailInput = useRef(null);
@@ -127,6 +127,12 @@ const SignInForm = ({ onSubmit, busy, error, workspace, notice, methods = ["pass
127
127
  children: "Forgot password?"
128
128
  })
129
129
  ]
130
+ }),
131
+ onBrowse && /* @__PURE__ */ jsx("button", {
132
+ type: "button",
133
+ onClick: onBrowse,
134
+ className: "mt-4 w-full text-center text-[13px] font-medium text-muted-foreground hover:text-foreground hover:underline",
135
+ children: "Continue without signing in"
130
136
  })
131
137
  ]
132
138
  });
@@ -5,7 +5,7 @@ import { useState } from "react";
5
5
  import { AuthError } from "@protobase/client";
6
6
  import { jsx } from "react/jsx-runtime";
7
7
  //#region src/auth/sign-in-page.tsx
8
- const SignInPage = ({ workspace, notice, onForgotPassword, onCodeSent, error: initialError }) => {
8
+ const SignInPage = ({ workspace, notice, onForgotPassword, onBrowse, onCodeSent, error: initialError }) => {
9
9
  const { signIn, signInSocial, signInProviders, signInMethods, passkeyDomains, sendSignInCode, signInWithPasskey } = useAuth();
10
10
  const [busy, setBusy] = useState(false);
11
11
  const [error, setError] = useState(initialError);
@@ -27,6 +27,7 @@ const SignInPage = ({ workspace, notice, onForgotPassword, onCodeSent, error: in
27
27
  notice,
28
28
  methods: signInMethods,
29
29
  onForgotPassword,
30
+ onBrowse,
30
31
  providers: signInProviders,
31
32
  onContinueWith: (provider) => void attempt(() => signInSocial(provider), signInMessage),
32
33
  passkeyDomains,
@@ -2,9 +2,9 @@ import { formatMoney } from "../format/money.js";
2
2
  import { keys } from "../data/query-keys.js";
3
3
  import { useClient } from "../data/api-provider.js";
4
4
  import { humanize } from "./naming.js";
5
+ import { fetchLabels } from "./relation-labels.js";
5
6
  import { stateToText } from "./filter-string.js";
6
7
  import { useHistogram } from "../data/use-aggregates.js";
7
- import { fetchLabels } from "./relation-labels.js";
8
8
  import { widgetFields } from "./view-fields.js";
9
9
  import { useQueries, useQueryClient } from "@tanstack/react-query";
10
10
  import { enumLabel } from "@protobase/schema";
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.12.8";
2
+ var version = "0.12.10";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };
@@ -5,17 +5,22 @@ import { useCallback, useEffect, useState } from "react";
5
5
  import { AuthError } from "@protobase/client";
6
6
  //#region src/roles/use-active-role.ts
7
7
  const failureMessage = (failure, fallback) => failure instanceof AuthError ? authMessage(failure) : fallback;
8
- /** The role the session acts with; a failed read or switch shows as a toast. */
9
- const useActiveRole = (reopen) => {
8
+ /** The role the session acts with, read while `enabled` (someone is signed in); a failed read or switch shows as a toast. */
9
+ const useActiveRole = (reopen, enabled = true) => {
10
10
  const { session } = useAuth();
11
11
  const toast = useToast();
12
12
  const [current, setCurrent] = useState();
13
13
  useEffect(() => {
14
+ if (!enabled) return;
14
15
  session.roles.current().then(setCurrent, (failure) => toast.show({
15
16
  state: "error",
16
17
  title: failureMessage(failure, "Could not read your roles")
17
18
  }));
18
- }, [session, toast]);
19
+ }, [
20
+ session,
21
+ toast,
22
+ enabled
23
+ ]);
19
24
  const switchTo = useCallback((name) => void session.roles.switchTo(name).then(reopen, (failure) => toast.show({
20
25
  state: "error",
21
26
  title: failureMessage(failure, "Could not switch the role")