Browse Source

make magazine editor respond when saving

imwald
Silberengel 2 weeks ago
parent
commit
63786c5e8b
  1. 310
      assets/controllers/magazine_hierarchy_editor_controller.js
  2. 33
      assets/styles/magazine-editor.css
  3. 16
      templates/pages/magazine_edit.html.twig

310
assets/controllers/magazine_hierarchy_editor_controller.js

@ -11,7 +11,7 @@ const KIND_LONGFORM_DRAFT = 30024;
const DTAG_PATTERN = /^[a-zA-Z0-9_-]+$/; const DTAG_PATTERN = /^[a-zA-Z0-9_-]+$/;
export default class MagazineHierarchyEditorController extends Controller { export default class MagazineHierarchyEditorController extends Controller {
static targets = ['status', 'node', 'nodes', 'newNodeTemplate', 'aRowTemplate']; static targets = ['status', 'publishBtn', 'node', 'nodes', 'newNodeTemplate', 'aRowTemplate'];
static values = { static values = {
publishUrl: String, publishUrl: String,
@ -126,7 +126,11 @@ export default class MagazineHierarchyEditorController extends Controller {
} }
} }
publish() { /**
* @param {Event} [event]
*/
publish(event) {
event?.preventDefault?.();
void this._publish(); void this._publish();
} }
@ -641,135 +645,168 @@ export default class MagazineHierarchyEditorController extends Controller {
} }
async _publish() { async _publish() {
this.setStatus(''); if (this._publishInFlight) {
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.');
return; return;
} }
this._publishInFlight = true;
this.setPublishBusy(true, 'Checking…');
this.setStatus('Checking for changes…', { tone: 'info', scroll: true });
if (!this.hasNodesTarget) { try {
this.setStatus('Editor is missing the nodes list.'); if (!this.hasNip07()) {
return; this.setStatus('Install a Nostr extension (NIP-07) to sign index events.', { tone: 'error', scroll: true });
} return;
const nodes = queryEditorNodeFieldsets(this.nodesTarget); }
if (nodes.length === 0) { const ownerHex = (this.ownerHexValue || '').toLowerCase().trim();
this.setStatus('Nothing to publish.'); if (ownerHex.length !== 64) {
return; 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 (!this.hasNodesTarget) {
if (el.dataset.isNewNode === '1') { this.setStatus('Editor is missing the nodes list.', { tone: 'error', scroll: true });
this.applySlugChange(el); 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); for (const el of nodes) {
if (graphErr !== null) { if (el.dataset.isNewNode === '1') {
this.setStatus(graphErr); this.applySlugChange(el);
return; }
} }
const ordered = nodes.filter((el) => this.isNodeDirty(el)); const graphErr = this.validateAllNodes(rootD);
if (ordered.length === 0) { if (graphErr !== null) {
this.setStatus('No changes to publish.'); this.setStatus(graphErr, { tone: 'error', scroll: true });
return; return;
} }
const baseTime = Math.floor(Date.now() / 1000); const ordered = nodes.filter((el) => this.isNodeDirty(el));
const signedEvents = []; 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++) { let tags;
const el = ordered[i]; try {
const dTag = readDTag(el); tags = await this.buildTags(dTag, preserved, title, summary, aText, ownerHex);
const title = el.querySelector('[data-magazine-hierarchy-editor-target="title"]')?.value ?? ''; } catch (err) {
const summary = el.querySelector('[data-magazine-hierarchy-editor-target="summary"]')?.value ?? ''; this.setStatus(err instanceof Error ? err.message : String(err), { tone: 'error', scroll: true });
const content = el.querySelector('[data-magazine-hierarchy-editor-target="content"]')?.value ?? ''; return;
const aText = readJoinedALinesFromNode(el); }
const preservedRaw = el.querySelector('[data-magazine-hierarchy-editor-target="preservedJson"]')?.value ?? '[]';
let preserved; const unsigned = {
try { kind: KIND_PUBLICATION_INDEX,
preserved = JSON.parse(preservedRaw); created_at: baseTime + i,
} catch { tags,
this.setStatus(`Invalid preserved-tags JSON for #d ${dTag}.`); content: content ?? '',
return; };
}
if (!Array.isArray(preserved)) { let signed;
this.setStatus(`Preserved tags must be a JSON array for #d ${dTag}.`); try {
return; 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 { 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) { } 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; return;
} }
const unsigned = { const data = await res.json().catch(() => ({}));
kind: KIND_PUBLICATION_INDEX, if (!res.ok) {
created_at: baseTime + i, this.setStatus(typeof data.error === 'string' ? data.error : `HTTP ${res.status}`, {
tags, tone: 'error',
content: content ?? '', scroll: true,
}; });
let signed;
try {
signed = await window.nostr.signEvent(unsigned);
} catch (err) {
this.setStatus(`Signing failed (#d ${dTag}): ${err instanceof Error ? err.message : String(err)}`);
return; return;
} }
signedEvents.push(signed); const n = Number(data.published);
} const ingested = Number(data.longform_ingest_addresses);
for (const el of ordered) {
this.setStatus('Publishing…'); if (el.dataset.isNewNode === '1') {
let res; this.finalizeNewNodeFieldset(el);
try { }
res = await fetch(this.publishUrlValue, { this.nodeBaseline.set(el, snapshotFromElement(el));
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);
} }
this.nodeBaseline.set(el, snapshotFromElement(el)); if (Number.isFinite(n) && Number.isFinite(ingested) && ingested > 0) {
} this.setStatus(
if (Number.isFinite(n) && Number.isFinite(ingested) && ingested > 0) { `Published and stored ${n} index event(s); synced ${ingested} long-form address(es) from relays.`,
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.'); } 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) { * @param {string} msg
this.statusTarget.textContent = 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;
}
} }
} }

33
assets/styles/magazine-editor.css

@ -89,9 +89,38 @@
} }
.magazine-editor__status { .magazine-editor__status {
min-height: 1.5rem; margin: 0 0 0.75rem;
margin: 0 0 1rem; padding: 0.65rem 0.85rem;
border-radius: 5px;
font-size: 0.95rem; 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 { .magazine-editor__nodes {

16
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-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') }}" data-magazine-hierarchy-editor-client-tag-value="{{ website_short_name|e('html_attr') }}"
> >
<p class="magazine-editor__status" data-magazine-hierarchy-editor-target="status" aria-live="polite"></p>
<div class="magazine-editor__toolbar"> <div class="magazine-editor__toolbar">
<button type="button" class="btn btn-secondary btn-sm" data-mag-editor-cmd="add-top-category"> <button type="button" class="btn btn-secondary btn-sm" data-mag-editor-cmd="add-top-category">
Add top-level category Add top-level category
@ -256,7 +254,19 @@
</div> </div>
<div class="magazine-editor__actions"> <div class="magazine-editor__actions">
<button type="button" class="btn btn-primary" data-mag-editor-cmd="publish"> <p
class="magazine-editor__status"
data-magazine-hierarchy-editor-target="status"
aria-live="polite"
hidden
></p>
<button
type="button"
class="btn btn-primary"
data-magazine-hierarchy-editor-target="publishBtn"
data-mag-editor-cmd="publish"
data-action="click->magazine-hierarchy-editor#publish"
>
Sign and publish all changed events Sign and publish all changed events
</button> </button>
</div> </div>

Loading…
Cancel
Save