@tideworthy/tidysites-platform 0.4.0 → 0.5.1

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.1
4
+
5
+ - Editor: a record region nested inside a section (a service card in the services grid) gets its own "Edit details" chip; clicking it edits the record instead of following the card's link, and leaving it hands the chip back to the section.
6
+
7
+ ## 0.5.0
8
+
9
+ - 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.
10
+ - 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.
11
+ - Editor: Publish publishes every entry saved in this browser session (pending list in sessionStorage); the pill shows the count.
12
+ - 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.
13
+
3
14
  ## 0.4.0
4
15
 
5
16
  - 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`.
package/README.md CHANGED
@@ -55,7 +55,7 @@ const editMode = Astro.cookies.get("emdash-edit-mode")?.value === "true";
55
55
  {editMode && <TidyEditor collection="pages" id={page.data.id} blocks={blockSchema} value={content} />}
56
56
  ```
57
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 calls EmDash's publish. Article bodies keep EmDash's inline editor by passing the original array.
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. A section that lists records (a services grid) wraps each card in the same `data-tidy-record` attributes, so the card edits its record and the section's chip edits the section. 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
59
 
60
60
  ## Bindings the routes read
61
61
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tideworthy/tidysites-platform",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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",
@@ -4,7 +4,9 @@
4
4
  * layout when the page is in edit mode, with the entry the page shows, the
5
5
  * block schemas the template declares, and the block list as rendered. The
