Merge pull request #4 from lorsanstand/dev

Dev
This commit is contained in:
Станислав
2026-01-09 20:42:34 +03:00
committed by GitHub
16 changed files with 2996 additions and 20 deletions
+324 -2
View File
@@ -1,2 +1,324 @@
# Aether <div align="center">
Проектная работа
# 🌌 Aether
<img src="assets/logo.png" alt="Aether logo" width="150" style="border-radius: 15px;">
**Современная full-stack платформа для чатов с мощным backend и элегантным frontend**
[![Python](https://img.shields.io/badge/Python-3.13%2B-3776AB?style=flat-square&logo=python&logoColor=white)](https://www.python.org/downloads/)
[![FastAPI](https://img.shields.io/badge/FastAPI-0.124%2B-009688?style=flat-square&logo=fastapi&logoColor=white)](https://fastapi.tiangolo.com/)
[![React](https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react&logoColor=black)](https://react.dev/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178C6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
[![TailwindCSS](https://img.shields.io/badge/Tailwind-3.4-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white)](https://tailwindcss.com/)
[![License](https://img.shields.io/badge/License-Apache--2.0-blue?style=flat-square)](LICENSE)
[Особенности](#-особенности) • [Технологии](#-технологии) • [Установка](#-быстрый-старт) • [Документация](#-документация) • [Разработка](#-разработка)
</div>
---
## ✨ Особенности
- 🚀 **Высокая производительность**: Асинхронный backend на FastAPI с оптимизированными запросами к БД
- 🔐 **Безопасность**: JWT аутентификация с cookie, защита от CSRF, хеширование паролей bcrypt
- 📧 **Email сервис**: Подтверждение email, восстановление пароля через Celery задачи
- 💾 **Кэширование**: Redis для быстрого доступа к данным и сессий
- 🗄️ **База данных**: PostgreSQL с миграциями через Alembic
- 📦 **Файловое хранилище**: Интеграция с S3-совместимыми хранилищами
- 🎨 **Современный UI**: React 19 с TypeScript, Framer Motion анимации, Tailwind CSS
- 🔄 **Управление состоянием**: Zustand для простого и эффективного state management
- 📱 **Адаптивный дизайн**: Полностью responsive интерфейс для всех устройств
- 🧪 **Тестирование**: Pytest для backend тестов
## 🛠️ Технологии
### Backend
```
FastAPI - Современный веб-фреймворк для API
SQLAlchemy - ORM для работы с PostgreSQL
Alembic - Миграции базы данных
Pydantic - Валидация данных и настроек
Celery - Асинхронные задачи (email рассылки)
Redis - Кэширование и брокер сообщений
Passlib + bcrypt - Безопасное хеширование паролей
Python-Jose - JWT токены
Aiobotocore - Асинхронная работа с S3
Pytest - Тестирование
```
### Frontend
```
React 19 - UI библиотека
TypeScript - Типизированный JavaScript
Vite - Сборщик проекта
React Router - Маршрутизация
Zustand - Управление состоянием
Axios - HTTP клиент
Framer Motion - Анимации
Tailwind CSS - Utility-first CSS фреймворк
Lucide React - Иконки
```
### Инфраструктура
```
Docker - Контейнеризация
PostgreSQL - База данных
Redis - Кэш и брокер сообщений
S3 - Объектное хранилище
```
## 🚀 Быстрый старт
### Требования
- Python 3.13+
- Node.js 18+
- PostgreSQL 14+
- Redis 7+
- Docker & Docker Compose (опционально)
### Установка
#### 1. Клонируйте репозиторий
```bash
git clone https://github.com/yourusername/Aether.git
cd Aether
```
#### 2. Backend
```bash
cd backend
# Установите зависимости (используя pip)
pip install -e .
# Или используя poetry
poetry install
# Создайте .env файл
cat > .env << EOF
DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/aether
REDIS_URL=redis://localhost:6379
SECRET_KEY=your-secret-key-here
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-app-password
S3_ENDPOINT=https://s3.amazonaws.com
S3_ACCESS_KEY=your-access-key
S3_SECRET_KEY=your-secret-key
S3_BUCKET_NAME=aether-bucket
EOF
# Запустите миграции
alembic upgrade head
# Запустите сервер
uvicorn app.main:app --reload
# В отдельном терминале запустите Celery worker
celery -A app.core.celery_app worker --loglevel=info
```
#### 3. Frontend
```bash
cd frontend
# Установите зависимости
npm install
# Создайте .env файл
echo "VITE_API_URL=http://localhost:8000" > .env
# Запустите dev сервер
npm run dev
```
### 🐳 Docker (рекомендуется)
```bash
# В корне проекта
cd backend
docker-compose -f docker-compose.dev.yml up -d
```
## 📖 Документация
### API Endpoints
После запуска backend, документация доступна по адресам:
- Swagger UI: `http://localhost:8000/docs`
- ReDoc: `http://localhost:8000/redoc`
### Основные маршруты
#### Аутентификация
```
POST /auth/register - Регистрация нового пользователя
POST /auth/login - Вход в систему
POST /auth/logout - Выход из системы
POST /auth/verify-email - Подтверждение email
POST /auth/request-reset - Запрос на сброс пароля
POST /auth/reset-password - Сброс пароля
```
#### Пользователи
```
GET /users/me - Получить текущего пользователя
PUT /users/me - Обновить профиль
DELETE /users/me - Удалить аккаунт
```
#### Чаты
```
GET /chats - Получить список чатов
POST /chats - Создать новый чат
GET /chats/{id} - Получить чат по ID
PUT /chats/{id} - Обновить чат
DELETE /chats/{id} - Удалить чат
```
## 🏗️ Структура проекта
```
Aether/
├── backend/
│ ├── app/
│ │ ├── auth/ # Модуль аутентификации
│ │ ├── users/ # Модуль пользователей
│ │ ├── chats/ # Модуль чатов
│ │ ├── core/ # Основные компоненты (DB, Redis, Config)
│ │ ├── services/ # Бизнес-логика (Email, Redis)
│ │ ├── tasks/ # Celery задачи
│ │ ├── utils/ # Утилиты (OAuth2, Hash, Cache)
│ │ ├── templates/ # Email шаблоны
│ │ └── migration/ # Alembic миграции
│ ├── tests/ # Тесты
│ └── pyproject.toml # Зависимости Python
├── frontend/
│ ├── src/
│ │ ├── components/ # React компоненты
│ │ │ ├── auth/ # Компоненты аутентификации
│ │ │ ├── chat/ # Компоненты чата
│ │ │ └── common/ # Общие компоненты
│ │ ├── pages/ # Страницы приложения
│ │ ├── services/ # API сервисы
│ │ ├── store/ # Zustand хранилища
│ │ └── utils/ # Утилиты
│ └── package.json # Зависимости Node.js
└── assets/ # Статические файлы (логотипы и т.д.)
```
## 💻 Разработка
### Backend
```bash
# Запуск тестов
pytest
# Запуск тестов с coverage
pytest --cov=app tests/
# Создание новой миграции
alembic revision --autogenerate -m "description"
# Применение миграций
alembic upgrade head
# Откат миграций
alembic downgrade -1
# Форматирование кода
black app/
isort app/
# Проверка типов
mypy app/
```
### Frontend
```bash
# Запуск dev сервера
npm run dev
# Сборка для production
npm run build
# Просмотр production сборки
npm run preview
# Линтинг
npm run lint
# Проверка типов
npx tsc --noEmit
```
## 🧪 Тестирование
### Backend тесты
```bash
cd backend
pytest tests/ -v
```
## 🚢 Деплой
### Backend
```bash
# Сборка Docker образа
docker build -t aether-backend .
# Запуск контейнера
docker run -p 8000:8000 --env-file .env aether-backend
```
### Frontend
```bash
# Сборка для production
npm run build
# Папка dist/ готова к деплою на любой статический хостинг
```
## 🤝 Вклад в проект
Мы приветствуем вклад в развитие проекта! Пожалуйста:
1. Сделайте Fork репозитория
2. Создайте ветку для новой функции (`git checkout -b feature/AmazingFeature`)
3. Зафиксируйте изменения (`git commit -m 'Add some AmazingFeature'`)
4. Отправьте в ветку (`git push origin feature/AmazingFeature`)
5. Откройте Pull Request
## 📝 Лицензия
Этот проект распространяется под лицензией Apache 2.0. Подробности в файле [LICENSE](LICENSE).
## 👤 Автор
**lorsan**
- Email: stasstrochewskij@gmail.com
- GitHub: [@lorsan](https://github.com/lorsan)
## 🙏 Благодарности
- [FastAPI](https://fastapi.tiangolo.com/) за отличный фреймворк
- [React](https://react.dev/) команде за мощную библиотеку
- Всем контрибьюторам open-source проектов, которые используются в Aether
---
<div align="center">
Сделано с ❤️ by lorsan
</div>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

+139
View File
@@ -0,0 +1,139 @@
from contextlib import asynccontextmanager
from typing import AsyncIterator
import logging
from aiobotocore.session import get_session
from types_aiobotocore_s3 import S3Client as S3ClientAnnotated
from botocore.exceptions import ClientError
from fastapi import HTTPException, status
from app.core.config import settings
log = logging.getLogger(__name__)
class S3Client:
def __init__(
self,
access_key: str,
secret_key: str,
endpoint_url: str,
bucket_name: str
):
self.config = dict(
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
endpoint_url=endpoint_url
)
self.endpoint_url = endpoint_url
self.bucket_name = bucket_name
@asynccontextmanager
async def _get_client(self) -> AsyncIterator[S3ClientAnnotated]:
session = get_session()
async with session.create_client("s3", **self.config) as raw_client:
client: S3ClientAnnotated = raw_client
yield client
async def upload_file(
self,
file: bytes,
object_name: str,
content_type: str
) -> str:
log.info("Uploading file to S3", extra={"object_name": object_name, "content_type": content_type})
try:
async with self._get_client() as client:
await client.put_object(
Bucket=self.bucket_name,
Key=object_name,
Body=file,
ContentType=content_type
)
log.info("File uploaded to S3 successfully", extra={"object_name": object_name})
except ClientError as e:
log.error(f"S3 upload error: {str(e)}", extra={"object_name": object_name})
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail="S3 upload error")
except Exception as e:
log.error(f"Unexpected S3 error: {str(e)}", extra={"object_name": object_name})
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail="Unexpected S3 error")
return f"{self.endpoint_url}/{self.bucket_name}/{object_name}"
async def download_file(
self,
object_name: str
) -> bytes:
log.info("Downloading file from S3", extra={"object_name": object_name})
try:
async with self._get_client() as client:
response = await client.get_object(
Bucket=self.bucket_name,
Key=object_name
)
log.info("File downloaded from S3 successfully", extra={"object_name": object_name})
return await response["Body"].read()
except ClientError as ex:
log.error(f"S3 download error: {str(ex)}", extra={"object_name": object_name})
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail="S3 download error")
except Exception as e:
log.error(f"Unexpected S3 error: {str(e)}", extra={"object_name": object_name})
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail="Unexpected S3 error")
async def delete_file(self, object_name: str) -> None:
log.info("Deleting file from S3", extra={"object_name": object_name})
try:
async with self._get_client() as client:
await client.delete_object(
Bucket=self.bucket_name,
Key=object_name
)
log.info("File deleted from S3 successfully", extra={"object_name": object_name})
except ClientError as e:
log.error(f"S3 delete error: {str(e)}", extra={"object_name": object_name})
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail="S3 delete error")
except Exception as e:
log.error(f"Unexpected S3 error: {str(e)}", extra={"object_name": object_name})
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail="Unexpected S3 error")
async def delete_files(self, object_names: list[str]) -> None:
if not object_names:
return
log.info("Deleting multiple files from S3", extra={"count": len(object_names)})
try:
async with self._get_client() as client:
delete_payload = {
"Objects": [{'Key': name} for name in object_names],
"Quiet": True
}
await client.delete_objects(
Bucket=self.bucket_name,
Delete=delete_payload
)
log.info("Files deleted from S3 successfully", extra={"count": len(object_names)})
except ClientError as e:
log.error(f"S3 batch delete error: {str(e)}", extra={"count": len(object_names)})
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="S3 batch delete error"
)
except Exception as e:
log.error(f"Unexpected S3 error: {str(e)}", extra={"count": len(object_names)})
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Unexpected S3 error"
)
s3_client = S3Client(
access_key=settings.S3_ACCESS_KEY_ID,
secret_key=settings.S3_SECRET_ACCESS_KEY,
endpoint_url=settings.S3_URL,
bucket_name=settings.S3_BUCKET_NAME
)
+5
View File
@@ -28,6 +28,11 @@ class Settings(BaseSettings):
SMTP_PORT: int SMTP_PORT: int
SMTP_EMAIL: str SMTP_EMAIL: str
SMTP_PASS: str SMTP_PASS: str
S3_URL: str
S3_ACCESS_KEY_ID: str
S3_SECRET_ACCESS_KEY: str
S3_BUCKET_NAME: str
DB_HOST: str DB_HOST: str
DB_PORT: int DB_PORT: int
+14 -2
View File
@@ -1,7 +1,7 @@
from typing import Dict from typing import Dict
import logging import logging
from fastapi import APIRouter, Response, Depends from fastapi import APIRouter, Response, Depends, UploadFile, File
from app.users.schemas import User, UserUpdate from app.users.schemas import User, UserUpdate
from app.users.service import UserService from app.users.service import UserService
@@ -36,4 +36,16 @@ async def delete_current_user(response: Response, user: UserModel = Depends(get_
await AuthService.abort_all_sessions(user.id) await AuthService.abort_all_sessions(user.id)
await UserService.delete_user(user.id) await UserService.delete_user(user.id)
return {"status": True, "message": "User successfully deleted"} return {"status": True, "message": "User successfully deleted"}
@router.post("/me/avatar")
async def upload_avatar(
avatar: UploadFile = File(...),
user: UserModel = Depends(get_current_verified_user)
) -> User:
return await UserService.upload_avatar(user, avatar)
@router.delete('/me/avatar')
async def delete_avatar(user: UserModel = Depends(get_current_verified_user)) -> Dict:
await UserService.delete_avatar(user)
return {"status": True, "message": "Avatar successfully deleted"}
+56 -2
View File
@@ -3,11 +3,13 @@ import uuid
from datetime import timedelta from datetime import timedelta
from typing import List from typing import List
from fastapi import HTTPException, status from fastapi import HTTPException, status, UploadFile
from sqlalchemy import or_ from sqlalchemy import or_
from sqlalchemy.orm.sync import update
from app.utils.hash_password import hash_password, verify_password from app.utils.hash_password import hash_password, verify_password
from app.services.redis_service import EmailTokenStorage, ChangePasswordTokenStorage from app.services.redis_service import EmailTokenStorage, ChangePasswordTokenStorage
from app.core.S3_client import s3_client
from app.core.exceptions import InvalidTokenException, TokenExpiredException, UserNotFoundException from app.core.exceptions import InvalidTokenException, TokenExpiredException, UserNotFoundException
from app.users.models import UserModel from app.users.models import UserModel
from app.users.dao import UserDAO from app.users.dao import UserDAO
@@ -221,4 +223,56 @@ class UserService:
obj_in={"hashed_password": hash_password(new_password)} obj_in={"hashed_password": hash_password(new_password)}
) )
await session.commit() await session.commit()
log.info("Successfully reset password", extra={"user_id": user_id}) log.info("Successfully reset password", extra={"user_id": user_id})
@classmethod
async def upload_avatar(cls, user: UserModel, avatar: UploadFile) -> User:
async with async_session_maker() as session:
allowed_types = ["image/jpeg", "image/png", "image/gif"]
if not avatar.content_type in allowed_types:
log.warning("Using not allowed type photo", extra={"user_id": user.id})
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="allowed type png and jpeg")
if user.avatar_url is not None:
await cls.delete_avatar(user)
type: str = avatar.filename.split(".")[-1]
object_name: str = f"avatar_{user.id}_{uuid.uuid4()}.{type}"
url = await s3_client.upload_file(
file=avatar.file.read(),
object_name=object_name,
content_type=avatar.content_type
)
update_user = await UserDAO.update(
session,
UserModel.id==user.id,
obj_in={"avatar_url": url}
)
await session.commit()
log.info("Successfully upload avatar", extra={"user_id": user.id, "avatar_url": url})
return update_user
@classmethod
async def delete_avatar(cls, user: UserModel):
async with async_session_maker() as session:
if user.avatar_url is None:
log.warning("Avatar is none", extra={"user_id": user.id})
return
avatar_name = user.avatar_url.split("/")[-1]
await s3_client.delete_file(avatar_name)
await UserDAO.update(
session,
UserModel.id==user.id,
obj_in={"avatar_url": None}
)
log.info("Avatar successfully deleted", extra={"user_id": user.id})
await session.commit()
+1499 -1
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -19,7 +19,9 @@ dependencies = [
"python-jose (>=3.5.0,<4.0.0)", "python-jose (>=3.5.0,<4.0.0)",
"celery (>=5.6.1,<6.0.0)", "celery (>=5.6.1,<6.0.0)",
"bcrypt (<4.1)", "bcrypt (<4.1)",
"pytest (>=9.0.2,<10.0.0)" "pytest (>=9.0.2,<10.0.0)",
"aiobotocore (>=3.1.0,<4.0.0)",
"types-aiobotocore[essential] (>=3.1.0,<4.0.0)"
] ]
+9
View File
@@ -6,6 +6,7 @@ import AuthPage from './pages/AuthPage';
import VerifyEmailPage from './pages/VerifyEmailPage'; import VerifyEmailPage from './pages/VerifyEmailPage';
import ResetPasswordPage from './pages/ResetPasswordPage'; import ResetPasswordPage from './pages/ResetPasswordPage';
import ChatPage from './pages/ChatPage'; import ChatPage from './pages/ChatPage';
import ProfilePage from './pages/ProfilePage';
function PrivateRoute({ children }: { children: React.ReactNode }) { function PrivateRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
@@ -55,6 +56,14 @@ function App() {
</PrivateRoute> </PrivateRoute>
} }
/> />
<Route
path="/profile"
element={
<PrivateRoute>
<ProfilePage />
</PrivateRoute>
}
/>
<Route path="/" element={<Navigate to="/chat" />} /> <Route path="/" element={<Navigate to="/chat" />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
+8
View File
@@ -1,8 +1,10 @@
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useNavigate } from 'react-router-dom';
export default function ChatPage() { export default function ChatPage() {
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
const logout = useAuthStore((state) => state.logout); const logout = useAuthStore((state) => state.logout);
const navigate = useNavigate();
const handleLogout = async () => { const handleLogout = async () => {
// TODO: Call logout API // TODO: Call logout API
@@ -21,6 +23,12 @@ export default function ChatPage() {
<div className="p-4"> <div className="p-4">
<p className="text-sm text-text-muted font-inter">Привет, {user?.username}!</p> <p className="text-sm text-text-muted font-inter">Привет, {user?.username}!</p>
<button
onClick={() => navigate('/profile')}
className="mt-2 text-sm text-accent-olive hover:opacity-70 font-inter block"
>
Мой профиль
</button>
<button <button
onClick={handleLogout} onClick={handleLogout}
className="mt-2 text-sm text-error-soft hover:text-red-700 font-inter" className="mt-2 text-sm text-error-soft hover:text-red-700 font-inter"
+353
View File
@@ -0,0 +1,353 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { Camera, Trash2, LogOut, ArrowLeft } from 'lucide-react';
import { userService } from '../services/userService';
import type { UserUpdate } from '../services/userService';
import { useAuthStore } from '../store/authStore';
export default function ProfilePage() {
const navigate = useNavigate();
const user = useAuthStore((state) => state.user);
const logout = useAuthStore((state) => state.logout);
const [isEditing, setIsEditing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [formData, setFormData] = useState<UserUpdate>({
display_name: user?.display_name || '',
username: user?.username || '',
description: user?.description || '',
birth_day: user?.birth_day || '',
});
useEffect(() => {
if (user) {
setFormData({
display_name: user.display_name || '',
username: user.username,
description: user.description || '',
birth_day: user.birth_day || '',
});
}
}, [user]);
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
setIsLoading(true);
const updatedUser = await userService.uploadAvatar(file);
useAuthStore.getState().setUser(updatedUser);
setSuccess('Аватар успешно загружен');
setTimeout(() => setSuccess(''), 3000);
} catch (err: any) {
setError(err.response?.data?.detail || 'Ошибка загрузки аватара');
} finally {
setIsLoading(false);
}
};
const handleDeleteAvatar = async () => {
if (!confirm('Удалить аватар?')) return;
try {
setIsLoading(true);
await userService.deleteAvatar();
const updatedUser = await userService.getCurrentUser();
useAuthStore.getState().setUser(updatedUser);
setSuccess('Аватар удален');
setTimeout(() => setSuccess(''), 3000);
} catch (err: any) {
setError(err.response?.data?.detail || 'Ошибка удаления аватара');
} finally {
setIsLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccess('');
setIsLoading(true);
try {
const updatedUser = await userService.updateProfile(formData);
useAuthStore.getState().setUser(updatedUser);
setSuccess('Профиль обновлен');
setIsEditing(false);
setTimeout(() => setSuccess(''), 3000);
} catch (err: any) {
setError(err.response?.data?.detail || 'Ошибка обновления профиля');
} finally {
setIsLoading(false);
}
};
const handleLogout = async () => {
logout();
navigate('/auth');
};
const handleDeleteAccount = async () => {
if (!confirm('Вы уверены? Это действие необратимо!')) return;
try {
await userService.deleteAccount();
logout();
navigate('/auth');
} catch (err: any) {
setError(err.response?.data?.detail || 'Ошибка удаления аккаунта');
}
};
if (!user) return null;
return (
<div className="min-h-screen" style={{ backgroundColor: '#F5F5F1' }}>
<div className="max-w-4xl mx-auto p-6">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<button
onClick={() => navigate('/chat')}
className="flex items-center gap-2 font-inter font-medium hover:opacity-70 transition"
style={{ color: '#6B705C' }}
>
<ArrowLeft size={20} />
Назад к чатам
</button>
<button
onClick={handleLogout}
className="flex items-center gap-2 font-inter font-medium hover:opacity-70 transition"
style={{ color: '#C79A8B' }}
>
<LogOut size={20} />
Выйти
</button>
</div>
{/* Profile Card */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-3xl shadow-soft p-8"
>
{/* Avatar Section */}
<div className="flex flex-col items-center mb-8">
<div className="relative">
<div
className="w-32 h-32 rounded-full bg-gradient-to-br from-accent-terracotta to-accent-olive flex items-center justify-center text-white text-4xl font-lora shadow-logo overflow-hidden"
style={{
backgroundImage: user.avatar_url ? `url(${user.avatar_url})` : undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
{!user.avatar_url && user.username[0].toUpperCase()}
</div>
<label
htmlFor="avatar-upload"
className="absolute bottom-0 right-0 p-2 rounded-full cursor-pointer hover:opacity-80 transition"
style={{ backgroundColor: '#6B705C' }}
>
<Camera size={20} className="text-white" />
<input
id="avatar-upload"
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarUpload}
disabled={isLoading}
/>
</label>
{user.avatar_url && (
<button
type="button"
onClick={handleDeleteAvatar}
className="absolute bottom-0 left-0 p-2 rounded-full hover:opacity-80 transition"
style={{ backgroundColor: '#C79A8B' }}
disabled={isLoading}
>
<Trash2 size={20} className="text-white" />
</button>
)}
</div>
<h1 className="mt-4 text-2xl font-lora font-semibold" style={{ color: '#2C2C2C' }}>
{user.username}
</h1>
<p className="font-inter text-sm" style={{ color: '#8B8B8B' }}>
{user.email}
</p>
</div>
{/* Profile Form */}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block font-lora italic text-[15px] mb-2" style={{ color: '#8B8B8B' }}>
Имя для отображения
</label>
<input
type="text"
value={formData.display_name}
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })}
disabled={!isEditing}
className="w-full px-4 py-3 rounded-xl font-inter transition-all"
style={{
backgroundColor: isEditing ? '#F9F9F7' : '#EFEFEF',
color: '#2C2C2C',
border: '2px solid transparent',
}}
/>
</div>
<div>
<label className="block font-lora italic text-[15px] mb-2" style={{ color: '#8B8B8B' }}>
Никнейм
</label>
<input
type="text"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
disabled={!isEditing}
className="w-full px-4 py-3 rounded-xl font-inter transition-all"
style={{
backgroundColor: isEditing ? '#F9F9F7' : '#EFEFEF',
color: '#2C2C2C',
border: '2px solid transparent',
}}
/>
</div>
<div>
<label className="block font-lora italic text-[15px] mb-2" style={{ color: '#8B8B8B' }}>
Дата рождения
</label>
<input
type="date"
value={formData.birth_day}
onChange={(e) => setFormData({ ...formData, birth_day: e.target.value })}
disabled={!isEditing}
className="w-full px-4 py-3 rounded-xl font-inter transition-all"
style={{
backgroundColor: isEditing ? '#F9F9F7' : '#EFEFEF',
color: '#2C2C2C',
border: '2px solid transparent',
}}
/>
</div>
</div>
<div>
<label className="block font-lora italic text-[15px] mb-2" style={{ color: '#8B8B8B' }}>
О себе
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
disabled={!isEditing}
rows={4}
className="w-full px-4 py-3 rounded-xl font-inter transition-all resize-none"
style={{
backgroundColor: isEditing ? '#F9F9F7' : '#EFEFEF',
color: '#2C2C2C',
border: '2px solid transparent',
}}
placeholder="Расскажите о себе..."
/>
</div>
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="p-3 rounded-xl text-sm font-inter"
style={{
backgroundColor: 'rgba(199, 154, 139, 0.1)',
color: '#C79A8B',
border: '2px solid #C79A8B',
}}
>
{error}
</motion.div>
)}
{success && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="p-3 rounded-xl text-sm font-inter"
style={{
backgroundColor: 'rgba(107, 112, 92, 0.1)',
color: '#6B705C',
border: '2px solid #6B705C',
}}
>
{success}
</motion.div>
)}
<div className="flex gap-4">
{!isEditing ? (
<motion.button
type="button"
onClick={() => setIsEditing(true)}
whileTap={{ scale: 0.95 }}
style={{ backgroundColor: '#6B705C', color: 'white' }}
className="flex-1 py-3 px-6 rounded-full font-inter font-semibold hover:shadow-lg transition-all"
>
Редактировать профиль
</motion.button>
) : (
<>
<motion.button
type="submit"
disabled={isLoading}
whileTap={{ scale: 0.95 }}
style={{ backgroundColor: '#6B705C', color: 'white' }}
className="flex-1 py-3 px-6 rounded-full font-inter font-semibold hover:shadow-lg transition-all disabled:opacity-50"
>
{isLoading ? 'Сохранение...' : 'Сохранить'}
</motion.button>
<motion.button
type="button"
onClick={() => {
setIsEditing(false);
setError('');
}}
whileTap={{ scale: 0.95 }}
style={{ backgroundColor: '#8B8B8B', color: 'white' }}
className="flex-1 py-3 px-6 rounded-full font-inter font-semibold hover:shadow-lg transition-all"
>
Отмена
</motion.button>
</>
)}
</div>
</form>
{/* Danger Zone */}
<div className="mt-8 pt-8 border-t" style={{ borderColor: '#E5E5E5' }}>
<h3 className="font-lora font-semibold mb-4" style={{ color: '#C79A8B' }}>
Опасная зона
</h3>
<motion.button
onClick={handleDeleteAccount}
whileTap={{ scale: 0.95 }}
style={{ backgroundColor: '#C79A8B', color: 'white' }}
className="py-3 px-6 rounded-full font-inter font-semibold hover:shadow-lg transition-all"
>
Удалить аккаунт
</motion.button>
</div>
</motion.div>
</div>
</div>
);
}
+59 -5
View File
@@ -10,20 +10,74 @@ const apiClient = axios.create({
}, },
}); });
// Interceptor для обработки ошибок let isRefreshing = false;
let failedQueue: any[] = [];
const processQueue = (error: any, token: string | null = null) => {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
// Interceptor для обработки ошибок и автоматического обновления токенов
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => response,
(error) => { async (error) => {
if (error.response?.status === 401) { const originalRequest = error.config;
// Redirect to login if unauthorized, but not if already on public pages
// Если ошибка 401 и это не запрос на refresh и не повторный запрос
if (error.response?.status === 401 && !originalRequest._retry && originalRequest.url !== '/auth/refresh') {
// Проверяем, находимся ли на публичной странице
const publicPaths = ['/auth', '/verify-email', '/reset-password']; const publicPaths = ['/auth', '/verify-email', '/reset-password'];
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
const isPublicPage = publicPaths.some(path => currentPath.startsWith(path)); const isPublicPage = publicPaths.some(path => currentPath.startsWith(path));
if (!isPublicPage) { if (isPublicPage) {
return Promise.reject(error);
}
if (isRefreshing) {
// Если refresh уже в процессе, добавляем запрос в очередь
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then(() => {
return apiClient(originalRequest);
})
.catch((err) => {
return Promise.reject(err);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
// Пытаемся обновить токен
await apiClient.post('/auth/refresh');
// Если успешно, обрабатываем очередь и повторяем оригинальный запрос
processQueue(null, 'refreshed');
isRefreshing = false;
return apiClient(originalRequest);
} catch (refreshError) {
// Если refresh не удался, очищаем очередь и редиректим на логин
processQueue(refreshError, null);
isRefreshing = false;
window.location.href = '/auth'; window.location.href = '/auth';
return Promise.reject(refreshError);
} }
} }
return Promise.reject(error); return Promise.reject(error);
} }
); );
+2 -1
View File
@@ -1,4 +1,5 @@
import apiClient from './api'; import apiClient from './api';
import type { User } from './userService';
export interface LoginData { export interface LoginData {
username: string; username: string;
@@ -47,7 +48,7 @@ export const authService = {
return response.data; return response.data;
}, },
getCurrentUser: async () => { getCurrentUser: async (): Promise<User> => {
const response = await apiClient.get('/users/me'); const response = await apiClient.get('/users/me');
return response.data; return response.data;
}, },
+53
View File
@@ -0,0 +1,53 @@
import apiClient from './api';
export interface User {
id: number;
display_name: string;
username: string;
email: string;
birth_day?: string;
description?: string;
avatar_url?: string;
is_active: boolean;
is_verified: boolean;
is_superuser: boolean;
}
export interface UserUpdate {
display_name?: string;
username?: string;
birth_day?: string;
description?: string;
}
export const userService = {
getCurrentUser: async (): Promise<User> => {
const response = await apiClient.get('/users/me');
return response.data;
},
updateProfile: async (data: UserUpdate): Promise<User> => {
const response = await apiClient.put('/users/me', data);
return response.data;
},
uploadAvatar: async (file: File): Promise<User> => {
const formData = new FormData();
formData.append('avatar', file);
const response = await apiClient.post('/users/me/avatar', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
},
deleteAvatar: async (): Promise<void> => {
await apiClient.delete('/users/me/avatar');
},
deleteAccount: async (): Promise<void> => {
await apiClient.delete('/users/me');
},
};
+1 -6
View File
@@ -1,10 +1,5 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { User } from '../services/userService';
interface User {
id: string;
email: string;
username: string;
}
interface AuthStore { interface AuthStore {
user: User | null; user: User | null;
+471
View File
@@ -0,0 +1,471 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--color-ivory: #F5F5F1;
--color-white: #ffffff;
--color-terracotta: #D27D56;
--color-olive: #6B705C;
--color-text-primary: #3d3d3d;
--color-text-secondary: #5a5a5a;
--color-text-muted: #8a8a8a;
--color-input-bg: #F9F9F7;
--color-error: #D89B8C;
--shadow-soft: 0 20px 50px rgba(107, 112, 92, 0.1);
--shadow-hover: 0 25px 60px rgba(107, 112, 92, 0.15);
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
line-height: 1.6;
color: var(--color-text-primary);
overflow-x: hidden;
}
.background {
min-height: 100vh;
background-color: var(--color-ivory);
background-image:
radial-gradient(circle at 20% 50%, rgba(210, 125, 86, 0.03) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(107, 112, 92, 0.03) 0%, transparent 50%),
url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23999999' fill-opacity='0.02'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
position: relative;
}
.background::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(circle at center, transparent 0%, rgba(0, 0, 0, 0.02) 100%);
pointer-events: none;
}
.auth-container {
position: relative;
width: 100%;
max-width: 440px;
perspective: 1000px;
min-height: 600px;
}
.auth-card {
background: var(--color-white);
border-radius: 24px;
padding: 56px 48px 48px;
box-shadow: var(--shadow-soft);
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
transform-style: preserve-3d;
position: absolute;
width: 100%;
top: 0;
left: 0;
z-index: 1;
}
.auth-card:hover {
box-shadow: var(--shadow-hover);
}
.auth-card.hidden {
opacity: 0;
pointer-events: none;
transform: scale(0.95);
z-index: 0;
}
.auth-card.active {
opacity: 1;
pointer-events: all;
transform: scale(1);
z-index: 2;
}
@keyframes cardFlip {
0% {
opacity: 0;
transform: scale(0.9);
}
100% {
opacity: 1;
transform: rotateY(0deg) scale(1);
}
}
.logo {
text-align: center;
margin-bottom: 32px;
}
.logo-image {
width: 80px;
height: 80px;
object-fit: contain;
transition: transform 0.3s ease;
}
.logo-image:hover {
transform: scale(1.05);
}
.title {
font-family: 'Lora', Georgia, serif;
font-size: 28px;
font-weight: 400;
color: var(--color-text-primary);
text-align: center;
margin-bottom: 40px;
line-height: 1.3;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 28px;
}
.form-group {
position: relative;
}
.form-group label {
display: block;
font-family: 'Lora', Georgia, serif;
font-style: italic;
font-size: 15px;
color: var(--color-text-secondary);
margin-bottom: 10px;
transition: color 0.3s ease;
}
.label-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.forgot-link {
font-size: 13px;
color: var(--color-olive);
text-decoration: none;
transition: color 0.3s ease;
}
.forgot-link:hover {
color: var(--color-terracotta);
}
.form-input {
width: 100%;
padding: 16px 20px;
background: var(--color-input-bg);
border: none;
border-bottom: 2px solid transparent;
border-radius: 12px;
font-family: 'Inter', sans-serif;
font-size: 16px;
color: var(--color-text-primary);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
outline: none;
}
.form-input::placeholder {
color: var(--color-text-muted);
}
.form-input:focus {
background: var(--color-white);
border-bottom-color: var(--color-terracotta);
box-shadow: 0 4px 12px rgba(210, 125, 86, 0.1);
transform: translateY(-2px);
}
.form-input.error {
background: #FFF5F3;
border-bottom-color: var(--color-error);
}
.password-wrapper {
position: relative;
}
.password-toggle {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.3s ease;
}
.password-toggle:hover {
color: var(--color-terracotta);
}
.eye-icon {
transition: all 0.3s ease;
}
.password-toggle.active .eye-icon {
color: var(--color-olive);
}
.error-message {
display: block;
font-size: 13px;
color: var(--color-error);
margin-top: 8px;
min-height: 18px;
font-style: italic;
opacity: 0;
transform: translateY(-4px);
transition: all 0.3s ease;
}
.error-message.show {
opacity: 1;
transform: translateY(0);
}
.checkbox-group {
margin-top: 8px;
}
.checkbox-label {
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
}
.checkbox-input {
position: absolute;
opacity: 0;
cursor: pointer;
}
.checkbox-custom {
position: relative;
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--color-text-muted);
margin-right: 12px;
transition: all 0.3s ease;
flex-shrink: 0;
}
.checkbox-custom::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-olive);
transition: transform 0.3s cubic-bezier(0.68, -0.55, 0.27, 1.55);
}
.checkbox-input:checked + .checkbox-custom {
border-color: var(--color-olive);
}
.checkbox-input:checked + .checkbox-custom::after {
transform: translate(-50%, -50%) scale(1);
}
.checkbox-text {
font-size: 14px;
color: var(--color-text-secondary);
line-height: 1.5;
}
.btn-primary {
width: 100%;
padding: 18px 32px;
background: var(--color-olive);
color: var(--color-white);
border: none;
border-radius: 50px;
font-family: 'Inter', sans-serif;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 16px rgba(107, 112, 92, 0.25);
margin-top: 16px;
}
.btn-primary:hover {
background: #5a5f4d;
transform: translateY(-2px) scale(1.02);
box-shadow: 0 8px 24px rgba(107, 112, 92, 0.35);
}
.btn-primary:active {
transform: translateY(0) scale(0.98);
box-shadow: 0 2px 8px rgba(107, 112, 92, 0.2) inset;
}
.btn-secondary {
background: var(--color-terracotta);
box-shadow: 0 4px 16px rgba(210, 125, 86, 0.25);
}
.btn-secondary:hover {
background: #c46d46;
box-shadow: 0 8px 24px rgba(210, 125, 86, 0.35);
}
.btn-secondary:active {
box-shadow: 0 2px 8px rgba(210, 125, 86, 0.2) inset;
}
.switch-form {
margin-top: 32px;
text-align: center;
font-size: 14px;
color: var(--color-text-secondary);
}
.switch-form a {
color: var(--color-olive);
text-decoration: none;
font-weight: 500;
transition: color 0.3s ease;
}
.switch-form a:hover {
color: var(--color-terracotta);
}
/* Responsive */
@media (max-width: 520px) {
.auth-card {
padding: 40px 32px 32px;
border-radius: 20px;
}
.title {
font-size: 24px;
}
.form-input {
padding: 14px 16px;
font-size: 15px;
}
}
/* Verify Email Styles */
.verify-state {
text-align: center;
padding: 20px 0;
}
.spinner {
width: 64px;
height: 64px;
border: 4px solid var(--color-input-bg);
border-top-color: var(--color-olive);
border-radius: 50%;
margin: 0 auto 30px;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.success-icon {
color: #6B9B6D;
margin-bottom: 20px;
}
.success-icon svg {
stroke: currentColor;
animation: checkmark 0.5s ease-in-out;
}
@keyframes checkmark {
0% {
stroke-dasharray: 100;
stroke-dashoffset: 100;
opacity: 0;
transform: scale(0.8);
}
100% {
stroke-dasharray: 100;
stroke-dashoffset: 0;
opacity: 1;
transform: scale(1);
}
}
.error-icon {
color: var(--color-error);
margin-bottom: 20px;
}
.error-icon svg {
stroke: currentColor;
animation: shake 0.5s ease-in-out;
}
@keyframes shake {
0%, 100% {
transform: translateX(0);
}
25% {
transform: translateX(-10px);
}
75% {
transform: translateX(10px);
}
}
.verify-state .title {
margin-bottom: 15px;
}
.verify-state .subtitle {
color: var(--color-text-secondary);
font-size: 15px;
line-height: 1.6;
max-width: 400px;
margin: 0 auto 30px;
}
.verify-state .btn {
margin-top: 10px;
display: inline-block;
}
/* Accessibility */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}