Initial commit: Bookish Flask app for ebook conversion and reading

This commit is contained in:
truetype committed 2026-07-22 15:45:07 +02:00
commit 32245f76ac
20 files changed
+2388

No files matched your search

+605
View File
@@ -0,0 +1,605 @@
/* === Reset & Base === */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f5f5f7;
color: #1d1d1f;
line-height: 1.6;
min-height: 100vh;
}
.container {
max-width: 720px;
margin: 0 auto;
padding: 2rem 1rem;
}
/* === Navbar === */
.navbar {
background: #fff;
border-bottom: 1px solid #e0e0e0;
position: sticky;
top: 0;
z-index: 100;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
}
.nav-inner {
max-width: 720px;
margin: 0 auto;
padding: 0.75rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-brand {
font-size: 1.2rem;
font-weight: 600;
text-decoration: none;
color: #1d1d1f;
}
.nav-links {
display: flex;
gap: 1rem;
}
.nav-link {
text-decoration: none;
color: #555;
padding: 0.3rem 0.6rem;
border-radius: 6px;
font-size: 0.9rem;
transition: background 0.2s, color 0.2s;
}
.nav-link:hover {
background: #f0f0f0;
color: #1d1d1f;
}
.nav-link.active {
background: #e8e8ed;
color: #1d1d1f;
font-weight: 500;
}
/* === Converter Page === */
.convert-page h1 {
font-size: 1.8rem;
font-weight: 700;
margin-bottom: 0.3rem;
}
.subtitle {
color: #666;
margin-bottom: 2rem;
}
/* Drop Zone */
.drop-zone {
border: 2px dashed #c7c7cc;
border-radius: 12px;
padding: 3rem 2rem;
text-align: center;
cursor: pointer;
transition: border-color 0.3s, background 0.3s;
background: #fff;
}
.drop-zone:hover {
border-color: #007aff;
}
.drop-zone.drag-over {
border-color: #007aff;
background: #f0f7ff;
}
.drop-icon {
font-size: 2.5rem;
display: block;
margin-bottom: 0.8rem;
}
.drop-hint {
font-size: 0.85rem;
color: #999;
margin-top: 0.3rem;
}
.file-input {
display: none;
}
/* File Info */
.file-info {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.8rem 1rem;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
margin-top: 1rem;
}
.file-name {
flex: 1;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-remove {
background: none;
border: none;
font-size: 1.3rem;
cursor: pointer;
color: #999;
padding: 0.2rem;
line-height: 1;
}
.btn-remove:hover {
color: #ff3b30;
}
/* Format Grid */
.format-section {
margin-top: 1.5rem;
}
.section-label {
font-weight: 500;
font-size: 0.9rem;
color: #555;
display: block;
margin-bottom: 0.6rem;
}
.format-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.format-btn {
padding: 0.5rem 1rem;
border: 1px solid #d0d0d5;
border-radius: 8px;
background: #fff;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
color: #333;
}
.format-btn:hover {
border-color: #007aff;
color: #007aff;
}
.format-btn.selected {
background: #007aff;
color: #fff;
border-color: #007aff;
}
.format-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Convert Button */
.btn-convert {
width: 100%;
padding: 0.9rem;
margin-top: 1.5rem;
background: #007aff;
color: #fff;
border: none;
border-radius: 10px;
font-size: 1.05rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s, opacity 0.2s;
}
.btn-convert:hover:not(:disabled) {
background: #0066d6;
}
.btn-convert:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Progress */
.progress-section {
margin-top: 2rem;
}
.progress-bar-container {
width: 100%;
height: 8px;
background: #e0e0e0;
border-radius: 4px;
overflow: hidden;
}
.progress-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #007aff, #34c759);
border-radius: 4px;
transition: width 0.3s ease;
}
.progress-status {
margin-top: 0.6rem;
font-size: 0.9rem;
color: #666;
}
/* Result */
.result-section {
margin-top: 2rem;
}
.result-success {
background: #e8f8ee;
border: 1px solid #b8e6c8;
border-radius: 12px;
padding: 2rem;
text-align: center;
}
.result-icon {
font-size: 2rem;
display: block;
margin-bottom: 0.5rem;
}
.result-success p {
margin-bottom: 1rem;
font-weight: 500;
}
.btn-download, .btn-read {
display: inline-block;
padding: 0.7rem 1.5rem;
border-radius: 8px;
text-decoration: none;
font-weight: 500;
transition: background 0.2s;
margin: 0.3rem;
}
.btn-download {
background: #007aff;
color: #fff;
}
.btn-download:hover {
background: #0066d6;
}
.btn-read {
background: #f0f0f0;
color: #333;
border: 1px solid #d0d0d5;
cursor: pointer;
font-size: 0.95rem;
}
.btn-read:hover {
background: #e0e0e0;
}
/* Error */
.error-section {
margin-top: 2rem;
background: #fef0ef;
border: 1px solid #f5c6cb;
border-radius: 12px;
padding: 1.5rem;
color: #c00;
}
/* === Reader Page === */
.reader-page {
max-width: 100%;
padding: 0;
}
.reader-empty {
text-align: center;
padding-top: 2rem;
}
.reader-empty h2 {
font-size: 1.8rem;
font-weight: 700;
}
.reader-upload {
max-width: 500px;
margin: 2rem auto;
}
.reader-drop-zone {
padding: 2rem 1.5rem;
}
.reader-recent {
max-width: 500px;
margin: 2rem auto;
text-align: left;
}
.reader-recent h3 {
font-size: 1rem;
margin-bottom: 0.8rem;
color: #555;
}
#recentList {
list-style: none;
}
#recentList li {
padding: 0.6rem 0.8rem;
border-bottom: 1px solid #eee;
}
#recentList li a {
text-decoration: none;
color: #007aff;
}
/* Reader UI */
.reader-toolbar {
background: #fff;
border-bottom: 1px solid #e0e0e0;
padding: 0.5rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 50;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
}
.toolbar-left, .toolbar-center, .toolbar-right {
display: flex;
align-items: center;
gap: 0.4rem;
}
.tool-btn {
background: none;
border: 1px solid transparent;
padding: 0.4rem 0.6rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
color: #555;
transition: all 0.2s;
}
.tool-btn:hover {
background: #f0f0f0;
border-color: #d0d0d5;
}
.chapter-title {
font-size: 0.85rem;
color: #666;
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.page-info {
font-size: 0.8rem;
color: #999;
}
/* Reader Layout */
.reader-container {
display: flex;
height: calc(100vh - 100px);
}
.reader-sidebar {
width: 280px;
background: #fff;
border-right: 1px solid #e0e0e0;
overflow-y: auto;
flex-shrink: 0;
transition: margin-left 0.3s;
}
.reader-sidebar.collapsed {
margin-left: -280px;
}
.sidebar-tabs {
display: flex;
border-bottom: 1px solid #e0e0e0;
}
.sidebar-tab {
flex: 1;
padding: 0.7rem 0.3rem;
background: none;
border: none;
cursor: pointer;
font-size: 0.8rem;
color: #666;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.sidebar-tab.active {
color: #007aff;
border-bottom-color: #007aff;
}
.sidebar-content {
padding: 0.5rem;
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
#tocList {
list-style: none;
}
#tocList li {
padding: 0.4rem 0.5rem;
cursor: pointer;
border-radius: 4px;
font-size: 0.85rem;
color: #333;
transition: background 0.2s;
}
#tocList li:hover {
background: #f0f0f0;
}
#tocList li.active {
background: #e8f0ff;
color: #007aff;
font-weight: 500;
}
.search-input {
width: 100%;
padding: 0.6rem;
border: 1px solid #d0d0d5;
border-radius: 6px;
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
.search-result-item {
padding: 0.5rem;
cursor: pointer;
border-radius: 4px;
font-size: 0.85rem;
border-bottom: 1px solid #f0f0f0;
}
.search-result-item:hover {
background: #f0f0f0;
}
#bookmarksList {
list-style: none;
}
#bookmarksList li {
padding: 0.5rem;
font-size: 0.85rem;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
}
.reader-view {
flex: 1;
overflow-y: auto;
background: #fff;
position: relative;
}
#epubViewer {
width: 100%;
height: 100%;
position: relative;
}
#pdfViewer {
width: 100%;
height: 100%;
overflow-y: auto;
text-align: center;
padding: 1rem;
}
#pdfViewer canvas {
margin: 0 auto;
display: block;
}
.text-viewer {
padding: 2rem 3rem;
max-width: 700px;
margin: 0 auto;
line-height: 1.8;
font-size: 1rem;
}
.text-viewer img {
max-width: 100%;
height: auto;
}
.text-viewer table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
.text-viewer th, .text-viewer td {
border: 1px solid #ccc;
padding: 8px;
vertical-align: top;
}
/* === Responsive === */
@media (max-width: 600px) {
.container {
padding: 1rem;
}
.reader-sidebar {
width: 240px;
}
.reader-sidebar.collapsed {
margin-left: -240px;
}
.text-viewer {
padding: 1rem;
}
.format-grid {
gap: 0.4rem;
}
.format-btn {
font-size: 0.8rem;
padding: 0.4rem 0.7rem;
}
}
+226
View File
@@ -0,0 +1,226 @@
(() => {
"use strict";
const dropZone = document.getElementById("dropZone");
const fileInput = document.getElementById("fileInput");
const fileInfo = document.getElementById("fileInfo");
const fileName = document.getElementById("fileName");
const removeBtn = document.getElementById("removeFile");
const formatSection = document.getElementById("formatSection");
const formatGrid = document.getElementById("formatGrid");
const convertBtn = document.getElementById("convertBtn");
const form = document.getElementById("convert-form");
const progressSection = document.getElementById("progressSection");
const progressBar = document.getElementById("progressBar");
const progressStatus = document.getElementById("progressStatus");
const resultSection = document.getElementById("resultSection");
const downloadLink = document.getElementById("downloadLink");
const readLink = document.getElementById("readLink");
const errorSection = document.getElementById("errorSection");
const errorMessage = document.getElementById("errorMessage");
let selectedFile = null;
let selectedFormat = null;
let currentTaskId = null;
let readerFileId = null;
// --- Drag & Drop ---
dropZone.addEventListener("click", () => fileInput.click());
dropZone.addEventListener("dragover", (e) => {
e.preventDefault();
dropZone.classList.add("drag-over");
});
dropZone.addEventListener("dragleave", () => {
dropZone.classList.remove("drag-over");
});
dropZone.addEventListener("drop", (e) => {
e.preventDefault();
dropZone.classList.remove("drag-over");
if (e.dataTransfer.files.length > 0) {
handleFile(e.dataTransfer.files[0]);
}
});
fileInput.addEventListener("change", () => {
if (fileInput.files.length > 0) {
handleFile(fileInput.files[0]);
}
});
removeBtn.addEventListener("click", () => {
resetForm();
});
// --- Format Selection ---
formatGrid.querySelectorAll(".format-btn").forEach((btn) => {
btn.addEventListener("click", () => {
formatGrid.querySelectorAll(".format-btn").forEach((b) => b.classList.remove("selected"));
btn.classList.add("selected");
selectedFormat = btn.dataset.format;
convertBtn.disabled = false;
});
});
// --- Form Submit ---
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!selectedFile || !selectedFormat) return;
hideAllResults();
progressSection.style.display = "block";
convertBtn.disabled = true;
const formData = new FormData();
formData.append("file", selectedFile);
formData.append("output_format", selectedFormat);
try {
const resp = await fetch("/upload", { method: "POST", body: formData });
const data = await resp.json();
if (!resp.ok) {
showError(data.error || "Errore durante l'upload");
return;
}
currentTaskId = data.task_id;
readerFileId = data.filename;
// Connect SSE for progress
connectSSE(currentTaskId);
} catch (err) {
showError("Errore di connessione al server");
}
});
// --- SSE Progress ---
function connectSSE(taskId) {
const evtSource = new EventSource(`/progress/${taskId}`);
evtSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
updateProgressUI(data);
if (data.status === "completed") {
evtSource.close();
showResult(data);
} else if (data.status === "error") {
evtSource.close();
showError(data.error || "Errore durante la conversione");
}
} catch (err) {
// ignore parse errors
}
};
evtSource.onerror = () => {
evtSource.close();
if (currentTaskId) {
// Try fetching status directly as fallback
fetch(`/progress/${currentTaskId}`)
.then((r) => r.json())
.catch(() => {});
}
};
}
function updateProgressUI(data) {
progressBar.style.width = `${data.progress}%`;
const statusTexts = {
started: "Avvio conversione...",
analyzing: "Analisi del file in corso...",
converting: `Conversione in corso (${data.progress}%)...`,
finalizing: "Finalizzazione...",
completed: "Completato!",
error: "Errore",
};
progressStatus.textContent = statusTexts[data.status] || `Stato: ${data.status}`;
}
function showResult(data) {
progressSection.style.display = "none";
resultSection.style.display = "block";
const ext = selectedFormat;
downloadLink.href = data.download_url || "#";
downloadLink.download = `converted.${ext}`;
readLink.onclick = () => {
if (readerFileId) {
const encoded = encodeURIComponent(readerFileId);
window.open(`/reader/${encoded}`, "_blank");
}
};
convertBtn.disabled = false;
currentTaskId = null;
}
function showError(msg) {
progressSection.style.display = "none";
errorSection.style.display = "block";
errorMessage.textContent = msg;
convertBtn.disabled = false;
currentTaskId = null;
}
function hideAllResults() {
progressSection.style.display = "none";
resultSection.style.display = "none";
errorSection.style.display = "none";
}
// --- Helpers ---
function handleFile(file) {
selectedFile = file;
fileName.textContent = file.name;
fileInfo.style.display = "flex";
formatSection.style.display = "block";
resetOutputSelection();
updateValidFormats(file.name);
if (selectedFormat) {
convertBtn.disabled = false;
}
}
function resetForm() {
selectedFile = null;
selectedFormat = null;
currentTaskId = null;
readerFileId = null;
fileInput.value = "";
fileInfo.style.display = "none";
formatSection.style.display = "none";
hideAllResults();
convertBtn.disabled = true;
resetOutputSelection();
}
function resetOutputSelection() {
selectedFormat = null;
formatGrid.querySelectorAll(".format-btn").forEach((b) => b.classList.remove("selected"));
}
async function updateValidFormats(filename) {
const ext = filename.split(".").pop().toLowerCase();
try {
const resp = await fetch(`/formats?input=${ext}`);
const data = await resp.json();
const available = data.available || [];
formatGrid.querySelectorAll(".format-btn").forEach((btn) => {
const fmt = btn.dataset.format;
btn.disabled = !available.includes(fmt);
});
} catch (err) {
// Allow all on error
}
}
})();
+597
View File
@@ -0,0 +1,597 @@
(() => {
"use strict";
if (!BOOKISH_FILE) {
initReaderUpload();
return;
}
const ext = BOOKISH_EXT;
const filename = BOOKISH_FILE;
if (ext === "epub") {
initEpubReader(filename);
} else if (ext === "pdf") {
initPdfReader(filename);
} else if (["md", "txt", "html", "fb2", "docx"].includes(ext)) {
initTextViewer(filename, ext);
}
// --- Shared Reader Controls ---
initReaderControls();
// === EPUB Reader (epub.js) ===
function initEpubReader(file) {
const viewer = document.getElementById("epubViewer");
if (!viewer) return;
const book = ePub(`/reader/serve/${encodeURIComponent(file)}`);
const rendition = book.renderTo("epubViewer", {
width: "100%",
height: window.innerHeight - 100,
spread: "auto",
flow: "paginated",
});
rendition.display();
// Navigation
let currentToc = [];
book.loaded.navigation.then((toc) => {
currentToc = toc;
renderToc(toc, rendition, book);
});
rendition.on("relocated", (loc) => {
updatePageInfo(book, loc);
updateActiveToc(loc, currentToc, book);
savePosition(book, loc);
});
// Keyboard
document.addEventListener("keydown", (e) => {
if (e.key === "ArrowRight") rendition.next();
if (e.key === "ArrowLeft") rendition.prev();
if (e.key === "=" || e.key === "+") changeFontSize(rendition, 2);
if (e.key === "-") changeFontSize(rendition, -2);
});
// Toolbar controls
document.getElementById("zoomIn").addEventListener("click", () => rendition.next());
document.getElementById("zoomOut").addEventListener("click", () => rendition.prev());
// Store ref for theme
window._rendition = rendition;
window._book = book;
// Restore position
restorePosition(book, rendition, file);
}
// === PDF Reader (pdf.js) ===
function initPdfReader(file) {
const viewer = document.getElementById("pdfViewer");
if (!viewer) return;
const pdfjsLib = window.pdfjsLib;
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
let pdfDoc = null;
let currentPage = 1;
let totalPages = 0;
let scale = 1.2;
function renderPage(num) {
viewer.innerHTML = "";
pdfDoc.getPage(num).then((page) => {
const viewport = page.getViewport({ scale });
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.height = viewport.height;
canvas.width = viewport.width;
viewer.appendChild(canvas);
const renderCtx = {
canvasContext: ctx,
viewport,
};
return page.render(renderCtx).promise;
});
}
// Load pdf
pdfjsLib.getDocument(`/reader/serve/${encodeURIComponent(file)}`).promise.then((pdf) => {
pdfDoc = pdf;
totalPages = pdf.numPages;
renderPage(currentPage);
updatePageInfoPdf(currentPage, totalPages);
// Render TOC if available
pdf.getOutline().then((outline) => {
if (outline && outline.length > 0) {
renderPdfToc(outline, pdf);
}
});
});
document.getElementById("zoomIn").addEventListener("click", () => {
scale = Math.min(scale + 0.2, 3);
if (pdfDoc) renderPage(currentPage);
});
document.getElementById("zoomOut").addEventListener("click", () => {
scale = Math.max(scale - 0.2, 0.5);
if (pdfDoc) renderPage(currentPage);
});
document.addEventListener("keydown", (e) => {
if (!pdfDoc) return;
if (e.key === "ArrowRight" || e.key === " ") {
e.preventDefault();
if (currentPage < totalPages) {
currentPage++;
renderPage(currentPage);
updatePageInfoPdf(currentPage, totalPages);
}
}
if (e.key === "ArrowLeft") {
if (currentPage > 1) {
currentPage--;
renderPage(currentPage);
updatePageInfoPdf(currentPage, totalPages);
}
}
});
window._pdfViewer = { pdfDoc, renderPage, getCurrentPage: () => currentPage, totalPages };
}
// === Text Viewer (HTML, MD, TXT, FB2, DOCX via Pandoc) ===
function initTextViewer(file, ext) {
const viewer = document.getElementById("textViewer");
if (!viewer) return;
// Convert text via Pandoc on backend
fetch(`/reader/serve/${encodeURIComponent(file)}`)
.then((r) => r.text())
.then((content) => {
if (ext === "md" || ext === "fb2" || ext === "docx") {
// Pandoc converts these to HTML on load for reader
// But we need the HTML version. Try to get pre-converted HTML.
fetch(`/reader/serve/${encodeURIComponent(file)}`)
.then((r) => r.text())
.then((text) => {
// For plain text (md, txt), render as HTML with simple formatting
if (ext === "md") {
viewer.innerHTML = simpleMarkdownToHtml(text);
} else {
viewer.innerHTML = `<pre style="white-space:pre-wrap;font-family:inherit;font-size:1rem;line-height:1.8;">${escapeHtml(text)}</pre>`;
}
});
} else if (ext === "html") {
// Direct HTML content
viewer.innerHTML = content;
fixLinks(viewer);
} else {
viewer.textContent = content;
}
});
// No zoom controls needed for text view (CSS handles it)
document.getElementById("zoomIn").style.display = "none";
document.getElementById("zoomOut").style.display = "none";
}
function simpleMarkdownToHtml(md) {
// Simple markdown renderer without dependencies
let html = md
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
// Images
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%">')
// Links
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>')
// Bold
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
// Italic
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
// Inline code
.replace(/`([^`]+)`/g, "<code>$1</code>")
// Headings
.replace(/^### (.+)$/gm, "<h3>$1</h3>")
.replace(/^## (.+)$/gm, "<h2>$1</h2>")
.replace(/^# (.+)$/gm, "<h1>$1</h1>")
// Horizontal rule
.replace(/^---$/gm, "<hr>")
// Blockquote
.replace(/^> (.+)$/gm, "<blockquote>$1</blockquote>")
// Paragraphs (double newline)
.replace(/\n\n/g, "</p><p>")
// Line breaks
.replace(/\n/g, "<br>");
return `<p>${html}</p>`;
}
function escapeHtml(text) {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function fixLinks(container) {
container.querySelectorAll("a").forEach((a) => a.setAttribute("target", "_blank"));
}
// === Shared Controls ===
function initReaderControls() {
const sidebar = document.getElementById("sidebar");
const sidebarToggle = document.getElementById("sidebarToggle");
const themeToggle = document.getElementById("themeToggle");
const searchToggle = document.getElementById("searchToggle");
const searchInput = document.getElementById("searchInput");
const searchResults = document.getElementById("searchResults");
const fullscreenToggle = document.getElementById("fullscreenToggle");
// Sidebar toggle
if (sidebarToggle && sidebar) {
sidebarToggle.addEventListener("click", () => {
sidebar.classList.toggle("collapsed");
sidebarToggle.textContent = sidebar.classList.contains("collapsed") ? "☰" : "✕";
});
}
// Sidebar tabs
document.querySelectorAll(".sidebar-tab").forEach((tab) => {
tab.addEventListener("click", () => {
document.querySelectorAll(".sidebar-tab").forEach((t) => t.classList.remove("active"));
document.querySelectorAll(".tab-panel").forEach((p) => p.classList.remove("active"));
tab.classList.add("active");
const panel = document.getElementById(`panel-${tab.dataset.tab}`);
if (panel) panel.classList.add("active");
});
});
// Theme toggle
if (themeToggle) {
let themeIndex = 0;
const themes = [
{ name: "light", bg: "#fff", fg: "#1d1d1f", label: "☀️" },
{ name: "dark", bg: "#1d1d1f", fg: "#eee", label: "🌙" },
{ name: "sepia", bg: "#fbf0d9", fg: "#433422", label: "🟤" },
];
themeToggle.addEventListener("click", () => {
themeIndex = (themeIndex + 1) % themes.length;
const t = themes[themeIndex];
themeToggle.textContent = t.label;
applyTheme(t);
});
}
// Search
if (searchInput && searchResults) {
searchInput.addEventListener("input", debounce(() => {
const q = searchInput.value.trim().toLowerCase();
if (!q || q.length < 2) {
searchResults.innerHTML = "";
return;
}
searchInBook(q);
}, 300));
}
// Fullscreen
if (fullscreenToggle) {
fullscreenToggle.addEventListener("click", () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen().catch(() => {});
}
});
}
// Load saved theme
const savedTheme = localStorage.getItem("bookish_theme");
if (savedTheme) {
try {
const t = JSON.parse(savedTheme);
applyTheme(t);
} catch (e) {}
}
}
function applyTheme(t) {
document.body.style.background = t.bg;
document.body.style.color = t.fg;
localStorage.setItem("bookish_theme", JSON.stringify(t));
// epub.js theme if available
if (window._rendition && window._rendition.themes) {
const themeName = t.name === "dark" ? "dark" : t.name === "sepia" ? "sepia" : "light";
try {
window._rendition.themes.select(themeName);
} catch (e) {
// Theme might not be registered
}
}
}
function changeFontSize(rendition, delta) {
const current = parseFloat(localStorage.getItem("bookish_fontsize") || "100");
const newSize = Math.max(60, Math.min(200, current + delta * 10));
localStorage.setItem("bookish_fontsize", newSize.toString());
rendition.themes.fontSize(`${newSize}%`);
}
function updatePageInfo(book, loc) {
const info = document.getElementById("pageInfo");
if (info && loc && loc.start) {
const current = loc.start.displayed.page || 0;
const total = loc.start.displayed.total || 0;
info.textContent = `${current} / ${total}`;
}
}
function updatePageInfoPdf(current, total) {
const info = document.getElementById("pageInfo");
if (info) {
info.textContent = `${current} / ${total}`;
}
}
function renderToc(toc, rendition, book) {
const list = document.getElementById("tocList");
if (!list) return;
list.innerHTML = "";
function appendItems(items, parent) {
items.forEach((item) => {
const li = document.createElement("li");
li.textContent = item.label;
li.style.paddingLeft = `${(item.subitems ? 0.5 : 1.5)}rem`;
li.addEventListener("click", () => {
rendition.display(item.href);
document.querySelectorAll("#tocList li").forEach((l) => l.classList.remove("active"));
li.classList.add("active");
});
parent.appendChild(li);
if (item.subitems && item.subitems.length > 0) {
appendItems(item.subitems, parent);
}
});
}
appendItems(toc, list);
}
function renderPdfToc(outline, pdf) {
const list = document.getElementById("tocList");
if (!list) return;
list.innerHTML = "";
function appendOutline(items, parent) {
items.forEach((item) => {
const li = document.createElement("li");
li.textContent = item.title;
li.addEventListener("click", async () => {
const dest = await pdf.getDestination(item.dest);
if (dest) {
const pageIndex = await pdf.getPageIndex(dest[0]);
if (window._pdfViewer) {
window._pdfViewer.currentPage = pageIndex + 1;
window._pdfViewer.renderPage(window._pdfViewer.currentPage);
updatePageInfoPdf(window._pdfViewer.currentPage, window._pdfViewer.totalPages);
}
}
});
parent.appendChild(li);
if (item.items && item.items.length > 0) {
appendOutline(item.items, parent);
}
});
}
appendOutline(outline, list);
}
function updateActiveToc(loc, toc, book) {
// epub.js provides location.start.cfi
document.querySelectorAll("#tocList li").forEach((l) => l.classList.remove("active"));
}
// === Search ===
function searchInBook(query) {
const results = document.getElementById("searchResults");
if (!results) return;
// For epub.js
if (window._book && window._book.search) {
window._book.search(query).then((res) => {
results.innerHTML = "";
if (!res || res.length === 0) {
results.innerHTML = "<p style='padding:0.5rem;color:#999'>Nessun risultato</p>";
return;
}
res.forEach((r) => {
const div = document.createElement("div");
div.className = "search-result-item";
div.textContent = r.excerpt || r.text || r.cfi;
div.addEventListener("click", () => {
if (window._rendition) {
window._rendition.display(r.cfi);
}
});
results.appendChild(div);
});
});
} else if (window._pdfViewer && window._pdfViewer.pdfDoc) {
pdfSearch(query, results);
} else {
results.innerHTML = "<p style='padding:0.5rem;color:#999'>Ricerca non disponibile per questo formato</p>";
}
}
let pdfSearchTimer = null;
function pdfSearch(query, resultsEl) {
const pdf = window._pdfViewer.pdfDoc;
resultsEl.innerHTML = "<p style='padding:0.5rem;color:#999'>Ricerca in corso...</p>";
if (pdfSearchTimer) clearTimeout(pdfSearchTimer);
pdfSearchTimer = setTimeout(async () => {
let allResults = [];
for (let i = 1; i <= pdf.numPages; i++) {
try {
const page = await pdf.getPage(i);
const text = await page.getTextContent();
const pageText = text.items.map((t) => t.str).join(" ");
if (pageText.toLowerCase().includes(query.toLowerCase())) {
allResults.push({ page: i, text: pageText.substring(0, 120) });
}
} catch (e) {}
}
resultsEl.innerHTML = "";
if (allResults.length === 0) {
resultsEl.innerHTML = "<p style='padding:0.5rem;color:#999'>Nessun risultato</p>";
return;
}
allResults.forEach((r) => {
const div = document.createElement("div");
div.className = "search-result-item";
div.textContent = `Pag. ${r.page}: ${r.text}...`;
div.addEventListener("click", () => {
if (window._pdfViewer) {
window._pdfViewer.currentPage = r.page;
window._pdfViewer.renderPage(r.page);
updatePageInfoPdf(r.page, window._pdfViewer.totalPages);
}
});
resultsEl.appendChild(div);
});
}, 500);
}
// === Position Save/Restore ===
function savePosition(book, loc) {
try {
const key = `bookish_pos_${book.url() || "unknown"}`;
const data = {
cfi: loc.start ? loc.start.cfi : null,
chapter: loc.start ? loc.start.href : null,
timestamp: Date.now(),
};
localStorage.setItem(key, JSON.stringify(data));
} catch (e) {}
}
function restorePosition(book, rendition, filename) {
try {
const key = `bookish_pos_${filename}`;
const saved = localStorage.getItem(key);
if (saved) {
const data = JSON.parse(saved);
if (data.cfi) {
rendition.display(data.cfi);
}
}
} catch (e) {}
}
// === Reader Upload ===
function initReaderUpload() {
const dropZone = document.getElementById("readerDropZone");
const fileInput = document.getElementById("readerFileInput");
const recentReads = document.getElementById("recentReads");
const recentList = document.getElementById("recentList");
if (dropZone && fileInput) {
dropZone.addEventListener("click", () => fileInput.click());
dropZone.addEventListener("dragover", (e) => {
e.preventDefault();
dropZone.classList.add("drag-over");
});
dropZone.addEventListener("dragleave", () => {
dropZone.classList.remove("drag-over");
});
dropZone.addEventListener("drop", (e) => {
e.preventDefault();
dropZone.classList.remove("drag-over");
if (e.dataTransfer.files.length > 0) {
uploadAndRead(e.dataTransfer.files[0]);
}
});
fileInput.addEventListener("change", () => {
if (fileInput.files.length > 0) {
uploadAndRead(fileInput.files[0]);
}
});
}
// Show recent reads
showRecentReads();
}
function uploadAndRead(file) {
const formData = new FormData();
formData.append("file", file);
formData.append("output_format", "html"); // placeholder, we just upload
fetch("/upload", {
method: "POST",
body: formData,
})
.then((r) => r.json())
.then((data) => {
if (data.task_id) {
const ext = file.name.split(".").pop().toLowerCase();
const encoded = encodeURIComponent(data.filename);
// We'll just redirect to reader page after upload completes
// For simplicity, redirect immediately
window.location.href = `/reader/${encoded}`;
} else {
alert("Errore upload: " + (data.error || "errore sconosciuto"));
}
})
.catch((err) => {
alert("Errore di connessione");
});
}
function showRecentReads() {
// Show file from localStorage
const recent = JSON.parse(localStorage.getItem("bookish_recent") || "[]");
if (recent.length > 0) {
const section = document.getElementById("recentReads");
const list = document.getElementById("recentList");
if (section && list) {
section.style.display = "block";
list.innerHTML = recent
.slice(0, 10)
.map(
(f) =>
`<li><a href="/reader/${encodeURIComponent(f.filename)}">${f.name}</a> <span style="color:#999;font-size:0.8rem">${f.format}</span></li>`
)
.join("");
}
}
}
// === Utils ===
function debounce(fn, ms) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
})();