@cancia/toolbar 0.12.1 → 0.13.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;
@@ -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;
@@ -2810,6 +2890,57 @@ function renderField(field, initial, depth = 0) {
2810
2890
  };
2811
2891
  break;
2812
2892
  }
2893
+ case "link": {
2894
+ const parsed = parseLinkValue3(initial);
2895
+ const box = document.createElement("div");
2896
+ box.style.cssText = `
2897
+ display: flex; flex-direction: column; gap: 8px;
2898
+ background: ${v("surface-raised")};
2899
+ border: 1px solid ${v("border")};
2900
+ border-radius: ${v("radius")};
2901
+ padding: 10px;
2902
+ `;
2903
+ const mkRow = (labelText2, value, placeholder) => {
2904
+ const row = document.createElement("label");
2905
+ row.style.cssText = `display: flex; flex-direction: column; gap: 4px;`;
2906
+ const cap = document.createElement("span");
2907
+ cap.textContent = labelText2;
2908
+ cap.style.cssText = `font-size: 11px; font-weight: 600; color: ${v("fg-muted")}; letter-spacing: 0.02em;`;
2909
+ const inp = document.createElement("input");
2910
+ inp.type = "text";
2911
+ inp.className = "cancia-form-input";
2912
+ inp.style.cssText = `${INPUT_BASE} caret-color: ${v("accent")};`;
2913
+ inp.value = value;
2914
+ inp.placeholder = placeholder;
2915
+ row.appendChild(cap);
2916
+ row.appendChild(inp);
2917
+ box.appendChild(row);
2918
+ return inp;
2919
+ };
2920
+ const labelInput = mkRow("Label", parsed.label, "Book a call");
2921
+ const hrefInput = mkRow("URL", parsed.href, "/start or https://\u2026");
2922
+ wrapper.appendChild(box);
2923
+ getValue2 = () => {
2924
+ const label2 = labelInput.value.trim();
2925
+ const href = hrefInput.value.trim();
2926
+ if (!label2 && !href) return void 0;
2927
+ return { label: label2, href };
2928
+ };
2929
+ validate = () => {
2930
+ const value = getValue2();
2931
+ setError(null);
2932
+ if (field.required && !value) {
2933
+ setError(`${field.label || "This field"} is required`);
2934
+ return { value, ok: false };
2935
+ }
2936
+ if (value && value.href && !isSafeHref2(value.href)) {
2937
+ setError("That link type isn't allowed. Use https://, /a-path, #anchor, mailto: or tel:");
2938
+ return { value, ok: false };
2939
+ }
2940
+ return { value, ok: true };
2941
+ };
2942
+ break;
2943
+ }
2813
2944
  default: {
2814
2945
  const input2 = document.createElement("input");
2815
2946
  input2.type = field.widget === "url" ? "url" : field.widget === "email" ? "email" : "text";
@@ -3319,6 +3450,19 @@ function preValidate(fieldStates) {
3319
3450
  }
3320
3451
  return { data, ok };
3321
3452
  }
3453
+ function paintServerIssues(fieldStates, issues) {
3454
+ if (!issues?.length) return 0;
3455
+ let painted = 0;
3456
+ for (const issue of issues) {
3457
+ const head = issue.path?.[0];
3458
+ if (head === void 0) continue;
3459
+ const target = fieldStates.find((fs) => fs.field.name === String(head));
3460
+ if (!target) continue;
3461
+ target.setError(issue.message);
3462
+ painted++;
3463
+ }
3464
+ return painted;
3465
+ }
3322
3466
  function validateValue(field, value) {
3323
3467
  if (value === void 0 || value === null || value === "") return null;
3324
3468
  if (typeof value === "string") {
@@ -3624,10 +3768,15 @@ function openEntryModal(opts) {
3624
3768
  closeEntryModal();
3625
3769
  } catch (err) {
3626
3770
  const error = err;
3627
- if (error.message.includes("Validation failed")) {
3628
- showFormError("Server-side validation failed. Check the fields above.");
3771
+ const painted = paintServerIssues(fieldStates, error.issues);
3772
+ if (painted > 0) {
3773
+ showFormError(
3774
+ painted === 1 ? "One field needs fixing \u2014 see the message below it." : `${painted} fields need fixing \u2014 see the messages below them.`
3775
+ );
3629
3776
  } else if (error.code === "REV_CONFLICT") {
3630
3777
  showFormError("This entry was changed by someone else. Close and reopen to see the latest version.");
3778
+ } else if (error.issues?.length) {
3779
+ showFormError(error.issues.map((i) => `${i.path.join(".") || "Entry"}: ${i.message}`).join(" \xB7 "));
3631
3780
  } else {
3632
3781
  showFormError(error.message);
3633
3782
  }