diff --git a/browse/build.sh b/browse/build.sh index 8ffa917..41a3536 100755 --- a/browse/build.sh +++ b/browse/build.sh @@ -71,6 +71,7 @@ concat_files \ "js/preview.js" \ "js/preview-markdown.js" \ "js/preview-yaml.js" \ + "js/preview-zddc-form.js" \ "js/hovercard.js" \ "js/grid.js" \ "js/upload.js" \ diff --git a/browse/js/preview-zddc-form.js b/browse/js/preview-zddc-form.js new file mode 100644 index 0000000..6b5efd8 --- /dev/null +++ b/browse/js/preview-zddc-form.js @@ -0,0 +1,322 @@ +// preview-zddc-form.js — schema-driven FORM view for .zddc files. +// +// The user shouldn't have to understand YAML cascades to configure a project. +// This renders the .zddc as a form: the OPTION fields (the blanks an operator +// fills — title, admins, role members) are editable widgets; the STRUCTURE +// (paths, WORM, tools, behaviours — what a ZDDC project IS) is shown read-only +// for context. The split is driven by the server's .zddc JSON Schema +// (/.api/zddc-schema, x-zddc-tier: structure|option). Saving merges the edited +// option values back into the file (preserving all structure keys) and PUTs the +// YAML — which works for an on-disk .zddc and for a .zddc.zip bundle member +// (the server's ServeZipWrite). An "Edit raw YAML" escape hands off to the +// CodeMirror editor for anything the form doesn't cover (field_codes, display, +// convert, advanced acl). +// +// This is the primary .zddc editor; the raw-YAML plugin (preview-yaml.js) is +// the power-user fallback. +(function (app) { + 'use strict'; + + var util = app.modules.util || window.app.modules.util; + var escapeHtml = util.escapeHtml; + var saveFile = util.saveFile; + var isEditableZipMember = util.isEditableZipMember; + + var current = null; // { node, dirty, etag, lastModified } + + // Cached .zddc schema (property → {tier, description}). + var schemaProps = null; + function loadSchema() { + if (schemaProps) return Promise.resolve(schemaProps); + return fetch('/.api/zddc-schema', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (j) { schemaProps = (j && j.properties) || {}; return schemaProps; }) + .catch(function () { schemaProps = {}; return schemaProps; }); + } + + function handles(node) { + return !!node && (node.name === '.zddc' || /\.zddc$/i.test(node.name || '')); + } + + function canSave(node) { + if (isEditableZipMember(node)) return true; + if (node.url && window.app.state.source === 'server' && window.zddc.cap) { + // A .zddc edit is an ActionAdmin write — needs the 'a' verb. + return window.zddc.cap.has(node, 'a'); + } + return false; + } + + function isDirty() { return !!(current && current.dirty); } + function currentNode() { return current ? current.node : null; } + function dispose() { current = null; } + + function desc(name) { + return (schemaProps && schemaProps[name] && schemaProps[name].description) || ''; + } + + // ── small DOM helpers ─────────────────────────────────────────────────── + function el(tag, cls, text) { + var e = document.createElement(tag); + if (cls) e.className = cls; + if (text != null) e.textContent = text; + return e; + } + // A growable list of single-string rows (used for admins + role members). + function listEditor(values, placeholder, onChange, readOnly) { + var wrap = el('div', 'zf-list'); + function addRow(val) { + var row = el('div', 'zf-list__row'); + row.style.cssText = 'display:flex;gap:.4rem;margin:.2rem 0;'; + var input = el('input'); + input.type = 'text'; + input.value = val || ''; + input.placeholder = placeholder || ''; + input.style.cssText = 'flex:1;padding:.3rem;font-family:var(--code,monospace);'; + input.disabled = !!readOnly; + input.addEventListener('input', onChange); + row.appendChild(input); + if (!readOnly) { + var del = el('button', null, '−'); + del.type = 'button'; + del.title = 'Remove'; + del.addEventListener('click', function () { row.remove(); onChange(); }); + row.appendChild(del); + } + wrap.appendChild(row); + } + (values || []).forEach(addRow); + if (!readOnly) { + var add = el('button', 'zf-add', '+ add'); + add.type = 'button'; + add.style.cssText = 'margin-top:.2rem;'; + add.addEventListener('click', function () { addRow(''); onChange(); }); + wrap.appendChild(add); + } + wrap._values = function () { + return Array.prototype.slice.call(wrap.querySelectorAll('.zf-list__row input')) + .map(function (i) { return i.value.trim(); }) + .filter(function (v) { return v; }); + }; + return wrap; + } + + async function render(node, container, ctx) { + dispose(); + var text, etag = null, lastModified = null; + try { + if (ctx.getContentWithVersion) { + var loaded = await ctx.getContentWithVersion(node); + text = new TextDecoder('utf-8', { fatal: false }).decode(loaded.buf); + etag = loaded.etag; + lastModified = loaded.lastModified; + } else { + text = new TextDecoder('utf-8', { fatal: false }).decode(await ctx.getArrayBuffer(node)); + } + } catch (e) { + container.innerHTML = '
' + + 'Could not read ' + escapeHtml(node.name) + ': ' + escapeHtml(e.message || String(e)) + '
'; + return; + } + + var data = {}; + try { data = (window.jsyaml && window.jsyaml.load(text)) || {}; } catch (_) { data = {}; } + if (typeof data !== 'object' || Array.isArray(data)) data = {}; + await loadSchema(); + + var editable = canSave(node); + current = { node: node, dirty: false, etag: etag, lastModified: lastModified }; + + container.innerHTML = ''; + var shell = el('div', 'yaml-shell zddc-form'); + shell.style.cssText = 'padding:.75rem 1rem;overflow:auto;height:100%;box-sizing:border-box;'; + container.appendChild(shell); + + // Header. + var hdr = el('div', 'md-shell__infohdr'); + hdr.appendChild(el('span', 'md-shell__title', node.name)); + var srcTag = el('span', 'md-shell__source', isEditableZipMember(node) ? 'config bundle' : (editable ? '.zddc form' : 'read-only')); + hdr.appendChild(srcTag); + var dirtyEl = el('span', 'md-shell__dirty'); + hdr.appendChild(dirtyEl); + var statusEl = el('span', 'md-shell__status'); + hdr.appendChild(statusEl); + var rawBtn = el('button', 'btn btn-sm btn-secondary', 'Edit raw YAML'); + rawBtn.type = 'button'; + rawBtn.title = 'Switch to the raw YAML editor (covers every key).'; + rawBtn.addEventListener('click', function () { + var ym = window.app.modules.yamledit; + if (ym && ym.render) { dispose(); ym.render(node, container, ctx); } + }); + hdr.appendChild(rawBtn); + var saveBtn = el('button', 'btn btn-sm btn-primary', 'Save'); + saveBtn.type = 'button'; + saveBtn.disabled = true; + hdr.appendChild(saveBtn); + shell.appendChild(hdr); + + function markDirty() { + if (!current) return; + current.dirty = true; + dirtyEl.textContent = '● modified'; + if (editable) saveBtn.disabled = false; + } + + var help = el('p', 'help'); + help.style.cssText = 'color:var(--color-text-muted,#666);font-size:.85rem;margin:.3rem 0 .8rem;'; + help.textContent = editable + ? 'Fill in the project options below. Structure (the folder shape, WORM, tools) is managed by the baseline and shown read-only — edit it via raw YAML if you must.' + : 'Read-only — you need admin authority over this path to edit it.'; + shell.appendChild(help); + + // ── OPTION fields ─────────────────────────────────────────────────── + function section(title, hint) { + var s = el('section', 'zf-section'); + s.style.cssText = 'margin:0 0 1rem;'; + var h = el('h3', null, title); + h.style.cssText = 'font-size:1em;margin:.6rem 0 .2rem;'; + s.appendChild(h); + if (hint) { + var p = el('p', 'help', hint); + p.style.cssText = 'color:var(--color-text-muted,#888);font-size:.8rem;margin:0 0 .3rem;'; + s.appendChild(p); + } + shell.appendChild(s); + return s; + } + + // title + var titleSec = section('Title', desc('title')); + var titleInput = el('input'); + titleInput.type = 'text'; + titleInput.value = (typeof data.title === 'string') ? data.title : ''; + titleInput.disabled = !editable; + titleInput.style.cssText = 'width:100%;max-width:32rem;padding:.35rem;'; + titleInput.addEventListener('input', markDirty); + titleSec.appendChild(titleInput); + + // admins + var adminsSec = section('Admins', desc('admins')); + var adminsList = listEditor(Array.isArray(data.admins) ? data.admins : [], 'email or *@domain', markDirty, !editable); + adminsSec.appendChild(adminsList); + + // roles (map name → {members:[]}) + var rolesSec = section('Roles', desc('roles') || 'Who belongs to each project role.'); + var rolesHost = el('div', 'zf-roles'); + rolesSec.appendChild(rolesHost); + var roleEditors = []; // {name, membersEl, getName} + function addRole(name, members) { + var box = el('div', 'zf-role'); + box.style.cssText = 'border:1px solid rgba(0,0,0,0.1);border-radius:4px;padding:.4rem .6rem;margin:.3rem 0;'; + var nameRow = el('div'); + nameRow.style.cssText = 'display:flex;gap:.4rem;align-items:center;margin-bottom:.2rem;'; + var nameInput = el('input'); + nameInput.type = 'text'; + nameInput.value = name || ''; + nameInput.placeholder = 'role name (e.g. document_controller)'; + nameInput.style.cssText = 'font-family:var(--code,monospace);font-weight:600;flex:1;padding:.25rem;'; + nameInput.disabled = !editable; + nameInput.addEventListener('input', markDirty); + nameRow.appendChild(el('span', null, '👥')); + nameRow.appendChild(nameInput); + box.appendChild(nameRow); + var membersList = listEditor(members || [], 'member email or *@domain', markDirty, !editable); + box.appendChild(membersList); + rolesHost.appendChild(box); + roleEditors.push({ getName: function () { return nameInput.value.trim(); }, members: membersList }); + } + var roles = (data.roles && typeof data.roles === 'object') ? data.roles : {}; + Object.keys(roles).forEach(function (rn) { + var m = (roles[rn] && Array.isArray(roles[rn].members)) ? roles[rn].members : []; + addRole(rn, m); + }); + if (editable) { + var addRoleBtn = el('button', 'zf-add', '+ add role'); + addRoleBtn.type = 'button'; + addRoleBtn.addEventListener('click', function () { addRole('', []); markDirty(); }); + rolesSec.appendChild(addRoleBtn); + } + + // ── STRUCTURE (read-only) ─────────────────────────────────────────── + var structKeys = Object.keys(data).filter(function (k) { + return schemaProps[k] && schemaProps[k].tier === 'structure'; + }); + // Also surface option keys this form doesn't render yet, as read-only. + var rawHandled = { title: 1, admins: 1, roles: 1 }; + var otherKeys = Object.keys(data).filter(function (k) { + return !rawHandled[k] && !(schemaProps[k] && schemaProps[k].tier === 'structure'); + }); + if (structKeys.length || otherKeys.length) { + var det = el('details', 'zf-structure'); + det.style.cssText = 'margin-top:.5rem;'; + var sum = el('summary', null, 'Structure & advanced (read-only — edit via raw YAML)'); + sum.style.cssText = 'cursor:pointer;color:var(--color-text-muted,#666);font-size:.85rem;'; + det.appendChild(sum); + var subset = {}; + structKeys.concat(otherKeys).forEach(function (k) { subset[k] = data[k]; }); + var pre = el('pre'); + pre.style.cssText = 'background:var(--code-bg,#f6f8fa);padding:.5rem;border-radius:4px;overflow:auto;font-size:.8rem;'; + try { pre.textContent = window.jsyaml ? window.jsyaml.dump(subset) : JSON.stringify(subset, null, 2); } + catch (_) { pre.textContent = JSON.stringify(subset, null, 2); } + det.appendChild(pre); + shell.appendChild(det); + } + + // ── Save ──────────────────────────────────────────────────────────── + function buildContent() { + var out = {}; + // Preserve everything not managed by the form (structure + unrendered options). + Object.keys(data).forEach(function (k) { if (!rawHandled[k]) out[k] = data[k]; }); + var t = titleInput.value.trim(); + if (t) out.title = t; + var admins = adminsList._values(); + if (admins.length) out.admins = admins; + var rolesOut = {}; + roleEditors.forEach(function (re) { + var n = re.getName(); + if (!n) return; + var mem = re.members._values(); + rolesOut[n] = mem.length ? { members: mem } : { members: [] }; + }); + if (Object.keys(rolesOut).length) out.roles = rolesOut; + return window.jsyaml.dump(out); + } + + saveBtn.addEventListener('click', async function () { + if (!current || !editable) return; + saveBtn.disabled = true; + statusEl.textContent = 'Saving…'; + var content; + try { content = buildContent(); } + catch (e) { statusEl.textContent = 'Serialize failed: ' + (e.message || e); return; } + try { + var res = await saveFile(node, content, 'application/yaml; charset=utf-8', + { etag: current.etag, lastModified: current.lastModified }); + if (!current) return; + current.etag = (res && res.etag) || current.etag; + current.dirty = false; + dirtyEl.textContent = ''; + statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString(); + if (window.zddc && window.zddc.toast) window.zddc.toast('Saved ' + node.name, 'success'); + } catch (e) { + if (e && e.status === 412 && window.app.modules.conflict) { + window.app.modules.conflict.open({ + name: node.name, theirsText: '', minePut: function () { return saveFile(node, content, 'application/yaml; charset=utf-8', {}); } + }); + statusEl.textContent = 'Conflict — changed on server.'; + } else { + statusEl.textContent = 'Save failed: ' + (e && e.message ? e.message : e); + } + saveBtn.disabled = false; + } + }); + } + + app.modules.zddcform = { + handles: handles, + render: render, + isDirty: isDirty, + currentNode: currentNode, + dispose: dispose + }; +})(window.app); diff --git a/browse/js/preview.js b/browse/js/preview.js index 64277a9..8f767ea 100644 --- a/browse/js/preview.js +++ b/browse/js/preview.js @@ -100,7 +100,7 @@ function editorModules() { var m = window.app.modules; - return [m.markdown, m.yamledit].filter(Boolean); + return [m.markdown, m.yamledit, m.zddcform].filter(Boolean); } function disposeEditors() { @@ -211,6 +211,19 @@ return; } + // .zddc form view: a schema-driven form (option fields editable, + // structure read-only) is the PRIMARY editor for .zddc files. It hands + // off to the raw YAML editor on demand. Other YAML files skip it. + var zddcForm = window.app.modules.zddcform; + if (zddcForm && zddcForm.handles(node)) { + try { + await zddcForm.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion }); + } catch (e) { + renderError(container, '.zddc form render failed: ' + (e.message || e)); + } + return; + } + // YAML plugin: .yaml / .yml / .zddc / *.zddc.yaml route to a // CodeMirror 5 editor with js-yaml linting; .zddc files also // get a schema-aware lint pass.