chore(embedded): cut v0.0.27-beta
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Failing after 9s
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Failing after 9s
This commit is contained in:
parent
5f1df08077
commit
8d8094bb9f
7 changed files with 455 additions and 40 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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('Couldn’t 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,15 +10222,26 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">×</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 & 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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue