@pramen/cms-editor 0.0.47 → 0.0.48
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.qy6fhg00.js} +121 -121
- package/package.json +1 -1
- package/src/components.tsx +1 -4
- package/src/fields.tsx +114 -3
- package/src/types.ts +4 -0
package/package.json
CHANGED
package/src/components.tsx
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
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
11
|
import type { AssembledPage, AuditEntry, BlockType, CollectionMeta, ContentType, FieldDefinition, Media, Page, RegionDefinition, RenderedBlock } from "./types";
|
|
@@ -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 {
|
package/src/fields.tsx
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
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
8
|
import type { FieldDefinition, Media } from "./types";
|
|
9
9
|
|
|
@@ -152,7 +152,8 @@ export function FieldForm({ schema, value, onChange, api }: { schema: FieldDefin
|
|
|
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,7 +171,7 @@ 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: unknown; onChange: (v: unknown) => void; api: Api; hideLabelAs?: string }) {
|
|
174
|
+
function FieldInput({ def, value, onChange, api, hideLabelAs, siblings }: { def: FieldDefinition; value: unknown; onChange: (v: unknown) => void; api: Api; hideLabelAs?: string; siblings?: Record<string, unknown> }) {
|
|
174
175
|
const label: ReactNode = hideLabelAs !== undefined ? undefined : (
|
|
175
176
|
<>
|
|
176
177
|
{def.label ?? def.name} {def.required ? <span className="text-danger">*</span> : null}
|
|
@@ -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
|
|
@@ -431,6 +442,106 @@ 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.
|
package/src/types.ts
CHANGED
|
@@ -13,6 +13,8 @@ export type FieldType =
|
|
|
13
13
|
| "datetime"
|
|
14
14
|
/** A publication timestamp — rendered as publish-now / schedule / unpublish. */
|
|
15
15
|
| "publish"
|
|
16
|
+
/** A URL segment, derived from the field named by `from` while it is untouched. */
|
|
17
|
+
| "slug"
|
|
16
18
|
| "media"
|
|
17
19
|
| "select"
|
|
18
20
|
| "repeater"
|
|
@@ -32,6 +34,8 @@ export interface FieldDefinition {
|
|
|
32
34
|
* return `{ value, label }[]`. Lets a select offer live data (e.g. existing campaigns)
|
|
33
35
|
* instead of a static list. Takes precedence over `options`. */
|
|
34
36
|
optionsFrom?: string;
|
|
37
|
+
/** For `slug`: the sibling field this one is derived from (e.g. `"title"`). */
|
|
38
|
+
from?: string;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
export interface RegionDefinition {
|