@tideworthy/tidysites-platform 0.10.6 → 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,16 +1,16 @@
1
1
  # Changelog
2
2
 
3
- ## 0.10.6
3
+ ## 0.10.8
4
4
 
5
- - 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."
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
6
 
7
- ## 0.10.5
7
+ ## 0.10.7
8
8
 
9
- - Editor bar: an interim note.
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."
10
10
 
11
- ## 0.10.5
11
+ ## 0.10.5, 0.10.6
12
12
 
13
- - Editor: the bar's note reads "Changes here go live when you publish."
13
+ - Editor bar: interim notes.
14
14
 
15
15
  ## 0.10.4
16
16
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tideworthy/tidysites-platform",
3
- "version": "0.10.6",
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",
@@ -55,7 +55,7 @@ const htmlClass = !signedIn ? (signInUrl ? "tidy-has-bar tidy-idle" : "") : !act
55
55
  {(signedIn || signInUrl) && (
56
56
  <>
57
57
  <div class="tidy-bar" id="tidy-bar" role="region" aria-label="Site editing">
58
- <span class="tidy-bar-label"><span class="tidy-bar-dot" aria-hidden="true"></span>{label} website<span class="tidy-bar-note">Changes here go live when you publish.</span></span>
58
+ <span class="tidy-bar-label"><span class="tidy-bar-dot" aria-hidden="true"></span>{label} website<span class="tidy-bar-note">A private copy to see working changes on the website, not publicly accessible.</span></span>
59
59
  <div class="tidy-bar-actions" id="tidy-bar-actions">
60
60
  {!signedIn && <a class="tidy-btn tidy-btn-primary" href={signInUrl!}>Sign in to edit</a>}
61
61
  {signedIn && !active && <a class="tidy-btn tidy-btn-primary" href={toggle(true)}>Edit</a>}
@@ -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,17 @@ 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; }
142
+ .tidy-dialog-backdrop { position: fixed; inset: 0; z-index: 2147483003; display: grid; place-items: center; background: rgba(23,33,28,.45); padding: 16px; }
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; }
144
+ .tidy-dialog h2 { margin: 0 0 6px; font-size: 17px; }
145
+ .tidy-dialog p { margin: 0 0 18px; color: #4a5851; }
146
+ .tidy-dialog-actions { display: flex; gap: 8px; justify-content: flex-end; }
147
+ .tidy-dialog .tidy-btn { color: #17211c; border-color: #c9d2cc; background: #fff; padding: 7px 14px; }
148
+ .tidy-dialog .tidy-btn-primary { background: #2b7fd1; border-color: #2b7fd1; color: #fff; }
149
+ .tidy-dialog .tidy-btn-danger { background: #b3261e; border-color: #b3261e; color: #fff; }
138
150
  .tidy-viewer[hidden] { display: none; }
139
151
  .tidy-viewer-bar { display: flex; align-items: center; gap: 8px; padding: 0 16px; height: 44px; background: #17211c; color: #fff; font: 500 13px/1 system-ui, sans-serif; }
140
152
  .tidy-viewer-label { flex: 1; opacity: .85; }
@@ -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.6";
2
+ export const VERSION = "0.10.8";
3
3
  export const PLUGIN_ID = "tidy-platform";