Initial commit: Bookish Flask app for ebook conversion and reading
This commit is contained in:
commit
32245f76ac
20 files changed
+2388
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
// 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
}
|
||||
})();
|
||||
Reference in new issue
Block a user