diff --git a/landing/css/landing.css b/landing/css/landing.css index a467535..f4f6a98 100644 --- a/landing/css/landing.css +++ b/landing/css/landing.css @@ -518,3 +518,110 @@ body { color: var(--text-muted); 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; +} diff --git a/landing/js/landing.js b/landing/js/landing.js index 6124da6..91dbb6e 100644 --- a/landing/js/landing.js +++ b/landing/js/landing.js @@ -828,6 +828,133 @@ // ── 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(); @@ -856,6 +983,17 @@ 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'); @@ -891,6 +1029,9 @@ 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/landing/template.html b/landing/template.html index 2e9618d..25f3c48 100644 --- a/landing/template.html +++ b/landing/template.html @@ -85,6 +85,10 @@

Projects

+
+ + +
@@ -94,6 +98,53 @@
+ + +