chore(embedded): cut v0.0.27-beta
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Failing after 9s

This commit is contained in:
ZDDC 2026-06-10 14:57:28 -05:00
parent 5f1df08077
commit 8d8094bb9f
7 changed files with 455 additions and 40 deletions

View file

@ -2717,7 +2717,7 @@ td[data-field="trackingNumber"] {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span> <span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 18:57:44 · e2c2d15</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>

View file

@ -2824,7 +2824,7 @@ li.CodeMirror-hint-active {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Browse</span> <span class="app-header__title">ZDDC Browse</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 18:57:44 · e2c2d15</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button>

View file

@ -1672,6 +1672,7 @@ body.is-elevated::after {
.cl-dot--done { background: var(--success); border-color: var(--success); } .cl-dot--done { background: var(--success); border-color: var(--success); }
.cl-dot--excluded { background: var(--text-muted); border-color: var(--text-muted); opacity: 0.6; } .cl-dot--excluded { background: var(--text-muted); border-color: var(--text-muted); opacity: 0.6; }
.file-item.excluded .file-name { text-decoration: line-through; color: var(--text-muted); } .file-item.excluded .file-name { text-decoration: line-through; color: var(--text-muted); }
.folder-item.excluded .folder-name { text-decoration: line-through; color: var(--text-muted); }
/* placed-file row in the target pane is clickable (reveal in source) */ /* placed-file row in the target pane is clickable (reveal in source) */
.tfile { cursor: pointer; } .tfile { cursor: pointer; }
@ -2239,7 +2240,7 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span> <span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 18:57:44 · e2c2d15</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button>
@ -7326,6 +7327,15 @@ X.B(E,Y);return E}return J}())
}); });
notify(); notify();
} }
// Forget any assignment for these source keys (e.g. when a .zip flips
// between single-file and folder mode and the old keys cease to exist).
function dropAssignments(keys) {
var changed = false;
(keys || []).forEach(function (k) {
if (state.assignments[k]) { delete state.assignments[k]; changed = true; }
});
if (changed) notify();
}
function setTitleOverride(key, title) { function setTitleOverride(key, title) {
var a = assignmentFor(key); var a = assignmentFor(key);
a.titleOverride = title && title.trim() ? title.trim() : null; a.titleOverride = title && title.trim() ? title.trim() : null;
@ -7655,12 +7665,18 @@ X.B(E,Y);return E}return J}())
function parseFolderLevels(name) { function parseFolderLevels(name) {
var s = String(name == null ? '' : name).trim(); var s = String(name == null ? '' : name).trim();
if (!s) return []; if (!s) return [];
var segs = s.split('-'); var u = s.indexOf('_'); // the "_" separates the tracking number from the leaf
var last = segs.pop(); if (u < 0) {
var u = last.indexOf('_'); // No "_" → a pure tracking-number path: nest by "-".
if (u >= 0) { segs.push(last.slice(0, u)); segs.push(last.slice(u + 1)); } return s.split('-').map(function (x) { return x.trim(); }).filter(Boolean);
else { segs.push(last); } }
return segs.map(function (x) { return x.trim(); }).filter(Boolean); // Tracking number (before "_") nests by "-"; everything AFTER the "_" is
// ONE leaf, kept whole — the revision may itself contain hyphens, e.g. a
// date revision "2025-11-17 (IFI)".
var segs = s.slice(0, u).split('-').map(function (x) { return x.trim(); }).filter(Boolean);
var leaf = s.slice(u + 1).trim();
if (leaf) segs.push(leaf);
return segs;
} }
// Children array for a tracking node (or the roots for null), or null. // Children array for a tracking node (or the roots for null), or null.
function trackingChildren(parentId) { function trackingChildren(parentId) {
@ -7747,7 +7763,7 @@ X.B(E,Y);return E}return J}())
srcKeyForFile: srcKeyForFile, defaultTitle: defaultTitle, srcKeyForFile: srcKeyForFile, defaultTitle: defaultTitle,
// assignments // assignments
assignmentFor: assignmentFor, getAssignment: getAssignment, assignmentFor: assignmentFor, getAssignment: getAssignment,
place: place, setExcluded: setExcluded, place: place, setExcluded: setExcluded, dropAssignments: dropAssignments,
setTitleOverride: setTitleOverride, setTitleOverride: setTitleOverride,
// trees // trees
addTrackingNode: addTrackingNode, addParty: addParty, addTrackingNode: addTrackingNode, addParty: addParty,
@ -8557,18 +8573,9 @@ X.B(E,Y);return E}return J}())
fo.folderPath = node.path; fo.folderPath = node.path;
files.push(fo); files.push(fo);
if (scanStats) scanStats.files++; if (scanStats) scanStats.files++;
if (fo.extension === 'zip' && typeof JSZip !== 'undefined') { // A .zip is a single file by default (one classifiable unit).
// Don't read the archive during the listing — make an // The user can later "Expand as folder" (expandZipAsFolder) to
// expandable, lazy zip node scanned on open (scanZipNode). // pull its members into the fileset.
const zipName = zddc.joinExtension(fo.originalFilename, fo.extension);
const zipPath = node.path + '/' + zipName;
const zh = { name: zipName, kind: 'directory', isZipRoot: true, zipPath: zipPath };
const zipNode = makeNode(zh, zipPath, node);
zipNode._zipFileObj = fo;
zipNode.scanState = 'zip-pending';
childDirs.push(zipNode);
if (scanStats) scanStats.folders++;
}
} else if (entry.kind === 'directory') { } else if (entry.kind === 'directory') {
const childPath = node.path + '/' + entry.name; const childPath = node.path + '/' + entry.name;
childDirs.push(makeNode(entry, childPath, node)); childDirs.push(makeNode(entry, childPath, node));
@ -9103,6 +9110,76 @@ X.B(E,Y);return E}return J}())
if (!entry) throw new Error('zip member not found: ' + fileObj.zipEntryPath); if (!entry) throw new Error('zip member not found: ' + fileObj.zipEntryPath);
return await entry.async('blob'); return await entry.async('blob');
} }
// ── per-zip mode toggle (single file ⇄ expandable folder) ───────────────
function findNodeByPath(path) {
var hit = null;
(function walk(ns) { (ns || []).forEach(function (n) { if (hit) return; if (n.path === path) hit = n; else walk(n.children); }); })(window.app.folderTree || []);
return hit;
}
// Recompute subtree totals after a structural change (expand/collapse a zip).
function recomputeTotals() {
(function walk(ns) {
(ns || []).forEach(function (n) {
walk(n.children || []);
var rf = (n.files || []).length, rd = (n.children || []).length;
(n.children || []).forEach(function (c) { rf += c.runFiles || 0; rd += c.runDirs || 0; });
n.runFiles = rf; n.runDirs = rd;
n.fileCount = (n.files || []).length; n.subdirCount = (n.children || []).length;
});
})(window.app.folderTree || []);
}
// Turn a .zip FILE into an expandable archive folder in place: scan its
// members into the fileset and drop the now-meaningless single-file
// assignment. Members come from the live handle, or (snapshot-restored) are
// re-read from the workspace root via scanZipNode's fallback.
async function expandZipAsFolder(file) {
var parent = findNodeByPath(file.folderPath);
if (!parent) return null;
var zipName = zddc.joinExtension(file.originalFilename, file.extension);
var zipPath = parent.path + '/' + zipName;
var zipNode = makeNode({ name: zipName, kind: 'directory', isZipRoot: true, zipPath: zipPath }, zipPath, parent);
zipNode._zipFileObj = file.handle ? file : null; // null → scanZipNode resolves from root
zipNode.scanState = 'zip-pending';
zipNode.expanded = true;
parent.files = (parent.files || []).filter(function (f) { return f !== file; });
parent.children = parent.children || [];
parent.children.push(zipNode);
var c = window.app.modules.classify;
if (c && c.dropAssignments) c.dropAssignments([c.srcKeyForFile(file)]);
await scanZipNode(zipNode);
recomputeTotals();
return zipNode;
}
// Collapse an expanded archive folder back to a single .zip file, dropping
// every member's assignment.
function collapseZipToFile(zipNode) {
if (!zipNode || !zipNode.isZipRoot) return null;
var parent = zipNode.parent || findNodeByPath(zipNode.path.slice(0, zipNode.path.lastIndexOf('/')));
if (!parent) return null;
var c = window.app.modules.classify;
if (c && c.dropAssignments) {
var keys = [];
(function walk(n) { (n.files || []).forEach(function (f) { keys.push(c.srcKeyForFile(f)); }); (n.children || []).forEach(walk); })(zipNode);
c.dropAssignments(keys);
}
var split = zddc.splitExtension(zipNode.name);
var file = {
handle: (zipNode._zipFileObj && zipNode._zipFileObj.handle) || null,
folderHandle: (zipNode._zipFileObj && zipNode._zipFileObj.folderHandle) || null,
originalFilename: split.name, extension: split.extension,
size: null, lastModified: null,
trackingNumber: '', revision: '', status: '', title: '',
isDirty: false, error: false, errorMessage: '', validation: null, sha256: null,
folderPath: parent.path,
};
parent.children = (parent.children || []).filter(function (n) { return n !== zipNode; });
parent.files = parent.files || [];
parent.files.push(file);
zipCache.delete(zipNode.zipPath);
recomputeTotals();
return file;
}
async function resolveDirHandle(rootHandle, relPath) { async function resolveDirHandle(rootHandle, relPath) {
var cur = rootHandle; var cur = rootHandle;
var parts = (relPath || '').split('/').filter(Boolean); var parts = (relPath || '').split('/').filter(Boolean);
@ -9172,6 +9249,8 @@ X.B(E,Y);return E}return J}())
resolveDirHandle, resolveDirHandle,
ensureZipLoaded, ensureZipLoaded,
extractZipMember, extractZipMember,
expandZipAsFolder,
collapseZipToFile,
resumeScan resumeScan
}; };
})(); })();
@ -9486,10 +9565,12 @@ X.B(E,Y);return E}return J}())
} }
item.appendChild(icon); item.appendChild(icon);
// Classify mode: an aggregate state dot for the folder's subtree. // Classify mode: an aggregate state dot for the folder's subtree, and a
// struck-through name when the WHOLE subtree is excluded (mirrors files).
if (classifyOn()) { if (classifyOn()) {
const agg = aggregateState(subtreeFiles(folder)); const agg = aggregateState(subtreeFiles(folder));
if (agg) item.appendChild(stateDot(agg)); if (agg) item.appendChild(stateDot(agg));
if (agg === 'excluded') item.classList.add('excluded');
} }
// Folder name // Folder name
@ -10078,7 +10159,31 @@ X.B(E,Y);return E}return J}())
} }
} }
// ── context menu (exclude / include / clear) ─────────────────────────── // ── per-zip mode toggle (single file ⇄ expandable folder) ───────────────
function persistTreeChange() {
var ws = window.app.modules.workspace;
if (ws && ws.onRescanned) ws.onRescanned();
}
async function expandZip(file) {
if (!file.handle && !window.app.rootHandle) {
if (window.zddc) window.zddc.toast('Connect the source directory first to expand this archive.', 'warning');
return;
}
try {
var node = await window.app.modules.scanner.expandZipAsFolder(file);
if (node) { render(); persistTreeChange(); }
} catch (e) {
if (window.zddc) window.zddc.toast('Couldnt expand the archive — ' + (e.message || e), 'error');
}
}
function collapseZip(zipNode) {
if (!zipNode) return;
window.app.modules.scanner.collapseZipToFile(zipNode);
render();
persistTreeChange();
}
// ── context menu (exclude / include / clear / zip mode) ─────────────────
var menuEl = null; var menuEl = null;
function hideMenu() { if (menuEl) { menuEl.remove(); menuEl = null; } } function hideMenu() { if (menuEl) { menuEl.remove(); menuEl = null; } }
function showMenu(x, y, items) { function showMenu(x, y, items) {
@ -10117,16 +10222,27 @@ X.B(E,Y);return E}return J}())
if (a.trackingNodeId) items.push({ label: 'Clear tracking', fn: function () { c.place([key], null, 'tracking'); } }); if (a.trackingNodeId) items.push({ label: 'Clear tracking', fn: function () { c.place([key], null, 'tracking'); } });
if (a.transmittalNodeId) items.push({ label: 'Clear transmittal', fn: function () { c.place([key], null, 'transmittal'); } }); if (a.transmittalNodeId) items.push({ label: 'Clear transmittal', fn: function () { c.place([key], null, 'transmittal'); } });
} }
var file = findFileByKey(key);
if (file && file.isVirtual) {
items.push({ label: 'Collapse archive to single file', fn: function () { collapseZip(findFolderByPath(file.zipPath)); } });
} else if (file && file.extension === 'zip') {
items.push({ label: 'Expand as folder', fn: function () { expandZip(file); } });
}
} else { } else {
var folder = findFolderByPath(folderEl.dataset.path); var folder = findFolderByPath(folderEl.dataset.path);
if (folder && folder.isZipRoot) {
items.push({ label: 'Collapse to single file', fn: function () { collapseZip(folder); } });
}
var keys = keysFor(subtreeFiles(folder || { files: [], children: [] })); var keys = keysFor(subtreeFiles(folder || { files: [], children: [] }));
if (!keys.length) return; if (keys.length) {
var allExcl = keys.every(function (k) { var a = c.getAssignment(k); return a && a.excluded; }); var allExcl = keys.every(function (k) { var a = c.getAssignment(k); return a && a.excluded; });
items.push({ items.push({
label: (allExcl ? 'Include' : 'Exclude') + ' folder (' + keys.length + ' file' + (keys.length === 1 ? '' : 's') + ')', label: (allExcl ? 'Include' : 'Exclude') + ' folder (' + keys.length + ' file' + (keys.length === 1 ? '' : 's') + ')',
fn: function () { c.setExcluded(keys, !allExcl); }, fn: function () { c.setExcluded(keys, !allExcl); },
}); });
} }
if (!items.length) return;
}
showMenu(e.clientX, e.clientY, items); showMenu(e.clientX, e.clientY, items);
} }

