@pramen/cms-editor 0.0.47 → 0.0.49
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/index.html +1 -1
- package/dist/{main.t7eeyc52.js → main.ykztb0px.js} +121 -121
- package/package.json +1 -1
- package/src/api.ts +8 -6
- package/src/app-context.tsx +2 -2
- package/src/components.tsx +17 -20
- package/src/fields.tsx +124 -13
- package/src/types.ts +27 -5
package/package.json
CHANGED
package/src/api.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// same transport shape as @pramen/admin's api.ts. Config is persisted in localStorage.
|
|
3
3
|
|
|
4
4
|
import type { AssembledPage, AuditEntry, BlockType, ContentType, Media, Page } from "./types";
|
|
5
|
+
import type { RpcInput } from "./types";
|
|
5
6
|
|
|
6
7
|
export interface Config {
|
|
7
8
|
baseUrl: string;
|
|
@@ -72,20 +73,21 @@ export class Api {
|
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
/** Call a CMS RPC handler. Throws ApiError on a non-`ok` envelope. */
|
|
75
|
-
async call<T = unknown>(name: string, input?:
|
|
76
|
+
async call<T = unknown>(name: string, input?: RpcInput): Promise<T> {
|
|
76
77
|
// Expired token: hand off to sign-in instead of firing a request that will 403 into an
|
|
77
78
|
// error banner. The returned promise never settles — navigation is already underway.
|
|
78
79
|
if (this.onExpired && this.cfg.token && isTokenExpired(this.cfg.token)) {
|
|
79
80
|
this.onExpired();
|
|
80
81
|
return new Promise<T>(() => {});
|
|
81
82
|
}
|
|
83
|
+
const headers = new Headers({
|
|
84
|
+
"content-type": "application/json",
|
|
85
|
+
"x-pramen-tenant": this.cfg.tenant || "main",
|
|
86
|
+
});
|
|
87
|
+
if (this.cfg.token) headers.set("authorization", `Bearer ${this.cfg.token}`);
|
|
82
88
|
const res = await fetch(`${this.base()}/rpc/${name}`, {
|
|
83
89
|
method: "POST",
|
|
84
|
-
headers
|
|
85
|
-
"content-type": "application/json",
|
|
86
|
-
"x-pramen-tenant": this.cfg.tenant || "main",
|
|
87
|
-
...(this.cfg.token ? { authorization: `Bearer ${this.cfg.token}` } : {}),
|
|
88
|
-
},
|
|
90
|
+
headers,
|
|
89
91
|
body: JSON.stringify(input ?? {}),
|
|
90
92
|
});
|
|
91
93
|
let body: { ok?: boolean; result?: unknown; error?: string; code?: string };
|
package/src/app-context.tsx
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
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 type { CollectionMeta } from "./types";
|
|
9
|
+
import type { CollectionMeta, JsonValue } from "./types";
|
|
10
10
|
|
|
11
11
|
declare global {
|
|
12
12
|
interface Window {
|
|
@@ -42,7 +42,7 @@ function redirectToSignIn(): void {
|
|
|
42
42
|
export interface Me {
|
|
43
43
|
userId?: string;
|
|
44
44
|
roles?: string[];
|
|
45
|
-
[k: string]:
|
|
45
|
+
[k: string]: JsonValue | undefined;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
interface AppContextValue {
|
package/src/components.tsx
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
import { Button, Heading, Input, ModalDialog, ModalOverlay, ModalSurface, Textarea } from "@podoba/react";
|
|
6
6
|
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
7
7
|
import { Api, ApiError } from "./api";
|
|
8
|
-
import { FieldForm } from "./fields";
|
|
8
|
+
import { FieldForm, slugify } from "./fields";
|
|
9
9
|
import type { Config } from "./api";
|
|
10
10
|
import type { Me } from "./app-context";
|
|
11
|
-
import type { AssembledPage, AuditEntry, BlockType, CollectionMeta, ContentType, FieldDefinition, Media, Page, RegionDefinition, RenderedBlock } from "./types";
|
|
11
|
+
import type { AssembledPage, AuditEntry, BlockType, CollectionMeta, ContentType, FieldDefinition, FieldValues, Media, Page, RegionDefinition, RenderedBlock } from "./types";
|
|
12
12
|
|
|
13
13
|
export type InspectorTab = "settings" | "seo" | "workflow" | "i18n" | "audit";
|
|
14
14
|
export const INSPECTOR_TABS: InspectorTab[] = ["settings", "seo", "workflow", "i18n", "audit"];
|
|
@@ -201,7 +201,7 @@ function cellText(v: unknown): string {
|
|
|
201
201
|
const COLLECTION_PAGE_SIZE = 50;
|
|
202
202
|
|
|
203
203
|
export function CollectionList({ api, def, onOpen, onNew, onError }: { api: Api; def: CollectionMeta; onOpen: (id: string) => void; onNew: () => void; onError: (s: string) => void }) {
|
|
204
|
-
const [rows, setRows] = useState<
|
|
204
|
+
const [rows, setRows] = useState<FieldValues[]>([]);
|
|
205
205
|
const [offset, setOffset] = useState(0);
|
|
206
206
|
const [hasMore, setHasMore] = useState(false);
|
|
207
207
|
const [loading, setLoading] = useState(true);
|
|
@@ -210,7 +210,7 @@ export function CollectionList({ api, def, onOpen, onNew, onError }: { api: Api;
|
|
|
210
210
|
(off: number) => {
|
|
211
211
|
setLoading(true);
|
|
212
212
|
return api
|
|
213
|
-
.call<
|
|
213
|
+
.call<FieldValues[]>("collectionList", { collection: def.slug, limit: COLLECTION_PAGE_SIZE, offset: off })
|
|
214
214
|
.then((r) => {
|
|
215
215
|
setRows((prev) => (off === 0 ? r : [...prev, ...r]));
|
|
216
216
|
// A full page means there is probably more; a short one is definitely the end.
|
|
@@ -267,7 +267,7 @@ export function CollectionList({ api, def, onOpen, onNew, onError }: { api: Api;
|
|
|
267
267
|
|
|
268
268
|
export function CollectionEditor({ api, def, id, onSaved, onDeleted, onBack, onError }: { api: Api; def: CollectionMeta; id: string | null; onSaved: () => void; onDeleted: () => void; onBack: () => void; onError: (s: string) => void }) {
|
|
269
269
|
const isNew = id === null;
|
|
270
|
-
const [values, setValues] = useState<
|
|
270
|
+
const [values, setValues] = useState<FieldValues>({});
|
|
271
271
|
const [loading, setLoading] = useState(!isNew);
|
|
272
272
|
const [missing, setMissing] = useState(false);
|
|
273
273
|
const [busy, setBusy] = useState(false);
|
|
@@ -278,7 +278,7 @@ export function CollectionEditor({ api, def, id, onSaved, onDeleted, onBack, onE
|
|
|
278
278
|
let live = true;
|
|
279
279
|
setLoading(true);
|
|
280
280
|
setMissing(false);
|
|
281
|
-
api.call<
|
|
281
|
+
api.call<FieldValues | null>("collectionGet", { collection: def.slug, id })
|
|
282
282
|
.then((row) => { if (!live) return; if (row) setValues(row); else setMissing(true); })
|
|
283
283
|
.catch((e) => onError(errMsg(e)))
|
|
284
284
|
.finally(() => { if (live) setLoading(false); });
|
|
@@ -296,7 +296,7 @@ export function CollectionEditor({ api, def, id, onSaved, onDeleted, onBack, onE
|
|
|
296
296
|
} else {
|
|
297
297
|
// Editing: stay on the form. Reflect the persisted row the update echoes back (server
|
|
298
298
|
// defaults / normalization applied) and flash a confirmation instead of navigating.
|
|
299
|
-
const updated = await api.call<
|
|
299
|
+
const updated = await api.call<FieldValues>("collectionUpdate", { collection: def.slug, id, values });
|
|
300
300
|
if (updated) setValues(updated);
|
|
301
301
|
setOk(true);
|
|
302
302
|
setTimeout(() => setOk(false), 1200);
|
|
@@ -427,7 +427,7 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
|
|
|
427
427
|
});
|
|
428
428
|
try {
|
|
429
429
|
const { block, placement } = await api.call<{
|
|
430
|
-
block: { id: string; title?: string | null; fields?:
|
|
430
|
+
block: { id: string; title?: string | null; fields?: FieldValues | null };
|
|
431
431
|
placement: { id: string; isShared?: boolean | number };
|
|
432
432
|
}>("addBlock", { pageId: page.id, blockTypeSlug: slug, region, fields: {} });
|
|
433
433
|
const rb: RenderedBlock = {
|
|
@@ -470,7 +470,7 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
|
|
|
470
470
|
|
|
471
471
|
// Patch a block's raw fields into local state after an inline save — keeps the collapsed
|
|
472
472
|
// preview fresh without a full reload (which would remount every editor + lose caret/focus).
|
|
473
|
-
const patchBlockFields = useCallback((placementId: string, fields:
|
|
473
|
+
const patchBlockFields = useCallback((placementId: string, fields: FieldValues) => {
|
|
474
474
|
setAssembled((prev) => {
|
|
475
475
|
if (!prev) return prev;
|
|
476
476
|
const next: Record<string, RenderedBlock[]> = {};
|
|
@@ -539,7 +539,7 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
|
|
|
539
539
|
not in the inspector. For a content type with no regions (a fixed layout, all
|
|
540
540
|
of it page fields) this is the entire editor; the canvas is never empty. */}
|
|
541
541
|
{pageSchema.length ? (
|
|
542
|
-
<PageFields api={api} page={page} schema={pageSchema} initialFields={(assembled?.page.fields as
|
|
542
|
+
<PageFields api={api} page={page} schema={pageSchema} initialFields={(assembled?.page.fields as FieldValues) ?? {}} onDirtyChange={reportDirty} onError={setErr} />
|
|
543
543
|
) : null}
|
|
544
544
|
{regions.map((r) => {
|
|
545
545
|
const blocks = assembled?.regions[r.name] ?? [];
|
|
@@ -611,7 +611,7 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
|
|
|
611
611
|
isLast: boolean;
|
|
612
612
|
onMove: (dir: number) => void;
|
|
613
613
|
onRemove: () => void;
|
|
614
|
-
onPatch: (placementId: string, fields:
|
|
614
|
+
onPatch: (placementId: string, fields: FieldValues) => void;
|
|
615
615
|
onDirtyChange: (placementId: string, dirty: boolean) => void;
|
|
616
616
|
onError: (s: string) => void;
|
|
617
617
|
dragging: boolean;
|
|
@@ -621,7 +621,7 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
|
|
|
621
621
|
onDropBlock: () => void;
|
|
622
622
|
onDragEndBlock: () => void;
|
|
623
623
|
}) {
|
|
624
|
-
const [fields, setFields] = useState<
|
|
624
|
+
const [fields, setFields] = useState<FieldValues | null>(null);
|
|
625
625
|
const [collapsed, setCollapsed] = useState(false);
|
|
626
626
|
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved">("idle");
|
|
627
627
|
const saved = useRef<string>(""); // JSON of the last-persisted fields — the dirty baseline
|
|
@@ -635,7 +635,7 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
|
|
|
635
635
|
useEffect(() => {
|
|
636
636
|
if (block.pending) { setFields({}); saved.current = "{}"; return; }
|
|
637
637
|
let alive = true;
|
|
638
|
-
api.call<{ fields?:
|
|
638
|
+
api.call<{ fields?: FieldValues }>("getBlock", { blockId }).then((b) => {
|
|
639
639
|
if (!alive) return;
|
|
640
640
|
const f = b?.fields ?? {};
|
|
641
641
|
setFields(f);
|
|
@@ -680,7 +680,7 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
|
|
|
680
680
|
return () => clearTimeout(t);
|
|
681
681
|
}, [fields, block.pending]);
|
|
682
682
|
|
|
683
|
-
const change = (next:
|
|
683
|
+
const change = (next: FieldValues) => setFields(next);
|
|
684
684
|
|
|
685
685
|
// Report dirty state up (for the editor's leave/unload guard); clear it on unmount so a
|
|
686
686
|
// removed block never leaves a stale "unsaved" flag behind.
|
|
@@ -891,8 +891,8 @@ function PageMeta({ api, page, onSaved, onError }: { api: Api; page: Page; onSav
|
|
|
891
891
|
}
|
|
892
892
|
|
|
893
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:
|
|
895
|
-
const [fields, setFields] = useState<
|
|
894
|
+
function PageFields({ api, page, schema, initialFields, onDirtyChange, onError }: { api: Api; page: Page; schema: FieldDefinition[]; initialFields: FieldValues; onDirtyChange: (id: string, dirty: boolean) => void; onError: (s: string) => void }) {
|
|
895
|
+
const [fields, setFields] = useState<FieldValues>(initialFields);
|
|
896
896
|
const [ok, setOk] = useState(false);
|
|
897
897
|
const [busy, setBusy] = useState(false);
|
|
898
898
|
|
|
@@ -1467,9 +1467,6 @@ export function errMsg(e: unknown): string {
|
|
|
1467
1467
|
if (e instanceof ApiError) return e.message;
|
|
1468
1468
|
return e instanceof Error ? e.message : String(e);
|
|
1469
1469
|
}
|
|
1470
|
-
function slugify(s: string): string {
|
|
1471
|
-
return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1472
|
-
}
|
|
1473
1470
|
/** Strip HTML tags + decode the few entities the WYSIWYG emits, for a clean text preview —
|
|
1474
1471
|
* so a collapsed rich_text block reads "Test Toakdopwad" instead of "<b>Test</b> …". */
|
|
1475
1472
|
function plainText(html: string): string {
|
|
@@ -1483,7 +1480,7 @@ function plainText(html: string): string {
|
|
|
1483
1480
|
.trim();
|
|
1484
1481
|
}
|
|
1485
1482
|
/** One-line preview for a collapsed block: the first non-empty string field, tags stripped. */
|
|
1486
|
-
function blockPreview(fields:
|
|
1483
|
+
function blockPreview(fields: FieldValues): string {
|
|
1487
1484
|
const first = Object.values(fields).find((v) => typeof v === "string" && v.trim());
|
|
1488
1485
|
if (typeof first !== "string") return "";
|
|
1489
1486
|
const text = plainText(first);
|
package/src/fields.tsx
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
import { Button, Heading, Input, ModalDialog, ModalOverlay, ModalSurface, Text, Textarea } from "@podoba/react";
|
|
5
5
|
import { BlockEditor } from "@podoba/react/editor";
|
|
6
|
-
import { useEffect, useState, type DragEvent, type ReactNode } from "react";
|
|
6
|
+
import { useEffect, useRef, useState, type DragEvent, type ReactNode } from "react";
|
|
7
7
|
import type { Api } from "./api";
|
|
8
|
-
import type { FieldDefinition, Media } from "./types";
|
|
8
|
+
import type { FieldDefinition, FieldValue, FieldValues, Media } from "./types";
|
|
9
9
|
|
|
10
10
|
// Tokenized bare control (podoba's filled-field skin) for the native inputs that
|
|
11
11
|
// don't map cleanly onto a podoba primitive (number/date/select/file).
|
|
@@ -147,12 +147,13 @@ function PublishControl({ value, onChange }: { value: string; onChange: (v: stri
|
|
|
147
147
|
);
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
export function FieldForm({ schema, value, onChange, api }: { schema: FieldDefinition[]; value:
|
|
151
|
-
const set = (name: string, v:
|
|
150
|
+
export function FieldForm({ schema, value, onChange, api }: { schema: FieldDefinition[]; value: FieldValues; onChange: (v: FieldValues) => void; api: Api }) {
|
|
151
|
+
const set = (name: string, v: FieldValue) => onChange({ ...value, [name]: v });
|
|
152
152
|
return (
|
|
153
153
|
<div className="flex flex-col gap-4">
|
|
154
154
|
{schema.map((def) => (
|
|
155
|
-
|
|
155
|
+
// `siblings` is only read by field types that derive from another field (slug).
|
|
156
|
+
<FieldInput key={def.name} def={def} value={value[def.name]} onChange={(v) => set(def.name, v)} api={api} siblings={value} />
|
|
156
157
|
))}
|
|
157
158
|
</div>
|
|
158
159
|
);
|
|
@@ -170,13 +171,13 @@ export function FieldForm({ schema, value, onChange, api }: { schema: FieldDefin
|
|
|
170
171
|
* accessible-name computation via `aria-labelledby`. Those three go through the bare
|
|
171
172
|
* `CONTROL` skin instead, the same way `number`/`date` already do.
|
|
172
173
|
*/
|
|
173
|
-
function FieldInput({ def, value, onChange, api, hideLabelAs }: { def: FieldDefinition; value:
|
|
174
|
+
function FieldInput({ def, value, onChange, api, hideLabelAs, siblings }: { def: FieldDefinition; value: FieldValue; onChange: (v: FieldValue) => void; api: Api; hideLabelAs?: string; siblings?: FieldValues }) {
|
|
174
175
|
const label: ReactNode = hideLabelAs !== undefined ? undefined : (
|
|
175
176
|
<>
|
|
176
177
|
{def.label ?? def.name} {def.required ? <span className="text-danger">*</span> : null}
|
|
177
178
|
</>
|
|
178
179
|
);
|
|
179
|
-
const asText = (v:
|
|
180
|
+
const asText = (v: FieldValue) => (typeof v === "string" ? v : v == null ? "" : JSON.stringify(v));
|
|
180
181
|
switch (def.type) {
|
|
181
182
|
case "text":
|
|
182
183
|
case "url": {
|
|
@@ -214,6 +215,16 @@ function FieldInput({ def, value, onChange, api, hideLabelAs }: { def: FieldDefi
|
|
|
214
215
|
<input className={CONTROL} type={def.type === "date" ? "date" : "datetime-local"} aria-label={hideLabelAs} value={(value as string) ?? ""} onChange={(e) => onChange(e.target.value || null)} />
|
|
215
216
|
</FieldShell>
|
|
216
217
|
);
|
|
218
|
+
case "slug":
|
|
219
|
+
return (
|
|
220
|
+
<SlugField
|
|
221
|
+
def={def}
|
|
222
|
+
label={label}
|
|
223
|
+
value={typeof value === "string" ? value : ""}
|
|
224
|
+
source={typeof siblings?.[def.from ?? ""] === "string" ? (siblings[def.from ?? ""] as string) : ""}
|
|
225
|
+
onChange={onChange as (v: string) => void}
|
|
226
|
+
/>
|
|
227
|
+
);
|
|
217
228
|
case "publish":
|
|
218
229
|
// Deliberately NOT FieldShell: it wraps children in a <label>, whose implicit
|
|
219
230
|
// control would be PublishControl's first <button> ("Publish now") — clicking the
|
|
@@ -247,12 +258,12 @@ function FieldInput({ def, value, onChange, api, hideLabelAs }: { def: FieldDefi
|
|
|
247
258
|
return (
|
|
248
259
|
<FieldShell label={label}>
|
|
249
260
|
<div className="rounded-lg border border-border bg-surface-muted p-3.5">
|
|
250
|
-
<FieldForm schema={def.fields ?? []} value={(value as
|
|
261
|
+
<FieldForm schema={def.fields ?? []} value={(value as FieldValues) ?? {}} onChange={onChange as (v: FieldValues) => void} api={api} />
|
|
251
262
|
</div>
|
|
252
263
|
</FieldShell>
|
|
253
264
|
);
|
|
254
265
|
case "repeater":
|
|
255
|
-
return <Repeater def={def} value={(value as
|
|
266
|
+
return <Repeater def={def} value={(value as FieldValues[]) ?? []} onChange={onChange as (v: FieldValues[]) => void} api={api} label={label} />;
|
|
256
267
|
default:
|
|
257
268
|
return null;
|
|
258
269
|
}
|
|
@@ -281,7 +292,7 @@ export function RichText({ value, onChange }: { value: string; onChange: (v: str
|
|
|
281
292
|
* Reordering is drag-and-drop from the ⠿ handle, mirroring the block canvas. ↑/↓ stay,
|
|
282
293
|
* because dragging is unavailable to keyboard users and awkward on touch.
|
|
283
294
|
*/
|
|
284
|
-
function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition; value:
|
|
295
|
+
function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition; value: FieldValues[]; onChange: (v: FieldValues[]) => void; api: Api; label: ReactNode }) {
|
|
285
296
|
const items = Array.isArray(value) ? value : [];
|
|
286
297
|
const fields = def.fields ?? [];
|
|
287
298
|
// One field, and not itself a tall control — the case where the card is pure overhead.
|
|
@@ -289,7 +300,7 @@ function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition;
|
|
|
289
300
|
|
|
290
301
|
const [drag, setDrag] = useState<{ from: number; over: number } | null>(null);
|
|
291
302
|
|
|
292
|
-
const upd = (i: number, v:
|
|
303
|
+
const upd = (i: number, v: FieldValues) => onChange(items.map((it, j) => (j === i ? v : it)));
|
|
293
304
|
const add = () => onChange([...items, {}]);
|
|
294
305
|
const del = (i: number) => onChange(items.filter((_, j) => j !== i));
|
|
295
306
|
const moveTo = (from: number, to: number) => {
|
|
@@ -311,7 +322,7 @@ function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition;
|
|
|
311
322
|
* instead titled every slide with the uuid of its image, which is worse than no summary
|
|
312
323
|
* at all — the point is to tell two rows apart at a glance.
|
|
313
324
|
*/
|
|
314
|
-
const summarise = (it:
|
|
325
|
+
const summarise = (it: FieldValues): string => {
|
|
315
326
|
const readable = ["text", "textarea", "richtext", "select", "url"];
|
|
316
327
|
for (const f of fields) {
|
|
317
328
|
if (!readable.includes(f.type)) continue;
|
|
@@ -431,10 +442,110 @@ function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition;
|
|
|
431
442
|
);
|
|
432
443
|
}
|
|
433
444
|
|
|
445
|
+
/**
|
|
446
|
+
* Turn a title into a URL segment.
|
|
447
|
+
*
|
|
448
|
+
* Diacritics are decomposed and their marks dropped, so Czech text transliterates the way
|
|
449
|
+
* a reader expects — "Vrátnice výrobního závodu" becomes "vratnice-vyrobniho-zavodu"
|
|
450
|
+
* rather than losing the accented letters entirely.
|
|
451
|
+
*/
|
|
452
|
+
export function slugify(input: string): string {
|
|
453
|
+
// Trailing trim AFTER the length cap — slicing a hyphen-terminated prefix out of a long
|
|
454
|
+
// title would otherwise leave the slug ending in "-".
|
|
455
|
+
return slugifyInput(input).replace(/-+$/, "");
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Per-keystroke normalization: everything `slugify` does EXCEPT the trailing-hyphen trim.
|
|
460
|
+
*
|
|
461
|
+
* The input is controlled, so anything this strips can never be typed at all — trimming the
|
|
462
|
+
* trailing "-" here would make a separator unenterable (type "my-", `slugify` hands back
|
|
463
|
+
* "my", the value prop never changes, React restores the DOM). The full `slugify` runs on
|
|
464
|
+
* blur instead, once the word is finished.
|
|
465
|
+
*/
|
|
466
|
+
function slugifyInput(input: string): string {
|
|
467
|
+
return input
|
|
468
|
+
.normalize("NFD")
|
|
469
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
470
|
+
.toLowerCase()
|
|
471
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
472
|
+
.replace(/-{2,}/g, "-")
|
|
473
|
+
.replace(/^-+/, "")
|
|
474
|
+
.slice(0, 80);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* A slug that follows another field while it is untouched.
|
|
479
|
+
*
|
|
480
|
+
* It deliberately stops following the moment the value differs from what it last derived
|
|
481
|
+
* — which includes every row that already has a slug. Renaming a published project must
|
|
482
|
+
* not silently change its URL and break every link to it; "Generate from …" is there for
|
|
483
|
+
* when that IS what you want.
|
|
484
|
+
*/
|
|
485
|
+
function SlugField({ def, label, value, source, onChange }: { def: FieldDefinition; label: ReactNode; value: string; source: string; onChange: (v: string) => void }) {
|
|
486
|
+
// What this control last wrote. While the field still holds it, the field is "untouched"
|
|
487
|
+
// and free to follow; anything else means a human typed it.
|
|
488
|
+
const derived = useRef<string | null>(null);
|
|
489
|
+
// Whether a human has edited this field. Once true it stays true for the session —
|
|
490
|
+
// emptying the field is an edit, not an invitation to start following again.
|
|
491
|
+
const touched = useRef(false);
|
|
492
|
+
// The source we last saw. Seeded on the first run so merely OPENING a row never derives:
|
|
493
|
+
// a row saved before this field existed has a filled title and an empty slug, and an
|
|
494
|
+
// onChange there marks the block dirty and autosaves something nobody asked for. Only a
|
|
495
|
+
// real change to the source field derives; "Generate from …" covers the rest.
|
|
496
|
+
const lastSource = useRef<string | null>(null);
|
|
497
|
+
|
|
498
|
+
useEffect(() => {
|
|
499
|
+
if (!def.from) return;
|
|
500
|
+
const prev = lastSource.current;
|
|
501
|
+
lastSource.current = source;
|
|
502
|
+
if (prev === null || prev === source || !source) return;
|
|
503
|
+
if (touched.current) return;
|
|
504
|
+
// Follow only an empty field or one still holding our own last derivation — an existing
|
|
505
|
+
// slug is a live URL, and renaming its page must not break every link to it.
|
|
506
|
+
if (value !== "" && value !== derived.current) return;
|
|
507
|
+
const next = slugify(source);
|
|
508
|
+
if (next === value) return;
|
|
509
|
+
derived.current = next;
|
|
510
|
+
onChange(next);
|
|
511
|
+
}, [source, value, def.from, onChange]);
|
|
512
|
+
|
|
513
|
+
const suggestion = source ? slugify(source) : "";
|
|
514
|
+
const canGenerate = Boolean(suggestion) && suggestion !== value;
|
|
515
|
+
|
|
516
|
+
return (
|
|
517
|
+
<FieldShell label={label}>
|
|
518
|
+
<input
|
|
519
|
+
className={CONTROL}
|
|
520
|
+
value={value}
|
|
521
|
+
onChange={(e) => {
|
|
522
|
+
touched.current = true; // typed by hand — stop following from here on
|
|
523
|
+
derived.current = null;
|
|
524
|
+
onChange(slugifyInput(e.target.value));
|
|
525
|
+
}}
|
|
526
|
+
onBlur={() => {
|
|
527
|
+
const clean = slugify(value);
|
|
528
|
+
if (clean !== value) onChange(clean);
|
|
529
|
+
}}
|
|
530
|
+
placeholder={suggestion}
|
|
531
|
+
/>
|
|
532
|
+
{canGenerate ? (
|
|
533
|
+
<button
|
|
534
|
+
type="button"
|
|
535
|
+
className="self-start text-caption text-fg-subtle underline hover:text-fg"
|
|
536
|
+
onClick={() => { derived.current = suggestion; touched.current = false; onChange(suggestion); }}
|
|
537
|
+
>
|
|
538
|
+
Generate from {def.from}: {suggestion}
|
|
539
|
+
</button>
|
|
540
|
+
) : null}
|
|
541
|
+
</FieldShell>
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
434
545
|
// A `select` field. Static `options` render as-is; when `optionsFrom` is set, the options are
|
|
435
546
|
// fetched once from that query handler (returns `{ value, label }[]`) — e.g. a live list of
|
|
436
547
|
// campaigns — so the editor never has to hardcode or copy identifiers by hand.
|
|
437
|
-
function SelectField({ def, value, onChange, api, ariaLabel }: { def: FieldDefinition; value: string | null; onChange: (v:
|
|
548
|
+
function SelectField({ def, value, onChange, api, ariaLabel }: { def: FieldDefinition; value: string | null; onChange: (v: FieldValue) => void; api: Api; ariaLabel?: string }) {
|
|
438
549
|
const [dyn, setDyn] = useState<{ value: string; label: string }[] | null>(null);
|
|
439
550
|
const from = def.optionsFrom;
|
|
440
551
|
useEffect(() => {
|
package/src/types.ts
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
// @pramen/cms) so the editor stays a self-contained browser app with no server-package
|
|
3
3
|
// dependency — it speaks to the CMS purely over HTTP.
|
|
4
4
|
|
|
5
|
+
/** Any JSON value — the wire form of everything the CMS stores. */
|
|
6
|
+
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
7
|
+
|
|
8
|
+
/** One authored field value. Mirrors `FieldValue` in @pramen/cms: a `"media"` field
|
|
9
|
+
* arrives resolved to a `Media`, and `group`/`repeater` fields nest further bags. */
|
|
10
|
+
export type FieldValue = JsonValue | Media | FieldValues | FieldValue[];
|
|
11
|
+
|
|
12
|
+
/** A block / collection / page `fields` bag — field name -> authored value. */
|
|
13
|
+
export interface FieldValues {
|
|
14
|
+
[field: string]: FieldValue;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The JSON body of an editor RPC call: an object of values, any of which may be
|
|
18
|
+
* omitted (an absent key is simply not sent). */
|
|
19
|
+
export interface RpcInput {
|
|
20
|
+
[key: string]: FieldValue | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
5
23
|
export type FieldType =
|
|
6
24
|
| "text"
|
|
7
25
|
| "textarea"
|
|
@@ -13,6 +31,8 @@ export type FieldType =
|
|
|
13
31
|
| "datetime"
|
|
14
32
|
/** A publication timestamp — rendered as publish-now / schedule / unpublish. */
|
|
15
33
|
| "publish"
|
|
34
|
+
/** A URL segment, derived from the field named by `from` while it is untouched. */
|
|
35
|
+
| "slug"
|
|
16
36
|
| "media"
|
|
17
37
|
| "select"
|
|
18
38
|
| "repeater"
|
|
@@ -23,7 +43,7 @@ export interface FieldDefinition {
|
|
|
23
43
|
label?: string;
|
|
24
44
|
type: FieldType;
|
|
25
45
|
required?: boolean;
|
|
26
|
-
default?:
|
|
46
|
+
default?: FieldValue;
|
|
27
47
|
fields?: FieldDefinition[];
|
|
28
48
|
min?: number;
|
|
29
49
|
max?: number;
|
|
@@ -32,6 +52,8 @@ export interface FieldDefinition {
|
|
|
32
52
|
* return `{ value, label }[]`. Lets a select offer live data (e.g. existing campaigns)
|
|
33
53
|
* instead of a static list. Takes precedence over `options`. */
|
|
34
54
|
optionsFrom?: string;
|
|
55
|
+
/** For `slug`: the sibling field this one is derived from (e.g. `"title"`). */
|
|
56
|
+
from?: string;
|
|
35
57
|
}
|
|
36
58
|
|
|
37
59
|
export interface RegionDefinition {
|
|
@@ -43,7 +65,7 @@ export interface RegionDefinition {
|
|
|
43
65
|
export interface DefaultBlockDefinition {
|
|
44
66
|
region: string;
|
|
45
67
|
blockTypeSlug: string;
|
|
46
|
-
fields?:
|
|
68
|
+
fields?: FieldValues;
|
|
47
69
|
}
|
|
48
70
|
|
|
49
71
|
export interface BlockType {
|
|
@@ -90,7 +112,7 @@ export interface Page {
|
|
|
90
112
|
slug: string;
|
|
91
113
|
status: string;
|
|
92
114
|
locale: string;
|
|
93
|
-
fields?:
|
|
115
|
+
fields?: FieldValues | null; // content-type-level structured data (fieldsSchema)
|
|
94
116
|
translationGroupId?: string | null;
|
|
95
117
|
metaTitle?: string | null;
|
|
96
118
|
metaDescription?: string | null;
|
|
@@ -118,13 +140,13 @@ export interface RenderedBlock {
|
|
|
118
140
|
block_id: string; // block instance id (edit)
|
|
119
141
|
block_type: string;
|
|
120
142
|
title: string | null;
|
|
121
|
-
fields:
|
|
143
|
+
fields: FieldValues;
|
|
122
144
|
is_shared: boolean;
|
|
123
145
|
pending?: boolean; // optimistic placeholder — not yet persisted (temp ids, no getBlock)
|
|
124
146
|
}
|
|
125
147
|
|
|
126
148
|
export interface AssembledPage {
|
|
127
|
-
page: Page & { translations?: { locale: string; slug: string }[]; seo?:
|
|
149
|
+
page: Page & { translations?: { locale: string; slug: string }[]; seo?: FieldValues };
|
|
128
150
|
regions: Record<string, RenderedBlock[]>;
|
|
129
151
|
}
|
|
130
152
|
|