@tideworthy/tidysites-platform 0.5.0 → 0.6.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 +9 -0
- package/README.md +5 -1
- package/package.json +2 -1
- package/src/editor/TidyEditor.astro +36 -15
- package/src/editor/runtime.ts +15 -2
- package/src/index.ts +1 -0
- package/src/routes/edit.ts +26 -0
- package/src/version.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
- `GET /_tidy/edit?on=1|0&to=/path`: switches on-site editing on or off for the signed-in editor (the `emdash-edit-mode` cookie) and returns to the page. No bearer; a visitor without a session is sent back unchanged.
|
|
6
|
+
- Editor: mounts itself. The layout renders `<TidyEditor>` on every page; it shows nothing to a visitor, an "Edit" pill to a signed-in editor, and the editor once editing is on. A page without blocks gets the pill only (EmDash's inline editing handles its prose). The pill gains "Done". Every prop is optional now; EmDash's toolbar is hidden only while this editor runs (`html.tidy-editing`).
|
|
7
|
+
|
|
8
|
+
## 0.5.1
|
|
9
|
+
|
|
10
|
+
- 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.
|
|
11
|
+
|
|
3
12
|
## 0.5.0
|
|
4
13
|
|
|
5
14
|
- 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.
|
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 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.
|
|
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
|
|
|
@@ -96,6 +96,10 @@ Mints a one-time sign-in link for a person the platform has already authenticate
|
|
|
96
96
|
|
|
97
97
|
The browser lands here. Signs the user in (an Astro session, exactly as EmDash's own login does), sets or clears the visual-editing cookie, and redirects with 303 to the requested path. A link can be used once. Expired, reused, or unknown links get a 410 page.
|
|
98
98
|
|
|
99
|
+
### `GET /_tidy/edit?on=1|0&to=/path`
|
|
100
|
+
|
|
101
|
+
No bearer. For the browser session the claim signed in: sets (`on=1`) or clears the `emdash-edit-mode` cookie and answers 303 to `to` (a local path; anything else becomes `/`). Without a signed-in session it answers the same 303 and changes nothing. The editor's pill links here.
|
|
102
|
+
|
|
99
103
|
### `GET /_tidy/health`
|
|
100
104
|
|
|
101
105
|
`{ ok, plugin, setupComplete, siteTitle, siteUrl, migrations: { applied, pending }, time }`. `ok` is false and the status is 503 when migrations are pending.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tideworthy/tidysites-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"./routes/bootstrap": "./src/routes/bootstrap.ts",
|
|
26
26
|
"./routes/session": "./src/routes/session.ts",
|
|
27
27
|
"./routes/session-claim": "./src/routes/session-claim.ts",
|
|
28
|
+
"./routes/edit": "./src/routes/edit.ts",
|
|
28
29
|
"./routes/health": "./src/routes/health.ts",
|
|
29
30
|
"./routes/rotate-token": "./src/routes/rotate-token.ts",
|
|
30
31
|
"./routes/fill": "./src/routes/fill.ts",
|
|
@@ -1,31 +1,50 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* The Tideworthy on-site editor
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
3
|
+
* The Tideworthy on-site editor. Mount it from the layout on every page; it
|
|
4
|
+
* renders nothing for a visitor, an "Edit" pill for a signed-in editor, and
|
|
5
|
+
* the editor itself once editing is on (the `emdash-edit-mode` cookie, set
|
|
6
|
+
* and cleared by /_tidy/edit from the pill). Block pages hand it the entry
|
|
7
|
+
* they show, the block schemas the template declares, and the block list as
|
|
8
|
+
* rendered; the template frames each custom block with `data-tidy-block`
|
|
9
|
+
* (its key) and `data-tidy-type`, and wraps the list in `data-tidy-blocks`.
|
|
10
|
+
* A record region (`data-tidy-record`, `data-tidy-record-id`,
|
|
11
|
+
* `data-tidy-record-label`) gets an "Edit details" chip whose form comes
|
|
12
|
+
* from EmDash's manifest. A page without blocks (an article) gets the pill
|
|
13
|
+
* only; EmDash's inline editing handles its prose in edit mode.
|
|
10
14
|
*/
|
|
11
15
|
import type { Block, BlockSchema } from "./ops";
|
|
12
16
|
|
|
13
17
|
interface Props {
|
|
14
|
-
collection
|
|
15
|
-
id
|
|
18
|
+
collection?: string;
|
|
19
|
+
id?: string;
|
|
16
20
|
field?: string;
|
|
17
|
-
blocks
|
|
18
|
-
value
|
|
21
|
+
blocks?: BlockSchema[];
|
|
22
|
+
value?: Block[] | null;
|
|
19
23
|
/** Records reachable from the pill (the business) or from `[data-tidy-record]` regions on the page. */
|
|
20
24
|
records?: Array<{ collection: string; id: string; label: string }>;
|
|
21
25
|
}
|
|
22
26
|
const { collection, id, field = "content", blocks, value, records = [] } = Astro.props;
|
|
23
|
-
const
|
|
27
|
+
const signedIn = Boolean(Astro.session ? await Astro.session.get("user") : null);
|
|
28
|
+
const active = Astro.cookies.get("emdash-edit-mode")?.value === "true";
|
|
29
|
+
const here = Astro.url.pathname + Astro.url.search;
|
|
30
|
+
const toggle = (on: boolean) => `/_tidy/edit?on=${on ? 1 : 0}&to=${encodeURIComponent(here)}`;
|
|
31
|
+
const editable = active && Boolean(collection && id && blocks && value);
|
|
32
|
+
const config = editable ? JSON.stringify({ collection, id, field, blocks, value, records, done: toggle(false) }) : null;
|
|
24
33
|
---
|
|
25
|
-
|
|
26
|
-
<
|
|
34
|
+
{signedIn && !active && (
|
|
35
|
+
<div class="tidy-status tidy-status-idle"><a class="tidy-btn tidy-btn-primary" href={toggle(true)}>Edit</a></div>
|
|
36
|
+
)}
|
|
37
|
+
{signedIn && active && !editable && (
|
|
38
|
+
<div class="tidy-status"><span>Editing</span><a class="tidy-btn" href={toggle(false)}>Done</a></div>
|
|
39
|
+
)}
|
|
40
|
+
{config && (
|
|
41
|
+
<>
|
|
42
|
+
<script type="application/json" id="tidy-editor-config" set:html={config.replace(/</g, "\\u003c")} />
|
|
43
|
+
<script src="./runtime.ts"></script>
|
|
44
|
+
</>
|
|
45
|
+
)}
|
|
27
46
|
<style is:global>
|
|
28
|
-
#emdash-toolbar { display: none !important; }
|
|
47
|
+
.tidy-editing #emdash-toolbar { display: none !important; }
|
|
29
48
|
[data-tidy-record] { outline: 2px dashed transparent; outline-offset: 4px; transition: outline-color 120ms; cursor: pointer; }
|
|
30
49
|
[data-tidy-record]:hover, [data-tidy-record].tidy-active { outline-color: #2b7fd1; }
|
|
31
50
|
.tidy-image-preview { display: grid; place-items: center; min-height: 96px; border: 1px solid #d3dcd6; border-radius: 6px; background: #f6f7f5; overflow: hidden; }
|
|
@@ -71,4 +90,6 @@ const config = JSON.stringify({ collection, id, field, blocks, value, records })
|
|
|
71
90
|
.tidy-add-item:hover { border-color: #2b7fd1; background: rgba(43,127,209,.06); }
|
|
72
91
|
.tidy-add-item span { color: #4a5851; font-size: 13px; }
|
|
73
92
|
.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); }
|
|
93
|
+
.tidy-status a { text-decoration: none; }
|
|
94
|
+
.tidy-status-idle { padding: 4px; }
|
|
74
95
|
</style>
|
package/src/editor/runtime.ts
CHANGED
|
@@ -32,6 +32,8 @@ interface Config {
|
|
|
32
32
|
blocks: BlockSchema[];
|
|
33
33
|
value: Block[];
|
|
34
34
|
records?: RecordRef[];
|
|
35
|
+
/** Where "Done" goes: /_tidy/edit switching editing off and returning here. */
|
|
36
|
+
done?: string;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
interface ManifestField {
|
|
@@ -88,6 +90,7 @@ export function start(config: Config): void {
|
|
|
88
90
|
const frames = () => Array.from(document.querySelectorAll<HTMLElement>("[data-tidy-block]"));
|
|
89
91
|
|
|
90
92
|
// EmDash's inline editor is for prose pages; here the sections and records own their fields.
|
|
93
|
+
document.documentElement.classList.add("tidy-editing");
|
|
91
94
|
document.querySelectorAll("[data-emdash-ref]").forEach((n) => n.removeAttribute("data-emdash-ref"));
|
|
92
95
|
|
|
93
96
|
// --- pending publishes ---------------------------------------------------
|
|
@@ -99,7 +102,8 @@ export function start(config: Config): void {
|
|
|
99
102
|
const statusText = el("span");
|
|
100
103
|
const bizBtn = el("button", { type: "button", class: "tidy-btn", text: "Business details" });
|
|
101
104
|
const publishBtn = el("button", { type: "button", class: "tidy-btn tidy-btn-primary", text: "Publish" });
|
|
102
|
-
|
|
105
|
+
const doneBtn = el("a", { class: "tidy-btn", href: config.done ?? `/_tidy/edit?on=0&to=${encodeURIComponent(location.pathname + location.search)}`, text: "Done" });
|
|
106
|
+
status.append(statusText, bizBtn, publishBtn, doneBtn);
|
|
103
107
|
document.body.append(status);
|
|
104
108
|
const business = (config.records ?? []).find((r) => r.collection === "business");
|
|
105
109
|
bizBtn.hidden = !business;
|
|
@@ -202,6 +206,8 @@ export function start(config: Config): void {
|
|
|
202
206
|
for (const f of frames()) {
|
|
203
207
|
f.addEventListener("mouseenter", () => showBlockChip(f));
|
|
204
208
|
f.addEventListener("click", (e) => {
|
|
209
|
+
// A record listed inside the section (a service card) handles its own click.
|
|
210
|
+
if ((e.target as HTMLElement).closest("[data-tidy-record]")) return;
|
|
205
211
|
if (isInteractive(e.target)) return;
|
|
206
212
|
e.preventDefault();
|
|
207
213
|
showBlockChip(f);
|
|
@@ -209,14 +215,21 @@ export function start(config: Config): void {
|
|
|
209
215
|
});
|
|
210
216
|
}
|
|
211
217
|
for (const r of Array.from(document.querySelectorAll<HTMLElement>("[data-tidy-record]"))) {
|
|
218
|
+
const frame = r.parentElement?.closest<HTMLElement>("[data-tidy-block]") ?? null;
|
|
212
219
|
r.addEventListener("mouseenter", () => showRecordChip(r));
|
|
220
|
+
// Leaving a card inside a section hands the chip back to the section.
|
|
221
|
+
if (frame) r.addEventListener("mouseleave", () => showBlockChip(frame));
|
|
213
222
|
r.addEventListener("click", (e) => {
|
|
214
|
-
|
|
223
|
+
// Cards are often links; in edit mode a click edits instead of navigating.
|
|
224
|
+
if ((e.target as HTMLElement).closest("button,input,select,textarea,label")) return;
|
|
215
225
|
e.preventDefault();
|
|
226
|
+
e.stopPropagation();
|
|
216
227
|
showRecordChip(r);
|
|
217
228
|
void openRecord({ collection: r.dataset.tidyRecord ?? "", id: r.dataset.tidyRecordId ?? "", label: r.dataset.tidyRecordLabel ?? "" });
|
|
218
229
|
});
|
|
219
230
|
}
|
|
231
|
+
// The chip sits outside every frame; hovering it must not count as leaving.
|
|
232
|
+
chip.addEventListener("mouseenter", () => current?.classList.add("tidy-active"));
|
|
220
233
|
bEdit.addEventListener("click", () => {
|
|
221
234
|
if (!current) return;
|
|
222
235
|
if (current.dataset.tidyBlock) openBlock(current.dataset.tidyBlock);
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ export const PLATFORM_ROUTES = [
|
|
|
25
25
|
{ pattern: "/_tidy/bootstrap", entrypoint: `${PACKAGE}/routes/bootstrap` },
|
|
26
26
|
{ pattern: "/_tidy/session", entrypoint: `${PACKAGE}/routes/session` },
|
|
27
27
|
{ pattern: "/_tidy/session/claim", entrypoint: `${PACKAGE}/routes/session-claim` },
|
|
28
|
+
{ pattern: "/_tidy/edit", entrypoint: `${PACKAGE}/routes/edit` },
|
|
28
29
|
{ pattern: "/_tidy/health", entrypoint: `${PACKAGE}/routes/health` },
|
|
29
30
|
{ pattern: "/_tidy/rotate-token", entrypoint: `${PACKAGE}/routes/rotate-token` },
|
|
30
31
|
{ pattern: "/_tidy/fill", entrypoint: `${PACKAGE}/routes/fill` },
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /_tidy/edit?on=1|0&to=/path: switch on-site editing on or off for the
|
|
3
|
+
* signed-in editor and go back to the page. No bearer: the browser calls it
|
|
4
|
+
* from the editor's pill. Anyone without a session is sent back unchanged.
|
|
5
|
+
*/
|
|
6
|
+
import type { APIRoute } from "astro";
|
|
7
|
+
import { isSafeLocalPath } from "../lib/http";
|
|
8
|
+
import { methodNotAllowed } from "./_shared";
|
|
9
|
+
|
|
10
|
+
export const prerender = false;
|
|
11
|
+
|
|
12
|
+
const EDIT_COOKIE = "emdash-edit-mode";
|
|
13
|
+
|
|
14
|
+
export const GET: APIRoute = async (context) => {
|
|
15
|
+
const to = context.url.searchParams.get("to");
|
|
16
|
+
const back = isSafeLocalPath(to) ? to : "/";
|
|
17
|
+
const headers = { location: back, "cache-control": "no-store" };
|
|
18
|
+
const user = context.session ? await context.session.get("user") : null;
|
|
19
|
+
if (!user) return new Response(null, { status: 303, headers });
|
|
20
|
+
const secure = context.url.protocol === "https:";
|
|
21
|
+
if (context.url.searchParams.get("on") === "1") context.cookies.set(EDIT_COOKIE, "true", { path: "/", sameSite: "lax", secure });
|
|
22
|
+
else context.cookies.delete(EDIT_COOKIE, { path: "/" });
|
|
23
|
+
return new Response(null, { status: 303, headers });
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const ALL = methodNotAllowed("GET");
|
package/src/version.ts
CHANGED