pandorga 1.3.11 → 1.4.2

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.
@@ -17,8 +17,6 @@ import {
17
17
  loadPageSizePreference,
18
18
  savePageSizePreference,
19
19
  formatEntryListMeta,
20
- itemTags,
21
- collectDistinctTags,
22
20
  } from "./lib/list-controls.js";
23
21
  import {
24
22
  yamlEditKind,
@@ -212,8 +210,6 @@ const state = {
212
210
  listPageSizeByCollection: Object.create(null),
213
211
  /** Per-collection current page (1-based; session only). */
214
212
  listPageByCollection: Object.create(null),
215
- /** Per-collection tag filter (session only; writing lists). */
216
- listTagByCollection: Object.create(null),
217
213
  /** In-session nav entry counts keyed by collection name. */
218
214
  navCountByCollection: Object.create(null),
219
215
  /** Session tree cache: collection name → { items }. Avoids re-fetch on revisit. */
@@ -297,6 +293,25 @@ function navGroupDomId(group) {
297
293
  );
298
294
  }
299
295
 
296
+ /** Expand/collapse a left-nav group without rebuilding the shell (keeps scroll). */
297
+ function toggleNavGroup(group) {
298
+ const open = !state.navOpenGroups.has(group);
299
+ if (open) state.navOpenGroups.add(group);
300
+ else state.navOpenGroups.delete(group);
301
+
302
+ const items = document.getElementById(navGroupDomId(group));
303
+ if (!items) return;
304
+ items.hidden = !open;
305
+ const toggle = items
306
+ .closest(".studio-nav-group")
307
+ ?.querySelector(".studio-nav-group-toggle");
308
+ if (toggle) {
309
+ toggle.setAttribute("aria-expanded", open ? "true" : "false");
310
+ const icon = toggle.querySelector(".material-symbols-outlined");
311
+ if (icon) icon.textContent = open ? "expand_more" : "chevron_right";
312
+ }
313
+ }
314
+
300
315
  function collectionHint(col) {
301
316
  const cleaned = sanitizeUiCopy(col.description);
302
317
  if (cleaned) return cleaned;
@@ -532,19 +547,6 @@ function resetListPage() {
532
547
  state.listPageByCollection[name] = 1;
533
548
  }
534
549
 
535
- function currentListTag() {
536
- const name = state.collection?.name;
537
- if (!name) return "";
538
- return state.listTagByCollection[name] || "";
539
- }
540
-
541
- function setListTag(tag) {
542
- const name = state.collection?.name;
543
- if (!name) return;
544
- state.listTagByCollection[name] = String(tag || "");
545
- resetListPage();
546
- }
547
-
548
550
  function rememberNavCount(col, count) {
549
551
  if (!col?.name) return;
550
552
  state.navCountByCollection[col.name] = Math.max(0, Number(count) || 0);
@@ -792,7 +794,7 @@ function renderThemeMenu() {
792
794
  ));
793
795
 
794
796
  const btn = el("button", {
795
- className: "btn btn-icon",
797
+ className: "btn btn-tool",
796
798
  type: "button",
797
799
  title: "Theme",
798
800
  "aria-label": "Theme",
@@ -812,54 +814,168 @@ function renderThemeMenu() {
812
814
  text: THEME_MENU_ICONS[themeChoice()] || THEME_MENU_ICONS.system,
813
815
  "aria-hidden": "true",
814
816
  }),
817
+ el("span", { className: "btn-label", text: "Theme" }),
815
818
  ]);
816
819
 
817
820
  return el("div", { className: "studio-theme-menu" }, [btn, menu]);
818
821
  }
819
822
 
