@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/dist/app.css +1 -1
- package/dist/index.html +1 -1
- package/dist/main.qy6fhg00.js +396 -0
- package/package.json +1 -1
- package/src/components.tsx +1 -4
- package/src/fields.tsx +430 -29
- package/src/types.ts +6 -0
- package/dist/main.w2tw3v4q.js +0 -396
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 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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 {
|