novela/containers/novela/templates/_sidebar.html
Ivo Oskamp 347f959d80 Sidebar: show running build version
Display the running build version at the bottom of the sidebar (e.g. v0.2.11
for releases, v0.2.11.3 for dev builds), linking to the changelog. New
version.py exposes display_version(); shared_templates.py registers it as the
app_version Jinja global; main.py adds /api/version.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:02:03 +02:00

497 lines
21 KiB
HTML

{% if develop_mode() %}
<div class="develop-banner"><span class="develop-banner-text">DEVELOP</span></div>
{% endif %}
<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>
<a href="/changelog" class="sidebar-version" title="Running Novela build">{{ app_version() }}</a>
</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>