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.
+
+
+
+
@@ -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,
diff --git a/zddc/internal/apps/embedded/transmittal.html b/zddc/internal/apps/embedded/transmittal.html
index 5c269ca..ad6bf8e 100644
--- a/zddc/internal/apps/embedded/transmittal.html
+++ b/zddc/internal/apps/embedded/transmittal.html
@@ -2770,7 +2770,7 @@ dialog.modal--narrow {