@protobase/ui 0.40.6 → 0.40.8

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 (43) hide show
  1. package/dist/app/app.js +12 -3
  2. package/dist/app/comments/comment-composer.js +2 -0
  3. package/dist/app/create/create-page.js +10 -2
  4. package/dist/app/messages.js +12 -0
  5. package/dist/app/new-version/messages.js +24 -0
  6. package/dist/app/new-version/new-version-banner.js +37 -0
  7. package/dist/app/new-version/new-version-button.js +27 -0
  8. package/dist/app/new-version/new-version-toast.js +43 -0
  9. package/dist/app/new-version/use-modal-open.js +19 -0
  10. package/dist/app/pages/blocks/modal-form.js +4 -0
  11. package/dist/app/pages/blocks/record-cards.js +14 -4
  12. package/dist/app/record-page.js +1 -1
  13. package/dist/app/related-cards/related-cards.js +18 -13
  14. package/dist/app/router.js +68 -26
  15. package/dist/app-shell/app-shell.js +6 -3
  16. package/dist/app-shell/sidebar-notice.js +12 -0
  17. package/dist/app-shell/sidebar.d.ts +1 -1
  18. package/dist/app-shell/sidebar.js +2 -1
  19. package/dist/data/api-provider.js +1 -0
  20. package/dist/data/meta-data.d.ts +9 -0
  21. package/dist/data/use-meta.d.ts +3 -2
  22. package/dist/data/use-meta.js +8 -4
  23. package/dist/data/version-freeze.js +17 -0
  24. package/dist/package.js +1 -1
  25. package/dist/primitives/snap-row-width.js +13 -0
  26. package/dist/primitives/snap-row.js +21 -0
  27. package/dist/shell/assets/app-DXbtlR1O.js +9 -0
  28. package/dist/shell/assets/{index-BX850JXd.css → index-DT92y8N1.css} +1 -1
  29. package/dist/shell/assets/{index-GTJA-4i3.js → index-hbOjGIrj.js} +2 -2
  30. package/dist/shell/assets/{records-map-DVCcd765.js → records-map-DW4-2p9A.js} +2 -2
  31. package/dist/shell/assets/{shared-protobase-ui-D1hRi4LJ.js → shared-protobase-ui-DOLAGMZi.js} +2 -2
  32. package/dist/shell/index.html +4 -4
  33. package/dist/shell/protobase.shell.json +1 -1
  34. package/dist/shell/protobase.sw.js +2 -2
  35. package/dist/shell-source-maps/assets/app-DXbtlR1O.js.map +1 -0
  36. package/dist/shell-source-maps/assets/{index-GTJA-4i3.js.map → index-hbOjGIrj.js.map} +1 -1
  37. package/dist/shell-source-maps/assets/{records-map-DVCcd765.js.map → records-map-DW4-2p9A.js.map} +1 -1
  38. package/dist/shell-source-maps/assets/{shared-protobase-ui-D1hRi4LJ.js.map → shared-protobase-ui-DOLAGMZi.js.map} +1 -1
  39. package/dist/toasts/toast-provider.js +11 -2
  40. package/dist/toasts/toast.js +3 -2
  41. package/package.json +4 -4
  42. package/dist/shell/assets/app-Di5oi8xq.js +0 -9
  43. package/dist/shell-source-maps/assets/app-Di5oi8xq.js.map +0 -1
package/dist/app/app.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { setFallbackLocale } from "../locale/active-locale.js";
2
2
  import { accountMenuItems } from "../account/account-pages.js";
3
+ import { useSidebarNoticeShown } from "../app-shell/sidebar-notice.js";
3
4
  import { AppShell } from "../app-shell/app-shell.js";
4
5
  import { AuthProvider, useAuth } from "../auth/auth-provider.js";
5
6
  import { AccountSecurityPage } from "../account/account-security-page.js";
@@ -52,6 +53,9 @@ import { PluginNotice } from "../plugins/plugin-notice.js";
52
53
  import { RecordPage } from "./record-page.js";
53
54
  import { SourceNotice } from "./source-notice.js";
54
55
  import { useGlobalSearch } from "./search/use-global-search.js";
56
+ import { NewVersionBanner } from "./new-version/new-version-banner.js";
57
+ import { NewVersionButton } from "./new-version/new-version-button.js";
58
+ import { NewVersionToast } from "./new-version/new-version-toast.js";
55
59
  import { RuntimeUpdateButton } from "./runtime/runtime-update.js";
56
60
  import { StaffBanner } from "./staff-banner.js";
57
61
  import { useNavRecent } from "./use-nav-recent.js";
@@ -140,6 +144,8 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
140
144
  url: runtimeUrl,
141
145
  client: runtime
142
146
  });
