This commit is contained in:
3DThing 2025-06-10 16:38:27 +03:00
parent fb5733d5e7
commit e03bd2374c
57 changed files with 15525 additions and 0 deletions

7
COMIT.bat Normal file
View File

@ -0,0 +1,7 @@
@echo off
set /p msg=Commit Message:
git add .
git commit -m "%msg%"
git pull --rebase
git push
pause

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

107
backend/api/v0/feedback.py Normal file
View File

@ -0,0 +1,107 @@
import json
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from core.db import get_db, Poster, FeedBack
from core.crypt import decode_jwt, is_admin
from core.file import get_upload_dir, save_file, get_poster_dir, generate_file_url
from pydantic import BaseModel, EmailStr
from datetime import datetime
router = APIRouter()
security = HTTPBearer()
class Comment(BaseModel):
user_id: int
text: str
rating: int
@router.post("/comment")
async def create_comment(
comment: Comment,
db: Session = Depends(get_db),
credentials: HTTPAuthorizationCredentials = Depends(security),
request: Request = None,
):
token = credentials.credentials
decoded_data = decode_jwt(token)
user_id = decoded_data.get("user_id")
if not user_id:
raise HTTPException(status_code=401, detail="Пользователь не авторизован.")
if comment.rating < 1 or comment.rating > 5:
raise HTTPException(status_code=400, detail="Рейтинг должен быть от 1 до 5.")
new_comment = FeedBack(
userid=user_id,
text=comment.text,
rating=comment.rating,
date=datetime.utcnow()
)
db.add(new_comment)
db.commit()
db.refresh(new_comment)
return {"message": "Комментарий успешно добавлен.", "comment": new_comment}
@router.delete("/comment/{comment_id}")
async def delete_comment(
comment_id: int,
db: Session = Depends(get_db),
credentials: HTTPAuthorizationCredentials = Depends(security),
):
token = credentials.credentials
decoded_data = decode_jwt(token)
user_id = decoded_data.get("user_id")
if not user_id:
raise HTTPException(status_code=401, detail="Пользователь не авторизован.")
comment = db.query(FeedBack).filter(FeedBack.id == comment_id, FeedBack.userid == user_id).first()
if not comment:
raise HTTPException(status_code=404, detail="Комментарий не найден.")
if is_admin(user_id, db):
raise HTTPException(status_code=403, detail="Недостаточно прав для выполнения этого действия.")
db.delete(comment)
db.commit()
return {"message": "Комментарий успешно удален."}
@router.get("/comments")
async def get_comments(
db: Session = Depends(get_db),
request: Request = None,
):
comments = db.query(FeedBack).order_by(FeedBack.date.desc()).all()
comments_list = []
for comment in comments:
comments_list.append({
"id": comment.id,
"userid": comment.userid,
"text": comment.text,
"date": comment.date.isoformat(),
"rating": comment.rating
})
return comments_list
@router.get("/average-rating")
async def get_average_rating(
db: Session = Depends(get_db),
):
average_rating = db.query(func.avg(FeedBack.rating)).scalar()
if average_rating is None:
return {"Нет рэйтинга.": "Рейтинг", "Рейтинг": 0}
return {"Рэйтинг": round(average_rating, 2)}

257
backend/api/v0/poster.py Normal file
View File

@ -0,0 +1,257 @@
import json
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from core.db import get_db, Poster, PosterLike
from core.crypt import decode_jwt, is_admin
from core.file import get_upload_dir, save_file, get_poster_dir, generate_file_url
from pydantic import BaseModel, EmailStr
from datetime import datetime
router = APIRouter()
security = HTTPBearer()
@router.get("/getallposter")
async def get_all_poster(
db: Session = Depends(get_db),
):
posters = db.query(Poster).all()
if not posters:
raise HTTPException(status_code=404, detail="Постеры не найдены.")
return {
"posters": [
{
"id": poster.id,
"title": poster.title,
"description": (poster.description[:250] + "...") if poster.description and len(poster.description) > 250 else poster.description,
"image": poster.image,
"date": poster.date.isoformat() if poster.date else None,
"price": poster.price,
"like": poster.like,
}
for poster in posters
]
}
@router.post("/postercreate")
def create_poster(
title: str = Form(...),
description: str = Form(...),
price: int = Form(...),
date: datetime = Form(...),
file: UploadFile = File(...),
db: Session = Depends(get_db),
credentials: HTTPAuthorizationCredentials = Depends(security),
request: Request = None,
):
token = credentials.credentials
decoded_data = decode_jwt(token)
user_id = decoded_data.get("user_id")
if not is_admin(user_id, db):
raise HTTPException(status_code=403, detail="Недостаточно прав для выполнения этого действия.")
if not file:
raise HTTPException(status_code=400, detail="Файл не был загружен.")
if not file.filename.endswith(('.jpg', '.jpeg', '.png')):
raise HTTPException(status_code=400, detail="Недопустимый формат файла. Допустимые форматы: .jpg, .jpeg, .png.")
try:
poster_id = db.query(Poster).count() + 1
upload_dir = get_poster_dir(poster_id)
file_path = save_file(file, upload_dir)
file_url = generate_file_url(request, file_path)
new_poster = Poster(
title=title,
description=description,
date=date,
price=price,
like=0,
datecreation=datetime.utcnow(),
image=str(file_url),
)
db.add(new_poster)
db.commit()
db.refresh(new_poster)
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Ошибка при загрузке файла: {str(e)}")
return {"message": "Постер успешно создан.", "poster": new_poster}
@router.post("/posterupdate")
def update_poster(
poster_id: int = Form(...),
title: str = Form(...),
description: str = Form(...),
price: int = Form(...),
date: datetime = Form(...),
file: UploadFile = File(None),
db: Session = Depends(get_db),
credentials: HTTPAuthorizationCredentials = Depends(security),
request: Request = None,
):
token = credentials.credentials
decoded_data = decode_jwt(token)
user_id = decoded_data.get("user_id")
if not is_admin(user_id, db):
raise HTTPException(status_code=403, detail="Недостаточно прав для выполнения этого действия.")
poster = db.query(Poster).filter(Poster.id == poster_id).first()
if not poster:
raise HTTPException(status_code=404, detail="Постер не найден.")
if file:
if not file.filename.endswith(('.jpg', '.jpeg', '.png')):
raise HTTPException(status_code=400, detail="Недопустимый формат файла. Допустимые форматы: .jpg, .jpeg, .png.")
try:
upload_dir = get_poster_dir(poster_id)
file_path = save_file(file, upload_dir)
file_url = generate_file_url(request, file_path)
poster.image = str(file_url)
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Ошибка при загрузке файла: {str(e)}")
poster.title = title
poster.description = description
poster.date = date
poster.price = price
try:
db.commit()
db.refresh(poster)
except IntegrityError:
db.rollback()
raise HTTPException(status_code=400, detail="Ошибка при обновлении постера.")
return {"message": "Постер успешно обновлен.", "poster": poster}
@router.post("/setlike")
def set_like(
poster_id: int = Form(...),
like: int = Form(...),
db: Session = Depends(get_db),
credentials: HTTPAuthorizationCredentials = Depends(security),
):
token = credentials.credentials
decoded_data = decode_jwt(token)
user_id = decoded_data.get("user_id")
if not user_id:
raise HTTPException(status_code=401, detail="Пользователь не авторизован.")
# Проверка существования постера
poster = db.query(Poster).filter(Poster.id == poster_id).first()
if not poster:
raise HTTPException(status_code=404, detail="Постер не найден.")
# Валидация значения like
if like not in [1, -1]:
raise HTTPException(status_code=400, detail="Значение like должно быть 1 или -1.")
try:
# Проверка на существующий лайк
existing_like = db.query(PosterLike).filter(
PosterLike.poster_id == poster_id,
PosterLike.user_id == user_id
).first()
if like == 1:
# Пользователь хочет поставить лайк
if existing_like:
# Лайк уже есть - ничего не делаем
return {
"message": "Вы уже оценили этот постер.",
"poster": {
"id": poster.id,
"title": poster.title,
"like": poster.like,
"image": poster.image,
"price": poster.price,
"date": poster.date.isoformat(),
"description": poster.description
}
}
else:
# Добавляем лайк
new_like = PosterLike(poster_id=poster_id, user_id=user_id)
db.add(new_like)
poster.like += 1
else:
# Пользователь хочет убрать лайк
if existing_like:
# Удаляем запись о лайке
db.delete(existing_like)
poster.like -= 1
else:
# Лайка не было - ничего не делаем
return {
"message": "Вы еще не ставили лайк этому постеру.",
"poster": {
"id": poster.id,
"title": poster.title,
"like": poster.like,
"image": poster.image,
"price": poster.price,
"date": poster.date.isoformat(),
"description": poster.description
}
}
db.commit()
db.refresh(poster)
return {
"message": "Лайк успешно обновлен.",
"poster": {
"id": poster.id,
"title": poster.title,
"like": poster.like,
"image": poster.image,
"price": poster.price,
"date": poster.date.isoformat(),
"description": poster.description
}
}
except IntegrityError as e:
db.rollback()
print(f"Ошибка IntegrityError: {str(e)}")
raise HTTPException(status_code=400, detail="Ошибка при обновлении лайка постера.")
except Exception as e:
db.rollback()
print(f"Неожиданная ошибка: {str(e)}")
raise HTTPException(status_code=500, detail=f"Произошла ошибка: {str(e)}")
@router.get("/getposter/{poster_id}")
async def get_poster(
poster_id: int,
db: Session = Depends(get_db),
):
poster = db.query(Poster).filter(Poster.id == poster_id).first()
if not poster:
raise HTTPException(status_code=404, detail="Постер не найден.")
return {
"id": poster.id,
"title": poster.title,
"description": poster.description,
"image": poster.image,
"date": poster.date.isoformat() if poster.date else None,
"price": poster.price,
"like": poster.like,
}

464
backend/api/v0/user.py Normal file
View File

@ -0,0 +1,464 @@
from fastapi import FastAPI, HTTPException, Depends, APIRouter, Response, Header, File, UploadFile, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from urllib.parse import quote, unquote
from core.db import get_db, User
from core.crypt import hash_password, verify_password, gen_jwt, decode_jwt
from core.file import get_upload_dir, save_file, generate_file_url
import os
from pathlib import Path
from datetime import datetime
router = APIRouter()
security = HTTPBearer()
class RegisterRequest(BaseModel):
login: str
password: str
email: EmailStr
last_name: str
first_name: str
middle_name: str | None = None
@router.post("/register")
def register_user(user_data: RegisterRequest, db: Session = Depends(get_db)):
existing_user = db.query(User).filter(User.login == user_data.login).first()
if existing_user:
raise HTTPException(status_code=400, detail="Пользователь с таким логином уже существует.")
existing_email = db.query(User).filter(User.email == user_data.email).first()
if existing_email:
raise HTTPException(status_code=400, detail="Пользователь с такой почтой уже существует.")
hashed_password = hash_password(user_data.password)
new_user = User(
login=user_data.login,
password=hashed_password,
email=user_data.email,
last_name=user_data.last_name,
first_name=user_data.first_name,
middle_name=user_data.middle_name,
access_level=1,
)
try:
db.add(new_user)
db.commit()
db.refresh(new_user)
except IntegrityError:
db.rollback()
raise HTTPException(status_code=500, detail="Ошибка при добавлении пользователя в базу данных.")
return {"message": "Пользователь успешно зарегистрирован."}
class LoginRequest(BaseModel):
login: str
password: str
@router.post("/login")
def login_user(user_data: LoginRequest, db: Session = Depends(get_db), response: Response = None):
user = db.query(User).filter(User.login == user_data.login).first()
if not user:
raise HTTPException(status_code=404, detail="Пользователя с таким логином не существует.")
if not verify_password(user.password, user_data.password):
raise HTTPException(status_code=401, detail="Неверный пароль.")
JWT_token = gen_jwt(user.id, user.access_level)
try:
if response:
cookie_params = {
"httponly": False,
"samesite": "lax",
"max_age": 3600*24*7,
"path": "/",
}
response.set_cookie(key="id", value=str(user.id), **cookie_params)
response.set_cookie(key="login", value=quote(user.login), **cookie_params)
response.set_cookie(key="email", value=quote(user.email), **cookie_params)
full_name = f"{user.last_name} {user.first_name} {user.middle_name or ''}".strip()
response.set_cookie(key="full_name", value=quote(full_name), **cookie_params)
avatar_value = quote(user.avatar) if user.avatar else quote("/default-avatar.png")
response.set_cookie(key="avatar", value=avatar_value, **cookie_params)
response.set_cookie(key="JWT_token", value=JWT_token, **cookie_params)
print(f"JWT токен установлен для пользователя {user.login}: {JWT_token[:10]}...")
except Exception as e:
print(f"Ошибка при установке cookie: {str(e)}")
return {
"message": "Успешный вход.",
"token": JWT_token,
"user_id": user.id,
"login": user.login
}
@router.post("/logout")
def logout_user(response: Response):
response.delete_cookie(key="login")
response.delete_cookie(key="email")
response.delete_cookie(key="full_name")
response.delete_cookie(key="id")
response.delete_cookie(key="avatar")
response.delete_cookie(key="JWT_token")
return {"message": "Вы успешно вышли из системы."}
@router.get("/user/{user_id}")
def get_user_data(user_id: int, db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="Пользователь с таким ID не найден.")
return {
"login": user.login,
"avatar": user.avatar,
"first_name": user.first_name,
"last_name": user.last_name,
"middle_name": user.middle_name,
"email": user.email
}
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
@router.post("/change_password")
def change_password(
password_data: ChangePasswordRequest,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
):
token = credentials.credentials
decoded_data = decode_jwt(token)
user_id = decoded_data.get("user_id")
if not user_id:
raise HTTPException(status_code=401, detail="Недействительный токен.")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="Пользователь не найден.")
if not verify_password(user.password, password_data.old_password):
raise HTTPException(status_code=401, detail="Неверный старый пароль.")
user.password = hash_password(password_data.new_password)
try:
db.commit()
except:
db.rollback()
raise HTTPException(status_code=500, detail="Ошибка при обновлении пароля.")
return {"message": "Пароль успешно изменён."}
class ChangeUsernameRequest(BaseModel):
new_login: str
@router.post("/change_username")
def change_username(
login_data: ChangeUsernameRequest,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
response: Response = None
):
token = credentials.credentials
decoded_data = decode_jwt(token)
user_id = decoded_data.get("user_id")
if not user_id:
raise HTTPException(status_code=401, detail="Недействительный токен.")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="Пользователь не найден.")
existing_user = db.query(User).filter(User.login == login_data.new_login).first()
if existing_user:
raise HTTPException(status_code=400, detail="Логин уже используется другим пользователем.")
try:
user.login = login_data.new_login
db.commit()
db.refresh(user)
# Безопасно устанавливаем cookie
try:
if response:
response.set_cookie(
key="login",
value=user.login,
httponly=False
)
except:
# Игнорируем ошибку установки cookie
pass
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail="Ошибка при обновлении логина.")
return {"message": "Логин успешно изменен."}
class ChangeFirstNameRequest(BaseModel):
new_first_name: str
@router.post("/change_name")
def change_first_name(
name_data: ChangeFirstNameRequest,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
response: Response = None
):
token = credentials.credentials
decoded_data = decode_jwt(token)
user_id = decoded_data.get("user_id")
if not user_id:
raise HTTPException(status_code=401, detail="Недействительный токен.")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="Пользователь с таким ID не найден.")
try:
user.first_name = name_data.new_first_name
db.commit()
db.refresh(user)
# Безопасная установка cookie с проверкой response и URL-кодированием
try:
if response:
full_name = f"{user.last_name} {user.first_name} {user.middle_name or ''}".strip()
response.set_cookie(
key="full_name",
value=quote(full_name), # URL-кодирование для поддержки кириллицы
httponly=False,
samesite="lax",
max_age=3600*24*30, # 30 дней
path="/"
)
except Exception as e:
print(f"Ошибка при установке cookie: {str(e)}")
# Продолжаем выполнение даже при ошибке cookie
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Ошибка при обновлении имени: {str(e)}")
# Возвращаем обновленные данные для использования на клиенте
return {
"message": "Имя успешно изменено.",
"full_name": f"{user.last_name} {user.first_name} {user.middle_name or ''}".strip(),
"updated_at": str(datetime.now())
}
class ChangeLastNameRequest(BaseModel):
new_last_name: str
@router.post("/change_last_name")
def change_last_name(
last_name_data: ChangeLastNameRequest,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
response: Response = None
):
token = credentials.credentials
decoded_data = decode_jwt(token)
user_id = decoded_data.get("user_id")
if not user_id:
raise HTTPException(status_code=401, detail="Недействительный токен.")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="Пользователь с таким ID не найден.")
try:
user.last_name = last_name_data.new_last_name
db.commit()
db.refresh(user)
# Безопасно устанавливаем cookie
try:
if response:
response.set_cookie(
key="full_name",
value=f"{user.last_name} {user.first_name} {user.middle_name or ''}",
httponly=False
)
except:
# Игнорируем ошибку установки cookie
pass
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail="Ошибка при обновлении фамилии.")
return {"message": "Фамилия успешно изменена."}
class ChangeMiddleNameRequest(BaseModel):
new_middle_name: str | None = None
@router.post("/change_middle_name")
def change_middle_name(
middle_name_data: ChangeMiddleNameRequest,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
response: Response = None
):
token = credentials.credentials
decoded_data = decode_jwt(token)
user_id = decoded_data.get("user_id")
if not user_id:
raise HTTPException(status_code=401, detail="Недействительный токен.")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="Пользователь с таким ID не найден.")
try:
user.middle_name = middle_name_data.new_middle_name
db.commit()
db.refresh(user)
# Безопасно устанавливаем cookie
try:
if response:
response.set_cookie(
key="full_name",
value=f"{user.last_name} {user.first_name} {user.middle_name or ''}",
httponly=False
)
except:
# Игнорируем ошибку установки cookie
pass
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail="Ошибка при обновлении отчества.")
return {"message": "Отчество успешно изменено."}
class ChangeEmailRequest(BaseModel):
new_email: EmailStr
@router.post("/change_email")
def change_email(
email_data: ChangeEmailRequest,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
response: Response = None
):
token = credentials.credentials
decoded_data = decode_jwt(token)
user_id = decoded_data.get("user_id")
if not user_id:
raise HTTPException(status_code=401, detail="Недействительный токен.")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="Пользователь с таким ID не найден.")
existing_user = db.query(User).filter(User.email == email_data.new_email).first()
if existing_user:
raise HTTPException(status_code=400, detail="Почта уже используется другим пользователем.")
try:
user.email = email_data.new_email
db.commit()
db.refresh(user)
# Безопасно устанавливаем cookie
try:
if response:
response.set_cookie(
key="email",
value=user.email,
httponly=False
)
except:
# Игнорируем ошибку установки cookie
pass
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail="Ошибка при обновлении почты.")
return {"message": "Почта успешно изменена."}
@router.post("/upload_avatar")
def upload_avatar(
file: UploadFile = File(...),
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
request: Request = None,
response: Response = None
):
token = credentials.credentials
decoded_data = decode_jwt(token)
user_id = decoded_data.get("user_id")
if not user_id:
raise HTTPException(status_code=401, detail="Недействительный токен.")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="Пользователь с таким ID не найден.")
if not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="Файл должен быть изображением.")
try:
upload_dir = get_upload_dir(user_id)
file_path = save_file(file, upload_dir)
file_url = generate_file_url(request, file_path)
user.avatar = str(file_url)
db.commit()
db.refresh(user)
# Безопасно устанавливаем cookie
try:
if response:
response.set_cookie(
key="avatar",
value=file_url,
httponly=False
)
except:
# Игнорируем ошибку установки cookie
pass
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Ошибка при загрузке файла: {str(e)}")
return {"message": "Аватар успешно загружен.", "file_url": file_url}

Binary file not shown.

Binary file not shown.

Binary file not shown.

72
backend/core/crypt.py Normal file
View File

@ -0,0 +1,72 @@
import hashlib
from core.db import User, SessionLocal
from sqlalchemy.orm import Session
from jwt import encode, decode, ExpiredSignatureError, InvalidTokenError
from fastapi import HTTPException
from datetime import datetime, timedelta
SECRET_KEY = "SosttetHuhhhy"
ALGORITM = "HS256"
"""
Функция для генерации JWT токена.
При вводе аргументов user_id и access_level, функция возвращает JWT токен.
"""
def gen_jwt(user_id: int, access_level: int) -> str:
payload = {
"user_id": user_id,
"access_level": access_level,
"exp": datetime.utcnow() + timedelta(days=1)
}
token = encode(payload, SECRET_KEY, algorithm=ALGORITM)
return token
"""
Функция для расшифровки JWT токена.
Принимает токен и возвращает словарь с user_id и access_level.
"""
def decode_jwt(token: str) -> dict:
try:
payload = decode(token, SECRET_KEY, algorithms=[ALGORITM])
return {
"user_id": payload.get("user_id"),
"access_level": payload.get("access_level")
}
except ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Срок действия токена истёк.")
except InvalidTokenError:
raise HTTPException(status_code=401, detail="Неверный токен.")
"""
Функция для хеширования пароля с использованием SHA-256.
При вводе аргумента password, функция возвращает его хеш.
"""
def hash_password(password: str) -> str:
sha256_hash = hashlib.sha256()
sha256_hash.update(password.encode('utf-8'))
return sha256_hash.hexdigest()
"""
Функция для проверки пароля.
При вводе аргументов stored_password и provided_password, функция
возвращает True, если хеши совпадают, и False в противном случае.
"""
def verify_password(stored_password: str, provided_password: str) -> bool:
return stored_password == hash_password(provided_password)
"""
Функция для определения уровня доступа пользователя.
"""
def is_admin(user_id: int, db: Session) -> bool:
user = db.query(User).filter(User.id == user_id).first()
if user.access_level ==1:
return False
elif user.access_level == 2:
return True

83
backend/core/db.py Normal file
View File

