diff --git a/server/config/__init__.py b/server/config/__init__.py index c78f109..69377f8 100644 --- a/server/config/__init__.py +++ b/server/config/__init__.py @@ -26,7 +26,7 @@ class AppConfig: db_pass_salt: str = field("DB_PASS_SALT", "") db_search_path: str = field("DB_SEARCH_PATH", "beerds") - fs_local_mount_dir: str = field("FS_LOCAL_MOUNT_DIR", default="./tmp/files") + fs_local_mount_dir: str = field("FS_LOCAL_MOUNT_DIR", default="./files") fs_s3_bucket: str = field("FS_S3_BUCKET", "") fs_s3_access_key_id: str = field("FS_ACCESS_KEY_ID", "") fs_s3_access_key: str = field("FS_SECRET_ACCESS_KEY", "") diff --git a/server/infra/db/db_mapper.py b/server/infra/db/db_mapper.py index d2cdef0..bcdf3a4 100644 --- a/server/infra/db/db_mapper.py +++ b/server/infra/db/db_mapper.py @@ -1,9 +1,7 @@ - from sqlalchemy.orm import registry mapper_registry = registry() - def dict_to_dataclass[T](data: dict, class_type: type[T]) -> T: return class_type(**data) diff --git a/server/infra/db/pg.py b/server/infra/db/pg.py index 3128a36..24c93cc 100644 --- a/server/infra/db/pg.py +++ b/server/infra/db/pg.py @@ -39,3 +39,9 @@ class AsyncDB(AbstractDB): def new_session(self): return asyncio.async_sessionmaker(self.engine, expire_on_commit=False)() + + def session_master(self): + return self.new_session() + + def session_slave(self): + return self.new_session() diff --git a/server/infra/web/__init__.py b/server/infra/web/__init__.py index cd8c64a..469f7ab 100644 --- a/server/infra/web/__init__.py +++ b/server/infra/web/__init__.py @@ -1,5 +1,6 @@ from server.infra.web.description import DescriptionController from server.infra.web.recognizer import BreedsController from server.infra.web.seo import SeoController +from server.infra.web.vote import VoteController -__all__ = ("DescriptionController", "SeoController", "BreedsController") +__all__ = ("DescriptionController", "SeoController", "BreedsController", "VoteController") diff --git a/server/infra/web/recognizer.py b/server/infra/web/recognizer.py index d9812a1..8afd43b 100644 --- a/server/infra/web/recognizer.py +++ b/server/infra/web/recognizer.py @@ -19,10 +19,12 @@ class BreedsController(Controller): async def beerds_dogs(self, data: Annotated[UploadFile, Body(media_type=RequestEncodingType.MULTI_PART)]) -> dict: recognizer_service: RecognizerService = inject.instance(RecognizerService) body = await data.read() - return await recognizer_service.predict_dog_image(body) + result = await recognizer_service.predict_dog_image(body) + return result.to_serializable() @post("/cats") async def beerds_cats(self, data: Annotated[UploadFile, Body(media_type=RequestEncodingType.MULTI_PART)]) -> dict: recognizer_service: RecognizerService = inject.instance(RecognizerService) body = await data.read() - return await recognizer_service.predict_cat_image(body) + result = await recognizer_service.predict_cat_image(body) + return result.to_serializable() diff --git a/server/infra/web/vote.py b/server/infra/web/vote.py new file mode 100644 index 0000000..c1910d9 --- /dev/null +++ b/server/infra/web/vote.py @@ -0,0 +1,39 @@ +from datetime import UTC, datetime +from uuid import uuid4 + +import inject +from litestar import ( + Controller, + post, +) +from pydantic import BaseModel + +from server.modules.rate import Vote, VotesService +from server.modules.descriptions import CharactersService + + +class VoteReq(BaseModel): + attachment_id: str + beerd_name: str + rate: int + + def to_domain(self, name_id_convert: dict) -> Vote: + return Vote( + id=str(uuid4()), + attachment_id=self.attachment_id, + beerd_id=name_id_convert[self.beerd_name], + rate=self.rate, + created_at=datetime.now(UTC), + ) + + +class VoteController(Controller): + path = "/votes" + + @post("/do") + async def beerds_dogs(self, data: VoteReq) -> dict: + rate_service: VotesService = inject.instance(VotesService) + characters_service: CharactersService = inject.instance(CharactersService) + breeds = await characters_service.get_characters() + await rate_service.add_vote(data.to_domain({b.name: b.id for b in breeds})) + return {"success": True} diff --git a/server/main.py b/server/main.py index 8bf326a..a459c78 100644 --- a/server/main.py +++ b/server/main.py @@ -12,9 +12,11 @@ from litestar.template.config import TemplateConfig from server.config import get_app_config from server.infra.db import AsyncDB -from server.infra.web import BreedsController, DescriptionController, SeoController +from server.infra.web import BreedsController, DescriptionController, SeoController, VoteController +from server.modules.attachments import AtachmentService, DBAttachmentRepository, LocalStorageDriver from server.modules.descriptions import CharactersService, PGCharactersRepository from server.modules.recognizer import RecognizerRepository, RecognizerService +from server.modules.rate import PGVoteRepository, VotesService os.environ["CUDA_VISIBLE_DEVICES"] = "-1" @@ -28,8 +30,11 @@ def inject_config(binder: inject.Binder): cnf = get_app_config() db = AsyncDB(cnf) loop.run_until_complete(db.connect()) - binder.bind(RecognizerService, RecognizerService(RecognizerRepository())) + attach_service = AtachmentService(LocalStorageDriver(), DBAttachmentRepository(db)) + binder.bind(RecognizerService, RecognizerService(RecognizerRepository(), attach_service)) binder.bind(CharactersService, CharactersService(PGCharactersRepository(db))) + binder.bind(VotesService, VotesService(PGVoteRepository(db))) + binder.bind(AtachmentService, attach_service) inject.configure(inject_config) @@ -39,6 +44,7 @@ app = Litestar( BreedsController, DescriptionController, SeoController, + VoteController, create_static_files_router(path="/static", directories=["server/static"]), ], template_config=TemplateConfig( diff --git a/server/migration/versions/2026-01-12-1828-474b572b7fe2-parent-commit-a284498.py b/server/migration/versions/2026-01-12-1828-474b572b7fe2-parent-commit-a284498.py index 3ec63bd..6bfaa1c 100644 --- a/server/migration/versions/2026-01-12-1828-474b572b7fe2-parent-commit-a284498.py +++ b/server/migration/versions/2026-01-12-1828-474b572b7fe2-parent-commit-a284498.py @@ -46,11 +46,11 @@ def upgrade() -> None: op.create_table( "votes", sa.Column("id", sa.String(), nullable=False), - sa.Column("attachemnt_id", sa.String(), nullable=False), + sa.Column("attachment_id", sa.String(), nullable=False), sa.Column("beerd_id", sa.String(), nullable=False), sa.Column("rate", sa.BigInteger(), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(["attachemnt_id"], ["attachments.id"], name="votes_attachemnt_id_fk"), + sa.ForeignKeyConstraint(["attachment_id"], ["attachments.id"], name="votes_attachment_id_fk"), sa.ForeignKeyConstraint(["beerd_id"], ["beerds.id"], name="votes_beerd_id_fk"), sa.PrimaryKeyConstraint("id"), ) diff --git a/server/modules/attachments/domains/attachments.py b/server/modules/attachments/domains/attachments.py index 0d79639..d591072 100644 --- a/server/modules/attachments/domains/attachments.py +++ b/server/modules/attachments/domains/attachments.py @@ -13,6 +13,5 @@ class Attachment(UJsonMixin): storage_driver_name: str path: str media_type: str - created_by: str content_type: str is_deleted: bool = False diff --git a/server/modules/attachments/repository/attachments.py b/server/modules/attachments/repository/attachments.py index 8821cfd..9545ce2 100644 --- a/server/modules/attachments/repository/attachments.py +++ b/server/modules/attachments/repository/attachments.py @@ -5,7 +5,7 @@ from sqlalchemy import CursorResult, delete, insert, select, update from server.config import get_app_config from server.infra.db import AbstractDB, AbstractSession, AsyncDB, MockDB -from server.modules.attachments.domains.attachments import Attachment +from server.modules.attachments.repository.models import Attachment class AttachmentRepository(metaclass=ABCMeta): diff --git a/server/modules/attachments/repository/models.py b/server/modules/attachments/repository/models.py index 864a323..70fd0a5 100644 --- a/server/modules/attachments/repository/models.py +++ b/server/modules/attachments/repository/models.py @@ -6,6 +6,7 @@ from sqlalchemy import BigInteger, Boolean, Column, DateTime, String from server.config import get_app_config from server.infra.db.db_mapper import mapper_registry +from server.modules.attachments.domains.attachments import Attachment as AttachmentModel @mapper_registry.mapped @@ -33,3 +34,15 @@ class Attachment(UJsonMixin): def __str__(self): return f"{get_app_config().app_public_url}/api/v0/attachment/{self.id}.original.ext" + + def to_domain(self) -> AttachmentModel: + return AttachmentModel( + id=self.id, + size=self.size, + media_type=self.media_type, + content_type=self.content_type, + created_at=self.created_at, + updated_at=self.updated_at, + storage_driver_name=self.storage_driver_name, + path=self.path, + ) diff --git a/server/modules/attachments/services/attachment.py b/server/modules/attachments/services/attachment.py index 9dbef06..08cd474 100644 --- a/server/modules/attachments/services/attachment.py +++ b/server/modules/attachments/services/attachment.py @@ -21,6 +21,7 @@ from server.infra.db import AbstractSession from server.infra.logger import get_logger from server.modules.attachments.domains.attachments import Attachment from server.modules.attachments.repository.attachments import AttachmentRepository +from server.modules.attachments.repository.models import Attachment as AttachmentModel class StorageDriversType(str, Enum): @@ -240,22 +241,21 @@ class AtachmentService: def url(self, attachment_id: str, content_type: str | None = None) -> str: return f"{self._cnf.app_public_url}/api/v0/attachment/{attachment_id}.original.{self.extension(content_type)}" - async def create(self, file: bytes, user_id: str) -> Attachment: + async def create(self, file: bytes) -> Attachment: path = await self._driver.put(file) content_type = self.content_type(file) - attach = Attachment( + attach = AttachmentModel( size=len(file), storage_driver_name=str(self._driver.get_name()), path=path, media_type=self.media_type(content_type), content_type=content_type, - created_by=user_id, id=str(uuid.uuid4()), created_at=datetime.now(UTC), updated_at=datetime.now(UTC), ) await self._repository.create(attach) - return attach + return attach.to_domain() async def get_info(self, session: AbstractSession | None, attach_id: list[str]) -> list[Attachment]: if not attach_id: diff --git a/server/modules/descriptions/repository/repository.py b/server/modules/descriptions/repository/repository.py index f944fc5..76dfe91 100644 --- a/server/modules/descriptions/repository/repository.py +++ b/server/modules/descriptions/repository/repository.py @@ -69,7 +69,7 @@ class PGCharactersRepository(ACharactersRepository): async with self._db.async_session() as session: # Писем SELECT‑запрос (получаем все строки) - stmt = select( + stmt = select( # type: ignore models.Beerds.id, models.Beerds.name, models.Beerds.alias, @@ -104,7 +104,7 @@ class PGCharactersRepository(ACharactersRepository): async with self._db.async_session() as session: stmt = ( - select( + select( # type: ignore models.Beerds.id, models.Beerds.name, models.Beerds.alias, diff --git a/server/modules/rate/__init__.py b/server/modules/rate/__init__.py new file mode 100644 index 0000000..b2db803 --- /dev/null +++ b/server/modules/rate/__init__.py @@ -0,0 +1,10 @@ +from server.modules.rate.domain import Vote +from server.modules.rate.repository import AVoteRepository, PGVoteRepository +from server.modules.rate.service import VotesService + +__all__ = ( + "VotesService", + "AVoteRepository", + "PGVoteRepository", + "Vote", +) diff --git a/server/modules/rate/domain.py b/server/modules/rate/domain.py new file mode 100644 index 0000000..f80819a --- /dev/null +++ b/server/modules/rate/domain.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(frozen=True) +class Vote: + id: str + attachment_id: str + beerd_id: str + rate: int + created_at: datetime diff --git a/server/modules/rate/domain/__init__.py b/server/modules/rate/domain/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/server/modules/rate/repository/__init__.py b/server/modules/rate/repository/__init__.py index e69de29..ff282f7 100644 --- a/server/modules/rate/repository/__init__.py +++ b/server/modules/rate/repository/__init__.py @@ -0,0 +1,13 @@ +from server.modules.rate.domain import Vote +from server.modules.rate.repository.models import Vote as VoteModel +from server.modules.rate.repository.repository import AVoteRepository, PGVoteRepository +from server.modules.rate.service import VotesService + +__all__ = ( + "Vote", + "PGVoteRepository", + "ACharactersRepository", + "AVoteRepository", + "VotesService", + "VoteModel", +) diff --git a/server/modules/rate/repository/models.py b/server/modules/rate/repository/models.py index 426e63f..b669c00 100644 --- a/server/modules/rate/repository/models.py +++ b/server/modules/rate/repository/models.py @@ -2,8 +2,6 @@ from dataclasses import dataclass, field from datetime import UTC, datetime from dataclasses_ujson.dataclasses_ujson import UJsonMixin # type: ignore -from server.config import get_app_config -from server.infra.db.db_mapper import mapper_registry from sqlalchemy import ( BigInteger, Column, @@ -12,6 +10,10 @@ from sqlalchemy import ( String, ) +from server.config import get_app_config +from server.infra.db.db_mapper import mapper_registry +from server.modules.rate import domain + @mapper_registry.mapped @dataclass @@ -19,12 +21,12 @@ class Vote(UJsonMixin): __sa_dataclass_metadata_key__ = "sa" __tablename__ = "votes" __table_args__ = ( - ForeignKeyConstraint(["attachemnt_id"], ["attachments.id"], "votes_attachemnt_id_fk"), + ForeignKeyConstraint(["attachment_id"], ["attachments.id"], "votes_attachment_id_fk"), ForeignKeyConstraint(["beerd_id"], ["beerds.id"], "votes_beerd_id_fk"), ) id: str = field(metadata={"sa": Column(String(), primary_key=True, nullable=False)}) - attachemnt_id: str = field(metadata={"sa": Column(String(), nullable=False)}) + attachment_id: str = field(metadata={"sa": Column(String(), nullable=False)}) beerd_id: str = field(metadata={"sa": Column(String(), nullable=False)}) rate: int = field(metadata={"sa": Column(BigInteger(), nullable=False)}) created_at: datetime = field( @@ -34,3 +36,13 @@ class Vote(UJsonMixin): def __str__(self): return f"{get_app_config().app_public_url}/api/v0/attachment/{self.id}.original.ext" + + @staticmethod + def from_domain(d: domain.Vote) -> "Vote": + return Vote( + id=d.id, + attachment_id=d.attachment_id, + beerd_id=d.beerd_id, + rate=d.rate, + created_at=d.created_at, + ) diff --git a/server/modules/rate/repository/repository.py b/server/modules/rate/repository/repository.py new file mode 100644 index 0000000..165431c --- /dev/null +++ b/server/modules/rate/repository/repository.py @@ -0,0 +1,81 @@ +""" +Repository for working with the :class:`~server.modules.rate.repository.models.Vote` model. +Only the “write‑only” operation – adding a vote – is required in the current +application logic. The repository keeps the database interaction +encapsulated and provides an asynchronous API that can be used by the +service layer (or any other consumer). + +The implementation uses the same pattern that is already present in the +``CharactersRepository`` – an async session manager from +``server.infra.db.AsyncDB`` and an interface that makes unit‑testing +straightforward. +""" + +from abc import ABCMeta, abstractmethod + +from server.infra.db import AsyncDB +from server.modules.rate.repository import models + + +# --------------------------------------------------------------------------- # +# 1️⃣ Base interface +# --------------------------------------------------------------------------- # +class AVoteRepository(metaclass=ABCMeta): + """ + Abstract repository that declares the contract for working with votes. + At the moment only one operation is required – inserting a new vote. + """ + + @abstractmethod + async def add_vote(self, vote: models.Vote) -> None: + """Persist ``vote`` into the database.""" + raise NotImplementedError + + +# --------------------------------------------------------------------------- # +# 2️⃣ Concrete PostgreSQL implementation +# --------------------------------------------------------------------------- # +class PGVoteRepository(AVoteRepository): + """ + PostgreSQL implementation of :class:`AVoteRepository`. + + The repository is intentionally *minimal* – only an ``add_vote`` method + is exposed. The rest of the CRUD operations are expected to be added + later if/when the business logic grows. + """ + + _db: AsyncDB + + def __init__(self, db: AsyncDB): + self._db = db + + # --------------------------------------------------------------------- # + # 2.1 Add a vote + # --------------------------------------------------------------------- # + async def add_vote(self, vote: models.Vote) -> None: + """ + Insert ``vote`` into the ``votes`` table. + + Parameters + ---------- + vote: + Instance of :class:`~server.modules.rate.repository.models.Vote` + containing all necessary fields (``id``, ``attachment_id``, + ``beerd_id``, ``rate`` and ``created_at``). The instance + is a *mapped* dataclass, therefore SQLAlchemy can persist it + directly. + + Notes + ----- + * No explicit caching – the operation mutates the database and + should never be served from a cache. + * The method is deliberately ``async`` because it uses + ``AsyncDB.async_session``. + """ + async with self._db.async_session() as session: + # We use the *instance* directly – SQLAlchemy will handle the + # mapping for us. ``insert`` could also be used, but the + # instance‑based approach keeps type‑checking and IDE + # auto‑completion in sync with the dataclass. + session.add(vote) + await session.commit() diff --git a/server/modules/rate/service.py b/server/modules/rate/service.py new file mode 100644 index 0000000..75806c7 --- /dev/null +++ b/server/modules/rate/service.py @@ -0,0 +1,12 @@ +from server.modules.rate.domain import Vote +from server.modules.rate.repository import AVoteRepository, VoteModel + + +class VotesService: + __slots__ = ("_repository",) + + def __init__(self, repository: AVoteRepository): + self._repository = repository + + async def add_vote(self, vote: Vote): + return await self._repository.add_vote(VoteModel.from_domain(vote)) diff --git a/server/modules/rate/service/__init__.py b/server/modules/rate/service/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/server/modules/recognizer/service.py b/server/modules/recognizer/service.py index a2c53be..db3723b 100644 --- a/server/modules/recognizer/service.py +++ b/server/modules/recognizer/service.py @@ -1,13 +1,16 @@ import io import os -from typing import Any, NewType +from dataclasses import dataclass +from typing import Any, NewType, Protocol +from dataclasses_ujson.dataclasses_ujson import UJsonMixin # type: ignore from PIL import Image os.environ["CUDA_VISIBLE_DEVICES"] = "-1" import torch from torchvision import transforms # type: ignore +from server.modules.attachments.domains.attachments import Attachment from server.modules.recognizer.repository import ARecognizerRepository TorchModel = NewType("TorchModel", torch.nn.Module) @@ -23,11 +26,31 @@ DOG_MODEL = load_model("server/models/dogs_model.pth") CAT_MODEL = load_model("server/models/cats_model.pth") -class RecognizerService: - __slots__ = "_repository" +class AttachmentService(Protocol): + async def create(self, file: bytes) -> Attachment: + pass - def __init__(self, repository: ARecognizerRepository): + +@dataclass +class ResultImages(UJsonMixin): + name: str + url: list[str] + + +@dataclass +class RecognizerResult(UJsonMixin): + results: dict + images: list + description: dict[str, list] | None + uploaded_attach_id: str | None + + +class RecognizerService: + __slots__ = ("_repository", "_attachment_service") + + def __init__(self, repository: ARecognizerRepository, attachment_service: AttachmentService): self._repository = repository + self._attachment_service = attachment_service async def images_cats(self) -> dict: return await self._repository.images_cats() @@ -35,7 +58,8 @@ class RecognizerService: async def images_dogs(self) -> dict: return await self._repository.images_dogs() - async def predict_dog_image(self, image: bytes) -> dict: + async def predict_dog_image(self, image: bytes) -> RecognizerResult: + attachment = await self._attachment_service.create(image) predicted_data = self._predict(image, DOG_MODEL) results = {} images = [] @@ -43,42 +67,36 @@ class RecognizerService: images_dogs = await self._repository.images_dogs() for d in predicted_data: predicted_idx, probabilities = d - predicted_label = self._repository.labels_dogs()[str(predicted_idx)] + predicted_label: str = self._repository.labels_dogs()[str(predicted_idx)] name = predicted_label.replace("_", " ") images.append( - { - "name": name, - "url": [f"/static/assets/dog/{predicted_label}/{i}" for i in images_dogs[predicted_label]], - } + ResultImages( + name=name, url=[f"/static/assets/dog/{predicted_label}/{i}" for i in images_dogs[predicted_label]] + ) ) description.setdefault(name, []).append(f"/dogs-characteristics/{name.replace(' ', '_')}") results[probabilities] = name - return { - "results": results, - "images": images, - "description": description, - } + return RecognizerResult( + results=results, images=images, description=description, uploaded_attach_id=attachment.id + ) - async def predict_cat_image(self, image: bytes) -> dict: + async def predict_cat_image(self, image: bytes) -> RecognizerResult: + attachment = await self._attachment_service.create(image) predicted_data = self._predict(image, CAT_MODEL) results = {} images = [] images_cats = await self._repository.images_cats() for d in predicted_data: predicted_idx, probabilities = d - predicted_label = self._repository.labels_cats()[str(predicted_idx)] + predicted_label: str = self._repository.labels_cats()[str(predicted_idx)] name = predicted_label.replace("_", " ") images.append( - { - "name": name, - "url": [f"/static/assets/cat/{predicted_label}/{i}" for i in images_cats[predicted_label]], - } + ResultImages( + name=name, url=[f"/static/assets/cat/{predicted_label}/{i}" for i in images_cats[predicted_label]] + ) ) results[probabilities] = name - return { - "results": results, - "images": images, - } + return RecognizerResult(results=results, images=images, description=None, uploaded_attach_id=attachment.id) def _predict(self, image: bytes, model, device="cpu") -> list[Any]: img_size = (224, 224) diff --git a/server/static/scripts.js b/server/static/scripts.js index c923fcc..840a771 100644 --- a/server/static/scripts.js +++ b/server/static/scripts.js @@ -1,6 +1,9 @@ let urlCreator = window.URL || window.webkitURL; +let current_attachment_id = 0; +let current_beerd_name = []; + async function SavePhoto(self) { document.getElementById("result").innerHTML = ""; let photo = document.getElementById("file-input").files[0]; @@ -10,6 +13,8 @@ async function SavePhoto(self) { if (response.ok) { let json = await response.json(); + current_attachment_id = json.uploaded_attach_id; + let text = "

Результаты

"; let uniqChecker = {}; @@ -32,6 +37,7 @@ async function SavePhoto(self) { // Обработка основных результатов for (let key in json.results) { + current_beerd_name.push(json.results[key]); if (json.description != undefined) { text += `
${json.results[key]} (вероятность: ${Math.round(parseFloat(key) * 100)}%)
Описание
`; } else { @@ -51,7 +57,6 @@ async function SavePhoto(self) { text += "
"; uniqChecker[json.results[key]] = key; } - // Обработка дополнительных результатов for (let key in json.results_net) { if (uniqChecker[json.results_net[key]] !== undefined) continue; @@ -70,6 +75,9 @@ async function SavePhoto(self) { } document.getElementById("result").innerHTML = text; + document.querySelector(".star-rating").style.display = "block"; + document.getElementById("rate-stars").style.display = "block"; + document.getElementById("feedback").textContent = ""; setTimeout(function(){ window.scrollBy({ top: 300, @@ -181,4 +189,90 @@ function openModal(imgElement) { } function closeModal() { document.getElementById('modal').style.display = "none"; -} \ No newline at end of file +} + + +/* ────────────────────────────────────────────────────── */ +/* 3️⃣ Скрипт: выбор звезды, отправка POST‑запроса */ +/* ────────────────────────────────────────────────────── */ +(() => { + const ratingContainer = document.querySelector('.star-rating'); + const stars = Array.from(ratingContainer.querySelectorAll('.star')); + const feedback = document.getElementById('feedback'); + + let currentRating = 0; // 0 = пока не выбрано + + // Установить визуальное состояние (цвет, aria‑checked) + function setRating(value) { + currentRating = value; + stars.forEach(star => { + const starValue = Number(star.dataset.value); + const isSelected = starValue <= value; + star.classList.toggle('selected', isSelected); + star.setAttribute('aria-checked', isSelected); + }); + } + + // Отправка POST /vote + async function sendVote(value) { + // Подготовьте объект‑payload. Добавьте id‑токены, если нужны. + const payload = { + rate: value, + attachment_id: current_attachment_id, + beerd_name: current_beerd_name[0], + }; + + try { + const resp = await fetch('/votes/do', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + credentials: 'include' // если ваш API использует cookies + }); + + if (!resp.ok) { + throw new Error(`Ошибка ${resp.status}`); + } + + feedback.textContent = 'Спасибо за оценку!'; + feedback.style.color = '#080'; + document.getElementById("rate-stars").style.display = "none"; + } catch (err) { + console.error(err); + feedback.textContent = 'Произошла ошибка, попробуйте позже.'; + feedback.style.color = '#a00'; + } + } + + // Обработчики клика и клавиатуры + stars.forEach(star => { + // Клик мышкой + star.addEventListener('click', () => { + const value = Number(star.dataset.value); + setRating(value); + sendVote(value); + }); + + // Навигация клавиатурой: пробел/Enter выбирает звезду + star.addEventListener('keydown', e => { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + const value = Number(star.dataset.value); + setRating(value); + sendVote(value); + } + }); + + // При наведении меняем состояние всех звёзд до текущего + star.addEventListener('mouseover', () => { + setRating(Number(star.dataset.value)); + }); + + star.addEventListener('mouseout', () => { + setRating(currentRating); // вернём к сохранённому + }); + }); + + // По умолчанию не показываем выбранную оценку + setRating(0); +})(); \ No newline at end of file diff --git a/server/static/styles.css b/server/static/styles.css index cd7c519..75a396b 100644 --- a/server/static/styles.css +++ b/server/static/styles.css @@ -339,4 +339,34 @@ input[type="text"]:hover { color: white; margin-top: 10px; font-size: 18px; - } \ No newline at end of file + } +/* ────────────────────────────────────────────────────── */ +/* 1️⃣ Стили для звездочек */ +/* ────────────────────────────────────────────────────── */ +.star-rating { + display: none; +} + +.star-rating span { + display: inline-flex; + gap: .2rem; /* отступы между звёздами */ + font-size: 2rem; /* размер звёзд */ + user-select: none; /* отключаем выделение текста */ +} + +.star { + cursor: pointer; + color: #f0f0f0; /* белый (светло‑серый) фон */ + transition: color .15s ease;/* плавное изменение цвета */ +} + +.star.selected, +.star:hover, +.star:focus { + color: #ffd700; /* золотой при наведении/выборе */ +} + +.star:focus-visible { /* обводка для клавиатурного фокуса */ + outline: 2px solid #333; + outline-offset: 2px; +} \ No newline at end of file diff --git a/server/templates/base.html b/server/templates/base.html index e6733b6..27fedc2 100644 --- a/server/templates/base.html +++ b/server/templates/base.html @@ -8,7 +8,7 @@ {% block meta %}{% endblock %} {% block title %}{% endblock %} - + + diff --git a/server/templates/dogs.html b/server/templates/dogs.html index 75115c2..8e7f9e1 100644 --- a/server/templates/dogs.html +++ b/server/templates/dogs.html @@ -19,6 +19,20 @@
+ + +
+

Оцените результат

+
+ + + + + + +
+
+