Spaces:
Running
Running
Upload 11 files
Browse files- base.py +31 -0
- book_utils.py +301 -0
- database.py +336 -0
- generate_icons.py +41 -0
- llm_service.py +141 -0
- settings.py +11 -0
- static/icon-192.png +0 -0
- static/icon-512.png +0 -0
- static/manifest.json +22 -0
- static/sw.js +33 -0
- templates/index.html +1080 -0
base.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
SUPPORTED_LANGUAGES = [
|
| 2 |
+
{'name': 'Bulgarian', 'code': 'bg', 'small': True, 'regional': False, 'flag': '🇧🇬'},
|
| 3 |
+
{'name': 'Croatian', 'code': 'hr', 'small': True, 'regional': False, 'flag': '🇭🇷'},
|
| 4 |
+
{'name': 'Czech', 'code': 'cs', 'small': True, 'regional': False, 'flag': '🇨🇿'},
|
| 5 |
+
{'name': 'Danish', 'code': 'da', 'small': True, 'regional': False, 'flag': '🇩🇰'},
|
| 6 |
+
{'name': 'Dutch', 'code': 'nl', 'small': False, 'regional': False, 'flag': '🇳🇱'},
|
| 7 |
+
{'name': 'English', 'code': 'en', 'small': False, 'regional': False, 'flag': '🇬🇧'},
|
| 8 |
+
{'name': 'Estonian', 'code': 'et', 'small': True, 'regional': False, 'flag': '🇪🇪'},
|
| 9 |
+
{'name': 'Finnish', 'code': 'fi', 'small': True, 'regional': False, 'flag': '🇫🇮'},
|
| 10 |
+
{'name': 'French', 'code': 'fr', 'small': False, 'regional': False, 'flag': '🇫🇷'},
|
| 11 |
+
{'name': 'German', 'code': 'de', 'small': False, 'regional': False, 'flag': '🇩🇪'},
|
| 12 |
+
{'name': 'Greek', 'code': 'el', 'small': True, 'regional': False, 'flag': '🇬🇷'},
|
| 13 |
+
{'name': 'Hungarian', 'code': 'hu', 'small': True, 'regional': False, 'flag': '🇭🇺'},
|
| 14 |
+
{'name': 'Irish', 'code': 'ga', 'small': True, 'regional': False, 'flag': '🇮🇪'},
|
| 15 |
+
{'name': 'Italian', 'code': 'it', 'small': False, 'regional': False, 'flag': '🇮🇹'},
|
| 16 |
+
{'name': 'Latvian', 'code': 'lv', 'small': True, 'regional': False, 'flag': '🇱🇻'},
|
| 17 |
+
{'name': 'Lithuanian', 'code': 'lt', 'small': True, 'regional': False, 'flag': '🇱🇹'},
|
| 18 |
+
{'name': 'Maltese', 'code': 'mt', 'small': True, 'regional': False, 'flag': '🇲🇹'},
|
| 19 |
+
{'name': 'Polish', 'code': 'pl', 'small': False, 'regional': False, 'flag': '🇵🇱'},
|
| 20 |
+
{'name': 'Portuguese', 'code': 'pt', 'small': False, 'regional': False, 'flag': '🇵🇹'},
|
| 21 |
+
{'name': 'Romanian', 'code': 'ro', 'small': False, 'regional': False, 'flag': '🇷🇴'},
|
| 22 |
+
{'name': 'Slovak', 'code': 'sk', 'small': True, 'regional': False, 'flag': '🇸🇰'},
|
| 23 |
+
{'name': 'Slovenian', 'code': 'sl', 'small': True, 'regional': False, 'flag': '🇸🇮'},
|
| 24 |
+
{'name': 'Spanish', 'code': 'es', 'small': False, 'regional': False, 'flag': '🇪🇸'},
|
| 25 |
+
{'name': 'Swedish', 'code': 'sv', 'small': True, 'regional': False, 'flag': '🇸🇪'},
|
| 26 |
+
{'name': 'Catalan', 'code': 'ca', 'small': True, 'regional': True, 'flag': '🏴'},
|
| 27 |
+
{'name': 'Breton', 'code': 'br', 'small': True, 'regional': True, 'flag': '🏴'},
|
| 28 |
+
{'name': 'Welsh', 'code': 'cy', 'small': True, 'regional': True, 'flag': '🏴'},
|
| 29 |
+
{'name': 'Scottish Gaelic', 'code': 'gd', 'small': True, 'regional': True, 'flag': '🏴'},
|
| 30 |
+
{'name': 'Ukrainian', 'code': 'uk', 'small': False, 'regional': False, 'flag': '🇺🇦'}
|
| 31 |
+
]
|
book_utils.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Utilities for parsing PDF, EPUB, MOBI files and extracting/generating covers.
|
| 2 |
+
|
| 3 |
+
Content is returned as a list of segments. Each segment is either:
|
| 4 |
+
{"type": "text", "content": "..."} or
|
| 5 |
+
{"type": "image", "src": "/book_images/abc123_0.png"}
|
| 6 |
+
This allows the frontend to render images inline between text blocks.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import base64
|
| 11 |
+
import fitz # PyMuPDF
|
| 12 |
+
import ebooklib
|
| 13 |
+
from ebooklib import epub
|
| 14 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 15 |
+
from bs4 import BeautifulSoup
|
| 16 |
+
import mobi
|
| 17 |
+
import shutil
|
| 18 |
+
|
| 19 |
+
import settings
|
| 20 |
+
|
| 21 |
+
IMG_COUNTER = 0
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def ensure_dirs():
|
| 25 |
+
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
|
| 26 |
+
os.makedirs(settings.COVERS_DIR, exist_ok=True)
|
| 27 |
+
os.makedirs(settings.IMAGES_DIR, exist_ok=True)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _save_image_bytes(img_bytes: bytes, book_id: str, ext: str = "png") -> str:
|
| 31 |
+
"""Save image bytes to disk and return the URL path."""
|
| 32 |
+
global IMG_COUNTER
|
| 33 |
+
IMG_COUNTER += 1
|
| 34 |
+
fname = f"{book_id}_{IMG_COUNTER}.{ext}"
|
| 35 |
+
fpath = os.path.join(settings.IMAGES_DIR, fname)
|
| 36 |
+
with open(fpath, "wb") as f:
|
| 37 |
+
f.write(img_bytes)
|
| 38 |
+
return f"/book_images/{fname}"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _clean_pdf_text(raw: str) -> str:
|
| 42 |
+
"""Clean PDF text: join mid-sentence line breaks, keep paragraph breaks."""
|
| 43 |
+
import re
|
| 44 |
+
lines = raw.split("\n")
|
| 45 |
+
result = []
|
| 46 |
+
for line in lines:
|
| 47 |
+
line = line.rstrip()
|
| 48 |
+
if not line:
|
| 49 |
+
# Empty line = paragraph break
|
| 50 |
+
if result and result[-1] != "\n":
|
| 51 |
+
result.append("\n")
|
| 52 |
+
continue
|
| 53 |
+
if result and result[-1] != "\n":
|
| 54 |
+
prev = result[-1]
|
| 55 |
+
# If previous line ends with sentence-ending punctuation, start new paragraph
|
| 56 |
+
if prev and prev[-1] in '.!?:;»""\u201d\u2019':
|
| 57 |
+
result.append("\n")
|
| 58 |
+
result.append(line)
|
| 59 |
+
elif prev and prev[-1] == '-':
|
| 60 |
+
# Hyphenated word across lines: join without space, remove hyphen
|
| 61 |
+
result[-1] = prev[:-1] + line
|
| 62 |
+
else:
|
| 63 |
+
# Mid-sentence break: join with space
|
| 64 |
+
result[-1] = prev + " " + line
|
| 65 |
+
else:
|
| 66 |
+
result.append(line)
|
| 67 |
+
# Collapse multiple newlines
|
| 68 |
+
text = "\n".join(result)
|
| 69 |
+
text = re.sub(r'\n{3,}', '\n\n', text)
|
| 70 |
+
return text.strip()
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def extract_pdf_content(filepath: str, book_id: str) -> list[dict]:
|
| 74 |
+
"""Extract text and images from PDF, returning ordered segments."""
|
| 75 |
+
doc = fitz.open(filepath)
|
| 76 |
+
segments = []
|
| 77 |
+
for page in doc:
|
| 78 |
+
blocks = page.get_text("dict", flags=fitz.TEXT_PRESERVE_WHITESPACE)["blocks"]
|
| 79 |
+
# Collect raw text blocks and images sorted by position
|
| 80 |
+
page_text_parts = []
|
| 81 |
+
for block in sorted(blocks, key=lambda b: (b["bbox"][1], b["bbox"][0])):
|
| 82 |
+
if block["type"] == 0: # text block
|
| 83 |
+
text = ""
|
| 84 |
+
for line in block.get("lines", []):
|
| 85 |
+
for span in line.get("spans", []):
|
| 86 |
+
text += span.get("text", "")
|
| 87 |
+
text += "\n"
|
| 88 |
+
text = text.strip()
|
| 89 |
+
if text:
|
| 90 |
+
page_text_parts.append(text)
|
| 91 |
+
elif block["type"] == 1: # image block
|
| 92 |
+
# Flush accumulated text before the image
|
| 93 |
+
if page_text_parts:
|
| 94 |
+
merged = _clean_pdf_text("\n".join(page_text_parts))
|
| 95 |
+
if merged:
|
| 96 |
+
# Split into paragraphs
|
| 97 |
+
for para in merged.split("\n"):
|
| 98 |
+
para = para.strip()
|
| 99 |
+
if para:
|
| 100 |
+
segments.append({"type": "text", "content": para})
|
| 101 |
+
page_text_parts = []
|
| 102 |
+
try:
|
| 103 |
+
img_bytes = block.get("image")
|
| 104 |
+
if img_bytes and len(img_bytes) > 500:
|
| 105 |
+
ext = block.get("ext", "png") or "png"
|
| 106 |
+
src = _save_image_bytes(img_bytes, book_id, ext)
|
| 107 |
+
segments.append({"type": "image", "src": src})
|
| 108 |
+
except Exception:
|
| 109 |
+
pass
|
| 110 |
+
# Flush remaining text from this page
|
| 111 |
+
if page_text_parts:
|
| 112 |
+
merged = _clean_pdf_text("\n".join(page_text_parts))
|
| 113 |
+
if merged:
|
| 114 |
+
for para in merged.split("\n"):
|
| 115 |
+
para = para.strip()
|
| 116 |
+
if para:
|
| 117 |
+
segments.append({"type": "text", "content": para})
|
| 118 |
+
doc.close()
|
| 119 |
+
return segments
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def extract_pdf_cover(filepath: str, book_id: str) -> str | None:
|
| 123 |
+
doc = fitz.open(filepath)
|
| 124 |
+
if len(doc) == 0:
|
| 125 |
+
doc.close()
|
| 126 |
+
return None
|
| 127 |
+
page = doc[0]
|
| 128 |
+
images = page.get_images(full=True)
|
| 129 |
+
if images:
|
| 130 |
+
xref = images[0][0]
|
| 131 |
+
base_image = doc.extract_image(xref)
|
| 132 |
+
img_bytes = base_image["image"]
|
| 133 |
+
ext = base_image["ext"]
|
| 134 |
+
cover_path = os.path.join(settings.COVERS_DIR, f"{book_id}.{ext}")
|
| 135 |
+
with open(cover_path, "wb") as f:
|
| 136 |
+
f.write(img_bytes)
|
| 137 |
+
doc.close()
|
| 138 |
+
return cover_path
|
| 139 |
+
doc.close()
|
| 140 |
+
return None
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def extract_epub_content(filepath: str, book_id: str) -> list[dict]:
|
| 144 |
+
"""Extract text and images from EPUB, returning ordered segments."""
|
| 145 |
+
book = epub.read_epub(filepath, options={'ignore_ncx': True})
|
| 146 |
+
|
| 147 |
+
# Build a map of image items by their file name for lookup
|
| 148 |
+
image_map = {}
|
| 149 |
+
for item in book.get_items_of_type(ebooklib.ITEM_IMAGE):
|
| 150 |
+
image_map[item.get_name()] = item
|
| 151 |
+
# Also map by basename for relative references
|
| 152 |
+
image_map[os.path.basename(item.get_name())] = item
|
| 153 |
+
|
| 154 |
+
segments = []
|
| 155 |
+
for item in book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
|
| 156 |
+
soup = BeautifulSoup(item.get_content(), "html.parser")
|
| 157 |
+
|
| 158 |
+
for elem in soup.descendants:
|
| 159 |
+
if elem.name == 'img' or elem.name == 'image':
|
| 160 |
+
src_attr = elem.get('src') or elem.get('xlink:href') or elem.get('href', '')
|
| 161 |
+
# Resolve the image
|
| 162 |
+
img_name = src_attr.split('/')[-1] if src_attr else ''
|
| 163 |
+
img_item = image_map.get(src_attr) or image_map.get(img_name)
|
| 164 |
+
if img_item:
|
| 165 |
+
try:
|
| 166 |
+
img_bytes = img_item.get_content()
|
| 167 |
+
if len(img_bytes) > 500:
|
| 168 |
+
ext = img_name.rsplit('.', 1)[-1] if '.' in img_name else 'png'
|
| 169 |
+
s = _save_image_bytes(img_bytes, book_id, ext)
|
| 170 |
+
segments.append({"type": "image", "src": s})
|
| 171 |
+
except Exception:
|
| 172 |
+
pass
|
| 173 |
+
elif elem.name in ('p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'blockquote'):
|
| 174 |
+
text = elem.get_text(separator=" ", strip=True)
|
| 175 |
+
if text:
|
| 176 |
+
seg = {"type": "text", "content": text}
|
| 177 |
+
if elem.name in ('h1', 'h2', 'h3'):
|
| 178 |
+
seg["heading"] = True
|
| 179 |
+
segments.append(seg)
|
| 180 |
+
|
| 181 |
+
# Deduplicate consecutive identical text segments
|
| 182 |
+
deduped = []
|
| 183 |
+
for seg in segments:
|
| 184 |
+
if deduped and seg["type"] == "text" and deduped[-1]["type"] == "text" and deduped[-1]["content"] == seg["content"]:
|
| 185 |
+
continue
|
| 186 |
+
deduped.append(seg)
|
| 187 |
+
return deduped
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def extract_epub_cover(filepath: str, book_id: str) -> str | None:
|
| 191 |
+
book = epub.read_epub(filepath, options={'ignore_ncx': True})
|
| 192 |
+
for item in book.get_items():
|
| 193 |
+
if 'cover' in (item.get_name() or '').lower() and item.get_type() in (
|
| 194 |
+
ebooklib.ITEM_IMAGE, ebooklib.ITEM_COVER
|
| 195 |
+
):
|
| 196 |
+
ext = item.get_name().rsplit('.', 1)[-1] if '.' in item.get_name() else 'jpg'
|
| 197 |
+
cover_path = os.path.join(settings.COVERS_DIR, f"{book_id}.{ext}")
|
| 198 |
+
with open(cover_path, "wb") as f:
|
| 199 |
+
f.write(item.get_content())
|
| 200 |
+
return cover_path
|
| 201 |
+
for item in book.get_items_of_type(ebooklib.ITEM_IMAGE):
|
| 202 |
+
ext = item.get_name().rsplit('.', 1)[-1] if '.' in item.get_name() else 'jpg'
|
| 203 |
+
cover_path = os.path.join(settings.COVERS_DIR, f"{book_id}.{ext}")
|
| 204 |
+
with open(cover_path, "wb") as f:
|
| 205 |
+
f.write(item.get_content())
|
| 206 |
+
return cover_path
|
| 207 |
+
return None
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def extract_mobi_content(filepath: str, book_id: str) -> list[dict]:
|
| 211 |
+
"""Extract text from MOBI as segments."""
|
| 212 |
+
tempdir, extracted_path = mobi.extract(filepath)
|
| 213 |
+
try:
|
| 214 |
+
if extracted_path and os.path.isfile(extracted_path):
|
| 215 |
+
with open(extracted_path, "r", encoding="utf-8", errors="ignore") as f:
|
| 216 |
+
content = f.read()
|
| 217 |
+
soup = BeautifulSoup(content, "html.parser")
|
| 218 |
+
segments = []
|
| 219 |
+
for elem in soup.find_all(['p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']):
|
| 220 |
+
text = elem.get_text(separator=" ", strip=True)
|
| 221 |
+
if text:
|
| 222 |
+
segments.append({"type": "text", "content": text})
|
| 223 |
+
return segments if segments else [{"type": "text", "content": "[Empty book]"}]
|
| 224 |
+
return [{"type": "text", "content": "[Could not extract MOBI content]"}]
|
| 225 |
+
finally:
|
| 226 |
+
if tempdir and os.path.isdir(tempdir):
|
| 227 |
+
shutil.rmtree(tempdir, ignore_errors=True)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def extract_mobi_cover(filepath: str, book_id: str) -> str | None:
|
| 231 |
+
return None
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def generate_cover(title: str, book_id: str) -> str:
|
| 235 |
+
img = Image.new("RGB", (400, 600), color=(45, 55, 72))
|
| 236 |
+
draw = ImageDraw.Draw(img)
|
| 237 |
+
try:
|
| 238 |
+
font = ImageFont.truetype("arial.ttf", 28)
|
| 239 |
+
except (OSError, IOError):
|
| 240 |
+
font = ImageFont.load_default()
|
| 241 |
+
words = title.split()
|
| 242 |
+
lines, current = [], ""
|
| 243 |
+
for w in words:
|
| 244 |
+
test = f"{current} {w}".strip()
|
| 245 |
+
bbox = draw.textbbox((0, 0), test, font=font)
|
| 246 |
+
if bbox[2] - bbox[0] > 360:
|
| 247 |
+
if current: lines.append(current)
|
| 248 |
+
current = w
|
| 249 |
+
else:
|
| 250 |
+
current = test
|
| 251 |
+
if current: lines.append(current)
|
| 252 |
+
y = 200
|
| 253 |
+
for line in lines:
|
| 254 |
+
bbox = draw.textbbox((0, 0), line, font=font)
|
| 255 |
+
w = bbox[2] - bbox[0]
|
| 256 |
+
draw.text(((400 - w) / 2, y), line, fill="white", font=font)
|
| 257 |
+
y += 40
|
| 258 |
+
cover_path = os.path.join(settings.COVERS_DIR, f"{book_id}.png")
|
| 259 |
+
img.save(cover_path)
|
| 260 |
+
return cover_path
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def parse_book(filepath: str, book_id: str, title: str) -> dict:
|
| 264 |
+
"""Parse a book file and return content segments + cover path + chapters."""
|
| 265 |
+
import re
|
| 266 |
+
ext = os.path.splitext(filepath)[1].lower()
|
| 267 |
+
segments = []
|
| 268 |
+
cover = None
|
| 269 |
+
|
| 270 |
+
if ext == ".pdf":
|
| 271 |
+
segments = extract_pdf_content(filepath, book_id)
|
| 272 |
+
cover = extract_pdf_cover(filepath, book_id)
|
| 273 |
+
elif ext == ".epub":
|
| 274 |
+
segments = extract_epub_content(filepath, book_id)
|
| 275 |
+
cover = extract_epub_cover(filepath, book_id)
|
| 276 |
+
elif ext == ".mobi":
|
| 277 |
+
segments = extract_mobi_content(filepath, book_id)
|
| 278 |
+
cover = extract_mobi_cover(filepath, book_id)
|
| 279 |
+
else:
|
| 280 |
+
raise ValueError(f"Unsupported format: {ext}")
|
| 281 |
+
|
| 282 |
+
if not cover:
|
| 283 |
+
cover = generate_cover(title, book_id)
|
| 284 |
+
|
| 285 |
+
# Build chapter index from headings or chapter-like patterns
|
| 286 |
+
chapters = []
|
| 287 |
+
chapter_pattern = re.compile(
|
| 288 |
+
r'^(chapter|chapitre|capitolo|capítulo|kapitel|hoofdstuk|kapittel|rozdział)\s+\w+',
|
| 289 |
+
re.IGNORECASE
|
| 290 |
+
)
|
| 291 |
+
for i, seg in enumerate(segments):
|
| 292 |
+
if seg.get("heading"):
|
| 293 |
+
chapters.append({"title": seg["content"][:80], "segment": i})
|
| 294 |
+
elif seg["type"] == "text" and chapter_pattern.match(seg["content"].strip()):
|
| 295 |
+
chapters.append({"title": seg["content"].strip()[:80], "segment": i})
|
| 296 |
+
|
| 297 |
+
# Flat page text for DB
|
| 298 |
+
current_text = [seg["content"] for seg in segments if seg["type"] == "text"]
|
| 299 |
+
pages = ["\n\n".join(current_text)] if current_text else ["[Empty]"]
|
| 300 |
+
|
| 301 |
+
return {"pages": pages, "segments": segments, "cover": cover, "chapters": chapters}
|
database.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PostgreSQL (Neon) persistence layer for books and reading progress."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import uuid
|
| 6 |
+
import psycopg2
|
| 7 |
+
import psycopg2.extras
|
| 8 |
+
import settings
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def get_db():
|
| 12 |
+
conn = psycopg2.connect(settings.DATABASE_URL)
|
| 13 |
+
return conn
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _row_to_dict(cursor) -> dict | None:
|
| 17 |
+
"""Fetch one row as a dict."""
|
| 18 |
+
row = cursor.fetchone()
|
| 19 |
+
if not row:
|
| 20 |
+
return None
|
| 21 |
+
cols = [desc[0] for desc in cursor.description]
|
| 22 |
+
return dict(zip(cols, row))
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _rows_to_dicts(cursor) -> list[dict]:
|
| 26 |
+
"""Fetch all rows as dicts."""
|
| 27 |
+
rows = cursor.fetchall()
|
| 28 |
+
cols = [desc[0] for desc in cursor.description]
|
| 29 |
+
return [dict(zip(cols, row)) for row in rows]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def init_db():
|
| 33 |
+
"""Ensure upload dirs exist. Tables should be created via SQL migration."""
|
| 34 |
+
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def save_book(book_id: str, title: str, filepath: str, cover: str,
|
| 38 |
+
language: str, pages: list[str], folder_id: str | None = None,
|
| 39 |
+
segments: list[dict] | None = None, chapters: list[dict] | None = None):
|
| 40 |
+
conn = get_db()
|
| 41 |
+
cur = conn.cursor()
|
| 42 |
+
cur.execute(
|
| 43 |
+
"INSERT INTO books (id, title, filepath, cover, language, pages, total_pages, folder_id, segments, chapters) "
|
| 44 |
+
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) "
|
| 45 |
+
"ON CONFLICT (id) DO UPDATE SET title=EXCLUDED.title, filepath=EXCLUDED.filepath, "
|
| 46 |
+
"cover=EXCLUDED.cover, language=EXCLUDED.language, pages=EXCLUDED.pages, "
|
| 47 |
+
"total_pages=EXCLUDED.total_pages, folder_id=EXCLUDED.folder_id, "
|
| 48 |
+
"segments=EXCLUDED.segments, chapters=EXCLUDED.chapters",
|
| 49 |
+
(book_id, title, filepath, cover, language, json.dumps(pages), len(pages), folder_id,
|
| 50 |
+
json.dumps(segments or []), json.dumps(chapters or []))
|
| 51 |
+
)
|
| 52 |
+
cur.execute(
|
| 53 |
+
"INSERT INTO reading_progress (book_id) VALUES (%s) ON CONFLICT (book_id) DO NOTHING",
|
| 54 |
+
(book_id,)
|
| 55 |
+
)
|
| 56 |
+
conn.commit()
|
| 57 |
+
cur.close()
|
| 58 |
+
conn.close()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def get_all_books(folder_id: str | None = None) -> list[dict]:
|
| 62 |
+
conn = get_db()
|
| 63 |
+
cur = conn.cursor()
|
| 64 |
+
if folder_id:
|
| 65 |
+
cur.execute(
|
| 66 |
+
"SELECT b.id, b.title, b.filepath, b.cover, b.language, b.total_pages, b.folder_id, "
|
| 67 |
+
"COALESCE(rp.current_page, 0) as current_page, "
|
| 68 |
+
"COALESCE(rp.percent_read, 0.0) as percent_read "
|
| 69 |
+
"FROM books b LEFT JOIN reading_progress rp ON b.id = rp.book_id "
|
| 70 |
+
"WHERE b.folder_id = %s ORDER BY rp.last_read_at DESC", (folder_id,)
|
| 71 |
+
)
|
| 72 |
+
else:
|
| 73 |
+
cur.execute(
|
| 74 |
+
"SELECT b.id, b.title, b.filepath, b.cover, b.language, b.total_pages, b.folder_id, "
|
| 75 |
+
"COALESCE(rp.current_page, 0) as current_page, "
|
| 76 |
+
"COALESCE(rp.percent_read, 0.0) as percent_read "
|
| 77 |
+
"FROM books b LEFT JOIN reading_progress rp ON b.id = rp.book_id "
|
| 78 |
+
"ORDER BY rp.last_read_at DESC"
|
| 79 |
+
)
|
| 80 |
+
result = _rows_to_dicts(cur)
|
| 81 |
+
cur.close()
|
| 82 |
+
conn.close()
|
| 83 |
+
return result
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def get_book(book_id: str) -> dict | None:
|
| 87 |
+
conn = get_db()
|
| 88 |
+
cur = conn.cursor()
|
| 89 |
+
cur.execute(
|
| 90 |
+
"SELECT b.*, COALESCE(rp.current_page, 0) as current_page, "
|
| 91 |
+
"COALESCE(rp.percent_read, 0.0) as percent_read "
|
| 92 |
+
"FROM books b LEFT JOIN reading_progress rp ON b.id = rp.book_id "
|
| 93 |
+
"WHERE b.id = %s", (book_id,)
|
| 94 |
+
)
|
| 95 |
+
book = _row_to_dict(cur)
|
| 96 |
+
cur.close()
|
| 97 |
+
conn.close()
|
| 98 |
+
if not book:
|
| 99 |
+
return None
|
| 100 |
+
book["pages"] = json.loads(book["pages"])
|
| 101 |
+
return book
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def get_book_page(book_id: str, page_num: int) -> dict | None:
|
| 105 |
+
conn = get_db()
|
| 106 |
+
cur = conn.cursor()
|
| 107 |
+
cur.execute("SELECT pages, total_pages FROM books WHERE id = %s", (book_id,))
|
| 108 |
+
row = _row_to_dict(cur)
|
| 109 |
+
cur.close()
|
| 110 |
+
conn.close()
|
| 111 |
+
if not row:
|
| 112 |
+
return None
|
| 113 |
+
pages = json.loads(row["pages"])
|
| 114 |
+
if page_num < 0 or page_num >= len(pages):
|
| 115 |
+
return None
|
| 116 |
+
return {"text": pages[page_num], "total_pages": row["total_pages"]}
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def update_progress(book_id: str, current_page: int):
|
| 120 |
+
conn = get_db()
|
| 121 |
+
cur = conn.cursor()
|
| 122 |
+
cur.execute("SELECT total_pages FROM books WHERE id = %s", (book_id,))
|
| 123 |
+
row = _row_to_dict(cur)
|
| 124 |
+
if not row:
|
| 125 |
+
cur.close()
|
| 126 |
+
conn.close()
|
| 127 |
+
return
|
| 128 |
+
total = row["total_pages"]
|
| 129 |
+
percent = round(((current_page + 1) / total) * 100, 1) if total > 0 else 0.0
|
| 130 |
+
cur.execute(
|
| 131 |
+
"UPDATE reading_progress SET current_page = %s, percent_read = %s, "
|
| 132 |
+
"last_read_at = NOW() WHERE book_id = %s",
|
| 133 |
+
(current_page, percent, book_id)
|
| 134 |
+
)
|
| 135 |
+
conn.commit()
|
| 136 |
+
cur.close()
|
| 137 |
+
conn.close()
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def update_progress_direct(book_id: str, current_page: int, percent: float):
|
| 141 |
+
conn = get_db()
|
| 142 |
+
cur = conn.cursor()
|
| 143 |
+
cur.execute(
|
| 144 |
+
"UPDATE reading_progress SET current_page = %s, percent_read = %s, "
|
| 145 |
+
"last_read_at = NOW() WHERE book_id = %s",
|
| 146 |
+
(current_page, round(percent, 1), book_id)
|
| 147 |
+
)
|
| 148 |
+
conn.commit()
|
| 149 |
+
cur.close()
|
| 150 |
+
conn.close()
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def rename_book(book_id: str, new_title: str):
|
| 154 |
+
conn = get_db()
|
| 155 |
+
cur = conn.cursor()
|
| 156 |
+
cur.execute("UPDATE books SET title = %s WHERE id = %s", (new_title, book_id))
|
| 157 |
+
conn.commit()
|
| 158 |
+
cur.close()
|
| 159 |
+
conn.close()
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def update_book_cover(book_id: str, cover_path: str):
|
| 163 |
+
conn = get_db()
|
| 164 |
+
cur = conn.cursor()
|
| 165 |
+
cur.execute("UPDATE books SET cover = %s WHERE id = %s", (cover_path, book_id))
|
| 166 |
+
conn.commit()
|
| 167 |
+
cur.close()
|
| 168 |
+
conn.close()
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def delete_book(book_id: str) -> dict | None:
|
| 172 |
+
conn = get_db()
|
| 173 |
+
cur = conn.cursor()
|
| 174 |
+
cur.execute("SELECT filepath, cover FROM books WHERE id = %s", (book_id,))
|
| 175 |
+
row = _row_to_dict(cur)
|
| 176 |
+
if not row:
|
| 177 |
+
cur.close()
|
| 178 |
+
conn.close()
|
| 179 |
+
return None
|
| 180 |
+
cur.execute("DELETE FROM reading_progress WHERE book_id = %s", (book_id,))
|
| 181 |
+
cur.execute("DELETE FROM bookmarks WHERE book_id = %s", (book_id,))
|
| 182 |
+
cur.execute("DELETE FROM books WHERE id = %s", (book_id,))
|
| 183 |
+
conn.commit()
|
| 184 |
+
cur.close()
|
| 185 |
+
conn.close()
|
| 186 |
+
return row
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def get_progress(book_id: str) -> dict | None:
|
| 190 |
+
conn = get_db()
|
| 191 |
+
cur = conn.cursor()
|
| 192 |
+
cur.execute(
|
| 193 |
+
"SELECT current_page, percent_read, last_read_at FROM reading_progress WHERE book_id = %s",
|
| 194 |
+
(book_id,)
|
| 195 |
+
)
|
| 196 |
+
row = _row_to_dict(cur)
|
| 197 |
+
cur.close()
|
| 198 |
+
conn.close()
|
| 199 |
+
return row
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def get_book_segments(book_id: str) -> dict | None:
|
| 203 |
+
conn = get_db()
|
| 204 |
+
cur = conn.cursor()
|
| 205 |
+
cur.execute("SELECT segments, chapters, language, title FROM books WHERE id = %s", (book_id,))
|
| 206 |
+
row = _row_to_dict(cur)
|
| 207 |
+
cur.close()
|
| 208 |
+
conn.close()
|
| 209 |
+
if not row:
|
| 210 |
+
return None
|
| 211 |
+
return {
|
| 212 |
+
"segments": json.loads(row["segments"] or "[]"),
|
| 213 |
+
"chapters": json.loads(row["chapters"] or "[]"),
|
| 214 |
+
"language": row["language"],
|
| 215 |
+
"title": row["title"],
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
# ==================== FOLDERS ====================
|
| 220 |
+
|
| 221 |
+
def create_folder(name: str) -> str:
|
| 222 |
+
folder_id = uuid.uuid4().hex[:10]
|
| 223 |
+
conn = get_db()
|
| 224 |
+
cur = conn.cursor()
|
| 225 |
+
cur.execute("INSERT INTO folders (id, name) VALUES (%s, %s)", (folder_id, name))
|
| 226 |
+
conn.commit()
|
| 227 |
+
cur.close()
|
| 228 |
+
conn.close()
|
| 229 |
+
return folder_id
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def get_all_folders() -> list[dict]:
|
| 233 |
+
conn = get_db()
|
| 234 |
+
cur = conn.cursor()
|
| 235 |
+
cur.execute(
|
| 236 |
+
"SELECT f.id, f.name, COUNT(b.id) as book_count "
|
| 237 |
+
"FROM folders f LEFT JOIN books b ON f.id = b.folder_id "
|
| 238 |
+
"GROUP BY f.id, f.name ORDER BY f.name"
|
| 239 |
+
)
|
| 240 |
+
result = _rows_to_dicts(cur)
|
| 241 |
+
cur.close()
|
| 242 |
+
conn.close()
|
| 243 |
+
return result
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def rename_folder(folder_id: str, new_name: str):
|
| 247 |
+
conn = get_db()
|
| 248 |
+
cur = conn.cursor()
|
| 249 |
+
cur.execute("UPDATE folders SET name = %s WHERE id = %s", (new_name, folder_id))
|
| 250 |
+
conn.commit()
|
| 251 |
+
cur.close()
|
| 252 |
+
conn.close()
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def delete_folder(folder_id: str):
|
| 256 |
+
conn = get_db()
|
| 257 |
+
cur = conn.cursor()
|
| 258 |
+
cur.execute("UPDATE books SET folder_id = NULL WHERE folder_id = %s", (folder_id,))
|
| 259 |
+
cur.execute("DELETE FROM folders WHERE id = %s", (folder_id,))
|
| 260 |
+
conn.commit()
|
| 261 |
+
cur.close()
|
| 262 |
+
conn.close()
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def move_book_to_folder(book_id: str, folder_id: str | None):
|
| 266 |
+
conn = get_db()
|
| 267 |
+
cur = conn.cursor()
|
| 268 |
+
cur.execute("UPDATE books SET folder_id = %s WHERE id = %s", (folder_id, book_id))
|
| 269 |
+
conn.commit()
|
| 270 |
+
cur.close()
|
| 271 |
+
conn.close()
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
# ==================== BOOKMARKS ====================
|
| 275 |
+
|
| 276 |
+
def get_bookmarks(book_id: str) -> list[dict]:
|
| 277 |
+
conn = get_db()
|
| 278 |
+
cur = conn.cursor()
|
| 279 |
+
cur.execute(
|
| 280 |
+
"SELECT id, name, segment_index, created_at FROM bookmarks "
|
| 281 |
+
"WHERE book_id = %s ORDER BY segment_index",
|
| 282 |
+
(book_id,)
|
| 283 |
+
)
|
| 284 |
+
result = _rows_to_dicts(cur)
|
| 285 |
+
cur.close()
|
| 286 |
+
conn.close()
|
| 287 |
+
return result
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def add_bookmark(book_id: str, name: str, segment_index: int) -> int:
|
| 291 |
+
conn = get_db()
|
| 292 |
+
cur = conn.cursor()
|
| 293 |
+
cur.execute(
|
| 294 |
+
"INSERT INTO bookmarks (book_id, name, segment_index) VALUES (%s, %s, %s) RETURNING id",
|
| 295 |
+
(book_id, name, segment_index)
|
| 296 |
+
)
|
| 297 |
+
bm_id = cur.fetchone()[0]
|
| 298 |
+
conn.commit()
|
| 299 |
+
cur.close()
|
| 300 |
+
conn.close()
|
| 301 |
+
return bm_id
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def rename_bookmark(bookmark_id: int, new_name: str):
|
| 305 |
+
conn = get_db()
|
| 306 |
+
cur = conn.cursor()
|
| 307 |
+
cur.execute("UPDATE bookmarks SET name = %s WHERE id = %s", (new_name, bookmark_id))
|
| 308 |
+
conn.commit()
|
| 309 |
+
cur.close()
|
| 310 |
+
conn.close()
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def delete_bookmark(bookmark_id: int):
|
| 314 |
+
conn = get_db()
|
| 315 |
+
cur = conn.cursor()
|
| 316 |
+
cur.execute("DELETE FROM bookmarks WHERE id = %s", (bookmark_id,))
|
| 317 |
+
conn.commit()
|
| 318 |
+
cur.close()
|
| 319 |
+
conn.close()
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
# ==================== LAST READ ====================
|
| 323 |
+
|
| 324 |
+
def get_last_read_book() -> dict | None:
|
| 325 |
+
conn = get_db()
|
| 326 |
+
cur = conn.cursor()
|
| 327 |
+
cur.execute(
|
| 328 |
+
"SELECT b.id, rp.percent_read FROM books b "
|
| 329 |
+
"JOIN reading_progress rp ON b.id = rp.book_id "
|
| 330 |
+
"WHERE rp.percent_read > 0 "
|
| 331 |
+
"ORDER BY rp.last_read_at DESC LIMIT 1"
|
| 332 |
+
)
|
| 333 |
+
row = _row_to_dict(cur)
|
| 334 |
+
cur.close()
|
| 335 |
+
conn.close()
|
| 336 |
+
return row
|
generate_icons.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate PWA icons with a book graphic."""
|
| 2 |
+
from PIL import Image, ImageDraw
|
| 3 |
+
|
| 4 |
+
def make_icon(size, path):
|
| 5 |
+
img = Image.new("RGB", (size, size), color=(26, 26, 46))
|
| 6 |
+
draw = ImageDraw.Draw(img)
|
| 7 |
+
|
| 8 |
+
# Draw a simple open book shape
|
| 9 |
+
m = size // 8 # margin
|
| 10 |
+
cx = size // 2
|
| 11 |
+
cy = size // 2
|
| 12 |
+
|
| 13 |
+
# Left page
|
| 14 |
+
draw.polygon([
|
| 15 |
+
(cx, m + size // 6),
|
| 16 |
+
(m, m),
|
| 17 |
+
(m, size - m),
|
| 18 |
+
(cx, size - m - size // 6),
|
| 19 |
+
], fill=(233, 69, 96))
|
| 20 |
+
|
| 21 |
+
# Right page
|
| 22 |
+
draw.polygon([
|
| 23 |
+
(cx, m + size // 6),
|
| 24 |
+
(size - m, m),
|
| 25 |
+
(size - m, size - m),
|
| 26 |
+
(cx, size - m - size // 6),
|
| 27 |
+
], fill=(200, 50, 80))
|
| 28 |
+
|
| 29 |
+
# Spine line
|
| 30 |
+
draw.line([(cx, m + size // 6), (cx, size - m - size // 6)], fill=(26, 26, 46), width=max(2, size // 64))
|
| 31 |
+
|
| 32 |
+
# Page lines on left
|
| 33 |
+
for i in range(4):
|
| 34 |
+
y = m + size // 4 + i * (size // 8)
|
| 35 |
+
draw.line([(m + size // 6, y), (cx - size // 10, y)], fill=(26, 26, 46), width=max(1, size // 128))
|
| 36 |
+
|
| 37 |
+
img.save(path)
|
| 38 |
+
|
| 39 |
+
make_icon(192, "static/icon-192.png")
|
| 40 |
+
make_icon(512, "static/icon-512.png")
|
| 41 |
+
print("Icons generated.")
|
llm_service.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LLM service: translation via HuggingFace (Llama / EuroLLM / Aya) and grammar analysis via Stanza API."""
|
| 2 |
+
|
| 3 |
+
import requests
|
| 4 |
+
from base import SUPPORTED_LANGUAGES
|
| 5 |
+
import settings
|
| 6 |
+
|
| 7 |
+
LLAMA_MODEL = "meta-llama/Llama-3.1-8B-Instruct"
|
| 8 |
+
EUROLLM_MODEL = "utter-project/EuroLLM-22B-Instruct-2512"
|
| 9 |
+
AYA_MODEL = "CohereLabs/aya-expanse-32b"
|
| 10 |
+
HF_ROUTER_URL = "https://router.huggingface.co/v1/chat/completions"
|
| 11 |
+
|
| 12 |
+
# Languages where EuroLLM is preferred (EU official small languages)
|
| 13 |
+
EUROLLM_CODES = {
|
| 14 |
+
lang['code'] for lang in SUPPORTED_LANGUAGES
|
| 15 |
+
if lang.get('small') and not lang.get('regional')
|
| 16 |
+
}
|
| 17 |
+
# Regional / minority languages -> Aya
|
| 18 |
+
AYA_CODES = {
|
| 19 |
+
lang['code'] for lang in SUPPORTED_LANGUAGES
|
| 20 |
+
if lang.get('regional')
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _get_lang_info(code: str) -> dict | None:
|
| 25 |
+
for lang in SUPPORTED_LANGUAGES:
|
| 26 |
+
if lang['code'] == code:
|
| 27 |
+
return lang
|
| 28 |
+
return None
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _pick_model(source_lang_code: str) -> str:
|
| 32 |
+
"""Route to the best model based on source language."""
|
| 33 |
+
if source_lang_code in AYA_CODES:
|
| 34 |
+
return AYA_MODEL
|
| 35 |
+
if source_lang_code in EUROLLM_CODES:
|
| 36 |
+
return EUROLLM_MODEL
|
| 37 |
+
return LLAMA_MODEL
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _call_hf_chat(model: str, messages: list[dict], max_tokens: int = 1500) -> str:
|
| 41 |
+
"""Call HuggingFace Router chat completions API."""
|
| 42 |
+
headers = {
|
| 43 |
+
"Authorization": f"Bearer {settings.HF_TOKEN}",
|
| 44 |
+
"Content-Type": "application/json",
|
| 45 |
+
}
|
| 46 |
+
payload = {
|
| 47 |
+
"model": model,
|
| 48 |
+
"messages": messages,
|
| 49 |
+
"max_tokens": max_tokens,
|
| 50 |
+
}
|
| 51 |
+
resp = requests.post(HF_ROUTER_URL, json=payload, headers=headers, timeout=120)
|
| 52 |
+
resp.raise_for_status()
|
| 53 |
+
data = resp.json()
|
| 54 |
+
return data["choices"][0]["message"]["content"]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def translate_pages(text: str, source_lang_code: str) -> str:
|
| 58 |
+
"""Translate text from source language to English."""
|
| 59 |
+
lang_info = _get_lang_info(source_lang_code)
|
| 60 |
+
lang_name = lang_info['name'] if lang_info else source_lang_code
|
| 61 |
+
model = _pick_model(source_lang_code)
|
| 62 |
+
messages = [
|
| 63 |
+
{"role": "system", "content": "You are a professional translator. Translate the following text to English accurately, preserving formatting and paragraph breaks. Output ONLY the translation."},
|
| 64 |
+
{"role": "user", "content": f"Translate from {lang_name} to English:\n\n{text}"},
|
| 65 |
+
]
|
| 66 |
+
return _call_hf_chat(model, messages)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def explain_selection(text: str, source_lang_code: str) -> str:
|
| 70 |
+
"""Explain a highlighted text selection with grammatical and syntactical notes in English."""
|
| 71 |
+
lang_info = _get_lang_info(source_lang_code)
|
| 72 |
+
lang_name = lang_info['name'] if lang_info else source_lang_code
|
| 73 |
+
model = _pick_model(source_lang_code)
|
| 74 |
+
messages = [
|
| 75 |
+
{"role": "system", "content": (
|
| 76 |
+
f"You are a {lang_name} language expert. The user highlights a passage in {lang_name}. "
|
| 77 |
+
"Provide:\n1. An English translation of the passage.\n"
|
| 78 |
+
"2. Grammatical notes (parts of speech, cases, tenses, moods).\n"
|
| 79 |
+
"3. Syntactical analysis (sentence structure, clauses, word order).\n"
|
| 80 |
+
"4. Any idiomatic or cultural notes.\n"
|
| 81 |
+
"Be concise but thorough."
|
| 82 |
+
)},
|
| 83 |
+
{"role": "user", "content": text},
|
| 84 |
+
]
|
| 85 |
+
return _call_hf_chat(model, messages)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def word_by_word_analysis(text: str, source_lang_code: str) -> dict:
|
| 89 |
+
"""Perform word-by-word morpho-syntactic analysis using Stanza API, plus full sentence translation."""
|
| 90 |
+
lang_info = _get_lang_info(source_lang_code)
|
| 91 |
+
lang_code = lang_info['code'] if lang_info else "en"
|
| 92 |
+
api_url = f"https://randusertry-stanzalazymodels.hf.space/{lang_code}/analyze"
|
| 93 |
+
|
| 94 |
+
# Translate the full selected text first
|
| 95 |
+
sentence_translation = ""
|
| 96 |
+
try:
|
| 97 |
+
sentence_translation = translate_pages(text, source_lang_code)
|
| 98 |
+
except Exception:
|
| 99 |
+
sentence_translation = "[Translation failed]"
|
| 100 |
+
|
| 101 |
+
try:
|
| 102 |
+
resp = requests.post(api_url, json={"text": text}, timeout=60)
|
| 103 |
+
if resp.status_code != 200:
|
| 104 |
+
return {"translation": sentence_translation, "words": [{"error": f"Stanza API returned {resp.status_code}"}]}
|
| 105 |
+
|
| 106 |
+
data = resp.json()
|
| 107 |
+
word_objects = []
|
| 108 |
+
|
| 109 |
+
# Flexible response handling (flat list vs nested sentences)
|
| 110 |
+
if isinstance(data, list):
|
| 111 |
+
if len(data) > 0 and isinstance(data[0], dict) and ('text' in data[0] or 'lemma' in data[0]):
|
| 112 |
+
words_to_process = data
|
| 113 |
+
else:
|
| 114 |
+
words_to_process = []
|
| 115 |
+
for sent in data:
|
| 116 |
+
if isinstance(sent, list):
|
| 117 |
+
words_to_process.extend(sent)
|
| 118 |
+
elif isinstance(sent, dict) and "words" in sent:
|
| 119 |
+
words_to_process.extend(sent["words"])
|
| 120 |
+
else:
|
| 121 |
+
words_to_process = []
|
| 122 |
+
for sent in data.get("sentences", []):
|
| 123 |
+
words_to_process.extend(sent if isinstance(sent, list) else sent.get("words", []))
|
| 124 |
+
|
| 125 |
+
for word in words_to_process:
|
| 126 |
+
if not isinstance(word, dict):
|
| 127 |
+
continue
|
| 128 |
+
upos = word.get("pos") or word.get("upos", "")
|
| 129 |
+
morph = word.get("morph") or word.get("feats", "")
|
| 130 |
+
if upos == "PUNCT":
|
| 131 |
+
continue
|
| 132 |
+
word_objects.append({
|
| 133 |
+
"form": word.get("text"),
|
| 134 |
+
"grammar_comments": f"{upos} {morph}".strip(),
|
| 135 |
+
"lemma": word.get("lemma"),
|
| 136 |
+
})
|
| 137 |
+
|
| 138 |
+
return {"translation": sentence_translation, "words": word_objects}
|
| 139 |
+
|
| 140 |
+
except Exception as e:
|
| 141 |
+
return {"translation": sentence_translation, "words": [{"error": str(e)}]}
|
settings.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
|
| 4 |
+
load_dotenv()
|
| 5 |
+
|
| 6 |
+
HF_TOKEN = os.getenv("HF_TOKEN", "")
|
| 7 |
+
DATABASE_URL = os.getenv("DATABASE_URL", "")
|
| 8 |
+
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "uploads")
|
| 9 |
+
COVERS_DIR = os.getenv("COVERS_DIR", "covers")
|
| 10 |
+
IMAGES_DIR = os.getenv("IMAGES_DIR", "book_images")
|
| 11 |
+
MAX_UPLOAD_SIZE_MB = 50
|
static/icon-192.png
ADDED
|
|
static/icon-512.png
ADDED
|
|
static/manifest.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Ebook Reader",
|
| 3 |
+
"short_name": "EReader",
|
| 4 |
+
"description": "Read books in PDF, EPUB, MOBI with translation and grammar analysis",
|
| 5 |
+
"start_url": "/",
|
| 6 |
+
"display": "standalone",
|
| 7 |
+
"background_color": "#1a1a2e",
|
| 8 |
+
"theme_color": "#e94560",
|
| 9 |
+
"orientation": "any",
|
| 10 |
+
"icons": [
|
| 11 |
+
{
|
| 12 |
+
"src": "/static/icon-192.png",
|
| 13 |
+
"sizes": "192x192",
|
| 14 |
+
"type": "image/png"
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"src": "/static/icon-512.png",
|
| 18 |
+
"sizes": "512x512",
|
| 19 |
+
"type": "image/png"
|
| 20 |
+
}
|
| 21 |
+
]
|
| 22 |
+
}
|
static/sw.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const CACHE_NAME = 'ereader-v1';
|
| 2 |
+
const PRECACHE = ['/', '/static/manifest.json'];
|
| 3 |
+
|
| 4 |
+
self.addEventListener('install', (e) => {
|
| 5 |
+
e.waitUntil(
|
| 6 |
+
caches.open(CACHE_NAME).then(c => c.addAll(PRECACHE)).then(() => self.skipWaiting())
|
| 7 |
+
);
|
| 8 |
+
});
|
| 9 |
+
|
| 10 |
+
self.addEventListener('activate', (e) => {
|
| 11 |
+
e.waitUntil(
|
| 12 |
+
caches.keys().then(keys =>
|
| 13 |
+
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
|
| 14 |
+
).then(() => self.clients.claim())
|
| 15 |
+
);
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
self.addEventListener('fetch', (e) => {
|
| 19 |
+
const req = e.request;
|
| 20 |
+
// Network-first for API calls, cache-first for static assets
|
| 21 |
+
if (req.url.includes('/translate') || req.url.includes('/explain') ||
|
| 22 |
+
req.url.includes('/analyze') || req.url.includes('/upload') ||
|
| 23 |
+
req.method !== 'GET') {
|
| 24 |
+
return;
|
| 25 |
+
}
|
| 26 |
+
e.respondWith(
|
| 27 |
+
fetch(req).then(resp => {
|
| 28 |
+
const clone = resp.clone();
|
| 29 |
+
caches.open(CACHE_NAME).then(c => c.put(req, clone));
|
| 30 |
+
return resp;
|
| 31 |
+
}).catch(() => caches.match(req))
|
| 32 |
+
);
|
| 33 |
+
});
|
templates/index.html
ADDED
|
@@ -0,0 +1,1080 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
| 6 |
+
<title>Ebook Reader</title>
|
| 7 |
+
<link rel="manifest" href="/static/manifest.json">
|
| 8 |
+
<meta name="theme-color" content="#e94560">
|
| 9 |
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
| 10 |
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
| 11 |
+
<link rel="apple-touch-icon" href="/static/icon-192.png">
|
| 12 |
+
<style>
|
| 13 |
+
:root {
|
| 14 |
+
--bg: #1a1a2e; --surface: #16213e; --surface2: #0f3460;
|
| 15 |
+
--accent: #e94560; --text: #eee; --text-dim: #aaa;
|
| 16 |
+
--page-bg: #1e1e2e; --page-text: #d4d4d4; --page-shadow: rgba(0,0,0,0.5);
|
| 17 |
+
}
|
| 18 |
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 19 |
+
body { font-family: 'Georgia','Times New Roman',serif; background: var(--bg); color: var(--text); min-height: 100vh; overflow: hidden; }
|
| 20 |
+
|
| 21 |
+
/* ==================== LIBRARY VIEW ==================== */
|
| 22 |
+
#library-view { padding: 2rem; max-width: 1200px; margin: 0 auto; overflow-y: auto; height: 100vh; }
|
| 23 |
+
#library-view h1 { font-size: 2rem; margin-bottom: 1.5rem; color: var(--accent); }
|
| 24 |
+
.upload-area { background: var(--surface); border: 2px dashed var(--surface2); border-radius: 12px; padding: 2rem; margin-bottom: 2rem; display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-end; }
|
| 25 |
+
.upload-area label { display: block; font-size: 0.85rem; color: var(--text-dim); margin-bottom: 0.3rem; }
|
| 26 |
+
.upload-area input[type="text"], .upload-area select, .upload-area input[type="file"] { background: var(--bg); border: 1px solid var(--surface2); color: var(--text); padding: 0.5rem 0.8rem; border-radius: 6px; font-size: 0.95rem; }
|
| 27 |
+
.btn { background: var(--accent); color: #fff; border: none; padding: 0.6rem 1.4rem; border-radius: 6px; cursor: pointer; font-size: 0.95rem; transition: opacity 0.2s; }
|
| 28 |
+
.btn:hover { opacity: 0.85; }
|
| 29 |
+
.btn-sm { padding: 0.3rem 0.8rem; font-size: 0.8rem; }
|
| 30 |
+
.btn-secondary { background: var(--surface2); color: var(--text); }
|
| 31 |
+
.book-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1.5rem; }
|
| 32 |
+
.book-card { background: var(--surface); border-radius: 10px; overflow: hidden; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; position: relative; }
|
| 33 |
+
.book-card:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0,0,0,0.4); }
|
| 34 |
+
.book-card img { width: 100%; height: 260px; object-fit: cover; }
|
| 35 |
+
.book-card .book-info { padding: 0.8rem; }
|
| 36 |
+
.book-card .book-title { font-size: 0.95rem; font-weight: bold; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
| 37 |
+
.book-card .book-pages { font-size: 0.75rem; color: var(--text-dim); }
|
| 38 |
+
.book-card .book-actions { display: flex; gap: 0.4rem; margin-top: 0.5rem; flex-wrap: wrap; }
|
| 39 |
+
.book-card .resume-overlay {
|
| 40 |
+
position: absolute; top: 0; left: 0; width: 100%; height: 260px;
|
| 41 |
+
display: flex; align-items: center; justify-content: center;
|
| 42 |
+
background: rgba(0,0,0,0.5); opacity: 0; transition: opacity 0.2s; pointer-events: none;
|
| 43 |
+
}
|
| 44 |
+
.book-card:hover .resume-overlay.has-progress { opacity: 1; pointer-events: auto; }
|
| 45 |
+
.resume-overlay button {
|
| 46 |
+
background: var(--accent); border: none; color: #fff; width: 56px; height: 56px;
|
| 47 |
+
border-radius: 50%; font-size: 1.6rem; cursor: pointer; display: flex;
|
| 48 |
+
align-items: center; justify-content: center; box-shadow: 0 4px 16px rgba(0,0,0,0.5);
|
| 49 |
+
transition: transform 0.15s;
|
| 50 |
+
}
|
| 51 |
+
.resume-overlay button:hover { transform: scale(1.1); }
|
| 52 |
+
.progress-bar-bg { height: 4px; background: var(--bg); border-radius: 2px; margin-top: 0.4rem; overflow: hidden; }
|
| 53 |
+
.progress-bar-fill { height: 100%; background: var(--accent); border-radius: 2px; }
|
| 54 |
+
.library-layout { display: flex; gap: 1.5rem; }
|
| 55 |
+
.folder-sidebar { width: 200px; flex-shrink: 0; }
|
| 56 |
+
.folder-sidebar h3 { font-size: 0.9rem; color: var(--text-dim); margin-bottom: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
| 57 |
+
.folder-list { list-style: none; }
|
| 58 |
+
.folder-list li { padding: 0.5rem 0.7rem; border-radius: 6px; cursor: pointer; font-size: 0.9rem; display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.2rem; }
|
| 59 |
+
.folder-list li:hover, .folder-list li.active { background: var(--surface); }
|
| 60 |
+
.folder-list li .folder-actions { display: none; gap: 0.2rem; }
|
| 61 |
+
.folder-list li:hover .folder-actions { display: flex; }
|
| 62 |
+
.folder-list li .folder-actions button { background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 0.75rem; }
|
| 63 |
+
.folder-list li .folder-actions button:hover { color: var(--accent); }
|
| 64 |
+
.library-main { flex: 1; min-width: 0; }
|
| 65 |
+
</style>
|
| 66 |
+
</head>
|
| 67 |
+
<body>
|
| 68 |
+
<div id="library-view">
|
| 69 |
+
<h1>📚 Ebook Reader</h1>
|
| 70 |
+
<div class="upload-area">
|
| 71 |
+
<div><label for="upload-file">Book file (.pdf, .epub, .mobi)</label><input type="file" id="upload-file" accept=".pdf,.epub,.mobi"></div>
|
| 72 |
+
<div><label for="upload-title">Title (optional)</label><input type="text" id="upload-title" placeholder="Auto-detect from filename"></div>
|
| 73 |
+
<div><label for="upload-lang">Book language</label><select id="upload-lang"><option value="auto">🔍 Auto-detect</option>{% for lang in languages %}<option value="{{ lang.code }}">{{ lang.flag }} {{ lang.name }}</option>{% endfor %}</select></div>
|
| 74 |
+
<div><label for="upload-folder">Folder (optional)</label><select id="upload-folder"><option value="">— None —</option>{% for f in folders %}<option value="{{ f.id }}">📁 {{ f.name }}</option>{% endfor %}</select></div>
|
| 75 |
+
<button class="btn" onclick="uploadBook()">Upload</button>
|
| 76 |
+
</div>
|
| 77 |
+
<div class="library-layout">
|
| 78 |
+
<div class="folder-sidebar">
|
| 79 |
+
<h3>Folders</h3>
|
| 80 |
+
<ul class="folder-list">
|
| 81 |
+
<li class="{{ 'active' if not current_folder }}" onclick="filterFolder(null)"><span>📚 All Books</span></li>
|
| 82 |
+
{% for f in folders %}
|
| 83 |
+
<li class="{{ 'active' if current_folder == f.id }}" onclick="filterFolder('{{ f.id }}')">
|
| 84 |
+
<span>📁 {{ f.name }} ({{ f.book_count }})</span>
|
| 85 |
+
<span class="folder-actions">
|
| 86 |
+
<button onclick="event.stopPropagation(); renameFolderPrompt('{{ f.id }}', '{{ f.name }}')">✏️</button>
|
| 87 |
+
<button onclick="event.stopPropagation(); deleteFolderConfirm('{{ f.id }}')">🗑️</button>
|
| 88 |
+
</span>
|
| 89 |
+
</li>
|
| 90 |
+
{% endfor %}
|
| 91 |
+
</ul>
|
| 92 |
+
<button class="btn btn-sm btn-secondary" style="margin-top:0.8rem;width:100%;" onclick="createFolderPrompt()">+ New Folder</button>
|
| 93 |
+
</div>
|
| 94 |
+
<div class="library-main"><div class="book-grid" id="book-grid">
|
| 95 |
+
{% for b in books %}
|
| 96 |
+
<div class="book-card" ondblclick="openBook('{{ b.id }}')">
|
| 97 |
+
<img src="/book/{{ b.id }}/cover" alt="Cover">
|
| 98 |
+
<div class="resume-overlay {{ 'has-progress' if b.percent_read > 0 }}">
|
| 99 |
+
<button onclick="event.stopPropagation(); openBook('{{ b.id }}')" title="Resume reading ({{ b.percent_read }}%)">▶</button>
|
| 100 |
+
</div>
|
| 101 |
+
<div class="book-info">
|
| 102 |
+
<div class="book-title" title="{{ b.title }}">{{ b.title }}</div>
|
| 103 |
+
<div class="book-pages">{{ b.total_pages }} pages · {{ b.percent_read }}% read</div>
|
| 104 |
+
<div class="progress-bar-bg"><div class="progress-bar-fill" style="width:{{ b.percent_read }}%"></div></div>
|
| 105 |
+
<div class="book-actions">
|
| 106 |
+
<button class="btn btn-sm btn-secondary" onclick="event.stopPropagation(); renameBook('{{ b.id }}')">Rename</button>
|
| 107 |
+
<button class="btn btn-sm btn-secondary" onclick="event.stopPropagation(); moveBookPrompt('{{ b.id }}')">Move</button>
|
| 108 |
+
<button class="btn btn-sm btn-secondary" onclick="event.stopPropagation(); deleteBook('{{ b.id }}')">Delete</button>
|
| 109 |
+
<button class="btn btn-sm" onclick="event.stopPropagation(); openBook('{{ b.id }}')">Read</button>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
{% endfor %}
|
| 114 |
+
</div></div>
|
| 115 |
+
</div>
|
| 116 |
+
</div>
|
| 117 |
+
|
| 118 |
+
<!-- ==================== READER VIEW ==================== -->
|
| 119 |
+
<div id="reader-view" style="display:none;">
|
| 120 |
+
<style>
|
| 121 |
+
#reader-view {
|
| 122 |
+
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
| 123 |
+
display: flex; flex-direction: column; z-index: 10; background: var(--bg);
|
| 124 |
+
}
|
| 125 |
+
/* Minimal top bar — only visible on desktop or when overlay is open */
|
| 126 |
+
.reader-topbar {
|
| 127 |
+
background: var(--surface); padding: 0.4rem 0.8rem;
|
| 128 |
+
display: flex; align-items: center; gap: 0.6rem;
|
| 129 |
+
border-bottom: 1px solid var(--surface2); flex-shrink: 0;
|
| 130 |
+
transition: transform 0.3s;
|
| 131 |
+
}
|
| 132 |
+
.reader-topbar .book-title-display {
|
| 133 |
+
font-weight: bold; font-size: 0.95rem; flex: 1;
|
| 134 |
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 60px;
|
| 135 |
+
}
|
| 136 |
+
.zoom-controls { display: flex; align-items: center; gap: 0.3rem; }
|
| 137 |
+
.zoom-controls button {
|
| 138 |
+
background: var(--surface2); color: var(--text); border: none;
|
| 139 |
+
width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-size: 1rem;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
/* Page area — takes all remaining space */
|
| 143 |
+
.reader-body { flex: 1; display: flex; overflow: hidden; position: relative; }
|
| 144 |
+
.page-container {
|
| 145 |
+
flex: 1; overflow-y: auto; display: flex; justify-content: center;
|
| 146 |
+
align-items: flex-start; padding: 1.5rem;
|
| 147 |
+
}
|
| 148 |
+
.page-paper {
|
| 149 |
+
background: var(--page-bg); color: var(--page-text);
|
| 150 |
+
/* Book-like dimensions: ~60-65 characters per line ≈ physical paperback */
|
| 151 |
+
width: 100%; max-width: 600px;
|
| 152 |
+
min-height: calc(100vh - 6rem);
|
| 153 |
+
padding: 2.5rem 2.2rem;
|
| 154 |
+
border-radius: 4px; box-shadow: 0 2px 20px var(--page-shadow);
|
| 155 |
+
font-size: 1.1rem; line-height: 1.75;
|
| 156 |
+
overflow: visible; position: relative;
|
| 157 |
+
}
|
| 158 |
+
.page-paper .seg-text {
|
| 159 |
+
word-wrap: break-word; overflow-wrap: break-word;
|
| 160 |
+
margin-bottom: 0.8em; text-align: justify;
|
| 161 |
+
text-indent: 1.5em;
|
| 162 |
+
}
|
| 163 |
+
.page-paper .seg-text:first-child { text-indent: 0; }
|
| 164 |
+
.page-paper .seg-img {
|
| 165 |
+
display: block; max-width: 100%; height: auto;
|
| 166 |
+
margin: 1em auto; border-radius: 4px;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
/* Bottom nav — desktop only by default */
|
| 170 |
+
.page-nav {
|
| 171 |
+
display: flex; justify-content: center; align-items: center;
|
| 172 |
+
gap: 1rem; padding: 0.5rem; background: var(--surface);
|
| 173 |
+
border-top: 1px solid var(--surface2); flex-shrink: 0;
|
| 174 |
+
transition: transform 0.3s;
|
| 175 |
+
}
|
| 176 |
+
.page-nav span { font-size: 0.9rem; color: var(--text-dim); }
|
| 177 |
+
|
| 178 |
+
/* ==================== OVERLAY MENU (single tap) ==================== */
|
| 179 |
+
.reader-overlay {
|
| 180 |
+
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
| 181 |
+
z-index: 100; display: none; flex-direction: column;
|
| 182 |
+
pointer-events: none;
|
| 183 |
+
}
|
| 184 |
+
.reader-overlay.open { display: flex; }
|
| 185 |
+
.reader-overlay .overlay-bg {
|
| 186 |
+
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
|
| 187 |
+
background: rgba(0,0,0,0.4); pointer-events: auto;
|
| 188 |
+
}
|
| 189 |
+
.overlay-top {
|
| 190 |
+
position: relative; z-index: 1; pointer-events: auto;
|
| 191 |
+
background: var(--surface); padding: 0.6rem 1rem;
|
| 192 |
+
display: flex; align-items: center; gap: 0.6rem;
|
| 193 |
+
border-bottom: 1px solid var(--surface2);
|
| 194 |
+
}
|
| 195 |
+
.overlay-top .book-title-overlay {
|
| 196 |
+
font-weight: bold; font-size: 0.95rem; flex: 1;
|
| 197 |
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
| 198 |
+
}
|
| 199 |
+
.overlay-bottom {
|
| 200 |
+
position: relative; z-index: 1; pointer-events: auto;
|
| 201 |
+
background: var(--surface); margin-top: auto;
|
| 202 |
+
border-top: 1px solid var(--surface2);
|
| 203 |
+
}
|
| 204 |
+
.overlay-nav {
|
| 205 |
+
display: flex; justify-content: center; align-items: center;
|
| 206 |
+
gap: 1rem; padding: 0.5rem 1rem;
|
| 207 |
+
}
|
| 208 |
+
.overlay-nav span { font-size: 0.9rem; color: var(--text-dim); }
|
| 209 |
+
.overlay-slider {
|
| 210 |
+
padding: 0.3rem 1rem 0.6rem; display: flex; align-items: center; gap: 0.6rem;
|
| 211 |
+
}
|
| 212 |
+
.overlay-slider input[type="range"] {
|
| 213 |
+
flex: 1; accent-color: var(--accent); cursor: pointer;
|
| 214 |
+
}
|
| 215 |
+
.overlay-slider span { font-size: 0.8rem; color: var(--text-dim); min-width: 40px; text-align: center; }
|
| 216 |
+
.overlay-actions {
|
| 217 |
+
display: flex; justify-content: center; gap: 0.5rem;
|
| 218 |
+
padding: 0.4rem 1rem 0.6rem; flex-wrap: wrap;
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
/* ==================== SIDE PANEL (translation etc) ==================== */
|
| 222 |
+
.side-panel {
|
| 223 |
+
overflow: hidden; background: var(--surface);
|
| 224 |
+
border-left: 1px solid var(--surface2); transition: width 0.3s, transform 0.3s;
|
| 225 |
+
display: flex; flex-direction: column;
|
| 226 |
+
/* Default: overlay mode for all but big screens */
|
| 227 |
+
position: fixed; right: 0; top: 0; bottom: 0; z-index: 150;
|
| 228 |
+
width: 400px; max-width: 100vw;
|
| 229 |
+
transform: translateX(100%);
|
| 230 |
+
}
|
| 231 |
+
.side-panel.open { transform: translateX(0); }
|
| 232 |
+
.side-panel-header {
|
| 233 |
+
padding: 0.8rem 1rem; display: flex; justify-content: space-between;
|
| 234 |
+
align-items: center; border-bottom: 1px solid var(--surface2);
|
| 235 |
+
}
|
| 236 |
+
.side-panel-header h3 { font-size: 1rem; }
|
| 237 |
+
.side-panel-content {
|
| 238 |
+
flex: 1; overflow-y: auto; padding: 1rem;
|
| 239 |
+
font-size: 0.9rem; line-height: 1.6; white-space: pre-wrap;
|
| 240 |
+
}
|
| 241 |
+
.side-panel-tabs { display: flex; border-bottom: 1px solid var(--surface2); }
|
| 242 |
+
.side-panel-tabs button {
|
| 243 |
+
flex: 1; background: none; border: none; color: var(--text-dim);
|
| 244 |
+
padding: 0.6rem; cursor: pointer; font-size: 0.8rem;
|
| 245 |
+
border-bottom: 2px solid transparent;
|
| 246 |
+
}
|
| 247 |
+
.side-panel-tabs button.active { color: var(--accent); border-bottom-color: var(--accent); }
|
| 248 |
+
.word-table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
|
| 249 |
+
.word-table th, .word-table td { padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--surface2); text-align: left; }
|
| 250 |
+
.word-table th { color: var(--accent); }
|
| 251 |
+
|
| 252 |
+
/* ==================== CHAPTER/BOOKMARK PANEL (inside overlay) ==================== */
|
| 253 |
+
.nav-panel {
|
| 254 |
+
position: fixed; top: 0; left: 0; bottom: 0; width: 320px;
|
| 255 |
+
background: var(--surface); z-index: 200; transform: translateX(-100%);
|
| 256 |
+
transition: transform 0.3s; display: flex; flex-direction: column;
|
| 257 |
+
box-shadow: 4px 0 20px rgba(0,0,0,0.5);
|
| 258 |
+
}
|
| 259 |
+
.nav-panel.open { transform: translateX(0); }
|
| 260 |
+
.nav-panel-header {
|
| 261 |
+
padding: 0.8rem 1rem; display: flex; justify-content: space-between;
|
| 262 |
+
align-items: center; border-bottom: 1px solid var(--surface2);
|
| 263 |
+
}
|
| 264 |
+
.nav-panel-header h3 { font-size: 1rem; }
|
| 265 |
+
.nav-panel-tabs { display: flex; border-bottom: 1px solid var(--surface2); }
|
| 266 |
+
.nav-panel-tabs button {
|
| 267 |
+
flex: 1; background: none; border: none; color: var(--text-dim);
|
| 268 |
+
padding: 0.6rem; cursor: pointer; font-size: 0.85rem;
|
| 269 |
+
border-bottom: 2px solid transparent;
|
| 270 |
+
}
|
| 271 |
+
.nav-panel-tabs button.active { color: var(--accent); border-bottom-color: var(--accent); }
|
| 272 |
+
.nav-panel-body { flex: 1; overflow-y: auto; }
|
| 273 |
+
.nav-item {
|
| 274 |
+
padding: 0.7rem 1rem; cursor: pointer; font-size: 0.85rem;
|
| 275 |
+
border-bottom: 1px solid var(--surface2); color: var(--text);
|
| 276 |
+
display: flex; justify-content: space-between; align-items: center;
|
| 277 |
+
}
|
| 278 |
+
.nav-item:hover { background: var(--surface2); }
|
| 279 |
+
.nav-item .nav-item-actions { display: flex; gap: 0.3rem; }
|
| 280 |
+
.nav-item .nav-item-actions button {
|
| 281 |
+
background: none; border: none; color: var(--text-dim);
|
| 282 |
+
cursor: pointer; font-size: 0.75rem; padding: 0.2rem;
|
| 283 |
+
}
|
| 284 |
+
.nav-item .nav-item-actions button:hover { color: var(--accent); }
|
| 285 |
+
.nav-empty { padding: 1rem; color: var(--text-dim); font-size: 0.85rem; text-align: center; }
|
| 286 |
+
.nav-panel-footer { padding: 0.6rem 1rem; border-top: 1px solid var(--surface2); }
|
| 287 |
+
|
| 288 |
+
/* Spinner */
|
| 289 |
+
.spinner { display: inline-block; width: 18px; height: 18px; border: 2px solid var(--text-dim); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.6s linear infinite; }
|
| 290 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 291 |
+
|
| 292 |
+
/* Selection tooltip */
|
| 293 |
+
.selection-tooltip {
|
| 294 |
+
position: fixed; background: var(--surface2); border-radius: 6px;
|
| 295 |
+
padding: 0.3rem; display: none; z-index: 300;
|
| 296 |
+
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
| 297 |
+
}
|
| 298 |
+
.selection-tooltip button {
|
| 299 |
+
background: none; border: none; color: var(--text);
|
| 300 |
+
padding: 0.4rem 0.7rem; cursor: pointer; font-size: 0.8rem; border-radius: 4px;
|
| 301 |
+
}
|
| 302 |
+
.selection-tooltip button:hover { background: var(--accent); }
|
| 303 |
+
|
| 304 |
+
/* ==================== MOBILE: immersive ==================== */
|
| 305 |
+
@media (max-width: 700px) {
|
| 306 |
+
.reader-topbar { display: none; }
|
| 307 |
+
.page-nav { display: none; }
|
| 308 |
+
.reader-body { overflow: hidden; }
|
| 309 |
+
.page-container {
|
| 310 |
+
padding: 0; overflow-y: auto; -webkit-overflow-scrolling: touch;
|
| 311 |
+
display: block;
|
| 312 |
+
}
|
| 313 |
+
.page-paper {
|
| 314 |
+
border-radius: 0; box-shadow: none; max-width: 100%;
|
| 315 |
+
padding: 1.2rem 1rem; min-height: 100vh;
|
| 316 |
+
font-size: 1.05rem; line-height: 1.7;
|
| 317 |
+
}
|
| 318 |
+
.side-panel {
|
| 319 |
+
width: 100%; max-width: 100%;
|
| 320 |
+
}
|
| 321 |
+
.nav-panel { width: 85vw; max-width: 320px; }
|
| 322 |
+
}
|
| 323 |
+
/* Desktop: show topbar and bottom nav always */
|
| 324 |
+
@media (min-width: 701px) {
|
| 325 |
+
.reader-topbar { display: flex; }
|
| 326 |
+
.page-nav { display: flex; }
|
| 327 |
+
}
|
| 328 |
+
/* Big screens: side panel sits inline next to text */
|
| 329 |
+
@media (min-width: 1200px) {
|
| 330 |
+
.side-panel {
|
| 331 |
+
position: relative; right: auto; top: auto; bottom: auto;
|
| 332 |
+
z-index: auto; transform: none;
|
| 333 |
+
width: 0; flex-shrink: 0; transition: width 0.3s;
|
| 334 |
+
}
|
| 335 |
+
.side-panel.open { width: 400px; transform: none; }
|
| 336 |
+
}
|
| 337 |
+
</style>
|
| 338 |
+
|
| 339 |
+
<!-- Top bar (desktop) -->
|
| 340 |
+
<div class="reader-topbar" id="reader-topbar">
|
| 341 |
+
<button class="btn btn-sm btn-secondary" onclick="closeReader()">← Library</button>
|
| 342 |
+
<span class="book-title-display" id="reader-title"></span>
|
| 343 |
+
<div class="zoom-controls">
|
| 344 |
+
<button onclick="changeZoom(-1)">−</button>
|
| 345 |
+
<span id="zoom-level">100%</span>
|
| 346 |
+
<button onclick="changeZoom(1)">+</button>
|
| 347 |
+
</div>
|
| 348 |
+
<button class="btn btn-sm btn-secondary" onclick="toggleNavPanel()">📑</button>
|
| 349 |
+
<button class="btn btn-sm" onclick="toggleTranslation()">🌐</button>
|
| 350 |
+
</div>
|
| 351 |
+
|
| 352 |
+
<div class="reader-body">
|
| 353 |
+
<div class="page-container" id="page-container">
|
| 354 |
+
<div class="page-paper" id="page-paper"></div>
|
| 355 |
+
</div>
|
| 356 |
+
<div class="side-panel" id="side-panel">
|
| 357 |
+
<div class="side-panel-header">
|
| 358 |
+
<h3 id="panel-title">Translation</h3>
|
| 359 |
+
<button class="btn btn-sm btn-secondary" onclick="closeSidePanel()">✕</button>
|
| 360 |
+
</div>
|
| 361 |
+
<div class="side-panel-tabs">
|
| 362 |
+
<button id="tab-translation" class="active" onclick="switchTab('translation')">Translation</button>
|
| 363 |
+
<button id="tab-explain" onclick="switchTab('explain')">Explain</button>
|
| 364 |
+
<button id="tab-words" onclick="switchTab('words')">Word-by-Word</button>
|
| 365 |
+
</div>
|
| 366 |
+
<div class="side-panel-content" id="panel-content">
|
| 367 |
+
<p style="color:var(--text-dim)">Click "Translate" or highlight text to begin.</p>
|
| 368 |
+
</div>
|
| 369 |
+
</div>
|
| 370 |
+
</div>
|
| 371 |
+
|
| 372 |
+
<!-- Bottom nav (desktop) -->
|
| 373 |
+
<div class="page-nav" id="page-nav">
|
| 374 |
+
<button class="btn btn-sm btn-secondary" onclick="prevPage()">◀ Prev</button>
|
| 375 |
+
<span id="page-indicator">1 / 1</span>
|
| 376 |
+
<button class="btn btn-sm btn-secondary" onclick="nextPage()">Next ▶</button>
|
| 377 |
+
</div>
|
| 378 |
+
|
| 379 |
+
<!-- ==================== OVERLAY (single tap on mobile) ==================== -->
|
| 380 |
+
<div class="reader-overlay" id="reader-overlay">
|
| 381 |
+
<div class="overlay-bg" onclick="closeOverlay()"></div>
|
| 382 |
+
<div class="overlay-top">
|
| 383 |
+
<button class="btn btn-sm btn-secondary" onclick="closeReader()">← Library</button>
|
| 384 |
+
<span class="book-title-overlay" id="overlay-title"></span>
|
| 385 |
+
<div class="zoom-controls">
|
| 386 |
+
<button onclick="changeZoom(-1)">−</button>
|
| 387 |
+
<span id="overlay-zoom">100%</span>
|
| 388 |
+
<button onclick="changeZoom(1)">+</button>
|
| 389 |
+
</div>
|
| 390 |
+
</div>
|
| 391 |
+
<div class="overlay-bottom">
|
| 392 |
+
<div class="overlay-slider">
|
| 393 |
+
<span id="overlay-page-num">1</span>
|
| 394 |
+
<input type="range" id="overlay-slider" min="0" max="1" value="0" oninput="onSliderInput(this.value)">
|
| 395 |
+
<span id="overlay-page-total">1</span>
|
| 396 |
+
</div>
|
| 397 |
+
<div class="overlay-nav">
|
| 398 |
+
<button class="btn btn-sm btn-secondary" onclick="prevPage()">◀ Prev</button>
|
| 399 |
+
<span id="overlay-indicator">1 / 1</span>
|
| 400 |
+
<button class="btn btn-sm btn-secondary" onclick="nextPage()">Next ▶</button>
|
| 401 |
+
</div>
|
| 402 |
+
<div class="overlay-actions">
|
| 403 |
+
<button class="btn btn-sm btn-secondary" onclick="toggleNavPanel()">📑 Chapters</button>
|
| 404 |
+
<button class="btn btn-sm" onclick="toggleTranslation()">🌐 Translate</button>
|
| 405 |
+
<button class="btn btn-sm btn-secondary" onclick="addBookmarkPrompt()">🔖 Bookmark</button>
|
| 406 |
+
</div>
|
| 407 |
+
</div>
|
| 408 |
+
</div>
|
| 409 |
+
|
| 410 |
+
<!-- ==================== NAV PANEL (chapters + bookmarks) ==================== -->
|
| 411 |
+
<div class="nav-panel" id="nav-panel">
|
| 412 |
+
<div class="nav-panel-header">
|
| 413 |
+
<h3>Navigation</h3>
|
| 414 |
+
<button class="btn btn-sm btn-secondary" onclick="closeNavPanel()">✕</button>
|
| 415 |
+
</div>
|
| 416 |
+
<div class="nav-panel-tabs">
|
| 417 |
+
<button id="navtab-chapters" class="active" onclick="switchNavTab('chapters')">📑 Chapters</button>
|
| 418 |
+
<button id="navtab-bookmarks" onclick="switchNavTab('bookmarks')">🔖 Bookmarks</button>
|
| 419 |
+
</div>
|
| 420 |
+
<div class="nav-panel-body" id="nav-panel-body"></div>
|
| 421 |
+
<div class="nav-panel-footer" id="nav-panel-footer" style="display:none;">
|
| 422 |
+
<button class="btn btn-sm" style="width:100%;" onclick="addBookmarkPrompt()">+ Add Bookmark Here</button>
|
| 423 |
+
</div>
|
| 424 |
+
</div>
|
| 425 |
+
|
| 426 |
+
<!-- Selection tooltip -->
|
| 427 |
+
<div class="selection-tooltip" id="selection-tooltip">
|
| 428 |
+
<button onclick="explainSelection()">💡 Explain</button>
|
| 429 |
+
<button onclick="analyzeSelection()">🔍 Analyze</button>
|
| 430 |
+
<button onclick="translateSelection()">🌐 Translate</button>
|
| 431 |
+
</div>
|
| 432 |
+
</div>
|
| 433 |
+
|
| 434 |
+
<script>
|
| 435 |
+
// ==================== STATE ====================
|
| 436 |
+
let currentBookId = null;
|
| 437 |
+
let segments = [];
|
| 438 |
+
let visiblePages = [];
|
| 439 |
+
let currentPage = 0;
|
| 440 |
+
let bookLanguage = 'en';
|
| 441 |
+
let zoomLevel = 100;
|
| 442 |
+
const MIN_ZOOM = 60, MAX_ZOOM = 200;
|
| 443 |
+
let selectedText = '';
|
| 444 |
+
let translationCache = {};
|
| 445 |
+
let chapters = [];
|
| 446 |
+
let bookmarks = [];
|
| 447 |
+
let overlayOpen = false;
|
| 448 |
+
let navPanelOpen = false;
|
| 449 |
+
let currentNavTab = 'chapters';
|
| 450 |
+
let tapTimer = null;
|
| 451 |
+
let lastTapTime = 0;
|
| 452 |
+
|
| 453 |
+
// ==================== LIBRARY ====================
|
| 454 |
+
async function uploadBook() {
|
| 455 |
+
const fileInput = document.getElementById('upload-file');
|
| 456 |
+
if (!fileInput.files.length) return alert('Select a file first');
|
| 457 |
+
const fd = new FormData();
|
| 458 |
+
fd.append('file', fileInput.files[0]);
|
| 459 |
+
fd.append('title', document.getElementById('upload-title').value);
|
| 460 |
+
fd.append('language', document.getElementById('upload-lang').value);
|
| 461 |
+
fd.append('folder_id', document.getElementById('upload-folder').value);
|
| 462 |
+
const btn = event.target; btn.disabled = true; btn.textContent = 'Uploading...';
|
| 463 |
+
try {
|
| 464 |
+
const r = await fetch('/upload', { method: 'POST', body: fd });
|
| 465 |
+
if (!r.ok) { const e = await r.json(); throw new Error(e.detail || 'Upload failed'); }
|
| 466 |
+
location.reload();
|
| 467 |
+
} catch(e) { alert(e.message); } finally { btn.disabled = false; btn.textContent = 'Upload'; }
|
| 468 |
+
}
|
| 469 |
+
async function renameBook(id) { const t = prompt('New title:'); if (!t) return; const fd = new FormData(); fd.append('title', t); await fetch(`/rename/${id}`, {method:'POST',body:fd}); location.reload(); }
|
| 470 |
+
async function deleteBook(id) { if (!confirm('Delete this book?')) return; await fetch(`/book/${id}`, {method:'DELETE'}); location.reload(); }
|
| 471 |
+
function filterFolder(fid) { location.href = fid ? '/?folder='+fid : '/'; }
|
| 472 |
+
async function createFolderPrompt() { const n = prompt('New folder name:'); if (!n) return; await fetch('/folders', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n})}); location.reload(); }
|
| 473 |
+
async function renameFolderPrompt(fid, cur) { const n = prompt('Rename folder:', cur); if (!n||n===cur) return; await fetch(`/folders/${fid}/rename`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n})}); location.reload(); }
|
| 474 |
+
async function deleteFolderConfirm(fid) { if (!confirm('Delete folder?')) return; await fetch(`/folders/${fid}`, {method:'DELETE'}); location.reload(); }
|
| 475 |
+
async function moveBookPrompt(bookId) {
|
| 476 |
+
const folders = {{ folders | tojson }};
|
| 477 |
+
let msg = 'Move to folder:\n0 — None\n'; folders.forEach((f,i) => { msg += `${i+1} — ${f.name}\n`; });
|
| 478 |
+
const c = prompt(msg); if (c===null) return; const idx = parseInt(c);
|
| 479 |
+
const fid = (idx>0 && idx<=folders.length) ? folders[idx-1].id : null;
|
| 480 |
+
await fetch(`/book/${bookId}/move`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({folder_id:fid})}); location.reload();
|
| 481 |
+
}
|
| 482 |
+
</script>
|
| 483 |
+
|
| 484 |
+
<script>
|
| 485 |
+
// ==================== PAGINATION ====================
|
| 486 |
+
function getAvailableHeight() {
|
| 487 |
+
const paper = document.getElementById('page-paper');
|
| 488 |
+
const cs = getComputedStyle(paper);
|
| 489 |
+
return paper.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
function paginateSegments() {
|
| 493 |
+
const paper = document.getElementById('page-paper');
|
| 494 |
+
const availH = getAvailableHeight();
|
| 495 |
+
if (!segments.length || availH <= 0) { visiblePages = []; return; }
|
| 496 |
+
|
| 497 |
+
let measurer = document.getElementById('page-measurer');
|
| 498 |
+
if (!measurer) {
|
| 499 |
+
measurer = document.createElement('div');
|
| 500 |
+
measurer.id = 'page-measurer';
|
| 501 |
+
paper.appendChild(measurer);
|
| 502 |
+
}
|
| 503 |
+
measurer.style.cssText = 'position:absolute;visibility:hidden;left:0;right:0;' +
|
| 504 |
+
'max-width:' + paper.clientWidth + 'px;' +
|
| 505 |
+
'padding:0 ' + getComputedStyle(paper).paddingLeft + ';' +
|
| 506 |
+
'word-wrap:break-word;overflow-wrap:break-word;' +
|
| 507 |
+
'line-height:' + getComputedStyle(paper).lineHeight + ';' +
|
| 508 |
+
'font-size:' + getComputedStyle(paper).fontSize + ';' +
|
| 509 |
+
'font-family:' + getComputedStyle(paper).fontFamily + ';' +
|
| 510 |
+
'margin-bottom:0.8em;text-align:justify;text-indent:1.5em;';
|
| 511 |
+
|
| 512 |
+
visiblePages = [];
|
| 513 |
+
let segIdx = 0, charOff = 0, usedH = 0;
|
| 514 |
+
let pageStart = { seg: 0, off: 0 };
|
| 515 |
+
|
| 516 |
+
while (segIdx < segments.length) {
|
| 517 |
+
const seg = segments[segIdx];
|
| 518 |
+
if (seg.type === 'image') {
|
| 519 |
+
const imgH = Math.min(availH * 0.6, 300) + 32;
|
| 520 |
+
if (usedH + imgH > availH && usedH > 0) {
|
| 521 |
+
visiblePages.push({ startSeg: pageStart.seg, startOff: pageStart.off, endSeg: segIdx, endOff: 0 });
|
| 522 |
+
pageStart = { seg: segIdx, off: 0 }; usedH = 0;
|
| 523 |
+
}
|
| 524 |
+
usedH += imgH; segIdx++; charOff = 0;
|
| 525 |
+
} else {
|
| 526 |
+
const text = seg.content.substring(charOff);
|
| 527 |
+
if (!text) { segIdx++; charOff = 0; continue; }
|
| 528 |
+
measurer.textContent = text;
|
| 529 |
+
const fullH = measurer.scrollHeight;
|
| 530 |
+
if (usedH + fullH <= availH) {
|
| 531 |
+
usedH += fullH; segIdx++; charOff = 0;
|
| 532 |
+
} else {
|
| 533 |
+
const remaining = availH - usedH;
|
| 534 |
+
if (remaining < 20) {
|
| 535 |
+
visiblePages.push({ startSeg: pageStart.seg, startOff: pageStart.off, endSeg: segIdx, endOff: charOff });
|
| 536 |
+
pageStart = { seg: segIdx, off: charOff }; usedH = 0; continue;
|
| 537 |
+
}
|
| 538 |
+
let lo = 1, hi = text.length, best = 0;
|
| 539 |
+
while (lo <= hi) {
|
| 540 |
+
const mid = (lo + hi) >> 1;
|
| 541 |
+
measurer.textContent = text.substring(0, mid);
|
| 542 |
+
if (measurer.scrollHeight <= remaining) { best = mid; lo = mid + 1; } else { hi = mid - 1; }
|
| 543 |
+
}
|
| 544 |
+
if (best < text.length && best > 0) {
|
| 545 |
+
const sub = text.substring(0, best);
|
| 546 |
+
const lastSp = Math.max(sub.lastIndexOf(' '), sub.lastIndexOf('\n'));
|
| 547 |
+
if (lastSp > best * 0.3) best = lastSp + 1;
|
| 548 |
+
}
|
| 549 |
+
if (best <= 0) best = 1;
|
| 550 |
+
const endOff = charOff + best;
|
| 551 |
+
visiblePages.push({ startSeg: pageStart.seg, startOff: pageStart.off, endSeg: segIdx, endOff: endOff });
|
| 552 |
+
pageStart = { seg: segIdx, off: endOff }; charOff = endOff; usedH = 0;
|
| 553 |
+
}
|
| 554 |
+
}
|
| 555 |
+
}
|
| 556 |
+
if (pageStart.seg < segments.length) {
|
| 557 |
+
visiblePages.push({ startSeg: pageStart.seg, startOff: pageStart.off, endSeg: segments.length, endOff: 0 });
|
| 558 |
+
}
|
| 559 |
+
measurer.textContent = '';
|
| 560 |
+
if (!visiblePages.length) visiblePages = [{ startSeg: 0, startOff: 0, endSeg: segments.length, endOff: 0 }];
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
function isMobile() { return window.innerWidth <= 700; }
|
| 564 |
+
|
| 565 |
+
function renderCurrentPage() {
|
| 566 |
+
const paper = document.getElementById('page-paper');
|
| 567 |
+
Array.from(paper.children).forEach(c => { if (c.id !== 'page-measurer') paper.removeChild(c); });
|
| 568 |
+
|
| 569 |
+
if (isMobile() && visiblePages.length === 0) {
|
| 570 |
+
// Fallback: dump all segments for scroll
|
| 571 |
+
for (const seg of segments) {
|
| 572 |
+
if (seg.type === 'image') {
|
| 573 |
+
const img = document.createElement('img');
|
| 574 |
+
img.src = seg.src; img.className = 'seg-img'; img.alt = 'Book image';
|
| 575 |
+
paper.appendChild(img);
|
| 576 |
+
} else {
|
| 577 |
+
const div = document.createElement('div');
|
| 578 |
+
div.className = 'seg-text'; div.textContent = seg.content;
|
| 579 |
+
paper.appendChild(div);
|
| 580 |
+
}
|
| 581 |
+
}
|
| 582 |
+
updateAllIndicators();
|
| 583 |
+
return;
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
if (!visiblePages.length) return;
|
| 587 |
+
const pg = visiblePages[Math.min(currentPage, visiblePages.length - 1)];
|
| 588 |
+
|
| 589 |
+
for (let si = pg.startSeg; si < pg.endSeg && si < segments.length; si++) {
|
| 590 |
+
const seg = segments[si];
|
| 591 |
+
if (seg.type === 'image') {
|
| 592 |
+
const img = document.createElement('img');
|
| 593 |
+
img.src = seg.src; img.className = 'seg-img'; img.alt = 'Book image';
|
| 594 |
+
paper.appendChild(img);
|
| 595 |
+
} else {
|
| 596 |
+
const div = document.createElement('div');
|
| 597 |
+
div.className = 'seg-text';
|
| 598 |
+
const startOff = (si === pg.startSeg) ? pg.startOff : 0;
|
| 599 |
+
const endOff = (si === pg.endSeg - 1 && pg.endOff > 0) ? pg.endOff : seg.content.length;
|
| 600 |
+
let text = (si < pg.endSeg - 1)
|
| 601 |
+
? ((si === pg.startSeg) ? seg.content.substring(startOff) : seg.content)
|
| 602 |
+
: seg.content.substring(startOff, endOff || seg.content.length);
|
| 603 |
+
div.textContent = text;
|
| 604 |
+
paper.appendChild(div);
|
| 605 |
+
}
|
| 606 |
+
}
|
| 607 |
+
// Handle single-segment page
|
| 608 |
+
if (pg.startSeg === pg.endSeg && pg.startSeg < segments.length) {
|
| 609 |
+
const seg = segments[pg.startSeg];
|
| 610 |
+
if (seg.type === 'text') {
|
| 611 |
+
const div = document.createElement('div');
|
| 612 |
+
div.className = 'seg-text';
|
| 613 |
+
div.textContent = seg.content.substring(pg.startOff, pg.endOff || seg.content.length);
|
| 614 |
+
paper.appendChild(div);
|
| 615 |
+
}
|
| 616 |
+
}
|
| 617 |
+
updateAllIndicators();
|
| 618 |
+
}
|
| 619 |
+
|
| 620 |
+
function getPageText(pageIdx) {
|
| 621 |
+
if (!visiblePages.length) return '';
|
| 622 |
+
const pg = visiblePages[pageIdx]; let text = '';
|
| 623 |
+
for (let si = pg.startSeg; si < pg.endSeg && si < segments.length; si++) {
|
| 624 |
+
if (segments[si].type !== 'text') continue;
|
| 625 |
+
const startOff = (si === pg.startSeg) ? pg.startOff : 0;
|
| 626 |
+
const endOff = (si === pg.endSeg - 1 && pg.endOff > 0) ? pg.endOff : segments[si].content.length;
|
| 627 |
+
text += (si < pg.endSeg - 1)
|
| 628 |
+
? ((si === pg.startSeg) ? segments[si].content.substring(startOff) : segments[si].content)
|
| 629 |
+
: segments[si].content.substring(startOff, endOff || segments[si].content.length);
|
| 630 |
+
text += '\n\n';
|
| 631 |
+
}
|
| 632 |
+
return text.trim();
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
function getTotalCharCount() {
|
| 636 |
+
let total = 0;
|
| 637 |
+
for (const s of segments) if (s.type === 'text') total += s.content.length;
|
| 638 |
+
return total;
|
| 639 |
+
}
|
| 640 |
+
function getCharOffsetForPage(pageIdx) {
|
| 641 |
+
if (!visiblePages.length) return 0;
|
| 642 |
+
const pg = visiblePages[pageIdx]; let off = 0;
|
| 643 |
+
for (let i = 0; i < pg.startSeg && i < segments.length; i++) {
|
| 644 |
+
if (segments[i].type === 'text') off += segments[i].content.length;
|
| 645 |
+
}
|
| 646 |
+
return off + pg.startOff;
|
| 647 |
+
}
|
| 648 |
+
function findPageForCharOffset(charOff) {
|
| 649 |
+
for (let i = 0; i < visiblePages.length; i++) {
|
| 650 |
+
const endOff = getCharOffsetForPage(i) + getPageTextLength(i);
|
| 651 |
+
if (endOff >= charOff) return i;
|
| 652 |
+
}
|
| 653 |
+
return visiblePages.length - 1;
|
| 654 |
+
}
|
| 655 |
+
function findPageByStartOffset(startCharOff) {
|
| 656 |
+
let best = 0;
|
| 657 |
+
for (let i = 0; i < visiblePages.length; i++) {
|
| 658 |
+
if (getCharOffsetForPage(i) <= startCharOff) best = i; else break;
|
| 659 |
+
}
|
| 660 |
+
return best;
|
| 661 |
+
}
|
| 662 |
+
function getPageTextLength(pageIdx) {
|
| 663 |
+
const pg = visiblePages[pageIdx]; let len = 0;
|
| 664 |
+
for (let si = pg.startSeg; si < pg.endSeg && si < segments.length; si++) {
|
| 665 |
+
if (segments[si].type !== 'text') continue;
|
| 666 |
+
const startOff = (si === pg.startSeg) ? pg.startOff : 0;
|
| 667 |
+
const endOff = (si === pg.endSeg - 1 && pg.endOff > 0) ? pg.endOff : segments[si].content.length;
|
| 668 |
+
len += ((si < pg.endSeg - 1) ? segments[si].content.length - startOff : (endOff || segments[si].content.length) - startOff);
|
| 669 |
+
}
|
| 670 |
+
return len;
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
function updateAllIndicators() {
|
| 674 |
+
const total = visiblePages.length || 1;
|
| 675 |
+
const cur = currentPage + 1;
|
| 676 |
+
const txt = `${cur} / ${total}`;
|
| 677 |
+
const el1 = document.getElementById('page-indicator');
|
| 678 |
+
const el2 = document.getElementById('overlay-indicator');
|
| 679 |
+
const el3 = document.getElementById('overlay-page-num');
|
| 680 |
+
const el4 = document.getElementById('overlay-page-total');
|
| 681 |
+
const slider = document.getElementById('overlay-slider');
|
| 682 |
+
if (el1) el1.textContent = txt;
|
| 683 |
+
if (el2) el2.textContent = txt;
|
| 684 |
+
if (el3) el3.textContent = cur;
|
| 685 |
+
if (el4) el4.textContent = total;
|
| 686 |
+
if (slider) { slider.max = Math.max(total - 1, 0); slider.value = currentPage; }
|
| 687 |
+
}
|
| 688 |
+
</script>
|
| 689 |
+
|
| 690 |
+
<script>
|
| 691 |
+
// ==================== READER CORE ====================
|
| 692 |
+
async function openBook(bookId) {
|
| 693 |
+
currentBookId = bookId;
|
| 694 |
+
translationCache = {};
|
| 695 |
+
currentPage = 0;
|
| 696 |
+
bookmarks = [];
|
| 697 |
+
|
| 698 |
+
const resp = await fetch(`/book/${bookId}/full_text`);
|
| 699 |
+
const data = await resp.json();
|
| 700 |
+
segments = data.segments || [];
|
| 701 |
+
chapters = data.chapters || [];
|
| 702 |
+
bookLanguage = data.language || 'en';
|
| 703 |
+
|
| 704 |
+
if (!segments.length && data.text) {
|
| 705 |
+
segments = [{ type: 'text', content: data.text }];
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
const title = data.title || 'Book';
|
| 709 |
+
document.getElementById('reader-title').textContent = title;
|
| 710 |
+
document.getElementById('overlay-title').textContent = title;
|
| 711 |
+
document.getElementById('library-view').style.display = 'none';
|
| 712 |
+
document.getElementById('reader-view').style.display = 'flex';
|
| 713 |
+
applyZoom();
|
| 714 |
+
|
| 715 |
+
// Load bookmarks
|
| 716 |
+
try {
|
| 717 |
+
const bmResp = await fetch(`/book/${bookId}/bookmarks`);
|
| 718 |
+
bookmarks = await bmResp.json();
|
| 719 |
+
} catch(e) { bookmarks = []; }
|
| 720 |
+
|
| 721 |
+
requestAnimationFrame(() => {
|
| 722 |
+
paginateSegments();
|
| 723 |
+
// Restore saved progress
|
| 724 |
+
fetch(`/book/${bookId}/progress`).then(r => r.ok ? r.json() : null).then(prog => {
|
| 725 |
+
if (prog && prog.percent_read > 0) {
|
| 726 |
+
const totalChars = getTotalCharCount();
|
| 727 |
+
const charOff = Math.floor((prog.percent_read / 100) * totalChars);
|
| 728 |
+
currentPage = findPageForCharOffset(charOff);
|
| 729 |
+
}
|
| 730 |
+
renderCurrentPage();
|
| 731 |
+
}).catch(() => renderCurrentPage());
|
| 732 |
+
});
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
+
function closeReader() {
|
| 736 |
+
saveProgress();
|
| 737 |
+
document.getElementById('reader-view').style.display = 'none';
|
| 738 |
+
document.getElementById('library-view').style.display = 'block';
|
| 739 |
+
closeSidePanel(); closeOverlay(); closeNavPanel();
|
| 740 |
+
currentBookId = null; segments = []; visiblePages = []; chapters = []; bookmarks = [];
|
| 741 |
+
}
|
| 742 |
+
|
| 743 |
+
function prevPage() {
|
| 744 |
+
if (currentPage <= 0) return;
|
| 745 |
+
currentPage--; renderCurrentPage(); saveProgress();
|
| 746 |
+
}
|
| 747 |
+
function nextPage() {
|
| 748 |
+
if (currentPage >= visiblePages.length - 1) return;
|
| 749 |
+
currentPage++; renderCurrentPage(); saveProgress();
|
| 750 |
+
}
|
| 751 |
+
|
| 752 |
+
function saveProgress() {
|
| 753 |
+
if (!currentBookId || !visiblePages.length) return;
|
| 754 |
+
const totalChars = getTotalCharCount();
|
| 755 |
+
const pageEndOff = getCharOffsetForPage(currentPage) + getPageTextLength(currentPage);
|
| 756 |
+
const percent = totalChars > 0 ? Math.round((pageEndOff / totalChars) * 1000) / 10 : 0;
|
| 757 |
+
fetch(`/book/${currentBookId}/progress`, {
|
| 758 |
+
method: 'POST', headers: {'Content-Type':'application/json'},
|
| 759 |
+
body: JSON.stringify({ current_page: currentPage, percent_read: percent }),
|
| 760 |
+
}).catch(() => {});
|
| 761 |
+
}
|
| 762 |
+
|
| 763 |
+
function applyZoom() {
|
| 764 |
+
const scale = zoomLevel / 100;
|
| 765 |
+
const paper = document.getElementById('page-paper');
|
| 766 |
+
paper.style.fontSize = (1.1 * scale) + 'rem';
|
| 767 |
+
paper.style.maxWidth = (600 * scale) + 'px';
|
| 768 |
+
document.getElementById('zoom-level').textContent = zoomLevel + '%';
|
| 769 |
+
document.getElementById('overlay-zoom').textContent = zoomLevel + '%';
|
| 770 |
+
}
|
| 771 |
+
|
| 772 |
+
function changeZoom(dir) {
|
| 773 |
+
const newZoom = zoomLevel + dir * 10;
|
| 774 |
+
if (newZoom < MIN_ZOOM || newZoom > MAX_ZOOM) return;
|
| 775 |
+
const startCharOff = visiblePages.length ? getCharOffsetForPage(currentPage) : 0;
|
| 776 |
+
zoomLevel = newZoom;
|
| 777 |
+
applyZoom();
|
| 778 |
+
requestAnimationFrame(() => {
|
| 779 |
+
paginateSegments();
|
| 780 |
+
currentPage = findPageByStartOffset(startCharOff);
|
| 781 |
+
renderCurrentPage();
|
| 782 |
+
});
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
function onSliderInput(val) {
|
| 786 |
+
const page = parseInt(val);
|
| 787 |
+
if (page >= 0 && page < visiblePages.length) {
|
| 788 |
+
currentPage = page; renderCurrentPage(); saveProgress();
|
| 789 |
+
}
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
// ==================== OVERLAY (single tap) ====================
|
| 793 |
+
function openOverlay() {
|
| 794 |
+
overlayOpen = true;
|
| 795 |
+
document.getElementById('reader-overlay').classList.add('open');
|
| 796 |
+
updateAllIndicators();
|
| 797 |
+
}
|
| 798 |
+
function closeOverlay() {
|
| 799 |
+
overlayOpen = false;
|
| 800 |
+
document.getElementById('reader-overlay').classList.remove('open');
|
| 801 |
+
}
|
| 802 |
+
function toggleOverlay() {
|
| 803 |
+
if (overlayOpen) closeOverlay(); else openOverlay();
|
| 804 |
+
}
|
| 805 |
+
|
| 806 |
+
// Single tap detection on page-container (not on text selection or links)
|
| 807 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 808 |
+
const container = document.getElementById('page-container');
|
| 809 |
+
if (!container) return;
|
| 810 |
+
|
| 811 |
+
container.addEventListener('click', (e) => {
|
| 812 |
+
// Don't trigger overlay if user selected text
|
| 813 |
+
const sel = window.getSelection();
|
| 814 |
+
if (sel && sel.toString().trim().length > 0) return;
|
| 815 |
+
// Don't trigger if clicking a button or link
|
| 816 |
+
if (e.target.closest('button, a, .selection-tooltip')) return;
|
| 817 |
+
// Don't trigger if side panel or nav panel is open
|
| 818 |
+
if (document.getElementById('side-panel').classList.contains('open')) return;
|
| 819 |
+
if (navPanelOpen) return;
|
| 820 |
+
|
| 821 |
+
toggleOverlay();
|
| 822 |
+
});
|
| 823 |
+
});
|
| 824 |
+
|
| 825 |
+
// ==================== NAV PANEL (chapters + bookmarks) ====================
|
| 826 |
+
function toggleNavPanel() {
|
| 827 |
+
if (navPanelOpen) closeNavPanel(); else openNavPanel();
|
| 828 |
+
}
|
| 829 |
+
function openNavPanel() {
|
| 830 |
+
navPanelOpen = true;
|
| 831 |
+
document.getElementById('nav-panel').classList.add('open');
|
| 832 |
+
renderNavPanel();
|
| 833 |
+
}
|
| 834 |
+
function closeNavPanel() {
|
| 835 |
+
navPanelOpen = false;
|
| 836 |
+
document.getElementById('nav-panel').classList.remove('open');
|
| 837 |
+
}
|
| 838 |
+
function switchNavTab(tab) {
|
| 839 |
+
currentNavTab = tab;
|
| 840 |
+
document.querySelectorAll('.nav-panel-tabs button').forEach(b => b.classList.remove('active'));
|
| 841 |
+
document.getElementById('navtab-' + tab)?.classList.add('active');
|
| 842 |
+
document.getElementById('nav-panel-footer').style.display = (tab === 'bookmarks') ? 'block' : 'none';
|
| 843 |
+
renderNavPanel();
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
function renderNavPanel() {
|
| 847 |
+
const body = document.getElementById('nav-panel-body');
|
| 848 |
+
body.innerHTML = '';
|
| 849 |
+
|
| 850 |
+
if (currentNavTab === 'chapters') {
|
| 851 |
+
if (!chapters.length) {
|
| 852 |
+
body.innerHTML = '<div class="nav-empty">No chapters detected</div>';
|
| 853 |
+
return;
|
| 854 |
+
}
|
| 855 |
+
chapters.forEach(ch => {
|
| 856 |
+
const item = document.createElement('div');
|
| 857 |
+
item.className = 'nav-item';
|
| 858 |
+
item.innerHTML = `<span>${esc(ch.title)}</span>`;
|
| 859 |
+
item.onclick = () => { goToSegment(ch.segment); closeNavPanel(); closeOverlay(); };
|
| 860 |
+
body.appendChild(item);
|
| 861 |
+
});
|
| 862 |
+
} else {
|
| 863 |
+
if (!bookmarks.length) {
|
| 864 |
+
body.innerHTML = '<div class="nav-empty">No bookmarks yet</div>';
|
| 865 |
+
return;
|
| 866 |
+
}
|
| 867 |
+
bookmarks.forEach(bm => {
|
| 868 |
+
const item = document.createElement('div');
|
| 869 |
+
item.className = 'nav-item';
|
| 870 |
+
|
| 871 |
+
const label = document.createElement('span');
|
| 872 |
+
label.textContent = '🔖 ' + bm.name;
|
| 873 |
+
label.onclick = () => { goToSegment(bm.segment_index); closeNavPanel(); closeOverlay(); };
|
| 874 |
+
|
| 875 |
+
const actions = document.createElement('span');
|
| 876 |
+
actions.className = 'nav-item-actions';
|
| 877 |
+
|
| 878 |
+
const renameBtn = document.createElement('button');
|
| 879 |
+
renameBtn.textContent = '✏️';
|
| 880 |
+
renameBtn.onclick = (e) => { e.stopPropagation(); renameBookmarkPrompt(bm.id, bm.name); };
|
| 881 |
+
|
| 882 |
+
const deleteBtn = document.createElement('button');
|
| 883 |
+
deleteBtn.textContent = '🗑️';
|
| 884 |
+
deleteBtn.onclick = (e) => { e.stopPropagation(); deleteBookmarkConfirm(bm.id); };
|
| 885 |
+
|
| 886 |
+
actions.appendChild(renameBtn);
|
| 887 |
+
actions.appendChild(deleteBtn);
|
| 888 |
+
item.appendChild(label);
|
| 889 |
+
item.appendChild(actions);
|
| 890 |
+
body.appendChild(item);
|
| 891 |
+
});
|
| 892 |
+
}
|
| 893 |
+
}
|
| 894 |
+
|
| 895 |
+
function goToSegment(segmentIdx) {
|
| 896 |
+
for (let i = 0; i < visiblePages.length; i++) {
|
| 897 |
+
if (visiblePages[i].startSeg >= segmentIdx ||
|
| 898 |
+
(visiblePages[i].startSeg <= segmentIdx && visiblePages[i].endSeg > segmentIdx)) {
|
| 899 |
+
currentPage = i; renderCurrentPage(); saveProgress(); return;
|
| 900 |
+
}
|
| 901 |
+
}
|
| 902 |
+
}
|
| 903 |
+
|
| 904 |
+
// ==================== BOOKMARKS ====================
|
| 905 |
+
async function addBookmarkPrompt() {
|
| 906 |
+
const name = prompt('Bookmark name:');
|
| 907 |
+
if (!name || !name.trim()) return;
|
| 908 |
+
// Use the first segment of the current page as the bookmark position
|
| 909 |
+
const segIdx = visiblePages.length ? visiblePages[currentPage].startSeg : 0;
|
| 910 |
+
try {
|
| 911 |
+
const resp = await fetch(`/book/${currentBookId}/bookmarks`, {
|
| 912 |
+
method: 'POST', headers: {'Content-Type':'application/json'},
|
| 913 |
+
body: JSON.stringify({ name: name.trim(), segment_index: segIdx })
|
| 914 |
+
});
|
| 915 |
+
const bm = await resp.json();
|
| 916 |
+
bookmarks.push(bm);
|
| 917 |
+
bookmarks.sort((a, b) => a.segment_index - b.segment_index);
|
| 918 |
+
if (navPanelOpen && currentNavTab === 'bookmarks') renderNavPanel();
|
| 919 |
+
} catch(e) { alert('Failed to add bookmark'); }
|
| 920 |
+
}
|
| 921 |
+
|
| 922 |
+
async function renameBookmarkPrompt(bmId, currentName) {
|
| 923 |
+
const name = prompt('Rename bookmark:', currentName);
|
| 924 |
+
if (!name || !name.trim() || name === currentName) return;
|
| 925 |
+
try {
|
| 926 |
+
await fetch(`/bookmarks/${bmId}/rename`, {
|
| 927 |
+
method: 'POST', headers: {'Content-Type':'application/json'},
|
| 928 |
+
body: JSON.stringify({ name: name.trim() })
|
| 929 |
+
});
|
| 930 |
+
const bm = bookmarks.find(b => b.id === bmId);
|
| 931 |
+
if (bm) bm.name = name.trim();
|
| 932 |
+
renderNavPanel();
|
| 933 |
+
} catch(e) { alert('Failed to rename bookmark'); }
|
| 934 |
+
}
|
| 935 |
+
|
| 936 |
+
async function deleteBookmarkConfirm(bmId) {
|
| 937 |
+
if (!confirm('Delete this bookmark?')) return;
|
| 938 |
+
try {
|
| 939 |
+
await fetch(`/bookmarks/${bmId}`, { method: 'DELETE' });
|
| 940 |
+
bookmarks = bookmarks.filter(b => b.id !== bmId);
|
| 941 |
+
renderNavPanel();
|
| 942 |
+
} catch(e) { alert('Failed to delete bookmark'); }
|
| 943 |
+
}
|
| 944 |
+
</script>
|
| 945 |
+
|
| 946 |
+
<script>
|
| 947 |
+
// ==================== TRANSLATION ====================
|
| 948 |
+
async function toggleTranslation() {
|
| 949 |
+
closeOverlay();
|
| 950 |
+
openSidePanel(); switchTab('translation');
|
| 951 |
+
await translateCurrentPage();
|
| 952 |
+
}
|
| 953 |
+
async function translateCurrentPage() {
|
| 954 |
+
const panel = document.getElementById('panel-content');
|
| 955 |
+
if (translationCache[currentPage]) { panel.textContent = translationCache[currentPage]; return; }
|
| 956 |
+
panel.innerHTML = '<span class="spinner"></span> Translating...';
|
| 957 |
+
const text = getPageText(currentPage);
|
| 958 |
+
try {
|
| 959 |
+
const resp = await fetch('/translate', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({text, language: bookLanguage}) });
|
| 960 |
+
const data = await resp.json();
|
| 961 |
+
translationCache[currentPage] = data.translation;
|
| 962 |
+
panel.textContent = data.translation;
|
| 963 |
+
} catch(e) { panel.textContent = 'Translation failed: ' + e.message; }
|
| 964 |
+
}
|
| 965 |
+
|
| 966 |
+
// ==================== SELECTION TOOLTIP ====================
|
| 967 |
+
document.addEventListener('mouseup', (e) => {
|
| 968 |
+
const tooltip = document.getElementById('selection-tooltip');
|
| 969 |
+
const sel = window.getSelection();
|
| 970 |
+
const text = sel?.toString().trim();
|
| 971 |
+
if (text && text.length > 0 && document.getElementById('page-paper')?.contains(sel.anchorNode)) {
|
| 972 |
+
selectedText = text;
|
| 973 |
+
tooltip.style.display = 'flex';
|
| 974 |
+
tooltip.style.left = Math.min(e.clientX, window.innerWidth - 200) + 'px';
|
| 975 |
+
tooltip.style.top = (e.clientY - 45) + 'px';
|
| 976 |
+
} else { tooltip.style.display = 'none'; }
|
| 977 |
+
});
|
| 978 |
+
document.addEventListener('mousedown', (e) => {
|
| 979 |
+
const tooltip = document.getElementById('selection-tooltip');
|
| 980 |
+
if (tooltip && !tooltip.contains(e.target)) tooltip.style.display = 'none';
|
| 981 |
+
});
|
| 982 |
+
|
| 983 |
+
async function explainSelection() {
|
| 984 |
+
document.getElementById('selection-tooltip').style.display = 'none';
|
| 985 |
+
closeOverlay();
|
| 986 |
+
openSidePanel(); switchTab('explain');
|
| 987 |
+
const panel = document.getElementById('panel-content');
|
| 988 |
+
panel.innerHTML = '<span class="spinner"></span> Analyzing...';
|
| 989 |
+
try {
|
| 990 |
+
const resp = await fetch('/explain', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({text: selectedText, language: bookLanguage}) });
|
| 991 |
+
const data = await resp.json();
|
| 992 |
+
panel.textContent = data.explanation;
|
| 993 |
+
} catch(e) { panel.textContent = 'Failed: ' + e.message; }
|
| 994 |
+
}
|
| 995 |
+
async function translateSelection() {
|
| 996 |
+
document.getElementById('selection-tooltip').style.display = 'none';
|
| 997 |
+
closeOverlay();
|
| 998 |
+
openSidePanel(); switchTab('translation');
|
| 999 |
+
const panel = document.getElementById('panel-content');
|
| 1000 |
+
panel.innerHTML = '<span class="spinner"></span> Translating...';
|
| 1001 |
+
try {
|
| 1002 |
+
const resp = await fetch('/translate', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({text: selectedText, language: bookLanguage}) });
|
| 1003 |
+
const data = await resp.json();
|
| 1004 |
+
panel.textContent = data.translation;
|
| 1005 |
+
} catch(e) { panel.textContent = 'Failed: ' + e.message; }
|
| 1006 |
+
}
|
| 1007 |
+
async function analyzeSelection() {
|
| 1008 |
+
document.getElementById('selection-tooltip').style.display = 'none';
|
| 1009 |
+
closeOverlay();
|
| 1010 |
+
openSidePanel(); switchTab('words');
|
| 1011 |
+
const panel = document.getElementById('panel-content');
|
| 1012 |
+
panel.innerHTML = '<span class="spinner"></span> Analyzing word-by-word...';
|
| 1013 |
+
try {
|
| 1014 |
+
const resp = await fetch('/analyze', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({text: selectedText, language: bookLanguage}) });
|
| 1015 |
+
const data = await resp.json();
|
| 1016 |
+
if (data.words?.length > 0 && !data.words[0].error) {
|
| 1017 |
+
let html = '';
|
| 1018 |
+
if (data.translation) html += `<div style="margin-bottom:1rem;padding:0.8rem;background:var(--bg);border-radius:6px;"><strong style="color:var(--accent);">Translation:</strong><br>${esc(data.translation)}</div>`;
|
| 1019 |
+
html += '<table class="word-table"><tr><th>Word</th><th>Lemma</th><th>Grammar</th></tr>';
|
| 1020 |
+
for (const w of data.words) html += `<tr><td>${esc(w.form)}</td><td>${esc(w.lemma)}</td><td>${esc(w.grammar_comments)}</td></tr>`;
|
| 1021 |
+
html += '</table>';
|
| 1022 |
+
panel.innerHTML = html;
|
| 1023 |
+
} else { panel.textContent = data.words?.[0]?.error || 'No analysis available'; }
|
| 1024 |
+
} catch(e) { panel.textContent = 'Failed: ' + e.message; }
|
| 1025 |
+
}
|
| 1026 |
+
|
| 1027 |
+
function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
| 1028 |
+
|
| 1029 |
+
// ==================== SIDE PANEL ====================
|
| 1030 |
+
function openSidePanel() { document.getElementById('side-panel').classList.add('open'); }
|
| 1031 |
+
function closeSidePanel() { document.getElementById('side-panel').classList.remove('open'); }
|
| 1032 |
+
function switchTab(tab) {
|
| 1033 |
+
document.querySelectorAll('.side-panel-tabs button').forEach(b => b.classList.remove('active'));
|
| 1034 |
+
document.getElementById('tab-' + tab)?.classList.add('active');
|
| 1035 |
+
document.getElementById('panel-title').textContent = {translation:'Translation', explain:'Explanation', words:'Word-by-Word Analysis'}[tab] || 'Panel';
|
| 1036 |
+
}
|
| 1037 |
+
|
| 1038 |
+
// ==================== KEYBOARD ====================
|
| 1039 |
+
document.addEventListener('keydown', (e) => {
|
| 1040 |
+
if (!currentBookId) return;
|
| 1041 |
+
if (e.key === 'ArrowLeft') prevPage();
|
| 1042 |
+
if (e.key === 'ArrowRight') nextPage();
|
| 1043 |
+
if (e.key === '+' || e.key === '=') changeZoom(1);
|
| 1044 |
+
if (e.key === '-') changeZoom(-1);
|
| 1045 |
+
if (e.key === 'Escape') { closeSidePanel(); closeOverlay(); closeNavPanel(); }
|
| 1046 |
+
if (e.key === 'Home') { currentPage = 0; renderCurrentPage(); saveProgress(); }
|
| 1047 |
+
});
|
| 1048 |
+
|
| 1049 |
+
// Re-paginate on resize
|
| 1050 |
+
let resizeTimer;
|
| 1051 |
+
window.addEventListener('resize', () => {
|
| 1052 |
+
clearTimeout(resizeTimer);
|
| 1053 |
+
resizeTimer = setTimeout(() => {
|
| 1054 |
+
if (!currentBookId) return;
|
| 1055 |
+
const startCharOff = visiblePages.length ? getCharOffsetForPage(currentPage) : 0;
|
| 1056 |
+
paginateSegments();
|
| 1057 |
+
currentPage = findPageByStartOffset(startCharOff);
|
| 1058 |
+
renderCurrentPage();
|
| 1059 |
+
}, 200);
|
| 1060 |
+
});
|
| 1061 |
+
|
| 1062 |
+
// ==================== AUTO-RESUME LAST READ BOOK ====================
|
| 1063 |
+
document.addEventListener('DOMContentLoaded', async () => {
|
| 1064 |
+
try {
|
| 1065 |
+
const resp = await fetch('/last-read');
|
| 1066 |
+
const data = await resp.json();
|
| 1067 |
+
if (data.book_id) {
|
| 1068 |
+
openBook(data.book_id);
|
| 1069 |
+
}
|
| 1070 |
+
} catch(e) { /* stay on library */ }
|
| 1071 |
+
});
|
| 1072 |
+
</script>
|
| 1073 |
+
|
| 1074 |
+
<script>
|
| 1075 |
+
if ('serviceWorker' in navigator) {
|
| 1076 |
+
navigator.serviceWorker.register('/static/sw.js').catch(() => {});
|
| 1077 |
+
}
|
| 1078 |
+
</script>
|
| 1079 |
+
</body>
|
| 1080 |
+
</html>
|