@tideworthy/tidysites-platform 0.10.7 → 0.10.8

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,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.8
4
+
5
+ - Editor: Request a change, from the bar and a floating button at the corner. A panel takes what to change and which page (the current one by default) and hands it to the platform through the control plane (`POST /_tidy/change`); it shows up under Changes.
6
+
3
7
  ## 0.10.7
4
8
 
5
9
  - Editor: Publish live, Restore and Remove section confirm in a dialog of the editor's own, never the browser's box. The bar's note reads "A private copy to see working changes on the website, not publicly accessible."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tideworthy/tidysites-platform",
3
- "version": "0.10.7",
3
+ "version": "0.10.8",
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",
@@ -31,7 +31,8 @@
31
31
  "./routes/fill": "./src/routes/fill.ts",
32
32
  "./routes/schema": "./src/routes/schema.ts",
33
33
  "./routes/maintenance": "./src/routes/maintenance.ts",
34
- "./routes/revisions": "./src/routes/revisions.ts"
34
+ "./routes/revisions": "./src/routes/revisions.ts",
35
+ "./routes/change": "./src/routes/change.ts"
35
36
  },
36
37
  "scripts": {
37
38
  "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.editor.json",
@@ -64,6 +64,7 @@ const htmlClass = !signedIn ? (signInUrl ? "tidy-has-bar tidy-idle" : "") : !act
64
64
  </div>
65
65
  <script is:inline define:vars={{ htmlClass }}>document.documentElement.className += " " + htmlClass;</script>
66
66
  {signedIn && <script src="./revisions.ts"></script>}
67
+ {signedIn && <script src="./request.ts"></script>}
67
68
  </>
68
69
  )}
