novela/containers/novela/templates/_sidebar.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

492 lines
21 KiB
HTML

<button class="sidebar-toggle" id="sidebar-toggle" onclick="toggleSidebar()" aria-label="Menu">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<div class="sidebar-overlay" id="sidebar-overlay" onclick="closeSidebar()"></div>
<aside class="sidebar" id="sidebar">
<div class="sidebar-logo">
<a href="/home" style="text-decoration:none;color:inherit">
<img src="/static/logo.png" alt="N" class="sidebar-logo-img"/>
<h1>No<span>vela</span></h1>
</a>
</div>
<ul class="sidebar-nav">
<li>
<a href="/home"{% if active == 'home' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
Home
</a>
</li>
</ul>
<hr class="sidebar-divider"/>
<div class="sidebar-section-label">Library</div>
<ul class="sidebar-nav"{% if active == 'library' %} id="lib-nav"{% endif %}>
<li>
<a href="{% if active == 'library' %}#{% else %}/library{% endif %}"
{% if active == 'library' %}id="nav-all" class="active" onclick="switchView('all'); return false;"
{% elif active == 'book' %}class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>
</svg>
All books
<span class="sidebar-count" id="count-all"></span>
</a>
</li>
<li>
<a href="{% if active == 'library' %}#{% elif active == 'book' %}/library#wtr{% else %}/library#wtr{% endif %}"
{% if active == 'library' %}id="nav-wtr" onclick="switchView('wtr'); return false;"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
Want to Read
<span class="sidebar-count" id="count-wtr"></span>
</a>
</li>
<li>
<a href="{% if active == 'library' %}#{% else %}/library#new{% endif %}"
{% if active == 'library' %}id="nav-new" onclick="switchView('new'); return false;"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 3v18"/><path d="M3 12h18"/>
</svg>
New
<span class="sidebar-count" id="count-new"></span>
</a>
</li>
<li>
<a href="{% if active == 'library' %}#{% else %}/library#incomplete{% endif %}"
{% if active == 'library' %}id="nav-incomplete" onclick="switchView('incomplete'); return false;"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
Incomplete
<span class="sidebar-count" id="count-incomplete"></span>
</a>
</li>
<li>
<a href="{% if active == 'library' %}#{% else %}/library#series{% endif %}"
{% if active == 'library' %}id="nav-series" onclick="switchView('series'); return false;"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="6" height="18" rx="1"/>
<rect x="9" y="3" width="6" height="18" rx="1"/>
<rect x="16" y="3" width="6" height="18" rx="1"/>
</svg>
Series
<span class="sidebar-count" id="count-series"></span>
</a>
</li>
<li>
<a href="{% if active == 'library' %}#{% else %}/library#authors{% endif %}"
{% if active == 'library' %}id="nav-authors" onclick="switchView('authors'); return false;"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
Authors
<span class="sidebar-count" id="count-authors"></span>
</a>
</li>
<li>
<a href="{% if active == 'library' %}#{% else %}/library#publishers{% endif %}"
{% if active == 'library' %}id="nav-publishers" onclick="switchView('publishers'); return false;"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 21h18"/>
<path d="M5 21V7l7-4 7 4v14"/>
<path d="M9 21v-6h6v6"/>
</svg>
Publishers
<span class="sidebar-count" id="count-publishers"></span>
</a>
</li>
<li>
<a href="{% if active == 'library' %}#{% else %}/library#archived{% endif %}"
{% if active == 'library' %}id="nav-archived" onclick="switchView('archived'); return false;"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
Archived
<span class="sidebar-count" id="count-archived"></span>
</a>
</li>
<li>
<a href="{% if active == 'library' %}#{% else %}/library#bookmarks{% endif %}"
{% if active == 'library' %}id="nav-bookmarks" onclick="switchView('bookmarks'); return false;"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>
</svg>
Bookmarks
<span class="sidebar-count" id="count-bookmarks"></span>
</a>
</li>
<li>
<a href="{% if active == 'library' %}#{% else %}/library#rated{% endif %}"
{% if active == 'library' %}id="nav-rated" onclick="switchView('rated'); return false;"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
Rated
<span class="sidebar-count" id="count-rated"></span>
</a>
</li>
<li>
<a href="{% if active == 'library' %}#{% else %}/library#duplicates{% endif %}"
{% if active == 'library' %}id="nav-duplicates" onclick="switchView('duplicates'); return false;"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
</svg>
Duplicates
<span class="sidebar-count" id="count-duplicates"></span>
</a>
</li>
<li>
<a href="/search"{% if active == 'search' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
</svg>
Search
</a>
</li>
<li>
<a href="/stats"{% if active == 'stats' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
Statistics
</a>
</li>
</ul>
<hr class="sidebar-divider"/>
<div class="sidebar-section-label">Following</div>
<ul class="sidebar-nav">
<li>
<a href="/following"{% if active == 'following' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
<line x1="19" y1="8" x2="19" y2="14"/>
<line x1="22" y1="11" x2="16" y2="11"/>
</svg>
Authors
<span class="sidebar-count" id="count-following"></span>
</a>
</li>
</ul>
<hr class="sidebar-divider"/>
<div class="sidebar-section-label">Tools</div>
<ul class="sidebar-nav">
<li>
<a href="/convert"{% if active == 'convert' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
</svg>
Convert
</a>
</li>
<li>
<a href="/builder"{% if active == 'builder' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
</svg>
Book Builder
</a>
</li>
<li>
<a href="/bulk-import"{% if active == 'bulk_import' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Bulk Import
</a>
</li>
<li>
<a href="/credentials-manager"{% if active == 'credentials' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 010 14.14M4.93 4.93a10 10 0 000 14.14"/>
</svg>
Credentials
</a>
</li>
<li>
<a href="/debug"{% if active == 'debug' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
</svg>
Debug
</a>
</li>
<li>
<a href="/backup"{% if active == 'backup' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 8v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8"/>
<polyline points="1 8 12 2 23 8"/>
<path d="M12 22v-8"/>
</svg>
Backup
</a>
</li>
<li>
<a href="/settings"{% if active == 'settings' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
Settings
</a>
</li>
<li>
<a href="/changelog"{% if active == 'changelog' %} class="active"{% endif %}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
Changelog
</a>
</li>
</ul>
<div class="sidebar-bottom">
<div class="disk-warning" id="disk-warning" style="display:none">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span id="disk-warning-text"></span>
</div>
<a href="/backup" class="backup-status-bar" id="backup-status-bar" title="Go to Backup">
<span class="backup-dot" id="backup-dot"></span>
<span class="backup-status-text" id="backup-status-text">Backup…</span>
</a>
<button class="btn-rescan" onclick="rescanLibraryGlobal()" id="rescan-btn">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
<span id="rescan-label">Rescan library</span>
</button>
</div>
</aside>
<script>
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('open');
document.getElementById('sidebar-overlay').classList.toggle('open');
}
function closeSidebar() {
document.getElementById('sidebar').classList.remove('open');
document.getElementById('sidebar-overlay').classList.remove('open');
}
// Close sidebar on any nav link click (mobile)
document.querySelectorAll('.sidebar-nav a').forEach(a => {
a.addEventListener('click', () => { if (window.innerWidth <= 768) closeSidebar(); });
});
function applyLibraryCounts(books) {
const active = books.filter(b => !b.archived);
const wtrCount = active.filter(b => b.want_to_read).length;
const newCount = active.filter(b => b.needs_review).length;
const seriesCount = new Set(active.filter(b => b.series).map(b => b.series)).size;
const authorCount = new Set(active.map(b => b.author).filter(Boolean)).size;
const publisherCount = new Set(active.map(b => b.publisher).filter(Boolean)).size;
const archivedCount = books.filter(b => b.archived).length;
const ratedCount = active.filter(b => b.rating > 0).length;
const incompleteCount = active.filter(b => (b.publication_status || '').toLowerCase() !== 'complete').length;
const dupMap = new Map();
active.forEach(b => {
const key = (b.title || '').trim().toLowerCase() + '|' + (b.author || '').trim().toLowerCase();
dupMap.set(key, (dupMap.get(key) || 0) + 1);
});
const dupCount = active.filter(b => {
const key = (b.title || '').trim().toLowerCase() + '|' + (b.author || '').trim().toLowerCase();
return dupMap.get(key) >= 2;
}).length;
const setCount = (id, value) => {
const el = document.getElementById(id);
if (el) el.textContent = value || '';
};
setCount('count-all', active.length);
setCount('count-wtr', wtrCount);
setCount('count-new', newCount);
setCount('count-series', seriesCount);
setCount('count-authors', authorCount);
setCount('count-publishers', publisherCount);
setCount('count-rated', ratedCount);
setCount('count-archived', archivedCount);
setCount('count-duplicates', dupCount);
setCount('count-incomplete', incompleteCount);
}
async function refreshLibraryCounts() {
try {
const resp = await fetch('/library/list');
if (!resp.ok) return;
const books = await resp.json();
applyLibraryCounts(books);
} catch (_) {
// silently ignore; sidebar remains usable without counts
}
}
async function loadBackupStatus() {
const bar = document.getElementById('backup-status-bar');
const dot = document.getElementById('backup-dot');
const text = document.getElementById('backup-status-text');
if (!bar) return;
try {
const r = await fetch('/api/backup/status');
const d = await r.json();
const s = d.status || 'never';
dot.className = 'backup-dot';
if (s === 'running') {
dot.classList.add('dot-running');
text.textContent = 'Backup running…';
loadBackupProgress();
return;
} else if (s === 'success') {
dot.classList.add('dot-ok');
const ago = d.finished_at ? _backupAgo(d.finished_at) : '';
text.textContent = 'Backup OK' + (ago ? ' · ' + ago : '');
} else if (s === 'error') {
dot.classList.add('dot-err');
text.textContent = 'Backup failed';
} else {
dot.classList.add('dot-dim');
text.textContent = 'No backup yet';
}
} catch (_) {
const dot2 = document.getElementById('backup-dot');
if (dot2) dot2.className = 'backup-dot dot-dim';
if (text) text.textContent = 'Backup unavailable';
}
}
async function loadBackupProgress() {
const dot = document.getElementById('backup-dot');
const text = document.getElementById('backup-status-text');
if (!dot) return;
try {
const r = await fetch('/api/backup/progress');
const d = await r.json();
if (!d.running) {
// Backup finished; reload full status
await loadBackupStatus();
return;
}
dot.className = 'backup-dot dot-running';
const phase = d.phase || '';
const phaseLbl = phase === 'scanning' ? 'scanning' :
phase === 'snapshot' ? 'snapshot' :
phase === 'pg_dump' ? 'pg_dump' : 'uploading';
if (d.total > 0) {
text.textContent = `${d.done} / ${d.total} · ${phaseLbl}`;
} else {
text.textContent = `Backup · ${phaseLbl}`;
}
} catch (_) {
// Ignore; will retry
}
setTimeout(loadBackupProgress, 3000);
}
function _backupAgo(isoStr) {
try {
// Ensure the string is parsed as UTC (append Z if no timezone info present)
const s = /[Zz+\-]\d*$/.test(isoStr.trim()) ? isoStr : isoStr + 'Z';
const diff = Math.floor((Date.now() - new Date(s).getTime()) / 1000);
if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
} catch (_) { return ''; }
}
async function rescanLibraryGlobal() {
const btn = document.getElementById('rescan-btn');
const label = document.getElementById('rescan-label');
if (btn) btn.disabled = true;
if (label) label.textContent = 'Scanning…';
try {
const resp = await fetch('/library/rescan', { method: 'POST' });
if (!resp.ok) throw new Error('rescan failed');
await refreshLibraryCounts();
} catch (_) {
alert('Rescan failed.');
} finally {
if (btn) btn.disabled = false;
if (label) label.textContent = 'Rescan library';
}
}
async function refreshBookmarkCount() {
try {
const resp = await fetch('/api/bookmarks');
if (!resp.ok) return;
const bms = await resp.json();
const el = document.getElementById('count-bookmarks');
if (el) el.textContent = bms.length || '';
} catch (_) {}
}
async function refreshFollowingCount() {
try {
const resp = await fetch('/api/following');
if (!resp.ok) return;
const authors = await resp.json();
const el = document.getElementById('count-following');
if (el) el.textContent = authors.filter(a => a.url).length || '';
} catch (_) {}
}
async function checkDiskUsage() {
try {
const r = await fetch('/api/disk');
if (!r.ok) return;
const d = await r.json();
const el = document.getElementById('disk-warning');
const txt = document.getElementById('disk-warning-text');
if (!el || !txt) return;
const gb = d.free / (1024 ** 3);
const critical = d.pct_used >= 95 || gb < 0.5;
const warning = d.pct_used >= 85 || gb < 2;
if (critical || warning) {
const freeStr = gb < 1 ? (d.free / (1024 ** 2)).toFixed(0) + ' MB' : gb.toFixed(1) + ' GB';
txt.textContent = `Storage ${d.pct_used}% used · ${freeStr} free`;
el.className = 'disk-warning' + (critical ? ' critical' : '');
el.style.display = 'flex';
} else {
el.style.display = 'none';
}
} catch (_) {}
}
refreshLibraryCounts();
refreshBookmarkCount();
refreshFollowingCount();
loadBackupStatus();
checkDiskUsage();
setInterval(checkDiskUsage, 60_000);
</script>