- 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>
325 lines
12 KiB
HTML
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
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>
|