diff --git a/assets/controllers/magazine_hierarchy_editor_controller.js b/assets/controllers/magazine_hierarchy_editor_controller.js index 2591963..f1971c0 100644 --- a/assets/controllers/magazine_hierarchy_editor_controller.js +++ b/assets/controllers/magazine_hierarchy_editor_controller.js @@ -11,7 +11,7 @@ const KIND_LONGFORM_DRAFT = 30024; const DTAG_PATTERN = /^[a-zA-Z0-9_-]+$/; export default class MagazineHierarchyEditorController extends Controller { - static targets = ['status', 'node', 'nodes', 'newNodeTemplate', 'aRowTemplate']; + static targets = ['status', 'publishBtn', 'node', 'nodes', 'newNodeTemplate', 'aRowTemplate']; static values = { publishUrl: String, @@ -126,7 +126,11 @@ export default class MagazineHierarchyEditorController extends Controller { } } - publish() { + /** + * @param {Event} [event] + */ + publish(event) { + event?.preventDefault?.(); void this._publish(); } @@ -641,135 +645,168 @@ export default class MagazineHierarchyEditorController extends Controller { } async _publish() { - this.setStatus(''); - if (!this.hasNip07()) { - this.setStatus('Install a Nostr extension (NIP-07) to sign index events.'); - return; - } - const ownerHex = (this.ownerHexValue || '').toLowerCase().trim(); - if (ownerHex.length !== 64) { - this.setStatus('Missing owner pubkey.'); - return; - } - const rootD = (this.rootDTagValue || '').trim(); - if (rootD === '') { - this.setStatus('Missing root index #d.'); + if (this._publishInFlight) { return; } + this._publishInFlight = true; + this.setPublishBusy(true, 'Checking…'); + this.setStatus('Checking for changes…', { tone: 'info', scroll: true }); - 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; - } + try { + if (!this.hasNip07()) { + this.setStatus('Install a Nostr extension (NIP-07) to sign index events.', { tone: 'error', scroll: true }); + return; + } + const ownerHex = (this.ownerHexValue || '').toLowerCase().trim(); + if (ownerHex.length !== 64) { + this.setStatus('Missing owner pubkey.', { tone: 'error', scroll: true }); + return; + } + const rootD = (this.rootDTagValue || '').trim(); + if (rootD === '') { + this.setStatus('Missing root index #d.', { tone: 'error', scroll: true }); + return; + } - for (const el of nodes) { - if (el.dataset.isNewNode === '1') { - this.applySlugChange(el); + if (!this.hasNodesTarget) { + this.setStatus('Editor is missing the nodes list.', { tone: 'error', scroll: true }); + return; + } + const nodes = queryEditorNodeFieldsets(this.nodesTarget); + if (nodes.length === 0) { + this.setStatus('Nothing to publish.', { tone: 'error', scroll: true }); + return; } - } - const graphErr = this.validateAllNodes(rootD); - if (graphErr !== null) { - this.setStatus(graphErr); - return; - } + for (const el of nodes) { + if (el.dataset.isNewNode === '1') { + this.applySlugChange(el); + } + } - const ordered = nodes.filter((el) => this.isNodeDirty(el)); - if (ordered.length === 0) { - this.setStatus('No changes to publish.'); - return; - } + const graphErr = this.validateAllNodes(rootD); + if (graphErr !== null) { + this.setStatus(graphErr, { tone: 'error', scroll: true }); + return; + } - const baseTime = Math.floor(Date.now() / 1000); - const signedEvents = []; + const ordered = nodes.filter((el) => this.isNodeDirty(el)); + if (ordered.length === 0) { + this.setStatus('No changes to publish.', { tone: 'info', scroll: true }); + return; + } - this.setStatus(ordered.length === 1 ? 'Signing…' : `Signing ${ordered.length} changed index event(s)…`); + const baseTime = Math.floor(Date.now() / 1000); + const signedEvents = []; + + const signingLabel = + ordered.length === 1 + ? 'Signing 1 event in extension…' + : `Signing ${ordered.length} events in extension…`; + this.setPublishBusy(true, 'Waiting for extension…'); + this.setStatus(signingLabel, { tone: 'info', scroll: true }); + + for (let i = 0; i < ordered.length; i++) { + const el = ordered[i]; + const dTag = readDTag(el); + 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 = readJoinedALinesFromNode(el); + const preservedRaw = + el.querySelector('[data-magazine-hierarchy-editor-target="preservedJson"]')?.value ?? '[]'; + + let preserved; + try { + preserved = JSON.parse(preservedRaw); + } catch { + this.setStatus(`Invalid preserved-tags JSON for #d ${dTag}.`, { tone: 'error', scroll: true }); + return; + } + if (!Array.isArray(preserved)) { + this.setStatus(`Preserved tags must be a JSON array for #d ${dTag}.`, { tone: 'error', scroll: true }); + return; + } - for (let i = 0; i < ordered.length; i++) { - const el = ordered[i]; - const dTag = readDTag(el); - 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 = readJoinedALinesFromNode(el); - const preservedRaw = el.querySelector('[data-magazine-hierarchy-editor-target="preservedJson"]')?.value ?? '[]'; + let tags; + try { + tags = await this.buildTags(dTag, preserved, title, summary, aText, ownerHex); + } catch (err) { + this.setStatus(err instanceof Error ? err.message : String(err), { tone: 'error', scroll: true }); + return; + } - let preserved; - try { - preserved = JSON.parse(preservedRaw); - } catch { - this.setStatus(`Invalid preserved-tags JSON for #d ${dTag}.`); - return; - } - if (!Array.isArray(preserved)) { - this.setStatus(`Preserved tags must be a JSON array for #d ${dTag}.`); - return; + const unsigned = { + kind: KIND_PUBLICATION_INDEX, + created_at: baseTime + i, + tags, + content: content ?? '', + }; + + let signed; + try { + signed = await window.nostr.signEvent(unsigned); + } catch (err) { + this.setStatus(`Signing failed (#d ${dTag}): ${err instanceof Error ? err.message : String(err)}`, { + tone: 'error', + scroll: true, + }); + return; + } + signedEvents.push(signed); } - let tags; + this.setPublishBusy(true, 'Publishing…'); + this.setStatus('Publishing to relays and saving…', { tone: 'info', scroll: true }); + let res; try { - tags = await this.buildTags(dTag, preserved, title, summary, aText, ownerHex); + res = await fetch(this.publishUrlValue, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + credentials: 'same-origin', + body: JSON.stringify({ csrf: this.csrfValue, events: signedEvents }), + }); } catch (err) { - this.setStatus(err instanceof Error ? err.message : String(err)); + this.setStatus(`Network error: ${err instanceof Error ? err.message : String(err)}`, { + tone: 'error', + scroll: true, + }); return; } - const unsigned = { - kind: KIND_PUBLICATION_INDEX, - created_at: baseTime + i, - tags, - content: content ?? '', - }; - - let signed; - try { - signed = await window.nostr.signEvent(unsigned); - } catch (err) { - this.setStatus(`Signing failed (#d ${dTag}): ${err instanceof Error ? err.message : String(err)}`); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + this.setStatus(typeof data.error === 'string' ? data.error : `HTTP ${res.status}`, { + tone: 'error', + scroll: true, + }); return; } - signedEvents.push(signed); - } - - this.setStatus('Publishing…'); - let res; - try { - res = await fetch(this.publishUrlValue, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - credentials: 'same-origin', - body: JSON.stringify({ csrf: this.csrfValue, events: signedEvents }), - }); - } catch (err) { - this.setStatus(`Network error: ${err instanceof Error ? err.message : String(err)}`); - return; - } - - const data = await res.json().catch(() => ({})); - if (!res.ok) { - this.setStatus(typeof data.error === 'string' ? data.error : `HTTP ${res.status}`); - return; - } - const n = Number(data.published); - const ingested = Number(data.longform_ingest_addresses); - for (const el of ordered) { - if (el.dataset.isNewNode === '1') { - this.finalizeNewNodeFieldset(el); + const n = Number(data.published); + const ingested = Number(data.longform_ingest_addresses); + for (const el of ordered) { + if (el.dataset.isNewNode === '1') { + this.finalizeNewNodeFieldset(el); + } + this.nodeBaseline.set(el, snapshotFromElement(el)); } - this.nodeBaseline.set(el, snapshotFromElement(el)); - } - if (Number.isFinite(n) && Number.isFinite(ingested) && ingested > 0) { - this.setStatus(`Published and stored ${n} index event(s); synced ${ingested} long-form address(es) from relays.`); - } else { - this.setStatus(Number.isFinite(n) ? `Published and stored ${n} index event(s).` : 'Published.'); + if (Number.isFinite(n) && Number.isFinite(ingested) && ingested > 0) { + this.setStatus( + `Published and stored ${n} index event(s); synced ${ingested} long-form address(es) from relays.`, + { tone: 'success', scroll: true }, + ); + } else { + this.setStatus(Number.isFinite(n) ? `Published and stored ${n} index event(s).` : 'Published.', { + tone: 'success', + scroll: true, + }); + } + } finally { + this._publishInFlight = false; + this.setPublishBusy(false); } } @@ -862,9 +899,56 @@ export default class MagazineHierarchyEditorController extends Controller { } } - setStatus(msg) { - if (this.hasStatusTarget) { - this.statusTarget.textContent = msg; + /** + * @param {string} msg + * @param {{ tone?: 'info' | 'success' | 'error', scroll?: boolean }} [opts] + */ + setStatus(msg, opts = {}) { + if (!this.hasStatusTarget) { + return; + } + const el = this.statusTarget; + const tone = opts.tone ?? 'info'; + const trimmed = msg.trim(); + el.textContent = trimmed; + el.hidden = trimmed === ''; + el.classList.remove( + 'magazine-editor__status--info', + 'magazine-editor__status--success', + 'magazine-editor__status--error', + ); + if (trimmed !== '') { + el.classList.add(`magazine-editor__status--${tone}`); + if (opts.scroll) { + el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + } + } + + /** + * @param {boolean} busy + * @param {string} [label] + */ + setPublishBusy(busy, label) { + if (!this.hasPublishBtnTarget) { + return; + } + const btn = this.publishBtnTarget; + if (busy) { + if (!this._publishBtnDefaultLabel) { + this._publishBtnDefaultLabel = btn.textContent?.trim() ?? 'Sign and publish all changed events'; + } + btn.disabled = true; + btn.setAttribute('aria-busy', 'true'); + if (label) { + btn.textContent = label; + } + } else { + btn.disabled = false; + btn.removeAttribute('aria-busy'); + if (this._publishBtnDefaultLabel) { + btn.textContent = this._publishBtnDefaultLabel; + } } } diff --git a/assets/styles/magazine-editor.css b/assets/styles/magazine-editor.css index f9254e3..9f50456 100644 --- a/assets/styles/magazine-editor.css +++ b/assets/styles/magazine-editor.css @@ -89,9 +89,38 @@ } .magazine-editor__status { - min-height: 1.5rem; - margin: 0 0 1rem; + margin: 0 0 0.75rem; + padding: 0.65rem 0.85rem; + border-radius: 5px; font-size: 0.95rem; + line-height: 1.4; +} + +.magazine-editor__status[hidden] { + display: none; +} + +.magazine-editor__status--info { + background-color: var(--color-bg-light); + color: var(--color-text); + border: 1px solid var(--color-border, #3a3a3a); +} + +.magazine-editor__status--success { + background-color: color-mix(in srgb, var(--color-bg-light) 80%, #2d7a4a); + color: var(--color-text); + border: 1px solid color-mix(in srgb, #2d7a4a 35%, transparent); +} + +.magazine-editor__status--error { + background-color: color-mix(in srgb, var(--color-bg-light) 85%, #c0392b); + color: var(--color-text); + border: 1px solid color-mix(in srgb, #c0392b 35%, transparent); +} + +.magazine-editor__actions .btn[aria-busy='true'] { + cursor: wait; + opacity: 0.85; } .magazine-editor__nodes { diff --git a/templates/pages/magazine_edit.html.twig b/templates/pages/magazine_edit.html.twig index f5e8e21..bdd0ac9 100644 --- a/templates/pages/magazine_edit.html.twig +++ b/templates/pages/magazine_edit.html.twig @@ -80,8 +80,6 @@ data-magazine-hierarchy-editor-default-preserved-json-value="{{ editor_payload.default_category_preserved_tags|json_encode(constant('JSON_UNESCAPED_UNICODE'))|e('html_attr') }}" data-magazine-hierarchy-editor-client-tag-value="{{ website_short_name|e('html_attr') }}" > -
-