@ -0,0 +1,83 @@
from sqlalchemy import create_engine, Column, String, Integer, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from datetime import datetime
DATABASE_URL = "sqlite:///c:/Users/rbiter/Desktop/AboutMe/backend/database.db"
engine = create_engine(DATABASE_URL)
Base = declarative_base()
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True, autoincrement=True)
login = Column(String, nullable=False, unique=True)
password = Column(String, nullable=False)
email = Column(String, nullable=False, unique=True)
last_name = Column(String, nullable=False)
first_name = Column(String, nullable=False)
middle_name = Column(String, nullable=True)
access_level = Column(Integer, nullable=False, default=1)
avatar = Column(String, nullable=True)
dateregistration = Column(DateTime, default=datetime.utcnow)
class Poster(Base):
__tablename__ = 'posters'
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String, nullable=False)
description = Column(String, nullable=False)
image = Column(String, nullable=False)
date = Column(DateTime, nullable=False)
price = Column(Integer, nullable=False, default=0)
like = Column(Integer, nullable=False, default=0)
datecreation = Column(DateTime, default=datetime.utcnow)
class Sale(Base):
__tablename__ = "sales"
id = Column(Integer, primary_key=True, autoincrement=True)
amount = Column(Integer, nullable=False)
status = Column(String(50), nullable=False, default="pending") # pending, paid, failed, canceled
created_at = Column(DateTime, default=datetime.utcnow)
description = Column(String(255), nullable=False)
poster_id = Column(Integer, ForeignKey("posters.id"), nullable=True)
user_id = Column(Integer, nullable=False)
user_email = Column(String, ForeignKey("users.email"), nullable=False)
payment_id = Column(String, nullable=False, unique=True)
confirmation_url = Column(String, nullable=True)
class PosterLike(Base):
__tablename__ = 'poster_likes'
id = Column(Integer, primary_key=True, autoincrement=True)
poster_id = Column(Integer, ForeignKey('posters.id'), nullable=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
date_created = Column(DateTime, default=datetime.utcnow)
__table_args__ = (UniqueConstraint('poster_id', 'user_id', name='uix_poster_user'),)
class FeedBack(Base):
__tablename__ = "feedback"
id = Column(Integer, primary_key=True, autoincrement=True)
userid = Column(Integer, ForeignKey("users.id"), nullable=False)
text = Column(String, nullable=False)
date = Column(DateTime, default=datetime.utcnow)
rating = Column(Integer, nullable=False)
def initialize_database():
Base.metadata.create_all(engine)
with engine.connect() as connection:
print("База данных успешно создана или подключена!")
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

32
backend/core/file.py Normal file
View File

@ -0,0 +1,32 @@
from fastapi import File, UploadFile, HTTPException, Depends
from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from pathlib import Path
from urllib.parse import urljoin
from fastapi import Request
BASE_URL = "http://127.0.0.1:8000"
def get_upload_dir(user_id: int) -> Path:
"""Возвращает путь для сохранения файлов пользователя."""
upload_dir = Path(f"res/user/{user_id}")
upload_dir.mkdir(parents=True, exist_ok=True)
return upload_dir
def get_poster_dir(poster_id: int) -> Path:
"""Возвращает путь для сохранения обложек постеров."""
poster_dir = Path(f"res/poster/{poster_id}")
poster_dir.mkdir(parents=True, exist_ok=True)
return poster_dir
def save_file(file: UploadFile, target_dir: Path) -> Path:
"""Сохраняет файл в указанной директории и возвращает путь к файлу."""
file_path = target_dir / file.filename
with open(file_path, "wb") as f:
f.write(file.file.read())
return file_path
def generate_file_url(request: Request, file_path: Path) -> str:
"""Генерирует URL для доступа к файлу."""
relative_path = file_path.relative_to(Path("res"))
return urljoin(str(request.base_url), f"res/{relative_path}")

BIN
backend/database.db Normal file

Binary file not shown.

30
backend/main.py Normal file
View File

@ -0,0 +1,30 @@
import core.db as db
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from api.v0.user import router as user_router
from fastapi.staticfiles import StaticFiles
from api.v0.poster import router as poster_router
from api.v0.feedback import router as feedback_router
db.initialize_database()
app = FastAPI()
app.include_router(user_router)
app.include_router(poster_router)
app.include_router(feedback_router)
app.mount("/res", StaticFiles(directory="res"), name="res")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True, workers=1)

39
backend/pay.py Normal file
View File

@ -0,0 +1,39 @@
import uuid
import requests
import base64
shop_id = '1017909'
api_key = 'test_udCBy7nXqGavVoj3RrzIRXN9UL02_UnF0FBBykxWH60'
auth_token = base64.b64encode(f'{shop_id}:{api_key}'.encode()).decode()
idempotence_key = str(uuid.uuid4())
headers = {
'Content-Type': 'application/json',
'Authorization': f'Basic {auth_token}',
'Idempotence-Key': idempotence_key
}
data = {
"amount": {
"value": "1500.00",
"currency": "RUB"
},
"confirmation": {
"type": "redirect",
"return_url": "https://example.com/return"
},
"capture": True,
"description": "Зеленый слоник 2",
"metadata": {
"order_id": str(uuid.uuid4())
}
}
response = requests.post("https://api.yookassa.ru/v3/payments", json=data, headers=headers)
if response.status_code in [200, 201]:
print(response.json()["confirmation"]["confirmation_url"])
else:
print(f"Ошибка {response.status_code}: {response.text}")

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1023 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1023 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

41
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

36
frontend/README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

7
frontend/next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

6812
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
frontend/package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@types/leaflet": "^1.9.17",
"date-fns": "^4.1.0",
"frontend": "file:",
"leaflet": "^1.9.4",
"next": "15.3.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/date-fns": "^2.6.3",
"@types/google.maps": "^3.58.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.3.2",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View File

@ -0,0 +1 @@
<?xml version="1.0" ?><svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:#101820;}</style></defs><title/><g data-name="Layer 54" id="Layer_54"><path class="cls-1" d="M16,28.72a3,3,0,0,1-2.13-.88L3.57,17.54a8.72,8.72,0,0,1-2.52-6.25,8.06,8.06,0,0,1,8.14-8A8.06,8.06,0,0,1,15,5.68l1,1,.82-.82h0a8.39,8.39,0,0,1,11-.89,8.25,8.25,0,0,1,.81,12.36L18.13,27.84A3,3,0,0,1,16,28.72ZM9.15,5.28A6.12,6.12,0,0,0,4.89,7a6,6,0,0,0-1.84,4.33A6.72,6.72,0,0,0,5,16.13l10.3,10.3a1,1,0,0,0,1.42,0L27.23,15.91A6.25,6.25,0,0,0,29,11.11a6.18,6.18,0,0,0-2.43-4.55,6.37,6.37,0,0,0-8.37.71L16.71,8.8a1,1,0,0,1-1.42,0l-1.7-1.7a6.28,6.28,0,0,0-4.4-1.82Z"/></g></svg>

After

Width:  |  Height:  |  Size: 673 B

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_1" data-name="Слой_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 256 256">
<!-- Generator: Adobe Illustrator 29.5.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 137) -->
<defs>
<style>
.st0 {
fill:rgb(0, 0, 0);
}
</style>
</defs>
<path class="st0" d="M170.69,92.48c.66-4,5.92-6.06,9.18-3.67,19.69,18.69,39.05,37.8,58.5,56.76,3.14,3.57,3.1,7.5.16,11.18l-57.47,57.47c-2.38,2.19-6.19,3.02-8.71.61-.43-.41-1.65-2.43-1.65-2.88v-21.21h-41.44v14.08c0,5.78-7.55,11.61-13.12,11.49l-87.75.02c-7.43-.56-12.41-5.36-13.11-12.79.7-15.43-.9-31.62.01-46.96,1.51-25.36,24.52-41.1,48.71-40.33l106.7.02v-23.8ZM163.68,172.08c10.44-9.9,20.46-20.23,30.59-30.44,2.72-2.13-3.67-8.19-5.26-8.87-1.22-.53-2.86-.54-3.91.35l-25.25,25.26c-2.16,2.09-4.86,2.15-7.27.46l-13.92-13.59c-1.04-.28-2.08-.28-3.05.22-.44.23-5.25,5.01-5.52,5.49-.74,1.3-.67,2.7.28,3.87l23.37,22.93c.46.26,1.43.56,1.95.58,2.58.07,6.15-4.5,8-6.25Z"/>
<path class="st0" d="M76.08,40.29c21.34-1.5,39.9,17.52,37.26,38.88-3.38,27.41-36.14,41.02-57.42,22.53-23.13-20.09-10.76-59.23,20.16-61.41Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_1" data-name="Слой_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 256 256">
<!-- Generator: Adobe Illustrator 29.5.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 137) -->
<defs>
<style>
.st0 {
fill: #f18a32;
}
</style>
</defs>
<path class="st0" d="M233.46,95.57c4.79,4.96,5.37,11.18,1.53,16.91-32.46,39.31-63.28,80.09-96.19,119.02-5.65,6.69-10.56,13.42-20.46,8.27l-26.35-21.27c-.36-.34-.36-.67-.31-1.13.16-1.53,2.14-4.76,2.6-6.8,3.82-17-15.37-32.8-31.29-25.18-1.95.94-5.27,4.07-7.16,3.39l-33.39-26.02c-4.36-4.19-5.4-10.59-2.51-15.96L122.14,20.79c9.49-9.68,16.39-3.46,24.59,3.05,7.46,5.92,14.6,12.29,21.97,18.33,1.19,1.8-1.35,4.22-2.12,5.97-8.09,18.57,12.28,36.25,30.28,27.84,1.87-.87,5.84-4.43,7.46-3.98l29.14,23.57ZM118.76,54.43c-1.64.38-7.19,7.17-7.52,8.75-.89,4.34,3.62,7.79,7.24,5.21,1.3-.92,6.08-6.41,6.41-7.83.85-3.71-2.42-6.99-6.13-6.13ZM101.67,75.17c-1.63.38-8.39,8.28-8.74,9.97-1.03,5.02,4.74,7.77,8.42,4.76.77-.63,4.85-5.37,5.5-6.3,1.28-1.86,1.8-3.49.72-5.55-1.17-2.23-3.38-3.46-5.89-2.88ZM82,98.82c-1.59,1.37-6.13,6.78-7.45,8.62-1.75,2.42-3.08,4.28-1,7.08,1.9,2.56,4.68,2.55,7.1.65,1.31-1.03,4.48-5.01,5.68-6.53,2.27-2.87,5.55-5.67,2.46-9.35-1.91-2.27-4.55-2.39-6.78-.47ZM161.46,110.2c-2.13.03-3.41,1.53-4.76,2.91-9.56,9.75-18.22,21.05-27.85,30.73-3.84,3.86-5.24,4.45-9.76.8-4.21-3.4-8.12-8.63-12.2-12.21-2.76-2.42-5.38-4.3-8.58-1.22-.78.75-4.03,4.59-4.34,5.42-.84,2.19.03,3.28,1.32,4.96,6.82,8.92,17.68,17.45,25.01,26.25,5.2,3.75,7.9.31,11.4-3.25,9.96-10.15,20.04-21.23,29.7-31.73,1.83-1.99,9.44-9.8,10.05-11.51.35-.96.49-1.91.3-2.92-.27-1.45-5.72-6.21-7.17-7.08-.79-.47-2.21-1.17-3.12-1.15ZM63.44,122.76c-.42.09-.74.33-1.09.55-.97.61-8.5,9.41-8.78,10.34-1.44,4.8,3.01,8.12,7.12,5.49,1.03-.66,7.56-8.32,8.06-9.44,1.66-3.71-1.45-7.78-5.3-6.95Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_1" data-name="Слой_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 256 256">
<!-- Generator: Adobe Illustrator 29.5.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 137) -->
<defs>
<style>
.st0 {
fill: #fefefe;
}
.st1 {
fill: #ef8233;
}
.st2 {
fill: #1c5494;
}
</style>
</defs>
<path class="st2" d="M243.9,41.39c.16.05.18.8.29.83.54.14,1.26-.36,2.22-.02.67.24.77,1.14.95,1.24,4.86,2.5,5.76,4.18,7.88,9.51-1.15,13.91,2,31.75.17,45.2-.69,5.05-7.16,6.18-10.67,8.84-10.11,7.64-12.66,24.54-5.73,35.03,1.24,1.88,9.05,8.77,10.85,9.48,2.55,1.01,4.82-.16,5.49,3.93-.9,14.81,1.34,31.28.03,45.89-.7,7.71-6.48,12.9-14.13,13.56H14.65c-7.98-.69-13.17-5.6-14.13-13.56l.27-43.14c1.01-3.12,10.41-4.09,13.56-5.32,23.93-9.3,21.66-43.44-3.16-52.22-3.45-1.22-9.54-.67-10.55-5.17.97-12.82-1.78-28.91-.08-41.35.92-6.69,7.02-11.89,13.43-13l229.9.26ZM246.47,158.95c-23.3-9.28-26.98-43.22-8.09-58.95,2.6-2.16,5.48-3.17,8.09-5.13l.06-39.51c-.35-2.23-2.42-4.08-4.33-5.1l-226.3-.31c-2.87-.17-5.02,2.06-6.3,4.4l-.25,36.07c1.65,2.59,2.41.86,4.42,1.33.34.08,3.38,2.21,3.89,2.47,9.03,4.61,15.5,8.16,19.66,18.7,9.05,22.95-4.35,45.76-27.92,50.42l-.08,36.1c.13,1.07,1.48,5.22,2,5.84.13.15,3.41.57,3.94.82l225.92-.09c2.59-.53,4.62-2.12,5.36-4.71l-.07-42.35Z"/>
<path class="st2" d="M153.27,67.17c.19.09-.02,1.04,1.09,1.13,1.57.12,3.45-.88,4.36-.74.43.07.36,1.32.74,1.33.63.02,1.53-.77,2.39-.7.38.03.36,1.08.88,1.24,4.23,1.35,5.76,1.39,10.06,3.16,52.61,21.58,49.17,95.76-4.85,113.02-69.71,22.27-113.58-76.18-48.29-111.22,5.86-3.15,27.96-9.81,33.62-7.23ZM139.75,179.46c2.35,1.21,18.48-.44,21.99-1.29,51.58-12.41,53.81-85.79,1.45-100.51s-89.98,51.72-49.42,88.44c9.09,8.23,16.74,8.63,25.97,13.37Z"/>
<path class="st2" d="M98.5,184.23c.12.04,2.02,1.36,2.1,1.46,2.2,2.6,1.03,7.25-2.85,7.15l-50.34-.02c-4.67-1.99-3.66-8.99,1.88-8.8l49.21.21Z"/>
<path class="st2" d="M46.02,165.33c3.34-2.41,27.2.08,32.81-.79,4.45.7,5.4,6.99,1.3,8.77l-32.71-.02c-3.53-.54-3.71-6.3-1.4-7.97Z"/>
<path class="st1" d="M139.75,179.46c-9.23-4.74-16.88-5.13-25.97-13.37-40.56-36.71-3.79-103.4,49.42-88.44s50.13,88.11-1.45,100.51c-3.52.85-19.64,2.49-21.99,1.29ZM180.29,104.92c-1.77-1.93-5.18-1.99-7.04-.2l-33.98,34.58-16.64-15.42c-3.5-1.55-7.56,1.79-6.69,5.38,1.22,5.02,17.53,17.17,21.26,21.92,1.94-.23,4.11-.67,5.55-2.08,9.84-12.48,28.17-24.75,37.11-37.15,1.7-2.36,2.69-4.57.43-7.04Z"/>
<path class="st0" d="M180.29,104.92c2.26,2.47,1.27,4.68-.43,7.04-8.95,12.4-27.27,24.67-37.11,37.15-1.45,1.4-3.61,1.85-5.55,2.08-3.74-4.74-20.05-16.9-21.26-21.92-.87-3.59,3.2-6.93,6.69-5.38l16.64,15.42,33.98-34.58c1.86-1.8,5.27-1.73,7.04.2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="510px" height="510px" viewBox="0 0 510 510" style="enable-background:new 0 0 510 510;" xml:space="preserve">
<g>
<g id="account-circle">
<path d="M255,0C114.75,0,0,114.75,0,255s114.75,255,255,255s255-114.75,255-255S395.25,0,255,0z M255,76.5
c43.35,0,76.5,33.15,76.5,76.5s-33.15,76.5-76.5,76.5c-43.35,0-76.5-33.15-76.5-76.5S211.65,76.5,255,76.5z M255,438.6
c-63.75,0-119.85-33.149-153-81.6c0-51,102-79.05,153-79.05S408,306,408,357C374.85,405.45,318.75,438.6,255,438.6z"/>
</g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1005 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

View File

@ -0,0 +1,560 @@
'use client';
import { useEffect, CSSProperties } from 'react';
import Header from '@/components/Header';
import Fotter from '@/components/Fotter';
// Стили для страницы About
const styles = `
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInLeft {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fadeInRight {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes glowPulse {
0% {
text-shadow: 0 0 5px rgba(255,127,39,0.3);
}
50% {
text-shadow: 0 0 20px rgba(255,127,39,0.6);
}
100% {
text-shadow: 0 0 5px rgba(255,127,39,0.3);
}
}
@keyframes borderGlow {
0% {
box-shadow: 0 0 5px rgba(255,127,39,0.3);
}
50% {
box-shadow: 0 0 15px rgba(255,127,39,0.5);
}
100% {
box-shadow: 0 0 5px rgba(255,127,39,0.3);
}
}
.page-container {
background: radial-gradient(circle at top right, rgba(30,30,30,0.8) 0%, rgba(15,15,15,0.8) 100%);
min-height: 100vh;
position: relative;
overflow-x: hidden;
}
.page-container::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('/noise-texture.png'), linear-gradient(145deg, rgba(255,127,39,0.1) 0%, rgba(0,0,0,0) 70%);
opacity: 0.4;
pointer-events: none;
z-index: -1;
}
.content-container {
animation: fadeInUp 0.6s ease-out forwards;
width: 100%;
}
.section-title {
color: #ff7f27;
font-size: 2.5rem;
margin-bottom: 1.5rem;
font-weight: bold;
background: linear-gradient(to right, #ff7f27, #ff5500);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: glowPulse 3s infinite;
text-align: center;
letter-spacing: -0.5px;
}
.section-subtitle {
font-size: 1.5rem;
margin: 2rem 0 1rem;
color: #ff7f27;
font-weight: 600;
}
.about-section {
margin-bottom: 3rem;
background: rgba(0,0,0,0.3);
border-radius: 1rem;
padding: 2rem;
border: 1px solid rgba(255,127,39,0.2);
opacity: 0;
animation: fadeInUp 0.8s forwards;
}
.about-intro {
text-align: center;
max-width: 800px;
margin: 0 auto 3rem;
line-height: 1.7;
font-size: 1.1rem;
opacity: 0;
animation: fadeInUp 0.8s forwards;
animation-delay: 0.2s;
color: rgba(255,255,255,0.9);
}
.team-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2rem;
margin: 2rem 0;
}
.team-member {
background: linear-gradient(145deg, rgba(30,30,30,0.6), rgba(15,15,15,0.8));
border-radius: 1rem;
padding: 1.5rem;
width: 280px;
text-align: center;
border: 1px solid rgba(255,127,39,0.2);
opacity: 0;
animation: fadeInUp 0.5s forwards;
transition: all 0.3s ease;
}
.team-member:hover {
transform: translateY(-5px);
box-shadow: 0 8px 20px rgba(0,0,0,0.3);
border-color: rgba(255,127,39,0.5);
}
.member-avatar {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 3px solid #ff7f27;
padding: 3px;
margin: 0 auto 1rem;
}
.member-name {
font-size: 1.3rem;
font-weight: bold;
margin-bottom: 0.5rem;
background: linear-gradient(to right, #ff7f27, #ff5500);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.member-position {
color: rgba(255,255,255,0.7);
font-size: 0.9rem;
margin-bottom: 1rem;
}
.contact-item {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
padding: 1rem;
background: rgba(0,0,0,0.2);
border-radius: 0.8rem;
transition: all 0.3s ease;
}
.contact-item:hover {
background: rgba(255,127,39,0.1);
transform: translateX(5px);
}
.mission-box {
border-left: 4px solid #ff7f27;
padding-left: 1.5rem;
margin: 2rem 0;
position: relative;
opacity: 0;
animation: fadeInLeft 0.8s forwards;
animation-delay: 0.4s;
}
.mission-box::before {
content: '❝';
position: absolute;
left: -1.5rem;
top: -1rem;
font-size: 3rem;
color: #ff7f27;
opacity: 0.5;
}
.values-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
margin: 2rem 0;
}
.value-item {
background: rgba(0,0,0,0.2);
border-radius: 0.8rem;
padding: 1.5rem;
border: 1px solid rgba(255,127,39,0.1);
transition: all 0.3s ease;
opacity: 0;
animation: fadeInRight 0.6s forwards;
}
.value-item:hover {
background: rgba(0,0,0,0.3);
border-color: #ff7f27;
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(255,127,39,0.2);
}
.value-icon {
background: linear-gradient(145deg, #ff7f27, #ff5500);
width: 50px;
height: 50px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
}
`;
// Стили для главного контейнера
const mainContentStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
width: '100%',
maxWidth: '90vw',
margin: '0 auto',
flex: 1,
paddingTop: '76px',
paddingBottom: '30px',
};
export default function About() {
// Эффект для анимации элементов при прокрутке
useEffect(() => {
const handleAnimateElements = () => {
const elements = document.querySelectorAll('.animate-on-scroll');
elements.forEach((el, index) => {
if (el instanceof HTMLElement) {
el.style.animationDelay = `${index * 0.1}s`;
}
});
};
handleAnimateElements();
// Дополнительная анимация при прокрутке (для будущего расширения)
const handleScroll = () => {
// Будущая логика анимации при прокрутке
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return (
<div className="page-container">
<style>{styles}</style>
<Header />
<main style={mainContentStyle} className="content-container">
<h1 className="section-title">О нашем проекте</h1>
<p className="about-intro">
Мы команда энтузиастов, создающих уникальные культурные события,
которые объединяют людей и дарят незабываемые впечатления. Наша платформа
помогает находить и приобретать билеты на самые яркие мероприятия вашего города.
</p>
<section className="about-section" style={{ animationDelay: '0.2s' }}>
<h2 className="section-subtitle">Наша миссия</h2>
<div className="mission-box">
<p style={{ fontSize: '1.1rem', lineHeight: 1.7 }}>
Мы стремимся сделать культурную жизнь доступной каждому, предоставляя
удобный сервис для поиска и покупки билетов на разнообразные мероприятия.
Наша цель создать пространство, где каждый найдет что-то по душе,
расширит свой кругозор и получит новые впечатления.
</p>
</div>
<h2 className="section-subtitle">Наши ценности</h2>
<div className="values-container">
{[
{
title: 'Доступность',
icon: '✨',
desc: 'Мы делаем культурные события доступными для всех',
delay: '0.3s'
},
{
title: 'Разнообразие',
icon: '🎭',
desc: 'Поддерживаем широкий спектр мероприятий для любых интересов',
delay: '0.4s'
},
{
title: 'Качество',
icon: '🏆',
desc: 'Тщательно отбираем события, чтобы предложить лучшее',
delay: '0.5s'
},
{
title: 'Инновации',
icon: '💡',
desc: 'Постоянно совершенствуем нашу платформу и сервисы',
delay: '0.6s'
}
].map((value, index) => (
<div
key={index}
className="value-item"
style={{ animationDelay: value.delay }}
>
<div className="value-icon">
<span style={{ fontSize: '1.5rem' }}>{value.icon}</span>
</div>
<h3 style={{
color: '#ff7f27',
marginBottom: '0.5rem',
fontSize: '1.25rem'
}}>
{value.title}
</h3>
<p style={{ color: 'rgba(255,255,255,0.8)' }}>
{value.desc}
</p>
</div>
))}
</div>
</section>
<section className="about-section" style={{ animationDelay: '0.4s' }}>
<h2 className="section-subtitle">Наша команда</h2>
<div className="team-container">
{[
{
name: 'Вальтер Владислав',
position: 'Разработчик',
photo: 'https://sun9-73.userapi.com/impg/0L7o8ihONzvGVLWaps-rW_EpVxnF6eRxlkZbmQ/_fnB1AnXl_E.jpg?size=721x1080&quality=95&sign=cc610fe8deefb4ea1a92be84d0df65a7&type=album',
delay: '0.2s'
},
{
name: 'Изосимова Татьяна',
position: 'Преподователь "Интернет Программирование"',
photo: 'https://sun9-5.userapi.com/impg/VLRZ9cfoeqZsX8cND0-_t6UBa2R18ixtbOsrUg/IyYDh9y4_Oo.jpg?size=810x1080&quality=96&sign=ee2565303309ea04e802010d67090ea0&type=album',
delay: '0.3s'
},
{
name: 'Володин Сергей',
position: 'Преподователь "Разработка корпоритивных информационных систем"',
photo: 'https://www.mfua.ru/upload/resize_cache/iblock/294/468_576_1/wd0mv0y90q2thfyzpr71uvzz37lbn7u7.jpg',
delay: '0.4s'
},
{
name: 'Пахом',
position: 'Талисман',
photo: 'https://img.championat.com/i/l/v/16518528031486776581.jpg',
delay: '0.5s'
}
].map((member, index) => (
<div
key={index}
className="team-member"
style={{ animationDelay: member.delay }}
>
<img src={member.photo} alt={member.name} className="member-avatar" />
<h3 className="member-name">{member.name}</h3>
<p className="member-position">{member.position}</p>
<div style={{
display: 'flex',
justifyContent: 'center',
gap: '1rem',
marginTop: '1rem'
}}>
{member.name === 'Вальтер Владислав' ? (
<>
<a
href="https://github.com/3DThing"
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: 'none' }}
>
<div
style={{
width: 32,
height: 32,
borderRadius: '50%',
background: 'rgba(255,127,39,0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.3s ease'
}}
onMouseOver={(e) => {
e.currentTarget.style.background = 'rgba(255,127,39,0.3)';
e.currentTarget.style.transform = 'translateY(-3px)';
}}
onMouseOut={(e) => {
e.currentTarget.style.background = 'rgba(255,127,39,0.15)';
e.currentTarget.style.transform = 'translateY(0)';
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" stroke="#ff7f27" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
</a>
<a
href="https://vk.com/id632206075"
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: 'none' }}
>
<div
style={{
width: 32,
height: 32,
borderRadius: '50%',
background: 'rgba(255,127,39,0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.3s ease'
}}
onMouseOver={(e) => {
e.currentTarget.style.background = 'rgba(255,127,39,0.3)';
e.currentTarget.style.transform = 'translateY(-3px)';
}}
onMouseOut={(e) => {
e.currentTarget.style.background = 'rgba(255,127,39,0.15)';
e.currentTarget.style.transform = 'translateY(0)';
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M3.4 3.4C2 4.81333 2 7.07333 2 11.6V12.4C2 16.92 2 19.18 3.4 20.6C4.81333 22 7.07333 22 11.6 22H12.4C16.92 22 19.18 22 20.6 20.6C22 19.1867 22 16.9267 22 12.4V11.6C22 7.08 22 4.82 20.6 3.4C19.1867 2 16.9267 2 12.4 2H11.6C7.08 2 4.82 2 3.4 3.4ZM5.37333 8.08667C5.48 13.2867 8.08 16.4067 12.64 16.4067H12.9067V13.4333C14.58 13.6 15.8467 14.8267 16.3533 16.4067H18.72C18.4773 15.5089 18.0469 14.6727 17.4574 13.9533C16.8679 13.234 16.1326 12.6478 15.3 12.2333C16.0461 11.779 16.6905 11.1756 17.1929 10.461C17.6953 9.7464 18.045 8.93585 18.22 8.08H16.0733C15.6067 9.73334 14.22 11.2333 12.9067 11.3733V8.08667H10.7533V13.8467C9.42 13.5133 7.74 11.9 7.66666 8.08667H5.37333Z" fill="#ff7f27"/>
</svg>
</div>
</a>
<a
href="https://t.me/lucifer364"
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: 'none' }}
>
<div
style={{
width: 32,
height: 32,
borderRadius: '50%',
background: 'rgba(255,127,39,0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.3s ease'
}}
onMouseOver={(e) => {
e.currentTarget.style.background = 'rgba(255,127,39,0.3)';
e.currentTarget.style.transform = 'translateY(-3px)';
}}
onMouseOut={(e) => {
e.currentTarget.style.background = 'rgba(255,127,39,0.15)';
e.currentTarget.style.transform = 'translateY(0)';
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M23.1117 4.49449C23.4296 2.94472 21.9074 1.65683 20.4317 2.227L2.3425 9.21601C0.694517 9.85273 0.621087 12.1572 2.22518 12.8975L6.1645 14.7157L8.03849 21.2746C8.13583 21.6153 8.40618 21.8791 8.74917 21.968C9.09216 22.0568 9.45658 21.9576 9.70712 21.707L12.5938 18.8203L16.6375 21.8531C17.8113 22.7334 19.5019 22.0922 19.7967 20.6549L23.1117 4.49449ZM3.0633 11.0816L21.1525 4.0926L17.8375 20.2531L13.1 16.6999C12.7019 16.4013 12.1448 16.4409 11.7929 16.7928L10.5565 18.0292L10.928 15.9861L18.2071 8.70703C18.5614 8.35278 18.5988 7.79106 18.2947 7.39293C17.9906 6.99479 17.4389 6.88312 17.0039 7.13168L6.95124 12.876L3.0633 11.0816ZM8.17695 14.4791L8.78333 16.6015L9.01614 15.321C9.05253 15.1209 9.14908 14.9366 9.29291 14.7928L11.5128 12.573L8.17695 14.4791Z" fill="#ff7f27"/>
</svg>
</div>
</a>
</>
) : member.name === 'Изосимова Татьяна' ? (
<a
href="https://vk.com/id19862788"
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: 'none' }}
>
<div
style={{
width: 32,
height: 32,
borderRadius: '50%',
background: 'rgba(255,127,39,0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.3s ease'
}}
onMouseOver={(e) => {
e.currentTarget.style.background = 'rgba(255,127,39,0.3)';
e.currentTarget.style.transform = 'translateY(-3px)';
}}
onMouseOut={(e) => {
e.currentTarget.style.background = 'rgba(255,127,39,0.15)';
e.currentTarget.style.transform = 'translateY(0)';
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M3.4 3.4C2 4.81333 2 7.07333 2 11.6V12.4C2 16.92 2 19.18 3.4 20.6C4.81333 22 7.07333 22 11.6 22H12.4C16.92 22 19.18 22 20.6 20.6C22 19.1867 22 16.9267 22 12.4V11.6C22 7.08 22 4.82 20.6 3.4C19.1867 2 16.9267 2 12.4 2H11.6C7.08 2 4.82 2 3.4 3.4ZM5.37333 8.08667C5.48 13.2867 8.08 16.4067 12.64 16.4067H12.9067V13.4333C14.58 13.6 15.8467 14.8267 16.3533 16.4067H18.72C18.4773 15.5089 18.0469 14.6727 17.4574 13.9533C16.8679 13.234 16.1326 12.6478 15.3 12.2333C16.0461 11.779 16.6905 11.1756 17.1929 10.461C17.6953 9.7464 18.045 8.93585 18.22 8.08H16.0733C15.6067 9.73334 14.22 11.2333 12.9067 11.3733V8.08667H10.7533V13.8467C9.42 13.5133 7.74 11.9 7.66666 8.08667H5.37333Z" fill="#ff7f27"/>
</svg>
</div>
</a>
) : null /* Для Володина Сергея и Пахома не показываем иконки */}
</div>
</div>
))}
</div>
</section>
{/* Секция "Связаться с нами" удалена, поскольку эта информация будет на отдельной странице */}
</main>
<Fotter />
</div>
);
}

View File

@ -0,0 +1,86 @@
import { useEffect, useRef } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
interface MapComponentProps {
position: [number, number];
}
export default function MapComponent({ position }: MapComponentProps) {
const mapRef = useRef<L.Map | null>(null);
const mapContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Проверяем, что контейнер существует и карта еще не создана
if (!mapRef.current && mapContainerRef.current) {
// Создаем карту
mapRef.current = L.map(mapContainerRef.current, {
center: position,
zoom: 15,
scrollWheelZoom: false,
attributionControl: false,
doubleClickZoom: false,
dragging: true,
keyboard: false,
touchZoom: false,
});
// Добавляем слой тайлов
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(mapRef.current);
// Создаем оранжевый маркер с белой окантовкой
const orangeIcon = L.divIcon({
html: `<div style="
width: 24px;
height: 24px;
border-radius: 50%;
background-color: #ff7f27;
border: 3px solid white;
box-shadow: 0 2px 5px rgba(0,0,0,0.5);
transform: translate(-50%, -50%);
"></div>`,
className: 'custom-marker',
iconSize: [24, 24],
iconAnchor: [12, 12]
});
// Добавляем маркер
const marker = L.marker(position, { icon: orangeIcon }).addTo(mapRef.current);
// Добавляем всплывающее окно
marker.bindPopup(`
<div style="padding: 10px; color: #333;">
<strong>AboutMe Events</strong><br/>
Культурные мероприятия и события
</div>
`);
}
// Очищаем ресурсы при размонтировании компонента
return () => {
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
}
};
}, [position]);
return (
<>
<div
ref={mapContainerRef}
style={{ width: '100%', height: '100%', borderRadius: '1rem' }}
/>
<div style={{
position: 'absolute',
top: 0, left: 0, right: 0, bottom: 0,
pointerEvents: 'none',
boxShadow: 'inset 0 0 15px 5px rgba(0,0,0,0.2)',
borderRadius: '1rem',
zIndex: 100
}}></div>
</>
);
}

