@tideworthy/tidysites-platform 0.3.0 → 0.5.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ - Editor: image picker over the site's media library with upload, for block fields whose id names an image URL (`imageUrl`, `photoUrl`, …) and for record fields of kind image.
6
+ - Editor: record details. A `[data-tidy-record]` region (with `data-tidy-record-id` and `data-tidy-record-label`) gets an "Edit details" chip; the business record is always reachable from the pill. Forms come from EmDash's manifest (string, text, richText, number, boolean, select, image). Record saves publish at once, since other pages read the published version.
7
+ - Editor: Publish publishes every entry saved in this browser session (pending list in sessionStorage); the pill shows the count.
8
+ - Editor: EmDash's toolbar is hidden and `data-emdash-ref` attributes are removed on block pages, so one editor owns the page. Article pages, which do not mount this editor, keep EmDash's inline editing.
9
+
10
+ ## 0.4.0
11
+
12
+ - The Tideworthy on-site editor: `@tideworthy/tidysites-platform/editor` is an Astro component a template renders in edit mode with the page's block schemas and block list. Every framed block (`data-tidy-block`, `data-tidy-type`, list in `data-tidy-blocks`) gets a hover chip with Edit, move up, move down, add a section below, and remove; Edit opens a side panel whose form comes from the block's Block Kit field schema (text, multiline, number, toggle, select, repeater). Saves go through EmDash's `PUT /_emdash/api/content/:collection/:id` as a draft with the editor's session; Publish is EmDash's publish call. Exists because EmDash's inline editor renders custom blocks as "edit in admin" placeholders. Pure block operations in `@tideworthy/tidysites-platform/editor/ops`.
13
+
3
14
  ## 0.3.0
4
15
 
5
16
  - `tidyPlatform()` registers a middleware: with a `TIDY_FRAME_ANCESTORS` variable on the site (space-separated https origins), responses carry `Content-Security-Policy: frame-ancestors 'self' <origins>` in place of EmDash's `X-Frame-Options: SAMEORIGIN`, so the platform can embed the staging preview. Inert without the variable. New export `@tideworthy/tidysites-platform/middleware`.
package/README.md CHANGED
@@ -43,6 +43,20 @@ export { default, PluginBridge } from "@emdash-cms/cloudflare/worker";
43
43
 
44
44
  EmDash answers every page with `X-Frame-Options: SAMEORIGIN` unless a Content-Security-Policy is present. `tidyPlatform()` registers a middleware that, when the site carries a `TIDY_FRAME_ANCESTORS` variable (space-separated https origins), replaces that header with `Content-Security-Policy: frame-ancestors 'self' <origins>` so the platform's website page can embed the site. Without the variable nothing changes.
45
45
 
46
+ ## The on-site editor
47
+
48
+ EmDash's inline editor edits plain rich text; a Block Kit block inside a Portable Text field renders as an "edit in admin" placeholder. Templates built from blocks use the Tideworthy editor instead:
49
+
50
+ ```astro
51
+ ---
52
+ import TidyEditor from "@tideworthy/tidysites-platform/editor";
53
+ const editMode = Astro.cookies.get("emdash-edit-mode")?.value === "true";
54
+ ---
55
+ {editMode && <TidyEditor collection="pages" id={page.data.id} blocks={blockSchema} value={content} />}
56
+ ```
57
+
58
+ The template renders the block list inside `<div data-tidy-blocks>` and each custom block inside `<div data-tidy-block={_key} data-tidy-type={_type}>`, and passes `PortableText` a copy of the array (`[...blocks]`) so EmDash's inline editor does not mount on it. `blocks` is the plugin definition's `portableTextBlocks`. Hovering a block shows a chip (Edit, move, add below, remove); Edit opens a panel built from the block's fields; Save writes a draft through EmDash's content API with the signed-in editor's session and reloads; Publish publishes every entry saved in the session. A text field whose id ends in `imageUrl` (or `photoUrl`, `logoUrl`) gets an image picker over the media library with upload. Records: wrap a region in `data-tidy-record="services" data-tidy-record-id="…" data-tidy-record-label="Service details"` and pass `records` to the component; the form comes from EmDash's manifest and saves publish at once. Pass the business record in `records` and it is always one click away in the pill. On these pages EmDash's toolbar is hidden and its inline editing is off; article bodies keep EmDash's inline editor by passing the original array and not mounting this component.
59
+
46
60
  ## Bindings the routes read
47
61
 
