@broberg/cms-inline-edit 0.4.1 → 0.4.2

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/dist/index.cjs CHANGED
@@ -27,30 +27,60 @@ function resolveOptions(options) {
27
27
  return {
28
28
  tokenParam: "cms_edit",
29
29
  storageKey: "wh-inline-edit-token",
30
+ connectLabel: "\u{1F512} Log ind for at redigere",
30
31
  ...options
31
32
  };
32
33
  }
33
- function initInlineEdit(options) {
34
+ async function initInlineEdit(options) {
34
35
  if (typeof window === "undefined") return;
35
36
  const resolved = resolveOptions(options);
36
- const token = bootstrapToken(resolved);
37
- if (!token) return;
38
- activateEditMode(token, resolved);
37
+ captureTokenFromUrl(resolved);
38
+ const enabled = await checkEnabled(resolved);
39
+ if (!enabled) return;
40
+ const token = localStorage.getItem(resolved.storageKey);
41
+ if (token && !isExpired(token)) {
42
+ activateEditMode(token, resolved);
43
+ } else {
44
+ if (token) localStorage.removeItem(resolved.storageKey);
45
+ showConnectPrompt(resolved);
46
+ }
39
47
  }
40
- function bootstrapToken(options) {
48
+ function captureTokenFromUrl(options) {
41
49
  const url = new URL(window.location.href);
42
50
  const urlToken = url.searchParams.get(options.tokenParam);
43
- if (urlToken) {
44
- sessionStorage.setItem(options.storageKey, urlToken);
45
- url.searchParams.delete(options.tokenParam);
46
- window.history.replaceState({}, "", url.toString());
47
- return urlToken;
51
+ if (!urlToken) return;
52
+ localStorage.setItem(options.storageKey, urlToken);
53
+ url.searchParams.delete(options.tokenParam);
54
+ window.history.replaceState({}, "", url.toString());
55
+ }
56
+ async function checkEnabled(options) {
57
+ try {
58
+ const res = await fetch(`${options.cmsBaseUrl}/api/inline-edit/status?site=${options.siteId}`);
59
+ if (!res.ok) return false;
60
+ const body = await res.json();
61
+ return body.enabled === true;
62
+ } catch {
63
+ return false;
48
64
  }
49
- return sessionStorage.getItem(options.storageKey);
65
+ }
66
+ function isExpired(token) {
67
+ const claims = decodeJwtPayload(token);
68
+ const exp = typeof claims?.exp === "number" ? claims.exp : 0;
69
+ return exp <= Date.now() / 1e3;
70
+ }
71
+ function showConnectPrompt(options) {
72
+ const returnUrl = window.location.href;
73
+ const connectUrl = `${options.cmsBaseUrl}/admin/inline-edit/connect?site=${encodeURIComponent(options.siteId)}&return=${encodeURIComponent(returnUrl)}`;
74
+ const link = document.createElement("a");
75
+ link.href = connectUrl;
76
+ link.textContent = options.connectLabel;
77
+ link.setAttribute("data-cms-inline-edit-connect", "");
78
+ link.style.cssText = "position:fixed;bottom:16px;left:16px;background:#1c2027;color:#fff;font:600 12px system-ui,sans-serif;padding:8px 14px;border-radius:999px;text-decoration:none;z-index:2147483647;box-shadow:0 4px 16px rgba(0,0,0,.3);";
79
+ document.body.appendChild(link);
50
80
  }
51
81
  function activateEditMode(token, options) {
52
82
  injectStyles();
53
- showActiveBadge(token);
83
+ showActiveBadge(token, options);
54
84
  const fields = document.querySelectorAll("[data-cms-field]");
55
85
  fields.forEach((el) => wireField(el, token, options));
56
86
  }
@@ -138,13 +168,24 @@ function showPill(el, state) {
138
168
  pill.style.color = "#fff";
139
169
  }
140
170
  }
141
- function showActiveBadge(token) {
171
+ function showActiveBadge(token, options) {
142
172
  const claims = decodeJwtPayload(token);
143
173
  const name = claims?.name || claims?.email || "ukendt";
144
174
  const badge = document.createElement("div");
145
175
  badge.setAttribute("data-cms-inline-edit-badge", "");
146
- badge.textContent = `\u270F\uFE0F Redigerer som ${name}`;
147
- badge.style.cssText = "position:fixed;bottom:16px;left:16px;background:#1c2027;color:#fff;font:600 12px system-ui,sans-serif;padding:8px 14px;border-radius:999px;z-index:2147483647;box-shadow:0 4px 16px rgba(0,0,0,.3);";
176
+ badge.style.cssText = "position:fixed;bottom:16px;left:16px;display:flex;align-items:center;gap:8px;background:#1c2027;color:#fff;font:600 12px system-ui,sans-serif;padding:8px 14px;border-radius:999px;z-index:2147483647;box-shadow:0 4px 16px rgba(0,0,0,.3);";
177
+ const label = document.createElement("span");
178
+ label.textContent = `\u270F\uFE0F Redigerer som ${name}`;
179
+ badge.appendChild(label);
180
+ const disconnect = document.createElement("button");
181
+ disconnect.type = "button";
182
+ disconnect.textContent = "Afbryd";
183
+ disconnect.style.cssText = "background:none;border:none;color:#8ab4ff;font:600 11px system-ui,sans-serif;cursor:pointer;padding:0;text-decoration:underline;";
184
+ disconnect.addEventListener("click", () => {
185
+ localStorage.removeItem(options.storageKey);
186
+ window.location.reload();
187
+ });
188
+ badge.appendChild(disconnect);
148
189
  document.body.appendChild(badge);
149
190
  }
150
191
  function decodeJwtPayload(token) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * Browser entry — zero dependencies. Activates click-to-edit on any element\n * carrying data-cms-collection/data-cms-slug/data-cms-field (F129's attribute\n * convention), when a valid edit-session token is present.\n */\n\nexport interface InlineEditOptions {\n /** Base URL of the CMS API, e.g. \"https://webhouse.app\". */\n cmsBaseUrl: string;\n /** Site id passed as ?site= on every CMS API call, e.g. \"broberg-ai\". */\n siteId: string;\n /** URL query param carrying the short-lived edit-session token. Default \"cms_edit\". */\n tokenParam?: string;\n /** sessionStorage key the token is persisted under across page loads. Default \"wh-inline-edit-token\". */\n storageKey?: string;\n}\n\ninterface ResolvedOptions extends Required<InlineEditOptions> {}\n\nfunction resolveOptions(options: InlineEditOptions): ResolvedOptions {\n return {\n tokenParam: \"cms_edit\",\n storageKey: \"wh-inline-edit-token\",\n ...options,\n };\n}\n\nexport function initInlineEdit(options: InlineEditOptions): void {\n if (typeof window === \"undefined\") return;\n const resolved = resolveOptions(options);\n const token = bootstrapToken(resolved);\n if (!token) return;\n activateEditMode(token, resolved);\n}\n\nfunction bootstrapToken(options: ResolvedOptions): string | null {\n const url = new URL(window.location.href);\n const urlToken = url.searchParams.get(options.tokenParam);\n if (urlToken) {\n sessionStorage.setItem(options.storageKey, urlToken);\n url.searchParams.delete(options.tokenParam);\n window.history.replaceState({}, \"\", url.toString());\n return urlToken;\n }\n return sessionStorage.getItem(options.storageKey);\n}\n\nfunction activateEditMode(token: string, options: ResolvedOptions): void {\n injectStyles();\n showActiveBadge(token);\n\n const fields = document.querySelectorAll<HTMLElement>(\"[data-cms-field]\");\n fields.forEach((el) => wireField(el, token, options));\n}\n\nfunction wireField(el: HTMLElement, token: string, options: ResolvedOptions): void {\n el.addEventListener(\"click\", () => {\n if (el.getAttribute(\"contenteditable\") === \"true\") return;\n el.dataset.cmsOriginalValue = el.textContent ?? \"\";\n el.setAttribute(\"contenteditable\", \"true\");\n el.focus();\n });\n\n el.addEventListener(\"blur\", () => {\n el.removeAttribute(\"contenteditable\");\n const original = el.dataset.cmsOriginalValue ?? \"\";\n const current = el.textContent ?? \"\";\n if (current.trim() === original.trim()) return;\n void saveField(el, token, options);\n });\n\n el.addEventListener(\"paste\", (e) => {\n e.preventDefault();\n const text = e.clipboardData?.getData(\"text/plain\") ?? \"\";\n document.execCommand(\"insertText\", false, text);\n });\n\n el.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n el.blur();\n }\n });\n}\n\nasync function saveField(el: HTMLElement, token: string, options: ResolvedOptions): Promise<void> {\n const collection = el.dataset.cmsCollection;\n const slug = el.dataset.cmsSlug;\n const field = el.dataset.cmsField;\n if (!collection || !slug || !field) return;\n const value = el.textContent?.trim() ?? \"\";\n\n showPill(el, \"saving\");\n try {\n const getRes = await fetch(\n `${options.cmsBaseUrl}/api/cms/${collection}/${slug}?site=${options.siteId}`,\n { headers: { Authorization: `Bearer ${token}` } },\n );\n if (!getRes.ok) throw new Error(`GET failed: ${getRes.status}`);\n const doc = (await getRes.json()) as { data?: Record<string, unknown> };\n const mergedData = { ...doc.data, [field]: value };\n\n const patchRes = await fetch(\n `${options.cmsBaseUrl}/api/cms/${collection}/${slug}?site=${options.siteId}`,\n {\n method: \"PATCH\",\n headers: { Authorization: `Bearer ${token}`, \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ data: mergedData }),\n },\n );\n if (!patchRes.ok) throw new Error(`PATCH failed: ${patchRes.status}`);\n showPill(el, \"saved\");\n } catch {\n showPill(el, \"error\");\n }\n}\n\nconst pills = new WeakMap<HTMLElement, HTMLElement>();\n\nfunction showPill(el: HTMLElement, state: \"saving\" | \"saved\" | \"error\"): void {\n let pill = pills.get(el);\n if (!pill) {\n pill = document.createElement(\"span\");\n pill.setAttribute(\"data-cms-inline-edit-pill\", \"\");\n document.body.appendChild(pill);\n pills.set(el, pill);\n }\n const rect = el.getBoundingClientRect();\n pill.style.cssText =\n `position:fixed;top:${rect.top - 26}px;left:${rect.left}px;font:600 11px system-ui,sans-serif;` +\n `padding:3px 9px;border-radius:5px;z-index:2147483647;pointer-events:none;` +\n `box-shadow:0 2px 8px rgba(0,0,0,.25);`;\n\n if (state === \"saving\") {\n pill.textContent = \"Gemmer…\";\n pill.style.background = \"#1c2027\";\n pill.style.color = \"#fff\";\n } else if (state === \"saved\") {\n pill.textContent = \"Gemt ✓\";\n pill.style.background = \"#16a34a\";\n pill.style.color = \"#fff\";\n setTimeout(() => {\n pill?.remove();\n pills.delete(el);\n }, 1500);\n } else {\n pill.textContent = \"Fejl — prøv igen\";\n pill.style.background = \"#dc2626\";\n pill.style.color = \"#fff\";\n }\n}\n\nfunction showActiveBadge(token: string): void {\n const claims = decodeJwtPayload(token);\n const name = (claims?.name as string) || (claims?.email as string) || \"ukendt\";\n const badge = document.createElement(\"div\");\n badge.setAttribute(\"data-cms-inline-edit-badge\", \"\");\n badge.textContent = `✏️ Redigerer som ${name}`;\n badge.style.cssText =\n \"position:fixed;bottom:16px;left:16px;background:#1c2027;color:#fff;\" +\n \"font:600 12px system-ui,sans-serif;padding:8px 14px;border-radius:999px;\" +\n \"z-index:2147483647;box-shadow:0 4px 16px rgba(0,0,0,.3);\";\n document.body.appendChild(badge);\n}\n\n/** Reads JWT claims for DISPLAY ONLY — never used for authorization decisions. */\nfunction decodeJwtPayload(token: string): Record<string, unknown> | null {\n try {\n const payload = token.split(\".\")[1];\n if (!payload) return null;\n return JSON.parse(atob(payload.replace(/-/g, \"+\").replace(/_/g, \"/\")));\n } catch {\n return null;\n }\n}\n\nfunction injectStyles(): void {\n const style = document.createElement(\"style\");\n style.textContent = `\n [data-cms-field] { outline: 1px dashed transparent; outline-offset: 2px; cursor: text; transition: outline-color .15s; }\n [data-cms-field]:hover { outline-color: rgba(0,178,255,.5); }\n [data-cms-field][contenteditable=\"true\"] { outline: 2px solid #00b2ff; outline-offset: 2px; }\n `;\n document.head.appendChild(style);\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * Browser entry — zero dependencies. Site-wide: activated once per browser\n * via a \"connect\" link (see docs/features/F157-inline-editing.md), then\n * every page load on the site automatically offers click-to-edit on any\n * element carrying data-cms-collection/data-cms-slug/data-cms-field\n * (F129's attribute convention) — no per-document step.\n */\n\nexport interface InlineEditOptions {\n /** Base URL of the CMS API, e.g. \"https://webhouse.app\". */\n cmsBaseUrl: string;\n /** Site id passed as ?site= on every CMS API call, e.g. \"broberg-ai\". */\n siteId: string;\n /** URL query param carrying a freshly-minted token from the connect redirect. Default \"cms_edit\". */\n tokenParam?: string;\n /** localStorage key the token is persisted under — survives across sessions/tabs. Default \"wh-inline-edit-token\". */\n storageKey?: string;\n /** Label for the \"not connected yet\" prompt. Default \"🔒 Log ind for at redigere\". */\n connectLabel?: string;\n}\n\ninterface ResolvedOptions extends Required<InlineEditOptions> {}\n\nfunction resolveOptions(options: InlineEditOptions): ResolvedOptions {\n return {\n tokenParam: \"cms_edit\",\n storageKey: \"wh-inline-edit-token\",\n connectLabel: \"🔒 Log ind for at redigere\",\n ...options,\n };\n}\n\nexport async function initInlineEdit(options: InlineEditOptions): Promise<void> {\n if (typeof window === \"undefined\") return;\n const resolved = resolveOptions(options);\n\n captureTokenFromUrl(resolved);\n\n const enabled = await checkEnabled(resolved);\n if (!enabled) return;\n\n const token = localStorage.getItem(resolved.storageKey);\n if (token && !isExpired(token)) {\n activateEditMode(token, resolved);\n } else {\n if (token) localStorage.removeItem(resolved.storageKey);\n showConnectPrompt(resolved);\n }\n}\n\n/** Captures a token minted by /admin/inline-edit/connect, then strips it from the URL. */\nfunction captureTokenFromUrl(options: ResolvedOptions): void {\n const url = new URL(window.location.href);\n const urlToken = url.searchParams.get(options.tokenParam);\n if (!urlToken) return;\n localStorage.setItem(options.storageKey, urlToken);\n url.searchParams.delete(options.tokenParam);\n window.history.replaceState({}, \"\", url.toString());\n}\n\nasync function checkEnabled(options: ResolvedOptions): Promise<boolean> {\n try {\n const res = await fetch(`${options.cmsBaseUrl}/api/inline-edit/status?site=${options.siteId}`);\n if (!res.ok) return false;\n const body = (await res.json()) as { enabled?: boolean };\n return body.enabled === true;\n } catch {\n return false;\n }\n}\n\nfunction isExpired(token: string): boolean {\n const claims = decodeJwtPayload(token);\n const exp = typeof claims?.exp === \"number\" ? claims.exp : 0;\n return exp <= Date.now() / 1000;\n}\n\nfunction showConnectPrompt(options: ResolvedOptions): void {\n const returnUrl = window.location.href;\n const connectUrl =\n `${options.cmsBaseUrl}/admin/inline-edit/connect?site=${encodeURIComponent(options.siteId)}` +\n `&return=${encodeURIComponent(returnUrl)}`;\n\n const link = document.createElement(\"a\");\n link.href = connectUrl;\n link.textContent = options.connectLabel;\n link.setAttribute(\"data-cms-inline-edit-connect\", \"\");\n link.style.cssText =\n \"position:fixed;bottom:16px;left:16px;background:#1c2027;color:#fff;\" +\n \"font:600 12px system-ui,sans-serif;padding:8px 14px;border-radius:999px;\" +\n \"text-decoration:none;z-index:2147483647;box-shadow:0 4px 16px rgba(0,0,0,.3);\";\n document.body.appendChild(link);\n}\n\nfunction activateEditMode(token: string, options: ResolvedOptions): void {\n injectStyles();\n showActiveBadge(token, options);\n\n const fields = document.querySelectorAll<HTMLElement>(\"[data-cms-field]\");\n fields.forEach((el) => wireField(el, token, options));\n}\n\nfunction wireField(el: HTMLElement, token: string, options: ResolvedOptions): void {\n el.addEventListener(\"click\", () => {\n if (el.getAttribute(\"contenteditable\") === \"true\") return;\n el.dataset.cmsOriginalValue = el.textContent ?? \"\";\n el.setAttribute(\"contenteditable\", \"true\");\n el.focus();\n });\n\n el.addEventListener(\"blur\", () => {\n el.removeAttribute(\"contenteditable\");\n const original = el.dataset.cmsOriginalValue ?? \"\";\n const current = el.textContent ?? \"\";\n if (current.trim() === original.trim()) return;\n void saveField(el, token, options);\n });\n\n el.addEventListener(\"paste\", (e) => {\n e.preventDefault();\n const text = e.clipboardData?.getData(\"text/plain\") ?? \"\";\n document.execCommand(\"insertText\", false, text);\n });\n\n el.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n el.blur();\n }\n });\n}\n\nasync function saveField(el: HTMLElement, token: string, options: ResolvedOptions): Promise<void> {\n const collection = el.dataset.cmsCollection;\n const slug = el.dataset.cmsSlug;\n const field = el.dataset.cmsField;\n if (!collection || !slug || !field) return;\n const value = el.textContent?.trim() ?? \"\";\n\n showPill(el, \"saving\");\n try {\n const getRes = await fetch(\n `${options.cmsBaseUrl}/api/cms/${collection}/${slug}?site=${options.siteId}`,\n { headers: { Authorization: `Bearer ${token}` } },\n );\n if (!getRes.ok) throw new Error(`GET failed: ${getRes.status}`);\n const doc = (await getRes.json()) as { data?: Record<string, unknown> };\n const mergedData = { ...doc.data, [field]: value };\n\n const patchRes = await fetch(\n `${options.cmsBaseUrl}/api/cms/${collection}/${slug}?site=${options.siteId}`,\n {\n method: \"PATCH\",\n headers: { Authorization: `Bearer ${token}`, \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ data: mergedData }),\n },\n );\n if (!patchRes.ok) throw new Error(`PATCH failed: ${patchRes.status}`);\n showPill(el, \"saved\");\n } catch {\n showPill(el, \"error\");\n }\n}\n\nconst pills = new WeakMap<HTMLElement, HTMLElement>();\n\nfunction showPill(el: HTMLElement, state: \"saving\" | \"saved\" | \"error\"): void {\n let pill = pills.get(el);\n if (!pill) {\n pill = document.createElement(\"span\");\n pill.setAttribute(\"data-cms-inline-edit-pill\", \"\");\n document.body.appendChild(pill);\n pills.set(el, pill);\n }\n const rect = el.getBoundingClientRect();\n pill.style.cssText =\n `position:fixed;top:${rect.top - 26}px;left:${rect.left}px;font:600 11px system-ui,sans-serif;` +\n `padding:3px 9px;border-radius:5px;z-index:2147483647;pointer-events:none;` +\n `box-shadow:0 2px 8px rgba(0,0,0,.25);`;\n\n if (state === \"saving\") {\n pill.textContent = \"Gemmer…\";\n pill.style.background = \"#1c2027\";\n pill.style.color = \"#fff\";\n } else if (state === \"saved\") {\n pill.textContent = \"Gemt ✓\";\n pill.style.background = \"#16a34a\";\n pill.style.color = \"#fff\";\n setTimeout(() => {\n pill?.remove();\n pills.delete(el);\n }, 1500);\n } else {\n pill.textContent = \"Fejl — prøv igen\";\n pill.style.background = \"#dc2626\";\n pill.style.color = \"#fff\";\n }\n}\n\nfunction showActiveBadge(token: string, options: ResolvedOptions): void {\n const claims = decodeJwtPayload(token);\n const name = (claims?.name as string) || (claims?.email as string) || \"ukendt\";\n\n const badge = document.createElement(\"div\");\n badge.setAttribute(\"data-cms-inline-edit-badge\", \"\");\n badge.style.cssText =\n \"position:fixed;bottom:16px;left:16px;display:flex;align-items:center;gap:8px;\" +\n \"background:#1c2027;color:#fff;font:600 12px system-ui,sans-serif;padding:8px 14px;\" +\n \"border-radius:999px;z-index:2147483647;box-shadow:0 4px 16px rgba(0,0,0,.3);\";\n\n const label = document.createElement(\"span\");\n label.textContent = `✏️ Redigerer som ${name}`;\n badge.appendChild(label);\n\n const disconnect = document.createElement(\"button\");\n disconnect.type = \"button\";\n disconnect.textContent = \"Afbryd\";\n disconnect.style.cssText =\n \"background:none;border:none;color:#8ab4ff;font:600 11px system-ui,sans-serif;\" +\n \"cursor:pointer;padding:0;text-decoration:underline;\";\n disconnect.addEventListener(\"click\", () => {\n localStorage.removeItem(options.storageKey);\n window.location.reload();\n });\n badge.appendChild(disconnect);\n\n document.body.appendChild(badge);\n}\n\n/** Reads JWT claims for DISPLAY ONLY — never used for authorization decisions. */\nfunction decodeJwtPayload(token: string): Record<string, unknown> | null {\n try {\n const payload = token.split(\".\")[1];\n if (!payload) return null;\n return JSON.parse(atob(payload.replace(/-/g, \"+\").replace(/_/g, \"/\")));\n } catch {\n return null;\n }\n}\n\nfunction injectStyles(): void {\n const style = document.createElement(\"style\");\n style.textContent = `\n [data-cms-field] { outline: 1px dashed transparent; outline-offset: 2px; cursor: text; transition: outline-color .15s; }\n [data-cms-field]:hover { outline-color: rgba(0,178,255,.5); }\n [data-cms-field][contenteditable=\"true\"] { outline: 2px solid #00b2ff; outline-offset: 2px; }\n `;\n document.head.appendChild(style);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAuBA,SAAS,eAAe,SAA6C;AACnE,SAAO;AAAA,IACL,YAAY;AAAA,IACZ,YAAY;AAAA,IACZ,cAAc;AAAA,IACd,GAAG;AAAA,EACL;AACF;AAEA,eAAsB,eAAe,SAA2C;AAC9E,MAAI,OAAO,WAAW,YAAa;AACnC,QAAM,WAAW,eAAe,OAAO;AAEvC,sBAAoB,QAAQ;AAE5B,QAAM,UAAU,MAAM,aAAa,QAAQ;AAC3C,MAAI,CAAC,QAAS;AAEd,QAAM,QAAQ,aAAa,QAAQ,SAAS,UAAU;AACtD,MAAI,SAAS,CAAC,UAAU,KAAK,GAAG;AAC9B,qBAAiB,OAAO,QAAQ;AAAA,EAClC,OAAO;AACL,QAAI,MAAO,cAAa,WAAW,SAAS,UAAU;AACtD,sBAAkB,QAAQ;AAAA,EAC5B;AACF;AAGA,SAAS,oBAAoB,SAAgC;AAC3D,QAAM,MAAM,IAAI,IAAI,OAAO,SAAS,IAAI;AACxC,QAAM,WAAW,IAAI,aAAa,IAAI,QAAQ,UAAU;AACxD,MAAI,CAAC,SAAU;AACf,eAAa,QAAQ,QAAQ,YAAY,QAAQ;AACjD,MAAI,aAAa,OAAO,QAAQ,UAAU;AAC1C,SAAO,QAAQ,aAAa,CAAC,GAAG,IAAI,IAAI,SAAS,CAAC;AACpD;AAEA,eAAe,aAAa,SAA4C;AACtE,MAAI;AACF,UAAM,MAAM,MAAM,MAAM,GAAG,QAAQ,UAAU,gCAAgC,QAAQ,MAAM,EAAE;AAC7F,QAAI,CAAC,IAAI,GAAI,QAAO;AACpB,UAAM,OAAQ,MAAM,IAAI,KAAK;AAC7B,WAAO,KAAK,YAAY;AAAA,EAC1B,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,UAAU,OAAwB;AACzC,QAAM,SAAS,iBAAiB,KAAK;AACrC,QAAM,MAAM,OAAO,QAAQ,QAAQ,WAAW,OAAO,MAAM;AAC3D,SAAO,OAAO,KAAK,IAAI,IAAI;AAC7B;AAEA,SAAS,kBAAkB,SAAgC;AACzD,QAAM,YAAY,OAAO,SAAS;AAClC,QAAM,aACJ,GAAG,QAAQ,UAAU,mCAAmC,mBAAmB,QAAQ,MAAM,CAAC,WAC/E,mBAAmB,SAAS,CAAC;AAE1C,QAAM,OAAO,SAAS,cAAc,GAAG;AACvC,OAAK,OAAO;AACZ,OAAK,cAAc,QAAQ;AAC3B,OAAK,aAAa,gCAAgC,EAAE;AACpD,OAAK,MAAM,UACT;AAGF,WAAS,KAAK,YAAY,IAAI;AAChC;AAEA,SAAS,iBAAiB,OAAe,SAAgC;AACvE,eAAa;AACb,kBAAgB,OAAO,OAAO;AAE9B,QAAM,SAAS,SAAS,iBAA8B,kBAAkB;AACxE,SAAO,QAAQ,CAAC,OAAO,UAAU,IAAI,OAAO,OAAO,CAAC;AACtD;AAEA,SAAS,UAAU,IAAiB,OAAe,SAAgC;AACjF,KAAG,iBAAiB,SAAS,MAAM;AACjC,QAAI,GAAG,aAAa,iBAAiB,MAAM,OAAQ;AACnD,OAAG,QAAQ,mBAAmB,GAAG,eAAe;AAChD,OAAG,aAAa,mBAAmB,MAAM;AACzC,OAAG,MAAM;AAAA,EACX,CAAC;AAED,KAAG,iBAAiB,QAAQ,MAAM;AAChC,OAAG,gBAAgB,iBAAiB;AACpC,UAAM,WAAW,GAAG,QAAQ,oBAAoB;AAChD,UAAM,UAAU,GAAG,eAAe;AAClC,QAAI,QAAQ,KAAK,MAAM,SAAS,KAAK,EAAG;AACxC,SAAK,UAAU,IAAI,OAAO,OAAO;AAAA,EACnC,CAAC;AAED,KAAG,iBAAiB,SAAS,CAAC,MAAM;AAClC,MAAE,eAAe;AACjB,UAAM,OAAO,EAAE,eAAe,QAAQ,YAAY,KAAK;AACvD,aAAS,YAAY,cAAc,OAAO,IAAI;AAAA,EAChD,CAAC;AAED,KAAG,iBAAiB,WAAW,CAAC,MAAM;AACpC,QAAI,EAAE,QAAQ,SAAS;AACrB,QAAE,eAAe;AACjB,SAAG,KAAK;AAAA,IACV;AAAA,EACF,CAAC;AACH;AAEA,eAAe,UAAU,IAAiB,OAAe,SAAyC;AAChG,QAAM,aAAa,GAAG,QAAQ;AAC9B,QAAM,OAAO,GAAG,QAAQ;AACxB,QAAM,QAAQ,GAAG,QAAQ;AACzB,MAAI,CAAC,cAAc,CAAC,QAAQ,CAAC,MAAO;AACpC,QAAM,QAAQ,GAAG,aAAa,KAAK,KAAK;AAExC,WAAS,IAAI,QAAQ;AACrB,MAAI;AACF,UAAM,SAAS,MAAM;AAAA,MACnB,GAAG,QAAQ,UAAU,YAAY,UAAU,IAAI,IAAI,SAAS,QAAQ,MAAM;AAAA,MAC1E,EAAE,SAAS,EAAE,eAAe,UAAU,KAAK,GAAG,EAAE;AAAA,IAClD;AACA,QAAI,CAAC,OAAO,GAAI,OAAM,IAAI,MAAM,eAAe,OAAO,MAAM,EAAE;AAC9D,UAAM,MAAO,MAAM,OAAO,KAAK;AAC/B,UAAM,aAAa,EAAE,GAAG,IAAI,MAAM,CAAC,KAAK,GAAG,MAAM;AAEjD,UAAM,WAAW,MAAM;AAAA,MACrB,GAAG,QAAQ,UAAU,YAAY,UAAU,IAAI,IAAI,SAAS,QAAQ,MAAM;AAAA,MAC1E;AAAA,QACE,QAAQ;AAAA,QACR,SAAS,EAAE,eAAe,UAAU,KAAK,IAAI,gBAAgB,mBAAmB;AAAA,QAChF,MAAM,KAAK,UAAU,EAAE,MAAM,WAAW,CAAC;AAAA,MAC3C;AAAA,IACF;AACA,QAAI,CAAC,SAAS,GAAI,OAAM,IAAI,MAAM,iBAAiB,SAAS,MAAM,EAAE;AACpE,aAAS,IAAI,OAAO;AAAA,EACtB,QAAQ;AACN,aAAS,IAAI,OAAO;AAAA,EACtB;AACF;AAEA,IAAM,QAAQ,oBAAI,QAAkC;AAEpD,SAAS,SAAS,IAAiB,OAA2C;AAC5E,MAAI,OAAO,MAAM,IAAI,EAAE;AACvB,MAAI,CAAC,MAAM;AACT,WAAO,SAAS,cAAc,MAAM;AACpC,SAAK,aAAa,6BAA6B,EAAE;AACjD,aAAS,KAAK,YAAY,IAAI;AAC9B,UAAM,IAAI,IAAI,IAAI;AAAA,EACpB;AACA,QAAM,OAAO,GAAG,sBAAsB;AACtC,OAAK,MAAM,UACT,sBAAsB,KAAK,MAAM,EAAE,WAAW,KAAK,IAAI;AAIzD,MAAI,UAAU,UAAU;AACtB,SAAK,cAAc;AACnB,SAAK,MAAM,aAAa;AACxB,SAAK,MAAM,QAAQ;AAAA,EACrB,WAAW,UAAU,SAAS;AAC5B,SAAK,cAAc;AACnB,SAAK,MAAM,aAAa;AACxB,SAAK,MAAM,QAAQ;AACnB,eAAW,MAAM;AACf,YAAM,OAAO;AACb,YAAM,OAAO,EAAE;AAAA,IACjB,GAAG,IAAI;AAAA,EACT,OAAO;AACL,SAAK,cAAc;AACnB,SAAK,MAAM,aAAa;AACxB,SAAK,MAAM,QAAQ;AAAA,EACrB;AACF;AAEA,SAAS,gBAAgB,OAAe,SAAgC;AACtE,QAAM,SAAS,iBAAiB,KAAK;AACrC,QAAM,OAAQ,QAAQ,QAAoB,QAAQ,SAAoB;AAEtE,QAAM,QAAQ,SAAS,cAAc,KAAK;AAC1C,QAAM,aAAa,8BAA8B,EAAE;AACnD,QAAM,MAAM,UACV;AAIF,QAAM,QAAQ,SAAS,cAAc,MAAM;AAC3C,QAAM,cAAc,8BAAoB,IAAI;AAC5C,QAAM,YAAY,KAAK;AAEvB,QAAM,aAAa,SAAS,cAAc,QAAQ;AAClD,aAAW,OAAO;AAClB,aAAW,cAAc;AACzB,aAAW,MAAM,UACf;AAEF,aAAW,iBAAiB,SAAS,MAAM;AACzC,iBAAa,WAAW,QAAQ,UAAU;AAC1C,WAAO,SAAS,OAAO;AAAA,EACzB,CAAC;AACD,QAAM,YAAY,UAAU;AAE5B,WAAS,KAAK,YAAY,KAAK;AACjC;AAGA,SAAS,iBAAiB,OAA+C;AACvE,MAAI;AACF,UAAM,UAAU,MAAM,MAAM,GAAG,EAAE,CAAC;AAClC,QAAI,CAAC,QAAS,QAAO;AACrB,WAAO,KAAK,MAAM,KAAK,QAAQ,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG,CAAC,CAAC;AAAA,EACvE,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,eAAqB;AAC5B,QAAM,QAAQ,SAAS,cAAc,OAAO;AAC5C,QAAM,cAAc;AAAA;AAAA;AAAA;AAAA;AAKpB,WAAS,KAAK,YAAY,KAAK;AACjC;","names":[]}
package/dist/index.d.cts CHANGED
@@ -1,18 +1,22 @@
1
1
  /**
2
- * Browser entry — zero dependencies. Activates click-to-edit on any element
3
- * carrying data-cms-collection/data-cms-slug/data-cms-field (F129's attribute
4
- * convention), when a valid edit-session token is present.
2
+ * Browser entry — zero dependencies. Site-wide: activated once per browser
3
+ * via a "connect" link (see docs/features/F157-inline-editing.md), then
4
+ * every page load on the site automatically offers click-to-edit on any
5
+ * element carrying data-cms-collection/data-cms-slug/data-cms-field
6
+ * (F129's attribute convention) — no per-document step.
5
7
  */
6
8
  interface InlineEditOptions {
7
9
  /** Base URL of the CMS API, e.g. "https://webhouse.app". */
8
10
  cmsBaseUrl: string;
9
11
  /** Site id passed as ?site= on every CMS API call, e.g. "broberg-ai". */
10
12
  siteId: string;
11
- /** URL query param carrying the short-lived edit-session token. Default "cms_edit". */
13
+ /** URL query param carrying a freshly-minted token from the connect redirect. Default "cms_edit". */
12
14
  tokenParam?: string;
13
- /** sessionStorage key the token is persisted under across page loads. Default "wh-inline-edit-token". */
15
+ /** localStorage key the token is persisted under — survives across sessions/tabs. Default "wh-inline-edit-token". */
14
16
  storageKey?: string;
17
+ /** Label for the "not connected yet" prompt. Default "🔒 Log ind for at redigere". */
18
+ connectLabel?: string;
15
19
  }
16
- declare function initInlineEdit(options: InlineEditOptions): void;
20
+ declare function initInlineEdit(options: InlineEditOptions): Promise<void>;
17
21
 
18
22
  export { type InlineEditOptions, initInlineEdit };
package/dist/index.d.ts CHANGED
@@ -1,18 +1,22 @@
1
1
  /**
2
- * Browser entry — zero dependencies. Activates click-to-edit on any element
3
- * carrying data-cms-collection/data-cms-slug/data-cms-field (F129's attribute
4
- * convention), when a valid edit-session token is present.
2
+ * Browser entry — zero dependencies. Site-wide: activated once per browser
3
+ * via a "connect" link (see docs/features/F157-inline-editing.md), then
4
+ * every page load on the site automatically offers click-to-edit on any
5
+ * element carrying data-cms-collection/data-cms-slug/data-cms-field
6
+ * (F129's attribute convention) — no per-document step.
5
7
  */
6
8
  interface InlineEditOptions {
7
9
  /** Base URL of the CMS API, e.g. "https://webhouse.app". */
8
10
  cmsBaseUrl: string;
9
11
  /** Site id passed as ?site= on every CMS API call, e.g. "broberg-ai". */
10
12
  siteId: string;
11
- /** URL query param carrying the short-lived edit-session token. Default "cms_edit". */
13
+ /** URL query param carrying a freshly-minted token from the connect redirect. Default "cms_edit". */
12
14
  tokenParam?: string;
13
- /** sessionStorage key the token is persisted under across page loads. Default "wh-inline-edit-token". */
15
+ /** localStorage key the token is persisted under — survives across sessions/tabs. Default "wh-inline-edit-token". */
14
16
  storageKey?: string;
17
+ /** Label for the "not connected yet" prompt. Default "🔒 Log ind for at redigere". */
18
+ connectLabel?: string;
15
19
  }
16
- declare function initInlineEdit(options: InlineEditOptions): void;
20
+ declare function initInlineEdit(options: InlineEditOptions): Promise<void>;
17
21
 
18
22
  export { type InlineEditOptions, initInlineEdit };
package/dist/index.js CHANGED
@@ -3,30 +3,60 @@ function resolveOptions(options) {
3
3
  return {
4
4
  tokenParam: "cms_edit",
5
5
  storageKey: "wh-inline-edit-token",
6
+ connectLabel: "\u{1F512} Log ind for at redigere",
6
7
  ...options
7
8
  };
8
9
  }
9
- function initInlineEdit(options) {
10
+ async function initInlineEdit(options) {
10
11
  if (typeof window === "undefined") return;
11
12
  const resolved = resolveOptions(options);
12
- const token = bootstrapToken(resolved);
13
- if (!token) return;
14
- activateEditMode(token, resolved);
13
+ captureTokenFromUrl(resolved);
14
+ const enabled = await checkEnabled(resolved);
15
+ if (!enabled) return;
16
+ const token = localStorage.getItem(resolved.storageKey);
17
+ if (token && !isExpired(token)) {
18
+ activateEditMode(token, resolved);
19
+ } else {
20
+ if (token) localStorage.removeItem(resolved.storageKey);
21
+ showConnectPrompt(resolved);
22
+ }
15
23
  }
16
- function bootstrapToken(options) {
24
+ function captureTokenFromUrl(options) {
17
25
  const url = new URL(window.location.href);
18
26
  const urlToken = url.searchParams.get(options.tokenParam);
19
- if (urlToken) {
20
- sessionStorage.setItem(options.storageKey, urlToken);
21
- url.searchParams.delete(options.tokenParam);
22
- window.history.replaceState({}, "", url.toString());
23
- return urlToken;
27
+ if (!urlToken) return;
28
+ localStorage.setItem(options.storageKey, urlToken);
29
+ url.searchParams.delete(options.tokenParam);
30
+ window.history.replaceState({}, "", url.toString());
31
+ }
32
+ async function checkEnabled(options) {
33
+ try {
34
+ const res = await fetch(`${options.cmsBaseUrl}/api/inline-edit/status?site=${options.siteId}`);
35
+ if (!res.ok) return false;
36
+ const body = await res.json();
37
+ return body.enabled === true;
38
+ } catch {
39
+ return false;
24
40
  }
25
- return sessionStorage.getItem(options.storageKey);
41
+ }
42
+ function isExpired(token) {
43
+ const claims = decodeJwtPayload(token);
44
+ const exp = typeof claims?.exp === "number" ? claims.exp : 0;
45
+ return exp <= Date.now() / 1e3;
46
+ }
47
+ function showConnectPrompt(options) {
48
+ const returnUrl = window.location.href;
49
+ const connectUrl = `${options.cmsBaseUrl}/admin/inline-edit/connect?site=${encodeURIComponent(options.siteId)}&return=${encodeURIComponent(returnUrl)}`;
50
+ const link = document.createElement("a");
51
+ link.href = connectUrl;
52
+ link.textContent = options.connectLabel;
53
+ link.setAttribute("data-cms-inline-edit-connect", "");
54
+ link.style.cssText = "position:fixed;bottom:16px;left:16px;background:#1c2027;color:#fff;font:600 12px system-ui,sans-serif;padding:8px 14px;border-radius:999px;text-decoration:none;z-index:2147483647;box-shadow:0 4px 16px rgba(0,0,0,.3);";
55
+ document.body.appendChild(link);
26
56
  }
27
57
  function activateEditMode(token, options) {
28
58
  injectStyles();
29
- showActiveBadge(token);
59
+ showActiveBadge(token, options);
30
60
  const fields = document.querySelectorAll("[data-cms-field]");
31
61
  fields.forEach((el) => wireField(el, token, options));
32
62
  }
@@ -114,13 +144,24 @@ function showPill(el, state) {
114
144
  pill.style.color = "#fff";
115
145
  }
116
146
  }
117
- function showActiveBadge(token) {
147
+ function showActiveBadge(token, options) {
118
148
  const claims = decodeJwtPayload(token);
119
149
  const name = claims?.name || claims?.email || "ukendt";
120
150
  const badge = document.createElement("div");
121
151
  badge.setAttribute("data-cms-inline-edit-badge", "");
122
- badge.textContent = `\u270F\uFE0F Redigerer som ${name}`;
123
- badge.style.cssText = "position:fixed;bottom:16px;left:16px;background:#1c2027;color:#fff;font:600 12px system-ui,sans-serif;padding:8px 14px;border-radius:999px;z-index:2147483647;box-shadow:0 4px 16px rgba(0,0,0,.3);";
152
+ badge.style.cssText = "position:fixed;bottom:16px;left:16px;display:flex;align-items:center;gap:8px;background:#1c2027;color:#fff;font:600 12px system-ui,sans-serif;padding:8px 14px;border-radius:999px;z-index:2147483647;box-shadow:0 4px 16px rgba(0,0,0,.3);";
153
+ const label = document.createElement("span");
154
+ label.textContent = `\u270F\uFE0F Redigerer som ${name}`;
155
+ badge.appendChild(label);
156
+ const disconnect = document.createElement("button");
157
+ disconnect.type = "button";
158
+ disconnect.textContent = "Afbryd";
159
+ disconnect.style.cssText = "background:none;border:none;color:#8ab4ff;font:600 11px system-ui,sans-serif;cursor:pointer;padding:0;text-decoration:underline;";
160
+ disconnect.addEventListener("click", () => {
161
+ localStorage.removeItem(options.storageKey);
162
+ window.location.reload();
163
+ });
164
+ badge.appendChild(disconnect);
124
165
  document.body.appendChild(badge);
125
166
  }
126
167
  function decodeJwtPayload(token) {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * Browser entry — zero dependencies. Activates click-to-edit on any element\n * carrying data-cms-collection/data-cms-slug/data-cms-field (F129's attribute\n * convention), when a valid edit-session token is present.\n */\n\nexport interface InlineEditOptions {\n /** Base URL of the CMS API, e.g. \"https://webhouse.app\". */\n cmsBaseUrl: string;\n /** Site id passed as ?site= on every CMS API call, e.g. \"broberg-ai\". */\n siteId: string;\n /** URL query param carrying the short-lived edit-session token. Default \"cms_edit\". */\n tokenParam?: string;\n /** sessionStorage key the token is persisted under across page loads. Default \"wh-inline-edit-token\". */\n storageKey?: string;\n}\n\ninterface ResolvedOptions extends Required<InlineEditOptions> {}\n\nfunction resolveOptions(options: InlineEditOptions): ResolvedOptions {\n return {\n tokenParam: \"cms_edit\",\n storageKey: \"wh-inline-edit-token\",\n ...options,\n };\n}\n\nexport function initInlineEdit(options: InlineEditOptions): void {\n if (typeof window === \"undefined\") return;\n const resolved = resolveOptions(options);\n const token = bootstrapToken(resolved);\n if (!token) return;\n activateEditMode(token, resolved);\n}\n\nfunction bootstrapToken(options: ResolvedOptions): string | null {\n const url = new URL(window.location.href);\n const urlToken = url.searchParams.get(options.tokenParam);\n if (urlToken) {\n sessionStorage.setItem(options.storageKey, urlToken);\n url.searchParams.delete(options.tokenParam);\n window.history.replaceState({}, \"\", url.toString());\n return urlToken;\n }\n return sessionStorage.getItem(options.storageKey);\n}\n\nfunction activateEditMode(token: string, options: ResolvedOptions): void {\n injectStyles();\n showActiveBadge(token);\n\n const fields = document.querySelectorAll<HTMLElement>(\"[data-cms-field]\");\n fields.forEach((el) => wireField(el, token, options));\n}\n\nfunction wireField(el: HTMLElement, token: string, options: ResolvedOptions): void {\n el.addEventListener(\"click\", () => {\n if (el.getAttribute(\"contenteditable\") === \"true\") return;\n el.dataset.cmsOriginalValue = el.textContent ?? \"\";\n el.setAttribute(\"contenteditable\", \"true\");\n el.focus();\n });\n\n el.addEventListener(\"blur\", () => {\n el.removeAttribute(\"contenteditable\");\n const original = el.dataset.cmsOriginalValue ?? \"\";\n const current = el.textContent ?? \"\";\n if (current.trim() === original.trim()) return;\n void saveField(el, token, options);\n });\n\n el.addEventListener(\"paste\", (e) => {\n e.preventDefault();\n const text = e.clipboardData?.getData(\"text/plain\") ?? \"\";\n document.execCommand(\"insertText\", false, text);\n });\n\n el.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n el.blur();\n }\n });\n}\n\nasync function saveField(el: HTMLElement, token: string, options: ResolvedOptions): Promise<void> {\n const collection = el.dataset.cmsCollection;\n const slug = el.dataset.cmsSlug;\n const field = el.dataset.cmsField;\n if (!collection || !slug || !field) return;\n const value = el.textContent?.trim() ?? \"\";\n\n showPill(el, \"saving\");\n try {\n const getRes = await fetch(\n `${options.cmsBaseUrl}/api/cms/${collection}/${slug}?site=${options.siteId}`,\n { headers: { Authorization: `Bearer ${token}` } },\n );\n if (!getRes.ok) throw new Error(`GET failed: ${getRes.status}`);\n const doc = (await getRes.json()) as { data?: Record<string, unknown> };\n const mergedData = { ...doc.data, [field]: value };\n\n const patchRes = await fetch(\n `${options.cmsBaseUrl}/api/cms/${collection}/${slug}?site=${options.siteId}`,\n {\n method: \"PATCH\",\n headers: { Authorization: `Bearer ${token}`, \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ data: mergedData }),\n },\n );\n if (!patchRes.ok) throw new Error(`PATCH failed: ${patchRes.status}`);\n showPill(el, \"saved\");\n } catch {\n showPill(el, \"error\");\n }\n}\n\nconst pills = new WeakMap<HTMLElement, HTMLElement>();\n\nfunction showPill(el: HTMLElement, state: \"saving\" | \"saved\" | \"error\"): void {\n let pill = pills.get(el);\n if (!pill) {\n pill = document.createElement(\"span\");\n pill.setAttribute(\"data-cms-inline-edit-pill\", \"\");\n document.body.appendChild(pill);\n pills.set(el, pill);\n }\n const rect = el.getBoundingClientRect();\n pill.style.cssText =\n `position:fixed;top:${rect.top - 26}px;left:${rect.left}px;font:600 11px system-ui,sans-serif;` +\n `padding:3px 9px;border-radius:5px;z-index:2147483647;pointer-events:none;` +\n `box-shadow:0 2px 8px rgba(0,0,0,.25);`;\n\n if (state === \"saving\") {\n pill.textContent = \"Gemmer…\";\n pill.style.background = \"#1c2027\";\n pill.style.color = \"#fff\";\n } else if (state === \"saved\") {\n pill.textContent = \"Gemt ✓\";\n pill.style.background = \"#16a34a\";\n pill.style.color = \"#fff\";\n setTimeout(() => {\n pill?.remove();\n pills.delete(el);\n }, 1500);\n } else {\n pill.textContent = \"Fejl — prøv igen\";\n pill.style.background = \"#dc2626\";\n pill.style.color = \"#fff\";\n }\n}\n\nfunction showActiveBadge(token: string): void {\n const claims = decodeJwtPayload(token);\n const name = (claims?.name as string) || (claims?.email as string) || \"ukendt\";\n const badge = document.createElement(\"div\");\n badge.setAttribute(\"data-cms-inline-edit-badge\", \"\");\n badge.textContent = `✏️ Redigerer som ${name}`;\n badge.style.cssText =\n \"position:fixed;bottom:16px;left:16px;background:#1c2027;color:#fff;\" +\n \"font:600 12px system-ui,sans-serif;padding:8px 14px;border-radius:999px;\" +\n \"z-index:2147483647;box-shadow:0 4px 16px rgba(0,0,0,.3);\";\n document.body.appendChild(badge);\n}\n\n/** Reads JWT claims for DISPLAY ONLY — never used for authorization decisions. */\nfunction decodeJwtPayload(token: string): Record<string, unknown> | null {\n try {\n const payload = token.split(\".\")[1];\n if (!payload) return null;\n return JSON.parse(atob(payload.replace(/-/g, \"+\").replace(/_/g, \"/\")));\n } catch {\n return null;\n }\n}\n\nfunction injectStyles(): void {\n const style = document.createElement(\"style\");\n style.textContent = `\n [data-cms-field] { outline: 1px dashed transparent; outline-offset: 2px; cursor: text; transition: outline-color .15s; }\n [data-cms-field]:hover { outline-color: rgba(0,178,255,.5); }\n [data-cms-field][contenteditable=\"true\"] { outline: 2px solid #00b2ff; outline-offset: 2px; }\n `;\n document.head.appendChild(style);\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * Browser entry — zero dependencies. Site-wide: activated once per browser\n * via a \"connect\" link (see docs/features/F157-inline-editing.md), then\n * every page load on the site automatically offers click-to-edit on any\n * element carrying data-cms-collection/data-cms-slug/data-cms-field\n * (F129's attribute convention) — no per-document step.\n */\n\nexport interface InlineEditOptions {\n /** Base URL of the CMS API, e.g. \"https://webhouse.app\". */\n cmsBaseUrl: string;\n /** Site id passed as ?site= on every CMS API call, e.g. \"broberg-ai\". */\n siteId: string;\n /** URL query param carrying a freshly-minted token from the connect redirect. Default \"cms_edit\". */\n tokenParam?: string;\n /** localStorage key the token is persisted under — survives across sessions/tabs. Default \"wh-inline-edit-token\". */\n storageKey?: string;\n /** Label for the \"not connected yet\" prompt. Default \"🔒 Log ind for at redigere\". */\n connectLabel?: string;\n}\n\ninterface ResolvedOptions extends Required<InlineEditOptions> {}\n\nfunction resolveOptions(options: InlineEditOptions): ResolvedOptions {\n return {\n tokenParam: \"cms_edit\",\n storageKey: \"wh-inline-edit-token\",\n connectLabel: \"🔒 Log ind for at redigere\",\n ...options,\n };\n}\n\nexport async function initInlineEdit(options: InlineEditOptions): Promise<void> {\n if (typeof window === \"undefined\") return;\n const resolved = resolveOptions(options);\n\n captureTokenFromUrl(resolved);\n\n const enabled = await checkEnabled(resolved);\n if (!enabled) return;\n\n const token = localStorage.getItem(resolved.storageKey);\n if (token && !isExpired(token)) {\n activateEditMode(token, resolved);\n } else {\n if (token) localStorage.removeItem(resolved.storageKey);\n showConnectPrompt(resolved);\n }\n}\n\n/** Captures a token minted by /admin/inline-edit/connect, then strips it from the URL. */\nfunction captureTokenFromUrl(options: ResolvedOptions): void {\n const url = new URL(window.location.href);\n const urlToken = url.searchParams.get(options.tokenParam);\n if (!urlToken) return;\n localStorage.setItem(options.storageKey, urlToken);\n url.searchParams.delete(options.tokenParam);\n window.history.replaceState({}, \"\", url.toString());\n}\n\nasync function checkEnabled(options: ResolvedOptions): Promise<boolean> {\n try {\n const res = await fetch(`${options.cmsBaseUrl}/api/inline-edit/status?site=${options.siteId}`);\n if (!res.ok) return false;\n const body = (await res.json()) as { enabled?: boolean };\n return body.enabled === true;\n } catch {\n return false;\n }\n}\n\nfunction isExpired(token: string): boolean {\n const claims = decodeJwtPayload(token);\n const exp = typeof claims?.exp === \"number\" ? claims.exp : 0;\n return exp <= Date.now() / 1000;\n}\n\nfunction showConnectPrompt(options: ResolvedOptions): void {\n const returnUrl = window.location.href;\n const connectUrl =\n `${options.cmsBaseUrl}/admin/inline-edit/connect?site=${encodeURIComponent(options.siteId)}` +\n `&return=${encodeURIComponent(returnUrl)}`;\n\n const link = document.createElement(\"a\");\n link.href = connectUrl;\n link.textContent = options.connectLabel;\n link.setAttribute(\"data-cms-inline-edit-connect\", \"\");\n link.style.cssText =\n \"position:fixed;bottom:16px;left:16px;background:#1c2027;color:#fff;\" +\n \"font:600 12px system-ui,sans-serif;padding:8px 14px;border-radius:999px;\" +\n \"text-decoration:none;z-index:2147483647;box-shadow:0 4px 16px rgba(0,0,0,.3);\";\n document.body.appendChild(link);\n}\n\nfunction activateEditMode(token: string, options: ResolvedOptions): void {\n injectStyles();\n showActiveBadge(token, options);\n\n const fields = document.querySelectorAll<HTMLElement>(\"[data-cms-field]\");\n fields.forEach((el) => wireField(el, token, options));\n}\n\nfunction wireField(el: HTMLElement, token: string, options: ResolvedOptions): void {\n el.addEventListener(\"click\", () => {\n if (el.getAttribute(\"contenteditable\") === \"true\") return;\n el.dataset.cmsOriginalValue = el.textContent ?? \"\";\n el.setAttribute(\"contenteditable\", \"true\");\n el.focus();\n });\n\n el.addEventListener(\"blur\", () => {\n el.removeAttribute(\"contenteditable\");\n const original = el.dataset.cmsOriginalValue ?? \"\";\n const current = el.textContent ?? \"\";\n if (current.trim() === original.trim()) return;\n void saveField(el, token, options);\n });\n\n el.addEventListener(\"paste\", (e) => {\n e.preventDefault();\n const text = e.clipboardData?.getData(\"text/plain\") ?? \"\";\n document.execCommand(\"insertText\", false, text);\n });\n\n el.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n el.blur();\n }\n });\n}\n\nasync function saveField(el: HTMLElement, token: string, options: ResolvedOptions): Promise<void> {\n const collection = el.dataset.cmsCollection;\n const slug = el.dataset.cmsSlug;\n const field = el.dataset.cmsField;\n if (!collection || !slug || !field) return;\n const value = el.textContent?.trim() ?? \"\";\n\n showPill(el, \"saving\");\n try {\n const getRes = await fetch(\n `${options.cmsBaseUrl}/api/cms/${collection}/${slug}?site=${options.siteId}`,\n { headers: { Authorization: `Bearer ${token}` } },\n );\n if (!getRes.ok) throw new Error(`GET failed: ${getRes.status}`);\n const doc = (await getRes.json()) as { data?: Record<string, unknown> };\n const mergedData = { ...doc.data, [field]: value };\n\n const patchRes = await fetch(\n `${options.cmsBaseUrl}/api/cms/${collection}/${slug}?site=${options.siteId}`,\n {\n method: \"PATCH\",\n headers: { Authorization: `Bearer ${token}`, \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ data: mergedData }),\n },\n );\n if (!patchRes.ok) throw new Error(`PATCH failed: ${patchRes.status}`);\n showPill(el, \"saved\");\n } catch {\n showPill(el, \"error\");\n }\n}\n\nconst pills = new WeakMap<HTMLElement, HTMLElement>();\n\nfunction showPill(el: HTMLElement, state: \"saving\" | \"saved\" | \"error\"): void {\n let pill = pills.get(el);\n if (!pill) {\n pill = document.createElement(\"span\");\n pill.setAttribute(\"data-cms-inline-edit-pill\", \"\");\n document.body.appendChild(pill);\n pills.set(el, pill);\n }\n const rect = el.getBoundingClientRect();\n pill.style.cssText =\n `position:fixed;top:${rect.top - 26}px;left:${rect.left}px;font:600 11px system-ui,sans-serif;` +\n `padding:3px 9px;border-radius:5px;z-index:2147483647;pointer-events:none;` +\n `box-shadow:0 2px 8px rgba(0,0,0,.25);`;\n\n if (state === \"saving\") {\n pill.textContent = \"Gemmer…\";\n pill.style.background = \"#1c2027\";\n pill.style.color = \"#fff\";\n } else if (state === \"saved\") {\n pill.textContent = \"Gemt ✓\";\n pill.style.background = \"#16a34a\";\n pill.style.color = \"#fff\";\n setTimeout(() => {\n pill?.remove();\n pills.delete(el);\n }, 1500);\n } else {\n pill.textContent = \"Fejl — prøv igen\";\n pill.style.background = \"#dc2626\";\n pill.style.color = \"#fff\";\n }\n}\n\nfunction showActiveBadge(token: string, options: ResolvedOptions): void {\n const claims = decodeJwtPayload(token);\n const name = (claims?.name as string) || (claims?.email as string) || \"ukendt\";\n\n const badge = document.createElement(\"div\");\n badge.setAttribute(\"data-cms-inline-edit-badge\", \"\");\n badge.style.cssText =\n \"position:fixed;bottom:16px;left:16px;display:flex;align-items:center;gap:8px;\" +\n \"background:#1c2027;color:#fff;font:600 12px system-ui,sans-serif;padding:8px 14px;\" +\n \"border-radius:999px;z-index:2147483647;box-shadow:0 4px 16px rgba(0,0,0,.3);\";\n\n const label = document.createElement(\"span\");\n label.textContent = `✏️ Redigerer som ${name}`;\n badge.appendChild(label);\n\n const disconnect = document.createElement(\"button\");\n disconnect.type = \"button\";\n disconnect.textContent = \"Afbryd\";\n disconnect.style.cssText =\n \"background:none;border:none;color:#8ab4ff;font:600 11px system-ui,sans-serif;\" +\n \"cursor:pointer;padding:0;text-decoration:underline;\";\n disconnect.addEventListener(\"click\", () => {\n localStorage.removeItem(options.storageKey);\n window.location.reload();\n });\n badge.appendChild(disconnect);\n\n document.body.appendChild(badge);\n}\n\n/** Reads JWT claims for DISPLAY ONLY — never used for authorization decisions. */\nfunction decodeJwtPayload(token: string): Record<string, unknown> | null {\n try {\n const payload = token.split(\".\")[1];\n if (!payload) return null;\n return JSON.parse(atob(payload.replace(/-/g, \"+\").replace(/_/g, \"/\")));\n } catch {\n return null;\n }\n}\n\nfunction injectStyles(): void {\n const style = document.createElement(\"style\");\n style.textContent = `\n [data-cms-field] { outline: 1px dashed transparent; outline-offset: 2px; cursor: text; transition: outline-color .15s; }\n [data-cms-field]:hover { outline-color: rgba(0,178,255,.5); }\n [data-cms-field][contenteditable=\"true\"] { outline: 2px solid #00b2ff; outline-offset: 2px; }\n `;\n document.head.appendChild(style);\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@broberg/cms-inline-edit",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Click-to-edit inline editing for live @webhouse/cms-powered sites — copy-owned, framework-agnostic",
5
5
  "type": "module",
6
6
  "license": "MIT",