novela/containers/novela/templates/book.html
Ivo Oskamp 92cd301658 Add PDF reader/editor support, fix metadata save and dir cleanup
- PDF reader: page-image rendering via /library/pdf/{filename}?page=N;
  new /api/pdf/info/{filename} endpoint returns page count; reader.html
  branches on FORMAT (epub/pdf) injected by server
- PDF metadata edit: PATCH /library/book now updates DB for all formats;
  _sync_epub_metadata only called for .epub; non-EPUB formats skip file write
- Fix file path on metadata save: _make_rel_path now includes format prefix
  (epub/, pdf/, comics/) matching common.make_rel_path used during import;
  previously files were moved outside their format directory
- Fix empty dir cleanup: prune_empty_dirs always runs after successful
  metadata save, not only when file was moved
- Hide Edit EPUB button for non-EPUB files in book detail
- Docs: TECHNICAL.md and changelog-develop.md updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:01 +01:00

330 lines
15 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="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/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 %} [{{ series_index }}]{% 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 == 'hiatus' %}status-hiatus{% 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 %}
<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>
<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') %}
<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 %}
<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><input class="edit-input" id="ed-author" type="text"/></div>
<div class="edit-field"><label class="edit-label">Publisher</label><input class="edit-input" id="ed-publisher" type="text"/></div>
<div class="edit-row">
<div class="edit-field"><label class="edit-label">Series</label><input class="edit-input" id="ed-series" type="text"/></div>
<div class="edit-field"><label class="edit-label">Volume</label><input class="edit-input" id="ed-series-index" type="number" min="0"/></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="Hiatus">Hiatus</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>This will permanently delete the EPUB file and all reading progress for <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 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 }},
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 src="/static/book.js"></script>
</body>
</html>