128 lines
4.4 KiB
JavaScript
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
// ── 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()); }
|
|
});
|
|
}
|