diff --git a/assets/bootstrap.js b/assets/bootstrap.js index ab27e11..93bf085 100644 --- a/assets/bootstrap.js +++ b/assets/bootstrap.js @@ -45,8 +45,8 @@ try { } try { app.register('magazine-hierarchy-editor', MagazineHierarchyEditorController); -} catch { - /* already registered by the bundle */ +} catch (e) { + console.warn('[bootstrap] magazine-hierarchy-editor did not register; editor buttons will not work.', e); } try { app.register('footer-magazine-edit', FooterMagazineEditController); diff --git a/assets/controllers/magazine_hierarchy_editor_controller.js b/assets/controllers/magazine_hierarchy_editor_controller.js index e7bb1c5..3e86f5d 100644 --- a/assets/controllers/magazine_hierarchy_editor_controller.js +++ b/assets/controllers/magazine_hierarchy_editor_controller.js @@ -9,23 +9,120 @@ const KIND_LONGFORM_DRAFT = 30024; * Only signs nodes that differ from the page load snapshot, plus any other index required for * graph closure (root + every nested kind-30040 `a` reachable from those events). */ -export default class extends Controller { - static targets = ['status', 'node']; +const DTAG_PATTERN = /^[a-zA-Z0-9_-]+$/; + +export default class MagazineHierarchyEditorController extends Controller { + static targets = ['status', 'node', 'nodes', 'newNodeTemplate', 'aRowTemplate']; static values = { publishUrl: String, csrf: String, ownerHex: String, rootDTag: String, + defaultPreservedJson: { type: String, default: '[]' }, + /** Site-owned NIP-56 `client` name; foreign `client` rows are dropped before signing. */ + clientTag: { type: String, default: '' }, }; connect() { this.nodeBaseline = new WeakMap(); this.captureBaselines(); + /** + * Clicks: `document` capture + `this.element.contains(target)` so we still run when bubble + * never reaches the panel (e.g. `stopPropagation` from another listener) or fieldset/legend + * hit-testing is odd. + */ + this._onDocClickCapture = this._onDocClickCapture.bind(this); + this._onPanelFocusOut = this._onPanelFocusOut.bind(this); + document.addEventListener('click', this._onDocClickCapture, true); + this.element.addEventListener('focusout', this._onPanelFocusOut); + } + + disconnect() { + document.removeEventListener('click', this._onDocClickCapture, true); + this.element.removeEventListener('focusout', this._onPanelFocusOut); + } + + /** + * @param {MouseEvent} ev + */ + _onDocClickCapture(ev) { + if (!(ev.target instanceof Node)) { + return; + } + if (!this.element.contains(ev.target)) { + return; + } + this._onPanelClick(ev); + } + + /** + * @param {MouseEvent} ev + */ + _onPanelClick(ev) { + if (!(ev.target instanceof Element)) { + return; + } + const t = ev.target; + if (t.closest('[data-mag-editor-cmd="publish"]')) { + void this._publish(); + return; + } + if (t.closest('[data-mag-editor-cmd="add-top-category"]')) { + this.addTopLevelCategory(); + return; + } + const addSubCmd = t.closest('[data-mag-editor-cmd="add-subcategory"]'); + if (addSubCmd instanceof HTMLElement) { + this.addSubcategory(ev, addSubCmd); + return; + } + const rmCatCmd = t.closest('[data-mag-editor-cmd="remove-category"]'); + if (rmCatCmd instanceof HTMLElement) { + this.removeCategory(ev, rmCatCmd); + return; + } + const addArticle = t.closest('[data-mag-a-add]'); + if (addArticle instanceof HTMLElement) { + this.addALine(ev, addArticle); + return; + } + const rmArticle = t.closest('.magazine-editor__a-remove-icon'); + if (rmArticle instanceof HTMLElement) { + this.removeALine(ev, rmArticle); + return; + } + const sub = t.closest('.magazine-editor__add-sub'); + if (sub instanceof HTMLElement) { + this.addSubcategory(ev, sub); + return; + } + const rmCat = t.closest('.magazine-editor__remove-node'); + if (rmCat instanceof HTMLElement) { + this.removeCategory(ev, rmCat); + return; + } + } + + /** + * @param {FocusEvent} ev + */ + _onPanelFocusOut(ev) { + const inp = ev.target; + if (!(inp instanceof HTMLInputElement)) { + return; + } + if (!inp.matches('[data-magazine-hierarchy-editor-target="dTag"]')) { + return; + } + this.commitDTag(ev); } captureBaselines() { - for (const el of this.nodeTargets) { + if (!this.hasNodesTarget) { + return; + } + for (const el of queryEditorNodeFieldsets(this.nodesTarget)) { this.nodeBaseline.set(el, snapshotFromElement(el)); } } @@ -34,6 +131,516 @@ export default class extends Controller { void this._publish(); } + /** + * @param {Event} event + * @param {HTMLElement} [triggerEl] + */ + addALine(event, triggerEl) { + const trigger = + triggerEl instanceof HTMLElement + ? triggerEl + : event.currentTarget instanceof HTMLElement && event.currentTarget.hasAttribute('data-mag-a-add') + ? event.currentTarget + : event.target instanceof Element + ? event.target.closest('[data-mag-a-add]') + : null; + if (!(trigger instanceof HTMLElement)) { + return; + } + const list = trigger.closest('[data-mag-a-list]'); + if (!list || !this.hasARowTemplateTarget) { + return; + } + const addBtn = list.querySelector('[data-mag-a-add]'); + if (!addBtn) { + return; + } + const frag = this.aRowTemplateTarget.content.cloneNode(true); + const row = frag.querySelector('[data-mag-a-row]'); + if (!row) { + return; + } + list.insertBefore(row, addBtn); + } + + /** + * @param {Event} event + * @param {HTMLElement} [triggerEl] + */ + removeALine(event, triggerEl) { + const trigger = + triggerEl instanceof HTMLElement + ? triggerEl + : event.currentTarget instanceof HTMLElement && event.currentTarget.classList.contains('magazine-editor__a-remove-icon') + ? event.currentTarget + : event.target instanceof Element + ? event.target.closest('.magazine-editor__a-remove-icon') + : null; + if (!(trigger instanceof HTMLElement)) { + return; + } + const row = trigger.closest('[data-mag-a-row]'); + row?.remove(); + } + + addTopLevelCategory() { + this.setStatus(''); + const rootD = (this.rootDTagValue || '').trim(); + if (rootD === '') { + this.setStatus('Missing root index #d.'); + return; + } + this.insertNewNode(rootD, 1); + } + + /** + * @param {Event} event + * @param {HTMLElement} [triggerEl] + */ + addSubcategory(event, triggerEl) { + this.setStatus(''); + const btn = + triggerEl instanceof HTMLElement + ? triggerEl + : event.currentTarget instanceof HTMLElement && event.currentTarget.classList.contains('magazine-editor__add-sub') + ? event.currentTarget + : event.target instanceof Element + ? event.target.closest('.magazine-editor__add-sub') + : null; + if (!(btn instanceof HTMLElement)) { + return; + } + const parentEl = btn.closest('[data-magazine-hierarchy-editor-target="node"]'); + if (!parentEl) { + return; + } + const rootD = (this.rootDTagValue || '').trim(); + const placed = (parentEl.dataset.magPlacedD || '').trim(); + const parentD = (placed !== '' ? placed : readDTag(parentEl)).trim(); + if (parentD === '' || parentD === rootD) { + this.setStatus('Use “Add top-level category” for indices linked from the root.'); + return; + } + const depth = parseInt(parentEl.dataset.magDepth || '1', 10); + this.insertNewNode(parentD, depth + 1); + } + + /** + * Blur on Index #d (new or existing category / subcategory). + * @param {Event} event + */ + commitDTag(event) { + const t = + event.target instanceof HTMLElement + ? event.target + : event.currentTarget instanceof HTMLElement + ? event.currentTarget + : null; + if (!(t instanceof HTMLElement)) { + return; + } + const el = t.closest('[data-magazine-hierarchy-editor-target="node"]'); + if (!el || isRootFieldset(el)) { + return; + } + if (el.dataset.isNewNode === '1') { + this.applySlugChange(el); + } else { + this.commitExistingDTagRename(el); + } + } + + /** + * @param {Event} event + * @param {HTMLElement} [triggerEl] + */ + removeCategory(event, triggerEl) { + this.setStatus(''); + const btn = + triggerEl instanceof HTMLElement + ? triggerEl + : event.currentTarget instanceof HTMLElement && event.currentTarget.classList.contains('magazine-editor__remove-node') + ? event.currentTarget + : event.target instanceof Element + ? event.target.closest('.magazine-editor__remove-node') + : null; + if (!(btn instanceof HTMLElement)) { + return; + } + const fs = btn.closest('[data-magazine-hierarchy-editor-target="node"]'); + if (!fs || isRootFieldset(fs)) { + return; + } + const ownerHex = (this.ownerHexValue || '').toLowerCase().trim(); + if (ownerHex.length !== 64) { + this.setStatus('Missing owner pubkey.'); + return; + } + + if (fs.dataset.isNewNode === '1') { + const parentD = (fs.dataset.magParentD || '').trim(); + const placed = (fs.dataset.placedSlug || '').trim(); + if (parentD !== '' && placed !== '') { + const parentFs = this.findFieldsetByDTag(parentD); + if (parentFs) { + this.remove30040ChildLine(parentFs, placed, ownerHex); + } + } + fs.remove(); + return; + } + + const sub = collectSubtreeFieldsets(fs, this.nodesTarget); + sub.sort((a, b) => parseInt(b.dataset.magDepth || '0', 10) - parseInt(a.dataset.magDepth || '0', 10)); + for (const n of sub) { + const p = (n.dataset.magParentD || '').trim(); + const d = readDTag(n); + if (p === '' || d === '') { + continue; + } + const pFs = this.findFieldsetByDTag(p); + if (pFs) { + this.remove30040ChildLine(pFs, d, ownerHex); + } + } + for (const n of sub) { + n.remove(); + } + } + + insertNewNode(parentD, depth) { + if (!this.hasNewNodeTemplateTarget || !this.hasNodesTarget) { + this.setStatus('Editor template is incomplete; reload the page.'); + return; + } + const ownerHex = (this.ownerHexValue || '').toLowerCase().trim(); + if (ownerHex.length !== 64) { + this.setStatus('Missing owner pubkey.'); + return; + } + const parentDTrim = parentD.trim(); + if (parentDTrim === '') { + return; + } + + const frag = this.newNodeTemplateTarget.content.cloneNode(true); + const fs = frag.querySelector('fieldset[data-magazine-hierarchy-editor-target="node"]'); + if (!fs) { + this.setStatus('Could not clone new node template.'); + return; + } + + fs.dataset.magParentD = parentDTrim; + fs.dataset.isNewNode = '1'; + fs.dataset.magDepth = String(depth); + const slug = `mag-new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + fs.dataset.placedSlug = slug; + + fs.style.setProperty('--mag-node-depth', String(depth)); + fs.style.marginLeft = `calc(max(0, var(--mag-node-depth) - 1) * 1.15rem)`; + if (depth > 1) { + fs.classList.add('magazine-editor__node--nested'); + } + + const lab = fs.querySelector('[data-new-node-legend-label]'); + if (lab) { + lab.textContent = depth <= 1 ? 'New category' : 'New subcategory'; + } + + let preservedStr = '[]'; + try { + preservedStr = JSON.stringify(JSON.parse(this.defaultPreservedJsonValue || '[]')); + } catch { + preservedStr = '[]'; + } + const preservedEl = fs.querySelector('[data-magazine-hierarchy-editor-target="preservedJson"]'); + if (preservedEl) { + preservedEl.value = preservedStr; + } + + const dIn = fs.querySelector('[data-magazine-hierarchy-editor-target="dTag"]'); + if (dIn && 'value' in dIn) { + dIn.value = slug; + } + + if (!this.append30040ChildLine(parentDTrim, slug, ownerHex)) { + this.setStatus( + `Could not add the nested kind-30040 link to the parent’s a list (parent #d “${parentDTrim}”). Reload the page if this persists.`, + ); + return; + } + const parentFs = this.findFieldsetByDTag(parentDTrim); + const anchor = parentFs ? lastDescendantFieldsetAmongNodes(parentFs, this.nodesTarget) : null; + if (anchor?.parentElement === this.nodesTarget) { + anchor.insertAdjacentElement('afterend', fs); + } else { + this.nodesTarget.appendChild(fs); + } + this.nodeBaseline.set(fs, snapshotFromElement(fs)); + } + + /** + * @param {HTMLElement} el + */ + applySlugChange(el) { + const ownerHex = (this.ownerHexValue || '').toLowerCase().trim(); + const parentD = (el.dataset.magParentD || '').trim(); + const placed = (el.dataset.placedSlug || '').trim(); + const next = readDTag(el).trim(); + if (placed === next) { + return; + } + if (parentD === '' || ownerHex.length !== 64) { + return; + } + const parentFs = this.findFieldsetByDTag(parentD); + if (!parentFs) { + return; + } + if (placed !== '') { + this.remove30040ChildLine(parentFs, placed, ownerHex); + } + if (next !== '') { + if (!this.append30040ChildLine(parentD, next, ownerHex)) { + if (placed !== '') { + this.append30040ChildLine(parentD, placed, ownerHex); + } + this.setStatus( + `Could not add #d “${next}” to the parent’s a list (parent “${parentD}”).`, + ); + return; + } + } + el.dataset.placedSlug = next; + } + + /** + * @param {HTMLElement} el Fieldset (not root, not new-node). + */ + commitExistingDTagRename(el) { + const ownerHex = (this.ownerHexValue || '').toLowerCase().trim(); + if (ownerHex.length !== 64) { + return; + } + const dIn = el.querySelector('[data-magazine-hierarchy-editor-target="dTag"]'); + const oldD = (el.dataset.magPlacedD || '').trim(); + const next = readDTag(el).trim(); + if (oldD === next) { + return; + } + if (next === '') { + this.setStatus('#d cannot be empty.'); + if (dIn instanceof HTMLInputElement) { + dIn.value = oldD; + } + return; + } + if (!DTAG_PATTERN.test(next)) { + this.setStatus(`Invalid #d "${next}" (use letters, digits, underscores, and hyphens only).`); + if (dIn instanceof HTMLInputElement) { + dIn.value = oldD; + } + return; + } + const rootD = (this.rootDTagValue || '').trim(); + if (next === rootD) { + this.setStatus('A category #d cannot equal the root magazine identifier.'); + if (dIn instanceof HTMLInputElement) { + dIn.value = oldD; + } + return; + } + for (const other of queryEditorNodeFieldsets(this.nodesTarget)) { + if (other === el) { + continue; + } + if (readDTag(other) === next) { + this.setStatus(`Duplicate #d "${next}".`); + if (dIn instanceof HTMLInputElement) { + dIn.value = oldD; + } + return; + } + } + const parentD = (el.dataset.magParentD || '').trim(); + if (parentD === '') { + return; + } + const parentFs = this.findFieldsetByDTag(parentD); + if (!parentFs) { + return; + } + const list = parentFs.querySelector('[data-mag-a-list]'); + if (!list) { + return; + } + if (!rewrite30040ChildLineInList(list, ownerHex, oldD, next)) { + this.setStatus(`Could not find parent link for old #d "${oldD}"; reload the page.`); + if (dIn instanceof HTMLInputElement) { + dIn.value = oldD; + } + return; + } + el.dataset.magPlacedD = next; + this.setStatus(''); + } + + /** + * @param {string} d + * @returns {HTMLElement | null} + */ + findFieldsetByDTag(d) { + const want = d.trim(); + if (!this.hasNodesTarget) { + return null; + } + for (const el of queryEditorNodeFieldsets(this.nodesTarget)) { + if (readDTag(el).trim() === want) { + return el; + } + } + + return null; + } + + /** + * @param {string} parentD Parent index #d (root or category). + * @param {string} childD Nested kind-30040 child #d. + * @param {string} ownerHex + */ + /** + * @returns {boolean} false if the parent fieldset or a-list could not be updated + */ + append30040ChildLine(parentD, childD, ownerHex) { + const parentFs = this.findFieldsetByDTag(parentD.trim()); + if (!parentFs) { + return false; + } + const list = parentFs.querySelector('[data-mag-a-list]'); + const addBtn = list?.querySelector('[data-mag-a-add]'); + if (!list || !addBtn || !this.hasARowTemplateTarget) { + return false; + } + const oh = ownerHex.toLowerCase(); + const cd = childD.trim(); + const want = `${KIND_PUBLICATION_INDEX}:${oh}:${cd}`; + for (const line of collectALineValuesFromList(list)) { + if (lineMatches30040Child(line, oh, cd)) { + return true; + } + } + const frag = this.aRowTemplateTarget.content.cloneNode(true); + const row = frag.querySelector('[data-mag-a-row]'); + const inp = row?.querySelector('[data-mag-a-line]'); + if (!row || !(inp instanceof HTMLInputElement)) { + return false; + } + inp.value = want; + list.insertBefore(row, addBtn); + return true; + } + + /** + * @param {HTMLElement} parentFs + * @param {string} childD + * @param {string} ownerHex + */ + remove30040ChildLine(parentFs, childD, ownerHex) { + const list = parentFs.querySelector('[data-mag-a-list]'); + if (!list) { + return; + } + const oh = ownerHex.toLowerCase(); + const cd = childD.trim(); + for (const row of list.querySelectorAll('[data-mag-a-row]')) { + const inp = row.querySelector('[data-mag-a-line]'); + if (!(inp instanceof HTMLInputElement)) { + continue; + } + if (lineMatches30040Child(inp.value, oh, cd)) { + row.remove(); + + return; + } + } + } + + /** + * @param {string} rootD + * @returns {string | null} + */ + validateAllNodes(rootD) { + const seen = new Set(); + if (!this.hasNodesTarget) { + return 'Editor is missing the nodes list.'; + } + for (const el of queryEditorNodeFieldsets(this.nodesTarget)) { + const d = readDTag(el).trim(); + if (d === '') { + return 'Every magazine index must have a non-empty #d identifier.'; + } + if (isRootFieldset(el)) { + if (d !== rootD) { + return 'Root fieldset #d does not match the configured magazine root.'; + } + } else { + if (!DTAG_PATTERN.test(d)) { + return `Invalid #d "${d}" (use letters, digits, underscores, and hyphens only).`; + } + if (d === rootD) { + return 'A category #d cannot equal the root magazine identifier.'; + } + } + if (seen.has(d)) { + return `Duplicate #d identifier: ${d}.`; + } + seen.add(d); + } + + return null; + } + + /** + * @param {HTMLElement} el + */ + finalizeNewNodeFieldset(el) { + const d = readDTag(el).trim(); + const depth = parseInt(el.dataset.magDepth || '1', 10); + el.querySelector('[data-new-node-d-row]')?.removeAttribute('data-new-node-d-row'); + + const legend = el.querySelector('legend.magazine-editor__legend'); + if (legend) { + const kindLabel = depth > 1 ? 'Subcategory' : 'Category'; + legend.replaceChildren(); + legend.className = 'magazine-editor__legend magazine-editor__legend--row'; + const main = document.createElement('span'); + main.className = 'magazine-editor__legend-main'; + main.append(`${kindLabel}`); + legend.appendChild(main); + const actions = document.createElement('span'); + actions.className = 'magazine-editor__legend-actions'; + const btnSub = document.createElement('button'); + btnSub.type = 'button'; + btnSub.className = 'btn btn-secondary btn-sm magazine-editor__add-sub'; + btnSub.setAttribute('data-mag-editor-cmd', 'add-subcategory'); + btnSub.textContent = 'Add subcategory'; + actions.appendChild(btnSub); + const btnRm = document.createElement('button'); + btnRm.type = 'button'; + btnRm.className = 'btn btn-secondary btn-sm magazine-editor__remove-node'; + btnRm.setAttribute('data-mag-editor-cmd', 'remove-category'); + btnRm.setAttribute('aria-label', 'Remove category'); + btnRm.textContent = 'Remove'; + actions.appendChild(btnRm); + legend.appendChild(actions); + } + + el.dataset.magPlacedD = d; + el.classList.remove('magazine-editor__node--new'); + el.removeAttribute('data-is-new-node'); + el.removeAttribute('data-placed-slug'); + } + async _publish() { this.setStatus(''); if (!this.hasNip07()) { @@ -51,12 +658,28 @@ export default class extends Controller { return; } - const nodes = this.nodeTargets; + if (!this.hasNodesTarget) { + this.setStatus('Editor is missing the nodes list.'); + return; + } + const nodes = queryEditorNodeFieldsets(this.nodesTarget); if (nodes.length === 0) { this.setStatus('Nothing to publish.'); return; } + for (const el of nodes) { + if (el.dataset.isNewNode === '1') { + this.applySlugChange(el); + } + } + + const graphErr = this.validateAllNodes(rootD); + if (graphErr !== null) { + this.setStatus(graphErr); + return; + } + const dirty = nodes.filter((el) => this.isNodeDirty(el)); if (dirty.length === 0) { this.setStatus('No changes to publish.'); @@ -92,7 +715,7 @@ export default class extends Controller { const title = el.querySelector('[data-magazine-hierarchy-editor-target="title"]')?.value ?? ''; const summary = el.querySelector('[data-magazine-hierarchy-editor-target="summary"]')?.value ?? ''; const content = el.querySelector('[data-magazine-hierarchy-editor-target="content"]')?.value ?? ''; - const aText = el.querySelector('[data-magazine-hierarchy-editor-target="aLines"]')?.value ?? ''; + const aText = readJoinedALinesFromNode(el); const preservedRaw = el.querySelector('[data-magazine-hierarchy-editor-target="preservedJson"]')?.value ?? '[]'; let preserved; @@ -156,18 +779,25 @@ export default class extends Controller { } const n = Number(data.published); for (const el of ordered) { + if (el.dataset.isNewNode === '1') { + this.finalizeNewNodeFieldset(el); + } this.nodeBaseline.set(el, snapshotFromElement(el)); } this.setStatus(Number.isFinite(n) ? `Published and stored ${n} index event(s).` : 'Published.'); } isNodeDirty(el) { + if (el.dataset.isNewNode === '1') { + return true; + } const cur = snapshotFromElement(el); const base = this.nodeBaseline.get(el); if (!base) { return true; } return ( + cur.dTag !== base.dTag || cur.title !== base.title || cur.summary !== base.summary || cur.content !== base.content || @@ -187,10 +817,12 @@ export default class extends Controller { async expandPublishSet(rootD, dirtyFieldsets, ownerHex) { /** @type {Map} */ const dToEl = new Map(); - for (const el of this.nodeTargets) { - const d = readDTag(el); - if (d) { - dToEl.set(d, el); + if (this.hasNodesTarget) { + for (const el of queryEditorNodeFieldsets(this.nodesTarget)) { + const d = readDTag(el); + if (d) { + dToEl.set(d, el); + } } } @@ -251,10 +883,17 @@ export default class extends Controller { if (norm[0].toLowerCase() === 'd') { continue; } + if (norm[0].toLowerCase() === 'client') { + continue; + } tags.push(norm); } tags.push(['title', title]); tags.push(['summary', summary]); + const clientName = (this.clientTagValue || '').trim(); + if (clientName !== '') { + tags.push(['client', clientName]); + } const lines = aText .split('\n') @@ -302,6 +941,85 @@ export default class extends Controller { } } +/** + * Index fieldsets that are direct children of the nodes list (excludes `