@pramen/cms-editor 0.0.44 → 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/dist/app.css +1 -1
- package/dist/index.html +1 -1
- package/dist/{main.2k0x7rgk.js → main.w2tw3v4q.js} +110 -110
- package/package.json +1 -1
- package/src/app-context.tsx +18 -1
- package/src/components.tsx +126 -25
- package/src/routes/_layout.tsx +28 -11
- package/src/routes/page.tsx +2 -1
package/package.json
CHANGED
package/src/app-context.tsx
CHANGED
|
@@ -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: "" });
|
package/src/components.tsx
CHANGED
|
@@ -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.
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
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
|
-
|
|
369
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
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" ? <
|
|
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
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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
|
-
|
|
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);
|
package/src/routes/_layout.tsx
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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>
|
package/src/routes/page.tsx
CHANGED
|
@@ -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
|
});
|