novela/containers/novela/static/book.js

305 lines
13 KiB
JavaScript

/* ── Novela — Book detail page script ─────────────────────────────────── */
/* Requires: BOOK global defined inline before this script is loaded */
const { filename, title, author } = BOOK;
// ── Placeholder cover ──────────────────────────────────────────────────────
const canvas = document.getElementById('cover-canvas');
canvas.width = 180;
canvas.height = 270;
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 makePlaceholderCover(cv, ttl, auth) {
const w = cv.width || 180, h = cv.height || 270;
const ctx = cv.getContext('2d');
const [bg, fg] = COVER_PALETTES[strHash(ttl) % 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';
const words = ttl.split(' '); let line = '', lines = [];
for (const word of words) {
const test = line ? line + ' ' + word : word;
if (ctx.measureText(test).width > w * 0.72 && line) { lines.push(line); line = word; }
else line = test;
}
if (line) lines.push(line);
lines = lines.slice(0, 4);
const lineH = Math.round(w * 0.12);
const startY = h * 0.28 - ((lines.length - 1) * lineH) / 2;
lines.forEach((l, i) => ctx.fillText(l, w * 0.55, startY + i * lineH));
ctx.fillStyle = fg; ctx.font = `${Math.round(w * 0.075)}px 'DM Mono', monospace`;
ctx.globalAlpha = 0.85;
const a = auth.length > 18 ? auth.slice(0, 17) + '…' : auth;
ctx.fillText(a, w * 0.55, h * 0.86);
ctx.globalAlpha = 1;
}
requestAnimationFrame(() => makePlaceholderCover(canvas, title, author));
if (BOOK.has_cover) {
const img = document.getElementById('cover-img');
if (img && img.complete && img.naturalWidth > 0) canvas.style.display = 'none';
else if (img) img.onload = () => canvas.style.display = 'none';
}
// ── Want to Read toggle ────────────────────────────────────────────────────
async function toggleWtr() {
const resp = await fetch(`/library/want-to-read/${encodeURIComponent(filename)}`, { method: 'POST' });
const result = await resp.json();
if (result.error) return;
const btn = document.getElementById('wtr-btn');
const svg = document.getElementById('wtr-svg');
btn.classList.toggle('active', result.want_to_read);
svg.setAttribute('fill', result.want_to_read ? 'currentColor' : 'none');
}
// ── Mark as Read modal ─────────────────────────────────────────────────────
function openMarkReadModal() {
const now = new Date();
document.getElementById('read-year').value = now.getFullYear();
document.getElementById('read-month').value = now.getMonth() + 1;
document.getElementById('read-day').value = now.getDate();
document.getElementById('read-time').value = '';
document.getElementById('mark-read-modal').classList.add('open');
document.getElementById('read-year').focus();
document.getElementById('read-year').select();
}
document.getElementById('read-year').addEventListener('input', function() {
if (this.value.length === 4) { const m = document.getElementById('read-month'); m.focus(); m.select(); }
});
document.getElementById('read-month').addEventListener('input', function() {
if (this.value.length === 2) { const d = document.getElementById('read-day'); d.focus(); d.select(); }
});
async function confirmMarkRead() {
const year = document.getElementById('read-year').value;
const month = String(document.getElementById('read-month').value).padStart(2, '0');
const day = String(document.getElementById('read-day').value).padStart(2, '0');
const time = document.getElementById('read-time').value;
const body = (year && month && day)
? { read_at: `${year}-${month}-${day}T${time || '12:00'}:00` }
: {};
await fetch(`/library/mark-read/${encodeURIComponent(filename)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
window.location.reload();
}
// ── Mark as unread ─────────────────────────────────────────────────────────
async function markUnread() {
const resp = await fetch(`/library/progress/${encodeURIComponent(filename)}`, { method: 'DELETE' });
if ((await resp.json()).ok) window.location.reload();
}
// ── Archive toggle ─────────────────────────────────────────────────────────
async function toggleArchive() {
const resp = await fetch(`/library/archive/${encodeURIComponent(filename)}`, { method: 'POST' });
const result = await resp.json();
if (result.error) return;
const btn = document.getElementById('archive-btn');
btn.innerHTML = btn.innerHTML.replace(
result.archived ? 'Archive' : 'Unarchive',
result.archived ? 'Unarchive' : 'Archive'
);
}
// ── Add cover ──────────────────────────────────────────────────────────────
async function uploadCover(input) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (e) => {
const b64 = e.target.result.split(',')[1];
const resp = await fetch(`/library/cover/${encodeURIComponent(filename)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cover_b64: b64 }),
});
if (resp.ok) window.location.reload();
else alert('Cover upload failed.');
};
reader.readAsDataURL(file);
input.value = '';
}
// ── Delete ─────────────────────────────────────────────────────────────────
document.getElementById('delete-title').textContent = title;
async function confirmDelete() {
const resp = await fetch(`/library/file/${encodeURIComponent(filename)}`, { method: 'DELETE' });
if (resp.ok) window.location.href = '/library';
else alert('Delete failed.');
}
// ── PillInput — reusable tag pill input with autocomplete ──────────────────
class PillInput {
constructor(boxId, inputId, dropdownId) {
this.box = document.getElementById(boxId);
this.input = document.getElementById(inputId);
this.dropdown = document.getElementById(dropdownId);
this.values = [];
this.all = [];
this.ddIndex = -1;
this.box.addEventListener('click', () => this.input.focus());
this.input.addEventListener('input', () => this._onInput());
this.input.addEventListener('keydown', (e) => this._onKeydown(e));
this.input.addEventListener('blur', () => setTimeout(() => this._hideDropdown(), 150));
}
set(values) { this.values = [...values]; this._render(); }
setSuggestions(all) { this.all = all; }
getValues() { return [...this.values]; }
_render() {
[...this.box.querySelectorAll('.genre-tag')].forEach(t => t.remove());
this.values.forEach((v, i) => {
const pill = document.createElement('span');
pill.className = 'genre-tag';
pill.innerHTML = `${v} <button class="genre-tag-x" type="button">&times;</button>`;
pill.querySelector('.genre-tag-x').onclick = () => { this.values.splice(i, 1); this._render(); };
this.box.insertBefore(pill, this.input);
});
}
_add(v) {
v = v.trim();
if (v && !this.values.includes(v)) { this.values.push(v); this._render(); }
this.input.value = '';
this._hideDropdown();
}
_showDropdown(items) {
if (!items.length) { this.dropdown.style.display = 'none'; return; }
this.dropdown.innerHTML = items.map(g =>
`<div class="genre-option" data-val="${g.replace(/"/g,'&quot;')}">${g}</div>`
).join('');
this.dropdown.querySelectorAll('.genre-option').forEach(el => {
el.onmousedown = (e) => { e.preventDefault(); this._add(el.dataset.val); };
});
this.dropdown.style.display = 'block';
this.ddIndex = -1;
}
_hideDropdown() {
this.dropdown.style.display = 'none';
this.ddIndex = -1;
}
_onInput() {
const q = this.input.value.trim().toLowerCase();
if (!q) { this._hideDropdown(); return; }
const matches = this.all.filter(g => g.toLowerCase().includes(q) && !this.values.includes(g));
this._showDropdown(matches);
}
_onKeydown(e) {
const opts = this.dropdown.querySelectorAll('.genre-option');
if (e.key === 'ArrowDown') {
e.preventDefault();
this.ddIndex = Math.min(this.ddIndex + 1, opts.length - 1);
opts.forEach((o, i) => o.classList.toggle('active', i === this.ddIndex));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.ddIndex = Math.max(this.ddIndex - 1, -1);
opts.forEach((o, i) => o.classList.toggle('active', i === this.ddIndex));
} else if (e.key === 'Enter') {
e.preventDefault();
if (this.ddIndex >= 0 && opts[this.ddIndex]) this._add(opts[this.ddIndex].dataset.val);
else if (this.input.value.trim()) this._add(this.input.value);
} else if (e.key === 'Escape') {
this._hideDropdown();
}
}
}
const genreInput = new PillInput('genre-box', 'genre-input', 'genre-dropdown');
const subgenreInput = new PillInput('subgenre-box', 'subgenre-input', 'subgenre-dropdown');
const tagInput = new PillInput('tag-box', 'tag-input', 'tag-dropdown');
// ── Edit panel ─────────────────────────────────────────────────────────────
async function openEdit() {
const [allGenres, allSubgenres, allTags] = await Promise.all([
fetch('/api/genres?type=genre').then(r => r.json()),
fetch('/api/genres?type=subgenre').then(r => r.json()),
fetch('/api/genres?type=tag').then(r => r.json()),
]);
genreInput.setSuggestions(allGenres);
subgenreInput.setSuggestions(allSubgenres);
tagInput.setSuggestions(allTags);
document.getElementById('ed-title').value = BOOK.title;
document.getElementById('ed-author').value = BOOK.author;
document.getElementById('ed-publisher').value = BOOK.publisher;
document.getElementById('ed-series').value = BOOK.series;
document.getElementById('ed-series-index').value = BOOK.series_index;
document.getElementById('ed-status').value = BOOK.publication_status;
document.getElementById('ed-url').value = BOOK.source_url;
document.getElementById('ed-publish-date').value = BOOK.publish_date;
document.getElementById('ed-description').value = BOOK.description;
genreInput.set(BOOK.genres);
subgenreInput.set(BOOK.subgenres);
tagInput.set(BOOK.tags);
document.getElementById('edit-backdrop').classList.add('open');
document.getElementById('edit-panel').classList.add('open');
}
function closeEdit() {
document.getElementById('edit-backdrop').classList.remove('open');
document.getElementById('edit-panel').classList.remove('open');
genreInput._hideDropdown();
subgenreInput._hideDropdown();
tagInput._hideDropdown();
}
async function saveEdit() {
const body = {
title: document.getElementById('ed-title').value,
author: document.getElementById('ed-author').value,
publisher: document.getElementById('ed-publisher').value,
series: document.getElementById('ed-series').value,
series_index: document.getElementById('ed-series-index').value,
publication_status: document.getElementById('ed-status').value,
source_url: document.getElementById('ed-url').value,
publish_date: document.getElementById('ed-publish-date').value,
description: document.getElementById('ed-description').value,
genres: genreInput.getValues(),
subgenres: subgenreInput.getValues(),
tags: tagInput.getValues(),
};
const resp = await fetch(`/library/book/${encodeURIComponent(filename)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const result = await resp.json();
if (resp.ok && result.filename) {
window.location.href = `/library/book/${encodeURIComponent(result.filename)}`;
} else if (resp.ok) {
window.location.reload();
} else {
alert(result.error || 'Save failed.');
}
}