View file

@ -1656,6 +1656,113 @@ body {
font-style: italic; font-style: italic;
} }
/* ── New-project dialog ──────────────────────────────────────────────────── */
.np-modal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: flex-start;
justify-content: center;
overflow-y: auto;
padding: 3rem 1rem;
}
.np-modal__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
}
.np-modal__dialog {
position: relative;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
width: 100%;
max-width: 560px;
padding: 1.25rem 1.5rem 1.5rem;
margin: auto;
}
.np-modal__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.np-modal__head h2 { margin: 0; font-size: 1.25rem; }
.np-modal__close {
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
color: var(--text-muted);
cursor: pointer;
padding: 0.1rem 0.4rem;
}
.np-modal__close:hover { color: var(--text); }
.np-help { color: var(--text-muted); font-size: 0.85rem; margin: 0 0 1rem; }
.np-field { display: block; margin-bottom: 0.75rem; font-size: 0.85rem; color: var(--text-secondary, var(--text-muted)); }
.np-field input {
display: block;
width: 100%;
margin-top: 0.25rem;
padding: 0.4rem 0.55rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary, var(--bg));
color: var(--text);
font-size: 0.9rem;
box-sizing: border-box;
}
.np-err { display: block; color: var(--danger); font-size: 0.8rem; margin-top: 0.25rem; }
.np-grouphdr { font-size: 0.95em; margin: 1rem 0 0.3rem; font-weight: 600; }
.np-sub { font-weight: 400; color: var(--text-muted); font-size: 0.8rem; }
.np-list { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.4rem; }
.np-row { display: flex; gap: 0.4rem; align-items: center; }
.np-row__input { flex: 1; }
.np-row__verbs { flex: 0 0 8rem; }
.np-row input {
padding: 0.35rem 0.5rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary, var(--bg));
color: var(--text);
font-size: 0.88rem;
box-sizing: border-box;
}
.np-del {
flex: 0 0 auto;
background: none;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-muted);
cursor: pointer;
width: 1.9rem;
height: 1.9rem;
font-size: 1.1rem;
line-height: 1;
}
.np-del:hover { color: var(--danger); border-color: var(--danger); }
.np-add {
background: none;
border: 1px dashed var(--border);
border-radius: var(--radius);
color: var(--primary);
cursor: pointer;
font-size: 0.82rem;
padding: 0.3rem 0.6rem;
}
.np-add:hover { border-color: var(--primary); }
.np-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.25rem;
border-top: 1px solid var(--border);
padding-top: 1rem;
}
</style> </style>
</head> </head>
<body> <body>
@ -1671,7 +1778,7 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC</span> <span class="app-header__title">ZDDC</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 18:57:44 · e2c2d15</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -1734,6 +1841,10 @@ body {
<h2>Projects</h2> <h2>Projects</h2>
<span id="projectCount" class="landing-count"></span> <span id="projectCount" class="landing-count"></span>
</div> </div>
<div class="landing-header-actions">
<!-- Shown only when the server reports can_create_project. -->
<button id="newProjectBtn" class="btn btn-primary btn-sm hidden" onclick="LandingApp.openNewProject()">+ New project</button>
</div>
</div> </div>
<div id="projectListContainer" class="project-list-container"> <div id="projectListContainer" class="project-list-container">
@ -1743,6 +1854,53 @@ body {
</div> </div>
</div><!-- /pickerView --> </div><!-- /pickerView -->
<!-- New-project dialog. Mirrors the profile page's create form; POSTs to
/.profile/projects, which gates on the create verb at the root. -->
<div id="newProjectModal" class="np-modal hidden" role="dialog" aria-modal="true" aria-labelledby="npHeading">
<div class="np-modal__backdrop" onclick="LandingApp.closeNewProject()"></div>
<div class="np-modal__dialog">
<div class="np-modal__head">
<h2 id="npHeading">Create new project</h2>
<button type="button" class="np-modal__close" onclick="LandingApp.closeNewProject()" aria-label="Close">&times;</button>
</div>
<p class="np-help">Creates a top-level project folder. You're recorded as its creator and added to its admins automatically. Assign members to the project roles below — one email (or role pattern) per row.</p>
<form id="npForm" autocomplete="off">
<label class="np-field">Name
<input type="text" id="npName" maxlength="64" placeholder="e.g. Site-3" required>
<span class="np-err" id="npNameErr"></span>
</label>
<label class="np-field">Title (optional)
<input type="text" id="npTitleInput" maxlength="200" placeholder="Human-readable project title">
</label>
<h3 class="np-grouphdr">Admins <span class="np-sub">full control (you're already an admin)</span></h3>
<div class="np-list" data-field="admins"></div>
<button type="button" class="np-add" data-target="admins">+ Add admin</button>
<h3 class="np-grouphdr">Document controllers <span class="np-sub">manage filing &amp; records — read / write / create / delete</span></h3>
<div class="np-list" data-field="document_controllers"></div>
<button type="button" class="np-add" data-target="document_controllers">+ Add document controller</button>
<h3 class="np-grouphdr">Project team <span class="np-sub">contribute documents — read / write / create</span></h3>
<div class="np-list" data-field="project_team"></div>
<button type="button" class="np-add" data-target="project_team">+ Add team member</button>
<h3 class="np-grouphdr">Guests <span class="np-sub">read-only access</span></h3>
<div class="np-list" data-field="guests"></div>
<button type="button" class="np-add" data-target="guests">+ Add guest</button>
<h3 class="np-grouphdr">Advanced — ACL permissions <span class="np-sub">pattern → verbs (r w c d a); empty verbs = explicit deny</span></h3>
<div class="np-list" data-field="acl.permissions"></div>
<button type="button" class="np-add" data-target="acl.permissions">+ Add permission</button>
<div class="np-actions">
<button type="button" class="btn btn-secondary" onclick="LandingApp.closeNewProject()">Cancel</button>
<button type="submit" class="btn btn-primary">Create project</button>
</div>
</form>
</div>
</div>
<!-- Project mode (/<project>). Stage cards + MDL section. Shown <!-- Project mode (/<project>). Stage cards + MDL section. Shown
by landing.js when location.pathname is a single segment. --> by landing.js when location.pathname is a single segment. -->
<div id="projectView" class="hidden"> <div id="projectView" class="hidden">
@ -4108,6 +4266,133 @@ body {
// ── Bootstrap ──────────────────────────────────────────────────────────── // ── Bootstrap ────────────────────────────────────────────────────────────
// ── New project (server-only; gated on can_create_project) ───────────────
// The /.profile endpoints are rooted at the server root, which is where the
// picker lives, so the project base doubles as the API base.
function apiBase() { return location.origin + location.pathname.replace(/\/[^\/]*$/, '/'); }
async function fetchAccess() {
try {
var resp = await fetch(apiBase() + '.profile/access', {
headers: { 'Accept': 'application/json' }, cache: 'no-cache', credentials: 'same-origin',
});
if (!resp.ok) return null;
if ((resp.headers.get('Content-Type') || '').toLowerCase().indexOf('json') === -1) return null;
return await resp.json();
} catch (e) { return null; }
}
function npRowFor() {
var row = document.createElement('div'); row.className = 'np-row';
var input = document.createElement('input');
input.type = 'text'; input.className = 'np-row__input'; input.placeholder = 'email or role pattern';
var del = document.createElement('button');
del.type = 'button'; del.className = 'np-del'; del.textContent = '×'; del.setAttribute('aria-label', 'Remove');
row.appendChild(input); row.appendChild(del);
return row;
}
function npPermRow() {
var row = document.createElement('div'); row.className = 'np-row';
var pat = document.createElement('input');
pat.type = 'text'; pat.className = 'np-row__input'; pat.placeholder = 'pattern (email or role)'; pat.dataset.role = 'pattern';
var verbs = document.createElement('input');
verbs.type = 'text'; verbs.className = 'np-row__verbs'; verbs.placeholder = 'verbs e.g. rwc'; verbs.dataset.role = 'verbs';
var del = document.createElement('button');
del.type = 'button'; del.className = 'np-del'; del.textContent = '×'; del.setAttribute('aria-label', 'Remove');
row.appendChild(pat); row.appendChild(verbs); row.appendChild(del);
return row;
}
function npCollectList(field) {
var out = [];
document.querySelectorAll('#npForm .np-list[data-field="' + field + '"] .np-row').forEach(function (r) {
var v = r.querySelector('input').value.trim(); if (v) out.push(v);
});
return out;
}
function npCollectPerms() {
var out = {};
document.querySelectorAll('#npForm .np-list[data-field="acl.permissions"] .np-row').forEach(function (r) {
var pat = r.querySelector('input[data-role="pattern"]').value.trim(); if (!pat) return;
out[pat] = r.querySelector('input[data-role="verbs"]').value.trim();
});
return out;
}
var npWired = false;
function wireNewProjectForm() {
if (npWired) return;
var form = document.getElementById('npForm');
if (!form) return;
npWired = true;
form.querySelectorAll('button.np-add').forEach(function (btn) {
btn.addEventListener('click', function () {
var field = btn.dataset.target;
var host = form.querySelector('.np-list[data-field="' + field + '"]');
host.appendChild(field === 'acl.permissions' ? npPermRow() : npRowFor());
});
});
form.addEventListener('click', function (e) {
if (e.target && e.target.classList && e.target.classList.contains('np-del')) {
e.target.closest('.np-row').remove();
}
});
form.addEventListener('submit', submitNewProject);
}
function openNewProject() {
wireNewProjectForm();
var modal = document.getElementById('newProjectModal');
if (modal) modal.classList.remove('hidden');
var name = document.getElementById('npName');
if (name) name.focus();
}
function closeNewProject() {
var modal = document.getElementById('newProjectModal');
if (modal) modal.classList.add('hidden');
}
function submitNewProject(ev) {
ev.preventDefault();
var nameErr = document.getElementById('npNameErr'); nameErr.textContent = '';
var perms = npCollectPerms();
var body = { parent: '/', name: document.getElementById('npName').value.trim() };
var title = document.getElementById('npTitleInput').value.trim();
if (title) body.title = title;
if (Object.keys(perms).length) body.acl = { permissions: perms };
var admins = npCollectList('admins'); if (admins.length) body.admins = admins;
var dcs = npCollectList('document_controllers'); if (dcs.length) body.document_controllers = dcs;
var team = npCollectList('project_team'); if (team.length) body.project_team = team;
var guests = npCollectList('guests'); if (guests.length) body.guests = guests;
var submitBtn = ev.target.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
fetch(apiBase() + '.profile/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body),
}).then(function (r) {
return r.text().then(function (t) { return { ok: r.ok, status: r.status, text: t }; });
}).then(function (res) {
if (submitBtn) submitBtn.disabled = false;
if (res.ok) {
closeNewProject();
document.getElementById('npForm').reset();
form_clearLists();
// Surface the newly-created project in the list.
fetchProjects().then(render);
return;
}
try {
var p = JSON.parse(res.text);
if (p && p.errors && p.errors.length) {
nameErr.textContent = p.errors.map(function (e) { return e.field + ': ' + e.message; }).join('; ');
return;
}
} catch (e) { /* not a field-error envelope */ }
nameErr.textContent = 'HTTP ' + res.status + ': ' + res.text;
});
}
function form_clearLists() {
document.querySelectorAll('#npForm .np-list').forEach(function (l) { l.textContent = ''; });
}
async function init() { async function init() {
if (detectMode() === 'project') { if (detectMode() === 'project') {
await renderProjectMode(); await renderProjectMode();
@ -4136,6 +4421,17 @@ body {
render(); render();
// Show "+ New project" only if the server says this caller may create
// one (same gate the POST endpoint enforces, so the UI never dangles
// an affordance the server would 404).
fetchAccess().then(function (access) {
if (access && access.can_create_project) {
var btn = document.getElementById('newProjectBtn');
if (btn) btn.classList.remove('hidden');
wireNewProjectForm();
}
});
// Wire up keyboard shortcuts in the action-bar input: Enter saves, // Wire up keyboard shortcuts in the action-bar input: Enter saves,
// Escape cancels. // Escape cancels.
var input = document.getElementById('groupNameInput'); var input = document.getElementById('groupNameInput');
@ -4171,6 +4467,9 @@ body {
saveGroup: saveGroup, saveGroup: saveGroup,
openSelectedVisible: openSelectedVisible, openSelectedVisible: openSelectedVisible,
dismissWarning: dismissWarning, dismissWarning: dismissWarning,
// New-project dialog.
openNewProject: openNewProject,
closeNewProject: closeNewProject,
// Project-mode entry points (also tested directly). // Project-mode entry points (also tested directly).
detectMode: detectMode, detectMode: detectMode,
renderProjectMode: renderProjectMode, renderProjectMode: renderProjectMode,

View file

@ -2770,7 +2770,7 @@ dialog.modal--narrow {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span> <span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 18:57:43 · e2c2d15</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08</span></span>
</div> </div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span> <span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action; <!-- Publish split-button (Transmittal-specific primary action;

View file

@ -1,8 +1,8 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line. # Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.27-beta · 2026-06-10 18:57:44 · e2c2d15 archive=v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08
transmittal=v0.0.27-beta · 2026-06-10 18:57:43 · e2c2d15 transmittal=v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08
classifier=v0.0.27-beta · 2026-06-10 18:57:44 · e2c2d15 classifier=v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08
landing=v0.0.27-beta · 2026-06-10 18:57:44 · e2c2d15 landing=v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08
form=v0.0.27-beta · 2026-06-10 18:57:44 · e2c2d15 form=v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08
tables=v0.0.27-beta · 2026-06-10 18:57:44 · e2c2d15 tables=v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08
browse=v0.0.27-beta · 2026-06-10 18:57:44 · e2c2d15 browse=v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08

View file

@ -1722,7 +1722,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 18:57:44 · e2c2d15</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">