@pramen/cms-editor 0.0.45 → 0.0.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/cms-editor",
3
- "version": "0.0.45",
3
+ "version": "0.0.46",
4
4
  "description": "Visual block/page editor for @pramen/cms — a standalone React SPA that talks to the CMS handlers over HTTP.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -4,7 +4,7 @@
4
4
  // Setup screen instead of mounting the router at all.
5
5
 
6
6
  import { Button, Input } from "@podoba/react";
7
- import { createContext, use, useEffect, useMemo, useState } from "react";
7
+ import { createContext, use, useCallback, useEffect, useMemo, useRef, useState } from "react";
8
8
  import { Api, clearConfig, isTokenExpired, loadConfig, saveConfig, type Config } from "./api";
9
9
  import type { CollectionMeta } from "./types";
10
10
 
@@ -57,6 +57,14 @@ interface AppContextValue {
57
57
  setError: (s: string) => void;
58
58
  /** Sign out — drop the token so the config gate falls back to Setup. */
59
59
  reconfigure: () => void;
60
+ /** Ask the current screen whether it is safe to navigate away; `false` cancels.
61
+ * `beforeunload` only covers a real page unload, so in-app navigation (the topbar, sign
62
+ * out) would otherwise discard unsaved edits with no prompt. With no guard registered
63
+ * this is always `true`. */
64
+ confirmNavigation: () => boolean;
65
+ /** Register the current screen's guard (`null` on unmount). Screens that can hold
66
+ * unsaved edits — the page editor — register here; the root layout consults it. */
67
+ setNavGuard: (fn: (() => boolean) | null) => void;
60
68
  }
61
69
 
62
70
  const AppContext = createContext<AppContextValue | null>(null);
@@ -77,6 +85,13 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
77
85
  const authValid = Boolean(cfg.baseUrl && cfg.token) && !isTokenExpired(cfg.token);
78
86
  const api = useMemo(() => new Api(cfg, SIGN_IN_URL ? redirectToSignIn : undefined), [cfg]);
79
87
 
88
+ // Unsaved-changes guard for in-app navigation. A ref, not state: the guard is read at
89
+ // click time and re-registering it must never re-render the whole app. Both accessors are
90
+ // stable so a screen's register effect runs once per mount.
91
+ const navGuard = useRef<(() => boolean) | null>(null);
92
+ const setNavGuard = useCallback((fn: (() => boolean) | null) => { navGuard.current = fn; }, []);
93
+ const confirmNavigation = useCallback(() => navGuard.current?.() ?? true, []);
94
+
80
95
  // When an external sign-in page is configured, bounce there on boot AND the moment the
81
96
  // token expires mid-session (poll + on tab focus), so an idle editor never sits on a dead
82
97
  // session showing errors. Keyed on the token's own `exp` — the server can't distinguish an
@@ -119,6 +134,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
119
134
  collections,
120
135
  error,
121
136
  setError,