6
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`.
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.
8
10
  */
9
11
  import type { Block, BlockSchema } from "./ops";
10
12
 
@@ -14,15 +16,27 @@ interface Props {
14
16
  field?: string;
15
17
  blocks: BlockSchema[];
16
18
  value: Block[];
17
- /** The entry's status as rendered: "draft" shows the Publish control at once. */
18
- status?: string;
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 }>;
19
21
  }
20
- const { collection, id, field = "content", blocks, value, status } = Astro.props;
21
- const config = JSON.stringify({ collection, id, field, blocks, value, status });
22
+ const { collection, id, field = "content", blocks, value, records = [] } = Astro.props;
23
+ const config = JSON.stringify({ collection, id, field, blocks, value, records });
22
24
  ---
23
25
  <script type="application/json" id="tidy-editor-config" set:html={config.replace(/</g, "\\u003c")} />
24
26
  <script src="./runtime.ts"></script>
25
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; }
26
40
  [data-tidy-block] { position: relative; outline: 2px dashed transparent; outline-offset: -2px; transition: outline-color 120ms; cursor: pointer; }
27
41
  [data-tidy-block]:hover, [data-tidy-block].tidy-active { outline-color: #2b7fd1; }
28
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); }
@@ -1,26 +1,62 @@
1
1
  /// <reference lib="dom" />
2
2
  /// <reference lib="dom.iterable" />
3
3
  /**
4
- * The Tideworthy on-site editor. Runs on a page rendered in edit mode. Every
5
- * custom block on the page sits in a frame (`[data-tidy-block]`, rendered by
6
- * the template); hovering a frame shows a chip with Edit, Up, Down, Remove,
7
- * and Add; Edit opens a panel whose form comes from the block's field schema.
8
- * Saves go through EmDash's own content API with the editor's session, as a
9
- * draft; Publish is the same call EmDash's toolbar makes.
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.
10
19
  */
11
20
  import { coerce, defaultsFor, insertBlock, moveBlock, removeBlock, updateBlock, type Block, type BlockSchema, type FieldSchema } from "./ops";
12
21
 
22
+ interface RecordRef {
23
+ collection: string;
24
+ id: string;
25
+ label: string;
26
+ }
27
+
13
28
  interface Config {
14
29
  collection: string;
15
30
  id: string;
16
31
  field: string;
17
32
  blocks: BlockSchema[];
18
33
  value: Block[];
19
- status?: string;
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;
20
52
  }
21
53
 
22
54
  const API = "/_emdash/api";
23
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"]);
24
60
 
25
61
  function el<K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Record<string, string> = {}, children: Array<Node | string> = []): HTMLElementTagNameMap[K] {
26
62
  const e = document.createElement(tag);
@@ -33,53 +69,98 @@ function el<K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Record<string,
33
69
  return e;
34
70
  }
35
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
+
36
85
  export function start(config: Config): void {
37
86
  let value: Block[] = config.value;
38
87
  const schemaOf = (type: string) => config.blocks.find((b) => b.type === type);
39
88
  const frames = () => Array.from(document.querySelectorAll<HTMLElement>("[data-tidy-block]"));
40
89
 
41
- // --- status pill -------------------------------------------------------
42
- const status = el("div", { class: "tidy-status", hidden: "" });
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" });
43
99
  const statusText = el("span");
100
+ const bizBtn = el("button", { type: "button", class: "tidy-btn", text: "Business details" });
44
101
  const publishBtn = el("button", { type: "button", class: "tidy-btn tidy-btn-primary", text: "Publish" });
45
- status.append(statusText, publishBtn);
102
+ status.append(statusText, bizBtn, publishBtn);
46
103
  document.body.append(status);
47
- function setStatus(text: string, showPublish: boolean) {
48
- statusText.textContent = text;
49
- publishBtn.hidden = !showPublish;
50
- status.hidden = false;
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");
51
111
  }
112
+ refreshStatus();
52
113
  publishBtn.addEventListener("click", async () => {
53
114
  publishBtn.disabled = true;
54
- setStatus("Publishing", true);
55
- const r = await fetch(`${API}/content/${encodeURIComponent(config.collection)}/${encodeURIComponent(config.id)}/publish`, { method: "POST", credentials: "same-origin", headers: CSRF });
56
- if (r.ok) location.reload();
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();
57
126
  else {
58
127
  publishBtn.disabled = false;
59
- setStatus(`Publish failed (${r.status})`, true);
128
+ refreshStatus(`Publish failed for ${failed}`);
60
129
  }
61
130
  });
62
- // Saves make a draft; Publish is the same call EmDash's toolbar makes and is always at hand.
63
- setStatus(config.status === "draft" ? "Unpublished changes" : "Editing", true);
64
131
 
65
- async function save(next: Block[]): Promise<void> {
66
- setStatus("Saving", false);
67
- const r = await fetch(`${API}/content/${encodeURIComponent(config.collection)}/${encodeURIComponent(config.id)}`, {
68
- method: "PUT",
69
- credentials: "same-origin",
70
- headers: { ...CSRF, "content-type": "application/json" },
71
- body: JSON.stringify({ data: { [config.field]: next } })
72
- });
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 }) });
73
141
  if (!r.ok) {
74
- setStatus(`Save failed (${r.status})`, false);
75
- return;
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();
76
156
  }
77
- value = next;
78
- setStatus("Saved. Reloading", false);
157
+ refreshStatus("Saved. Reloading");
79
158
  location.reload();
159
+ return true;
80
160
  }
161
+ const saveBlocks = (next: Block[]) => saveEntry(config.collection, config.id, { [config.field]: next });
81
162
 
82
- // --- chip on the hovered frame ------------------------------------------
163
+ // --- chip on the hovered frame or record ---------------------------------
83
164
  const chip = el("div", { class: "tidy-chip", hidden: "" });
84
165
  const chipLabel = el("span", { class: "tidy-chip-label" });
85
166
  const bEdit = el("button", { type: "button", class: "tidy-btn", text: "Edit" });
@@ -91,44 +172,76 @@ export function start(config: Config): void {
91
172
  document.body.append(chip);
92
173
  let current: HTMLElement | null = null;
93
174
 
94
- function showChip(frame: HTMLElement) {
95
- current = frame;
96
- const key = frame.dataset.tidyBlock ?? "";
97
- const type = frame.dataset.tidyType ?? "";
98
- chipLabel.textContent = schemaOf(type)?.label ?? type;
99
- const r = frame.getBoundingClientRect();
175
+ function placeChip(target: HTMLElement) {
176
+ const r = target.getBoundingClientRect();
100
177
  chip.style.top = `${Math.max(8, r.top + window.scrollY - 2)}px`;
101
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 ?? "";
102
187
  const i = value.findIndex((b) => b._key === key);
103
188
  bUp.disabled = i <= 0;
104
189
  bDown.disabled = i < 0 || i >= value.length - 1;
105
- chip.hidden = false;
106
- frames().forEach((f) => f.classList.toggle("tidy-active", f === frame));
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);
107
200
  }
201
+ const isInteractive = (t: EventTarget | null) => Boolean((t as HTMLElement | null)?.closest("a,button,input,select,textarea,label"));
108
202
  for (const f of frames()) {
109
- f.addEventListener("mouseenter", () => showChip(f));
203
+ f.addEventListener("mouseenter", () => showBlockChip(f));
110
204
  f.addEventListener("click", (e) => {
111
- // Links and buttons inside a section keep working; clicking anything else edits it.
112
- if ((e.target as HTMLElement).closest("a,button,input,select,textarea,label")) return;
205
+ // A record listed inside the section (a service card) handles its own click.
206
+ if ((e.target as HTMLElement).closest("[data-tidy-record]")) return;
207
+ if (isInteractive(e.target)) return;
113
208
  e.preventDefault();
114
- showChip(f);
115
- openPanel(f.dataset.tidyBlock ?? "");
209
+ showBlockChip(f);
210
+ openBlock(f.dataset.tidyBlock ?? "");
116
211
  });
117
212
  }
118
- chip.addEventListener("mouseenter", () => { if (current) current.classList.add("tidy-active"); });
119
- bEdit.addEventListener("click", () => current && openPanel(current.dataset.tidyBlock ?? ""));
120
- bUp.addEventListener("click", () => current && confirmSave(moveBlock(value, current.dataset.tidyBlock ?? "", -1)));
121
- bDown.addEventListener("click", () => current && confirmSave(moveBlock(value, current.dataset.tidyBlock ?? "", 1)));
122
- bRemove.addEventListener("click", () => {
213
+ for (const r of Array.from(document.querySelectorAll<HTMLElement>("[data-tidy-record]"))) {
214
+ const frame = r.parentElement?.closest<HTMLElement>("[data-tidy-block]") ?? null;
215
+ r.addEventListener("mouseenter", () => showRecordChip(r));
216
+ // Leaving a card inside a section hands the chip back to the section.
217
+ if (frame) r.addEventListener("mouseleave", () => showBlockChip(frame));
218
+ r.addEventListener("click", (e) => {
219
+ // Cards are often links; in edit mode a click edits instead of navigating.
220
+ if ((e.target as HTMLElement).closest("button,input,select,textarea,label")) return;
221
+ e.preventDefault();
222
+ e.stopPropagation();
223
+ showRecordChip(r);
224
+ void openRecord({ collection: r.dataset.tidyRecord ?? "", id: r.dataset.tidyRecordId ?? "", label: r.dataset.tidyRecordLabel ?? "" });
225
+ });
226
+ }
227
+ // The chip sits outside every frame; hovering it must not count as leaving.
228
+ chip.addEventListener("mouseenter", () => current?.classList.add("tidy-active"));
229
+ bEdit.addEventListener("click", () => {
123
230
  if (!current) return;
124
- if (confirm(`Remove this ${chipLabel.textContent} section?`)) void save(removeBlock(value, current.dataset.tidyBlock ?? ""));
231
+ if (current.dataset.tidyBlock) openBlock(current.dataset.tidyBlock);
232
+ else void openRecord({ collection: current.dataset.tidyRecord ?? "", id: current.dataset.tidyRecordId ?? "", label: current.dataset.tidyRecordLabel ?? "" });
233
+ });
234
+ bUp.addEventListener("click", () => current?.dataset.tidyBlock && moved(moveBlock(value, current.dataset.tidyBlock, -1)));
235
+ bDown.addEventListener("click", () => current?.dataset.tidyBlock && moved(moveBlock(value, current.dataset.tidyBlock, 1)));
236
+ bRemove.addEventListener("click", () => {
237
+ if (!current?.dataset.tidyBlock) return;
238
+ if (confirm(`Remove this ${chipLabel.textContent} section?`)) void saveBlocks(removeBlock(value, current.dataset.tidyBlock));
125
239
  });
126
- bAdd.addEventListener("click", () => current && openAdd(current.dataset.tidyBlock ?? null));
127
- function confirmSave(next: Block[]) {
128
- if (next !== value) void save(next);
240
+ bAdd.addEventListener("click", () => current?.dataset.tidyBlock && openAdd(current.dataset.tidyBlock));
241
+ function moved(next: Block[]) {
242
+ if (next !== value) void saveBlocks(next);
129
243
  }
130
244
 
131
- // "Add a section" at the end of the block list.
132
245
  const list = document.querySelector<HTMLElement>("[data-tidy-blocks]");
133
246
  if (list) {
134
247
  const add = el("button", { type: "button", class: "tidy-add-end", text: "+ Add a section" });
@@ -143,7 +256,81 @@ export function start(config: Config): void {
143
256
  panel.hidden = true;
144
257
  panel.replaceChildren();
145
258
  }
259
+ function openPanel(title: string, description: string | undefined, body: HTMLElement) {
260
+ panel.replaceChildren(el("header", { class: "tidy-panel-head" }, [el("h2", { text: title }), ...(description ? [el("p", { text: description })] : [])]), body);
261
+ panel.hidden = false;
262
+ (body.querySelector("input:not([type=file]),textarea,select") as HTMLElement | null)?.focus();
263
+ }
264
+ document.addEventListener("keydown", (e) => {
265
+ if (e.key === "Escape" && !panel.hidden) closePanel();
266
+ });
146
267
 
268
+ // --- image picker -----------------------------------------------------------
269
+ /** An image control. `get` returns the stored value (URL string or media value); set through `onPick`. */
270
+ function imageControl(label: string, currentUrl: string | null, onPick: (item: MediaItem | null) => void): HTMLElement {
271
+ const wrap = el("div", { class: "tidy-field tidy-image" }, [el("span", { class: "tidy-field-label", text: label })]);
272
+ const preview = el("div", { class: "tidy-image-preview" });
273
+ const setPreview = (url: string | null) => {
274
+ preview.replaceChildren(url ? el("img", { src: url, alt: "" }) : el("span", { class: "tidy-image-empty", text: "No image" }));
275
+ };
276
+ setPreview(currentUrl);
277
+ const choose = el("button", { type: "button", class: "tidy-btn tidy-btn-sm", text: "Choose image" });
278
+ const upload = el("button", { type: "button", class: "tidy-btn tidy-btn-sm", text: "Upload" });
279
+ const clear = el("button", { type: "button", class: "tidy-btn tidy-btn-sm tidy-btn-danger", text: "Remove" });
280
+ const file = el("input", { type: "file", accept: "image/*", hidden: "" });
281
+ const grid = el("div", { class: "tidy-image-grid", hidden: "" });
282
+ wrap.append(preview, el("div", { class: "tidy-image-actions" }, [choose, upload, clear, file]), grid);
283
+ choose.addEventListener("click", async () => {
284
+ if (!grid.hidden) {
285
+ grid.hidden = true;
286
+ return;
287
+ }
288
+ grid.hidden = false;
289
+ grid.replaceChildren(el("span", { class: "tidy-image-empty", text: "Loading" }));
290
+ const r = await api("/media?mimeType=image/&limit=60");
291
+ const j = (await r.json().catch(() => ({}))) as { data?: { items?: MediaItem[] }; items?: MediaItem[] };
292
+ const items = j.data?.items ?? j.items ?? [];
293
+ grid.replaceChildren();
294
+ if (items.length === 0) grid.append(el("span", { class: "tidy-image-empty", text: "No images yet. Upload one." }));
295
+ for (const item of items) {
296
+ 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" })]);
297
+ b.addEventListener("click", () => {
298
+ onPick(item);
299
+ setPreview(mediaUrl(item));
300
+ grid.hidden = true;
301
+ });
302
+ grid.append(b);
303
+ }
304
+ });
305
+ upload.addEventListener("click", () => file.click());
306
+ file.addEventListener("change", async () => {
307
+ const f = file.files?.[0];
308
+ if (!f) return;
309
+ upload.disabled = true;
310
+ upload.textContent = "Uploading";
311
+ const body = new FormData();
312
+ body.append("file", f);
313
+ body.append("alt", f.name.replace(/\.[a-z0-9]+$/i, "").replace(/[-_]+/g, " "));
314
+ const r = await api("/media", { method: "POST", body });
315
+ const j = (await r.json().catch(() => ({}))) as { data?: { item?: MediaItem } };
316
+ upload.disabled = false;
317
+ upload.textContent = "Upload";
318
+ file.value = "";
319
+ if (!r.ok || !j.data?.item) {
320
+ alert(`Upload failed (${r.status})`);
321
+ return;
322
+ }
323
+ onPick(j.data.item);
324
+ setPreview(mediaUrl(j.data.item));
325
+ });
326
+ clear.addEventListener("click", () => {
327
+ onPick(null);
328
+ setPreview(null);
329
+ });
330
+ return wrap;
331
+ }
332
+
333
+ // --- block forms ---------------------------------------------------------
147
334
  function fieldInput(f: FieldSchema, v: unknown, name: string): HTMLElement {
148
335
  const wrap = el("label", { class: "tidy-field" }, [el("span", { class: "tidy-field-label", text: f.label })]);
149
336
  if (f.type === "toggle") {
@@ -175,8 +362,19 @@ export function start(config: Config): void {
175
362
  return wrap;
176
363
  }
177
364
 
178
- /** Builds the form for a block; returns a reader that yields the block's data from the inputs. */
179
- function buildForm(schema: BlockSchema, data: Record<string, unknown>): { form: HTMLFormElement; read: () => Record<string, unknown> } {
365
+ /** A text field that stores an image URL gets the picker; the hidden input keeps the URL for the reader. */
366
+ function blockField(f: FieldSchema, v: unknown, name: string): HTMLElement {
367
+ if (f.type !== "text_input" || !IMAGE_FIELD.test(f.action_id)) return fieldInput(f, v, name);
368
+ const hidden = el("input", { type: "hidden", name });
369
+ hidden.value = typeof v === "string" ? v : "";
370
+ const control = imageControl(f.label, hidden.value || null, (item) => {
371
+ hidden.value = item ? mediaUrl(item) : "";
372
+ });
373
+ control.append(hidden);
374
+ return control;
375
+ }
376
+
377
+ function buildBlockForm(schema: BlockSchema, data: Record<string, unknown>): { form: HTMLFormElement; read: () => Record<string, unknown> } {
180
378
  const form = el("form", { class: "tidy-form" });
181
379
  const repeaters = new Map<string, { field: FieldSchema; items: HTMLElement }>();
182
380
  for (const f of schema.fields) {
@@ -186,7 +384,7 @@ export function start(config: Config): void {
186
384
  const rows = Array.isArray(data[f.action_id]) ? (data[f.action_id] as Record<string, unknown>[]) : [];
187
385
  const addItem = (row: Record<string, unknown>) => {
188
386
  const item = el("div", { class: "tidy-repeater-item" });
189
- for (const sub of f.fields ?? []) item.append(fieldInput(sub, row[sub.action_id], sub.action_id));
387
+ for (const sub of f.fields ?? []) item.append(blockField(sub, row[sub.action_id], sub.action_id));
190
388
  const rm = el("button", { type: "button", class: "tidy-btn tidy-btn-danger tidy-btn-sm", text: `Remove ${f.item_label ?? "item"}` });
191
389
  rm.addEventListener("click", () => item.remove());
192
390
  item.append(rm);
@@ -198,24 +396,26 @@ export function start(config: Config): void {
198
396
  box.append(items, add);
199
397
  form.append(box);
200
398
  repeaters.set(f.action_id, { field: f, items });
201
- } else form.append(fieldInput(f, data[f.action_id], f.action_id));
399
+ } else form.append(blockField(f, data[f.action_id], f.action_id));
202
400
  }
401
+ const readScope = (scope: ParentNode, fields: FieldSchema[]): Record<string, unknown> => {
402
+ const out: Record<string, unknown> = {};
403
+ for (const f of fields) {
404
+ if (f.type === "repeater") continue;
405
+ const input = scope.querySelector<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(`[name="${f.action_id}"]`);
406
+ out[f.action_id] = coerce(f, f.type === "toggle" ? (input as HTMLInputElement | null)?.checked : input?.value);
407
+ }
408
+ return out;
409
+ };
203
410
  const read = (): Record<string, unknown> => {
204
411
  const out: Record<string, unknown> = {};
205
412
  for (const f of schema.fields) {
206
413
  if (f.type === "repeater") {
207
414
  const rep = repeaters.get(f.action_id)!;
208
- out[f.action_id] = Array.from(rep.items.children).map((item) => {
209
- const row: Record<string, unknown> = {};
210
- for (const sub of f.fields ?? []) {
211
- const input = item.querySelector<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(`[name="${sub.action_id}"]`);
212
- row[sub.action_id] = coerce(sub, sub.type === "toggle" ? (input as HTMLInputElement | null)?.checked : input?.value);
213
- }
214
- return row;
215
- });
415
+ out[f.action_id] = Array.from(rep.items.children).map((item) => readScope(item, f.fields ?? []));
216
416
  continue;
217
417
  }
218
- const input = form.querySelector<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(`:scope > .tidy-field [name="${f.action_id}"]`);
418
+ const input = Array.from(form.children).map((c) => c.querySelector<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(`[name="${f.action_id}"]`)).find(Boolean) ?? null;
219
419
  out[f.action_id] = coerce(f, f.type === "toggle" ? (input as HTMLInputElement | null)?.checked : input?.value);
220
420
  }
221
421
  return out;
@@ -223,44 +423,117 @@ export function start(config: Config): void {
223
423
  return { form, read };
224
424
  }
225
425
 
226
- function openPanel(key: string) {
426
+ function openBlock(key: string) {
227
427
  const block = value.find((b) => b._key === key);
228
428
  const schema = block ? schemaOf(block._type) : undefined;
229
429
  if (!block || !schema) return;
230
- panel.replaceChildren();
231
- const { form, read } = buildForm(schema, block);
430
+ const { form, read } = buildBlockForm(schema, block);
232
431
  const saveBtn = el("button", { type: "submit", class: "tidy-btn tidy-btn-primary", text: "Save" });
233
432
  const cancel = el("button", { type: "button", class: "tidy-btn", text: "Cancel" });
234
433
  cancel.addEventListener("click", closePanel);
235
434
  form.addEventListener("submit", (e) => {
236
435
  e.preventDefault();
237
436
  saveBtn.disabled = true;
238
- void save(updateBlock(value, key, read()));
437
+ void saveBlocks(updateBlock(value, key, read()));
239
438
  });
240
439
  form.append(el("div", { class: "tidy-panel-actions" }, [saveBtn, cancel]));
241
- panel.append(el("header", { class: "tidy-panel-head" }, [el("h2", { text: schema.label }), ...(schema.description ? [el("p", { text: schema.description })] : [])]), form);
242
- panel.hidden = false;
243
- (form.querySelector("input,textarea,select") as HTMLElement | null)?.focus();
440
+ openPanel(schema.label, schema.description, form);
244
441
  }
245
442
 
246
443
  function openAdd(afterKey: string | null) {
247
- panel.replaceChildren();
248
- const head = el("header", { class: "tidy-panel-head" }, [el("h2", { text: "Add a section" })]);
249
444
  const listEl = el("div", { class: "tidy-add-list" });
250
445
  for (const s of config.blocks.filter((b) => b.category !== "Inline")) {
251
446
  const b = el("button", { type: "button", class: "tidy-add-item" }, [el("strong", { text: s.label }), el("span", { text: s.description ?? "" })]);
252
- b.addEventListener("click", () => void save(insertBlock(value, s.type, afterKey, defaultsFor(s))));
447
+ b.addEventListener("click", () => void saveBlocks(insertBlock(value, s.type, afterKey, defaultsFor(s))));
253
448
  listEl.append(b);
254
449
  }
255
450
  const cancel = el("button", { type: "button", class: "tidy-btn", text: "Cancel" });
256
451
  cancel.addEventListener("click", closePanel);
257
- panel.append(head, listEl, el("div", { class: "tidy-panel-actions" }, [cancel]));
258
- panel.hidden = false;
452
+ const body = el("div", {}, [listEl, el("div", { class: "tidy-panel-actions" }, [cancel])]);
453
+ openPanel("Add a section", undefined, body);
259
454
  }
260
455
 
261
- document.addEventListener("keydown", (e) => {
262
- if (e.key === "Escape" && !panel.hidden) closePanel();
263
- });
456
+ // --- record forms (fields from EmDash's manifest) ---------------------------
457
+ let manifest: Promise<Record<string, { fields: Record<string, ManifestField> }>> | null = null;
458
+ function collections() {
459
+ manifest ??= api("/manifest")
460
+ .then((r) => r.json())
461
+ .then((j: { data?: { collections?: Record<string, { fields: Record<string, ManifestField> }> }; collections?: Record<string, { fields: Record<string, ManifestField> }> }) => j.data?.collections ?? j.collections ?? {});
462
+ return manifest;
463
+ }
464
+
465
+ async function openRecord(ref: RecordRef) {
466
+ openPanel(ref.label, undefined, el("p", { class: "tidy-image-empty", text: "Loading" }));
467
+ const [cols, entryRes] = await Promise.all([collections(), api(`/content/${encodeURIComponent(ref.collection)}/${encodeURIComponent(ref.id)}`)]);
468
+ const fields = cols[ref.collection]?.fields ?? {};
469
+ const entry = (await entryRes.json().catch(() => ({}))) as { data?: { item?: { data?: Record<string, unknown> } } };
470
+ const data = entry.data?.item?.data ?? {};
471
+ const form = el("form", { class: "tidy-form" });
472
+ const values: Record<string, unknown> = {};
473
+ const readers: Array<() => void> = [];
474
+ for (const [name, f] of Object.entries(fields)) {
475
+ if (HIDDEN_RECORD_FIELDS.has(name) || ["portableText", "reference", "json", "datetime"].includes(f.kind)) continue;
476
+ const label = f.label ?? name;
477
+ const v = data[name];
478
+ if (f.kind === "image") {
479
+ const cur = v as { src?: string; meta?: { storageKey?: string } } | null | undefined;
480
+ values[name] = v ?? null;
481
+ form.append(imageControl(label, cur?.src ?? (cur?.meta?.storageKey ? `${API}/media/file/${cur.meta.storageKey}` : null), (item) => {
482
+ values[name] = item ? mediaValue(item) : null;
483
+ }));
484
+ continue;
485
+ }
486
+ if (f.kind === "boolean") {
487
+ const w = el("label", { class: "tidy-field tidy-field-toggle" }, [el("span", { class: "tidy-field-label", text: label })]);
488
+ const cb = el("input", { type: "checkbox" });
489
+ cb.checked = v === true;
490
+ w.prepend(cb);
491
+ form.append(w);
492
+ readers.push(() => (values[name] = cb.checked));
493
+ continue;
494
+ }
495
+ if (f.kind === "number") {
496
+ const w = el("label", { class: "tidy-field" }, [el("span", { class: "tidy-field-label", text: label })]);
497
+ const i = el("input", { type: "number", step: "any" });
498
+ i.value = typeof v === "number" ? String(v) : "";
499
+ w.append(i);
500
+ form.append(w);
501
+ readers.push(() => (values[name] = i.value === "" ? null : Number(i.value)));
502
+ continue;
503
+ }
504
+ if (f.kind === "select" && Array.isArray(f.options)) {
505
+ const w = el("label", { class: "tidy-field" }, [el("span", { class: "tidy-field-label", text: label })]);
506
+ const s = el("select");
507
+ for (const o of f.options as Array<{ value: string; label: string }>) {
508
+ const opt = el("option", { value: o.value, text: o.label });
509
+ if (o.value === v) opt.selected = true;
510
+ s.append(opt);
511
+ }
512
+ w.append(s);
513
+ form.append(w);
514
+ readers.push(() => (values[name] = s.value));
515
+ continue;
516
+ }
517
+ const w = el("label", { class: "tidy-field" }, [el("span", { class: "tidy-field-label", text: label })]);
518
+ const long = f.kind === "text" || f.kind === "richText";
519
+ const i = long ? el("textarea", { rows: "4" }) : el("input", { type: "text" });
520
+ i.value = typeof v === "string" ? v : v === null || v === undefined ? "" : String(v);
521
+ w.append(i);
522
+ form.append(w);
523
+ readers.push(() => (values[name] = i.value));
524
+ }
525
+ const saveBtn = el("button", { type: "submit", class: "tidy-btn tidy-btn-primary", text: "Save" });
526
+ const cancel = el("button", { type: "button", class: "tidy-btn", text: "Cancel" });
527
+ cancel.addEventListener("click", closePanel);
528
+ form.addEventListener("submit", (e) => {
529
+ e.preventDefault();
530
+ saveBtn.disabled = true;
531
+ for (const r of readers) r();
532
+ void saveEntry(ref.collection, ref.id, values, true);
533
+ });
534
+ form.append(el("div", { class: "tidy-panel-actions" }, [saveBtn, cancel]));
535
+ openPanel(ref.label, undefined, form);
536
+ }
264
537
  }
265
538
 
266
539
  const cfgEl = document.getElementById("tidy-editor-config");
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.4.0";
2
+ export const VERSION = "0.5.1";
3
3
  export const PLUGIN_ID = "tidy-platform";