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; @@ -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 { @@ -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 { @@ -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 { @@ -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;
}
}
}

33
assets/styles/magazine-editor.css

@ -89,9 +89,38 @@ @@ -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 {

16
templates/pages/magazine_edit.html.twig

@ -80,8 +80,6 @@ @@ -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') }}"
>
<p class="magazine-editor__status" data-magazine-hierarchy-editor-target="status" aria-live="polite"></p>
<div class="magazine-editor__toolbar">
<button type="button" class="btn btn-secondary btn-sm" data-mag-editor-cmd="add-top-category">
Add top-level category
@ -256,7 +254,19 @@ @@ -256,7 +254,19 @@
</div>
<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
</button>
</div>

Loading…
Cancel
Save