@pramen/cms-editor 0.0.46 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/cms-editor",
3
- "version": "0.0.46",
3
+ "version": "0.0.48",
4
4
  "description": "Visual block/page editor for @pramen/cms — a standalone React SPA that talks to the CMS handlers over HTTP.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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>&nbsp;…". */
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 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
 
@@ -14,35 +14,186 @@ const CONTROL = "h-10 w-full rounded-lg border border-border bg-surface-card px-
14
14
  function FieldShell({ label, children }: { label: ReactNode; children: ReactNode }) {
15
15
  return (
16
16
  <label className="flex w-full flex-col gap-2">
17
- <span className="text-sm font-medium text-fg">{label}</span>
17
+ {/* No label element at all when there is no label — an empty one still occupies a
18
+ row and, with a required marker, showed a stray asterisk above the control. */}
19
+ {label === undefined ? null : <span className="text-sm font-medium text-fg">{label}</span>}
18
20
  {children}
19
21
  </label>
20
22
  );
21
23
  }
22
24
 
25
+ /**
26
+ * `YYYY-MM-DDTHH:MM` in LOCAL time — what <input type="datetime-local"> shows and expects.
27
+ *
28
+ * It is only ever the DISPLAY format. The stored value is always a UTC ISO string,
29
+ * because the server decides "is this published yet?" by comparing against its own clock:
30
+ * storing an editor's wall-clock time made "Publish now" in UTC+2 look two hours in the
31
+ * future, so a row published this way stayed invisible until the clock caught up.
32
+ *
33
+ * Returns "" for anything Date can't parse — a legacy or hand-written column value must
34
+ * not reach the input as `NaN-NaN-NaNTNaN:NaN`, which the browser silently discards.
35
+ */
36
+ function toLocalInput(value: string): string {
37
+ const d = new Date(value);
38
+ if (Number.isNaN(d.getTime())) return "";
39
+ const pad = (n: number) => String(n).padStart(2, "0");
40
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
41
+ }
42
+
43
+ /** A `datetime-local` string (local wall clock) -> the UTC ISO instant we store. */
44
+ function fromLocalInput(local: string): string | null {
45
+ const at = new Date(local);
46
+ return Number.isNaN(at.getTime()) ? null : at.toISOString();
47
+ }
48
+
49
+ function formatWhen(value: string): string {
50
+ const at = new Date(value);
51
+ return Number.isNaN(at.getTime()) ? value : at.toLocaleString();
52
+ }
53
+
54
+ /**
55
+ * Re-render once the row crosses from "scheduled" to "published".
56
+ *
57
+ * `scheduled` is derived from the clock, so without this a form left open across the
58
+ * scheduled instant keeps claiming "Scheduled for 14:00" well after 14:00. The timeout is
59
+ * clamped to the 32-bit setTimeout ceiling — a longer delay overflows and fires
60
+ * immediately — and `tick` is a dependency so a clamped wait re-arms instead of giving up.
61
+ */
62
+ function useTickAt(at: number | null): void {
63
+ const [tick, setTick] = useState(0);
64
+ useEffect(() => {
65
+ if (at === null) return;
66
+ const ms = at - Date.now();
67
+ if (ms <= 0) return;
68
+ const timer = setTimeout(() => setTick((n) => n + 1), Math.min(ms + 1000, 2 ** 31 - 1));
69
+ return () => clearTimeout(timer);
70
+ }, [at, tick]);
71
+ }
72
+
73
+ /**
74
+ * The three states a `publish` field can be in, as the decision an editor is making:
75
+ * publish it now, publish it at a chosen time, or take it down.
76
+ *
77
+ * A bare datetime-local input made "publish this" mean "work out the current time and
78
+ * type it in", and "unpublish" mean "clear a text box" — neither of which reads as the
79
+ * action it is.
80
+ *
81
+ * NOTE for the caller: these are <button>s, so this must NOT be rendered inside a
82
+ * <label> — a <button> is labelable, and clicking the label text would forward a click
83
+ * to the first one (publishing the row).
84
+ */
85
+ function PublishControl({ value, onChange }: { value: string; onChange: (v: string | null) => void }) {
86
+ const [scheduling, setScheduling] = useState(false);
87
+ // The picker's own text, kept separate from the stored instant. A `datetime-local`
88
+ // reports "" for ANY incomplete state, so deleting the year to retype it would
89
+ // otherwise read as "unpublish" — and the debounced autosave would take the row off
90
+ // the site mid-keystroke. Only an explicit Unpublish clears the stored value.
91
+ const [draft, setDraft] = useState("");
92
+ const published = Boolean(value);
93
+ const at = published ? new Date(value).getTime() : Number.NaN;
94
+ const scheduled = Number.isFinite(at) && at > Date.now();
95
+
96
+ useTickAt(scheduled ? at : null);
97
+
98
+ const status = !published
99
+ ? { text: "Not published", tone: "text-fg-muted" }
100
+ : scheduled
101
+ ? { text: `Scheduled for ${formatWhen(value)}`, tone: "text-accent-strong" }
102
+ : { text: `Published ${formatWhen(value)}`, tone: "text-fg" };
103
+
104
+ const toggleScheduler = () => {
105
+ setDraft(toLocalInput(value));
106
+ setScheduling((s) => !s);
107
+ };
108
+
109
+ return (
110
+ <div className="flex flex-col gap-2">
111
+ <span className={`text-sm ${status.tone}`}>{status.text}</span>
112
+ <div className="flex flex-wrap items-center gap-2">
113
+ {/* Also offered while SCHEDULED: taking a scheduled row live early otherwise meant
114
+ hand-typing the current time, the very thing this control exists to remove. */}
115
+ {!published || scheduled ? (
116
+ <Button size="sm" onPress={() => { setScheduling(false); onChange(new Date().toISOString()); }}>
117
+ Publish now
118
+ </Button>
119
+ ) : null}
120
+ <Button variant="secondary" size="sm" onPress={toggleScheduler}>
121
+ {scheduled ? "Change schedule" : published ? "Change time" : "Schedule…"}
122
+ </Button>
123
+ {published ? (
124
+ // Clearing the value is what takes the row off the site — the read policy is
125
+ // scoped to this field being set.
126
+ <Button variant="ghost" size="sm" className="text-danger" onPress={() => { setScheduling(false); onChange(null); }}>
127
+ Unpublish
128
+ </Button>
129
+ ) : null}
130
+ </div>
131
+ {scheduling ? (
132
+ <input
133
+ className={CONTROL}
134
+ type="datetime-local"
135
+ // Shown in the editor's own timezone; stored as UTC. Driven by `draft`, not
136
+ // `value`, so a half-typed date survives instead of snapping back.
137
+ value={draft}
138
+ autoFocus
139
+ onChange={(e) => {
140
+ setDraft(e.target.value);
141
+ const iso = e.target.value ? fromLocalInput(e.target.value) : null;
142
+ if (iso) onChange(iso);
143
+ }}
144
+ />
145
+ ) : null}
146
+ </div>
147
+ );
148
+ }
149
+
23
150
  export function FieldForm({ schema, value, onChange, api }: { schema: FieldDefinition[]; value: Record<string, unknown>; onChange: (v: Record<string, unknown>) => void; api: Api }) {
24
151
  const set = (name: string, v: unknown) => onChange({ ...value, [name]: v });
25
152
  return (
26
153
  <div className="flex flex-col gap-4">
27
154
  {schema.map((def) => (
28
- <FieldInput key={def.name} def={def} value={value[def.name]} onChange={(v) => set(def.name, v)} api={api} />
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} />
29
157
  ))}
30
158
  </div>
31
159
  );
