mirror of
https://github.com/wowlikon/LiB.git
synced 2026-02-04 04:31:09 +00:00
транскодирование изображений
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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="Список авторов"
|
||||
)
|
||||
|
||||
@@ -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": []}
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 = $(`
|
||||
<div class="drop-overlay absolute inset-0 flex flex-col items-center justify-center z-20 pointer-events-none">
|
||||
<div class="absolute inset-2 border-2 border-dashed border-gray-600 rounded-lg"></div>
|
||||
<svg class="w-10 h-10 text-gray-600 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path>
|
||||
</svg>
|
||||
<span class="text-gray-700 text-sm font-medium text-center px-4">Отпустите для загрузки</span>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$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 = $(`
|
||||
<div class="upload-loader absolute inset-0 bg-black bg-opacity-50 flex flex-col items-center justify-center z-20">
|
||||
<svg class="animate-spin w-8 h-8 text-white mb-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span class="text-white text-sm">Загрузка...</span>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$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 = $(`
|
||||
<div class="upload-loader absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center z-20">
|
||||
<svg class="animate-spin w-8 h-8 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$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(`
|
||||
<img
|
||||
src="${Utils.escapeHtml(previewUrl)}"
|
||||
alt="Обложка книги ${Utils.escapeHtml(book.title)}"
|
||||
class="w-full h-full object-cover"
|
||||
onerror="this.onerror=null; this.parentElement.querySelector('.cover-fallback').classList.remove('hidden'); this.classList.add('hidden');"
|
||||
/>
|
||||
<div class="cover-fallback hidden w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center absolute inset-0">
|
||||
<svg class="w-20 h-20 text-white opacity-80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
|
||||
</svg>
|
||||
</div>
|
||||
${
|
||||
canManage
|
||||
? `
|
||||
<button
|
||||
id="delete-cover-btn"
|
||||
class="absolute top-2 right-2 w-7 h-7 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-10"
|
||||
title="Удалить обложку"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all flex flex-col items-center justify-center cursor-pointer z-0" id="cover-replace-overlay">
|
||||
<svg class="w-8 h-8 text-white opacity-0 group-hover:opacity-100 transition-opacity mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path>
|
||||
</svg>
|
||||
<span class="text-white text-center opacity-0 group-hover:opacity-100 transition-opacity text-xs font-medium pointer-events-none px-2">
|
||||
Заменить
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
`);
|
||||
|
||||
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(`
|
||||
<div
|
||||
id="cover-upload-zone"
|
||||
class="w-full h-full bg-gray-100 flex flex-col items-center justify-center cursor-pointer hover:bg-gray-200 transition-all text-center relative"
|
||||
>
|
||||
<div class="absolute inset-2 border-2 border-dashed border-gray-300 rounded-lg pointer-events-none"></div>
|
||||
<svg class="w-8 h-8 text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path>
|
||||
</svg>
|
||||
<span class="text-gray-500 text-xs font-medium px-2">
|
||||
Добавить обложку
|
||||
</span>
|
||||
<span class="text-gray-400 text-xs mt-1 px-2">
|
||||
или перетащите
|
||||
</span>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$("#cover-upload-zone").on("click", function () {
|
||||
$("#cover-file-input").trigger("click");
|
||||
});
|
||||
} else {
|
||||
$container.html(`
|
||||
<div class="w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center">
|
||||
<svg class="w-20 h-20 text-white opacity-80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
|
||||
</svg>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(`
|
||||
<img
|
||||
src="${Utils.escapeHtml(book.preview_url)}"
|
||||
alt="Обложка книги ${Utils.escapeHtml(book.title)}"
|
||||
class="w-full h-full object-cover"
|
||||
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';"
|
||||
/>
|
||||
<div class="hidden w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center">
|
||||
<svg class="w-20 h-20 text-white opacity-80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
|
||||
</svg>
|
||||
</div>
|
||||
`);
|
||||
} else {
|
||||
$coverContainer.html(`
|
||||
<div class="w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center">
|
||||
<svg class="w-20 h-20 text-white opacity-80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
|
||||
</svg>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
if (book.genres && book.genres.length > 0) {
|
||||
$("#genres-section").removeClass("hidden");
|
||||
const $genres = $("#genres-container");
|
||||
$genres.empty();
|
||||
book.genres.forEach((g) => {
|
||||
$genres.append(`
|
||||
<a href="/books?genre_id=${g.id}" class="inline-flex items-center bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-full text-sm transition-colors border border-gray-200">
|
||||
${Utils.escapeHtml(g.name)}
|
||||
</a>
|
||||
<a href="/books?genre_id=${g.id}" class="inline-flex items-center bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-full text-sm transition-colors border border-gray-200">
|
||||
${Utils.escapeHtml(g.name)}
|
||||
</a>
|
||||
`);
|
||||
});
|
||||
}
|
||||
@@ -297,12 +563,12 @@ $(document).ready(() => {
|
||||
$authors.empty();
|
||||
book.authors.forEach((a) => {
|
||||
$authors.append(`
|
||||
<a href="/author/${a.id}" class="flex items-center bg-white hover:bg-gray-50 rounded-lg p-2 border border-gray-200 shadow-sm transition-colors group">
|
||||
<div class="w-8 h-8 bg-gray-200 text-gray-600 group-hover:bg-gray-300 rounded-full flex items-center justify-center text-sm font-bold mr-2 transition-colors">
|
||||
${a.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span class="text-gray-800 font-medium text-sm">${Utils.escapeHtml(a.name)}</span>
|
||||
</a>
|
||||
<a href="/author/${a.id}" class="flex items-center bg-white hover:bg-gray-50 rounded-lg p-2 border border-gray-200 shadow-sm transition-colors group">
|
||||
<div class="w-8 h-8 bg-gray-200 text-gray-600 group-hover:bg-gray-300 rounded-full flex items-center justify-center text-sm font-bold mr-2 transition-colors">
|
||||
${a.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span class="text-gray-800 font-medium text-sm">${Utils.escapeHtml(a.name)}</span>
|
||||
</a>
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="text-sm font-medium text-gray-600">Период анализа:</label>
|
||||
<select id="period-select" class="px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-gray-400 transition bg-white">
|
||||
<option value="7">7 дней</option>
|
||||
<option value="30" selected>30 дней</option>
|
||||
<option value="7" selected>7 дней</option>
|
||||
<option value="30">30 дней</option>
|
||||
<option value="90">90 дней</option>
|
||||
<option value="180">180 дней</option>
|
||||
<option value="365">365 дней</option>
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<meta property="og:title" content="{{ title }}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:description" content="Ваша персональная библиотека книг" />
|
||||
<meta property="og:url" content="//{{ domain }}/" />
|
||||
<!--<meta property="og:image" content="//{{ domain }}/img/{{ img }}.png" />-->
|
||||
<meta property="og:url" content="//{{ domain }}/" />
|
||||
|
||||
<script
|
||||
defer
|
||||
@@ -23,42 +23,55 @@
|
||||
<body
|
||||
class="flex flex-col min-h-screen bg-gray-100"
|
||||
x-data="{
|
||||
user: null,
|
||||
async init() {
|
||||
document.addEventListener('auth:login', async (e) => {
|
||||
this.user = e.detail;
|
||||
this.user.avatar = await Utils.getGravatarUrl(this.user.email);
|
||||
});
|
||||
await Auth.init();
|
||||
}
|
||||
}"
|
||||
user: null,
|
||||
menuOpen: false,
|
||||
async init() {
|
||||
document.addEventListener('auth:login', async (e) => {
|
||||
this.user = e.detail;
|
||||
this.user.avatar = await Utils.getGravatarUrl(this.user.email);
|
||||
});
|
||||
await Auth.init();
|
||||
}
|
||||
}"
|
||||
>
|
||||
<header class="bg-gray-600 text-white p-4 shadow-md">
|
||||
<div class="mx-auto pl-5 pr-3 flex justify-between items-center">
|
||||
<a class="flex gap-4 items-center max-w-10 h-auto" href="/">
|
||||
<img class="invert" src="/static/logo.svg" />
|
||||
<h1 class="text-2xl font-bold">LiB</h1>
|
||||
</a>
|
||||
<nav>
|
||||
<div class="mx-auto px-3 md:pl-5 md:pr-3 flex justify-between items-center">
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
@click="menuOpen = !menuOpen"
|
||||
class="md:hidden flex gap-2 items-center hover:opacity-80 transition focus:outline-none"
|
||||
:aria-expanded="menuOpen"
|
||||
aria-label="Меню навигации"
|
||||
>
|
||||
<img class="invert max-w-10 h-auto" src="/static/logo.svg" />
|
||||
<h1 class="text-xl font-bold">
|
||||
<span class="text-gray-300 mr-1">≡</span>LiB
|
||||
</h1>
|
||||
</button>
|
||||
|
||||
<a class="hidden md:flex gap-4 items-center max-w-10 h-auto" href="/">
|
||||
<img class="invert" src="/static/logo.svg" />
|
||||
<h1 class="text-2xl font-bold">LiB</h1>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<nav class="hidden md:block">
|
||||
<ul class="flex space-x-4">
|
||||
<li>
|
||||
<a href="/" class="hover:text-gray-200">Главная</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/books" class="hover:text-gray-200"
|
||||
>Книги</a
|
||||
>
|
||||
<a href="/books" class="hover:text-gray-200">Книги</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/authors" class="hover:text-gray-200"
|
||||
>Авторы</a
|
||||
>
|
||||
<a href="/authors" class="hover:text-gray-200">Авторы</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/api" class="hover:text-gray-200">API</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="relative" x-data="{ open: false }">
|
||||
<template x-if="!user">
|
||||
<a
|
||||
@@ -110,7 +123,7 @@
|
||||
<div
|
||||
x-show="open"
|
||||
x-transition
|
||||
class="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden text-gray-900"
|
||||
class="absolute right-0 mt-2 w-56 max-w-[calc(100vw-2rem)] bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden text-gray-900"
|
||||
style="display: none"
|
||||
>
|
||||
<div class="px-4 py-3 border-b border-gray-200">
|
||||
@@ -235,17 +248,71 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
x-show="menuOpen"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2"
|
||||
@click.outside="menuOpen = false"
|
||||
class="md:hidden mt-4 pb-2 border-t border-gray-500"
|
||||
style="display: none"
|
||||
>
|
||||
<ul class="flex flex-col space-y-1 pt-3">
|
||||
<li>
|
||||
<a
|
||||
href="/"
|
||||
@click="menuOpen = false"
|
||||
class="block px-3 py-2 rounded hover:bg-gray-500 transition"
|
||||
>
|
||||
Главная
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/books"
|
||||
@click="menuOpen = false"
|
||||
class="block px-3 py-2 rounded hover:bg-gray-500 transition"
|
||||
>
|
||||
Книги
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/authors"
|
||||
@click="menuOpen = false"
|
||||
class="block px-3 py-2 rounded hover:bg-gray-500 transition"
|
||||
>
|
||||
Авторы
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/api"
|
||||
@click="menuOpen = false"
|
||||
class="block px-3 py-2 rounded hover:bg-gray-500 transition"
|
||||
>
|
||||
API
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="flex-grow">{% block content %}{% endblock %}</main>
|
||||
<div
|
||||
id="toast-container"
|
||||
class="fixed bottom-5 right-5 flex flex-col gap-2 z-50"
|
||||
class="fixed bottom-5 left-4 right-4 md:left-auto md:right-5 flex flex-col gap-2 z-50 items-center md:items-end"
|
||||
></div>
|
||||
|
||||
<footer class="bg-gray-800 text-white p-4 mt-8">
|
||||
<div class="container mx-auto text-center">
|
||||
<p>© 2026 LiB Library. Разработано в рамках дипломного проекта.
|
||||
Код открыт под лицензией <a href="https://github.com/wowlikon/LiB/blob/main/LICENSE">MIT</a>.
|
||||
<div class="container mx-auto text-center text-sm md:text-base">
|
||||
<p>
|
||||
© 2026 LiB Library. Разработано в рамках дипломного проекта.
|
||||
<br class="sm:hidden" />
|
||||
Код открыт под лицензией
|
||||
<a href="https://github.com/wowlikon/LiB/blob/main/LICENSE" class="underline hover:text-gray-300">MIT</a>.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -65,33 +65,33 @@
|
||||
<div
|
||||
class="flex flex-col items-center mb-6 md:mb-0 md:mr-8 flex-shrink-0 w-full md:w-auto"
|
||||
>
|
||||
<div
|
||||
id="book-cover-container"
|
||||
class="w-40 h-56 rounded-lg shadow-md mb-4 overflow-hidden flex items-center justify-center bg-gray-100"
|
||||
>
|
||||
<div class="w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center">
|
||||
<svg
|
||||
class="w-20 h-20 text-white opacity-80"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="book-cover-container"
|
||||
class="relative w-40 h-56 rounded-lg shadow-md mb-4 overflow-hidden flex items-center justify-center bg-gray-100 group"
|
||||
>
|
||||
<div class="w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center">
|
||||
<svg
|
||||
class="w-20 h-20 text-white opacity-80"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="cover-file-input" class="hidden" accept="image/*" />
|
||||
<div
|
||||
id="book-status-container"
|
||||
class="relative w-full flex justify-center z-10 mb-4"
|
||||
></div>
|
||||
<div id="book-actions-container" class="w-full"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 w-full">
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:items-start md:justify-between mb-2"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "LiB"
|
||||
version = "0.8.1"
|
||||
version = "0.9.0"
|
||||
description = "Это простое API для управления авторами, книгами и их жанрами."
|
||||
authors = [{ name = "wowlikon" }]
|
||||
readme = "README.md"
|
||||
|
||||
Reference in New Issue
Block a user