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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +99 -0
- data/_includes/content-runtime/43-blocks.html +40 -16
- data/_layouts/home.html +5 -1
- data/assets/css/ledger/home.css +45 -1
- data/functions/api/studio/[[path]].js +27 -0
- data/functions/api/studio/_lib/github.js +23 -0
- data/lib/pandorga/version.rb +1 -1
- data/scripts/test/render-parity/blocks/project-inventory-row.json +10 -4
- data/scripts/test/render-parity/run-absent-field-checks.mjs +2 -1
- data/scripts/test/test-studio-slug-config.rb +21 -14
- data/studio/assets/index-BQdNT4zp.css +1 -0
- data/studio/assets/index-pL2vB_T0.js +295 -0
- data/studio/index.html +2 -2
- data/studio/schema.yml +7 -7
- data/studio-app/src/api/client.js +5 -0
- data/studio-app/src/lib/schema.js +1 -1
- data/studio-app/src/main.js +406 -177
- data/studio-app/src/media/browser.js +108 -29
- data/studio-app/src/styles/studio.css +478 -251
- data/templates/articles/studio.yml +1 -1
- data/templates/bibliography/studio.yml +1 -1
- data/templates/blog/studio.yml +1 -1
- data/templates/cv/studio.yml +2 -2
- data/templates/media/studio.yml +1 -1
- data/templates/portfolio/studio.yml +1 -1
- metadata +3 -3
- data/studio/assets/index-B2os0c1O.css +0 -1
- data/studio/assets/index-CaMlZpH1.js +0 -285
data/studio-app/src/main.js
CHANGED
|
@@ -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-
|
|
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
|
|
821
|
-
const
|
|
822
|
-
|
|
823
|
-
|
|
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:
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
|
|
832
|
-
|
|
833
|
-
el("
|
|
834
|
-
|
|
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
|
-
|
|
838
|
-
|
|
922
|
+
ariaLabel: "Discard edits",
|
|
923
|
+
action: "discard",
|
|
839
924
|
disabled: !state.file || !state.dirty || !state.baseline,
|
|
840
925
|
onClick: () => discardChanges(),
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
851
|
-
|
|
936
|
+
ariaLabel: saveLabel,
|
|
937
|
+
action: "save",
|
|
852
938
|
disabled: state.saving || !state.file || !state.dirty,
|
|
853
939
|
onClick: () => saveCurrent(),
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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(
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
}
|
|
2020
|
-
|
|
2021
|
-
|
|
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:
|
|
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
|
|
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
|
|
2343
|
-
const
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
"
|
|
2347
|
-
{
|
|
2348
|
-
|
|
2349
|
-
|
|
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
|
-
"
|
|
2431
|
+
"div",
|
|
2370
2432
|
{
|
|
2371
|
-
className: "ledger-row",
|
|
2372
|
-
|
|
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,
|
|
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();
|