147
+ const sidebarBanner = useSidebarNoticeShown(sidebarMode ?? "text-small");
148
+ const reloadButton = meta.versionChange && !sidebarBanner && /* @__PURE__ */ jsx(NewVersionButton, {});
143
149
  const role = useActiveRole(useCallback(() => reopenIn(basePath), [basePath]), Boolean(user));
144
150
  const acting = user && actingUser(user, role.current);
145
151
  const admin = acting ? isAdmin(acting) : false;
@@ -241,6 +247,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
241
247
  onSwitch: role.switchTo
242
248
  } : void 0,
243
249
  onNavigate: open,
250
+ sidebarNotice: /* @__PURE__ */ jsx(NewVersionBanner, {}),
244
251
  search: search.available ? {
245
252
  placeholder: search.placeholder,
246
253
  text: search.text,
@@ -250,12 +257,13 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
250
257
  groups: search.groups,
251
258
  onSelect: open
252
259
  } : void 0,
253
- actions: (Actions || runtimeButton || assistantButton) && /* @__PURE__ */ jsxs(Fragment$1, { children: [
260
+ actions: (Actions || reloadButton || runtimeButton || assistantButton) && /* @__PURE__ */ jsxs(Fragment$1, { children: [
254
261
  Actions && /* @__PURE__ */ jsx(CustomBoundary, {
255
262
  name: "shell.actions",
256
263
  fallback: null,
257
264
  children: /* @__PURE__ */ jsx(Actions, {})
258
265
  }),
266
+ reloadButton,
259
267
  runtimeButton,
260
268
  assistantButton
261
269
  ] }),
@@ -285,13 +293,14 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
285
293
  };
286
294
  const Authenticated = ({ baseUrl, client, sources, workspace, sidebarMode, ui, assistant, runtime }) => {
287
295
  const { state, session, markSignedOut } = useAuth();
296
+ const sidebarBanner = useSidebarNoticeShown(sidebarMode ?? "text-small");
288
297
  return /* @__PURE__ */ jsx(ApiProvider, {
289
298
  baseUrl,
290
299
  client,
291
300
  sources,
292
301
  token: state.kind === "visitor" ? void 0 : session.token,
293
302
  onUnauthenticated: markSignedOut,
294
- children: /* @__PURE__ */ jsx(MetaGate, { children: /* @__PURE__ */ jsx(ToastProvider, { children: /* @__PURE__ */ jsx(PluginGate, {
303
+ children: /* @__PURE__ */ jsx(MetaGate, { children: /* @__PURE__ */ jsxs(ToastProvider, { children: [!sidebarBanner && /* @__PURE__ */ jsx(NewVersionToast, {}), /* @__PURE__ */ jsx(PluginGate, {
295
304
  ui,
296
305
  children: /* @__PURE__ */ jsx(Routes, {
297
306
  workspace,
@@ -299,7 +308,7 @@ const Authenticated = ({ baseUrl, client, sources, workspace, sidebarMode, ui, a
299
308
  assistant,
300
309
  runtime
301
310
  })
302
- }) }) })
311
+ })] }) })
303
312
  });
304
313
  };
305
314
  const PublicFormGate = ({ baseUrl, client, ui, children }) => {
@@ -1,5 +1,6 @@
1
1
  import { cn } from "../../lib/cn.js";
2
2
  import { Button } from "../../primitives/button.js";
3
+ import { useNavigationGuard } from "../router.js";
3
4
  import { useToast } from "../../toasts/toast-provider.js";
4
5
  import { refusalReason } from "../displays/board-columns.js";
5
6
  import { Textarea } from "../../primitives/textarea.js";
@@ -29,6 +30,7 @@ const CommentComposer = ({ resource, recordKey }) => {
29
30
  const listId = useId();
30
31
  const toast = useToast();
31
32
  const post = usePostComment(resource, recordKey);
33
+ useNavigationGuard(draft.trim() !== "" || post.isPending, "input-lost");
32
34
  const mention = activeMention(draft, caret);
33
35
  const open = focused && mention !== void 0 && mention.start !== closed;
34
36
  const people = useMentionable(resource, recordKey, open ? mention.query : void 0);
@@ -1,4 +1,5 @@
1
1
  import { text } from "../messages.js";
2
+ import { useNavigationGuard } from "../router.js";
2
3
  import { useClient } from "../../data/api-provider.js";
3
4
  import { Notice, useAdminMeta, usePermissions } from "../meta-gate.js";
4
5
  import { humanize } from "../../live/naming.js";
@@ -50,6 +51,12 @@ const LoadedCreate = ({ resource, values }) => {
50
51
  const [errors, setErrors] = useState({});
51
52
  const [rejections, setRejections] = useState(0);
52
53
  const formRef = useFocusFirstInvalid(rejections);
54
+ const [newRecord, setNewRecord] = useState();
55
+ const [touched, setTouched] = useState(false);
56
+ useNavigationGuard(!newRecord && (touched || feedback.phase === "saving"), "input-lost");
57
+ useEffect(() => {
58
+ if (newRecord) go(newRecord, { replace: true });
59
+ }, [newRecord, go]);
53
60
  useEffect(() => {
54
61
  for (const [name, key] of Object.entries(values ?? {})) {
55
62
  const target = meta.resources[model.fields[name]?.relation?.resource ?? ""];
@@ -74,6 +81,7 @@ const LoadedCreate = ({ resource, values }) => {
74
81
  ]);
75
82
  const change = (name, value) => {
76
83
  setErrors((current) => without(current, name));
84
+ setTouched(true);
77
85
  setDraft((current) => ({
78
86
  ...current,
79
87
  [name]: value
@@ -97,11 +105,11 @@ const LoadedCreate = ({ resource, values }) => {
97
105
  "facets",
98
106
  "series"
99
107
  ]) queryClient.invalidateQueries({ queryKey: [prefix, resource] });
100
- go({
108
+ setNewRecord({
101
109
  kind: "record",
102
110
  resource,
103
111
  key: recordKey(model, created.record)
104
- }, { replace: true });
112
+ });
105
113
  });
106
114
  const formSections = sections.map((section) => ({
107
115
  title: section.title,
@@ -47,8 +47,14 @@ const text = defineMessages({
47
47
  loading: "Loading",
48
48
  leaveTitle: "Leave without saving?",
49
49
  leaveDescription: "You have unsaved changes on this page. They stay in this browser, and come back when you open the record again.",
50
+ leaveInputLost: "You have unsaved changes on this page. They are lost when you leave.",
50
51
  stay: "Stay",
51
52
  leave: "Leave",
53
+ reloadTitle: "Reload without saving?",
54
+ reloadDraftKept: "You have unsaved changes on this page. They stay in this browser, and come back after the page reloads.",
55
+ reloadInputLost: "You have unsaved changes on this page. They are lost when the page reloads.",
56
+ keepEditing: "Keep editing",
57
+ reload: "Reload",
52
58
  show: (label) => `Show ${label}`,
53
59
  hide: (label) => `Hide ${label}`,
54
60
  couldNotShow: (label) => `Could not show the ${label}`,
@@ -110,8 +116,14 @@ const text = defineMessages({
110
116
  loading: "Laden",
111
117
  leaveTitle: "Weggaan zonder te bewaren?",
112
118
  leaveDescription: "Je hebt niet-bewaarde wijzigingen op deze pagina. Ze blijven in deze browser, en komen terug als je het record weer opent.",
119
+ leaveInputLost: "Je hebt niet-bewaarde wijzigingen op deze pagina. Ze gaan verloren als je weggaat.",
113
120
  stay: "Blijven",
114
121
  leave: "Weggaan",
122
+ reloadTitle: "Opnieuw laden zonder op te slaan?",
123
+ reloadDraftKept: "Er zijn niet-opgeslagen wijzigingen op deze pagina. Ze blijven bewaard in deze browser en komen terug zodra de pagina opnieuw is geladen.",
124
+ reloadInputLost: "Er zijn niet-opgeslagen wijzigingen op deze pagina. Ze gaan verloren als de pagina opnieuw wordt geladen.",
125
+ keepEditing: "Verder bewerken",
126
+ reload: "Opnieuw laden",
115
127
  show: (label) => `${label} tonen`,
116
128
  hide: (label) => `${label} verbergen`,
117
129
  couldNotShow: (label) => `Kon ${label} niet tonen`,
@@ -0,0 +1,24 @@
1
+ import { defineMessages } from "../../locale/messages.js";
2
+ //#region src/app/new-version/messages.ts
3
+ const text = defineMessages({
4
+ en: {
5
+ title: "This app has been updated",
6
+ description: "Reload the page to use the new version, when it suits you.",
7
+ reload: "Reload",
8
+ reloadPage: "Reload page",
9
+ reloadPageHint: "Reload page to use the new version",
10
+ bannerTitle: "{} updated",
11
+ bannerLine: "Save your work, then click here to reload. You may see errors until you do."
12
+ },
13
+ nl: {
14
+ title: "Deze app is bijgewerkt",
15
+ description: "Laad de pagina opnieuw voor de nieuwe versie, wanneer het uitkomt.",
16
+ reload: "Opnieuw laden",
17
+ reloadPage: "Opnieuw laden",
18
+ reloadPageHint: "Opnieuw laden om de nieuwe versie te gebruiken",
19
+ bannerTitle: "{} bijgewerkt",
20
+ bannerLine: "Sla je werk op en klik hier om te herladen. Tot dan kunnen er fouten optreden."
21
+ }
22
+ });
23
+ //#endregion
24
+ export { text };
@@ -0,0 +1,37 @@
1
+ import { useRouter } from "../router.js";
2
+ import { useAdminMeta } from "../meta-gate.js";
3
+ import { slotted } from "../../locale/slotted.js";
4
+ import { text } from "./messages.js";
5
+ import { RotateCw } from "lucide-react";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ //#region src/app/new-version/new-version-banner.tsx
8
+ /**
9
+ * Across the bottom of a full-width sidebar, from the moment the page keeps its `/meta` for a new version until it
10
+ * reloads: the app's name, that it was updated, and a click that reloads, asking first while a guard holds unsaved
11
+ * changes. It stands in for `NewVersionToast` and `NewVersionButton`, which float over the page.
12
+ */
13
+ const NewVersionBanner = () => {
14
+ const { versionChange, appName } = useAdminMeta();
15
+ const { reload } = useRouter();
16
+ if (!versionChange) return null;
17
+ return /* @__PURE__ */ jsxs("button", {
18
+ type: "button",
19
+ onClick: reload,
20
+ className: "flex w-full shrink-0 items-start gap-2 border-t border-warning/40 bg-warning-soft px-3.5 py-2 text-left text-warning-text transition-colors hover:bg-warning/15 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary",
21
+ children: [/* @__PURE__ */ jsx(RotateCw, {
22
+ "aria-hidden": true,
23
+ className: "mt-0.5 size-3.5 shrink-0"
24
+ }), /* @__PURE__ */ jsxs("span", {
25
+ className: "flex min-w-0 flex-col gap-0.5",
26
+ children: [/* @__PURE__ */ jsx("span", {
27
+ className: "break-words font-semibold",
28
+ children: appName ? slotted(text("bannerTitle"), appName) : text("title")
29
+ }), /* @__PURE__ */ jsx("span", {
30
+ className: "text-xs",
31
+ children: text("bannerLine")
32
+ })]
33
+ })]
34
+ });
35
+ };
36
+ //#endregion
37
+ export { NewVersionBanner };
@@ -0,0 +1,27 @@
1
+ import { Button } from "../../primitives/button.js";
2
+ import { useRouter } from "../router.js";
3
+ import { useAdminMeta } from "../meta-gate.js";
4
+ import { text } from "./messages.js";
5
+ import { RotateCw } from "lucide-react";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ //#region src/app/new-version/new-version-button.tsx
8
+ /** The top bar's Reload page button, from the moment the page keeps its `/meta` for a new version until it reloads. */
9
+ const NewVersionButton = () => {
10
+ const changed = Boolean(useAdminMeta().versionChange);
11
+ const { reload } = useRouter();
12
+ if (!changed) return null;
13
+ return /* @__PURE__ */ jsxs(Button, {
14
+ size: "sm",
15
+ variant: "ghost",
16
+ "aria-label": text("reloadPageHint"),
17
+ title: text("reloadPageHint"),
18
+ onClick: reload,
19
+ className: "max-md:size-9 max-md:p-0",
20
+ children: [/* @__PURE__ */ jsx(RotateCw, { className: "size-3.5 text-primary" }), /* @__PURE__ */ jsx("span", {
21
+ className: "max-md:sr-only",
22
+ children: text("reloadPage")
23
+ })]
24
+ });
25
+ };
26
+ //#endregion
27
+ export { NewVersionButton };
@@ -0,0 +1,43 @@
1
+ import { useRouter } from "../router.js";
2
+ import { useAdminMeta } from "../meta-gate.js";
3
+ import { useToast } from "../../toasts/toast-provider.js";
4
+ import { text } from "./messages.js";
5
+ import { useModalOpen } from "./use-modal-open.js";
6
+ import { useEffect, useState } from "react";
7
+ //#region src/app/new-version/new-version-toast.tsx
8
+ /**
9
+ * Once the server runs another Protobase version than the page started with, a toast that says so and reloads the
10
+ * page, asking first while a guard holds unsaved changes. It stays until Reload or ×, and after × it does not come
11
+ * back; the top bar's `NewVersionButton` stays. It steps aside while a modal is open, whose buttons it could cover on a
12
+ * phone. The page keeps the `/meta` it has meanwhile (see `keepOnVersionChange`).
13
+ */
14
+ const NewVersionToast = () => {
15
+ const changed = Boolean(useAdminMeta().versionChange);
16
+ const modalOpen = useModalOpen();
17
+ const [closed, setClosed] = useState(false);
18
+ const toast = useToast();
19
+ const { reload } = useRouter();
20
+ const shown = changed && !closed && !modalOpen;
21
+ useEffect(() => {
22
+ if (!shown) return;
23
+ const id = toast.show({
24
+ state: "info",
25
+ sticky: true,
26
+ title: text("title"),
27
+ description: text("description"),
28
+ action: {
29
+ label: text("reload"),
30
+ onClick: reload
31
+ },
32
+ onClose: () => setClosed(true)
33
+ });
34
+ return () => toast.dismiss(id);
35
+ }, [
36
+ shown,
37
+ toast,
38
+ reload
39
+ ]);
40
+ return null;
41
+ };
42
+ //#endregion
43
+ export { NewVersionToast };
@@ -0,0 +1,19 @@
1
+ import { useEffect, useState } from "react";
2
+ //#region src/app/new-version/use-modal-open.ts
3
+ const modalOpen = () => document.querySelector("[aria-modal=\"true\"]") !== null;
4
+ /** Whether a modal, a dialog or a sheet, is open anywhere on the page, following them as they open and close. */
5
+ const useModalOpen = () => {
6
+ const [open, setOpen] = useState(modalOpen);
7
+ useEffect(() => {
8
+ const observer = new MutationObserver(() => setOpen(modalOpen()));
9
+ observer.observe(document.body, {
10
+ childList: true,
11
+ subtree: true
12
+ });
13
+ setOpen(modalOpen());
14
+ return () => observer.disconnect();
15
+ }, []);
16
+ return open;
17
+ };
18
+ //#endregion
19
+ export { useModalOpen };
@@ -1,5 +1,6 @@
1
1
  import { Button } from "../../../primitives/button.js";
2
2
  import { Dialog } from "../../../primitives/dialog.js";
3
+ import { useNavigationGuard } from "../../router.js";
3
4
  import { useClient } from "../../../data/api-provider.js";
4
5
  import { useAdminMeta, usePermissions } from "../../meta-gate.js";
5
6
  import { text } from "../messages.js";
@@ -45,6 +46,8 @@ const ModalFormDialog = ({ model, record, mode, label, title, fields, values, on
45
46
  const formRef = useFocusFirstInvalid(rejections);
46
47
  const [failure, setFailure] = useState();
47
48
  const [saving, setSaving] = useState(false);
49
+ const [touched, setTouched] = useState(false);
50
+ useNavigationGuard(touched || saving, "input-lost");
48
51
  const submit = async () => {
49
52
  setSaving(true);
50
53
  setFailure(void 0);
@@ -113,6 +116,7 @@ const ModalFormDialog = ({ model, record, mode, label, title, fields, values, on
113
116
  resource: model.name,
114
117
  onChange: (next) => {
115
118
  setErrors(({ [name]: _, ...rest }) => rest);
119
+ setTouched(true);
116
120
  setDraft((current) => ({
117
121
  ...current,
118
122
  [name]: next
@@ -4,13 +4,22 @@ import { listColumnNames } from "../../../live/columns-from-model.js";
4
4
  import { propsOf } from "./block-props.js";
5
5
  import { RelatedCards } from "../../related-cards/related-cards.js";
6
6
  import { jsx } from "react/jsx-runtime";
7
+ import { cardsLayoutOf } from "@protobase/layout";
7
8
  //#region src/app/pages/blocks/record-cards.tsx
9
+ const shares = {
10
+ "1/4": 1 / 4,
11
+ "1/3": 1 / 3,
12
+ "1/2": 1 / 2,
13
+ "2/3": 2 / 3
14
+ };
8
15
  /**
9
- * What a filter finds as the record page's related cards, stacked full width under a heading like a record page
10
- * section's, with no card frame around them, so no card sits in another.
16
+ * What a filter finds as the record page's related cards, stacked full width or in a row that scrolls sideways, under
17
+ * a heading like a record page section's, with no card frame around them, so no card sits in another.
11
18
  */
12
19
  const RecordCardsBlock = ({ node, slots }) => {
13
- const { resource, filter = "", sort = "", fields, cardActions = [], limit = 10, display, title, description, empty } = propsOf(node);
20
+ const props = propsOf(node);
21
+ const { resource, filter = "", sort = "", fields, cardActions = [], limit = 10, display, title, description, empty } = props;
22
+ const { layout, cardWidth } = cardsLayoutOf(props);
14
23
  const { resources, views } = useAdminMeta();
15
24
  const model = resources[resource];
16
25
  if (!model) return null;
@@ -31,7 +40,8 @@ const RecordCardsBlock = ({ node, slots }) => {
31
40
  actions: cardActions,
32
41
  limit,
33
42
  display,
34
- empty
43
+ empty,
44
+ carousel: layout === "carousel" ? shares[cardWidth] : void 0
35
45
  })
36
46
  })
37
47
  });
@@ -153,7 +153,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
153
153
  const restore = useRestore(model);
154
154
  const canUpdate = can(permissions, "update") && stored.permissions?.update !== false;
155
155
  const { shown, offersEdit, editing } = recordShown(view?.recordMode, canUpdate, address?.kind === "record" && address.edit === true);
156
- useNavigationGuard(editor.dirty && shown === "form");
156
+ useNavigationGuard(editor.dirty && shown === "form" || editor.saving);
157
157
  usePauseFocusRefresh(editor.dirty);
158
158
  const openGroups = address?.kind === "record" ? address.open : void 0;
159
159
  const atEdit = address?.kind === "record" && address.edit === true;
@@ -5,6 +5,7 @@ import { defaultOrderBy } from "../list-order.js";
5
5
  import { AppLink } from "../use-app-address.js";
6
6
  import { ErrorBanner } from "../error-banner.js";
7
7
  import { scopeOf, useCount, useListPage } from "../pages/use-layout-data.js";
8
+ import { SnapRow } from "../../primitives/snap-row.js";
8
9
  import { text } from "../embedded-list/messages.js";
9
10
  import { cutOffNote } from "../embedded-list/cut-off-note.js";
10
11
  import { embeddedDisplay, viewAllAddress } from "../embedded-list/embedded-display.js";
@@ -14,10 +15,10 @@ import { ArrowRight } from "lucide-react";
14
15
  import { jsx, jsxs } from "react/jsx-runtime";
15
16
  //#region src/app/related-cards/related-cards.tsx
16
17
  /**
17
- * The first `limit` records of a list as cards stacked full width, each with its fields and its actions' buttons, and a
18
- * link to the whole list beside a note such as "The first 10 of 37." when there are more.
18
+ * The first `limit` records of a list as cards stacked full width or in a `carousel`, each with its fields and its
19
+ * actions' buttons, and a link to the whole list beside a note such as "The first 10 of 37." when there are more.
19
20
  */
20
- const RelatedCards = ({ model, view, filter, sort, fields, actions, limit, display, empty }) => {
21
+ const RelatedCards = ({ model, view, filter, sort, fields, actions, limit, display, empty, carousel }) => {
21
22
  const xs = useIsXs();
22
23
  const orderBy = sort || defaultOrderBy(model, view);
23
24
  const page = useListPage(model, {
@@ -39,20 +40,24 @@ const RelatedCards = ({ model, view, filter, sort, fields, actions, limit, displ
39
40
  className: "text-muted-foreground",
40
41
  children: empty ?? text$1("nothingYet")
41
42
  });
43
+ const cards = rows.map((row) => {
44
+ const scope = scopeOf(model, row);
45
+ return /* @__PURE__ */ jsx(RelatedCard, {
46
+ scope,
47
+ view,
48
+ fields,
49
+ actions
50
+ }, scope.key);
51
+ });
42
52
  const note = count.data === void 0 ? void 0 : cutOffNote(rows.length, count.data, false);
43
53
  return /* @__PURE__ */ jsxs("div", {
44
54
  className: "flex flex-col gap-3",
45
- children: [/* @__PURE__ */ jsx("ul", {
55
+ children: [carousel === void 0 ? /* @__PURE__ */ jsx("ul", {
46
56
  className: "flex flex-col gap-3",
47
- children: rows.map((row) => {
48
- const scope = scopeOf(model, row);
49
- return /* @__PURE__ */ jsx(RelatedCard, {
50
- scope,
51
- view,
52
- fields,
53
- actions
54
- }, scope.key);
55
- })
57
+ children: cards
58
+ }) : /* @__PURE__ */ jsx(SnapRow, {
59
+ share: carousel,
60
+ children: cards
56
61
  }), /* @__PURE__ */ jsxs("div", {
57
62
  className: "flex flex-wrap items-center justify-between gap-2",
58
63
  children: [/* @__PURE__ */ jsx("p", {
@@ -34,13 +34,15 @@ const Router = ({ basePath = "", initialUrl, children }) => {
34
34
  url: initialUrl ?? browserUrl(),
35
35
  entry: memory ? newEntry() : historyEntry()
36
36
  }));
37
- const guards = useRef(/* @__PURE__ */ new Set());
37
+ const guards = useRef(/* @__PURE__ */ new Map());
38
+ const losesInput = () => [...guards.current.values()].includes("input-lost");
38
39
  const current = useRef(page);
39
40
  current.current = page;
40
41
  const position = useRef(memory ? 0 : historyIndex());
41
42
  const undoing = useRef(false);
42
43
  const proceeding = useRef(false);
43
44
  const leaveAfterUndo = useRef(void 0);
45
+ const reloading = useRef(false);
44
46
  const [pending, setPending] = useState();
45
47
  useEffect(() => {
46
48
  if (memory) return;
@@ -64,7 +66,8 @@ const Router = ({ basePath = "", initialUrl, children }) => {
64
66
  window.history.go(-delta);
65
67
  setPending({
66
68
  kind: "history",
67
- delta
69
+ delta,
70
+ inputLost: losesInput()
68
71
  });
69
72
  return;
70
73
  }
@@ -97,19 +100,32 @@ const Router = ({ basePath = "", initialUrl, children }) => {
97
100
  if (pathOf(full) !== pathOf(current.current.url) && guards.current.size > 0) return setPending({
98
101
  kind: "link",
99
102
  full,
100
- replace
103
+ replace,
104
+ inputLost: losesInput()
101
105
  });
102
106
  go(full, replace);
103
107
  }, [basePath, go]);
104
- const registerGuard = useCallback(() => {
108
+ const registerGuard = useCallback((kind = "draft-kept") => {
105
109
  const token = Symbol("guard");
106
- guards.current.add(token);
110
+ guards.current.set(token, kind);
107
111
  return () => void guards.current.delete(token);
108
112
  }, []);
113
+ const reload = useCallback(() => {
114
+ if (guards.current.size > 0) return setPending({
115
+ kind: "reload",
116
+ inputLost: losesInput()
117
+ });
118
+ window.location.reload();
119
+ }, []);
120
+ const reloadConfirmed = useCallback(() => reloading.current, []);
109
121
  const leave = () => {
110
122
  if (!pending) return;
111
123
  setPending(void 0);
112
124
  if (pending.kind === "link") return go(pending.full, pending.replace);
125
+ if (pending.kind === "reload") {
126
+ reloading.current = true;
127
+ return window.location.reload();
128
+ }
113
129
  proceeding.current = true;
114
130
  if (undoing.current) leaveAfterUndo.current = pending.delta;
115
131
  else window.history.go(pending.delta);
@@ -122,29 +138,49 @@ const Router = ({ basePath = "", initialUrl, children }) => {
122
138
  basePath,
123
139
  entry: page.entry,
124
140
  navigate,
125
- registerGuard
141
+ registerGuard,
142
+ reload,
143
+ reloadConfirmed
126
144
  };
127
145
  }, [
128
146
  page,
129
147
  basePath,
130
148
  navigate,
131
- registerGuard
149
+ registerGuard,
150
+ reload,
151
+ reloadConfirmed
132
152
  ]);
133
153
  return /* @__PURE__ */ jsxs(RouterContext.Provider, {
134
154
  value,
135
- children: [children, pending && /* @__PURE__ */ jsx(Dialog, {
136
- title: text("leaveTitle"),
137
- description: text("leaveDescription"),
138
- onClose: () => setPending(void 0),
139
- actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
140
- onClick: () => setPending(void 0),
141
- children: text("stay")
142
- }), /* @__PURE__ */ jsx(Button, {
143
- variant: "primary",
144
- onClick: leave,
145
- children: text("leave")
146
- })] })
147
- })]
155
+ children: [
156
+ children,
157
+ pending?.kind === "reload" && /* @__PURE__ */ jsx(Dialog, {
158
+ title: text("reloadTitle"),
159
+ description: text(pending.inputLost ? "reloadInputLost" : "reloadDraftKept"),
160
+ onClose: () => setPending(void 0),
161
+ actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
162
+ onClick: () => setPending(void 0),
163
+ children: text("keepEditing")
164
+ }), /* @__PURE__ */ jsx(Button, {
165
+ variant: "primary",
166
+ onClick: leave,
167
+ children: text("reload")
168
+ })] })
169
+ }),
170
+ pending && pending.kind !== "reload" && /* @__PURE__ */ jsx(Dialog, {
171
+ title: text("leaveTitle"),
172
+ description: text(pending.inputLost ? "leaveInputLost" : "leaveDescription"),
173
+ onClose: () => setPending(void 0),
174
+ actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
175
+ onClick: () => setPending(void 0),
176
+ children: text("stay")
177
+ }), /* @__PURE__ */ jsx(Button, {
178
+ variant: "primary",
179
+ onClick: leave,
180
+ children: text("leave")
181
+ })] })
182
+ })
183
+ ]
148
184
  });
149
185
  };
150
186
  /** A link to a path below the base path, as `appPath` writes it, that the router follows without reloading the page. */
@@ -158,16 +194,22 @@ const Link = ({ to, replace, ...rest }) => {
158
194
  ...rest
159
195
  });
160
196
  };
161
- /** While `active`, leaving the page asks first (in-app links and closing the tab). */
162
- const useNavigationGuard = (active) => {
163
- const { registerGuard } = useRouter();
164
- useEffect(() => active ? registerGuard() : void 0, [active, registerGuard]);
197
+ /** While `active`, leaving the page asks first (in-app links, a reload and closing the tab). */
198
+ const useNavigationGuard = (active, kind = "draft-kept") => {
199
+ const { registerGuard, reloadConfirmed } = useRouter();
200
+ useEffect(() => active ? registerGuard(kind) : void 0, [
201
+ active,
202
+ kind,
203
+ registerGuard
204
+ ]);
165
205
  useEffect(() => {
166
206
  if (!active) return;
167
- const warn = (event) => event.preventDefault();
207
+ const warn = (event) => {
208
+ if (!reloadConfirmed()) event.preventDefault();
209
+ };
168
210
  window.addEventListener("beforeunload", warn);
169
211
  return () => window.removeEventListener("beforeunload", warn);
170
- }, [active]);
212
+ }, [active, reloadConfirmed]);
171
213
  };
172
214
  //#endregion
173
215
  export { Link, Router, useNavigationGuard, useRouter };
@@ -4,14 +4,16 @@ import { BottomBar } from "./bottom-bar.js";
4
4
  import { Drawer } from "./drawer.js";
5
5
  import { PageScrollContext, usePageGutter, usePageInsets } from "./page-scroll.js";
6
6
  import { Sidebar } from "./sidebar.js";
7
+ import { useSidebarNoticeShown } from "./sidebar-notice.js";
7
8
  import { TopBarSlotContext } from "./top-bar-slot.js";
8
9
  import { TopBar } from "./top-bar.js";
9
10
  import { useEffect, useRef, useState } from "react";
10
11
  import { jsx, jsxs } from "react/jsx-runtime";
11
12
  //#region src/app-shell/app-shell.tsx
12
13
  const drawerId = "app-drawer";
13
- const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, appName, appIcon, logo, actions, rightPanel, forceExpanded, profileMenuOpen, navGroups, userMenu, bottomBar, onNavigate, onSignOut, signIn, organizations, roles, theme, search, pageScroll: scrollsByDefault = false, className, children }) => {
14
+ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, appName, appIcon, logo, actions, rightPanel, sidebarNotice, forceExpanded, profileMenuOpen, navGroups, userMenu, bottomBar, onNavigate, onSignOut, signIn, organizations, roles, theme, search, pageScroll: scrollsByDefault = false, className, children }) => {
14
15
  const desktop = useIsDesktop();
16
+ const noticeShown = useSidebarNoticeShown(sidebarMode);
15
17
  const [drawerOpen, setDrawerOpen] = useState(false);
16
18
  useEffect(() => {
17
19
  if (desktop) setDrawerOpen(false);
@@ -24,9 +26,10 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, appNam
24
26
  const bottomBarRef = useRef(null);
25
27
  usePageInsets(pageScroll, topBarRef, bottomBarRef, Boolean(tabs));
26
28
  usePageGutter(pageScroll);
27
- const sidebar = (mode, sticky = false) => /* @__PURE__ */ jsx(Sidebar, {
29
+ const sidebar = (mode, sticky = false, notice) => /* @__PURE__ */ jsx(Sidebar, {
28
30
  mode,
29
31
  sticky,
32
+ notice,
30
33
  activeItem,
31
34
  user,
32
35
  workspace,
@@ -49,7 +52,7 @@ const AppShell = ({ sidebarMode, activeItem, breadcrumb, user, workspace, appNam
49
52
  children: /* @__PURE__ */ jsxs("div", {
50
53
  className: cn(pageScroll ? "flex min-h-full w-full overflow-x-clip bg-background" : "relative flex h-full min-h-0 w-full overflow-hidden bg-background", className),
51
54
  children: [
52
- desktop ? sidebar(sidebarMode, pageScroll) : /* @__PURE__ */ jsx(Drawer, {
55
+ desktop ? sidebar(sidebarMode, pageScroll, noticeShown && sidebarNotice) : /* @__PURE__ */ jsx(Drawer, {
53
56
  id: drawerId,
54
57
  open: drawerOpen,
55
58
  onClose: () => setDrawerOpen(false),
@@ -0,0 +1,12 @@
1
+ import { useIsDesktop } from "../lib/use-media-query.js";
2
+ import { isTextMode } from "./sidebar-mode.js";
3
+ //#region src/app-shell/sidebar-notice.ts
4
+ /**
5
+ * Whether the shell draws its `sidebarNotice`: where the sidebar shows at full width, from the `md` breakpoint up in a
6
+ * text mode. Collapsed to icons, or a drawer on phones, it has no room, so the app says the same another way.
7
+ */
8
+ const useSidebarNoticeShown = (mode) => {
9
+ return useIsDesktop() && isTextMode(mode);
10
+ };
11
+ //#endregion
12
+ export { useSidebarNoticeShown };
@@ -1,4 +1,4 @@
1
1
  import "../primitives/route-link.js";
2
2
  import "./nav.js";
3
3
  import "./profile-menu.js";
4
- import { ComponentType } from "react";
4
+ import { ComponentType, ReactNode } from "react";