69
70
  {config && (
@@ -135,6 +136,9 @@ const htmlClass = !signedIn ? (signInUrl ? "tidy-has-bar tidy-idle" : "") : !act
135
136
  .tidy-add-item:hover { border-color: #2b7fd1; background: rgba(43,127,209,.06); }
136
137
  .tidy-add-item span { color: #4a5851; font-size: 13px; }
137
138
  .tidy-viewer { position: fixed; inset: 0; z-index: 2147483002; display: flex; flex-direction: column; background: #fff; }
139
+ .tidy-fab { position: fixed; right: 20px; bottom: 20px; z-index: 2147483000; width: 52px; height: 52px; border-radius: 50%; border: 0; background: #2b7fd1; color: #fff; display: grid; place-items: center; cursor: pointer; box-shadow: 0 10px 30px rgba(0,0,0,.28); }
140
+ .tidy-fab:hover { background: #2470bd; }
141
+ .tidy-form { display: grid; gap: 14px; margin-bottom: 16px; }
138
142
  .tidy-dialog-backdrop { position: fixed; inset: 0; z-index: 2147483003; display: grid; place-items: center; background: rgba(23,33,28,.45); padding: 16px; }
139
143
  .tidy-dialog { width: min(440px, 100%); background: #fff; color: #17211c; border-radius: 12px; padding: 20px; box-shadow: 0 24px 60px rgba(0,0,0,.3); font: 14px/1.45 system-ui, sans-serif; }
140
144
  .tidy-dialog h2 { margin: 0 0 6px; font-size: 17px; }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Request a change, from the staging site: a button on the bar and a
3
+ * floating one at the corner open a panel with the form; the request goes
4
+ * to the platform through /_tidy/change and shows up under Changes.
5
+ */
6
+ const ROUTE = "/_tidy/change";
7
+
8
+ function el<K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Record<string, string> = {}, children: Array<Node | string> = []): HTMLElementTagNameMap[K] {
9
+ const node = document.createElement(tag);
10
+ for (const [k, v] of Object.entries(attrs)) {
11
+ if (k === "text") node.textContent = v;
12
+ else node.setAttribute(k, v);
13
+ }
14
+ node.append(...children);
15
+ return node;
16
+ }
17
+
18
+ export function mountRequestChange(): void {
19
+ const actions = document.getElementById("tidy-bar-actions");
20
+ if (!actions) return;
21
+ const here = location.pathname + location.search;
22
+
23
+ const panel = el("div", { class: "tidy-panel", hidden: "" });
24
+ document.body.append(panel);
25
+ let sending = false;
26
+
27
+ function close() {
28
+ panel.hidden = true;
29
+ panel.replaceChildren();
30
+ }
31
+
32
+ function render() {
33
+ const head = el("div", { class: "tidy-panel-head" }, [el("h2", { text: "Request a change" }), el("p", { text: "Say what you would like changed. It shows up under Changes and someone picks it up." })]);
34
+ const body = el("textarea", { id: "tidy-change-body", rows: "7", maxlength: "10000", required: "" });
35
+ const page = el("input", { id: "tidy-change-page", type: "text", value: here });
36
+ const err = el("p", { class: "tidy-field-note", hidden: "" });
37
+ const cancel = el("button", { type: "button", class: "tidy-btn", text: "Cancel" });
38
+ const send = el("button", { type: "button", class: "tidy-btn tidy-btn-primary", text: "Send request" });
39
+ cancel.addEventListener("click", close);
40
+ send.addEventListener("click", async () => {
41
+ if (sending) return;
42
+ const text = body.value.trim();
43
+ if (!text) {
44
+ body.focus();
45
+ return;
46
+ }
47
+ sending = true;
48
+ send.disabled = true;
49
+ send.textContent = "Sending";
50
+ err.hidden = true;
51
+ const r = await fetch(ROUTE, { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify({ body: text, pageUrl: page.value.trim() || null }) }).catch(() => null);
52
+ const j = ((await r?.json().catch(() => ({}))) ?? {}) as { ok?: boolean; error?: string };
53
+ sending = false;
54
+ if (r?.ok && j.ok) {
55
+ panel.replaceChildren(
56
+ el("div", { class: "tidy-panel-head" }, [el("h2", { text: "Request sent" }), el("p", { text: "It is under Changes now. You will hear back there." })]),
57
+ el("div", { class: "tidy-panel-actions" }, [el("button", { type: "button", class: "tidy-btn", text: "Close" })])
58
+ );
59
+ panel.querySelector("button")?.addEventListener("click", close);
60
+ return;
61
+ }
62
+ send.disabled = false;
63
+ send.textContent = "Send request";
64
+ err.textContent = j.error ?? "The request could not be sent. Try again.";
65
+ err.hidden = false;
66
+ });
67
+ panel.replaceChildren(
68
+ head,
69
+ el("div", { class: "tidy-form" }, [
70
+ el("div", { class: "tidy-field" }, [el("label", { class: "tidy-field-label", for: "tidy-change-body", text: "What would you like changed?" }), body]),
71
+ el("div", { class: "tidy-field" }, [el("label", { class: "tidy-field-label", for: "tidy-change-page", text: "Which page" }), page]),
72
+ err
73
+ ]),
74
+ el("div", { class: "tidy-panel-actions" }, [cancel, send])
75
+ );
76
+ panel.hidden = false;
77
+ body.focus();
78
+ }
79
+
80
+ const barBtn = el("button", { type: "button", class: "tidy-btn", text: "Request a change" });
81
+ barBtn.addEventListener("click", render);
82
+ actions.prepend(barBtn);
83
+
84
+ const fab = el("button", { type: "button", class: "tidy-fab", title: "Request a change", "aria-label": "Request a change" });
85
+ fab.innerHTML = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
86
+ fab.addEventListener("click", render);
87
+ document.body.append(fab);
88
+ document.addEventListener("keydown", (e) => {
89
+ if (e.key === "Escape" && !panel.hidden) close();
90
+ });
91
+ }
92
+
93
+ mountRequestChange();
package/src/index.ts CHANGED
@@ -31,7 +31,8 @@ export const PLATFORM_ROUTES = [
31
31
  { pattern: "/_tidy/fill", entrypoint: `${PACKAGE}/routes/fill` },
32
32
  { pattern: "/_tidy/schema", entrypoint: `${PACKAGE}/routes/schema` },
33
33
  { pattern: "/_tidy/maintenance", entrypoint: `${PACKAGE}/routes/maintenance` },
34
- { pattern: "/_tidy/revisions", entrypoint: `${PACKAGE}/routes/revisions` }
34
+ { pattern: "/_tidy/revisions", entrypoint: `${PACKAGE}/routes/revisions` },
35
+ { pattern: "/_tidy/change", entrypoint: `${PACKAGE}/routes/change` }
35
36
  ] as const;
36
37
 
37
38
  /**
@@ -0,0 +1,48 @@
1
+ /**
2
+ * /_tidy/change: a change request from the signed-in person on the staging
3
+ * site, handed to the platform through the control plane with the site's
4
+ * own secret. The Astro session is the gate; the person's name and email
5
+ * come from the site's user record.
6
+ *
7
+ * POST { body, pageUrl? } → { ok, changeId }
8
+ */
9
+ import type { APIRoute } from "astro";
10
+ import { isSafeLocalPath, json, readJson } from "../lib/http";
11
+ import { methodNotAllowed, platformRoute } from "./_shared";
12
+
13
+ export const prerender = false;
14
+
15
+ interface ControlEnv {
16
+ TIDY_CONTROL_ORIGIN?: unknown;
17
+ TIDY_SITE_ID?: unknown;
18
+ TIDY_PLATFORM_SECRET?: unknown;
19
+ PLATFORM?: { fetch(input: string, init?: RequestInit): Promise<Response> };
20
+ }
21
+
22
+ export const POST: APIRoute = platformRoute(
23
+ async (context, deps, rawEnv) => {
24
+ const session = context.session ? ((await context.session.get("user")) as { id?: string } | null) : null;
25
+ if (!session?.id) return new Response("Unauthorized", { status: 401 });
26
+ const env = rawEnv as ControlEnv;
27
+ const origin = env.TIDY_CONTROL_ORIGIN;
28
+ const siteId = env.TIDY_SITE_ID;
29
+ const secret = env.TIDY_PLATFORM_SECRET;
30
+ const platform = env.PLATFORM;
31
+ if (!platform || typeof origin !== "string" || typeof siteId !== "string" || typeof secret !== "string") return json({ error: "not configured" }, 503);
32
+ const input = await readJson<{ body?: unknown; pageUrl?: unknown }>(context.request);
33
+ const body = typeof input.body === "string" ? input.body.trim() : "";
34
+ if (!body) return json({ error: "Say what you would like changed." }, 400);
35
+ const pageUrl = isSafeLocalPath(input.pageUrl) ? input.pageUrl : null;
36
+ const user = await deps.store.findUserById(session.id);
37
+ const r = await platform.fetch(`${origin.replace(/\/+$/, "")}/sites/${encodeURIComponent(siteId)}/changes`, {
38
+ method: "POST",
39
+ headers: { authorization: `Bearer ${secret}`, "content-type": "application/json" },
40
+ body: JSON.stringify({ body: body.slice(0, 10_000), pageUrl, requester: user ? { name: user.name, email: user.email } : null })
41
+ });
42
+ const j = (await r.json().catch(() => ({}))) as Record<string, unknown>;
43
+ return json(r.ok ? { ok: true, changeId: j.changeId ?? null } : { error: (j.error as string) ?? "The request could not be sent." }, r.ok ? 200 : 502, { "cache-control": "no-store" });
44
+ },
45
+ { requireBearer: false }
46
+ );
47
+
48
+ export const ALL = methodNotAllowed("POST");
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.10.7";
2
+ export const VERSION = "0.10.8";
3
3
  export const PLUGIN_ID = "tidy-platform";