@cancia/toolbar 0.4.1 → 0.5.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/dist/cancia.js CHANGED
@@ -28,6 +28,20 @@ function setPending(key, lang, value) {
28
28
  const full = pendingKey(key, lang);
29
29
  state.pending.set(full, { key, lang, value });
30
30
  }
31
+ function parseLinkOverlay(raw) {
32
+ if (!raw) return { label: "", href: "" };
33
+ const trimmed = raw.trim();
34
+ if (trimmed.startsWith("{")) {
35
+ try {
36
+ const p = JSON.parse(trimmed);
37
+ if (p && typeof p === "object") {
38
+ return { label: String(p.label ?? ""), href: String(p.href ?? "") };
39
+ }
40
+ } catch {
41
+ }
42
+ }
43
+ return { label: raw, href: "" };
44
+ }
31
45
  function applyOverlay() {
32
46
  document.querySelectorAll("[data-cms]").forEach((el) => {
33
47
  if (el.dataset.cmsList !== void 0) return;
@@ -39,6 +53,14 @@ function applyOverlay() {
39
53
  el.src = savedValue;
40
54
  return;
41
55
  }
56
+ if (el.dataset.cmsType === "link" || el.tagName === "A") {
57
+ const link = parseLinkOverlay(savedValue);
58
+ if (link.href && el.tagName === "A") el.setAttribute("href", link.href);
59
+ const labelEl = el.querySelector("[data-cms-label]");
60
+ if (labelEl) labelEl.textContent = link.label;
61
+ else if (el.childElementCount === 0) el.textContent = link.label;
62
+ return;
63
+ }
42
64
  if (el.childElementCount > 0) {
43
65
  console.warn(
44
66
  `Cancia: skipping overlay for "${key}" \u2014 element has child elements (textContent would destroy them).`
@@ -68,6 +90,13 @@ function headers() {
68
90
  if (state.sessionToken) h["Authorization"] = `Bearer ${state.sessionToken}`;
69
91
  return h;
70
92
  }
93
+ function currentRoute() {
94
+ return typeof location !== "undefined" ? location.pathname : "";
95
+ }
96
+ function routeParam() {
97
+ const r = currentRoute();
98
+ return r ? `&route=${encodeURIComponent(r)}` : "";
99
+ }
71
100
  async function fetchContent() {
72
101
  const { apiUrl, site } = state.config;
73
102
  const res = await fetch(`${apiUrl}/api/cancia/content?site=${encodeURIComponent(site)}`, {
@@ -81,7 +110,8 @@ async function saveEntry(key, lang, value) {
81
110
  const res = await fetch(`${apiUrl}/api/cancia/save`, {
82
111
  method: "POST",
83
112
  headers: headers(),
84
- body: JSON.stringify({ key, lang, value, site })
113
+ // `route` lets the server invalidate this page's cache tag (034, invalidate mode).
114
+ body: JSON.stringify({ key, lang, value, site, route: currentRoute() })
85
115
  });
86
116
  if (!res.ok) throw new Error(`Cancia: failed to save (${res.status})`);
87
117
  }
@@ -157,7 +187,7 @@ async function fetchTranslations(listName) {
157
187
  async function createListEntry(listName, data, locale, id) {
158
188
  const { apiUrl, site } = state.config;
159
189
  const res = await fetch(
160
- `${apiUrl}/api/cancia/lists/${encodeURIComponent(listName)}?site=${encodeURIComponent(site)}${localeParam(locale)}`,
190
+ `${apiUrl}/api/cancia/lists/${encodeURIComponent(listName)}?site=${encodeURIComponent(site)}${localeParam(locale)}${routeParam()}`,
161
191
  { method: "POST", headers: headers(), body: JSON.stringify({ data, id }) }
162
192
  );
163
193
  if (!res.ok) {
@@ -170,7 +200,7 @@ async function createListEntry(listName, data, locale, id) {
170
200
  async function updateListEntry(listName, id, data, rev, locale) {
171
201
  const { apiUrl, site } = state.config;
172
202
  const res = await fetch(
173
- `${apiUrl}/api/cancia/lists/${encodeURIComponent(listName)}/${encodeURIComponent(id)}?site=${encodeURIComponent(site)}${localeParam(locale)}`,
203
+ `${apiUrl}/api/cancia/lists/${encodeURIComponent(listName)}/${encodeURIComponent(id)}?site=${encodeURIComponent(site)}${localeParam(locale)}${routeParam()}`,
174
204
  { method: "PATCH", headers: headers(), body: JSON.stringify({ data, _rev: rev }) }
175
205
  );
176
206
  if (!res.ok) {
@@ -184,7 +214,7 @@ async function updateListEntry(listName, id, data, rev, locale) {
184
214
  async function deleteListEntry(listName, id, locale) {
185
215
  const { apiUrl, site } = state.config;
186
216
  const res = await fetch(
187
- `${apiUrl}/api/cancia/lists/${encodeURIComponent(listName)}/${encodeURIComponent(id)}?site=${encodeURIComponent(site)}${localeParam(locale)}`,
217
+ `${apiUrl}/api/cancia/lists/${encodeURIComponent(listName)}/${encodeURIComponent(id)}?site=${encodeURIComponent(site)}${localeParam(locale)}${routeParam()}`,
188
218
  { method: "DELETE", headers: headers() }
189
219
  );
190
220
  if (!res.ok) throw new Error(`Cancia: failed to delete entry (${res.status})`);
@@ -192,7 +222,7 @@ async function deleteListEntry(listName, id, locale) {
192
222
  async function reorderList(listName, ids) {
193
223
  const { apiUrl, site } = state.config;
194
224
  const res = await fetch(
195
- `${apiUrl}/api/cancia/lists/${encodeURIComponent(listName)}/reorder?site=${encodeURIComponent(site)}`,
225
+ `${apiUrl}/api/cancia/lists/${encodeURIComponent(listName)}/reorder?site=${encodeURIComponent(site)}${routeParam()}`,
196
226
  { method: "POST", headers: headers(), body: JSON.stringify({ ids }) }
197
227
  );
198
228
  if (!res.ok) throw new Error(`Cancia: failed to reorder list (${res.status})`);
@@ -226,7 +256,10 @@ function accent() {
226
256
  }
227
257
  function fieldType(el) {
228
258
  if (el.dataset.cmsType === "image") return "image";
229
- return el.tagName === "IMG" ? "image" : "text";
259
+ if (el.dataset.cmsType === "link") return "link";
260
+ if (el.tagName === "IMG") return "image";
261
+ if (el.tagName === "A") return "link";
262
+ return "text";
230
263
  }
231
264
  function elementMode(el) {
232
265
  return el.dataset.cmsList ? "list" : "field";
@@ -311,6 +344,10 @@ var IMAGE_ICON = `<svg width="10" height="10" viewBox="0 0 14 14" fill="none" st
311
344
  <circle cx="4.5" cy="4.5" r="1.2"/>
312
345
  <path d="M1 9.5l3.5-3 2.5 2.5 2-1.5 3 3.5"/>
313
346
  </svg>`;
347
+ var LINK_ICON = `<svg width="10" height="10" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
348
+ <path d="M6 8a2.5 2.5 0 003.6.3l2.2-2.2a2.5 2.5 0 00-3.5-3.5L7.2 3.6"/>
349
+ <path d="M8 6a2.5 2.5 0 00-3.6-.3L2.2 7.9a2.5 2.5 0 003.5 3.5l1.1-1.1"/>
350
+ </svg>`;
314
351
  var LIST_ICON = `<svg width="10" height="10" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
315
352
  <rect x="1" y="2" width="3" height="3" rx="0.5"/>
316
353
  <rect x="1" y="7.5" width="3" height="3" rx="0.5"/>
@@ -340,7 +377,7 @@ function positionOverlay(el, animate = false) {
340
377
  tooltipText = `list: ${el.dataset.cmsList}`;
341
378
  } else {
342
379
  const type = fieldType(el);
343
- badgeIcon = type === "image" ? IMAGE_ICON : FIELD_ICON;
380
+ badgeIcon = type === "image" ? IMAGE_ICON : type === "link" ? LINK_ICON : FIELD_ICON;
344
381
  badgeLabel = type;
345
382
  tooltipText = el.dataset.cms ?? "";
346
383
  }
@@ -426,9 +463,27 @@ function handleClick(e) {
426
463
  onSelect?.({ kind: "field", el: target, key, fieldType: fieldType(target) });
427
464
  }
428
465
  }
466
+ function warnUnhoverableRegions() {
467
+ const els = document.querySelectorAll(CMS_SELECTOR);
468
+ els.forEach((el) => {
469
+ const rect = el.getBoundingClientRect();
470
+ if (rect.width > 0 && rect.height > 0) return;
471
+ const cs = getComputedStyle(el);
472
+ const selfInflicted = cs.display === "contents" || cs.position === "absolute" || cs.clipPath !== "none";
473
+ if (!selfInflicted) return;
474
+ const name = el.dataset.cmsList ? `list "${el.dataset.cmsList}"` : `field "${el.dataset.cms}"`;
475
+ console.warn(
476
+ `[cancia] ${name} is annotated but has no layout box (${Math.round(rect.width)}\xD7${Math.round(
477
+ rect.height
478
+ )}) \u2014 it cannot be hovered or clicked in the editor. Avoid sr-only / display:contents on CMS regions; give the container a real box.`,
479
+ el
480
+ );
481
+ });
482
+ }
429
483
  function attachHighlight(selectCallback) {
430
484
  injectStyles();
431
485
  onSelect = selectCallback;
486
+ warnUnhoverableRegions();
432
487
  document.addEventListener("mouseover", handleMouseOver, true);
433
488
  document.addEventListener("mouseout", handleMouseOut, true);
434
489
  document.addEventListener("click", handleClick, true);
@@ -651,6 +706,175 @@ function buildTextPopup(key, anchorEl, onClose) {
651
706
  wrap.appendChild(footer);
652
707
  return wrap;
653
708
  }
709
+ function isSafeHrefValue(href) {
710
+ const trimmed = href.trim();
711
+ if (trimmed === "") return true;
712
+ if (/^(https?:|mailto:|tel:)/i.test(trimmed)) return true;
713
+ const schemeMatch = /^([a-z][a-z0-9+.-]*):/i.exec(trimmed);
714
+ if (schemeMatch) {
715
+ const firstSep = trimmed.search(/[/?#]/);
716
+ if (firstSep === -1 || schemeMatch[1].length < firstSep) return false;
717
+ }
718
+ return true;
719
+ }
720
+ function parseLink(raw) {
721
+ if (!raw) return { label: "", href: "" };
722
+ const trimmed = raw.trim();
723
+ if (trimmed.startsWith("{")) {
724
+ try {
725
+ const p = JSON.parse(trimmed);
726
+ if (p && typeof p === "object") {
727
+ return { label: String(p.label ?? ""), href: String(p.href ?? "") };
728
+ }
729
+ } catch {
730
+ }
731
+ }
732
+ return { label: raw, href: "" };
733
+ }
734
+ function buildLinkPopup(key, anchorEl, onClose) {
735
+ const langs = state.config?.languages ?? ["en"];
736
+ let activeLang = state.activeLang || langs[0];
737
+ const wrap = document.createElement("div");
738
+ wrap.dataset.canciaPopup = "1";
739
+ applyPopupStyles(wrap);
740
+ wrap.appendChild(buildHeader(key, onClose));
741
+ const labelNode = anchorEl.querySelector("[data-cms-label]") ?? anchorEl;
742
+ const domLabel = labelNode.textContent?.trim() ?? "";
743
+ const domHref = anchorEl.getAttribute("href") ?? "";
744
+ const readCurrent = (lang) => {
745
+ const stored = parseLink(getValue(key, lang));
746
+ return {
747
+ label: stored.label || domLabel,
748
+ // The href is shared across languages (see the note below), so fall back
749
+ // to the default language's stored value before the DOM.
750
+ href: stored.href || parseLink(getValue(key, langs[0])).href || domHref
751
+ };
752
+ };
753
+ const inputStyle = `
754
+ width: 100%; box-sizing: border-box;
755
+ background: rgba(255,255,255,0.03); color: rgba(255,255,255,0.9);
756
+ border: 1px solid rgba(255,255,255,0.07); border-radius: 10px;
757
+ padding: 9px 12px;
758
+ font-size: 13px; font-family: inherit; outline: none;
759
+ transition: border-color 0.18s, background 0.18s;
760
+ caret-color: ${accent2()};
761
+ `;
762
+ const makeLabelled = (text, input) => {
763
+ const field = document.createElement("div");
764
+ field.style.cssText = `display: flex; flex-direction: column; gap: 5px; margin-bottom: 10px;`;
765
+ const lab = document.createElement("div");
766
+ lab.textContent = text;
767
+ lab.style.cssText = `font-size:10px;letter-spacing:0.08em;text-transform:uppercase;color:rgba(255,255,255,0.35);`;
768
+ input.style.cssText = inputStyle;
769
+ input.addEventListener("focus", () => {
770
+ input.style.borderColor = `${accent2()}66`;
771
+ input.style.background = "rgba(255,255,255,0.05)";
772
+ });
773
+ input.addEventListener("blur", () => {
774
+ input.style.borderColor = "rgba(255,255,255,0.07)";
775
+ input.style.background = "rgba(255,255,255,0.03)";
776
+ });
777
+ field.appendChild(lab);
778
+ field.appendChild(input);
779
+ return field;
780
+ };
781
+ const labelInput = document.createElement("input");
782
+ labelInput.type = "text";
783
+ labelInput.placeholder = "Book a call";
784
+ const hrefInput = document.createElement("input");
785
+ hrefInput.type = "text";
786
+ hrefInput.inputMode = "url";
787
+ hrefInput.placeholder = "/start or https://\u2026";
788
+ const current = readCurrent(activeLang);
789
+ labelInput.value = current.label;
790
+ hrefInput.value = current.href;
791
+ if (langs.length > 1) {
792
+ const tabs = document.createElement("div");
793
+ tabs.style.cssText = `display: flex; gap: 0; margin-bottom: 12px; border-bottom: 1px solid rgba(255,255,255,0.06);`;
794
+ const renderTabs2 = () => {
795
+ tabs.innerHTML = "";
796
+ langs.forEach((lang) => {
797
+ const tab = document.createElement("button");
798
+ tab.textContent = lang.toUpperCase();
799
+ const isActive = lang === activeLang;
800
+ tab.style.cssText = `
801
+ padding: 5px 10px 6px; border: none; border-bottom: 2px solid;
802
+ margin-bottom: -1px;
803
+ font-size: 11px; font-weight: 600; cursor: pointer; letter-spacing: 0.06em;
804
+ background: transparent;
805
+ border-bottom-color: ${isActive ? accent2() : "transparent"};
806
+ color: ${isActive ? "#fff" : "rgba(255,255,255,0.3)"};
807
+ transition: color 0.15s, border-color 0.15s;
808
+ `;
809
+ tab.addEventListener("click", () => {
810
+ stage(activeLang);
811
+ activeLang = lang;
812
+ state.activeLang = lang;
813
+ applyOverlay();
814
+ labelInput.value = readCurrent(lang).label;
815
+ renderTabs2();
816
+ });
817
+ tabs.appendChild(tab);
818
+ });
819
+ };
820
+ renderTabs2();
821
+ wrap.appendChild(tabs);
822
+ }
823
+ wrap.appendChild(makeLabelled("Label", labelInput));
824
+ wrap.appendChild(makeLabelled("URL", hrefInput));
825
+ const hint = document.createElement("div");
826
+ hint.style.cssText = `font-size:11px;color:rgba(255,255,255,0.28);margin:-4px 0 10px;line-height:1.45;`;
827
+ hint.textContent = langs.length > 1 ? "Relative (/start), #anchor, mailto: and tel: all work. The URL is shared across languages." : "Relative (/start), #anchor, mailto: and tel: all work.";
828
+ wrap.appendChild(hint);
829
+ const warn = document.createElement("div");
830
+ warn.style.cssText = `font-size:11px;color:#f0a; margin:-4px 0 10px; display:none;`;
831
+ wrap.appendChild(warn);
832
+ const paint = () => {
833
+ labelNode.textContent = labelInput.value;
834
+ if (isSafeHrefValue(hrefInput.value)) anchorEl.setAttribute("href", hrefInput.value);
835
+ };
836
+ const stage = (lang) => {
837
+ const value = {
838
+ label: labelInput.value,
839
+ href: hrefInput.value.trim()
840
+ };
841
+ const existing = parseLink(getValue(key, lang));
842
+ if (existing.label !== value.label || existing.href !== value.href) {
843
+ setPending(key, lang, JSON.stringify(value));
844
+ }
845
+ };
846
+ const validate = () => {
847
+ const ok = isSafeHrefValue(hrefInput.value);
848
+ warn.style.display = ok ? "none" : "block";
849
+ warn.textContent = ok ? "" : "That URL scheme isn\u2019t allowed and won\u2019t be saved.";
850
+ return ok;
851
+ };
852
+ labelInput.addEventListener("input", () => {
853
+ paint();
854
+ onPendingChange();
855
+ });
856
+ hrefInput.addEventListener("input", () => {
857
+ validate();
858
+ paint();
859
+ onPendingChange();
860
+ });
861
+ setTimeout(() => labelInput.focus(), 80);
862
+ const footer = document.createElement("div");
863
+ footer.dataset.canciaFooter = "1";
864
+ footer.style.cssText = `display: flex; justify-content: flex-end; margin-top: 10px;`;
865
+ const saveBtn = makePrimaryButton("Save", accent2());
866
+ saveBtn.dataset.canciaSave = "1";
867
+ saveBtn.title = "Save (\u2318S)";
868
+ saveBtn.addEventListener("click", () => {
869
+ if (!validate()) return;
870
+ stage(activeLang);
871
+ onPendingChange();
872
+ onClose();
873
+ });
874
+ footer.appendChild(saveBtn);
875
+ wrap.appendChild(footer);
876
+ return wrap;
877
+ }
654
878
  function buildImagePopup(key, anchorEl, onClose) {
655
879
  const wrap = document.createElement("div");
656
880
  wrap.dataset.canciaPopup = "1";
@@ -872,13 +1096,11 @@ function applyPopupStyles(el) {
872
1096
  }
873
1097
  function openPopup(key, fieldType2, anchorEl, onClose) {
874
1098
  closePopup();
875
- const popup = fieldType2 === "image" ? buildImagePopup(key, anchorEl, () => {
876
- onClose();
877
- closePopup();
878
- }) : buildTextPopup(key, anchorEl, () => {
1099
+ const done = () => {
879
1100
  onClose();
880
1101
  closePopup();
881
- });
1102
+ };
1103
+ const popup = fieldType2 === "image" ? buildImagePopup(key, anchorEl, done) : fieldType2 === "link" ? buildLinkPopup(key, anchorEl, done) : buildTextPopup(key, anchorEl, done);
882
1104
  document.body.appendChild(popup);
883
1105
  popupEl = popup;
884
1106
  const { top, left, origin } = getPopupPosition(anchorEl);