305 lines
13 KiB
JavaScript
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">×</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,'"')}">${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.');
|
|
}
|
|
}
|