820
- function renderStudioToolbar() {
821
- const saveLabel = state.saving ? "Saving…" : state.dirty ? "Save draft" : "Saved";
822
- const saveIcon = state.saving ? "progress_activity" : state.dirty ? "save" : "check";
823
- return el("div", { className: "studio-toolbar studio-icon-bar" }, [
823
+ function isLocalDevHost() {
824
+ const host = String(window.location.hostname || "");
825
+ return host === "localhost" || host === "127.0.0.1" || host === "[::1]";
826
+ }
827
+
828
+ function collectionAllowsCreate(col = state.collection) {
829
+ if (!col || col.type !== "collection") return false;
830
+ return col.operations?.create !== false;
831
+ }
832
+
833
+ function collectionAllowsDelete(col = state.collection) {
834
+ if (!col) return false;
835
+ return col.operations?.delete === true;
836
+ }
837
+
838
+ function sanitizeEntryKey(raw) {
839
+ return String(raw || "")
840
+ .trim()
841
+ .toLowerCase()
842
+ .replace(/[^a-z0-9-]+/g, "-")
843
+ .replace(/^-+|-+$/g, "")
844
+ .slice(0, 80);
845
+ }
846
+
847
+ function expandFilenamePattern(pattern, key) {
848
+ const now = new Date();
849
+ const y = String(now.getFullYear());
850
+ const m = String(now.getMonth() + 1).padStart(2, "0");
851
+ const d = String(now.getDate()).padStart(2, "0");
852
+ return String(pattern || "{key}.md")
853
+ .replaceAll("{year}", y)
854
+ .replaceAll("{month}", m)
855
+ .replaceAll("{day}", d)
856
+ .replaceAll("{key}", key);
857
+ }
858
+
859
+ function todayIsoDate() {
860
+ const now = new Date();
861
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
862
+ }
863
+
864
+ function toolbarActionButton({
865
+ className,
866
+ title,
867
+ ariaLabel,
868
+ action = null,
869
+ disabled = false,
870
+ onClick,
871
+ icon,
872
+ label = null,
873
+ spinning = false,
874
+ }) {
875
+ const kids = [
824
876
  el("span", {
825
- className: "status-pill" + (state.error ? " error" : ""),
826
- text: state.error
827
- ? "Error"
828
- : state.status || (state.session?.email || state.session?.userId || ""),
829
- title: state.error || state.errorDetail || state.status || "",
877
+ className:
878
+ "material-symbols-outlined" + (spinning ? " studio-icon-spin" : ""),
879
+ text: icon,
880
+ "aria-hidden": "true",
830
881
  }),
831
- el("span", { className: "spacer" }),
832
- renderThemeMenu(),
833
- el("button", {
834
- className: "btn btn-icon",
882
+ ];
883
+ if (label) {
884
+ kids.push(el("span", { className: "btn-label", text: label }));
885
+ }
886
+ return el(
887
+ "button",
888
+ {
889
+ className,
835
890
  type: "button",
891
+ title,
892
+ "aria-label": ariaLabel,
893
+ "data-studio-action": action,
894
+ disabled,
895
+ onClick,
896
+ },
897
+ kids
898
+ );
899
+ }
900
+
901
+ function renderStudioToolbar({ brand = null, menuToggle = null } = {}) {
902
+ const saveLabel = state.saving ? "Saving…" : state.dirty ? "Save" : "Saved";
903
+ const saveIcon = state.saving ? "progress_activity" : state.dirty ? "save" : "check";
904
+ const statusText = state.error ? "Error" : state.status || "";
905
+
906
+ const brandCluster = el("div", { className: "studio-toolbar-brand" }, [
907
+ brand,
908
+ statusText
909
+ ? el("span", {
910
+ className: "status-pill studio-toolbar-status" + (state.error ? " error" : ""),
911
+ text: statusText,
912
+ title: state.error || state.errorDetail || state.status || "",
913
+ })
914
+ : null,
915
+ ]);
916
+
917
+ const actions = [
918
+ renderThemeMenu(),
919
+ toolbarActionButton({
920
+ className: "btn btn-tool",
836
921
  title: "Revert all edits to the last loaded or saved version",
837
- "aria-label": "Discard edits",
838
- "data-studio-action": "discard",
922
+ ariaLabel: "Discard edits",
923
+ action: "discard",
839
924
  disabled: !state.file || !state.dirty || !state.baseline,
840
925
  onClick: () => discardChanges(),
841
- }, [materialIcon("undo")]),
842
- el("button", {
843
- className: "btn btn-icon btn-primary",
844
- type: "button",
926
+ icon: "undo",
927
+ label: "Revert",
928
+ }),
929
+ toolbarActionButton({
930
+ className: "btn btn-tool btn-primary",
845
931
  title: state.saving
846
932
  ? "Saving this draft"
847
933
  : state.dirty
848
934
  ? "Commit this draft"
849
935
  : "No edits to commit yet",
850
- "aria-label": saveLabel,
851
- "data-studio-action": "save",
936
+ ariaLabel: saveLabel,
937
+ action: "save",
852
938
  disabled: state.saving || !state.file || !state.dirty,
853
939
  onClick: () => saveCurrent(),
854
- }, [
855
- el("span", {
856
- className:
857
- "material-symbols-outlined" + (state.saving ? " studio-icon-spin" : ""),
858
- text: saveIcon,
859
- "aria-hidden": "true",
860
- }),
861
- ]),
862
- ]);
940
+ icon: saveIcon,
941
+ label: saveLabel,
942
+ spinning: state.saving,
943
+ }),
944
+ ];
945
+ if (state.file && collectionAllowsDelete()) {
946
+ actions.push(
947
+ toolbarActionButton({
948
+ className: "btn btn-tool btn-danger",
949
+ title: "Delete this entry from the repository",
950
+ ariaLabel: "Delete entry",
951
+ action: "delete",
952
+ onClick: () => deleteCurrentEntry(),
953
+ icon: "delete",
954
+ label: "Delete",
955
+ })
956
+ );
957
+ }
958
+ if (isLocalDevHost()) {
959
+ actions.push(
960
+ toolbarActionButton({
961
+ className: "btn btn-tool btn-reexport",
962
+ title: "Export content JSON locally and reload",
963
+ ariaLabel: "Export locally",
964
+ action: "reexport",
965
+ onClick: () => reexportLocalContent(),
966
+ icon: "sync",
967
+ label: "Export",
968
+ })
969
+ );
970
+ }
971
+
972
+ const kids = [
973
+ brandCluster,
974
+ el("span", { className: "spacer", "aria-hidden": "true" }),
975
+ ...actions,
976
+ ];
977
+ if (menuToggle) kids.push(menuToggle);
978
+ return el("header", { className: "studio-toolbar studio-icon-bar" }, kids);
863
979
  }
864
980
 
865
981
  function clerkUserEmail(clerk) {
@@ -985,11 +1101,11 @@ function renderPendingReviewGate(clerk, emailHint) {
985
1101
  app.append(box);
986
1102
  }
987
1103
 
988
- /** Left-nav / map section order (References before Portfolio). */
1104
+ /** Left-nav / map section order. */
989
1105
  const NAV_GROUP_ORDER = [
990
1106
  "Writing",
991
- "References",
992
1107
  "Portfolio",
1108
+ "References",
993
1109
  "Pages",
994
1110
  "CV Summary",
995
1111
  "Presentation",
@@ -1040,9 +1156,7 @@ function renderMediaNavGroup() {
1040
1156
  "aria-expanded": open ? "true" : "false",
1041
1157
  "aria-controls": items.id,
1042
1158
  onClick: () => {
1043
- if (state.navOpenGroups.has(group)) state.navOpenGroups.delete(group);
1044
- else state.navOpenGroups.add(group);
1045
- renderShell();
1159
+ toggleNavGroup(group);
1046
1160
  },
1047
1161
  }, [
1048
1162
  el("span", {
@@ -1102,13 +1216,13 @@ function renderShell() {
1102
1216
  const menuOpen = !!state.navMenuOpen;
1103
1217
 
1104
1218
  const brand = el("button", {
1105
- className: "studio-brand studio-brand-home",
1219
+ className: "studio-brand studio-brand-home studio-brand-toolbar",
1106
1220
  type: "button",
1107
1221
  title: "Studio home",
1108
1222
  "aria-current": !state.collection && !state.mediaOpen ? "page" : null,
1109
1223
  onClick: () => goHome(),
1110
1224
  }, [
1111
- studioMark({ size: 40 }),
1225
+ studioMark({ size: 22 }),
1112
1226
  el("span", { className: "studio-brand-copy" }, [
1113
1227
  el("span", { className: "studio-brand-name", text: "Studio" }),
1114
1228
  ]),
@@ -1144,6 +1258,8 @@ function renderShell() {
1144
1258
  id: "studio-nav-panel",
1145
1259
  });
1146
1260
 
1261
+ panel.append(renderMediaNavGroup());
1262
+
1147
1263
  for (const [group, cols] of groups) {
1148
1264
  const open = state.navOpenGroups.has(group);
1149
1265
  const items = el("div", {
@@ -1186,9 +1302,7 @@ function renderShell() {
1186
1302
  "aria-expanded": open ? "true" : "false",
1187
1303
  "aria-controls": items.id,
1188
1304
  onClick: () => {
1189
- if (state.navOpenGroups.has(group)) state.navOpenGroups.delete(group);
1190
- else state.navOpenGroups.add(group);
1191
- renderShell();
1305
+ toggleNavGroup(group);
1192
1306
  },
1193
1307
  }, [
1194
1308
  el("span", {
@@ -1203,14 +1317,6 @@ function renderShell() {
1203
1317
  ]);
1204
1318
 
1205
1319
  panel.append(el("div", { className: "studio-nav-group" }, [toggle, items]));
1206
-
1207
- if (group === "Portfolio" || (group === "References" && !groups.has("Portfolio"))) {
1208
- panel.append(renderMediaNavGroup());
1209
- }
1210
- }
1211
-
1212
- if (!groups.has("Portfolio") && !groups.has("References")) {
1213
- panel.append(renderMediaNavGroup());
1214
1320
  }
1215
1321
 
1216
1322
  const nav = el(
@@ -1219,16 +1325,12 @@ function renderShell() {
1219
1325
  className: "studio-nav" + (menuOpen ? " is-open" : ""),
1220
1326
  "aria-label": "Collections",
1221
1327
  },
1222
- [
1223
- el("div", { className: "studio-nav-bar" }, [brand, menuToggle]),
1224
- panel,
1225
- ]
1328
+ [panel]
1226
1329
  );
1227
1330
 
1228
- const toolbar = renderStudioToolbar();
1331
+ const toolbar = renderStudioToolbar({ brand, menuToggle });
1229
1332
 
1230
1333
  const main = el("div", { className: "studio-main" }, [
1231
- toolbar,
1232
1334
  renderErrorBanner(),
1233
1335
  renderStudioBreadcrumb(),
1234
1336
  el("div", {
@@ -1237,7 +1339,7 @@ function renderShell() {
1237
1339
  }),
1238
1340
  ]);
1239
1341
 
1240
- app.append(el("div", { className: "studio-shell" }, [nav, main]));
1342
+ app.append(el("div", { className: "studio-shell" }, [toolbar, nav, main]));
1241
1343
  renderMain();
1242
1344
  syncThemeMenuUi();
1243
1345
  if (!window.__studioMenuDismissBound) {
@@ -1565,7 +1667,7 @@ function syncCommitButtons() {
1565
1667
  const save = document.querySelector("[data-studio-action='save']");
1566
1668
  const discard = document.querySelector("[data-studio-action='discard']");
1567
1669
  if (save) {
1568
- const saveLabel = state.saving ? "Saving…" : state.dirty ? "Save draft" : "Saved";
1670
+ const saveLabel = state.saving ? "Saving…" : state.dirty ? "Save" : "Saved";
1569
1671
  save.disabled = state.saving || !state.file || !state.dirty;
1570
1672
  save.title = state.saving
1571
1673
  ? "Saving this draft"
@@ -1582,6 +1684,8 @@ function syncCommitButtons() {
1582
1684
  : "check";
1583
1685
  icon.classList.toggle("studio-icon-spin", state.saving);
1584
1686
  }
1687
+ const label = save.querySelector(".btn-label");
1688
+ if (label) label.textContent = saveLabel;
1585
1689
  }
1586
1690
  if (discard) {
1587
1691
  discard.disabled = !state.file || !state.dirty || !state.baseline;
@@ -1948,7 +2052,7 @@ function renderYamlEditorPane() {
1948
2052
 
1949
2053
  function renderStudioMap() {
1950
2054
  const groups = collectionGroups(state.schema);
1951
- const sections = [];
2055
+ const sections = [renderMediaMapSection()];
1952
2056
 
1953
2057
  for (const [group, cols] of groups) {
1954
2058
  const cards = cols.map((col) =>
@@ -1984,23 +2088,8 @@ function renderStudioMap() {
1984
2088
  el("div", { className: "studio-map-grid" }, cards),
1985
2089
  ])
1986
2090
  );
1987
-
1988
- // Insert Media section after Portfolio (or after References if no Portfolio).
1989
- if (group === "Portfolio" || (group === "References" && !groups.has("Portfolio"))) {
1990
- sections.push(renderMediaMapSection());
1991
- }
1992
- }
1993
-
1994
- if (!groups.has("Portfolio") && !groups.has("References")) {
1995
- sections.push(renderMediaMapSection());
1996
2091
  }
1997
2092
 
1998
- // Split groups across two columns so wide screens read as a ledger board,
1999
- // not a single skinny stack in empty space.
2000
- const splitAt = Math.ceil(sections.length / 2);
2001
- const colA = sections.slice(0, splitAt);
2002
- const colB = sections.slice(splitAt);
2003
-
2004
2093
  return el("div", { className: "pane studio-map" }, [
2005
2094
  el("span", {
2006
2095
  className: "studio-shard studio-shard--lead",
@@ -2010,20 +2099,15 @@ function renderStudioMap() {
2010
2099
  className: "studio-shard studio-shard--beta studio-shard--trail",
2011
2100
  "aria-hidden": "true",
2012
2101
  }),
2013
- el("header", { className: "studio-map-header" }, [
2014
- el("p", { className: "studio-map-kicker", text: "Studio" }),
2015
- el("h1", { text: "Studio map" }),
2016
- el("p", {
2017
- className: "studio-map-lede",
2018
- text: "Pick a collection, page, or the media library. Groups match the left navigation.",
2019
- }),
2020
- ]),
2021
- el("div", { className: "studio-map-board", role: "navigation", "aria-label": "Collections map" }, [
2022
- el("div", { className: "studio-map-col" }, colA),
2023
- colB.length
2024
- ? el("div", { className: "studio-map-col" }, colB)
2025
- : null,
2026
- ]),
2102
+ el(
2103
+ "div",
2104
+ {
2105
+ className: "studio-map-board",
2106
+ role: "navigation",
2107
+ "aria-label": "Collections map",
2108
+ },
2109
+ sections
2110
+ ),
2027
2111
  ]);
2028
2112
  }
2029
2113
 
@@ -2057,12 +2141,10 @@ function renderListPane() {
2057
2141
  const query = currentListQuery();
2058
2142
  const sortId = currentListSortId();
2059
2143
  const pageSizeId = currentListPageSizeId();
2060
- const tagFilter = currentListTag();
2061
2144
  const writing = isWritingCollection();
2062
2145
  const filtered = applyListControls(state.items, {
2063
2146
  query,
2064
2147
  sortId,
2065
- tag: writing ? tagFilter : "",
2066
2148
  });
2067
2149
  const pageInfo = paginateItems(filtered, {
2068
2150
  pageSizeId,
@@ -2179,8 +2261,6 @@ function renderListPane() {
2179
2261
  ]),
2180
2262
  ]);
2181
2263
 
2182
- const tagChips = writing ? renderTagFilterChips(state.items, tagFilter) : null;
2183
-
2184
2264
  let emptyNode = null;
2185
2265
  if (!filtered.length) {
2186
2266
  if (state.error) {
@@ -2205,9 +2285,7 @@ function renderListPane() {
2205
2285
  } else {
2206
2286
  emptyNode = el("p", {
2207
2287
  className: "list-empty",
2208
- text: tagFilter
2209
- ? "No entries match this tag filter."
2210
- : "No entries match this search.",
2288
+ text: "No entries match this search.",
2211
2289
  });
2212
2290
  }
2213
2291
  }
@@ -2250,7 +2328,7 @@ function renderListPane() {
2250
2328
  // Title already shows total entry count — list-count only when range/filter adds info.
2251
2329
  let countText = "";
2252
2330
  if (state.items.length > 0) {
2253
- const filteredNote = query || tagFilter;
2331
+ const filteredNote = query;
2254
2332
  if (!filtered.length) {
2255
2333
  countText = filteredNote ? `0 of ${state.items.length}` : "";
2256
2334
  } else if (pageInfo.pageSize == null) {
@@ -2269,7 +2347,6 @@ function renderListPane() {
2269
2347
  ? el("div", { className: "ledger-columns", "aria-hidden": "true" }, [
2270
2348
  el("span", { className: "ledger-col-thumb" }),
2271
2349
  el("span", { className: "ledger-col-manuscript", text: "Manuscript" }),
2272
- el("span", { className: "ledger-col-taxonomy", text: "Taxonomy" }),
2273
2350
  el("span", { className: "ledger-col-ops", text: "Operations" }),
2274
2351
  ])
2275
2352
  : null;
@@ -2277,7 +2354,6 @@ function renderListPane() {
2277
2354
  return el("div", { className: "pane" }, [
2278
2355
  header,
2279
2356
  controls,
2280
- tagChips,
2281
2357
  countText
2282
2358
  ? el("p", { className: "list-count", text: countText })
2283
2359
  : null,
@@ -2287,48 +2363,6 @@ function renderListPane() {
2287
2363
  ]);
2288
2364
  }
2289
2365
 
2290
- function renderTagFilterChips(items, activeTag) {
2291
- const tags = collectDistinctTags(items);
2292
- if (!tags.length) return null;
2293
- const chips = [
2294
- el("button", {
2295
- className:
2296
- "list-tag-chip" + (!activeTag ? " is-active" : ""),
2297
- type: "button",
2298
- text: `All (${items.length})`,
2299
- "aria-pressed": !activeTag ? "true" : "false",
2300
- onClick: () => {
2301
- setListTag("");
2302
- renderMain();
2303
- },
2304
- }),
2305
- ];
2306
- for (const tag of tags) {
2307
- const count = items.filter((item) =>
2308
- itemTags(item).some((t) => t.toLowerCase() === tag.toLowerCase())
2309
- ).length;
2310
- if (!count) continue;
2311
- const active = activeTag.toLowerCase() === tag.toLowerCase();
2312
- chips.push(
2313
- el("button", {
2314
- className: "list-tag-chip" + (active ? " is-active" : ""),
2315
- type: "button",
2316
- text: `${tag} (${count})`,
2317
- "aria-pressed": active ? "true" : "false",
2318
- onClick: () => {
2319
- setListTag(active ? "" : tag);
2320
- renderMain();
2321
- },
2322
- })
2323
- );
2324
- }
2325
- return el("div", {
2326
- className: "list-tag-filters",
2327
- role: "group",
2328
- "aria-label": "Filter by tag",
2329
- }, chips);
2330
- }
2331
-
2332
2366
  function languageChip(itemOrFields) {
2333
2367
  const lang = String(itemOrFields?.language || "").trim();
2334
2368
  if (!lang) return null;
@@ -2339,21 +2373,49 @@ function languageChip(itemOrFields) {
2339
2373
  });
2340
2374
  }
2341
2375
 
2342
- function taxonomyChips(item) {
2343
- const tags = itemTags(item);
2344
- if (!tags.length) return null;
2345
- return el(
2346
- "div",
2347
- { className: "ledger-taxonomy" },
2348
- tags.map((tag) =>
2349
- el("span", { className: "ledger-tag", text: tag })
2350
- )
2376
+ function renderLedgerOps(item, { open }) {
2377
+ const canDelete = collectionAllowsDelete();
2378
+ const label = item.title || item.name || "entry";
2379
+ const editBtn = el(
2380
+ "button",
2381
+ {
2382
+ className: "btn btn-icon ledger-op-btn ledger-edit-btn",
2383
+ type: "button",
2384
+ title: `Edit ${label}`,
2385
+ "aria-label": `Edit ${label}`,
2386
+ onClick: (e) => {
2387
+ e.stopPropagation();
2388
+ open();
2389
+ },
2390
+ },
2391
+ [materialIcon("edit")]
2351
2392
  );
2393
+ const deleteBtn = canDelete
2394
+ ? el(
2395
+ "button",
2396
+ {
2397
+ className: "btn btn-icon ledger-op-btn ledger-delete-btn",
2398
+ type: "button",
2399
+ title: `Delete ${label}`,
2400
+ "aria-label": `Delete ${label}`,
2401
+ onClick: (e) => {
2402
+ e.stopPropagation();
2403
+ deleteEntry(item.path, item.sha);
2404
+ },
2405
+ },
2406
+ [materialIcon("delete")]
2407
+ )
2408
+ : null;
2409
+ return el("div", { className: "ledger-ops", role: "group", "aria-label": "Operations" }, [
2410
+ editBtn,
2411
+ deleteBtn,
2412
+ ]);
2352
2413
  }
2353
2414
 
2354
2415
  function renderLedgerRow(item, writing) {
2355
2416
  const metaText = formatEntryListMeta(item, state.collection);
2356
2417
  const open = () => openFile(item.path);
2418
+ const ops = renderLedgerOps(item, { open });
2357
2419
 
2358
2420
  if (!writing) {
2359
2421
  const titleBlock = el("div", { className: "ledger-copy" }, [
@@ -2366,14 +2428,21 @@ function renderLedgerRow(item, writing) {
2366
2428
  : null,
2367
2429
  ]);
2368
2430
  return el(
2369
- "button",
2431
+ "div",
2370
2432
  {
2371
- className: "ledger-row",
2372
- type: "button",
2433
+ className: "ledger-row ledger-row--plain",
2434
+ role: "button",
2435
+ tabindex: "0",
2373
2436
  title: entryHint(item),
2374
2437
  onClick: open,
2438
+ onKeydown: (e) => {
2439
+ if (e.key === "Enter" || e.key === " ") {
2440
+ e.preventDefault();
2441
+ open();
2442
+ }
2443
+ },
2375
2444
  },
2376
- [thumbnailNode(item), titleBlock]
2445
+ [thumbnailNode(item), titleBlock, ops]
2377
2446
  );
2378
2447
  }
2379
2448
 
@@ -2392,13 +2461,6 @@ function renderLedgerRow(item, writing) {
2392
2461
  : null,
2393
2462
  ]);
2394
2463
 
2395
- const taxonomy = taxonomyChips(item) || el("div", { className: "ledger-taxonomy" });
2396
-
2397
- const ops = el("div", { className: "ledger-ops", "aria-hidden": "true" }, [
2398
- el("span", { className: "ledger-ops-edit", text: "Edit" }),
2399
- materialIcon("chevron_right", "ledger-ops-icon"),
2400
- ]);
2401
-
2402
2464
  return el(
2403
2465
  "div",
2404
2466
  {
@@ -2414,7 +2476,7 @@ function renderLedgerRow(item, writing) {
2414
2476
  }
2415
2477
  },
2416
2478
  },
2417
- [thumbnailNode(item), manuscript, taxonomy, ops]
2479
+ [thumbnailNode(item), manuscript, ops]
2418
2480
  );
2419
2481
  }
2420
2482
 
@@ -2472,6 +2534,21 @@ function renderListHeader(collection, { entryCount = null } = {}) {
2472
2534
  })
2473
2535
  );
2474
2536
  }
2537
+ if (collectionAllowsCreate(collection)) {
2538
+ titleRow.push(
2539
+ el(
2540
+ "button",
2541
+ {
2542
+ className: "btn btn-tool btn-primary list-new-btn",
2543
+ type: "button",
2544
+ title: `Add a new ${label} entry`,
2545
+ "aria-label": `New ${label} entry`,
2546
+ onClick: () => createNewEntry(),
2547
+ },
2548
+ [materialIcon("add"), el("span", { className: "btn-label", text: "New" })]
2549
+ )
2550
+ );
2551
+ }
2475
2552
  return el("header", { className: "list-register" }, [
2476
2553
  el("p", { className: "list-register-kicker" }, [
2477
2554
  el("span", { className: "list-register-mark", "aria-hidden": "true" }),
@@ -3704,6 +3781,158 @@ function discardChanges() {
3704
3781
  renderShell();
3705
3782
  }
3706
3783
 
3784
+ async function reexportLocalContent() {
3785
+ state.status = "Reexporting…";
3786
+ clearError();
3787
+ renderShell();
3788
+ try {
3789
+ const res = await fetch("/__dev/reexport", {
3790
+ method: "POST",
3791
+ headers: { Accept: "application/json" },
3792
+ });
3793
+ let data = {};
3794
+ try {
3795
+ data = await res.json();
3796
+ } catch {
3797
+ data = {};
3798
+ }
3799
+ if (!res.ok || !data.ok) {
3800
+ throw new Error((data && data.error) || `Export failed: ${res.status}`);
3801
+ }
3802
+ state.status = "Reexported — reloading…";
3803
+ renderShell();
3804
+ setTimeout(() => {
3805
+ try {
3806
+ const url = new URL(window.location.href);
3807
+ url.searchParams.set("_refresh", String(Date.now()));
3808
+ window.location.replace(url.toString());
3809
+ } catch {
3810
+ window.location.reload();
3811
+ }
3812
+ }, 200);
3813
+ } catch (err) {
3814
+ captureError(err);
3815
+ renderShell();
3816
+ }
3817
+ }
3818
+
3819
+ async function createNewEntry() {
3820
+ const col = state.collection;
3821
+ if (!collectionAllowsCreate(col)) return;
3822
+ const raw = window.prompt(`New ${col.label || "entry"} key (slug):`);
3823
+ if (raw == null) return;
3824
+ const key = sanitizeEntryKey(raw);
3825
+ if (!key) {
3826
+ state.error = "Key must use letters, numbers, or hyphens.";
3827
+ state.errorDetail = "";
3828
+ renderShell();
3829
+ return;
3830
+ }
3831
+ const filename = expandFilenamePattern(col.filename, key);
3832
+ const path = `${String(col.path || "").replace(/\/$/, "")}/${filename}`;
3833
+
3834
+ const fields = emptyItemFromFields(col.fields || []);
3835
+ for (const field of col.fields || []) {
3836
+ if (
3837
+ field?.hidden &&
3838
+ field.name &&
3839
+ Object.prototype.hasOwnProperty.call(field, "default")
3840
+ ) {
3841
+ fields[field.name] = field.default;
3842
+ }
3843
+ }
3844
+ fields.key = key;
3845
+ if (!fields.title) fields.title = key;
3846
+ if (String(col.filename || "").includes("{year}") && !fields.date) {
3847
+ fields.date = todayIsoDate();
3848
+ }
3849
+
3850
+ let content;
3851
+ if (col.format === "yaml") {
3852
+ content = dumpYaml(pruneEmptyFields({ ...fields }, col.fields));
3853
+ } else {
3854
+ content = rebuildDocument("---\n---\n\n", {
3855
+ fields,
3856
+ body: "\n",
3857
+ format: col.format || "yaml-frontmatter",
3858
+ });
3859
+ }
3860
+ if (!content.endsWith("\n")) content += "\n";
3861
+
3862
+ state.status = `Creating ${path}…`;
3863
+ clearError();
3864
+ renderShell();
3865
+ try {
3866
+ let exists = false;
3867
+ try {
3868
+ await studioApi.getFile(path);
3869
+ exists = true;
3870
+ } catch (err) {
3871
+ if (err.status !== 404 && err.code !== "not_found") throw err;
3872
+ }
3873
+ if (exists) throw new Error(`${path} already exists`);
3874
+
3875
+ await studioApi.putFile({
3876
+ path,
3877
+ content,
3878
+ sha: null,
3879
+ message: `studio: create ${path}`,
3880
+ });
3881
+ invalidateTreeCache(col.name);
3882
+ state.status = `Created ${path}`;
3883
+ await selectCollection(col);
3884
+ await openFile(path);
3885
+ } catch (err) {
3886
+ captureError(err);
3887
+ renderShell();
3888
+ }
3889
+ }
3890
+
3891
+ async function deleteEntry(path, sha = null) {
3892
+ const col = state.collection;
3893
+ if (!collectionAllowsDelete(col) || !path) return;
3894
+ const name = String(path).split("/").pop() || path;
3895
+ if (!window.confirm(`Delete ${name}?\n\nThis commits a deletion to main and cannot be undone from Studio.`)) {
3896
+ return;
3897
+ }
3898
+ state.status = `Deleting ${path}…`;
3899
+ clearError();
3900
+ renderShell();
3901
+ try {
3902
+ let fileSha = sha;
3903
+ if (!fileSha) {
3904
+ const data = await studioApi.getFile(path);
3905
+ fileSha = data.sha;
3906
+ }
3907
+ await studioApi.deleteFile({
3908
+ path,
3909
+ sha: fileSha,
3910
+ message: `studio: delete ${path}`,
3911
+ });
3912
+ invalidateTreeCache(col?.name);
3913
+ if (state.file === path) {
3914
+ destroyBodyEditor();
3915
+ state.file = null;
3916
+ state.baseline = null;
3917
+ state.sha = null;
3918
+ state.dirty = false;
3919
+ state.fields = {};
3920
+ state.body = "";
3921
+ }
3922
+ state.status = `Deleted ${path}`;
3923
+ if (col) await selectCollection(col);
3924
+ else renderShell();
3925
+ } catch (err) {
3926
+ captureError(err);
3927
+ renderShell();
3928
+ }
3929
+ }
3930
+
3931
+ async function deleteCurrentEntry() {
3932
+ if (!state.file) return;
3933
+ await deleteEntry(state.file, state.sha);
3934
+ }
3935
+
3707
3936
  async function saveCurrent() {
3708
3937
  if (!state.file || state.saving) return;
3709
3938
  syncBodyFromEditor();