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>
|
||||
<div class="header-title-group">
|
||||
<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>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
||||
|
|
|
|||
|
|
@ -2824,7 +2824,7 @@ li.CodeMirror-hint-active {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1672,6 +1672,7 @@ body.is-elevated::after {
|
|||
.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; }
|
||||
.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) */
|
||||
.tfile { cursor: pointer; }
|
||||
|
|
@ -2239,7 +2240,7 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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>
|
||||
<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>
|
||||
|
|
@ -7326,6 +7327,15 @@ X.B(E,Y);return E}return J}())
|
|||
});
|
||||
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) {
|
||||
var a = assignmentFor(key);
|
||||
a.titleOverride = title && title.trim() ? title.trim() : null;
|
||||
|
|
@ -7655,12 +7665,18 @@ X.B(E,Y);return E}return J}())
|
|||
function parseFolderLevels(name) {
|
||||
var s = String(name == null ? '' : name).trim();
|
||||
if (!s) return [];
|
||||
var segs = s.split('-');
|
||||
var last = segs.pop();
|
||||
var u = last.indexOf('_');
|
||||
if (u >= 0) { segs.push(last.slice(0, u)); segs.push(last.slice(u + 1)); }
|
||||
else { segs.push(last); }
|
||||
return segs.map(function (x) { return x.trim(); }).filter(Boolean);
|
||||
var u = s.indexOf('_'); // the "_" separates the tracking number from the leaf
|
||||
if (u < 0) {
|
||||
// No "_" → a pure tracking-number path: nest by "-".
|
||||
return s.split('-').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.
|
||||
function trackingChildren(parentId) {
|
||||
|
|
@ -7747,7 +7763,7 @@ X.B(E,Y);return E}return J}())
|
|||
srcKeyForFile: srcKeyForFile, defaultTitle: defaultTitle,
|
||||
// assignments
|
||||
assignmentFor: assignmentFor, getAssignment: getAssignment,
|
||||
place: place, setExcluded: setExcluded,
|
||||
place: place, setExcluded: setExcluded, dropAssignments: dropAssignments,
|
||||
setTitleOverride: setTitleOverride,
|
||||
// trees
|
||||
addTrackingNode: addTrackingNode, addParty: addParty,
|
||||
|
|
@ -8557,18 +8573,9 @@ X.B(E,Y);return E}return J}())
|
|||
fo.folderPath = node.path;
|
||||
files.push(fo);
|
||||
if (scanStats) scanStats.files++;
|
||||
if (fo.extension === 'zip' && typeof JSZip !== 'undefined') {
|
||||
// Don't read the archive during the listing — make an
|
||||
// expandable, lazy zip node scanned on open (scanZipNode).
|
||||
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++;
|
||||
}
|
||||
// A .zip is a single file by default (one classifiable unit).
|
||||
// The user can later "Expand as folder" (expandZipAsFolder) to
|
||||
// pull its members into the fileset.
|
||||
} else if (entry.kind === 'directory') {
|
||||
const childPath = node.path + '/' + entry.name;
|
||||
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);
|
||||
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) {
|
||||
var cur = rootHandle;
|
||||
var parts = (relPath || '').split('/').filter(Boolean);
|
||||
|
|
@ -9172,6 +9249,8 @@ X.B(E,Y);return E}return J}())
|
|||
resolveDirHandle,
|
||||
ensureZipLoaded,
|
||||
extractZipMember,
|
||||
expandZipAsFolder,
|
||||
collapseZipToFile,
|
||||
resumeScan
|
||||
};
|
||||
})();
|
||||
|
|
@ -9486,10 +9565,12 @@ X.B(E,Y);return E}return J}())
|
|||
}
|
||||
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()) {
|
||||
const agg = aggregateState(subtreeFiles(folder));
|
||||
if (agg) item.appendChild(stateDot(agg));
|
||||
if (agg === 'excluded') item.classList.add('excluded');
|
||||
}
|
||||
|
||||
// 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;
|
||||
function hideMenu() { if (menuEl) { menuEl.remove(); menuEl = null; } }
|
||||
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.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 {
|
||||
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: [] }));
|
||||
if (!keys.length) return;
|
||||
var allExcl = keys.every(function (k) { var a = c.getAssignment(k); return a && a.excluded; });
|
||||
items.push({
|
||||
label: (allExcl ? 'Include' : 'Exclude') + ' folder (' + keys.length + ' file' + (keys.length === 1 ? '' : 's') + ')',
|
||||
fn: function () { c.setExcluded(keys, !allExcl); },
|
||||
});
|
||||
if (keys.length) {
|
||||
var allExcl = keys.every(function (k) { var a = c.getAssignment(k); return a && a.excluded; });
|
||||
items.push({
|
||||
label: (allExcl ? 'Include' : 'Exclude') + ' folder (' + keys.length + ' file' + (keys.length === 1 ? '' : 's') + ')',
|
||||
fn: function () { c.setExcluded(keys, !allExcl); },
|
||||
});
|
||||
}
|
||||
if (!items.length) return;
|
||||
}
|
||||
showMenu(e.clientX, e.clientY, items);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1656,6 +1656,113 @@ body {
|
|||
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>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -1671,7 +1778,7 @@ body {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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 class="header-right">
|
||||
|
|
@ -1734,6 +1841,10 @@ body {
|
|||
<h2>Projects</h2>
|
||||
<span id="projectCount" class="landing-count"></span>
|
||||
</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 id="projectListContainer" class="project-list-container">
|
||||
|
|
@ -1743,6 +1854,53 @@ body {
|
|||
</div>
|
||||
</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
|
||||
by landing.js when location.pathname is a single segment. -->
|
||||
<div id="projectView" class="hidden">
|
||||
|
|
@ -4108,6 +4266,133 @@ body {
|
|||
|
||||
// ── 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() {
|
||||
if (detectMode() === 'project') {
|
||||
await renderProjectMode();
|
||||
|
|
@ -4136,6 +4421,17 @@ body {
|
|||
|
||||
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,
|
||||
// Escape cancels.
|
||||
var input = document.getElementById('groupNameInput');
|
||||
|
|
@ -4171,6 +4467,9 @@ body {
|
|||
saveGroup: saveGroup,
|
||||
openSelectedVisible: openSelectedVisible,
|
||||
dismissWarning: dismissWarning,
|
||||
// New-project dialog.
|
||||
openNewProject: openNewProject,
|
||||
closeNewProject: closeNewProject,
|
||||
// Project-mode entry points (also tested directly).
|
||||
detectMode: detectMode,
|
||||
renderProjectMode: renderProjectMode,
|
||||
|
|
|
|||
|
|
@ -2770,7 +2770,7 @@ dialog.modal--narrow {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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>
|
||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||
<!-- Publish split-button (Transmittal-specific primary action;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# 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
|
||||
transmittal=v0.0.27-beta · 2026-06-10 18:57:43 · e2c2d15
|
||||
classifier=v0.0.27-beta · 2026-06-10 18:57:44 · e2c2d15
|
||||
landing=v0.0.27-beta · 2026-06-10 18:57:44 · e2c2d15
|
||||
form=v0.0.27-beta · 2026-06-10 18:57:44 · e2c2d15
|
||||
tables=v0.0.27-beta · 2026-06-10 18:57:44 · e2c2d15
|
||||
browse=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 19:57:21 · 5f1df08
|
||||
classifier=v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08
|
||||
landing=v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08
|
||||
form=v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08
|
||||
tables=v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08
|
||||
browse=v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08
|
||||
|
|
|
|||
|
|
@ -1722,7 +1722,7 @@ body.is-elevated::after {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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 class="header-right">
|
||||
|
|
|
|||
Loading…
Reference in a new issue