diff --git a/app/api/api_v1/__init__.py b/app/api/api_v1/__init__.py index 992c4a6..b088f38 100644 --- a/app/api/api_v1/__init__.py +++ b/app/api/api_v1/__init__.py @@ -3,6 +3,7 @@ from fastapi import APIRouter from api.api_v1.projects import router as project_router +from api.api_v1.tags import router as tag_router from .auth import router as auth_router from .upload_file import router as upload_file_router @@ -15,4 +16,5 @@ router.include_router(auth_router) router.include_router(upload_file_router) router.include_router(project_router) +router.include_router(tag_router) router.include_router(user_router) diff --git a/app/api/api_v1/projects/__init__.py b/app/api/api_v1/projects/__init__.py index c3d24ce..df4592b 100644 --- a/app/api/api_v1/projects/__init__.py +++ b/app/api/api_v1/projects/__init__.py @@ -2,7 +2,7 @@ from fastapi import APIRouter from .details_views import router as details_router -from .list_view import router as list_router +from .list_views import router as list_router router = APIRouter( prefix="/projects", diff --git a/app/api/api_v1/projects/list_view.py b/app/api/api_v1/projects/list_views.py similarity index 100% rename from app/api/api_v1/projects/list_view.py rename to app/api/api_v1/projects/list_views.py diff --git a/app/api/api_v1/tags/__init__.py b/app/api/api_v1/tags/__init__.py new file mode 100644 index 0000000..bafcb82 --- /dev/null +++ b/app/api/api_v1/tags/__init__.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter + +from .details_views import router as details_views_router +from .list_views import router as list_views_router + +router = APIRouter( + prefix="/tags", + tags=["Tags"], +) + +router.include_router(list_views_router) +router.include_router(details_views_router) diff --git a/app/api/api_v1/tags/details_views.py b/app/api/api_v1/tags/details_views.py new file mode 100644 index 0000000..7db00f7 --- /dev/null +++ b/app/api/api_v1/tags/details_views.py @@ -0,0 +1,36 @@ +from dependencies.annotations import AuthUserByAccessTokenDep, TagServiceDep +from fastapi import APIRouter, status +from schemas.tag import TagResponseList + +router = APIRouter() + + +@router.get( + "/project/{project_id}", + status_code=status.HTTP_200_OK, + response_model=TagResponseList, +) +async def get_project_tags( + project_id: int, + user_id: AuthUserByAccessTokenDep, + tag_service: TagServiceDep, +) -> TagResponseList: + return await tag_service.get_project_tags( + project_id=project_id, + user_id=user_id, + ) + + +@router.delete( + "/{tag_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_project_tag( + tag_id: int, + user_id: AuthUserByAccessTokenDep, + tag_service: TagServiceDep, +) -> None: + return await tag_service.delete_by_id( + tag_id=tag_id, + user_id=user_id, + ) diff --git a/app/api/api_v1/tags/list_views.py b/app/api/api_v1/tags/list_views.py new file mode 100644 index 0000000..a3a69db --- /dev/null +++ b/app/api/api_v1/tags/list_views.py @@ -0,0 +1,21 @@ +from dependencies.annotations import AuthUserByAccessTokenDep, TagServiceDep +from fastapi import APIRouter, status +from schemas.tag import TagCreate, TagResponse + +router = APIRouter() + + +@router.post( + "/create", + status_code=status.HTTP_201_CREATED, + response_model=TagResponse, +) +async def create_tag( + user_id: AuthUserByAccessTokenDep, + create_tag_data: TagCreate, + tag_service: TagServiceDep, +) -> TagResponse: + return await tag_service.create_tag( + user_id=user_id, + create_tag_data=create_tag_data, + ) diff --git a/app/core/constants.py b/app/core/constants.py index 69ebe74..ac5fbca 100644 --- a/app/core/constants.py +++ b/app/core/constants.py @@ -26,6 +26,9 @@ PROJECT_PAGINATION_SIZE_MIN_VALUE = 1 PROJECT_PAGINATION_PAGE_MIN_VALUE = 1 +TAG_MIN_LENGTH = 3 +TAG_MAX_LENGTH = 25 + RENDER_WIDTH_MIN_VALUE = 20 RENDER_WIDTH_MAX_VALUE = 2160 diff --git a/app/core/exceptions/tag.py b/app/core/exceptions/tag.py new file mode 100644 index 0000000..594bd24 --- /dev/null +++ b/app/core/exceptions/tag.py @@ -0,0 +1,23 @@ +from core.exceptions.base import NotFoundError + + +class TagNotFoundError(NotFoundError): + """ + Класс для ошибок, связанных с + ненахождением тэга проекта. + """ + + def __init__(self, detail: str) -> None: + self.detail = detail + + +class TagIdNotFoundError(TagNotFoundError): + """ + Класс для ошибок, связанных с + ненахождением тэга по id. + """ + + def __init__(self, tag_id: int) -> None: + self.tag_id = tag_id + detail = f"Tag with id = {tag_id} not found." + super().__init__(detail) diff --git a/app/core/interfaces/repositories.py b/app/core/interfaces/repositories.py index 4673931..2d0f0ca 100644 --- a/app/core/interfaces/repositories.py +++ b/app/core/interfaces/repositories.py @@ -1,11 +1,12 @@ from abc import ABC, abstractmethod -from infrastructure.database.models import File, Outbox, Project, Render, User +from infrastructure.database.models import File, Outbox, Project, Render, Tag, User from pydantic import EmailStr from schemas.event import EventCreate from schemas.file import FileCreate from schemas.project import ProjectCreate, ProjectPartialUpdate from schemas.render import RenderCreate +from schemas.tag import TagCreate from schemas.user import UserCreate, UserUpdate @@ -182,9 +183,42 @@ async def delete_by_id(self, project_id: int) -> None: """ +class AbstractTagRepository(AbstractRepository): + """ + Интерфейс для работы с тэгами проектов. + """ + + @abstractmethod + async def get_by_id(self, tag_id: int) -> Tag | None: + """ + Метод для получения информации о тэге по id. + """ + + @abstractmethod + async def get_project_tags(self, project_id: int) -> list[Tag] | None: + """ + Метод для получения тэгов проекта. + """ + + @abstractmethod + async def create_tag( + self, + create_tag_data: TagCreate, + ) -> Tag: + """ + Метод для добавления тэга к проекту. + """ + + @abstractmethod + async def delete_by_id(self, tag_id: int) -> None: + """ + Метод для удаления тэга по id. + """ + + class AbstractOutboxRepository(AbstractRepository): """ - Класс для работы с outbox таблицей. + Интерфейс для работы с outbox таблицей. """ @abstractmethod diff --git a/app/dependencies/annotations.py b/app/dependencies/annotations.py index da15738..f39106a 100644 --- a/app/dependencies/annotations.py +++ b/app/dependencies/annotations.py @@ -8,6 +8,7 @@ from services.auth import AuthService from services.file_uploader import FileUploader from services.project import ProjectService +from services.tag import TagService from services.user import UserService from dependencies.auth import ( @@ -21,6 +22,7 @@ get_auth_service, get_input_file_uploader, get_project_service, + get_tag_service, get_user_service, ) @@ -39,6 +41,11 @@ Depends(get_auth_service), ] +TagServiceDep = Annotated[ + TagService, + Depends(get_tag_service), +] + AuthUserByAccessTokenDep = Annotated[ int, Depends(get_auth_user_by_access_token), diff --git a/app/dependencies/services.py b/app/dependencies/services.py index a5e320f..8f9dcc9 100644 --- a/app/dependencies/services.py +++ b/app/dependencies/services.py @@ -10,6 +10,7 @@ from services.auth import AuthService from services.file_uploader import FileUploader from services.project import ProjectService +from services.tag import TagService from services.user import UserService from dependencies.minio import get_minio_client @@ -70,3 +71,15 @@ async def get_project_service( unit_of_work, ) yield project_service + + +async def get_tag_service( + unit_of_work: Annotated[ + UnitOfWork, + Depends(get_unit_of_work), + ], +) -> AsyncGenerator[TagService]: + tag_service = TagService( + unit_of_work, + ) + yield tag_service diff --git a/app/infrastructure/database/models/__init__.py b/app/infrastructure/database/models/__init__.py index 0b233cb..d0957e2 100644 --- a/app/infrastructure/database/models/__init__.py +++ b/app/infrastructure/database/models/__init__.py @@ -3,6 +3,7 @@ "Outbox", "Project", "Render", + "Tag", "User", ) @@ -10,4 +11,5 @@ from .outbox import Outbox from .project import Project from .render import Render +from .tag import Tag from .user import User diff --git a/app/infrastructure/database/models/project.py b/app/infrastructure/database/models/project.py index 9c91e8d..9f84af3 100644 --- a/app/infrastructure/database/models/project.py +++ b/app/infrastructure/database/models/project.py @@ -14,7 +14,7 @@ from infrastructure.database.core import Base if TYPE_CHECKING: - from infrastructure.database.models import File, Render, User + from infrastructure.database.models import File, Render, Tag, User class Project(Base): @@ -59,6 +59,11 @@ class Project(Base): foreign_keys=[render_id], back_populates="project", ) + tags: Mapped[list["Tag"]] = relationship( + "Tag", + foreign_keys="Tag.project_id", + back_populates="project", + ) __table_args__ = ( CheckConstraint( diff --git a/app/infrastructure/database/models/tag.py b/app/infrastructure/database/models/tag.py new file mode 100644 index 0000000..3a1ebb3 --- /dev/null +++ b/app/infrastructure/database/models/tag.py @@ -0,0 +1,34 @@ +from typing import TYPE_CHECKING + +from core.constants import TAG_MAX_LENGTH, TAG_MIN_LENGTH +from sqlalchemy import CheckConstraint, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from infrastructure.database.core import Base +from infrastructure.database.models import Project + +if TYPE_CHECKING: + from infrastructure.database.models import Project + + +class Tag(Base): + __tablename__ = "tags" + + name: Mapped[str] = mapped_column(String(TAG_MAX_LENGTH)) + project_id: Mapped[int] = mapped_column(ForeignKey("projects.id")) + + project: Mapped["Project"] = relationship( + "Project", + foreign_keys=[project_id], + back_populates="tags", + ) + + __table_args__ = ( + CheckConstraint( + f""" + LENGTH(name) >= {TAG_MIN_LENGTH} + AND LENGTH(name) <= {TAG_MAX_LENGTH} + """, + name="length_tag_name", + ), + ) diff --git a/app/infrastructure/database/repositories/__init__.py b/app/infrastructure/database/repositories/__init__.py index 6d2a7cb..1e40387 100644 --- a/app/infrastructure/database/repositories/__init__.py +++ b/app/infrastructure/database/repositories/__init__.py @@ -3,10 +3,12 @@ "OutboxRepository", "ProjectRepository", "RenderRepository", + "TagRepository", "UserRepository", ) from .file import FileRepository from .outbox import OutboxRepository from .project import ProjectRepository from .render import RenderRepository +from .tag import TagRepository from .user import UserRepository diff --git a/app/infrastructure/database/repositories/tag.py b/app/infrastructure/database/repositories/tag.py new file mode 100644 index 0000000..dd2ce94 --- /dev/null +++ b/app/infrastructure/database/repositories/tag.py @@ -0,0 +1,33 @@ +from core.interfaces.repositories import AbstractTagRepository +from schemas.tag import TagCreate +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload + +from infrastructure.database.models import Tag + + +class TagRepository(AbstractTagRepository): + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def get_by_id(self, tag_id: int) -> Tag | None: + stmt = select(Tag).options(joinedload(Tag.project)).where(Tag.id == tag_id) + result = await self.session.execute(stmt) + return result.scalar() + + async def get_project_tags(self, project_id: int) -> list[Tag] | None: + stmt = select(Tag).where(Tag.project_id == project_id) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def create_tag(self, create_tag_data: TagCreate) -> Tag: + tag = Tag(**create_tag_data.model_dump()) + self.session.add(tag) + await self.session.flush() + return tag + + async def delete_by_id(self, tag_id: int) -> None: + stmt = delete(Tag).where(Tag.id == tag_id) + await self.session.execute(stmt) + await self.session.flush() diff --git a/app/migrations/versions/c237a3caa1ac_add_tags_table.py b/app/migrations/versions/c237a3caa1ac_add_tags_table.py new file mode 100644 index 0000000..4885a60 --- /dev/null +++ b/app/migrations/versions/c237a3caa1ac_add_tags_table.py @@ -0,0 +1,39 @@ +"""add tags table + +Revision ID: c237a3caa1ac +Revises: cde50160235e +Create Date: 2026-08-26 16:00:57.287138 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c237a3caa1ac' +down_revision: Union[str, Sequence[str], None] = 'cde50160235e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('tags', + sa.Column('name', sa.String(length=25), nullable=False), + sa.Column('project_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.CheckConstraint('\n LENGTH(name) >= 3\n AND LENGTH(name) <= 25\n ', name=op.f('ck_tags_length_tag_name')), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], name=op.f('fk_tags_project_id_projects')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_tags_id')) + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('tags') + # ### end Alembic commands ### diff --git a/app/schemas/constraints/tag.py b/app/schemas/constraints/tag.py new file mode 100644 index 0000000..d554472 --- /dev/null +++ b/app/schemas/constraints/tag.py @@ -0,0 +1,12 @@ +from typing import Annotated + +from annotated_types import Len +from core.constants import TAG_MAX_LENGTH, TAG_MIN_LENGTH + +NameConstraint = Annotated[ + str, + Len( + min_length=TAG_MIN_LENGTH, + max_length=TAG_MAX_LENGTH, + ), +] diff --git a/app/schemas/project.py b/app/schemas/project.py index dad560f..5f14717 100644 --- a/app/schemas/project.py +++ b/app/schemas/project.py @@ -19,7 +19,7 @@ class ProjectBase(BaseModel): """ - Базовая схема для создания проекта. + Базовая схема для проекта. """ name: NameConstraint diff --git a/app/schemas/tag.py b/app/schemas/tag.py new file mode 100644 index 0000000..3ac6a65 --- /dev/null +++ b/app/schemas/tag.py @@ -0,0 +1,37 @@ +from typing import ClassVar + +from pydantic import BaseModel, ConfigDict + +from schemas.constraints.tag import NameConstraint + + +class TagBase(BaseModel): + """ + Базовая схема для работы с тэгами. + """ + + name: NameConstraint + project_id: int + model_config: ClassVar[ConfigDict] = ConfigDict(from_attributes=True) + + +class TagCreate(TagBase): + """ + Схема для добавления тэга к проекту. + """ + + +class TagResponse(TagBase): + """ + Схема для вывода информации о тэге. + """ + + id: int + + +class TagResponseList(BaseModel): + """ + Схема для вывода информации о списке тэгов проекта. + """ + + tag_list: list[TagResponse] diff --git a/app/services/tag.py b/app/services/tag.py new file mode 100644 index 0000000..72b8619 --- /dev/null +++ b/app/services/tag.py @@ -0,0 +1,69 @@ +from typing import cast + +from core.constants import ProjectVisibility +from core.exceptions.auth import PermissionDeniedError +from core.exceptions.project import ProjectIdNotFoundError +from core.exceptions.tag import TagIdNotFoundError +from core.interfaces.clients import AbstractUnitOfWorkClient +from infrastructure.database.models import User +from infrastructure.database.repositories import ProjectRepository, TagRepository +from schemas.tag import TagCreate, TagResponse, TagResponseList + + +class TagService: + def __init__(self, unit_of_work: AbstractUnitOfWorkClient) -> None: + self.unit_of_work = unit_of_work + self.project_repository = self.unit_of_work.get_repository(ProjectRepository) + self.tag_repository = self.unit_of_work.get_repository(TagRepository) + + async def get_project_tags(self, project_id: int, user_id: int) -> TagResponseList: + project = await self.project_repository.get_by_id(project_id) + if project is None: + raise ProjectIdNotFoundError(project_id) + + get_owner_coroutine = self.project_repository.get_project_owner( + project_id, + ) + owner = cast(User, await get_owner_coroutine) + if project.visibility == ProjectVisibility.private and user_id != owner.id: + raise PermissionDeniedError + + tag_list = [ + TagResponse.model_validate(tag) + for tag in await self.tag_repository.get_project_tags(project_id) + ] + return TagResponseList(tag_list=tag_list) + + async def create_tag( + self, + create_tag_data: TagCreate, + user_id: int, + ) -> TagResponse: + project_id = create_tag_data.project_id + project = await self.project_repository.get_by_id(project_id) + if project is None: + raise ProjectIdNotFoundError(project_id) + + get_owner_coroutine = self.project_repository.get_project_owner( + project_id, + ) + owner = cast(User, await get_owner_coroutine) + if owner.id != user_id: + raise PermissionDeniedError + + tag = await self.tag_repository.create_tag(create_tag_data) + return TagResponse.model_validate(tag) + + async def delete_by_id(self, tag_id: int, user_id: int) -> None: + tag = await self.tag_repository.get_by_id(tag_id) + if tag is None: + raise TagIdNotFoundError(tag_id) + + get_owner_coroutine = self.project_repository.get_project_owner( + tag.project.id, + ) + owner = cast(User, await get_owner_coroutine) + if owner.id != user_id: + raise PermissionDeniedError + + await self.tag_repository.delete_by_id(tag_id) diff --git a/frontend/script.js b/frontend/script.js index 37de06e..c492b70 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -195,6 +195,151 @@ async function updateCurrentUserProfile(surname, name, username, email) { }); } +// ===== ТЭГИ ===== +async function getProjectTags(projectId) { + return await apiRequest(`/tags/project/${projectId}`); +} + +async function createTag(name, projectId) { + return await apiRequest('/tags/create', { + method: 'POST', + body: JSON.stringify({ + name: name, + project_id: projectId + }) + }); +} + +// ===== УДАЛЕНИЕ ТЭГА ===== +async function deleteTag(tagId) { + const token = getAccessToken(); + const response = await fetch(`${API_BASE}/tags/${tagId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + let errorMessage = 'Ошибка удаления тега'; + try { + const error = await response.json(); + errorMessage = error.detail || errorMessage; + } catch (e) { + // Если ответ не JSON + const text = await response.text(); + if (text) errorMessage = text; + } + throw new Error(errorMessage); + } + + // DELETE возвращает 204 No Content - ничего не возвращаем + return null; +} + +// ===== ЗАГРУЗКА ТЭГОВ ПРОЕКТА ===== +async function loadProjectTags(projectId, isOwner) { + const container = document.getElementById('tagsList'); + if (!container) return; + + try { + const data = await getProjectTags(projectId); + console.log('🏷️ Тэги проекта:', data); + + const tags = data.tag_list || []; + + if (tags.length === 0) { + container.innerHTML = ` +
+ + Нет тегов +
+ `; + return; + } + + container.innerHTML = tags.map(tag => ` +
+ ${tag.name} + ${isOwner ? ` + + ` : ''} +
+ `).join(''); + + } catch (error) { + console.error('❌ Ошибка загрузки тегов:', error); + container.innerHTML = ` +
+ + Ошибка загрузки +
+ `; + } +} + +// ===== УПРАВЛЕНИЕ ТЭГАМИ ===== +function showAddTagForm() { + const form = document.getElementById('addTagForm'); + const input = document.getElementById('newTagInput'); + if (form) { + form.style.display = 'block'; + if (input) { + input.focus(); + } + } +} + +function hideAddTagForm() { + const form = document.getElementById('addTagForm'); + if (form) { + form.style.display = 'none'; + } + const input = document.getElementById('newTagInput'); + if (input) { + input.value = ''; + } +} + +async function addTag(projectId) { + const input = document.getElementById('newTagInput'); + if (!input) return; + + const name = input.value.trim(); + if (!name) { + alert('Введите название тега'); + return; + } + + if (name.length > 30) { + alert('Название тега не должно превышать 30 символов'); + return; + } + + try { + await createTag(name, projectId); + hideAddTagForm(); + // Перезагружаем теги + const isOwner = true; // или передавать из контекста + loadProjectTags(projectId, true); + } catch (error) { + alert('Ошибка добавления тега: ' + error.message); + } +} + +// ===== УДАЛЕНИЕ ТЭГА (ОБРАБОТЧИК) ===== +async function deleteTagHandler(tagId, projectId) { + try { + await deleteTag(tagId); + const isOwner = true; + loadProjectTags(projectId, isOwner); + } catch (error) { + alert('Ошибка удаления тега: ' + error.message); + } +} // ===== МОИ ПРОЕКТЫ ===== @@ -394,8 +539,8 @@ function renderProjectCard(project) { // Статус проекта const statusMap = { 'pending': '⏳ В очереди', - 'processing': '🔄 Рендеринг...', - 'completed': '✅ Готов', + 'rendering': 'Рендеринг', + 'completed': 'Готов', 'failed': '❌ Ошибка' }; const statusText = statusMap[project.status] || project.status || 'Неизвестно'; @@ -761,9 +906,9 @@ async function loadProjectDetail(projectId) { // Статус const statusMap = { 'pending': '⏳ В очереди', - 'processing': '🔄 Рендеринг...', - 'rendering': '🔄 Рендеринг...', - 'completed': '✅ Готов', + 'processing': 'Рендеринг', + 'rendering': 'Рендеринг', + 'completed': 'Готов', 'failed': '❌ Ошибка' }; const statusText = statusMap[project.status] || project.status || 'Неизвестно'; @@ -845,14 +990,14 @@ async function loadProjectDetail(projectId) { ` : ''}
- +

📝 Описание

${project.description || 'Нет описания'}

- +

📁 Файлы

@@ -870,11 +1015,11 @@ async function loadProjectDetail(projectId) { ` Смотреть ` : - `${project.status === 'rendering' ? '⏳ Рендерится...' : 'Не готов'}` + `${project.status === 'rendering' ? '⏳ Рендерится' : 'Не готов'}` }
- + ${project.render ? `
@@ -882,27 +1027,11 @@ async function loadProjectDetail(projectId) {
` : ''} -
- - -
- -
-

🖼️ Результат

- ${isRendered && renderUrl ? - `Render` : - `
- -

${project.status === 'rendering' ? '⏳ Рендерится...' : 'Рендер не готов'}

- ${project.status === 'rendering' ? 'Обновите страницу через несколько минут' : ''} -
` - } -
@@ -916,10 +1045,60 @@ async function loadProjectDetail(projectId) {
+ + +
+
+
+

Тэги

+ ${isOwner ? ` + + ` : ''} +
+ + ${isOwner ? ` + + ` : ''} + +
+
+ Загрузка... +
+
+
+
+ + + +
+
+

Результат рендера

+ ${isRendered && renderUrl ? + `Render` : + `
+ +

${project.status === 'rendering' ? '⏳ Рендерится' : 'Рендер не готов'}

+ ${project.status === 'rendering' ? 'Обновите страницу через несколько минут' : ''} +
` + } +
+ `; - + loadProjectTags(projectId, isOwner); // ===== ОБРАБОТЧИК ОБНОВЛЕНИЯ ПРОЕКТА ===== const updateProjectForm = document.getElementById('updateProjectForm'); if (updateProjectForm) { @@ -946,7 +1125,7 @@ async function loadProjectDetail(projectId) { await updateProject(projectId, name, description, visibility); successDiv.style.display = 'block'; - successDiv.textContent = '✅ Проект успешно обновлен!'; + successDiv.textContent = 'Проект успешно обновлен!'; setTimeout(() => { loadProjectDetail(projectId); @@ -1250,7 +1429,7 @@ document.addEventListener('DOMContentLoaded', () => { // 5. Успех successDiv.style.display = 'block'; - successDiv.textContent = '✅ Проект успешно создан! Перенаправление...'; + successDiv.textContent = 'Проект успешно создан!'; // 6. Переход на страницу проекта setTimeout(() => { @@ -1496,7 +1675,7 @@ async function loadProfile(userId = null, page = 1) { await updateCurrentUserProfile(surname, name, username, email); successDiv.style.display = 'block'; - successDiv.textContent = '✅ Профиль успешно обновлен!'; + successDiv.textContent = 'Профиль успешно обновлен!'; setTimeout(() => { loadProfile(); diff --git a/frontend/style.css b/frontend/style.css index 97e887b..7b22f16 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -1446,4 +1446,526 @@ a { #sunSphereContainer:active { cursor: grabbing; +} + +/* ===== ТЭГИ ===== */ +.tags-section { + margin-top: 20px; + padding: 16px; + background: #f8f9fa; + border-radius: 12px; +} + +.tags-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.tags-header h3 { + margin: 0; + font-size: 15px; + font-weight: 600; + color: #333; + display: flex; + align-items: center; + gap: 8px; +} + +.tags-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + min-height: 36px; +} + +.tag-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + background: #4a6cf7; + color: white; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + transition: all 0.2s; +} + +.tag-chip .tag-name { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tag-chip .tag-delete { + background: none; + border: none; + color: rgba(255,255,255,0.7); + cursor: pointer; + padding: 0; + font-size: 12px; + display: flex; + align-items: center; + transition: color 0.2s; +} + +.tag-chip .tag-delete:hover { + color: #fff; +} + +.empty-tags { + display: flex; + align-items: center; + gap: 8px; + color: #999; + font-size: 14px; + padding: 4px 0; +} + +.empty-tags i { + font-size: 16px; +} + +.loading-tags { + color: #999; + font-size: 13px; + display: flex; + align-items: center; + gap: 8px; +} + +.error-tags { + color: #e74c3c; + font-size: 13px; + display: flex; + align-items: center; + gap: 8px; +} + +/* Форма добавления тега */ +.add-tag-form { + margin-bottom: 12px; +} + +.add-tag-input-group { + display: flex; + gap: 8px; + align-items: center; +} + +.tag-input { + flex: 1; + padding: 6px 12px; + border: 1px solid #ddd; + border-radius: 8px; + font-size: 14px; + outline: none; + transition: border-color 0.2s; +} + +.tag-input:focus { + border-color: #4a6cf7; +} + +.btn-sm { + padding: 6px 12px; + font-size: 13px; +} + +.btn-success { + background: #2ecc71; + color: white; +} + +.btn-success:hover { + background: #27ae60; +} + +/* Адаптив */ +@media (max-width: 600px) { + .tags-header { + flex-wrap: wrap; + gap: 8px; + } + + .add-tag-input-group { + flex-wrap: wrap; + } + + .tag-input { + min-width: 120px; + } +} + +/* ===== ТЭГИ (НОВАЯ КОЛОНКА) ===== */ + +/* Основная сетка — 3 колонки */ +.project-detail-body { + display: grid; + grid-template-columns: 1fr 1fr 280px; + gap: 32px; +} + +/* На больших экранах — 3 колонки */ +@media (min-width: 1201px) { + .project-detail-body { + grid-template-columns: 1fr 1fr 280px; + } + .project-detail-tags { + grid-column: 3; + } +} + +/* На средних экранах — 2 колонки, теги внизу */ +@media (max-width: 1200px) { + .project-detail-body { + grid-template-columns: 1fr 1fr; + } + .project-detail-tags { + grid-column: 1 / -1; + } +} + +/* На мобильных — 1 колонка */ +@media (max-width: 768px) { + .project-detail-body { + grid-template-columns: 1fr; + } + .project-detail-tags { + grid-column: 1; + } +} + +/* Блок тегов */ +.project-detail-tags { + min-width: 0; +} + +.tags-section { + padding: 16px; + background: #f8f9fa; + border-radius: 12px; + position: sticky; + top: 80px; +} + +.tags-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.tags-header h3 { + margin: 0; + font-size: 15px; + font-weight: 600; + color: #333; + display: flex; + align-items: center; + gap: 8px; +} + +.tags-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + min-height: 36px; +} + +.tag-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + background: #4a6cf7; + color: white; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + transition: all 0.2s; +} + +.tag-chip .tag-name { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tag-chip .tag-delete { + background: none; + border: none; + color: rgba(255,255,255,0.7); + cursor: pointer; + padding: 0; + font-size: 12px; + display: flex; + align-items: center; + transition: color 0.2s; +} + +.tag-chip .tag-delete:hover { + color: #fff; +} + +.empty-tags { + display: flex; + align-items: center; + gap: 8px; + color: #999; + font-size: 14px; + padding: 4px 0; +} + +.loading-tags { + color: #999; + font-size: 13px; + display: flex; + align-items: center; + gap: 8px; +} + +.error-tags { + color: #e74c3c; + font-size: 13px; + display: flex; + align-items: center; + gap: 8px; +} + +.add-tag-form { + margin-bottom: 12px; +} + +.add-tag-input-group { + display: flex; + gap: 6px; + align-items: center; +} + +.tag-input { + flex: 1; + padding: 6px 10px; + border: 1px solid #ddd; + border-radius: 8px; + font-size: 13px; + outline: none; + transition: border-color 0.2s; + min-width: 80px; +} + +.tag-input:focus { + border-color: #4a6cf7; +} + +.btn-sm { + padding: 6px 12px; + font-size: 13px; +} + +.btn-success { + background: #2ecc71; + color: white; +} + +.btn-success:hover { + background: #27ae60; +} + +/* ===== ТЭГИ ===== */ +.tags-section { + padding: 16px; + background: #f8f9fa; + border-radius: 12px; + position: sticky; + top: 80px; +} + +.tags-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.tags-header h3 { + margin: 0; + font-size: 15px; + font-weight: 600; + color: #333; + display: flex; + align-items: center; + gap: 8px; +} + +.tags-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + min-height: 36px; +} + +.tag-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + background: #4a6cf7; + color: white; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + transition: all 0.2s; +} + +.tag-chip .tag-name { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tag-chip .tag-delete { + background: none; + border: none; + color: rgba(255,255,255,0.7); + cursor: pointer; + padding: 0; + font-size: 12px; + display: flex; + align-items: center; + transition: color 0.2s; +} + +.tag-chip .tag-delete:hover { + color: #fff; +} + +.empty-tags { + display: flex; + align-items: center; + gap: 8px; + color: #999; + font-size: 14px; + padding: 4px 0; +} + +.loading-tags { + color: #999; + font-size: 13px; + display: flex; + align-items: center; + gap: 8px; +} + +.error-tags { + color: #e74c3c; + font-size: 13px; + display: flex; + align-items: center; + gap: 8px; +} + +.add-tag-form { + margin-bottom: 12px; +} + +.add-tag-input-group { + display: flex; + gap: 6px; + align-items: center; +} + +.tag-input { + flex: 1; + padding: 6px 10px; + border: 1px solid #ddd; + border-radius: 8px; + font-size: 13px; + outline: none; + transition: border-color 0.2s; + min-width: 80px; +} + +.tag-input:focus { + border-color: #4a6cf7; +} + +.btn-sm { + padding: 6px 12px; + font-size: 13px; +} + +.btn-success { + background: #2ecc71; + color: white; +} + +.btn-success:hover { + background: #27ae60; +} + +/* ===== ДЕТАЛЬНАЯ СТРАНИЦА ПРОЕКТА ===== */ + +/* Основная сетка — 2 колонки: левая (описание) + правая (теги) */ +.project-detail-body { + display: grid; + grid-template-columns: 1fr 280px; + gap: 32px; +} + +/* Результат рендера внизу на всю ширину */ +.project-detail-render { + margin-top: 32px; +} + +@media (max-width: 900px) { + .project-detail-body { + grid-template-columns: 1fr; + } +} + +/* ===== КРУПНЫЙ РЕНДЕР ===== */ +.render-preview-large { + background: #f8f9fa; + border-radius: 12px; + padding: 20px; +} + +.render-preview-large h3 { + font-size: 16px; + font-weight: 600; + color: #333; + margin: 0 0 12px 0; +} + +.render-image-large { + width: 100%; + max-height: 600px; + object-fit: contain; + border-radius: 8px; + background: #f0f2f5; +} + +.no-render-large { + text-align: center; + padding: 80px 20px; + color: #999; +} + +.no-render-large i { + font-size: 48px; + display: block; + margin-bottom: 12px; +} + +.no-render-large small { + display: block; + margin-top: 8px; + color: #bbb; + font-size: 13px; } \ No newline at end of file