32
160
  }
33
161
 
34
- function FieldInput({ def, value, onChange, api }: { def: FieldDefinition; value: unknown; onChange: (v: unknown) => void; api: Api }) {
35
- const label: ReactNode = (
162
+ /**
163
+ * `hideLabelAs` renders the field with NO visible label, using the given string as its
164
+ * accessible name instead. The single-field repeater asks for this: the list already
165
+ * carries the name, so repeating it on every row is noise — but an unlabelled input is
166
+ * announced as a bare edit field, so the name has to go somewhere.
167
+ *
168
+ * It cannot be done by passing an empty label: `text`/`url`/`textarea` render through
169
+ * podoba's `Input`/`Textarea`, which emit `<Label>{label}</Label>` unconditionally — an
170
+ * empty label element still claims a flex row and its `gap-3`, and still wins the
171
+ * accessible-name computation via `aria-labelledby`. Those three go through the bare
172
+ * `CONTROL` skin instead, the same way `number`/`date` already do.
173
+ */
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> }) {
175
+ const label: ReactNode = hideLabelAs !== undefined ? undefined : (
36
176
  <>
37
177
  {def.label ?? def.name} {def.required ? <span className="text-danger">*</span> : null}
38
178
  </>
39
179
  );
180
+ const asText = (v: unknown) => (typeof v === "string" ? v : v == null ? "" : JSON.stringify(v));
40
181
  switch (def.type) {
41
182
  case "text":
42
- case "url":
43
- return <Input label={label} value={(value as string) ?? ""} onChange={onChange} placeholder={def.type === "url" ? "https://…" : ""} />;
183
+ case "url": {
184
+ const placeholder = def.type === "url" ? "https://…" : "";
185
+ return hideLabelAs !== undefined ? (
186
+ <input className={CONTROL} type="text" aria-label={hideLabelAs} placeholder={placeholder} value={(value as string) ?? ""} onChange={(e) => onChange(e.target.value)} />
187
+ ) : (
188
+ <Input label={label} value={(value as string) ?? ""} onChange={onChange} placeholder={placeholder} />
189
+ );
190
+ }
44
191
  case "textarea":
45
- return <Textarea label={label} value={typeof value === "string" ? value : value == null ? "" : JSON.stringify(value)} onChange={onChange} />;
192
+ return hideLabelAs !== undefined ? (
193
+ <textarea className={`${CONTROL} h-auto min-h-20 py-2.5`} aria-label={hideLabelAs} value={asText(value)} onChange={(e) => onChange(e.target.value)} />
194
+ ) : (
195
+ <Textarea label={label} value={asText(value)} onChange={onChange} />
196
+ );
46
197
  case "richtext":
47
198
  // A rich-text value is an HTML string (round-trips with the site's set:html
48
199
  // renderers). A legacy object value isn't editable here — fall back to raw text.
@@ -54,16 +205,36 @@ function FieldInput({ def, value, onChange, api }: { def: FieldDefinition; value
54
205
  case "number":
55
206
  return (
56
207
  <FieldShell label={label}>
57
- <input className={CONTROL} type="number" value={value == null ? "" : String(value)} onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))} />
208
+ <input className={CONTROL} type="number" aria-label={hideLabelAs} value={value == null ? "" : String(value)} onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))} />
58
209
  </FieldShell>
