ebk 0.4.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ebk/__init__.py +35 -0
- ebk/ai/__init__.py +23 -0
- ebk/ai/knowledge_graph.py +450 -0
- ebk/ai/llm_providers/__init__.py +26 -0
- ebk/ai/llm_providers/anthropic.py +209 -0
- ebk/ai/llm_providers/base.py +295 -0
- ebk/ai/llm_providers/gemini.py +285 -0
- ebk/ai/llm_providers/ollama.py +294 -0
- ebk/ai/metadata_enrichment.py +394 -0
- ebk/ai/question_generator.py +328 -0
- ebk/ai/reading_companion.py +224 -0
- ebk/ai/semantic_search.py +433 -0
- ebk/ai/text_extractor.py +393 -0
- ebk/calibre_import.py +66 -0
- ebk/cli.py +6433 -0
- ebk/config.py +230 -0
- ebk/db/__init__.py +37 -0
- ebk/db/migrations.py +507 -0
- ebk/db/models.py +725 -0
- ebk/db/session.py +144 -0
- ebk/decorators.py +1 -0
- ebk/exports/__init__.py +0 -0
- ebk/exports/base_exporter.py +218 -0
- ebk/exports/echo_export.py +279 -0
- ebk/exports/html_library.py +1743 -0
- ebk/exports/html_utils.py +87 -0
- ebk/exports/hugo.py +59 -0
- ebk/exports/jinja_export.py +286 -0
- ebk/exports/multi_facet_export.py +159 -0
- ebk/exports/opds_export.py +232 -0
- ebk/exports/symlink_dag.py +479 -0
- ebk/exports/zip.py +25 -0
- ebk/extract_metadata.py +341 -0
- ebk/ident.py +89 -0
- ebk/library_db.py +1440 -0
- ebk/opds.py +748 -0
- ebk/plugins/__init__.py +42 -0
- ebk/plugins/base.py +502 -0
- ebk/plugins/hooks.py +442 -0
- ebk/plugins/registry.py +499 -0
- ebk/repl/__init__.py +9 -0
- ebk/repl/find.py +126 -0
- ebk/repl/grep.py +173 -0
- ebk/repl/shell.py +1677 -0
- ebk/repl/text_utils.py +320 -0
- ebk/search_parser.py +413 -0
- ebk/server.py +3608 -0
- ebk/services/__init__.py +28 -0
- ebk/services/annotation_extraction.py +351 -0
- ebk/services/annotation_service.py +380 -0
- ebk/services/export_service.py +577 -0
- ebk/services/import_service.py +447 -0
- ebk/services/personal_metadata_service.py +347 -0
- ebk/services/queue_service.py +253 -0
- ebk/services/tag_service.py +281 -0
- ebk/services/text_extraction.py +317 -0
- ebk/services/view_service.py +12 -0
- ebk/similarity/__init__.py +77 -0
- ebk/similarity/base.py +154 -0
- ebk/similarity/core.py +471 -0
- ebk/similarity/extractors.py +168 -0
- ebk/similarity/metrics.py +376 -0
- ebk/skills/SKILL.md +182 -0
- ebk/skills/__init__.py +1 -0
- ebk/vfs/__init__.py +101 -0
- ebk/vfs/base.py +298 -0
- ebk/vfs/library_vfs.py +122 -0
- ebk/vfs/nodes/__init__.py +54 -0
- ebk/vfs/nodes/authors.py +196 -0
- ebk/vfs/nodes/books.py +480 -0
- ebk/vfs/nodes/files.py +155 -0
- ebk/vfs/nodes/metadata.py +385 -0
- ebk/vfs/nodes/root.py +100 -0
- ebk/vfs/nodes/similar.py +165 -0
- ebk/vfs/nodes/subjects.py +184 -0
- ebk/vfs/nodes/tags.py +371 -0
- ebk/vfs/resolver.py +228 -0
- ebk/vfs_router.py +275 -0
- ebk/views/__init__.py +32 -0
- ebk/views/dsl.py +668 -0
- ebk/views/service.py +619 -0
- ebk-0.4.4.dist-info/METADATA +755 -0
- ebk-0.4.4.dist-info/RECORD +87 -0
- ebk-0.4.4.dist-info/WHEEL +5 -0
- ebk-0.4.4.dist-info/entry_points.txt +2 -0
- ebk-0.4.4.dist-info/licenses/LICENSE +21 -0
- ebk-0.4.4.dist-info/top_level.txt +1 -0
ebk/server.py
ADDED
|
@@ -0,0 +1,3608 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Web server for ebk library management.
|
|
3
|
+
|
|
4
|
+
Provides a REST API and web interface for managing ebook libraries.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, List
|
|
9
|
+
import tempfile
|
|
10
|
+
import shutil
|
|
11
|
+
|
|
12
|
+
from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Query
|
|
13
|
+
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
|
14
|
+
from fastapi.staticfiles import StaticFiles
|
|
15
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
from .library_db import Library
|
|
19
|
+
from .extract_metadata import extract_metadata
|
|
20
|
+
from . import opds
|
|
21
|
+
from . import vfs_router
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Pydantic models for API
|
|
25
|
+
class BookResponse(BaseModel):
|
|
26
|
+
id: int
|
|
27
|
+
title: str
|
|
28
|
+
subtitle: Optional[str]
|
|
29
|
+
authors: List[str]
|
|
30
|
+
language: Optional[str]
|
|
31
|
+
publisher: Optional[str]
|
|
32
|
+
publication_date: Optional[str]
|
|
33
|
+
series: Optional[str]
|
|
34
|
+
series_index: Optional[float]
|
|
35
|
+
description: Optional[str]
|
|
36
|
+
subjects: List[str]
|
|
37
|
+
files: List[dict]
|
|
38
|
+
rating: Optional[float]
|
|
39
|
+
favorite: bool
|
|
40
|
+
reading_status: str
|
|
41
|
+
tags: List[str]
|
|
42
|
+
cover_path: Optional[str]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BookUpdateRequest(BaseModel):
|
|
46
|
+
title: Optional[str] = None
|
|
47
|
+
subtitle: Optional[str] = None
|
|
48
|
+
language: Optional[str] = None
|
|
49
|
+
publisher: Optional[str] = None
|
|
50
|
+
publication_date: Optional[str] = None
|
|
51
|
+
description: Optional[str] = None
|
|
52
|
+
rating: Optional[float] = None
|
|
53
|
+
favorite: Optional[bool] = None
|
|
54
|
+
reading_status: Optional[str] = None
|
|
55
|
+
tags: Optional[List[str]] = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class LibraryStats(BaseModel):
|
|
59
|
+
total_books: int
|
|
60
|
+
total_authors: int
|
|
61
|
+
total_subjects: int
|
|
62
|
+
total_files: int
|
|
63
|
+
total_size_mb: float
|
|
64
|
+
languages: List[str]
|
|
65
|
+
formats: List[str]
|
|
66
|
+
favorites_count: int = 0
|
|
67
|
+
reading_count: int = 0
|
|
68
|
+
completed_count: int = 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class PaginatedBooksResponse(BaseModel):
|
|
72
|
+
items: List[BookResponse]
|
|
73
|
+
total: int
|
|
74
|
+
offset: int
|
|
75
|
+
limit: int
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class FolderImportRequest(BaseModel):
|
|
79
|
+
folder_path: str
|
|
80
|
+
recursive: bool = True
|
|
81
|
+
extensions: str = "pdf,epub,mobi,azw3,txt"
|
|
82
|
+
limit: Optional[int] = None
|
|
83
|
+
extract_text: bool = True
|
|
84
|
+
extract_cover: bool = True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class CalibreImportRequest(BaseModel):
|
|
88
|
+
calibre_path: str
|
|
89
|
+
limit: Optional[int] = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ImportProgress(BaseModel):
|
|
93
|
+
total: int
|
|
94
|
+
imported: int
|
|
95
|
+
failed: int
|
|
96
|
+
current_file: Optional[str] = None
|
|
97
|
+
status: str # "running", "completed", "failed"
|
|
98
|
+
errors: List[str] = []
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class URLImportRequest(BaseModel):
|
|
102
|
+
url: str
|
|
103
|
+
extract_text: bool = True
|
|
104
|
+
extract_cover: bool = True
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class OPDSImportRequest(BaseModel):
|
|
108
|
+
opds_url: str
|
|
109
|
+
limit: Optional[int] = None
|
|
110
|
+
extract_text: bool = True
|
|
111
|
+
extract_cover: bool = True
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ISBNImportRequest(BaseModel):
|
|
115
|
+
isbn: str
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# View-related models
|
|
119
|
+
class ViewResponse(BaseModel):
|
|
120
|
+
name: str
|
|
121
|
+
description: Optional[str]
|
|
122
|
+
builtin: bool
|
|
123
|
+
count: Optional[int]
|
|
124
|
+
created_at: Optional[str] = None
|
|
125
|
+
updated_at: Optional[str] = None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class ViewDetailResponse(BaseModel):
|
|
129
|
+
name: str
|
|
130
|
+
description: Optional[str]
|
|
131
|
+
builtin: bool
|
|
132
|
+
definition: dict
|
|
133
|
+
count: Optional[int]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class ViewCreateRequest(BaseModel):
|
|
137
|
+
name: str
|
|
138
|
+
description: Optional[str] = None
|
|
139
|
+
definition: Optional[dict] = None
|
|
140
|
+
# Shorthand filters if definition not provided
|
|
141
|
+
subject: Optional[str] = None
|
|
142
|
+
author: Optional[str] = None
|
|
143
|
+
favorite: Optional[bool] = None
|
|
144
|
+
reading_status: Optional[str] = None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class ViewUpdateRequest(BaseModel):
|
|
148
|
+
description: Optional[str] = None
|
|
149
|
+
definition: Optional[dict] = None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class ViewOverrideRequest(BaseModel):
|
|
153
|
+
title: Optional[str] = None
|
|
154
|
+
description: Optional[str] = None
|
|
155
|
+
position: Optional[int] = None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# Global library instance
|
|
159
|
+
_library: Optional[Library] = None
|
|
160
|
+
_library_path: Optional[Path] = None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def get_library() -> Library:
|
|
164
|
+
"""Get the current library instance."""
|
|
165
|
+
if _library is None:
|
|
166
|
+
raise HTTPException(status_code=500, detail="Library not initialized")
|
|
167
|
+
return _library
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def init_library(library_path: Path):
|
|
171
|
+
"""Initialize the library."""
|
|
172
|
+
global _library, _library_path
|
|
173
|
+
_library_path = library_path
|
|
174
|
+
_library = Library.open(library_path)
|
|
175
|
+
# Initialize VFS for the library
|
|
176
|
+
vfs_router.init_vfs_from_library(_library)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def set_library(library: Library):
|
|
180
|
+
"""Set the library instance directly (for testing)."""
|
|
181
|
+
global _library, _library_path
|
|
182
|
+
_library = library
|
|
183
|
+
# Initialize VFS for the library
|
|
184
|
+
vfs_router.init_vfs_from_library(_library)
|
|
185
|
+
_library_path = library.library_path
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def create_app(library_path: Path) -> FastAPI:
|
|
189
|
+
"""Create FastAPI application with initialized library."""
|
|
190
|
+
# Initialize library
|
|
191
|
+
init_library(library_path)
|
|
192
|
+
|
|
193
|
+
# Initialize OPDS with the same library
|
|
194
|
+
opds.set_library(_library)
|
|
195
|
+
|
|
196
|
+
# Return the pre-configured app with all routes
|
|
197
|
+
return app
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# Create FastAPI app
|
|
201
|
+
app = FastAPI(
|
|
202
|
+
title="ebk Library Manager",
|
|
203
|
+
description="Web interface for managing ebook libraries",
|
|
204
|
+
version="1.0.0"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Enable CORS
|
|
208
|
+
app.add_middleware(
|
|
209
|
+
CORSMiddleware,
|
|
210
|
+
allow_origins=["*"],
|
|
211
|
+
allow_credentials=True,
|
|
212
|
+
allow_methods=["*"],
|
|
213
|
+
allow_headers=["*"],
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Include OPDS router
|
|
217
|
+
app.include_router(opds.router)
|
|
218
|
+
|
|
219
|
+
# Include VFS router
|
|
220
|
+
app.include_router(vfs_router.router)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@app.get("/", response_class=HTMLResponse)
|
|
224
|
+
async def root():
|
|
225
|
+
"""Serve the main web interface."""
|
|
226
|
+
return get_web_interface()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@app.get("/api/books", response_model=PaginatedBooksResponse)
|
|
230
|
+
async def list_books(
|
|
231
|
+
limit: int = Query(50, ge=1, le=1000),
|
|
232
|
+
offset: int = Query(0, ge=0),
|
|
233
|
+
search: Optional[str] = None,
|
|
234
|
+
author: Optional[str] = None,
|
|
235
|
+
subject: Optional[str] = None,
|
|
236
|
+
language: Optional[str] = None,
|
|
237
|
+
favorite: Optional[bool] = None,
|
|
238
|
+
reading_status: Optional[str] = None,
|
|
239
|
+
format_filter: Optional[str] = None,
|
|
240
|
+
sort_by: Optional[str] = Query(None, alias="sort"),
|
|
241
|
+
sort_order: Optional[str] = Query("asc", alias="order"),
|
|
242
|
+
min_rating: Optional[float] = Query(None, alias="rating")
|
|
243
|
+
):
|
|
244
|
+
"""List books with filtering, sorting, and pagination."""
|
|
245
|
+
lib = get_library()
|
|
246
|
+
|
|
247
|
+
query = lib.query()
|
|
248
|
+
|
|
249
|
+
# Apply filters BEFORE pagination
|
|
250
|
+
if author:
|
|
251
|
+
query = query.filter_by_author(author)
|
|
252
|
+
if subject:
|
|
253
|
+
query = query.filter_by_subject(subject)
|
|
254
|
+
if language:
|
|
255
|
+
query = query.filter_by_language(language)
|
|
256
|
+
if favorite is not None:
|
|
257
|
+
query = query.filter_by_favorite(favorite)
|
|
258
|
+
if reading_status:
|
|
259
|
+
query = query.filter_by_reading_status(reading_status)
|
|
260
|
+
if format_filter:
|
|
261
|
+
query = query.filter_by_format(format_filter)
|
|
262
|
+
if min_rating is not None:
|
|
263
|
+
query = query.filter_by_rating(int(min_rating))
|
|
264
|
+
if search:
|
|
265
|
+
query = query.filter_by_text(search)
|
|
266
|
+
|
|
267
|
+
# Get total count BEFORE pagination
|
|
268
|
+
total = query.count()
|
|
269
|
+
|
|
270
|
+
# Apply sorting before pagination
|
|
271
|
+
if sort_by:
|
|
272
|
+
desc = (sort_order == "desc")
|
|
273
|
+
query = query.order_by(sort_by, desc=desc)
|
|
274
|
+
else:
|
|
275
|
+
# Default sort by title
|
|
276
|
+
query = query.order_by("title", desc=False)
|
|
277
|
+
|
|
278
|
+
# Apply pagination AFTER all filters and sorting
|
|
279
|
+
query = query.limit(limit).offset(offset)
|
|
280
|
+
books = query.all()
|
|
281
|
+
|
|
282
|
+
# Convert to paginated response format
|
|
283
|
+
return PaginatedBooksResponse(
|
|
284
|
+
items=[_book_to_response(book) for book in books],
|
|
285
|
+
total=total,
|
|
286
|
+
offset=offset,
|
|
287
|
+
limit=limit
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@app.get("/api/books/{book_id}", response_model=BookResponse)
|
|
292
|
+
async def get_book(book_id: int):
|
|
293
|
+
"""Get a specific book by ID."""
|
|
294
|
+
lib = get_library()
|
|
295
|
+
book = lib.get_book(book_id)
|
|
296
|
+
|
|
297
|
+
if not book:
|
|
298
|
+
raise HTTPException(status_code=404, detail="Book not found")
|
|
299
|
+
|
|
300
|
+
return _book_to_response(book)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@app.patch("/api/books/{book_id}")
|
|
304
|
+
async def update_book(book_id: int, update: BookUpdateRequest):
|
|
305
|
+
"""Update book metadata."""
|
|
306
|
+
lib = get_library()
|
|
307
|
+
book = lib.get_book(book_id)
|
|
308
|
+
|
|
309
|
+
if not book:
|
|
310
|
+
raise HTTPException(status_code=404, detail="Book not found")
|
|
311
|
+
|
|
312
|
+
# Update fields
|
|
313
|
+
if update.title is not None:
|
|
314
|
+
book.title = update.title
|
|
315
|
+
if update.subtitle is not None:
|
|
316
|
+
book.subtitle = update.subtitle
|
|
317
|
+
if update.language is not None:
|
|
318
|
+
book.language = update.language
|
|
319
|
+
if update.publisher is not None:
|
|
320
|
+
book.publisher = update.publisher
|
|
321
|
+
if update.publication_date is not None:
|
|
322
|
+
book.publication_date = update.publication_date
|
|
323
|
+
if update.description is not None:
|
|
324
|
+
book.description = update.description
|
|
325
|
+
|
|
326
|
+
# Update personal metadata
|
|
327
|
+
# Handle reading status and rating together to avoid multiple calls
|
|
328
|
+
if update.reading_status is not None or update.rating is not None:
|
|
329
|
+
current_status = book.personal.reading_status if book.personal else 'unread'
|
|
330
|
+
new_status = update.reading_status if update.reading_status is not None else current_status
|
|
331
|
+
lib.update_reading_status(book_id, status=new_status, rating=update.rating)
|
|
332
|
+
|
|
333
|
+
if update.favorite is not None:
|
|
334
|
+
lib.set_favorite(book_id, update.favorite)
|
|
335
|
+
|
|
336
|
+
if update.tags is not None:
|
|
337
|
+
# Clear existing tags and add new ones
|
|
338
|
+
if book.personal and book.personal.personal_tags:
|
|
339
|
+
lib.remove_tags(book_id, book.personal.personal_tags)
|
|
340
|
+
if update.tags:
|
|
341
|
+
lib.add_tags(book_id, update.tags)
|
|
342
|
+
|
|
343
|
+
lib.session.commit()
|
|
344
|
+
|
|
345
|
+
# Refresh and return
|
|
346
|
+
lib.session.refresh(book)
|
|
347
|
+
return _book_to_response(book)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@app.delete("/api/books/{book_id}")
|
|
351
|
+
async def delete_book(book_id: int, delete_files: bool = Query(False)):
|
|
352
|
+
"""Delete a book from the library."""
|
|
353
|
+
lib = get_library()
|
|
354
|
+
book = lib.get_book(book_id)
|
|
355
|
+
|
|
356
|
+
if not book:
|
|
357
|
+
raise HTTPException(status_code=404, detail="Book not found")
|
|
358
|
+
|
|
359
|
+
# Delete files if requested
|
|
360
|
+
if delete_files and _library_path:
|
|
361
|
+
for file in book.files:
|
|
362
|
+
file_path = _library_path / file.path
|
|
363
|
+
if file_path.exists():
|
|
364
|
+
file_path.unlink()
|
|
365
|
+
|
|
366
|
+
# Delete from database
|
|
367
|
+
lib.session.delete(book)
|
|
368
|
+
lib.session.commit()
|
|
369
|
+
|
|
370
|
+
return {"message": "Book deleted successfully"}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@app.post("/api/books/import")
|
|
374
|
+
async def import_book(
|
|
375
|
+
file: UploadFile = File(...),
|
|
376
|
+
extract_text: bool = Form(True),
|
|
377
|
+
extract_cover: bool = Form(True)
|
|
378
|
+
):
|
|
379
|
+
"""Import a new book file."""
|
|
380
|
+
lib = get_library()
|
|
381
|
+
|
|
382
|
+
# Save uploaded file to temp location
|
|
383
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=Path(file.filename).suffix) as tmp:
|
|
384
|
+
shutil.copyfileobj(file.file, tmp)
|
|
385
|
+
tmp_path = Path(tmp.name)
|
|
386
|
+
|
|
387
|
+
try:
|
|
388
|
+
# Extract metadata
|
|
389
|
+
metadata = extract_metadata(str(tmp_path))
|
|
390
|
+
|
|
391
|
+
# Import to library
|
|
392
|
+
book = lib.add_book(
|
|
393
|
+
tmp_path,
|
|
394
|
+
metadata=metadata,
|
|
395
|
+
extract_text=extract_text,
|
|
396
|
+
extract_cover=extract_cover
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
if not book:
|
|
400
|
+
raise HTTPException(status_code=400, detail="Failed to import book")
|
|
401
|
+
|
|
402
|
+
return _book_to_response(book)
|
|
403
|
+
|
|
404
|
+
finally:
|
|
405
|
+
# Clean up temp file
|
|
406
|
+
tmp_path.unlink()
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@app.post("/api/books/import/folder")
|
|
410
|
+
async def import_folder(request: FolderImportRequest):
|
|
411
|
+
"""Import books from a folder."""
|
|
412
|
+
lib = get_library()
|
|
413
|
+
folder_path = Path(request.folder_path)
|
|
414
|
+
|
|
415
|
+
if not folder_path.exists():
|
|
416
|
+
raise HTTPException(status_code=400, detail=f"Folder not found: {folder_path}")
|
|
417
|
+
|
|
418
|
+
if not folder_path.is_dir():
|
|
419
|
+
raise HTTPException(status_code=400, detail=f"Not a directory: {folder_path}")
|
|
420
|
+
|
|
421
|
+
# Parse extensions
|
|
422
|
+
extensions = [ext.strip().lower() for ext in request.extensions.split(",")]
|
|
423
|
+
|
|
424
|
+
# Find all matching files
|
|
425
|
+
files = []
|
|
426
|
+
if request.recursive:
|
|
427
|
+
for ext in extensions:
|
|
428
|
+
files.extend(folder_path.rglob(f"*.{ext}"))
|
|
429
|
+
else:
|
|
430
|
+
for ext in extensions:
|
|
431
|
+
files.extend(folder_path.glob(f"*.{ext}"))
|
|
432
|
+
|
|
433
|
+
# Apply limit if specified
|
|
434
|
+
if request.limit:
|
|
435
|
+
files = files[:request.limit]
|
|
436
|
+
|
|
437
|
+
# Import books
|
|
438
|
+
results = {
|
|
439
|
+
"total": len(files),
|
|
440
|
+
"imported": 0,
|
|
441
|
+
"failed": 0,
|
|
442
|
+
"errors": [],
|
|
443
|
+
"books": []
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
for file_path in files:
|
|
447
|
+
try:
|
|
448
|
+
metadata = extract_metadata(str(file_path))
|
|
449
|
+
book = lib.add_book(
|
|
450
|
+
file_path,
|
|
451
|
+
metadata=metadata,
|
|
452
|
+
extract_text=request.extract_text,
|
|
453
|
+
extract_cover=request.extract_cover
|
|
454
|
+
)
|
|
455
|
+
if book:
|
|
456
|
+
results["imported"] += 1
|
|
457
|
+
results["books"].append(_book_to_response(book))
|
|
458
|
+
else:
|
|
459
|
+
results["failed"] += 1
|
|
460
|
+
results["errors"].append(f"Failed to import: {file_path.name}")
|
|
461
|
+
except Exception as e:
|
|
462
|
+
results["failed"] += 1
|
|
463
|
+
results["errors"].append(f"{file_path.name}: {str(e)}")
|
|
464
|
+
|
|
465
|
+
return results
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
@app.post("/api/books/import/calibre")
|
|
469
|
+
async def import_calibre(request: CalibreImportRequest):
|
|
470
|
+
"""Import books from a Calibre library."""
|
|
471
|
+
from .calibre_import import import_calibre_library
|
|
472
|
+
|
|
473
|
+
lib = get_library()
|
|
474
|
+
calibre_path = Path(request.calibre_path)
|
|
475
|
+
|
|
476
|
+
if not calibre_path.exists():
|
|
477
|
+
raise HTTPException(status_code=400, detail=f"Calibre library not found: {calibre_path}")
|
|
478
|
+
|
|
479
|
+
# Check for Calibre metadata database
|
|
480
|
+
metadata_db = calibre_path / "metadata.db"
|
|
481
|
+
if not metadata_db.exists():
|
|
482
|
+
raise HTTPException(status_code=400, detail="Not a valid Calibre library (metadata.db not found)")
|
|
483
|
+
|
|
484
|
+
try:
|
|
485
|
+
results = import_calibre_library(
|
|
486
|
+
calibre_path,
|
|
487
|
+
lib,
|
|
488
|
+
limit=request.limit
|
|
489
|
+
)
|
|
490
|
+
return {
|
|
491
|
+
"total": results.get("total", 0),
|
|
492
|
+
"imported": results.get("imported", 0),
|
|
493
|
+
"failed": results.get("failed", 0),
|
|
494
|
+
"errors": results.get("errors", [])
|
|
495
|
+
}
|
|
496
|
+
except Exception as e:
|
|
497
|
+
raise HTTPException(status_code=500, detail=f"Calibre import failed: {str(e)}")
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@app.post("/api/books/import/url")
|
|
501
|
+
async def import_from_url(request: URLImportRequest):
|
|
502
|
+
"""Import an ebook from a URL."""
|
|
503
|
+
import httpx
|
|
504
|
+
import re
|
|
505
|
+
|
|
506
|
+
lib = get_library()
|
|
507
|
+
url = request.url.strip()
|
|
508
|
+
|
|
509
|
+
# Validate URL
|
|
510
|
+
if not url.startswith(('http://', 'https://')):
|
|
511
|
+
raise HTTPException(status_code=400, detail="Invalid URL. Must start with http:// or https://")
|
|
512
|
+
|
|
513
|
+
# Supported extensions
|
|
514
|
+
supported_extensions = {'.pdf', '.epub', '.mobi', '.azw', '.azw3', '.txt'}
|
|
515
|
+
|
|
516
|
+
try:
|
|
517
|
+
# Download the file
|
|
518
|
+
async with httpx.AsyncClient(follow_redirects=True, timeout=60.0) as client:
|
|
519
|
+
response = await client.get(url)
|
|
520
|
+
response.raise_for_status()
|
|
521
|
+
|
|
522
|
+
# Try to determine filename from Content-Disposition header or URL
|
|
523
|
+
filename = None
|
|
524
|
+
content_disposition = response.headers.get('content-disposition', '')
|
|
525
|
+
if 'filename=' in content_disposition:
|
|
526
|
+
match = re.search(r'filename[*]?=["\']?([^"\';]+)', content_disposition)
|
|
527
|
+
if match:
|
|
528
|
+
filename = match.group(1)
|
|
529
|
+
|
|
530
|
+
if not filename:
|
|
531
|
+
# Extract from URL path
|
|
532
|
+
from urllib.parse import urlparse, unquote
|
|
533
|
+
parsed = urlparse(url)
|
|
534
|
+
filename = unquote(parsed.path.split('/')[-1])
|
|
535
|
+
|
|
536
|
+
if not filename:
|
|
537
|
+
filename = 'downloaded_book'
|
|
538
|
+
|
|
539
|
+
# Check extension
|
|
540
|
+
ext = Path(filename).suffix.lower()
|
|
541
|
+
if ext not in supported_extensions:
|
|
542
|
+
# Try to guess from content-type
|
|
543
|
+
content_type = response.headers.get('content-type', '')
|
|
544
|
+
if 'pdf' in content_type:
|
|
545
|
+
ext = '.pdf'
|
|
546
|
+
elif 'epub' in content_type:
|
|
547
|
+
ext = '.epub'
|
|
548
|
+
else:
|
|
549
|
+
raise HTTPException(
|
|
550
|
+
status_code=400,
|
|
551
|
+
detail=f"Unsupported file type. Supported: {', '.join(supported_extensions)}"
|
|
552
|
+
)
|
|
553
|
+
filename = Path(filename).stem + ext
|
|
554
|
+
|
|
555
|
+
# Save to temp file
|
|
556
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
|
|
557
|
+
tmp.write(response.content)
|
|
558
|
+
tmp_path = Path(tmp.name)
|
|
559
|
+
|
|
560
|
+
# Extract metadata and import
|
|
561
|
+
metadata = extract_metadata(str(tmp_path))
|
|
562
|
+
book = lib.add_book(
|
|
563
|
+
tmp_path,
|
|
564
|
+
metadata=metadata,
|
|
565
|
+
extract_text=request.extract_text,
|
|
566
|
+
extract_cover=request.extract_cover
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
# Clean up temp file
|
|
570
|
+
tmp_path.unlink()
|
|
571
|
+
|
|
572
|
+
if not book:
|
|
573
|
+
raise HTTPException(status_code=400, detail="Failed to import book from URL")
|
|
574
|
+
|
|
575
|
+
return _book_to_response(book)
|
|
576
|
+
|
|
577
|
+
except httpx.HTTPError as e:
|
|
578
|
+
raise HTTPException(status_code=400, detail=f"Failed to download file: {str(e)}")
|
|
579
|
+
except Exception as e:
|
|
580
|
+
raise HTTPException(status_code=500, detail=f"Import failed: {str(e)}")
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
@app.post("/api/books/import/opds")
|
|
584
|
+
async def import_from_opds(request: OPDSImportRequest):
|
|
585
|
+
"""Import books from an OPDS catalog feed."""
|
|
586
|
+
import httpx
|
|
587
|
+
import xml.etree.ElementTree as ET
|
|
588
|
+
|
|
589
|
+
lib = get_library()
|
|
590
|
+
opds_url = request.opds_url.strip()
|
|
591
|
+
|
|
592
|
+
if not opds_url.startswith(('http://', 'https://')):
|
|
593
|
+
raise HTTPException(status_code=400, detail="Invalid URL. Must start with http:// or https://")
|
|
594
|
+
|
|
595
|
+
results = {
|
|
596
|
+
"total": 0,
|
|
597
|
+
"imported": 0,
|
|
598
|
+
"failed": 0,
|
|
599
|
+
"errors": [],
|
|
600
|
+
"books": []
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
try:
|
|
604
|
+
async with httpx.AsyncClient(follow_redirects=True, timeout=30.0) as client:
|
|
605
|
+
# Fetch OPDS feed
|
|
606
|
+
response = await client.get(opds_url)
|
|
607
|
+
response.raise_for_status()
|
|
608
|
+
|
|
609
|
+
# Parse OPDS (Atom) feed
|
|
610
|
+
root = ET.fromstring(response.content)
|
|
611
|
+
|
|
612
|
+
# OPDS uses Atom namespace
|
|
613
|
+
ns = {
|
|
614
|
+
'atom': 'http://www.w3.org/2005/Atom',
|
|
615
|
+
'opds': 'http://opds-spec.org/2010/catalog',
|
|
616
|
+
'dc': 'http://purl.org/dc/elements/1.1/'
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
entries = root.findall('.//atom:entry', ns)
|
|
620
|
+
if not entries:
|
|
621
|
+
# Try without namespace (some feeds don't use it properly)
|
|
622
|
+
entries = root.findall('.//entry')
|
|
623
|
+
|
|
624
|
+
if request.limit:
|
|
625
|
+
entries = entries[:request.limit]
|
|
626
|
+
|
|
627
|
+
results["total"] = len(entries)
|
|
628
|
+
|
|
629
|
+
for entry in entries:
|
|
630
|
+
try:
|
|
631
|
+
# Find acquisition link (the actual book file)
|
|
632
|
+
acquisition_link = None
|
|
633
|
+
for link in entry.findall('atom:link', ns) or entry.findall('link'):
|
|
634
|
+
rel = link.get('rel', '')
|
|
635
|
+
href = link.get('href', '')
|
|
636
|
+
link_type = link.get('type', '')
|
|
637
|
+
|
|
638
|
+
# Look for acquisition links
|
|
639
|
+
if 'acquisition' in rel and href:
|
|
640
|
+
# Prefer epub, then pdf
|
|
641
|
+
if 'epub' in link_type:
|
|
642
|
+
acquisition_link = href
|
|
643
|
+
break
|
|
644
|
+
elif 'pdf' in link_type and not acquisition_link:
|
|
645
|
+
acquisition_link = href
|
|
646
|
+
|
|
647
|
+
if not acquisition_link:
|
|
648
|
+
results["failed"] += 1
|
|
649
|
+
title_el = entry.find('atom:title', ns) or entry.find('title')
|
|
650
|
+
title = title_el.text if title_el is not None else 'Unknown'
|
|
651
|
+
results["errors"].append(f"No download link found for: {title}")
|
|
652
|
+
continue
|
|
653
|
+
|
|
654
|
+
# Make URL absolute if needed
|
|
655
|
+
if not acquisition_link.startswith(('http://', 'https://')):
|
|
656
|
+
from urllib.parse import urljoin
|
|
657
|
+
acquisition_link = urljoin(opds_url, acquisition_link)
|
|
658
|
+
|
|
659
|
+
# Download and import
|
|
660
|
+
file_response = await client.get(acquisition_link, timeout=60.0)
|
|
661
|
+
file_response.raise_for_status()
|
|
662
|
+
|
|
663
|
+
# Determine extension
|
|
664
|
+
content_type = file_response.headers.get('content-type', '')
|
|
665
|
+
if 'epub' in content_type:
|
|
666
|
+
ext = '.epub'
|
|
667
|
+
elif 'pdf' in content_type:
|
|
668
|
+
ext = '.pdf'
|
|
669
|
+
elif 'mobi' in content_type:
|
|
670
|
+
ext = '.mobi'
|
|
671
|
+
else:
|
|
672
|
+
ext = '.epub' # Default guess
|
|
673
|
+
|
|
674
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
|
|
675
|
+
tmp.write(file_response.content)
|
|
676
|
+
tmp_path = Path(tmp.name)
|
|
677
|
+
|
|
678
|
+
metadata = extract_metadata(str(tmp_path))
|
|
679
|
+
book = lib.add_book(
|
|
680
|
+
tmp_path,
|
|
681
|
+
metadata=metadata,
|
|
682
|
+
extract_text=request.extract_text,
|
|
683
|
+
extract_cover=request.extract_cover
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
tmp_path.unlink()
|
|
687
|
+
|
|
688
|
+
if book:
|
|
689
|
+
results["imported"] += 1
|
|
690
|
+
results["books"].append(_book_to_response(book))
|
|
691
|
+
else:
|
|
692
|
+
results["failed"] += 1
|
|
693
|
+
|
|
694
|
+
except Exception as e:
|
|
695
|
+
results["failed"] += 1
|
|
696
|
+
results["errors"].append(str(e))
|
|
697
|
+
|
|
698
|
+
return results
|
|
699
|
+
|
|
700
|
+
except httpx.HTTPError as e:
|
|
701
|
+
raise HTTPException(status_code=400, detail=f"Failed to fetch OPDS feed: {str(e)}")
|
|
702
|
+
except ET.ParseError as e:
|
|
703
|
+
raise HTTPException(status_code=400, detail=f"Invalid OPDS feed format: {str(e)}")
|
|
704
|
+
except Exception as e:
|
|
705
|
+
raise HTTPException(status_code=500, detail=f"OPDS import failed: {str(e)}")
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
@app.post("/api/books/import/isbn")
|
|
709
|
+
async def import_from_isbn(request: ISBNImportRequest):
|
|
710
|
+
"""Create a book entry from ISBN lookup (metadata only, no file)."""
|
|
711
|
+
import httpx
|
|
712
|
+
import re
|
|
713
|
+
|
|
714
|
+
lib = get_library()
|
|
715
|
+
isbn = re.sub(r'[^0-9X]', '', request.isbn.upper())
|
|
716
|
+
|
|
717
|
+
if len(isbn) not in (10, 13):
|
|
718
|
+
raise HTTPException(status_code=400, detail="Invalid ISBN. Must be 10 or 13 digits.")
|
|
719
|
+
|
|
720
|
+
try:
|
|
721
|
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
722
|
+
# Try Google Books API first
|
|
723
|
+
google_url = f"https://www.googleapis.com/books/v1/volumes?q=isbn:{isbn}"
|
|
724
|
+
response = await client.get(google_url)
|
|
725
|
+
|
|
726
|
+
metadata = None
|
|
727
|
+
|
|
728
|
+
if response.status_code == 200:
|
|
729
|
+
data = response.json()
|
|
730
|
+
if data.get('totalItems', 0) > 0:
|
|
731
|
+
volume = data['items'][0]['volumeInfo']
|
|
732
|
+
metadata = {
|
|
733
|
+
'title': volume.get('title', 'Unknown'),
|
|
734
|
+
'subtitle': volume.get('subtitle'),
|
|
735
|
+
'authors': volume.get('authors', []),
|
|
736
|
+
'publisher': volume.get('publisher'),
|
|
737
|
+
'publication_date': volume.get('publishedDate'),
|
|
738
|
+
'description': volume.get('description'),
|
|
739
|
+
'page_count': volume.get('pageCount'),
|
|
740
|
+
'language': volume.get('language'),
|
|
741
|
+
'subjects': volume.get('categories', []),
|
|
742
|
+
'identifiers': [{'scheme': 'isbn', 'value': isbn}],
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
# Fallback to Open Library if Google didn't find it
|
|
746
|
+
if not metadata:
|
|
747
|
+
ol_url = f"https://openlibrary.org/api/books?bibkeys=ISBN:{isbn}&format=json&jscmd=data"
|
|
748
|
+
response = await client.get(ol_url)
|
|
749
|
+
|
|
750
|
+
if response.status_code == 200:
|
|
751
|
+
data = response.json()
|
|
752
|
+
key = f"ISBN:{isbn}"
|
|
753
|
+
if key in data:
|
|
754
|
+
book_data = data[key]
|
|
755
|
+
metadata = {
|
|
756
|
+
'title': book_data.get('title', 'Unknown'),
|
|
757
|
+
'subtitle': book_data.get('subtitle'),
|
|
758
|
+
'authors': [a.get('name') for a in book_data.get('authors', [])],
|
|
759
|
+
'publisher': book_data.get('publishers', [{}])[0].get('name') if book_data.get('publishers') else None,
|
|
760
|
+
'publication_date': book_data.get('publish_date'),
|
|
761
|
+
'page_count': book_data.get('number_of_pages'),
|
|
762
|
+
'subjects': [s.get('name') for s in book_data.get('subjects', [])],
|
|
763
|
+
'identifiers': [{'scheme': 'isbn', 'value': isbn}],
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if not metadata:
|
|
767
|
+
raise HTTPException(status_code=404, detail=f"No book found for ISBN: {isbn}")
|
|
768
|
+
|
|
769
|
+
# Create book entry without a file
|
|
770
|
+
from .db.models import Book, Author, Subject, Identifier
|
|
771
|
+
from .services.import_service import get_sort_name
|
|
772
|
+
import hashlib
|
|
773
|
+
|
|
774
|
+
# Generate unique_id based on ISBN
|
|
775
|
+
unique_id = hashlib.md5(f"isbn:{isbn}".encode()).hexdigest()
|
|
776
|
+
|
|
777
|
+
book = Book(
|
|
778
|
+
unique_id=unique_id,
|
|
779
|
+
title=metadata['title'],
|
|
780
|
+
subtitle=metadata.get('subtitle'),
|
|
781
|
+
publisher=metadata.get('publisher'),
|
|
782
|
+
publication_date=metadata.get('publication_date'),
|
|
783
|
+
description=metadata.get('description'),
|
|
784
|
+
page_count=metadata.get('page_count'),
|
|
785
|
+
language=metadata.get('language'),
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
# Add authors
|
|
789
|
+
for author_name in metadata.get('authors', []):
|
|
790
|
+
if author_name:
|
|
791
|
+
author = lib.session.query(Author).filter_by(name=author_name).first()
|
|
792
|
+
if not author:
|
|
793
|
+
author = Author(name=author_name, sort_name=get_sort_name(author_name))
|
|
794
|
+
lib.session.add(author)
|
|
795
|
+
book.authors.append(author)
|
|
796
|
+
|
|
797
|
+
# Add subjects
|
|
798
|
+
for subject_name in metadata.get('subjects', []):
|
|
799
|
+
if subject_name:
|
|
800
|
+
subject = lib.session.query(Subject).filter_by(name=subject_name).first()
|
|
801
|
+
if not subject:
|
|
802
|
+
subject = Subject(name=subject_name)
|
|
803
|
+
lib.session.add(subject)
|
|
804
|
+
book.subjects.append(subject)
|
|
805
|
+
|
|
806
|
+
# Add identifiers
|
|
807
|
+
for ident in metadata.get('identifiers', []):
|
|
808
|
+
identifier = Identifier(scheme=ident['scheme'], value=ident['value'])
|
|
809
|
+
book.identifiers.append(identifier)
|
|
810
|
+
|
|
811
|
+
lib.session.add(book)
|
|
812
|
+
lib.session.commit()
|
|
813
|
+
|
|
814
|
+
return _book_to_response(book)
|
|
815
|
+
|
|
816
|
+
except httpx.HTTPError as e:
|
|
817
|
+
raise HTTPException(status_code=400, detail=f"Failed to lookup ISBN: {str(e)}")
|
|
818
|
+
except Exception as e:
|
|
819
|
+
if isinstance(e, HTTPException):
|
|
820
|
+
raise
|
|
821
|
+
raise HTTPException(status_code=500, detail=f"ISBN import failed: {str(e)}")
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
@app.get("/api/books/{book_id}/files/{file_format}")
|
|
825
|
+
async def download_file(book_id: int, file_format: str):
|
|
826
|
+
"""Download a book file."""
|
|
827
|
+
lib = get_library()
|
|
828
|
+
book = lib.get_book(book_id)
|
|
829
|
+
|
|
830
|
+
if not book:
|
|
831
|
+
raise HTTPException(status_code=404, detail="Book not found")
|
|
832
|
+
|
|
833
|
+
# Find file with matching format
|
|
834
|
+
book_file = next((f for f in book.files if f.format.lower() == file_format.lower()), None)
|
|
835
|
+
|
|
836
|
+
if not book_file or not _library_path:
|
|
837
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
838
|
+
|
|
839
|
+
file_path = _library_path / book_file.path
|
|
840
|
+
|
|
841
|
+
if not file_path.exists():
|
|
842
|
+
raise HTTPException(status_code=404, detail="File not found on disk")
|
|
843
|
+
|
|
844
|
+
return FileResponse(
|
|
845
|
+
file_path,
|
|
846
|
+
media_type="application/octet-stream",
|
|
847
|
+
filename=f"{book.title}.{file_format}"
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
@app.get("/api/books/{book_id}/cover")
|
|
852
|
+
async def get_cover(book_id: int):
|
|
853
|
+
"""Get the cover image for a book."""
|
|
854
|
+
lib = get_library()
|
|
855
|
+
book = lib.get_book(book_id)
|
|
856
|
+
|
|
857
|
+
if not book:
|
|
858
|
+
raise HTTPException(status_code=404, detail="Book not found")
|
|
859
|
+
|
|
860
|
+
# Find primary cover
|
|
861
|
+
if not book.covers:
|
|
862
|
+
raise HTTPException(status_code=404, detail="No cover available")
|
|
863
|
+
|
|
864
|
+
primary_cover = next((c for c in book.covers if c.is_primary), book.covers[0])
|
|
865
|
+
|
|
866
|
+
if not _library_path:
|
|
867
|
+
raise HTTPException(status_code=500, detail="Library path not initialized")
|
|
868
|
+
|
|
869
|
+
cover_path = _library_path / primary_cover.path
|
|
870
|
+
|
|
871
|
+
if not cover_path.exists():
|
|
872
|
+
raise HTTPException(status_code=404, detail="Cover file not found on disk")
|
|
873
|
+
|
|
874
|
+
return FileResponse(
|
|
875
|
+
cover_path,
|
|
876
|
+
media_type="image/png",
|
|
877
|
+
filename=f"cover_{book_id}.png"
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
@app.get("/api/stats", response_model=LibraryStats)
|
|
882
|
+
async def get_stats():
|
|
883
|
+
"""Get library statistics."""
|
|
884
|
+
lib = get_library()
|
|
885
|
+
stats = lib.stats()
|
|
886
|
+
|
|
887
|
+
# Calculate total size from all files
|
|
888
|
+
from .db.models import File, PersonalMetadata
|
|
889
|
+
from sqlalchemy import func
|
|
890
|
+
total_size = lib.session.query(func.sum(File.size_bytes)).scalar() or 0
|
|
891
|
+
|
|
892
|
+
# Get favorites count
|
|
893
|
+
favorites_count = lib.session.query(func.count(PersonalMetadata.id)).filter(
|
|
894
|
+
PersonalMetadata.favorite == True
|
|
895
|
+
).scalar() or 0
|
|
896
|
+
|
|
897
|
+
# Convert language/format dicts to lists
|
|
898
|
+
languages = list(stats['languages'].keys()) if isinstance(stats['languages'], dict) else stats['languages']
|
|
899
|
+
formats = list(stats['formats'].keys()) if isinstance(stats['formats'], dict) else stats['formats']
|
|
900
|
+
|
|
901
|
+
return LibraryStats(
|
|
902
|
+
total_books=stats['total_books'],
|
|
903
|
+
total_authors=stats['total_authors'],
|
|
904
|
+
total_subjects=stats['total_subjects'],
|
|
905
|
+
total_files=stats['total_files'],
|
|
906
|
+
total_size_mb=total_size / (1024 ** 2),
|
|
907
|
+
languages=languages,
|
|
908
|
+
formats=formats,
|
|
909
|
+
favorites_count=favorites_count,
|
|
910
|
+
reading_count=stats.get('reading_count', 0),
|
|
911
|
+
completed_count=stats.get('read_count', 0)
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
@app.get("/api/search")
|
|
916
|
+
async def search_books(q: str, limit: int = Query(50, ge=1, le=1000)):
|
|
917
|
+
"""Full-text search across books."""
|
|
918
|
+
lib = get_library()
|
|
919
|
+
results = lib.search(q, limit=limit)
|
|
920
|
+
return [_book_to_response(book) for book in results]
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def _book_to_response(book) -> dict:
|
|
924
|
+
"""Convert Book ORM object to API response."""
|
|
925
|
+
# Get primary cover if available
|
|
926
|
+
cover_path = None
|
|
927
|
+
if book.covers:
|
|
928
|
+
primary_cover = next((c for c in book.covers if c.is_primary), book.covers[0])
|
|
929
|
+
cover_path = primary_cover.path
|
|
930
|
+
|
|
931
|
+
return {
|
|
932
|
+
"id": book.id,
|
|
933
|
+
"title": book.title,
|
|
934
|
+
"subtitle": book.subtitle,
|
|
935
|
+
"authors": [a.name for a in book.authors],
|
|
936
|
+
"language": book.language,
|
|
937
|
+
"publisher": book.publisher,
|
|
938
|
+
"publication_date": book.publication_date,
|
|
939
|
+
"series": book.series,
|
|
940
|
+
"series_index": book.series_index,
|
|
941
|
+
"description": book.description,
|
|
942
|
+
"subjects": [s.name for s in book.subjects],
|
|
943
|
+
"files": [
|
|
944
|
+
{
|
|
945
|
+
"format": f.format,
|
|
946
|
+
"size_bytes": f.size_bytes,
|
|
947
|
+
"path": f.path
|
|
948
|
+
}
|
|
949
|
+
for f in book.files
|
|
950
|
+
],
|
|
951
|
+
"rating": book.personal.rating if book.personal else None,
|
|
952
|
+
"favorite": book.personal.favorite if book.personal else False,
|
|
953
|
+
"reading_status": book.personal.reading_status if book.personal else "unread",
|
|
954
|
+
"tags": book.personal.personal_tags if (book.personal and book.personal.personal_tags) else [],
|
|
955
|
+
"cover_path": cover_path
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
# =========================================================================
|
|
960
|
+
# Views API Endpoints
|
|
961
|
+
# =========================================================================
|
|
962
|
+
|
|
963
|
+
@app.get("/api/views", response_model=List[ViewResponse])
|
|
964
|
+
async def list_views():
|
|
965
|
+
"""List all views (builtin and user-defined)."""
|
|
966
|
+
from .views import ViewService
|
|
967
|
+
lib = get_library()
|
|
968
|
+
svc = ViewService(lib.session)
|
|
969
|
+
views = svc.list(include_builtin=True)
|
|
970
|
+
|
|
971
|
+
return [
|
|
972
|
+
ViewResponse(
|
|
973
|
+
name=v['name'],
|
|
974
|
+
description=v.get('description'),
|
|
975
|
+
builtin=v.get('builtin', False),
|
|
976
|
+
count=v.get('count'),
|
|
977
|
+
created_at=v.get('created_at').isoformat() if v.get('created_at') else None,
|
|
978
|
+
updated_at=v.get('updated_at').isoformat() if v.get('updated_at') else None,
|
|
979
|
+
)
|
|
980
|
+
for v in views
|
|
981
|
+
]
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
@app.get("/api/views/{view_name}", response_model=ViewDetailResponse)
|
|
985
|
+
async def get_view(view_name: str):
|
|
986
|
+
"""Get view details including definition."""
|
|
987
|
+
from .views import ViewService
|
|
988
|
+
from .views.dsl import is_builtin_view, get_builtin_view
|
|
989
|
+
lib = get_library()
|
|
990
|
+
svc = ViewService(lib.session)
|
|
991
|
+
|
|
992
|
+
if is_builtin_view(view_name):
|
|
993
|
+
defn = get_builtin_view(view_name)
|
|
994
|
+
count = svc.count(view_name)
|
|
995
|
+
return ViewDetailResponse(
|
|
996
|
+
name=view_name,
|
|
997
|
+
description=defn.get('description'),
|
|
998
|
+
builtin=True,
|
|
999
|
+
definition=defn,
|
|
1000
|
+
count=count
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
view = svc.get(view_name)
|
|
1004
|
+
if not view:
|
|
1005
|
+
raise HTTPException(status_code=404, detail=f"View '{view_name}' not found")
|
|
1006
|
+
|
|
1007
|
+
return ViewDetailResponse(
|
|
1008
|
+
name=view.name,
|
|
1009
|
+
description=view.description,
|
|
1010
|
+
builtin=False,
|
|
1011
|
+
definition=view.definition,
|
|
1012
|
+
count=view.cached_count or svc.count(view_name)
|
|
1013
|
+
)
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
@app.get("/api/views/{view_name}/books", response_model=PaginatedBooksResponse)
|
|
1017
|
+
async def get_view_books(
|
|
1018
|
+
view_name: str,
|
|
1019
|
+
limit: int = Query(50, ge=1, le=1000),
|
|
1020
|
+
offset: int = Query(0, ge=0)
|
|
1021
|
+
):
|
|
1022
|
+
"""Get books in a view with pagination."""
|
|
1023
|
+
from .views import ViewService
|
|
1024
|
+
lib = get_library()
|
|
1025
|
+
svc = ViewService(lib.session)
|
|
1026
|
+
|
|
1027
|
+
try:
|
|
1028
|
+
transformed = svc.evaluate(view_name)
|
|
1029
|
+
except ValueError as e:
|
|
1030
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
1031
|
+
|
|
1032
|
+
total = len(transformed)
|
|
1033
|
+
page = transformed[offset:offset + limit]
|
|
1034
|
+
books = [tb.book for tb in page]
|
|
1035
|
+
|
|
1036
|
+
return PaginatedBooksResponse(
|
|
1037
|
+
items=[_book_to_response(book) for book in books],
|
|
1038
|
+
total=total,
|
|
1039
|
+
offset=offset,
|
|
1040
|
+
limit=limit
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
@app.post("/api/views", response_model=ViewDetailResponse)
|
|
1045
|
+
async def create_view(request: ViewCreateRequest):
|
|
1046
|
+
"""Create a new view."""
|
|
1047
|
+
from .views import ViewService
|
|
1048
|
+
lib = get_library()
|
|
1049
|
+
svc = ViewService(lib.session)
|
|
1050
|
+
|
|
1051
|
+
try:
|
|
1052
|
+
# Build filter kwargs from shorthand options
|
|
1053
|
+
filter_kwargs = {}
|
|
1054
|
+
if request.subject:
|
|
1055
|
+
filter_kwargs['subject'] = request.subject
|
|
1056
|
+
if request.author:
|
|
1057
|
+
filter_kwargs['author'] = request.author
|
|
1058
|
+
if request.favorite is not None:
|
|
1059
|
+
filter_kwargs['favorite'] = request.favorite
|
|
1060
|
+
if request.reading_status:
|
|
1061
|
+
filter_kwargs['reading_status'] = request.reading_status
|
|
1062
|
+
|
|
1063
|
+
view = svc.create(
|
|
1064
|
+
name=request.name,
|
|
1065
|
+
definition=request.definition,
|
|
1066
|
+
description=request.description,
|
|
1067
|
+
**filter_kwargs
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
return ViewDetailResponse(
|
|
1071
|
+
name=view.name,
|
|
1072
|
+
description=view.description,
|
|
1073
|
+
builtin=False,
|
|
1074
|
+
definition=view.definition,
|
|
1075
|
+
count=svc.count(view.name)
|
|
1076
|
+
)
|
|
1077
|
+
except ValueError as e:
|
|
1078
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
@app.patch("/api/views/{view_name}", response_model=ViewDetailResponse)
|
|
1082
|
+
async def update_view(view_name: str, request: ViewUpdateRequest):
|
|
1083
|
+
"""Update a view."""
|
|
1084
|
+
from .views import ViewService
|
|
1085
|
+
from .views.dsl import is_builtin_view
|
|
1086
|
+
lib = get_library()
|
|
1087
|
+
svc = ViewService(lib.session)
|
|
1088
|
+
|
|
1089
|
+
if is_builtin_view(view_name):
|
|
1090
|
+
raise HTTPException(status_code=400, detail="Cannot modify builtin views")
|
|
1091
|
+
|
|
1092
|
+
try:
|
|
1093
|
+
view = svc.update(
|
|
1094
|
+
name=view_name,
|
|
1095
|
+
definition=request.definition,
|
|
1096
|
+
description=request.description
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
return ViewDetailResponse(
|
|
1100
|
+
name=view.name,
|
|
1101
|
+
description=view.description,
|
|
1102
|
+
builtin=False,
|
|
1103
|
+
definition=view.definition,
|
|
1104
|
+
count=svc.count(view.name)
|
|
1105
|
+
)
|
|
1106
|
+
except ValueError as e:
|
|
1107
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
@app.delete("/api/views/{view_name}")
|
|
1111
|
+
async def delete_view(view_name: str):
|
|
1112
|
+
"""Delete a view."""
|
|
1113
|
+
from .views import ViewService
|
|
1114
|
+
from .views.dsl import is_builtin_view
|
|
1115
|
+
lib = get_library()
|
|
1116
|
+
svc = ViewService(lib.session)
|
|
1117
|
+
|
|
1118
|
+
if is_builtin_view(view_name):
|
|
1119
|
+
raise HTTPException(status_code=400, detail="Cannot delete builtin views")
|
|
1120
|
+
|
|
1121
|
+
if svc.delete(view_name):
|
|
1122
|
+
return {"message": f"View '{view_name}' deleted successfully"}
|
|
1123
|
+
else:
|
|
1124
|
+
raise HTTPException(status_code=404, detail=f"View '{view_name}' not found")
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
@app.post("/api/views/{view_name}/books/{book_id}")
|
|
1128
|
+
async def add_book_to_view(view_name: str, book_id: int):
|
|
1129
|
+
"""Add a book to a view."""
|
|
1130
|
+
from .views import ViewService
|
|
1131
|
+
from .views.dsl import is_builtin_view
|
|
1132
|
+
lib = get_library()
|
|
1133
|
+
svc = ViewService(lib.session)
|
|
1134
|
+
|
|
1135
|
+
if is_builtin_view(view_name):
|
|
1136
|
+
raise HTTPException(status_code=400, detail="Cannot modify builtin views")
|
|
1137
|
+
|
|
1138
|
+
try:
|
|
1139
|
+
svc.add_book(view_name, book_id)
|
|
1140
|
+
return {"message": f"Book {book_id} added to view '{view_name}'"}
|
|
1141
|
+
except ValueError as e:
|
|
1142
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
@app.delete("/api/views/{view_name}/books/{book_id}")
|
|
1146
|
+
async def remove_book_from_view(view_name: str, book_id: int):
|
|
1147
|
+
"""Remove a book from a view."""
|
|
1148
|
+
from .views import ViewService
|
|
1149
|
+
from .views.dsl import is_builtin_view
|
|
1150
|
+
lib = get_library()
|
|
1151
|
+
svc = ViewService(lib.session)
|
|
1152
|
+
|
|
1153
|
+
if is_builtin_view(view_name):
|
|
1154
|
+
raise HTTPException(status_code=400, detail="Cannot modify builtin views")
|
|
1155
|
+
|
|
1156
|
+
try:
|
|
1157
|
+
svc.remove_book(view_name, book_id)
|
|
1158
|
+
return {"message": f"Book {book_id} removed from view '{view_name}'"}
|
|
1159
|
+
except ValueError as e:
|
|
1160
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
@app.put("/api/views/{view_name}/overrides/{book_id}")
|
|
1164
|
+
async def set_view_override(view_name: str, book_id: int, request: ViewOverrideRequest):
|
|
1165
|
+
"""Set metadata overrides for a book within a view."""
|
|
1166
|
+
from .views import ViewService
|
|
1167
|
+
from .views.dsl import is_builtin_view
|
|
1168
|
+
lib = get_library()
|
|
1169
|
+
svc = ViewService(lib.session)
|
|
1170
|
+
|
|
1171
|
+
if is_builtin_view(view_name):
|
|
1172
|
+
raise HTTPException(status_code=400, detail="Cannot modify builtin views")
|
|
1173
|
+
|
|
1174
|
+
try:
|
|
1175
|
+
svc.set_override(
|
|
1176
|
+
view_name=view_name,
|
|
1177
|
+
book_id=book_id,
|
|
1178
|
+
title=request.title,
|
|
1179
|
+
description=request.description,
|
|
1180
|
+
position=request.position
|
|
1181
|
+
)
|
|
1182
|
+
return {"message": f"Override set for book {book_id} in view '{view_name}'"}
|
|
1183
|
+
except ValueError as e:
|
|
1184
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
@app.delete("/api/views/{view_name}/overrides/{book_id}")
|
|
1188
|
+
async def remove_view_override(view_name: str, book_id: int, field: Optional[str] = None):
|
|
1189
|
+
"""Remove overrides for a book within a view."""
|
|
1190
|
+
from .views import ViewService
|
|
1191
|
+
from .views.dsl import is_builtin_view
|
|
1192
|
+
lib = get_library()
|
|
1193
|
+
svc = ViewService(lib.session)
|
|
1194
|
+
|
|
1195
|
+
if is_builtin_view(view_name):
|
|
1196
|
+
raise HTTPException(status_code=400, detail="Cannot modify builtin views")
|
|
1197
|
+
|
|
1198
|
+
try:
|
|
1199
|
+
if svc.unset_override(view_name, book_id, field):
|
|
1200
|
+
return {"message": f"Override removed for book {book_id} in view '{view_name}'"}
|
|
1201
|
+
else:
|
|
1202
|
+
raise HTTPException(status_code=404, detail="Override not found")
|
|
1203
|
+
except ValueError as e:
|
|
1204
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
@app.get("/api/views/{view_name}/yaml")
|
|
1208
|
+
async def export_view_yaml(view_name: str):
|
|
1209
|
+
"""Export a view definition as YAML."""
|
|
1210
|
+
from .views import ViewService
|
|
1211
|
+
lib = get_library()
|
|
1212
|
+
svc = ViewService(lib.session)
|
|
1213
|
+
|
|
1214
|
+
try:
|
|
1215
|
+
yaml_content = svc.export_yaml(view_name)
|
|
1216
|
+
return JSONResponse(
|
|
1217
|
+
content={"yaml": yaml_content},
|
|
1218
|
+
media_type="application/json"
|
|
1219
|
+
)
|
|
1220
|
+
except ValueError as e:
|
|
1221
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
@app.post("/api/views/import")
|
|
1225
|
+
async def import_view_yaml(yaml_content: str = Form(...), overwrite: bool = Form(False)):
|
|
1226
|
+
"""Import a view from YAML."""
|
|
1227
|
+
from .views import ViewService
|
|
1228
|
+
lib = get_library()
|
|
1229
|
+
svc = ViewService(lib.session)
|
|
1230
|
+
|
|
1231
|
+
try:
|
|
1232
|
+
view = svc.import_yaml(yaml_content, overwrite=overwrite)
|
|
1233
|
+
return ViewDetailResponse(
|
|
1234
|
+
name=view.name,
|
|
1235
|
+
description=view.description,
|
|
1236
|
+
builtin=False,
|
|
1237
|
+
definition=view.definition,
|
|
1238
|
+
count=svc.count(view.name)
|
|
1239
|
+
)
|
|
1240
|
+
except ValueError as e:
|
|
1241
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def get_web_interface() -> str:
|
|
1245
|
+
"""Generate the web interface HTML."""
|
|
1246
|
+
return '''<!DOCTYPE html>
|
|
1247
|
+
<html lang="en">
|
|
1248
|
+
<head>
|
|
1249
|
+
<meta charset="UTF-8">
|
|
1250
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1251
|
+
<title>ebk Library Manager</title>
|
|
1252
|
+
<style>
|
|
1253
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1254
|
+
|
|
1255
|
+
:root {
|
|
1256
|
+
--bg-primary: #f8fafc;
|
|
1257
|
+
--bg-secondary: #ffffff;
|
|
1258
|
+
--bg-tertiary: #f1f5f9;
|
|
1259
|
+
--bg-hover: #e2e8f0;
|
|
1260
|
+
--text-primary: #0f172a;
|
|
1261
|
+
--text-secondary: #475569;
|
|
1262
|
+
--text-muted: #94a3b8;
|
|
1263
|
+
--border: #e2e8f0;
|
|
1264
|
+
--accent: #6366f1;
|
|
1265
|
+
--accent-hover: #4f46e5;
|
|
1266
|
+
--accent-light: #eef2ff;
|
|
1267
|
+
--success: #10b981;
|
|
1268
|
+
--success-light: #d1fae5;
|
|
1269
|
+
--warning: #f59e0b;
|
|
1270
|
+
--warning-light: #fef3c7;
|
|
1271
|
+
--danger: #ef4444;
|
|
1272
|
+
--danger-light: #fee2e2;
|
|
1273
|
+
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
|
|
1274
|
+
--shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06);
|
|
1275
|
+
--shadow-md: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -1px rgba(0,0,0,0.06);
|
|
1276
|
+
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05);
|
|
1277
|
+
--radius: 8px;
|
|
1278
|
+
--radius-lg: 12px;
|
|
1279
|
+
--radius-xl: 16px;
|
|
1280
|
+
--transition: 0.15s ease;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
[data-theme="dark"] {
|
|
1284
|
+
--bg-primary: #0f172a;
|
|
1285
|
+
--bg-secondary: #1e293b;
|
|
1286
|
+
--bg-tertiary: #334155;
|
|
1287
|
+
--bg-hover: #475569;
|
|
1288
|
+
--text-primary: #f1f5f9;
|
|
1289
|
+
--text-secondary: #cbd5e1;
|
|
1290
|
+
--text-muted: #64748b;
|
|
1291
|
+
--border: #334155;
|
|
1292
|
+
--accent-light: #312e81;
|
|
1293
|
+
--success-light: #064e3b;
|
|
1294
|
+
--warning-light: #78350f;
|
|
1295
|
+
--danger-light: #7f1d1d;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
html { font-size: 16px; }
|
|
1299
|
+
|
|
1300
|
+
body {
|
|
1301
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
1302
|
+
background: var(--bg-primary);
|
|
1303
|
+
color: var(--text-primary);
|
|
1304
|
+
line-height: 1.6;
|
|
1305
|
+
min-height: 100vh;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/* Layout */
|
|
1309
|
+
.app { display: flex; min-height: 100vh; }
|
|
1310
|
+
|
|
1311
|
+
/* Sidebar */
|
|
1312
|
+
.sidebar {
|
|
1313
|
+
width: 280px;
|
|
1314
|
+
background: var(--bg-secondary);
|
|
1315
|
+
border-right: 1px solid var(--border);
|
|
1316
|
+
display: flex;
|
|
1317
|
+
flex-direction: column;
|
|
1318
|
+
position: fixed;
|
|
1319
|
+
top: 0;
|
|
1320
|
+
left: 0;
|
|
1321
|
+
bottom: 0;
|
|
1322
|
+
z-index: 100;
|
|
1323
|
+
transform: translateX(-100%);
|
|
1324
|
+
transition: transform 0.3s ease;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
.sidebar.open { transform: translateX(0); }
|
|
1328
|
+
|
|
1329
|
+
@media (min-width: 1024px) {
|
|
1330
|
+
.sidebar {
|
|
1331
|
+
transform: translateX(0);
|
|
1332
|
+
position: sticky;
|
|
1333
|
+
height: 100vh;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
.sidebar-header {
|
|
1338
|
+
padding: 24px;
|
|
1339
|
+
border-bottom: 1px solid var(--border);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
.logo {
|
|
1343
|
+
display: flex;
|
|
1344
|
+
align-items: center;
|
|
1345
|
+
gap: 12px;
|
|
1346
|
+
font-size: 1.25rem;
|
|
1347
|
+
font-weight: 700;
|
|
1348
|
+
color: var(--accent);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
.logo-icon {
|
|
1352
|
+
width: 40px;
|
|
1353
|
+
height: 40px;
|
|
1354
|
+
background: linear-gradient(135deg, var(--accent), #8b5cf6);
|
|
1355
|
+
border-radius: var(--radius);
|
|
1356
|
+
display: flex;
|
|
1357
|
+
align-items: center;
|
|
1358
|
+
justify-content: center;
|
|
1359
|
+
font-size: 1.25rem;
|
|
1360
|
+
color: white;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
.sidebar-nav {
|
|
1364
|
+
flex: 1;
|
|
1365
|
+
overflow-y: auto;
|
|
1366
|
+
padding: 16px;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
.nav-section { margin-bottom: 24px; }
|
|
1370
|
+
|
|
1371
|
+
.nav-section-title {
|
|
1372
|
+
font-size: 0.7rem;
|
|
1373
|
+
font-weight: 600;
|
|
1374
|
+
text-transform: uppercase;
|
|
1375
|
+
letter-spacing: 0.08em;
|
|
1376
|
+
color: var(--text-muted);
|
|
1377
|
+
padding: 8px 12px;
|
|
1378
|
+
margin-bottom: 4px;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
.nav-item {
|
|
1382
|
+
display: flex;
|
|
1383
|
+
align-items: center;
|
|
1384
|
+
gap: 12px;
|
|
1385
|
+
padding: 10px 12px;
|
|
1386
|
+
border-radius: var(--radius);
|
|
1387
|
+
color: var(--text-secondary);
|
|
1388
|
+
cursor: pointer;
|
|
1389
|
+
transition: all var(--transition);
|
|
1390
|
+
font-size: 0.9rem;
|
|
1391
|
+
font-weight: 500;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
.nav-item:hover {
|
|
1395
|
+
background: var(--bg-tertiary);
|
|
1396
|
+
color: var(--text-primary);
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
.nav-item.active {
|
|
1400
|
+
background: var(--accent);
|
|
1401
|
+
color: white;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
.nav-item-icon { font-size: 1.1rem; opacity: 0.9; }
|
|
1405
|
+
|
|
1406
|
+
.nav-item-count {
|
|
1407
|
+
margin-left: auto;
|
|
1408
|
+
font-size: 0.75rem;
|
|
1409
|
+
font-weight: 600;
|
|
1410
|
+
background: var(--bg-tertiary);
|
|
1411
|
+
padding: 2px 8px;
|
|
1412
|
+
border-radius: 10px;
|
|
1413
|
+
color: var(--text-muted);
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
.nav-item.active .nav-item-count {
|
|
1417
|
+
background: rgba(255,255,255,0.2);
|
|
1418
|
+
color: white;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
.sidebar-footer {
|
|
1422
|
+
padding: 16px 24px;
|
|
1423
|
+
border-top: 1px solid var(--border);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
.sidebar-overlay {
|
|
1427
|
+
display: none;
|
|
1428
|
+
position: fixed;
|
|
1429
|
+
inset: 0;
|
|
1430
|
+
background: rgba(0,0,0,0.5);
|
|
1431
|
+
z-index: 99;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
.sidebar-overlay.active { display: block; }
|
|
1435
|
+
|
|
1436
|
+
/* Main Content */
|
|
1437
|
+
.main {
|
|
1438
|
+
flex: 1;
|
|
1439
|
+
display: flex;
|
|
1440
|
+
flex-direction: column;
|
|
1441
|
+
min-width: 0;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/* Header */
|
|
1445
|
+
.header {
|
|
1446
|
+
background: var(--bg-secondary);
|
|
1447
|
+
border-bottom: 1px solid var(--border);
|
|
1448
|
+
padding: 16px 24px;
|
|
1449
|
+
display: flex;
|
|
1450
|
+
align-items: center;
|
|
1451
|
+
gap: 16px;
|
|
1452
|
+
position: sticky;
|
|
1453
|
+
top: 0;
|
|
1454
|
+
z-index: 50;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
.menu-btn {
|
|
1458
|
+
display: flex;
|
|
1459
|
+
align-items: center;
|
|
1460
|
+
justify-content: center;
|
|
1461
|
+
width: 40px;
|
|
1462
|
+
height: 40px;
|
|
1463
|
+
border: none;
|
|
1464
|
+
background: var(--bg-tertiary);
|
|
1465
|
+
border-radius: var(--radius);
|
|
1466
|
+
cursor: pointer;
|
|
1467
|
+
color: var(--text-primary);
|
|
1468
|
+
font-size: 1.25rem;
|
|
1469
|
+
transition: all var(--transition);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
.menu-btn:hover { background: var(--bg-hover); }
|
|
1473
|
+
|
|
1474
|
+
@media (min-width: 1024px) { .menu-btn { display: none; } }
|
|
1475
|
+
|
|
1476
|
+
.search-box {
|
|
1477
|
+
flex: 1;
|
|
1478
|
+
max-width: 600px;
|
|
1479
|
+
position: relative;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
.search-input {
|
|
1483
|
+
width: 100%;
|
|
1484
|
+
padding: 12px 16px 12px 48px;
|
|
1485
|
+
border: 2px solid var(--border);
|
|
1486
|
+
border-radius: var(--radius-lg);
|
|
1487
|
+
font-size: 0.95rem;
|
|
1488
|
+
background: var(--bg-primary);
|
|
1489
|
+
color: var(--text-primary);
|
|
1490
|
+
transition: all var(--transition);
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
.search-input:focus {
|
|
1494
|
+
outline: none;
|
|
1495
|
+
border-color: var(--accent);
|
|
1496
|
+
box-shadow: 0 0 0 3px var(--accent-light);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
.search-input::placeholder { color: var(--text-muted); }
|
|
1500
|
+
|
|
1501
|
+
.search-icon {
|
|
1502
|
+
position: absolute;
|
|
1503
|
+
left: 16px;
|
|
1504
|
+
top: 50%;
|
|
1505
|
+
transform: translateY(-50%);
|
|
1506
|
+
color: var(--text-muted);
|
|
1507
|
+
font-size: 1.1rem;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
.header-actions {
|
|
1511
|
+
display: flex;
|
|
1512
|
+
align-items: center;
|
|
1513
|
+
gap: 8px;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
.icon-btn {
|
|
1517
|
+
display: flex;
|
|
1518
|
+
align-items: center;
|
|
1519
|
+
justify-content: center;
|
|
1520
|
+
width: 40px;
|
|
1521
|
+
height: 40px;
|
|
1522
|
+
border: none;
|
|
1523
|
+
background: var(--bg-tertiary);
|
|
1524
|
+
border-radius: var(--radius);
|
|
1525
|
+
cursor: pointer;
|
|
1526
|
+
color: var(--text-secondary);
|
|
1527
|
+
font-size: 1.1rem;
|
|
1528
|
+
transition: all var(--transition);
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
.icon-btn:hover {
|
|
1532
|
+
background: var(--accent);
|
|
1533
|
+
color: white;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
.icon-btn.active {
|
|
1537
|
+
background: var(--accent);
|
|
1538
|
+
color: white;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
/* Toolbar */
|
|
1542
|
+
.toolbar {
|
|
1543
|
+
background: var(--bg-secondary);
|
|
1544
|
+
border-bottom: 1px solid var(--border);
|
|
1545
|
+
padding: 12px 24px;
|
|
1546
|
+
display: flex;
|
|
1547
|
+
align-items: center;
|
|
1548
|
+
gap: 12px;
|
|
1549
|
+
flex-wrap: wrap;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
.toolbar-group {
|
|
1553
|
+
display: flex;
|
|
1554
|
+
align-items: center;
|
|
1555
|
+
gap: 8px;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
.toolbar-label {
|
|
1559
|
+
font-size: 0.8rem;
|
|
1560
|
+
color: var(--text-muted);
|
|
1561
|
+
font-weight: 500;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
.toolbar-select {
|
|
1565
|
+
padding: 8px 12px;
|
|
1566
|
+
border: 1px solid var(--border);
|
|
1567
|
+
border-radius: var(--radius);
|
|
1568
|
+
font-size: 0.85rem;
|
|
1569
|
+
background: var(--bg-primary);
|
|
1570
|
+
color: var(--text-primary);
|
|
1571
|
+
cursor: pointer;
|
|
1572
|
+
transition: all var(--transition);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
.toolbar-select:focus {
|
|
1576
|
+
outline: none;
|
|
1577
|
+
border-color: var(--accent);
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
.toolbar-divider {
|
|
1581
|
+
width: 1px;
|
|
1582
|
+
height: 24px;
|
|
1583
|
+
background: var(--border);
|
|
1584
|
+
margin: 0 8px;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
.results-info {
|
|
1588
|
+
margin-left: auto;
|
|
1589
|
+
font-size: 0.85rem;
|
|
1590
|
+
color: var(--text-muted);
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
/* Stats Bar */
|
|
1594
|
+
.stats-bar {
|
|
1595
|
+
display: flex;
|
|
1596
|
+
gap: 24px;
|
|
1597
|
+
padding: 16px 24px;
|
|
1598
|
+
background: var(--bg-secondary);
|
|
1599
|
+
border-bottom: 1px solid var(--border);
|
|
1600
|
+
overflow-x: auto;
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
.stat-item {
|
|
1604
|
+
display: flex;
|
|
1605
|
+
align-items: center;
|
|
1606
|
+
gap: 12px;
|
|
1607
|
+
padding: 12px 16px;
|
|
1608
|
+
background: var(--bg-tertiary);
|
|
1609
|
+
border-radius: var(--radius);
|
|
1610
|
+
min-width: fit-content;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
.stat-icon {
|
|
1614
|
+
width: 40px;
|
|
1615
|
+
height: 40px;
|
|
1616
|
+
border-radius: var(--radius);
|
|
1617
|
+
display: flex;
|
|
1618
|
+
align-items: center;
|
|
1619
|
+
justify-content: center;
|
|
1620
|
+
font-size: 1.2rem;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
.stat-icon.books { background: var(--accent-light); color: var(--accent); }
|
|
1624
|
+
.stat-icon.authors { background: var(--success-light); color: var(--success); }
|
|
1625
|
+
.stat-icon.files { background: var(--warning-light); color: var(--warning); }
|
|
1626
|
+
.stat-icon.storage { background: var(--danger-light); color: var(--danger); }
|
|
1627
|
+
|
|
1628
|
+
.stat-content { display: flex; flex-direction: column; }
|
|
1629
|
+
.stat-value { font-size: 1.25rem; font-weight: 700; color: var(--text-primary); line-height: 1.2; }
|
|
1630
|
+
.stat-label { font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
|
1631
|
+
|
|
1632
|
+
/* Content Area */
|
|
1633
|
+
.content {
|
|
1634
|
+
flex: 1;
|
|
1635
|
+
padding: 24px;
|
|
1636
|
+
overflow-y: auto;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
/* Grid View */
|
|
1640
|
+
.book-grid {
|
|
1641
|
+
display: grid;
|
|
1642
|
+
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
1643
|
+
gap: 20px;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
.book-card {
|
|
1647
|
+
background: var(--bg-secondary);
|
|
1648
|
+
border-radius: var(--radius-lg);
|
|
1649
|
+
overflow: hidden;
|
|
1650
|
+
box-shadow: var(--shadow);
|
|
1651
|
+
transition: all var(--transition);
|
|
1652
|
+
cursor: pointer;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
.book-card:hover {
|
|
1656
|
+
transform: translateY(-4px);
|
|
1657
|
+
box-shadow: var(--shadow-lg);
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
.book-cover {
|
|
1661
|
+
aspect-ratio: 2/3;
|
|
1662
|
+
background: var(--bg-tertiary);
|
|
1663
|
+
display: flex;
|
|
1664
|
+
align-items: center;
|
|
1665
|
+
justify-content: center;
|
|
1666
|
+
overflow: hidden;
|
|
1667
|
+
position: relative;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
.book-cover img {
|
|
1671
|
+
width: 100%;
|
|
1672
|
+
height: 100%;
|
|
1673
|
+
object-fit: cover;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
.book-cover-placeholder {
|
|
1677
|
+
font-size: 3rem;
|
|
1678
|
+
color: var(--text-muted);
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
.book-favorite {
|
|
1682
|
+
position: absolute;
|
|
1683
|
+
top: 8px;
|
|
1684
|
+
right: 8px;
|
|
1685
|
+
background: rgba(0,0,0,0.5);
|
|
1686
|
+
color: var(--warning);
|
|
1687
|
+
padding: 4px 8px;
|
|
1688
|
+
border-radius: var(--radius);
|
|
1689
|
+
font-size: 0.9rem;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
.book-info { padding: 16px; }
|
|
1693
|
+
|
|
1694
|
+
.book-title {
|
|
1695
|
+
font-size: 0.95rem;
|
|
1696
|
+
font-weight: 600;
|
|
1697
|
+
color: var(--text-primary);
|
|
1698
|
+
line-height: 1.4;
|
|
1699
|
+
display: -webkit-box;
|
|
1700
|
+
-webkit-line-clamp: 2;
|
|
1701
|
+
-webkit-box-orient: vertical;
|
|
1702
|
+
overflow: hidden;
|
|
1703
|
+
margin-bottom: 4px;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
.book-author {
|
|
1707
|
+
font-size: 0.85rem;
|
|
1708
|
+
color: var(--text-secondary);
|
|
1709
|
+
white-space: nowrap;
|
|
1710
|
+
overflow: hidden;
|
|
1711
|
+
text-overflow: ellipsis;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
.book-meta {
|
|
1715
|
+
display: flex;
|
|
1716
|
+
align-items: center;
|
|
1717
|
+
gap: 8px;
|
|
1718
|
+
margin-top: 12px;
|
|
1719
|
+
flex-wrap: wrap;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
.badge {
|
|
1723
|
+
font-size: 0.7rem;
|
|
1724
|
+
padding: 3px 8px;
|
|
1725
|
+
border-radius: 4px;
|
|
1726
|
+
font-weight: 600;
|
|
1727
|
+
text-transform: uppercase;
|
|
1728
|
+
letter-spacing: 0.02em;
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
.badge-format { background: var(--bg-tertiary); color: var(--text-secondary); }
|
|
1732
|
+
.badge-language { background: var(--warning-light); color: #92400e; }
|
|
1733
|
+
.badge-rating { background: var(--accent-light); color: var(--accent); }
|
|
1734
|
+
|
|
1735
|
+
.book-rating {
|
|
1736
|
+
display: flex;
|
|
1737
|
+
align-items: center;
|
|
1738
|
+
gap: 4px;
|
|
1739
|
+
color: var(--warning);
|
|
1740
|
+
font-size: 0.85rem;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
/* List View */
|
|
1744
|
+
.book-list { display: flex; flex-direction: column; gap: 12px; }
|
|
1745
|
+
|
|
1746
|
+
.book-list-item {
|
|
1747
|
+
background: var(--bg-secondary);
|
|
1748
|
+
border-radius: var(--radius);
|
|
1749
|
+
padding: 16px;
|
|
1750
|
+
display: flex;
|
|
1751
|
+
gap: 16px;
|
|
1752
|
+
align-items: flex-start;
|
|
1753
|
+
box-shadow: var(--shadow);
|
|
1754
|
+
cursor: pointer;
|
|
1755
|
+
transition: all var(--transition);
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
.book-list-item:hover { box-shadow: var(--shadow-md); }
|
|
1759
|
+
|
|
1760
|
+
.book-list-cover {
|
|
1761
|
+
width: 60px;
|
|
1762
|
+
height: 90px;
|
|
1763
|
+
background: var(--bg-tertiary);
|
|
1764
|
+
border-radius: 4px;
|
|
1765
|
+
overflow: hidden;
|
|
1766
|
+
flex-shrink: 0;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
.book-list-cover img { width: 100%; height: 100%; object-fit: cover; }
|
|
1770
|
+
|
|
1771
|
+
.book-list-info { flex: 1; min-width: 0; }
|
|
1772
|
+
|
|
1773
|
+
.book-list-title { font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
|
|
1774
|
+
.book-list-author { font-size: 0.9rem; color: var(--text-secondary); margin-bottom: 8px; }
|
|
1775
|
+
|
|
1776
|
+
.book-list-meta {
|
|
1777
|
+
display: flex;
|
|
1778
|
+
gap: 16px;
|
|
1779
|
+
font-size: 0.8rem;
|
|
1780
|
+
color: var(--text-muted);
|
|
1781
|
+
flex-wrap: wrap;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
/* Table View */
|
|
1785
|
+
.book-table {
|
|
1786
|
+
width: 100%;
|
|
1787
|
+
border-collapse: collapse;
|
|
1788
|
+
font-size: 0.9rem;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
.book-table th {
|
|
1792
|
+
text-align: left;
|
|
1793
|
+
padding: 12px 16px;
|
|
1794
|
+
background: var(--bg-tertiary);
|
|
1795
|
+
font-weight: 600;
|
|
1796
|
+
color: var(--text-secondary);
|
|
1797
|
+
border-bottom: 2px solid var(--border);
|
|
1798
|
+
cursor: pointer;
|
|
1799
|
+
user-select: none;
|
|
1800
|
+
transition: background var(--transition);
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
.book-table th:hover { background: var(--bg-hover); }
|
|
1804
|
+
|
|
1805
|
+
.book-table td {
|
|
1806
|
+
padding: 12px 16px;
|
|
1807
|
+
border-bottom: 1px solid var(--border);
|
|
1808
|
+
color: var(--text-primary);
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
.book-table tr:hover td { background: var(--bg-tertiary); }
|
|
1812
|
+
|
|
1813
|
+
.table-title { font-weight: 500; cursor: pointer; }
|
|
1814
|
+
.table-title:hover { color: var(--accent); }
|
|
1815
|
+
|
|
1816
|
+
/* Pagination */
|
|
1817
|
+
.pagination {
|
|
1818
|
+
display: flex;
|
|
1819
|
+
justify-content: center;
|
|
1820
|
+
align-items: center;
|
|
1821
|
+
gap: 8px;
|
|
1822
|
+
margin-top: 32px;
|
|
1823
|
+
flex-wrap: wrap;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
.page-btn {
|
|
1827
|
+
padding: 10px 16px;
|
|
1828
|
+
border: 1px solid var(--border);
|
|
1829
|
+
background: var(--bg-secondary);
|
|
1830
|
+
color: var(--text-primary);
|
|
1831
|
+
border-radius: var(--radius);
|
|
1832
|
+
cursor: pointer;
|
|
1833
|
+
font-size: 0.9rem;
|
|
1834
|
+
font-weight: 500;
|
|
1835
|
+
transition: all var(--transition);
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
.page-btn:hover:not(:disabled) {
|
|
1839
|
+
border-color: var(--accent);
|
|
1840
|
+
color: var(--accent);
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
.page-btn.active {
|
|
1844
|
+
background: var(--accent);
|
|
1845
|
+
border-color: var(--accent);
|
|
1846
|
+
color: white;
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
.page-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
1850
|
+
|
|
1851
|
+
.page-info {
|
|
1852
|
+
padding: 0 16px;
|
|
1853
|
+
color: var(--text-muted);
|
|
1854
|
+
font-size: 0.9rem;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
/* Empty State */
|
|
1858
|
+
.empty-state {
|
|
1859
|
+
text-align: center;
|
|
1860
|
+
padding: 80px 20px;
|
|
1861
|
+
color: var(--text-muted);
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
.empty-state-icon { font-size: 4rem; margin-bottom: 16px; opacity: 0.5; }
|
|
1865
|
+
.empty-state h3 { color: var(--text-secondary); margin-bottom: 8px; font-size: 1.25rem; }
|
|
1866
|
+
|
|
1867
|
+
/* Loading */
|
|
1868
|
+
.loading {
|
|
1869
|
+
display: flex;
|
|
1870
|
+
flex-direction: column;
|
|
1871
|
+
align-items: center;
|
|
1872
|
+
justify-content: center;
|
|
1873
|
+
padding: 60px 20px;
|
|
1874
|
+
color: var(--text-muted);
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
.spinner {
|
|
1878
|
+
width: 40px;
|
|
1879
|
+
height: 40px;
|
|
1880
|
+
border: 3px solid var(--border);
|
|
1881
|
+
border-top-color: var(--accent);
|
|
1882
|
+
border-radius: 50%;
|
|
1883
|
+
animation: spin 0.8s linear infinite;
|
|
1884
|
+
margin-bottom: 16px;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
1888
|
+
|
|
1889
|
+
/* Modal */
|
|
1890
|
+
.modal {
|
|
1891
|
+
display: none;
|
|
1892
|
+
position: fixed;
|
|
1893
|
+
inset: 0;
|
|
1894
|
+
background: rgba(0,0,0,0.6);
|
|
1895
|
+
z-index: 200;
|
|
1896
|
+
padding: 20px;
|
|
1897
|
+
overflow-y: auto;
|
|
1898
|
+
backdrop-filter: blur(4px);
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
.modal.active {
|
|
1902
|
+
display: flex;
|
|
1903
|
+
align-items: flex-start;
|
|
1904
|
+
justify-content: center;
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
.modal-content {
|
|
1908
|
+
background: var(--bg-secondary);
|
|
1909
|
+
border-radius: var(--radius-xl);
|
|
1910
|
+
max-width: 700px;
|
|
1911
|
+
width: 100%;
|
|
1912
|
+
margin-top: 40px;
|
|
1913
|
+
box-shadow: var(--shadow-lg);
|
|
1914
|
+
animation: modalIn 0.2s ease;
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
@keyframes modalIn {
|
|
1918
|
+
from { opacity: 0; transform: translateY(-20px); }
|
|
1919
|
+
to { opacity: 1; transform: translateY(0); }
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
.modal-header {
|
|
1923
|
+
padding: 24px;
|
|
1924
|
+
border-bottom: 1px solid var(--border);
|
|
1925
|
+
display: flex;
|
|
1926
|
+
align-items: flex-start;
|
|
1927
|
+
justify-content: space-between;
|
|
1928
|
+
gap: 16px;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
.modal-title {
|
|
1932
|
+
font-size: 1.25rem;
|
|
1933
|
+
font-weight: 700;
|
|
1934
|
+
color: var(--text-primary);
|
|
1935
|
+
line-height: 1.3;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
.modal-close {
|
|
1939
|
+
width: 36px;
|
|
1940
|
+
height: 36px;
|
|
1941
|
+
border: none;
|
|
1942
|
+
background: var(--bg-tertiary);
|
|
1943
|
+
border-radius: var(--radius);
|
|
1944
|
+
cursor: pointer;
|
|
1945
|
+
font-size: 1.5rem;
|
|
1946
|
+
color: var(--text-secondary);
|
|
1947
|
+
display: flex;
|
|
1948
|
+
align-items: center;
|
|
1949
|
+
justify-content: center;
|
|
1950
|
+
flex-shrink: 0;
|
|
1951
|
+
transition: all var(--transition);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
.modal-close:hover { background: var(--danger); color: white; }
|
|
1955
|
+
|
|
1956
|
+
.modal-body {
|
|
1957
|
+
padding: 24px;
|
|
1958
|
+
max-height: 70vh;
|
|
1959
|
+
overflow-y: auto;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
/* Form Elements */
|
|
1963
|
+
.form-group { margin-bottom: 20px; }
|
|
1964
|
+
|
|
1965
|
+
.form-label {
|
|
1966
|
+
display: block;
|
|
1967
|
+
font-weight: 600;
|
|
1968
|
+
margin-bottom: 8px;
|
|
1969
|
+
color: var(--text-primary);
|
|
1970
|
+
font-size: 0.9rem;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
.form-control {
|
|
1974
|
+
width: 100%;
|
|
1975
|
+
padding: 12px 16px;
|
|
1976
|
+
border: 2px solid var(--border);
|
|
1977
|
+
border-radius: var(--radius);
|
|
1978
|
+
font-size: 0.95rem;
|
|
1979
|
+
background: var(--bg-primary);
|
|
1980
|
+
color: var(--text-primary);
|
|
1981
|
+
transition: all var(--transition);
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
.form-control:focus {
|
|
1985
|
+
outline: none;
|
|
1986
|
+
border-color: var(--accent);
|
|
1987
|
+
box-shadow: 0 0 0 3px var(--accent-light);
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
textarea.form-control { min-height: 120px; resize: vertical; }
|
|
1991
|
+
|
|
1992
|
+
.form-check {
|
|
1993
|
+
display: flex;
|
|
1994
|
+
align-items: center;
|
|
1995
|
+
gap: 8px;
|
|
1996
|
+
cursor: pointer;
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
.form-check input[type="checkbox"] {
|
|
2000
|
+
width: 18px;
|
|
2001
|
+
height: 18px;
|
|
2002
|
+
accent-color: var(--accent);
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
/* Buttons */
|
|
2006
|
+
.btn {
|
|
2007
|
+
display: inline-flex;
|
|
2008
|
+
align-items: center;
|
|
2009
|
+
justify-content: center;
|
|
2010
|
+
gap: 8px;
|
|
2011
|
+
padding: 12px 20px;
|
|
2012
|
+
border: none;
|
|
2013
|
+
border-radius: var(--radius);
|
|
2014
|
+
font-size: 0.9rem;
|
|
2015
|
+
font-weight: 600;
|
|
2016
|
+
cursor: pointer;
|
|
2017
|
+
transition: all var(--transition);
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
.btn-primary { background: var(--accent); color: white; }
|
|
2021
|
+
.btn-primary:hover { background: var(--accent-hover); }
|
|
2022
|
+
|
|
2023
|
+
.btn-secondary { background: var(--bg-tertiary); color: var(--text-primary); }
|
|
2024
|
+
.btn-secondary:hover { background: var(--bg-hover); }
|
|
2025
|
+
|
|
2026
|
+
.btn-success { background: var(--success); color: white; }
|
|
2027
|
+
.btn-success:hover { background: #059669; }
|
|
2028
|
+
|
|
2029
|
+
.btn-danger { background: var(--danger); color: white; }
|
|
2030
|
+
.btn-danger:hover { background: #dc2626; }
|
|
2031
|
+
|
|
2032
|
+
.btn-outline {
|
|
2033
|
+
background: transparent;
|
|
2034
|
+
border: 2px solid var(--border);
|
|
2035
|
+
color: var(--text-primary);
|
|
2036
|
+
}
|
|
2037
|
+
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
|
|
2038
|
+
|
|
2039
|
+
.btn-group { display: flex; gap: 12px; flex-wrap: wrap; }
|
|
2040
|
+
|
|
2041
|
+
/* Alerts */
|
|
2042
|
+
.alert {
|
|
2043
|
+
padding: 16px 20px;
|
|
2044
|
+
border-radius: var(--radius);
|
|
2045
|
+
margin-bottom: 20px;
|
|
2046
|
+
font-size: 0.9rem;
|
|
2047
|
+
display: flex;
|
|
2048
|
+
align-items: center;
|
|
2049
|
+
gap: 12px;
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
.alert-success { background: var(--success-light); color: #065f46; }
|
|
2053
|
+
.alert-error { background: var(--danger-light); color: #991b1b; }
|
|
2054
|
+
|
|
2055
|
+
/* Detail Section */
|
|
2056
|
+
.detail-section { margin-bottom: 24px; }
|
|
2057
|
+
|
|
2058
|
+
.detail-label {
|
|
2059
|
+
font-size: 0.75rem;
|
|
2060
|
+
font-weight: 600;
|
|
2061
|
+
text-transform: uppercase;
|
|
2062
|
+
letter-spacing: 0.05em;
|
|
2063
|
+
color: var(--text-muted);
|
|
2064
|
+
margin-bottom: 8px;
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
.detail-value { color: var(--text-primary); line-height: 1.6; }
|
|
2068
|
+
|
|
2069
|
+
.detail-tags {
|
|
2070
|
+
display: flex;
|
|
2071
|
+
flex-wrap: wrap;
|
|
2072
|
+
gap: 8px;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
.tag {
|
|
2076
|
+
background: var(--bg-tertiary);
|
|
2077
|
+
color: var(--text-secondary);
|
|
2078
|
+
padding: 6px 12px;
|
|
2079
|
+
border-radius: 20px;
|
|
2080
|
+
font-size: 0.8rem;
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
/* File List */
|
|
2084
|
+
.file-list { list-style: none; }
|
|
2085
|
+
|
|
2086
|
+
.file-item {
|
|
2087
|
+
display: flex;
|
|
2088
|
+
align-items: center;
|
|
2089
|
+
gap: 12px;
|
|
2090
|
+
padding: 12px 0;
|
|
2091
|
+
border-bottom: 1px solid var(--border);
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
.file-item:last-child { border-bottom: none; }
|
|
2095
|
+
|
|
2096
|
+
.file-btn {
|
|
2097
|
+
display: inline-flex;
|
|
2098
|
+
align-items: center;
|
|
2099
|
+
gap: 8px;
|
|
2100
|
+
padding: 10px 16px;
|
|
2101
|
+
background: var(--accent);
|
|
2102
|
+
color: white;
|
|
2103
|
+
border-radius: var(--radius);
|
|
2104
|
+
text-decoration: none;
|
|
2105
|
+
font-weight: 600;
|
|
2106
|
+
font-size: 0.85rem;
|
|
2107
|
+
transition: all var(--transition);
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
.file-btn:hover { background: var(--accent-hover); }
|
|
2111
|
+
|
|
2112
|
+
.file-size { color: var(--text-muted); font-size: 0.85rem; }
|
|
2113
|
+
|
|
2114
|
+
/* Modal Cover */
|
|
2115
|
+
.modal-cover {
|
|
2116
|
+
text-align: center;
|
|
2117
|
+
margin-bottom: 24px;
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
.modal-cover img {
|
|
2121
|
+
max-width: 200px;
|
|
2122
|
+
max-height: 300px;
|
|
2123
|
+
border-radius: var(--radius);
|
|
2124
|
+
box-shadow: var(--shadow-lg);
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
/* Tabs */
|
|
2128
|
+
.tabs {
|
|
2129
|
+
display: flex;
|
|
2130
|
+
border-bottom: 2px solid var(--border);
|
|
2131
|
+
margin-bottom: 20px;
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
.tab {
|
|
2135
|
+
padding: 12px 20px;
|
|
2136
|
+
border: none;
|
|
2137
|
+
background: none;
|
|
2138
|
+
color: var(--text-secondary);
|
|
2139
|
+
font-size: 0.9rem;
|
|
2140
|
+
font-weight: 500;
|
|
2141
|
+
cursor: pointer;
|
|
2142
|
+
border-bottom: 2px solid transparent;
|
|
2143
|
+
margin-bottom: -2px;
|
|
2144
|
+
transition: all var(--transition);
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
.tab:hover {
|
|
2148
|
+
color: var(--text-primary);
|
|
2149
|
+
background: var(--bg-tertiary);
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
.tab.active {
|
|
2153
|
+
color: var(--accent);
|
|
2154
|
+
border-bottom-color: var(--accent);
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
.tab-content {
|
|
2158
|
+
display: none;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
.tab-content.active {
|
|
2162
|
+
display: block;
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
.import-help {
|
|
2166
|
+
background: var(--bg-tertiary);
|
|
2167
|
+
border-radius: var(--radius);
|
|
2168
|
+
padding: 12px 16px;
|
|
2169
|
+
margin-bottom: 16px;
|
|
2170
|
+
font-size: 0.85rem;
|
|
2171
|
+
color: var(--text-secondary);
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
.import-help code {
|
|
2175
|
+
background: var(--bg-secondary);
|
|
2176
|
+
padding: 2px 6px;
|
|
2177
|
+
border-radius: 4px;
|
|
2178
|
+
font-family: monospace;
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
/* Keyboard Shortcuts Hint */
|
|
2182
|
+
.keyboard-hint {
|
|
2183
|
+
position: fixed;
|
|
2184
|
+
bottom: 20px;
|
|
2185
|
+
right: 20px;
|
|
2186
|
+
background: var(--bg-secondary);
|
|
2187
|
+
border: 1px solid var(--border);
|
|
2188
|
+
border-radius: var(--radius);
|
|
2189
|
+
padding: 12px 16px;
|
|
2190
|
+
font-size: 0.8rem;
|
|
2191
|
+
color: var(--text-muted);
|
|
2192
|
+
box-shadow: var(--shadow-lg);
|
|
2193
|
+
z-index: 50;
|
|
2194
|
+
display: none;
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
@media (min-width: 1024px) { .keyboard-hint { display: block; } }
|
|
2198
|
+
|
|
2199
|
+
.kbd {
|
|
2200
|
+
display: inline-block;
|
|
2201
|
+
background: var(--bg-tertiary);
|
|
2202
|
+
border: 1px solid var(--border);
|
|
2203
|
+
border-radius: 4px;
|
|
2204
|
+
padding: 2px 6px;
|
|
2205
|
+
font-family: monospace;
|
|
2206
|
+
font-size: 0.75rem;
|
|
2207
|
+
margin: 0 2px;
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
/* Print Styles */
|
|
2211
|
+
@media print {
|
|
2212
|
+
.sidebar, .header, .toolbar, .pagination, .keyboard-hint { display: none !important; }
|
|
2213
|
+
.main { margin-left: 0 !important; }
|
|
2214
|
+
.book-card, .book-list-item { break-inside: avoid; }
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
/* Scrollbar */
|
|
2218
|
+
::-webkit-scrollbar { width: 8px; height: 8px; }
|
|
2219
|
+
::-webkit-scrollbar-track { background: var(--bg-primary); }
|
|
2220
|
+
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
|
2221
|
+
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
|
2222
|
+
</style>
|
|
2223
|
+
</head>
|
|
2224
|
+
<body>
|
|
2225
|
+
<div class="sidebar-overlay" onclick="toggleSidebar()"></div>
|
|
2226
|
+
|
|
2227
|
+
<div class="app">
|
|
2228
|
+
<aside class="sidebar" id="sidebar">
|
|
2229
|
+
<div class="sidebar-header">
|
|
2230
|
+
<div class="logo">
|
|
2231
|
+
<div class="logo-icon">📚</div>
|
|
2232
|
+
<span>ebk Library</span>
|
|
2233
|
+
</div>
|
|
2234
|
+
</div>
|
|
2235
|
+
<nav class="sidebar-nav">
|
|
2236
|
+
<div class="nav-section">
|
|
2237
|
+
<div class="nav-section-title">Library</div>
|
|
2238
|
+
<div class="nav-item active" data-filter="all" onclick="setFilter('all')">
|
|
2239
|
+
<span class="nav-item-icon">📖</span>
|
|
2240
|
+
All Books
|
|
2241
|
+
<span class="nav-item-count" id="count-all">0</span>
|
|
2242
|
+
</div>
|
|
2243
|
+
<div class="nav-item" data-filter="favorites" onclick="setFilter('favorites')">
|
|
2244
|
+
<span class="nav-item-icon">⭐</span>
|
|
2245
|
+
Favorites
|
|
2246
|
+
<span class="nav-item-count" id="count-favorites">0</span>
|
|
2247
|
+
</div>
|
|
2248
|
+
<div class="nav-item" data-filter="reading" onclick="setFilter('reading')">
|
|
2249
|
+
<span class="nav-item-icon">📄</span>
|
|
2250
|
+
Currently Reading
|
|
2251
|
+
<span class="nav-item-count" id="count-reading">0</span>
|
|
2252
|
+
</div>
|
|
2253
|
+
<div class="nav-item" data-filter="completed" onclick="setFilter('completed')">
|
|
2254
|
+
<span class="nav-item-icon">✅</span>
|
|
2255
|
+
Completed
|
|
2256
|
+
<span class="nav-item-count" id="count-completed">0</span>
|
|
2257
|
+
</div>
|
|
2258
|
+
</div>
|
|
2259
|
+
<div class="nav-section">
|
|
2260
|
+
<div class="nav-section-title">Actions</div>
|
|
2261
|
+
<div class="nav-item" onclick="showImportModal()">
|
|
2262
|
+
<span class="nav-item-icon">➕</span>
|
|
2263
|
+
Import Book
|
|
2264
|
+
</div>
|
|
2265
|
+
<div class="nav-item" onclick="refreshBooks()">
|
|
2266
|
+
<span class="nav-item-icon">🔄</span>
|
|
2267
|
+
Refresh
|
|
2268
|
+
</div>
|
|
2269
|
+
</div>
|
|
2270
|
+
<div class="nav-section" id="views-section">
|
|
2271
|
+
<div class="nav-section-title">Views</div>
|
|
2272
|
+
<div id="views-list">
|
|
2273
|
+
<div class="nav-item" style="color: var(--text-muted); font-style: italic;">
|
|
2274
|
+
Loading views...
|
|
2275
|
+
</div>
|
|
2276
|
+
</div>
|
|
2277
|
+
</div>
|
|
2278
|
+
</nav>
|
|
2279
|
+
<div class="sidebar-footer">
|
|
2280
|
+
<div style="font-size: 0.75rem; color: var(--text-muted);">
|
|
2281
|
+
ebk Library Manager
|
|
2282
|
+
</div>
|
|
2283
|
+
</div>
|
|
2284
|
+
</aside>
|
|
2285
|
+
|
|
2286
|
+
<main class="main">
|
|
2287
|
+
<header class="header">
|
|
2288
|
+
<button class="menu-btn" onclick="toggleSidebar()">☰</button>
|
|
2289
|
+
<div class="search-box">
|
|
2290
|
+
<span class="search-icon">🔍</span>
|
|
2291
|
+
<input type="text" class="search-input" id="search-input"
|
|
2292
|
+
placeholder="Search books by title, author, description..." autocomplete="off">
|
|
2293
|
+
</div>
|
|
2294
|
+
<div class="header-actions">
|
|
2295
|
+
<button class="icon-btn active" id="view-grid" onclick="setView('grid')" title="Grid View">▦</button>
|
|
2296
|
+
<button class="icon-btn" id="view-list" onclick="setView('list')" title="List View">☰</button>
|
|
2297
|
+
<button class="icon-btn" id="view-table" onclick="setView('table')" title="Table View">▤</button>
|
|
2298
|
+
<button class="icon-btn" id="theme-toggle" onclick="toggleTheme()" title="Toggle Dark Mode">🌙</button>
|
|
2299
|
+
</div>
|
|
2300
|
+
</header>
|
|
2301
|
+
|
|
2302
|
+
<div class="stats-bar" id="stats-bar">
|
|
2303
|
+
<div class="stat-item">
|
|
2304
|
+
<div class="stat-icon books">📚</div>
|
|
2305
|
+
<div class="stat-content">
|
|
2306
|
+
<div class="stat-value" id="stat-books">0</div>
|
|
2307
|
+
<div class="stat-label">Books</div>
|
|
2308
|
+
</div>
|
|
2309
|
+
</div>
|
|
2310
|
+
<div class="stat-item">
|
|
2311
|
+
<div class="stat-icon authors">👤</div>
|
|
2312
|
+
<div class="stat-content">
|
|
2313
|
+
<div class="stat-value" id="stat-authors">0</div>
|
|
2314
|
+
<div class="stat-label">Authors</div>
|
|
2315
|
+
</div>
|
|
2316
|
+
</div>
|
|
2317
|
+
<div class="stat-item">
|
|
2318
|
+
<div class="stat-icon files">📄</div>
|
|
2319
|
+
<div class="stat-content">
|
|
2320
|
+
<div class="stat-value" id="stat-files">0</div>
|
|
2321
|
+
<div class="stat-label">Files</div>
|
|
2322
|
+
</div>
|
|
2323
|
+
</div>
|
|
2324
|
+
<div class="stat-item">
|
|
2325
|
+
<div class="stat-icon storage">💾</div>
|
|
2326
|
+
<div class="stat-content">
|
|
2327
|
+
<div class="stat-value" id="stat-storage">0 MB</div>
|
|
2328
|
+
<div class="stat-label">Storage</div>
|
|
2329
|
+
</div>
|
|
2330
|
+
</div>
|
|
2331
|
+
</div>
|
|
2332
|
+
|
|
2333
|
+
<div class="toolbar">
|
|
2334
|
+
<div class="toolbar-group">
|
|
2335
|
+
<span class="toolbar-label">Sort:</span>
|
|
2336
|
+
<select class="toolbar-select" id="sort-field" onchange="applyFilters()">
|
|
2337
|
+
<option value="title">Title</option>
|
|
2338
|
+
<option value="created_at">Date Added</option>
|
|
2339
|
+
<option value="publication_date">Published</option>
|
|
2340
|
+
<option value="rating">Rating</option>
|
|
2341
|
+
</select>
|
|
2342
|
+
<select class="toolbar-select" id="sort-order" onchange="applyFilters()">
|
|
2343
|
+
<option value="asc">A-Z</option>
|
|
2344
|
+
<option value="desc">Z-A</option>
|
|
2345
|
+
</select>
|
|
2346
|
+
</div>
|
|
2347
|
+
<div class="toolbar-divider"></div>
|
|
2348
|
+
<div class="toolbar-group">
|
|
2349
|
+
<span class="toolbar-label">Language:</span>
|
|
2350
|
+
<select class="toolbar-select" id="filter-language" onchange="applyFilters()">
|
|
2351
|
+
<option value="">All</option>
|
|
2352
|
+
</select>
|
|
2353
|
+
</div>
|
|
2354
|
+
<div class="toolbar-group">
|
|
2355
|
+
<span class="toolbar-label">Format:</span>
|
|
2356
|
+
<select class="toolbar-select" id="filter-format" onchange="applyFilters()">
|
|
2357
|
+
<option value="">All</option>
|
|
2358
|
+
</select>
|
|
2359
|
+
</div>
|
|
2360
|
+
<div class="toolbar-group">
|
|
2361
|
+
<span class="toolbar-label">Rating:</span>
|
|
2362
|
+
<select class="toolbar-select" id="filter-rating" onchange="applyFilters()">
|
|
2363
|
+
<option value="">Any</option>
|
|
2364
|
+
<option value="4">4+ Stars</option>
|
|
2365
|
+
<option value="3">3+ Stars</option>
|
|
2366
|
+
<option value="2">2+ Stars</option>
|
|
2367
|
+
<option value="1">1+ Stars</option>
|
|
2368
|
+
</select>
|
|
2369
|
+
</div>
|
|
2370
|
+
<button class="btn btn-outline" onclick="clearFilters()" style="padding: 8px 12px; font-size: 0.8rem;">
|
|
2371
|
+
Clear Filters
|
|
2372
|
+
</button>
|
|
2373
|
+
<div class="results-info" id="results-info"></div>
|
|
2374
|
+
</div>
|
|
2375
|
+
|
|
2376
|
+
<div id="message-container"></div>
|
|
2377
|
+
|
|
2378
|
+
<div class="content">
|
|
2379
|
+
<div id="loading" class="loading" style="display: none;">
|
|
2380
|
+
<div class="spinner"></div>
|
|
2381
|
+
<p>Loading books...</p>
|
|
2382
|
+
</div>
|
|
2383
|
+
<div id="book-container"></div>
|
|
2384
|
+
<div id="empty-state" class="empty-state" style="display: none;">
|
|
2385
|
+
<div class="empty-state-icon">🕮</div>
|
|
2386
|
+
<h3>No books found</h3>
|
|
2387
|
+
<p>Try adjusting your search or filters</p>
|
|
2388
|
+
</div>
|
|
2389
|
+
<div class="pagination" id="pagination"></div>
|
|
2390
|
+
</div>
|
|
2391
|
+
</main>
|
|
2392
|
+
</div>
|
|
2393
|
+
|
|
2394
|
+
<!-- Edit Modal -->
|
|
2395
|
+
<div id="edit-modal" class="modal">
|
|
2396
|
+
<div class="modal-content">
|
|
2397
|
+
<div class="modal-header">
|
|
2398
|
+
<h2 class="modal-title">Edit Book</h2>
|
|
2399
|
+
<button class="modal-close" onclick="closeModal('edit-modal')">×</button>
|
|
2400
|
+
</div>
|
|
2401
|
+
<div class="modal-body">
|
|
2402
|
+
<form id="edit-form" onsubmit="saveBook(event)">
|
|
2403
|
+
<input type="hidden" id="edit-book-id">
|
|
2404
|
+
<div class="form-group">
|
|
2405
|
+
<label class="form-label">Title</label>
|
|
2406
|
+
<input type="text" id="edit-title" class="form-control">
|
|
2407
|
+
</div>
|
|
2408
|
+
<div class="form-group">
|
|
2409
|
+
<label class="form-label">Subtitle</label>
|
|
2410
|
+
<input type="text" id="edit-subtitle" class="form-control">
|
|
2411
|
+
</div>
|
|
2412
|
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
|
|
2413
|
+
<div class="form-group">
|
|
2414
|
+
<label class="form-label">Language</label>
|
|
2415
|
+
<input type="text" id="edit-language" class="form-control">
|
|
2416
|
+
</div>
|
|
2417
|
+
<div class="form-group">
|
|
2418
|
+
<label class="form-label">Publisher</label>
|
|
2419
|
+
<input type="text" id="edit-publisher" class="form-control">
|
|
2420
|
+
</div>
|
|
2421
|
+
</div>
|
|
2422
|
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
|
|
2423
|
+
<div class="form-group">
|
|
2424
|
+
<label class="form-label">Publication Date</label>
|
|
2425
|
+
<input type="text" id="edit-publication-date" class="form-control" placeholder="YYYY-MM-DD">
|
|
2426
|
+
</div>
|
|
2427
|
+
<div class="form-group">
|
|
2428
|
+
<label class="form-label">Rating (1-5)</label>
|
|
2429
|
+
<input type="number" id="edit-rating" class="form-control" min="1" max="5" step="0.5">
|
|
2430
|
+
</div>
|
|
2431
|
+
</div>
|
|
2432
|
+
<div class="form-group">
|
|
2433
|
+
<label class="form-label">Description</label>
|
|
2434
|
+
<textarea id="edit-description" class="form-control"></textarea>
|
|
2435
|
+
</div>
|
|
2436
|
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
|
|
2437
|
+
<div class="form-group">
|
|
2438
|
+
<label class="form-label">Reading Status</label>
|
|
2439
|
+
<select id="edit-status" class="form-control">
|
|
2440
|
+
<option value="unread">Unread</option>
|
|
2441
|
+
<option value="reading">Reading</option>
|
|
2442
|
+
<option value="completed">Completed</option>
|
|
2443
|
+
</select>
|
|
2444
|
+
</div>
|
|
2445
|
+
<div class="form-group">
|
|
2446
|
+
<label class="form-label">Tags (comma-separated)</label>
|
|
2447
|
+
<input type="text" id="edit-tags" class="form-control">
|
|
2448
|
+
</div>
|
|
2449
|
+
</div>
|
|
2450
|
+
<div class="form-group">
|
|
2451
|
+
<label class="form-check">
|
|
2452
|
+
<input type="checkbox" id="edit-favorite">
|
|
2453
|
+
<span>Mark as Favorite</span>
|
|
2454
|
+
</label>
|
|
2455
|
+
</div>
|
|
2456
|
+
<div class="btn-group">
|
|
2457
|
+
<button type="submit" class="btn btn-primary">Save Changes</button>
|
|
2458
|
+
<button type="button" class="btn btn-danger" onclick="deleteBook()">Delete Book</button>
|
|
2459
|
+
<button type="button" class="btn btn-secondary" onclick="closeModal('edit-modal')">Cancel</button>
|
|
2460
|
+
</div>
|
|
2461
|
+
</form>
|
|
2462
|
+
</div>
|
|
2463
|
+
</div>
|
|
2464
|
+
</div>
|
|
2465
|
+
|
|
2466
|
+
<!-- Import Modal -->
|
|
2467
|
+
<div id="import-modal" class="modal">
|
|
2468
|
+
<div class="modal-content" style="max-width: 600px;">
|
|
2469
|
+
<div class="modal-header">
|
|
2470
|
+
<h2 class="modal-title">Import Books</h2>
|
|
2471
|
+
<button class="modal-close" onclick="closeModal('import-modal')">×</button>
|
|
2472
|
+
</div>
|
|
2473
|
+
<div class="modal-body">
|
|
2474
|
+
<div class="tabs">
|
|
2475
|
+
<button class="tab active" onclick="switchImportTab('file')">File</button>
|
|
2476
|
+
<button class="tab" onclick="switchImportTab('url')">URL</button>
|
|
2477
|
+
<button class="tab" onclick="switchImportTab('folder')">Folder</button>
|
|
2478
|
+
<button class="tab" onclick="switchImportTab('calibre')">Calibre</button>
|
|
2479
|
+
<button class="tab" onclick="switchImportTab('opds')">OPDS</button>
|
|
2480
|
+
<button class="tab" onclick="switchImportTab('isbn')">ISBN</button>
|
|
2481
|
+
</div>
|
|
2482
|
+
|
|
2483
|
+
<!-- Single File Import -->
|
|
2484
|
+
<div id="import-tab-file" class="tab-content active">
|
|
2485
|
+
<div class="import-help">
|
|
2486
|
+
Upload a single ebook file (PDF, EPUB, MOBI, AZW3, TXT)
|
|
2487
|
+
</div>
|
|
2488
|
+
<form id="import-form-file" onsubmit="importSingleFile(event)">
|
|
2489
|
+
<div class="form-group">
|
|
2490
|
+
<label class="form-label">Select File</label>
|
|
2491
|
+
<input type="file" id="import-file" class="form-control"
|
|
2492
|
+
accept=".pdf,.epub,.mobi,.azw,.azw3,.txt" required>
|
|
2493
|
+
</div>
|
|
2494
|
+
<div class="form-group">
|
|
2495
|
+
<label class="form-check">
|
|
2496
|
+
<input type="checkbox" id="import-extract-text" checked>
|
|
2497
|
+
<span>Extract full text for search</span>
|
|
2498
|
+
</label>
|
|
2499
|
+
</div>
|
|
2500
|
+
<div class="form-group">
|
|
2501
|
+
<label class="form-check">
|
|
2502
|
+
<input type="checkbox" id="import-extract-cover" checked>
|
|
2503
|
+
<span>Extract cover image</span>
|
|
2504
|
+
</label>
|
|
2505
|
+
</div>
|
|
2506
|
+
<div class="btn-group">
|
|
2507
|
+
<button type="submit" class="btn btn-success">Import File</button>
|
|
2508
|
+
<button type="button" class="btn btn-secondary" onclick="closeModal('import-modal')">Cancel</button>
|
|
2509
|
+
</div>
|
|
2510
|
+
</form>
|
|
2511
|
+
</div>
|
|
2512
|
+
|
|
2513
|
+
<!-- Folder Import -->
|
|
2514
|
+
<div id="import-tab-folder" class="tab-content">
|
|
2515
|
+
<div class="import-help">
|
|
2516
|
+
Import all ebook files from a folder on the server. Enter the absolute path to the folder.
|
|
2517
|
+
</div>
|
|
2518
|
+
<form id="import-form-folder" onsubmit="importFolder(event)">
|
|
2519
|
+
<div class="form-group">
|
|
2520
|
+
<label class="form-label">Folder Path</label>
|
|
2521
|
+
<input type="text" id="import-folder-path" class="form-control"
|
|
2522
|
+
placeholder="/path/to/ebooks" required>
|
|
2523
|
+
</div>
|
|
2524
|
+
<div class="form-group">
|
|
2525
|
+
<label class="form-label">File Extensions</label>
|
|
2526
|
+
<input type="text" id="import-folder-extensions" class="form-control"
|
|
2527
|
+
value="pdf,epub,mobi,azw3,txt" placeholder="pdf,epub,mobi">
|
|
2528
|
+
</div>
|
|
2529
|
+
<div class="form-group">
|
|
2530
|
+
<label class="form-label">Limit (optional)</label>
|
|
2531
|
+
<input type="number" id="import-folder-limit" class="form-control"
|
|
2532
|
+
placeholder="No limit" min="1">
|
|
2533
|
+
</div>
|
|
2534
|
+
<div class="form-group">
|
|
2535
|
+
<label class="form-check">
|
|
2536
|
+
<input type="checkbox" id="import-folder-recursive" checked>
|
|
2537
|
+
<span>Search subdirectories recursively</span>
|
|
2538
|
+
</label>
|
|
2539
|
+
</div>
|
|
2540
|
+
<div class="form-group">
|
|
2541
|
+
<label class="form-check">
|
|
2542
|
+
<input type="checkbox" id="import-folder-extract-text" checked>
|
|
2543
|
+
<span>Extract full text for search</span>
|
|
2544
|
+
</label>
|
|
2545
|
+
</div>
|
|
2546
|
+
<div class="form-group">
|
|
2547
|
+
<label class="form-check">
|
|
2548
|
+
<input type="checkbox" id="import-folder-extract-cover" checked>
|
|
2549
|
+
<span>Extract cover images</span>
|
|
2550
|
+
</label>
|
|
2551
|
+
</div>
|
|
2552
|
+
<div class="btn-group">
|
|
2553
|
+
<button type="submit" class="btn btn-success">Import Folder</button>
|
|
2554
|
+
<button type="button" class="btn btn-secondary" onclick="closeModal('import-modal')">Cancel</button>
|
|
2555
|
+
</div>
|
|
2556
|
+
</form>
|
|
2557
|
+
</div>
|
|
2558
|
+
|
|
2559
|
+
<!-- Calibre Import -->
|
|
2560
|
+
<div id="import-tab-calibre" class="tab-content">
|
|
2561
|
+
<div class="import-help">
|
|
2562
|
+
Import books from a Calibre library. Enter the path to the Calibre library folder
|
|
2563
|
+
(the folder containing <code>metadata.db</code>).
|
|
2564
|
+
</div>
|
|
2565
|
+
<form id="import-form-calibre" onsubmit="importCalibre(event)">
|
|
2566
|
+
<div class="form-group">
|
|
2567
|
+
<label class="form-label">Calibre Library Path</label>
|
|
2568
|
+
<input type="text" id="import-calibre-path" class="form-control"
|
|
2569
|
+
placeholder="/path/to/Calibre Library" required>
|
|
2570
|
+
</div>
|
|
2571
|
+
<div class="form-group">
|
|
2572
|
+
<label class="form-label">Limit (optional)</label>
|
|
2573
|
+
<input type="number" id="import-calibre-limit" class="form-control"
|
|
2574
|
+
placeholder="No limit" min="1">
|
|
2575
|
+
</div>
|
|
2576
|
+
<div class="btn-group">
|
|
2577
|
+
<button type="submit" class="btn btn-success">Import from Calibre</button>
|
|
2578
|
+
<button type="button" class="btn btn-secondary" onclick="closeModal('import-modal')">Cancel</button>
|
|
2579
|
+
</div>
|
|
2580
|
+
</form>
|
|
2581
|
+
</div>
|
|
2582
|
+
|
|
2583
|
+
<!-- URL Import -->
|
|
2584
|
+
<div id="import-tab-url" class="tab-content">
|
|
2585
|
+
<div class="import-help">
|
|
2586
|
+
Download and import an ebook from a direct URL. The URL must point directly to an ebook file (PDF, EPUB, MOBI, etc.).
|
|
2587
|
+
</div>
|
|
2588
|
+
<form id="import-form-url" onsubmit="importFromURL(event)">
|
|
2589
|
+
<div class="form-group">
|
|
2590
|
+
<label class="form-label">Book URL</label>
|
|
2591
|
+
<input type="url" id="import-url" class="form-control"
|
|
2592
|
+
placeholder="https://example.com/book.epub" required>
|
|
2593
|
+
</div>
|
|
2594
|
+
<div class="form-group">
|
|
2595
|
+
<label class="form-check">
|
|
2596
|
+
<input type="checkbox" id="import-url-extract-text" checked>
|
|
2597
|
+
<span>Extract full text for search</span>
|
|
2598
|
+
</label>
|
|
2599
|
+
</div>
|
|
2600
|
+
<div class="form-group">
|
|
2601
|
+
<label class="form-check">
|
|
2602
|
+
<input type="checkbox" id="import-url-extract-cover" checked>
|
|
2603
|
+
<span>Extract cover image</span>
|
|
2604
|
+
</label>
|
|
2605
|
+
</div>
|
|
2606
|
+
<div class="btn-group">
|
|
2607
|
+
<button type="submit" class="btn btn-success">Download & Import</button>
|
|
2608
|
+
<button type="button" class="btn btn-secondary" onclick="closeModal('import-modal')">Cancel</button>
|
|
2609
|
+
</div>
|
|
2610
|
+
</form>
|
|
2611
|
+
</div>
|
|
2612
|
+
|
|
2613
|
+
<!-- OPDS Import -->
|
|
2614
|
+
<div id="import-tab-opds" class="tab-content">
|
|
2615
|
+
<div class="import-help">
|
|
2616
|
+
Import books from an OPDS catalog feed. OPDS is a standard format used by many digital libraries and ebook servers.
|
|
2617
|
+
</div>
|
|
2618
|
+
<form id="import-form-opds" onsubmit="importFromOPDS(event)">
|
|
2619
|
+
<div class="form-group">
|
|
2620
|
+
<label class="form-label">OPDS Catalog URL</label>
|
|
2621
|
+
<input type="url" id="import-opds-url" class="form-control"
|
|
2622
|
+
placeholder="https://example.com/opds/catalog.xml" required>
|
|
2623
|
+
</div>
|
|
2624
|
+
<div class="form-group">
|
|
2625
|
+
<label class="form-label">Limit (optional)</label>
|
|
2626
|
+
<input type="number" id="import-opds-limit" class="form-control"
|
|
2627
|
+
placeholder="No limit" min="1">
|
|
2628
|
+
</div>
|
|
2629
|
+
<div class="form-group">
|
|
2630
|
+
<label class="form-check">
|
|
2631
|
+
<input type="checkbox" id="import-opds-extract-text" checked>
|
|
2632
|
+
<span>Extract full text for search</span>
|
|
2633
|
+
</label>
|
|
2634
|
+
</div>
|
|
2635
|
+
<div class="form-group">
|
|
2636
|
+
<label class="form-check">
|
|
2637
|
+
<input type="checkbox" id="import-opds-extract-cover" checked>
|
|
2638
|
+
<span>Extract cover images</span>
|
|
2639
|
+
</label>
|
|
2640
|
+
</div>
|
|
2641
|
+
<div class="btn-group">
|
|
2642
|
+
<button type="submit" class="btn btn-success">Import from OPDS</button>
|
|
2643
|
+
<button type="button" class="btn btn-secondary" onclick="closeModal('import-modal')">Cancel</button>
|
|
2644
|
+
</div>
|
|
2645
|
+
</form>
|
|
2646
|
+
</div>
|
|
2647
|
+
|
|
2648
|
+
<!-- ISBN Lookup Import -->
|
|
2649
|
+
<div id="import-tab-isbn" class="tab-content">
|
|
2650
|
+
<div class="import-help">
|
|
2651
|
+
Create a book entry by ISBN lookup. Fetches metadata from Google Books and Open Library.
|
|
2652
|
+
<strong>Note:</strong> This creates a metadata-only entry without an actual ebook file.
|
|
2653
|
+
</div>
|
|
2654
|
+
<form id="import-form-isbn" onsubmit="importFromISBN(event)">
|
|
2655
|
+
<div class="form-group">
|
|
2656
|
+
<label class="form-label">ISBN (10 or 13 digits)</label>
|
|
2657
|
+
<input type="text" id="import-isbn" class="form-control"
|
|
2658
|
+
placeholder="978-3-16-148410-0" required
|
|
2659
|
+
pattern="[0-9Xx\\-\\s]{10,17}">
|
|
2660
|
+
</div>
|
|
2661
|
+
<div class="btn-group">
|
|
2662
|
+
<button type="submit" class="btn btn-success">Lookup & Create</button>
|
|
2663
|
+
<button type="button" class="btn btn-secondary" onclick="closeModal('import-modal')">Cancel</button>
|
|
2664
|
+
</div>
|
|
2665
|
+
</form>
|
|
2666
|
+
</div>
|
|
2667
|
+
|
|
2668
|
+
<!-- Import Progress -->
|
|
2669
|
+
<div id="import-progress" style="display: none;">
|
|
2670
|
+
<div class="loading">
|
|
2671
|
+
<div class="spinner"></div>
|
|
2672
|
+
<p id="import-progress-text">Importing books...</p>
|
|
2673
|
+
</div>
|
|
2674
|
+
</div>
|
|
2675
|
+
|
|
2676
|
+
<!-- Import Results -->
|
|
2677
|
+
<div id="import-results" style="display: none;"></div>
|
|
2678
|
+
</div>
|
|
2679
|
+
</div>
|
|
2680
|
+
</div>
|
|
2681
|
+
|
|
2682
|
+
<!-- Details Modal -->
|
|
2683
|
+
<div id="details-modal" class="modal">
|
|
2684
|
+
<div class="modal-content">
|
|
2685
|
+
<div class="modal-header">
|
|
2686
|
+
<h2 class="modal-title" id="details-title"></h2>
|
|
2687
|
+
<button class="modal-close" onclick="closeModal('details-modal')">×</button>
|
|
2688
|
+
</div>
|
|
2689
|
+
<div class="modal-body" id="details-body"></div>
|
|
2690
|
+
</div>
|
|
2691
|
+
</div>
|
|
2692
|
+
|
|
2693
|
+
<div class="keyboard-hint">
|
|
2694
|
+
<kbd>/</kbd> Search <kbd>Esc</kbd> Close <kbd>g</kbd> Grid <kbd>l</kbd> List <kbd>t</kbd> Table
|
|
2695
|
+
</div>
|
|
2696
|
+
|
|
2697
|
+
<script>
|
|
2698
|
+
// State
|
|
2699
|
+
let books = [];
|
|
2700
|
+
let currentBookId = null;
|
|
2701
|
+
let currentPage = 1;
|
|
2702
|
+
let booksPerPage = 48;
|
|
2703
|
+
let totalBooks = 0;
|
|
2704
|
+
let currentView = 'grid';
|
|
2705
|
+
let currentFilter = 'all';
|
|
2706
|
+
let currentViewName = null; // Name of the view when filtering by view
|
|
2707
|
+
let isSearching = false;
|
|
2708
|
+
let availableViews = []; // Cached list of views
|
|
2709
|
+
|
|
2710
|
+
// Initialize
|
|
2711
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
2712
|
+
initTheme();
|
|
2713
|
+
loadStats();
|
|
2714
|
+
loadViews();
|
|
2715
|
+
restoreStateFromURL();
|
|
2716
|
+
loadBooks();
|
|
2717
|
+
setupEventListeners();
|
|
2718
|
+
});
|
|
2719
|
+
|
|
2720
|
+
function initTheme() {
|
|
2721
|
+
const saved = localStorage.getItem('ebk_theme');
|
|
2722
|
+
if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
2723
|
+
document.documentElement.setAttribute('data-theme', 'dark');
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
function toggleTheme() {
|
|
2728
|
+
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
|
2729
|
+
document.documentElement.setAttribute('data-theme', isDark ? 'light' : 'dark');
|
|
2730
|
+
localStorage.setItem('ebk_theme', isDark ? 'light' : 'dark');
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
function toggleSidebar() {
|
|
2734
|
+
document.getElementById('sidebar').classList.toggle('open');
|
|
2735
|
+
document.querySelector('.sidebar-overlay').classList.toggle('active');
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
function setupEventListeners() {
|
|
2739
|
+
// Search debouncing
|
|
2740
|
+
let searchTimeout;
|
|
2741
|
+
document.getElementById('search-input').addEventListener('input', (e) => {
|
|
2742
|
+
clearTimeout(searchTimeout);
|
|
2743
|
+
searchTimeout = setTimeout(() => {
|
|
2744
|
+
if (e.target.value.length >= 2) {
|
|
2745
|
+
isSearching = true;
|
|
2746
|
+
currentPage = 1;
|
|
2747
|
+
updateURL();
|
|
2748
|
+
searchBooks(e.target.value);
|
|
2749
|
+
} else if (e.target.value.length === 0) {
|
|
2750
|
+
isSearching = false;
|
|
2751
|
+
currentPage = 1;
|
|
2752
|
+
updateURL();
|
|
2753
|
+
loadBooks();
|
|
2754
|
+
}
|
|
2755
|
+
}, 300);
|
|
2756
|
+
});
|
|
2757
|
+
|
|
2758
|
+
// Keyboard shortcuts
|
|
2759
|
+
document.addEventListener('keydown', (e) => {
|
|
2760
|
+
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
|
|
2761
|
+
if (e.key === 'Escape') e.target.blur();
|
|
2762
|
+
return;
|
|
2763
|
+
}
|
|
2764
|
+
if (e.key === '/') { e.preventDefault(); document.getElementById('search-input').focus(); }
|
|
2765
|
+
else if (e.key === 'Escape') { closeAllModals(); }
|
|
2766
|
+
else if (e.key === 'g') { setView('grid'); }
|
|
2767
|
+
else if (e.key === 'l') { setView('list'); }
|
|
2768
|
+
else if (e.key === 't') { setView('table'); }
|
|
2769
|
+
});
|
|
2770
|
+
|
|
2771
|
+
// Browser back/forward
|
|
2772
|
+
window.addEventListener('popstate', () => {
|
|
2773
|
+
restoreStateFromURL();
|
|
2774
|
+
loadBooks();
|
|
2775
|
+
});
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
function updateURL() {
|
|
2779
|
+
const params = new URLSearchParams();
|
|
2780
|
+
if (currentPage > 1) params.set('page', currentPage);
|
|
2781
|
+
const searchQuery = document.getElementById('search-input').value;
|
|
2782
|
+
if (searchQuery) params.set('q', searchQuery);
|
|
2783
|
+
if (currentFilter !== 'all') params.set('filter', currentFilter);
|
|
2784
|
+
const language = document.getElementById('filter-language').value;
|
|
2785
|
+
const format = document.getElementById('filter-format').value;
|
|
2786
|
+
const rating = document.getElementById('filter-rating').value;
|
|
2787
|
+
if (language) params.set('language', language);
|
|
2788
|
+
if (format) params.set('format', format);
|
|
2789
|
+
if (rating) params.set('rating', rating);
|
|
2790
|
+
const sortField = document.getElementById('sort-field').value;
|
|
2791
|
+
const sortOrder = document.getElementById('sort-order').value;
|
|
2792
|
+
if (sortField !== 'title') params.set('sort', sortField);
|
|
2793
|
+
if (sortOrder !== 'asc') params.set('order', sortOrder);
|
|
2794
|
+
const newURL = params.toString() ? '?' + params.toString() : window.location.pathname;
|
|
2795
|
+
window.history.pushState({}, '', newURL);
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
function restoreStateFromURL() {
|
|
2799
|
+
const params = new URLSearchParams(window.location.search);
|
|
2800
|
+
currentPage = parseInt(params.get('page')) || 1;
|
|
2801
|
+
const searchQuery = params.get('q') || '';
|
|
2802
|
+
document.getElementById('search-input').value = searchQuery;
|
|
2803
|
+
isSearching = searchQuery.length >= 2;
|
|
2804
|
+
currentFilter = params.get('filter') || 'all';
|
|
2805
|
+
document.getElementById('filter-language').value = params.get('language') || '';
|
|
2806
|
+
document.getElementById('filter-format').value = params.get('format') || '';
|
|
2807
|
+
document.getElementById('filter-rating').value = params.get('rating') || '';
|
|
2808
|
+
document.getElementById('sort-field').value = params.get('sort') || 'title';
|
|
2809
|
+
document.getElementById('sort-order').value = params.get('order') || 'asc';
|
|
2810
|
+
updateNavItems();
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
let libraryStats = null;
|
|
2814
|
+
|
|
2815
|
+
async function loadStats() {
|
|
2816
|
+
try {
|
|
2817
|
+
const response = await fetch('/api/stats');
|
|
2818
|
+
libraryStats = await response.json();
|
|
2819
|
+
|
|
2820
|
+
document.getElementById('stat-books').textContent = libraryStats.total_books;
|
|
2821
|
+
document.getElementById('stat-authors').textContent = libraryStats.total_authors;
|
|
2822
|
+
document.getElementById('stat-files').textContent = libraryStats.total_files;
|
|
2823
|
+
document.getElementById('stat-storage').textContent = libraryStats.total_size_mb.toFixed(1) + ' MB';
|
|
2824
|
+
|
|
2825
|
+
// Update sidebar counts
|
|
2826
|
+
document.getElementById('count-all').textContent = libraryStats.total_books;
|
|
2827
|
+
document.getElementById('count-favorites').textContent = libraryStats.favorites_count;
|
|
2828
|
+
document.getElementById('count-reading').textContent = libraryStats.reading_count;
|
|
2829
|
+
document.getElementById('count-completed').textContent = libraryStats.completed_count;
|
|
2830
|
+
|
|
2831
|
+
// Populate dropdowns
|
|
2832
|
+
const langSelect = document.getElementById('filter-language');
|
|
2833
|
+
libraryStats.languages.forEach(lang => {
|
|
2834
|
+
const opt = document.createElement('option');
|
|
2835
|
+
opt.value = lang;
|
|
2836
|
+
opt.textContent = lang.toUpperCase();
|
|
2837
|
+
langSelect.appendChild(opt);
|
|
2838
|
+
});
|
|
2839
|
+
|
|
2840
|
+
const formatSelect = document.getElementById('filter-format');
|
|
2841
|
+
libraryStats.formats.forEach(fmt => {
|
|
2842
|
+
const opt = document.createElement('option');
|
|
2843
|
+
opt.value = fmt;
|
|
2844
|
+
opt.textContent = fmt.toUpperCase();
|
|
2845
|
+
formatSelect.appendChild(opt);
|
|
2846
|
+
});
|
|
2847
|
+
} catch (error) {
|
|
2848
|
+
console.error('Error loading stats:', error);
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
function buildQueryParams() {
|
|
2853
|
+
const params = new URLSearchParams();
|
|
2854
|
+
const offset = (currentPage - 1) * booksPerPage;
|
|
2855
|
+
params.append('limit', booksPerPage);
|
|
2856
|
+
params.append('offset', offset);
|
|
2857
|
+
|
|
2858
|
+
// Sidebar filter
|
|
2859
|
+
if (currentFilter === 'favorites') params.append('favorite', 'true');
|
|
2860
|
+
if (currentFilter === 'reading') params.append('reading_status', 'reading');
|
|
2861
|
+
if (currentFilter === 'completed') params.append('reading_status', 'completed');
|
|
2862
|
+
|
|
2863
|
+
const language = document.getElementById('filter-language').value;
|
|
2864
|
+
const format = document.getElementById('filter-format').value;
|
|
2865
|
+
const rating = document.getElementById('filter-rating').value;
|
|
2866
|
+
if (language) params.append('language', language);
|
|
2867
|
+
if (format) params.append('format_filter', format);
|
|
2868
|
+
if (rating) params.append('rating', rating);
|
|
2869
|
+
|
|
2870
|
+
const sortField = document.getElementById('sort-field').value;
|
|
2871
|
+
const sortOrder = document.getElementById('sort-order').value;
|
|
2872
|
+
params.append('sort', sortField);
|
|
2873
|
+
params.append('order', sortOrder);
|
|
2874
|
+
|
|
2875
|
+
return params.toString();
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
async function loadBooks() {
|
|
2879
|
+
const loading = document.getElementById('loading');
|
|
2880
|
+
const container = document.getElementById('book-container');
|
|
2881
|
+
const emptyState = document.getElementById('empty-state');
|
|
2882
|
+
|
|
2883
|
+
loading.style.display = 'flex';
|
|
2884
|
+
container.innerHTML = '';
|
|
2885
|
+
emptyState.style.display = 'none';
|
|
2886
|
+
|
|
2887
|
+
try {
|
|
2888
|
+
const queryParams = buildQueryParams();
|
|
2889
|
+
const response = await fetch('/api/books?' + queryParams);
|
|
2890
|
+
const data = await response.json();
|
|
2891
|
+
|
|
2892
|
+
books = data.items;
|
|
2893
|
+
totalBooks = data.total;
|
|
2894
|
+
|
|
2895
|
+
if (books.length === 0) {
|
|
2896
|
+
emptyState.style.display = 'block';
|
|
2897
|
+
} else {
|
|
2898
|
+
renderBooks();
|
|
2899
|
+
}
|
|
2900
|
+
updatePagination();
|
|
2901
|
+
updateResultsInfo();
|
|
2902
|
+
updateNavCounts();
|
|
2903
|
+
} catch (error) {
|
|
2904
|
+
showError('Failed to load books: ' + error.message);
|
|
2905
|
+
} finally {
|
|
2906
|
+
loading.style.display = 'none';
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
async function searchBooks(query) {
|
|
2911
|
+
const loading = document.getElementById('loading');
|
|
2912
|
+
const container = document.getElementById('book-container');
|
|
2913
|
+
const emptyState = document.getElementById('empty-state');
|
|
2914
|
+
|
|
2915
|
+
loading.style.display = 'flex';
|
|
2916
|
+
container.innerHTML = '';
|
|
2917
|
+
emptyState.style.display = 'none';
|
|
2918
|
+
|
|
2919
|
+
try {
|
|
2920
|
+
const response = await fetch('/api/search?q=' + encodeURIComponent(query));
|
|
2921
|
+
books = await response.json();
|
|
2922
|
+
totalBooks = books.length;
|
|
2923
|
+
|
|
2924
|
+
if (books.length === 0) {
|
|
2925
|
+
emptyState.style.display = 'block';
|
|
2926
|
+
} else {
|
|
2927
|
+
renderBooks();
|
|
2928
|
+
}
|
|
2929
|
+
updateResultsInfo();
|
|
2930
|
+
document.getElementById('pagination').innerHTML = '';
|
|
2931
|
+
} catch (error) {
|
|
2932
|
+
showError('Search failed: ' + error.message);
|
|
2933
|
+
} finally {
|
|
2934
|
+
loading.style.display = 'none';
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
function renderBooks() {
|
|
2939
|
+
const container = document.getElementById('book-container');
|
|
2940
|
+
if (currentView === 'grid') {
|
|
2941
|
+
container.innerHTML = '<div class="book-grid">' + books.map(renderGridCard).join('') + '</div>';
|
|
2942
|
+
} else if (currentView === 'list') {
|
|
2943
|
+
container.innerHTML = '<div class="book-list">' + books.map(renderListItem).join('') + '</div>';
|
|
2944
|
+
} else {
|
|
2945
|
+
container.innerHTML = renderTable();
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2948
|
+
|
|
2949
|
+
function renderGridCard(book) {
|
|
2950
|
+
const author = book.authors.join(', ') || 'Unknown Author';
|
|
2951
|
+
const rating = book.rating ? '★'.repeat(Math.round(book.rating)) : '';
|
|
2952
|
+
return '<div class="book-card" onclick="showBookDetails(' + book.id + ')">' +
|
|
2953
|
+
'<div class="book-cover">' +
|
|
2954
|
+
(book.cover_path ?
|
|
2955
|
+
'<img src="/api/books/' + book.id + '/cover" alt="" loading="lazy" onerror="this.parentElement.innerHTML=\\'<div class=book-cover-placeholder>📖</div>\\'">' :
|
|
2956
|
+
'<div class="book-cover-placeholder">📖</div>') +
|
|
2957
|
+
(book.favorite ? '<span class="book-favorite">⭐</span>' : '') +
|
|
2958
|
+
'</div>' +
|
|
2959
|
+
'<div class="book-info">' +
|
|
2960
|
+
'<div class="book-title">' + escapeHtml(book.title) + '</div>' +
|
|
2961
|
+
'<div class="book-author">' + escapeHtml(author) + '</div>' +
|
|
2962
|
+
'<div class="book-meta">' +
|
|
2963
|
+
book.files.map(f => '<span class="badge badge-format">' + f.format.toUpperCase() + '</span>').join('') +
|
|
2964
|
+
(book.language ? '<span class="badge badge-language">' + book.language.toUpperCase() + '</span>' : '') +
|
|
2965
|
+
'</div>' +
|
|
2966
|
+
(rating ? '<div class="book-rating">' + rating + '</div>' : '') +
|
|
2967
|
+
'</div>' +
|
|
2968
|
+
'</div>';
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
function renderListItem(book) {
|
|
2972
|
+
const author = book.authors.join(', ') || 'Unknown Author';
|
|
2973
|
+
return '<div class="book-list-item" onclick="showBookDetails(' + book.id + ')">' +
|
|
2974
|
+
'<div class="book-list-cover">' +
|
|
2975
|
+
(book.cover_path ? '<img src="/api/books/' + book.id + '/cover" alt="" loading="lazy">' : '') +
|
|
2976
|
+
'</div>' +
|
|
2977
|
+
'<div class="book-list-info">' +
|
|
2978
|
+
'<div class="book-list-title">' + (book.favorite ? '⭐ ' : '') + escapeHtml(book.title) + '</div>' +
|
|
2979
|
+
'<div class="book-list-author">' + escapeHtml(author) + '</div>' +
|
|
2980
|
+
'<div class="book-list-meta">' +
|
|
2981
|
+
(book.publication_date ? '<span>📅 ' + book.publication_date + '</span>' : '') +
|
|
2982
|
+
(book.language ? '<span>🌐 ' + book.language.toUpperCase() + '</span>' : '') +
|
|
2983
|
+
book.files.map(f => '<span>📄 ' + f.format.toUpperCase() + '</span>').join('') +
|
|
2984
|
+
(book.rating ? '<span>⭐ ' + book.rating + '</span>' : '') +
|
|
2985
|
+
'</div>' +
|
|
2986
|
+
'</div>' +
|
|
2987
|
+
'</div>';
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
function renderTable() {
|
|
2991
|
+
return '<table class="book-table">' +
|
|
2992
|
+
'<thead><tr>' +
|
|
2993
|
+
'<th>Title</th>' +
|
|
2994
|
+
'<th>Author</th>' +
|
|
2995
|
+
'<th>Year</th>' +
|
|
2996
|
+
'<th>Format</th>' +
|
|
2997
|
+
'<th>Rating</th>' +
|
|
2998
|
+
'</tr></thead>' +
|
|
2999
|
+
'<tbody>' +
|
|
3000
|
+
books.map(book => '<tr>' +
|
|
3001
|
+
'<td><span class="table-title" onclick="showBookDetails(' + book.id + ')">' +
|
|
3002
|
+
(book.favorite ? '⭐ ' : '') + escapeHtml(book.title) + '</span></td>' +
|
|
3003
|
+
'<td>' + escapeHtml(book.authors.join(', ') || '-') + '</td>' +
|
|
3004
|
+
'<td>' + (book.publication_date ? book.publication_date.substring(0,4) : '-') + '</td>' +
|
|
3005
|
+
'<td>' + book.files.map(f => f.format.toUpperCase()).join(', ') + '</td>' +
|
|
3006
|
+
'<td>' + (book.rating ? '★'.repeat(Math.round(book.rating)) : '-') + '</td>' +
|
|
3007
|
+
'</tr>').join('') +
|
|
3008
|
+
'</tbody></table>';
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
function setView(view) {
|
|
3012
|
+
currentView = view;
|
|
3013
|
+
document.querySelectorAll('.header-actions .icon-btn').forEach(b => b.classList.remove('active'));
|
|
3014
|
+
document.getElementById('view-' + view).classList.add('active');
|
|
3015
|
+
renderBooks();
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
function setFilter(filter) {
|
|
3019
|
+
currentFilter = filter;
|
|
3020
|
+
currentViewName = null; // Clear view selection
|
|
3021
|
+
currentPage = 1;
|
|
3022
|
+
updateNavItems();
|
|
3023
|
+
updateViewNavItems();
|
|
3024
|
+
updateURL();
|
|
3025
|
+
loadBooks();
|
|
3026
|
+
if (window.innerWidth < 1024) toggleSidebar();
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
function updateNavItems() {
|
|
3030
|
+
document.querySelectorAll('.nav-item[data-filter]').forEach(item => {
|
|
3031
|
+
item.classList.toggle('active', item.dataset.filter === currentFilter);
|
|
3032
|
+
});
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
function updateNavCounts() {
|
|
3036
|
+
// Update counts from cached stats
|
|
3037
|
+
if (libraryStats) {
|
|
3038
|
+
document.getElementById('count-all').textContent = libraryStats.total_books;
|
|
3039
|
+
document.getElementById('count-favorites').textContent = libraryStats.favorites_count;
|
|
3040
|
+
document.getElementById('count-reading').textContent = libraryStats.reading_count;
|
|
3041
|
+
document.getElementById('count-completed').textContent = libraryStats.completed_count;
|
|
3042
|
+
}
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
// Views management
|
|
3046
|
+
async function loadViews() {
|
|
3047
|
+
try {
|
|
3048
|
+
const response = await fetch('/api/views');
|
|
3049
|
+
if (response.ok) {
|
|
3050
|
+
availableViews = await response.json();
|
|
3051
|
+
renderViewsList();
|
|
3052
|
+
}
|
|
3053
|
+
} catch (error) {
|
|
3054
|
+
console.error('Failed to load views:', error);
|
|
3055
|
+
document.getElementById('views-list').innerHTML =
|
|
3056
|
+
'<div class="nav-item" style="color: var(--text-muted);">Failed to load views</div>';
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
function renderViewsList() {
|
|
3061
|
+
const container = document.getElementById('views-list');
|
|
3062
|
+
|
|
3063
|
+
// Filter to only show user-defined views (not built-ins which are in Library section)
|
|
3064
|
+
const userViews = availableViews.filter(v => !v.builtin);
|
|
3065
|
+
|
|
3066
|
+
if (userViews.length === 0) {
|
|
3067
|
+
container.innerHTML = '<div class="nav-item" style="color: var(--text-muted); font-style: italic;">No custom views</div>';
|
|
3068
|
+
return;
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
container.innerHTML = userViews.map(view => `
|
|
3072
|
+
<div class="nav-item ${currentFilter === 'view' && currentViewName === view.name ? 'active' : ''}"
|
|
3073
|
+
data-view="${escapeHtml(view.name)}"
|
|
3074
|
+
onclick="setViewFilter('${escapeHtml(view.name)}')"
|
|
3075
|
+
title="${escapeHtml(view.description || '')}">
|
|
3076
|
+
<span class="nav-item-icon">📑</span>
|
|
3077
|
+
${escapeHtml(view.name)}
|
|
3078
|
+
${view.count !== null ? `<span class="nav-item-count">${view.count}</span>` : ''}
|
|
3079
|
+
</div>
|
|
3080
|
+
`).join('');
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
async function setViewFilter(viewName) {
|
|
3084
|
+
currentFilter = 'view';
|
|
3085
|
+
currentViewName = viewName;
|
|
3086
|
+
currentPage = 1;
|
|
3087
|
+
updateNavItems();
|
|
3088
|
+
updateViewNavItems();
|
|
3089
|
+
updateURL();
|
|
3090
|
+
|
|
3091
|
+
// Load books from the view
|
|
3092
|
+
try {
|
|
3093
|
+
const response = await fetch(`/api/views/${encodeURIComponent(viewName)}/books?limit=${booksPerPage}&offset=${(currentPage-1)*booksPerPage}`);
|
|
3094
|
+
if (response.ok) {
|
|
3095
|
+
const data = await response.json();
|
|
3096
|
+
books = data.items;
|
|
3097
|
+
totalBooks = data.total;
|
|
3098
|
+
renderBooks();
|
|
3099
|
+
updateResultsInfo();
|
|
3100
|
+
} else {
|
|
3101
|
+
showNotification('Failed to load view', 'error');
|
|
3102
|
+
}
|
|
3103
|
+
} catch (error) {
|
|
3104
|
+
console.error('Failed to load view:', error);
|
|
3105
|
+
showNotification('Failed to load view', 'error');
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
if (window.innerWidth < 1024) toggleSidebar();
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
function updateViewNavItems() {
|
|
3112
|
+
document.querySelectorAll('.nav-item[data-view]').forEach(item => {
|
|
3113
|
+
item.classList.toggle('active', currentFilter === 'view' && item.dataset.view === currentViewName);
|
|
3114
|
+
});
|
|
3115
|
+
}
|
|
3116
|
+
|
|
3117
|
+
function updateResultsInfo() {
|
|
3118
|
+
const info = document.getElementById('results-info');
|
|
3119
|
+
if (isSearching) {
|
|
3120
|
+
info.textContent = totalBooks + ' results found';
|
|
3121
|
+
} else if (currentFilter === 'view' && currentViewName) {
|
|
3122
|
+
const start = (currentPage - 1) * booksPerPage + 1;
|
|
3123
|
+
const end = Math.min(currentPage * booksPerPage, totalBooks);
|
|
3124
|
+
info.textContent = `View: ${currentViewName} - Showing ${start}-${end} of ${totalBooks} books`;
|
|
3125
|
+
} else {
|
|
3126
|
+
const start = (currentPage - 1) * booksPerPage + 1;
|
|
3127
|
+
const end = Math.min(currentPage * booksPerPage, totalBooks);
|
|
3128
|
+
info.textContent = 'Showing ' + start + '-' + end + ' of ' + totalBooks + ' books';
|
|
3129
|
+
}
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
function updatePagination() {
|
|
3133
|
+
const pagination = document.getElementById('pagination');
|
|
3134
|
+
const totalPages = Math.ceil(totalBooks / booksPerPage);
|
|
3135
|
+
|
|
3136
|
+
if (totalPages <= 1) {
|
|
3137
|
+
pagination.innerHTML = '';
|
|
3138
|
+
return;
|
|
3139
|
+
}
|
|
3140
|
+
|
|
3141
|
+
let html = '<button class="page-btn" onclick="goToPage(' + (currentPage - 1) + ')" ' +
|
|
3142
|
+
(currentPage === 1 ? 'disabled' : '') + '>← Prev</button>';
|
|
3143
|
+
|
|
3144
|
+
const start = Math.max(1, currentPage - 2);
|
|
3145
|
+
const end = Math.min(totalPages, currentPage + 2);
|
|
3146
|
+
|
|
3147
|
+
if (start > 1) html += '<button class="page-btn" onclick="goToPage(1)">1</button>';
|
|
3148
|
+
if (start > 2) html += '<span class="page-info">...</span>';
|
|
3149
|
+
|
|
3150
|
+
for (let i = start; i <= end; i++) {
|
|
3151
|
+
html += '<button class="page-btn' + (i === currentPage ? ' active' : '') + '" onclick="goToPage(' + i + ')">' + i + '</button>';
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
if (end < totalPages - 1) html += '<span class="page-info">...</span>';
|
|
3155
|
+
if (end < totalPages) html += '<button class="page-btn" onclick="goToPage(' + totalPages + ')">' + totalPages + '</button>';
|
|
3156
|
+
|
|
3157
|
+
html += '<button class="page-btn" onclick="goToPage(' + (currentPage + 1) + ')" ' +
|
|
3158
|
+
(currentPage === totalPages ? 'disabled' : '') + '>Next →</button>';
|
|
3159
|
+
|
|
3160
|
+
pagination.innerHTML = html;
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
function goToPage(page) {
|
|
3164
|
+
const totalPages = Math.ceil(totalBooks / booksPerPage);
|
|
3165
|
+
if (page < 1 || page > totalPages) return;
|
|
3166
|
+
currentPage = page;
|
|
3167
|
+
updateURL();
|
|
3168
|
+
loadBooks();
|
|
3169
|
+
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
function applyFilters() {
|
|
3173
|
+
currentPage = 1;
|
|
3174
|
+
updateURL();
|
|
3175
|
+
loadBooks();
|
|
3176
|
+
}
|
|
3177
|
+
|
|
3178
|
+
function clearFilters() {
|
|
3179
|
+
document.getElementById('sort-field').value = 'title';
|
|
3180
|
+
document.getElementById('sort-order').value = 'asc';
|
|
3181
|
+
document.getElementById('filter-language').value = '';
|
|
3182
|
+
document.getElementById('filter-format').value = '';
|
|
3183
|
+
document.getElementById('filter-rating').value = '';
|
|
3184
|
+
document.getElementById('search-input').value = '';
|
|
3185
|
+
currentFilter = 'all';
|
|
3186
|
+
isSearching = false;
|
|
3187
|
+
currentPage = 1;
|
|
3188
|
+
updateNavItems();
|
|
3189
|
+
updateURL();
|
|
3190
|
+
loadBooks();
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
async function showBookDetails(bookId) {
|
|
3194
|
+
try {
|
|
3195
|
+
const response = await fetch('/api/books/' + bookId);
|
|
3196
|
+
const book = await response.json();
|
|
3197
|
+
|
|
3198
|
+
document.getElementById('details-title').textContent = book.title;
|
|
3199
|
+
|
|
3200
|
+
let html = '';
|
|
3201
|
+
|
|
3202
|
+
if (book.cover_path) {
|
|
3203
|
+
html += '<div class="modal-cover"><img src="/api/books/' + book.id + '/cover" alt="" onerror="this.style.display=\\'none\\'"></div>';
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
if (book.files && book.files.length > 0) {
|
|
3207
|
+
html += '<div class="detail-section"><div class="detail-label">Download</div><div class="detail-tags">' +
|
|
3208
|
+
book.files.map(f =>
|
|
3209
|
+
'<a href="/api/books/' + book.id + '/files/' + f.format.toLowerCase() + '" target="_blank" class="file-btn">' +
|
|
3210
|
+
'📄 ' + f.format.toUpperCase() + ' <span style="opacity:0.7">(' + formatBytes(f.size_bytes) + ')</span></a>'
|
|
3211
|
+
).join('') + '</div></div>';
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
if (book.authors && book.authors.length > 0) {
|
|
3215
|
+
html += '<div class="detail-section"><div class="detail-label">Authors</div><div class="detail-value">' + book.authors.join(', ') + '</div></div>';
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
if (book.subtitle) {
|
|
3219
|
+
html += '<div class="detail-section"><div class="detail-label">Subtitle</div><div class="detail-value">' + escapeHtml(book.subtitle) + '</div></div>';
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
if (book.description) {
|
|
3223
|
+
html += '<div class="detail-section"><div class="detail-label">Description</div><div class="detail-value">' + book.description + '</div></div>';
|
|
3224
|
+
}
|
|
3225
|
+
|
|
3226
|
+
const meta = [];
|
|
3227
|
+
if (book.publisher) meta.push('Publisher: ' + book.publisher);
|
|
3228
|
+
if (book.publication_date) meta.push('Published: ' + book.publication_date);
|
|
3229
|
+
if (book.language) meta.push('Language: ' + book.language.toUpperCase());
|
|
3230
|
+
if (book.rating) meta.push('Rating: ' + '★'.repeat(Math.round(book.rating)) + ' (' + book.rating + '/5)');
|
|
3231
|
+
if (book.reading_status) meta.push('Status: ' + book.reading_status);
|
|
3232
|
+
|
|
3233
|
+
if (meta.length > 0) {
|
|
3234
|
+
html += '<div class="detail-section"><div class="detail-label">Details</div><div class="detail-value">' + meta.join(' • ') + '</div></div>';
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
if (book.subjects && book.subjects.length > 0) {
|
|
3238
|
+
html += '<div class="detail-section"><div class="detail-label">Subjects</div><div class="detail-tags">' +
|
|
3239
|
+
book.subjects.map(s => '<span class="tag">' + escapeHtml(s) + '</span>').join('') + '</div></div>';
|
|
3240
|
+
}
|
|
3241
|
+
|
|
3242
|
+
if (book.tags && book.tags.length > 0) {
|
|
3243
|
+
html += '<div class="detail-section"><div class="detail-label">Tags</div><div class="detail-tags">' +
|
|
3244
|
+
book.tags.map(t => '<span class="tag">' + escapeHtml(t) + '</span>').join('') + '</div></div>';
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3247
|
+
html += '<div style="margin-top: 24px;"><button class="btn btn-primary" onclick="closeModal(\\'details-modal\\'); editBook(' + book.id + ');">✎ Edit Metadata</button></div>';
|
|
3248
|
+
|
|
3249
|
+
document.getElementById('details-body').innerHTML = html;
|
|
3250
|
+
document.getElementById('details-modal').classList.add('active');
|
|
3251
|
+
} catch (error) {
|
|
3252
|
+
showError('Failed to load book details: ' + error.message);
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
|
|
3256
|
+
async function editBook(bookId) {
|
|
3257
|
+
currentBookId = bookId;
|
|
3258
|
+
try {
|
|
3259
|
+
const response = await fetch('/api/books/' + bookId);
|
|
3260
|
+
const book = await response.json();
|
|
3261
|
+
|
|
3262
|
+
document.getElementById('edit-book-id').value = book.id;
|
|
3263
|
+
document.getElementById('edit-title').value = book.title || '';
|
|
3264
|
+
document.getElementById('edit-subtitle').value = book.subtitle || '';
|
|
3265
|
+
document.getElementById('edit-language').value = book.language || '';
|
|
3266
|
+
document.getElementById('edit-publisher').value = book.publisher || '';
|
|
3267
|
+
document.getElementById('edit-publication-date').value = book.publication_date || '';
|
|
3268
|
+
document.getElementById('edit-description').value = book.description || '';
|
|
3269
|
+
document.getElementById('edit-rating').value = book.rating || '';
|
|
3270
|
+
document.getElementById('edit-status').value = book.reading_status || 'unread';
|
|
3271
|
+
document.getElementById('edit-favorite').checked = book.favorite || false;
|
|
3272
|
+
document.getElementById('edit-tags').value = (book.tags || []).join(', ');
|
|
3273
|
+
|
|
3274
|
+
document.getElementById('edit-modal').classList.add('active');
|
|
3275
|
+
} catch (error) {
|
|
3276
|
+
showError('Failed to load book: ' + error.message);
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
|
|
3280
|
+
async function saveBook(event) {
|
|
3281
|
+
event.preventDefault();
|
|
3282
|
+
const bookId = document.getElementById('edit-book-id').value;
|
|
3283
|
+
const tags = document.getElementById('edit-tags').value.split(',').map(t => t.trim()).filter(t => t);
|
|
3284
|
+
|
|
3285
|
+
const update = {
|
|
3286
|
+
title: document.getElementById('edit-title').value,
|
|
3287
|
+
subtitle: document.getElementById('edit-subtitle').value,
|
|
3288
|
+
language: document.getElementById('edit-language').value,
|
|
3289
|
+
publisher: document.getElementById('edit-publisher').value,
|
|
3290
|
+
publication_date: document.getElementById('edit-publication-date').value,
|
|
3291
|
+
description: document.getElementById('edit-description').value,
|
|
3292
|
+
rating: parseFloat(document.getElementById('edit-rating').value) || null,
|
|
3293
|
+
reading_status: document.getElementById('edit-status').value,
|
|
3294
|
+
favorite: document.getElementById('edit-favorite').checked,
|
|
3295
|
+
tags: tags
|
|
3296
|
+
};
|
|
3297
|
+
|
|
3298
|
+
try {
|
|
3299
|
+
const response = await fetch('/api/books/' + bookId, {
|
|
3300
|
+
method: 'PATCH',
|
|
3301
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3302
|
+
body: JSON.stringify(update)
|
|
3303
|
+
});
|
|
3304
|
+
if (!response.ok) throw new Error('Failed to update book');
|
|
3305
|
+
closeModal('edit-modal');
|
|
3306
|
+
showSuccess('Book updated successfully');
|
|
3307
|
+
refreshBooks();
|
|
3308
|
+
} catch (error) {
|
|
3309
|
+
showError('Failed to save changes: ' + error.message);
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3313
|
+
async function deleteBook() {
|
|
3314
|
+
if (!confirm('Are you sure you want to delete this book?')) return;
|
|
3315
|
+
const bookId = currentBookId;
|
|
3316
|
+
const deleteFiles = confirm('Also delete files from disk?');
|
|
3317
|
+
|
|
3318
|
+
try {
|
|
3319
|
+
const response = await fetch('/api/books/' + bookId + '?delete_files=' + deleteFiles, { method: 'DELETE' });
|
|
3320
|
+
if (!response.ok) throw new Error('Failed to delete book');
|
|
3321
|
+
closeModal('edit-modal');
|
|
3322
|
+
showSuccess('Book deleted successfully');
|
|
3323
|
+
refreshBooks();
|
|
3324
|
+
} catch (error) {
|
|
3325
|
+
showError('Failed to delete book: ' + error.message);
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
3328
|
+
|
|
3329
|
+
function showImportModal() {
|
|
3330
|
+
// Reset modal state
|
|
3331
|
+
document.getElementById('import-progress').style.display = 'none';
|
|
3332
|
+
document.getElementById('import-results').style.display = 'none';
|
|
3333
|
+
document.querySelectorAll('.tab-content').forEach(t => t.style.display = '');
|
|
3334
|
+
document.querySelectorAll('.tabs').forEach(t => t.style.display = '');
|
|
3335
|
+
switchImportTab('file');
|
|
3336
|
+
document.getElementById('import-modal').classList.add('active');
|
|
3337
|
+
if (window.innerWidth < 1024) toggleSidebar();
|
|
3338
|
+
}
|
|
3339
|
+
|
|
3340
|
+
function switchImportTab(tabName) {
|
|
3341
|
+
// Update tab buttons
|
|
3342
|
+
document.querySelectorAll('.tabs .tab').forEach(t => t.classList.remove('active'));
|
|
3343
|
+
document.querySelector(`.tabs .tab[onclick*="${tabName}"]`).classList.add('active');
|
|
3344
|
+
|
|
3345
|
+
// Update tab content
|
|
3346
|
+
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
|
3347
|
+
document.getElementById('import-tab-' + tabName).classList.add('active');
|
|
3348
|
+
}
|
|
3349
|
+
|
|
3350
|
+
function showImportProgress(text) {
|
|
3351
|
+
document.querySelectorAll('.tab-content').forEach(t => t.style.display = 'none');
|
|
3352
|
+
document.querySelectorAll('.tabs').forEach(t => t.style.display = 'none');
|
|
3353
|
+
document.getElementById('import-progress-text').textContent = text;
|
|
3354
|
+
document.getElementById('import-progress').style.display = 'block';
|
|
3355
|
+
document.getElementById('import-results').style.display = 'none';
|
|
3356
|
+
}
|
|
3357
|
+
|
|
3358
|
+
function showImportResults(results, type) {
|
|
3359
|
+
document.getElementById('import-progress').style.display = 'none';
|
|
3360
|
+
const resultsDiv = document.getElementById('import-results');
|
|
3361
|
+
|
|
3362
|
+
let html = '<div class="detail-section">';
|
|
3363
|
+
if (results.imported > 0) {
|
|
3364
|
+
html += `<div class="alert alert-success">Successfully imported ${results.imported} of ${results.total} books</div>`;
|
|
3365
|
+
}
|
|
3366
|
+
if (results.failed > 0) {
|
|
3367
|
+
html += `<div class="alert alert-error">${results.failed} books failed to import</div>`;
|
|
3368
|
+
}
|
|
3369
|
+
if (results.errors && results.errors.length > 0) {
|
|
3370
|
+
html += '<div class="detail-label">Errors</div>';
|
|
3371
|
+
html += '<ul style="color: var(--danger); font-size: 0.85rem; margin-left: 20px;">';
|
|
3372
|
+
results.errors.slice(0, 10).forEach(err => {
|
|
3373
|
+
html += `<li>${escapeHtml(err)}</li>`;
|
|
3374
|
+
});
|
|
3375
|
+
if (results.errors.length > 10) {
|
|
3376
|
+
html += `<li>...and ${results.errors.length - 10} more errors</li>`;
|
|
3377
|
+
}
|
|
3378
|
+
html += '</ul>';
|
|
3379
|
+
}
|
|
3380
|
+
html += '</div>';
|
|
3381
|
+
html += '<div class="btn-group">';
|
|
3382
|
+
html += '<button class="btn btn-primary" onclick="closeModal('import-modal'); refreshBooks();">Done</button>';
|
|
3383
|
+
html += '</div>';
|
|
3384
|
+
|
|
3385
|
+
resultsDiv.innerHTML = html;
|
|
3386
|
+
resultsDiv.style.display = 'block';
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
async function importSingleFile(event) {
|
|
3390
|
+
event.preventDefault();
|
|
3391
|
+
const fileInput = document.getElementById('import-file');
|
|
3392
|
+
const file = fileInput.files[0];
|
|
3393
|
+
if (!file) { showError('Please select a file'); return; }
|
|
3394
|
+
|
|
3395
|
+
showImportProgress('Importing ' + file.name + '...');
|
|
3396
|
+
|
|
3397
|
+
const formData = new FormData();
|
|
3398
|
+
formData.append('file', file);
|
|
3399
|
+
formData.append('extract_text', document.getElementById('import-extract-text').checked);
|
|
3400
|
+
formData.append('extract_cover', document.getElementById('import-extract-cover').checked);
|
|
3401
|
+
|
|
3402
|
+
try {
|
|
3403
|
+
const response = await fetch('/api/books/import', { method: 'POST', body: formData });
|
|
3404
|
+
if (!response.ok) {
|
|
3405
|
+
const error = await response.json();
|
|
3406
|
+
throw new Error(error.detail || 'Import failed');
|
|
3407
|
+
}
|
|
3408
|
+
showImportResults({ total: 1, imported: 1, failed: 0, errors: [] }, 'file');
|
|
3409
|
+
document.getElementById('import-form-file').reset();
|
|
3410
|
+
} catch (error) {
|
|
3411
|
+
showImportResults({ total: 1, imported: 0, failed: 1, errors: [error.message] }, 'file');
|
|
3412
|
+
}
|
|
3413
|
+
}
|
|
3414
|
+
|
|
3415
|
+
async function importFolder(event) {
|
|
3416
|
+
event.preventDefault();
|
|
3417
|
+
const folderPath = document.getElementById('import-folder-path').value.trim();
|
|
3418
|
+
if (!folderPath) { showError('Please enter a folder path'); return; }
|
|
3419
|
+
|
|
3420
|
+
showImportProgress('Scanning folder for ebooks...');
|
|
3421
|
+
|
|
3422
|
+
const data = {
|
|
3423
|
+
folder_path: folderPath,
|
|
3424
|
+
recursive: document.getElementById('import-folder-recursive').checked,
|
|
3425
|
+
extensions: document.getElementById('import-folder-extensions').value.trim() || 'pdf,epub,mobi,azw3,txt',
|
|
3426
|
+
limit: document.getElementById('import-folder-limit').value || null,
|
|
3427
|
+
extract_text: document.getElementById('import-folder-extract-text').checked,
|
|
3428
|
+
extract_cover: document.getElementById('import-folder-extract-cover').checked
|
|
3429
|
+
};
|
|
3430
|
+
|
|
3431
|
+
try {
|
|
3432
|
+
const response = await fetch('/api/books/import/folder', {
|
|
3433
|
+
method: 'POST',
|
|
3434
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3435
|
+
body: JSON.stringify(data)
|
|
3436
|
+
});
|
|
3437
|
+
if (!response.ok) {
|
|
3438
|
+
const error = await response.json();
|
|
3439
|
+
throw new Error(error.detail || 'Folder import failed');
|
|
3440
|
+
}
|
|
3441
|
+
const results = await response.json();
|
|
3442
|
+
showImportResults(results, 'folder');
|
|
3443
|
+
document.getElementById('import-form-folder').reset();
|
|
3444
|
+
} catch (error) {
|
|
3445
|
+
showImportResults({ total: 0, imported: 0, failed: 0, errors: [error.message] }, 'folder');
|
|
3446
|
+
}
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3449
|
+
async function importCalibre(event) {
|
|
3450
|
+
event.preventDefault();
|
|
3451
|
+
const calibrePath = document.getElementById('import-calibre-path').value.trim();
|
|
3452
|
+
if (!calibrePath) { showError('Please enter a Calibre library path'); return; }
|
|
3453
|
+
|
|
3454
|
+
showImportProgress('Importing from Calibre library...');
|
|
3455
|
+
|
|
3456
|
+
const data = {
|
|
3457
|
+
calibre_path: calibrePath,
|
|
3458
|
+
limit: document.getElementById('import-calibre-limit').value || null
|
|
3459
|
+
};
|
|
3460
|
+
|
|
3461
|
+
try {
|
|
3462
|
+
const response = await fetch('/api/books/import/calibre', {
|
|
3463
|
+
method: 'POST',
|
|
3464
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3465
|
+
body: JSON.stringify(data)
|
|
3466
|
+
});
|
|
3467
|
+
if (!response.ok) {
|
|
3468
|
+
const error = await response.json();
|
|
3469
|
+
throw new Error(error.detail || 'Calibre import failed');
|
|
3470
|
+
}
|
|
3471
|
+
const results = await response.json();
|
|
3472
|
+
showImportResults(results, 'calibre');
|
|
3473
|
+
document.getElementById('import-form-calibre').reset();
|
|
3474
|
+
} catch (error) {
|
|
3475
|
+
showImportResults({ total: 0, imported: 0, failed: 0, errors: [error.message] }, 'calibre');
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3478
|
+
|
|
3479
|
+
async function importFromURL(event) {
|
|
3480
|
+
event.preventDefault();
|
|
3481
|
+
const url = document.getElementById('import-url').value.trim();
|
|
3482
|
+
if (!url) { showError('Please enter a URL'); return; }
|
|
3483
|
+
|
|
3484
|
+
showImportProgress('Downloading and importing from URL...');
|
|
3485
|
+
|
|
3486
|
+
const data = {
|
|
3487
|
+
url: url,
|
|
3488
|
+
extract_text: document.getElementById('import-url-extract-text').checked,
|
|
3489
|
+
extract_cover: document.getElementById('import-url-extract-cover').checked
|
|
3490
|
+
};
|
|
3491
|
+
|
|
3492
|
+
try {
|
|
3493
|
+
const response = await fetch('/api/books/import/url', {
|
|
3494
|
+
method: 'POST',
|
|
3495
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3496
|
+
body: JSON.stringify(data)
|
|
3497
|
+
});
|
|
3498
|
+
if (!response.ok) {
|
|
3499
|
+
const error = await response.json();
|
|
3500
|
+
throw new Error(error.detail || 'URL import failed');
|
|
3501
|
+
}
|
|
3502
|
+
showImportResults({ total: 1, imported: 1, failed: 0, errors: [] }, 'url');
|
|
3503
|
+
document.getElementById('import-form-url').reset();
|
|
3504
|
+
} catch (error) {
|
|
3505
|
+
showImportResults({ total: 1, imported: 0, failed: 1, errors: [error.message] }, 'url');
|
|
3506
|
+
}
|
|
3507
|
+
}
|
|
3508
|
+
|
|
3509
|
+
async function importFromOPDS(event) {
|
|
3510
|
+
event.preventDefault();
|
|
3511
|
+
const opdsUrl = document.getElementById('import-opds-url').value.trim();
|
|
3512
|
+
if (!opdsUrl) { showError('Please enter an OPDS catalog URL'); return; }
|
|
3513
|
+
|
|
3514
|
+
showImportProgress('Fetching OPDS catalog...');
|
|
3515
|
+
|
|
3516
|
+
const data = {
|
|
3517
|
+
opds_url: opdsUrl,
|
|
3518
|
+
limit: document.getElementById('import-opds-limit').value || null,
|
|
3519
|
+
extract_text: document.getElementById('import-opds-extract-text').checked,
|
|
3520
|
+
extract_cover: document.getElementById('import-opds-extract-cover').checked
|
|
3521
|
+
};
|
|
3522
|
+
|
|
3523
|
+
try {
|
|
3524
|
+
const response = await fetch('/api/books/import/opds', {
|
|
3525
|
+
method: 'POST',
|
|
3526
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3527
|
+
body: JSON.stringify(data)
|
|
3528
|
+
});
|
|
3529
|
+
if (!response.ok) {
|
|
3530
|
+
const error = await response.json();
|
|
3531
|
+
throw new Error(error.detail || 'OPDS import failed');
|
|
3532
|
+
}
|
|
3533
|
+
const results = await response.json();
|
|
3534
|
+
showImportResults(results, 'opds');
|
|
3535
|
+
document.getElementById('import-form-opds').reset();
|
|
3536
|
+
} catch (error) {
|
|
3537
|
+
showImportResults({ total: 0, imported: 0, failed: 0, errors: [error.message] }, 'opds');
|
|
3538
|
+
}
|
|
3539
|
+
}
|
|
3540
|
+
|
|
3541
|
+
async function importFromISBN(event) {
|
|
3542
|
+
event.preventDefault();
|
|
3543
|
+
const isbn = document.getElementById('import-isbn').value.trim();
|
|
3544
|
+
if (!isbn) { showError('Please enter an ISBN'); return; }
|
|
3545
|
+
|
|
3546
|
+
showImportProgress('Looking up ISBN...');
|
|
3547
|
+
|
|
3548
|
+
const data = { isbn: isbn };
|
|
3549
|
+
|
|
3550
|
+
try {
|
|
3551
|
+
const response = await fetch('/api/books/import/isbn', {
|
|
3552
|
+
method: 'POST',
|
|
3553
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3554
|
+
body: JSON.stringify(data)
|
|
3555
|
+
});
|
|
3556
|
+
if (!response.ok) {
|
|
3557
|
+
const error = await response.json();
|
|
3558
|
+
throw new Error(error.detail || 'ISBN lookup failed');
|
|
3559
|
+
}
|
|
3560
|
+
showImportResults({ total: 1, imported: 1, failed: 0, errors: [] }, 'isbn');
|
|
3561
|
+
document.getElementById('import-form-isbn').reset();
|
|
3562
|
+
} catch (error) {
|
|
3563
|
+
showImportResults({ total: 1, imported: 0, failed: 1, errors: [error.message] }, 'isbn');
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
|
|
3567
|
+
function closeModal(modalId) {
|
|
3568
|
+
document.getElementById(modalId).classList.remove('active');
|
|
3569
|
+
}
|
|
3570
|
+
|
|
3571
|
+
function closeAllModals() {
|
|
3572
|
+
document.querySelectorAll('.modal.active').forEach(m => m.classList.remove('active'));
|
|
3573
|
+
}
|
|
3574
|
+
|
|
3575
|
+
function refreshBooks() {
|
|
3576
|
+
loadBooks();
|
|
3577
|
+
loadStats();
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3580
|
+
function showError(message) {
|
|
3581
|
+
const container = document.getElementById('message-container');
|
|
3582
|
+
container.innerHTML = '<div class="alert alert-error">⚠ ' + escapeHtml(message) + '</div>';
|
|
3583
|
+
setTimeout(() => container.innerHTML = '', 5000);
|
|
3584
|
+
}
|
|
3585
|
+
|
|
3586
|
+
function showSuccess(message) {
|
|
3587
|
+
const container = document.getElementById('message-container');
|
|
3588
|
+
container.innerHTML = '<div class="alert alert-success">✅ ' + escapeHtml(message) + '</div>';
|
|
3589
|
+
setTimeout(() => container.innerHTML = '', 3000);
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3592
|
+
function escapeHtml(text) {
|
|
3593
|
+
if (!text) return '';
|
|
3594
|
+
const div = document.createElement('div');
|
|
3595
|
+
div.textContent = text;
|
|
3596
|
+
return div.innerHTML;
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
function formatBytes(bytes) {
|
|
3600
|
+
if (!bytes) return '0 B';
|
|
3601
|
+
const k = 1024;
|
|
3602
|
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
3603
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
3604
|
+
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
|
|
3605
|
+
}
|
|
3606
|
+
</script>
|
|
3607
|
+
</body>
|
|
3608
|
+
</html>'''
|