@pramen/cms-editor 0.0.50 → 0.0.52
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/README.md +45 -2
- package/dist/config.js +14 -4
- package/dist/index.html +4 -2
- package/dist/main.a0tgw8hg.js +399 -0
- package/package.json +1 -1
- package/src/app-context.tsx +21 -2
- package/src/brand.ts +96 -0
- package/src/components.tsx +33 -6
- package/src/main.tsx +10 -0
- package/src/routes/_layout.tsx +12 -5
- package/src/routes/page.tsx +12 -3
- package/src/types.ts +19 -0
- package/dist/main.bx4zkbes.js +0 -399
package/package.json
CHANGED
package/src/app-context.tsx
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
import { Button, Input } from "@podoba/react";
|
|
7
7
|
import { createContext, use, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
8
8
|
import { Api, clearConfig, isTokenExpired, loadConfig, saveConfig, type Config } from "./api";
|
|
9
|
-
import
|
|
9
|
+
import { BRAND, SETUP_TITLE, type BrandConfig } from "./brand";
|
|
10
|
+
import { DEFAULT_CAPABILITIES, type CmsCapabilities, type CollectionMeta, type JsonValue } from "./types";
|
|
10
11
|
|
|
11
12
|
declare global {
|
|
12
13
|
interface Window {
|
|
@@ -20,6 +21,13 @@ declare global {
|
|
|
20
21
|
/** Extra top-nav links to companion tools the host serves (e.g. a curation page).
|
|
21
22
|
* Rendered as plain external `<a>` links after the built-in tabs. */
|
|
22
23
|
extraNav?: { label: string; href: string }[];
|
|
24
|
+
/** The wordmark in the topbar, on the Setup screen, and in the browser tab.
|
|
25
|
+
*
|
|
26
|
+
* This editor ships as a package an agency deploys FOR ITS CLIENT, so the default
|
|
27
|
+
* put the framework's name where the client's belongs — someone logging into their
|
|
28
|
+
* own CMS was greeted by "pramen". Set `name` (and optionally `suffix`) to the
|
|
29
|
+
* deployment's own; `suffix: null` drops the "· cms" half entirely. */
|
|
30
|
+
brand?: BrandConfig;
|
|
23
31
|
};
|
|
24
32
|
}
|
|
25
33
|
}
|
|
@@ -53,6 +61,10 @@ interface AppContextValue {
|
|
|
53
61
|
/** Collections registered on the server (from `listCollections`) — drives the nav + the
|
|
54
62
|
* generic list/edit routes. Empty when the server registers none. */
|
|
55
63
|
collections: CollectionMeta[];
|
|
64
|
+
/** What the SERVER says this deployment supports (from `listCmsCapabilities`) — today,
|
|
65
|
+
* its declared locales. The editor renders its i18n surface off this rather than a local
|
|
66
|
+
* flag, so the UI and the data can never disagree about whether the site is multilingual. */
|
|
67
|
+
cms: CmsCapabilities;
|
|
56
68
|
error: string;
|
|
57
69
|
setError: (s: string) => void;
|
|
58
70
|
/** Sign out — drop the token so the config gate falls back to Setup. */
|
|
@@ -79,6 +91,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|
|
79
91
|
const [cfg, setCfg] = useState<Config>(loadConfig());
|
|
80
92
|
const [me, setMe] = useState<Me | null>(null);
|
|
81
93
|
const [collections, setCollections] = useState<CollectionMeta[]>([]);
|
|
94
|
+
const [cms, setCms] = useState<CmsCapabilities>(DEFAULT_CAPABILITIES);
|
|
82
95
|
const [error, setError] = useState("");
|
|
83
96
|
// A usable session = a base URL + a token that is NOT expired. An expired token counts as
|
|
84
97
|
// no session: otherwise the editor mounts and every RPC 403s into an error banner.
|
|
@@ -118,6 +131,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|
|
118
131
|
// Collections drive the nav + list/edit routes. An app that registers none (or an older
|
|
119
132
|
// server without the handler) just leaves the nav as-is — a failure is non-fatal.
|
|
120
133
|
api.call<CollectionMeta[]>("listCollections").then(setCollections).catch(() => setCollections([]));
|
|
134
|
+
// A server older than this handler leaves the monolingual default, which is the safe
|
|
135
|
+
// way round: the i18n surface stays hidden rather than half-rendered.
|
|
136
|
+
api.call<CmsCapabilities>("listCmsCapabilities").then(setCms).catch(() => setCms(DEFAULT_CAPABILITIES));
|
|
121
137
|
}, [api, authValid]);
|
|
122
138
|
|
|
123
139
|
if (!authValid) {
|
|
@@ -132,6 +148,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|
|
132
148
|
me,
|
|
133
149
|
isAdmin: (me?.roles ?? []).includes("admin"),
|
|
134
150
|
collections,
|
|
151
|
+
cms,
|
|
135
152
|
error,
|
|
136
153
|
setError,
|
|
137
154
|
confirmNavigation,
|
|
@@ -149,8 +166,10 @@ function Setup({ cfg, onSave }: { cfg: Config; onSave: (c: Config) => void }) {
|
|
|
149
166
|
return (
|
|
150
167
|
<div className="mx-auto mt-[14vh] w-full max-w-[520px] px-6">
|
|
151
168
|
<div className="rounded-panel border border-border bg-surface-card px-10 py-8 shadow-[0_24px_60px_rgba(30,20,10,0.08)]">
|
|
169
|
+
{/* `SETUP_TITLE`, not `BRAND.title`: this screen has always read "… cms editor", and
|
|
170
|
+
the default has to render byte-identical to before the brand seam existed. */}
|
|
152
171
|
<h1 className="mb-4 text-display text-fg">
|
|
153
|
-
|
|
172
|
+
{BRAND.name} <span className="text-fg-subtle">{SETUP_TITLE}</span>
|
|
154
173
|
</h1>
|
|
155
174
|
<p className="mb-6 text-sm text-fg-muted">
|
|
156
175
|
Point at your Worker and paste an editor/reviewer JWT. CORS must allow this origin (<code>CORS_ORIGINS</code>).
|
package/src/brand.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// The deployment's wordmark, resolved once from the host's /config.js.
|
|
2
|
+
//
|
|
3
|
+
// Deliberately free of React, podoba and DOM-lib imports: it is read at module load (before
|
|
4
|
+
// any component renders) and it is the one piece of chrome a host is expected to change, so
|
|
5
|
+
// it stays a pure function of config that can be exercised without a browser.
|
|
6
|
+
|
|
7
|
+
/** What the host may set under `window.PRAMEN_CMS_EDITOR.brand`. */
|
|
8
|
+
export interface BrandConfig {
|
|
9
|
+
/** The wordmark. Blank/absent keeps the default. */
|
|
10
|
+
name?: string;
|
|
11
|
+
/** The muted half after the middot. Absent keeps the default; `null` drops it. */
|
|
12
|
+
suffix?: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A resolved wordmark. */
|
|
16
|
+
export interface Brand {
|
|
17
|
+
name: string;
|
|
18
|
+
suffix: string | null;
|
|
19
|
+
/** Name and suffix joined for `document.title` — where the middot reads correctly. */
|
|
20
|
+
title: string;
|
|
21
|
+
/** The same words with no punctuation, for an accessible name. A screen reader announces
|
|
22
|
+
* "·" as "middle dot" at higher verbosity, so the decorative separator that belongs in a
|
|
23
|
+
* tab title does not belong in an aria-label. */
|
|
24
|
+
spoken: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_BRAND_NAME = "pramen";
|
|
28
|
+
export const DEFAULT_BRAND_SUFFIX = "cms";
|
|
29
|
+
|
|
30
|
+
/** Coerce one config value to a trimmed string, or undefined for anything else.
|
|
31
|
+
*
|
|
32
|
+
* /config.js is hand-edited, untyped, and often templated from an env var, so a value here
|
|
33
|
+
* can be any JSON type. `?.trim()` guards null and undefined ONLY — `suffix: false` (a
|
|
34
|
+
* plausible slip next to `hidePages: true`) or `name: 123` would THROW, and since `BRAND` is
|
|
35
|
+
* resolved at module load in the entry bundle's import graph, that throw aborts evaluation
|
|
36
|
+
* before `createRoot` and renders a blank page with nothing but a console error. Every other
|
|
37
|
+
* field in this config object already fails safe; this one must too. */
|
|
38
|
+
function str(v: unknown): string | undefined {
|
|
39
|
+
if (typeof v !== "string") return undefined;
|
|
40
|
+
const trimmed = v.trim();
|
|
41
|
+
return trimmed === "" ? undefined : trimmed;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Resolve the configured wordmark, falling back to what used to be hardcoded.
|
|
45
|
+
*
|
|
46
|
+
* The distinction that matters is ABSENT vs. `null` on `suffix`: a host that only renames
|
|
47
|
+
* the product keeps the "· cms" half, while `suffix: null` is the explicit "just our name".
|
|
48
|
+
*
|
|
49
|
+
* A `brand` that is present but yields no usable name is WARNED about rather than silently
|
|
50
|
+
* accepted: `brand: "Acme"` (the shorthand instead of `{ name: "Acme" }`), a misspelled key,
|
|
51
|
+
* or a template that resolved to an empty string all end up shipping the framework's name to
|
|
52
|
+
* the client — the exact outcome this config exists to prevent — and a green deploy is the
|
|
53
|
+
* worst place to discover it. */
|
|
54
|
+
export function resolveBrand(cfg?: BrandConfig): Brand {
|
|
55
|
+
const configured = str(cfg?.name);
|
|
56
|
+
if (cfg !== undefined && cfg !== null && configured === undefined) {
|
|
57
|
+
console.warn(
|
|
58
|
+
`pramen/cms-editor: \`brand\` is set but has no usable \`name\`, so the wordmark stays "${DEFAULT_BRAND_NAME}". Expected \`brand: { name: "Your name" }\`.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const name = configured ?? DEFAULT_BRAND_NAME;
|
|
62
|
+
// `undefined` (not configured) keeps the default; `null` — and any non-string — drops it.
|
|
63
|
+
const suffix = cfg?.suffix === undefined ? DEFAULT_BRAND_SUFFIX : (str(cfg.suffix) ?? null);
|
|
64
|
+
return { name, suffix, title: suffix ? `${name} · ${suffix}` : name, spoken: suffix ? `${name} ${suffix}` : name };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The global the host's /config.js writes. Declared structurally rather than reaching for
|
|
68
|
+
* `Window`, so this module needs no DOM lib — and so a test can hand it a plain object. */
|
|
69
|
+
export interface BrandHost {
|
|
70
|
+
PRAMEN_CMS_EDITOR?: { brand?: BrandConfig };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Pull the brand config off a host global, tolerating its absence (SSR, tests, a /config.js
|
|
74
|
+
* that 404'd). Exported so the READ is testable, not just the resolution. */
|
|
75
|
+
export function readBrandConfig(host: BrandHost | undefined): BrandConfig | undefined {
|
|
76
|
+
return host?.PRAMEN_CMS_EDITOR?.brand;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The wordmark for THIS page load. Read at module load, like `SIGN_IN_URL` — /config.js is
|
|
80
|
+
* a plain script tag ahead of the bundle, so it is already set. */
|
|
81
|
+
export const BRAND: Brand = resolveBrand(readBrandConfig(globalThis as BrandHost));
|
|
82
|
+
|
|
83
|
+
/** The two chrome strings built from the wordmark, in one place so the words that are NOT
|
|
84
|
+
* the client's name can be seen together — and so neither call site re-invents them.
|
|
85
|
+
*
|
|
86
|
+
* Both keep saying "editor" on the DEFAULT wordmark, because that is what the Setup screen
|
|
87
|
+
* and the browser tab have always said; a configured brand replaces the lot rather than
|
|
88
|
+
* having an English noun appended to it. */
|
|
89
|
+
export const SETUP_TITLE: string = BRAND.suffix ? `· ${BRAND.suffix}${isDefault(BRAND) ? " editor" : ""}` : "";
|
|
90
|
+
export const DOCUMENT_TITLE: string = isDefault(BRAND) ? `${BRAND.title} editor` : BRAND.title;
|
|
91
|
+
|
|
92
|
+
/** Whether nothing was configured — the unbranded default, which must render exactly as it
|
|
93
|
+
* did before this module existed. */
|
|
94
|
+
function isDefault(b: Brand): boolean {
|
|
95
|
+
return b.name === DEFAULT_BRAND_NAME && b.suffix === DEFAULT_BRAND_SUFFIX;
|
|
96
|
+
}
|
package/src/components.tsx
CHANGED
|
@@ -7,13 +7,20 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
|
|
|
7
7
|
import { Api, ApiError } from "./api";
|
|
8
8
|
import { CONTROL, FieldForm, formatWhen, fromLocalInput, slugify, toLocalInput } from "./fields";
|
|
9
9
|
import type { Config } from "./api";
|
|
10
|
-
import type
|
|
10
|
+
import { useApp, type Me } from "./app-context";
|
|
11
11
|
import { isRichTextDoc, richTextToPlainText } from "./rich-text";
|
|
12
12
|
import type { AssembledPage, AuditEntry, BlockType, CollectionMeta, ContentType, FieldDefinition, FieldValue, FieldValues, Media, Page, RegionDefinition, RenderedBlock } from "./types";
|
|
13
13
|
|
|
14
14
|
export type InspectorTab = "settings" | "seo" | "workflow" | "i18n" | "audit";
|
|
15
15
|
export const INSPECTOR_TABS: InspectorTab[] = ["settings", "seo", "workflow", "i18n", "audit"];
|
|
16
16
|
|
|
17
|
+
/** The tabs a deployment actually shows. ONE definition, used by the tab bar, the panel
|
|
18
|
+
* switch and the route's deep-link fallback — three places that previously each re-derived
|
|
19
|
+
* "is i18n visible?" and could disagree. */
|
|
20
|
+
export function visibleTabs(multilingual: boolean): InspectorTab[] {
|
|
21
|
+
return multilingual ? INSPECTOR_TABS : INSPECTOR_TABS.filter((t) => t !== "i18n");
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
// --- presentational primitives (podoba tokens; replaces styles.ts classes) ---
|
|
18
25
|
|
|
19
26
|
const ROW = "flex items-center gap-3 rounded-[14px] border border-transparent bg-surface-card px-[18px] py-3.5";
|
|
@@ -102,6 +109,8 @@ const Dim = ({ children }: { children: ReactNode }) => <span className="text-fg-
|
|
|
102
109
|
// --- pages list --------------------------------------------------------------
|
|
103
110
|
|
|
104
111
|
export function PageList({ api, pages, blockTypes, onOpen, onCreated, onError }: { api: Api; pages: Page[]; blockTypes: BlockType[]; onOpen: (p: Page) => void; onCreated: () => void; onError: (s: string) => void }) {
|
|
112
|
+
// From the SERVER (listCmsCapabilities), not a local flag — see `CmsCapabilities`.
|
|
113
|
+
const { cms: { multilingual } } = useApp();
|
|
105
114
|
const [creating, setCreating] = useState(false);
|
|
106
115
|
return (
|
|
107
116
|
<>
|
|
@@ -116,7 +125,7 @@ export function PageList({ api, pages, blockTypes, onOpen, onCreated, onError }:
|
|
|
116
125
|
<div className={`${ROW} cursor-pointer hover:bg-surface-muted`} key={p.id} onClick={() => onOpen(p)}>
|
|
117
126
|
<span className="flex-1 truncate font-medium">{p.title}</span>
|
|
118
127
|
<span className="text-fg-subtle">/{p.slug}</span>
|
|
119
|
-
<span className="text-fg-subtle">{p.locale}</span>
|
|
128
|
+
{multilingual ? <span className="text-fg-subtle">{p.locale}</span> : null}
|
|
120
129
|
<Pill status={p.status}>{p.status}</Pill>
|
|
121
130
|
</div>
|
|
122
131
|
))}
|
|
@@ -538,6 +547,7 @@ export function CollectionEditor({ api, def, id, onSaved, onDeleted, onBack, onE
|
|
|
538
547
|
// --- page editor -------------------------------------------------------------
|
|
539
548
|
|
|
540
549
|
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 }) {
|
|
550
|
+
const { cms: { multilingual } } = useApp();
|
|
541
551
|
const [ct, setCt] = useState<ContentType | null>(null);
|
|
542
552
|
const [assembled, setAssembled] = useState<AssembledPage | null>(null);
|
|
543
553
|
const [err, setErr] = useState("");
|
|
@@ -776,14 +786,14 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
|
|
|
776
786
|
|
|
777
787
|
<div className="overflow-auto rounded-panel border border-border bg-surface-card p-5">
|
|
778
788
|
<div className="mb-3 flex gap-1">
|
|
779
|
-
{
|
|
789
|
+
{visibleTabs(multilingual).map((t) => (
|
|
780
790
|
<Button key={t} variant="ghost" size="sm" className={tab === t ? "bg-surface-muted text-fg" : "text-fg-muted"} onPress={() => onTab(t)}>{t}</Button>
|
|
781
791
|
))}
|
|
782
792
|
</div>
|
|
783
793
|
{tab === "settings" ? <PageMeta api={api} page={page} onSaved={onChange} onError={setErr} /> : null}
|
|
784
794
|
{tab === "seo" ? <SeoPanel api={api} page={page} onError={setErr} /> : null}
|
|
785
795
|
{tab === "workflow" ? <Workflow api={api} page={page} onChanged={(p) => { onChange(p); }} onError={setErr} /> : null}
|
|
786
|
-
{tab === "i18n" ? <I18n api={api} page={page} onError={setErr} /> : null}
|
|
796
|
+
{tab === "i18n" && multilingual ? <I18n api={api} page={page} onError={setErr} /> : null}
|
|
787
797
|
{tab === "audit" ? <AuditLog api={api} pageId={page.id} onError={setErr} /> : null}
|
|
788
798
|
</div>
|
|
789
799
|
</div>
|
|
@@ -1044,6 +1054,7 @@ function Inserter({ allowed, btBySlug, onAdd, compact }: { allowed: string[]; bt
|
|
|
1044
1054
|
* type has no regions look empty — a wide, blank canvas next to a cramped form.
|
|
1045
1055
|
*/
|
|
1046
1056
|
function PageMeta({ api, page, onSaved, onError }: { api: Api; page: Page; onSaved: (p: Page) => void; onError: (s: string) => void }) {
|
|
1057
|
+
const { cms: { multilingual, locales } } = useApp();
|
|
1047
1058
|
const [title, setTitle] = useState(page.title);
|
|
1048
1059
|
const [slug, setSlug] = useState(page.slug);
|
|
1049
1060
|
const [locale, setLocale] = useState(page.locale);
|
|
@@ -1057,7 +1068,12 @@ function PageMeta({ api, page, onSaved, onError }: { api: Api; page: Page; onSav
|
|
|
1057
1068
|
try {
|
|
1058
1069
|
// Meta only — `fields` is deliberately omitted so saving here can never clobber
|
|
1059
1070
|
// content edited in the canvas (updatePage patches only what it is given).
|
|
1060
|
-
|
|
1071
|
+
// `locale` is sent ONLY where it is editable. On a single-locale deployment there is
|
|
1072
|
+
// no control for it, so including it would blind-overwrite whatever the row holds
|
|
1073
|
+
// with mount-time state — reverting an import or another editor's change through a
|
|
1074
|
+
// field this user cannot see. `updatePage` treats an absent key as no-change.
|
|
1075
|
+
const patch = multilingual ? { title, slug: slug.trim(), locale } : { title, slug: slug.trim() };
|
|
1076
|
+
const r = await api.call<{ page?: Page }>("updatePage", { pageId: page.id, ...patch });
|
|
1061
1077
|
if (r?.page) onSaved(r.page);
|
|
1062
1078
|
setOk(true);
|
|
1063
1079
|
setTimeout(() => setOk(false), 1500);
|
|
@@ -1074,7 +1090,18 @@ function PageMeta({ api, page, onSaved, onError }: { api: Api; page: Page; onSav
|
|
|
1074
1090
|
{ok ? <Banner ok>saved</Banner> : null}
|
|
1075
1091
|
<Input label="Title" value={title} onChange={setTitle} />
|
|
1076
1092
|
<Input label="Slug" value={slug} onChange={setSlug} />
|
|
1077
|
-
|
|
1093
|
+
{/* A SELECT over the declared locales, not free text: a typo'd or blank locale saves
|
|
1094
|
+
fine, previews fine (the editor round-trips the same string) and then 404s on the
|
|
1095
|
+
live site, which is the hardest kind of wrong to see. */}
|
|
1096
|
+
{multilingual ? (
|
|
1097
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
1098
|
+
<span className="font-medium text-fg">Locale</span>
|
|
1099
|
+
<select className={CONTROL} value={locale} onChange={(e) => setLocale(e.target.value)}>
|
|
1100
|
+
{locales.includes(locale) ? null : <option value={locale}>{locale || "(unset)"} — not a declared locale</option>}
|
|
1101
|
+
{locales.map((l) => <option key={l} value={l}>{l}</option>)}
|
|
1102
|
+
</select>
|
|
1103
|
+
</label>
|
|
1104
|
+
) : null}
|
|
1078
1105
|
<Button onPress={save} isDisabled={busy || !title.trim() || !slug.trim()}>{busy ? "Saving…" : "Save"}</Button>
|
|
1079
1106
|
<KV><span>Status</span><span>{page.status}</span></KV>
|
|
1080
1107
|
</div>
|
package/src/main.tsx
CHANGED
|
@@ -3,10 +3,20 @@ import { StrictMode } from "react";
|
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
4
|
import { pageRegistry, routes } from "virtual:buzola/routes";
|
|
5
5
|
import { AppProvider } from "./app-context";
|
|
6
|
+
import { DOCUMENT_TITLE } from "./brand";
|
|
6
7
|
|
|
7
8
|
// Styling is podoba: @podoba/tokens/variables.css + the compiled Tailwind (podoba
|
|
8
9
|
// preset) are <link>ed by index.html (see scripts/build.ts). No more inline CSS.
|
|
9
10
|
|
|
11
|
+
// The <title> in index.html is baked at build time, before any host config exists, so it
|
|
12
|
+
// can only be the default. Re-apply the configured wordmark once /config.js has been read —
|
|
13
|
+
// the static tag stays the pre-hydration fallback.
|
|
14
|
+
//
|
|
15
|
+
// `DOCUMENT_TITLE`, not `${BRAND.title} editor`: appending a fixed English noun to the one
|
|
16
|
+
// string this feature exists to hand over would put a foreign word in a rebranded client's
|
|
17
|
+
// tab, and `suffix: null` ("just our name") could never drop it.
|
|
18
|
+
document.title = DOCUMENT_TITLE;
|
|
19
|
+
|
|
10
20
|
const el = document.getElementById("app");
|
|
11
21
|
if (el)
|
|
12
22
|
createRoot(el).render(
|
package/src/routes/_layout.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { Outlet, useNavigate, useRoute } from "@buzola/router";
|
|
|
6
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
|
+
import { BRAND } from "../brand";
|
|
9
10
|
|
|
10
11
|
const THEME_KEY = "pramen.cms.theme";
|
|
11
12
|
|
|
@@ -57,11 +58,11 @@ export default function RootLayout() {
|
|
|
57
58
|
<button
|
|
58
59
|
type="button"
|
|
59
60
|
onClick={guarded(() => navigate("home"))}
|
|
60
|
-
aria-label=
|
|
61
|
+
aria-label={`${BRAND.spoken} — home`}
|
|
61
62
|
className="flex items-baseline gap-1 rounded-md px-1 py-0.5 transition-colors hover:bg-surface-muted"
|
|
62
63
|
>
|
|
63
|
-
<span className="text-callout font-bold tracking-[0.01em] text-fg">
|
|
64
|
-
<span className="text-fg-subtle">·
|
|
64
|
+
<span className="text-callout font-bold tracking-[0.01em] text-fg">{BRAND.name}</span>
|
|
65
|
+
{BRAND.suffix ? <span className="text-fg-subtle">· {BRAND.suffix}</span> : null}
|
|
65
66
|
</button>
|
|
66
67
|
</Topbar.Brand>
|
|
67
68
|
<Topbar.Nav aria-label="Primary">
|
|
@@ -89,8 +90,14 @@ export default function RootLayout() {
|
|
|
89
90
|
</Button>
|
|
90
91
|
{extraNav.map((l) => (
|
|
91
92
|
// Companion tools live OUTSIDE this SPA (a separate static page/worker route), so
|
|
92
|
-
// open them in a new tab.
|
|
93
|
-
//
|
|
93
|
+
// open them in a new tab. NOT a style choice, and `target: "_self"` alone would not
|
|
94
|
+
// fix it: `_404.tsx` registers the catch-all `/:__notFound+`, so buzola matches
|
|
95
|
+
// EVERY same-origin path and intercepts it — a same-tab click and `location.assign`
|
|
96
|
+
// both land on the in-app 404 (verified). Only a cross-origin url escapes on its own.
|
|
97
|
+
//
|
|
98
|
+
// The supported same-tab route is `router.leaveApp(href)` (@buzola/router >= 0.0.16),
|
|
99
|
+
// which releases one navigation to the browser. This package still pins ^0.0.12, so
|
|
100
|
+
// adopting it is a version bump plus a `target` option on the config item.
|
|
94
101
|
<a
|
|
95
102
|
key={l.href}
|
|
96
103
|
href={l.href}
|
package/src/routes/page.tsx
CHANGED
|
@@ -6,14 +6,14 @@ import { createPage, useNavigate } from "@buzola/router";
|
|
|
6
6
|
import { Button } from "@podoba/react";
|
|
7
7
|
import { useEffect, useState } from "react";
|
|
8
8
|
import { useApp } from "../app-context";
|
|
9
|
-
import {
|
|
9
|
+
import { PageEditor, errMsg, visibleTabs, type InspectorTab } from "../components";
|
|
10
10
|
import type { BlockType, Page } from "../types";
|
|
11
11
|
|
|
12
12
|
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, setNavGuard } = useApp();
|
|
16
|
+
const { api, cms, setError, setNavGuard } = useApp();
|
|
17
17
|
const navigate = useNavigate();
|
|
18
18
|
const [page, setPage] = useState<Page | null>(null);
|
|
19
19
|
const [blockTypes, setBlockTypes] = useState<BlockType[]>([]);
|
|
@@ -35,8 +35,17 @@ export default createPage()
|
|
|
35
35
|
return () => { live = false; };
|
|
36
36
|
}, [api, params.pageId, setError]);
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
// The visible set comes from the server (`visibleTabs`), so a deep link to `?tab=i18n`
|
|
39
|
+
// on a single-locale deployment falls back to settings — and the URL is REWRITTEN to
|
|
40
|
+
// match. Rendering one tab under another tab's address means a refresh, a Back, or a
|
|
41
|
+
// shared link all disagree with what is on screen; `setTab` already replaces without
|
|
42
|
+
// adding a history entry, so reconciling costs nothing.
|
|
43
|
+
const shown = visibleTabs(cms.multilingual);
|
|
44
|
+
const tab: InspectorTab = shown.includes(params.tab as InspectorTab) ? (params.tab as InspectorTab) : "settings";
|
|
39
45
|
const setTab = (t: InspectorTab) => navigate("page", { params: { pageId: params.pageId, tab: t }, replace: true });
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (params.tab !== undefined && params.tab !== tab) setTab(tab);
|
|
48
|
+
}, [params.tab, tab]);
|
|
40
49
|
|
|
41
50
|
if (missing) {
|
|
42
51
|
return (
|
package/src/types.ts
CHANGED
|
@@ -134,6 +134,25 @@ export interface CollectionMeta {
|
|
|
134
134
|
supports?: CollectionFeature[];
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/** What the server says this deployment supports (mirror of `listCmsCapabilities`).
|
|
138
|
+
*
|
|
139
|
+
* Server-declared, like a collection's `supports: [...]`: the editor asks what exists
|
|
140
|
+
* rather than being configured to hide things locally, so the chrome and the data cannot
|
|
141
|
+
* disagree. `multilingual` is the derived answer to the question the UI actually asks. */
|
|
142
|
+
export interface CmsCapabilities {
|
|
143
|
+
/** Declared locales, most-preferred first. */
|
|
144
|
+
locales: string[];
|
|
145
|
+
/** The locale a page gets when created without one — `locales[0]`. */
|
|
146
|
+
defaultLocale: string;
|
|
147
|
+
/** More than one declared locale. Gates the whole i18n surface. */
|
|
148
|
+
multilingual: boolean;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Used until `listCmsCapabilities` answers, and when it cannot (an older server). Assumes
|
|
152
|
+
* MONOLINGUAL: a hidden i18n surface on a multilingual site is recoverable by reloading,
|
|
153
|
+
* where a half-rendered one on a single-locale site is what this replaced. */
|
|
154
|
+
export const DEFAULT_CAPABILITIES: CmsCapabilities = { locales: ["en"], defaultLocale: "en", multilingual: false };
|
|
155
|
+
|
|
137
156
|
/** Mirror of @pramen/cms `CollectionFeature`. */
|
|
138
157
|
export type CollectionFeature = "drafts" | "scheduling" | "revisions" | "preview";
|
|
139
158
|
|