@pramen/cms-editor 0.0.49 → 0.0.51
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/README.md +45 -2
- package/dist/app.css +1 -1
- package/dist/config.js +14 -4
- package/dist/index.html +4 -2
- package/dist/main.a0tgw8hg.js +399 -0
- package/package.json +15 -11
- package/src/api.ts +6 -0
- package/src/app-context.tsx +21 -2
- package/src/brand.ts +96 -0
- package/src/components.tsx +296 -16
- package/src/fields.tsx +102 -15
- package/src/main.tsx +10 -0
- package/src/rich-text.ts +30 -0
- package/src/routes/_layout.tsx +12 -5
- package/src/routes/page.tsx +12 -3
- package/src/types.ts +53 -2
- package/dist/main.ykztb0px.js +0 -396
package/src/components.tsx
CHANGED
|
@@ -5,14 +5,22 @@
|
|
|
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, slugify } from "./fields";
|
|
8
|
+
import { CONTROL, FieldForm, formatWhen, fromLocalInput, slugify, toLocalInput } from "./fields";
|
|
9
9
|
import type { Config } from "./api";
|
|
10
|
-
import type
|
|
11
|
-
import
|
|
10
|
+
import { useApp, type Me } from "./app-context";
|
|
11
|
+
import { isRichTextDoc, richTextToPlainText } from "./rich-text";
|
|
12
|
+
import type { AssembledPage, AuditEntry, BlockType, CollectionMeta, ContentType, FieldDefinition, FieldValue, FieldValues, Media, Page, RegionDefinition, RenderedBlock } from "./types";
|
|
12
13
|
|
|
13
14
|
export type InspectorTab = "settings" | "seo" | "workflow" | "i18n" | "audit";
|
|
14
15
|
export const INSPECTOR_TABS: InspectorTab[] = ["settings", "seo", "workflow", "i18n", "audit"];
|
|
15
16
|
|
|
17
|
+
/** The tabs a deployment actually shows. ONE definition, used by the tab bar, the panel
|
|
18
|
+
* switch and the route's deep-link fallback — three places that previously each re-derived
|
|
19
|
+
* "is i18n visible?" and could disagree. */
|
|
20
|
+
export function visibleTabs(multilingual: boolean): InspectorTab[] {
|
|
21
|
+
return multilingual ? INSPECTOR_TABS : INSPECTOR_TABS.filter((t) => t !== "i18n");
|
|
22
|
+
}
|
|
23
|
+
|
|
16
24
|
// --- presentational primitives (podoba tokens; replaces styles.ts classes) ---
|
|
17
25
|
|
|
18
26
|
const ROW = "flex items-center gap-3 rounded-[14px] border border-transparent bg-surface-card px-[18px] py-3.5";
|
|
@@ -101,6 +109,8 @@ const Dim = ({ children }: { children: ReactNode }) => <span className="text-fg-
|
|
|
101
109
|
// --- pages list --------------------------------------------------------------
|
|
102
110
|
|
|
103
111
|
export function PageList({ api, pages, blockTypes, onOpen, onCreated, onError }: { api: Api; pages: Page[]; blockTypes: BlockType[]; onOpen: (p: Page) => void; onCreated: () => void; onError: (s: string) => void }) {
|
|
112
|
+
// From the SERVER (listCmsCapabilities), not a local flag — see `CmsCapabilities`.
|
|
113
|
+
const { cms: { multilingual } } = useApp();
|
|
104
114
|
const [creating, setCreating] = useState(false);
|
|
105
115
|
return (
|
|
106
116
|
<>
|
|
@@ -115,7 +125,7 @@ export function PageList({ api, pages, blockTypes, onOpen, onCreated, onError }:
|
|
|
115
125
|
<div className={`${ROW} cursor-pointer hover:bg-surface-muted`} key={p.id} onClick={() => onOpen(p)}>
|
|
116
126
|
<span className="flex-1 truncate font-medium">{p.title}</span>
|
|
117
127
|
<span className="text-fg-subtle">/{p.slug}</span>
|
|
118
|
-
<span className="text-fg-subtle">{p.locale}</span>
|
|
128
|
+
{multilingual ? <span className="text-fg-subtle">{p.locale}</span> : null}
|
|
119
129
|
<Pill status={p.status}>{p.status}</Pill>
|
|
120
130
|
</div>
|
|
121
131
|
))}
|
|
@@ -186,11 +196,16 @@ function CreatePage({ api, onClose, onCreated, onError }: { api: Api; onClose: (
|
|
|
186
196
|
// collections, zero per-collection code. Rows are addressed by `def.idField` (the entity's
|
|
187
197
|
// PK column, defaults "id"); the server resolves the real PK from the value.
|
|
188
198
|
|
|
189
|
-
/** Render a list-cell value as a short string (objects/arrays are summarized, not dumped).
|
|
199
|
+
/** Render a list-cell value as a short string (objects/arrays are summarized, not dumped).
|
|
200
|
+
* A `richtext` column is a document tree, so flatten it to words rather than showing "—". */
|
|
190
201
|
function cellText(v: unknown): string {
|
|
191
202
|
if (v == null) return "";
|
|
192
203
|
if (typeof v === "boolean") return v ? "yes" : "no";
|
|
193
204
|
if (Array.isArray(v)) return v.length === 1 ? "1 item" : `${v.length} items`;
|
|
205
|
+
if (isRichTextDoc(v)) {
|
|
206
|
+
const text = richTextToPlainText(v).replace(/\s+/g, " ").trim();
|
|
207
|
+
return text.length > 80 ? text.slice(0, 80) + "…" : text;
|
|
208
|
+
}
|
|
194
209
|
if (typeof v === "object") return "—";
|
|
195
210
|
return String(v);
|
|
196
211
|
}
|
|
@@ -265,6 +280,185 @@ export function CollectionList({ api, def, onOpen, onNew, onError }: { api: Api;
|
|
|
265
280
|
);
|
|
266
281
|
}
|
|
267
282
|
|
|
283
|
+
/** The workflow surface for a collection row — the UI half of the server's `supports`.
|
|
284
|
+
*
|
|
285
|
+
* Without this the feature is unreachable from the editor: `collectionCreate` always seeds
|
|
286
|
+
* `status: "draft"` and `collectionUpdate` strips `status` from the values bag (it is a
|
|
287
|
+
* MANAGED column, deliberately not in the write whitelist), so a row authored here could
|
|
288
|
+
* never be made public. Publishing has to be its own gated call, and this is where it is
|
|
289
|
+
* made. Every control is driven by `def.supports`, so a plain CRUD collection renders
|
|
290
|
+
* nothing at all.
|
|
291
|
+
*
|
|
292
|
+
* The row's managed columns come back on `collectionGet`/the write echoes, so the panel
|
|
293
|
+
* reads its state from the same `values` bag the form holds. */
|
|
294
|
+
function CollectionWorkflow({
|
|
295
|
+
api,
|
|
296
|
+
def,
|
|
297
|
+
id,
|
|
298
|
+
values,
|
|
299
|
+
onChanged,
|
|
300
|
+
onError,
|
|
301
|
+
}: {
|
|
302
|
+
api: Api;
|
|
303
|
+
def: CollectionMeta;
|
|
304
|
+
id: string;
|
|
305
|
+
values: FieldValues;
|
|
306
|
+
onChanged: (row: FieldValues) => void;
|
|
307
|
+
onError: (s: string) => void;
|
|
308
|
+
}) {
|
|
309
|
+
const supports = def.supports ?? [];
|
|
310
|
+
const [busy, setBusy] = useState(false);
|
|
311
|
+
const [scheduling, setScheduling] = useState(false);
|
|
312
|
+
const [publishAt, setPublishAt] = useState("");
|
|
313
|
+
const [takedownAt, setTakedownAt] = useState("");
|
|
314
|
+
const [preview, setPreview] = useState<string | null>(null);
|
|
315
|
+
const [revisions, setRevisions] = useState<Array<{ id: string; revision: number; note: string | null; actor: string | null; createdAt: string }> | null>(null);
|
|
316
|
+
|
|
317
|
+
const status = typeof values.status === "string" ? values.status : "draft";
|
|
318
|
+
const published = status === "published";
|
|
319
|
+
const str = (k: string) => (typeof values[k] === "string" && values[k] !== "" ? (values[k] as string) : null);
|
|
320
|
+
const scheduledAt = str("scheduledAt");
|
|
321
|
+
const unpublishAt = str("unpublishAt");
|
|
322
|
+
const publishedAt = str("publishedAt");
|
|
323
|
+
|
|
324
|
+
if (supports.length === 0) return null;
|
|
325
|
+
|
|
326
|
+
const act = async (name: string, input: Record<string, unknown> = {}) => {
|
|
327
|
+
setBusy(true);
|
|
328
|
+
try {
|
|
329
|
+
const row = await api.call<FieldValues>(name, { collection: def.slug, id, ...input });
|
|
330
|
+
// The publish/unpublish handlers echo the persisted row; `collectionSchedule` returns
|
|
331
|
+
// `{ ok, scheduledAt, … }`, so re-read rather than merging a non-row shape in.
|
|
332
|
+
if (row && typeof row === "object" && def.idField in row) onChanged(row);
|
|
333
|
+
else {
|
|
334
|
+
const fresh = await api.call<FieldValues | null>("collectionGet", { collection: def.slug, id });
|
|
335
|
+
if (fresh) onChanged(fresh);
|
|
336
|
+
}
|
|
337
|
+
return true;
|
|
338
|
+
} catch (e) {
|
|
339
|
+
onError(errMsg(e));
|
|
340
|
+
return false;
|
|
341
|
+
} finally {
|
|
342
|
+
setBusy(false);
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const saveSchedule = async () => {
|
|
347
|
+
const at = publishAt ? fromLocalInput(publishAt) : null;
|
|
348
|
+
if (!at) return onError("Pick a publication date and time first.");
|
|
349
|
+
const down = takedownAt ? fromLocalInput(takedownAt) : null;
|
|
350
|
+
if (takedownAt && !down) return onError("That takedown date is not a valid date and time.");
|
|
351
|
+
// `unpublishAt` is PATCH semantics server-side: omitted leaves an existing takedown
|
|
352
|
+
// standing, `null` cancels it. Send it explicitly whenever the scheduler is open, so
|
|
353
|
+
// what the editor sees in the two inputs is exactly what is stored.
|
|
354
|
+
const ok = await act("collectionSchedule", { publishAt: Date.parse(at), unpublishAt: down ? Date.parse(down) : null });
|
|
355
|
+
if (ok) setScheduling(false);
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const openScheduler = () => {
|
|
359
|
+
setPublishAt(toLocalInput(scheduledAt ?? publishedAt ?? new Date().toISOString()));
|
|
360
|
+
setTakedownAt(unpublishAt ? toLocalInput(unpublishAt) : "");
|
|
361
|
+
setScheduling((v) => !v);
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const mintPreview = async () => {
|
|
365
|
+
setBusy(true);
|
|
366
|
+
try {
|
|
367
|
+
const r = await api.call<{ url: string }>("signCollectionPreview", { collection: def.slug, id });
|
|
368
|
+
const url = api.resolve(r.url);
|
|
369
|
+
setPreview(url);
|
|
370
|
+
// Best-effort: the clipboard needs a secure context and a permission, and the link is
|
|
371
|
+
// rendered either way.
|
|
372
|
+
await navigator.clipboard?.writeText(url).catch(() => {});
|
|
373
|
+
} catch (e) {
|
|
374
|
+
onError(errMsg(e));
|
|
375
|
+
} finally {
|
|
376
|
+
setBusy(false);
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const loadRevisions = async () => {
|
|
381
|
+
if (revisions) return setRevisions(null); // toggle closed
|
|
382
|
+
try {
|
|
383
|
+
setRevisions(await api.call<NonNullable<typeof revisions>>("collectionListRevisions", { collection: def.slug, id }));
|
|
384
|
+
} catch (e) {
|
|
385
|
+
onError(errMsg(e));
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const restore = async (revisionId: string) => {
|
|
390
|
+
if (!confirm("Restore this version? The current content is snapshotted first, so this is itself undoable.")) return;
|
|
391
|
+
if (await act("collectionRestoreRevision", { revisionId })) setRevisions(null);
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
return (
|
|
395
|
+
<div className="flex flex-col gap-3 rounded-[14px] border border-border bg-surface-card px-[18px] py-4">
|
|
396
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
397
|
+
<span className="text-fg-subtle">Status</span>
|
|
398
|
+
<Pill status={status}>{status}</Pill>
|
|
399
|
+
{publishedAt && published ? <span className="text-fg-subtle">since {formatWhen(publishedAt)}</span> : null}
|
|
400
|
+
{scheduledAt ? <span className="text-accent-strong">publishes {formatWhen(scheduledAt)}</span> : null}
|
|
401
|
+
{unpublishAt ? <span className="text-danger">comes down {formatWhen(unpublishAt)}</span> : null}
|
|
402
|
+
</div>
|
|
403
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
404
|
+
{supports.includes("drafts") && !published ? (
|
|
405
|
+
<Button size="sm" isDisabled={busy} onPress={() => void act("collectionPublish")}>Publish now</Button>
|
|
406
|
+
) : null}
|
|
407
|
+
{supports.includes("drafts") && published ? (
|
|
408
|
+
<Button variant="secondary" size="sm" isDisabled={busy} onPress={() => void act("collectionUnpublish")}>Unpublish</Button>
|
|
409
|
+
) : null}
|
|
410
|
+
{supports.includes("scheduling") ? (
|
|
411
|
+
<Button variant="secondary" size="sm" isDisabled={busy} onPress={openScheduler}>{scheduledAt ? "Change schedule" : "Schedule…"}</Button>
|
|
412
|
+
) : null}
|
|
413
|
+
{supports.includes("preview") ? (
|
|
414
|
+
<Button variant="ghost" size="sm" isDisabled={busy} onPress={() => void mintPreview()}>Preview link</Button>
|
|
415
|
+
) : null}
|
|
416
|
+
{supports.includes("revisions") ? (
|
|
417
|
+
<Button variant="ghost" size="sm" isDisabled={busy} onPress={() => void loadRevisions()}>{revisions ? "Hide history" : "History"}</Button>
|
|
418
|
+
) : null}
|
|
419
|
+
</div>
|
|
420
|
+
{scheduling ? (
|
|
421
|
+
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
|
422
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
423
|
+
<span className="font-medium text-fg">Publish at</span>
|
|
424
|
+
<input className={CONTROL} type="datetime-local" value={publishAt} onChange={(e) => setPublishAt(e.target.value)} />
|
|
425
|
+
</label>
|
|
426
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
427
|
+
<span className="font-medium text-fg">Take down at (optional)</span>
|
|
428
|
+
<input className={CONTROL} type="datetime-local" value={takedownAt} onChange={(e) => setTakedownAt(e.target.value)} />
|
|
429
|
+
</label>
|
|
430
|
+
<p className="text-fg-subtle">
|
|
431
|
+
Scheduling does not take a live row down — it publishes at the first instant. A takedown must be after the publication time. Leave it empty to
|
|
432
|
+
cancel one.
|
|
433
|
+
</p>
|
|
434
|
+
<div className="flex gap-2">
|
|
435
|
+
<Button size="sm" isDisabled={busy} onPress={() => void saveSchedule()}>Save schedule</Button>
|
|
436
|
+
<Button variant="ghost" size="sm" onPress={() => setScheduling(false)}>Cancel</Button>
|
|
437
|
+
</div>
|
|
438
|
+
</div>
|
|
439
|
+
) : null}
|
|
440
|
+
{preview ? (
|
|
441
|
+
<div className="border-t border-border pt-3 text-sm">
|
|
442
|
+
<span className="text-fg-subtle">Preview link (copied): </span>
|
|
443
|
+
<a className="break-all underline" href={preview} target="_blank" rel="noreferrer">{preview}</a>
|
|
444
|
+
</div>
|
|
445
|
+
) : null}
|
|
446
|
+
{revisions ? (
|
|
447
|
+
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
|
448
|
+
{revisions.map((r) => (
|
|
449
|
+
<div className={`${ROW} text-xs`} key={r.id}>
|
|
450
|
+
<span className="rounded-full bg-surface-muted px-2 py-0.5 font-mono text-xs text-fg-muted">#{r.revision}</span>
|
|
451
|
+
<span className="flex-1 truncate text-fg-subtle">{r.note ?? "edit"} · {r.actor ?? "system"} · {formatWhen(r.createdAt)}</span>
|
|
452
|
+
<Button variant="ghost" size="sm" isDisabled={busy} onPress={() => void restore(r.id)}>Restore</Button>
|
|
453
|
+
</div>
|
|
454
|
+
))}
|
|
455
|
+
{revisions.length === 0 ? <p className="text-fg-subtle">No history yet.</p> : null}
|
|
456
|
+
</div>
|
|
457
|
+
) : null}
|
|
458
|
+
</div>
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
268
462
|
export function CollectionEditor({ api, def, id, onSaved, onDeleted, onBack, onError }: { api: Api; def: CollectionMeta; id: string | null; onSaved: () => void; onDeleted: () => void; onBack: () => void; onError: (s: string) => void }) {
|
|
269
463
|
const isNew = id === null;
|
|
270
464
|
const [values, setValues] = useState<FieldValues>({});
|
|
@@ -333,6 +527,12 @@ export function CollectionEditor({ api, def, id, onSaved, onDeleted, onBack, onE
|
|
|
333
527
|
) : (
|
|
334
528
|
<div className="flex max-w-[720px] flex-col gap-4">
|
|
335
529
|
{ok ? <Banner ok>saved</Banner> : null}
|
|
530
|
+
{/* Publishing is a separate, separately-gated call — `status` is a managed column
|
|
531
|
+
the ordinary save cannot touch — so the workflow controls live outside the
|
|
532
|
+
form. Only on an existing row: there is nothing to publish until it exists. */}
|
|
533
|
+
{!isNew && id ? (
|
|
534
|
+
<CollectionWorkflow api={api} def={def} id={id} values={values} onChanged={setValues} onError={onError} />
|
|
535
|
+
) : null}
|
|
336
536
|
<FieldForm schema={def.fields} value={values} onChange={setValues} api={api} />
|
|
337
537
|
<div className="mt-2 flex items-center gap-2">
|
|
338
538
|
<Button onPress={save} isDisabled={busy}>{busy ? "Saving…" : isNew ? "Create" : "Save"}</Button>
|
|
@@ -347,6 +547,7 @@ export function CollectionEditor({ api, def, id, onSaved, onDeleted, onBack, onE
|
|
|
347
547
|
// --- page editor -------------------------------------------------------------
|
|
348
548
|
|
|
349
549
|
export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange, registerGuard }: { api: Api; page: Page; blockTypes: BlockType[]; tab: InspectorTab; onTab: (t: InspectorTab) => void; onBack: () => void; onChange: (p: Page) => void; registerGuard: (fn: (() => boolean) | null) => void }) {
|
|
550
|
+
const { cms: { multilingual } } = useApp();
|
|
350
551
|
const [ct, setCt] = useState<ContentType | null>(null);
|
|
351
552
|
const [assembled, setAssembled] = useState<AssembledPage | null>(null);
|
|
352
553
|
const [err, setErr] = useState("");
|
|
@@ -585,14 +786,14 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
|
|
|
585
786
|
|
|
586
787
|
<div className="overflow-auto rounded-panel border border-border bg-surface-card p-5">
|
|
587
788
|
<div className="mb-3 flex gap-1">
|
|
588
|
-
{
|
|
789
|
+
{visibleTabs(multilingual).map((t) => (
|
|
589
790
|
<Button key={t} variant="ghost" size="sm" className={tab === t ? "bg-surface-muted text-fg" : "text-fg-muted"} onPress={() => onTab(t)}>{t}</Button>
|
|
590
791
|
))}
|
|
591
792
|
</div>
|
|
592
793
|
{tab === "settings" ? <PageMeta api={api} page={page} onSaved={onChange} onError={setErr} /> : null}
|
|
593
794
|
{tab === "seo" ? <SeoPanel api={api} page={page} onError={setErr} /> : null}
|
|
594
795
|
{tab === "workflow" ? <Workflow api={api} page={page} onChanged={(p) => { onChange(p); }} onError={setErr} /> : null}
|
|
595
|
-
{tab === "i18n" ? <I18n api={api} page={page} onError={setErr} /> : null}
|
|
796
|
+
{tab === "i18n" && multilingual ? <I18n api={api} page={page} onError={setErr} /> : null}
|
|
596
797
|
{tab === "audit" ? <AuditLog api={api} pageId={page.id} onError={setErr} /> : null}
|
|
597
798
|
</div>
|
|
598
799
|
</div>
|
|
@@ -629,7 +830,7 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
|
|
|
629
830
|
const blockId = block.block_id;
|
|
630
831
|
const placementId = block.id;
|
|
631
832
|
|
|
632
|
-
// Load RAW fields (media as ids, richtext as
|
|
833
|
+
// Load RAW fields (media as ids, richtext as a document tree) so the value round-trips on save.
|
|
633
834
|
// A pending optimistic block has no persisted row yet — start empty and skip the fetch
|
|
634
835
|
// (its temp id would 404); when it reconciles to real ids the card remounts and fetches.
|
|
635
836
|
useEffect(() => {
|
|
@@ -853,6 +1054,7 @@ function Inserter({ allowed, btBySlug, onAdd, compact }: { allowed: string[]; bt
|
|
|
853
1054
|
* type has no regions look empty — a wide, blank canvas next to a cramped form.
|
|
854
1055
|
*/
|
|
855
1056
|
function PageMeta({ api, page, onSaved, onError }: { api: Api; page: Page; onSaved: (p: Page) => void; onError: (s: string) => void }) {
|
|
1057
|
+
const { cms: { multilingual, locales } } = useApp();
|
|
856
1058
|
const [title, setTitle] = useState(page.title);
|
|
857
1059
|
const [slug, setSlug] = useState(page.slug);
|
|
858
1060
|
const [locale, setLocale] = useState(page.locale);
|
|
@@ -866,7 +1068,12 @@ function PageMeta({ api, page, onSaved, onError }: { api: Api; page: Page; onSav
|
|
|
866
1068
|
try {
|
|
867
1069
|
// Meta only — `fields` is deliberately omitted so saving here can never clobber
|
|
868
1070
|
// content edited in the canvas (updatePage patches only what it is given).
|
|
869
|
-
|
|
1071
|
+
// `locale` is sent ONLY where it is editable. On a single-locale deployment there is
|
|
1072
|
+
// no control for it, so including it would blind-overwrite whatever the row holds
|
|
1073
|
+
// with mount-time state — reverting an import or another editor's change through a
|
|
1074
|
+
// field this user cannot see. `updatePage` treats an absent key as no-change.
|
|
1075
|
+
const patch = multilingual ? { title, slug: slug.trim(), locale } : { title, slug: slug.trim() };
|
|
1076
|
+
const r = await api.call<{ page?: Page }>("updatePage", { pageId: page.id, ...patch });
|
|
870
1077
|
if (r?.page) onSaved(r.page);
|
|
871
1078
|
setOk(true);
|
|
872
1079
|
setTimeout(() => setOk(false), 1500);
|
|
@@ -883,7 +1090,18 @@ function PageMeta({ api, page, onSaved, onError }: { api: Api; page: Page; onSav
|
|
|
883
1090
|
{ok ? <Banner ok>saved</Banner> : null}
|
|
884
1091
|
<Input label="Title" value={title} onChange={setTitle} />
|
|
885
1092
|
<Input label="Slug" value={slug} onChange={setSlug} />
|
|
886
|
-
|
|
1093
|
+
{/* A SELECT over the declared locales, not free text: a typo'd or blank locale saves
|
|
1094
|
+
fine, previews fine (the editor round-trips the same string) and then 404s on the
|
|
1095
|
+
live site, which is the hardest kind of wrong to see. */}
|
|
1096
|
+
{multilingual ? (
|
|
1097
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
1098
|
+
<span className="font-medium text-fg">Locale</span>
|
|
1099
|
+
<select className={CONTROL} value={locale} onChange={(e) => setLocale(e.target.value)}>
|
|
1100
|
+
{locales.includes(locale) ? null : <option value={locale}>{locale || "(unset)"} — not a declared locale</option>}
|
|
1101
|
+
{locales.map((l) => <option key={l} value={l}>{l}</option>)}
|
|
1102
|
+
</select>
|
|
1103
|
+
</label>
|
|
1104
|
+
) : null}
|
|
887
1105
|
<Button onPress={save} isDisabled={busy || !title.trim() || !slug.trim()}>{busy ? "Saving…" : "Save"}</Button>
|
|
888
1106
|
<KV><span>Status</span><span>{page.status}</span></KV>
|
|
889
1107
|
</div>
|
|
@@ -1095,6 +1313,10 @@ export function MediaLibrary({ api, onError }: { api: Api; onError: (s: string)
|
|
|
1095
1313
|
const [hasMore, setHasMore] = useState(false);
|
|
1096
1314
|
const [selected, setSelected] = useState<Media | null>(null);
|
|
1097
1315
|
const [busy, setBusy] = useState(false);
|
|
1316
|
+
// Trashed files. Deleting no longer removes the R2 object, so without this the bytes stay
|
|
1317
|
+
// publicly fetchable with no way to reach purgeMedia — the case a takedown request needs.
|
|
1318
|
+
const [trash, setTrash] = useState<Media[]>([]);
|
|
1319
|
+
const [showTrash, setShowTrash] = useState(false);
|
|
1098
1320
|
|
|
1099
1321
|
const load = useCallback(
|
|
1100
1322
|
(off: number) => {
|
|
@@ -1109,7 +1331,30 @@ export function MediaLibrary({ api, onError }: { api: Api; onError: (s: string)
|
|
|
1109
1331
|
},
|
|
1110
1332
|
[api, onError],
|
|
1111
1333
|
);
|
|
1334
|
+
const loadTrash = useCallback(() => {
|
|
1335
|
+
api.listTrash().then((r) => setTrash(r.media ?? [])).catch((e) => onError(errMsg(e)));
|
|
1336
|
+
}, [api, onError]);
|
|
1112
1337
|
useEffect(() => { load(0); }, [load]);
|
|
1338
|
+
useEffect(() => { loadTrash(); }, [loadTrash]);
|
|
1339
|
+
|
|
1340
|
+
const restore = async (id: string) => {
|
|
1341
|
+
try {
|
|
1342
|
+
await api.restoreMedia(id);
|
|
1343
|
+
loadTrash();
|
|
1344
|
+
load(0);
|
|
1345
|
+
} catch (e) {
|
|
1346
|
+
onError(errMsg(e));
|
|
1347
|
+
}
|
|
1348
|
+
};
|
|
1349
|
+
const purge = async (id: string) => {
|
|
1350
|
+
if (!confirm("Delete this file permanently? The file itself is removed and cannot be recovered.")) return;
|
|
1351
|
+
try {
|
|
1352
|
+
await api.purgeMedia(id);
|
|
1353
|
+
loadTrash();
|
|
1354
|
+
} catch (e) {
|
|
1355
|
+
onError(errMsg(e));
|
|
1356
|
+
}
|
|
1357
|
+
};
|
|
1113
1358
|
|
|
1114
1359
|
const upload = async (files: FileList | null) => {
|
|
1115
1360
|
if (!files?.length) return;
|
|
@@ -1153,13 +1398,37 @@ export function MediaLibrary({ api, onError }: { api: Api; onError: (s: string)
|
|
|
1153
1398
|
<Button variant="secondary" size="sm" onPress={() => load(offset)}>Load more</Button>
|
|
1154
1399
|
</div>
|
|
1155
1400
|
) : null}
|
|
1401
|
+
{trash.length > 0 ? (
|
|
1402
|
+
<div className="mt-6 border-t border-border pt-4">
|
|
1403
|
+
<button type="button" className="text-small text-fg-muted underline" onClick={() => setShowTrash((v) => !v)}>
|
|
1404
|
+
{showTrash ? "Hide" : "Show"} trash ({trash.length})
|
|
1405
|
+
</button>
|
|
1406
|
+
{showTrash ? (
|
|
1407
|
+
<>
|
|
1408
|
+
<p className="mt-2 text-small text-fg-subtle">
|
|
1409
|
+
Trashed files are hidden from the library but the file itself still exists — a page published
|
|
1410
|
+
while it was in use keeps showing it. Delete permanently to remove the file.
|
|
1411
|
+
</p>
|
|
1412
|
+
<div className="mt-2.5 flex flex-col gap-1.5">
|
|
1413
|
+
{trash.map((m) => (
|
|
1414
|
+
<div key={m.id} className="flex items-center gap-2.5 rounded-lg border border-border bg-surface-muted px-3 py-2">
|
|
1415
|
+
<span className="flex-1 truncate text-small text-fg-muted">{m.file?.filename ?? m.id}</span>
|
|
1416
|
+
<Button variant="secondary" size="sm" onPress={() => restore(m.id)}>Restore</Button>
|
|
1417
|
+
<Button variant="secondary" size="sm" onPress={() => purge(m.id)}>Delete permanently</Button>
|
|
1418
|
+
</div>
|
|
1419
|
+
))}
|
|
1420
|
+
</div>
|
|
1421
|
+
</>
|
|
1422
|
+
) : null}
|
|
1423
|
+
</div>
|
|
1424
|
+
) : null}
|
|
1156
1425
|
{selected ? (
|
|
1157
1426
|
<MediaDetail
|
|
1158
1427
|
api={api}
|
|
1159
1428
|
media={selected}
|
|
1160
1429
|
onClose={() => setSelected(null)}
|
|
1161
1430
|
onSaved={(m) => { setSelected(m); setMedia((prev) => prev.map((x) => (x.id === m.id ? m : x))); }}
|
|
1162
|
-
onDeleted={(id) => { setSelected(null); setMedia((prev) => prev.filter((x) => x.id !== id)); }}
|
|
1431
|
+
onDeleted={(id) => { setSelected(null); setMedia((prev) => prev.filter((x) => x.id !== id)); loadTrash(); }}
|
|
1163
1432
|
onError={onError}
|
|
1164
1433
|
/>
|
|
1165
1434
|
) : null}
|
|
@@ -1185,7 +1454,7 @@ function MediaDetail({ api, media, onClose, onSaved, onDeleted, onError }: { api
|
|
|
1185
1454
|
}
|
|
1186
1455
|
};
|
|
1187
1456
|
const del = async () => {
|
|
1188
|
-
if (!confirm("
|
|
1457
|
+
if (!confirm("Move this file to the trash? It disappears from the library, but a page published while it was in use keeps showing it — delete it permanently from the trash to remove the file itself.")) return;
|
|
1189
1458
|
setBusy(true);
|
|
1190
1459
|
try {
|
|
1191
1460
|
await api.deleteMedia(media.id);
|
|
@@ -1479,11 +1748,22 @@ function plainText(html: string): string {
|
|
|
1479
1748
|
.replace(/\s+/g, " ")
|
|
1480
1749
|
.trim();
|
|
1481
1750
|
}
|
|
1482
|
-
/**
|
|
1751
|
+
/** Readable text for one field value, whatever shape it is. A `richtext` field is a
|
|
1752
|
+
* document tree, so the first non-empty STRING is no longer enough — a block whose only
|
|
1753
|
+
* field is prose would read "empty". Legacy HTML strings still pass through `plainText`. */
|
|
1754
|
+
function fieldText(v: FieldValue): string {
|
|
1755
|
+
if (typeof v === "string") return plainText(v);
|
|
1756
|
+
if (isRichTextDoc(v)) return richTextToPlainText(v).replace(/\s+/g, " ").trim();
|
|
1757
|
+
return "";
|
|
1758
|
+
}
|
|
1759
|
+
/** One-line preview for a collapsed block: the first field with readable text in it. */
|
|
1483
1760
|
function blockPreview(fields: FieldValues): string {
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1761
|
+
let text = "";
|
|
1762
|
+
for (const v of Object.values(fields)) {
|
|
1763
|
+
text = fieldText(v);
|
|
1764
|
+
if (text) break;
|
|
1765
|
+
}
|
|
1766
|
+
if (!text) return "";
|
|
1487
1767
|
return text.length > 90 ? text.slice(0, 90) + "…" : text;
|
|
1488
1768
|
}
|
|
1489
1769
|
function reorderMove(blocks: RenderedBlock[], region: string, i: number, d: number, reorder: (region: string, order: string[]) => void) {
|
package/src/fields.tsx
CHANGED
|
@@ -3,13 +3,19 @@
|
|
|
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 { generateHTML, generateJSON } from "@tiptap/core";
|
|
7
|
+
import Highlight from "@tiptap/extension-highlight";
|
|
8
|
+
import TaskItem from "@tiptap/extension-task-item";
|
|
9
|
+
import TaskList from "@tiptap/extension-task-list";
|
|
10
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
6
11
|
import { useEffect, useRef, useState, type DragEvent, type ReactNode } from "react";
|
|
7
12
|
import type { Api } from "./api";
|
|
8
|
-
import
|
|
13
|
+
import { isRichTextDoc, richTextToPlainText } from "./rich-text";
|
|
14
|
+
import type { FieldDefinition, FieldValue, FieldValues, Media, RichTextDoc } from "./types";
|
|
9
15
|
|
|
10
16
|
// Tokenized bare control (podoba's filled-field skin) for the native inputs that
|
|
11
17
|
// don't map cleanly onto a podoba primitive (number/date/select/file).
|
|
12
|
-
const CONTROL = "h-10 w-full rounded-lg border border-border bg-surface-card px-4 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus:border-brand-green";
|
|
18
|
+
export const CONTROL = "h-10 w-full rounded-lg border border-border bg-surface-card px-4 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus:border-brand-green";
|
|
13
19
|
|
|
14
20
|
function FieldShell({ label, children }: { label: ReactNode; children: ReactNode }) {
|
|
15
21
|
return (
|
|
@@ -33,7 +39,7 @@ function FieldShell({ label, children }: { label: ReactNode; children: ReactNode
|
|
|
33
39
|
* Returns "" for anything Date can't parse — a legacy or hand-written column value must
|
|
34
40
|
* not reach the input as `NaN-NaN-NaNTNaN:NaN`, which the browser silently discards.
|
|
35
41
|
*/
|
|
36
|
-
function toLocalInput(value: string): string {
|
|
42
|
+
export function toLocalInput(value: string): string {
|
|
37
43
|
const d = new Date(value);
|
|
38
44
|
if (Number.isNaN(d.getTime())) return "";
|
|
39
45
|
const pad = (n: number) => String(n).padStart(2, "0");
|
|
@@ -41,12 +47,12 @@ function toLocalInput(value: string): string {
|
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
/** A `datetime-local` string (local wall clock) -> the UTC ISO instant we store. */
|
|
44
|
-
function fromLocalInput(local: string): string | null {
|
|
50
|
+
export function fromLocalInput(local: string): string | null {
|
|
45
51
|
const at = new Date(local);
|
|
46
52
|
return Number.isNaN(at.getTime()) ? null : at.toISOString();
|
|
47
53
|
}
|
|
48
54
|
|
|
49
|
-
function formatWhen(value: string): string {
|
|
55
|
+
export function formatWhen(value: string): string {
|
|
50
56
|
const at = new Date(value);
|
|
51
57
|
return Number.isNaN(at.getTime()) ? value : at.toLocaleString();
|
|
52
58
|
}
|
|
@@ -195,11 +201,11 @@ function FieldInput({ def, value, onChange, api, hideLabelAs, siblings }: { def:
|
|
|
195
201
|
<Textarea label={label} value={asText(value)} onChange={onChange} />
|
|
196
202
|
);
|
|
197
203
|
case "richtext":
|
|
198
|
-
// A rich-text value is
|
|
199
|
-
//
|
|
204
|
+
// A rich-text value is a document tree. A legacy HTML string still opens (it seeds
|
|
205
|
+
// the editor as-is) and is upgraded to a doc by the first save.
|
|
200
206
|
return (
|
|
201
207
|
<FieldShell label={label}>
|
|
202
|
-
<RichText value={
|
|
208
|
+
<RichText value={value as RichTextDoc | string | null} onChange={onChange as (v: RichTextDoc) => void} />
|
|
203
209
|
</FieldShell>
|
|
204
210
|
);
|
|
205
211
|
case "number":
|
|
@@ -271,13 +277,90 @@ function FieldInput({ def, value, onChange, api, hideLabelAs, siblings }: { def:
|
|
|
271
277
|
|
|
272
278
|
// --- rich text (WYSIWYG) --------------------------------------------------------------
|
|
273
279
|
// The `richtext` field is podoba's Notion-style BlockEditor (Tiptap): `/` slash palette,
|
|
274
|
-
// block conversion, inline bubble toolbar.
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
280
|
+
// block conversion, inline bubble toolbar.
|
|
281
|
+
//
|
|
282
|
+
// The STORED value is a document tree (`RichTextDoc`), never HTML. BlockEditor's own
|
|
283
|
+
// value contract is an HTML string — its docs call that presentation-only — so the
|
|
284
|
+
// conversion happens here, at the boundary, and the HTML never leaves this component:
|
|
285
|
+
// seeded from the stored doc on mount, converted back to a doc on every change.
|
|
286
|
+
//
|
|
287
|
+
// The extension set MUST match BlockEditor's, or a round-trip silently drops whatever
|
|
288
|
+
// only its schema knows (task lists, highlights). Kept beside it here for that reason.
|
|
289
|
+
const RT_EXTENSIONS = [
|
|
290
|
+
StarterKit.configure({ heading: { levels: [1, 2, 3] }, link: { openOnClick: false, autolink: true } }),
|
|
291
|
+
Highlight,
|
|
292
|
+
TaskList,
|
|
293
|
+
TaskItem.configure({ nested: true }),
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
/** Parse editor HTML into a document. Never throws: a parse failure yields an empty
|
|
297
|
+
* document rather than taking the render down (this runs on every keystroke). */
|
|
298
|
+
function htmlToDoc(html: string): RichTextDoc {
|
|
299
|
+
try {
|
|
300
|
+
return generateJSON(html, RT_EXTENSIONS) as RichTextDoc;
|
|
301
|
+
} catch (e) {
|
|
302
|
+
console.error("pramen/cms-editor: could not parse editor HTML", e);
|
|
303
|
+
return { type: "doc", content: [] };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Seed HTML for the editor. A legacy HTML string passes through untouched — that is the
|
|
308
|
+
* migration ramp (see `RichText`, which upgrades it on mount).
|
|
309
|
+
*
|
|
310
|
+
* `generateHTML` throws a RangeError for any node or mark outside RT_EXTENSIONS, and this
|
|
311
|
+
* runs in a useState initializer with no ErrorBoundary above it — so an un-normalized
|
|
312
|
+
* document (a custom `richTextSchema`, an import, a bootstrap seed, `ctx.db.exec`) would
|
|
313
|
+
* throw during render and blank the whole SPA, not just this field. Fail to an empty
|
|
314
|
+
* editor and say so instead. */
|
|
315
|
+
function docToEditorHtml(value: RichTextDoc | string | null | undefined): string {
|
|
316
|
+
if (typeof value === "string") return value;
|
|
317
|
+
if (!isRichTextDoc(value)) return "";
|
|
318
|
+
try {
|
|
319
|
+
return generateHTML(value, RT_EXTENSIONS);
|
|
320
|
+
} catch (e) {
|
|
321
|
+
console.error("pramen/cms-editor: rich-text document uses nodes this editor cannot render", e);
|
|
322
|
+
return "";
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function RichText({ value, onChange }: { value: RichTextDoc | string | null; onChange: (v: RichTextDoc) => void }) {
|
|
327
|
+
// BlockEditor requires its own HTML echoed back VERBATIM — normalising in render would
|
|
328
|
+
// re-seed the document on every keystroke and throw the caret back to the start. So the
|
|
329
|
+
// HTML lives in local state and the doc goes upward.
|
|
330
|
+
const [html, setHtml] = useState(() => docToEditorHtml(value));
|
|
331
|
+
const emitted = useRef<RichTextDoc | null>(null);
|
|
332
|
+
|
|
333
|
+
// Upgrade a legacy HTML value to a document AS SOON AS IT OPENS, not on first edit of
|
|
334
|
+
// this field. The server only tolerates a legacy string that is byte-identical to what
|
|
335
|
+
// is stored, and both renderers emit nothing for a string — so a value that is never
|
|
336
|
+
// upgraded stays invisible on the site forever. Converting on mount means any ordinary
|
|
337
|
+
// save (even of a sibling field) writes it back as a document.
|
|
338
|
+
const upgraded = useRef(false);
|
|
339
|
+
useEffect(() => {
|
|
340
|
+
if (upgraded.current || typeof value !== "string" || value === "") return;
|
|
341
|
+
upgraded.current = true;
|
|
342
|
+
const doc = htmlToDoc(value);
|
|
343
|
+
emitted.current = doc;
|
|
344
|
+
onChange(doc);
|
|
345
|
+
}, [value, onChange]);
|
|
346
|
+
|
|
347
|
+
// Re-seed only when the parent hands us a doc that is not the one we last emitted —
|
|
348
|
+
// i.e. the form switched to a different block, not our own change coming back around.
|
|
349
|
+
useEffect(() => {
|
|
350
|
+
if (value !== null && value === emitted.current) return;
|
|
351
|
+
setHtml(docToEditorHtml(value));
|
|
352
|
+
// Only the incoming value should re-seed; `html` is this effect's output, not its input.
|
|
353
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
354
|
+
}, [value]);
|
|
355
|
+
|
|
356
|
+
const handleChange = (nextHtml: string) => {
|
|
357
|
+
setHtml(nextHtml);
|
|
358
|
+
const doc = htmlToDoc(nextHtml);
|
|
359
|
+
emitted.current = doc;
|
|
360
|
+
onChange(doc);
|
|
361
|
+
};
|
|
278
362
|
|
|
279
|
-
|
|
280
|
-
return <BlockEditor value={value} onChange={onChange} minHeight={180} placeholder="Write, or press '/' for blocks…" />;
|
|
363
|
+
return <BlockEditor value={html} onChange={handleChange} minHeight={180} placeholder="Write, or press '/' for blocks…" />;
|
|
281
364
|
}
|
|
282
365
|
|
|
283
366
|
/**
|
|
@@ -327,7 +410,11 @@ function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition;
|
|
|
327
410
|
for (const f of fields) {
|
|
328
411
|
if (!readable.includes(f.type)) continue;
|
|
329
412
|
const v = it[f.name];
|
|
330
|
-
|
|
413
|
+
// A `richtext` value is a document tree, so a string check alone would skip the one
|
|
414
|
+
// prose field an item has — exactly the row this summary exists to distinguish.
|
|
415
|
+
const text = isRichTextDoc(v) ? richTextToPlainText(v) : typeof v === "string" ? v.replace(/<[^>]+>/g, " ") : "";
|
|
416
|
+
const trimmed = text.replace(/\s+/g, " ").trim();
|
|
417
|
+
if (trimmed) return trimmed.slice(0, 80);
|
|
331
418
|
}
|
|
332
419
|
return "";
|
|
333
420
|
};
|
package/src/main.tsx
CHANGED
|
@@ -3,10 +3,20 @@ import { StrictMode } from "react";
|
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
4
|
import { pageRegistry, routes } from "virtual:buzola/routes";
|
|
5
5
|
import { AppProvider } from "./app-context";
|
|
6
|
+
import { DOCUMENT_TITLE } from "./brand";
|
|
6
7
|
|
|
7
8
|
// Styling is podoba: @podoba/tokens/variables.css + the compiled Tailwind (podoba
|
|
8
9
|
// preset) are <link>ed by index.html (see scripts/build.ts). No more inline CSS.
|
|
9
10
|
|
|
11
|
+
// The <title> in index.html is baked at build time, before any host config exists, so it
|
|
12
|
+
// can only be the default. Re-apply the configured wordmark once /config.js has been read —
|
|
13
|
+
// the static tag stays the pre-hydration fallback.
|
|
14
|
+
//
|
|
15
|
+
// `DOCUMENT_TITLE`, not `${BRAND.title} editor`: appending a fixed English noun to the one
|
|
16
|
+
// string this feature exists to hand over would put a foreign word in a rebranded client's
|
|
17
|
+
// tab, and `suffix: null` ("just our name") could never drop it.
|
|
18
|
+
document.title = DOCUMENT_TITLE;
|
|
19
|
+
|
|
10
20
|
const el = document.getElementById("app");
|
|
11
21
|
if (el)
|
|
12
22
|
createRoot(el).render(
|