novela/containers/novela/static/builder.js
2026-03-26 10:24:57 +01:00

299 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── State ─────────────────────────────────────────────────────────────────────
const { draftId } = BUILDER;
let chapters = BUILDER.chapters ? BUILDER.chapters.slice() : [];
let currentIdx = -1;
let isDirty = false;
const AUTOSAVE_MS = 30_000;
// ── Init ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
if (typeof BUILDER === 'undefined') {
// Overzichtspagina — bind verwijder-knoppen drafts
bindDraftDeleteButtons();
return;
}
if (chapters.length === 0) {
apiAddChapter('Hoofdstuk 1', -1);
} else {
renderChapterList();
loadChapter(0);
}
setInterval(() => {
if (isDirty && currentIdx >= 0) saveCurrentChapter({ silent: true });
}, AUTOSAVE_MS);
bindToolbar();
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveCurrentChapter();
}
});
});
// ── Toolbar ───────────────────────────────────────────────────────────────────
function bindToolbar() {
document.querySelectorAll('.tb-btn[data-cmd]').forEach(btn => {
btn.addEventListener('mousedown', e => {
e.preventDefault();
document.execCommand(btn.dataset.cmd, false, null);
getEditor().focus();
});
});
}
function getEditor() {
return document.getElementById('builder-content');
}
// ── Status ────────────────────────────────────────────────────────────────────
function markDirty() {
isDirty = true;
setStatus('dirty', 'Niet-opgeslagen wijzigingen');
}
function setStatus(type, msg) {
const el = document.getElementById('save-status');
if (!el) return;
el.textContent = msg;
el.className = 'save-status' + (type ? ' ' + type : '');
}
// ── Chapter list ──────────────────────────────────────────────────────────────
function renderChapterList() {
const el = document.getElementById('chapter-list');
el.innerHTML = '';
chapters.forEach((ch, i) => {
const item = document.createElement('div');
item.className = 'chapter-item' + (i === currentIdx ? ' active' : '');
const label = document.createElement('span');
label.className = 'chapter-item-title';
label.textContent = ch.title || `Hoofdstuk ${i + 1}`;
label.onclick = () => switchChapter(i);
const del = document.createElement('button');
del.className = 'chapter-item-delete';
del.title = 'Hoofdstuk verwijderen';
del.textContent = '×';
del.onclick = e => { e.stopPropagation(); deleteChapter(i); };
item.appendChild(label);
item.appendChild(del);
el.appendChild(item);
});
}
// ── Load / switch ─────────────────────────────────────────────────────────────
async function switchChapter(idx) {
if (idx === currentIdx) return;
if (isDirty) await saveCurrentChapter({ silent: true });
loadChapter(idx);
}
function loadChapter(idx) {
currentIdx = idx;
isDirty = false;
const ch = chapters[idx];
const editor = getEditor();
editor.innerHTML = ch.content || '<p><br></p>';
editor.oninput = markDirty;
renderChapterList();
setStatus('', '');
}
// ── Save ──────────────────────────────────────────────────────────────────────
async function saveCurrentChapter({ silent = false } = {}) {
if (currentIdx < 0) return;
const content = getEditor().innerHTML;
const title = chapters[currentIdx].title;
try {
const resp = await fetch(`/api/builder/${draftId}/chapter/${currentIdx}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content }),
});
if (resp.ok) {
chapters[currentIdx].content = content;
isDirty = false;
if (!silent) {
const now = new Date().toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' });
setStatus('saved', `Opgeslagen om ${now}`);
}
} else {
setStatus('error', 'Opslaan mislukt');
}
} catch {
setStatus('error', 'Netwerkfout bij opslaan');
}
}
// ── Add chapter ───────────────────────────────────────────────────────────────
async function addChapter() {
const title = prompt('Naam van het nieuwe hoofdstuk:', `Hoofdstuk ${chapters.length + 1}`);
if (!title) return;
if (isDirty) await saveCurrentChapter({ silent: true });
await apiAddChapter(title, currentIdx);
}
async function apiAddChapter(title, afterIndex) {
const resp = await fetch(`/api/builder/${draftId}/chapter`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, after_index: afterIndex }),
});
if (!resp.ok) { setStatus('error', 'Hoofdstuk toevoegen mislukt'); return; }
const data = await resp.json();
await refreshDraft(data.index);
}
async function refreshDraft(targetIdx) {
const resp = await fetch(`/api/builder/${draftId}`);
if (!resp.ok) return;
const data = await resp.json();
chapters = data.chapters;
renderChapterList();
loadChapter(Math.min(targetIdx ?? currentIdx, chapters.length - 1));
}
// ── Delete chapter ────────────────────────────────────────────────────────────
async function deleteChapter(idx) {
if (chapters.length <= 1) { alert('Kan het laatste hoofdstuk niet verwijderen.'); return; }
if (!confirm(`Hoofdstuk "${chapters[idx].title}" verwijderen?`)) return;
const resp = await fetch(`/api/builder/${draftId}/chapter/${idx}`, { method: 'DELETE' });
if (!resp.ok) { setStatus('error', 'Verwijderen mislukt'); return; }
const data = await resp.json();
await refreshDraft(data.index);
}
// ── Toolbar acties ────────────────────────────────────────────────────────────
function insertBreak() {
const editor = getEditor();
editor.focus();
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
range.deleteContents();
const img = document.createElement('img');
img.src = '/static/break.png';
img.style.height = '15px';
img.className = 'scene-break';
img.alt = '* * *';
const center = document.createElement('center');
center.appendChild(img);
const p = document.createElement('p');
p.innerHTML = '<br>';
range.insertNode(p);
range.insertNode(center);
const newRange = document.createRange();
newRange.setStart(p, 0);
newRange.collapse(true);
sel.removeAllRanges();
sel.addRange(newRange);
markDirty();
}
function wrapBlockquote(cssClass) {
const editor = getEditor();
editor.focus();
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
const selectedText = range.toString();
if (!selectedText.trim()) return;
const bq = document.createElement('blockquote');
if (cssClass) bq.className = cssClass;
const p = document.createElement('p');
p.textContent = selectedText;
bq.appendChild(p);
range.deleteContents();
range.insertNode(bq);
markDirty();
}
// ── Normaliseer ───────────────────────────────────────────────────────────────
async function normalizeChapter() {
if (currentIdx < 0) return;
const rawHtml = getEditor().innerHTML;
const resp = await fetch(`/api/builder/${draftId}/normalize/${currentIdx}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: rawHtml }),
});
if (!resp.ok) { setStatus('error', 'Normaliseren mislukt'); return; }
const data = await resp.json();
if (!confirm('Normalisatie toepassen? Huidige inhoud wordt overschreven.')) return;
getEditor().innerHTML = data.content;
markDirty();
await saveCurrentChapter({ silent: true });
setStatus('saved', 'Genormaliseerd en opgeslagen');
}
// ── Publiceer ─────────────────────────────────────────────────────────────────
async function publishDraft() {
if (isDirty) await saveCurrentChapter({ silent: true });
if (!confirm('Dit boek publiceren en aan de library toevoegen?')) return;
const btn = document.getElementById('btn-publish');
btn.disabled = true;
setStatus('', 'Bezig met publiceren…');
try {
const resp = await fetch(`/api/builder/${draftId}/publish`, { method: 'POST' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
setStatus('error', err.error || 'Publiceren mislukt');
btn.disabled = false;
return;
}
const data = await resp.json();
window.location.href = `/library/book/${encodeURIComponent(data.filename)}`;
} catch {
setStatus('error', 'Netwerkfout bij publiceren');
btn.disabled = false;
}
}
// ── Draft verwijderen (overzichtspagina) ──────────────────────────────────────
function bindDraftDeleteButtons() {
document.querySelectorAll('.draft-card-delete').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
if (!confirm('Draft verwijderen?')) return;
await fetch(`/api/builder/${id}`, { method: 'DELETE' });
btn.closest('.draft-card').remove();
});
});
}