- 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>
492 lines
21 KiB
HTML
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>
|