tets
7
COMIT.bat
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
@echo off
|
||||||
|
set /p msg=Commit Message:
|
||||||
|
git add .
|
||||||
|
git commit -m "%msg%"
|
||||||
|
git pull --rebase
|
||||||
|
git push
|
||||||
|
pause
|
BIN
backend/__pycache__/main.cpython-310.pyc
Normal file
BIN
backend/api/v0/__pycache__/feedback.cpython-310.pyc
Normal file
BIN
backend/api/v0/__pycache__/poster.cpython-310.pyc
Normal file
BIN
backend/api/v0/__pycache__/user.cpython-310.pyc
Normal file
107
backend/api/v0/feedback.py
Normal 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
@ -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
@ -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}
|
BIN
backend/core/__pycache__/crypt.cpython-310.pyc
Normal file
BIN
backend/core/__pycache__/db.cpython-310.pyc
Normal file
BIN
backend/core/__pycache__/file.cpython-310.pyc
Normal file
72
backend/core/crypt.py
Normal 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
@ -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
@ -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
30
backend/main.py
Normal 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
@ -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}")
|
BIN
backend/res/poster/1/zs2.png
Normal file
After Width: | Height: | Size: 3.2 MiB |
BIN
backend/res/poster/2/Cropped_desktop_wallpaper.png
Normal file
After Width: | Height: | Size: 1023 KiB |
BIN
backend/res/user/1/17428267109723.png
Normal file
After Width: | Height: | Size: 1.2 MiB |
BIN
backend/res/user/1/Cropped_desktop_wallpaper.png
Normal file
After Width: | Height: | Size: 1023 KiB |
BIN
backend/res/user/1/_fnB1AnXl_E.jpg
Normal file
After Width: | Height: | Size: 196 KiB |
After Width: | Height: | Size: 383 KiB |
BIN
backend/res/user/1/photo_2025-02-02_18-44-41.jpg
Normal file
After Width: | Height: | Size: 133 KiB |
BIN
backend/res/user/2/17428267109723.png
Normal file
After Width: | Height: | Size: 1.2 MiB |
BIN
backend/res/user/4/17428267109723.png
Normal file
After Width: | Height: | Size: 1.2 MiB |
41
frontend/.gitignore
vendored
Normal 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
@ -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.
|
16
frontend/eslint.config.mjs
Normal 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
@ -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
34
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
5
frontend/postcss.config.mjs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: ["@tailwindcss/postcss"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
1
frontend/public/icon/like.svg
Normal 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 |
13
frontend/public/icon/login.svg
Normal 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 |
12
frontend/public/icon/logo.svg
Normal 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 |
25
frontend/public/icon/ticket.svg
Normal 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 |
43
frontend/public/icon/user.svg
Normal 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 |
BIN
frontend/public/poster/zs2.png
Normal file
After Width: | Height: | Size: 3.2 MiB |
560
frontend/src/app/about/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
86
frontend/src/app/contactsback/MapComponent.tsx
Normal 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: '© <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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
485
frontend/src/app/contactsback/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
BIN
frontend/src/app/favicon.ico
Normal file
After Width: | Height: | Size: 25 KiB |
1106
frontend/src/app/feedback/page.tsx
Normal file
26
frontend/src/app/globals.css
Normal 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;
|
||||||
|
}
|
34
frontend/src/app/layout.tsx
Normal 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
@ -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>
|
||||||
|
);
|
||||||
|
}
|
23
frontend/src/components/Fotter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
508
frontend/src/components/Header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
494
frontend/src/components/RandomComment.tsx
Normal 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;
|
620
frontend/src/components/Slideposter.tsx
Normal 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
|
585
frontend/src/components/modal/Login.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
442
frontend/src/components/modal/Poster.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
507
frontend/src/components/modal/Profile.tsx
Normal 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}`);
|
||||||
|
}
|
||||||
|
|
964
frontend/src/components/modal/Reg.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
781
frontend/src/components/modal/Settings.tsx
Normal 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
@ -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"]
|
||||||
|
}
|
||||||
|
|