137
+ confirmNavigation,
138
+ setNavGuard,
122
139
  reconfigure: () => {
123
140
  if (SIGN_IN_URL) { redirectToSignIn(); return; }
124
141
  setMe(null); setError(""); setCfg({ ...cfg, token: "" });
@@ -346,16 +346,22 @@ export function CollectionEditor({ api, def, id, onSaved, onDeleted, onBack, onE
346
346
 
347
347
  // --- page editor -------------------------------------------------------------
348
348
 
349
- export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange }: { api: Api; page: Page; blockTypes: BlockType[]; tab: InspectorTab; onTab: (t: InspectorTab) => void; onBack: () => void; onChange: (p: Page) => void }) {
349
+ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange, registerGuard }: { api: Api; page: Page; blockTypes: BlockType[]; tab: InspectorTab; onTab: (t: InspectorTab) => void; onBack: () => void; onChange: (p: Page) => void; registerGuard: (fn: (() => boolean) | null) => void }) {
350
350
  const [ct, setCt] = useState<ContentType | null>(null);
351
351
  const [assembled, setAssembled] = useState<AssembledPage | null>(null);
352
352
  const [err, setErr] = useState("");
353
353
 
354
- // Unsaved-changes guard. Each BlockCard reports its dirty state up; while any block has
355
- // unsaved edits, warn before leaving the editor (← all pages) and before a browser unload
356
- // (refresh / close / navigating off-site). Reorder/add/remove keep block state React
357
- // reuses BlockCard instances by key so those don't lose edits and need no guard; only
358
- // unmounting the whole editor (leaving) or a page unload discards the local edits.
354
+ // Unsaved-changes guard. Every editable surface on the canvas each BlockCard, plus the
355
+ // page's own PageFields form (keyed "page") reports its dirty state up; while anything
356
+ // is dirty, warn before leaving. PageFields must participate: for a content type with no
357
+ // regions it is the ENTIRE editor, sitting right under the back button. Reorder/add/remove
358
+ // keep local state React reuses instances by key so those don't lose edits and need no
359
+ // guard; only unmounting the editor or a page unload discards them.
360
+ //
361
+ // Three exits, three mechanisms, one predicate (`confirmLeave`): the ← all pages buttons
362
+ // ask directly, `beforeunload` covers refresh/close/off-site, and `registerGuard` publishes
363
+ // it to the root layout so the topbar (wordmark, tabs, sign out) asks too — an in-app
364
+ // navigation fires no `beforeunload`, so without that it would discard edits silently.
359
365
  const dirtyRef = useRef<Set<string>>(new Set());
360
366
  const [dirtyCount, setDirtyCount] = useState(0);
361
367
  const reportDirty = useCallback((id: string, isDirty: boolean) => {
@@ -365,8 +371,17 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
365
371
  else s.delete(id);
366
372
  setDirtyCount(s.size);
367
373
  }, []);
368
- const confirmLeave = () =>
369
- dirtyRef.current.size === 0 || window.confirm(`You have unsaved changes in ${dirtyRef.current.size} block${dirtyRef.current.size === 1 ? "" : "s"}. Leave without saving?`);
374
+ // Stable (reads `dirtyRef`, never state), so the layout can hold it for the editor's
375
+ // whole lifetime. Synchronous by design a caller decides whether to navigate on the
376
+ // return value, which a modal dialog could not answer in time.
377
+ const confirmLeave = useCallback(
378
+ () => dirtyRef.current.size === 0 || window.confirm(`You have ${dirtyRef.current.size} unsaved change${dirtyRef.current.size === 1 ? "" : "s"}. Leave without saving?`),
379
+ [],
380
+ );
381
+ useEffect(() => {
382
+ registerGuard(confirmLeave);
383
+ return () => registerGuard(null);
384
+ }, [registerGuard, confirmLeave]);
370
385
 
371
386
  const btBySlug = useMemo(() => new Map(blockTypes.map((b) => [b.slug, b])), [blockTypes]);
372
387
 
@@ -390,6 +405,8 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
390
405
  }, [dirtyCount]);
391
406
 
392
407
  const regions: RegionDefinition[] = ct?.regions ?? [];
408
+ /** The content type's PAGE-level fields — rendered on the canvas, not in the inspector. */
409
+ const pageSchema: FieldDefinition[] = ct?.fieldsSchema ?? [];
393
410
 
394
411
  const patchRegion = (region: string, fn: (list: RenderedBlock[]) => RenderedBlock[]) =>
395
412
  setAssembled((prev) => (prev ? { ...prev, regions: { ...prev.regions, [region]: fn(prev.regions[region] ?? []) } } : prev));
@@ -478,11 +495,20 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
478
495
  reorder(region, ids);
479
496
  };
480
497
 
