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