@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/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
- <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 (
@@ -31,18 +158,41 @@ export function FieldForm({ schema, value, onChange, api }: { schema: FieldDefin
31
158
  );
32
159
  }
33
160
 
34
- function FieldInput({ def, value, onChange, api }: { def: FieldDefinition; value: unknown; onChange: (v: unknown) => void; api: Api }) {
35
- const label: ReactNode = (
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
- return <Input label={label} value={(value as string) ?? ""} onChange={onChange} placeholder={def.type === "url" ? "https://…" : ""} />;
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 <Textarea label={label} value={typeof value === "string" ? value : value == null ? "" : JSON.stringify(value)} onChange={onChange} />;
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 move = (i: number, d: number) => {
118
- const j = i + d;
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
- [next[i], next[j]] = [next[j], next[i]];
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
- {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}
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>
@@ -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 { Badge, Button, Card, MoonIcon, SunIcon, Text, Topbar } from "@podoba/react";
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 { cfg, isAdmin, collections, error, reconfigure } = useApp();
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
- <span className="text-callout font-bold tracking-[0.01em] text-fg">pramen</span>
49
- <span className="text-fg-subtle">· cms</span>
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
- <Badge color="grey" label={cfg.tenant} />
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>
@@ -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
  });
package/src/types.ts CHANGED
@@ -11,6 +11,8 @@ export type FieldType =
11
11
  | "boolean"
12
12
  | "date"
13
13
  | "datetime"
14
+ /** A publication timestamp — rendered as publish-now / schedule / unpublish. */
15
+ | "publish"
14
16
  | "media"
15
17
  | "select"
16
18
  | "repeater"