@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 +11 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/editor/TidyEditor.astro +19 -5
- package/src/editor/runtime.ts +357 -84
- package/src/version.ts +1 -1
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
|
|
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.
|
|
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
|
-
/**
|
|
18
|
-
|
|
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,
|
|
21
|
-
const config = JSON.stringify({ collection, id, field, blocks, value,
|
|
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); }
|
package/src/editor/runtime.ts
CHANGED
|
@@ -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.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
42
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
95
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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", () =>
|
|
203
|
+
f.addEventListener("mouseenter", () => showBlockChip(f));
|
|
110
204
|
f.addEventListener("click", (e) => {
|
|
111
|
-
//
|
|
112
|
-
if ((e.target as HTMLElement).closest("
|
|
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
|
-
|
|
115
|
-
|
|
209
|
+
showBlockChip(f);
|
|
210
|
+
openBlock(f.dataset.tidyBlock ?? "");
|
|
116
211
|
});
|
|
117
212
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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 (
|
|
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
|
|
127
|
-
function
|
|
128
|
-
if (next !== value) void
|
|
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
|
-
/**
|
|
179
|
-
function
|
|
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(
|
|
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(
|
|
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>(
|
|
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
|
|
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
|
-
|
|
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
|
|
437
|
+
void saveBlocks(updateBlock(value, key, read()));
|
|
239
438
|
});
|
|
240
439
|
form.append(el("div", { class: "tidy-panel-actions" }, [saveBtn, cancel]));
|
|
241
|
-
|
|
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
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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