diff --git a/containers/novela/epub.py b/containers/novela/epub.py
index 59c9a96..fef8474 100644
--- a/containers/novela/epub.py
+++ b/containers/novela/epub.py
@@ -1,7 +1,9 @@
import io
import re
import zipfile
+from datetime import datetime, timezone
from html import escape as he
+from pathlib import Path
def detect_image_format(data: bytes, base: str) -> tuple[str, str]:
@@ -430,3 +432,147 @@ def write_epub_file(epub_path, internal_path: str, content: str) -> None:
with open(epub_path, "wb") as f:
f.write(buf.getvalue())
+
+
+def build_epub(
+ title: str,
+ author: str,
+ publisher: str,
+ chapters: list[dict],
+) -> bytes:
+ """Bouw een EPUB 2.0 bestand vanuit builder-data. Geeft raw bytes terug."""
+ import uuid as _uuid
+ book_id = str(_uuid.uuid4())
+ now_str = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
+
+ # mimetype — ongecomprimeerd als eerste entry
+ mi = zipfile.ZipInfo("mimetype")
+ mi.compress_type = zipfile.ZIP_STORED
+ z.writestr(mi, "application/epub+zip")
+
+ z.writestr(
+ "META-INF/container.xml",
+ '\n'
+ '\n'
+ ' \n'
+ ' \n'
+ ' \n'
+ '\n',
+ )
+
+ style_css = (
+ "body { font-family: Georgia, serif; font-size: 1em;"
+ " line-height: 1.6; margin: 1em; }\n"
+ "p { margin: 0 0 0.8em 0; text-indent: 1.2em; }\n"
+ "p:first-child, h1 + p, h2 + p, h3 + p { text-indent: 0; }\n"
+ "h1, h2, h3 { font-weight: bold; margin: 1.2em 0 0.4em; }\n"
+ "blockquote { margin: 1em 2em; padding: 0.3em 0.8em;"
+ " border-left: 3px solid #aaa; }\n"
+ "blockquote.author-note { font-style: italic; color: #666;"
+ " border-left: 3px solid #555; margin: 1.2em 2em;"
+ " padding: 0.4em 1em; font-size: 0.92em; }\n"
+ "center img { display: block; margin: 1em auto; }\n"
+ )
+ z.writestr("OEBPS/Styles/style.css", style_css)
+
+ break_png_path = Path("static/break.png")
+ if break_png_path.exists():
+ z.write(str(break_png_path), "OEBPS/Images/break.png")
+
+ manifest_items: list[str] = []
+ spine_idrefs: list[str] = []
+ ncx_nav_points: list[str] = []
+
+ for i, ch in enumerate(chapters):
+ ch_id = f"chapter_{i + 1:03d}"
+ ch_filename = f"OEBPS/Text/{ch_id}.xhtml"
+ ch_title = he(ch.get("title") or f"Hoofdstuk {i + 1}")
+ ch_content = ch.get("content") or "
"
+
+ xhtml = (
+ '\n'
+ '\n'
+ '\n'
+ "\n"
+ ' \n'
+ f" {ch_title}\n"
+ ' \n'
+ "\n"
+ "\n"
+ f" {ch_title}
\n"
+ f" {ch_content}\n"
+ "\n"
+ "\n"
+ )
+ z.writestr(ch_filename, xhtml)
+
+ manifest_items.append(
+ f' '
+ )
+ spine_idrefs.append(f' ')
+ ncx_nav_points.append(
+ f' \n'
+ f' {ch_title}\n'
+ f' \n'
+ f' '
+ )
+
+ safe_title = he(title)
+ safe_author = he(author)
+
+ ncx = (
+ '\n'
+ '\n'
+ '\n'
+ "\n"
+ f' \n'
+ ' \n'
+ ' \n'
+ ' \n'
+ "\n"
+ f"{safe_title}\n"
+ f"{safe_author}\n"
+ "\n"
+ + "\n".join(ncx_nav_points)
+ + "\n\n\n"
+ )
+ z.writestr("OEBPS/toc.ncx", ncx)
+
+ has_break = break_png_path.exists()
+ opf = (
+ '\n'
+ '\n'
+ '\n'
+ f' {safe_title}\n'
+ f' {safe_author}\n'
+ f' {he(publisher or "")}\n'
+ f' {book_id}\n'
+ f' {now_str}\n'
+ ' nl\n'
+ "\n"
+ "\n"
+ ' \n'
+ ' \n'
+ + (
+ ' \n'
+ if has_break else ""
+ )
+ + "\n".join(manifest_items)
+ + "\n\n"
+ '\n'
+ + "\n".join(spine_idrefs)
+ + "\n\n"
+ "\n"
+ )
+ z.writestr("OEBPS/content.opf", opf)
+
+ return buf.getvalue()
diff --git a/containers/novela/main.py b/containers/novela/main.py
index 9aba2f4..fdf7068 100644
--- a/containers/novela/main.py
+++ b/containers/novela/main.py
@@ -9,6 +9,7 @@ from migrations import run_migrations
from routers.backup import start_backup_scheduler, stop_backup_scheduler
from routers import (
backup_router,
+ builder_router,
editor_router,
grabber_router,
library_router,
@@ -38,6 +39,7 @@ app.include_router(editor_router)
app.include_router(grabber_router)
app.include_router(settings_router)
app.include_router(backup_router)
+app.include_router(builder_router)
@app.get("/")
diff --git a/containers/novela/migrations.py b/containers/novela/migrations.py
index 7a95446..40919aa 100644
--- a/containers/novela/migrations.py
+++ b/containers/novela/migrations.py
@@ -261,6 +261,23 @@ def migrate_series_suffix() -> None:
)
+def migrate_create_builder_drafts() -> None:
+ _exec(
+ """
+ CREATE TABLE IF NOT EXISTS builder_drafts (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ title VARCHAR(500) NOT NULL,
+ author VARCHAR(255) NOT NULL,
+ publisher VARCHAR(255) NOT NULL DEFAULT '',
+ source_url VARCHAR(1000) NOT NULL DEFAULT '',
+ chapters JSONB NOT NULL DEFAULT '[]',
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW()
+ )
+ """
+ )
+
+
def run_migrations() -> None:
migrate_create_library()
migrate_create_book_tags()
@@ -276,3 +293,4 @@ def run_migrations() -> None:
migrate_remove_cover_missing_tag()
migrate_create_bookmarks()
migrate_series_suffix()
+ migrate_create_builder_drafts()
diff --git a/containers/novela/routers/__init__.py b/containers/novela/routers/__init__.py
index 4c1b8cb..3d13cb3 100644
--- a/containers/novela/routers/__init__.py
+++ b/containers/novela/routers/__init__.py
@@ -1,4 +1,5 @@
from routers.backup import router as backup_router
+from routers.builder import router as builder_router
from routers.editor import router as editor_router
from routers.grabber import router as grabber_router
from routers.library import router as library_router
@@ -12,4 +13,5 @@ __all__ = [
"grabber_router",
"backup_router",
"settings_router",
+ "builder_router",
]
diff --git a/containers/novela/routers/builder.py b/containers/novela/routers/builder.py
new file mode 100644
index 0000000..1674b69
--- /dev/null
+++ b/containers/novela/routers/builder.py
@@ -0,0 +1,270 @@
+"""Book Builder — routes voor het handmatig aanmaken van EPUB-boeken."""
+
+import json
+from pathlib import Path
+
+from fastapi import APIRouter, Request
+from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
+from fastapi.templating import Jinja2Templates
+
+from db import get_db_conn
+from epub import build_epub
+from routers.common import LIBRARY_DIR, make_rel_path, upsert_book
+from xhtml import normalize_wysiwyg_html
+
+router = APIRouter()
+templates = Jinja2Templates(directory="templates")
+
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+def _get_draft(conn, draft_id: str) -> dict | None:
+ with conn.cursor() as cur:
+ cur.execute(
+ "SELECT id, title, author, publisher, source_url, chapters "
+ "FROM builder_drafts WHERE id = %s",
+ (draft_id,),
+ )
+ row = cur.fetchone()
+ if not row:
+ return None
+ return {
+ "id": str(row[0]),
+ "title": row[1],
+ "author": row[2],
+ "publisher": row[3],
+ "source_url": row[4],
+ "chapters": row[5] or [],
+ }
+
+
+# ── Pagina-routes ─────────────────────────────────────────────────────────────
+
+@router.get("/builder", response_class=HTMLResponse)
+async def builder_index(request: Request):
+ with get_db_conn() as conn:
+ with conn.cursor() as cur:
+ cur.execute(
+ "SELECT id, title, author, updated_at "
+ "FROM builder_drafts ORDER BY updated_at DESC"
+ )
+ rows = cur.fetchall()
+ drafts = [
+ {"id": str(r[0]), "title": r[1], "author": r[2], "updated_at": r[3]}
+ for r in rows
+ ]
+ return templates.TemplateResponse(
+ request, "builder.html", {"view": "index", "drafts": drafts, "active": "builder"}
+ )
+
+
+@router.get("/builder/{draft_id}", response_class=HTMLResponse)
+async def builder_editor(draft_id: str, request: Request):
+ with get_db_conn() as conn:
+ draft = _get_draft(conn, draft_id)
+ if not draft:
+ return HTMLResponse("Draft niet gevonden", status_code=404)
+ return templates.TemplateResponse(
+ request, "builder.html", {"view": "editor", "draft": draft, "active": "builder"}
+ )
+
+
+# ── Draft aanmaken / verwijderen ──────────────────────────────────────────────
+
+@router.post("/builder")
+async def create_draft(request: Request):
+ form = await request.form()
+ title = (form.get("title") or "").strip()
+ author = (form.get("author") or "").strip()
+ publisher = (form.get("publisher") or "").strip()
+ source_url = (form.get("source_url") or "").strip()
+
+ if not title or not author:
+ return HTMLResponse("Titel en auteur zijn verplicht", status_code=400)
+
+ with get_db_conn() as conn:
+ with conn.cursor() as cur:
+ cur.execute(
+ "INSERT INTO builder_drafts (title, author, publisher, source_url, chapters) "
+ "VALUES (%s, %s, %s, %s, '[]'::jsonb) RETURNING id",
+ (title, author, publisher, source_url),
+ )
+ draft_id = str(cur.fetchone()[0])
+ conn.commit()
+
+ return RedirectResponse(f"/builder/{draft_id}", status_code=303)
+
+
+@router.delete("/api/builder/{draft_id}")
+async def delete_draft(draft_id: str):
+ with get_db_conn() as conn:
+ with conn.cursor() as cur:
+ cur.execute("DELETE FROM builder_drafts WHERE id = %s", (draft_id,))
+ conn.commit()
+ return JSONResponse({"ok": True})
+
+
+@router.get("/api/builder/{draft_id}")
+async def get_draft_api(draft_id: str):
+ with get_db_conn() as conn:
+ draft = _get_draft(conn, draft_id)
+ if not draft:
+ return JSONResponse({"error": "not found"}, status_code=404)
+ return JSONResponse(draft)
+
+
+# ── Chapter CRUD ──────────────────────────────────────────────────────────────
+
+@router.post("/api/builder/{draft_id}/chapter")
+async def add_chapter(draft_id: str, request: Request):
+ body = await request.json()
+ title = (body.get("title") or "Nieuw hoofdstuk").strip() or "Nieuw hoofdstuk"
+ after_index = int(body.get("after_index", -1))
+
+ with get_db_conn() as conn:
+ draft = _get_draft(conn, draft_id)
+ if not draft:
+ return JSONResponse({"error": "not found"}, status_code=404)
+
+ chapters = draft["chapters"]
+ new_chapter = {"title": title, "content": ""}
+ insert_at = after_index + 1 if 0 <= after_index < len(chapters) else len(chapters)
+ chapters.insert(insert_at, new_chapter)
+
+ with conn.cursor() as cur:
+ cur.execute(
+ "UPDATE builder_drafts SET chapters = %s::jsonb, updated_at = NOW() "
+ "WHERE id = %s",
+ (json.dumps(chapters), draft_id),
+ )
+ conn.commit()
+
+ return JSONResponse({"ok": True, "index": insert_at, "count": len(chapters)})
+
+
+@router.put("/api/builder/{draft_id}/chapter/{idx}")
+async def save_chapter(draft_id: str, idx: int, request: Request):
+ body = await request.json()
+
+ with get_db_conn() as conn:
+ draft = _get_draft(conn, draft_id)
+ if not draft:
+ return JSONResponse({"error": "not found"}, status_code=404)
+
+ chapters = draft["chapters"]
+ if idx < 0 or idx >= len(chapters):
+ return JSONResponse({"error": "chapter not found"}, status_code=404)
+
+ if "title" in body:
+ chapters[idx]["title"] = body["title"]
+ if "content" in body:
+ chapters[idx]["content"] = body["content"]
+
+ with conn.cursor() as cur:
+ cur.execute(
+ "UPDATE builder_drafts SET chapters = %s::jsonb, updated_at = NOW() "
+ "WHERE id = %s",
+ (json.dumps(chapters), draft_id),
+ )
+ conn.commit()
+
+ return JSONResponse({"ok": True})
+
+
+@router.delete("/api/builder/{draft_id}/chapter/{idx}")
+async def delete_chapter(draft_id: str, idx: int):
+ with get_db_conn() as conn:
+ draft = _get_draft(conn, draft_id)
+ if not draft:
+ return JSONResponse({"error": "not found"}, status_code=404)
+
+ chapters = draft["chapters"]
+ if idx < 0 or idx >= len(chapters):
+ return JSONResponse({"error": "chapter not found"}, status_code=404)
+ if len(chapters) <= 1:
+ return JSONResponse(
+ {"error": "Kan het laatste hoofdstuk niet verwijderen"}, status_code=400
+ )
+
+ chapters.pop(idx)
+
+ with conn.cursor() as cur:
+ cur.execute(
+ "UPDATE builder_drafts SET chapters = %s::jsonb, updated_at = NOW() "
+ "WHERE id = %s",
+ (json.dumps(chapters), draft_id),
+ )
+ conn.commit()
+
+ return JSONResponse({"ok": True, "index": min(idx, len(chapters) - 1), "count": len(chapters)})
+
+
+# ── Normaliseer preview ───────────────────────────────────────────────────────
+
+@router.post("/api/builder/{draft_id}/normalize/{idx}")
+async def normalize_chapter_preview(draft_id: str, idx: int, request: Request):
+ """Normaliseer één hoofdstuk en geef genormaliseerde HTML terug als preview.
+ Slaat het resultaat NIET op — de frontend vraagt bevestiging.
+ """
+ body = await request.json()
+ raw_html = body.get("content", "")
+ normalized = normalize_wysiwyg_html(raw_html)
+ return JSONResponse({"ok": True, "content": normalized})
+
+
+# ── Publiceer als EPUB ────────────────────────────────────────────────────────
+
+@router.post("/api/builder/{draft_id}/publish")
+async def publish_draft(draft_id: str):
+ with get_db_conn() as conn:
+ draft = _get_draft(conn, draft_id)
+ if not draft:
+ return JSONResponse({"error": "not found"}, status_code=404)
+ if not draft["chapters"]:
+ return JSONResponse({"error": "Geen hoofdstukken om te publiceren"}, status_code=400)
+
+ # Normaliseer alle hoofdstukken
+ normalized_chapters = [
+ {"title": ch["title"], "content": normalize_wysiwyg_html(ch["content"])}
+ for ch in draft["chapters"]
+ ]
+
+ # Bouw EPUB
+ epub_bytes = build_epub(
+ title=draft["title"],
+ author=draft["author"],
+ publisher=draft["publisher"],
+ chapters=normalized_chapters,
+ )
+
+ # Bepaal bestandspad
+ rel_path = make_rel_path(
+ media_type="epub",
+ publisher=draft["publisher"] or "Unknown",
+ author=draft["author"],
+ title=draft["title"],
+ series="",
+ series_index=0,
+ ext=".epub",
+ )
+ dest = LIBRARY_DIR / rel_path
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ dest.write_bytes(epub_bytes)
+
+ filename = str(rel_path)
+ meta = {
+ "media_type": "epub",
+ "title": draft["title"],
+ "author": draft["author"],
+ "publisher": draft["publisher"],
+ "source_url": draft["source_url"],
+ "needs_review": True,
+ "has_cover": False,
+ }
+ upsert_book(conn, filename, meta)
+
+ with conn.cursor() as cur:
+ cur.execute("DELETE FROM builder_drafts WHERE id = %s", (draft_id,))
+ conn.commit()
+
+ return JSONResponse({"ok": True, "filename": filename})
diff --git a/containers/novela/static/builder.css b/containers/novela/static/builder.css
new file mode 100644
index 0000000..19a806f
--- /dev/null
+++ b/containers/novela/static/builder.css
@@ -0,0 +1,296 @@
+/* ── Wrapper ── */
+.builder-wrap {
+ margin-left: var(--sidebar, 220px);
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+/* ── Overzichtspagina ── */
+.builder-index {
+ padding: 2rem;
+ max-width: 640px;
+}
+.builder-index-title {
+ font-size: 1.2rem;
+ font-weight: 600;
+ margin-bottom: 1.5rem;
+ color: var(--text);
+}
+.builder-create-card {
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 1.2rem 1.4rem;
+ margin-bottom: 2rem;
+}
+.builder-create-title {
+ font-size: 0.85rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-dim);
+ margin-bottom: 1rem;
+}
+.builder-create-form { display: flex; flex-direction: column; gap: 0.7rem; }
+.bc-row { display: flex; flex-direction: column; gap: 0.25rem; }
+.bc-label { font-size: 0.78rem; color: var(--text-dim); }
+.bc-required { color: #e07070; }
+.bc-input {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ color: var(--text);
+ padding: 0.45rem 0.7rem;
+ font-size: 0.88rem;
+ outline: none;
+}
+.bc-input:focus { border-color: var(--accent); }
+.bc-actions { margin-top: 0.4rem; }
+
+/* Drafts lijst */
+.builder-drafts-label {
+ font-size: 0.78rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-dim);
+ margin-bottom: 0.6rem;
+}
+.builder-drafts-list { display: flex; flex-direction: column; gap: 0.5rem; }
+.draft-card {
+ display: flex;
+ align-items: baseline;
+ gap: 0.8rem;
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ border-radius: 5px;
+ padding: 0.6rem 0.9rem;
+}
+.draft-card-title {
+ font-size: 0.9rem;
+ color: var(--text);
+ text-decoration: none;
+ flex: 1;
+}
+.draft-card-title:hover { text-decoration: underline; }
+.draft-card-meta { font-size: 0.76rem; color: var(--text-dim); }
+.draft-card-delete {
+ background: none;
+ border: none;
+ color: var(--text-dim);
+ cursor: pointer;
+ font-size: 1rem;
+ padding: 0 0.2rem;
+}
+.draft-card-delete:hover { color: #e07070; }
+
+/* ── Editor layout ── */
+.builder-editor {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ overflow: hidden;
+}
+
+/* Header */
+.builder-header {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 0 1rem;
+ height: 44px;
+ min-height: 44px;
+ border-bottom: 1px solid var(--border);
+ background: var(--surface);
+}
+.builder-back {
+ display: flex;
+ align-items: center;
+ gap: 0.3rem;
+ font-size: 0.8rem;
+ color: var(--text-dim);
+ text-decoration: none;
+}
+.builder-back:hover { color: var(--text); }
+.builder-header-title {
+ flex: 1;
+ font-size: 0.9rem;
+ font-weight: 500;
+ color: var(--text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.builder-header-actions { display: flex; align-items: center; gap: 0.7rem; }
+
+/* Toolbar */
+.builder-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 0.2rem;
+ padding: 0.3rem 1rem;
+ border-bottom: 1px solid var(--border);
+ background: var(--surface2);
+}
+.tb-btn {
+ background: none;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ color: var(--text-dim);
+ cursor: pointer;
+ font-size: 0.82rem;
+ padding: 0.25rem 0.5rem;
+ line-height: 1.4;
+}
+.tb-btn:hover {
+ background: var(--surface);
+ border-color: var(--border);
+ color: var(--text);
+}
+.tb-sep {
+ width: 1px;
+ height: 16px;
+ background: var(--border);
+ margin: 0 0.3rem;
+}
+.tb-normalize {
+ margin-left: auto;
+ font-size: 0.78rem;
+ color: var(--accent);
+ border-color: var(--accent) !important;
+}
+
+/* Body */
+.builder-body {
+ display: flex;
+ flex: 1;
+ overflow: hidden;
+}
+
+/* Chapter panel */
+.builder-chapter-panel {
+ width: 200px;
+ min-width: 200px;
+ display: flex;
+ flex-direction: column;
+ border-right: 1px solid var(--border);
+ background: var(--surface2);
+ overflow: hidden;
+}
+.chapter-panel-title {
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.07em;
+ color: var(--text-dim);
+ padding: 0.6rem 0.9rem 0.3rem;
+}
+.chapter-list { flex: 1; overflow-y: auto; }
+.chapter-item {
+ display: flex;
+ align-items: center;
+ padding: 0.4rem 0.9rem;
+ cursor: pointer;
+ border-left: 2px solid transparent;
+ gap: 0.3rem;
+}
+.chapter-item:hover { background: var(--surface); }
+.chapter-item.active {
+ border-left-color: var(--accent);
+ background: var(--surface);
+}
+.chapter-item-title {
+ flex: 1;
+ font-size: 0.83rem;
+ color: var(--text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.chapter-item-delete {
+ background: none;
+ border: none;
+ color: transparent;
+ cursor: pointer;
+ font-size: 0.9rem;
+ padding: 0;
+}
+.chapter-item:hover .chapter-item-delete { color: var(--text-dim); }
+.chapter-item-delete:hover { color: #e07070 !important; }
+.btn-add-chapter {
+ background: none;
+ border: none;
+ border-top: 1px solid var(--border);
+ color: var(--text-dim);
+ cursor: pointer;
+ font-size: 0.82rem;
+ padding: 0.6rem 0.9rem;
+ text-align: left;
+ width: 100%;
+}
+.btn-add-chapter:hover { color: var(--text); }
+
+/* Editor pane */
+.builder-editor-pane {
+ flex: 1;
+ overflow-y: auto;
+ padding: 2rem 3rem;
+ background: var(--surface);
+}
+.builder-content {
+ max-width: 680px;
+ margin: 0 auto;
+ min-height: 60vh;
+ font-family: Georgia, serif;
+ font-size: 1rem;
+ line-height: 1.75;
+ color: var(--text);
+ outline: none;
+}
+.builder-content p { margin: 0 0 0.8em; }
+.builder-content blockquote {
+ border-left: 3px solid var(--border);
+ margin: 1em 2em;
+ padding: 0.3em 0.8em;
+ color: var(--text-dim);
+}
+.builder-content blockquote.author-note {
+ font-style: italic;
+ color: var(--text-dim);
+ border-left-color: #555;
+ font-size: 0.93em;
+}
+.builder-content img.scene-break {
+ display: block;
+ margin: 1em auto;
+ height: 15px;
+}
+
+/* Status */
+.save-status { font-size: 0.78rem; color: var(--text-dim); }
+.save-status.dirty { color: var(--warning, #c8a03a); }
+.save-status.saved { color: var(--success, #6baa6b); }
+.save-status.error { color: var(--error, #c85a3a); }
+
+/* Knoppen */
+.btn-primary {
+ background: var(--accent);
+ border: none;
+ border-radius: 4px;
+ color: #fff;
+ cursor: pointer;
+ font-size: 0.83rem;
+ padding: 0.4rem 0.9rem;
+}
+.btn-primary:hover { opacity: 0.85; }
+.btn-publish {
+ background: #4a7a4a;
+ border: none;
+ border-radius: 4px;
+ color: #fff;
+ cursor: pointer;
+ font-size: 0.83rem;
+ padding: 0.35rem 0.8rem;
+}
+.btn-publish:hover { background: #5a8a5a; }
+.btn-publish:disabled { opacity: 0.5; cursor: not-allowed; }
diff --git a/containers/novela/static/builder.js b/containers/novela/static/builder.js
new file mode 100644
index 0000000..9948fd7
--- /dev/null
+++ b/containers/novela/static/builder.js
@@ -0,0 +1,298 @@
+// ── 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 || '
';
+ 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 = '
';
+
+ 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();
+ });
+ });
+}
diff --git a/containers/novela/static/epub-style.css b/containers/novela/static/epub-style.css
index 1a05a41..84b1b49 100644
--- a/containers/novela/static/epub-style.css
+++ b/containers/novela/static/epub-style.css
@@ -252,3 +252,13 @@ span.chat b {
}
}
+/* Author note — blockquote voor commentaar van de auteur buiten de hoofdtekst */
+blockquote.author-note {
+ font-style: italic;
+ color: #888;
+ border-left: 3px solid #555;
+ margin: 1.2em 2em;
+ padding: 0.4em 1em;
+ font-size: 0.92em;
+}
+
diff --git a/containers/novela/templates/_sidebar.html b/containers/novela/templates/_sidebar.html
index 5c3cbd4..47d6c1e 100644
--- a/containers/novela/templates/_sidebar.html
+++ b/containers/novela/templates/_sidebar.html
@@ -153,6 +153,14 @@
Convert
+
+
+
+ Book Builder
+
+