novela/containers/novela/templates/book.html
Ivo Oskamp e4d2e2c636 DB-stored books, full-text search, backup restore, and AO3 scraper
- DB-stored books (Fase 1–6): chapters and images stored in PostgreSQL; grabber writes to DB, EPUB→DB conversion, DB→EPUB export, FTS search page (/search)
- Chapter editor: Monaco editor supports DB-stored books; inline title editing
- Grabber: DB/EPUB storage toggle on Convert page
- Backup: restore from Dropbox snapshot (browse snapshots, restore individual or selected files)
- AO3 scraper: initial implementation
- Changelog: v0.1.2 and v0.1.3 entries added to changelog.py and changelog.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 15:13:08 +02:00

409 lines
19 KiB
HTML
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.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — {{ title or filename }}</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/theme.css"/>
<link rel="stylesheet" href="/static/sidebar.css"/>
<link rel="stylesheet" href="/static/book.css"/>
</head>
<body>
{% include "_sidebar.html" %}
<main class="main">
<div class="book-hero">
<!-- Cover -->
<div class="cover-area">
<div class="cover-wrap" id="cover-wrap">
<canvas id="cover-canvas"></canvas>
{% if has_cover %}
<img src="/library/cover/{{ filename | urlencode }}" alt="{{ title }}" id="cover-img"
onerror="this.style.display='none'"/>
{% endif %}
</div>
{% set r = (rating | default(0)) | int %}
<div class="star-row interactive" id="book-stars">
{% for i in range(1, 6) %}
<span class="star {% if i <= r %}filled{% endif %}" onclick="rateBook({{ i }})"></span>
{% endfor %}
</div>
<button class="btn-wtr {% if want_to_read %}active{% endif %}" id="wtr-btn" onclick="toggleWtr()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="{% if want_to_read %}currentColor{% else %}none{% endif %}" stroke="currentColor" stroke-width="2.5" id="wtr-svg">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
<span id="wtr-label">Want to Read</span>
</button>
</div>
<!-- Info -->
<div class="book-info">
<div class="book-title">{{ title or filename }}</div>
{% if author %}<div class="book-author"><a href="/library#authors/{{ author | urlencode }}">{{ author }}</a></div>{% endif %}
<div class="meta-grid">
{% if series %}
<div class="meta-row">
<span class="meta-label">Series</span>
<span class="meta-value">{{ series }}{% if series_index is defined and (series_index or series_suffix or series_is_indexed) %} [{{ series_index }}{{ series_suffix }}]{% endif %}</span>
</div>
{% endif %}
<div class="meta-row">
<span class="meta-label">Publisher</span>
{% if publisher %}
<span class="meta-value"><a href="/library#publisher/{{ publisher | urlencode }}" class="publisher-link">{{ publisher }}</a></span>
{% else %}
<span class="meta-value"><a href="/library#publisher/__missing__" class="publisher-link">No publisher</a></span>
{% endif %}
</div>
{% if publication_status %}
<div class="meta-row">
<span class="meta-label">Status</span>
<span class="meta-value">
{% set st = publication_status | lower %}
<span class="status-pill {% if st == 'complete' %}status-complete{% elif st == 'ongoing' %}status-ongoing{% elif st == 'temporary hold' %}status-temporary-hold{% elif st == 'long-term hold' %}status-long-term-hold{% endif %}">
{{ publication_status }}
</span>
</span>
</div>
{% endif %}
{% if publish_date %}
<div class="meta-row">
<span class="meta-label">Updated</span>
<span class="meta-value">{{ publish_date }}</span>
</div>
{% endif %}
{% if genres %}
<div class="meta-row">
<span class="meta-label">Genres</span>
<span class="meta-value">{% for g in genres %}<a href="/library#genre/{{ g | urlencode }}" class="tag-pill">{{ g }}</a>{% endfor %}</span>
</div>
{% endif %}
{% if subgenres %}
<div class="meta-row">
<span class="meta-label">Sub-genres</span>
<span class="meta-value">{% for g in subgenres %}<a href="/library#genre/{{ g | urlencode }}" class="tag-pill">{{ g }}</a>{% endfor %}</span>
</div>
{% endif %}
{% if tags %}
<div class="meta-row">
<span class="meta-label">Tags</span>
<span class="meta-value">{% for g in tags %}<a href="/library#genre/{{ g | urlencode }}" class="tag-pill">{{ g }}</a>{% endfor %}</span>
</div>
{% endif %}
{% if description %}
<div class="meta-row">
<span class="meta-label">Description</span>
<div class="meta-value book-description">{{ description }}</div>
</div>
{% endif %}
{% if source_url %}
<div class="meta-row">
<span class="meta-label">Bron</span>
<span class="meta-value">
<a href="{{ source_url }}" target="_blank" rel="noopener noreferrer"
style="color:var(--accent);font-family:var(--mono);font-size:0.78rem;word-break:break-all;">
{{ source_url }}
</a>
</span>
</div>
{% endif %}
</div>
{% if progress > 0 %}
<div class="progress-section">
<div class="progress-label">Reading progress</div>
<div class="progress-bar-wrap">
<div class="progress-bar-fill" style="width: {{ progress }}%"></div>
</div>
<div class="progress-pct">{{ progress }}% complete</div>
</div>
{% endif %}
{% if read_count > 0 %}
<div class="read-stats">
Read <span>{{ read_count }}×</span>
{% if last_read %}
· Last read <span>{{ last_read[:10] }}</span>
{% endif %}
</div>
{% endif %}
<div class="action-row">
<a class="btn-primary" href="/library/read/{{ filename | urlencode }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
</svg>
{% if progress > 0 %}Continue reading{% else %}Start reading{% endif %}
</a>
{% if progress > 0 %}
<button class="btn-secondary" onclick="markUnread()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 1 0 .49-4"/>
</svg>
Mark as unread
</button>
{% endif %}
{% if storage_type == 'db' %}
<a class="btn-secondary" href="/api/library/export-epub/{{ filename | urlencode }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Export EPUB
</a>
{% else %}
<a class="btn-secondary" href="/download/{{ filename | urlencode }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Download
</a>
{% endif %}
<button class="btn-secondary" onclick="openMarkReadModal()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="20 6 9 17 4 12"/>
</svg>
Mark as Read
</button>
<button class="btn-secondary {% if archived %}btn-archive-active{% endif %}" id="archive-btn" onclick="toggleArchive()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="21 8 21 21 3 21 3 8"/>
<rect x="1" y="3" width="22" height="5"/>
<line x1="10" y1="12" x2="14" y2="12"/>
</svg>
{% if archived %}Unarchive{% else %}Archive{% endif %}
</button>
<button class="btn-secondary" onclick="openEdit()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
Edit
</button>
{% if filename.endswith('.epub') and storage_type != 'db' %}
<a class="btn-secondary" href="/library/editor/{{ filename | urlencode }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="16 18 22 12 16 6"/>
<polyline points="8 6 2 12 8 18"/>
</svg>
Edit EPUB
</a>
{% endif %}
{% if storage_type == 'db' %}
<a class="btn-secondary" href="/library/editor/{{ filename | urlencode }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
Edit chapters
</a>
{% endif %}
{% if filename.endswith('.epub') and storage_type != 'db' %}
<button class="btn-secondary" id="convert-db-btn" onclick="convertToDb()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
</svg>
Convert to DB
</button>
{% endif %}
<input type="file" id="cover-input" accept="image/*" style="display:none" onchange="uploadCover(this)"/>
<button class="btn-secondary" onclick="document.getElementById('cover-input').click()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
Add cover
</button>
<button class="btn-danger" onclick="document.getElementById('delete-modal').classList.add('open')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6"/><path d="M14 11v6"/>
<path d="M9 6V4h6v2"/>
</svg>
Delete
</button>
</div>
</div>
</div>
</main>
<div class="edit-backdrop" id="edit-backdrop" onclick="closeEdit()"></div>
<div class="edit-panel" id="edit-panel">
<div class="edit-panel-header">
<span class="edit-panel-title">Edit metadata</span>
<button class="edit-close" onclick="closeEdit()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="edit-field"><label class="edit-label">Title</label><input class="edit-input" id="ed-title" type="text"/></div>
<div class="edit-field">
<label class="edit-label">Author</label>
<div class="genre-wrap">
<input class="edit-input" id="ed-author" type="text" autocomplete="off"/>
<div class="genre-dropdown" id="author-dropdown" style="display:none"></div>
</div>
</div>
<div class="edit-field">
<label class="edit-label">Publisher</label>
<div class="genre-wrap">
<input class="edit-input" id="ed-publisher" type="text" autocomplete="off"/>
<div class="genre-dropdown" id="publisher-dropdown" style="display:none"></div>
</div>
</div>
<div class="edit-row">
<div class="edit-field">
<label class="edit-label">Series</label>
<div class="genre-wrap">
<input class="edit-input" id="ed-series" type="text" autocomplete="off"/>
<div class="genre-dropdown" id="series-dropdown" style="display:none"></div>
</div>
</div>
<div class="edit-field"><label class="edit-label">Volume</label><input class="edit-input" id="ed-series-index" type="text" placeholder="e.g. 1 or 21a"/></div>
</div>
<div class="edit-field">
<label class="edit-label">Status</label>
<select class="edit-select" id="ed-status">
<option value=""></option>
<option value="Complete">Complete</option>
<option value="Ongoing">Ongoing</option>
<option value="Temporary Hold">Temporary Hold</option>
<option value="Long-Term Hold">Long-Term Hold</option>
</select>
</div>
<div class="edit-field"><label class="edit-label">Source URL</label><input class="edit-input" id="ed-url" type="url" placeholder="https://…"/></div>
<div class="edit-field"><label class="edit-label">Description</label><textarea class="edit-input edit-textarea" id="ed-description" rows="5" placeholder="Story description…"></textarea></div>
<div class="edit-field"><label class="edit-label">Updated</label><input class="edit-input" id="ed-publish-date" type="date" style="color-scheme:dark"/></div>
<div class="edit-field">
<label class="edit-label">Genres</label>
<div class="genre-wrap">
<div class="genre-box" id="genre-box">
<input class="genre-input" id="genre-input" type="text" placeholder="Add genre…" autocomplete="off"/>
</div>
<div class="genre-dropdown" id="genre-dropdown" style="display:none"></div>
</div>
</div>
<div class="edit-field">
<label class="edit-label">Sub-genres</label>
<div class="genre-wrap">
<div class="genre-box" id="subgenre-box">
<input class="genre-input" id="subgenre-input" type="text" placeholder="Add sub-genre…" autocomplete="off"/>
</div>
<div class="genre-dropdown" id="subgenre-dropdown" style="display:none"></div>
</div>
</div>
<div class="edit-field">
<label class="edit-label">Tags</label>
<div class="genre-wrap">
<div class="genre-box" id="tag-box">
<input class="genre-input" id="tag-input" type="text" placeholder="Add tag…" autocomplete="off"/>
</div>
<div class="genre-dropdown" id="tag-dropdown" style="display:none"></div>
</div>
</div>
<div class="edit-footer">
<button class="btn-secondary" onclick="closeEdit()">Cancel</button>
<button class="btn-primary" onclick="saveEdit()">Save</button>
</div>
</div>
<div class="modal-backdrop" id="mark-read-modal">
<div class="modal">
<h3>Mark as Read</h3>
<div class="modal-field">
<label class="modal-label">Read on</label>
<div class="date-row">
<div class="date-field"><span class="date-sub-label">Year</span><input class="date-input" id="read-year" type="number" min="2000" max="2099" placeholder="YYYY" maxlength="4"/></div>
<div class="date-field"><span class="date-sub-label">Month</span><input class="date-input" id="read-month" type="number" min="1" max="12" placeholder="MM" maxlength="2"/></div>
<div class="date-field"><span class="date-sub-label">Day</span><input class="date-input" id="read-day" type="number" min="1" max="31" placeholder="DD" maxlength="2"/></div>
</div>
<div class="date-time-row">
<div class="date-field"><span class="date-sub-label">Time (optional, 24h)</span><input class="modal-input" id="read-time" type="time" style="color-scheme:dark"/></div>
</div>
</div>
<div class="modal-actions">
<button class="btn-secondary" onclick="document.getElementById('mark-read-modal').classList.remove('open')">Cancel</button>
<button class="btn-primary" onclick="confirmMarkRead()">Save</button>
</div>
</div>
</div>
<div class="modal-backdrop" id="delete-modal">
<div class="modal">
<h3>Delete book</h3>
<p>{% if storage_type == 'db' %}This will permanently delete the book and all its chapters from the database for{% else %}This will permanently delete the file and all reading progress for{% endif %} <strong id="delete-title"></strong>. This cannot be undone.</p>
<div class="modal-actions">
<button class="btn-secondary" onclick="document.getElementById('delete-modal').classList.remove('open')">Cancel</button>
<button class="btn-danger" onclick="confirmDelete()">Delete</button>
</div>
</div>
</div>
<script>
const STORAGE_TYPE = {{ storage_type | tojson }};
const BOOK = {
filename: {{ filename | tojson }},
title: {{ (title or filename) | tojson }},
author: {{ (author or '') | tojson }},
publisher: {{ (publisher or '') | tojson }},
series: {{ (series or '') | tojson }},
series_index: {{ series_index or 0 }},
series_suffix: {{ (series_suffix or '') | tojson }},
publication_status: {{ (publication_status or '') | tojson }},
source_url: {{ (source_url or '') | tojson }},
publish_date: {{ (publish_date or '') | tojson }},
description: {{ (description or '') | tojson }},
genres: {{ genres | tojson }},
subgenres: {{ subgenres | tojson }},
tags: {{ tags | tojson }},
has_cover: {{ 'true' if has_cover else 'false' }},
rating: {{ rating or 0 }},
};
</script>
<script>
async function convertToDb() {
const btn = document.getElementById('convert-db-btn');
if (!confirm('Convert this EPUB to DB storage?\nThe EPUB file will be deleted from disk.\nAll reading progress, bookmarks and ratings are preserved.')) return;
btn.disabled = true;
btn.textContent = 'Converting…';
try {
const resp = await fetch('/api/library/convert-to-db/' + encodeURIComponent(BOOK.filename), { method: 'POST' });
const data = await resp.json();
if (data.ok) {
window.location.href = '/library/book/' + encodeURIComponent(data.new_filename);
} else {
alert('Conversion failed: ' + (data.error || 'unknown error'));
btn.disabled = false;
btn.textContent = 'Convert to DB';
}
} catch (e) {
alert('Conversion failed: ' + e);
btn.disabled = false;
btn.textContent = 'Convert to DB';
}
}
</script>
<script src="/static/books.js"></script>
<script src="/static/book.js"></script>
</body>
</html>