View File

@ -0,0 +1,485 @@
'use client';
import { useEffect, CSSProperties } from 'react';
import Header from '@/components/Header';
import Fotter from '@/components/Fotter';
import dynamic from 'next/dynamic';
// Стили для страницы контактов (аналогичные стилям страницы About)
const styles = `
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInLeft {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fadeInRight {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes glowPulse {
0% {
text-shadow: 0 0 5px rgba(255,127,39,0.3);
}
50% {
text-shadow: 0 0 20px rgba(255,127,39,0.6);
}
100% {
text-shadow: 0 0 5px rgba(255,127,39,0.3);
}
}
@keyframes borderGlow {
0% {
box-shadow: 0 0 5px rgba(255,127,39,0.3);
}
50% {
box-shadow: 0 0 15px rgba(255,127,39,0.5);
}
100% {
box-shadow: 0 0 5px rgba(255,127,39,0.3);
}
}
.page-container {
background: radial-gradient(circle at top right, rgba(30,30,30,0.8) 0%, rgba(15,15,15,0.8) 100%);
min-height: 100vh;
position: relative;
overflow-x: hidden;
}
.page-container::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('/noise-texture.png'), linear-gradient(145deg, rgba(255,127,39,0.1) 0%, rgba(0,0,0,0) 70%);
opacity: 0.4;
pointer-events: none;
z-index: -1;
}
.content-container {
animation: fadeInUp 0.6s ease-out forwards;
width: 100%;
}
.section-title {
color: #ff7f27;
font-size: 3rem;
margin-bottom: 2rem;
font-weight: bold;
background: linear-gradient(to right, #ff7f27, #ff5500);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: glowPulse 3s infinite;
text-align: center;
letter-spacing: -0.5px;
}
.section-subtitle {
font-size: 1.8rem;
margin: 2rem 0 1.5rem;
color: #ff7f27;
font-weight: 600;
}
.contact-section {
margin-bottom: 3rem;
background: rgba(0,0,0,0.3);
border-radius: 1.5rem;
padding: 4rem; // Увеличиваем внутренний отступ
border: 1px solid rgba(255,127,39,0.2);
opacity: 0;
animation: fadeInUp 0.8s forwards;
box-shadow: 0 10px 25px rgba(0,0,0,0.2);
width: 100%; // Занимает всю доступную ширину
}
.contact-intro {
text-align: center;
max-width: 900px;
margin: 0 auto 4rem;
line-height: 1.8;
font-size: 1.25rem;
opacity: 0;
animation: fadeInUp 0.8s forwards;
animation-delay: 0.2s;
color: rgba(255,255,255,0.9);
}
.contact-item {
display: flex;
align-items: center;
gap: 1.5rem;
margin-bottom: 2rem;
padding: 2.5rem; // Увеличиваем размер контактных элементов
background: rgba(0,0,0,0.2);
border-radius: 1rem;
transition: all 0.3s ease;
opacity: 0;
animation: fadeInLeft 0.6s forwards;
border-left: 1px solid rgba(255,127,39,0.1);
}
.contact-item:hover {
background: rgba(255,127,39,0.1);
transform: translateY(-5px);
border-left: 3px solid #ff7f27;
box-shadow: 0 10px 20px rgba(0,0,0,0.2);
}
.contact-item-icon {
min-width: 70px;
height: 70px;
border-radius: 50%;
background: rgba(255,127,39,0.15);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.contact-item:hover .contact-item-icon {
background: rgba(255,127,39,0.3);
transform: scale(1.1);
}
.map-container {
width: 100%;
height: 700px; // Делаем карту выше
border-radius: 1.5rem;
overflow: hidden;
margin-top: 3rem;
border: 1px solid rgba(255,127,39,0.2);
opacity: 0;
animation: fadeInUp 0.8s forwards;
animation-delay: 0.6s;
position: relative;
box-shadow: 0 15px 30px rgba(0,0,0,0.3);
}
.map-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
box-shadow: inset 0 0 20px 10px rgba(0,0,0,0.2);
border-radius: 1.5rem;
z-index: 100;
}
.leaflet-container {
width: 100%;
height: 100%;
border-radius: 1.5rem;
z-index: 1;
}
.social-container {
display: flex;
justify-content: center;
gap: 2rem;
margin: 3rem 0;
}
.social-button {
width: 80px;
height: 80px;
border-radius: 50%;
background: rgba(255,127,39,0.15);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
opacity: 0;
animation: fadeInUp 0.5s forwards;
}
.social-button:hover {
background: rgba(255,127,39,0.3);
transform: translateY(-8px);
box-shadow: 0 15px 25px rgba(0,0,0,0.3);
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
.custom-icon {
animation: bounce 2s infinite ease-in-out;
}
.contact-value {
font-size: 1.2rem;
color: rgba(255,255,255,0.9);
margin-top: 0.3rem;
}
.contact-title {
color: #ff7f27;
margin-bottom: 0.5rem;
font-size: 1.4rem;
font-weight: 600;
}
.contact-grid {
display: grid;
grid-template-columns: repeat(2, 1fr); // Фиксируем 2 колонки для более широкого блока
gap: 2rem;
width: 100%;
}
`;
// Стили для главного контейнера
const mainContentStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
width: '100%',
maxWidth: '80%', // Изменено с 95vw на 80%
margin: '0 auto',
flex: 1,
paddingTop: '76px',
paddingBottom: '50px',
};
// Используем только один динамический импорт для нашего компонента карты
const MapWithNoSSR = dynamic(
() => import('./MapComponent'),
{
ssr: false,
loading: () => (
<div style={{
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0,0,0,0.5)',
borderRadius: '1.5rem',
}}>
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '1.5rem'
}}>
<svg width="80" height="80" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<style jsx>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
svg {
animation: spin 1.5s linear infinite;
}
`}</style>
<circle cx="12" cy="12" r="10" stroke="#ff7f27" strokeWidth="2" opacity="0.25" />
<path d="M12 2C13.3132 2 14.6136 2.25866 15.8268 2.76121C17.0401 3.26375 18.1425 4.00035 19.0711 4.92893C20.0997 5.85752 20.9362 6.95991 21.4388 8.17317C21.9413 9.38642 22.2 10.6868 22.2 12" stroke="#ff7f27" strokeWidth="2" strokeLinecap="round" />
</svg>
<p style={{ color: '#ff7f27', fontWeight: 'bold', fontSize: '1.2rem' }}>Загрузка карты...</p>
</div>
</div>
)
}
);
// Основной компонент страницы
export default function Contacts() {
// Координаты местоположения (Москва)
const position: [number, number] = [55.7558, 37.6173];
useEffect(() => {
// Только анимация элементов
const handleAnimateElements = () => {
const elements = document.querySelectorAll('.animate-on-scroll');
elements.forEach((el, index) => {
if (el instanceof HTMLElement) {
el.style.animationDelay = `${index * 0.1}s`;
}
});
};
handleAnimateElements();
}, []);
// Контактные данные для отображения
const contactInfo = [
{
icon: 'mail',
title: 'Электронная почта',
value: 'info@aboutme-events.ru',
delay: '0.3s'
},
{
icon: 'phone',
title: 'Телефон',
value: '+7 (800) 123-45-67',
delay: '0.4s'
},
{
icon: 'map',
title: 'Адрес',
value: 'г. Москва, ул. Творческая, д. 42, офис 314',
delay: '0.5s'
},
{
icon: 'clock',
title: 'Время работы',
value: 'Пн-Пт: 10:00 - 19:00, Сб: 11:00 - 17:00',
delay: '0.6s'
}
];
// Данные о социальных сетях
const socials = [
{ name: 'vk', icon: 'vk', delay: '0.4s' },
{ name: 'telegram', icon: 'telegram', delay: '0.5s' },
{ name: 'youtube', icon: 'youtube', delay: '0.6s' },
{ name: 'instagram', icon: 'instagram', delay: '0.7s' }
];
return (
<div className="page-container">
<style>{styles}</style>
<Header />
<main style={mainContentStyle} className="content-container">
<h1 className="section-title">Контакты</h1>
<p className="contact-intro">
Мы всегда рады общению! Свяжитесь с нами любым удобным способом,
и мы ответим на все ваши вопросы о мероприятиях, сотрудничестве
или предложениях.
</p>
<section className="contact-section" style={{ animationDelay: '0.2s', width: '100%' }}>
<h2 className="section-subtitle">Наши контакты</h2>
{/* Заменяем инлайн-стиль на класс */}
<div className="contact-grid">
{contactInfo.map((contact, index) => (
<div
key={index}
className="contact-item"
style={{ animationDelay: contact.delay }}
>
<div className="contact-item-icon">
{contact.icon === 'mail' && (
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4H20C21.1 4 22 4.9 22 6V18C22 19.1 21.1 20 20 20H4C2.9 20 2 19.1 2 18V6C2 4.9 2.9 4 4 4Z" stroke="#ff7f27" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M22 6L12 13L2 6" stroke="#ff7f27" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
{contact.icon === 'phone' && (
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 16.92V19.92C22.0011 20.1985 21.9441 20.4742 21.8329 20.7293C21.7216 20.9845 21.5588 21.2136 21.3559 21.4019C21.1529 21.5901 20.9148 21.7335 20.6557 21.8227C20.3966 21.9119 20.1227 21.9451 19.85 21.92C16.4482 21.5856 13.1766 20.5341 10.27 18.85C7.58 17.32 5.27 15.01 3.74 12.32C2.05 9.40063 1.00013 6.12404 0.670004 2.71997C0.644943 2.44729 0.677944 2.17336 0.767073 1.91429C0.856202 1.65523 0.999882 1.41724 1.18822 1.21431C1.37657 1.01138 1.60575 0.848628 1.86088 0.737462C2.116 0.626297 2.39159 0.569302 2.67 0.569969L5.67 0.569969C6.17539 0.564872 6.66584 0.736938 7.05843 1.05471C7.45103 1.37248 7.71686 1.81526 7.8 2.30997C7.97672 3.38853 8.2574 4.45124 8.64 5.47997C8.77393 5.81025 8.81413 6.17096 8.75636 6.52298C8.6986 6.87501 8.54569 7.20249 8.31 7.46997L7.09 8.68997C8.51356 11.4922 10.7078 13.6864 13.51 15.11L14.73 13.89C14.9975 13.6543 15.325 13.5014 15.677 13.4436C16.0291 13.3858 16.3898 13.426 16.72 13.56C17.7488 13.9426 18.8115 14.2233 19.89 14.4C20.3901 14.4848 20.8369 14.754 21.1542 15.1518C21.4716 15.5496 21.6399 16.0456 21.63 16.55L22 16.92Z" stroke="#ff7f27" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
{contact.icon === 'map' && (
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 10C21 17 12 23 12 23C12 23 3 17 3 10C3 7.61305 3.94821 5.32387 5.63604 3.63604C7.32387 1.94821 9.61305 1 12 1C14.3869 1 16.6761 1.94821 18.364 3.63604C20.0518 5.32387 21 7.61305 21 10Z" stroke="#ff7f27" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M12 13C13.6569 13 15 11.6569 15 10C15 8.34315 13.6569 7 12 7C10.3431 7 9 8.34315 9 10C9 11.6569 10.3431 13 12 13Z" stroke="#ff7f27" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
{contact.icon === 'clock' && (
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" stroke="#ff7f27" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M12 6V12L16 14" stroke="#ff7f27" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
</div>
<div>
<h3 className="contact-title">
{contact.title}
</h3>
<p className="contact-value">{contact.value}</p>
</div>
</div>
))}
</div>
<h2 className="section-subtitle" style={{ marginTop: '4rem' }}>Мы в социальных сетях</h2>
<div className="social-container">
{socials.map((social, index) => (
<div
key={index}
className="social-button"
style={{ animationDelay: social.delay }}
>
{social.icon === 'vk' && (
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 12C2 9.87827 2.84285 7.84344 4.34315 6.34315C5.84344 4.84285 7.87827 4 10 4H14C16.1217 4 18.1566 4.84285 19.6569 6.34315C21.1571 7.84344 22 9.87827 22 12C22 14.1217 21.1571 16.1566 19.6569 17.6569C18.1566 19.1571 16.1217 20 14 20H10C7.87827 20 5.84344 19.1571 4.34315 17.6569C2.84285 16.1566 2 14.1217 2 12Z" stroke="#ff7f27" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M8 8.5H9.5C9.5 8.5 10 9 10.5 10.5C11 12 11.5 12 12 12C12.5 12 12.5 11.5 12.5 10.5V9.5C12.5 9 12.5 8.5 13.5 8.5H15" stroke="#ff7f27" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M15.5 11C15.5 12.5 14 16 11.5 16C9 16 8 13 8 12" stroke="#ff7f27" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
{social.icon === 'telegram' && (
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 2L11 13" stroke="#ff7f27" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M22 2L15 22L11 13L2 9L22 2Z" stroke="#ff7f27" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
{social.icon === 'youtube' && (
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22.5401 6.42C22.4213 5.94541 22.1794 5.51057 21.8387 5.15941C21.4981 4.80824 21.0708 4.55318 20.6001 4.42C18.8801 4 12.0001 4 12.0001 4C12.0001 4 5.12008 4 3.40008 4.46C2.92933 4.59318 2.50206 4.84824 2.16143 5.19941C1.8208 5.55057 1.57887 5.98541 1.46008 6.46C1.14577 8.20556 0.991258 9.97631 1.00008 11.75C0.988802 13.537 1.14337 15.3213 1.46008 17.08C1.59168 17.5398 1.83666 17.9581 2.17823 18.2945C2.51981 18.6308 2.9378 18.8738 3.40008 19C5.12008 19.46 12.0001 19.46 12.0001 19.46C12.0001 19.46 18.8801 19.46 20.6001 19C21.0708 18.8668 21.4981 18.6118 21.8387 18.2606C22.1794 17.9094 22.4213 17.4746 22.5401 17C22.8524 15.2676 23.0068 13.5103 23.0001 11.75C23.0113 9.96295 22.8568 8.1787 22.5401 6.42Z" stroke="#ff7f27" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M9.75 15.02L15.5 11.75L9.75 8.47998V15.02Z" stroke="#ff7f27" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
{social.icon === 'instagram' && (
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 2H7C4.23858 2 2 4.23858 2 7V17C2 19.7614 4.23858 22 7 22H17C19.7614 22 22 19.7614 22 17V7C22 4.23858 19.7614 2 17 2Z" stroke="#ff7f27" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M16 11.37C16.1234 12.2022 15.9813 13.0522 15.5938 13.799C15.2063 14.5458 14.5932 15.1514 13.8416 15.5297C13.0901 15.9079 12.2385 16.0396 11.4078 15.9059C10.5771 15.7723 9.80977 15.3801 9.21485 14.7852C8.61993 14.1902 8.22774 13.4229 8.09408 12.5922C7.96042 11.7615 8.09208 10.9099 8.47034 10.1584C8.8486 9.40685 9.4542 8.79374 10.201 8.40624C10.9478 8.01874 11.7978 7.87658 12.63 8C13.4789 8.12588 14.2649 8.52146 14.8717 9.1283C15.4785 9.73515 15.8741 10.5211 16 11.37Z" stroke="#ff7f27" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M17.5 6.5H17.51" stroke="#ff7f27" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
</div>
))}
</div>
<h2 className="section-subtitle" style={{ marginTop: '4rem' }}>Наше расположение</h2>
<div className="map-container">
<MapWithNoSSR position={position} />
</div>
</section>
</main>
<Fotter />
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0c0000;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

139
frontend/src/app/page.tsx Normal file
View File

@ -0,0 +1,139 @@
'use client';
import Header from '@/components/Header';
import Footer from '@/components/Fotter';
import PosterSlider from '@/components/Slideposter';
import RandomComment from '@/components/RandomComment';
// Дополнительные стили для улучшения внешнего вида
const styles = `
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes gradientAnimation {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
@keyframes glowPulse {
0% {
text-shadow: 0 0 5px rgba(255,127,39,0.3);
}
50% {
text-shadow: 0 0 20px rgba(255,127,39,0.6);
}
100% {
text-shadow: 0 0 5px rgba(255,127,39,0.3);
}
}
.page-container {
background: radial-gradient(circle at top right, rgba(30,30,30,0.8) 0%, rgba(15,15,15,0.8) 100%);
min-height: 100vh;
position: relative;
overflow-x: hidden;
}
.page-container::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('/noise-texture.png'), linear-gradient(145deg, rgba(255,127,39,0.1) 0%, rgba(0,0,0,0) 70%);
opacity: 0.4;
pointer-events: none;
z-index: -1;
}
.content-container {
animation: fadeInUp 0.6s ease-out forwards;
width: 100%;
}
.section-title {
color: #ff7f27;
font-size: 2.5rem;
margin-bottom: 1.5rem;
font-weight: bold;
background: linear-gradient(to right, #ff7f27, #ff5500);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: glowPulse 3s infinite;
text-align: center;
}
.welcome-section {
text-align: center;
margin-bottom: 3rem;
}
.welcome-text {
max-width: 800px;
margin: 0 auto;
font-size: 1.1rem;
line-height: 1.7;
color: rgba(255,255,255,0.9);
opacity: 0;
animation: fadeInUp 0.6s forwards;
animation-delay: 0.3s;
}
/* Добавьте это для улучшения работы модальных окон */
.modal-backdrop {
isolation: isolate;
transform: translateZ(0);
-webkit-transform: translateZ(0);
will-change: backdrop-filter;
}
@media screen and (max-width: 768px) {
.modal-backdrop {
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
}
}
`;
// Стили для главного контейнера
const mainContentStyle = {
display: 'flex' as const,
flexDirection: 'column' as const,
alignItems: 'center' as const,
width: '100%',
maxWidth: '90vw',
margin: '0 auto',
flex: 1,
paddingTop: '76px',
paddingBottom: '30px',
};
export default function Home() {
return (
<div className="page-container">
<style>{styles}</style>
<Header />
<main style={mainContentStyle} className="content-container">
<PosterSlider />
<RandomComment />
</main>
<Footer />
</div>
);
}

View File

@ -0,0 +1,23 @@
'use client';
export default function Footer() {
return (
<div
className="footer"
style={{
backgroundColor: 'black',
color: 'rgb(240, 140, 50)',
padding: '12px',
width: '100%',
height: '70%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
marginTop: 'auto',
}}
>
Все права опущены во имя свободы
</div>
);
}

View File

@ -0,0 +1,508 @@
// frontend/src/components/Header.tsx
'use client';
import React, {
useEffect,
useState,
useCallback,
useRef,
CSSProperties,
} from 'react';
import ReactDOM from 'react-dom/client';
import LoginModal from './modal/Login';
import RegisterModal from './modal/Reg';
import ProfileModal from './modal/Profile';
import SettingsModal from './modal/Settings';
interface User {
login: string;
avatar: string;
}
/* helpers */
const readCookie = (n: string) => {
const value = document.cookie
.split('; ')
.find((c) => c.startsWith(`${n}=`))
?.split('=')[1];
// Декодируем URL-кодированное значение
if (value) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
return null;
}
/* palette */
const ACCENT = 'rgb(240,140,50)';
/* static styles */
const stHeader: CSSProperties = {
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: 56, // Увеличено для лучшей видимости
background: 'linear-gradient(to bottom, #111111, #000000)',
color: '#fff',
padding: '0 20px',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
boxShadow: '0 2px 15px rgba(0,0,0,0.5)',
};
const stLink: CSSProperties = {
color: ACCENT,
textDecoration: 'none',
fontSize: 14,
fontWeight: 'bold',
transition: 'all .3s',
padding: '5px 10px',
borderRadius: 8,
position: 'relative',
};
// Улучшенный стиль аватара пользователя
const stAvatarBase: CSSProperties = {
marginLeft: 'auto',
marginRight: 10,
display: 'flex',
alignItems: 'center',
position: 'relative',
cursor: 'pointer',
borderRadius: 24,
border: 0,
padding: '6px 12px',
userSelect: 'none',
transition: 'all 0.3s',
background: 'linear-gradient(145deg, #ff7f27, #ff5500)',
boxShadow: '0 2px 10px rgba(255,127,39,0.4)',
};
// Улучшенный стиль меню пользователя
const stMenu: CSSProperties = {
position: 'absolute',
top: 46,
right: 0,
background: 'linear-gradient(145deg, #0a0a0a, #1a1a1a)',
border: '1px solid #ff7f27',
borderRadius: 12,
padding: 6,
boxShadow: '0 8px 20px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,127,39,0.2)',
zIndex: 1000,
minWidth: 160,
animation: 'menuAppear 0.25s forwards',
transformOrigin: 'top right',
};
// Улучшенный стиль кнопок меню
const stBtn: CSSProperties = {
display: 'block',
width: '100%',
padding: '10px 16px',
background: 'transparent',
border: 0,
textAlign: 'left',
fontSize: 14,
fontWeight: 'bold',
color: ACCENT,
cursor: 'pointer',
transition: 'all 0.2s',
borderRadius: 8,
margin: '2px 0',
};
// Стили для анимаций
const styles = `
@keyframes menuAppear {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes buttonGlow {
0% {
box-shadow: 0 2px 10px rgba(255,127,39,0.3);
}
50% {
box-shadow: 0 2px 15px rgba(255,127,39,0.6);
}
100% {
box-shadow: 0 2px 10px rgba(255,127,39,0.3);
}
}
@keyframes linkPulse {
0% {
text-shadow: 0 0 3px rgba(255,127,39,0);
}
50% {
text-shadow: 0 0 8px rgba(255,127,39,0.5);
}
100% {
text-shadow: 0 0 3px rgba(255,127,39,0);
}
}
@keyframes avatarGlow {
0% {
box-shadow: 0 2px 10px rgba(255,127,39,0.4);
}
50% {
box-shadow: 0 2px 20px rgba(255,127,39,0.7);
}
100% {
box-shadow: 0 2px 10px rgba(255,127,39,0.4);
}
}
`;
// Улучшенный компонент кнопки меню
const MenuButton = ({ label, onClick }: { label: string; onClick?: () => void }) => {
const [h, setH] = useState(false);
return (
<button
style={{
...stBtn,
background: h ? 'rgba(255,127,39,0.15)' : 'transparent',
transform: h ? 'translateX(5px)' : 'translateX(0)',
}}
onMouseEnter={() => setH(true)}
onMouseLeave={() => setH(false)}
onMouseDown={() => setH(false)}
onMouseUp={() => setH(true)}
onClick={onClick}
>
{label}
</button>
);
};
// Улучшенный компонент меню пользователя
function UserMenu({
user,
onLogout,
onProfile,
onSettings,
}: {
user: User;
onLogout: () => void;
onProfile: () => void;
onSettings: () => void;
}) {
const [open, setOpen] = useState(false);
const [tone, setTone] = useState<'idle' | 'hover' | 'active'>('idle');
const ref = useRef<HTMLDivElement>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const close = (e: globalThis.MouseEvent) =>
ref.current && !ref.current.contains(e.target as Node) && setOpen(false);
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}, []);
const startHide = () => (timer.current = setTimeout(() => setOpen(false), 300));
const stopHide = () => {
if (timer.current) clearTimeout(timer.current);
};
const avatarStyle: CSSProperties = {
height: 36,
width: 36,
borderRadius: '50%',
border: '2px solid #000',
objectFit: 'cover',
boxShadow: '0 2px 5px rgba(0,0,0,0.3)',
transition: 'transform 0.2s',
transform: open ? 'scale(1.1)' : 'scale(1)',
};
const bg = tone === 'active'
? 'linear-gradient(145deg, #e96c00, #cc5e00)'
: tone === 'hover'
? 'linear-gradient(145deg, #ff7f27, #e96c00)'
: 'linear-gradient(145deg, #ff7f27, #ff5500)';
const boxShadow = tone === 'hover' || open
? '0 4px 15px rgba(255,127,39,0.5)'
: '0 2px 10px rgba(255,127,39,0.4)';
return (
<div
ref={ref}
style={{
...stAvatarBase,
background: bg,
boxShadow: boxShadow,
animation: tone === 'hover' ? 'avatarGlow 2s infinite' : 'none',
transform: tone === 'hover' ? 'translateY(-1px)' : 'translateY(0)'
}}
onClick={() => setOpen((v) => !v)}
onMouseEnter={() => setTone('hover')}
onMouseLeave={() => setTone('idle')}
onMouseDown={() => setTone('active')}
onMouseUp={() => setTone('hover')}
>
<img src={user.avatar} alt="" style={avatarStyle} />
<span style={{
marginLeft: 12,
color: '#000',
fontWeight: 'bold',
fontSize: 15,
letterSpacing: '0.5px',
}}>
{user.login}
</span>
{open && (
<div style={stMenu} onMouseEnter={stopHide} onMouseLeave={startHide}>
<MenuButton label="Профиль" onClick={onProfile} />
<MenuButton label="Настройки" onClick={onSettings} />
<div style={{
height: 1,
background: 'linear-gradient(to right, transparent, rgba(255,127,39,0.5), transparent)',
margin: '6px 0',
}}></div>
<MenuButton label="Выход" onClick={onLogout} />
</div>
)}
</div>
);
}
export default function Header() {
const [user, setUser] = useState<User | null>(null);
const [ready, setReady] = useState(false);
/* cookies → state */
useEffect(() => {
const login = readCookie('login');
const avatar = readCookie('avatar');
if (login && avatar)
setUser({
login,
avatar: avatar.replace(/\\\\/g, '/').replace(/"/g, ''),
});
setReady(true);
}, []);
/* live updates (аватар, логин) */
useEffect(() => {
const f = (e: Event) =>
setUser((u) => (u ? { ...u, ...(e as CustomEvent).detail } : u));
window.addEventListener('user-update', f);
return () => window.removeEventListener('user-update', f);
}, []);
/* -------- modal helpers -------- */
const openPortal = (cmp: React.ReactElement) => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = ReactDOM.createRoot(host);
const close = () => {
root.unmount();
host.remove();
};
root.render(cmp);
return close;
};
const openLoginModal = useCallback(() => {
const close = openPortal(
<LoginModal
onSuccess={(u) => {
setUser(u);
window.dispatchEvent(new CustomEvent('user-update', { detail: u }));
}}
onClose={() => close()}
onRegisterClick={() => {
close(); // Закрываем окно логина
openRegisterModal(); // Открываем окно регистрации
}}
/>,
);
return close;
}, []);
const openRegisterModal = useCallback(() => {
const close = openPortal(
<RegisterModal
onSuccess={(u) => {
setUser(u);
window.dispatchEvent(new CustomEvent('user-update', { detail: u }));
}}
onClose={() => close()}
onLoginClick={() => {
close(); // Закрываем окно регистрации
openLoginModal(); // Открываем окно логина
}}
/>,
);
return close;
}, []);
const openSettingsModal = useCallback(() => {
if (!user) return;
// Получаем id из cookie (removed unused userId variable)
const close = openPortal(
<SettingsModal
user={{ login: user.login, full_name: '', avatar: user.avatar, email: '' }}
onClose={() => close()}
onUpdate={(patch) => setUser((u) => (u ? { ...u, ...patch } : u))}
/>,
);
return close; // Важно вернуть функцию закрытия!
}, [user]);
const openProfileModal = useCallback(() => {
if (!user) return;
// Получаем id из cookie
// Removed unused userId variable assignment
const close = openPortal(
<ProfileModal
user={{ login: user.login, full_name: '', avatar: user.avatar }}
onClose={() => close()}
onSettings={() => {
close();
openSettingsModal();
}}
/>,
);
return close; // Важно вернуть функцию закрытия!
}, [user, openSettingsModal]);
const handleLogout = useCallback(async () => {
await fetch('http://localhost:8000/logout', { method: 'POST', credentials: 'include' });
['login', 'avatar', 'full_name', 'email', 'id', 'JWT_token'].forEach(
(n) => (document.cookie = `${n}=; Max-Age=0; path=/`),
);
setUser(null);
}, []);
if (!ready) return null;
return (
<header style={stHeader}>
<style>{styles}</style>
<img
src="icon/logo.svg"
alt="Logo"
style={{ height: 36, marginRight: 12 }}
onContextMenu={(e) => e.preventDefault()}
onDragStart={(e) => e.preventDefault()}
/>
<h1 style={{
fontSize: 18,
margin: 0,
fontWeight: 'bold',
textShadow: '0 2px 10px rgba(255,127,39,0.3)',
background: 'linear-gradient(to right, #ff7f27, #ff5500)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}>
БИЛЕТопад
</h1>
<nav style={{ marginLeft: 20, display: 'flex', gap: 16 }}>
{Object.entries({ Афиша: '/', 'О нас': '/about', Отзывы: '/feedback', Контакты: '/contacts' }).map(
([txt, href]) => (
<a
key={txt}
href={href}
style={stLink}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,127,39,0.1)';
e.currentTarget.style.animation = 'linkPulse 2s infinite';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.animation = 'none';
}}
onContextMenu={(e) => e.preventDefault()}
>
{txt}
</a>
),
)}
</nav>
{user ? (
<UserMenu
user={user}
onLogout={handleLogout}
onProfile={openProfileModal}
onSettings={openSettingsModal}
/>
) : (
<button
onClick={openLoginModal}
style={{
marginLeft: 'auto',
marginRight: 10,
background: 'linear-gradient(145deg, #ff7f27, #ff5500)', // Градиент вместо плоского цвета
color: '#000',
border: 0,
borderRadius: 24,
padding: '9px 16px',
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
fontSize: 15,
fontWeight: 'bold',
transition: 'all 0.3s',
boxShadow: '0 2px 10px rgba(255,127,39,0.3)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'linear-gradient(145deg, #ff7f27, #e96c00)';
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 4px 15px rgba(255,127,39,0.5)';
e.currentTarget.style.animation = 'buttonGlow 2s infinite';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'linear-gradient(145deg, #ff7f27, #ff5500)';
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 2px 10px rgba(255,127,39,0.3)';
e.currentTarget.style.animation = 'none';
}}
onMouseDown={(e) => {
e.currentTarget.style.background = 'linear-gradient(145deg, #e96c00, #cc5e00)';
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 2px 5px rgba(255,127,39,0.2)';
}}
onMouseUp={(e) => {
e.currentTarget.style.background = 'linear-gradient(145deg, #ff7f27, #e96c00)';
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 4px 15px rgba(255,127,39,0.5)';
}}
>
<img
src="icon/login.svg"
alt=""
style={{
height: 24,
marginRight: 10,
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))' // Добавление тени для иконки
}}
/>
Войти
</button>
)}
</header>
);
}

View File

@ -0,0 +1,494 @@
'use client';
import { useState, useEffect } from 'react';
import { format } from 'date-fns';
import { ru } from 'date-fns/locale';
// Интерфейсы для типизации данных
interface User {
login: string;
avatar: string;
first_name: string;
last_name: string;
middle_name?: string;
}
interface Comment {
id: number;
userid: number;
text: string;
date: string;
rating: number; // Добавляем поле рейтинга
user?: User;
}
// Компонент для отображения звездочек
const StarRating = ({ rating }: { rating: number }) => {
return (
<div className="rating-stars">
{[1, 2, 3, 4, 5].map((star) => (
<span
key={star}
className={`star ${star <= rating ? 'filled' : ''}`}
>
</span>
))}
</div>
);
};
const DEMO_AVERAGE_RATING = 4.7; // Демонстрационное значение среднего рейтинга
const RandomComment = () => {
const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [averageRating, setAverageRating] = useState<number>(DEMO_AVERAGE_RATING); // Устанавливаем начальное значение
const [ratingLoaded, setRatingLoaded] = useState<boolean>(false);
// Загрузка комментариев с сервера
useEffect(() => {
const fetchRandomComments = async () => {
try {
setLoading(true);
// Получаем все комментарии
const response = await fetch('http://localhost:8000/comments', {
credentials: 'include',
});
if (!response.ok) {
throw new Error('Не удалось загрузить комментарии');
}
const allComments = await response.json();
// Выбираем случайные комментарии (от 1 до 4)
const shuffled = [...allComments].sort(() => 0.5 - Math.random());
const count = Math.floor(Math.random() * 4) + 1; // от 1 до 4
const selectedComments = shuffled.slice(0, Math.min(count, shuffled.length));
// Добавляем информацию о пользователях
const commentsWithUsers = await Promise.all(
selectedComments.map(async (comment: Comment) => {
try {
const userResponse = await fetch(`http://localhost:8000/user/${comment.userid}`, {
credentials: 'include',
});
if (userResponse.ok) {
const userData = await userResponse.json();
return { ...comment, user: userData };
}
return comment;
} catch (e) {
console.error("Ошибка при получении данных пользователя:", e);
return comment;
}
})
);
setComments(commentsWithUsers);
} catch (error) {
console.error('Ошибка при загрузке комментариев:', error);
setError('Не удалось загрузить отзывы');
} finally {
setLoading(false);
}
};
fetchRandomComments();
}, []);
// Загружаем средний рейтинг
useEffect(() => {
const fetchAverageRating = async () => {
try {
// Добавляем сигнал для прерывания запроса через 5 секунд
const controller = new AbortController();
const signal = controller.signal;
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch('http://localhost:8000/average-rating', {
credentials: 'include',
signal
});
clearTimeout(timeoutId);
if (!response.ok) {
console.warn('Не удалось загрузить средний рейтинг с сервера');
return;
}
const data = await response.json();
if (data && data.Рейтинг !== undefined) {
setAverageRating(data.Рейтинг);
setRatingLoaded(true);
}
} catch (e) {
console.warn("Ошибка при получении среднего рейтинга:", e);
// При ошибке оставляем демо-значение
}
};
fetchAverageRating();
}, []);
// Форматирование даты в читаемый вид
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
return format(date, "d MMMM yyyy, HH:mm", { locale: ru });
} catch {
return dateString;
}
};
// Обрезка длинного текста комментария
const truncateText = (text: string, maxLength: number = 150) => {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
};
// Обновленные стили для карточек комментариев
const styles = `
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes glow {
0% {
box-shadow: 0 0 5px rgba(255,127,39,0.3);
}
50% {
box-shadow: 0 0 15px rgba(255,127,39,0.5);
}
100% {
box-shadow: 0 0 5px rgba(255,127,39,0.3);
}
}
@keyframes glowPulse {
0% {
text-shadow: 0 0 5px rgba(255,127,39,0.3);
}
50% {
text-shadow: 0 0 20px rgba(255,127,39,0.6);
}
100% {
text-shadow: 0 0 5px rgba(255,127,39,0.3);
}
}
.random-comments-container {
width: 100%;
margin: 3rem auto 2rem;
padding: 0;
max-width: 1400px;
}
.random-comments-title {
color: #ff7f27;
font-size: 2.5rem;
margin-bottom: 1.5rem;
font-weight: bold;
background: linear-gradient(to right, #ff7f27, #ff5500);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: glowPulse 3s infinite;
text-align: center;
letter-spacing: -0.5px;
}
.comments-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
width: 100%;
justify-content: center;
}
.comment-card {
background: linear-gradient(145deg, rgba(30,30,30,0.6), rgba(15,15,15,0.8));
border-radius: 1.2rem;
border: 1px solid rgba(255,127,39,0.2);
padding: 1.5rem 1rem;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
animation: fadeInUp 0.5s forwards;
animation-delay: calc(var(--index) * 0.1s);
transform: translateY(20px);
opacity: 0;
height: 100%;
min-height: 320px;
max-width: none;
margin: 0;
}
.comment-card:hover {
transform: translateY(-5px);
border-color: rgba(255,127,39,0.5);
box-shadow: 0 8px 20px rgba(0,0,0,0.2);
z-index: 1;
}
.comment-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(to right, transparent, #ff7f27, transparent);
}
.avatar-container {
position: relative;
width: 100px;
height: 100px;
border-radius: 50%;
overflow: hidden;
border: 3px solid #ff7f27;
padding: 2px;
background: rgba(0,0,0,0.2);
margin-bottom: 1rem;
box-shadow: 0 5px 15px rgba(255,127,39,0.3);
}
.user-avatar {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.user-info {
margin-bottom: 0.4rem;
width: 100%;
}
.user-name {
font-size: 1.2rem;
font-weight: bold;
margin-bottom: 0.2rem;
background: linear-gradient(to right, #ff7f27, #ff5500);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.comment-date {
font-size: 0.8rem;
color: rgba(255,255,255,0.6);
}
.comment-text {
line-height: 1.5;
color: rgba(255,255,255,0.85);
font-size: 0.95rem;
position: relative;
margin-top: 0.5rem;
font-style: italic;
}
.quote-icon {
position: absolute;
bottom: 10px;
right: 10px;
opacity: 0.1;
color: #ff7f27;
font-size: 24px;
transform: rotate(180deg);
}
.loading-container {
text-align: center;
padding: 2rem;
font-style: italic;
color: rgba(255,255,255,0.6);
}
/* Обновленные стили для среднего рейтинга - сделаем его более заметным */
.average-rating-container {
display: flex;
justify-content: center;
align-items: center;
gap: 15px;
margin: -5px auto 30px;
animation: fadeInUp 0.6s forwards;
background: rgba(0,0,0,0.3);
border-radius: 15px;
padding: 15px 30px;
max-width: 400px;
box-shadow: 0 0 20px rgba(255,127,39,0.15);
border: 1px solid rgba(255,127,39,0.2);
}
.average-rating-container:hover {
animation: glow 1.5s infinite; /* Добавляем анимацию при наведении */
border-color: rgba(255,127,39,0.3);
transform: translateY(-2px);
}
.rating-stars {
display: flex;
gap: 6px;
}
.star {
color: rgba(255,127,39,0.2);
font-size: 28px; /* Увеличенный размер звезд */
transition: all 0.2s ease;
}
.star.filled {
color: #ff7f27;
}
.average-rating-label {
color: rgba(255,255,255,0.9);
font-size: 1.25rem;
font-weight: 500;
}
.average-rating-value {
color: #ff7f27;
font-weight: bold;
font-size: 1.5rem;
text-shadow: 0 0 10px rgba(255,127,39,0.4);
}
/* Новый стиль для контейнера рейтинга пользователя */
.user-rating {
display: flex;
justify-content: center;
width: 100%;
margin: 10px 0 15px;
}
@media (max-width: 768px) {
.comments-grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
}
@media (max-width: 480px) {
.comments-grid {
grid-template-columns: 1fr;
gap: 15px;
}
.avatar-container {
width: 90px;
height: 90px;
}
}
`;
if (loading) {
return (
<div className="random-comments-container">
<style>{styles}</style>
<div className="loading-container">
Загрузка отзывов...
</div>
</div>
);
}
if (error || comments.length === 0) {
return (
<section className="random-comments-container">
<style>{styles}</style>
<h2 className="random-comments-title">Отзывы пользователей</h2>
{/* Отображаем средний рейтинг даже если нет комментариев */}
<div className="average-rating-container">
<StarRating rating={Math.round(averageRating)} />
<span className="average-rating-value">
{averageRating.toFixed(1)}
</span>
</div>
{error ? (
<div className="loading-container">
Произошла ошибка при загрузке отзывов.
</div>
) : (
<div className="loading-container">
Отзывы пока отсутствуют. Будьте первым!
</div>
)}
</section>
);
}
return (
<section className="random-comments-container">
<style>{styles}</style>
<h2 className="random-comments-title">Отзывы пользователей</h2>
<div className="comments-grid">
{comments.map((comment, index) => (
<div
key={comment.id}
className="comment-card"
style={{ '--index': index } as React.CSSProperties}
>
{/* Аватар вверху */}
<div className="avatar-container">
<img
src={comment.user?.avatar || '/default-avatar.png'}
alt={`Аватар ${comment.user?.login || 'пользователя'}`}
className="user-avatar"
onError={(e) => {
(e.target as HTMLImageElement).src = '/default-avatar.png';
}}
/>
</div>
{/* Имя и дата под аватаром */}
<div className="user-info">
<div className="user-name">
{comment.user ? `${comment.user.first_name} ${comment.user.last_name.charAt(0)}.` : 'Пользователь'}
</div>
<div className="comment-date">
{formatDate(comment.date)}
</div>
</div>
{/* Рейтинг в отдельном блоке по центру */}
{comment.rating > 0 && (
<div className="user-rating">
<StarRating rating={comment.rating} />
</div>
)}
{/* Текст отзыва внизу */}
<div className="comment-text">
"{truncateText(comment.text)}"
</div>
<div className="quote-icon"></div>
</div>
))}
</div>
</section>
);
};
export default RandomComment;