59
210
  );
60
211
  case "date":
61
212
  case "datetime":
62
213
  return (
63
214
  <FieldShell label={label}>
64
- <input className={CONTROL} type={def.type === "date" ? "date" : "datetime-local"} value={(value as string) ?? ""} onChange={(e) => onChange(e.target.value || null)} />
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)} />
65
216
  </FieldShell>
66
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
+ );
228
+ case "publish":
229
+ // Deliberately NOT FieldShell: it wraps children in a <label>, whose implicit
230
+ // control would be PublishControl's first <button> ("Publish now") — clicking the
231
+ // label text would then publish the row. Same reason the boolean case opts out.
232
+ return (
233
+ <div className="flex w-full flex-col gap-2">
234
+ <span className="text-sm font-medium text-fg">{label}</span>
235
+ <PublishControl value={typeof value === "string" ? value : ""} onChange={onChange as (v: string | null) => void} />
236
+ </div>
237
+ );
67
238
  case "boolean":
68
239
  return (
69
240
  <label className="flex items-center gap-2">
@@ -74,7 +245,7 @@ function FieldInput({ def, value, onChange, api }: { def: FieldDefinition; value
74
245
  case "select":
75
246
  return (
76
247
  <FieldShell label={label}>
77
- <SelectField def={def} value={value as string | null} onChange={onChange} api={api} />
248
+ <SelectField def={def} value={value as string | null} onChange={onChange} api={api} ariaLabel={hideLabelAs} />
78
249
  </FieldShell>
79
250
  );
80
251
  case "media":
@@ -109,42 +280,272 @@ export function RichText({ value, onChange }: { value: string; onChange: (v: str
109
280
  return <BlockEditor value={value} onChange={onChange} minHeight={180} placeholder="Write, or press '/' for blocks…" />;
110
281
  }
111
282
 
283
+ /**
284
+ * A repeater's items.
285
+ *
286
+ * Two shapes, because a list of one field and a list of six are different things to edit.
287
+ * A SINGLE-field repeater (bullet points) renders one row per item — handle, input,
288
+ * actions — with no card and no field label repeated down the page saying the same word.
289
+ * Anything wider keeps a card, with a header that summarises the item so eight slides are
290
+ * scannable without expanding each one.
291
+ *
292
+ * Reordering is drag-and-drop from the ⠿ handle, mirroring the block canvas. ↑/↓ stay,
293
+ * because dragging is unavailable to keyboard users and awkward on touch.
294
+ */
112
295
  function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition; value: Record<string, unknown>[]; onChange: (v: unknown[]) => void; api: Api; label: ReactNode }) {
113
296
  const items = Array.isArray(value) ? value : [];
297
+ const fields = def.fields ?? [];
298
+ // One field, and not itself a tall control — the case where the card is pure overhead.
299
+ const compact = fields.length === 1 && !["repeater", "group", "richtext", "media"].includes(fields[0].type);
300
+
301
+ const [drag, setDrag] = useState<{ from: number; over: number } | null>(null);
302
+
114
303
  const upd = (i: number, v: Record<string, unknown>) => onChange(items.map((it, j) => (j === i ? v : it)));
115
304
  const add = () => onChange([...items, {}]);
116
305
  const del = (i: number) => onChange(items.filter((_, j) => j !== i));
117
- const move = (i: number, d: number) => {
118
- const j = i + d;
119
- if (j < 0 || j >= items.length) return;
306
+ const moveTo = (from: number, to: number) => {
307
+ if (from === to || to < 0 || to >= items.length) return;
120
308
  const next = items.slice();
121
- [next[i], next[j]] = [next[j], next[i]];
309
+ const [moved] = next.splice(from, 1);
310
+ next.splice(to, 0, moved);
122
311
  onChange(next);
123
312
  };
313
+ const move = (i: number, d: number) => moveTo(i, i + d);
314
+
315
+ const atMax = def.max != null && items.length >= def.max;
316
+ const atMin = def.min != null && items.length <= def.min;
317
+
318
+ /**
319
+ * The card header's summary: the item's first bit of human-readable text.
320
+ *
321
+ * Restricted to prose-ish field types on purpose. Taking the first non-empty STRING
322
+ * instead titled every slide with the uuid of its image, which is worse than no summary
323
+ * at all — the point is to tell two rows apart at a glance.
324
+ */
325
+ const summarise = (it: Record<string, unknown>): string => {
326
+ const readable = ["text", "textarea", "richtext", "select", "url"];
327
+ for (const f of fields) {
328
+ if (!readable.includes(f.type)) continue;
329
+ const v = it[f.name];
330
+ if (typeof v === "string" && v.trim()) return v.replace(/<[^>]+>/g, " ").trim().slice(0, 80);
331
+ }
332
+ return "";
333
+ };
334
+
335
+ const dropZone = (i: number) => ({
336
+ onDragOver: (e: DragEvent) => {
337
+ if (!drag) return;
338
+ e.preventDefault();
339
+ setDrag((d) => (d && d.over !== i ? { ...d, over: i } : d));
340
+ },
341
+ onDrop: (e: DragEvent) => {
342
+ if (!drag) return;
343
+ e.preventDefault();
344
+ moveTo(drag.from, i);
345
+ setDrag(null);
346
+ },
347
+ });
348
+
349
+ const handle = (i: number) => (
350
+ <span
351
+ draggable
352
+ onDragStart={(e) => {
353
+ e.dataTransfer.effectAllowed = "move";
354
+ // Firefox refuses to start a drag without a payload, but the payload must be
355
+ // EMPTY: a card-variant drop zone spans the item's rich-text body, and
356
+ // ProseMirror's own `drop` listener (on view.dom, so it runs before React's
357
+ // delegated one — preventDefault here can't stop it) pastes `text/plain` at the
358
+ // cursor. An empty slice leaves the doc equal and PM bails. Same reason the block
359
+ // canvas passes "" (components.tsx).
360
+ e.dataTransfer.setData("text/plain", "");
361
+ setDrag({ from: i, over: i });
362
+ }}
363
+ onDragEnd={() => setDrag(null)}
364
+ className="cursor-grab select-none px-1 text-fg-subtle transition-colors hover:text-fg active:cursor-grabbing"
365
+ title="Drag to reorder"
366
+ aria-hidden
367
+ >⠿</span>
368
+ );
369
+
370
+ const rowState = (i: number) =>
371
+ `${drag && drag.over === i && drag.from !== i ? "ring-2 ring-brand-green" : ""} ${drag && drag.from === i ? "opacity-40" : ""}`;
372
+
373
+ /**
374
+ * Row actions. Revealed on row hover, on keyboard focus anywhere in the row, and
375
+ * unconditionally where hover doesn't exist.
376
+ *
377
+ * `group/row` is NAMED on purpose: `BlockCard` is also a `group`, and an unnamed
378
+ * `group-hover:` matches ANY `.group` ancestor — hovering the block revealed every
379
+ * row's actions at once. And Tailwind v4 emits `hover:`/`group-hover:` under
380
+ * `@media (hover: hover)`, so on touch (where drag-and-drop doesn't work either)
381
+ * a hover-only affordance leaves no way at all to reorder or delete.
382
+ */
383
+ const ACTIONS_VISIBILITY =
384
+ "opacity-0 transition-opacity group-hover/row:opacity-100 group-focus-within/row:opacity-100 [@media(hover:none)]:opacity-100";
385
+
386
+ const actions = (i: number) => (
387
+ <div className="flex shrink-0 items-center">
388
+ <button type="button" className="px-1.5 text-fg-subtle hover:text-fg disabled:opacity-30" title="Move up" disabled={i === 0} onClick={() => move(i, -1)}>↑</button>
389
+ <button type="button" className="px-1.5 text-fg-subtle hover:text-fg disabled:opacity-30" title="Move down" disabled={i === items.length - 1} onClick={() => move(i, 1)}>↓</button>
390
+ <button type="button" className="px-1.5 text-fg-subtle hover:text-danger disabled:opacity-30" title="Remove" disabled={atMin} onClick={() => del(i)}>✕</button>
391
+ </div>
392
+ );
393
+
124
394
  return (
125
395
  <div className="flex flex-col gap-2">
126
396
  <span className="text-sm font-medium text-fg">{label}</span>
127
- {items.map((it, i) => (
128
- <div className="rounded-lg border border-border bg-surface-muted p-3.5" key={i}>
129
- <div className="mb-2 flex justify-end gap-1">
130
- <Button variant="ghost" size="sm" onPress={() => move(i, -1)}>↑</Button>
131
- <Button variant="ghost" size="sm" onPress={() => move(i, 1)}>↓</Button>
132
- <Button variant="ghost" size="sm" className="text-danger" onPress={() => del(i)}>✕</Button>
133
- </div>
134
- <FieldForm schema={def.fields ?? []} value={it} onChange={(v) => upd(i, v)} api={api} />
135
- </div>
136
- ))}
137
- <Button variant="secondary" size="sm" className="self-start" onPress={add} isDisabled={def.max != null && items.length >= def.max}>
138
- + add {def.label ?? def.name}
397
+
398
+ {items.length === 0 ? <span className="text-sm text-fg-muted">None yet.</span> : null}
399
+
400
+ <div className={`flex flex-col ${compact ? "gap-1" : "gap-2"}`}>
401
+ {items.map((it, i) =>
402
+ compact ? (
403
+ // One row: handle, input, actions.
404
+ <div key={i} className={`group/row flex items-center gap-1 rounded-lg px-1 py-0.5 transition-colors ${rowState(i)}`} {...dropZone(i)}>
405
+ {handle(i)}
406
+ <div className="min-w-0 flex-1">
407
+ {/* No visible label it would repeat the list's own name down the page
408
+ ("Bod", "Bod", "Bod"…) but the input still needs an accessible one,
409
+ and one that tells the rows apart. */}
410
+ <FieldInput
411
+ def={fields[0]}
412
+ hideLabelAs={`${def.label ?? def.name} ${i + 1}`}
413
+ value={it[fields[0].name]}
414
+ onChange={(v) => upd(i, { ...it, [fields[0].name]: v })}
415
+ api={api}
416
+ />
417
+ </div>
418
+ <div className={ACTIONS_VISIBILITY}>{actions(i)}</div>
419
+ </div>
420
+ ) : (
421
+ <div key={i} className={`rounded-lg border border-border bg-surface-muted transition-colors ${rowState(i)}`} {...dropZone(i)}>
422
+ <div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
423
+ {handle(i)}
424
+ <span className="text-caption text-fg-subtle">{i + 1}</span>
425
+ <span className="min-w-0 flex-1 truncate text-sm text-fg-muted">{summarise(it)}</span>
426
+ {actions(i)}
427
+ </div>
428
+ <div className="p-3.5">
429
+ <FieldForm schema={fields} value={it} onChange={(v) => upd(i, v)} api={api} />
430
+ </div>
431
+ </div>
432
+ ),
433
+ )}
434
+ </div>
435
+
436
+ {/* Named, not a bare "+ Add": a content type with several repeaters would otherwise
437
+ render several buttons with identical accessible names. */}
438
+ <Button variant="secondary" size="sm" className="self-start" onPress={add} isDisabled={atMax}>
439
+ + Add {def.label ?? def.name}
139
440
  </Button>
140
441
  </div>
141
442
  );
142
443
  }
143
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
+
144
545
  // A `select` field. Static `options` render as-is; when `optionsFrom` is set, the options are
145
546
  // fetched once from that query handler (returns `{ value, label }[]`) — e.g. a live list of
146
547
  // campaigns — so the editor never has to hardcode or copy identifiers by hand.
147
- function SelectField({ def, value, onChange, api }: { def: FieldDefinition; value: string | null; onChange: (v: unknown) => void; api: Api }) {
548
+ function SelectField({ def, value, onChange, api, ariaLabel }: { def: FieldDefinition; value: string | null; onChange: (v: unknown) => void; api: Api; ariaLabel?: string }) {
148
549
  const [dyn, setDyn] = useState<{ value: string; label: string }[] | null>(null);
149
550
  const from = def.optionsFrom;
150
551
  useEffect(() => {
@@ -159,7 +560,7 @@ function SelectField({ def, value, onChange, api }: { def: FieldDefinition; valu
159
560
  const loading = Boolean(from) && dyn === null;
160
561
  const opts = from ? dyn ?? [] : (def.options ?? []).map((o) => ({ value: o, label: o }));
161
562
  return (
162
- <select className={CONTROL} value={value ?? ""} onChange={(e) => onChange(e.target.value || null)}>
563
+ <select className={CONTROL} aria-label={ariaLabel} value={value ?? ""} onChange={(e) => onChange(e.target.value || null)}>
163
564
  <option value="">{loading ? "Načítám…" : "—"}</option>
164
565
  {opts.map((o) => (
165
566
  <option key={o.value} value={o.value}>{o.label}</option>
package/src/types.ts CHANGED
@@ -11,6 +11,10 @@ export type FieldType =
11
11
  | "boolean"
12
12
  | "date"
13
13
  | "datetime"
14
+ /** A publication timestamp — rendered as publish-now / schedule / unpublish. */
15
+ | "publish"
16
+ /** A URL segment, derived from the field named by `from` while it is untouched. */
17
+ | "slug"
14
18
  | "media"
15
19
  | "select"
16
20
  | "repeater"
@@ -30,6 +34,8 @@ export interface FieldDefinition {
30
34
  * return `{ value, label }[]`. Lets a select offer live data (e.g. existing campaigns)
31
35
  * instead of a static list. Takes precedence over `options`. */
32
36
  optionsFrom?: string;
37
+ /** For `slug`: the sibling field this one is derived from (e.g. `"title"`). */
38
+ from?: string;
33
39
  }
34
40
 
35
41
  export interface RegionDefinition {