498
+ // Shown wherever the back button is — including the no-regions layout, whose only editable
499
+ // surface is PageFields, so the rail that used to carry this badge isn't rendered at all.
500
+ const dirtyBadge = dirtyCount > 0
501
+ ? <span className="text-[11px] text-accent-strong">● {dirtyCount} unsaved change{dirtyCount === 1 ? "" : "s"}</span>
502
+ : null;
503
+
481
504
  return (
482
- <div className="grid min-h-[calc(100vh-68px)] grid-cols-[260px_1fr_400px] gap-5 px-7 pb-7 pt-2 max-[820px]:grid-cols-1">
505
+ // With no regions the left rail would be a 260px column holding just a back button,
506
+ // so it collapses and the content column takes the space.
507
+ <div className={`grid min-h-[calc(100vh-68px)] gap-5 px-7 pb-7 pt-2 max-[820px]:grid-cols-1 ${regions.length ? "grid-cols-[260px_1fr_400px]" : "grid-cols-[1fr_400px]"}`}>
508
+ {regions.length === 0 ? null : (
483
509
  <div className="overflow-auto rounded-panel bg-surface-muted p-[18px]">
484
510
  <Button variant="ghost" size="sm" onPress={() => { if (confirmLeave()) onBack(); }}>← all pages</Button>
485
- {dirtyCount > 0 ? <p className="mt-2 text-[11px] text-accent-strong">● {dirtyCount} unsaved block{dirtyCount === 1 ? "" : "s"}</p> : null}
511
+ {dirtyBadge ? <p className="mt-2">{dirtyBadge}</p> : null}
486
512
  <Section>Regions</Section>
487
513
  {regions.map((r) => (
488
514
  <div key={r.name} className={`${ROW} mb-2`}>
@@ -496,11 +522,25 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
496
522
  <Pill status={page.status}>{page.status}</Pill>
497
523
  </div>
498
524
  </div>
525
+ )}
499
526
 
500
527
  {/* The canvas: one inline document. `pl-8` reserves the left gutter that each
501
528
  block's drag handle occupies on hover. Regions are titled sections. */}
502
529
  <div className="overflow-auto py-1.5 pl-8 pr-2">
503
530
  {err ? <Banner>{err}</Banner> : null}
531
+ {regions.length === 0 ? (
532
+ <div className="mb-2 flex items-center gap-2">
533
+ <Button variant="ghost" size="sm" onPress={() => { if (confirmLeave()) onBack(); }}>← all pages</Button>
534
+ <Pill status={page.status}>{page.status}</Pill>
535
+ {dirtyBadge}
536
+ </div>
537
+ ) : null}
538
+ {/* The page's own fields are CONTENT, so they belong on the canvas at full width —
539
+ not in the inspector. For a content type with no regions (a fixed layout, all
540
+ of it page fields) this is the entire editor; the canvas is never empty. */}
541
+ {pageSchema.length ? (
542
+ <PageFields api={api} page={page} schema={pageSchema} initialFields={(assembled?.page.fields as Record<string, unknown>) ?? {}} onDirtyChange={reportDirty} onError={setErr} />
543
+ ) : null}
504
544
  {regions.map((r) => {
505
545
  const blocks = assembled?.regions[r.name] ?? [];
506
546
  const allowed = r.allowedTypes && r.allowedTypes.length ? r.allowedTypes : blockTypes.map((b) => b.slug);
@@ -538,7 +578,9 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
538
578
  </div>
539
579
  );
540
580
  })}
541
- {regions.length === 0 ? <p className="text-fg-subtle">This page's content type has no regions.</p> : null}
581
+ {regions.length === 0 && pageSchema.length === 0
582
+ ? <p className="text-fg-subtle">This page's content type defines no fields or regions.</p>
583
+ : null}
542
584
  </div>
543
585
 
544
586
  <div className="overflow-auto rounded-panel border border-border bg-surface-card p-5">
@@ -547,7 +589,7 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
547
589
  <Button key={t} variant="ghost" size="sm" className={tab === t ? "bg-surface-muted text-fg" : "text-fg-muted"} onPress={() => onTab(t)}>{t}</Button>
548
590
  ))}
549
591
  </div>
550
- {tab === "settings" ? <Settings api={api} page={page} ct={ct} initialFields={(assembled?.page.fields as Record<string, unknown>) ?? {}} onSaved={onChange} onError={setErr} /> : null}
592
+ {tab === "settings" ? <PageMeta api={api} page={page} onSaved={onChange} onError={setErr} /> : null}
551
593
  {tab === "seo" ? <SeoPanel api={api} page={page} onError={setErr} /> : null}
552
594
  {tab === "workflow" ? <Workflow api={api} page={page} onChanged={(p) => { onChange(p); }} onError={setErr} /> : null}
553
595
  {tab === "i18n" ? <I18n api={api} page={page} onError={setErr} /> : null}
@@ -803,24 +845,28 @@ function Inserter({ allowed, btBySlug, onAdd, compact }: { allowed: string[]; bt
803
845
  );
804
846
  }
805
847
 
806
- // Edit the page record itself — title/slug/locale and, for a content type with page-level
807
- // fields (e.g. a "Lecture" with date/speaker), its structured `fields`. Backed by updatePage.
808
- function Settings({ api, page, ct, initialFields, onSaved, onError }: { api: Api; page: Page; ct: ContentType | null; initialFields: Record<string, unknown>; onSaved: (p: Page) => void; onError: (s: string) => void }) {
848
+ /**
849
+ * Page META title / slug / locale. Settings, not content, so it lives in the inspector.
850
+ *
851
+ * Split from the page's FIELDS (below) because the two are different things: the fields
852
+ * are what the page SAYS, and burying them in a 400px inspector made a page whose content
853
+ * type has no regions look empty — a wide, blank canvas next to a cramped form.
854
+ */
855
+ function PageMeta({ api, page, onSaved, onError }: { api: Api; page: Page; onSaved: (p: Page) => void; onError: (s: string) => void }) {
809
856
  const [title, setTitle] = useState(page.title);
810
857
  const [slug, setSlug] = useState(page.slug);
811
858
  const [locale, setLocale] = useState(page.locale);
812
- const [fields, setFields] = useState<Record<string, unknown>>(initialFields);
813
859
  const [ok, setOk] = useState(false);
814
860
  const [busy, setBusy] = useState(false);
815
- const schema: FieldDefinition[] = ct?.fieldsSchema ?? [];
816
861
 
817
862
  useEffect(() => { setTitle(page.title); setSlug(page.slug); setLocale(page.locale); }, [page.id, page.title, page.slug, page.locale]);
818
- useEffect(() => { setFields(initialFields); }, [initialFields]);
819
863
 
820
864
  const save = async () => {
821
865
  setBusy(true);
822
866
  try {
823
- const r = await api.call<{ page?: Page }>("updatePage", { pageId: page.id, title, slug, locale, ...(schema.length ? { fields } : {}) });
867
+ // Meta only `fields` is deliberately omitted so saving here can never clobber
868
+ // content edited in the canvas (updatePage patches only what it is given).
869
+ const r = await api.call<{ page?: Page }>("updatePage", { pageId: page.id, title, slug, locale });
824
870
  if (r?.page) onSaved(r.page);
825
871
  setOk(true);
826
872
  setTimeout(() => setOk(false), 1500);
@@ -838,18 +884,73 @@ function Settings({ api, page, ct, initialFields, onSaved, onError }: { api: Api
838
884
  <Input label="Title" value={title} onChange={setTitle} />
839
885
  <Input label="Slug" value={slug} onChange={setSlug} />
840
886
  <Input label="Locale" value={locale} onChange={setLocale} />
841
- {schema.length ? (
842
- <>
843
- <Section>Fields</Section>
844
- <FieldForm schema={schema} value={fields} onChange={setFields} api={api} />
845
- </>
846
- ) : null}
847
887
  <Button onPress={save} isDisabled={busy || !title.trim() || !slug.trim()}>{busy ? "Saving…" : "Save"}</Button>
848
888
  <KV><span>Status</span><span>{page.status}</span></KV>
849
889
  </div>
850
890
  );
851
891
  }
852
892
 
893
+ /** The page's own FIELDS — its content. Rendered in the canvas, at full width. */
894
+ function PageFields({ api, page, schema, initialFields, onDirtyChange, onError }: { api: Api; page: Page; schema: FieldDefinition[]; initialFields: Record<string, unknown>; onDirtyChange: (id: string, dirty: boolean) => void; onError: (s: string) => void }) {
895
+ const [fields, setFields] = useState<Record<string, unknown>>(initialFields);
896
+ const [ok, setOk] = useState(false);
897
+ const [busy, setBusy] = useState(false);
898
+
899
+ // Dirty is DERIVED from a snapshot of what's persisted, the way BlockCard does it — not a
900
+ // one-way flag set on every keystroke. Typing a character and deleting it again leaves the
901
+ // form matching the store, and the guard this feeds now fronts the whole topbar: a sticky
902
+ // flag would mean confirming your way past a prompt with nothing to save.
903
+ const saved = useRef(JSON.stringify(initialFields));
904
+ const dirty = JSON.stringify(fields) !== saved.current;
905
+
906
+ // Re-seed the form ONLY when the editor switches to a different page — never on a mere
907
+ // `initialFields` identity change. Every reload() (add/remove/reorder a block, save the
908
+ // slug) replaces `assembled` wholesale, and when `page.fields` is SQL NULL the caller's
909
+ // `?? {}` fallback mints a fresh object on EVERY render — keying off the object would
910
+ // silently drop in-progress edits and clear the "● unsaved" badge with no warning.
911
+ const seededFor = useRef(page.id);
912
+ useEffect(() => {
913
+ if (seededFor.current === page.id) return;
914
+ seededFor.current = page.id;
915
+ setFields(initialFields);
916
+ saved.current = JSON.stringify(initialFields);
917
+ }, [page.id, initialFields]);
918
+
919
+ // Join the editor's unsaved-changes guard (mirrors BlockCard).
920
+ useEffect(() => { onDirtyChange("page", dirty); }, [dirty, onDirtyChange]);
921
+ useEffect(() => () => { onDirtyChange("page", false); }, [onDirtyChange]);
922
+
923
+ const save = async () => {
924
+ // Snapshot BEFORE the round trip: an edit made while it's in flight must stay dirty.
925
+ const snapshot = JSON.stringify(fields);
926
+ setBusy(true);
927
+ try {
928
+ await api.call("updatePage", { pageId: page.id, fields });
929
+ saved.current = snapshot;
930
+ setOk(true);
931
+ setTimeout(() => setOk(false), 1500);
932
+ } catch (e) {
933
+ onError(errMsg(e));
934
+ } finally {
935
+ setBusy(false);
936
+ }
937
+ };
938
+
939
+ return (
940
+ <div className="mb-10">
941
+ <div className="mb-1 flex items-center gap-2">
942
+ <span className="text-caption font-medium uppercase tracking-wide text-fg-subtle">Content</span>
943
+ {dirty ? <span className="text-[11px] text-accent-strong">● unsaved</span> : null}
944
+ </div>
945
+ {ok ? <Banner ok>saved</Banner> : null}
946
+ <FieldForm schema={schema} value={fields} onChange={setFields} api={api} />
947
+ <div className="mt-3">
948
+ <Button onPress={save} isDisabled={busy}>{busy ? "Saving…" : "Save"}</Button>
949
+ </div>
950
+ </div>
951
+ );
952
+ }
953
+
853
954
  function SeoPanel({ api, page, onError }: { api: Api; page: Page; onError: (s: string) => void }) {
854
955
  const [f, setF] = useState({ metaTitle: page.metaTitle ?? "", metaDescription: page.metaDescription ?? "", canonicalUrl: page.canonicalUrl ?? "", robots: page.robots ?? "", ogTitle: page.ogTitle ?? "", ogDescription: page.ogDescription ?? "" });
855
956
  const [ok, setOk] = useState(false);
@@ -3,17 +3,24 @@
3
3
  // current path, so a deep link or refresh lands with the right tab lit.
4
4
 
5
5
  import { Outlet, useNavigate, useRoute } from "@buzola/router";
6
- import { Badge, Button, Card, MoonIcon, SunIcon, Text, Topbar } from "@podoba/react";
6
+ import { Button, Card, MoonIcon, SunIcon, Text, Topbar } from "@podoba/react";
7
7
  import { useEffect, useState } from "react";
8
8
  import { useApp } from "../app-context";
9
9
 
10
10
  const THEME_KEY = "pramen.cms.theme";
11
11
 
12
12
  export default function RootLayout() {
13
- const { cfg, isAdmin, collections, error, reconfigure } = useApp();
13
+ const { isAdmin, collections, error, reconfigure, confirmNavigation } = useApp();
14
14
  const navigate = useNavigate();
15
15
  const { pathname } = useRoute();
16
16
 
17
+ // Every chrome action here is a way OUT of the current screen, so it runs through that
18
+ // screen's unsaved-changes guard first (the page editor registers one; with no guard
19
+ // registered this is a pass-through). In-app navigation fires no `beforeunload`, so
20
+ // without this the topbar silently discards unsaved edits. The external `extraNav` links
21
+ // open in a new tab and leave nothing behind, so they stay unguarded.
22
+ const guarded = (go: () => void) => () => { if (confirmNavigation()) go(); };
23
+
17
24
  // Dark mode: podoba tokens flip under `[data-theme="dark"]` — no `dark:` prefixes.
18
25
  const [theme, setTheme] = useState(() => (typeof localStorage !== "undefined" ? localStorage.getItem(THEME_KEY) ?? "light" : "light"));
19
26
  useEffect(() => {
@@ -45,30 +52,39 @@ export default function RootLayout() {
45
52
  <div className="min-h-screen bg-surface text-fg">
46
53
  <Topbar className="sticky top-0 z-10 bg-surface px-7">
47
54
  <Topbar.Brand>
48
- <span className="text-callout font-bold tracking-[0.01em] text-fg">pramen</span>
49
- <span className="text-fg-subtle">· cms</span>
55
+ {/* The wordmark is the way back to the top of the admin, as it is on every
56
+ other site — a `button` (not an `<a>`) so the SPA router handles it. */}
57
+ <button
58
+ type="button"
59
+ onClick={guarded(() => navigate("home"))}
60
+ aria-label="pramen cms — home"
61
+ className="flex items-baseline gap-1 rounded-md px-1 py-0.5 transition-colors hover:bg-surface-muted"
62
+ >
63
+ <span className="text-callout font-bold tracking-[0.01em] text-fg">pramen</span>
64
+ <span className="text-fg-subtle">· cms</span>
65
+ </button>
50
66
  </Topbar.Brand>
51
67
  <Topbar.Nav aria-label="Primary">
52
68
  {hidePages ? null : (
53
- <Button variant="ghost" size="sm" className={tabCls("pages")} onPress={() => navigate("home")}>
69
+ <Button variant="ghost" size="sm" className={tabCls("pages")} onPress={guarded(() => navigate("home"))}>
54
70
  Pages
55
71
  </Button>
56
72
  )}
57
73
  {collections.map((c) => (
58
- <Button key={c.slug} variant="ghost" size="sm" className={tabCls(`col:${c.slug}`)} onPress={() => navigate("collection", { params: { slug: c.slug } })}>
74
+ <Button key={c.slug} variant="ghost" size="sm" className={tabCls(`col:${c.slug}`)} onPress={guarded(() => navigate("collection", { params: { slug: c.slug } }))}>
59
75
  {c.icon ? `${c.icon} ` : ""}
60
76
  {c.pluralLabel}
61
77
  </Button>
62
78
  ))}
63
- <Button variant="ghost" size="sm" className={tabCls("media")} onPress={() => navigate("media")}>
79
+ <Button variant="ghost" size="sm" className={tabCls("media")} onPress={guarded(() => navigate("media"))}>
64
80
  Media
65
81
  </Button>
66
82
  {isAdmin ? (
67
- <Button variant="ghost" size="sm" className={tabCls("users")} onPress={() => navigate("users")}>
83
+ <Button variant="ghost" size="sm" className={tabCls("users")} onPress={guarded(() => navigate("users"))}>
68
84
  Users
69
85
  </Button>
70
86
  ) : null}
71
- <Button variant="ghost" size="sm" className={tabCls("settings")} onPress={() => navigate("settings")}>
87
+ <Button variant="ghost" size="sm" className={tabCls("settings")} onPress={guarded(() => navigate("settings"))}>
72
88
  Settings
73
89
  </Button>
74
90
  {extraNav.map((l) => (
@@ -87,7 +103,8 @@ export default function RootLayout() {
87
103
  ))}
88
104
  </Topbar.Nav>
89
105
  <Topbar.Actions>
90
- <Badge color="grey" label={cfg.tenant} />
106
+ {/* The tenant is deployment configuration, not something an editor acts on —
107
+ it stays visible on the Settings page (Connection), not in the chrome. */}
91
108
  <Button
92
109
  variant="ghost"
93
110
  size="sm"
@@ -96,7 +113,7 @@ export default function RootLayout() {
96
113
  >
97
114
  {theme === "dark" ? <SunIcon className="h-4 w-4" /> : <MoonIcon className="h-4 w-4" />}
98
115
  </Button>
99
- <Button variant="ghost" size="sm" onPress={reconfigure}>
116
+ <Button variant="ghost" size="sm" onPress={guarded(reconfigure)}>
100
117
  sign out
101
118
  </Button>
102
119
  </Topbar.Actions>
@@ -13,7 +13,7 @@ export default createPage()
13
13
  .params({ pageId: "string", tab: "?string" })
14
14
  .route("/pages/:pageId")
15
15
  .render(function PageEditorRoute({ params }) {
16
- const { api, setError } = useApp();
16
+ const { api, setError, setNavGuard } = useApp();
17
17
  const navigate = useNavigate();
18
18
  const [page, setPage] = useState<Page | null>(null);
19
19
  const [blockTypes, setBlockTypes] = useState<BlockType[]>([]);
@@ -57,6 +57,7 @@ export default createPage()
57
57
  onTab={setTab}
58
58
  onBack={() => navigate("home")}
59
59
  onChange={setPage}
60
+ registerGuard={setNavGuard}
60
61
  />
61
62
  );
62
63
  });