@cancia/toolbar 0.0.1 → 0.1.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
@@ -189,6 +189,14 @@ async function deleteListEntry(listName, id, locale) {
189
189
  );
190
190
  if (!res.ok) throw new Error(`Cancia: failed to delete entry (${res.status})`);
191
191
  }
192
+ async function reorderList(listName, ids) {
193
+ const { apiUrl, site } = state.config;
194
+ const res = await fetch(
195
+ `${apiUrl}/api/cancia/lists/${encodeURIComponent(listName)}/reorder?site=${encodeURIComponent(site)}`,
196
+ { method: "POST", headers: headers(), body: JSON.stringify({ ids }) }
197
+ );
198
+ if (!res.ok) throw new Error(`Cancia: failed to reorder list (${res.status})`);
199
+ }
192
200
  async function flushPending() {
193
201
  const entries = Array.from(state.pending.values());
194
202
  const results = await Promise.allSettled(
@@ -958,11 +966,55 @@ function escapeHtml(s) {
958
966
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
959
967
  }
960
968
  function formatExcerpt(value, maxLength = 120) {
961
- if (typeof value !== "string") return "";
962
- const trimmed = value.trim();
969
+ const text = toPlainText(value);
970
+ if (!text) return "";
971
+ const trimmed = text.trim();
963
972
  if (trimmed.length <= maxLength) return trimmed;
964
973
  return trimmed.slice(0, maxLength).trimEnd() + "\u2026";
965
974
  }
975
+ function toPlainText(value) {
976
+ if (typeof value === "string") return value;
977
+ if (Array.isArray(value)) {
978
+ const parts = [];
979
+ for (const block of value) {
980
+ if (block && typeof block === "object" && Array.isArray(block.children)) {
981
+ for (const span of block.children) {
982
+ const t = span?.text;
983
+ if (typeof t === "string") parts.push(t);
984
+ }
985
+ parts.push(" ");
986
+ }
987
+ }
988
+ return parts.join("").replace(/\s+/g, " ").trim();
989
+ }
990
+ return "";
991
+ }
992
+ function derivePreview(schema, data) {
993
+ let subtitle = "";
994
+ if (schema.bodyField) subtitle = formatExcerpt(data[schema.bodyField]);
995
+ if (!subtitle) {
996
+ const TEXTISH = /* @__PURE__ */ new Set(["text", "textarea", "richtext"]);
997
+ for (const f of schema.fields) {
998
+ if (f.name === schema.titleField) continue;
999
+ if (!TEXTISH.has(f.widget)) continue;
1000
+ const s = formatExcerpt(data[f.name]);
1001
+ if (s) {
1002
+ subtitle = s;
1003
+ break;
1004
+ }
1005
+ }
1006
+ }
1007
+ let thumbnail = "";
1008
+ for (const f of schema.fields) {
1009
+ if (f.widget !== "image") continue;
1010
+ const v = data[f.name];
1011
+ if (typeof v === "string" && v.trim()) {
1012
+ thumbnail = v.trim();
1013
+ break;
1014
+ }
1015
+ }
1016
+ return { subtitle, thumbnail };
1017
+ }
966
1018
  function locales() {
967
1019
  return state.config?.languages ?? [];
968
1020
  }
@@ -1097,25 +1149,106 @@ function renderEntries(body, schema, activeLocale, entriesInLocale, translations
1097
1149
  return;
1098
1150
  }
1099
1151
  body.innerHTML = "";
1152
+ const dragRecs = [];
1153
+ let dragging = null;
1154
+ let orderBeforeDrag = [];
1155
+ const currentOrder = () => dragRecs.map((r) => r.id);
1156
+ async function commitOrder(prevOrder) {
1157
+ try {
1158
+ await reorderList(schema.name, currentOrder());
1159
+ } catch {
1160
+ const byId = new Map(dragRecs.map((r) => [r.id, r]));
1161
+ dragRecs.length = 0;
1162
+ for (const id of prevOrder) {
1163
+ const rec = byId.get(id);
1164
+ if (rec) {
1165
+ dragRecs.push(rec);
1166
+ body.appendChild(rec.wrap);
1167
+ }
1168
+ }
1169
+ const err = document.createElement("div");
1170
+ err.textContent = "Couldn't save the new order. Reverted.";
1171
+ err.style.cssText = `text-align:center; padding:8px; color:#c0392b; font-size:12px;`;
1172
+ body.insertBefore(err, body.firstChild);
1173
+ setTimeout(() => err.remove(), 3e3);
1174
+ }
1175
+ }
1100
1176
  rows.forEach((row) => {
1101
- const item = document.createElement("button");
1102
1177
  const isStub = row.entry === null;
1178
+ const wrap = document.createElement("div");
1179
+ wrap.style.cssText = `display: flex; align-items: stretch; gap: 4px; margin-bottom: 6px;`;
1180
+ const handle = document.createElement("div");
1181
+ handle.textContent = "\u22EE\u22EE";
1182
+ handle.title = "Drag to reorder";
1183
+ handle.draggable = true;
1184
+ handle.style.cssText = `
1185
+ cursor: grab; user-select: none;
1186
+ display: flex; align-items: center; justify-content: center;
1187
+ color: #bbb; font-size: 13px; letter-spacing: -2px;
1188
+ padding: 0 2px; flex-shrink: 0;
1189
+ `;
1190
+ const item = document.createElement("button");
1103
1191
  item.style.cssText = `
1104
1192
  appearance: none; border: 1px solid transparent; background: ${isStub ? "#fff8ee" : "#fafafa"};
1105
- text-align: left; width: 100%;
1193
+ text-align: left; flex: 1; min-width: 0;
1106
1194
  padding: 12px 14px; border-radius: 8px; cursor: pointer;
1107
- margin-bottom: 6px;
1108
1195
  transition: background 0.12s, border-color 0.12s, transform 0.12s;
1109
1196
  display: block;
1110
1197
  `;
1198
+ const rec = { wrap, id: row.id };
1199
+ handle.addEventListener("dragstart", (e) => {
1200
+ dragging = rec;
1201
+ orderBeforeDrag = currentOrder();
1202
+ handle.style.cursor = "grabbing";
1203
+ wrap.style.opacity = "0.5";
1204
+ e.dataTransfer?.setData("text/plain", "");
1205
+ if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
1206
+ });
1207
+ handle.addEventListener("dragend", () => {
1208
+ handle.style.cursor = "grab";
1209
+ wrap.style.opacity = "1";
1210
+ dragging = null;
1211
+ });
1212
+ wrap.addEventListener("dragover", (e) => {
1213
+ if (!dragging || dragging === rec) return;
1214
+ e.preventDefault();
1215
+ const rect = wrap.getBoundingClientRect();
1216
+ const after = e.clientY > rect.top + rect.height / 2;
1217
+ const from = dragRecs.indexOf(dragging);
1218
+ let to = dragRecs.indexOf(rec);
1219
+ if (from < 0 || to < 0) return;
1220
+ if (after) to += 1;
1221
+ if (from < to) to -= 1;
1222
+ if (from === to) return;
1223
+ dragRecs.splice(from, 1);
1224
+ dragRecs.splice(to, 0, dragging);
1225
+ body.insertBefore(dragging.wrap, after ? wrap.nextSibling : wrap);
1226
+ });
1227
+ wrap.addEventListener("drop", (e) => {
1228
+ if (!dragging) return;
1229
+ e.preventDefault();
1230
+ const next = currentOrder();
1231
+ const changed = next.length !== orderBeforeDrag.length || next.some((id, i) => id !== orderBeforeDrag[i]);
1232
+ if (changed) void commitOrder(orderBeforeDrag);
1233
+ });
1111
1234
  if (row.entry) {
1112
1235
  const titleValue = row.entry.data[schema.titleField];
1113
1236
  const title = typeof titleValue === "string" && titleValue.trim().length > 0 ? titleValue : `(untitled ${schema.labelSingular.toLowerCase()})`;
1114
- const excerptValue = schema.bodyField ? row.entry.data[schema.bodyField] : void 0;
1115
- const excerpt = formatExcerpt(excerptValue);
1237
+ const { subtitle, thumbnail } = derivePreview(schema, row.entry.data);
1238
+ const textCol = `
1239
+ <div style="min-width: 0; flex: 1;">
1240
+ <div style="font-weight: 600; font-size: 14px; color: #1a1a1d; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(title)}</div>
1241
+ ${subtitle ? `<div style="font-size: 12px; color: #666; margin-top: 4px; line-height: 1.4;">${escapeHtml(subtitle)}</div>` : ""}
1242
+ </div>
1243
+ `;
1244
+ const thumb = thumbnail ? `<img src="${escapeHtml(thumbnail)}" alt="" loading="lazy" style="
1245
+ width: 44px; height: 44px; flex-shrink: 0; object-fit: cover;
1246
+ border-radius: 6px; background: #eee; border: 1px solid #eaeaea;
1247
+ " onerror="this.style.display='none'" />` : "";
1116
1248
  item.innerHTML = `
1117
- <div style="font-weight: 600; font-size: 14px; color: #1a1a1d;">${escapeHtml(title)}</div>
1118
- ${excerpt ? `<div style="font-size: 12px; color: #666; margin-top: 4px; line-height: 1.4;">${escapeHtml(excerpt)}</div>` : ""}
1249
+ <div style="display: flex; align-items: center; gap: 12px;">
1250
+ ${thumb}${textCol}
1251
+ </div>
1119
1252
  `;
1120
1253
  item.addEventListener("mouseenter", () => {
1121
1254
  item.style.background = "#f3f3f3";
@@ -1156,7 +1289,10 @@ function renderEntries(body, schema, activeLocale, entriesInLocale, translations
1156
1289
  () => currentOnTranslateEntry?.(row.id, sourceEntry, activeLocale)
1157
1290
  );
1158
1291
  }
1159
- body.appendChild(item);
1292
+ wrap.appendChild(handle);
1293
+ wrap.appendChild(item);
1294
+ dragRecs.push(rec);
1295
+ body.appendChild(wrap);
1160
1296
  });
1161
1297
  }
1162
1298
  async function openListPanel(opts) {
@@ -1250,6 +1386,13 @@ function isListPanelOpen() {
1250
1386
  }
1251
1387
 
1252
1388
  // src/entry-modal.ts
1389
+ import {
1390
+ rowsToPortableText,
1391
+ portableTextToRows,
1392
+ portableTextSubsetSchema,
1393
+ PT_STYLES
1394
+ } from "@cancia/astro/richtext";
1395
+ import { slugify } from "@cancia/astro/schema";
1253
1396
  var MODAL_Z = 2147483647;
1254
1397
  var BACKDROP_Z2 = 2147483646;
1255
1398
  var modalEl = null;
@@ -1327,7 +1470,8 @@ var INPUT_BASE = `
1327
1470
  outline: none;
1328
1471
  transition: border-color 0.18s, background 0.18s;
1329
1472
  `;
1330
- function renderField(field, initial) {
1473
+ var MAX_FIELD_DEPTH = 6;
1474
+ function renderField(field, initial, depth = 0) {
1331
1475
  const wrapper = document.createElement("div");
1332
1476
  wrapper.style.cssText = `margin-bottom: 14px;`;
1333
1477
  const labelRow = document.createElement("label");
@@ -1348,7 +1492,7 @@ function renderField(field, initial) {
1348
1492
  labelText.appendChild(star);
1349
1493
  }
1350
1494
  labelRow.appendChild(labelText);
1351
- wrapper.appendChild(labelRow);
1495
+ if (field.label) wrapper.appendChild(labelRow);
1352
1496
  if (field.description) {
1353
1497
  const help = document.createElement("div");
1354
1498
  help.style.cssText = `font-size: 11px; color: rgba(255,255,255,0.35); margin-bottom: 6px; line-height: 1.4;`;
@@ -1368,7 +1512,37 @@ function renderField(field, initial) {
1368
1512
  }
1369
1513
  };
1370
1514
  let getValue2;
1515
+ let validate = null;
1516
+ let onInput;
1517
+ let setValue;
1371
1518
  switch (field.widget) {
1519
+ case "array": {
1520
+ const built = renderArrayField(field, initial, setError, depth);
1521
+ wrapper.appendChild(built.control);
1522
+ getValue2 = built.getValue;
1523
+ validate = built.validate;
1524
+ break;
1525
+ }
1526
+ case "object": {
1527
+ const built = renderObjectField(field, initial, setError, depth);
1528
+ wrapper.appendChild(built.control);
1529
+ getValue2 = built.getValue;
1530
+ validate = built.validate;
1531
+ break;
1532
+ }
1533
+ case "reference": {
1534
+ const built = renderReferenceField(field, initial);
1535
+ wrapper.appendChild(built.control);
1536
+ getValue2 = built.getValue;
1537
+ break;
1538
+ }
1539
+ case "richtext": {
1540
+ const built = renderRichTextField(field, initial, setError);
1541
+ wrapper.appendChild(built.control);
1542
+ getValue2 = built.getValue;
1543
+ validate = built.validate;
1544
+ break;
1545
+ }
1372
1546
  case "textarea": {
1373
1547
  const ta = document.createElement("textarea");
1374
1548
  ta.className = "cancia-form-textarea";
@@ -1579,40 +1753,591 @@ function renderField(field, initial) {
1579
1753
  if (typeof initial === "string") input.value = initial;
1580
1754
  wrapper.appendChild(input);
1581
1755
  getValue2 = () => input.value;
1756
+ onInput = (cb) => input.addEventListener("input", cb);
1757
+ setValue = (v) => {
1758
+ input.value = v;
1759
+ };
1582
1760
  break;
1583
1761
  }
1584
1762
  }
1585
1763
  wrapper.appendChild(errorEl);
1586
- return { wrapper, fieldState: { field, getValue: getValue2, setError } };
1764
+ const scalarValidate = () => {
1765
+ const value = getValue2();
1766
+ setError(null);
1767
+ if (field.required && (value === void 0 || value === "" || value === null)) {
1768
+ setError(`${field.label || "This field"} is required`);
1769
+ return { value, ok: false };
1770
+ }
1771
+ const err = validateValue(field, value);
1772
+ if (err) {
1773
+ setError(err);
1774
+ return { value, ok: false };
1775
+ }
1776
+ return { value, ok: true };
1777
+ };
1778
+ return {
1779
+ wrapper,
1780
+ fieldState: { field, getValue: getValue2, setError, validate: validate ?? scalarValidate, onInput, setValue }
1781
+ };
1782
+ }
1783
+ function renderArrayField(field, initial, setOwnError, depth) {
1784
+ const itemSchema = field.of;
1785
+ const container = document.createElement("div");
1786
+ container.style.cssText = `
1787
+ display: flex; flex-direction: column; gap: 8px;
1788
+ background: rgba(255,255,255,0.02);
1789
+ border: 1px solid rgba(255,255,255,0.07);
1790
+ border-radius: 10px;
1791
+ padding: 10px;
1792
+ `;
1793
+ const rowsWrap = document.createElement("div");
1794
+ rowsWrap.style.cssText = `display: flex; flex-direction: column; gap: 8px;`;
1795
+ container.appendChild(rowsWrap);
1796
+ if (!itemSchema || depth >= MAX_FIELD_DEPTH) {
1797
+ const note = document.createElement("div");
1798
+ note.style.cssText = `font-size: 11px; color: rgba(255,255,255,0.4);`;
1799
+ note.textContent = itemSchema ? "Nesting too deep to edit here." : "This array has no item schema.";
1800
+ container.appendChild(note);
1801
+ return { control: container, getValue: () => [], validate: () => ({ value: [], ok: true }) };
1802
+ }
1803
+ const rows = [];
1804
+ let dragging = null;
1805
+ function makeRow(itemValue) {
1806
+ const row = document.createElement("div");
1807
+ row.style.cssText = `
1808
+ display: flex; align-items: flex-start; gap: 8px;
1809
+ background: rgba(255,255,255,0.02);
1810
+ border: 1px solid rgba(255,255,255,0.06);
1811
+ border-radius: 8px;
1812
+ padding: 8px;
1813
+ `;
1814
+ const handle = document.createElement("div");
1815
+ handle.textContent = "\u22EE\u22EE";
1816
+ handle.title = "Drag to reorder";
1817
+ handle.draggable = true;
1818
+ handle.style.cssText = `
1819
+ cursor: grab; user-select: none;
1820
+ color: rgba(255,255,255,0.35);
1821
+ font-size: 13px; line-height: 1.2;
1822
+ padding: 4px 2px; flex-shrink: 0;
1823
+ letter-spacing: -2px;
1824
+ `;
1825
+ const { wrapper, fieldState } = renderField(itemSchema, itemValue, depth + 1);
1826
+ wrapper.style.marginBottom = "0";
1827
+ wrapper.style.flex = "1";
1828
+ wrapper.style.minWidth = "0";
1829
+ const removeBtn = document.createElement("button");
1830
+ removeBtn.type = "button";
1831
+ removeBtn.textContent = "\xD7";
1832
+ removeBtn.title = "Remove";
1833
+ removeBtn.style.cssText = `
1834
+ appearance: none; cursor: pointer; flex-shrink: 0;
1835
+ background: transparent; border: 1px solid transparent;
1836
+ color: rgba(255,135,134,0.7);
1837
+ font-size: 16px; line-height: 1;
1838
+ padding: 2px 7px; border-radius: 6px;
1839
+ transition: background 0.15s;
1840
+ `;
1841
+ removeBtn.addEventListener("mouseenter", () => {
1842
+ removeBtn.style.background = "rgba(255,135,134,0.1)";
1843
+ });
1844
+ removeBtn.addEventListener("mouseleave", () => {
1845
+ removeBtn.style.background = "transparent";
1846
+ });
1847
+ row.appendChild(handle);
1848
+ row.appendChild(wrapper);
1849
+ row.appendChild(removeBtn);
1850
+ const rec = { el: row, state: fieldState };
1851
+ removeBtn.addEventListener("click", () => {
1852
+ const i = rows.indexOf(rec);
1853
+ if (i >= 0) rows.splice(i, 1);
1854
+ row.remove();
1855
+ setOwnError(null);
1856
+ });
1857
+ handle.addEventListener("dragstart", (e) => {
1858
+ dragging = rec;
1859
+ handle.style.cursor = "grabbing";
1860
+ row.style.opacity = "0.5";
1861
+ e.dataTransfer?.setData("text/plain", "");
1862
+ if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
1863
+ });
1864
+ handle.addEventListener("dragend", () => {
1865
+ handle.style.cursor = "grab";
1866
+ row.style.opacity = "1";
1867
+ dragging = null;
1868
+ });
1869
+ row.addEventListener("dragover", (e) => {
1870
+ if (!dragging || dragging === rec) return;
1871
+ e.preventDefault();
1872
+ const rect = row.getBoundingClientRect();
1873
+ const after = e.clientY > rect.top + rect.height / 2;
1874
+ const from = rows.indexOf(dragging);
1875
+ let to = rows.indexOf(rec);
1876
+ if (from < 0 || to < 0) return;
1877
+ if (after) to += 1;
1878
+ if (from < to) to -= 1;
1879
+ if (from === to) return;
1880
+ rows.splice(from, 1);
1881
+ rows.splice(to, 0, dragging);
1882
+ rowsWrap.insertBefore(dragging.el, after ? row.nextSibling : row);
1883
+ });
1884
+ return rec;
1885
+ }
1886
+ function addRow(itemValue) {
1887
+ const rec = makeRow(itemValue);
1888
+ rows.push(rec);
1889
+ rowsWrap.appendChild(rec.el);
1890
+ }
1891
+ const initialItems = Array.isArray(initial) ? initial : [];
1892
+ for (const it of initialItems) addRow(it);
1893
+ const addBtn = document.createElement("button");
1894
+ addBtn.type = "button";
1895
+ const itemLabel = itemSchema.label || "item";
1896
+ addBtn.textContent = `+ Add ${itemLabel.toLowerCase()}`;
1897
+ addBtn.style.cssText = `
1898
+ appearance: none; cursor: pointer; align-self: flex-start;
1899
+ background: rgba(255,255,255,0.05); color: rgba(255,255,255,0.8);
1900
+ border: 1px solid rgba(255,255,255,0.08);
1901
+ font-size: 11px; font-weight: 500;
1902
+ padding: 6px 11px; border-radius: 7px;
1903
+ transition: background 0.15s;
1904
+ `;
1905
+ addBtn.addEventListener("mouseenter", () => {
1906
+ addBtn.style.background = "rgba(255,255,255,0.1)";
1907
+ });
1908
+ addBtn.addEventListener("mouseleave", () => {
1909
+ addBtn.style.background = "rgba(255,255,255,0.05)";
1910
+ });
1911
+ addBtn.addEventListener("click", () => addRow(defaultForField(itemSchema)));
1912
+ container.appendChild(addBtn);
1913
+ const collect = () => rows.map((r) => r.state.getValue());
1914
+ return {
1915
+ control: container,
1916
+ // Deleting the last item serialises to [] (not undefined) so a cleared
1917
+ // array persists as an empty array.
1918
+ getValue: () => collect(),
1919
+ validate: () => {
1920
+ setOwnError(null);
1921
+ let ok = true;
1922
+ for (const r of rows) {
1923
+ const res = r.state.validate();
1924
+ if (!res.ok) ok = false;
1925
+ }
1926
+ const value = collect();
1927
+ if (field.required && value.length === 0) {
1928
+ setOwnError(`${field.label || "This list"} needs at least one item`);
1929
+ ok = false;
1930
+ }
1931
+ return { value, ok };
1932
+ }
1933
+ };
1934
+ }
1935
+ function renderObjectField(field, initial, setOwnError, depth) {
1936
+ const subFields = field.fields ?? [];
1937
+ const subInitial = initial && typeof initial === "object" && !Array.isArray(initial) ? initial : {};
1938
+ const fieldset = document.createElement("div");
1939
+ fieldset.style.cssText = `
1940
+ display: flex; flex-direction: column; gap: 2px;
1941
+ background: rgba(255,255,255,0.02);
1942
+ border: 1px solid rgba(255,255,255,0.07);
1943
+ border-radius: 10px;
1944
+ padding: 10px 10px 0;
1945
+ `;
1946
+ if (depth >= MAX_FIELD_DEPTH) {
1947
+ const note = document.createElement("div");
1948
+ note.style.cssText = `font-size: 11px; color: rgba(255,255,255,0.4); padding-bottom: 10px;`;
1949
+ note.textContent = "Nesting too deep to edit here.";
1950
+ fieldset.appendChild(note);
1951
+ return { control: fieldset, getValue: () => ({}), validate: () => ({ value: {}, ok: true }) };
1952
+ }
1953
+ const childStates = [];
1954
+ for (const sub of subFields) {
1955
+ const { wrapper, fieldState } = renderField(sub, subInitial[sub.name], depth + 1);
1956
+ fieldset.appendChild(wrapper);
1957
+ childStates.push(fieldState);
1958
+ }
1959
+ const collect = () => {
1960
+ const out = {};
1961
+ for (const c of childStates) {
1962
+ const v = c.getValue();
1963
+ if (v !== void 0 && v !== "") out[c.field.name] = v;
1964
+ }
1965
+ return out;
1966
+ };
1967
+ return {
1968
+ control: fieldset,
1969
+ getValue: () => collect(),
1970
+ validate: () => {
1971
+ setOwnError(null);
1972
+ let ok = true;
1973
+ for (const c of childStates) {
1974
+ const res = c.validate();
1975
+ if (!res.ok) ok = false;
1976
+ }
1977
+ return { value: collect(), ok };
1978
+ }
1979
+ };
1980
+ }
1981
+ function renderReferenceField(field, initial) {
1982
+ const currentId = typeof initial === "string" ? initial : "";
1983
+ const targetList = field.referenceList;
1984
+ const sel = document.createElement("select");
1985
+ sel.className = "cancia-form-select";
1986
+ sel.style.cssText = `${INPUT_BASE} appearance: none; padding-right: 30px; background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'><path d='M2.5 3.75l2.5 2.5 2.5-2.5' stroke='rgba(255,255,255,0.4)' stroke-width='1.3' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>"); background-repeat: no-repeat; background-position: right 10px center; cursor: pointer;`;
1987
+ const opt = (value, text, selected = false) => {
1988
+ const o = document.createElement("option");
1989
+ o.value = value;
1990
+ o.textContent = text;
1991
+ if (selected) o.selected = true;
1992
+ return o;
1993
+ };
1994
+ const loadingOpt = opt("", "Loading\u2026");
1995
+ loadingOpt.disabled = true;
1996
+ sel.appendChild(loadingOpt);
1997
+ if (currentId) sel.appendChild(opt(currentId, `Current (${currentId})`, true));
1998
+ const getValue2 = () => sel.value === "" ? void 0 : sel.value;
1999
+ if (!targetList) {
2000
+ sel.innerHTML = "";
2001
+ const note = opt("", "No target list configured");
2002
+ note.disabled = true;
2003
+ sel.appendChild(note);
2004
+ return { control: sel, getValue: getValue2 };
2005
+ }
2006
+ const titleField = state.schemas[targetList]?.titleField;
2007
+ const titleOf = (entry) => {
2008
+ if (titleField) {
2009
+ const v = entry.data[titleField];
2010
+ if (typeof v === "string" && v.trim()) return v;
2011
+ }
2012
+ return `(untitled \xB7 ${entry.id})`;
2013
+ };
2014
+ void (async () => {
2015
+ let entries = [];
2016
+ let failed = false;
2017
+ try {
2018
+ entries = await fetchList(targetList, state.activeListLocale || state.activeLang || void 0);
2019
+ } catch {
2020
+ failed = true;
2021
+ }
2022
+ sel.innerHTML = "";
2023
+ if (failed) {
2024
+ const errOpt = opt("", "Failed to load options");
2025
+ errOpt.disabled = true;
2026
+ sel.appendChild(errOpt);
2027
+ if (currentId) sel.appendChild(opt(currentId, `Current (${currentId})`, true));
2028
+ return;
2029
+ }
2030
+ if (!field.required) sel.appendChild(opt("", "\u2014 none \u2014", currentId === ""));
2031
+ let matched = false;
2032
+ for (const entry of entries) {
2033
+ const isCurrent = entry.id === currentId;
2034
+ if (isCurrent) matched = true;
2035
+ sel.appendChild(opt(entry.id, titleOf(entry), isCurrent));
2036
+ }
2037
+ if (currentId && !matched) {
2038
+ sel.appendChild(opt(currentId, `\u26A0 missing (${currentId})`, true));
2039
+ }
2040
+ })();
2041
+ return { control: sel, getValue: getValue2 };
2042
+ }
2043
+ function renderRichTextField(field, initial, setOwnError) {
2044
+ const STYLE_LABELS = {
2045
+ normal: "Normal",
2046
+ h2: "Heading 2",
2047
+ h3: "Heading 3",
2048
+ blockquote: "Quote"
2049
+ };
2050
+ const LIST_LABELS = [
2051
+ { value: "", label: "No list" },
2052
+ { value: "bullet", label: "Bulleted" },
2053
+ { value: "number", label: "Numbered" }
2054
+ ];
2055
+ const container = document.createElement("div");
2056
+ container.style.cssText = `
2057
+ display: flex; flex-direction: column; gap: 8px;
2058
+ background: rgba(255,255,255,0.02);
2059
+ border: 1px solid rgba(255,255,255,0.07);
2060
+ border-radius: 10px;
2061
+ padding: 10px;
2062
+ `;
2063
+ const rowsWrap = document.createElement("div");
2064
+ rowsWrap.style.cssText = `display: flex; flex-direction: column; gap: 8px;`;
2065
+ container.appendChild(rowsWrap);
2066
+ const rows = [];
2067
+ let dragging = null;
2068
+ function makeRow(initialRow) {
2069
+ const row = document.createElement("div");
2070
+ row.style.cssText = `
2071
+ display: flex; align-items: flex-start; gap: 8px;
2072
+ background: rgba(255,255,255,0.02);
2073
+ border: 1px solid rgba(255,255,255,0.06);
2074
+ border-radius: 8px;
2075
+ padding: 8px;
2076
+ `;
2077
+ const handle = document.createElement("div");
2078
+ handle.textContent = "\u22EE\u22EE";
2079
+ handle.title = "Drag to reorder";
2080
+ handle.draggable = true;
2081
+ handle.style.cssText = `
2082
+ cursor: grab; user-select: none;
2083
+ color: rgba(255,255,255,0.35);
2084
+ font-size: 13px; line-height: 1.2;
2085
+ padding: 4px 2px; flex-shrink: 0;
2086
+ letter-spacing: -2px;
2087
+ `;
2088
+ const main = document.createElement("div");
2089
+ main.style.cssText = `flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px;`;
2090
+ const ta = document.createElement("textarea");
2091
+ ta.className = "cancia-form-textarea";
2092
+ ta.style.cssText = `${INPUT_BASE} resize: vertical; min-height: 54px; max-height: 240px; caret-color: ${accent4()};`;
2093
+ ta.rows = 2;
2094
+ ta.placeholder = "Text \u2014 use **bold**, *italic*, [label](https://\u2026)";
2095
+ ta.value = initialRow.text;
2096
+ const controls = document.createElement("div");
2097
+ controls.style.cssText = `display: flex; gap: 6px;`;
2098
+ const styleSel = document.createElement("select");
2099
+ styleSel.className = "cancia-form-select";
2100
+ styleSel.style.cssText = `${INPUT_BASE} width: auto; flex: 1; appearance: none; padding: 5px 26px 5px 9px; font-size: 11px; background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'><path d='M2.5 3.75l2.5 2.5 2.5-2.5' stroke='rgba(255,255,255,0.4)' stroke-width='1.3' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>"); background-repeat: no-repeat; background-position: right 9px center; cursor: pointer;`;
2101
+ for (const s of PT_STYLES) {
2102
+ const o = document.createElement("option");
2103
+ o.value = s;
2104
+ o.textContent = STYLE_LABELS[s];
2105
+ if (initialRow.style === s) o.selected = true;
2106
+ styleSel.appendChild(o);
2107
+ }
2108
+ const listSel = document.createElement("select");
2109
+ listSel.className = "cancia-form-select";
2110
+ listSel.style.cssText = styleSel.style.cssText;
2111
+ for (const { value, label } of LIST_LABELS) {
2112
+ const o = document.createElement("option");
2113
+ o.value = value;
2114
+ o.textContent = label;
2115
+ if ((initialRow.listItem ?? "") === value) o.selected = true;
2116
+ listSel.appendChild(o);
2117
+ }
2118
+ controls.appendChild(styleSel);
2119
+ controls.appendChild(listSel);
2120
+ main.appendChild(ta);
2121
+ main.appendChild(controls);
2122
+ const removeBtn = document.createElement("button");
2123
+ removeBtn.type = "button";
2124
+ removeBtn.textContent = "\xD7";
2125
+ removeBtn.title = "Remove block";
2126
+ removeBtn.style.cssText = `
2127
+ appearance: none; cursor: pointer; flex-shrink: 0;
2128
+ background: transparent; border: 1px solid transparent;
2129
+ color: rgba(255,135,134,0.7);
2130
+ font-size: 16px; line-height: 1;
2131
+ padding: 2px 7px; border-radius: 6px;
2132
+ transition: background 0.15s;
2133
+ `;
2134
+ removeBtn.addEventListener("mouseenter", () => {
2135
+ removeBtn.style.background = "rgba(255,135,134,0.1)";
2136
+ });
2137
+ removeBtn.addEventListener("mouseleave", () => {
2138
+ removeBtn.style.background = "transparent";
2139
+ });
2140
+ row.appendChild(handle);
2141
+ row.appendChild(main);
2142
+ row.appendChild(removeBtn);
2143
+ const rec = {
2144
+ el: row,
2145
+ read: () => {
2146
+ const style = styleSel.value ?? "normal";
2147
+ const listValue = listSel.value;
2148
+ const out = { text: ta.value, style };
2149
+ if (listValue) out.listItem = listValue;
2150
+ return out;
2151
+ }
2152
+ };
2153
+ removeBtn.addEventListener("click", () => {
2154
+ const i = rows.indexOf(rec);
2155
+ if (i >= 0) rows.splice(i, 1);
2156
+ row.remove();
2157
+ setOwnError(null);
2158
+ });
2159
+ handle.addEventListener("dragstart", (e) => {
2160
+ dragging = rec;
2161
+ handle.style.cursor = "grabbing";
2162
+ row.style.opacity = "0.5";
2163
+ e.dataTransfer?.setData("text/plain", "");
2164
+ if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
2165
+ });
2166
+ handle.addEventListener("dragend", () => {
2167
+ handle.style.cursor = "grab";
2168
+ row.style.opacity = "1";
2169
+ dragging = null;
2170
+ });
2171
+ row.addEventListener("dragover", (e) => {
2172
+ if (!dragging || dragging === rec) return;
2173
+ e.preventDefault();
2174
+ const rect = row.getBoundingClientRect();
2175
+ const after = e.clientY > rect.top + rect.height / 2;
2176
+ const from = rows.indexOf(dragging);
2177
+ let to = rows.indexOf(rec);
2178
+ if (from < 0 || to < 0) return;
2179
+ if (after) to += 1;
2180
+ if (from < to) to -= 1;
2181
+ if (from === to) return;
2182
+ rows.splice(from, 1);
2183
+ rows.splice(to, 0, dragging);
2184
+ rowsWrap.insertBefore(dragging.el, after ? row.nextSibling : row);
2185
+ });
2186
+ return rec;
2187
+ }
2188
+ function addRow(initialRow) {
2189
+ const rec = makeRow(initialRow);
2190
+ rows.push(rec);
2191
+ rowsWrap.appendChild(rec.el);
2192
+ }
2193
+ const initialRows = (() => {
2194
+ const parsed = portableTextSubsetSchema.safeParse(initial);
2195
+ if (parsed.success && parsed.data.length > 0) return portableTextToRows(parsed.data);
2196
+ return [{ text: "", style: "normal" }];
2197
+ })();
2198
+ for (const r of initialRows) addRow(r);
2199
+ const addBtn = document.createElement("button");
2200
+ addBtn.type = "button";
2201
+ addBtn.textContent = "+ Add block";
2202
+ addBtn.style.cssText = `
2203
+ appearance: none; cursor: pointer; align-self: flex-start;
2204
+ background: rgba(255,255,255,0.05); color: rgba(255,255,255,0.8);
2205
+ border: 1px solid rgba(255,255,255,0.08);
2206
+ font-size: 11px; font-weight: 500;
2207
+ padding: 6px 11px; border-radius: 7px;
2208
+ transition: background 0.15s;
2209
+ `;
2210
+ addBtn.addEventListener("mouseenter", () => {
2211
+ addBtn.style.background = "rgba(255,255,255,0.1)";
2212
+ });
2213
+ addBtn.addEventListener("mouseleave", () => {
2214
+ addBtn.style.background = "rgba(255,255,255,0.05)";
2215
+ });
2216
+ addBtn.addEventListener("click", () => addRow({ text: "", style: "normal" }));
2217
+ container.appendChild(addBtn);
2218
+ const serialise = () => {
2219
+ const editorRows = rows.map((r) => r.read()).filter((r) => r.text.trim() !== "");
2220
+ return rowsToPortableText(editorRows);
2221
+ };
2222
+ return {
2223
+ control: container,
2224
+ getValue: () => serialise(),
2225
+ validate: () => {
2226
+ setOwnError(null);
2227
+ const value = serialise();
2228
+ const parsed = portableTextSubsetSchema.safeParse(value);
2229
+ if (!parsed.success) {
2230
+ setOwnError("This rich-text content is not valid. Check links and formatting.");
2231
+ return { value, ok: false };
2232
+ }
2233
+ if (field.required && value.length === 0) {
2234
+ setOwnError(`${field.label || "This field"} is required`);
2235
+ return { value, ok: false };
2236
+ }
2237
+ return { value: parsed.data, ok: true };
2238
+ }
2239
+ };
2240
+ }
2241
+ function defaultForField(field) {
2242
+ if (field.widget === "array" || field.widget === "richtext") return [];
2243
+ if (field.widget === "object") {
2244
+ const out = {};
2245
+ for (const sub of field.fields ?? []) {
2246
+ const d = defaultForField(sub);
2247
+ if (d !== void 0) out[sub.name] = d;
2248
+ }
2249
+ return out;
2250
+ }
2251
+ if (field.widget === "checkbox") return false;
2252
+ return "";
1587
2253
  }
1588
2254
  function preValidate(fieldStates) {
1589
2255
  const data = {};
1590
2256
  let ok = true;
1591
2257
  for (const f of fieldStates) {
1592
- const value = f.getValue();
1593
- f.setError(null);
1594
- if (f.field.required && (value === void 0 || value === "" || value === null)) {
1595
- f.setError(`${f.field.label} is required`);
2258
+ const { value, ok: fieldOk } = f.validate();
2259
+ if (!fieldOk) {
1596
2260
  ok = false;
1597
2261
  continue;
1598
2262
  }
1599
- if (typeof value === "string") {
1600
- if (f.field.minLength !== void 0 && value.length < f.field.minLength) {
1601
- f.setError(`Must be at least ${f.field.minLength} character${f.field.minLength === 1 ? "" : "s"}`);
1602
- ok = false;
1603
- continue;
2263
+ if (f.field.widget === "array" || f.field.widget === "object" || f.field.widget === "richtext") {
2264
+ data[f.field.name] = value;
2265
+ } else if (value !== void 0 && value !== "") {
2266
+ data[f.field.name] = value;
2267
+ }
2268
+ }
2269
+ return { data, ok };
2270
+ }
2271
+ function validateValue(field, value) {
2272
+ if (value === void 0 || value === null || value === "") return null;
2273
+ if (typeof value === "string") {
2274
+ if (field.minLength !== void 0 && value.length < field.minLength) {
2275
+ return `Must be at least ${field.minLength} character${field.minLength === 1 ? "" : "s"}`;
2276
+ }
2277
+ if (field.maxLength !== void 0 && value.length > field.maxLength) {
2278
+ return `Must be at most ${field.maxLength} characters`;
2279
+ }
2280
+ if (field.pattern !== void 0) {
2281
+ let re = null;
2282
+ try {
2283
+ re = new RegExp(field.pattern);
2284
+ } catch {
2285
+ re = null;
1604
2286
  }
1605
- if (f.field.maxLength !== void 0 && value.length > f.field.maxLength) {
1606
- f.setError(`Must be at most ${f.field.maxLength} characters`);
1607
- ok = false;
1608
- continue;
2287
+ if (re && !re.test(value)) {
2288
+ return "Invalid format";
1609
2289
  }
1610
2290
  }
1611
- if (value !== void 0 && value !== "") {
1612
- data[f.field.name] = value;
2291
+ if (field.options && field.options.length > 0 && !field.options.includes(value)) {
2292
+ return "Choose one of the allowed options";
2293
+ }
2294
+ if (field.widget === "email" && !isLikelyEmail(value)) {
2295
+ return "Enter a valid email address";
2296
+ }
2297
+ if ((field.widget === "url" || field.widget === "image") && !isLikelyUrl(value)) {
2298
+ return "Enter a valid URL";
1613
2299
  }
1614
2300
  }
1615
- return { data, ok };
2301
+ if (typeof value === "number") {
2302
+ if (field.min !== void 0 && value < field.min) {
2303
+ return `Must be at least ${field.min}`;
2304
+ }
2305
+ if (field.max !== void 0 && value > field.max) {
2306
+ return `Must be at most ${field.max}`;
2307
+ }
2308
+ }
2309
+ return null;
2310
+ }
2311
+ function isLikelyEmail(s) {
2312
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s);
2313
+ }
2314
+ function isLikelyUrl(s) {
2315
+ try {
2316
+ new URL(s);
2317
+ return true;
2318
+ } catch {
2319
+ return false;
2320
+ }
2321
+ }
2322
+ function wireSlugAutoFill(schema, fieldStates, sourceEntry) {
2323
+ const byName = new Map(fieldStates.map((f) => [f.field.name, f]));
2324
+ for (const slug of fieldStates) {
2325
+ if (slug.field.widget !== "slug") continue;
2326
+ const sourceName = slug.field.source;
2327
+ if (!sourceName) continue;
2328
+ const src = byName.get(sourceName);
2329
+ if (!src || !src.onInput || !slug.setValue) continue;
2330
+ const existing = sourceEntry?.data[slug.field.name];
2331
+ let dirty = typeof existing === "string" && existing.trim().length > 0;
2332
+ slug.onInput?.(() => {
2333
+ dirty = true;
2334
+ });
2335
+ src.onInput(() => {
2336
+ if (dirty) return;
2337
+ const sv = src.getValue();
2338
+ slug.setValue?.(typeof sv === "string" ? slugify(sv) : "");
2339
+ });
2340
+ }
1616
2341
  }
1617
2342
  function openEntryModal(opts) {
1618
2343
  closeEntryModal();
@@ -1741,6 +2466,7 @@ function openEntryModal(opts) {
1741
2466
  body.appendChild(wrapper);
1742
2467
  fieldStates.push(fieldState);
1743
2468
  }
2469
+ wireSlugAutoFill(opts.schema, fieldStates, sourceForInitial);
1744
2470
  const footer = document.createElement("div");
1745
2471
  footer.style.cssText = `
1746
2472
  display: flex; align-items: center; justify-content: space-between;