@pramen/cms-editor 0.0.45 → 0.0.47
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.t7eeyc52.js +396 -0
- package/package.json +1 -1
- package/src/app-context.tsx +18 -1
- package/src/components.tsx +126 -25
- package/src/fields.tsx +318 -28
- package/src/routes/_layout.tsx +28 -11
- package/src/routes/page.tsx +2 -1
- package/src/types.ts +2 -0
- package/dist/main.2k0x7rgk.js +0 -396
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, 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,12 +14,139 @@ 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 (
|
|
@@ -31,18 +158,41 @@ export function FieldForm({ schema, value, onChange, api }: { schema: FieldDefin
|
|
|
31
158
|
);
|
|
32
159
|
}
|
|
33
160
|
|
|
34
|
-
|
|
35
|
-
|
|
161
|
+
/**
|
|
162
|
+
* `hideLabelAs` renders the field with NO visible label, using the given string as its
|
|
163
|
+
* accessible name instead. The single-field repeater asks for this: the list already
|
|
164
|
+
* carries the name, so repeating it on every row is noise — but an unlabelled input is
|
|
165
|
+
* announced as a bare edit field, so the name has to go somewhere.
|
|
166
|
+
*
|
|
167
|
+
* It cannot be done by passing an empty label: `text`/`url`/`textarea` render through
|
|
168
|
+
* podoba's `Input`/`Textarea`, which emit `<Label>{label}</Label>` unconditionally — an
|
|
169
|
+
* empty label element still claims a flex row and its `gap-3`, and still wins the
|
|
170
|
+
* accessible-name computation via `aria-labelledby`. Those three go through the bare
|
|
171
|
+
* `CONTROL` skin instead, the same way `number`/`date` already do.
|
|
172
|
+
*/
|
|
173
|
+
function FieldInput({ def, value, onChange, api, hideLabelAs }: { def: FieldDefinition; value: unknown; onChange: (v: unknown) => void; api: Api; hideLabelAs?: string }) {
|
|
174
|
+
const label: ReactNode = hideLabelAs !== undefined ? undefined : (
|
|
36
175
|
<>
|
|
37
176
|
{def.label ?? def.name} {def.required ? <span className="text-danger">*</span> : null}
|
|
38
177
|
</>
|
|
39
178
|
);
|
|
179
|
+
const asText = (v: unknown) => (typeof v === "string" ? v : v == null ? "" : JSON.stringify(v));
|
|
40
180
|
switch (def.type) {
|
|
41
181
|
case "text":
|
|
42
|
-
case "url":
|
|
43
|
-
|
|
182
|
+
case "url": {
|
|
183
|
+
const placeholder = def.type === "url" ? "https://…" : "";
|
|
184
|
+
return hideLabelAs !== undefined ? (
|
|
185
|
+
<input className={CONTROL} type="text" aria-label={hideLabelAs} placeholder={placeholder} value={(value as string) ?? ""} onChange={(e) => onChange(e.target.value)} />
|
|
186
|
+
) : (
|
|
187
|
+
<Input label={label} value={(value as string) ?? ""} onChange={onChange} placeholder={placeholder} />
|
|
188
|
+
);
|
|
189
|
+
}
|
|
44
190
|
case "textarea":
|
|
45
|
-
return
|
|
191
|
+
return hideLabelAs !== undefined ? (
|
|
192
|
+
<textarea className={`${CONTROL} h-auto min-h-20 py-2.5`} aria-label={hideLabelAs} value={asText(value)} onChange={(e) => onChange(e.target.value)} />
|
|
193
|
+
) : (
|
|
194
|
+
<Textarea label={label} value={asText(value)} onChange={onChange} />
|
|
195
|
+
);
|
|
46
196
|
case "richtext":
|
|
47
197
|
// A rich-text value is an HTML string (round-trips with the site's set:html
|
|
48
198
|
// renderers). A legacy object value isn't editable here — fall back to raw text.
|
|
@@ -54,16 +204,26 @@ function FieldInput({ def, value, onChange, api }: { def: FieldDefinition; value
|
|
|
54
204
|
case "number":
|
|
55
205
|
return (
|
|
56
206
|
<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))} />
|
|
207
|
+
<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
208
|
</FieldShell>
|
|
59
209
|
);
|
|
60
210
|
case "date":
|
|
61
211
|
case "datetime":
|
|
62
212
|
return (
|
|
63
213
|
<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)} />
|
|
214
|
+
<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
215
|
</FieldShell>
|
|
66
216
|
);
|
|
217
|
+
case "publish":
|
|
218
|
+
// Deliberately NOT FieldShell: it wraps children in a <label>, whose implicit
|
|
219
|
+
// control would be PublishControl's first <button> ("Publish now") — clicking the
|
|
220
|
+
// label text would then publish the row. Same reason the boolean case opts out.
|
|
221
|
+
return (
|
|
222
|
+
<div className="flex w-full flex-col gap-2">
|
|
223
|
+
<span className="text-sm font-medium text-fg">{label}</span>
|
|
224
|
+
<PublishControl value={typeof value === "string" ? value : ""} onChange={onChange as (v: string | null) => void} />
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
67
227
|
case "boolean":
|
|
68
228
|
return (
|
|
69
229
|
<label className="flex items-center gap-2">
|
|
@@ -74,7 +234,7 @@ function FieldInput({ def, value, onChange, api }: { def: FieldDefinition; value
|
|
|
74
234
|
case "select":
|
|
75
235
|
return (
|
|
76
236
|
<FieldShell label={label}>
|
|
77
|
-
<SelectField def={def} value={value as string | null} onChange={onChange} api={api} />
|
|
237
|
+
<SelectField def={def} value={value as string | null} onChange={onChange} api={api} ariaLabel={hideLabelAs} />
|
|
78
238
|
</FieldShell>
|
|
79
239
|
);
|
|
80
240
|
case "media":
|
|
@@ -109,33 +269,163 @@ export function RichText({ value, onChange }: { value: string; onChange: (v: str
|
|
|
109
269
|
return <BlockEditor value={value} onChange={onChange} minHeight={180} placeholder="Write, or press '/' for blocks…" />;
|
|
110
270
|
}
|
|
111
271
|
|
|
272
|
+
/**
|
|
273
|
+
* A repeater's items.
|
|
274
|
+
*
|
|
275
|
+
* Two shapes, because a list of one field and a list of six are different things to edit.
|
|
276
|
+
* A SINGLE-field repeater (bullet points) renders one row per item — handle, input,
|
|
277
|
+
* actions — with no card and no field label repeated down the page saying the same word.
|
|
278
|
+
* Anything wider keeps a card, with a header that summarises the item so eight slides are
|
|
279
|
+
* scannable without expanding each one.
|
|
280
|
+
*
|
|
281
|
+
* Reordering is drag-and-drop from the ⠿ handle, mirroring the block canvas. ↑/↓ stay,
|
|
282
|
+
* because dragging is unavailable to keyboard users and awkward on touch.
|
|
283
|
+
*/
|
|
112
284
|
function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition; value: Record<string, unknown>[]; onChange: (v: unknown[]) => void; api: Api; label: ReactNode }) {
|
|
113
285
|
const items = Array.isArray(value) ? value : [];
|
|
286
|
+
const fields = def.fields ?? [];
|
|
287
|
+
// One field, and not itself a tall control — the case where the card is pure overhead.
|
|
288
|
+
const compact = fields.length === 1 && !["repeater", "group", "richtext", "media"].includes(fields[0].type);
|
|
289
|
+
|
|
290
|
+
const [drag, setDrag] = useState<{ from: number; over: number } | null>(null);
|
|
291
|
+
|
|
114
292
|
const upd = (i: number, v: Record<string, unknown>) => onChange(items.map((it, j) => (j === i ? v : it)));
|
|
115
293
|
const add = () => onChange([...items, {}]);
|
|
116
294
|
const del = (i: number) => onChange(items.filter((_, j) => j !== i));
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
if (j < 0 || j >= items.length) return;
|
|
295
|
+
const moveTo = (from: number, to: number) => {
|
|
296
|
+
if (from === to || to < 0 || to >= items.length) return;
|
|
120
297
|
const next = items.slice();
|
|
121
|
-
|
|
298
|
+
const [moved] = next.splice(from, 1);
|
|
299
|
+
next.splice(to, 0, moved);
|
|
122
300
|
onChange(next);
|
|
123
301
|
};
|
|
302
|
+
const move = (i: number, d: number) => moveTo(i, i + d);
|
|
303
|
+
|
|
304
|
+
const atMax = def.max != null && items.length >= def.max;
|
|
305
|
+
const atMin = def.min != null && items.length <= def.min;
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The card header's summary: the item's first bit of human-readable text.
|
|
309
|
+
*
|
|
310
|
+
* Restricted to prose-ish field types on purpose. Taking the first non-empty STRING
|
|
311
|
+
* instead titled every slide with the uuid of its image, which is worse than no summary
|
|
312
|
+
* at all — the point is to tell two rows apart at a glance.
|
|
313
|
+
*/
|
|
314
|
+
const summarise = (it: Record<string, unknown>): string => {
|
|
315
|
+
const readable = ["text", "textarea", "richtext", "select", "url"];
|
|
316
|
+
for (const f of fields) {
|
|
317
|
+
if (!readable.includes(f.type)) continue;
|
|
318
|
+
const v = it[f.name];
|
|
319
|
+
if (typeof v === "string" && v.trim()) return v.replace(/<[^>]+>/g, " ").trim().slice(0, 80);
|
|
320
|
+
}
|
|
321
|
+
return "";
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const dropZone = (i: number) => ({
|
|
325
|
+
onDragOver: (e: DragEvent) => {
|
|
326
|
+
if (!drag) return;
|
|
327
|
+
e.preventDefault();
|
|
328
|
+
setDrag((d) => (d && d.over !== i ? { ...d, over: i } : d));
|
|
329
|
+
},
|
|
330
|
+
onDrop: (e: DragEvent) => {
|
|
331
|
+
if (!drag) return;
|
|
332
|
+
e.preventDefault();
|
|
333
|
+
moveTo(drag.from, i);
|
|
334
|
+
setDrag(null);
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const handle = (i: number) => (
|
|
339
|
+
<span
|
|
340
|
+
draggable
|
|
341
|
+
onDragStart={(e) => {
|
|
342
|
+
e.dataTransfer.effectAllowed = "move";
|
|
343
|
+
// Firefox refuses to start a drag without a payload, but the payload must be
|
|
344
|
+
// EMPTY: a card-variant drop zone spans the item's rich-text body, and
|
|
345
|
+
// ProseMirror's own `drop` listener (on view.dom, so it runs before React's
|
|
346
|
+
// delegated one — preventDefault here can't stop it) pastes `text/plain` at the
|
|
347
|
+
// cursor. An empty slice leaves the doc equal and PM bails. Same reason the block
|
|
348
|
+
// canvas passes "" (components.tsx).
|
|
349
|
+
e.dataTransfer.setData("text/plain", "");
|
|
350
|
+
setDrag({ from: i, over: i });
|
|
351
|
+
}}
|
|
352
|
+
onDragEnd={() => setDrag(null)}
|
|
353
|
+
className="cursor-grab select-none px-1 text-fg-subtle transition-colors hover:text-fg active:cursor-grabbing"
|
|
354
|
+
title="Drag to reorder"
|
|
355
|
+
aria-hidden
|
|
356
|
+
>⠿</span>
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
const rowState = (i: number) =>
|
|
360
|
+
`${drag && drag.over === i && drag.from !== i ? "ring-2 ring-brand-green" : ""} ${drag && drag.from === i ? "opacity-40" : ""}`;
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Row actions. Revealed on row hover, on keyboard focus anywhere in the row, and
|
|
364
|
+
* unconditionally where hover doesn't exist.
|
|
365
|
+
*
|
|
366
|
+
* `group/row` is NAMED on purpose: `BlockCard` is also a `group`, and an unnamed
|
|
367
|
+
* `group-hover:` matches ANY `.group` ancestor — hovering the block revealed every
|
|
368
|
+
* row's actions at once. And Tailwind v4 emits `hover:`/`group-hover:` under
|
|
369
|
+
* `@media (hover: hover)`, so on touch (where drag-and-drop doesn't work either)
|
|
370
|
+
* a hover-only affordance leaves no way at all to reorder or delete.
|
|
371
|
+
*/
|
|
372
|
+
const ACTIONS_VISIBILITY =
|
|
373
|
+
"opacity-0 transition-opacity group-hover/row:opacity-100 group-focus-within/row:opacity-100 [@media(hover:none)]:opacity-100";
|
|
374
|
+
|
|
375
|
+
const actions = (i: number) => (
|
|
376
|
+
<div className="flex shrink-0 items-center">
|
|
377
|
+
<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>
|
|
378
|
+
<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>
|
|
379
|
+
<button type="button" className="px-1.5 text-fg-subtle hover:text-danger disabled:opacity-30" title="Remove" disabled={atMin} onClick={() => del(i)}>✕</button>
|
|
380
|
+
</div>
|
|
381
|
+
);
|
|
382
|
+
|
|
124
383
|
return (
|
|
125
384
|
<div className="flex flex-col gap-2">
|
|
126
385
|
<span className="text-sm font-medium text-fg">{label}</span>
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
386
|
+
|
|
387
|
+
{items.length === 0 ? <span className="text-sm text-fg-muted">None yet.</span> : null}
|
|
388
|
+
|
|
389
|
+
<div className={`flex flex-col ${compact ? "gap-1" : "gap-2"}`}>
|
|
390
|
+
{items.map((it, i) =>
|
|
391
|
+
compact ? (
|
|
392
|
+
// One row: handle, input, actions.
|
|
393
|
+
<div key={i} className={`group/row flex items-center gap-1 rounded-lg px-1 py-0.5 transition-colors ${rowState(i)}`} {...dropZone(i)}>
|
|
394
|
+
{handle(i)}
|
|
395
|
+
<div className="min-w-0 flex-1">
|
|
396
|
+
{/* No visible label — it would repeat the list's own name down the page
|
|
397
|
+
("Bod", "Bod", "Bod"…) — but the input still needs an accessible one,
|
|
398
|
+
and one that tells the rows apart. */}
|
|
399
|
+
<FieldInput
|
|
400
|
+
def={fields[0]}
|
|
401
|
+
hideLabelAs={`${def.label ?? def.name} ${i + 1}`}
|
|
402
|
+
value={it[fields[0].name]}
|
|
403
|
+
onChange={(v) => upd(i, { ...it, [fields[0].name]: v })}
|
|
404
|
+
api={api}
|
|
405
|
+
/>
|
|
406
|
+
</div>
|
|
407
|
+
<div className={ACTIONS_VISIBILITY}>{actions(i)}</div>
|
|
408
|
+
</div>
|
|
409
|
+
) : (
|
|
410
|
+
<div key={i} className={`rounded-lg border border-border bg-surface-muted transition-colors ${rowState(i)}`} {...dropZone(i)}>
|
|
411
|
+
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
|
412
|
+
{handle(i)}
|
|
413
|
+
<span className="text-caption text-fg-subtle">{i + 1}</span>
|
|
414
|
+
<span className="min-w-0 flex-1 truncate text-sm text-fg-muted">{summarise(it)}</span>
|
|
415
|
+
{actions(i)}
|
|
416
|
+
</div>
|
|
417
|
+
<div className="p-3.5">
|
|
418
|
+
<FieldForm schema={fields} value={it} onChange={(v) => upd(i, v)} api={api} />
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
),
|
|
422
|
+
)}
|
|
423
|
+
</div>
|
|
424
|
+
|
|
425
|
+
{/* Named, not a bare "+ Add": a content type with several repeaters would otherwise
|
|
426
|
+
render several buttons with identical accessible names. */}
|
|
427
|
+
<Button variant="secondary" size="sm" className="self-start" onPress={add} isDisabled={atMax}>
|
|
428
|
+
+ Add {def.label ?? def.name}
|
|
139
429
|
</Button>
|
|
140
430
|
</div>
|
|
141
431
|
);
|
|
@@ -144,7 +434,7 @@ function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition;
|
|
|
144
434
|
// A `select` field. Static `options` render as-is; when `optionsFrom` is set, the options are
|
|
145
435
|
// fetched once from that query handler (returns `{ value, label }[]`) — e.g. a live list of
|
|
146
436
|
// 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 }) {
|
|
437
|
+
function SelectField({ def, value, onChange, api, ariaLabel }: { def: FieldDefinition; value: string | null; onChange: (v: unknown) => void; api: Api; ariaLabel?: string }) {
|
|
148
438
|
const [dyn, setDyn] = useState<{ value: string; label: string }[] | null>(null);
|
|
149
439
|
const from = def.optionsFrom;
|
|
150
440
|
useEffect(() => {
|
|
@@ -159,7 +449,7 @@ function SelectField({ def, value, onChange, api }: { def: FieldDefinition; valu
|
|
|
159
449
|
const loading = Boolean(from) && dyn === null;
|
|
160
450
|
const opts = from ? dyn ?? [] : (def.options ?? []).map((o) => ({ value: o, label: o }));
|
|
161
451
|
return (
|
|
162
|
-
<select className={CONTROL} value={value ?? ""} onChange={(e) => onChange(e.target.value || null)}>
|
|
452
|
+
<select className={CONTROL} aria-label={ariaLabel} value={value ?? ""} onChange={(e) => onChange(e.target.value || null)}>
|
|
163
453
|
<option value="">{loading ? "Načítám…" : "—"}</option>
|
|
164
454
|
{opts.map((o) => (
|
|
165
455
|
<option key={o.value} value={o.value}>{o.label}</option>
|
package/src/routes/_layout.tsx
CHANGED
|
@@ -3,17 +3,24 @@
|
|
|
3
3
|
// current path, so a deep link or refresh lands with the right tab lit.
|
|
4
4
|
|
|
5
5
|
import { Outlet, useNavigate, useRoute } from "@buzola/router";
|
|
6
|
-
import {
|
|
6
|
+
import { Button, Card, MoonIcon, SunIcon, Text, Topbar } from "@podoba/react";
|
|
7
7
|
import { useEffect, useState } from "react";
|
|
8
8
|
import { useApp } from "../app-context";
|
|
9
9
|
|
|
10
10
|
const THEME_KEY = "pramen.cms.theme";
|
|
11
11
|
|
|
12
12
|
export default function RootLayout() {
|
|
13
|
-
const {
|
|
13
|
+
const { isAdmin, collections, error, reconfigure, confirmNavigation } = useApp();
|
|
14
14
|
const navigate = useNavigate();
|
|
15
15
|
const { pathname } = useRoute();
|
|
16
16
|
|
|
17
|
+
// Every chrome action here is a way OUT of the current screen, so it runs through that
|
|
18
|
+
// screen's unsaved-changes guard first (the page editor registers one; with no guard
|
|
19
|
+
// registered this is a pass-through). In-app navigation fires no `beforeunload`, so
|
|
20
|
+
// without this the topbar silently discards unsaved edits. The external `extraNav` links
|
|
21
|
+
// open in a new tab and leave nothing behind, so they stay unguarded.
|
|
22
|
+
const guarded = (go: () => void) => () => { if (confirmNavigation()) go(); };
|
|
23
|
+
|
|
17
24
|
// Dark mode: podoba tokens flip under `[data-theme="dark"]` — no `dark:` prefixes.
|
|
18
25
|
const [theme, setTheme] = useState(() => (typeof localStorage !== "undefined" ? localStorage.getItem(THEME_KEY) ?? "light" : "light"));
|
|
19
26
|
useEffect(() => {
|
|
@@ -45,30 +52,39 @@ export default function RootLayout() {
|
|
|
45
52
|
<div className="min-h-screen bg-surface text-fg">
|
|
46
53
|
<Topbar className="sticky top-0 z-10 bg-surface px-7">
|
|
47
54
|
<Topbar.Brand>
|
|
48
|
-
|
|
49
|
-
|
|
55
|
+
{/* The wordmark is the way back to the top of the admin, as it is on every
|
|
56
|
+
other site — a `button` (not an `<a>`) so the SPA router handles it. */}
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
onClick={guarded(() => navigate("home"))}
|
|
60
|
+
aria-label="pramen cms — home"
|
|
61
|
+
className="flex items-baseline gap-1 rounded-md px-1 py-0.5 transition-colors hover:bg-surface-muted"
|
|
62
|
+
>
|
|
63
|
+
<span className="text-callout font-bold tracking-[0.01em] text-fg">pramen</span>
|
|
64
|
+
<span className="text-fg-subtle">· cms</span>
|
|
65
|
+
</button>
|
|
50
66
|
</Topbar.Brand>
|
|
51
67
|
<Topbar.Nav aria-label="Primary">
|
|
52
68
|
{hidePages ? null : (
|
|
53
|
-
<Button variant="ghost" size="sm" className={tabCls("pages")} onPress={() => navigate("home")}>
|
|
69
|
+
<Button variant="ghost" size="sm" className={tabCls("pages")} onPress={guarded(() => navigate("home"))}>
|
|
54
70
|
Pages
|
|
55
71
|
</Button>
|
|
56
72
|
)}
|
|
57
73
|
{collections.map((c) => (
|
|
58
|
-
<Button key={c.slug} variant="ghost" size="sm" className={tabCls(`col:${c.slug}`)} onPress={() => navigate("collection", { params: { slug: c.slug } })}>
|
|
74
|
+
<Button key={c.slug} variant="ghost" size="sm" className={tabCls(`col:${c.slug}`)} onPress={guarded(() => navigate("collection", { params: { slug: c.slug } }))}>
|
|
59
75
|
{c.icon ? `${c.icon} ` : ""}
|
|
60
76
|
{c.pluralLabel}
|
|
61
77
|
</Button>
|
|
62
78
|
))}
|
|
63
|
-
<Button variant="ghost" size="sm" className={tabCls("media")} onPress={() => navigate("media")}>
|
|
79
|
+
<Button variant="ghost" size="sm" className={tabCls("media")} onPress={guarded(() => navigate("media"))}>
|
|
64
80
|
Media
|
|
65
81
|
</Button>
|
|
66
82
|
{isAdmin ? (
|
|
67
|
-
<Button variant="ghost" size="sm" className={tabCls("users")} onPress={() => navigate("users")}>
|
|
83
|
+
<Button variant="ghost" size="sm" className={tabCls("users")} onPress={guarded(() => navigate("users"))}>
|
|
68
84
|
Users
|
|
69
85
|
</Button>
|
|
70
86
|
) : null}
|
|
71
|
-
<Button variant="ghost" size="sm" className={tabCls("settings")} onPress={() => navigate("settings")}>
|
|
87
|
+
<Button variant="ghost" size="sm" className={tabCls("settings")} onPress={guarded(() => navigate("settings"))}>
|
|
72
88
|
Settings
|
|
73
89
|
</Button>
|
|
74
90
|
{extraNav.map((l) => (
|
|
@@ -87,7 +103,8 @@ export default function RootLayout() {
|
|
|
87
103
|
))}
|
|
88
104
|
</Topbar.Nav>
|
|
89
105
|
<Topbar.Actions>
|
|
90
|
-
|
|
106
|
+
{/* The tenant is deployment configuration, not something an editor acts on —
|
|
107
|
+
it stays visible on the Settings page (Connection), not in the chrome. */}
|
|
91
108
|
<Button
|
|
92
109
|
variant="ghost"
|
|
93
110
|
size="sm"
|
|
@@ -96,7 +113,7 @@ export default function RootLayout() {
|
|
|
96
113
|
>
|
|
97
114
|
{theme === "dark" ? <SunIcon className="h-4 w-4" /> : <MoonIcon className="h-4 w-4" />}
|
|
98
115
|
</Button>
|
|
99
|
-
<Button variant="ghost" size="sm" onPress={reconfigure}>
|
|
116
|
+
<Button variant="ghost" size="sm" onPress={guarded(reconfigure)}>
|
|
100
117
|
sign out
|
|
101
118
|
</Button>
|
|
102
119
|
</Topbar.Actions>
|
package/src/routes/page.tsx
CHANGED
|
@@ -13,7 +13,7 @@ export default createPage()
|
|
|
13
13
|
.params({ pageId: "string", tab: "?string" })
|
|
14
14
|
.route("/pages/:pageId")
|
|
15
15
|
.render(function PageEditorRoute({ params }) {
|
|
16
|
-
const { api, setError } = useApp();
|
|
16
|
+
const { api, setError, setNavGuard } = useApp();
|
|
17
17
|
const navigate = useNavigate();
|
|
18
18
|
const [page, setPage] = useState<Page | null>(null);
|
|
19
19
|
const [blockTypes, setBlockTypes] = useState<BlockType[]>([]);
|
|
@@ -57,6 +57,7 @@ export default createPage()
|
|
|
57
57
|
onTab={setTab}
|
|
58
58
|
onBack={() => navigate("home")}
|
|
59
59
|
onChange={setPage}
|
|
60
|
+
registerGuard={setNavGuard}
|
|
60
61
|
/>
|
|
61
62
|
);
|
|
62
63
|
});
|