Add docker

This commit is contained in:
2026-01-10 17:51:08 +03:00
parent 62d33acde9
commit 8167c77a27
36 changed files with 1351 additions and 117 deletions
+61
View File
@@ -0,0 +1,61 @@
# === GENERAL ===
COMPANY_NAME="Aether Messenger"
MODE=PROD
LOG_LEVEL=INFO
# === BACKEND ===
BACKEND_HOST=0.0.0.0
BACKEND_PORT=8080
WORKERS=4
URL=https://yourdomain.com
# === FIRST SUPERUSER ===
FIRST_SUPER_USER_EMAIL=admin@example.com
FIRST_SUPER_USER_PASS=changeme123
FIRST_SUPER_USER_USERNAME=admin
# === CORS ===
# Добавьте свой домен
CORS_ORIGINS=["https://yourdomain.com", "http://localhost:3000"]
# === AUTH ===
SECRET_KEY=your-super-secret-key-change-me
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=15
EMAIL_TOKEN_EXPIRE_MINUTES=60
REFRESH_TOKEN_EXPIRE_DAYS=30
# === SMTP (Email) ===
SMTP_SERVER=smtp.gmail.com
SMTP_PORT=587
SMTP_EMAIL=your-email@gmail.com
SMTP_PASS=your-app-password
# === S3 Storage ===
S3_URL=https://s3.amazonaws.com
S3_ACCESS_KEY_ID=your-access-key
S3_SECRET_ACCESS_KEY=your-secret-key
S3_BUCKET_NAME=aether-bucket
# === DATABASE (PostgreSQL) ===
DB_HOST=database
DB_PORT=5432
DB_USER=aether_user
DB_PASS=strong_password_here
DB_NAME=aether_db
# === REDIS ===
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASS=
REDIS_DB=0
# === RABBITMQ ===
RMQ_HOST=rabbitmq
RMQ_USER=aether_rmq
RMQ_PASS=rabbitmq_password
RMQ_PORT=5672
# === FRONTEND ===
VITE_API_URL=/api/v1
FRONTEND_PORT=3056
Executable
+53
View File
@@ -0,0 +1,53 @@
COMPANY_NAME=AETHER
MODE=DEV
LOG_LEVEL=DEBUG
BACKEND_HOST=localhost
BACKEND_PORT=8080
WORKERS=4
FRONTEND_URL=http://localhost:5173
VITE_API_URL=/api/v1
FRONTEND_PORT=3056
FIRST_SUPER_USER_EMAIL=admin@example.com
FIRST_SUPER_USER_PASS=admin
FIRST_SUPER_USER_USERNAME=admin
DB_HOST=localhost
DB_PORT=5432
DB_PASS=postgres
DB_USER=postgres
DB_NAME=Aether
REDIS_HOST=localhost
REDIS_PORT=6379
# REDIS_PASS=
# REDIS_DB=
#CORS_HEADERS=["Content-Type", "Set-Cookie", "Access-Control-Allow-Headers", "Access-Control-Allow-Origin", "Authorization"]
#CORS_ORIGINS=["http://localhost:3000"]
#CORS_METHODS=["GET", "POST", "OPTIONS", "DELETE", "PATCH", "PUT"]
CORS_HEADERS=["Content-Type", "Set-Cookie", "Access-Control-Allow-Headers", "Access-Control-Allow-Origin", "Authorization"]
CORS_ORIGINS=["http://localhost:5500", "http://localhost:5173", "http://localhost:8080", "http://127.0.0.1:8080", "null"]
CORS_METHODS=["GET", "POST", "OPTIONS", "DELETE", "PATCH", "PUT"]
SECRET_KEY=sercretKey
ALGORITHM=HS256
SMTP_SERVER=localhost
SMTP_PORT=1025
SMTP_EMAIL=noreply@cityvibe.ru
SMTP_PASS=test
RMQ_HOST=localhost
RMQ_USER=guest
RMQ_PASS=guest
RMQ_PORT=5672
S3_URL=http://192.168.31.190:9002
S3_ACCESS_KEY_ID=lorsan
S3_SECRET_ACCESS_KEY=Lorser2009!
S3_BUCKET_NAME=aether
+1
View File
@@ -1,3 +1,4 @@
__pycache__ __pycache__
.env .env
test.py test.py
.env.old
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+23
View File
@@ -0,0 +1,23 @@
__pycache__
*.pyc
*.pyo
*.pyd
.Python
*.so
*.egg
*.egg-info
dist
build
*.log
.git
.gitignore
.env
.venv
venv/
ENV/
env/
.pytest_cache
.coverage
htmlcov/
.mypy_cache
.ruff_cache
+18
View File
@@ -0,0 +1,18 @@
FROM python:3.13.7-bookworm
WORKDIR /app
RUN pip install --no-cache-dir poetry
COPY pyproject.toml poetry.lock ./
RUN poetry config virtualenvs.create false && \
poetry install --no-interaction --no-ansi --no-root
COPY app/ ./app
COPY alembic.ini .
COPY scripts ./scripts
RUN chmod +x /app/scripts/prestart.sh
CMD ["python", "-m", "app.main"]
+2 -2
View File
@@ -98,9 +98,9 @@ async def change_password(
@router.post("/password/reset") @router.post("/password/reset")
async def send_reset_password_email( async def send_reset_password_email(
username: str username_email: str
) -> Dict: ) -> Dict:
await UserService.send_reset_password_email(username) await UserService.send_reset_password_email(username_email)
return {"status": True, "message": "Successfully send email reset password"} return {"status": True, "message": "Successfully send email reset password"}
@router.post("/password/reset/{token}") @router.post("/password/reset/{token}")
+13
View File
@@ -0,0 +1,13 @@
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import ForeignKey
from app.core.database import Base
class MessageModel(Base):
__tablename__ = "message"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
sender_id: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"), index=True)
recipient_id: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"), index=True)
content: Mapped[str] = mapped_column()
+8 -4
View File
@@ -9,10 +9,14 @@ class Settings(BaseSettings):
MODE: Literal["DEV", "TEST", "PROD"] MODE: Literal["DEV", "TEST", "PROD"]
LOG_LEVEL: Literal["ERROR", "WARNING", "INFO", "DEBUG"] LOG_LEVEL: Literal["ERROR", "WARNING", "INFO", "DEBUG"]
HOST: str BACKEND_HOST: str
PORT: int BACKEND_PORT: int
WORKERS: int WORKERS: int
URL: str FRONTEND_URL: str
FIRST_SUPER_USER_EMAIL: str
FIRST_SUPER_USER_PASS: str
FIRST_SUPER_USER_USERNAME: str
CORS_ORIGINS: List[str] = ["http://localhost:5500", "http://127.0.0.1:5500", "http://localhost:8080", "http://127.0.0.1:8080", "null"] CORS_ORIGINS: List[str] = ["http://localhost:5500", "http://127.0.0.1:5500", "http://localhost:8080", "http://127.0.0.1:8080", "null"]
CORS_HEADERS: List[str] = ["*"] CORS_HEADERS: List[str] = ["*"]
@@ -62,7 +66,7 @@ class Settings(BaseSettings):
def RABBITMQ_URL(self) -> str: def RABBITMQ_URL(self) -> str:
return f"amqp://{self.RMQ_USER}:{self.RMQ_PASS}@{self.RMQ_HOST}:{self.RMQ_PORT}//" return f"amqp://{self.RMQ_USER}:{self.RMQ_PASS}@{self.RMQ_HOST}:{self.RMQ_PORT}//"
model_config = SettingsConfigDict(env_file=".env", extra="allow") model_config = SettingsConfigDict(env_file="../.env", extra="allow")
settings: Settings = Settings() settings: Settings = Settings()
+9 -9
View File
@@ -29,6 +29,11 @@ async def lifespan(app: FastAPI):
api_router = APIRouter(prefix="/api/v1") api_router = APIRouter(prefix="/api/v1")
api_router.include_router(user_router) api_router.include_router(user_router)
api_router.include_router(auth_router) api_router.include_router(auth_router)
@api_router.get("/health")
async def test_health():
return {"status": True}
app = FastAPI( app = FastAPI(
title=settings.COMPANY_NAME, title=settings.COMPANY_NAME,
description="## Backend messenger aether", description="## Backend messenger aether",
@@ -82,24 +87,19 @@ async def log_requests(request: Request, call_next):
raise raise
@app.get("/health")
async def test_health():
return {"status": True}
if __name__ == "__main__": if __name__ == "__main__":
if settings.MODE == "PROD": if settings.MODE == "PROD":
UVICORN_PARAMS = dict( UVICORN_PARAMS = dict(
host=settings.HOST, host=settings.BACKEND_HOST,
port=settings.PORT, port=settings.BACKEND_PORT,
reload=False, reload=False,
workers=settings.WORKERS, workers=settings.WORKERS,
access_log=False access_log=False
) )
else: else:
UVICORN_PARAMS = dict( UVICORN_PARAMS = dict(
host=settings.HOST, host=settings.BACKEND_HOST,
port=settings.PORT, port=settings.BACKEND_PORT,
reload=True, reload=True,
access_log=False access_log=False
) )
+31
View File
@@ -0,0 +1,31 @@
import asyncio
from app.core.database import async_session_maker
from app.core.config import settings
from app.users.schemas import UserCreateDB
from app.users.dao import UserDAO
from app.utils.hash_password import hash_password
async def init() -> None:
async with async_session_maker() as session:
super_user = await UserDAO.find_one_or_none(session, email=settings.FIRST_SUPER_USER_EMAIL)
if super_user is not None:
return
await UserDAO.add(
session,
obj_in=UserCreateDB(
email=settings.FIRST_SUPER_USER_EMAIL,
username=settings.FIRST_SUPER_USER_USERNAME,
display_name="Admin",
hashed_password=hash_password(settings.FIRST_SUPER_USER_PASS),
is_active=True,
is_verified=True,
is_superuser=True
)
)
await session.commit()
asyncio.run(init())
+2 -2
View File
@@ -7,7 +7,7 @@ from app.users.schemas import User, UserUpdate
from app.users.service import UserService from app.users.service import UserService
from app.auth.service import AuthService from app.auth.service import AuthService
from app.users.models import UserModel from app.users.models import UserModel
from app.auth.dependencies import get_current_verified_user, get_current_superuser from app.auth.dependencies import get_current_verified_user, get_current_superuser, get_current_user
router = APIRouter(prefix="/users", tags=["User"]) router = APIRouter(prefix="/users", tags=["User"])
@@ -15,7 +15,7 @@ log = logging.getLogger(__name__)
@router.get("/me") @router.get("/me")
async def get_current_user(user: UserModel = Depends(get_current_verified_user)) -> User: async def get_current_user(user: UserModel = Depends(get_current_user)) -> User:
log.debug("Getting current user profile", extra={"user_id": str(user.id)}) log.debug("Getting current user profile", extra={"user_id": str(user.id)})
return user return user
+11 -6
View File
@@ -5,11 +5,10 @@ from typing import List
from fastapi import HTTPException, status, UploadFile 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.utils.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
@@ -66,7 +65,7 @@ class UserService:
@classmethod @classmethod
async def send_verify_email(cls, user: UserModel): async def send_verify_email(cls, user: UserModel):
token = cls._create_uuid_token() token = cls._create_uuid_token()
url = f"{settings.URL}/verify-email/{token}" url = f"{settings.FRONTEND_URL}/verify-email/{token}"
email_token_expires = timedelta(minutes=settings.EMAIL_TOKEN_EXPIRE_MINUTES) email_token_expires = timedelta(minutes=settings.EMAIL_TOKEN_EXPIRE_MINUTES)
await EmailTokenStorage.save_token( await EmailTokenStorage.save_token(
@@ -180,15 +179,21 @@ class UserService:
@classmethod @classmethod
async def send_reset_password_email(cls, username: str): async def send_reset_password_email(cls, username_email: str):
async with async_session_maker() as session: async with async_session_maker() as session:
user = await UserDAO.find_one_or_none(session, username=username) user = await UserDAO.find_one_or_none(
session,
or_(
UserModel.email==username_email,
UserModel.username==username_email
)
)
if user is None: if user is None:
raise UserNotFoundException raise UserNotFoundException
token = cls._create_uuid_token() token = cls._create_uuid_token()
url = f"{settings.URL}/reset-password/{token}" url = f"{settings.FRONTEND_URL}/reset-password/{token}"
token_expires = timedelta(minutes=settings.EMAIL_TOKEN_EXPIRE_MINUTES) token_expires = timedelta(minutes=settings.EMAIL_TOKEN_EXPIRE_MINUTES)
await ChangePasswordTokenStorage.save_token( await ChangePasswordTokenStorage.save_token(
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -e
set -x
alembic upgrade head
python -m app.pre_restart
+121
View File
@@ -0,0 +1,121 @@
services:
database:
image: postgres:latest
environment:
- POSTGRES_PASSWORD=${DB_PASS}
- POSTGRES_USER=${DB_USER}
- POSTGRES_DB=${DB_NAME}
ports:
- "5432:5432"
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}" ]
interval: 5s
timeout: 10s
retries: 5
networks:
- aether
volumes:
- db-data:/var/lib/postgresql
restart: unless-stopped
rabbitmq:
image: rabbitmq:3.8-management
environment:
RABBITMQ_DEFAULT_USER: "${RMQ_USER}"
RABBITMQ_DEFAULT_PASS: "${RMQ_PASS}"
ports:
- "5672:5672"
- "15672:15672"
networks:
- aether
volumes:
- rabbitmq-data:/var/lib/rabbitmq
restart: unless-stopped
redis:
image: redis:latest
ports:
- "6379:6379"
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 5s
retries: 5
networks:
- aether
volumes:
- redis-data:/data
restart: unless-stopped
prestart:
build:
context: ./backend
command: bash scripts/prestart.sh
depends_on:
database:
condition: service_healthy
networks:
- aether
env_file:
- .env
celery:
build:
context: ./backend
command: celery -A app.core.celery_app.celery_app worker -l INFO
depends_on:
database:
condition: service_healthy
prestart:
condition: service_completed_successfully
networks:
- aether
env_file:
- .env
restart: unless-stopped
backend:
build:
context: ./backend
depends_on:
database:
condition: service_healthy
prestart:
condition: service_completed_successfully
celery:
condition: service_started
redis:
condition: service_healthy
ports:
- "3541:${BACKEND_PORT}"
healthcheck:
test: ["CMD-SHELL", "curl", "http://${BACKEND_HOST}:${BACKEND_PORT}/api/v1/health"]
interval: 5s
timeout: 5s
retries: 5
networks:
- aether
env_file:
- .env
restart: unless-stopped
frontend:
build:
context: ./frontend
args:
VITE_API_URL: ${VITE_API_URL}
depends_on:
backend:
condition: service_healthy
ports:
- "${FRONTEND_PORT}:3000"
networks:
- aether
restart: unless-stopped
volumes:
db-data:
rabbitmq-data:
redis-data:
networks:
aether:
+15
View File
@@ -0,0 +1,15 @@
node_modules
dist
build
.git
.gitignore
.env.local
.env.development
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
coverage
.vscode
.idea
+28
View File
@@ -0,0 +1,28 @@
FROM node:20-alpine
WORKDIR /app
# Копируем файлы зависимостей
COPY package.json package-lock.json* ./
# Устанавливаем зависимости
RUN npm ci
# Копируем исходники
COPY . .
# ARG для передачи переменных окружения на этапе сборки
ARG VITE_API_URL
ENV VITE_API_URL=${VITE_API_URL}
# Собираем production build (Vite встроит переменные в код)
RUN npm run build
# Устанавливаем простой HTTP сервер для отдачи статики
RUN npm install -g serve
# Порт (только документация)
EXPOSE 3000
# Запускаем сервер для отдачи статики
CMD ["serve", "-s", "dist", "-l", "3000"]
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Aether — Messenger</title> <title>Aether — Messenger</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+18
View File
@@ -1,12 +1,15 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useAuthStore } from './store/authStore'; import { useAuthStore } from './store/authStore';
import { useThemeStore } from './store/themeStore';
import { authService } from './services/authService'; import { authService } from './services/authService';
import AuthPage from './pages/AuthPage'; 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 ForgotPasswordPage from './pages/ForgotPasswordPage';
import ChatPage from './pages/ChatPage'; import ChatPage from './pages/ChatPage';
import ProfilePage from './pages/ProfilePage'; import ProfilePage from './pages/ProfilePage';
import SettingsPage from './pages/SettingsPage';
function PrivateRoute({ children }: { children: React.ReactNode }) { function PrivateRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
@@ -26,6 +29,12 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
function App() { function App() {
const setUser = useAuthStore((state) => state.setUser); const setUser = useAuthStore((state) => state.setUser);
const setLoading = useAuthStore((state) => state.setLoading); const setLoading = useAuthStore((state) => state.setLoading);
const theme = useThemeStore((state) => state.theme);
useEffect(() => {
// Apply theme on mount
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
useEffect(() => { useEffect(() => {
const checkAuth = async () => { const checkAuth = async () => {
@@ -48,6 +57,7 @@ function App() {
<Route path="/auth" element={<AuthPage />} /> <Route path="/auth" element={<AuthPage />} />
<Route path="/verify-email/:token" element={<VerifyEmailPage />} /> <Route path="/verify-email/:token" element={<VerifyEmailPage />} />
<Route path="/reset-password/:token" element={<ResetPasswordPage />} /> <Route path="/reset-password/:token" element={<ResetPasswordPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route <Route
path="/chat" path="/chat"
element={ element={
@@ -64,6 +74,14 @@ function App() {
</PrivateRoute> </PrivateRoute>
} }
/> />
<Route
path="/settings"
element={
<PrivateRoute>
<SettingsPage />
</PrivateRoute>
}
/>
<Route path="/" element={<Navigate to="/chat" />} /> <Route path="/" element={<Navigate to="/chat" />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+8 -3
View File
@@ -21,7 +21,7 @@ export default function LoginForm() {
setIsLoading(true); setIsLoading(true);
try { try {
const data = await authService.login({ username, password }); await authService.login({ username, password });
const user = await authService.getCurrentUser(); const user = await authService.getCurrentUser();
setUser(user); setUser(user);
navigate('/chat'); navigate('/chat');
@@ -55,9 +55,14 @@ export default function LoginForm() {
<label htmlFor="password" className="block font-lora italic text-[15px] text-text-muted"> <label htmlFor="password" className="block font-lora italic text-[15px] text-text-muted">
Пароль Пароль
</label> </label>
<a href="#" className="font-inter text-sm hover:underline transition" style={{ color: '#6B705C' }}> <button
type="button"
onClick={() => navigate('/forgot-password')}
className="font-inter text-sm hover:underline transition"
style={{ color: 'var(--accent-primary)' }}
>
Забыли пароль? Забыли пароль?
</a> </button>
</div> </div>
<div className="relative"> <div className="relative">
<input <input
+17 -1
View File
@@ -6,6 +6,7 @@ import { authService } from '../../services/authService';
export default function RegisterForm() { export default function RegisterForm() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [displayName, setDisplayName] = useState('');
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
@@ -35,7 +36,7 @@ export default function RegisterForm() {
setIsLoading(true); setIsLoading(true);
try { try {
await authService.register({ email, username, password }); await authService.register({ email, display_name: displayName, username, password });
setSuccess('Регистрация успешна! Проверьте почту для подтверждения.'); setSuccess('Регистрация успешна! Проверьте почту для подтверждения.');
setTimeout(() => navigate('/auth'), 2000); setTimeout(() => navigate('/auth'), 2000);
} catch (err: any) { } catch (err: any) {
@@ -63,6 +64,21 @@ export default function RegisterForm() {
/> />
</div> </div>
<div>
<label htmlFor="displayName" className="block font-lora italic text-[15px] text-text-muted mb-2">
Имя для отображения
</label>
<input
id="displayName"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Как вас называть"
className="w-full px-0 py-3 bg-transparent border-0 border-b-2 border-gray-200 font-inter text-text-main placeholder:text-text-muted/50 focus:outline-none focus:border-accent-terracotta transition-all duration-300"
required
/>
</div>
<div> <div>
<label htmlFor="username" className="block font-lora italic text-[15px] text-text-muted mb-2"> <label htmlFor="username" className="block font-lora italic text-[15px] text-text-muted mb-2">
Никнейм Никнейм
@@ -0,0 +1,105 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Mail, X } from 'lucide-react';
import { authService } from '../../services/authService';
interface VerificationBannerProps {
userEmail: string;
}
export default function VerificationBanner({ userEmail }: VerificationBannerProps) {
const [isVisible, setIsVisible] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [message, setMessage] = useState('');
const [isSuccess, setIsSuccess] = useState(false);
const handleResendEmail = async () => {
setIsLoading(true);
setMessage('');
try {
await authService.resendVerificationEmail();
setIsSuccess(true);
setMessage('Письмо успешно отправлено! Проверьте свою почту.');
setTimeout(() => {
setMessage('');
}, 5000);
} catch (err: any) {
setIsSuccess(false);
setMessage(err.response?.data?.detail || 'Ошибка отправки письма');
setTimeout(() => {
setMessage('');
}, 5000);
} finally {
setIsLoading(false);
}
};
if (!isVisible) return null;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="mb-6 relative rounded-2xl p-4 shadow-sm"
style={{
backgroundColor: 'var(--accent-primary-soft)',
border: '2px solid var(--accent-primary)'
}}
>
<button
onClick={() => setIsVisible(false)}
className="absolute top-3 right-3 p-1 hover:opacity-70 transition"
style={{ color: 'var(--accent-primary)' }}
title="Закрыть"
>
<X size={18} />
</button>
<div className="flex items-start gap-3 pr-8">
<div className="flex-shrink-0 p-2 rounded-full" style={{ backgroundColor: 'var(--accent-primary)' }}>
<Mail size={20} className="text-white" />
</div>
<div className="flex-1">
<h3 className="font-inter font-semibold mb-1" style={{ color: 'var(--accent-primary)' }}>
Подтвердите свою почту
</h3>
<p className="text-sm font-inter mb-3" style={{ color: 'var(--text-primary)' }}>
Мы отправили письмо с подтверждением на <span className="font-semibold">{userEmail}</span>.
Проверьте почту и перейдите по ссылке для активации аккаунта.
</p>
{message && (
<motion.div
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="mb-3 text-sm font-inter"
style={{
color: isSuccess ? 'var(--accent-primary)' : 'var(--error-color)'
}}
>
{message}
</motion.div>
)}
<motion.button
onClick={handleResendEmail}
disabled={isLoading}
whileTap={{ scale: 0.98 }}
className="px-4 py-2 rounded-full font-inter text-sm font-semibold transition hover:shadow-md disabled:opacity-50"
style={{
backgroundColor: 'var(--accent-primary)',
color: 'white'
}}
>
{isLoading ? 'Отправка...' : 'Отправить письмо повторно'}
</motion.button>
</div>
</div>
</motion.div>
</AnimatePresence>
);
}
+43 -1
View File
@@ -2,6 +2,45 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* Light Theme (Default) */
:root,
[data-theme='light'] {
--bg-primary: #F5F5F1;
--bg-card: #FFFFFF;
--bg-input: #F9F9F7;
--bg-input-disabled: #EFEFEF;
--accent-primary: #6B705C;
--accent-primary-soft: rgba(107, 112, 92, 0.1);
--accent-secondary: #D27D56;
--text-primary: #2C2C2C;
--text-secondary: #8B8B8B;
--border-color: #E5E5E5;
--error-color: #C79A8B;
--error-soft: rgba(199, 154, 139, 0.1);
}
/* Dark Theme */
[data-theme='dark'] {
--bg-primary: #1a1a1a;
--bg-card: #242424;
--bg-input: #2d2d2d;
--bg-input-disabled: #1f1f1f;
--accent-primary: #8B9176;
--accent-primary-soft: rgba(139, 145, 118, 0.15);
--accent-secondary: #E29574;
--text-primary: #E5E5E5;
--text-secondary: #A8A8A8;
--border-color: #3a3a3a;
--error-color: #D89B8E;
--error-soft: rgba(216, 155, 142, 0.15);
}
body { body {
margin: 0; margin: 0;
min-height: 100vh; min-height: 100vh;
@@ -10,5 +49,8 @@ body {
font-weight: 400; font-weight: 400;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
color: #2C2C2C; color: var(--text-primary);
background-color: var(--bg-primary);
transition: background-color 0.3s ease, color 0.3s ease;
} }
+3 -2
View File
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import LoginForm from '../components/auth/LoginForm'; import LoginForm from '../components/auth/LoginForm';
import RegisterForm from '../components/auth/RegisterForm'; import RegisterForm from '../components/auth/RegisterForm';
import miniLogo from '../assets/mini-logo.png';
export default function AuthPage() { export default function AuthPage() {
const [isLogin, setIsLogin] = useState(true); const [isLogin, setIsLogin] = useState(true);
@@ -25,8 +26,8 @@ export default function AuthPage() {
<div className="bg-card-white rounded-[32px] shadow-soft px-10 py-12"> <div className="bg-card-white rounded-[32px] shadow-soft px-10 py-12">
{/* Logo */} {/* Logo */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="auth-logo w-[100px] h-[100px] mx-auto mb-8 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 border-[3px] border-[#EBEBE6]"> <div className="auth-logo w-[100px] h-[100px] mx-auto mb-8 flex items-center justify-center">
A <img src={miniLogo} alt="Aether Logo" className="w-full h-full object-contain" />
</div> </div>
<div className="font-lora text-accent-olive text-lg tracking-[2px] mb-6"> <div className="font-lora text-accent-olive text-lg tracking-[2px] mb-6">
AETHER AETHER
+116 -25
View File
@@ -1,53 +1,144 @@
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { MessageSquarePlus, Settings, User } from 'lucide-react';
import miniLogo from '../assets/mini-logo.png';
import VerificationBanner from '../components/common/VerificationBanner';
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 navigate = useNavigate(); const navigate = useNavigate();
const handleLogout = async () => {
// TODO: Call logout API
logout();
window.location.href = '/auth';
};
return ( return (
<div className="min-h-screen bg-gray-100"> <div className="min-h-screen h-screen flex" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div className="h-screen flex">
{/* Sidebar */} {/* Sidebar */}
<div className="w-80 bg-card-white border-r border-gray-200"> <div className="w-80 flex flex-col shadow-soft" style={{ backgroundColor: 'var(--bg-card)' }}>
<div className="p-4 border-b border-gray-200"> {/* Header */}
<h1 className="text-2xl font-lora font-semibold text-accent-olive">Aether</h1> <div className="p-6 border-b" style={{ borderColor: 'var(--border-color)' }}>
<h1 className="text-3xl font-lora font-semibold text-center tracking-wider" style={{ color: 'var(--accent-primary)' }}>
AETHER
</h1>
</div> </div>
{/* Verification Banner */}
{user && !user.is_verified && (
<div className="p-4"> <div className="p-4">
<p className="text-sm text-text-muted font-inter">Привет, {user?.username}!</p> <VerificationBanner userEmail={user.email} />
<button </div>
)}
{/* User Profile Section */}
<div className="p-6 border-b" style={{ borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-4">
{/* Avatar */}
<div
className="w-14 h-14 flex items-center justify-center flex-shrink-0 cursor-pointer hover:opacity-90 transition"
style={{
backgroundImage: user?.avatar_url ? `url(${user.avatar_url})` : undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
borderRadius: user?.avatar_url ? '50%' : '0',
}}
onClick={() => navigate('/profile')} onClick={() => navigate('/profile')}
className="mt-2 text-sm text-accent-olive hover:opacity-70 font-inter block" title="Перейти в профиль"
> >
Мой профиль {!user?.avatar_url && (
</button> <img src={miniLogo} alt="Avatar" className="w-full h-full object-contain" />
)}
</div>
{/* User Info */}
<div className="flex-1 min-w-0">
<h3 className="font-inter font-semibold truncate" style={{ color: 'var(--text-primary)' }}>
{user?.display_name || user?.username}
</h3>
<p className="text-sm font-inter truncate" style={{ color: 'var(--text-secondary)' }}>
@{user?.username}
</p>
</div>
{/* Settings Icon */}
<button <button
onClick={handleLogout} onClick={() => navigate('/settings')}
className="mt-2 text-sm text-error-soft hover:text-red-700 font-inter" className="p-2 hover:bg-gray-100 rounded-full transition"
title="Настройки"
> >
Выйти <Settings size={20} style={{ color: 'var(--text-secondary)' }} />
</button> </button>
</div> </div>
<div className="flex-1 overflow-y-auto"> {/* Action Button */}
<div className="p-4 text-center text-text-muted font-inter"> <div className="mt-4">
Чаты скоро появятся... <motion.button
onClick={() => navigate('/profile')}
whileTap={{ scale: 0.95 }}
className="w-full flex items-center justify-center gap-2 py-2 px-3 rounded-xl font-inter text-sm font-medium transition hover:opacity-80"
style={{ backgroundColor: 'var(--bg-input)', color: 'var(--accent-primary)' }}
>
<User size={16} />
Профиль
</motion.button>
</div> </div>
</div> </div>
{/* New Chat Button */}
<div className="p-4">
<motion.button
whileTap={{ scale: 0.98 }}
className="w-full flex items-center justify-center gap-3 py-3 px-4 rounded-2xl font-inter font-semibold shadow-sm hover:shadow-md transition"
style={{ backgroundColor: 'var(--accent-primary)', color: 'white' }}
>
<MessageSquarePlus size={20} />
Новый чат
</motion.button>
</div>
{/* Chats List */}
<div className="flex-1 overflow-y-auto px-4">
<div className="space-y-2">
{/* Placeholder for future chats */}
<div className="text-center py-12 px-4">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gradient-to-br from-accent-terracotta/20 to-accent-olive/20 flex items-center justify-center">
<MessageSquarePlus size={32} style={{ color: 'var(--accent-primary)', opacity: 0.5 }} />
</div>
<p className="font-inter text-sm" style={{ color: 'var(--text-secondary)' }}>
Пока нет чатов
</p>
<p className="font-inter text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
Начните новый диалог
</p>
</div>
</div>
</div>
{/* Footer Info */}
<div className="p-4 border-t text-center" style={{ borderColor: 'var(--border-color)' }}>
<p className="text-xs font-inter" style={{ color: 'var(--text-secondary)' }}>
Aether Chat v1.0
</p>
</div>
</div> </div>
{/* Main Chat Area */} {/* Main Chat Area */}
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<div className="flex-1 flex items-center justify-center text-text-muted font-inter"> {/* Empty State */}
Выберите чат или начните новый <div className="flex-1 flex items-center justify-center p-8">
<div className="text-center max-w-md">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.3 }}
>
<div className="w-32 h-32 mx-auto mb-6 flex items-center justify-center">
<img src={miniLogo} alt="Aether Logo" className="w-full h-full object-contain" />
</div>
<h2 className="text-2xl font-lora font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>
Добро пожаловать в Aether
</h2>
<p className="font-inter" style={{ color: 'var(--text-secondary)' }}>
Выберите существующий чат из списка слева или создайте новый, чтобы начать общение
</p>
</motion.div>
</div> </div>
</div> </div>
</div> </div>
+152
View File
@@ -0,0 +1,152 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { ArrowLeft } from 'lucide-react';
import { authService } from '../services/authService';
import miniLogo from '../assets/mini-logo.png';
export default function ForgotPasswordPage() {
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await authService.requestPasswordReset(username);
setSuccess(true);
} catch (err: any) {
setError(err.response?.data?.detail || 'Ошибка отправки письма');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center p-4 relative" style={{ backgroundColor: 'var(--bg-primary)' }}>
{/* Subtle texture background */}
<div className="absolute inset-0 opacity-30 pointer-events-none"
style={{
backgroundImage: 'radial-gradient(circle at center, rgba(0,0,0,0.03) 1%, transparent 1%)',
backgroundSize: '20px 20px'
}}>
</div>
<motion.div
className="w-full max-w-md relative z-10"
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
>
<div className="rounded-[32px] shadow-soft p-8" style={{ backgroundColor: 'var(--bg-card)' }}>
<div className="text-center mb-8">
<div className="w-20 h-20 mx-auto mb-4 flex items-center justify-center">
<img src={miniLogo} alt="Aether Logo" className="w-full h-full object-contain" />
</div>
<div className="font-lora text-lg tracking-[2px] mb-6" style={{ color: 'var(--accent-primary)' }}>
AETHER
</div>
<h2 className="text-xl font-lora font-semibold" style={{ color: 'var(--text-primary)' }}>
Восстановление пароля
</h2>
<p className="mt-2 text-sm font-inter" style={{ color: 'var(--text-secondary)' }}>
Введите почту или никнейм для получения ссылки на сброс пароля
</p>
</div>
{!success ? (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="username" className="block font-lora italic text-[15px] mb-2" style={{ color: 'var(--text-secondary)' }}>
Почта или никнейм
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="example@mail.com"
autoFocus
className="w-full px-0 py-3 bg-transparent border-0 border-b-2 font-inter placeholder:text-text-muted/50 focus:outline-none transition-all duration-300"
style={{
color: 'var(--text-primary)',
borderColor: 'var(--border-color)',
}}
required
/>
</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: 'var(--error-soft)',
color: 'var(--error-color)',
border: '2px solid var(--error-color)',
}}
>
{error}
</motion.div>
)}
<motion.button
type="submit"
disabled={isLoading}
whileTap={{ scale: 0.95 }}
style={{ backgroundColor: 'var(--accent-primary)', color: 'white' }}
className="w-full mt-8 py-[18px] px-10 rounded-full font-inter font-semibold uppercase tracking-wider hover:shadow-lg transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Отправка...' : 'Отправить ссылку'}
</motion.button>
<div className="text-center">
<button
type="button"
onClick={() => navigate('/auth')}
className="flex items-center gap-2 mx-auto font-inter text-sm hover:opacity-70 transition"
style={{ color: 'var(--accent-primary)' }}
>
<ArrowLeft size={16} />
Вернуться к входу
</button>
</div>
</form>
) : (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center space-y-4"
>
<div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto"
style={{ backgroundColor: 'var(--accent-primary-soft)' }}>
<svg className="w-8 h-8" style={{ color: 'var(--accent-primary)' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="text-lg font-lora font-semibold" style={{ color: 'var(--text-primary)' }}>
Письмо отправлено!
</h3>
<p className="text-sm font-inter" style={{ color: 'var(--text-secondary)' }}>
Проверьте почту и следуйте инструкциям для сброса пароля
</p>
<motion.button
onClick={() => navigate('/auth')}
whileTap={{ scale: 0.95 }}
className="mt-4 px-6 py-3 rounded-full font-inter font-semibold transition hover:shadow-lg"
style={{ backgroundColor: 'var(--accent-primary)', color: 'white' }}
>
Вернуться к входу
</motion.button>
</motion.div>
)}
</div>
</motion.div>
</div>
);
}
+268 -37
View File
@@ -1,10 +1,13 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Camera, Trash2, LogOut, ArrowLeft } from 'lucide-react'; import { Camera, Trash2, LogOut, ArrowLeft, Settings, Eye, EyeOff } from 'lucide-react';
import { userService } from '../services/userService'; import { userService } from '../services/userService';
import type { UserUpdate } from '../services/userService'; import type { UserUpdate } from '../services/userService';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { authService } from '../services/authService';
import miniLogo from '../assets/mini-logo.png';
import VerificationBanner from '../components/common/VerificationBanner';
export default function ProfilePage() { export default function ProfilePage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -16,6 +19,15 @@ export default function ProfilePage() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState(''); const [success, setSuccess] = useState('');
const [isChangingPassword, setIsChangingPassword] = useState(false);
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showOldPassword, setShowOldPassword] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
const [passwordError, setPasswordError] = useState('');
const [passwordSuccess, setPasswordSuccess] = useState('');
const [formData, setFormData] = useState<UserUpdate>({ const [formData, setFormData] = useState<UserUpdate>({
display_name: user?.display_name || '', display_name: user?.display_name || '',
username: user?.username || '', username: user?.username || '',
@@ -104,55 +116,106 @@ export default function ProfilePage() {
} }
}; };
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
setPasswordError('');
setPasswordSuccess('');
if (newPassword !== confirmPassword) {
setPasswordError('Пароли не совпадают');
return;
}
if (newPassword.length < 8) {
setPasswordError('Пароль должен быть минимум 8 символов');
return;
}
setIsLoading(true);
try {
await authService.changePassword(oldPassword, newPassword);
setPasswordSuccess('Пароль успешно изменен');
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
setIsChangingPassword(false);
setTimeout(() => setPasswordSuccess(''), 3000);
} catch (err: any) {
setPasswordError(err.response?.data?.detail || 'Ошибка изменения пароля');
} finally {
setIsLoading(false);
}
};
if (!user) return null; if (!user) return null;
return ( return (
<div className="min-h-screen" style={{ backgroundColor: '#F5F5F1' }}> <div className="min-h-screen" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div className="max-w-4xl mx-auto p-6"> <div className="max-w-4xl mx-auto p-6">
{/* Header */} {/* Header */}
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<button <button
onClick={() => navigate('/chat')} onClick={() => navigate('/chat')}
className="flex items-center gap-2 font-inter font-medium hover:opacity-70 transition" className="flex items-center gap-2 font-inter font-medium hover:opacity-70 transition"
style={{ color: '#6B705C' }} style={{ color: 'var(--accent-primary)' }}
> >
<ArrowLeft size={20} /> <ArrowLeft size={20} />
Назад к чатам Назад к чатам
</button> </button>
<div className="flex items-center gap-3">
<button
onClick={() => navigate('/settings')}
className="flex items-center gap-2 font-inter font-medium hover:opacity-70 transition"
style={{ color: 'var(--text-secondary)' }}
title="Настройки"
>
<Settings size={20} />
</button>
<button <button
onClick={handleLogout} onClick={handleLogout}
className="flex items-center gap-2 font-inter font-medium hover:opacity-70 transition" className="flex items-center gap-2 font-inter font-medium hover:opacity-70 transition"
style={{ color: '#C79A8B' }} style={{ color: 'var(--error-color)' }}
> >
<LogOut size={20} /> <LogOut size={20} />
Выйти Выйти
</button> </button>
</div> </div>
</div>
{/* Verification Banner */}
{!user.is_verified && (
<VerificationBanner userEmail={user.email} />
)}
{/* Profile Card */} {/* Profile Card */}
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-3xl shadow-soft p-8" className="rounded-3xl shadow-soft p-8"
style={{ backgroundColor: 'var(--bg-card)' }}
> >
{/* Avatar Section */} {/* Avatar Section */}
<div className="flex flex-col items-center mb-8"> <div className="flex flex-col items-center mb-8">
<div className="relative"> <div className="relative">
<div <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" className="w-32 h-32 flex items-center justify-center overflow-hidden"
style={{ style={{
backgroundImage: user.avatar_url ? `url(${user.avatar_url})` : undefined, backgroundImage: user.avatar_url ? `url(${user.avatar_url})` : undefined,
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',
borderRadius: user.avatar_url ? '50%' : '0',
}} }}
> >
{!user.avatar_url && user.username[0].toUpperCase()} {!user.avatar_url && (
<img src={miniLogo} alt="Avatar" className="w-full h-full object-contain" />
)}
</div> </div>
<label <label
htmlFor="avatar-upload" htmlFor="avatar-upload"
className="absolute bottom-0 right-0 p-2 rounded-full cursor-pointer hover:opacity-80 transition" className="absolute bottom-0 right-0 p-2 rounded-full cursor-pointer hover:opacity-80 transition"
style={{ backgroundColor: '#6B705C' }} style={{ backgroundColor: 'var(--accent-primary)' }}
> >
<Camera size={20} className="text-white" /> <Camera size={20} className="text-white" />
<input <input
@@ -170,7 +233,7 @@ export default function ProfilePage() {
type="button" type="button"
onClick={handleDeleteAvatar} onClick={handleDeleteAvatar}
className="absolute bottom-0 left-0 p-2 rounded-full hover:opacity-80 transition" className="absolute bottom-0 left-0 p-2 rounded-full hover:opacity-80 transition"
style={{ backgroundColor: '#C79A8B' }} style={{ backgroundColor: 'var(--error-color)' }}
disabled={isLoading} disabled={isLoading}
> >
<Trash2 size={20} className="text-white" /> <Trash2 size={20} className="text-white" />
@@ -178,10 +241,10 @@ export default function ProfilePage() {
)} )}
</div> </div>
<h1 className="mt-4 text-2xl font-lora font-semibold" style={{ color: '#2C2C2C' }}> <h1 className="mt-4 text-2xl font-lora font-semibold" style={{ color: 'var(--text-primary)' }}>
{user.username} {user.username}
</h1> </h1>
<p className="font-inter text-sm" style={{ color: '#8B8B8B' }}> <p className="font-inter text-sm" style={{ color: 'var(--text-secondary)' }}>
{user.email} {user.email}
</p> </p>
</div> </div>
@@ -190,7 +253,7 @@ export default function ProfilePage() {
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<label className="block font-lora italic text-[15px] mb-2" style={{ color: '#8B8B8B' }}> <label className="block font-lora italic text-[15px] mb-2" style={{ color: 'var(--text-secondary)' }}>
Имя для отображения Имя для отображения
</label> </label>
<input <input
@@ -200,15 +263,15 @@ export default function ProfilePage() {
disabled={!isEditing} disabled={!isEditing}
className="w-full px-4 py-3 rounded-xl font-inter transition-all" className="w-full px-4 py-3 rounded-xl font-inter transition-all"
style={{ style={{
backgroundColor: isEditing ? '#F9F9F7' : '#EFEFEF', backgroundColor: isEditing ? 'var(--bg-input)' : 'var(--bg-input-disabled)',
color: '#2C2C2C', color: 'var(--text-primary)',
border: '2px solid transparent', border: '2px solid transparent',
}} }}
/> />
</div> </div>
<div> <div>
<label className="block font-lora italic text-[15px] mb-2" style={{ color: '#8B8B8B' }}> <label className="block font-lora italic text-[15px] mb-2" style={{ color: 'var(--text-secondary)' }}>
Никнейм Никнейм
</label> </label>
<input <input
@@ -218,15 +281,15 @@ export default function ProfilePage() {
disabled={!isEditing} disabled={!isEditing}
className="w-full px-4 py-3 rounded-xl font-inter transition-all" className="w-full px-4 py-3 rounded-xl font-inter transition-all"
style={{ style={{
backgroundColor: isEditing ? '#F9F9F7' : '#EFEFEF', backgroundColor: isEditing ? 'var(--bg-input)' : 'var(--bg-input-disabled)',
color: '#2C2C2C', color: 'var(--text-primary)',
border: '2px solid transparent', border: '2px solid transparent',
}} }}
/> />
</div> </div>
<div> <div>
<label className="block font-lora italic text-[15px] mb-2" style={{ color: '#8B8B8B' }}> <label className="block font-lora italic text-[15px] mb-2" style={{ color: 'var(--text-secondary)' }}>
Дата рождения Дата рождения
</label> </label>
<input <input
@@ -236,8 +299,8 @@ export default function ProfilePage() {
disabled={!isEditing} disabled={!isEditing}
className="w-full px-4 py-3 rounded-xl font-inter transition-all" className="w-full px-4 py-3 rounded-xl font-inter transition-all"
style={{ style={{
backgroundColor: isEditing ? '#F9F9F7' : '#EFEFEF', backgroundColor: isEditing ? 'var(--bg-input)' : 'var(--bg-input-disabled)',
color: '#2C2C2C', color: 'var(--text-primary)',
border: '2px solid transparent', border: '2px solid transparent',
}} }}
/> />
@@ -245,7 +308,7 @@ export default function ProfilePage() {
</div> </div>
<div> <div>
<label className="block font-lora italic text-[15px] mb-2" style={{ color: '#8B8B8B' }}> <label className="block font-lora italic text-[15px] mb-2" style={{ color: 'var(--text-secondary)' }}>
О себе О себе
</label> </label>
<textarea <textarea
@@ -255,8 +318,8 @@ export default function ProfilePage() {
rows={4} rows={4}
className="w-full px-4 py-3 rounded-xl font-inter transition-all resize-none" className="w-full px-4 py-3 rounded-xl font-inter transition-all resize-none"
style={{ style={{
backgroundColor: isEditing ? '#F9F9F7' : '#EFEFEF', backgroundColor: isEditing ? 'var(--bg-input)' : 'var(--bg-input-disabled)',
color: '#2C2C2C', color: 'var(--text-primary)',
border: '2px solid transparent', border: '2px solid transparent',
}} }}
placeholder="Расскажите о себе..." placeholder="Расскажите о себе..."
@@ -269,9 +332,9 @@ export default function ProfilePage() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="p-3 rounded-xl text-sm font-inter" className="p-3 rounded-xl text-sm font-inter"
style={{ style={{
backgroundColor: 'rgba(199, 154, 139, 0.1)', backgroundColor: 'var(--error-soft)',
color: '#C79A8B', color: 'var(--error-color)',
border: '2px solid #C79A8B', border: '2px solid var(--error-color)',
}} }}
> >
{error} {error}
@@ -284,9 +347,9 @@ export default function ProfilePage() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="p-3 rounded-xl text-sm font-inter" className="p-3 rounded-xl text-sm font-inter"
style={{ style={{
backgroundColor: 'rgba(107, 112, 92, 0.1)', backgroundColor: 'var(--accent-primary-soft)',
color: '#6B705C', color: 'var(--accent-primary)',
border: '2px solid #6B705C', border: '2px solid var(--accent-primary)',
}} }}
> >
{success} {success}
@@ -297,9 +360,12 @@ export default function ProfilePage() {
{!isEditing ? ( {!isEditing ? (
<motion.button <motion.button
type="button" type="button"
onClick={() => setIsEditing(true)} onClick={(e) => {
e.preventDefault();
setIsEditing(true);
}}
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
style={{ backgroundColor: '#6B705C', color: 'white' }} style={{ backgroundColor: 'var(--accent-primary)', color: 'white' }}
className="flex-1 py-3 px-6 rounded-full font-inter font-semibold hover:shadow-lg transition-all" className="flex-1 py-3 px-6 rounded-full font-inter font-semibold hover:shadow-lg transition-all"
> >
Редактировать профиль Редактировать профиль
@@ -310,19 +376,29 @@ export default function ProfilePage() {
type="submit" type="submit"
disabled={isLoading} disabled={isLoading}
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
style={{ backgroundColor: '#6B705C', color: 'white' }} style={{ backgroundColor: 'var(--accent-primary)', color: 'white' }}
className="flex-1 py-3 px-6 rounded-full font-inter font-semibold hover:shadow-lg transition-all disabled:opacity-50" className="flex-1 py-3 px-6 rounded-full font-inter font-semibold hover:shadow-lg transition-all disabled:opacity-50"
> >
{isLoading ? 'Сохранение...' : 'Сохранить'} {isLoading ? 'Сохранение...' : 'Сохранить'}
</motion.button> </motion.button>
<motion.button <motion.button
type="button" type="button"
onClick={() => { onClick={(e) => {
e.preventDefault();
setIsEditing(false); setIsEditing(false);
setError(''); setError('');
// Восстанавливаем оригинальные данные при отмене
if (user) {
setFormData({
display_name: user.display_name || '',
username: user.username,
description: user.description || '',
birth_day: user.birth_day || '',
});
}
}} }}
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
style={{ backgroundColor: '#8B8B8B', color: 'white' }} style={{ backgroundColor: 'var(--text-secondary)', color: 'white' }}
className="flex-1 py-3 px-6 rounded-full font-inter font-semibold hover:shadow-lg transition-all" className="flex-1 py-3 px-6 rounded-full font-inter font-semibold hover:shadow-lg transition-all"
> >
Отмена Отмена
@@ -332,15 +408,170 @@ export default function ProfilePage() {
</div> </div>
</form> </form>
{/* Password Change Section */}
<div className="mt-8 pt-8 border-t" style={{ borderColor: 'var(--border-color)' }}>
<h3 className="font-lora font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Безопасность
</h3>
{!isChangingPassword ? (
<motion.button
type="button"
onClick={() => setIsChangingPassword(true)}
whileTap={{ scale: 0.95 }}
className="py-3 px-6 rounded-full font-inter font-semibold transition hover:shadow-lg"
style={{ backgroundColor: 'var(--bg-input)', color: 'var(--accent-primary)' }}
>
Изменить пароль
</motion.button>
) : (
<form onSubmit={handleChangePassword} className="space-y-4">
<div>
<label className="block font-lora italic text-[15px] mb-2" style={{ color: 'var(--text-secondary)' }}>
Старый пароль
</label>
<div className="relative">
<input
type={showOldPassword ? 'text' : 'password'}
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
className="w-full px-4 py-3 pr-12 rounded-xl font-inter transition-all"
style={{
backgroundColor: 'var(--bg-input)',
color: 'var(--text-primary)',
border: '2px solid transparent',
}}
required
/>
<button
type="button"
onClick={() => setShowOldPassword(!showOldPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2"
style={{ color: 'var(--text-secondary)' }}
>
{showOldPassword ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div>
<div>
<label className="block font-lora italic text-[15px] mb-2" style={{ color: 'var(--text-secondary)' }}>
Новый пароль
</label>
<div className="relative">
<input
type={showNewPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Минимум 8 символов"
className="w-full px-4 py-3 pr-12 rounded-xl font-inter transition-all"
style={{
backgroundColor: 'var(--bg-input)',
color: 'var(--text-primary)',
border: '2px solid transparent',
}}
required
minLength={8}
/>
<button
type="button"
onClick={() => setShowNewPassword(!showNewPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2"
style={{ color: 'var(--text-secondary)' }}
>
{showNewPassword ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div>
<div>
<label className="block font-lora italic text-[15px] mb-2" style={{ color: 'var(--text-secondary)' }}>
Подтвердите новый пароль
</label>
<input
type={showNewPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Повторите новый пароль"
className="w-full px-4 py-3 rounded-xl font-inter transition-all"
style={{
backgroundColor: 'var(--bg-input)',
color: 'var(--text-primary)',
border: '2px solid transparent',
}}
required
/>
</div>
{passwordError && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="p-3 rounded-xl text-sm font-inter"
style={{
backgroundColor: 'var(--error-soft)',
color: 'var(--error-color)',
border: '2px solid var(--error-color)',
}}
>
{passwordError}
</motion.div>
)}
{passwordSuccess && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="p-3 rounded-xl text-sm font-inter"
style={{
backgroundColor: 'var(--accent-primary-soft)',
color: 'var(--accent-primary)',
border: '2px solid var(--accent-primary)',
}}
>
{passwordSuccess}
</motion.div>
)}
<div className="flex gap-4">
<motion.button
type="submit"
disabled={isLoading}
whileTap={{ scale: 0.95 }}
className="flex-1 py-3 px-6 rounded-full font-inter font-semibold transition hover:shadow-lg disabled:opacity-50"
style={{ backgroundColor: 'var(--accent-primary)', color: 'white' }}
>
{isLoading ? 'Сохранение...' : 'Изменить пароль'}
</motion.button>
<motion.button
type="button"
onClick={() => {
setIsChangingPassword(false);
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
setPasswordError('');
}}
whileTap={{ scale: 0.95 }}
className="flex-1 py-3 px-6 rounded-full font-inter font-semibold transition hover:shadow-lg"
style={{ backgroundColor: 'var(--text-secondary)', color: 'white' }}
>
Отмена
</motion.button>
</div>
</form>
)}
</div>
{/* Danger Zone */} {/* Danger Zone */}
<div className="mt-8 pt-8 border-t" style={{ borderColor: '#E5E5E5' }}> <div className="mt-8 pt-8 border-t" style={{ borderColor: 'var(--border-color)' }}>
<h3 className="font-lora font-semibold mb-4" style={{ color: '#C79A8B' }}> <h3 className="font-lora font-semibold mb-4" style={{ color: 'var(--error-color)' }}>
Опасная зона Опасная зона
</h3> </h3>
<motion.button <motion.button
onClick={handleDeleteAccount} onClick={handleDeleteAccount}
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
style={{ backgroundColor: '#C79A8B', color: 'white' }} style={{ backgroundColor: 'var(--error-color)', color: 'white' }}
className="py-3 px-6 rounded-full font-inter font-semibold hover:shadow-lg transition-all" className="py-3 px-6 rounded-full font-inter font-semibold hover:shadow-lg transition-all"
> >
Удалить аккаунт Удалить аккаунт
+3 -2
View File
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
import { Eye, EyeOff } from 'lucide-react'; import { Eye, EyeOff } from 'lucide-react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { authService } from '../services/authService'; import { authService } from '../services/authService';
import miniLogo from '../assets/mini-logo.png';
export default function ResetPasswordPage() { export default function ResetPasswordPage() {
const { token } = useParams<{ token: string }>(); const { token } = useParams<{ token: string }>();
@@ -70,8 +71,8 @@ export default function ResetPasswordPage() {
> >
<div className="bg-card-white rounded-[32px] shadow-soft p-8"> <div className="bg-card-white rounded-[32px] shadow-soft p-8">
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="w-20 h-20 mx-auto mb-4 rounded-full bg-gradient-to-br from-accent-terracotta to-accent-olive flex items-center justify-center text-white text-3xl font-lora shadow-logo border-[3px] border-[#EBEBE6]"> <div className="w-20 h-20 mx-auto mb-4 flex items-center justify-center">
A <img src={miniLogo} alt="Aether Logo" className="w-full h-full object-contain" />
</div> </div>
<div className="font-lora text-lg tracking-[2px] mb-6" style={{ color: '#6B705C' }}> <div className="font-lora text-lg tracking-[2px] mb-6" style={{ color: '#6B705C' }}>
AETHER AETHER
+125
View File
@@ -0,0 +1,125 @@
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { ArrowLeft, Moon, Sun } from 'lucide-react';
import { useThemeStore } from '../store/themeStore';
export default function SettingsPage() {
const navigate = useNavigate();
const { theme, setTheme } = useThemeStore();
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div className="max-w-4xl mx-auto p-6">
{/* Header */}
<div className="mb-6">
<button
onClick={() => navigate('/chat')}
className="flex items-center gap-2 font-inter font-medium hover:opacity-70 transition"
style={{ color: 'var(--accent-primary)' }}
>
<ArrowLeft size={20} />
Назад к чатам
</button>
</div>
{/* Settings Card */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="rounded-3xl shadow-soft p-8"
style={{ backgroundColor: 'var(--bg-card)' }}
>
<h1 className="text-3xl font-lora font-semibold mb-8" style={{ color: 'var(--text-primary)' }}>
Настройки
</h1>
{/* Appearance Section */}
<div className="space-y-6">
<div>
<h2 className="text-xl font-lora font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Внешний вид
</h2>
{/* Theme Selector */}
<div className="space-y-3">
<label className="block font-lora italic text-[15px] mb-3" style={{ color: 'var(--text-secondary)' }}>
Тема оформления
</label>
<div className="grid grid-cols-2 gap-4">
{/* Light Theme */}
<motion.button
whileTap={{ scale: 0.98 }}
onClick={() => setTheme('light')}
className="relative p-6 rounded-2xl border-2 transition-all"
style={{
backgroundColor: theme === 'light' ? 'var(--accent-primary-soft)' : 'var(--bg-input)',
borderColor: theme === 'light' ? 'var(--accent-primary)' : 'transparent',
}}
>
<div className="flex flex-col items-center gap-3">
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-yellow-400 to-orange-500 flex items-center justify-center shadow-lg">
<Sun size={32} className="text-white" />
</div>
<span className="font-inter font-semibold" style={{ color: 'var(--text-primary)' }}>
Светлая тема
</span>
{theme === 'light' && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute top-3 right-3 w-6 h-6 rounded-full flex items-center justify-center"
style={{ backgroundColor: 'var(--accent-primary)' }}
>
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</motion.div>
)}
</div>
</motion.button>
{/* Dark Theme */}
<motion.button
whileTap={{ scale: 0.98 }}
onClick={() => setTheme('dark')}
className="relative p-6 rounded-2xl border-2 transition-all"
style={{
backgroundColor: theme === 'dark' ? 'var(--accent-primary-soft)' : 'var(--bg-input)',
borderColor: theme === 'dark' ? 'var(--accent-primary)' : 'transparent',
}}
>
<div className="flex flex-col items-center gap-3">
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-indigo-600 to-purple-700 flex items-center justify-center shadow-lg">
<Moon size={32} className="text-white" />
</div>
<span className="font-inter font-semibold" style={{ color: 'var(--text-primary)' }}>
Темная тема
</span>
{theme === 'dark' && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute top-3 right-3 w-6 h-6 rounded-full flex items-center justify-center"
style={{ backgroundColor: 'var(--accent-primary)' }}
>
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</motion.div>
)}
</div>
</motion.button>
</div>
<p className="text-sm font-inter mt-3" style={{ color: 'var(--text-secondary)' }}>
Выберите тему, которая лучше всего подходит для ваших глаз
</p>
</div>
</div>
</div>
</motion.div>
</div>
</div>
);
}
+4
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { authService } from '../services/authService'; import { authService } from '../services/authService';
import miniLogo from '../assets/mini-logo.png';
export default function VerifyEmailPage() { export default function VerifyEmailPage() {
const { token } = useParams<{ token: string }>(); const { token } = useParams<{ token: string }>();
@@ -41,6 +42,9 @@ export default function VerifyEmailPage() {
> >
<div className="bg-card-white rounded-[32px] shadow-soft p-8 text-center"> <div className="bg-card-white rounded-[32px] shadow-soft p-8 text-center">
<div className="mb-6"> <div className="mb-6">
<div className="w-20 h-20 mx-auto mb-4 flex items-center justify-center">
<img src={miniLogo} alt="Aether Logo" className="w-full h-full object-contain" />
</div>
<h1 className="text-2xl font-lora font-semibold text-accent-olive mb-2">Aether</h1> <h1 className="text-2xl font-lora font-semibold text-accent-olive mb-2">Aether</h1>
</div> </div>
+19 -9
View File
@@ -11,14 +11,17 @@ const apiClient = axios.create({
}); });
let isRefreshing = false; let isRefreshing = false;
let failedQueue: any[] = []; let failedQueue: Array<{
resolve: (value?: any) => void;
reject: (reason?: any) => void;
}> = [];
const processQueue = (error: any, token: string | null = null) => { const processQueue = (error: any = null) => {
failedQueue.forEach(prom => { failedQueue.forEach(prom => {
if (error) { if (error) {
prom.reject(error); prom.reject(error);
} else { } else {
prom.resolve(token); prom.resolve();
} }
}); });
@@ -31,8 +34,14 @@ apiClient.interceptors.response.use(
async (error) => { async (error) => {
const originalRequest = error.config; const originalRequest = error.config;
// Если ошибка 401 и это не запрос на refresh и не повторный запрос // Если ошибка 401 и запрос ещё не повторялся
if (error.response?.status === 401 && !originalRequest._retry && originalRequest.url !== '/auth/refresh') { if (error.response?.status === 401 && !originalRequest._retry) {
// Если это сам запрос на refresh - редирект на логин
if (originalRequest.url?.includes('/auth/refresh')) {
isRefreshing = false;
window.location.href = '/auth';
return Promise.reject(error);
}
// Проверяем, находимся ли на публичной странице // Проверяем, находимся ли на публичной странице
const publicPaths = ['/auth', '/verify-email', '/reset-password']; const publicPaths = ['/auth', '/verify-email', '/reset-password'];
@@ -43,8 +52,8 @@ apiClient.interceptors.response.use(
return Promise.reject(error); return Promise.reject(error);
} }
if (isRefreshing) {
// Если refresh уже в процессе, добавляем запрос в очередь // Если refresh уже в процессе, добавляем запрос в очередь
if (isRefreshing) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject }); failedQueue.push({ resolve, reject });
}) })
@@ -63,14 +72,15 @@ apiClient.interceptors.response.use(
// Пытаемся обновить токен // Пытаемся обновить токен
await apiClient.post('/auth/refresh'); await apiClient.post('/auth/refresh');
// Если успешно, обрабатываем очередь и повторяем оригинальный запрос // Если успешно, обрабатываем очередь
processQueue(null, 'refreshed'); processQueue();
isRefreshing = false; isRefreshing = false;
// Повторяем оригинальный запрос с обновленным токеном
return apiClient(originalRequest); return apiClient(originalRequest);
} catch (refreshError) { } catch (refreshError) {
// Если refresh не удался, очищаем очередь и редиректим на логин // Если refresh не удался, очищаем очередь и редиректим на логин
processQueue(refreshError, null); processQueue(refreshError);
isRefreshing = false; isRefreshing = false;
window.location.href = '/auth'; window.location.href = '/auth';
+21
View File
@@ -8,6 +8,7 @@ export interface LoginData {
export interface RegisterData { export interface RegisterData {
email: string; email: string;
display_name: string;
username: string; username: string;
password: string; password: string;
} }
@@ -41,6 +42,11 @@ export const authService = {
return response.data; return response.data;
}, },
resendVerificationEmail: async () => {
const response = await apiClient.post('/auth/email/resend-verification');
return response.data;
},
resetPassword: async (token: string, newPassword: string) => { resetPassword: async (token: string, newPassword: string) => {
const response = await apiClient.post(`/auth/password/reset/${token}`, null, { const response = await apiClient.post(`/auth/password/reset/${token}`, null, {
params: { new_password: newPassword } params: { new_password: newPassword }
@@ -48,6 +54,21 @@ export const authService = {
return response.data; return response.data;
}, },
requestPasswordReset: async (username_email: string) => {
const response = await apiClient.post('/auth/password/reset', null, {
params: { username_email }
});
return response.data;
},
changePassword: async (oldPassword: string, newPassword: string) => {
const response = await apiClient.post('/auth/password/change', {
old_password: oldPassword,
new_password: newPassword
});
return response.data;
},
getCurrentUser: async (): Promise<User> => { getCurrentUser: async (): Promise<User> => {
const response = await apiClient.get('/users/me'); const response = await apiClient.get('/users/me');
return response.data; return response.data;
+31
View File
@@ -0,0 +1,31 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type Theme = 'light' | 'dark';
interface ThemeStore {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
}
export const useThemeStore = create<ThemeStore>()(
persist(
(set) => ({
theme: 'light',
setTheme: (theme) => {
set({ theme });
document.documentElement.setAttribute('data-theme', theme);
},
toggleTheme: () =>
set((state) => {
const newTheme = state.theme === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', newTheme);
return { theme: newTheme };
}),
}),
{
name: 'theme-storage',
}
)
);