mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-10-27 08:40:39 +00:00
book(C.5.7): NIP-23 chapter import + pin/live toggle
Adds a source bar to the chapter editor so authors can drop in a naddr/nevent/note/hex id, pull the markdown via the shared relay resolver, and choose whether the chapter follows future edits. - Import button decodes the id with decodeNostrId, resolves the event through _resolveEmbedEvent (same logic the embed route uses), copies .content into chapterMarkdown, and records textRef. - nevent imports pin to the resolved event id; naddr imports default to live (follow d-tag). - Pin/live toggle: pinning a live chapter resolves the current event id and freezes it; flipping to live just clears textPinnedEventId. - Refresh re-resolves the linked source — picks up new versions on live links, just re-fetches the same id on pinned links. - Unlink confirms then drops textRef / textPinnedEventId. The local markdown stays put. - Source-bar status chip: "Local only" / "Live · d:…" / "Pinned · …", with short id + author preview. Disables the import input while linked so the path is unambiguous. - Picks up the article's NIP-23 title tag when the chapter is unnamed. Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
1 parent
3a97f54ea0
commit
af3cdf7af0
1 file changed
+248
+248
@@ -1527,6 +1527,31 @@
|
||||
.bookChapterEditorPages {
|
||||
font-size: .78rem; color: var(--muted); white-space: nowrap;
|
||||
}
|
||||
.bookChapterEditorSourceBar {
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
padding: 6px 0; border-bottom: 1px solid var(--line);
|
||||
font-size: .82rem;
|
||||
}
|
||||
.bookChapterEditorSourceBar input[type="text"] {
|
||||
flex: 1; min-width: 200px;
|
||||
padding: 4px 8px; border: 1px solid var(--line);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: .76rem;
|
||||
}
|
||||
.bookChapterEditorSourceBar button {
|
||||
padding: 3px 8px; font-size: .8rem;
|
||||
border: 1px solid var(--line); background: #fff; cursor: pointer;
|
||||
}
|
||||
.bookChapterEditorSourceLabel {
|
||||
font-size: .76rem; color: var(--muted); padding: 0 4px;
|
||||
border: 1px dashed var(--line); border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bookChapterEditorSourceLabel.linked { color: var(--accent); border-color: var(--accent); border-style: solid; }
|
||||
.bookChapterEditorSourceLabel.pinned { color: #6b21a8; border-color: #c084fc; border-style: solid; }
|
||||
.bookChapterEditorSourceMsg { font-size: .74rem; color: var(--muted); }
|
||||
.bookChapterEditorSourceMsg.error { color: #b91c1c; }
|
||||
.bookChapterEditorSourceMsg.ok { color: #15803d; }
|
||||
.bookChapterEditorBody {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
@@ -4331,6 +4356,15 @@
|
||||
<span class="bookViewSpacer"></span>
|
||||
<span class="hint" id="bookChapterEditorHint">Markdown autosaves to this book. Phase D1 publishes as a NIP-23 article.</span>
|
||||
</div>
|
||||
<div class="bookChapterEditorSourceBar">
|
||||
<span class="bookChapterEditorSourceLabel" id="bookChapterEditorSourceStatus">Local only</span>
|
||||
<input type="text" id="bookChapterEditorSourceInput" placeholder="naddr1… / nevent1… / note1… / hex" />
|
||||
<button type="button" id="bookChapterEditorImportBtn">Import from Nostr</button>
|
||||
<button type="button" id="bookChapterEditorRefreshBtn" style="display:none">↻ Refresh</button>
|
||||
<button type="button" id="bookChapterEditorPinBtn" style="display:none">Pin to current</button>
|
||||
<button type="button" id="bookChapterEditorUnlinkBtn" style="display:none">Unlink</button>
|
||||
<span class="bookChapterEditorSourceMsg" id="bookChapterEditorSourceMsg"></span>
|
||||
</div>
|
||||
<div class="bookChapterEditorBody">
|
||||
<textarea id="bookChapterEditorTextarea" placeholder="# Chapter 1 Write your chapter here…"></textarea>
|
||||
<div class="bookChapterEditorPreviewWrap">
|
||||
@@ -9647,6 +9681,212 @@
|
||||
pagesEl.textContent = `${wordCount} word${wordCount === 1 ? '' : 's'} · ${n} page${n === 1 ? '' : 's'}${warn}`;
|
||||
pagesEl.title = pag.warnings.join('\n');
|
||||
}
|
||||
syncBookChapterSourceBar(entry);
|
||||
}
|
||||
// Paint the source bar — status chip + button visibility based on
|
||||
// textRef / textPinnedEventId. Idempotent so callers can run it
|
||||
// after any state change.
|
||||
function syncBookChapterSourceBar(entry) {
|
||||
const status = document.getElementById('bookChapterEditorSourceStatus');
|
||||
const refreshBtn = document.getElementById('bookChapterEditorRefreshBtn');
|
||||
const pinBtn = document.getElementById('bookChapterEditorPinBtn');
|
||||
const unlinkBtn = document.getElementById('bookChapterEditorUnlinkBtn');
|
||||
const input = document.getElementById('bookChapterEditorSourceInput');
|
||||
if (!status || !entry) return;
|
||||
const ref = entry.textRef || null;
|
||||
const pinned = !!entry.textPinnedEventId;
|
||||
if (!ref) {
|
||||
status.textContent = 'Local only';
|
||||
status.className = 'bookChapterEditorSourceLabel';
|
||||
if (refreshBtn) refreshBtn.style.display = 'none';
|
||||
if (pinBtn) pinBtn.style.display = 'none';
|
||||
if (unlinkBtn) unlinkBtn.style.display = 'none';
|
||||
if (input) input.disabled = false;
|
||||
return;
|
||||
}
|
||||
// Linked. Label shows pin state + a short id.
|
||||
const shortId = (s) => (typeof s === 'string' && s.length > 14) ? s.slice(0, 8) + '…' + s.slice(-4) : (s || '');
|
||||
const refLabel = ref.dTag
|
||||
? `d:${shortId(ref.dTag)} by ${shortId(ref.pubkey || '')}`
|
||||
: (ref.eventId ? `event ${shortId(ref.eventId)}` : 'linked');
|
||||
status.textContent = pinned ? `Pinned · ${refLabel}` : `Live · ${refLabel}`;
|
||||
status.className = 'bookChapterEditorSourceLabel ' + (pinned ? 'pinned' : 'linked');
|
||||
if (refreshBtn) refreshBtn.style.display = '';
|
||||
if (pinBtn) {
|
||||
pinBtn.style.display = '';
|
||||
pinBtn.textContent = pinned ? 'Set to live' : 'Pin to current';
|
||||
}
|
||||
if (unlinkBtn) unlinkBtn.style.display = '';
|
||||
if (input) input.disabled = true;
|
||||
}
|
||||
// Import a NIP-23 article (or any markdown-bearing event) by
|
||||
// naddr / nevent / note / hex id. Resolves via the shared embed
|
||||
// event resolver, then drops .content into chapterMarkdown +
|
||||
// records textRef / textPinnedEventId on the entry.
|
||||
async function importBookChapterFromNostr() {
|
||||
const book = state.book;
|
||||
const id = book?.editingChapterId;
|
||||
if (!id) return;
|
||||
const entry = book.pages.find(p => p.id === id);
|
||||
if (!entry) return;
|
||||
const input = document.getElementById('bookChapterEditorSourceInput');
|
||||
const msg = document.getElementById('bookChapterEditorSourceMsg');
|
||||
const btn = document.getElementById('bookChapterEditorImportBtn');
|
||||
const setMsg = (text, kind) => {
|
||||
if (!msg) return;
|
||||
msg.textContent = text || '';
|
||||
msg.className = 'bookChapterEditorSourceMsg' + (kind ? ' ' + kind : '');
|
||||
};
|
||||
const raw = (input?.value || '').trim();
|
||||
if (!raw) { setMsg('Paste an naddr / nevent / note id first.', 'error'); return; }
|
||||
const decoded = decodeNostrId(raw);
|
||||
if (!decoded) { setMsg('Could not decode that id.', 'error'); return; }
|
||||
// Normalize hex / note into nevent shape for the resolver.
|
||||
const decodedForResolver = decoded.type === 'naddr'
|
||||
? decoded
|
||||
: { type: 'nevent', eventId: decoded.eventId, relays: decoded.relays || [], author: decoded.author || '', kind: decoded.kind || null };
|
||||
if (btn) btn.disabled = true;
|
||||
setMsg('Fetching…');
|
||||
try {
|
||||
const ev = await _resolveEmbedEvent(decodedForResolver);
|
||||
if (!ev) { setMsg('No event found on user relays.', 'error'); return; }
|
||||
pushHistory('book: chapter import');
|
||||
entry.chapterMarkdown = String(ev.content || '');
|
||||
entry.textRef = decoded.type === 'naddr'
|
||||
? { kind: decoded.kind, pubkey: decoded.pubkey, dTag: decoded.dTag, relays: decoded.relays || [] }
|
||||
: { eventId: ev.id, pubkey: ev.pubkey, kind: ev.kind || decoded.kind || null, relays: decoded.relays || [] };
|
||||
// nevent imports are inherently pinned (a specific event id);
|
||||
// naddr imports default to live (follow d-tag).
|
||||
entry.textPinnedEventId = decoded.type === 'naddr' ? null : ev.id;
|
||||
// Pick up the article's NIP-23 title tag if our chapter is
|
||||
// unnamed — saves a step.
|
||||
if (!entry.chapterTitle) {
|
||||
const title = (ev.tags || []).find(t => t[0] === 'title' && t[1])?.[1];
|
||||
if (title) entry.chapterTitle = String(title);
|
||||
}
|
||||
if (input) input.value = '';
|
||||
setMsg(`Imported ${String(ev.content || '').length.toLocaleString()} chars.`, 'ok');
|
||||
syncBookChapterEditor();
|
||||
render();
|
||||
} catch (err) {
|
||||
console.warn('chapter import failed', err);
|
||||
setMsg('Import failed: ' + (err?.message || 'unknown error'), 'error');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
// Re-fetch a linked chapter. For naddr-linked entries this picks
|
||||
// up new versions; for pinned/nevent entries it just refreshes
|
||||
// the same event from relays.
|
||||
async function refreshBookChapterFromNostr() {
|
||||
const book = state.book;
|
||||
const id = book?.editingChapterId;
|
||||
if (!id) return;
|
||||
const entry = book.pages.find(p => p.id === id);
|
||||
if (!entry || !entry.textRef) return;
|
||||
const msg = document.getElementById('bookChapterEditorSourceMsg');
|
||||
const setMsg = (text, kind) => {
|
||||
if (!msg) return;
|
||||
msg.textContent = text || '';
|
||||
msg.className = 'bookChapterEditorSourceMsg' + (kind ? ' ' + kind : '');
|
||||
};
|
||||
const ref = entry.textRef;
|
||||
const pinned = entry.textPinnedEventId;
|
||||
// Pinned → resolve by event id. Live → resolve by d-tag coord.
|
||||
const decoded = pinned
|
||||
? { type: 'nevent', eventId: pinned, relays: ref.relays || [], author: ref.pubkey || '', kind: ref.kind || null }
|
||||
: { type: 'naddr', kind: ref.kind, pubkey: ref.pubkey, dTag: ref.dTag, relays: ref.relays || [] };
|
||||
const btn = document.getElementById('bookChapterEditorRefreshBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
setMsg('Refreshing…');
|
||||
try {
|
||||
const ev = await _resolveEmbedEvent(decoded);
|
||||
if (!ev) { setMsg('No event found.', 'error'); return; }
|
||||
if (String(entry.chapterMarkdown || '') === String(ev.content || '')) {
|
||||
setMsg('Already up to date.', 'ok');
|
||||
return;
|
||||
}
|
||||
pushHistory('book: chapter refresh');
|
||||
entry.chapterMarkdown = String(ev.content || '');
|
||||
if (pinned) entry.textPinnedEventId = ev.id;
|
||||
setMsg(`Updated to ${String(ev.content || '').length.toLocaleString()} chars.`, 'ok');
|
||||
syncBookChapterEditor();
|
||||
render();
|
||||
} catch (err) {
|
||||
console.warn('chapter refresh failed', err);
|
||||
setMsg('Refresh failed: ' + (err?.message || 'unknown error'), 'error');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
// Flip pin/live on a linked chapter. Pinning grabs the current
|
||||
// event id (re-resolving so we capture the latest version
|
||||
// explicitly). Setting to live just clears textPinnedEventId.
|
||||
async function toggleBookChapterPin() {
|
||||
const book = state.book;
|
||||
const id = book?.editingChapterId;
|
||||
if (!id) return;
|
||||
const entry = book.pages.find(p => p.id === id);
|
||||
if (!entry || !entry.textRef) return;
|
||||
const msg = document.getElementById('bookChapterEditorSourceMsg');
|
||||
const setMsg = (text, kind) => {
|
||||
if (!msg) return;
|
||||
msg.textContent = text || '';
|
||||
msg.className = 'bookChapterEditorSourceMsg' + (kind ? ' ' + kind : '');
|
||||
};
|
||||
if (entry.textPinnedEventId) {
|
||||
pushHistory('book: chapter set live');
|
||||
entry.textPinnedEventId = null;
|
||||
setMsg('Following live d-tag.', 'ok');
|
||||
syncBookChapterEditor();
|
||||
return;
|
||||
}
|
||||
// Pinning: resolve current event id so we can pin to it.
|
||||
const ref = entry.textRef;
|
||||
if (!ref.dTag || !ref.pubkey) {
|
||||
// nevent imports already carry their own id; pin without resolving.
|
||||
if (ref.eventId) {
|
||||
pushHistory('book: chapter pin');
|
||||
entry.textPinnedEventId = ref.eventId;
|
||||
setMsg('Pinned.', 'ok');
|
||||
syncBookChapterEditor();
|
||||
return;
|
||||
}
|
||||
setMsg('Nothing to pin — link lacks a usable id.', 'error');
|
||||
return;
|
||||
}
|
||||
const decoded = { type: 'naddr', kind: ref.kind, pubkey: ref.pubkey, dTag: ref.dTag, relays: ref.relays || [] };
|
||||
const pinBtn = document.getElementById('bookChapterEditorPinBtn');
|
||||
if (pinBtn) pinBtn.disabled = true;
|
||||
setMsg('Resolving current version…');
|
||||
try {
|
||||
const ev = await _resolveEmbedEvent(decoded);
|
||||
if (!ev) { setMsg('Could not resolve current version.', 'error'); return; }
|
||||
pushHistory('book: chapter pin');
|
||||
entry.textPinnedEventId = ev.id;
|
||||
setMsg(`Pinned to ${ev.id.slice(0, 8)}…`, 'ok');
|
||||
syncBookChapterEditor();
|
||||
} catch (err) {
|
||||
console.warn('chapter pin failed', err);
|
||||
setMsg('Pin failed: ' + (err?.message || 'unknown error'), 'error');
|
||||
} finally {
|
||||
if (pinBtn) pinBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
function unlinkBookChapterFromNostr() {
|
||||
const book = state.book;
|
||||
const id = book?.editingChapterId;
|
||||
if (!id) return;
|
||||
const entry = book.pages.find(p => p.id === id);
|
||||
if (!entry || !entry.textRef) return;
|
||||
if (!confirm('Disconnect this chapter from its Nostr source? The markdown stays here; future edits on Nostr won\'t flow in.')) return;
|
||||
pushHistory('book: chapter unlink');
|
||||
entry.textRef = null;
|
||||
entry.textPinnedEventId = null;
|
||||
const msg = document.getElementById('bookChapterEditorSourceMsg');
|
||||
if (msg) { msg.textContent = 'Unlinked.'; msg.className = 'bookChapterEditorSourceMsg ok'; }
|
||||
syncBookChapterEditor();
|
||||
render();
|
||||
}
|
||||
// Minimal HTML escape + marked render. Sanitizes by routing
|
||||
// through marked.parse with `mangle:false, headerIds:false`
|
||||
@@ -32948,6 +33188,14 @@
|
||||
on('bookChapterEditorBack', 'click', closeBookChapter);
|
||||
on('bookChapterEditorTextarea', 'input', (e) => updateBookChapterMarkdown(e.target.value));
|
||||
on('bookChapterEditorTitle', 'input', (e) => updateBookChapterTitle(e.target.value));
|
||||
on('bookChapterEditorImportBtn', 'click', importBookChapterFromNostr);
|
||||
on('bookChapterEditorRefreshBtn', 'click', refreshBookChapterFromNostr);
|
||||
on('bookChapterEditorPinBtn', 'click', toggleBookChapterPin);
|
||||
on('bookChapterEditorUnlinkBtn', 'click', unlinkBookChapterFromNostr);
|
||||
// Enter in the import input submits.
|
||||
on('bookChapterEditorSourceInput', 'keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); importBookChapterFromNostr(); }
|
||||
});
|
||||
on('bookViewTogglePages', 'click', () => setBookSubView('pages'));
|
||||
on('bookViewToggleCover', 'click', () => setBookSubView('cover'));
|
||||
on('bookPageEditBack', 'click', () => {
|
||||
|
||||
Reference in new issue
Block a user