@tideworthy/tidysites-platform 0.10.7 → 0.10.9

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.9
4
+
5
+ - Editor: the request panel greets the person by name and speaks plainly; the floating button is a pen, not a chat bubble. `GET /_tidy/change` says who is signed in.
6
+
7
+ ## 0.10.8
8
+
9
+ - 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.
10
+
3
11
  ## 0.10.7
4
12
 
5
13
  - 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.9",
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,101 @@
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
+ let who: { name: string | null } | null = null;
33
+ const firstName = () => (who?.name ?? "").trim().split(/\s+/)[0] || "";
34
+
35
+ async function render() {
36
+ if (!who) who = (await fetch(ROUTE, { credentials: "same-origin" }).then((r) => (r.ok ? r.json() : null)).catch(() => null)) as { name: string | null } | null;
37
+ const name = firstName();
38
+ const head = el("div", { class: "tidy-panel-head" }, [
39
+ el("h2", { text: name ? `Hi ${name},` : "Hi there," }),
40
+ el("p", { text: "We would love to help you make some adjustments to your website. Describe what you would like to see different or new, and we will get a version back to you as soon as possible." })
41
+ ]);
42
+ const body = el("textarea", { id: "tidy-change-body", rows: "7", maxlength: "10000", required: "" });
43
+ const page = el("input", { id: "tidy-change-page", type: "text", value: here });
44
+ const err = el("p", { class: "tidy-field-note", hidden: "" });
45
+ const cancel = el("button", { type: "button", class: "tidy-btn", text: "Cancel" });
46
+ const send = el("button", { type: "button", class: "tidy-btn tidy-btn-primary", text: "Send request" });
47
+ cancel.addEventListener("click", close);
48
+ send.addEventListener("click", async () => {
49
+ if (sending) return;
50
+ const text = body.value.trim();
51
+ if (!text) {
52
+ body.focus();
53
+ return;
54
+ }
55
+ sending = true;
56
+ send.disabled = true;
57
+ send.textContent = "Sending";
58
+ err.hidden = true;
59
+ 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);
60
+ const j = ((await r?.json().catch(() => ({}))) ?? {}) as { ok?: boolean; error?: string };
61
+ sending = false;
62
+ if (r?.ok && j.ok) {
63
+ panel.replaceChildren(
64
+ el("div", { class: "tidy-panel-head" }, [el("h2", { text: name ? `Thanks, ${name}.` : "Thanks." }), el("p", { text: "We have your request and will get a version back to you as soon as possible." })]),
65
+ el("div", { class: "tidy-panel-actions" }, [el("button", { type: "button", class: "tidy-btn", text: "Close" })])
66
+ );
67
+ panel.querySelector("button")?.addEventListener("click", close);
68
+ return;
69
+ }
70
+ send.disabled = false;
71
+ send.textContent = "Send request";
72
+ err.textContent = j.error ?? "The request could not be sent. Try again.";
73
+ err.hidden = false;
74
+ });
75
+ panel.replaceChildren(
76
+ head,
77
+ el("div", { class: "tidy-form" }, [
78
+ el("div", { class: "tidy-field" }, [el("label", { class: "tidy-field-label", for: "tidy-change-body", text: "What would you like to see different or new?" }), body]),
79
+ el("div", { class: "tidy-field" }, [el("label", { class: "tidy-field-label", for: "tidy-change-page", text: "Which page" }), page]),
80
+ err
81
+ ]),
82
+ el("div", { class: "tidy-panel-actions" }, [cancel, send])
83
+ );
84
+ panel.hidden = false;
85
+ body.focus();
86
+ }
87
+
88
+ const barBtn = el("button", { type: "button", class: "tidy-btn", text: "Request a change" });
89
+ barBtn.addEventListener("click", render);
90
+ actions.prepend(barBtn);
91
+
92
+ const fab = el("button", { type: "button", class: "tidy-fab", title: "Request a change", "aria-label": "Request a change" });
93
+ 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="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/></svg>';
94
+ fab.addEventListener("click", render);
95
+ document.body.append(fab);
96
+ document.addEventListener("keydown", (e) => {
97
+ if (e.key === "Escape" && !panel.hidden) close();
98
+ });
99
+ }
100
+
101
+ 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,60 @@
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
+ * GET → { name, email } of the signed-in person
8
+ * POST { body, pageUrl? } → { ok, changeId }
9
+ */
10
+ import type { APIRoute } from "astro";
11
+ import { isSafeLocalPath, json, readJson } from "../lib/http";
12
+ import { methodNotAllowed, platformRoute } from "./_shared";
13
+
14
+ export const prerender = false;
15
+
16
+ interface ControlEnv {
17
+ TIDY_CONTROL_ORIGIN?: unknown;
18
+ TIDY_SITE_ID?: unknown;
19
+ TIDY_PLATFORM_SECRET?: unknown;
20
+ PLATFORM?: { fetch(input: string, init?: RequestInit): Promise<Response> };
21
+ }
22
+
23
+ export const POST: APIRoute = platformRoute(
24
+ async (context, deps, rawEnv) => {
25
+ const session = context.session ? ((await context.session.get("user")) as { id?: string } | null) : null;
26
+ if (!session?.id) return new Response("Unauthorized", { status: 401 });
27
+ const env = rawEnv as ControlEnv;
28
+ const origin = env.TIDY_CONTROL_ORIGIN;
29
+ const siteId = env.TIDY_SITE_ID;
30
+ const secret = env.TIDY_PLATFORM_SECRET;
31
+ const platform = env.PLATFORM;
32
+ if (!platform || typeof origin !== "string" || typeof siteId !== "string" || typeof secret !== "string") return json({ error: "not configured" }, 503);
33
+ const input = await readJson<{ body?: unknown; pageUrl?: unknown }>(context.request);
34
+ const body = typeof input.body === "string" ? input.body.trim() : "";
35
+ if (!body) return json({ error: "Say what you would like changed." }, 400);
36
+ const pageUrl = isSafeLocalPath(input.pageUrl) ? input.pageUrl : null;
37
+ const user = await deps.store.findUserById(session.id);
38
+ const r = await platform.fetch(`${origin.replace(/\/+$/, "")}/sites/${encodeURIComponent(siteId)}/changes`, {
39
+ method: "POST",
40
+ headers: { authorization: `Bearer ${secret}`, "content-type": "application/json" },
41
+ body: JSON.stringify({ body: body.slice(0, 10_000), pageUrl, requester: user ? { name: user.name, email: user.email } : null })
42
+ });
43
+ const j = (await r.json().catch(() => ({}))) as Record<string, unknown>;
44
+ 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" });
45
+ },
46
+ { requireBearer: false }
47
+ );
48
+
49
+ /** Who is signed in, for the panel's greeting: { name, email } or 401. */
50
+ export const GET: APIRoute = platformRoute(
51
+ async (context, deps) => {
52
+ const session = context.session ? ((await context.session.get("user")) as { id?: string } | null) : null;
53
+ if (!session?.id) return new Response("Unauthorized", { status: 401 });
54
+ const user = await deps.store.findUserById(session.id);
55
+ return json({ name: user?.name ?? null, email: user?.email ?? null }, 200, { "cache-control": "no-store" });
56
+ },
57
+ { requireBearer: false }
58
+ );
59
+
60
+ export const ALL = methodNotAllowed("GET, 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.9";
3
3
  export const PLUGIN_ID = "tidy-platform";