novela/containers/novela/templates/following.html
Ivo Oskamp 5d83bfccab Performance: lazy covers, ETag caching, single DOM pass, SQL tag aggregation
- IntersectionObserver defers both cover images and placeholder canvas
  drawing until cards enter viewport — eliminates 1000+ upfront ops
- ETag on /library/list: browser gets 304 Not Modified when nothing changed
- Single DOM pass in renderBooksGrid/renderDuplicatesView/renderSeriesDetail:
  card.querySelector replaces second iteration with 500+ getElementById calls
- book_tags joined via json_agg in main query, removing separate SELECT + Python merge
- loadLibrary: error handling prevents silent failures showing as infinite loading
- Delete TODO-PERF-library-load.md (all four bottlenecks resolved)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 01:04:32 +01:00

325 lines
12 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 — Following</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"/>
<style>
:root {
--bg: #0f0e0c; --surface: #1a1815; --surface2: #221f1b;
--border: #2e2a24; --accent: #c8783a; --accent2: #e8a063;
--text: #e8e2d9; --text-dim: #8a8278; --text-faint: #4a453e;
--success: #6baa6b; --warning: #c8a03a; --error: #c85a3a;
--radius: 6px; --sidebar: 220px;
--mono: 'DM Mono', monospace; --serif: 'Libre Baskerville', Georgia, serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
.main { margin-left: var(--sidebar); min-height: 100vh; padding: 2rem 2.5rem 4rem; }
@media (max-width: 768px) { .main { margin-left: 0; padding: 4rem 1rem 4rem; } }
.main-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 1.75rem; flex-wrap: wrap; gap: 1rem;
}
.main-title {
font-family: var(--mono); font-size: 0.7rem; letter-spacing: 0.12em;
text-transform: uppercase; color: var(--accent);
}
.filter-tabs { display: flex; gap: 0.5rem; }
.tab {
font-family: var(--mono); font-size: 0.72rem; padding: 0.3rem 0.75rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--surface); color: var(--text-dim); cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.tab.active { border-color: var(--accent); color: var(--accent); }
.tab .cnt { color: var(--text-faint); margin-left: 0.3rem; }
.author-list { display: flex; flex-direction: column; gap: 0.5rem; max-width: 860px; }
.author-row {
display: flex; align-items: center; gap: 1rem;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 0.75rem 1rem;
transition: border-color 0.15s;
}
.author-row:hover { border-color: var(--border); }
.author-row.has-url:hover { border-color: var(--accent); }
.author-avatar {
width: 36px; height: 36px; border-radius: 50%; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-family: var(--serif); font-size: 0.95rem; font-weight: bold;
}
.author-info { flex: 1; min-width: 0; }
.author-name-link {
font-family: var(--serif); font-size: 0.92rem; color: var(--text);
text-decoration: none;
}
.author-name-link:hover { color: var(--accent2); }
.author-meta {
font-family: var(--mono); font-size: 0.68rem; color: var(--text-dim);
margin-top: 0.15rem;
}
.author-url-area { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.url-display {
font-family: var(--mono); font-size: 0.68rem; color: var(--text-dim);
max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.url-display a { color: var(--text-dim); text-decoration: none; }
.url-display a:hover { color: var(--accent2); text-decoration: underline; }
.no-url-label {
font-family: var(--mono); font-size: 0.68rem; color: var(--text-faint);
}
.btn-visit {
font-family: var(--mono); font-size: 0.7rem; padding: 0.25rem 0.6rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: transparent; color: var(--text-dim); cursor: pointer;
white-space: nowrap; transition: border-color 0.15s, color 0.15s;
}
.btn-visit:hover { border-color: var(--accent); color: var(--accent); }
.btn-edit {
font-family: var(--mono); font-size: 0.7rem; padding: 0.25rem 0.6rem;
border: 1px solid transparent; border-radius: var(--radius);
background: transparent; color: var(--text-faint); cursor: pointer;
white-space: nowrap; transition: border-color 0.15s, color 0.15s;
}
.btn-edit:hover { border-color: var(--border); color: var(--text-dim); }
.url-edit-form { display: flex; align-items: center; gap: 0.5rem; }
.url-input {
font-family: var(--mono); font-size: 0.72rem; width: 280px;
background: var(--surface2); border: 1px solid var(--accent);
border-radius: var(--radius); color: var(--text);
padding: 0.25rem 0.5rem; outline: none;
}
@media (max-width: 600px) { .url-input { width: 160px; } }
.btn-save {
font-family: var(--mono); font-size: 0.7rem; padding: 0.25rem 0.6rem;
border: 1px solid var(--success); border-radius: var(--radius);
background: transparent; color: var(--success); cursor: pointer;
}
.btn-save:hover { background: var(--success); color: var(--bg); }
.btn-cancel {
font-family: var(--mono); font-size: 0.7rem; padding: 0.25rem 0.6rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: transparent; color: var(--text-dim); cursor: pointer;
}
.empty, .loading {
font-family: var(--mono); font-size: 0.8rem; color: var(--text-dim);
padding: 2rem 0;
}
</style>
</head>
<body>
{% include "_sidebar.html" %}
<main class="main">
<div class="main-header">
<div class="main-title">Following</div>
<div class="filter-tabs">
<button id="tab-following" class="tab active" onclick="setFilter('following')">
Following <span class="cnt" id="cnt-following">0</span>
</button>
<button id="tab-all" class="tab" onclick="setFilter('all')">
All Authors <span class="cnt" id="cnt-all">0</span>
</button>
</div>
</div>
<div id="author-list"><div class="loading">Loading…</div></div>
</main>
<script>
const COVER_PALETTES = [
['#1a2a3a','#4a8caa'],['#2a1a1a','#aa4a4a'],['#1a2a1a','#4aaa6a'],
['#2a1a2a','#8a4aaa'],['#2a2a1a','#aaa04a'],['#1a2a2a','#4aaa9a'],
['#2a1a14','#c8783a'],['#141a2a','#5a78c8'],
];
function strHash(s) {
let h = 0;
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
function esc(s) {
return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function timeAgo(isoStr) {
if (!isoStr) return '';
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 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
if (diff < 604800) return Math.floor(diff / 86400) + 'd ago';
if (diff < 2592000) return Math.floor(diff / 604800) + 'w ago';
return Math.floor(diff / 2592000) + 'mo ago';
}
function hostOf(url) {
try { return new URL(url).hostname.replace(/^www\./, ''); } catch (_) { return url; }
}
let allAuthors = [];
let currentFilter = 'following';
async function loadAuthors() {
try {
const resp = await fetch('/api/following');
allAuthors = await resp.json();
} catch (_) {
allAuthors = [];
}
renderList();
updateCounts();
}
function updateCounts() {
const followingCount = allAuthors.filter(a => a.url).length;
document.getElementById('cnt-following').textContent = followingCount;
document.getElementById('cnt-all').textContent = allAuthors.length;
const sidebarEl = document.getElementById('count-following');
if (sidebarEl) sidebarEl.textContent = followingCount || '';
}
function setFilter(f) {
currentFilter = f;
document.getElementById('tab-following').classList.toggle('active', f === 'following');
document.getElementById('tab-all').classList.toggle('active', f === 'all');
renderList();
}
function renderList() {
const container = document.getElementById('author-list');
const items = currentFilter === 'following'
? allAuthors.filter(a => a.url)
: allAuthors;
if (!items.length) {
container.innerHTML = `<div class="empty">${
currentFilter === 'following'
? 'No authors followed yet. Switch to "All Authors" to add URLs.'
: 'No authors in your library yet.'
}</div>`;
return;
}
const list = document.createElement('div');
list.className = 'author-list';
items.forEach(a => list.appendChild(makeRow(a)));
container.innerHTML = '';
container.appendChild(list);
}
function makeRow(author) {
const [bg, fg] = COVER_PALETTES[strHash(author.name) % COVER_PALETTES.length];
const initial = (author.name.trim()[0] || '?').toUpperCase();
const books = author.book_count;
const meta = books + ' book' + (books !== 1 ? 's' : '') + (author.last_added ? ' · ' + timeAgo(author.last_added) : '');
const row = document.createElement('div');
row.className = 'author-row' + (author.url ? ' has-url' : '');
row.dataset.name = author.name;
row.dataset.url = author.url || '';
row.innerHTML = `
<div class="author-avatar" style="background:${bg};color:${fg}">${esc(initial)}</div>
<div class="author-info">
<a class="author-name-link" href="/library#authors/${encodeURIComponent(author.name)}">${esc(author.name)}</a>
<div class="author-meta">${esc(meta)}</div>
</div>
<div class="author-url-area">${urlAreaHtml(author)}</div>`;
return row;
}
function urlAreaHtml(author) {
if (author.url) {
return `<div class="url-display" title="${esc(author.url)}"><a href="${esc(author.url)}" target="_blank" rel="noopener noreferrer">${esc(hostOf(author.url))}</a></div>
<button class="btn-visit" onclick="visitAuthor(this)" title="${esc(author.url)}">↗ Visit</button>
<button class="btn-edit" onclick="startEdit(this)">Edit</button>`;
}
return `<span class="no-url-label">—</span>
<button class="btn-edit" onclick="startEdit(this)">+ URL</button>`;
}
function visitAuthor(btn) {
const row = btn.closest('.author-row');
const url = row.dataset.url;
if (url) window.open(url, '_blank', 'noopener,noreferrer');
}
function startEdit(btn) {
const row = btn.closest('.author-row');
const currentUrl = row.dataset.url || '';
const area = row.querySelector('.author-url-area');
area.innerHTML = `
<div class="url-edit-form">
<input class="url-input" type="url" value="${esc(currentUrl)}" placeholder="https://…"/>
<button class="btn-save" onclick="saveUrl(this)">Save</button>
<button class="btn-cancel" onclick="cancelEdit(this)">Cancel</button>
</div>`;
const input = area.querySelector('.url-input');
input.focus();
input.select();
input.addEventListener('keydown', e => {
if (e.key === 'Enter') saveUrl(input.nextElementSibling);
if (e.key === 'Escape') cancelEdit(input.nextElementSibling.nextElementSibling);
});
}
function cancelEdit(btn) {
const row = btn.closest('.author-row');
const name = row.dataset.name;
const author = allAuthors.find(a => a.name === name);
if (!author) return;
row.querySelector('.author-url-area').innerHTML = urlAreaHtml(author);
}
async function saveUrl(btn) {
const row = btn.closest('.author-row');
const name = row.dataset.name;
const input = row.querySelector('.url-input');
const url = (input ? input.value : '').trim();
btn.disabled = true;
try {
const resp = await fetch('/api/following/' + encodeURIComponent(name), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
if (!resp.ok) throw new Error('Failed');
} catch (_) {
alert('Failed to save URL.');
if (btn) btn.disabled = false;
return;
}
const author = allAuthors.find(a => a.name === name);
if (author) author.url = url || null;
row.dataset.url = url;
row.className = 'author-row' + (url ? ' has-url' : '');
row.querySelector('.author-url-area').innerHTML = urlAreaHtml(author || { url: url || null });
updateCounts();
if (currentFilter === 'following' && !url) {
row.remove();
const list = document.querySelector('#author-list .author-list');
if (list && !list.children.length) {
document.getElementById('author-list').innerHTML =
'<div class="empty">No authors followed yet. Switch to "All Authors" to add URLs.</div>';
}
}
}
loadAuthors();
</script>
</body>
</html>