randusertry commited on
Commit
3e6b783
·
verified ·
1 Parent(s): 732702f

Upload 11 files

Browse files
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>