@cancia/toolbar 0.12.1 → 0.14.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
@@ -171,6 +171,10 @@ function getValue(key, lang) {
171
171
  if (state.cmsData[full] !== void 0) return state.cmsData[full];
172
172
  return "";
173
173
  }
174
+ function hasValue(key, lang) {
175
+ const full = `${key}.${lang}`;
176
+ return state.pending.has(full) || state.cmsData[full] !== void 0;
177
+ }
174
178
  function setPending(key, lang, value) {
175
179
  const full = pendingKey(key, lang);
176
180
  state.pending.set(full, { key, lang, value });
@@ -210,7 +214,12 @@ function applyOverlay() {
210
214
  }
211
215
  function revertPending() {
212
216
  for (const [fullKey, { key }] of state.pending) {
213
- const savedValue = state.cmsData[fullKey] ?? "";
217
+ if (state.cmsData[fullKey] === void 0) {
218
+ state.pending.clear();
219
+ location.reload();
220
+ return;
221
+ }
222
+ const savedValue = state.cmsData[fullKey];
214
223
  document.querySelectorAll(`[data-cms="${key}"]`).forEach((el) => {
215
224
  if (el.tagName === "IMG") {
216
225
  el.src = savedValue;
@@ -258,6 +267,22 @@ async function saveEntry(key, lang, value) {
258
267
  });
259
268
  if (!res.ok) throw new Error(`Cancia: failed to save (${res.status})`);
260
269
  }
270
+ async function deleteContent(key, lang) {
271
+ const { apiUrl, site } = state.config;
272
+ const res = await fetch(`${apiUrl}/api/cancia/content`, {
273
+ method: "DELETE",
274
+ headers: headers(),
275
+ body: JSON.stringify({ site, key, lang, route: currentRoute() })
276
+ });
277
+ if (!res.ok) throw await toApiError(res, "Could not reset this text. Check your connection and try again.");
278
+ }
279
+ async function toApiError(res, fallback) {
280
+ const body = await res.json().catch(() => ({}));
281
+ const err = new Error(body.error ?? fallback);
282
+ if (body.code) err.code = body.code;
283
+ if (body.issues?.length) err.issues = body.issues;
284
+ return err;
285
+ }
261
286
  function isAuthError(err) {
262
287
  return err instanceof Error && err.message.includes("(401)");
263
288
  }
@@ -334,8 +359,7 @@ async function createListEntry(listName, data, locale, id) {
334
359
  { method: "POST", headers: headers(), body: JSON.stringify({ data, id }) }
335
360
  );
336
361
  if (!res.ok) {
337
- const err = await res.json().catch(() => ({}));
338
- throw new Error(err.error ?? `Cancia: failed to create entry (${res.status})`);
362
+ throw await toApiError(res, "Could not create the entry. Check your connection and try again.");
339
363
  }
340
364
  const body = await res.json();
341
365
  return body.entry;
@@ -347,9 +371,7 @@ async function updateListEntry(listName, id, data, rev, locale) {
347
371
  { method: "PATCH", headers: headers(), body: JSON.stringify({ data, _rev: rev }) }
348
372
  );
349
373
  if (!res.ok) {
350
- const err = await res.json().catch(() => ({}));
351
- const message = err.error ?? `Cancia: failed to update entry (${res.status})`;
352
- throw Object.assign(new Error(message), { code: err.code });
374
+ throw await toApiError(res, "Could not save your changes. Check your connection and try again.");
353
375
  }
354
376
  const body = await res.json();
355
377
  return body.entry;
@@ -366,9 +388,9 @@ async function reorderList(listName, ids) {
366
388
  const { apiUrl, site } = state.config;
367
389
  const res = await fetch(
368
390
  `${apiUrl}/api/cancia/lists/${encodeURIComponent(listName)}/reorder?site=${encodeURIComponent(site)}${routeParam()}`,
369
- { method: "POST", headers: headers(), body: JSON.stringify({ ids }) }
391
+ { method: "POST", headers: headers(), body: JSON.stringify({ ids, knownCount: ids.length }) }
370
392
  );
371
- if (!res.ok) throw new Error(`Cancia: failed to reorder list (${res.status})`);
393
+ if (!res.ok) throw await toApiError(res, "Could not save the new order. Check your connection and try again.");
372
394
  }
373
395
  async function flushEdits(edits, save) {
374
396
  const results = await Promise.allSettled(edits.map((edit) => save(edit)));
@@ -1165,6 +1187,20 @@ function buildHeader(key, onClose) {
1165
1187
  header.appendChild(makeCloseButton(onClose));
1166
1188
  return header;
1167
1189
  }
1190
+ function seedValue(key, lang, anchorEl) {
1191
+ if (hasValue(key, lang)) return getValue(key, lang);
1192
+ return anchorEl.textContent?.trim() ?? "";
1193
+ }
1194
+ function activeImageLang() {
1195
+ return state.activeLang || state.config?.languages?.[0] || "en";
1196
+ }
1197
+ async function resetToAuthored(key, lang) {
1198
+ await deleteContent(key, lang);
1199
+ delete state.cmsData[`${key}.${lang}`];
1200
+ state.pending.delete(`${key}.${lang}`);
1201
+ onPendingChange();
1202
+ location.reload();
1203
+ }
1168
1204
  function buildTextPopup(key, anchorEl, onClose) {
1169
1205
  const langs = state.config?.languages ?? ["en"];
1170
1206
  let activeLang = state.activeLang || langs[0];
@@ -1199,9 +1235,7 @@ function buildTextPopup(key, anchorEl, onClose) {
1199
1235
  tab.addEventListener("click", () => {
1200
1236
  const current = wrap.querySelector("textarea");
1201
1237
  if (current) {
1202
- const existing = getValue(key, activeLang);
1203
- const fallback = anchorEl.textContent?.trim() || "";
1204
- if (current.value !== (existing || fallback)) {
1238
+ if (current.value !== seedValue(key, activeLang, anchorEl)) {
1205
1239
  setPending(key, activeLang, current.value);
1206
1240
  }
1207
1241
  }
@@ -1231,7 +1265,7 @@ function buildTextPopup(key, anchorEl, onClose) {
1231
1265
  };
1232
1266
  const renderTextarea = (isInit = false) => {
1233
1267
  if (!isInit && textarea) {
1234
- textarea.value = getValue(key, activeLang) || anchorEl.textContent?.trim() || "";
1268
+ textarea.value = seedValue(key, activeLang, anchorEl);
1235
1269
  textarea.style.borderColor = v("accent-ring");
1236
1270
  textarea.style.background = v("surface-hover");
1237
1271
  attachInputHandler();
@@ -1239,7 +1273,7 @@ function buildTextPopup(key, anchorEl, onClose) {
1239
1273
  }
1240
1274
  const footerEl = wrap.querySelector("[data-cancia-footer]");
1241
1275
  textarea = document.createElement("textarea");
1242
- textarea.value = getValue(key, activeLang) || anchorEl.textContent?.trim() || "";
1276
+ textarea.value = seedValue(key, activeLang, anchorEl);
1243
1277
  textarea.rows = 4;
1244
1278
  textarea.placeholder = "Enter text\u2026";
1245
1279
  textarea.style.cssText = `
@@ -1262,18 +1296,37 @@ function buildTextPopup(key, anchorEl, onClose) {
1262
1296
  const footer = document.createElement("div");
1263
1297
  footer.dataset.canciaFooter = "1";
1264
1298
  footer.style.cssText = `display: flex; justify-content: flex-end; margin-top: 10px;`;
1265
- const saveBtn = makePrimaryButton("Save", accent2());
1299
+ const saveBtn = makePrimaryButton("Done", accent2());
1266
1300
  saveBtn.dataset.canciaSave = "1";
1267
- saveBtn.title = "Save (\u2318S)";
1301
+ saveBtn.title = "Apply to the page (\u2318S) \u2014 then Save in the toolbar to publish";
1268
1302
  saveBtn.addEventListener("click", () => {
1269
- const existing = getValue(key, activeLang);
1270
- const fallback = anchorEl.textContent?.trim() || "";
1271
- if (textarea.value !== (existing || fallback)) {
1303
+ if (textarea.value !== seedValue(key, activeLang, anchorEl)) {
1272
1304
  setPending(key, activeLang, textarea.value);
1273
1305
  }
1274
1306
  onPendingChange();
1275
1307
  onClose();
1276
1308
  });
1309
+ if (hasValue(key, activeLang)) {
1310
+ const resetBtn = document.createElement("button");
1311
+ resetBtn.type = "button";
1312
+ resetBtn.textContent = "Reset";
1313
+ resetBtn.title = "Restore the text this page was built with";
1314
+ resetBtn.style.cssText = `${button("ghost")} font-size: ${v("text-xs")}; margin-right: auto;`;
1315
+ attachHover(resetBtn, { bg: v("surface-active") });
1316
+ attachPress(resetBtn);
1317
+ resetBtn.addEventListener("click", async () => {
1318
+ resetBtn.disabled = true;
1319
+ resetBtn.textContent = "Resetting\u2026";
1320
+ try {
1321
+ await resetToAuthored(key, activeLang);
1322
+ } catch (err) {
1323
+ resetBtn.disabled = false;
1324
+ resetBtn.textContent = "Reset";
1325
+ textarea.placeholder = err instanceof Error ? err.message : "Could not reset.";
1326
+ }
1327
+ });
1328
+ footer.appendChild(resetBtn);
1329
+ }
1277
1330
  footer.appendChild(saveBtn);
1278
1331
  wrap.appendChild(footer);
1279
1332
  return wrap;
@@ -1408,9 +1461,9 @@ function buildLinkPopup(key, anchorEl, onClose) {
1408
1461
  const footer = document.createElement("div");
1409
1462
  footer.dataset.canciaFooter = "1";
1410
1463
  footer.style.cssText = `display: flex; justify-content: flex-end; margin-top: 10px;`;
1411
- const saveBtn = makePrimaryButton("Save", accent2());
1464
+ const saveBtn = makePrimaryButton("Done", accent2());
1412
1465
  saveBtn.dataset.canciaSave = "1";
1413
- saveBtn.title = "Save (\u2318S)";
1466
+ saveBtn.title = "Apply to the page (\u2318S) \u2014 then Save in the toolbar to publish";
1414
1467
  saveBtn.addEventListener("click", () => {
1415
1468
  if (!validate()) return;
1416
1469
  stage(activeLang);
@@ -1448,6 +1501,33 @@ function buildImagePopup(key, anchorEl, onClose) {
1448
1501
  previewWrap.appendChild(previewImg);
1449
1502
  previewWrap.appendChild(previewLabel);
1450
1503
  wrap.appendChild(previewWrap);
1504
+ if (hasValue(key, activeImageLang())) {
1505
+ const resetRow = document.createElement("div");
1506
+ resetRow.style.cssText = `display:flex; justify-content:flex-end; margin: -4px 0 10px;`;
1507
+ const resetBtn = document.createElement("button");
1508
+ resetBtn.type = "button";
1509
+ resetBtn.textContent = "Reset to original";
1510
+ resetBtn.style.cssText = `
1511
+ ${button("ghost")}
1512
+ font-size: ${v("text-xs")};
1513
+ `;
1514
+ attachHover(resetBtn, { bg: v("surface-active") });
1515
+ attachPress(resetBtn);
1516
+ resetBtn.addEventListener("click", async () => {
1517
+ resetBtn.disabled = true;
1518
+ resetBtn.textContent = "Resetting\u2026";
1519
+ try {
1520
+ await resetToAuthored(key, activeImageLang());
1521
+ onClose();
1522
+ } catch (err) {
1523
+ resetBtn.disabled = false;
1524
+ resetBtn.textContent = "Reset to original";
1525
+ previewLabel.textContent = err instanceof Error ? err.message : "Could not reset. Try again.";
1526
+ }
1527
+ });
1528
+ resetRow.appendChild(resetBtn);
1529
+ wrap.appendChild(resetRow);
1530
+ }
1451
1531
  }
1452
1532
  const dropZone = document.createElement("label");
1453
1533
  dropZone.style.cssText = `
@@ -1803,9 +1883,9 @@ function buildRichPopup(key, anchorEl, onClose) {
1803
1883
  rowsWrap.appendChild(r.el);
1804
1884
  r.el.querySelector("textarea")?.focus();
1805
1885
  });
1806
- const saveBtn = makePrimaryButton("Save", accent2());
1886
+ const saveBtn = makePrimaryButton("Done", accent2());
1807
1887
  saveBtn.dataset.canciaSave = "1";
1808
- saveBtn.title = "Save (\u2318S)";
1888
+ saveBtn.title = "Apply to the page (\u2318S) \u2014 then Save in the toolbar to publish";
1809
1889
  saveBtn.addEventListener("click", () => {
1810
1890
  commit();
1811
1891
  onClose();
@@ -2430,7 +2510,7 @@ import {
2430
2510
  portableTextSubsetSchema as portableTextSubsetSchema2,
2431
2511
  PT_STYLES as PT_STYLES2
2432
2512
  } from "@cancia/astro/richtext";
2433
- import { slugify } from "@cancia/astro/schema";
2513
+ import { slugify, parseLinkValue as parseLinkValue3, isSafeHref as isSafeHref2 } from "@cancia/astro/schema";
2434
2514
  var MODAL_Z = v("z-bar");
2435
2515
  var BACKDROP_Z2 = v("z-panel");
2436
2516
  var modalEl = null;
@@ -2660,6 +2740,99 @@ function renderField(field, initial, depth = 0) {
2660
2740
  getValue2 = () => sel.value === "" ? void 0 : sel.value;
2661
2741
  break;
2662
2742
  }
2743
+ case "file": {
2744
+ let currentUrl = typeof initial === "string" ? initial : "";
2745
+ const box = document.createElement("div");
2746
+ box.style.cssText = `
2747
+ display: flex; flex-direction: column; gap: 8px;
2748
+ background: ${v("surface-raised")};
2749
+ border: 1px dashed ${v("border-strong")};
2750
+ border-radius: ${v("radius")};
2751
+ padding: 10px;
2752
+ `;
2753
+ const nameRow = document.createElement("div");
2754
+ nameRow.style.cssText = `display:flex; align-items:center; gap:8px; min-width:0;`;
2755
+ const nameLink = document.createElement("a");
2756
+ nameLink.target = "_blank";
2757
+ nameLink.rel = "noopener";
2758
+ nameLink.style.cssText = `
2759
+ font-size: ${v("text-sm")}; color: ${v("fg")}; text-decoration: underline;
2760
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
2761
+ `;
2762
+ const renderName = (url) => {
2763
+ if (!url) {
2764
+ nameLink.removeAttribute("href");
2765
+ nameLink.textContent = "No file attached";
2766
+ nameLink.style.color = v("fg-faint");
2767
+ nameLink.style.textDecoration = "none";
2768
+ return;
2769
+ }
2770
+ let label2 = url;
2771
+ try {
2772
+ label2 = decodeURIComponent(new URL(url, location.href).pathname.split("/").pop() || url);
2773
+ } catch {
2774
+ }
2775
+ nameLink.href = url;
2776
+ nameLink.textContent = label2;
2777
+ nameLink.style.color = v("fg");
2778
+ nameLink.style.textDecoration = "underline";
2779
+ };
2780
+ renderName(currentUrl);
2781
+ nameRow.appendChild(nameLink);
2782
+ box.appendChild(nameRow);
2783
+ const btnRow = document.createElement("div");
2784
+ btnRow.style.cssText = `display:flex; gap:8px; align-items:center;`;
2785
+ const fileInput = document.createElement("input");
2786
+ fileInput.type = "file";
2787
+ fileInput.accept = "application/pdf";
2788
+ fileInput.style.display = "none";
2789
+ const uploadBtn = document.createElement("button");
2790
+ uploadBtn.type = "button";
2791
+ uploadBtn.textContent = "Upload\u2026";
2792
+ uploadBtn.style.cssText = `${button("ghost")} font-size: ${v("text-xs")};`;
2793
+ attachHover(uploadBtn, { bg: v("surface-active") });
2794
+ uploadBtn.addEventListener("click", () => fileInput.click());
2795
+ const clearBtn = document.createElement("button");
2796
+ clearBtn.type = "button";
2797
+ clearBtn.textContent = "Clear";
2798
+ clearBtn.style.cssText = `${button("ghost")} font-size: ${v("text-xs")};`;
2799
+ attachHover(clearBtn, { bg: v("surface-active") });
2800
+ clearBtn.addEventListener("click", () => {
2801
+ currentUrl = "";
2802
+ renderName("");
2803
+ progressEl.textContent = "";
2804
+ });
2805
+ const progressEl = document.createElement("span");
2806
+ progressEl.style.cssText = `font-size: ${v("text-xs")}; color: ${v("fg-muted")};`;
2807
+ fileInput.addEventListener("change", async () => {
2808
+ const f = fileInput.files?.[0];
2809
+ if (!f) return;
2810
+ uploadBtn.disabled = true;
2811
+ try {
2812
+ const url = await uploadImage(f, (pct) => {
2813
+ progressEl.textContent = `Uploading\u2026 ${pct}%`;
2814
+ });
2815
+ currentUrl = url;
2816
+ renderName(url);
2817
+ progressEl.textContent = "Uploaded";
2818
+ } catch (err) {
2819
+ const msg = err instanceof Error && err.message.includes("(413)") ? "That file is too large." : "Upload failed \u2014 try again.";
2820
+ progressEl.textContent = msg;
2821
+ progressEl.style.color = v("danger");
2822
+ } finally {
2823
+ uploadBtn.disabled = false;
2824
+ fileInput.value = "";
2825
+ }
2826
+ });
2827
+ btnRow.appendChild(uploadBtn);
2828
+ btnRow.appendChild(clearBtn);
2829
+ btnRow.appendChild(progressEl);
2830
+ box.appendChild(btnRow);
2831
+ box.appendChild(fileInput);
2832
+ wrapper.appendChild(box);
2833
+ getValue2 = () => currentUrl === "" ? void 0 : currentUrl;
2834
+ break;
2835
+ }
2663
2836
  case "image": {
2664
2837
  const initialUrl = typeof initial === "string" ? initial : "";
2665
2838
  let currentUrl = initialUrl;
@@ -2810,6 +2983,57 @@ function renderField(field, initial, depth = 0) {
2810
2983
  };
2811
2984
  break;
2812
2985
  }
2986
+ case "link": {
2987
+ const parsed = parseLinkValue3(initial);
2988
+ const box = document.createElement("div");
2989
+ box.style.cssText = `
2990
+ display: flex; flex-direction: column; gap: 8px;
2991
+ background: ${v("surface-raised")};
2992
+ border: 1px solid ${v("border")};
2993
+ border-radius: ${v("radius")};
2994
+ padding: 10px;
2995
+ `;
2996
+ const mkRow = (labelText2, value, placeholder) => {
2997
+ const row = document.createElement("label");
2998
+ row.style.cssText = `display: flex; flex-direction: column; gap: 4px;`;
2999
+ const cap = document.createElement("span");
3000
+ cap.textContent = labelText2;
3001
+ cap.style.cssText = `font-size: 11px; font-weight: 600; color: ${v("fg-muted")}; letter-spacing: 0.02em;`;
3002
+ const inp = document.createElement("input");
3003
+ inp.type = "text";
3004
+ inp.className = "cancia-form-input";
3005
+ inp.style.cssText = `${INPUT_BASE} caret-color: ${v("accent")};`;
3006
+ inp.value = value;
3007
+ inp.placeholder = placeholder;
3008
+ row.appendChild(cap);
3009
+ row.appendChild(inp);
3010
+ box.appendChild(row);
3011
+ return inp;
3012
+ };
3013
+ const labelInput = mkRow("Label", parsed.label, "Book a call");
3014
+ const hrefInput = mkRow("URL", parsed.href, "/start or https://\u2026");
3015
+ wrapper.appendChild(box);
3016
+ getValue2 = () => {
3017
+ const label2 = labelInput.value.trim();
3018
+ const href = hrefInput.value.trim();
3019
+ if (!label2 && !href) return void 0;
3020
+ return { label: label2, href };
3021
+ };
3022
+ validate = () => {
3023
+ const value = getValue2();
3024
+ setError(null);
3025
+ if (field.required && !value) {
3026
+ setError(`${field.label || "This field"} is required`);
3027
+ return { value, ok: false };
3028
+ }
3029
+ if (value && value.href && !isSafeHref2(value.href)) {
3030
+ setError("That link type isn't allowed. Use https://, /a-path, #anchor, mailto: or tel:");
3031
+ return { value, ok: false };
3032
+ }
3033
+ return { value, ok: true };
3034
+ };
3035
+ break;
3036
+ }
2813
3037
  default: {
2814
3038
  const input2 = document.createElement("input");
2815
3039
  input2.type = field.widget === "url" ? "url" : field.widget === "email" ? "email" : "text";
@@ -3319,6 +3543,19 @@ function preValidate(fieldStates) {
3319
3543
  }
3320
3544
  return { data, ok };
3321
3545
  }
3546
+ function paintServerIssues(fieldStates, issues) {
3547
+ if (!issues?.length) return 0;
3548
+ let painted = 0;
3549
+ for (const issue of issues) {
3550
+ const head = issue.path?.[0];
3551
+ if (head === void 0) continue;
3552
+ const target = fieldStates.find((fs) => fs.field.name === String(head));
3553
+ if (!target) continue;
3554
+ target.setError(issue.message);
3555
+ painted++;
3556
+ }
3557
+ return painted;
3558
+ }
3322
3559
  function validateValue(field, value) {
3323
3560
  if (value === void 0 || value === null || value === "") return null;
3324
3561
  if (typeof value === "string") {
@@ -3624,10 +3861,15 @@ function openEntryModal(opts) {
3624
3861
  closeEntryModal();
3625
3862
  } catch (err) {
3626
3863
  const error = err;
3627
- if (error.message.includes("Validation failed")) {
3628
- showFormError("Server-side validation failed. Check the fields above.");
3864
+ const painted = paintServerIssues(fieldStates, error.issues);
3865
+ if (painted > 0) {
3866
+ showFormError(
3867
+ painted === 1 ? "One field needs fixing \u2014 see the message below it." : `${painted} fields need fixing \u2014 see the messages below them.`
3868
+ );
3629
3869
  } else if (error.code === "REV_CONFLICT") {
3630
3870
  showFormError("This entry was changed by someone else. Close and reopen to see the latest version.");
3871
+ } else if (error.issues?.length) {
3872
+ showFormError(error.issues.map((i) => `${i.path.join(".") || "Entry"}: ${i.message}`).join(" \xB7 "));
3631
3873
  } else {
3632
3874
  showFormError(error.message);
3633
3875
  }