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

362 lines
15 KiB
JavaScript

/* ── Novela — Book detail page script ─────────────────────────────────── */
/* Requires: books.js loaded first; BOOK global defined inline */
const { filename, title, author } = BOOK;
// ── Placeholder cover ──────────────────────────────────────────────────────
const canvas = document.getElementById('cover-canvas');
canvas.width = 180;
canvas.height = 270;
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';
}
// ── Rating ─────────────────────────────────────────────────────────────────
let currentRating = BOOK.rating || 0;
async function rateBook(rating) {
const newRating = currentRating === rating ? 0 : rating;
try {
const resp = await fetch(`/library/rating/${encodeURIComponent(filename)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating: newRating }),
});
const result = await resp.json();
if (!resp.ok || result.error) return;
currentRating = result.rating;
document.querySelectorAll('#book-stars .star').forEach((el, idx) => {
el.classList.toggle('filled', idx + 1 <= currentRating);
});
} catch {}
}
// ── 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();
}
flush() {
const v = this.input.value.trim();
if (v) this._add(v);
}
_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.key === ',') {
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');
// ── TextSuggest — single-value autocomplete for plain text inputs ───────────
class TextSuggest {
constructor(inputId, dropdownId) {
this.input = document.getElementById(inputId);
this.dropdown = document.getElementById(dropdownId);
this.all = [];
this.ddIndex = -1;
this.input.addEventListener('input', () => this._onInput());
this.input.addEventListener('keydown', (e) => this._onKeydown(e));
this.input.addEventListener('blur', () => setTimeout(() => this._hide(), 150));
}
setSuggestions(all) { this.all = all; }
_show(items) {
if (!items.length) { this._hide(); return; }
this.dropdown.innerHTML = items.map(v =>
`<div class="genre-option" data-val="${v.replace(/"/g,'&quot;')}">${v}</div>`
).join('');
this.dropdown.querySelectorAll('.genre-option').forEach(el => {
el.onmousedown = (e) => { e.preventDefault(); this.input.value = el.dataset.val; this._hide(); };
});
this.dropdown.style.display = 'block';
this.ddIndex = -1;
}
_hide() { this.dropdown.style.display = 'none'; this.ddIndex = -1; }
_onInput() {
const q = this.input.value.trim().toLowerCase();
if (!q) { this._hide(); return; }
this._show(this.all.filter(v => v.toLowerCase().includes(q)));
}
_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' && this.ddIndex >= 0 && opts[this.ddIndex]) {
e.preventDefault();
this.input.value = opts[this.ddIndex].dataset.val;
this._hide();
} else if (e.key === 'Escape') {
this._hide();
}
}
}
const authorSuggest = new TextSuggest('ed-author', 'author-dropdown');
const publisherSuggest = new TextSuggest('ed-publisher', 'publisher-dropdown');
const seriesSuggest = new TextSuggest('ed-series', 'series-dropdown');
// ── Edit panel ─────────────────────────────────────────────────────────────
async function openEdit() {
const [allGenres, allSubgenres, allTags, allAuthors, allPublishers, allSeries] = 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()),
fetch('/api/suggestions?type=author').then(r => r.json()),
fetch('/api/suggestions?type=publisher').then(r => r.json()),
fetch('/api/suggestions?type=series').then(r => r.json()),
]);
genreInput.setSuggestions(allGenres);
subgenreInput.setSuggestions(allSubgenres);
tagInput.setSuggestions(allTags);
authorSuggest.setSuggestions(allAuthors);
publisherSuggest.setSuggestions(allPublishers);
seriesSuggest.setSuggestions(allSeries);
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 + (BOOK.series_suffix || '');
document.getElementById('ed-status').value = BOOK.publication_status || 'Complete';
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() {
genreInput.flush();
subgenreInput.flush();
tagInput.flush();
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.');
}
}