novela/containers/novela/static/books.js
2026-03-31 20:03:18 +02:00

128 lines
4.4 KiB
JavaScript

// ── Novela — shared utilities ────────────────────────────────────────────────
// HTML-escape a string for safe insertion into markup.
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Placeholder cover generation ─────────────────────────────────────────────
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);
}
const COVER_PALETTES = [
['#1a2a3a', '#4a8caa'],
['#2a1a1a', '#aa4a4a'],
['#1a2a1a', '#4aaa6a'],
['#2a1a2a', '#8a4aaa'],
['#2a2a1a', '#aaa04a'],
['#1a2a2a', '#4aaa9a'],
['#2a1a14', '#ffa20e'],
['#141a2a', '#5a78c8'],
];
function wrapText(ctx, text, x, y, maxW, lineH) {
const words = text.split(' ');
let line = '';
let lines = [];
for (const word of words) {
const test = line ? line + ' ' + word : word;
if (ctx.measureText(test).width > maxW && line) { lines.push(line); line = word; }
else line = test;
}
if (line) lines.push(line);
lines = lines.slice(0, 4);
const startY = y - ((lines.length - 1) * lineH) / 2;
lines.forEach((l, i) => ctx.fillText(l, x, startY + i * lineH));
}
function truncate(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
function makePlaceholderCover(canvas, title, author) {
const w = canvas.width = canvas.offsetWidth || 150;
const h = canvas.height = canvas.offsetHeight || 225;
const ctx = canvas.getContext('2d');
const [bg, fg] = COVER_PALETTES[strHash(title) % COVER_PALETTES.length];
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = fg;
ctx.globalAlpha = 0.15;
ctx.fillRect(0, 0, w, h * 0.08);
ctx.globalAlpha = 1;
ctx.fillStyle = fg;
ctx.fillRect(w * 0.12, h * 0.12, w * 0.04, h * 0.55);
ctx.fillStyle = '#e8e2d9';
ctx.font = `bold ${Math.round(w * 0.105)}px 'Libre Baskerville', Georgia, serif`;
ctx.textAlign = 'center';
wrapText(ctx, title, w * 0.55, h * 0.28, w * 0.72, Math.round(w * 0.12));
ctx.fillStyle = fg;
ctx.font = `${Math.round(w * 0.075)}px 'DM Mono', monospace`;
ctx.globalAlpha = 0.85;
ctx.fillText(truncate(author, 18), w * 0.55, h * 0.86);
ctx.globalAlpha = 1;
}
// ── Shared book helpers ───────────────────────────────────────────────────────
function _filenameBase(filename) {
const leaf = String(filename || '').split('/').pop() || '';
return leaf.replace(/\.[^.]+$/, '');
}
function bookTitle(b) {
return b.title || (_filenameBase(b.filename).split('-')[2] ?? '').replace(/_/g, ' ');
}
function bookAuthor(b) {
if (b.author) return b.author;
return (_filenameBase(b.filename).split('-')[1] ?? '').replace(/_/g, ' ');
}
function tagValuesByType(b, type) {
return (b.tags || []).filter(t => t && t.tag_type === type && t.tag).map(t => t.tag);
}
function bookGenres(b) {
const explicit = tagValuesByType(b, 'genre');
return explicit.length ? explicit : tagValuesByType(b, 'subject');
}
function bookSubgenres(b) { return tagValuesByType(b, 'subgenre'); }
function bookPlainTags(b) { return tagValuesByType(b, 'tag'); }
// Filter a list of books by a free-text query across all searchable fields.
function filterBooks(books, query) {
const q = String(query || '').trim().toLowerCase();
if (!q) return books;
return books.filter(b =>
bookTitle(b).toLowerCase().includes(q) ||
bookAuthor(b).toLowerCase().includes(q) ||
(b.publisher || '').toLowerCase().includes(q) ||
bookGenres(b).some(g => g.toLowerCase().includes(q)) ||
bookSubgenres(b).some(g => g.toLowerCase().includes(q)) ||
bookPlainTags(b).some(g => g.toLowerCase().includes(q))
);
}
// Wire up a search input: show/hide clear button on input, trigger onSearch(query) on Enter.
function setupSearchInput(inputId, clearId, onSearch) {
const input = document.getElementById(inputId);
const clear = document.getElementById(clearId);
if (!input) return;
input.addEventListener('input', () => {
if (clear) clear.style.display = input.value.trim() ? '' : 'none';
});
input.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); onSearch(input.value.trim()); }
});
}