View File

@ -0,0 +1,620 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { format } from 'date-fns';
import { ru } from 'date-fns/locale';
import PosterModal from './modal/Poster';
import ReactDOM from 'react-dom/client';
interface Poster {
id: number;
title: string;
description: string;
image: string;
date: string | null;
price: number;
like: number;
}
interface DetailedPosterData {
id: number;
img: string; // адаптируем имя поля для модального окна
title: string;
likes: number; // адаптируем имя поля для модального окна
desc: string; // адаптируем имя поля для модального окна
price: number;
date: string;
}
const PosterSlider = () => {
const [posters, setPosters] = useState<Poster[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [translateX, setTranslateX] = useState<number>(0);
const [activeIndex, setActiveIndex] = useState<number>(0);
const sliderRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Состояния для модального окна
// Removed unused isModalOpen state
// Removed unused selectedPoster state
const [loadingPosterDetails, setLoadingPosterDetails] = useState<boolean>(false);
// Загрузка данных с сервера
useEffect(() => {
const fetchPosters = async () => {
try {
setLoading(true);
const response = await fetch('http://localhost:8000/getallposter', {
credentials: 'include'
});
if (!response.ok) {
throw new Error('Не удалось загрузить постеры');
}
const data = await response.json();
setPosters(data.posters);
} catch (error) {
console.error('Ошибка при загрузке постеров:', error);
setError('Не удалось загрузить постеры. Пожалуйста, попробуйте позже.');
} finally {
setLoading(false);
}
};
fetchPosters();
}, []);
// Добавляем функцию для создания портала
const openPortal = (cmp: React.ReactElement) => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = ReactDOM.createRoot(host);
const close = () => {
root.unmount();
host.remove();
};
root.render(cmp);
return close;
};
// Модифицируем функцию для открытия модального окна
const handlePosterClick = (poster: Poster) => {
setLoadingPosterDetails(true);
fetchPosterDetails(poster.id)
.then(posterData => {
if (posterData) {
const closeModal = openPortal(
<PosterModal
data={posterData}
isOpen={true}
onClose={() => {
closeModal();
// Удаляем эту строку, т.к. функция setSelectedPoster больше не существует
// setSelectedPoster(null);
}}
/>
);
}
})
.finally(() => {
setLoadingPosterDetails(false);
});
};
// Модифицируем функцию загрузки деталей постера
const fetchPosterDetails = async (posterId: number): Promise<DetailedPosterData | null> => {
try {
const response = await fetch(`http://localhost:8000/getposter/${posterId}`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error('Не удалось загрузить информацию о мероприятии');
}
const posterData = await response.json();
// Адаптируем данные для модального окна
return {
id: posterData.id,
img: posterData.image,
title: posterData.title,
likes: posterData.like,
desc: posterData.description,
price: posterData.price,
date: posterData.date || '',
};
} catch (error) {
console.error('Ошибка при загрузке детальной информации о постере:', error);
alert('Не удалось загрузить информацию о мероприятии. Пожалуйста, попробуйте позже.');
return null;
}
};
// Установка начального активного слайда и обновление при изменении размера окна
useEffect(() => {
if (containerRef.current && posters.length > 0) {
// Устанавливаем начальный активный индекс в центр, если есть достаточно постеров
const initialIndex = Math.min(Math.floor(posters.length / 2), 1);
centerActiveSlide(initialIndex);
}
const handleResize = () => {
centerActiveSlide(activeIndex);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [posters]);
// Пересчет позиции при изменении активного индекса
useEffect(() => {
if (containerRef.current && posters.length > 0) {
centerActiveSlide(activeIndex);
}
}, [activeIndex]);
// Форматирование даты и цены
const formatDate = (dateStr: string | null) => {
if (!dateStr) return 'Дата не указана';
const date = new Date(dateStr);
return format(date, "d MMMM yyyy, HH:mm", { locale: ru });
};
const formatPrice = (price: number) => {
return price.toLocaleString('ru-RU', {
style: 'currency',
currency: 'RUB',
maximumFractionDigits: 0
});
};
// Улучшенная функция центрирования активного слайда
const centerActiveSlide = (index: number) => {
if (!sliderRef.current || !containerRef.current || posters.length === 0) return;
const containerWidth = containerRef.current.clientWidth;
const cardWidth = 320; // Ширина карточки + отступ
// Строго центрируем выбранную карточку
const offset = (containerWidth / 2) - (cardWidth / 2);
let newTranslateX = (index * cardWidth) - offset;
// Проверяем, достаточно ли слайдов для прокрутки
const totalSlidesWidth = posters.length * cardWidth;
const canSlide = totalSlidesWidth > containerWidth;
// Если слайдов мало или они помещаются на экране - центрируем контейнер целиком
if (!canSlide) {
// Вычисляем смещение для центрирования группы карточек
const centeringOffset = (containerWidth - totalSlidesWidth) / 2;
newTranslateX = 0; // Нет смещения
// Применяем центрирующее смещение через стиль margin вместо transform
if (sliderRef.current) {
sliderRef.current.style.marginLeft = `${centeringOffset}px`;
}
} else {
// Возвращаем margin к нормальному значению
if (sliderRef.current) {
sliderRef.current.style.marginLeft = '0px';
}
// Ограничиваем максимальный сдвиг для прокрутки
const maxTranslateX = Math.max(0, (cardWidth * posters.length) - containerWidth);
newTranslateX = Math.max(0, Math.min(newTranslateX, maxTranslateX));
}
// Важное изменение: для первого и последнего слайда делаем особое смещение
if (index === 0) {
newTranslateX = 0; // Смещение для первого слайда
} else if (index === posters.length - 1 && canSlide) {
newTranslateX = totalSlidesWidth - containerWidth; // Смещение для последнего слайда
}
setTranslateX(newTranslateX);
setActiveIndex(index);
};
// Обработка колесика мыши для прокрутки слайдера
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault(); // Предотвращаем стандартную прокрутку страницы
if (e.deltaY > 0 && activeIndex < posters.length - 1) {
// Прокрутка вниз/вправо
setActiveIndex(prevIndex => Math.min(prevIndex + 1, posters.length - 1));
} else if (e.deltaY < 0 && activeIndex > 0) {
// Прокрутка вверх/влево
setActiveIndex(prevIndex => Math.max(prevIndex - 1, 0));
}
};
// Обработка наведения и клика
const handlePosterHover = (index: number) => {
setActiveIndex(index); // Активация слайда при наведении
};
// Закрытие модального окна (удалено, так как не используется)
// Расчет дистанции от активного слайда для эффекта затемнения
const calculateDimFactor = (index: number) => {
if (index === activeIndex) return 1; // Активный слайд на 100% яркий
const distance = Math.abs(index - activeIndex);
// Чем дальше от активного, тем больше затемнение
if (distance === 1) return 0.5; // Соседние слайды на 50% яркости
if (distance === 2) return 0.3; // Слайды через один на 30% яркости
return 0.2; // Остальные слайды почти невидимы
};
// Стили для компонентов с прозрачным фоном
const styles = `
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulseGlow {
0% {
text-shadow: 0 0 5px rgba(255,127,39,0.3);
}
50% {
text-shadow: 0 0 20px rgba(255,127,39,0.6);
}
100% {
text-shadow: 0 0 5px rgba(255,127,39,0.3);
}
}
.slider-section {
width: 90vw;
margin: 0 auto;
position: relative;
padding: 20px 0 40px;
background: transparent;
}
.slider-title {
font-size: 2.2rem;
margin-bottom: 1.8rem;
font-weight: bold;
text-align: center;
background: linear-gradient(to right, #ff7f27, #ff5500);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: pulseGlow 3s infinite;
letter-spacing: -0.5px;
}
.slider-container-outer {
position: relative;
width: 100%;
overflow: hidden;
padding: 40px 0;
background: transparent;
}
.slider-container {
position: relative;
overflow: visible;
width: 100%;
margin: 0 auto;
padding: 0;
background: transparent;
}
.slider-wrapper {
display: flex;
transition: transform 0.7s cubic-bezier(0.33, 1, 0.68, 1);
padding-bottom: 15px;
will-change: transform;
}
.poster-card {
flex: 0 0 300px;
height: 420px;
margin-right: 20px;
border-radius: 16px;
overflow: hidden;
position: relative;
cursor: pointer;
transition: all 0.4s cubic-bezier(0.2, 0, 0.2, 1), filter 0.4s ease;
animation: fadeIn 0.5s forwards;
background: linear-gradient(145deg, #0a0a0a, #1a1a1a);
border: 1px solid rgba(255,127,39,0.2);
filter: brightness(0.3) saturate(0.3);
transform-origin: center center;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
will-change: transform, filter, box-shadow;
}
.poster-card.active {
filter: brightness(1) saturate(1) !important; /* Яркий активный слайд */
border: 1px solid rgba(255,127,39,1);
transform: translateY(-8px) scale(1.08); /* Увеличенное выделение */
box-shadow: 0 10px 30px rgba(0,0,0,0.4), 0 0 25px rgba(255,127,39,0.4);
z-index: 11 !important; /* Всегда поверх остальных */
}
.poster-image {
width: 100%;
height: 100%;
object-fit: cover;
position: absolute;
z-index: 1;
transition: all 0.4s ease;
}
.poster-gradient {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 70%;
background: linear-gradient(to top, rgba(0,0,0,0.95) 10%, rgba(0,0,0,0.7) 50%, rgba(0,0,0,0) 100%);
z-index: 2;
}
.poster-content {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 20px;
color: #fff;
z-index: 3;
transform: translateY(0);
transition: transform 0.4s ease;
}
.poster-card.active .poster-content {
transform: translateY(-5px);
}
.poster-title {
font-size: 1.5rem;
font-weight: bold;
margin-bottom: 8px;
color: #ff7f27;
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
transition: color 0.3s ease;
}
.poster-card.active .poster-title {
color: #ffa54f;
}
.poster-description {
font-size: 0.9rem;
margin-bottom: 16px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
opacity: 0.8;
transition: opacity 0.3s ease;
}
.poster-card.active .poster-description {
opacity: 1;
}
.poster-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
}
.poster-price {
font-size: 1.2rem;
font-weight: bold;
color: #ff7f27;
transition: all 0.3s ease;
}
.poster-card.active .poster-price {
transform: scale(1.05);
}
.poster-date {
font-size: 0.85rem;
color: rgba(255,255,255,0.7);
}
/* Индикаторы для навигации */
.slider-indicators {
display: flex;
justify-content: center;
margin-top: 20px;
gap: 8px;
}
.slider-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(255,127,39,0.2);
cursor: pointer;
transition: all 0.3s ease;
}
.slider-indicator.active {
width: 20px;
border-radius: 5px;
background: #ff7f27;
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 300px;
width: 100%;
}
.loading-spinner {
width: 50px;
height: 50px;
border: 4px solid rgba(255,127,39,0.3);
border-radius: 50%;
border-top-color: #ff7f27;
animation: spin 1s infinite linear;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error-container {
text-align: center;
padding: 50px;
color: #ff453a;
background: rgba(255,69,58,0.1);
border: 1px solid rgba(255,69,58,0.3);
border-radius: 16px;
margin: 0 60px;
}
.title {
color: #ff7f27;
font-size: 2.5rem;
margin-bottom: 1.5rem;
font-weight: bold;
background: linear-gradient(to right, #ff7f27, #ff5500);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: glowPulse 3s infinite;
text-align: center;
letter-spacing: -0.5px;
}
`;
// Проверка на малое количество постеров
const hasLimitedPosters = posters.length <= 3;
return (
<div className="slider-section">
<style>{styles}</style>
<h2 className="title">Афиша</h2>
<div className="slider-container-outer">
<div className="slider-container" ref={containerRef}>
<div
className="slider-wrapper"
ref={sliderRef}
style={{ transform: `translateX(-${translateX}px)` }}
onWheel={handleWheel}
>
{posters.map((poster, index) => {
const dimFactor = calculateDimFactor(index);
return (
<div
className={`poster-card ${index === activeIndex ? 'active' : ''}`}
key={poster.id}
onClick={() => handlePosterClick(poster)}
onMouseEnter={() => handlePosterHover(index)}
style={{
animationDelay: `${index * 0.1}s`,
zIndex: index === activeIndex ? 10 : posters.length - Math.abs(index - activeIndex),
filter: `brightness(${dimFactor}) saturate(${dimFactor})`,
opacity: hasLimitedPosters ? 1 : 0.4 + dimFactor * 0.6,
}}
>
<img
src={poster.image}
alt={poster.title}
className="poster-image"
onError={(e) => {
(e.target as HTMLImageElement).src = '/default-poster.jpg';
}}
/>
<div className="poster-gradient"></div>
<div className="poster-content">
<h3 className="poster-title">{poster.title}</h3>
<p className="poster-description">{poster.description}</p>
<div className="poster-footer">
<div className="poster-price">{formatPrice(poster.price)}</div>
<div className="poster-date">{formatDate(poster.date)}</div>
</div>
</div>
</div>
);
})}
</div>
</div>
{/* Индикаторы для навигации */}
{posters.length > 1 && (
<div className="slider-indicators">
{posters.map((_, index) => (
<div
key={index}
className={`slider-indicator ${index === activeIndex ? 'active' : ''}`}
onClick={() => setActiveIndex(index)}
/>
))}
</div>
)}
{/* Индикатор загрузки для модального окна */}
{loadingPosterDetails && (
<div className="modal-loading-overlay">
<div className="loading-spinner"></div>
<style jsx>{`
.modal-loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1100;
}
`}</style>
</div>
)}
</div>
{!loading && !error && posters.length === 0 && (
<div
style={{
textAlign: 'center',
padding: '50px',
margin: '0 60px',
color: 'rgba(255,255,255,0.7)',
background: 'transparent',
borderRadius: '16px',
border: '1px dashed rgba(255,127,39,0.3)',
}}
>
<p>Пока нет доступных мероприятий</p>
</div>
)}
</div>
);
};
// Removed unused selectedPoster state
export default PosterSlider;
// Removed unused setSelectedPoster function

View File

@ -0,0 +1,585 @@
// frontend/src/components/modal/Login.tsx
'use client';
import { useState, CSSProperties, useCallback, useEffect } from 'react';
// Улучшенные стили с градиентами и эффектами
const wrapper: CSSProperties = {
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,.7)',
backdropFilter: 'blur(12px)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000,
transition: 'all 0.3s ease',
};
const modal: CSSProperties = {
position: 'relative',
background: 'linear-gradient(145deg, #0a0a0a, #1a1a1a)',
border: '1px solid #ff7f27',
borderRadius: 24,
padding: 40,
minWidth: 400,
maxWidth: '90vw',
color: '#ff7f27',
boxShadow: '0 8px 40px rgba(255,127,39,.25), 0 0 0 1px rgba(255,127,39,0.1)',
transformOrigin: 'center',
transition: 'transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
animation: 'loginCardAppear 0.4s forwards',
};
const closeBtn: CSSProperties = {
position: 'absolute',
top: 16,
right: 16,
background: 'rgba(0,0,0,0.4)',
border: '1px solid rgba(255,127,39,0.3)',
fontSize: 20,
color: '#ff7f27',
cursor: 'pointer',
width: 36,
height: 36,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '50%',
transition: 'all 0.2s',
};
const input: CSSProperties = {
width: '100%',
padding: '12px 16px',
margin: '8px 0 20px',
border: '1px solid rgba(255,127,39,0.6)',
borderRadius: 12,
background: 'rgba(0,0,0,0.2)',
color: '#fff',
fontSize: 15,
outline: 'none',
transition: 'all 0.2s',
boxShadow: 'inset 0 1px 3px rgba(0,0,0,0.2)',
};
const label: CSSProperties = {
fontSize: 14,
fontWeight: 'bold',
color: 'rgba(255,255,255,0.9)',
letterSpacing: '0.5px',
};
// Стили для капчи
const captchaContainer: CSSProperties = {
marginBottom: 20,
border: '1px solid rgba(255,127,39,0.3)',
borderRadius: 12,
padding: 15,
background: 'rgba(255,127,39,0.05)',
};
const captchaCanvas: CSSProperties = {
width: '100%',
height: 80,
marginBottom: 10,
borderRadius: 8,
background: 'rgba(0,0,0,0.3)',
border: '1px solid rgba(255,127,39,0.3)',
};
const styles = `
@keyframes loginCardAppear {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes pulseGlow {
0% {
box-shadow: 0 0 5px rgba(255,127,39,0.3);
}
50% {
box-shadow: 0 0 20px rgba(255,127,39,0.5);
}
100% {
box-shadow: 0 0 5px rgba(255,127,39,0.3);
}
}
input:focus {
border-color: #ff7f27;
box-shadow: 0 0 0 2px rgba(255,127,39,0.2), inset 0 1px 3px rgba(0,0,0,0.2);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
`;
interface Props {
onSuccess: (u: { login: string; avatar: string }) => void;
onClose: () => void;
onRegisterClick?: () => void; // Делаем необязательным
}
/* Исправленные вспомогательные функции */
const readCookie = (name: string) => {
const value = document.cookie
.split('; ')
.find((c) => c.startsWith(`${name}=`))
?.split('=')[1];
if (!value) return null;
// Декодируем URL-закодированное значение
try {
return decodeURIComponent(value);
} catch {
return value; // Если не URL-кодированное значение, вернуть как есть
}
};
const sanitizeAvatar = (v: string) => v.replace(/"/g, '').replace(/\\\\/g, '/');
export default function LoginModal({ onSuccess, onClose, onRegisterClick }: Props) {
const [login, setLogin] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// Состояния для капчи
const [captchaText, setCaptchaText] = useState('');
const [userCaptcha, setUserCaptcha] = useState('');
const [captchaValidated, setCaptchaValidated] = useState(false);
// Генерация капчи при загрузке компонента
useEffect(() => {
generateCaptcha();
}, []);
// Функция генерации капчи
const generateCaptcha = () => {
const canvas = document.getElementById('captchaCanvasLogin') as HTMLCanvasElement;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Установка фона
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Генерация случайного текста
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
let captchaString = '';
for (let i = 0; i < 6; i++) {
captchaString += chars.charAt(Math.floor(Math.random() * chars.length));
}
setCaptchaText(captchaString);
// Отрисовка текста
ctx.font = 'bold 28px Arial';
ctx.fillStyle = '#ff7f27';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Добавляем искажения для защиты
for (let i = 0; i < captchaString.length; i++) {
const x = (canvas.width / (captchaString.length + 1)) * (i + 1);
const y = canvas.height / 2 + Math.random() * 10 - 5;
const angle = Math.random() * 0.4 - 0.2;
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
ctx.fillText(captchaString[i], 0, 0);
ctx.restore();
}
// Добавляем шум
for (let i = 0; i < 100; i++) {
ctx.fillStyle = `rgba(255,127,39,${Math.random() * 0.3})`;
ctx.fillRect(Math.random() * canvas.width, Math.random() * canvas.height, 2, 2);
}
// Добавляем линии
for (let i = 0; i < 4; i++) {
ctx.beginPath();
ctx.moveTo(Math.random() * canvas.width, Math.random() * canvas.height);
ctx.lineTo(Math.random() * canvas.width, Math.random() * canvas.height);
ctx.strokeStyle = `rgba(255,127,39,${Math.random() * 0.5})`;
ctx.lineWidth = 1;
ctx.stroke();
}
// Сбрасываем ввод пользователя
setUserCaptcha('');
setCaptchaValidated(false);
};
// Валидация капчи
const validateCaptcha = () => {
if (userCaptcha.toLowerCase() === captchaText.toLowerCase()) {
setCaptchaValidated(true);
return true;
} else {
setCaptchaValidated(false);
setError('Неверный код с картинки. Попробуйте еще раз.');
generateCaptcha();
return false;
}
};
const handleLogin = useCallback(async () => {
if (!login || !password || loading) return;
// Проверяем капчу
if (!captchaValidated && !validateCaptcha()) {
return;
}
setLoading(true);
setError(null);
try {
const res = await fetch('http://localhost:8000/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login, password }),
credentials: 'include', // Важно для сохранения и отправки куки
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Ошибка авторизации');
console.log("Ответ от сервера:", data);
// Проверка, сохранился ли JWT в куки
setTimeout(() => {
const jwtCookie = document.cookie
.split('; ')
.find(c => c.startsWith('JWT_token='))
?.split('=')[1];
console.log("JWT токен в куки:", jwtCookie ? `${jwtCookie.substring(0, 10)}...` : "отсутствует");
// Если JWT нет в куках, но он есть в ответе, сохраним его вручную
if (!jwtCookie && data.token) {
console.log("Сохраняем JWT вручную");
document.cookie = `JWT_token=${data.token}; path=/; max-age=${60*60*24*7}`;
}
const userLogin = readCookie('login') || data.login;
const userAvatar = readCookie('avatar') || '/default-avatar.png';
if (userLogin) {
onSuccess({
login: userLogin,
avatar: userAvatar ? sanitizeAvatar(userAvatar) : '/default-avatar.png'
});
onClose();
} else {
throw new Error('Не удалось получить данные пользователя');
}
}, 300);
} catch (e: unknown) {
if (e instanceof Error) {
setError(e.message);
} else {
setError('Произошла неизвестная ошибка');
}
} finally {
setLoading(false);
}
}, [login, password, loading, captchaValidated, onSuccess, onClose, validateCaptcha]);
// Обработчик нажатия Enter
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleLogin();
}
};
// Обработчик клика на фоне
const handleWrapperClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
return (
<div style={wrapper} onClick={handleWrapperClick}>
<style>{styles}</style>
<div style={modal}>
<button
onClick={onClose}
style={closeBtn}
aria-label="Закрыть"
onMouseOver={(e) => {
e.currentTarget.style.background = 'rgba(255,127,39,0.2)';
e.currentTarget.style.transform = 'scale(1.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.background = 'rgba(0,0,0,0.4)';
e.currentTarget.style.transform = 'scale(1)';
}}
>
</button>
<h2 style={{
textAlign: 'center',
fontSize: 28,
marginBottom: 24,
fontWeight: 'bold',
textShadow: '0 2px 10px rgba(255,127,39,0.3)',
background: 'linear-gradient(to right, #ff7f27, #ff5500)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}>
Авторизация
</h2>
{error && (
<div style={{
color: '#ff3333',
marginBottom: 20,
padding: '8px 12px',
borderRadius: 8,
background: 'rgba(255,51,51,0.1)',
border: '1px solid rgba(255,51,51,0.3)',
animation: 'loginCardAppear 0.3s forwards',
}}>
{error}
</div>
)}
<label style={label}>Логин</label>
<input
type="text"
placeholder="Введите логин"
style={input}
value={login}
onChange={(e) => setLogin(e.target.value)}
onKeyDown={handleKeyDown}
/>
<label style={label}>Пароль</label>
<input
type="password"
placeholder="Введите пароль"
style={input}
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={handleKeyDown}
/>
{/* Капча вместо чекбокса "Я человек" */}
<div style={captchaContainer}>
<canvas
id="captchaCanvasLogin"
width="300"
height="80"
style={captchaCanvas}
/>
<div style={{ display: 'flex', gap: 10 }}>
<input
type="text"
placeholder="Введите текст с картинки"
style={{...input, margin: 0, flex: 1}}
value={userCaptcha}
onChange={(e) => setUserCaptcha(e.target.value)}
/>
<button
style={{
background: 'transparent',
border: '1px solid #ff7f27',
borderRadius: 8,
color: '#ff7f27',
cursor: 'pointer',
padding: '0 15px'
}}
onClick={generateCaptcha}
type="button"
>
</button>
<button
style={{
background: 'rgba(255,127,39,0.1)',
border: '1px solid #ff7f27',
borderRadius: 8,
color: '#ff7f27',
cursor: 'pointer',
padding: '0 15px'
}}
onClick={validateCaptcha}
type="button"
>
Проверить
</button>
</div>
{captchaValidated && (
<div style={{
color: '#4caf50',
marginTop: 10,
fontSize: 14,
display: 'flex',
alignItems: 'center',
gap: 5
}}>
<span style={{
fontSize: 16,
fontWeight: 'bold'
}}></span>
Проверка пройдена
</div>
)}
</div>
<div style={{
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
margin: '0 0 24px',
}}>
<a
href="#"
style={{
color: '#ff7f27',
textDecoration: 'none',
fontSize: 14,
transition: 'all 0.2s'
}}
onMouseOver={e => {
e.currentTarget.style.textDecoration = 'underline';
e.currentTarget.style.textShadow = '0 0 8px rgba(255,127,39,0.5)';
}}
onMouseOut={e => {
e.currentTarget.style.textDecoration = 'none';
e.currentTarget.style.textShadow = 'none';
}}
>
Восстановить пароль
</a>
</div>
<HoverButton
label={loading ? "Входим..." : "Войти"}
filled
onClick={handleLogin}
disabled={loading || !login || !password}
/>
<div style={{
margin: '24px 0 8px',
textAlign: 'center',
fontSize: 14,
color: 'rgba(255,255,255,0.6)',
position: 'relative',
}}>
<span style={{
background: 'linear-gradient(145deg, #0a0a0a, #1a1a1a)',
padding: '0 10px',
position: 'relative',
zIndex: 1,
}}>Нет аккаунта?</span>
<div style={{
position: 'absolute',
top: '50%',
left: 0,
right: 0,
height: 1,
background: 'rgba(255,255,255,0.1)',
zIndex: 0,
}}></div>
</div>
<HoverButton
label="Зарегистрироваться"
onClick={() => {
onClose();
// Добавляем проверку, чтобы избежать ошибки
if (typeof onRegisterClick === 'function') {
onRegisterClick();
}
}}
/>
</div>
</div>
);
}
type BtnProps = { label: string; filled?: boolean; onClick?: () => void; disabled?: boolean };
function HoverButton({ label, filled = false, onClick, disabled = false }: BtnProps) {
const [state, set] = useState<'idle' | 'hover' | 'active'>('idle');
const base: CSSProperties = {
width: '100%',
padding: 14,
borderRadius: 12,
fontWeight: 'bold',
fontSize: 16,
cursor: disabled ? 'default' : 'pointer',
transition: 'all .3s',
marginTop: 8,
};
const filledBg = state === 'active'
? '#cc5e00'
: state === 'hover'
? 'linear-gradient(145deg, #ff7f27, #e96c00)'
: 'linear-gradient(145deg, #ff7f27, #ff5500)';
const style: CSSProperties = filled
? {
...base,
background: filledBg,
color: '#000',
border: 0,
boxShadow: state === 'hover' ? '0 4px 15px rgba(255,127,39,0.4)' : '0 2px 10px rgba(255,127,39,0.3)',
transform: state === 'hover' && !disabled ? 'translateY(-2px)' : 'translateY(0)',
}
: {
...base,
background:
state === 'active'
? 'rgba(255,127,39,.2)'
: state === 'hover'
? 'rgba(255,127,39,.1)'
: 'transparent',
border: '1px solid #ff7f27',
color: '#fff',
transform: state === 'hover' && !disabled ? 'translateY(-1px)' : 'translateY(0)',
};
return (
<button
disabled={disabled}
style={{ ...style, opacity: disabled ? 0.7 : 1 }}
onMouseEnter={() => !disabled && set('hover')}
onMouseLeave={() => !disabled && set('idle')}
onMouseDown={() => !disabled && set('active')}
onMouseUp={() => !disabled && set('hover')}
onClick={disabled ? undefined : onClick}
>
{label}
</button>
);
}

View File

@ -0,0 +1,442 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { format } from 'date-fns';
import { ru } from 'date-fns/locale';
type PosterModalProps = {
data: {
id: number;
img: string;
title: string;
likes: number;
desc: string;
price: number;
date: string;
};
isOpen: boolean;
onClose: () => void;
};
export default function PosterModal({ data, isOpen, onClose }: PosterModalProps) {
const [liked, setLiked] = useState(false);
const [likesCount, setLikesCount] = useState(data.likes);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<{text: string, type: 'success' | 'error' | 'info' | null}>({
text: '',
type: null
});
// Блокировка прокрутки страницы при открытом модальном окне
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
// Обработка клавиши Escape
useEffect(() => {
const handleEscKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
window.addEventListener('keydown', handleEscKey);
return () => {
window.removeEventListener('keydown', handleEscKey);
};
}, [isOpen, onClose]);
// Функция для форматирования даты
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return format(date, "d MMMM yyyy 'г. в' HH:mm", { locale: ru });
};
// Функция обработки лайков
const handleLike = useCallback(async () => {
if (loading) return;
try {
setLoading(true);
// Получаем JWT токен из cookie
const token = document.cookie
.split('; ')
.find(row => row.startsWith('JWT_token='))
?.split('=')[1];
if (!token) {
setMessage({
text: 'Необходимо авторизоваться для оценки мероприятия',
type: 'error'
});
setTimeout(() => setMessage({text: '', type: null}), 3000);
return;
}
const formData = new FormData();
formData.append('poster_id', String(data.id));
formData.append('like', liked ? '-1' : '1');
const response = await fetch('http://localhost:8000/setlike', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData,
credentials: 'include'
});
const responseData = await response.json();
if (response.ok) {
setLikesCount(responseData.poster.like);
setMessage({
text: responseData.message,
type: 'success'
});
if (responseData.message !== "Вы уже оценили этот постер." &&
responseData.message !== "Вы еще не ставили лайк этому постеру.") {
setLiked(!liked);
}
} else {
setMessage({
text: responseData.detail || 'Произошла ошибка при оценке мероприятия',
type: 'error'
});
}
setTimeout(() => setMessage({text: '', type: null}), 3000);
} catch (error) {
console.error('Ошибка при отправке лайка:', error);
setMessage({
text: 'Не удалось обработать запрос. Попробуйте позже.',
type: 'error'
});
setTimeout(() => setMessage({text: '', type: null}), 3000);
} finally {
setLoading(false);
}
}, [data.id, liked, loading]);
if (!isOpen) return null;
// Расширенные стили с усиленными эффектами размытия и поддержкой кросс-браузерности
const styles = `
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes heartbeat {
0% { transform: scale(1); }
15% { transform: scale(1.25); }
25% { transform: scale(1.15); }
35% { transform: scale(1.25); }
100% { transform: scale(1); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-spinner {
animation: spin 1s linear infinite;
}
/* Улучшенная поддержка эффекта размытия для разных браузеров */
@supports (-webkit-backdrop-filter: none) or (backdrop-filter: none) {
.modal-backdrop {
-webkit-backdrop-filter: blur(25px) saturate(120%);
backdrop-filter: blur(25px) saturate(120%);
background-color: rgba(0,0,0,.65);
}
}
@supports not ((-webkit-backdrop-filter: none) or (backdrop-filter: none)) {
.modal-backdrop {
background-color: rgba(0,0,0,.9);
}
}
`;
const modalContainerStyle = {
position: 'fixed' as const,
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 2001, // Повышенный z-index
pointerEvents: 'none' as const,
};
return (
<>
<style>{styles}</style>
<div
className="modal-backdrop"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,.65)',
backdropFilter: 'blur(25px) saturate(120%)',
WebkitBackdropFilter: 'blur(25px) saturate(120%)',
zIndex: 2000, // Повышенный z-index
willChange: 'backdrop-filter',
pointerEvents: 'auto',
}}
onClick={onClose}
/>
<div style={modalContainerStyle}>
<div
onClick={e => e.stopPropagation()}
style={{
display: 'flex',
background: 'rgba(15,15,15,0.35)',
border: '1px solid rgba(255,127,39,0.5)',
borderRadius: 24,
overflow: 'hidden',
boxShadow: '0 15px 35px rgba(0,0,0,0.2)',
maxWidth: '90vw',
maxHeight: '85vh',
width: 'auto',
height: 'auto',
position: 'relative',
animation: 'fadeIn 0.3s forwards',
pointerEvents: 'auto',
}}
>
<button
onClick={onClose}
style={{
position: 'absolute',
top: 16,
right: 16,
background: 'rgba(0,0,0,0.5)',
border: '1px solid rgba(255,127,39,0.4)',
color: '#fff',
width: 32,
height: 32,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.2s',
zIndex: 10,
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6L18 18" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
<div style={{
width: '40%',
height: '85vh',
maxHeight: '85vh',
flexShrink: 0,
position: 'relative',
overflow: 'hidden',
borderRight: '1px solid rgba(255,127,39,0.3)',
}}>
<img
src={data.img}
alt={data.title}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'center',
display: 'block',
}}
/>
</div>
<div style={{
width: '60%',
padding: 40,
display: 'flex',
flexDirection: 'column',
color: '#fff',
}}>
<h1 style={{
color: '#ff7f27',
fontSize: 28,
fontWeight: 'bold',
marginBottom: 16,
textShadow: '0 2px 10px rgba(255,127,39,0.3)'
}}>
{data.title}
</h1>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
color: 'rgba(255,255,255,0.7)',
padding: '8px 0',
borderBottom: '1px solid rgba(255,127,39,0.2)'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 6,
position: 'relative'
}}>
<button
onClick={handleLike}
disabled={loading}
style={{
background: 'transparent',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
padding: 5,
transition: 'all 0.2s'
}}
title={liked ? 'Убрать оценку' : 'Оценить мероприятие'}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M7 22H4C3.46957 22 2.96086 21.7893 2.58579 21.4142C2.21071 21.0391 2 20.5304 2 20V13C2 12.4696 2.21071 11.9609 2.58579 11.5858C2.96086 11.2107 3.46957 11 4 11H7M14 9V5C14 4.20435 13.6839 3.44129 13.1213 2.87868C12.5587 2.31607 11.7956 2 11 2L7 11V22H18.28C18.7623 22.0055 19.2304 21.8364 19.5979 21.524C19.9654 21.2116 20.2077 20.7769 20.28 20.3L21.66 11.3C21.7035 11.0134 21.6842 10.7207 21.6033 10.4423C21.5225 10.1638 21.3821 9.90629 21.1919 9.68751C21.0016 9.46873 20.7661 9.29393 20.5016 9.17522C20.2371 9.0565 19.9499 8.99672 19.66 9H14Z"
stroke={liked ? "#ff7f27" : "rgba(255,255,255,0.7)"}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
fill={liked ? "#ff7f27" : "none"}
/>
</svg>
</button>
<span style={{ color: liked ? '#ff7f27' : 'rgba(255,255,255,0.7)' }}>
{likesCount}
</span>
{loading && (
<span style={{ marginLeft: 5 }}>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="loading-spinner"
>
<circle cx="12" cy="12" r="10" stroke="rgba(255,127,39,0.3)" strokeWidth="2" />
<path d="M12 2C6.47715 2 2 6.47715 2 12" stroke="#ff7f27" strokeWidth="2" />
</svg>
</span>
)}
{message.text && (
<div style={{
position: 'absolute',
left: '100%',
marginLeft: 16,
whiteSpace: 'nowrap',
fontSize: 14,
padding: '6px 12px',
borderRadius: 8,
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
zIndex: 5,
maxWidth: 250,
animation: 'fadeIn 0.2s forwards',
backgroundColor: message.type === 'error' ? 'rgba(255,60,60,0.15)' :
message.type === 'success' ? 'rgba(39,255,127,0.15)' : 'rgba(255,127,39,0.15)',
color: message.type === 'error' ? '#ff5252' :
message.type === 'success' ? '#57ff8a' : '#ff7f27',
border: `1px solid ${message.type === 'error' ? 'rgba(255,60,60,0.3)' :
message.type === 'success' ? 'rgba(39,255,127,0.3)' : 'rgba(255,127,39,0.3)'}`
}}>
{message.text}
</div>
)}
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 14
}}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 4H5C3.89543 4 3 4.89543 3 6V20C3 21.1046 3.89543 22 5 22H19C20.1046 22 21 21.1046 21 20V6C21 4.89543 20.1046 4 19 4Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M16 2V6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M8 2V6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M3 10H21" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
{formatDate(data.date)}
</div>
</div>
<div style={{
lineHeight: 1.7,
fontSize: 16,
color: 'rgba(255,255,255,0.9)',
margin: '20px 0 30px',
flex: 1
}}>
{data.desc}
</div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 'auto',
paddingTop: 16,
borderTop: '1px solid rgba(255,127,39,0.2)'
}}>
<div style={{
color: '#ff7f27',
fontSize: 24,
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
gap: 15
}}>
{data.price.toLocaleString('ru-RU', { style: 'currency', currency: 'RUB' })}
</div>
<button
style={{
padding: '12px 24px',
background: 'linear-gradient(145deg, #ff7f27, #ff5500)',
border: 0,
borderRadius: 24,
fontWeight: 'bold',
cursor: 'pointer',
color: '#000',
transition: 'all 0.3s',
boxShadow: '0 2px 10px rgba(255,127,39,0.4)'
}}
onClick={() => alert('Функция покупки билетов будет доступна в ближайшее время!')}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 4px 15px rgba(255,127,39,0.5)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 2px 10px rgba(255,127,39,0.4)';
}}
>
Купить билет
</button>
</div>
</div>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,507 @@
'use client';
import { CSSProperties, useState, useEffect } from 'react';
// Улучшенные стили с градиентами и эффектами
const wrapper: CSSProperties = {
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,.7)',
backdropFilter: 'blur(12px)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000,
transition: 'all 0.3s ease',
padding: '20px',
};
const modal: CSSProperties = {
position: 'relative',
background: 'linear-gradient(145deg, #0a0a0a, #1a1a1a)',
border: '1px solid #ff7f27',
borderRadius: 24,
padding: 40,
minWidth: 700, // Увеличено для более широкого профиля
maxWidth: '90vw',
width: '100%', // Добавлено для лучшего контроля ширины
color: '#ff7f27',
boxShadow: '0 8px 40px rgba(255,127,39,.25), 0 0 0 1px rgba(255,127,39,0.1)',
transformOrigin: 'center',
transition: 'transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
animation: 'profileCardAppear 0.4s forwards',
};
const profileHeader: CSSProperties = {
position: 'relative',
marginBottom: 30,
display: 'flex',
alignItems: 'center',
gap: 30,
};
const avatarContainer: CSSProperties = {
position: 'relative',
width: 140,
height: 140,
borderRadius: '50%',
background: 'linear-gradient(145deg, #ff7f27, #f9560b)',
boxShadow: '0 5px 15px rgba(255, 127, 39, 0.3)',
animation: 'pulseGlow 3s infinite',
overflow: 'hidden',
};
const avatarImage: CSSProperties = {
width: '100%',
height: '100%',
objectFit: 'cover',
borderRadius: '50%',
};
const profileInfo: CSSProperties = {
flex: 1,
};
const username: CSSProperties = {
fontSize: 28,
marginBottom: 12,
fontWeight: 'bold',
textShadow: '0 2px 10px rgba(255,127,39,0.3)',
background: 'linear-gradient(to right, #ff7f27, #ff5500)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
};
const fullname: CSSProperties = {
fontSize: 18,
marginBottom: 12,
color: 'rgba(255,255,255,0.9)',
};
const closeBtn: CSSProperties = {
position: 'absolute',
top: 16,
right: 16,
background: 'rgba(0,0,0,0.4)',
border: '1px solid rgba(255,127,39,0.3)',
fontSize: 20,
color: '#ff7f27',
cursor: 'pointer',
width: 36,
height: 36,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '50%',
transition: 'all 0.2s',
};
const divider: CSSProperties = {
width: '100%',
height: 2,
background: 'linear-gradient(to right, transparent, rgba(255,127,39,0.5), transparent)',
margin: '20px 0',
borderRadius: 2,
};
const tabsContainer: CSSProperties = {
display: 'flex',
borderBottom: '1px solid rgba(255,127,39,0.3)',
marginBottom: 20,
};
const tab: CSSProperties = {
padding: '12px 20px',
color: 'rgba(255,255,255,0.6)',
cursor: 'pointer',
transition: 'all 0.3s',
borderBottom: '2px solid transparent',
fontSize: 16,
fontWeight: 'bold',
};
const tabActive: CSSProperties = {
color: '#ff7f27',
borderBottom: '2px solid #ff7f27',
background: 'rgba(255,127,39,0.1)',
};
const tabContent: CSSProperties = {
minHeight: 200,
padding: '20px 0',
};
const settingsButton: CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
width: '100%',
padding: '12px 0',
borderRadius: 12,
background: 'linear-gradient(145deg, #ff7f27, #ff5500)',
color: '#000',
fontWeight: 'bold',
fontSize: 16,
cursor: 'pointer',
border: 0,
boxShadow: '0 4px 12px rgba(255,127,39,0.4)',
transition: 'all 0.2s',
marginTop: 10,
};
const logoutButton: CSSProperties = {
display: 'block',
margin: '12px auto 0',
padding: '8px 12px',
background: 'transparent',
border: '1px solid rgba(255,127,39,0.5)',
color: '#ff7f27',
borderRadius: 8,
fontSize: 14,
cursor: 'pointer',
transition: 'all 0.2s',
};
const emptyState: CSSProperties = {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px 0',
color: 'rgba(255,255,255,0.5)',
textAlign: 'center',
};
const ticketCard: CSSProperties = {
background: 'linear-gradient(145deg, rgba(40,40,40,0.7), rgba(20,20,20,0.9))',
borderRadius: 16,
padding: 24,
marginBottom: 16,
border: '1px solid rgba(255,127,39,0.3)',
boxShadow: '0 5px 15px rgba(0,0,0,0.3)',
position: 'relative',
overflow: 'hidden',
animation: 'fadeIn 0.5s forwards',
};
const ticketHeader: CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 12,
};
const ticketTitle: CSSProperties = {
fontSize: 22,
fontWeight: 'bold',
color: '#fff',
marginBottom: 8,
};
const ticketNumber: CSSProperties = {
fontSize: 20,
fontWeight: 'bold',
color: '#ff7f27',
padding: '6px 12px',
background: 'rgba(255,127,39,0.15)',
borderRadius: 10,
letterSpacing: 1,
};
const ticketDetails: CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
padding: '12px 0',
borderTop: '1px dashed rgba(255,255,255,0.2)',
borderBottom: '1px dashed rgba(255,255,255,0.2)',
margin: '15px 0',
};
const ticketDetail: CSSProperties = {
display: 'flex',
flexDirection: 'column',
};
const ticketLabel: CSSProperties = {
fontSize: 12,
color: 'rgba(255,255,255,0.5)',
marginBottom: 5,
};
const ticketValue: CSSProperties = {
fontSize: 14,
color: 'rgba(255,255,255,0.9)',
};
const ticketPaymentId: CSSProperties = {
fontSize: 12,
color: 'rgba(255,255,255,0.5)',
marginTop: 10,
wordBreak: 'break-all',
};
// Добавим интерфейс для более полных данных пользователя
interface UserData {
login: string;
avatar: string;
first_name: string;
last_name: string;
middle_name: string | null;
email: string;
full_name?: string; // Для обратной совместимости
}
export default function ProfileModal({
user,
onClose,
onSettings,
}: {
user: { avatar: string; login: string; full_name: string };
onClose: () => void;
onSettings: () => void;
}) {
const [activeTab, setActiveTab] = useState('tickets');
const [userData, setUserData] = useState<UserData | null>(null);
// Removed unused loading state
// Получение ID пользователя из cookie
const userId = document.cookie
.split('; ')
.find(row => row.startsWith('id='))
?.split('=')[1];
// Получение токена из cookie (removed unused token variable)
// Загрузка полных данных пользователя при открытии профиля
useEffect(() => {
if (userId) {
fetchUserData(userId);
} else {
setLoading(false);
}
}, [userId]);
// Функция получения данных пользователя
const fetchUserData = async (id: string) => {
try {
const response = await fetch(`http://localhost:8000/user/${id}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
console.log("Получены данные пользователя:", data);
setUserData(data);
} else {
console.error("Ошибка при получении данных пользователя");
}
} catch (error) {
console.error("Ошибка запроса:", error);
} finally {
setLoading(false);
}
};
// Функция форматирования полного имени
const formatName = () => {
if (userData) {
const parts = [];
if (userData.last_name) parts.push(userData.last_name);
if (userData.first_name) parts.push(userData.first_name);
if (userData.middle_name) parts.push(userData.middle_name);
return parts.join(' ');
}
// Fallback к старому формату, если нет новых данных
return user.full_name || '';
};
return (
<div
style={wrapper}
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div style={modal}>
<button
onClick={onClose}
style={closeBtn}
aria-label="Закрыть"
onMouseOver={(e) => {
e.currentTarget.style.background = 'rgba(255,127,39,0.2)';
e.currentTarget.style.transform = 'scale(1.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.background = 'rgba(0,0,0,0.4)';
e.currentTarget.style.transform = 'scale(1)';
}}
>
</button>
<div style={profileHeader}>
<div style={avatarContainer}>
<img
src={userData?.avatar || user.avatar}
alt={`${userData?.login || user.login} avatar`}
style={avatarImage}
onContextMenu={(e) => e.preventDefault()}
onDragStart={(e) => e.preventDefault()}
/>
</div>
<div style={profileInfo}>
<h2 style={username}>
{userData?.login || user.login}
</h2>
{formatName() && (
<p style={fullname}>
{formatName()}
</p>
)}
{/* Добавляем отображение email */}
{userData?.email && (
<p style={{
fontSize: 14,
color: 'rgba(255,255,255,0.7)',
display: 'flex',
alignItems: 'center',
gap: 6
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4H20C21.1 4 22 4.9 22 6V18C22 19.1 21.1 20 20 20H4C2.9 20 2 19.1 2 18V6C2 4.9 2.9 4 4 4Z" stroke="rgba(255,255,255,0.7)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M22 6L12 13L2 6" stroke="rgba(255,255,255,0.7)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
{userData.email}
</p>
)}
</div>
</div>
<div style={divider}></div>
{/* Вкладки */}
<div style={tabsContainer}>
<div
style={{
...tab,
...(activeTab === 'tickets' ? tabActive : {})
}}
onClick={() => setActiveTab('tickets')}
>
Мои билеты
</div>
<div
style={{
...tab,
...(activeTab === 'history' ? tabActive : {})
}}
onClick={() => setActiveTab('history')}
>
История заказов
</div>
</div>
{/* Содержимое вкладок */}
<div style={tabContent} className={`tab-content-active`}>
{activeTab === 'tickets' && (
<div>
<div style={ticketCard}>
<div style={ticketHeader}>
<div>
<div style={ticketTitle}>Зеленый слоник 2</div>
</div>
<div style={ticketNumber}>F22SB21</div>
</div>
<div style={ticketDetails}>
<div style={ticketDetail}>
<span style={ticketLabel}>ДАТА</span>
<span style={ticketValue}>21.05.2025</span>
</div>
<div style={ticketDetail}>
<span style={ticketLabel}>ВРЕМЯ</span>
<span style={ticketValue}>19:30</span>
</div>
<div style={ticketDetail}>
<span style={ticketLabel}>ЗАЛ</span>
<span style={ticketValue}>Главный</span>
</div>
<div style={ticketDetail}>
<span style={ticketLabel}>МЕСТО</span>
<span style={ticketValue}>VIP 12</span>
</div>
</div>
<div style={ticketPaymentId}>
<span style={{color: 'rgba(255,127,39,0.8)', marginRight: 5}}>Код платежа:</span>
2fbd2cd4-000f-5001-9000-1fb8d8bcd8a9
</div>
</div>
</div>
)}
{activeTab === 'history' && (
<div style={emptyState}>
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ marginBottom: 16, opacity: 0.5 }}>
<path d="M12 8V12L15 15M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="#ff7f27" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<h3 style={{ color: 'rgba(255,255,255,0.8)', marginBottom: 8 }}>История заказов пуста</h3>
<p style={{ maxWidth: '70%', margin: '0 auto' }}>Здесь появится история ваших прошлых заказов</p>
</div>
)}
</div>
<button
onClick={onSettings}
style={settingsButton}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 6px 15px rgba(255,127,39,0.5)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(255,127,39,0.4)';
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M19.4 15C19.2669 15.3016 19.2272 15.6362 19.286 15.9606C19.3448 16.285 19.4995 16.5843 19.73 16.82L19.79 16.88C19.976 17.0657 20.1235 17.2863 20.2241 17.5291C20.3248 17.7719 20.3766 18.0322 20.3766 18.295C20.3766 18.5578 20.3248 18.8181 20.2241 19.0609C20.1235 19.3037 19.976 19.5243 19.79 19.71C19.6043 19.896 19.3837 20.0435 19.1409 20.1441C18.8981 20.2448 18.6378 20.2966 18.375 20.2966C18.1122 20.2966 17.8519 20.2448 17.6091 20.1441C17.3663 20.0435 17.1457 19.896 16.96 19.71L16.9 19.65C16.6643 19.4195 16.365 19.2648 16.0406 19.206C15.7162 19.1472 15.3816 19.1869 15.08 19.32C14.7842 19.4468 14.532 19.6572 14.3543 19.9255C14.1766 20.1938 14.0813 20.5082 14.08 20.83V21C14.08 21.5304 13.8693 22.0391 13.4942 22.4142C13.1191 22.7893 12.6104 23 12.08 23C11.5496 23 11.0409 22.7893 10.6658 22.4142C10.2907 22.0391 10.08 21.5304 10.08 21V20.91C10.0723 20.579 9.96512 20.258 9.77251 19.9887C9.5799 19.7194 9.31074 19.5143 9 19.4C8.69838 19.2669 8.36381 19.2272 8.03941 19.286C7.71502 19.3448 7.41568 19.4995 7.18 19.73L7.12 19.79C6.93425 19.976 6.71368 20.1235 6.47088 20.2241C6.22808 20.3248 5.96783 20.3766 5.705 20.3766C5.44217 20.3766 5.18192 20.3248 4.93912 20.2241C4.69632 20.1235 4.47575 19.976 4.29 19.79C4.10405 19.6043 3.95653 19.3837 3.85588 19.1409C3.75523 18.8981 3.70343 18.6378 3.70343 18.375C3.70343 18.1122 3.75523 17.8519 3.85588 17.6091C3.95653 17.3663 4.10405 17.1457 4.29 16.96L4.35 16.9C4.58054 16.6643 4.73519 16.365 4.794 16.0406C4.85282 15.7162 4.81312 15.3816 4.68 15.08C4.55324 14.7842 4.34276 14.532 4.07447 14.3543C3.80618 14.1766 3.49179 14.0813 3.17 14.08H3C2.46957 14.08 1.96086 13.8693 1.58579 13.4942C1.21071 13.1191 1 12.6104 1 12.08C1 11.5496 1.21071 11.0409 1.58579 10.6658C1.96086 10.2907 2.46957 10.08 3 10.08H3.09C3.42099 10.0723 3.742 9.96512 4.0113 9.77251C4.28059 9.5799 4.48572 9.31074 4.6 9C4.73312 8.69838 4.77282 8.36381 4.714 8.03941C4.65519 7.71502 4.50054 7.41568 4.27 7.18L4.21 7.12C4.02405 6.93425 3.87653 6.71368 3.77588 6.47088C3.67523 6.22808 3.62343 5.96783 3.62343 5.705C3.62343 5.44217 3.67523 5.18192 3.77588 4.93912C3.87653 4.69632 4.02405 4.47575 4.21 4.29C4.39575 4.10405 4.61632 3.95653 4.85912 3.85588C5.10192 3.75523 5.36217 3.70343 5.625 3.70343C5.88783 3.70343 6.14808 3.75523 6.39088 3.85588C6.63368 3.95653 6.85425 4.10405 7.04 4.29L7.1 4.35C7.33568 4.58054 7.63502 4.73519 7.95941 4.794C8.28381 4.85282 8.61838 4.81312 8.92 4.68H9C9.29577 4.55324 9.54802 4.34276 9.72569 4.07447C9.90337 3.80618 9.99872 3.49179 10 3.17V3C10 2.46957 10.2107 1.96086 10.5858 1.58579C10.9609 1.21071 11.4696 1 12 1C12.5304 1 13.0391 1.21071 13.4142 1.58579C13.7893 1.96086 14 2.46957 14 3V3.09C14.0013 3.41179 14.0966 3.72618 14.2743 3.99447C14.452 4.26276 14.7042 4.47324 15 4.6C15.3016 4.73312 15.6362 4.77282 15.9606 4.714C16.285 4.65519 16.5843 4.50054 16.82 4.27L16.88 4.21C17.0657 4.02405 17.2863 3.87653 17.5291 3.77588C17.7719 3.67523 18.0322 3.62343 18.295 3.62343C18.5578 3.62343 18.8181 3.67523 19.0609 3.77588C19.3037 3.87653 19.5243 4.02405 19.71 4.21C19.896 4.39575 20.0435 4.61632 20.1441 4.85912C20.2448 5.10192 20.2966 5.36217 20.2966 5.625C20.2966 5.88783 20.2448 6.14808 20.1441 6.39088C20.0435 6.63368 19.896 6.85425 19.71 7.04L19.65 7.1C19.4195 7.33568 19.2648 7.63502 19.206 7.95941C19.1472 8.28381 19.1869 8.61838 19.32 8.92V9C19.4468 9.29577 19.6572 9.54802 19.9255 9.72569C20.1938 9.90337 20.5082 9.99872 20.83 10H21C21.5304 10 22.0391 10.2107 22.4142 10.5858C22.7893 10.9609 23 11.4696 23 12C23 12.5304 22.7893 13.0391 22.4142 13.4142C22.0391 13.7893 21.5304 14 21 14H20.91C20.5882 14.0013 20.2738 14.0966 20.0055 14.2743C19.7372 14.452 19.5268 14.7042 19.4 15Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
Настройки профиля
</button>
<button
onClick={onClose}
style={logoutButton}
onMouseOver={(e) => {
e.currentTarget.style.background = 'rgba(255,127,39,0.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
Выйти
</button>
</div>
</div>
);
}
function setLoading(isLoading: boolean) {
console.log(`Loading state set to: ${isLoading}`);
}

View File

@ -0,0 +1,964 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import type { CSSProperties } from 'react';
/* Вспомогательные функции */
const readCookie = (name: string) => {
const value = document.cookie
.split('; ')
.find((c) => c.startsWith(`${name}=`))
?.split('=')[1];
if (!value) return null;
// Декодируем URL-закодированное значение
try {
return decodeURIComponent(value);
} catch {
return value; // Если не URL-кодированное значение, вернуть как есть
}
};
// Removed unused sanitizeAvatar function
// Улучшенные стили с градиентами и эффектами
const wrapper: CSSProperties = {
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,.7)',
backdropFilter: 'blur(12px)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000,
transition: 'all 0.3s ease',
};
const modal: CSSProperties = {
position: 'relative',
background: 'linear-gradient(145deg, #0a0a0a, #1a1a1a)',
border: '1px solid #ff7f27',
borderRadius: 24,
padding: 40,
minWidth: 500,
maxWidth: '90vw',
maxHeight: '90vh',
overflowY: 'auto',
color: '#ff7f27',
boxShadow: '0 8px 40px rgba(255,127,39,.25), 0 0 0 1px rgba(255,127,39,0.1)',
transformOrigin: 'center',
transition: 'transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
animation: 'regCardAppear 0.4s forwards',
};
const closeBtn: CSSProperties = {
position: 'absolute',
top: 16,
right: 16,
background: 'rgba(0,0,0,0.4)',
border: '1px solid rgba(255,127,39,0.3)',
fontSize: 20,
color: '#ff7f27',
cursor: 'pointer',
width: 36,
height: 36,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '50%',
transition: 'all 0.2s',
};
const input: CSSProperties = {
width: '100%',
padding: '12px 16px',
margin: '8px 0 20px',
border: '1px solid rgba(255,127,39,0.6)',
borderRadius: 12,
background: 'rgba(0,0,0,0.2)',
color: '#fff',
fontSize: 15,
outline: 'none',
transition: 'all 0.2s',
boxShadow: 'inset 0 1px 3px rgba(0,0,0,0.2)',
};
const label: CSSProperties = {
fontSize: 14,
fontWeight: 'bold',
color: 'rgba(255,255,255,0.9)',
letterSpacing: '0.5px',
};
const offerBtn: CSSProperties = {
background: 'none',
border: 'none',
color: '#ff7f27',
textDecoration: 'underline',
cursor: 'pointer',
padding: 0,
fontSize: 14,
fontWeight: 'bold',
marginLeft: 5,
};
const offerModal: CSSProperties = {
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,.9)',
backdropFilter: 'blur(12px)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1100,
transition: 'all 0.3s ease',
};
const offerContent: CSSProperties = {
background: 'linear-gradient(145deg, #0a0a0a, #1a1a1a)',
border: '1px solid #ff7f27',
borderRadius: 24,
padding: 30,
width: '80%',
maxWidth: 700,
maxHeight: '80vh',
overflowY: 'auto',
color: '#fff',
boxShadow: '0 8px 40px rgba(255,127,39,.25)',
animation: 'fadeIn 0.3s forwards',
};
// Стили для капчи
const captchaContainer: CSSProperties = {
marginBottom: 20,
width: '45%',
border: '1px solid rgba(255,127,39,0.3)',
borderRadius: 12,
padding: 15,
background: 'rgba(255,127,39,0.05)',
marginLeft: 'auto', // Добавлено для центрирования
marginRight: 'auto', // Добавлено для центрирования
};
const captchaCanvas: CSSProperties = {
width: '100%',
height: 80,
marginBottom: 10,
borderRadius: 8,
background: 'rgba(0,0,0,0.3)',
border: '1px solid rgba(255,127,39,0.3)',
};
const styles = `
@keyframes regCardAppear {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulseGlow {
0% {
box-shadow: 0 0 5px rgba(255,127,39,0.3);
}
50% {
box-shadow: 0 0 20px rgba(255,127,39,0.5);
}
100% {
box-shadow: 0 0 5px rgba(255,127,39,0.3);
}
}
/* Стилизация скроллбара */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.3);
border-radius: 10px;
margin: 5px;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(145deg, #ff7f27, #e96c00);
border-radius: 10px;
border: 2px solid rgba(0, 0, 0, 0.3);
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(145deg, #ff9540, #ff7f27);
}
::-webkit-scrollbar-corner {
background: transparent;
}
input:focus {
border-color: #ff7f27;
box-shadow: 0 0 0 2px rgba(255,127,39,0.2), inset 0 1px 3px rgba(0,0,0,0.2);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
`;
interface Props {
onSuccess: (u: { login: string; avatar: string }) => void;
onClose: () => void;
onLoginClick: () => void;
}
export default function RegisterModal({ onSuccess, onClose, onLoginClick }: Props) {
const [login, setLogin] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [email, setEmail] = useState('');
const [lastName, setLastName] = useState('');
const [firstName, setFirstName] = useState('');
const [middleName, setMiddleName] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [showOffer, setShowOffer] = useState(false);
const [offerAccepted, setOfferAccepted] = useState(false);
const [offerRead, setOfferRead] = useState(false);
// Капча
const [captchaText, setCaptchaText] = useState('');
const [userCaptcha, setUserCaptcha] = useState('');
const [captchaValidated, setCaptchaValidated] = useState(false);
// Генерация капчи при загрузке компонента
useEffect(() => {
generateCaptcha();
}, []);
// Функция генерации капчи
const generateCaptcha = () => {
const canvas = document.getElementById('captchaCanvas') as HTMLCanvasElement;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Установка фона
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Генерация случайного текста
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
let captchaString = '';
for (let i = 0; i < 6; i++) {
captchaString += chars.charAt(Math.floor(Math.random() * chars.length));
}
setCaptchaText(captchaString);
// Отрисовка текста
ctx.font = 'bold 28px Arial';
ctx.fillStyle = '#ff7f27';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Добавляем искажения для защиты
for (let i = 0; i < captchaString.length; i++) {
const x = (canvas.width / (captchaString.length + 1)) * (i + 1);
const y = canvas.height / 2 + Math.random() * 10 - 5;
const angle = Math.random() * 0.4 - 0.2;
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
ctx.fillText(captchaString[i], 0, 0);
ctx.restore();
}
// Добавляем шум
for (let i = 0; i < 100; i++) {
ctx.fillStyle = `rgba(255,127,39,${Math.random() * 0.3})`;
ctx.fillRect(Math.random() * canvas.width, Math.random() * canvas.height, 2, 2);
}
// Добавляем линии
for (let i = 0; i < 4; i++) {
ctx.beginPath();
ctx.moveTo(Math.random() * canvas.width, Math.random() * canvas.height);
ctx.lineTo(Math.random() * canvas.width, Math.random() * canvas.height);
ctx.strokeStyle = `rgba(255,127,39,${Math.random() * 0.5})`;
ctx.lineWidth = 1;
ctx.stroke();
}
// Сбрасываем ввод пользователя
setUserCaptcha('');
setCaptchaValidated(false);
};
// Валидация капчи
const validateCaptcha = () => {
if (userCaptcha.toLowerCase() === captchaText.toLowerCase()) {
setCaptchaValidated(true);
return true;
} else {
setCaptchaValidated(false);
setError('Неверный код с картинки. Попробуйте еще раз.');
generateCaptcha();
return false;
}
};
// Проверка валидности формы
const isFormValid = () => {
return (
login &&
password &&
confirmPassword === password &&
email &&
lastName &&
firstName &&
offerAccepted &&
captchaValidated
);
};
// Обработчик публичной оферты
const handleOfferScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollHeight, scrollTop, clientHeight } = e.currentTarget;
// Если прочитали до конца (или почти до конца)
if (scrollHeight - scrollTop - clientHeight < 50) {
setOfferRead(true);
}
};
// Обработчик регистрации
const handleRegister = useCallback(async () => {
if (!isFormValid()) {
// Проверка индивидуальных ошибок
if (password !== confirmPassword) {
setError('Пароли не совпадают');
return;
}
if (!offerAccepted) {
setError('Необходимо принять условия публичной оферты');
return;
}
if (!captchaValidated && !validateCaptcha()) {
// validateCaptcha уже установит сообщение об ошибке
return;
}
return;
}
setLoading(true);
setError(null);
try {
const res = await fetch('http://localhost:8000/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
login,
password,
email,
last_name: lastName,
first_name: firstName,
middle_name: middleName || null
}),
credentials: 'include',
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || 'Ошибка при регистрации');
}
// После успешной регистрации, делаем автоматический вход
try {
const loginRes = await fetch('http://localhost:8000/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login, password }),
credentials: 'include',
});
if (loginRes.ok) {
const loginData = await loginRes.json();
console.log("Ответ после авто-логина:", loginData);
// Проверка, сохранился ли JWT в куки
setTimeout(() => {
const jwtCookie = document.cookie
.split('; ')
.find(c => c.startsWith('JWT_token='))
?.split('=')[1];
console.log("JWT токен в куки после регистрации:",
jwtCookie ? `${jwtCookie.substring(0, 10)}...` : "отсутствует");
// Если JWT нет в куках, но он есть в ответе, сохраним его вручную
if (!jwtCookie && loginData.token) {
console.log("Сохраняем JWT вручную после регистрации");
document.cookie = `JWT_token=${loginData.token}; path=/; max-age=${60*60*24*7}`;
}
const userLogin = readCookie('login') || login;
const userAvatar = readCookie('avatar') || '/default-avatar.png';
onSuccess({
login: userLogin,
avatar: userAvatar
});
onClose();
}, 300);
} else {
// Если логин не удался, просто показываем сообщение об успешной регистрации
alert('Регистрация успешна! Теперь вы можете войти в систему.');
onClose();
}
} catch (e) {
console.error('Ошибка автоматического входа:', e);
alert('Регистрация успешна! Теперь вы можете войти в систему.');
onClose();
}
} catch (e: unknown) {
if (e instanceof Error) {
setError(e.message);
} else {
setError('Произошла неизвестная ошибка');
}
} finally {
setLoading(false);
}
}, [login, password, confirmPassword, email, lastName, firstName,
middleName, offerAccepted, captchaValidated, onSuccess, onClose]);
// Обработчик нажатия Enter
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && isFormValid()) {
e.preventDefault();
handleRegister();
}
};
// Обработчик клика на фоне
const handleWrapperClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
return (
<div style={wrapper} onClick={handleWrapperClick}>
<style>{styles}</style>
<div style={modal}>
<button
onClick={onClose}
style={closeBtn}
aria-label="Закрыть"
onMouseOver={(e) => {
e.currentTarget.style.background = 'rgba(255,127,39,0.2)';
e.currentTarget.style.transform = 'scale(1.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.background = 'rgba(0,0,0,0.4)';
e.currentTarget.style.transform = 'scale(1)';
}}
>
</button>
<h2 style={{
textAlign: 'center',
fontSize: 28,
marginBottom: 24,
fontWeight: 'bold',
textShadow: '0 2px 10px rgba(255,127,39,0.3)',
background: 'linear-gradient(to right, #ff7f27, #ff5500)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}>
Регистрация
</h2>
{error && (
<div style={{
color: '#ff3333',
marginBottom: 20,
padding: '8px 12px',
borderRadius: 8,
background: 'rgba(255,51,51,0.1)',
border: '1px solid rgba(255,51,51,0.3)',
animation: 'fadeIn 0.3s forwards',
}}>
{error}
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 20px' }}>
<div>
<label style={label}>Логин*</label>
<input
type="text"
placeholder="Введите логин"
style={input}
value={login}
onChange={(e) => setLogin(e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
<div>
<label style={label}>Email*</label>
<input
type="email"
placeholder="Введите email"
style={input}
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 20px' }}>
<div>
<label style={label}>Пароль*</label>
<input
type="password"
placeholder="Введите пароль"
style={input}
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
<div>
<label style={label}>Подтверждение пароля*</label>
<input
type="password"
placeholder="Повторите пароль"
style={input}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 20px' }}>
<div>
<label style={label}>Фамилия*</label>
<input
type="text"
placeholder="Введите фамилию"
style={input}
value={lastName}
onChange={(e) => setLastName(e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
<div>
<label style={label}>Имя*</label>
<input
type="text"
placeholder="Введите имя"
style={input}
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
</div>
<label style={label}>Отчество (необязательно)</label>
<input
type="text"
placeholder="Введите отчество"
style={input}
value={middleName}
onChange={(e) => setMiddleName(e.target.value)}
onKeyDown={handleKeyDown}
/>
{/* Капча */}
<div style={captchaContainer}>
<canvas
id="captchaCanvas"
width="300"
height="80"
style={captchaCanvas}
/>
<div style={{ display: 'flex', gap: 10 }}>
<input
type="text"
placeholder="Введите текст с картинки"
style={{...input, margin: 0, flex: 1}}
value={userCaptcha}
onChange={(e) => setUserCaptcha(e.target.value)}
/>
<button
style={{
background: 'transparent',
border: '1px solid #ff7f27',
borderRadius: 8,
color: '#ff7f27',
cursor: 'pointer',
padding: '0 15px'
}}
onClick={generateCaptcha}
type="button"
>
</button>
<button
style={{
background: 'rgba(255,127,39,0.1)',
border: '1px solid #ff7f27',
borderRadius: 8,
color: '#ff7f27',
cursor: 'pointer',
padding: '0 15px'
}}
onClick={validateCaptcha}
type="button"
>
Проверить
</button>
</div>
{captchaValidated && (
<div style={{
color: '#4caf50',
marginTop: 10,
fontSize: 14,
display: 'flex',
alignItems: 'center',
gap: 5
}}>
<span style={{
fontSize: 16,
fontWeight: 'bold'
}}></span>
Проверка пройдена
</div>
)}
</div>
{/* Публичная оферта */}
<div style={{
margin: '20px 0',
display: 'flex',
alignItems: 'center',
}}>
<input
type="checkbox"
id="offerAccept"
checked={offerAccepted}
onChange={(e) => setOfferAccepted(e.target.checked)}
disabled={!offerRead}
style={{ marginRight: 10 }}
/>
<label htmlFor="offerAccept" style={{
fontSize: 14,
color: 'rgba(255,255,255,0.9)',
}}>
Я принимаю условия
<button
type="button"
style={offerBtn}
onClick={() => setShowOffer(true)}
>
публичной оферты
</button>
</label>
</div>
{!offerRead && (
<div style={{
color: '#ff7f27',
fontSize: 12,
marginBottom: 10
}}>
* Для принятия оферты необходимо ознакомиться с её содержанием
</div>
)}
<HoverButton
label={loading ? "Регистрация..." : "Зарегистрироваться"}
filled
onClick={handleRegister}
disabled={loading || !isFormValid()}
/>
<div style={{
margin: '24px 0 8px',
textAlign: 'center',
fontSize: 14,
color: 'rgba(255,255,255,0.6)',
position: 'relative',
}}>
<span style={{
background: 'linear-gradient(145deg, #0a0a0a, #1a1a1a)',
padding: '0 10px',
position: 'relative',
zIndex: 1,
}}>Уже есть аккаунт?</span>
</div>
<HoverButton
label="Войти"
onClick={() => {
onClose();
onLoginClick();
}}
/>
{/* Модальное окно с публичной офертой */}
{showOffer && (
<div style={offerModal} onClick={() => setShowOffer(false)}>
<div
style={offerContent}
onClick={(e) => e.stopPropagation()}
>
<h3 style={{
fontSize: 24,
marginBottom: 20,
textAlign: 'center',
color: '#ff7f27'
}}>
Публичная оферта
</h3>
<div
style={{
height: 400,
overflowY: 'auto',
padding: '0 10px',
marginBottom: 20,
fontSize: 14,
lineHeight: 1.6,
color: 'rgba(255,255,255,0.9)',
border: '1px solid rgba(255,127,39,0.3)',
borderRadius: 8,
background: 'rgba(0,0,0,0.2)',
}}
onScroll={handleOfferScroll}
>
<h4>1. ОБЩИЕ ПОЛОЖЕНИЯ</h4>
<p>1.1. Настоящая публичная оферта (далее Оферта) представляет собой официальное предложение , Билетопад далее именуемого «Исполнитель», адресованное любому дееспособному физическому лицу, далее именуемому «Пользователь».</p>
<p>1.2. В соответствии с пунктом 2 статьи 437 Гражданского Кодекса Российской Федерации данный документ является публичной офертой.</p>
<p>1.3. Акцепт Оферты осуществляется путем регистрации на сайте Исполнителя. При регистрации Пользователь обязан ознакомиться с условиями настоящей Оферты.</p>
<h4>2. ПРЕДМЕТ ОФЕРТЫ</h4>
<p>2.1. Предметом Оферты является предоставление Исполнителем Пользователю доступа к использованию сервиса Билетопад (далее Сервис).</p>
<p>2.2. Исполнитель предоставляет доступ к Сервису на условиях, предусмотренных настоящей Офертой.</p>
<h4>3. ПРАВА И ОБЯЗАННОСТИ СТОРОН</h4>
<p>3.1. Пользователь обязуется:</p>
<p>3.1.1. Предоставить при регистрации достоверную информацию.</p>
<p>3.1.2. Не передавать свои учетные данные третьим лицам.</p>
<p>3.1.3. Не использовать Сервис для распространения информации, которая является незаконной, вредоносной, оскорбительной.</p>
<p>3.1.4. Соблюдать авторские и смежные права Исполнителя и третьих лиц.</p>
<h4>4. ОТВЕТСТВЕННОСТЬ СТОРОН</h4>
<p>4.1. За неисполнение или ненадлежащее исполнение обязательств по настоящей Оферте Стороны несут ответственность в соответствии с законодательством Российской Федерации.</p>
<p>4.2. Исполнитель не несет ответственности за сбои в работе Сервиса, вызванные техническими причинами.</p>
<h4>5. СРОК ДЕЙСТВИЯ ОФЕРТЫ</h4>
<p>5.1. Оферта вступает в силу с момента размещения на сайте Исполнителя и действует до момента ее отзыва Исполнителем.</p>
<p>5.2. Исполнитель оставляет за собой право внести изменения в условия Оферты или отозвать Оферту в любой момент по своему усмотрению.</p>
<h4>6. ДОПОЛНИТЕЛЬНЫЕ УСЛОВИЯ</h4>
<p>6.1. Все споры и разногласия, которые могут возникнуть между Сторонами по вопросам, не нашедшим своего разрешения в тексте данной Оферты, будут разрешаться путем переговоров.</p>
<p>6.2. Во всем остальном, что не предусмотрено настоящей Офертой, Стороны руководствуются действующим законодательством Российской Федерации.</p>
<h4>7. ЗАКЛЮЧИТЕЛЬНЫЕ ПОЛОЖЕНИЯ</h4>
<p>7.1. Регистрируясь на сайте, Пользователь подтверждает, что ознакомлен с условиями настоящей Оферты и полностью с ними согласен.</p>
<p>Дата последнего обновления: 18 мая 2025 г.</p>
{/* Дополнительный контент для обеспечения возможности прокрутки */}
<div style={{ height: 200 }}></div>
</div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
{!offerRead ? (
<div style={{
color: '#ff7f27',
fontSize: 14
}}>
* Пожалуйста, прочитайте публичную оферту до конца
</div>
) : (
<div style={{
color: '#4caf50',
fontSize: 14,
display: 'flex',
alignItems: 'center',
gap: 5
}}>
<span style={{
fontSize: 16,
fontWeight: 'bold'
}}></span>
Вы ознакомились с офертой
</div>
)}
<div style={{display: 'flex', gap: '10px'}}>
{offerRead && (
<button
style={{
padding: '10px 20px',
background: 'linear-gradient(145deg, #ff7f27, #ff5500)',
color: '#000',
border: 'none',
borderRadius: 8,
cursor: 'pointer',
fontWeight: 'bold',
boxShadow: '0 2px 10px rgba(255,127,39,0.3)',
transition: 'all 0.2s',
}}
onClick={() => {
setOfferAccepted(true);
setShowOffer(false);
}}
onMouseOver={(e) => {
e.currentTarget.style.boxShadow = '0 4px 15px rgba(255,127,39,0.5)';
e.currentTarget.style.transform = 'translateY(-1px)';
}}
onMouseOut={(e) => {
e.currentTarget.style.boxShadow = '0 2px 10px rgba(255,127,39,0.3)';
e.currentTarget.style.transform = 'translateY(0)';
}}
>
Принять
</button>
)}
<button
style={{
padding: '10px 20px',
background: offerRead ? 'rgba(0,0,0,0.4)' : 'rgba(255,127,39,0.3)',
color: offerRead ? 'rgba(255,255,255,0.9)' : 'rgba(255,255,255,0.7)',
border: offerRead ? '1px solid rgba(255,255,255,0.2)' : 'none',
borderRadius: 8,
cursor: 'pointer',
fontWeight: 'bold',
transition: 'all 0.2s',
}}
onClick={() => setShowOffer(false)}
onMouseOver={(e) => {
if (offerRead) {
e.currentTarget.style.background = 'rgba(0,0,0,0.5)';
}
}}
onMouseOut={(e) => {
if (offerRead) {
e.currentTarget.style.background = 'rgba(0,0,0,0.4)';
}
}}
>
{offerRead ? 'Отмена' : 'Закрыть'}
</button>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}
type BtnProps = { label: string; filled?: boolean; onClick?: () => void; disabled?: boolean };
function HoverButton({ label, filled = false, onClick, disabled = false }: BtnProps) {
const [state, set] = useState<'idle' | 'hover' | 'active'>('idle');
const base: CSSProperties = {
width: '100%',
padding: 14,
borderRadius: 12,
fontWeight: 'bold',
fontSize: 16,
cursor: disabled ? 'default' : 'pointer',
transition: 'all .3s',
marginTop: 8,
};
const filledBg = state === 'active'
? '#cc5e00'
: state === 'hover'
? 'linear-gradient(145deg, #ff7f27, #e96c00)'
: 'linear-gradient(145deg, #ff7f27, #ff5500)';
const style: CSSProperties = filled
? {
...base,
background: filledBg,
color: '#000',
border: 0,
boxShadow: state === 'hover' ? '0 4px 15px rgba(255,127,39,0.4)' : '0 2px 10px rgba(255,127,39,0.3)',
transform: state === 'hover' && !disabled ? 'translateY(-2px)' : 'translateY(0)',
}
: {
...base,
background:
state === 'active'
? 'rgba(255,127,39,.2)'
: state === 'hover'
? 'rgba(255,127,39,.1)'
: 'transparent',
border: '1px solid #ff7f27',
color: '#fff',
transform: state === 'hover' && !disabled ? 'translateY(-1px)' : 'translateY(0)',
};
return (
<button
disabled={disabled}
style={{ ...style, opacity: disabled ? 0.7 : 1 }}
onMouseEnter={() => !disabled && set('hover')}
onMouseLeave={() => !disabled && set('idle')}
onMouseDown={() => !disabled && set('active')}
onMouseUp={() => !disabled && set('hover')}
onClick={disabled ? undefined : onClick}
>
{label}
</button>
);
}

View File

@ -0,0 +1,781 @@
'use client';
import { useState, useEffect, CSSProperties, ChangeEvent } from 'react';
/* Стили с анимациями и эффектами */
const wrapper: CSSProperties = {
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,.7)',
backdropFilter: 'blur(12px)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
zIndex: 1000,
transition: 'all 0.3s ease',
};
const modal: CSSProperties = {
position: 'relative',
background: 'linear-gradient(145deg, #0a0a0a, #1a1a1a)',
border: '2px solid #ff7f27',
borderRadius: 24,
padding: 36,
width: '100%',
maxWidth: 760,
color: '#ff7f27',
boxShadow: '0 8px 40px rgba(255,127,39,.25), 0 0 0 1px rgba(255,127,39,0.1)',
transformOrigin: 'center',
transition: 'transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
animation: 'settingsCardAppear 0.4s forwards',
};
const closeBtn: CSSProperties = {
position: 'absolute',
top: 16,
right: 16,
background: 'rgba(0,0,0,0.4)',
border: '1px solid rgba(255,127,39,0.3)',
fontSize: 20,
color: '#ff7f27',
cursor: 'pointer',
width: 36,
height: 36,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '50%',
transition: 'all 0.2s',
};
const lblStyle: CSSProperties = {
fontSize: 14,
marginTop: 10,
marginBottom: 6,
fontWeight: 'bold',
color: 'rgba(255,255,255,0.9)',
letterSpacing: '0.5px',
};
const input: CSSProperties = {
width: '100%',
padding: '12px 16px',
border: '1px solid rgba(255,127,39,0.6)',
borderRadius: 12,
background: 'rgba(0,0,0,0.2)',
color: '#fff',
fontSize: 14,
outline: 'none',
transition: 'all 0.2s',
boxShadow: 'inset 0 1px 3px rgba(0,0,0,0.2)',
};
const sectionTitle: CSSProperties = {
fontSize: 17,
marginTop: 24,
marginBottom: 12,
fontWeight: 'bold',
borderBottom: '1px solid rgba(255,127,39,.3)',
paddingBottom: 6,
background: 'linear-gradient(to right, #ff7f27, #ff5500)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
textShadow: '0 2px 10px rgba(255,127,39,0.2)',
};
// Сообщение об успехе
const successMsg: CSSProperties = {
color: '#4caf50',
fontSize: 12,
marginTop: 6,
opacity: 1,
transition: 'opacity 0.3s, transform 0.3s',
animation: 'fadeIn 0.3s forwards',
background: 'rgba(76,175,80,0.1)',
padding: '4px 8px',
borderRadius: 4,
boxShadow: '0 0 10px rgba(76,175,80,0.2)',
};
// Разделитель
const divider: CSSProperties = {
height: 2,
background: 'linear-gradient(to right, transparent, rgba(255,127,39,0.5), transparent)',
margin: '24px 0',
borderRadius: 2,
};
// Исправленный стиль контейнера аватарки
const avatarContainer: CSSProperties = {
position: 'relative',
width: 120,
height: 120,
margin: '0 auto',
borderRadius: '50%',
background: 'linear-gradient(145deg, #ff7f27, #f9560b)',
boxShadow: '0 5px 15px rgba(255, 127, 39, 0.3)',
animation: 'pulseGlow 3s infinite',
cursor: 'pointer',
overflow: 'hidden', // Важно: предотвращает выход изображения за пределы круга
};
const styles = `
@keyframes settingsCardAppear {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes pulseGlow {
0% {
box-shadow: 0 0 5px rgba(255,127,39,0.3);
}
50% {
box-shadow: 0 0 20px rgba(255,127,39,0.5);
}
100% {
box-shadow: 0 0 5px rgba(255,127,39,0.3);
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
input:focus {
border-color: #ff7f27;
box-shadow: 0 0 0 2px rgba(255,127,39,0.2), inset 0 1px 3px rgba(0,0,0,0.2);
}
`;
type User = { login: string; full_name: string; avatar: string; email: string };
export default function SettingsModal({
user,
onClose,
onUpdate,
}: {
user: User;
onClose: () => void;
onUpdate: (p: Partial<User>) => void;
}) {
// Состояние для полей формы
const [avatar, setAvatar] = useState(user.avatar);
const [login, setLogin] = useState(user.login);
const [email, setEmail] = useState(user.email || '');
const [surname, setSurname] = useState('');
const [name, setName] = useState('');
const [patronymic, setPatr] = useState('');
// Пароль
const [oldPass, setOldPass] = useState('');
const [newPass, setNewPass] = useState('');
// Состояние для API данных
const [loadingData, setLoadingData] = useState(true);
// Состояния для UI
const [busy, setBusy] = useState<Record<string, boolean>>({});
const [success, setSuccess] = useState<Record<string, boolean>>({});
// Авторизация
const token = document.cookie.match(/JWT_token=([^;]+)/)?.[1] || '';
const auth = { Authorization: `Bearer ${token}` };
// Получение ID пользователя из cookie
const userId = document.cookie
.split('; ')
.find(row => row.startsWith('id='))
?.split('=')[1];
// Загрузка данных пользователя при инициализации формы
useEffect(() => {
if (userId) {
fetchUserData(userId);
} else {
setLoadingData(false);
}
}, [userId]);
// Функция загрузки данных пользователя
const fetchUserData = async (id: string) => {
try {
const response = await fetch(`http://localhost:8000/user/${id}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
console.log("Получены данные пользователя:", data);
// setUserData(data); // Removed as setUserData is not defined
// Заполняем форму данными из API
setLogin(data.login);
setEmail(data.email);
setSurname(data.last_name || '');
setName(data.first_name || '');
setPatr(data.middle_name || '');
if (data.avatar) setAvatar(data.avatar);
} else {
console.error("Ошибка при получении данных пользователя");
}
} catch (error) {
console.error("Ошибка запроса:", error);
} finally {
setLoadingData(false);
}
};
// Эффект для отображения сообщения об успехе
useEffect(() => {
const timers: NodeJS.Timeout[] = [];
Object.keys(success).forEach(key => {
if (success[key]) {
const timer = setTimeout(() => {
setSuccess(prev => ({ ...prev, [key]: false }));
}, 3000);
timers.push(timer);
}
});
return () => timers.forEach(timer => clearTimeout(timer));
}, [success]);
// Функция для отправки запросов на сервер
const sendRequest = async (
url: string,
body: Record<string, unknown>,
key: string,
updateData?: Partial<User>
) => {
setBusy(prev => ({ ...prev, [key]: true }));
try {
const response = await fetch(`http://localhost:8000/${url}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...auth
},
body: JSON.stringify(body),
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
console.log("Успешный ответ от сервера:", data);
if (updateData) {
onUpdate(updateData);
}
setSuccess(prev => ({ ...prev, [key]: true }));
window.dispatchEvent(
new CustomEvent('user-update', {
detail: updateData || {}
})
);
if (key === 'firstName' || key === 'lastName' || key === 'patronymic') {
const fullName = `${surname} ${name} ${patronymic}`.trim();
document.cookie = `full_name=${encodeURIComponent(fullName)}; path=/`;
}
setTimeout(() => {
console.log("Текущие куки:", document.cookie);
}, 100);
return true;
} else {
const errorText = await response.text();
console.error(`Ошибка запроса ${url}:`, errorText);
return false;
}
} catch (error) {
console.error(`Ошибка запроса ${url}:`, error);
return false;
} finally {
setBusy(prev => ({ ...prev, [key]: false }));
}
};
// Функции для обновления отдельных полей
const updateLogin = () => sendRequest(
'change_username',
{ new_login: login },
'login',
{ login }
);
const updateEmail = () => sendRequest(
'change_email',
{ new_email: email },
'email',
{ email }
);
const updateLastName = () => sendRequest(
'change_last_name',
{ new_last_name: surname },
'lastName',
{ full_name: `${surname} ${name} ${patronymic}`.trim() }
);
const updateFirstName = () => sendRequest(
'change_name',
{ new_first_name: name },
'firstName',
{ full_name: `${surname} ${name} ${patronymic}`.trim() }
);
const updateMiddleName = () => sendRequest(
'change_middle_name',
{ new_middle_name: patronymic },
'patronymic',
{ full_name: `${surname} ${name} ${patronymic}`.trim() }
);
const updatePassword = () => {
if (!oldPass || !newPass) return;
sendRequest(
'change_password',
{ old_password: oldPass, new_password: newPass },
'password'
).then(success => {
if (success) {
setOldPass('');
setNewPass('');
}
});
};
// Обновление аватарки
const handleAvatarChange = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setBusy(prev => ({ ...prev, avatar: true }));
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('http://localhost:8000/upload_avatar', {
method: 'POST',
headers: auth,
body: formData,
credentials: 'include',
});
if (response.ok) {
const { file_url } = await response.json();
setAvatar(file_url);
onUpdate({ avatar: file_url });
setSuccess(prev => ({ ...prev, avatar: true }));
window.dispatchEvent(
new CustomEvent('user-update', {
detail: { avatar: file_url }
})
);
}
} catch (error) {
console.error('Ошибка при обновлении аватара:', error);
} finally {
setBusy(prev => ({ ...prev, avatar: false }));
}
};
// Обработчик закрытия модального окна
const handleClose = (e?: React.MouseEvent) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
onClose();
};
// Обработка клика вне модального окна
const handleWrapperClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
handleClose();
}
};
return (
<div className="settings-modal-wrapper" style={wrapper} onClick={handleWrapperClick}>
<style>{styles}</style>
<div className="settings-modal-content" style={modal}>
{/* Заголовок и кнопка закрытия */}
<button
className="close-button"
type="button"
onClick={handleClose}
style={closeBtn}
aria-label="Закрыть"
onMouseOver={(e) => {
e.currentTarget.style.background = 'rgba(255,127,39,0.2)';
e.currentTarget.style.transform = 'scale(1.1)';
}}
onMouseOut={(e) => {
e.currentTarget.style.background = 'rgba(0,0,0,0.4)';
e.currentTarget.style.transform = 'scale(1)';
}}
>
</button>
<h3 style={{
textAlign: 'center',
fontSize: 28,
marginBottom: 24,
fontWeight: 'bold',
textShadow: '0 2px 10px rgba(255,127,39,0.3)',
background: 'linear-gradient(to right, #ff7f27, #ff5500)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}>
Настройки профиля
</h3>
{/* Разделяем на две колонки для оптимизации пространства */}
<div style={{ display: 'flex', gap: 30 }}>
{/* Левая колонка - Аватарка и логин/email */}
<div style={{ flex: 1 }}>
{/* Аватарка */}
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginBottom: 24
}}>
<div
style={avatarContainer}
onClick={() => document.getElementById('avatar-upload')?.click()}
>
<img
src={avatar}
alt="Аватар пользователя"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
transition: 'transform 0.3s',
filter: busy.avatar ? 'brightness(0.7)' : 'none',
}}
onMouseOver={e => { e.currentTarget.style.transform = 'scale(1.05)' }}
onMouseOut={e => { e.currentTarget.style.transform = 'scale(1)' }}
/>
{busy.avatar && (
<div style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0,0,0,0.5)',
color: 'white',
fontSize: 14,
borderRadius: '50%',
}}>
Загрузка...
</div>
)}
<input
id="avatar-upload"
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleAvatarChange}
disabled={busy.avatar}
/>
</div>
{success.avatar && (
<div style={{...successMsg, textAlign: 'center', marginTop: 12}}>
Аватар успешно обновлен
</div>
)}
<p style={{
fontSize: 13,
marginTop: 12,
color: 'rgba(255,255,255,0.7)'
}}>
Нажмите на изображение, чтобы изменить аватар
</p>
</div>
{/* Учетные данные */}
<h4 style={sectionTitle}>Учетные данные</h4>
<div style={{ marginBottom: 20 }}>
<label style={lblStyle}>Логин</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<input
value={login}
onChange={(e) => setLogin(e.target.value)}
style={{ ...input, flex: 1 }}
/>
<HoverButton
label="Сохранить"
filled
onClick={updateLogin}
disabled={busy.login}
small
/>
</div>
{success.login && (
<div style={successMsg}>Логин успешно изменен</div>
)}
</div>
<div style={{ marginBottom: 16 }}>
<label style={lblStyle}>Почта</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
style={{ ...input, flex: 1 }}
/>
<HoverButton
label="Сохранить"
filled
onClick={updateEmail}
disabled={busy.email}
small
/>
</div>
{success.email && (
<div style={successMsg}>Email успешно изменен</div>
)}
</div>
</div>
{/* Правая колонка - Персональные данные и смена пароля */}
<div style={{ flex: 1 }}>
{/* Персональные данные */}
<h4 style={sectionTitle}>Персональные данные</h4>
<div style={{ marginBottom: 16 }}>
<label style={lblStyle}>Фамилия</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<input
value={surname}
onChange={(e) => setSurname(e.target.value)}
style={{ ...input, flex: 1 }}
/>
<HoverButton
label="Сохранить"
filled
onClick={updateLastName}
disabled={busy.lastName}
small
/>
</div>
{success.lastName && (
<div style={successMsg}>Фамилия успешно изменена</div>
)}
</div>
<div style={{ marginBottom: 16 }}>
<label style={lblStyle}>Имя</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
style={{ ...input, flex: 1 }}
/>
<HoverButton
label="Сохранить"
filled
onClick={updateFirstName}
disabled={busy.firstName}
small
/>
</div>
{success.firstName && (
<div style={successMsg}>Имя успешно изменено</div>
)}
</div>
<div style={{ marginBottom: 16 }}>
<label style={lblStyle}>Отчество</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<input
value={patronymic}
onChange={(e) => setPatr(e.target.value)}
style={{ ...input, flex: 1 }}
/>
<HoverButton
label="Сохранить"
filled
onClick={updateMiddleName}
disabled={busy.patronymic}
small
/>
</div>
{success.patronymic && (
<div style={successMsg}>Отчество успешно изменено</div>
)}
</div>
<div style={divider}></div>
{/* Смена пароля */}
<h4 style={sectionTitle}>Смена пароля</h4>
<div style={{ marginBottom: 16 }}>
<label style={lblStyle}>Текущий пароль</label>
<input
type="password"
placeholder="Введите текущий пароль"
value={oldPass}
onChange={(e) => setOldPass(e.target.value)}
style={input}
/>
</div>
<div style={{ marginBottom: 20 }}>
<label style={lblStyle}>Новый пароль</label>
<input
type="password"
placeholder="Введите новый пароль"
value={newPass}
onChange={(e) => setNewPass(e.target.value)}
style={input}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<HoverButton
label="Сменить пароль"
filled
onClick={updatePassword}
disabled={busy.password || !oldPass || !newPass}
/>
</div>
{success.password && (
<div style={{...successMsg, textAlign: 'right', marginTop: 8}}>
Пароль успешно изменен
</div>
)}
</div>
</div>
{/* Показываем loader при загрузке данных */}
{loadingData && (
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.7)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
borderRadius: 24,
zIndex: 5
}}>
<div style={{
color: '#ff7f27',
fontSize: 18,
textAlign: 'center'
}}>
Загрузка данных...
</div>
</div>
)}
</div>
</div>
);
}
/* Вспомогательные компоненты */
function HoverButton({
label,
filled = false,
onClick,
disabled = false,
small = false,
}: {
label: string;
filled?: boolean;
onClick?: () => void;
disabled?: boolean;
small?: boolean;
}) {
const [state, setState] = useState<'idle' | 'hover' | 'active'>('idle');
const base: CSSProperties = {
padding: small ? '8px 12px' : '10px 18px',
borderRadius: 12,
fontWeight: 'bold',
fontSize: small ? 12 : 14,
cursor: disabled ? 'default' : 'pointer',
transition: 'all .3s',
border: '1px solid #ff7f27',
whiteSpace: 'nowrap',
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
};
const filledBg =
state === 'active' ? '#cc5e00' : state === 'hover' ? '#e96c00' : 'linear-gradient(145deg, #ff7f27, #ff5500)';
const style: CSSProperties = filled
? {
...base,
background: filledBg,
color: '#000',
border: 0,
boxShadow: state === 'hover' ? '0 4px 15px rgba(255,127,39,0.4)' : '0 2px 10px rgba(255,127,39,0.3)',
transform: state === 'hover' ? 'translateY(-2px)' : 'translateY(0)',
}
: {
...base,
background:
state === 'active'
? 'rgba(255,127,39,.2)'
: state === 'hover'
? 'rgba(255,127,39,.1)'
: 'transparent',
color: '#fff',
transform: state === 'hover' ? 'translateY(-1px)' : 'translateY(0)',
};
return (
<button
disabled={disabled}
style={{ ...style, opacity: disabled ? 0.5 : 1 }}
onMouseEnter={() => setState('hover')}
onMouseLeave={() => setState('idle')}
onMouseDown={() => setState('active')}
onMouseUp={() => setState('hover')}
onClick={disabled ? undefined : onClick}
>
{label}
</button>
);
}

29
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}