diff --git a/library_service/models/dto/book.py b/library_service/models/dto/book.py index c8e014a..495944a 100644 --- a/library_service/models/dto/book.py +++ b/library_service/models/dto/book.py @@ -46,7 +46,7 @@ class BookRead(BookBase): id: int = Field(description="Идентификатор") status: BookStatus = Field(description="Статус") - preview_url: str | None = Field(None, description="URL изображения") + preview_urls: dict[str, str] = Field(default_factory=dict, description="URL изображений") class BookList(SQLModel): diff --git a/library_service/models/dto/misc.py b/library_service/models/dto/misc.py index ad89b65..a02d4dc 100644 --- a/library_service/models/dto/misc.py +++ b/library_service/models/dto/misc.py @@ -39,7 +39,7 @@ class BookWithAuthors(SQLModel): description: str = Field(description="Описание") page_count: int = Field(description="Количество страниц") status: BookStatus | None = Field(None, description="Статус") - preview_url: str | None = Field(default=None, description="URL изображения") + preview_urls: dict[str, str] = Field(default_factory=dict, description="URL изображений") authors: List[AuthorRead] = Field( default_factory=list, description="Список авторов" ) @@ -53,7 +53,7 @@ class BookWithGenres(SQLModel): description: str = Field(description="Описание") page_count: int = Field(description="Количество страниц") status: BookStatus | None = Field(None, description="Статус") - preview_url: str | None = Field(default=None, description="URL изображения") + preview_urls: dict[str, str] | None = Field(default=None, description="URL изображений") genres: List[GenreRead] = Field(default_factory=list, description="Список жанров") @@ -65,7 +65,7 @@ class BookWithAuthorsAndGenres(SQLModel): description: str = Field(description="Описание") page_count: int = Field(description="Количество страниц") status: BookStatus | None = Field(None, description="Статус") - preview_url: str | None = Field(default=None, description="URL изображения") + preview_urls: dict[str, str] | None = Field(default=None, description="URL изображений") authors: List[AuthorRead] = Field( default_factory=list, description="Список авторов" ) diff --git a/library_service/routers/books.py b/library_service/routers/books.py index 8b70f3d..bc271d3 100644 --- a/library_service/routers/books.py +++ b/library_service/routers/books.py @@ -1,4 +1,5 @@ """Модуль работы с книгами""" +from library_service.services import transcode_image import shutil from uuid import uuid4 @@ -139,9 +140,11 @@ def create_book( session.refresh(db_book) book_data = db_book.model_dump(exclude={"embedding", "preview_id"}) - if db_book.preview_id: - book_data["preview_url"] = f"/static/books/{db_book.preview_id}.png" - + book_data["preview_urls"] = { + "png": f"/static/books/{db_book.preview_id}.png", + "jpeg": f"/static/books/{db_book.preview_id}.jpg", + "webp": f"/static/books/{db_book.preview_id}.webp", + } if db_book.preview_id else {} return BookRead(**book_data) @@ -158,8 +161,11 @@ def read_books(session: Session = Depends(get_session)): books_data = [] for book in books: book_data = book.model_dump(exclude={"embedding", "preview_id"}) - if book.preview_id: - book_data["preview_url"] = f"/static/books/{book.preview_id}.png" + book_data["preview_urls"] = { + "png": f"/static/books/{book.preview_id}.png", + "jpeg": f"/static/books/{book.preview_id}.jpg", + "webp": f"/static/books/{book.preview_id}.webp", + } if book.preview_id else {} books_data.append(book_data) return BookList( @@ -198,8 +204,11 @@ def get_book( genre_reads = [GenreRead(**genre.model_dump()) for genre in genres] book_data = book.model_dump(exclude={"embedding", "preview_id"}) - if book.preview_id: - book_data["preview_url"] = f"/static/books/{book.preview_id}.png" + book_data["preview_urls"] = { + "png": f"/static/books/{book.preview_id}.png", + "jpeg": f"/static/books/{book.preview_id}.jpg", + "webp": f"/static/books/{book.preview_id}.webp", + } if book.preview_id else {} book_data["authors"] = author_reads book_data["genres"] = genre_reads @@ -259,8 +268,11 @@ def update_book( session.refresh(db_book) book_data = db_book.model_dump(exclude={"embedding", "preview_id"}) - if db_book.preview_id: - book_data["preview_url"] = f"/static/books/{db_book.preview_id}.png" + book_data["preview_urls"] = { + "png": f"/static/books/{db_book.preview_id}.png", + "jpeg": f"/static/books/{db_book.preview_id}.jpg", + "webp": f"/static/books/{db_book.preview_id}.webp", + } if db_book.preview_id else {} return BookRead(**book_data) @@ -300,34 +312,41 @@ async def upload_book_preview( book_id: int = Path(..., gt=0), session: Session = Depends(get_session) ): - if not file.content_type == "image/png": - raise HTTPException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "PNG required") + if not (file.content_type or "").startswith("image/"): + raise HTTPException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Image required") - if (file.size or 0) > 10 * 1024 * 1024: + if (file.size or 0) > 32 * 1024 * 1024: raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "File larger than 10 MB") file_uuid= uuid4() - filename = f"{file_uuid}.png" - file_path = BOOKS_PREVIEW_DIR / filename + tmp_path = BOOKS_PREVIEW_DIR / f"{file_uuid}.upload" - with open(file_path, "wb") as f: + with open(tmp_path, "wb") as f: shutil.copyfileobj(file.file, f) + transcode_image(tmp_path) + book = session.get(Book, book_id) if not book: - file_path.unlink() + tmp_path.unlink() raise HTTPException(status.HTTP_404_NOT_FOUND, "Book not found") if book.preview_id: - old_path = BOOKS_PREVIEW_DIR / f"{book.preview_id}.png" - if old_path.exists(): - old_path.unlink() + for path in BOOKS_PREVIEW_DIR.glob(f"{book.preview_id}.*"): + if path.exists(): + path.unlink(missing_ok=True) book.preview_id = file_uuid session.add(book) session.commit() - return {"preview_url": f"/static/books/{filename}"} + return { + "preview": { + "png": f"/static/books/{file_uuid}.png", + "jpeg": f"/static/books/{file_uuid}.jpg", + "webp": f"/static/books/{file_uuid}.webp", + } + } @router.delete("/{book_id}/preview") @@ -341,12 +360,12 @@ async def remove_book_preview( raise HTTPException(status.HTTP_404_NOT_FOUND, "Book not found") if book.preview_id: - old_path = BOOKS_PREVIEW_DIR / f"{book.preview_id}.png" - if old_path.exists(): - old_path.unlink() + for path in BOOKS_PREVIEW_DIR.glob(f"{book.preview_id}.*"): + if path.exists(): + path.unlink(missing_ok=True) book.preview_id = None session.add(book) session.commit() - return {"preview_url": None} + return {"preview_urls": []} diff --git a/library_service/services/__init__.py b/library_service/services/__init__.py index 934a395..0208e9b 100644 --- a/library_service/services/__init__.py +++ b/library_service/services/__init__.py @@ -13,6 +13,7 @@ from .captcha import ( prng, ) from .describe_er import SchemaGenerator +from .image_processing import transcode_image __all__ = [ "limiter", @@ -28,4 +29,5 @@ __all__ = [ "REDEEM_TTL", "prng", "SchemaGenerator", + "transcode_image", ] diff --git a/library_service/services/image_processing.py b/library_service/services/image_processing.py new file mode 100644 index 0000000..ba94431 --- /dev/null +++ b/library_service/services/image_processing.py @@ -0,0 +1,81 @@ +from pathlib import Path +from PIL import Image + + +TARGET_RATIO = 5 / 7 + + +def crop_image(img: Image.Image, target_ratio: float = TARGET_RATIO) -> Image.Image: + w, h = img.size + current_ratio = w / h + + if current_ratio > target_ratio: + new_w = int(h * target_ratio) + left = (w - new_w) // 2 + right = left + new_w + top = 0 + bottom = h + else: + new_h = int(w / target_ratio) + top = (h - new_h) // 2 + bottom = top + new_h + left = 0 + right = w + + return img.crop((left, top, right, bottom)) + + +def transcode_image( + src_path: str | Path, + *, + jpeg_quality: int = 85, + webp_quality: int = 80, + webp_lossless: bool = False, + resize_to: tuple[int, int] | None = None, +): + src_path = Path(src_path) + + if not src_path.exists(): + raise FileNotFoundError(src_path) + + stem = src_path.stem + folder = src_path.parent + + img = Image.open(src_path).convert("RGBA") + img = crop_image(img) + + if resize_to: + img = img.resize(resize_to, Image.LANCZOS) + + png_path = folder / f"{stem}.png" + img.save( + png_path, + format="PNG", + optimize=True, + interlace=1, + ) + + jpg_path = folder / f"{stem}.jpg" + img.convert("RGB").save( + jpg_path, + format="JPEG", + quality=jpeg_quality, + progressive=True, + optimize=True, + subsampling="4:2:0", + ) + + webp_path = folder / f"{stem}.webp" + img.save( + webp_path, + format="WEBP", + quality=webp_quality, + lossless=webp_lossless, + method=6, + ) + + return { + "png": png_path, + "jpeg": jpg_path, + "webp": webp_path, + } diff --git a/library_service/static/page/book.js b/library_service/static/page/book.js index 926dda5..8536564 100644 --- a/library_service/static/page/book.js +++ b/library_service/static/page/book.js @@ -34,6 +34,7 @@ $(document).ready(() => { const pathParts = window.location.pathname.split("/"); const bookId = parseInt(pathParts[pathParts.length - 1]); + let isDraggingOver = false; let currentBook = null; let cachedUsers = null; let selectedLoanUserId = null; @@ -48,6 +49,28 @@ $(document).ready(() => { } loadBookData(); setupEventHandlers(); + setupCoverUpload(); + } + + function getPreviewUrl(book) { + if (!book.preview_urls) { + return null; + } + + const priorities = ["webp", "jpeg", "jpg", "png"]; + + for (const format of priorities) { + if (book.preview_urls[format]) { + return book.preview_urls[format]; + } + } + + const availableFormats = Object.keys(book.preview_urls); + if (availableFormats.length > 0) { + return book.preview_urls[availableFormats[0]]; + } + + return null; } function setupEventHandlers() { @@ -75,6 +98,270 @@ $(document).ready(() => { $("#loan-due-date").val(future.toISOString().split("T")[0]); } + function setupCoverUpload() { + const $container = $("#book-cover-container"); + const $fileInput = $("#cover-file-input"); + + $fileInput.on("change", function (e) { + const file = e.target.files[0]; + if (file) { + uploadCover(file); + } + $(this).val(""); + }); + + $container.on("dragenter", function (e) { + e.preventDefault(); + e.stopPropagation(); + if (!window.canManage()) return; + isDraggingOver = true; + showDropOverlay(); + }); + + $container.on("dragover", function (e) { + e.preventDefault(); + e.stopPropagation(); + if (!window.canManage()) return; + isDraggingOver = true; + }); + + $container.on("dragleave", function (e) { + e.preventDefault(); + e.stopPropagation(); + if (!window.canManage()) return; + + const rect = this.getBoundingClientRect(); + const x = e.clientX; + const y = e.clientY; + + if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { + isDraggingOver = false; + hideDropOverlay(); + } + }); + + $container.on("drop", function (e) { + e.preventDefault(); + e.stopPropagation(); + if (!window.canManage()) return; + + isDraggingOver = false; + hideDropOverlay(); + + const files = e.dataTransfer?.files || []; + if (files.length > 0) { + const file = files[0]; + + if (!file.type.startsWith("image/")) { + Utils.showToast("Пожалуйста, загрузите изображение", "error"); + return; + } + + uploadCover(file); + } + }); + } + + function showDropOverlay() { + const $container = $("#book-cover-container"); + $container.find(".drop-overlay").remove(); + + const $overlay = $(` +
+
+ + + + Отпустите для загрузки +
+ `); + + $container.append($overlay); + } + + function hideDropOverlay() { + $("#book-cover-container .drop-overlay").remove(); + } + + async function uploadCover(file) { + const $container = $("#book-cover-container"); + + const maxSize = 32 * 1024 * 1024; + if (file.size > maxSize) { + Utils.showToast("Файл слишком большой. Максимум 32 MB", "error"); + return; + } + + if (!file.type.startsWith("image/")) { + Utils.showToast("Пожалуйста, загрузите изображение", "error"); + return; + } + + const $loader = $(` +
+ + + + + Загрузка... +
+ `); + + $container.find(".upload-loader").remove(); + $container.append($loader); + + try { + const formData = new FormData(); + formData.append("file", file); + + const response = await Api.uploadFile( + `/api/books/${bookId}/preview`, + formData, + ); + + if (!response) { + return; + } + + if (response.preview) { + currentBook.preview_urls = response.preview; + } else if (response.preview_urls) { + currentBook.preview_urls = response.preview_urls; + } else { + currentBook = response; + } + + Utils.showToast("Обложка успешно загружена", "success"); + renderBookCover(currentBook); + } catch (error) { + console.error("Upload error:", error); + Utils.showToast(error.message || "Ошибка загрузки обложки", "error"); + } finally { + $container.find(".upload-loader").remove(); + } + } + + async function deleteCover() { + if (!confirm("Удалить обложку книги?")) { + return; + } + + const $container = $("#book-cover-container"); + + const $loader = $(` +
+ + + + +
+ `); + + $container.find(".upload-loader").remove(); + $container.append($loader); + + try { + await Api.delete(`/api/books/${bookId}/preview`); + + currentBook.preview_urls = null; + Utils.showToast("Обложка удалена", "success"); + renderBookCover(currentBook); + } catch (error) { + console.error("Delete error:", error); + Utils.showToast(error.message || "Ошибка удаления обложки", "error"); + } finally { + $container.find(".upload-loader").remove(); + } + } + + function renderBookCover(book) { + const $container = $("#book-cover-container"); + const canManage = window.canManage(); + const previewUrl = getPreviewUrl(book); + + if (previewUrl) { + $container.html(` + Обложка книги ${Utils.escapeHtml(book.title)} + + ${ + canManage + ? ` + +
+ + + + + Заменить + +
+ ` + : "" + } + `); + + if (canManage) { + $("#delete-cover-btn").on("click", function (e) { + e.stopPropagation(); + deleteCover(); + }); + + $("#cover-replace-overlay").on("click", function () { + $("#cover-file-input").trigger("click"); + }); + } + } else { + if (canManage) { + $container.html(` +
+
+ + + + + Добавить обложку + + + или перетащите + +
+ `); + + $("#cover-upload-zone").on("click", function () { + $("#cover-file-input").trigger("click"); + }); + } else { + $container.html(` +
+ + + +
+ `); + } + } + } + function loadBookData() { Api.get(`/api/books/${bookId}`) .then((book) => { @@ -234,13 +521,16 @@ $(document).ready(() => { function renderBook(book) { $("#book-title").text(book.title); $("#book-id").text(`ID: ${book.id}`); - const $coverContainer = $("#book-cover-container"); + + renderBookCover(book); + if (book.page_count && book.page_count > 0) { $("#book-page-count-value").text(book.page_count); $("#book-page-count-text").removeClass("hidden"); } else { $("#book-page-count-text").addClass("hidden"); } + $("#book-authors-text").text( book.authors.map((a) => a.name).join(", ") || "Автор неизвестен", ); @@ -254,39 +544,15 @@ $(document).ready(() => { $("#book-actions-container").empty(); } - if (book.preview_url) { - $coverContainer.html(` - Обложка книги ${Utils.escapeHtml(book.title)} - - `); - } else { - $coverContainer.html(` -
- - - -
- `); - } - if (book.genres && book.genres.length > 0) { $("#genres-section").removeClass("hidden"); const $genres = $("#genres-container"); $genres.empty(); book.genres.forEach((g) => { $genres.append(` - - ${Utils.escapeHtml(g.name)} - + + ${Utils.escapeHtml(g.name)} + `); }); } @@ -297,12 +563,12 @@ $(document).ready(() => { $authors.empty(); book.authors.forEach((a) => { $authors.append(` - -
- ${a.name.charAt(0).toUpperCase()} -
- ${Utils.escapeHtml(a.name)} -
+ +
+ ${a.name.charAt(0).toUpperCase()} +
+ ${Utils.escapeHtml(a.name)} +
`); }); } diff --git a/library_service/static/utils.js b/library_service/static/utils.js index bda3893..f47a263 100644 --- a/library_service/static/utils.js +++ b/library_service/static/utils.js @@ -160,6 +160,67 @@ const Api = { body: formData.toString(), }); }, + + async uploadFile(endpoint, formData) { + const fullUrl = this.getBaseUrl() + endpoint; + const token = StorageHelper.get("access_token"); + + const headers = {}; + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + try { + const response = await fetch(fullUrl, { + method: "POST", + headers: headers, + body: formData, + credentials: "include", + }); + + if (response.status === 401) { + const refreshed = await Auth.tryRefresh(); + if (refreshed) { + headers["Authorization"] = + `Bearer ${StorageHelper.get("access_token")}`; + const retryResponse = await fetch(fullUrl, { + method: "POST", + headers: headers, + body: formData, + credentials: "include", + }); + if (retryResponse.ok) { + return retryResponse.json(); + } + } + Auth.logout(); + return null; + } + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + let errorMessage = `Ошибка ${response.status}`; + + if (typeof errorData.detail === "string") { + errorMessage = errorData.detail; + } else if (Array.isArray(errorData.detail)) { + errorMessage = errorData.detail.map((e) => e.msg || e).join(", "); + } else if (errorData.detail?.message) { + errorMessage = errorData.detail.message; + } else if (errorData.message) { + errorMessage = errorData.message; + } + + const error = new Error(errorMessage); + error.status = response.status; + throw error; + } + + return response.json(); + } catch (error) { + throw error; + } + }, }; const Auth = { diff --git a/library_service/templates/analytics.html b/library_service/templates/analytics.html index 57d7db8..53f061c 100644 --- a/library_service/templates/analytics.html +++ b/library_service/templates/analytics.html @@ -9,8 +9,8 @@