48
62
  | Binding | Type | Purpose |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tideworthy/tidysites-platform",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "The Tidysites platform integration for EmDash sites: bootstrap, sessions, maintenance, health, and publish hooks under /_tidy/*.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,8 @@
19
19
  ".": "./src/index.ts",
20
20
  "./plugin": "./src/plugin.ts",
21
21
  "./middleware": "./src/middleware.ts",
22
+ "./editor": "./src/editor/TidyEditor.astro",
23
+ "./editor/ops": "./src/editor/ops.ts",
22
24
  "./client": "./src/client.ts",
23
25
  "./routes/bootstrap": "./src/routes/bootstrap.ts",
24
26
  "./routes/session": "./src/routes/session.ts",
@@ -29,7 +31,7 @@
29
31
  "./routes/maintenance": "./src/routes/maintenance.ts"
30
32
  },
31
33
  "scripts": {
32
- "typecheck": "tsc --noEmit",
34
+ "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.editor.json",
33
35
  "test": "vitest run",
34
36
  "check": "npm run typecheck && npm run test"
35
37
  },
@@ -0,0 +1,74 @@
1
+ ---
2
+ /**
3
+ * The Tideworthy on-site editor for block-built pages. Render it from the
4
+ * layout when the page is in edit mode, with the entry the page shows, the
5
+ * block schemas the template declares, and the block list as rendered. The
6
+ * template frames each custom block with `data-tidy-block` (its key) and
7
+ * `data-tidy-type`, and wraps the list in `data-tidy-blocks`. A record region
8
+ * (`data-tidy-record`, `data-tidy-record-id`, `data-tidy-record-label`) gets an
9
+ * "Edit details" chip whose form comes from EmDash's manifest.
10
+ */
11
+ import type { Block, BlockSchema } from "./ops";
12
+
13
+ interface Props {
14
+ collection: string;
15
+ id: string;
16
+ field?: string;
17
+ blocks: BlockSchema[];
18
+ value: Block[];
19
+ /** Records reachable from the pill (the business) or from `[data-tidy-record]` regions on the page. */
20
+ records?: Array<{ collection: string; id: string; label: string }>;
21
+ }
22
+ const { collection, id, field = "content", blocks, value, records = [] } = Astro.props;
23
+ const config = JSON.stringify({ collection, id, field, blocks, value, records });
24
+ ---
25
+ <script type="application/json" id="tidy-editor-config" set:html={config.replace(/</g, "\\u003c")} />
26
+ <script src="./runtime.ts"></script>
27
+ <style is:global>
28
+ #emdash-toolbar { display: none !important; }
29
+ [data-tidy-record] { outline: 2px dashed transparent; outline-offset: 4px; transition: outline-color 120ms; cursor: pointer; }
30
+ [data-tidy-record]:hover, [data-tidy-record].tidy-active { outline-color: #2b7fd1; }
31
+ .tidy-image-preview { display: grid; place-items: center; min-height: 96px; border: 1px solid #d3dcd6; border-radius: 6px; background: #f6f7f5; overflow: hidden; }
32
+ .tidy-image-preview img { max-width: 100%; max-height: 180px; display: block; }
33
+ .tidy-image-empty { color: #7b877f; font-size: 13px; padding: 8px; }
34
+ .tidy-image-actions { display: flex; gap: 6px; flex-wrap: wrap; }
35
+ .tidy-image-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; max-height: 260px; overflow: auto; padding: 6px; border: 1px solid #d3dcd6; border-radius: 6px; }
36
+ .tidy-image-grid[hidden] { display: none; }
37
+ .tidy-image-thumb { padding: 0; border: 2px solid transparent; border-radius: 6px; overflow: hidden; background: #fff; cursor: pointer; aspect-ratio: 1; }
38
+ .tidy-image-thumb:hover { border-color: #2b7fd1; }
39
+ .tidy-image-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
40
+ [data-tidy-block] { position: relative; outline: 2px dashed transparent; outline-offset: -2px; transition: outline-color 120ms; cursor: pointer; }
41
+ [data-tidy-block]:hover, [data-tidy-block].tidy-active { outline-color: #2b7fd1; }
42
+ .tidy-chip { position: absolute; z-index: 2147483000; display: flex; gap: 4px; align-items: center; padding: 4px 6px; background: #17211c; color: #fff; border-radius: 8px; font: 500 13px/1.2 system-ui, sans-serif; box-shadow: 0 6px 20px rgba(0,0,0,.25); }
43
+ .tidy-chip[hidden], .tidy-panel[hidden], .tidy-status[hidden] { display: none; }
44
+ .tidy-chip-label { padding: 0 6px 0 4px; opacity: .85; }
45
+ .tidy-btn { appearance: none; border: 1px solid rgba(255,255,255,.25); background: rgba(255,255,255,.08); color: inherit; border-radius: 6px; padding: 4px 8px; font: inherit; cursor: pointer; }
46
+ .tidy-btn:hover { background: rgba(255,255,255,.18); }
47
+ .tidy-btn:disabled { opacity: .35; cursor: default; }
48
+ .tidy-btn-primary { background: #2b7fd1; border-color: #2b7fd1; color: #fff; }
49
+ .tidy-btn-primary:hover { background: #2470bd; }
50
+ .tidy-btn-danger:hover { background: #b3261e; border-color: #b3261e; }
51
+ .tidy-btn-sm { padding: 3px 8px; font-size: 12px; }
52
+ .tidy-add-end { display: block; width: calc(100% - 32px); margin: 16px; padding: 14px; border: 2px dashed #2b7fd1; border-radius: 10px; background: rgba(43,127,209,.06); color: #2b7fd1; font: 600 14px system-ui, sans-serif; cursor: pointer; }
53
+ .tidy-panel { position: fixed; top: 0; right: 0; bottom: 0; width: min(420px, 100vw); z-index: 2147483001; overflow: auto; background: #fff; color: #17211c; border-left: 1px solid #d3dcd6; box-shadow: -12px 0 40px rgba(0,0,0,.18); padding: 20px; font: 14px/1.45 system-ui, sans-serif; }
54
+ .tidy-panel-head h2 { margin: 0 0 4px; font-size: 18px; }
55
+ .tidy-panel-head p { margin: 0 0 16px; color: #4a5851; }
56
+ .tidy-form { display: grid; gap: 14px; }
57
+ .tidy-field { display: grid; gap: 6px; }
58
+ .tidy-field-label { font-weight: 600; font-size: 13px; }
59
+ .tidy-field input[type="text"], .tidy-field input[type="number"], .tidy-field textarea, .tidy-field select { width: 100%; box-sizing: border-box; border: 1px solid #c9d2cc; border-radius: 6px; padding: 8px 10px; font: inherit; background: #fff; color: inherit; }
60
+ .tidy-field textarea { resize: vertical; min-height: 88px; }
61
+ .tidy-field-toggle { display: flex; align-items: center; gap: 10px; }
62
+ .tidy-repeater { border: 1px solid #d3dcd6; border-radius: 8px; padding: 12px; display: grid; gap: 12px; }
63
+ .tidy-repeater legend { font-weight: 600; padding: 0 6px; }
64
+ .tidy-repeater-item { display: grid; gap: 10px; padding: 12px; background: #f6f7f5; border-radius: 6px; }
65
+ .tidy-panel .tidy-btn { color: #17211c; border-color: #c9d2cc; background: #fff; }
66
+ .tidy-panel .tidy-btn-primary { color: #fff; background: #2b7fd1; border-color: #2b7fd1; }
67
+ .tidy-panel .tidy-btn-danger { color: #b3261e; }
68
+ .tidy-panel-actions { display: flex; gap: 8px; justify-content: flex-end; padding-top: 8px; border-top: 1px solid #e6ede8; }
69
+ .tidy-add-list { display: grid; gap: 8px; margin-bottom: 16px; }
70
+ .tidy-add-item { display: grid; gap: 2px; text-align: left; padding: 10px 12px; border: 1px solid #d3dcd6; border-radius: 8px; background: #fff; cursor: pointer; font: inherit; }
71
+ .tidy-add-item:hover { border-color: #2b7fd1; background: rgba(43,127,209,.06); }
72
+ .tidy-add-item span { color: #4a5851; font-size: 13px; }
73
+ .tidy-status { position: fixed; left: 16px; bottom: 16px; z-index: 2147483000; display: flex; gap: 10px; align-items: center; padding: 8px 12px; background: #17211c; color: #fff; border-radius: 999px; font: 500 13px system-ui, sans-serif; box-shadow: 0 6px 20px rgba(0,0,0,.25); }
74
+ </style>
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Pure operations on a page's block list (a Portable Text array whose custom
3
+ * blocks carry `_type` and `_key`). The editor runtime calls these; the tests
4
+ * cover them without a browser.
5
+ */
6
+ export type Block = { _type: string; _key?: string } & Record<string, unknown>;
7
+
8
+ export interface FieldSchema {
9
+ type: "text_input" | "number_input" | "toggle" | "select" | "repeater" | string;
10
+ action_id: string;
11
+ label: string;
12
+ multiline?: boolean;
13
+ options?: Array<{ label: string; value: string }>;
14
+ fields?: FieldSchema[];
15
+ item_label?: string;
16
+ min_items?: number;
17
+ max_items?: number;
18
+ }
19
+
20
+ export interface BlockSchema {
21
+ type: string;
22
+ label: string;
23
+ category?: string;
24
+ description?: string;
25
+ fields: FieldSchema[];
26
+ }
27
+
28
+ export function newKey(random: () => number = Math.random): string {
29
+ let s = "";
30
+ for (let i = 0; i < 12; i++) s += "abcdefghijklmnopqrstuvwxyz0123456789"[Math.floor(random() * 36)];
31
+ return s;
32
+ }
33
+
34
+ export function findBlock(blocks: Block[], key: string): Block | undefined {
35
+ return blocks.find((b) => b._key === key);
36
+ }
37
+
38
+ /** The block with `key` gets `data` merged over it; `_type` and `_key` never change. */
39
+ export function updateBlock(blocks: Block[], key: string, data: Record<string, unknown>): Block[] {
40
+ return blocks.map((b) => (b._key === key ? { ...b, ...data, _type: b._type, _key: b._key } : b));
41
+ }
42
+
43
+ export function moveBlock(blocks: Block[], key: string, direction: -1 | 1): Block[] {
44
+ const i = blocks.findIndex((b) => b._key === key);
45
+ const j = i + direction;
46
+ if (i < 0 || j < 0 || j >= blocks.length) return blocks;
47
+ const out = blocks.slice();
48
+ const a = out[i] as Block;
49
+ out[i] = out[j] as Block;
50
+ out[j] = a;
51
+ return out;
52
+ }
53
+
54
+ export function removeBlock(blocks: Block[], key: string): Block[] {
55
+ return blocks.filter((b) => b._key !== key);
56
+ }
57
+
58
+ /** Insert a new block of `type` after the block with `afterKey` (or at the end). */
59
+ export function insertBlock(blocks: Block[], type: string, afterKey: string | null, data: Record<string, unknown>, key = newKey()): Block[] {
60
+ const block: Block = { ...data, _type: type, _key: key };
61
+ const i = afterKey ? blocks.findIndex((b) => b._key === afterKey) : -1;
62
+ if (i < 0) return [...blocks, block];
63
+ return [...blocks.slice(0, i + 1), block, ...blocks.slice(i + 1)];
64
+ }
65
+
66
+ /** Empty values for every field in a schema, so a new block renders without holes. */
67
+ export function defaultsFor(schema: BlockSchema): Record<string, unknown> {
68
+ const out: Record<string, unknown> = {};
69
+ for (const f of schema.fields) {
70
+ if (f.type === "toggle") out[f.action_id] = false;
71
+ else if (f.type === "number_input") out[f.action_id] = null;
72
+ else if (f.type === "select") out[f.action_id] = f.options?.[0]?.value ?? "";
73
+ else if (f.type === "repeater") out[f.action_id] = [];
74
+ else out[f.action_id] = "";
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /** Coerce a raw form value into the field's stored type. */
80
+ export function coerce(field: FieldSchema, raw: unknown): unknown {
81
+ switch (field.type) {
82
+ case "toggle":
83
+ return raw === true || raw === "on" || raw === "true";
84
+ case "number_input": {
85
+ if (raw === "" || raw === null || raw === undefined) return null;
86
+ const n = Number(raw);
87
+ return Number.isFinite(n) ? n : null;
88
+ }
89
+ case "repeater":
90
+ return Array.isArray(raw) ? raw : [];
91
+ default:
92
+ return typeof raw === "string" ? raw : raw === null || raw === undefined ? "" : String(raw);
93
+ }
94
+ }
@@ -0,0 +1,537 @@
1
+ /// <reference lib="dom" />
2
+ /// <reference lib="dom.iterable" />
3
+ /**
4
+ * The Tideworthy on-site editor. Runs on a page rendered in edit mode.
5
+ *
6
+ * Sections: every custom block sits in a frame (`[data-tidy-block]`, rendered
7
+ * by the template); hovering shows a chip with Edit, Up, Down, Add and Remove,
8
+ * and Edit opens a panel whose form comes from the block's field schema.
9
+ *
10
+ * Records: a region wrapped in `[data-tidy-record]` (a service's header, say)
11
+ * gets an "Edit … details" chip whose form comes from EmDash's manifest for
12
+ * that collection; the business record is always reachable from the pill.
13
+ *
14
+ * Images: a block field whose id names an image URL, or a record field of
15
+ * kind image, gets a picker over the site's media library with upload.
16
+ *
17
+ * Saves go through EmDash's content API with the editor's session, as drafts;
18
+ * Publish publishes every entry touched in this browser session.
19
+ */
20
+ import { coerce, defaultsFor, insertBlock, moveBlock, removeBlock, updateBlock, type Block, type BlockSchema, type FieldSchema } from "./ops";
21
+
22
+ interface RecordRef {
23
+ collection: string;
24
+ id: string;
25
+ label: string;
26
+ }
27
+
28
+ interface Config {
29
+ collection: string;
30
+ id: string;
31
+ field: string;
32
+ blocks: BlockSchema[];
33
+ value: Block[];
34
+ records?: RecordRef[];
35
+ }
36
+
37
+ interface ManifestField {
38
+ kind: string;
39
+ label?: string;
40
+ options?: Array<{ value: string; label: string }> | Record<string, unknown>;
41
+ }
42
+
43
+ interface MediaItem {
44
+ id: string;
45
+ filename: string;
46
+ mimeType: string;
47
+ width?: number | null;
48
+ height?: number | null;
49
+ alt?: string | null;
50
+ storageKey: string;
51
+ url?: string;
52
+ }
53
+
54
+ const API = "/_emdash/api";
55
+ const CSRF = { "X-EmDash-Request": "1" };
56
+ const PENDING_KEY = "tidy-pending";
57
+ const IMAGE_FIELD = /(image|photo|logo|picture)url$/i;
58
+ /** Record fields the panel never shows: system, structural, or edited elsewhere. */
59
+ const HIDDEN_RECORD_FIELDS = new Set(["content", "sort", "brand_primary", "brand_accent", "font_display", "font_body"]);
60
+
61
+ function el<K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Record<string, string> = {}, children: Array<Node | string> = []): HTMLElementTagNameMap[K] {
62
+ const e = document.createElement(tag);
63
+ for (const [k, v] of Object.entries(attrs)) {
64
+ if (k === "class") e.className = v;
65
+ else if (k === "text") e.textContent = v;
66
+ else e.setAttribute(k, v);
67
+ }
68
+ for (const c of children) e.append(c);
69
+ return e;
70
+ }
71
+
72
+ function mediaUrl(m: MediaItem): string {
73
+ return m.url ?? `${API}/media/file/${m.storageKey}`;
74
+ }
75
+
76
+ /** The value an image record field stores: EmDash's media value for a library item. */
77
+ function mediaValue(m: MediaItem): Record<string, unknown> {
78
+ return { provider: "local", id: m.id, alt: m.alt ?? "", width: m.width ?? undefined, height: m.height ?? undefined, mimeType: m.mimeType, filename: m.filename, meta: { storageKey: m.storageKey } };
79
+ }
80
+
81
+ function api(path: string, init: RequestInit = {}): Promise<Response> {
82
+ return fetch(`${API}${path}`, { credentials: "same-origin", ...init, headers: { ...CSRF, ...(init.headers as Record<string, string> | undefined) } });
83
+ }
84
+
85
+ export function start(config: Config): void {
86
+ let value: Block[] = config.value;
87
+ const schemaOf = (type: string) => config.blocks.find((b) => b.type === type);
88
+ const frames = () => Array.from(document.querySelectorAll<HTMLElement>("[data-tidy-block]"));
89
+
90
+ // EmDash's inline editor is for prose pages; here the sections and records own their fields.
91
+ document.querySelectorAll("[data-emdash-ref]").forEach((n) => n.removeAttribute("data-emdash-ref"));
92
+
93
+ // --- pending publishes ---------------------------------------------------
94
+ const pending = new Set<string>(JSON.parse(sessionStorage.getItem(PENDING_KEY) ?? "[]") as string[]);
95
+ const persistPending = () => sessionStorage.setItem(PENDING_KEY, JSON.stringify([...pending]));
96
+
97
+ // --- status pill ---------------------------------------------------------
98
+ const status = el("div", { class: "tidy-status" });
99
+ const statusText = el("span");
100
+ const bizBtn = el("button", { type: "button", class: "tidy-btn", text: "Business details" });
101
+ const publishBtn = el("button", { type: "button", class: "tidy-btn tidy-btn-primary", text: "Publish" });
102
+ status.append(statusText, bizBtn, publishBtn);
103
+ document.body.append(status);
104
+ const business = (config.records ?? []).find((r) => r.collection === "business");
105
+ bizBtn.hidden = !business;
106
+ bizBtn.addEventListener("click", () => business && void openRecord(business));
107
+ function refreshStatus(text?: string) {
108
+ const n = pending.size;
109
+ statusText.textContent = text ?? (n === 0 ? "Editing" : `${n} unpublished change${n === 1 ? "" : "s"}`);
110
+ publishBtn.hidden = n === 0 && !text?.startsWith("Publish failed");
111
+ }
112
+ refreshStatus();
113
+ publishBtn.addEventListener("click", async () => {
114
+ publishBtn.disabled = true;
115
+ refreshStatus("Publishing");
116
+ const targets = pending.size ? [...pending] : [`${config.collection}/${config.id}`];
117
+ let failed = 0;
118
+ for (const t of targets) {
119
+ const [collection, id] = t.split("/") as [string, string];
120
+ const r = await api(`/content/${encodeURIComponent(collection)}/${encodeURIComponent(id)}/publish`, { method: "POST" });
121
+ if (r.ok) pending.delete(t);
122
+ else failed++;
123
+ }
124
+ persistPending();
125
+ if (failed === 0) location.reload();
126
+ else {
127
+ publishBtn.disabled = false;
128
+ refreshStatus(`Publish failed for ${failed}`);
129
+ }
130
+ });
131
+
132
+ /**
133
+ * Page blocks save as a draft the page previews in edit mode; Publish makes
134
+ * them live. Record details (the business, a service) are read by many
135
+ * pages from their published version, so a draft would look like nothing
136
+ * happened: they save and publish in one step.
137
+ */
138
+ async function saveEntry(collection: string, id: string, data: Record<string, unknown>, publishNow = false): Promise<boolean> {
139
+ refreshStatus("Saving");
140
+ const r = await api(`/content/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ data }) });
141
+ if (!r.ok) {
142
+ refreshStatus(`Save failed (${r.status})`);
143
+ return false;
144
+ }
145
+ if (publishNow) {
146
+ const p = await api(`/content/${encodeURIComponent(collection)}/${encodeURIComponent(id)}/publish`, { method: "POST" });
147
+ if (!p.ok) {
148
+ pending.add(`${collection}/${id}`);
149
+ persistPending();
150
+ refreshStatus(`Saved; publish failed (${p.status})`);
151
+ return false;
152
+ }
153
+ } else {
154
+ pending.add(`${collection}/${id}`);
155
+ persistPending();
156
+ }
157
+ refreshStatus("Saved. Reloading");
158
+ location.reload();
159
+ return true;
160
+ }
161
+ const saveBlocks = (next: Block[]) => saveEntry(config.collection, config.id, { [config.field]: next });
162
+
163
+ // --- chip on the hovered frame or record ---------------------------------
164
+ const chip = el("div", { class: "tidy-chip", hidden: "" });
165
+ const chipLabel = el("span", { class: "tidy-chip-label" });
166
+ const bEdit = el("button", { type: "button", class: "tidy-btn", text: "Edit" });
167
+ const bUp = el("button", { type: "button", class: "tidy-btn", title: "Move up", text: "↑" });
168
+ const bDown = el("button", { type: "button", class: "tidy-btn", title: "Move down", text: "↓" });
169
+ const bAdd = el("button", { type: "button", class: "tidy-btn", title: "Add a section below", text: "+" });
170
+ const bRemove = el("button", { type: "button", class: "tidy-btn tidy-btn-danger", title: "Remove this section", text: "Remove" });
171
+ chip.append(chipLabel, bEdit, bUp, bDown, bAdd, bRemove);
172
+ document.body.append(chip);
173
+ let current: HTMLElement | null = null;
174
+
175
+ function placeChip(target: HTMLElement) {
176
+ const r = target.getBoundingClientRect();
177
+ chip.style.top = `${Math.max(8, r.top + window.scrollY - 2)}px`;
178
+ chip.style.left = `${Math.max(8, r.left + window.scrollX + 8)}px`;
179
+ chip.hidden = false;
180
+ document.querySelectorAll(".tidy-active").forEach((n) => n.classList.remove("tidy-active"));
181
+ target.classList.add("tidy-active");
182
+ }
183
+ function showBlockChip(frame: HTMLElement) {
184
+ current = frame;
185
+ const key = frame.dataset.tidyBlock ?? "";
186
+ chipLabel.textContent = schemaOf(frame.dataset.tidyType ?? "")?.label ?? frame.dataset.tidyType ?? "";
187
+ const i = value.findIndex((b) => b._key === key);
188
+ bUp.disabled = i <= 0;
189
+ bDown.disabled = i < 0 || i >= value.length - 1;
190
+ for (const b of [bUp, bDown, bAdd, bRemove]) b.hidden = false;
191
+ bEdit.textContent = "Edit";
192
+ placeChip(frame);
193
+ }
194
+ function showRecordChip(region: HTMLElement) {
195
+ current = region;
196
+ chipLabel.textContent = region.dataset.tidyRecordLabel ?? region.dataset.tidyRecord ?? "";
197
+ for (const b of [bUp, bDown, bAdd, bRemove]) b.hidden = true;
198
+ bEdit.textContent = "Edit details";
199
+ placeChip(region);
200
+ }
201
+ const isInteractive = (t: EventTarget | null) => Boolean((t as HTMLElement | null)?.closest("a,button,input,select,textarea,label"));
202
+ for (const f of frames()) {
203
+ f.addEventListener("mouseenter", () => showBlockChip(f));
204
+ f.addEventListener("click", (e) => {
205
+ if (isInteractive(e.target)) return;
206
+ e.preventDefault();
207
+ showBlockChip(f);
208
+ openBlock(f.dataset.tidyBlock ?? "");
209
+ });
210
+ }
211
+ for (const r of Array.from(document.querySelectorAll<HTMLElement>("[data-tidy-record]"))) {
212
+ r.addEventListener("mouseenter", () => showRecordChip(r));
213
+ r.addEventListener("click", (e) => {
214
+ if (isInteractive(e.target)) return;
215
+ e.preventDefault();
216
+ showRecordChip(r);
217
+ void openRecord({ collection: r.dataset.tidyRecord ?? "", id: r.dataset.tidyRecordId ?? "", label: r.dataset.tidyRecordLabel ?? "" });
218
+ });
219
+ }
220
+ bEdit.addEventListener("click", () => {
221
+ if (!current) return;
222
+ if (current.dataset.tidyBlock) openBlock(current.dataset.tidyBlock);
223
+ else void openRecord({ collection: current.dataset.tidyRecord ?? "", id: current.dataset.tidyRecordId ?? "", label: current.dataset.tidyRecordLabel ?? "" });
224
+ });
225
+ bUp.addEventListener("click", () => current?.dataset.tidyBlock && moved(moveBlock(value, current.dataset.tidyBlock, -1)));
226
+ bDown.addEventListener("click", () => current?.dataset.tidyBlock && moved(moveBlock(value, current.dataset.tidyBlock, 1)));
227
+ bRemove.addEventListener("click", () => {
228
+ if (!current?.dataset.tidyBlock) return;
229
+ if (confirm(`Remove this ${chipLabel.textContent} section?`)) void saveBlocks(removeBlock(value, current.dataset.tidyBlock));
230
+ });
231
+ bAdd.addEventListener("click", () => current?.dataset.tidyBlock && openAdd(current.dataset.tidyBlock));
232
+ function moved(next: Block[]) {
233
+ if (next !== value) void saveBlocks(next);
234
+ }
235
+
236
+ const list = document.querySelector<HTMLElement>("[data-tidy-blocks]");
237
+ if (list) {
238
+ const add = el("button", { type: "button", class: "tidy-add-end", text: "+ Add a section" });
239
+ add.addEventListener("click", () => openAdd(null));
240
+ list.after(add);
241
+ }
242
+
243
+ // --- panel ---------------------------------------------------------------
244
+ const panel = el("aside", { class: "tidy-panel", hidden: "" });
245
+ document.body.append(panel);
246
+ function closePanel() {
247
+ panel.hidden = true;
248
+ panel.replaceChildren();
249
+ }
250
+ function openPanel(title: string, description: string | undefined, body: HTMLElement) {
251
+ panel.replaceChildren(el("header", { class: "tidy-panel-head" }, [el("h2", { text: title }), ...(description ? [el("p", { text: description })] : [])]), body);
252
+ panel.hidden = false;
253
+ (body.querySelector("input:not([type=file]),textarea,select") as HTMLElement | null)?.focus();
254
+ }
255
+ document.addEventListener("keydown", (e) => {
256
+ if (e.key === "Escape" && !panel.hidden) closePanel();
257
+ });
258
+
259
+ // --- image picker -----------------------------------------------------------
260
+ /** An image control. `get` returns the stored value (URL string or media value); set through `onPick`. */
261
+ function imageControl(label: string, currentUrl: string | null, onPick: (item: MediaItem | null) => void): HTMLElement {
262
+ const wrap = el("div", { class: "tidy-field tidy-image" }, [el("span", { class: "tidy-field-label", text: label })]);
263
+ const preview = el("div", { class: "tidy-image-preview" });
264
+ const setPreview = (url: string | null) => {
265
+ preview.replaceChildren(url ? el("img", { src: url, alt: "" }) : el("span", { class: "tidy-image-empty", text: "No image" }));
266
+ };
267
+ setPreview(currentUrl);
268
+ const choose = el("button", { type: "button", class: "tidy-btn tidy-btn-sm", text: "Choose image" });
269
+ const upload = el("button", { type: "button", class: "tidy-btn tidy-btn-sm", text: "Upload" });
270
+ const clear = el("button", { type: "button", class: "tidy-btn tidy-btn-sm tidy-btn-danger", text: "Remove" });
271
+ const file = el("input", { type: "file", accept: "image/*", hidden: "" });
272
+ const grid = el("div", { class: "tidy-image-grid", hidden: "" });
273
+ wrap.append(preview, el("div", { class: "tidy-image-actions" }, [choose, upload, clear, file]), grid);
274
+ choose.addEventListener("click", async () => {
275
+ if (!grid.hidden) {
276
+ grid.hidden = true;
277
+ return;
278
+ }
279
+ grid.hidden = false;
280
+ grid.replaceChildren(el("span", { class: "tidy-image-empty", text: "Loading" }));
281
+ const r = await api("/media?mimeType=image/&limit=60");
282
+ const j = (await r.json().catch(() => ({}))) as { data?: { items?: MediaItem[] }; items?: MediaItem[] };
283
+ const items = j.data?.items ?? j.items ?? [];
284
+ grid.replaceChildren();
285
+ if (items.length === 0) grid.append(el("span", { class: "tidy-image-empty", text: "No images yet. Upload one." }));
286
+ for (const item of items) {
287
+ const b = el("button", { type: "button", class: "tidy-image-thumb", title: item.alt ?? item.filename }, [el("img", { src: mediaUrl(item), alt: item.alt ?? item.filename, loading: "lazy" })]);
288
+ b.addEventListener("click", () => {
289
+ onPick(item);
290
+ setPreview(mediaUrl(item));
291
+ grid.hidden = true;
292
+ });
293
+ grid.append(b);
294
+ }
295
+ });
296
+ upload.addEventListener("click", () => file.click());
297
+ file.addEventListener("change", async () => {
298
+ const f = file.files?.[0];
299
+ if (!f) return;
300
+ upload.disabled = true;
301
+ upload.textContent = "Uploading";
302
+ const body = new FormData();
303
+ body.append("file", f);
304
+ body.append("alt", f.name.replace(/\.[a-z0-9]+$/i, "").replace(/[-_]+/g, " "));
305
+ const r = await api("/media", { method: "POST", body });
306
+ const j = (await r.json().catch(() => ({}))) as { data?: { item?: MediaItem } };
307
+ upload.disabled = false;
308
+ upload.textContent = "Upload";
309
+ file.value = "";
310
+ if (!r.ok || !j.data?.item) {
311
+ alert(`Upload failed (${r.status})`);
312
+ return;
313
+ }
314
+ onPick(j.data.item);
315
+ setPreview(mediaUrl(j.data.item));
316
+ });
317
+ clear.addEventListener("click", () => {
318
+ onPick(null);
319
+ setPreview(null);
320
+ });
321
+ return wrap;
322
+ }
323
+
324
+ // --- block forms ---------------------------------------------------------
325
+ function fieldInput(f: FieldSchema, v: unknown, name: string): HTMLElement {
326
+ const wrap = el("label", { class: "tidy-field" }, [el("span", { class: "tidy-field-label", text: f.label })]);
327
+ if (f.type === "toggle") {
328
+ const cb = el("input", { type: "checkbox", name });
329
+ cb.checked = v === true;
330
+ wrap.className = "tidy-field tidy-field-toggle";
331
+ wrap.prepend(cb);
332
+ } else if (f.type === "select") {
333
+ const s = el("select", { name });
334
+ for (const o of f.options ?? []) {
335
+ const opt = el("option", { value: o.value, text: o.label });
336
+ if (o.value === v) opt.selected = true;
337
+ s.append(opt);
338
+ }
339
+ wrap.append(s);
340
+ } else if (f.type === "number_input") {
341
+ const i = el("input", { type: "number", name });
342
+ i.value = typeof v === "number" ? String(v) : "";
343
+ wrap.append(i);
344
+ } else if (f.multiline) {
345
+ const t = el("textarea", { name, rows: "4" });
346
+ t.value = typeof v === "string" ? v : "";
347
+ wrap.append(t);
348
+ } else {
349
+ const i = el("input", { type: "text", name });
350
+ i.value = typeof v === "string" ? v : v === null || v === undefined ? "" : String(v);
351
+ wrap.append(i);
352
+ }
353
+ return wrap;
354
+ }
355
+
356
+ /** A text field that stores an image URL gets the picker; the hidden input keeps the URL for the reader. */
357
+ function blockField(f: FieldSchema, v: unknown, name: string): HTMLElement {
358
+ if (f.type !== "text_input" || !IMAGE_FIELD.test(f.action_id)) return fieldInput(f, v, name);
359
+ const hidden = el("input", { type: "hidden", name });
360
+ hidden.value = typeof v === "string" ? v : "";
361
+ const control = imageControl(f.label, hidden.value || null, (item) => {
362
+ hidden.value = item ? mediaUrl(item) : "";
363
+ });
364
+ control.append(hidden);
365
+ return control;
366
+ }
367
+
368
+ function buildBlockForm(schema: BlockSchema, data: Record<string, unknown>): { form: HTMLFormElement; read: () => Record<string, unknown> } {
369
+ const form = el("form", { class: "tidy-form" });
370
+ const repeaters = new Map<string, { field: FieldSchema; items: HTMLElement }>();
371
+ for (const f of schema.fields) {
372
+ if (f.type === "repeater") {
373
+ const box = el("fieldset", { class: "tidy-repeater" }, [el("legend", { text: f.label })]);
374
+ const items = el("div", { class: "tidy-repeater-items" });
375
+ const rows = Array.isArray(data[f.action_id]) ? (data[f.action_id] as Record<string, unknown>[]) : [];
376
+ const addItem = (row: Record<string, unknown>) => {
377
+ const item = el("div", { class: "tidy-repeater-item" });
378
+ for (const sub of f.fields ?? []) item.append(blockField(sub, row[sub.action_id], sub.action_id));
379
+ const rm = el("button", { type: "button", class: "tidy-btn tidy-btn-danger tidy-btn-sm", text: `Remove ${f.item_label ?? "item"}` });
380
+ rm.addEventListener("click", () => item.remove());
381
+ item.append(rm);
382
+ items.append(item);
383
+ };
384
+ rows.forEach(addItem);
385
+ const add = el("button", { type: "button", class: "tidy-btn tidy-btn-sm", text: `Add ${f.item_label ?? "item"}` });
386
+ add.addEventListener("click", () => addItem({}));
387
+ box.append(items, add);
388
+ form.append(box);
389
+ repeaters.set(f.action_id, { field: f, items });
390
+ } else form.append(blockField(f, data[f.action_id], f.action_id));
391
+ }
392
+ const readScope = (scope: ParentNode, fields: FieldSchema[]): Record<string, unknown> => {
393
+ const out: Record<string, unknown> = {};
394
+ for (const f of fields) {
395
+ if (f.type === "repeater") continue;
396
+ const input = scope.querySelector<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(`[name="${f.action_id}"]`);
397
+ out[f.action_id] = coerce(f, f.type === "toggle" ? (input as HTMLInputElement | null)?.checked : input?.value);
398
+ }
399
+ return out;
400
+ };
401
+ const read = (): Record<string, unknown> => {
402
+ const out: Record<string, unknown> = {};
403
+ for (const f of schema.fields) {
404
+ if (f.type === "repeater") {
405
+ const rep = repeaters.get(f.action_id)!;
406
+ out[f.action_id] = Array.from(rep.items.children).map((item) => readScope(item, f.fields ?? []));
407
+ continue;
408
+ }
409
+ const input = Array.from(form.children).map((c) => c.querySelector<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(`[name="${f.action_id}"]`)).find(Boolean) ?? null;
410
+ out[f.action_id] = coerce(f, f.type === "toggle" ? (input as HTMLInputElement | null)?.checked : input?.value);
411
+ }
412
+ return out;
413
+ };
414
+ return { form, read };
415
+ }
416
+
417
+ function openBlock(key: string) {
418
+ const block = value.find((b) => b._key === key);
419
+ const schema = block ? schemaOf(block._type) : undefined;
420
+ if (!block || !schema) return;
421
+ const { form, read } = buildBlockForm(schema, block);
422
+ const saveBtn = el("button", { type: "submit", class: "tidy-btn tidy-btn-primary", text: "Save" });
423
+ const cancel = el("button", { type: "button", class: "tidy-btn", text: "Cancel" });
424
+ cancel.addEventListener("click", closePanel);
425
+ form.addEventListener("submit", (e) => {
426
+ e.preventDefault();
427
+ saveBtn.disabled = true;
428
+ void saveBlocks(updateBlock(value, key, read()));
429
+ });
430
+ form.append(el("div", { class: "tidy-panel-actions" }, [saveBtn, cancel]));
431
+ openPanel(schema.label, schema.description, form);
432
+ }
433
+
434
+ function openAdd(afterKey: string | null) {
435
+ const listEl = el("div", { class: "tidy-add-list" });
436
+ for (const s of config.blocks.filter((b) => b.category !== "Inline")) {
437
+ const b = el("button", { type: "button", class: "tidy-add-item" }, [el("strong", { text: s.label }), el("span", { text: s.description ?? "" })]);
438
+ b.addEventListener("click", () => void saveBlocks(insertBlock(value, s.type, afterKey, defaultsFor(s))));
439
+ listEl.append(b);
440
+ }
441
+ const cancel = el("button", { type: "button", class: "tidy-btn", text: "Cancel" });
442
+ cancel.addEventListener("click", closePanel);
443
+ const body = el("div", {}, [listEl, el("div", { class: "tidy-panel-actions" }, [cancel])]);
444
+ openPanel("Add a section", undefined, body);
445
+ }
446
+
447
+ // --- record forms (fields from EmDash's manifest) ---------------------------
448
+ let manifest: Promise<Record<string, { fields: Record<string, ManifestField> }>> | null = null;
449
+ function collections() {
450
+ manifest ??= api("/manifest")
451
+ .then((r) => r.json())
452
+ .then((j: { data?: { collections?: Record<string, { fields: Record<string, ManifestField> }> }; collections?: Record<string, { fields: Record<string, ManifestField> }> }) => j.data?.collections ?? j.collections ?? {});
453
+ return manifest;
454
+ }
455
+
456
+ async function openRecord(ref: RecordRef) {
457
+ openPanel(ref.label, undefined, el("p", { class: "tidy-image-empty", text: "Loading" }));
458
+ const [cols, entryRes] = await Promise.all([collections(), api(`/content/${encodeURIComponent(ref.collection)}/${encodeURIComponent(ref.id)}`)]);
459
+ const fields = cols[ref.collection]?.fields ?? {};
460
+ const entry = (await entryRes.json().catch(() => ({}))) as { data?: { item?: { data?: Record<string, unknown> } } };
461
+ const data = entry.data?.item?.data ?? {};
462
+ const form = el("form", { class: "tidy-form" });
463
+ const values: Record<string, unknown> = {};
464
+ const readers: Array<() => void> = [];
465
+ for (const [name, f] of Object.entries(fields)) {
466
+ if (HIDDEN_RECORD_FIELDS.has(name) || ["portableText", "reference", "json", "datetime"].includes(f.kind)) continue;
467
+ const label = f.label ?? name;
468
+ const v = data[name];
469
+ if (f.kind === "image") {
470
+ const cur = v as { src?: string; meta?: { storageKey?: string } } | null | undefined;
471
+ values[name] = v ?? null;
472
+ form.append(imageControl(label, cur?.src ?? (cur?.meta?.storageKey ? `${API}/media/file/${cur.meta.storageKey}` : null), (item) => {
473
+ values[name] = item ? mediaValue(item) : null;
474
+ }));
475
+ continue;
476
+ }
477
+ if (f.kind === "boolean") {
478
+ const w = el("label", { class: "tidy-field tidy-field-toggle" }, [el("span", { class: "tidy-field-label", text: label })]);
479
+ const cb = el("input", { type: "checkbox" });
480
+ cb.checked = v === true;
481
+ w.prepend(cb);
482
+ form.append(w);
483
+ readers.push(() => (values[name] = cb.checked));
484
+ continue;
485
+ }
486
+ if (f.kind === "number") {
487
+ const w = el("label", { class: "tidy-field" }, [el("span", { class: "tidy-field-label", text: label })]);
488
+ const i = el("input", { type: "number", step: "any" });
489
+ i.value = typeof v === "number" ? String(v) : "";
490
+ w.append(i);
491
+ form.append(w);
492
+ readers.push(() => (values[name] = i.value === "" ? null : Number(i.value)));
493
+ continue;
494
+ }
495
+ if (f.kind === "select" && Array.isArray(f.options)) {
496
+ const w = el("label", { class: "tidy-field" }, [el("span", { class: "tidy-field-label", text: label })]);
497
+ const s = el("select");
498
+ for (const o of f.options as Array<{ value: string; label: string }>) {
499
+ const opt = el("option", { value: o.value, text: o.label });
500
+ if (o.value === v) opt.selected = true;
501
+ s.append(opt);
502
+ }
503
+ w.append(s);
504
+ form.append(w);
505
+ readers.push(() => (values[name] = s.value));
506
+ continue;
507
+ }
508
+ const w = el("label", { class: "tidy-field" }, [el("span", { class: "tidy-field-label", text: label })]);
509
+ const long = f.kind === "text" || f.kind === "richText";
510
+ const i = long ? el("textarea", { rows: "4" }) : el("input", { type: "text" });
511
+ i.value = typeof v === "string" ? v : v === null || v === undefined ? "" : String(v);
512
+ w.append(i);
513
+ form.append(w);
514
+ readers.push(() => (values[name] = i.value));
515
+ }
516
+ const saveBtn = el("button", { type: "submit", class: "tidy-btn tidy-btn-primary", text: "Save" });
517
+ const cancel = el("button", { type: "button", class: "tidy-btn", text: "Cancel" });
518
+ cancel.addEventListener("click", closePanel);
519
+ form.addEventListener("submit", (e) => {
520
+ e.preventDefault();
521
+ saveBtn.disabled = true;
522
+ for (const r of readers) r();
523
+ void saveEntry(ref.collection, ref.id, values, true);
524
+ });
525
+ form.append(el("div", { class: "tidy-panel-actions" }, [saveBtn, cancel]));
526
+ openPanel(ref.label, undefined, form);
527
+ }
528
+ }
529
+
530
+ const cfgEl = document.getElementById("tidy-editor-config");
531
+ if (cfgEl?.textContent) {
532
+ try {
533
+ start(JSON.parse(cfgEl.textContent) as Config);
534
+ } catch (err) {
535
+ console.error("[tidy-editor]", err);
536
+ }
537
+ }
package/src/version.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  /** Kept in step with package.json by the release checklist in MAINTAINING.md. */
2
- export const VERSION = "0.3.0";
2
+ export const VERSION = "0.5.0";
3
3
  export const PLUGIN_ID = "tidy-platform";