feat(browse): CodeMirror for all editable text files; drop the .zddc form; tidy access dialog
- Editor routing: the CodeMirror editor is now the general editor for editable text files that aren't markdown — yaml/.zddc keep schema lint + completion + hover; txt/csv/tsv/json/xml/html/css/js/log/ini/conf/… open as a plaintext code editor (line numbers, find, save) instead of a read-only <pre>. HTML now edits its source (was an iframe render); PDF stays an iframe. Mode is yaml-only (the vendored CM mode); others plaintext, lint gutter suppressed. - Abandon the .zddc schema FORM (preview-zddc-form.js deleted): .zddc opens in CodeMirror. Guided dialogs (Manage access, …) are the front door for common tasks; CodeMirror is the full/raw surface. One fewer half-baked middle layer. - Manage Access dialog laid out cleanly: grid rows (who fills + shrinks, level sizes to content) so long emails/levels never overflow; short level labels with tooltips + a one-line legend; box-sizing fixed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
74ffefa191
commit
1bd73b1512
6 changed files with 71 additions and 372 deletions
|
|
@ -91,7 +91,6 @@ concat_files \
|
||||||
"js/preview.js" \
|
"js/preview.js" \
|
||||||
"js/preview-markdown.js" \
|
"js/preview-markdown.js" \
|
||||||
"js/preview-yaml.js" \
|
"js/preview-yaml.js" \
|
||||||
"js/preview-zddc-form.js" \
|
|
||||||
"js/hovercard.js" \
|
"js/hovercard.js" \
|
||||||
"js/grid.js" \
|
"js/grid.js" \
|
||||||
"js/upload.js" \
|
"js/upload.js" \
|
||||||
|
|
|
||||||
|
|
@ -27,28 +27,32 @@
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
.ma-list { display: flex; flex-direction: column; gap: 0.4rem; }
|
.ma-list { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
.ma-row { display: flex; gap: 0.5rem; align-items: center; }
|
/* who fills the row and shrinks (min-width:0); level + delete size to content
|
||||||
.ma-who {
|
so nothing overflows the dialog regardless of email/principal length. */
|
||||||
flex: 1 1 auto;
|
.ma-row {
|
||||||
min-width: 0;
|
display: grid;
|
||||||
padding: 0.35rem 0.5rem;
|
grid-template-columns: minmax(0, 1fr) max-content max-content;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.ma-who,
|
||||||
|
.ma-level {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
border: 1px solid var(--border, #ccc);
|
border: 1px solid var(--border, #ccc);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--bg, #fff);
|
background: var(--bg, #fff);
|
||||||
color: var(--text, #222);
|
color: var(--text, #222);
|
||||||
}
|
}
|
||||||
.ma-level {
|
.ma-who { width: 100%; min-width: 0; }
|
||||||
flex: 0 0 auto;
|
.ma-level { width: 8.5rem; cursor: pointer; }
|
||||||
padding: 0.35rem 0.4rem;
|
.ma-legend {
|
||||||
font: inherit;
|
margin: 0.5rem 0 0;
|
||||||
border: 1px solid var(--border, #ccc);
|
font-size: 0.74rem;
|
||||||
border-radius: 4px;
|
color: var(--text-muted, #888);
|
||||||
background: var(--bg, #fff);
|
|
||||||
color: var(--text, #222);
|
|
||||||
}
|
}
|
||||||
.ma-del {
|
.ma-del {
|
||||||
flex: 0 0 auto;
|
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-muted, #999);
|
color: var(--text-muted, #999);
|
||||||
|
|
|
||||||
|
|
@ -113,13 +113,15 @@
|
||||||
|
|
||||||
var sel = el('select', 'ma-level');
|
var sel = el('select', 'ma-level');
|
||||||
LEVELS.forEach(function (lv) {
|
LEVELS.forEach(function (lv) {
|
||||||
var o = el('option', null, lv.label + ' — ' + lv.hint);
|
var o = el('option', null, lv.label);
|
||||||
o.value = lv.id;
|
o.value = lv.id;
|
||||||
|
o.title = lv.hint;
|
||||||
sel.appendChild(o);
|
sel.appendChild(o);
|
||||||
});
|
});
|
||||||
if (model.level === 'custom') {
|
if (model.level === 'custom') {
|
||||||
var o2 = el('option', null, 'Custom (' + model.custom + ')');
|
var o2 = el('option', null, 'Custom');
|
||||||
o2.value = 'custom';
|
o2.value = 'custom';
|
||||||
|
o2.title = 'verbs: ' + model.custom;
|
||||||
sel.appendChild(o2);
|
sel.appendChild(o2);
|
||||||
}
|
}
|
||||||
sel.value = model.level;
|
sel.value = model.level;
|
||||||
|
|
@ -147,6 +149,10 @@
|
||||||
});
|
});
|
||||||
box.appendChild(addBtn);
|
box.appendChild(addBtn);
|
||||||
|
|
||||||
|
var legend = el('p', 'ma-legend',
|
||||||
|
'View = read · Contribute = add new files · Edit = overwrite + add · Manage = admin');
|
||||||
|
box.appendChild(legend);
|
||||||
|
|
||||||
// Inherit / make-private.
|
// Inherit / make-private.
|
||||||
var inhWrap = el('label', 'ma-inherit');
|
var inhWrap = el('label', 'ma-inherit');
|
||||||
var inhBox = el('input');
|
var inhBox = el('input');
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,26 @@
|
||||||
return ext === 'yaml' || ext === 'yml';
|
return ext === 'yaml' || ext === 'yml';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The CodeMirror editor is the general editor for editable TEXT files that
|
||||||
|
// aren't markdown (markdown has its own editor). Syntax highlighting is
|
||||||
|
// YAML-only — that's the one CM mode in the vendored bundle — so every
|
||||||
|
// other type opens as a plaintext editor (still line numbers, find,
|
||||||
|
// selection, save). svg/json-as-image etc. stay with their preview
|
||||||
|
// renderers; this set is deliberately the "edit the source" types.
|
||||||
|
var CODE_EXTS = {
|
||||||
|
yaml: 1, yml: 1, txt: 1, text: 1, csv: 1, tsv: 1, tab: 1,
|
||||||
|
json: 1, xml: 1, html: 1, htm: 1, css: 1, js: 1, mjs: 1,
|
||||||
|
log: 1, ini: 1, conf: 1, cfg: 1, toml: 1, env: 1,
|
||||||
|
sh: 1, bash: 1, properties: 1
|
||||||
|
};
|
||||||
|
function isCodeFile(node) {
|
||||||
|
if (!node || node.isDir || node.isZip) return false;
|
||||||
|
if (isYamlFile(node)) return true;
|
||||||
|
return !!CODE_EXTS[(node.ext || '').toLowerCase()];
|
||||||
|
}
|
||||||
|
// CodeMirror mode by extension — only yaml is vendored; others plaintext.
|
||||||
|
function codeMode(node) { return isYamlFile(node) ? 'yaml' : null; }
|
||||||
|
|
||||||
// ── Save (mirrors preview-markdown.js) ─────────────────────────────────
|
// ── Save (mirrors preview-markdown.js) ─────────────────────────────────
|
||||||
|
|
||||||
function saveContent(node, content, opts) {
|
function saveContent(node, content, opts) {
|
||||||
|
|
@ -345,8 +365,10 @@
|
||||||
schemaTag.addEventListener('keydown', function (ev) {
|
schemaTag.addEventListener('keydown', function (ev) {
|
||||||
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); openSchema(); }
|
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); openSchema(); }
|
||||||
});
|
});
|
||||||
} else {
|
} else if (isYamlFile(node)) {
|
||||||
schemaTag.textContent = 'YAML';
|
schemaTag.textContent = 'YAML';
|
||||||
|
} else {
|
||||||
|
schemaTag.textContent = (node.ext || 'text').toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
var dirtyEl = document.createElement('span');
|
var dirtyEl = document.createElement('span');
|
||||||
|
|
@ -386,16 +408,22 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
var writable = canSave(node);
|
var writable = canSave(node);
|
||||||
|
var mode = codeMode(node);
|
||||||
|
// Lint (js-yaml + the .zddc schema) only applies to YAML; other text
|
||||||
|
// types are plaintext, so skip the lint gutter for them.
|
||||||
|
var yamlMode = mode === 'yaml';
|
||||||
var editor = window.CodeMirror(editorHost, {
|
var editor = window.CodeMirror(editorHost, {
|
||||||
value: text,
|
value: text,
|
||||||
mode: 'yaml',
|
mode: mode,
|
||||||
lineNumbers: true,
|
lineNumbers: true,
|
||||||
tabSize: 2,
|
tabSize: 2,
|
||||||
indentUnit: 2,
|
indentUnit: 2,
|
||||||
indentWithTabs: false,
|
indentWithTabs: false,
|
||||||
lineWrapping: false,
|
lineWrapping: false,
|
||||||
gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'],
|
gutters: yamlMode
|
||||||
lint: { hasGutters: true },
|
? ['CodeMirror-lint-markers', 'CodeMirror-linenumbers']
|
||||||
|
: ['CodeMirror-linenumbers'],
|
||||||
|
lint: yamlMode ? { hasGutters: true } : false,
|
||||||
// autofocus:false keeps the keyboard caret in the browse
|
// autofocus:false keeps the keyboard caret in the browse
|
||||||
// tree pane so arrow-key nav can continue through yaml /
|
// tree pane so arrow-key nav can continue through yaml /
|
||||||
// .zddc files without diverting into the editor. User
|
// .zddc files without diverting into the editor. User
|
||||||
|
|
@ -413,8 +441,8 @@
|
||||||
// Stash the node on the editor so the lint helper can decide
|
// Stash the node on the editor so the lint helper can decide
|
||||||
// whether to apply the .zddc schema layer.
|
// whether to apply the .zddc schema layer.
|
||||||
editor._zddcNode = node;
|
editor._zddcNode = node;
|
||||||
// Force an initial lint pass now that _zddcNode is set.
|
// Force an initial lint pass now that _zddcNode is set (YAML only).
|
||||||
editor.performLint();
|
if (yamlMode) editor.performLint();
|
||||||
// Schema completion + hover docs for .zddc files (the machine grammar
|
// Schema completion + hover docs for .zddc files (the machine grammar
|
||||||
// drives keys, enum/boolean values, and nested paths via $ref:"#").
|
// drives keys, enum/boolean values, and nested paths via $ref:"#").
|
||||||
// Plain .yaml gets no schema (lint + highlighting only).
|
// Plain .yaml gets no schema (lint + highlighting only).
|
||||||
|
|
@ -543,8 +571,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function handles(node) {
|
function handles(node) {
|
||||||
if (!node || node.isDir || node.isZip) return false;
|
return isCodeFile(node);
|
||||||
return isYamlFile(node);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.app.modules.yamledit = {
|
window.app.modules.yamledit = {
|
||||||
|
|
|
||||||
|
|
@ -1,325 +0,0 @@
|
||||||
// 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 = '<div class="preview-empty" style="color:var(--danger)">'
|
|
||||||
+ 'Could not read ' + escapeHtml(node.name) + ': ' + escapeHtml(e.message || String(e)) + '</div>';
|
|
||||||
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 .5rem;';
|
|
||||||
help.textContent = editable
|
|
||||||
? 'Project options. Structural keys are read-only — use Edit raw YAML.'
|
|
||||||
: 'Read-only — you need admin authority over this path to edit it.';
|
|
||||||
shell.appendChild(help);
|
|
||||||
|
|
||||||
// ── OPTION fields ───────────────────────────────────────────────────
|
|
||||||
function section(title, hint, tight) {
|
|
||||||
var s = el('section', 'zf-section');
|
|
||||||
s.style.cssText = 'margin:0 0 1rem;';
|
|
||||||
var h = el('h3', null, title);
|
|
||||||
// `tight` drops the heading's top margin for the FIRST section so
|
|
||||||
// it doesn't stack with the intro's bottom margin (the gap above
|
|
||||||
// Title was reading as excessive). Later sections keep the gap.
|
|
||||||
h.style.cssText = 'font-size:1em;margin:' + (tight ? '0' : '.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'), true);
|
|
||||||
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);
|
|
||||||
|
|
@ -100,7 +100,7 @@
|
||||||
|
|
||||||
function editorModules() {
|
function editorModules() {
|
||||||
var m = window.app.modules;
|
var m = window.app.modules;
|
||||||
return [m.markdown, m.yamledit, m.zddcform].filter(Boolean);
|
return [m.markdown, m.yamledit].filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function disposeEditors() {
|
function disposeEditors() {
|
||||||
|
|
@ -211,39 +211,27 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// .zddc form view: a schema-driven form (option fields editable,
|
// CodeMirror editor: the general editor for editable text files that
|
||||||
// structure read-only) is the PRIMARY editor for .zddc files. It hands
|
// aren't markdown — yaml/.zddc (schema lint + completion + hover) plus
|
||||||
// off to the raw YAML editor on demand. Other YAML files skip it.
|
// txt/csv/tsv/json/xml/html/css/js/… as a plaintext code editor.
|
||||||
var zddcForm = window.app.modules.zddcform;
|
// Guided dialogs (Manage access, …) are the front door for the common
|
||||||
if (zddcForm && zddcForm.handles(node)) {
|
// .zddc tasks; this is the full/raw edit surface.
|
||||||
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.
|
|
||||||
var yamlMod = window.app.modules.yamledit;
|
var yamlMod = window.app.modules.yamledit;
|
||||||
if (yamlMod && yamlMod.handles(node)) {
|
if (yamlMod && yamlMod.handles(node)) {
|
||||||
try {
|
try {
|
||||||
await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
|
await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
renderError(container, 'YAML render failed: ' + (e.message || e));
|
renderError(container, 'Editor failed: ' + (e.message || e));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PDF / HTML → iframe.
|
// PDF → iframe (HTML now routes to the editor above).
|
||||||
if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
|
if (ext === 'pdf') {
|
||||||
try {
|
try {
|
||||||
var info = await getBlobUrl(node);
|
var info = await getBlobUrl(node);
|
||||||
if (seq !== renderSeq) return;
|
if (seq !== renderSeq) return;
|
||||||
var sandbox = (ext === 'pdf') ? '' : ' sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"';
|
container.innerHTML = '<iframe class="preview-iframe" src="' + escapeHtml(info.url) + '"></iframe>';
|
||||||
container.innerHTML = '<iframe class="preview-iframe" src="' + escapeHtml(info.url) + '"' + sandbox + '></iframe>';
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
renderError(container, e.message || String(e));
|
renderError(container, e.message || String(e));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue