@cancia/toolbar 0.12.0 → 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
@@ -145,6 +145,7 @@ function inlineToShorthand(el) {
145
145
  }
146
146
 
147
147
  // src/state.ts
148
+ import { parseLinkValue } from "@cancia/astro/schema";
148
149
  var state = {
149
150
  config: null,
150
151
  cmsData: {},
@@ -170,24 +171,14 @@ function getValue(key, lang) {
170
171
  if (state.cmsData[full] !== void 0) return state.cmsData[full];
171
172
  return "";
172
173
  }
174
+ function hasValue(key, lang) {
175
+ const full = `${key}.${lang}`;
176
+ return state.pending.has(full) || state.cmsData[full] !== void 0;
177
+ }
173
178
  function setPending(key, lang, value) {
174
179
  const full = pendingKey(key, lang);
175
180
  state.pending.set(full, { key, lang, value });
176
181
  }
177
- function parseLinkOverlay(raw) {
178
- if (!raw) return { label: "", href: "" };
179
- const trimmed = raw.trim();
180
- if (trimmed.startsWith("{")) {
181
- try {
182
- const p = JSON.parse(trimmed);
183
- if (p && typeof p === "object") {
184
- return { label: String(p.label ?? ""), href: String(p.href ?? "") };
185
- }
186
- } catch {
187
- }
188
- }
189
- return { label: raw, href: "" };
190
- }
191
182
  function applyOverlay() {
192
183
  document.querySelectorAll("[data-cms]").forEach((el) => {
193
184
  if (el.dataset.cmsList !== void 0) return;
@@ -205,7 +196,7 @@ function applyOverlay() {
205
196
  return;
206
197
  }
207
198
  if (el.dataset.cmsType === "link") {
208
- const link = parseLinkOverlay(savedValue);
199
+ const link = parseLinkValue(savedValue);
209
200
  if (link.href && el.tagName === "A") el.setAttribute("href", link.href);
210
201
  const labelEls = el.querySelectorAll("[data-cms-label]");
211
202
  if (labelEls.length > 0) labelEls.forEach((n) => n.textContent = link.label);
@@ -223,7 +214,12 @@ function applyOverlay() {
223
214
  }
224
215
  function revertPending() {
225
216
  for (const [fullKey, { key }] of state.pending) {
226
- 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];
227
223
  document.querySelectorAll(`[data-cms="${key}"]`).forEach((el) => {
228
224
  if (el.tagName === "IMG") {
229
225
  el.src = savedValue;
@@ -271,6 +267,22 @@ async function saveEntry(key, lang, value) {
271
267
  });
272
268
  if (!res.ok) throw new Error(`Cancia: failed to save (${res.status})`);
273
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
+ }
274
286
  function isAuthError(err) {
275
287
  return err instanceof Error && err.message.includes("(401)");
276
288
  }
@@ -347,8 +359,7 @@ async function createListEntry(listName, data, locale, id) {
347
359
  { method: "POST", headers: headers(), body: JSON.stringify({ data, id }) }
348
360
  );
349
361
  if (!res.ok) {
350
- const err = await res.json().catch(() => ({}));
351
- 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.");
352
363
  }
353
364
  const body = await res.json();
354
365
  return body.entry;
@@ -360,9 +371,7 @@ async function updateListEntry(listName, id, data, rev, locale) {
360
371
  { method: "PATCH", headers: headers(), body: JSON.stringify({ data, _rev: rev }) }
361
372
  );
362
373
  if (!res.ok) {
363
- const err = await res.json().catch(() => ({}));
364
- const message = err.error ?? `Cancia: failed to update entry (${res.status})`;
365
- 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.");
366
375
  }
367
376
  const body = await res.json();
368
377
  return body.entry;
@@ -383,19 +392,23 @@ async function reorderList(listName, ids) {
383
392
  );
384
393
  if (!res.ok) throw new Error(`Cancia: failed to reorder list (${res.status})`);
385
394
  }
395
+ async function flushEdits(edits, save) {
396
+ const results = await Promise.allSettled(edits.map((edit) => save(edit)));
397
+ const saved = [];
398
+ const failed = [];
399
+ results.forEach((result, i) => {
400
+ (result.status === "fulfilled" ? saved : failed).push(edits[i]);
401
+ });
402
+ return { saved, failed };
403
+ }
386
404
  async function flushPending() {
387
405
  const entries = Array.from(state.pending.values());
388
- const results = await Promise.allSettled(
389
- entries.map(({ key, lang, value }) => saveEntry(key, lang, value))
406
+ const { saved, failed } = await flushEdits(
407
+ entries,
408
+ ({ key, lang, value }) => saveEntry(key, lang, value)
390
409
  );
391
- results.forEach((result, i) => {
392
- if (result.status === "fulfilled") {
393
- const { key, lang } = entries[i];
394
- state.pending.delete(`${key}.${lang}`);
395
- }
396
- });
397
- const failed = results.filter((r) => r.status === "rejected").length;
398
- if (failed > 0) throw new Error(`Cancia: ${failed} save(s) failed`);
410
+ for (const { key, lang } of saved) state.pending.delete(`${key}.${lang}`);
411
+ if (failed.length > 0) throw new Error(`Cancia: ${failed.length} save(s) failed`);
399
412
  }
400
413
 
401
414
  // src/tokens.ts
@@ -1110,9 +1123,11 @@ function onPendingChange(cb) {
1110
1123
 
1111
1124
  // src/popup.ts
1112
1125
  import {
1126
+ isSafeHref,
1113
1127
  portableTextToRows,
1114
1128
  rowsToPortableText
1115
1129
  } from "@cancia/astro/richtext";
1130
+ import { parseLinkValue as parseLinkValue2 } from "@cancia/astro/schema";
1116
1131
  var popupEl = null;
1117
1132
  var outsideListener = null;
1118
1133
  var keyListener = null;
@@ -1172,6 +1187,20 @@ function buildHeader(key, onClose) {
1172
1187
  header.appendChild(makeCloseButton(onClose));
1173
1188
  return header;
1174
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
+ }
1175
1204
  function buildTextPopup(key, anchorEl, onClose) {
1176
1205
  const langs = state.config?.languages ?? ["en"];
1177
1206
  let activeLang = state.activeLang || langs[0];
@@ -1206,9 +1235,7 @@ function buildTextPopup(key, anchorEl, onClose) {
1206
1235
  tab.addEventListener("click", () => {
1207
1236
  const current = wrap.querySelector("textarea");
1208
1237
  if (current) {
1209
- const existing = getValue(key, activeLang);
1210
- const fallback = anchorEl.textContent?.trim() || "";
1211
- if (current.value !== (existing || fallback)) {
1238
+ if (current.value !== seedValue(key, activeLang, anchorEl)) {
1212
1239
  setPending(key, activeLang, current.value);
1213
1240
  }
1214
1241
  }
@@ -1238,7 +1265,7 @@ function buildTextPopup(key, anchorEl, onClose) {
1238
1265
  };
1239
1266
  const renderTextarea = (isInit = false) => {
1240
1267
  if (!isInit && textarea) {
1241
- textarea.value = getValue(key, activeLang) || anchorEl.textContent?.trim() || "";
1268
+ textarea.value = seedValue(key, activeLang, anchorEl);
1242
1269
  textarea.style.borderColor = v("accent-ring");
1243
1270
  textarea.style.background = v("surface-hover");
1244
1271
  attachInputHandler();
@@ -1246,7 +1273,7 @@ function buildTextPopup(key, anchorEl, onClose) {
1246
1273
  }
1247
1274
  const footerEl = wrap.querySelector("[data-cancia-footer]");
1248
1275
  textarea = document.createElement("textarea");
1249
- textarea.value = getValue(key, activeLang) || anchorEl.textContent?.trim() || "";
1276
+ textarea.value = seedValue(key, activeLang, anchorEl);
1250
1277
  textarea.rows = 4;
1251
1278
  textarea.placeholder = "Enter text\u2026";
1252
1279
  textarea.style.cssText = `
@@ -1269,46 +1296,47 @@ function buildTextPopup(key, anchorEl, onClose) {
1269
1296
  const footer = document.createElement("div");
1270
1297
  footer.dataset.canciaFooter = "1";
1271
1298
  footer.style.cssText = `display: flex; justify-content: flex-end; margin-top: 10px;`;
1272
- const saveBtn = makePrimaryButton("Save", accent2());
1299
+ const saveBtn = makePrimaryButton("Done", accent2());
1273
1300
  saveBtn.dataset.canciaSave = "1";
1274
- saveBtn.title = "Save (\u2318S)";
1301
+ saveBtn.title = "Apply to the page (\u2318S) \u2014 then Save in the toolbar to publish";
1275
1302
  saveBtn.addEventListener("click", () => {
1276
- const existing = getValue(key, activeLang);
1277
- const fallback = anchorEl.textContent?.trim() || "";
1278
- if (textarea.value !== (existing || fallback)) {
1303
+ if (textarea.value !== seedValue(key, activeLang, anchorEl)) {
1279
1304
  setPending(key, activeLang, textarea.value);
1280
1305
  }
1281
1306
  onPendingChange();
1282
1307
  onClose();
1283
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
+ }
1284
1330
  footer.appendChild(saveBtn);
1285
1331
  wrap.appendChild(footer);
1286
1332
  return wrap;
1287
1333
  }
1288
1334
  function isSafeHrefValue(href) {
1289
- const trimmed = href.trim();
1290
- if (trimmed === "") return true;
1291
- if (/^(https?:|mailto:|tel:)/i.test(trimmed)) return true;
1292
- const schemeMatch = /^([a-z][a-z0-9+.-]*):/i.exec(trimmed);
1293
- if (schemeMatch) {
1294
- const firstSep = trimmed.search(/[/?#]/);
1295
- if (firstSep === -1 || schemeMatch[1].length < firstSep) return false;
1296
- }
1297
- return true;
1335
+ if (href.trim() === "") return true;
1336
+ return isSafeHref(href);
1298
1337
  }
1299
1338
  function parseLink(raw) {
1300
- if (!raw) return { label: "", href: "" };
1301
- const trimmed = raw.trim();
1302
- if (trimmed.startsWith("{")) {
1303
- try {
1304
- const p = JSON.parse(trimmed);
1305
- if (p && typeof p === "object") {
1306
- return { label: String(p.label ?? ""), href: String(p.href ?? "") };
1307
- }
1308
- } catch {
1309
- }
1310
- }
1311
- return { label: raw, href: "" };
1339
+ return parseLinkValue2(raw);
1312
1340
  }
1313
1341
  function buildLinkPopup(key, anchorEl, onClose) {
1314
1342
  const langs = state.config?.languages ?? ["en"];
@@ -1433,9 +1461,9 @@ function buildLinkPopup(key, anchorEl, onClose) {
1433
1461
  const footer = document.createElement("div");
1434
1462
  footer.dataset.canciaFooter = "1";
1435
1463
  footer.style.cssText = `display: flex; justify-content: flex-end; margin-top: 10px;`;
1436
- const saveBtn = makePrimaryButton("Save", accent2());
1464
+ const saveBtn = makePrimaryButton("Done", accent2());
1437
1465
  saveBtn.dataset.canciaSave = "1";
1438
- saveBtn.title = "Save (\u2318S)";
1466
+ saveBtn.title = "Apply to the page (\u2318S) \u2014 then Save in the toolbar to publish";
1439
1467
  saveBtn.addEventListener("click", () => {
1440
1468
  if (!validate()) return;
1441
1469
  stage(activeLang);
@@ -1473,6 +1501,33 @@ function buildImagePopup(key, anchorEl, onClose) {
1473
1501
  previewWrap.appendChild(previewImg);
1474
1502
  previewWrap.appendChild(previewLabel);
1475
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
+ }
1476
1531
  }
1477
1532
  const dropZone = document.createElement("label");
1478
1533
  dropZone.style.cssText = `
@@ -1828,9 +1883,9 @@ function buildRichPopup(key, anchorEl, onClose) {
1828
1883
  rowsWrap.appendChild(r.el);
1829
1884
  r.el.querySelector("textarea")?.focus();
1830
1885
  });
1831
- const saveBtn = makePrimaryButton("Save", accent2());
1886
+ const saveBtn = makePrimaryButton("Done", accent2());
1832
1887
  saveBtn.dataset.canciaSave = "1";
1833
- saveBtn.title = "Save (\u2318S)";
1888
+ saveBtn.title = "Apply to the page (\u2318S) \u2014 then Save in the toolbar to publish";
1834
1889
  saveBtn.addEventListener("click", () => {
1835
1890
  commit();
1836
1891
  onClose();
@@ -1899,6 +1954,7 @@ function closePopup() {
1899
1954
  }
1900
1955
 
1901
1956
  // src/list-panel.ts
1957
+ import { isDraft } from "@cancia/astro/schema";
1902
1958
  var PANEL_Z = v("z-panel");
1903
1959
  var TOOLBAR_RESERVE = "100px";
1904
1960
  var BACKDROP_Z = v("z-overlay");
@@ -2239,7 +2295,7 @@ function renderEntries(body, schema, activeLocale, entriesInLocale, translations
2239
2295
  const titleValue = row.entry.data[schema.titleField];
2240
2296
  const title = typeof titleValue === "string" && titleValue.trim().length > 0 ? titleValue : `(untitled ${schema.labelSingular.toLowerCase()})`;
2241
2297
  const { subtitle, thumbnail } = derivePreview(schema, row.entry.data);
2242
- const isDraftRow = !!schema.draftField && row.entry.data[schema.draftField] === true;
2298
+ const isDraftRow = isDraft(schema, row.entry.data);
2243
2299
  const draftBadge = isDraftRow ? `<span style="
2244
2300
  flex-shrink: 0; margin-left: ${v("space-2")}; padding: 1px 6px;
2245
2301
  font-size: 11px; font-weight: 600; line-height: 1.5;
@@ -2454,7 +2510,7 @@ import {
2454
2510
  portableTextSubsetSchema as portableTextSubsetSchema2,
2455
2511
  PT_STYLES as PT_STYLES2
2456
2512
  } from "@cancia/astro/richtext";
2457
- import { slugify } from "@cancia/astro/schema";
2513
+ import { slugify, parseLinkValue as parseLinkValue3, isSafeHref as isSafeHref2 } from "@cancia/astro/schema";
2458
2514
  var MODAL_Z = v("z-bar");
2459
2515
  var BACKDROP_Z2 = v("z-panel");
2460
2516
  var modalEl = null;
@@ -2834,6 +2890,57 @@ function renderField(field, initial, depth = 0) {
2834
2890
  };
2835
2891
  break;
2836
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
+ }
2837
2944
  default: {
2838
2945
  const input2 = document.createElement("input");
2839
2946
  input2.type = field.widget === "url" ? "url" : field.widget === "email" ? "email" : "text";
@@ -3343,6 +3450,19 @@ function preValidate(fieldStates) {
3343
3450
  }
3344
3451
  return { data, ok };
3345
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
+ }
3346
3466
  function validateValue(field, value) {
3347
3467
  if (value === void 0 || value === null || value === "") return null;
3348
3468
  if (typeof value === "string") {
@@ -3648,10 +3768,15 @@ function openEntryModal(opts) {
3648
3768
  closeEntryModal();
3649
3769
  } catch (err) {
3650
3770
  const error = err;
3651
- if (error.message.includes("Validation failed")) {
3652
- 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
+ );
3653
3776
  } else if (error.code === "REV_CONFLICT") {
3654
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 "));
3655
3780
  } else {
3656
3781
  showFormError(error.message);
3657
3782
  }