Compare commits

12 Commits

Author SHA1 Message Date
33f49f4e47 Fix security 2025-12-18 17:15:21 +07:00
57bad3b4a8 Redesign health service + create backup service 2025-12-18 03:35:13 +07:00
e43e579329 Finish 2025-12-17 22:13:39 +07:00
967176fab8 service 2025-12-17 22:10:01 +07:00
f371178518 500 2025-12-17 21:50:10 +07:00
3920a9bf8c teapot 2025-12-17 21:38:43 +07:00
790b2d6083 ПОЧТИ ГОТОВО 2025-12-17 20:59:47 +07:00
675a0fea0c PIZDEC 2025-12-17 20:29:22 +07:00
0b3837b08e Zaebalsya 2025-12-17 20:19:26 +07:00
7e7cdbcd76 Fix 2025-12-17 19:50:55 +07:00
debdd66458 Fix UI 2025-12-17 18:27:09 +07:00
332491454d Redesign p1 2025-12-17 02:03:33 +07:00
77 changed files with 8710 additions and 2863 deletions

View File

@@ -10,6 +10,7 @@ OPENAI_API_KEY=sk-...
# Telegram Bot # Telegram Bot
TELEGRAM_BOT_TOKEN=123456:ABC-DEF... TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
BOT_API_SECRET=change_me_random_secret_for_bot_api
# S3 Storage - FirstVDS (set S3_ENABLED=true to use) # S3 Storage - FirstVDS (set S3_ENABLED=true to use)
S3_ENABLED=false S3_ENABLED=false
@@ -20,5 +21,14 @@ S3_SECRET_ACCESS_KEY=your-secret-access-key
S3_ENDPOINT_URL=https://s3.firstvds.ru S3_ENDPOINT_URL=https://s3.firstvds.ru
S3_PUBLIC_URL=https://your-bucket-name.s3.firstvds.ru S3_PUBLIC_URL=https://your-bucket-name.s3.firstvds.ru
# Backup Service
TELEGRAM_ADMIN_ID=947392854
S3_BACKUP_PREFIX=backups/
BACKUP_RETENTION_DAYS=14
# Status Service (optional - for external monitoring)
EXTERNAL_URL=https://your-domain.com
PUBLIC_URL=https://your-domain.com
# Frontend (for build) # Frontend (for build)
VITE_API_URL=/api/v1 VITE_API_URL=/api/v1

View File

@@ -31,6 +31,12 @@ help:
@echo " make shell - Open backend shell" @echo " make shell - Open backend shell"
@echo " make frontend-sh - Open frontend shell" @echo " make frontend-sh - Open frontend shell"
@echo "" @echo ""
@echo " Backup:"
@echo " make backup-now - Run backup immediately"
@echo " make backup-list - List available backups in S3"
@echo " make backup-restore - Restore from backup (interactive)"
@echo " make backup-logs - Show backup service logs"
@echo ""
@echo " Cleanup:" @echo " Cleanup:"
@echo " make clean - Stop and remove containers, volumes" @echo " make clean - Stop and remove containers, volumes"
@echo " make prune - Remove unused Docker resources" @echo " make prune - Remove unused Docker resources"
@@ -137,3 +143,20 @@ test-backend:
# Production # Production
prod: prod:
$(DC) -f docker-compose.yml up -d --build $(DC) -f docker-compose.yml up -d --build
# Backup
backup-now:
$(DC) exec backup python /app/backup.py
backup-list:
$(DC) exec backup python /app/restore.py
backup-restore:
@read -p "Backup filename: " file; \
$(DC) exec -it backup python /app/restore.py "$$file"
backup-logs:
$(DC) logs -f backup
backup-shell:
$(DC) exec backup bash

389
REDESIGN_PLAN.md Normal file
View File

@@ -0,0 +1,389 @@
# План редизайна фронтенда Game Marathon
## Концепция дизайна
**Стиль:** Минималистичный геймерский дизайн
- Темная база с неоновыми акцентами (cyan/purple/pink градиенты)
- Glitch эффекты на заголовках и при hover
- Glassmorphism для карточек (blur + transparency)
- Subtle grain/noise текстура на фоне
- Геометрические паттерны и линии
- Микро-анимации везде
**Цветовая палитра:**
```
Background: #0a0a0f (почти черный с синим оттенком)
Surface: #12121a (карточки)
Border: #1e1e2e (границы)
Primary: #00f0ff (cyan неон)
Secondary: #a855f7 (purple)
Accent: #f0abfc (pink)
Success: #22c55e
Error: #ef4444
Text: #e2e8f0
Text Muted: #64748b
```
---
## Фаза 1: Базовая инфраструктура
### 1.1 Обновление Tailwind Config
- [ ] Новая цветовая палитра (neon colors)
- [ ] Кастомные анимации:
- `glitch` - glitch эффект для текста
- `glow-pulse` - пульсация свечения
- `float` - плавное парение
- `slide-in-left/right/up/down` - слайды
- `scale-in` - появление с масштабом
- `shimmer` - блик на элементах
- [ ] Кастомные backdrop-blur классы
- [ ] Градиентные утилиты
### 1.2 Глобальные стили (index.css)
- [ ] CSS переменные для цветов
- [ ] Glitch keyframes анимация
- [ ] Noise/grain overlay
- [ ] Glow эффекты (box-shadow неон)
- [ ] Custom scrollbar (неоновый)
- [ ] Selection стили (выделение текста)
### 1.3 Новые UI компоненты
- [ ] `GlitchText` - текст с glitch эффектом
- [ ] `NeonButton` - кнопка с неоновым свечением
- [ ] `GlassCard` - карточка с glassmorphism
- [ ] `AnimatedCounter` - анимированные числа
- [ ] `ProgressBar` - неоновый прогресс бар
- [ ] `Badge` - бейджи со свечением
- [ ] `Skeleton` - скелетоны загрузки
- [ ] `Tooltip` - тултипы
- [ ] `Tabs` - табы с анимацией
- [ ] `Modal` - переработанная модалка
---
## Фаза 2: Layout и навигация
### 2.1 Новый Header
- [ ] Sticky header с blur при скролле
- [ ] Логотип с glitch эффектом при hover
- [ ] Анимированная навигация (underline slide)
- [ ] Notification bell с badge
- [ ] User dropdown с аватаром
- [ ] Mobile hamburger menu с slide-in
### 2.2 Sidebar (новый компонент)
- [ ] Collapsible sidebar для desktop
- [ ] Иконки с tooltip
- [ ] Active state с неоновой подсветкой
- [ ] Quick stats внизу
### 2.3 Footer
- [ ] Минималистичный footer
- [ ] Social links
- [ ] Version info
---
## Фаза 3: Страницы
### 3.1 HomePage (полный редизайн)
```
┌─────────────────────────────────────────────┐
│ HERO SECTION │
│ ┌─────────────────────────────────────┐ │
│ │ Animated background (particles/ │ │
│ │ geometric shapes) │ │
│ │ │ │
│ │ GAME <glitch>MARATHON</glitch> │ │
│ │ │ │
│ │ Tagline with typing effect │ │
│ │ │ │
│ │ [Start Playing] [Watch Demo] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ FEATURES SECTION (3 glass cards) │
│ ┌───────┐ ┌───────┐ ┌───────┐ │
│ │ Icon │ │ Icon │ │ Icon │ hover: │
│ │ Title │ │ Title │ │ Title │ lift + │
│ │ Desc │ │ Desc │ │ Desc │ glow │
│ └───────┘ └───────┘ └───────┘ │
├─────────────────────────────────────────────┤
│ HOW IT WORKS (timeline style) │
│ ○───────○───────○───────○ │
│ 1 2 3 4 │
│ Create Add Spin Win │
├─────────────────────────────────────────────┤
│ LIVE STATS (animated counters) │
│ [ 1,234 Marathons ] [ 5,678 Challenges ] │
├─────────────────────────────────────────────┤
│ CTA SECTION │
│ Ready to compete? [Join Now] │
└─────────────────────────────────────────────┘
```
### 3.2 Login/Register Pages
- [ ] Centered card с glassmorphism
- [ ] Animated background (subtle)
- [ ] Form inputs с glow при focus
- [ ] Password strength indicator
- [ ] Social login buttons (future)
- [ ] Smooth transitions между login/register
### 3.3 MarathonsPage (Dashboard)
```
┌─────────────────────────────────────────────┐
│ Header: "My Marathons" + Create button │
├─────────────────────────────────────────────┤
│ Quick Stats Bar │
│ [Active: 2] [Completed: 5] [Total Wins: 3]│
├─────────────────────────────────────────────┤
│ Filters/Tabs: All | Active | Completed │
├─────────────────────────────────────────────┤
│ Marathon Cards Grid (2-3 columns) │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Cover image/ │ │ │ │
│ │ gradient │ │ │ │
│ │ ──────────── │ │ │ │
│ │ Title │ │ │ │
│ │ Status badge │ │ │ │
│ │ Participants │ │ │ │
│ │ Progress bar │ │ │ │
│ └──────────────────┘ └──────────────────┘ │
├─────────────────────────────────────────────┤
│ Join by Code (expandable section) │
└─────────────────────────────────────────────┘
```
### 3.4 MarathonPage (Detail)
```
┌─────────────────────────────────────────────┐
│ Hero Banner │
│ ┌─────────────────────────────────────┐ │
│ │ Background gradient + pattern │ │
│ │ Marathon Title (large) │ │
│ │ Status | Dates | Participants │ │
│ │ [Play] [Leaderboard] [Settings] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ Event Banner (if active) - animated │
├────────────────────┬────────────────────────┤
│ Main Content │ Sidebar │
│ ┌──────────────┐ │ ┌──────────────────┐ │
│ │ Your Stats │ │ │ Activity Feed │ │
│ │ Points/Streak│ │ │ (scrollable) │ │
│ └──────────────┘ │ │ │ │
│ ┌──────────────┐ │ │ │ │
│ │ Quick Actions│ │ │ │ │
│ └──────────────┘ │ │ │ │
│ ┌──────────────┐ │ │ │ │
│ │ Games List │ │ │ │ │
│ │ (collapsible)│ │ │ │ │
│ └──────────────┘ │ └──────────────────┘ │
└────────────────────┴────────────────────────┘
```
### 3.5 PlayPage (Game Screen) - ГЛАВНАЯ СТРАНИЦА
```
┌─────────────────────────────────────────────┐
│ Top Bar: Points | Streak | Event Timer │
├─────────────────────────────────────────────┤
│ ┌─────────────────────────────────────┐ │
│ │ SPIN WHEEL │ │
│ │ (redesigned, neon style) │ │
│ │ ┌─────────┐ │ │
│ │ │ WHEEL │ │ │
│ │ │ │ │ │
│ │ └─────────┘ │ │
│ │ [SPIN BUTTON] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ Active Challenge Card (если есть) │
│ ┌─────────────────────────────────────┐ │
│ │ Game: [Title] | Difficulty: [★★★] │ │
│ │ ─────────────────────────────────── │ │
│ │ Challenge Title │ │
│ │ Description... │ │
│ │ ─────────────────────────────────── │ │
│ │ Points: 100 | Time: ~2h │ │
│ │ ─────────────────────────────────── │ │
│ │ Proof Upload Area │ │
│ │ [File] [URL] [Comment] │ │
│ │ ─────────────────────────────────── │ │
│ │ [Complete ✓] [Skip ✗] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ Mini Leaderboard (top 3 + you) │
└─────────────────────────────────────────────┘
```
### 3.6 LeaderboardPage
- [ ] Animated podium для top 3
- [ ] Table с hover эффектами
- [ ] Highlight для текущего пользователя
- [ ] Streak fire animation
- [ ] Sorting/filtering
### 3.7 ProfilePage
```
┌─────────────────────────────────────────────┐
│ Profile Header │
│ ┌─────────────────────────────────────┐ │
│ │ Avatar (large, glow border) │ │
│ │ Nickname [Edit] │ │
│ │ Member since: Date │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ Stats Cards (animated counters) │
│ [Marathons] [Wins] [Challenges] [Points] │
├─────────────────────────────────────────────┤
│ Achievements Section (future) │
├─────────────────────────────────────────────┤
│ Telegram Connection Card │
├─────────────────────────────────────────────┤
│ Security Section (password change) │
└─────────────────────────────────────────────┘
```
### 3.8 LobbyPage
- [ ] Step-by-step wizard UI
- [ ] Game cards grid с preview
- [ ] Challenge preview с difficulty badges
- [ ] AI generation progress animation
- [ ] Launch countdown
---
## Фаза 4: Специальные компоненты
### 4.1 SpinWheel (полный редизайн)
- [ ] 3D perspective эффект
- [ ] Неоновые сегменты с названиями игр
- [ ] Particle effects при кручении
- [ ] Glow trail за указателем
- [ ] Sound effects (optional)
- [ ] Confetti при выигрыше
### 4.2 EventBanner (переработка)
- [ ] Pulsating glow border
- [ ] Countdown timer с flip animation
- [ ] Event-specific icons/colors
- [ ] Dismiss animation
### 4.3 ActivityFeed (переработка)
- [ ] Timeline style
- [ ] Avatar circles
- [ ] Type-specific icons
- [ ] Hover для деталей
- [ ] New items animation (slide-in)
### 4.4 Challenge Cards
- [ ] Difficulty stars/badges
- [ ] Progress indicator
- [ ] Expandable details
- [ ] Proof preview thumbnail
---
## Фаза 5: Анимации и эффекты
### 5.1 Page Transitions
- [ ] Framer Motion page transitions
- [ ] Fade + slide between routes
- [ ] Loading skeleton screens
### 5.2 Micro-interactions
- [ ] Button press effects
- [ ] Input focus glow
- [ ] Success checkmark animation
- [ ] Error shake animation
- [ ] Loading spinners (custom)
### 5.3 Background Effects
- [ ] Animated gradient mesh
- [ ] Floating particles (optional)
- [ ] Grid pattern overlay
- [ ] Noise texture
### 5.4 Special Effects
- [ ] Glitch text на заголовках
- [ ] Neon glow на важных элементах
- [ ] Shimmer effect на loading
- [ ] Confetti на achievements
---
## Фаза 6: Responsive и Polish
### 6.1 Mobile Optimization
- [ ] Touch-friendly targets
- [ ] Swipe gestures
- [ ] Bottom navigation (mobile)
- [ ] Collapsible sections
### 6.2 Accessibility
- [ ] Keyboard navigation
- [ ] Focus indicators
- [ ] Screen reader support
- [ ] Reduced motion option
### 6.3 Performance
- [ ] Lazy loading images
- [ ] Code splitting
- [ ] Animation optimization
- [ ] Bundle size check
---
## Порядок реализации
### Sprint 1: Фундамент (2-3 дня)
1. Tailwind config + colors
2. Global CSS + animations
3. Base UI components (Button, Card, Input)
4. GlitchText component
5. Updated Layout/Header
### Sprint 2: Core Pages (3-4 дня)
1. HomePage с hero
2. Login/Register
3. MarathonsPage dashboard
4. Profile page
### Sprint 3: Game Flow (3-4 дня)
1. MarathonPage detail
2. SpinWheel redesign
3. PlayPage
4. LeaderboardPage
### Sprint 4: Polish (2-3 дня)
1. LobbyPage
2. Event components
3. Activity feed
4. Animations & transitions
### Sprint 5: Finalization (1-2 дня)
1. Mobile testing
2. Performance optimization
3. Bug fixes
4. Final polish
---
## Референсы для вдохновления
- Cyberpunk 2077 UI
- Discord dark theme
- Valorant game UI
- Steam profile pages
- Twitch streaming UI
- Epic Games Store
---
## Технические заметки
**Framer Motion:** Использовать для page transitions и сложных анимаций
**CSS:** Использовать для простых transitions и hover эффектов
**Tailwind:** Основной инструмент для стилей
**Custom Hooks:** useAnimation, useGlitch для переиспользования логики

View File

@@ -1,10 +1,11 @@
from typing import Annotated from typing import Annotated
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status, Header
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db from app.core.database import get_db
from app.core.security import decode_access_token from app.core.security import decode_access_token
from app.models import User, Participant, Marathon, UserRole, ParticipantRole from app.models import User, Participant, Marathon, UserRole, ParticipantRole
@@ -145,3 +146,21 @@ async def require_creator(
# Type aliases for cleaner dependency injection # Type aliases for cleaner dependency injection
CurrentUser = Annotated[User, Depends(get_current_user)] CurrentUser = Annotated[User, Depends(get_current_user)]
DbSession = Annotated[AsyncSession, Depends(get_db)] DbSession = Annotated[AsyncSession, Depends(get_db)]
async def verify_bot_secret(
x_bot_secret: str | None = Header(None, alias="X-Bot-Secret")
) -> None:
"""Verify that request comes from trusted bot using secret key."""
if not settings.BOT_API_SECRET:
# If secret is not configured, skip check (for development)
return
if x_bot_secret != settings.BOT_API_SECRET:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid or missing bot secret"
)
BotSecretDep = Annotated[None, Depends(verify_bot_secret)]

View File

@@ -1,16 +1,18 @@
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status, Request
from sqlalchemy import select from sqlalchemy import select
from app.api.deps import DbSession, CurrentUser from app.api.deps import DbSession, CurrentUser
from app.core.security import verify_password, get_password_hash, create_access_token from app.core.security import verify_password, get_password_hash, create_access_token
from app.core.rate_limit import limiter
from app.models import User from app.models import User
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPublic from app.schemas import UserRegister, UserLogin, TokenResponse, UserPrivate
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=TokenResponse) @router.post("/register", response_model=TokenResponse)
async def register(data: UserRegister, db: DbSession): @limiter.limit("5/minute")
async def register(request: Request, data: UserRegister, db: DbSession):
# Check if login already exists # Check if login already exists
result = await db.execute(select(User).where(User.login == data.login.lower())) result = await db.execute(select(User).where(User.login == data.login.lower()))
if result.scalar_one_or_none(): if result.scalar_one_or_none():
@@ -34,12 +36,13 @@ async def register(data: UserRegister, db: DbSession):
return TokenResponse( return TokenResponse(
access_token=access_token, access_token=access_token,
user=UserPublic.model_validate(user), user=UserPrivate.model_validate(user),
) )
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=TokenResponse)
async def login(data: UserLogin, db: DbSession): @limiter.limit("10/minute")
async def login(request: Request, data: UserLogin, db: DbSession):
# Find user # Find user
result = await db.execute(select(User).where(User.login == data.login.lower())) result = await db.execute(select(User).where(User.login == data.login.lower()))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
@@ -55,10 +58,11 @@ async def login(data: UserLogin, db: DbSession):
return TokenResponse( return TokenResponse(
access_token=access_token, access_token=access_token,
user=UserPublic.model_validate(user), user=UserPrivate.model_validate(user),
) )
@router.get("/me", response_model=UserPublic) @router.get("/me", response_model=UserPrivate)
async def get_me(current_user: CurrentUser): async def get_me(current_user: CurrentUser):
return UserPublic.model_validate(current_user) """Get current user's full profile (including private data)"""
return UserPrivate.model_validate(current_user)

View File

@@ -13,6 +13,7 @@ from app.schemas import (
ChallengePreview, ChallengePreview,
ChallengesPreviewResponse, ChallengesPreviewResponse,
ChallengesSaveRequest, ChallengesSaveRequest,
ChallengesGenerateRequest,
) )
from app.services.gpt import gpt_service from app.services.gpt import gpt_service
@@ -187,7 +188,12 @@ async def create_challenge(
@router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse) @router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse)
async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession): async def preview_challenges(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
data: ChallengesGenerateRequest | None = None,
):
"""Generate challenges preview for approved games in marathon using GPT (without saving). Organizers only.""" """Generate challenges preview for approved games in marathon using GPT (without saving). Organizers only."""
# Check marathon # Check marathon
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id)) result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
@@ -202,21 +208,35 @@ async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: Db
await require_organizer(db, current_user, marathon_id) await require_organizer(db, current_user, marathon_id)
# Get only APPROVED games # Get only APPROVED games
result = await db.execute( query = select(Game).where(
select(Game).where(
Game.marathon_id == marathon_id, Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value, Game.status == GameStatus.APPROVED.value,
) )
)
# Filter by specific game IDs if provided
if data and data.game_ids:
query = query.where(Game.id.in_(data.game_ids))
result = await db.execute(query)
games = result.scalars().all() games = result.scalars().all()
if not games: if not games:
raise HTTPException(status_code=400, detail="No approved games in marathon") raise HTTPException(status_code=400, detail="No approved games found")
# Filter games that don't have challenges yet # Build games list for generation (skip games that already have challenges, unless specific IDs requested)
games_to_generate = [] games_to_generate = []
game_map = {} game_map = {}
for game in games: for game in games:
# If specific games requested, generate even if they have challenges
if data and data.game_ids:
games_to_generate.append({
"id": game.id,
"title": game.title,
"genre": game.genre
})
game_map[game.id] = game.title
else:
# Otherwise only generate for games without challenges
existing = await db.scalar( existing = await db.scalar(
select(Challenge.id).where(Challenge.game_id == game.id).limit(1) select(Challenge.id).where(Challenge.game_id == game.id).limit(1)
) )

View File

@@ -10,6 +10,7 @@ from app.core.config import settings
from app.models import Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, Challenge, Activity, ActivityType from app.models import Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, Challenge, Activity, ActivityType
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
from app.services.storage import storage_service from app.services.storage import storage_service
from app.services.telegram_notifier import telegram_notifier
router = APIRouter(tags=["games"]) router = APIRouter(tags=["games"])
@@ -268,6 +269,13 @@ async def approve_game(game_id: int, current_user: CurrentUser, db: DbSession):
if game.status != GameStatus.PENDING.value: if game.status != GameStatus.PENDING.value:
raise HTTPException(status_code=400, detail="Game is not pending") raise HTTPException(status_code=400, detail="Game is not pending")
# Get marathon title for notification
marathon_result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
marathon = marathon_result.scalar_one()
# Save proposer id before status change
proposer_id = game.proposed_by_id
game.status = GameStatus.APPROVED.value game.status = GameStatus.APPROVED.value
game.approved_by_id = current_user.id game.approved_by_id = current_user.id
@@ -283,6 +291,12 @@ async def approve_game(game_id: int, current_user: CurrentUser, db: DbSession):
await db.commit() await db.commit()
await db.refresh(game) await db.refresh(game)
# Notify proposer (if not self-approving)
if proposer_id and proposer_id != current_user.id:
await telegram_notifier.notify_game_approved(
db, proposer_id, marathon.title, game.title
)
# Need to reload relationships # Need to reload relationships
game = await get_game_or_404(db, game_id) game = await get_game_or_404(db, game_id)
challenges_count = await db.scalar( challenges_count = await db.scalar(
@@ -302,6 +316,14 @@ async def reject_game(game_id: int, current_user: CurrentUser, db: DbSession):
if game.status != GameStatus.PENDING.value: if game.status != GameStatus.PENDING.value:
raise HTTPException(status_code=400, detail="Game is not pending") raise HTTPException(status_code=400, detail="Game is not pending")
# Get marathon title for notification
marathon_result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
marathon = marathon_result.scalar_one()
# Save proposer id and game title before changes
proposer_id = game.proposed_by_id
game_title = game.title
game.status = GameStatus.REJECTED.value game.status = GameStatus.REJECTED.value
# Log activity # Log activity
@@ -316,6 +338,12 @@ async def reject_game(game_id: int, current_user: CurrentUser, db: DbSession):
await db.commit() await db.commit()
await db.refresh(game) await db.refresh(game)
# Notify proposer
if proposer_id and proposer_id != current_user.id:
await telegram_notifier.notify_game_rejected(
db, proposer_id, marathon.title, game_title
)
# Need to reload relationships # Need to reload relationships
game = await get_game_or_404(db, game_id) game = await get_game_or_404(db, game_id)
challenges_count = await db.scalar( challenges_count = await db.scalar(

View File

@@ -1,6 +1,8 @@
from datetime import timedelta from datetime import timedelta
import secrets import secrets
from fastapi import APIRouter, HTTPException, status import string
from fastapi import APIRouter, HTTPException, status, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -9,8 +11,12 @@ from app.api.deps import (
require_participant, require_organizer, require_creator, require_participant, require_organizer, require_creator,
get_participant, get_participant,
) )
from app.core.security import decode_access_token
# Optional auth for endpoints that need it conditionally
optional_auth = HTTPBearer(auto_error=False)
from app.models import ( from app.models import (
Marathon, Participant, MarathonStatus, Game, GameStatus, Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole, Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
) )
from app.schemas import ( from app.schemas import (
@@ -40,7 +46,7 @@ async def get_marathon_by_code(invite_code: str, db: DbSession):
select(Marathon, func.count(Participant.id).label("participants_count")) select(Marathon, func.count(Participant.id).label("participants_count"))
.outerjoin(Participant) .outerjoin(Participant)
.options(selectinload(Marathon.creator)) .options(selectinload(Marathon.creator))
.where(Marathon.invite_code == invite_code) .where(func.upper(Marathon.invite_code) == invite_code.upper())
.group_by(Marathon.id) .group_by(Marathon.id)
) )
row = result.first() row = result.first()
@@ -62,7 +68,9 @@ async def get_marathon_by_code(invite_code: str, db: DbSession):
def generate_invite_code() -> str: def generate_invite_code() -> str:
return secrets.token_urlsafe(8) """Generate a clean 8-character uppercase alphanumeric code."""
alphabet = string.ascii_uppercase + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(8))
async def get_marathon_or_404(db, marathon_id: int) -> Marathon: async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
@@ -185,6 +193,15 @@ async def create_marathon(
async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession): async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
marathon = await get_marathon_or_404(db, marathon_id) marathon = await get_marathon_or_404(db, marathon_id)
# For private marathons, require participation (or admin/creator)
if not marathon.is_public and not current_user.is_admin:
participation = await get_participation(db, current_user.id, marathon_id)
if not participation:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not a participant of this private marathon",
)
# Count participants and approved games # Count participants and approved games
participants_count = await db.scalar( participants_count = await db.scalar(
select(func.count()).select_from(Participant).where(Participant.marathon_id == marathon_id) select(func.count()).select_from(Participant).where(Participant.marathon_id == marathon_id)
@@ -272,15 +289,33 @@ async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSess
if marathon.status != MarathonStatus.PREPARING.value: if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Marathon is not in preparing state") raise HTTPException(status_code=400, detail="Marathon is not in preparing state")
# Check if there are approved games with challenges # Check if there are approved games
games_count = await db.scalar( games_result = await db.execute(
select(func.count()).select_from(Game).where( select(Game).where(
Game.marathon_id == marathon_id, Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value, Game.status == GameStatus.APPROVED.value,
) )
) )
if games_count == 0: approved_games = games_result.scalars().all()
raise HTTPException(status_code=400, detail="Add and approve at least one game before starting")
if len(approved_games) == 0:
raise HTTPException(status_code=400, detail="Добавьте и одобрите хотя бы одну игру")
# Check that all approved games have at least one challenge
games_without_challenges = []
for game in approved_games:
challenge_count = await db.scalar(
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
)
if challenge_count == 0:
games_without_challenges.append(game.title)
if games_without_challenges:
games_list = ", ".join(games_without_challenges)
raise HTTPException(
status_code=400,
detail=f"У следующих игр нет челленджей: {games_list}"
)
marathon.status = MarathonStatus.ACTIVE.value marathon.status = MarathonStatus.ACTIVE.value
@@ -332,7 +367,7 @@ async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSes
@router.post("/join", response_model=MarathonResponse) @router.post("/join", response_model=MarathonResponse)
async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSession): async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSession):
result = await db.execute( result = await db.execute(
select(Marathon).where(Marathon.invite_code == data.invite_code) select(Marathon).where(func.upper(Marathon.invite_code) == data.invite_code.upper())
) )
marathon = result.scalar_one_or_none() marathon = result.scalar_one_or_none()
@@ -407,7 +442,16 @@ async def join_public_marathon(marathon_id: int, current_user: CurrentUser, db:
@router.get("/{marathon_id}/participants", response_model=list[ParticipantWithUser]) @router.get("/{marathon_id}/participants", response_model=list[ParticipantWithUser])
async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSession): async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSession):
await get_marathon_or_404(db, marathon_id) marathon = await get_marathon_or_404(db, marathon_id)
# For private marathons, require participation (or admin)
if not marathon.is_public and not current_user.is_admin:
participation = await get_participation(db, current_user.id, marathon_id)
if not participation:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not a participant of this private marathon",
)
result = await db.execute( result = await db.execute(
select(Participant) select(Participant)
@@ -476,8 +520,42 @@ async def set_participant_role(
@router.get("/{marathon_id}/leaderboard", response_model=list[LeaderboardEntry]) @router.get("/{marathon_id}/leaderboard", response_model=list[LeaderboardEntry])
async def get_leaderboard(marathon_id: int, db: DbSession): async def get_leaderboard(
await get_marathon_or_404(db, marathon_id) marathon_id: int,
db: DbSession,
credentials: HTTPAuthorizationCredentials | None = Depends(optional_auth),
):
"""
Get marathon leaderboard.
Public marathons: no auth required.
Private marathons: requires auth + participation check.
"""
marathon = await get_marathon_or_404(db, marathon_id)
# For private marathons, require authentication and participation
if not marathon.is_public:
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required for private marathon leaderboard",
headers={"WWW-Authenticate": "Bearer"},
)
payload = decode_access_token(credentials.credentials)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
user_id = int(payload.get("sub"))
participant = await get_participant(db, user_id, marathon_id)
if not participant:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not a participant of this marathon",
)
result = await db.execute( result = await db.execute(
select(Participant) select(Participant)

View File

@@ -5,7 +5,7 @@ from pydantic import BaseModel
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser from app.api.deps import DbSession, CurrentUser, BotSecretDep
from app.core.config import settings from app.core.config import settings
from app.core.security import create_telegram_link_token, verify_telegram_link_token from app.core.security import create_telegram_link_token, verify_telegram_link_token
from app.models import User, Participant, Marathon, Assignment, Challenge, Event, Game from app.models import User, Participant, Marathon, Assignment, Challenge, Event, Game
@@ -94,7 +94,7 @@ async def generate_link_token(current_user: CurrentUser):
@router.post("/confirm-link", response_model=TelegramLinkResponse) @router.post("/confirm-link", response_model=TelegramLinkResponse)
async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession): async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession, _: BotSecretDep):
"""Confirm Telegram account linking (called by bot).""" """Confirm Telegram account linking (called by bot)."""
logger.info(f"[TG_CONFIRM] ========== CONFIRM LINK REQUEST ==========") logger.info(f"[TG_CONFIRM] ========== CONFIRM LINK REQUEST ==========")
logger.info(f"[TG_CONFIRM] telegram_id: {data.telegram_id}") logger.info(f"[TG_CONFIRM] telegram_id: {data.telegram_id}")
@@ -145,7 +145,7 @@ async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession):
@router.get("/user/{telegram_id}", response_model=TelegramUserResponse | None) @router.get("/user/{telegram_id}", response_model=TelegramUserResponse | None)
async def get_user_by_telegram_id(telegram_id: int, db: DbSession): async def get_user_by_telegram_id(telegram_id: int, db: DbSession, _: BotSecretDep):
"""Get user by Telegram ID.""" """Get user by Telegram ID."""
logger.info(f"[TG_USER] Looking up user by telegram_id={telegram_id}") logger.info(f"[TG_USER] Looking up user by telegram_id={telegram_id}")
@@ -168,7 +168,7 @@ async def get_user_by_telegram_id(telegram_id: int, db: DbSession):
@router.post("/unlink/{telegram_id}", response_model=TelegramLinkResponse) @router.post("/unlink/{telegram_id}", response_model=TelegramLinkResponse)
async def unlink_telegram(telegram_id: int, db: DbSession): async def unlink_telegram(telegram_id: int, db: DbSession, _: BotSecretDep):
"""Unlink Telegram account.""" """Unlink Telegram account."""
result = await db.execute( result = await db.execute(
select(User).where(User.telegram_id == telegram_id) select(User).where(User.telegram_id == telegram_id)
@@ -187,7 +187,7 @@ async def unlink_telegram(telegram_id: int, db: DbSession):
@router.get("/marathons/{telegram_id}", response_model=list[TelegramMarathonResponse]) @router.get("/marathons/{telegram_id}", response_model=list[TelegramMarathonResponse])
async def get_user_marathons(telegram_id: int, db: DbSession): async def get_user_marathons(telegram_id: int, db: DbSession, _: BotSecretDep):
"""Get user's marathons by Telegram ID.""" """Get user's marathons by Telegram ID."""
# Get user # Get user
result = await db.execute( result = await db.execute(
@@ -231,7 +231,7 @@ async def get_user_marathons(telegram_id: int, db: DbSession):
@router.get("/marathon/{marathon_id}", response_model=TelegramMarathonDetails | None) @router.get("/marathon/{marathon_id}", response_model=TelegramMarathonDetails | None)
async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession): async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession, _: BotSecretDep):
"""Get marathon details for user by Telegram ID.""" """Get marathon details for user by Telegram ID."""
# Get user # Get user
result = await db.execute( result = await db.execute(
@@ -341,7 +341,7 @@ async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession
@router.get("/stats/{telegram_id}", response_model=TelegramStatsResponse | None) @router.get("/stats/{telegram_id}", response_model=TelegramStatsResponse | None)
async def get_user_stats(telegram_id: int, db: DbSession): async def get_user_stats(telegram_id: int, db: DbSession, _: BotSecretDep):
"""Get user's overall statistics by Telegram ID.""" """Get user's overall statistics by Telegram ID."""
# Get user # Get user
result = await db.execute( result = await db.execute(

View File

@@ -8,7 +8,7 @@ from app.models import User, Participant, Assignment, Marathon
from app.models.assignment import AssignmentStatus from app.models.assignment import AssignmentStatus
from app.models.marathon import MarathonStatus from app.models.marathon import MarathonStatus
from app.schemas import ( from app.schemas import (
UserPublic, UserUpdate, TelegramLink, MessageResponse, UserPublic, UserPrivate, UserUpdate, TelegramLink, MessageResponse,
PasswordChange, UserStats, UserProfilePublic, PasswordChange, UserStats, UserProfilePublic,
) )
from app.services.storage import storage_service from app.services.storage import storage_service
@@ -17,7 +17,8 @@ router = APIRouter(prefix="/users", tags=["users"])
@router.get("/{user_id}", response_model=UserPublic) @router.get("/{user_id}", response_model=UserPublic)
async def get_user(user_id: int, db: DbSession): async def get_user(user_id: int, db: DbSession, current_user: CurrentUser):
"""Get user profile. Requires authentication."""
result = await db.execute(select(User).where(User.id == user_id)) result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
@@ -58,23 +59,25 @@ async def get_user_avatar(user_id: int, db: DbSession):
) )
@router.patch("/me", response_model=UserPublic) @router.patch("/me", response_model=UserPrivate)
async def update_me(data: UserUpdate, current_user: CurrentUser, db: DbSession): async def update_me(data: UserUpdate, current_user: CurrentUser, db: DbSession):
"""Update current user's profile"""
if data.nickname is not None: if data.nickname is not None:
current_user.nickname = data.nickname current_user.nickname = data.nickname
await db.commit() await db.commit()
await db.refresh(current_user) await db.refresh(current_user)
return UserPublic.model_validate(current_user) return UserPrivate.model_validate(current_user)
@router.post("/me/avatar", response_model=UserPublic) @router.post("/me/avatar", response_model=UserPrivate)
async def upload_avatar( async def upload_avatar(
current_user: CurrentUser, current_user: CurrentUser,
db: DbSession, db: DbSession,
file: UploadFile = File(...), file: UploadFile = File(...),
): ):
"""Upload current user's avatar"""
# Validate file # Validate file
if not file.content_type.startswith("image/"): if not file.content_type.startswith("image/"):
raise HTTPException( raise HTTPException(
@@ -115,7 +118,7 @@ async def upload_avatar(
await db.commit() await db.commit()
await db.refresh(current_user) await db.refresh(current_user)
return UserPublic.model_validate(current_user) return UserPrivate.model_validate(current_user)
@router.post("/me/telegram", response_model=MessageResponse) @router.post("/me/telegram", response_model=MessageResponse)
@@ -193,8 +196,8 @@ async def get_my_stats(current_user: CurrentUser, db: DbSession):
@router.get("/{user_id}/stats", response_model=UserStats) @router.get("/{user_id}/stats", response_model=UserStats)
async def get_user_stats(user_id: int, db: DbSession): async def get_user_stats(user_id: int, db: DbSession, current_user: CurrentUser):
"""Получить статистику пользователя""" """Получить статистику пользователя. Requires authentication."""
result = await db.execute(select(User).where(User.id == user_id)) result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user:
@@ -207,8 +210,8 @@ async def get_user_stats(user_id: int, db: DbSession):
@router.get("/{user_id}/profile", response_model=UserProfilePublic) @router.get("/{user_id}/profile", response_model=UserProfilePublic)
async def get_user_profile(user_id: int, db: DbSession): async def get_user_profile(user_id: int, db: DbSession, current_user: CurrentUser):
"""Получить публичный профиль пользователя со статистикой""" """Получить публичный профиль пользователя со статистикой. Requires authentication."""
result = await db.execute(select(User).where(User.id == user_id)) result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()

View File

@@ -22,6 +22,7 @@ class Settings(BaseSettings):
TELEGRAM_BOT_TOKEN: str = "" TELEGRAM_BOT_TOKEN: str = ""
TELEGRAM_BOT_USERNAME: str = "" TELEGRAM_BOT_USERNAME: str = ""
TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES: int = 10 TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES: int = 10
BOT_API_SECRET: str = "" # Secret key for bot-to-backend communication
# Frontend # Frontend
FRONTEND_URL: str = "http://localhost:3000" FRONTEND_URL: str = "http://localhost:3000"

View File

@@ -0,0 +1,5 @@
from slowapi import Limiter
from slowapi.util import get_remote_address
# Rate limiter using client IP address as key
limiter = Limiter(key_func=get_remote_address)

View File

@@ -1,7 +1,10 @@
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@@ -14,6 +17,7 @@ from pathlib import Path
from app.core.config import settings from app.core.config import settings
from app.core.database import engine, Base, async_session_maker from app.core.database import engine, Base, async_session_maker
from app.core.rate_limit import limiter
from app.api.v1 import router as api_router from app.api.v1 import router as api_router
from app.services.event_scheduler import event_scheduler from app.services.event_scheduler import event_scheduler
from app.services.dispute_scheduler import dispute_scheduler from app.services.dispute_scheduler import dispute_scheduler
@@ -49,6 +53,10 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
# Rate limiting
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# CORS # CORS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,

View File

@@ -3,7 +3,7 @@ from app.schemas.user import (
UserLogin, UserLogin,
UserUpdate, UserUpdate,
UserPublic, UserPublic,
UserWithTelegram, UserPrivate,
TokenResponse, TokenResponse,
TelegramLink, TelegramLink,
PasswordChange, PasswordChange,
@@ -37,6 +37,7 @@ from app.schemas.challenge import (
ChallengesPreviewResponse, ChallengesPreviewResponse,
ChallengeSaveItem, ChallengeSaveItem,
ChallengesSaveRequest, ChallengesSaveRequest,
ChallengesGenerateRequest,
) )
from app.schemas.assignment import ( from app.schemas.assignment import (
CompleteAssignment, CompleteAssignment,
@@ -87,7 +88,7 @@ __all__ = [
"UserLogin", "UserLogin",
"UserUpdate", "UserUpdate",
"UserPublic", "UserPublic",
"UserWithTelegram", "UserPrivate",
"TokenResponse", "TokenResponse",
"TelegramLink", "TelegramLink",
"PasswordChange", "PasswordChange",
@@ -118,6 +119,7 @@ __all__ = [
"ChallengesPreviewResponse", "ChallengesPreviewResponse",
"ChallengeSaveItem", "ChallengeSaveItem",
"ChallengesSaveRequest", "ChallengesSaveRequest",
"ChallengesGenerateRequest",
# Assignment # Assignment
"CompleteAssignment", "CompleteAssignment",
"AssignmentResponse", "AssignmentResponse",

View File

@@ -88,3 +88,8 @@ class ChallengeSaveItem(BaseModel):
class ChallengesSaveRequest(BaseModel): class ChallengesSaveRequest(BaseModel):
"""Request to save previewed challenges""" """Request to save previewed challenges"""
challenges: list[ChallengeSaveItem] challenges: list[ChallengeSaveItem]
class ChallengesGenerateRequest(BaseModel):
"""Request to generate challenges for specific games"""
game_ids: list[int] | None = None # If None, generate for all approved games without challenges

View File

@@ -29,30 +29,30 @@ class UserUpdate(BaseModel):
class UserPublic(UserBase): class UserPublic(UserBase):
"""Public user info visible to other users - minimal data"""
id: int id: int
login: str
avatar_url: str | None = None avatar_url: str | None = None
role: str = "user" role: str = "user"
telegram_id: int | None = None telegram_avatar_url: str | None = None # Only TG avatar is public
telegram_username: str | None = None
telegram_first_name: str | None = None
telegram_last_name: str | None = None
telegram_avatar_url: str | None = None
created_at: datetime created_at: datetime
class Config: class Config:
from_attributes = True from_attributes = True
class UserWithTelegram(UserPublic): class UserPrivate(UserPublic):
"""Full user info visible only to the user themselves"""
login: str
telegram_id: int | None = None telegram_id: int | None = None
telegram_username: str | None = None telegram_username: str | None = None
telegram_first_name: str | None = None
telegram_last_name: str | None = None
class TokenResponse(BaseModel): class TokenResponse(BaseModel):
access_token: str access_token: str
token_type: str = "bearer" token_type: str = "bearer"
user: UserPublic user: UserPrivate
class TelegramLink(BaseModel): class TelegramLink(BaseModel):

View File

@@ -244,6 +244,38 @@ class TelegramNotifier:
) )
return await self.notify_user(db, user_id, message) return await self.notify_user(db, user_id, message)
async def notify_game_approved(
self,
db: AsyncSession,
user_id: int,
marathon_title: str,
game_title: str
) -> bool:
"""Notify user that their proposed game was approved."""
message = (
f"✅ <b>Твоя игра одобрена!</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Игра: {game_title}\n\n"
f"Теперь она доступна для всех участников."
)
return await self.notify_user(db, user_id, message)
async def notify_game_rejected(
self,
db: AsyncSession,
user_id: int,
marathon_title: str,
game_title: str
) -> bool:
"""Notify user that their proposed game was rejected."""
message = (
f"❌ <b>Твоя игра отклонена</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Игра: {game_title}\n\n"
f"Ты можешь предложить другую игру."
)
return await self.notify_user(db, user_id, message)
# Global instance # Global instance
telegram_notifier = TelegramNotifier() telegram_notifier = TelegramNotifier()

View File

@@ -31,5 +31,8 @@ python-magic==0.4.27
# S3 Storage # S3 Storage
boto3==1.34.0 boto3==1.34.0
# Rate limiting
slowapi==0.1.9
# Utils # Utils
python-dotenv==1.0.0 python-dotenv==1.0.0

30
backup-service/Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
FROM python:3.11-slim
WORKDIR /app
# Install PostgreSQL client (for pg_dump and psql) and cron
RUN apt-get update && apt-get install -y \
postgresql-client \
cron \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY . .
# Make scripts executable
RUN chmod +x backup.py restore.py
# Setup cron
COPY crontab /etc/cron.d/backup-cron
RUN chmod 0644 /etc/cron.d/backup-cron
RUN crontab /etc/cron.d/backup-cron
# Create log file
RUN touch /var/log/cron.log
# Start cron in foreground and tail logs
CMD ["sh", "-c", "printenv > /etc/environment && cron && tail -f /var/log/cron.log"]

217
backup-service/backup.py Normal file
View File

@@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""
PostgreSQL Backup Service for WebApp.
- Creates pg_dump backup
- Compresses with gzip
- Uploads to S3 FirstVDS
- Rotates old backups (configurable retention)
- Sends Telegram notifications
"""
import gzip
import os
import subprocess
import sys
from datetime import datetime, timedelta, timezone
import boto3
import httpx
from botocore.config import Config as BotoConfig
from botocore.exceptions import ClientError
from config import config
def create_s3_client():
"""Initialize S3 client (same pattern as backend storage.py)."""
return boto3.client(
"s3",
endpoint_url=config.S3_ENDPOINT_URL,
aws_access_key_id=config.S3_ACCESS_KEY_ID,
aws_secret_access_key=config.S3_SECRET_ACCESS_KEY,
region_name=config.S3_REGION or "us-east-1",
config=BotoConfig(signature_version="s3v4"),
)
def send_telegram_notification(message: str, is_error: bool = False) -> None:
"""Send notification to Telegram admin."""
if not config.TELEGRAM_BOT_TOKEN or not config.TELEGRAM_ADMIN_ID:
print("Telegram not configured, skipping notification")
return
emoji = "\u274c" if is_error else "\u2705"
text = f"{emoji} *Database Backup*\n\n{message}"
url = f"https://api.telegram.org/bot{config.TELEGRAM_BOT_TOKEN}/sendMessage"
data = {
"chat_id": config.TELEGRAM_ADMIN_ID,
"text": text,
"parse_mode": "Markdown",
}
try:
response = httpx.post(url, json=data, timeout=30)
response.raise_for_status()
print("Telegram notification sent")
except Exception as e:
print(f"Failed to send Telegram notification: {e}")
def create_backup() -> tuple[str, bytes]:
"""Create pg_dump backup and compress it."""
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
filename = f"marathon_backup_{timestamp}.sql.gz"
# Build pg_dump command
env = os.environ.copy()
env["PGPASSWORD"] = config.DB_PASSWORD
cmd = [
"pg_dump",
"-h",
config.DB_HOST,
"-p",
config.DB_PORT,
"-U",
config.DB_USER,
"-d",
config.DB_NAME,
"--no-owner",
"--no-acl",
"-F",
"p", # plain SQL format
]
print(f"Running pg_dump for database {config.DB_NAME}...")
result = subprocess.run(
cmd,
env=env,
capture_output=True,
)
if result.returncode != 0:
raise Exception(f"pg_dump failed: {result.stderr.decode()}")
# Compress the output
print("Compressing backup...")
compressed = gzip.compress(result.stdout, compresslevel=9)
return filename, compressed
def upload_to_s3(s3_client, filename: str, data: bytes) -> str:
"""Upload backup to S3."""
key = f"{config.S3_BACKUP_PREFIX}{filename}"
print(f"Uploading to S3: {key}...")
s3_client.put_object(
Bucket=config.S3_BUCKET_NAME,
Key=key,
Body=data,
ContentType="application/gzip",
)
return key
def rotate_old_backups(s3_client) -> int:
"""Delete backups older than BACKUP_RETENTION_DAYS."""
cutoff_date = datetime.now(timezone.utc) - timedelta(
days=config.BACKUP_RETENTION_DAYS
)
deleted_count = 0
print(f"Rotating backups older than {config.BACKUP_RETENTION_DAYS} days...")
# List all objects with backup prefix
try:
paginator = s3_client.get_paginator("list_objects_v2")
pages = paginator.paginate(
Bucket=config.S3_BUCKET_NAME,
Prefix=config.S3_BACKUP_PREFIX,
)
for page in pages:
for obj in page.get("Contents", []):
last_modified = obj["LastModified"]
if last_modified.tzinfo is None:
last_modified = last_modified.replace(tzinfo=timezone.utc)
if last_modified < cutoff_date:
s3_client.delete_object(
Bucket=config.S3_BUCKET_NAME,
Key=obj["Key"],
)
deleted_count += 1
print(f"Deleted old backup: {obj['Key']}")
except ClientError as e:
print(f"Error during rotation: {e}")
return deleted_count
def main() -> int:
"""Main backup routine."""
start_time = datetime.now()
print(f"{'=' * 50}")
print(f"Backup started at {start_time}")
print(f"{'=' * 50}")
try:
# Validate configuration
if not config.S3_BUCKET_NAME:
raise Exception("S3_BUCKET_NAME is not configured")
if not config.S3_ACCESS_KEY_ID:
raise Exception("S3_ACCESS_KEY_ID is not configured")
if not config.S3_SECRET_ACCESS_KEY:
raise Exception("S3_SECRET_ACCESS_KEY is not configured")
if not config.S3_ENDPOINT_URL:
raise Exception("S3_ENDPOINT_URL is not configured")
# Create S3 client
s3_client = create_s3_client()
# Create backup
filename, data = create_backup()
size_mb = len(data) / (1024 * 1024)
print(f"Backup created: {filename} ({size_mb:.2f} MB)")
# Upload to S3
s3_key = upload_to_s3(s3_client, filename, data)
print(f"Uploaded to S3: {s3_key}")
# Rotate old backups
deleted_count = rotate_old_backups(s3_client)
print(f"Deleted {deleted_count} old backups")
# Calculate duration
duration = datetime.now() - start_time
# Send success notification
message = (
f"Backup completed successfully!\n\n"
f"*File:* `{filename}`\n"
f"*Size:* {size_mb:.2f} MB\n"
f"*Duration:* {duration.seconds}s\n"
f"*Deleted old:* {deleted_count} files"
)
send_telegram_notification(message, is_error=False)
print(f"{'=' * 50}")
print("Backup completed successfully!")
print(f"{'=' * 50}")
return 0
except Exception as e:
error_msg = f"Backup failed!\n\n*Error:* `{str(e)}`"
send_telegram_notification(error_msg, is_error=True)
print(f"{'=' * 50}")
print(f"Backup failed: {e}")
print(f"{'=' * 50}")
return 1
if __name__ == "__main__":
sys.exit(main())

33
backup-service/config.py Normal file
View File

@@ -0,0 +1,33 @@
"""Configuration for backup service."""
import os
from dataclasses import dataclass
@dataclass
class Config:
"""Backup service configuration from environment variables."""
# Database
DB_HOST: str = os.getenv("DB_HOST", "db")
DB_PORT: str = os.getenv("DB_PORT", "5432")
DB_NAME: str = os.getenv("DB_NAME", "marathon")
DB_USER: str = os.getenv("DB_USER", "marathon")
DB_PASSWORD: str = os.getenv("DB_PASSWORD", "123")
# S3
S3_BUCKET_NAME: str = os.getenv("S3_BUCKET_NAME", "")
S3_REGION: str = os.getenv("S3_REGION", "ru-1")
S3_ACCESS_KEY_ID: str = os.getenv("S3_ACCESS_KEY_ID", "")
S3_SECRET_ACCESS_KEY: str = os.getenv("S3_SECRET_ACCESS_KEY", "")
S3_ENDPOINT_URL: str = os.getenv("S3_ENDPOINT_URL", "")
S3_BACKUP_PREFIX: str = os.getenv("S3_BACKUP_PREFIX", "backups/")
# Telegram
TELEGRAM_BOT_TOKEN: str = os.getenv("TELEGRAM_BOT_TOKEN", "")
TELEGRAM_ADMIN_ID: str = os.getenv("TELEGRAM_ADMIN_ID", "947392854")
# Backup settings
BACKUP_RETENTION_DAYS: int = int(os.getenv("BACKUP_RETENTION_DAYS", "14"))
config = Config()

4
backup-service/crontab Normal file
View File

@@ -0,0 +1,4 @@
# Backup cron job
# Run backup daily at 3:00 AM UTC
0 3 * * * /usr/local/bin/python /app/backup.py >> /var/log/cron.log 2>&1
# Empty line required at end of crontab

View File

@@ -0,0 +1,2 @@
boto3==1.34.0
httpx==0.26.0

158
backup-service/restore.py Normal file
View File

@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""
Restore PostgreSQL database from S3 backup.
Usage:
python restore.py - List available backups
python restore.py <filename> - Restore from specific backup
"""
import gzip
import os
import subprocess
import sys
import boto3
from botocore.config import Config as BotoConfig
from botocore.exceptions import ClientError
from config import config
def create_s3_client():
"""Initialize S3 client."""
return boto3.client(
"s3",
endpoint_url=config.S3_ENDPOINT_URL,
aws_access_key_id=config.S3_ACCESS_KEY_ID,
aws_secret_access_key=config.S3_SECRET_ACCESS_KEY,
region_name=config.S3_REGION or "us-east-1",
config=BotoConfig(signature_version="s3v4"),
)
def list_backups(s3_client) -> list[tuple[str, float, str]]:
"""List all available backups."""
print("Available backups:\n")
try:
paginator = s3_client.get_paginator("list_objects_v2")
pages = paginator.paginate(
Bucket=config.S3_BUCKET_NAME,
Prefix=config.S3_BACKUP_PREFIX,
)
backups = []
for page in pages:
for obj in page.get("Contents", []):
filename = obj["Key"].replace(config.S3_BACKUP_PREFIX, "")
size_mb = obj["Size"] / (1024 * 1024)
modified = obj["LastModified"].strftime("%Y-%m-%d %H:%M:%S")
backups.append((filename, size_mb, modified))
# Sort by date descending (newest first)
backups.sort(key=lambda x: x[2], reverse=True)
for filename, size_mb, modified in backups:
print(f" {filename} ({size_mb:.2f} MB) - {modified}")
return backups
except ClientError as e:
print(f"Error listing backups: {e}")
return []
def restore_backup(s3_client, filename: str) -> None:
"""Download and restore backup."""
key = f"{config.S3_BACKUP_PREFIX}{filename}"
print(f"Downloading {filename} from S3...")
try:
response = s3_client.get_object(
Bucket=config.S3_BUCKET_NAME,
Key=key,
)
compressed_data = response["Body"].read()
except ClientError as e:
raise Exception(f"Failed to download backup: {e}")
print("Decompressing...")
sql_data = gzip.decompress(compressed_data)
print(f"Restoring to database {config.DB_NAME}...")
# Build psql command
env = os.environ.copy()
env["PGPASSWORD"] = config.DB_PASSWORD
cmd = [
"psql",
"-h",
config.DB_HOST,
"-p",
config.DB_PORT,
"-U",
config.DB_USER,
"-d",
config.DB_NAME,
]
result = subprocess.run(
cmd,
env=env,
input=sql_data,
capture_output=True,
)
if result.returncode != 0:
stderr = result.stderr.decode()
# psql may return warnings that aren't fatal errors
if "ERROR" in stderr:
raise Exception(f"psql restore failed: {stderr}")
else:
print(f"Warnings: {stderr}")
print("Restore completed successfully!")
def main() -> int:
"""Main restore routine."""
# Validate configuration
if not config.S3_BUCKET_NAME:
print("Error: S3_BUCKET_NAME is not configured")
return 1
s3_client = create_s3_client()
if len(sys.argv) < 2:
# List available backups
backups = list_backups(s3_client)
if backups:
print(f"\nTo restore, run: python restore.py <filename>")
else:
print("No backups found.")
return 0
filename = sys.argv[1]
# Confirm restore
print(f"WARNING: This will restore database from {filename}")
print("This may overwrite existing data!")
print()
confirm = input("Type 'yes' to continue: ")
if confirm.lower() != "yes":
print("Restore cancelled.")
return 0
try:
restore_backup(s3_client, filename)
return 0
except Exception as e:
print(f"Restore failed: {e}")
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -5,6 +5,7 @@ class Settings(BaseSettings):
TELEGRAM_BOT_TOKEN: str TELEGRAM_BOT_TOKEN: str
API_URL: str = "http://backend:8000" API_URL: str = "http://backend:8000"
BOT_USERNAME: str = "" # Will be set dynamically on startup BOT_USERNAME: str = "" # Will be set dynamically on startup
BOT_API_SECRET: str = "" # Secret for backend API communication
class Config: class Config:
env_file = ".env" env_file = ".env"

View File

@@ -5,6 +5,7 @@ import sys
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode from aiogram.enums import ParseMode
from aiohttp import web
from config import settings from config import settings
from handlers import start, marathons, link from handlers import start, marathons, link
@@ -23,14 +24,41 @@ logger = logging.getLogger(__name__)
# Set aiogram logging level # Set aiogram logging level
logging.getLogger("aiogram").setLevel(logging.INFO) logging.getLogger("aiogram").setLevel(logging.INFO)
# Health check state
bot_running = False
async def health_handler(request):
"""Health check endpoint"""
if bot_running:
return web.json_response({"status": "ok", "service": "telegram-bot"})
return web.json_response({"status": "starting"}, status=503)
async def start_health_server():
"""Start health check HTTP server"""
app = web.Application()
app.router.add_get("/health", health_handler)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", 8080)
await site.start()
logger.info("Health check server started on port 8080")
return runner
async def main(): async def main():
global bot_running
logger.info("="*50) logger.info("="*50)
logger.info("Starting Game Marathon Bot...") logger.info("Starting Game Marathon Bot...")
logger.info(f"API_URL: {settings.API_URL}") logger.info(f"API_URL: {settings.API_URL}")
logger.info(f"BOT_TOKEN: {settings.TELEGRAM_BOT_TOKEN[:20]}...") logger.info(f"BOT_TOKEN: {settings.TELEGRAM_BOT_TOKEN[:20]}...")
logger.info("="*50) logger.info("="*50)
# Start health check server
health_runner = await start_health_server()
bot = Bot( bot = Bot(
token=settings.TELEGRAM_BOT_TOKEN, token=settings.TELEGRAM_BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML) default=DefaultBotProperties(parse_mode=ParseMode.HTML)
@@ -54,11 +82,18 @@ async def main():
dp.include_router(marathons.router) dp.include_router(marathons.router)
logger.info("Routers registered: start, link, marathons") logger.info("Routers registered: start, link, marathons")
# Mark bot as running
bot_running = True
# Start polling # Start polling
logger.info("Deleting webhook and starting polling...") logger.info("Deleting webhook and starting polling...")
await bot.delete_webhook(drop_pending_updates=True) await bot.delete_webhook(drop_pending_updates=True)
logger.info("Polling started! Waiting for messages...") logger.info("Polling started! Waiting for messages...")
try:
await dp.start_polling(bot) await dp.start_polling(bot)
finally:
bot_running = False
await health_runner.cleanup()
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -32,6 +32,11 @@ class APIClient:
session = await self._get_session() session = await self._get_session()
url = f"{self.base_url}/api/v1{endpoint}" url = f"{self.base_url}/api/v1{endpoint}"
# Add bot secret header for authentication
headers = kwargs.pop("headers", {})
if settings.BOT_API_SECRET:
headers["X-Bot-Secret"] = settings.BOT_API_SECRET
logger.info(f"[APIClient] {method} {url}") logger.info(f"[APIClient] {method} {url}")
if 'json' in kwargs: if 'json' in kwargs:
logger.info(f"[APIClient] Request body: {kwargs['json']}") logger.info(f"[APIClient] Request body: {kwargs['json']}")
@@ -39,7 +44,7 @@ class APIClient:
logger.info(f"[APIClient] Request params: {kwargs['params']}") logger.info(f"[APIClient] Request params: {kwargs['params']}")
try: try:
async with session.request(method, url, **kwargs) as response: async with session.request(method, url, headers=headers, **kwargs) as response:
logger.info(f"[APIClient] Response status: {response.status}") logger.info(f"[APIClient] Response status: {response.status}")
response_text = await response.text() response_text = await response.text()
logger.info(f"[APIClient] Response body: {response_text[:500]}") logger.info(f"[APIClient] Response body: {response_text[:500]}")

View File

@@ -28,6 +28,7 @@ services:
OPENAI_API_KEY: ${OPENAI_API_KEY} OPENAI_API_KEY: ${OPENAI_API_KEY}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-GameMarathonBot} TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-GameMarathonBot}
BOT_API_SECRET: ${BOT_API_SECRET:-}
DEBUG: ${DEBUG:-false} DEBUG: ${DEBUG:-false}
# S3 Storage # S3 Storage
S3_ENABLED: ${S3_ENABLED:-false} S3_ENABLED: ${S3_ENABLED:-false}
@@ -81,9 +82,60 @@ services:
environment: environment:
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
- API_URL=http://backend:8000 - API_URL=http://backend:8000
- BOT_API_SECRET=${BOT_API_SECRET:-}
depends_on: depends_on:
- backend - backend
restart: unless-stopped restart: unless-stopped
status:
build:
context: ./status-service
dockerfile: Dockerfile
container_name: marathon-status
environment:
BACKEND_URL: http://backend:8000
FRONTEND_URL: http://frontend:80
BOT_URL: http://bot:8080
EXTERNAL_URL: ${EXTERNAL_URL:-}
PUBLIC_URL: ${PUBLIC_URL:-}
CHECK_INTERVAL: "30"
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
TELEGRAM_ADMIN_ID: ${TELEGRAM_ADMIN_ID:-947392854}
volumes:
- status_data:/app/data
ports:
- "8001:8001"
depends_on:
- backend
- frontend
- bot
restart: unless-stopped
backup:
build:
context: ./backup-service
dockerfile: Dockerfile
container_name: marathon-backup
environment:
DB_HOST: db
DB_PORT: "5432"
DB_NAME: marathon
DB_USER: marathon
DB_PASSWORD: ${DB_PASSWORD:-marathon}
S3_BUCKET_NAME: ${S3_BUCKET_NAME:-}
S3_REGION: ${S3_REGION:-ru-1}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-}
S3_BACKUP_PREFIX: ${S3_BACKUP_PREFIX:-backups/}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
TELEGRAM_ADMIN_ID: ${TELEGRAM_ADMIN_ID:-947392854}
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-14}
depends_on:
db:
condition: service_healthy
restart: unless-stopped
volumes: volumes:
postgres_data: postgres_data:
status_data:

View File

@@ -20,6 +20,8 @@ import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage'
import { ProfilePage } from '@/pages/ProfilePage' import { ProfilePage } from '@/pages/ProfilePage'
import { UserProfilePage } from '@/pages/UserProfilePage' import { UserProfilePage } from '@/pages/UserProfilePage'
import { NotFoundPage } from '@/pages/NotFoundPage' import { NotFoundPage } from '@/pages/NotFoundPage'
import { TeapotPage } from '@/pages/TeapotPage'
import { ServerErrorPage } from '@/pages/ServerErrorPage'
// Protected route wrapper // Protected route wrapper
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -148,6 +150,15 @@ function App() {
<Route path="users/:id" element={<UserProfilePage />} /> <Route path="users/:id" element={<UserProfilePage />} />
{/* Easter egg - 418 I'm a teapot */}
<Route path="418" element={<TeapotPage />} />
<Route path="teapot" element={<TeapotPage />} />
<Route path="tea" element={<TeapotPage />} />
{/* Server error page */}
<Route path="500" element={<ServerErrorPage />} />
<Route path="error" element={<ServerErrorPage />} />
{/* 404 - must be last */} {/* 404 - must be last */}
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
</Route> </Route>

View File

@@ -22,11 +22,28 @@ client.interceptors.request.use((config) => {
client.interceptors.response.use( client.interceptors.response.use(
(response) => response, (response) => response,
(error: AxiosError<{ detail: string }>) => { (error: AxiosError<{ detail: string }>) => {
// Unauthorized - redirect to login
if (error.response?.status === 401) { if (error.response?.status === 401) {
localStorage.removeItem('token') localStorage.removeItem('token')
localStorage.removeItem('user') localStorage.removeItem('user')
window.location.href = '/login' window.location.href = '/login'
} }
// Server error or network error - redirect to 500 page
if (
error.response?.status === 500 ||
error.response?.status === 502 ||
error.response?.status === 503 ||
error.response?.status === 504 ||
error.code === 'ERR_NETWORK' ||
error.code === 'ECONNABORTED'
) {
// Only redirect if not already on error page
if (!window.location.pathname.startsWith('/500') && !window.location.pathname.startsWith('/error')) {
window.location.href = '/500'
}
}
return Promise.reject(error) return Promise.reject(error)
} }
) )

View File

@@ -79,8 +79,9 @@ export const gamesApi = {
await client.delete(`/challenges/${id}`) await client.delete(`/challenges/${id}`)
}, },
previewChallenges: async (marathonId: number): Promise<ChallengesPreviewResponse> => { previewChallenges: async (marathonId: number, gameIds?: number[]): Promise<ChallengesPreviewResponse> => {
const response = await client.post<ChallengesPreviewResponse>(`/marathons/${marathonId}/preview-challenges`) const data = gameIds?.length ? { game_ids: gameIds } : undefined
const response = await client.post<ChallengesPreviewResponse>(`/marathons/${marathonId}/preview-challenges`, data)
return response.data return response.data
}, },

View File

@@ -41,8 +41,9 @@ export const usersApi = {
}, },
// Получить аватар пользователя как blob URL // Получить аватар пользователя как blob URL
getAvatarUrl: async (userId: number): Promise<string> => { getAvatarUrl: async (userId: number, bustCache = false): Promise<string> => {
const response = await client.get(`/users/${userId}/avatar`, { const cacheBuster = bustCache ? `?t=${Date.now()}` : ''
const response = await client.get(`/users/${userId}/avatar${cacheBuster}`, {
responseType: 'blob', responseType: 'blob',
}) })
return URL.createObjectURL(response.data) return URL.createObjectURL(response.data)

View File

@@ -1,14 +1,13 @@
import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react' import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate, Link } from 'react-router-dom'
import { feedApi } from '@/api' import { feedApi } from '@/api'
import type { Activity, ActivityType } from '@/types' import type { Activity, ActivityType } from '@/types'
import { Loader2, ChevronDown, Bell, ExternalLink, AlertTriangle } from 'lucide-react' import { Loader2, ChevronDown, Activity as ActivityIcon, ExternalLink, AlertTriangle, Sparkles, Zap } from 'lucide-react'
import { UserAvatar } from '@/components/ui' import { UserAvatar } from '@/components/ui'
import { import {
formatRelativeTime, formatRelativeTime,
getActivityIcon, getActivityIcon,
getActivityColor, getActivityColor,
getActivityBgClass,
isEventActivity, isEventActivity,
formatActivityMessage, formatActivityMessage,
} from '@/utils/activity' } from '@/utils/activity'
@@ -100,52 +99,66 @@ export const ActivityFeed = forwardRef<ActivityFeedRef, ActivityFeedProps>(
if (isLoading) { if (isLoading) {
return ( return (
<div className={`bg-gray-800/50 rounded-xl border border-gray-700/50 p-4 flex flex-col ${className}`}> <div className={`glass rounded-2xl border border-dark-600 flex flex-col ${className}`}>
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-3 px-5 py-4 border-b border-dark-600">
<Bell className="w-5 h-5 text-primary-500" /> <div className="w-8 h-8 rounded-lg bg-neon-500/20 flex items-center justify-center">
<h3 className="font-medium text-white">Активность</h3> <ActivityIcon className="w-4 h-4 text-neon-400" />
</div> </div>
<div className="flex-1 flex items-center justify-center"> <h3 className="font-semibold text-white">Активность</h3>
<Loader2 className="w-6 h-6 animate-spin text-gray-500" /> </div>
<div className="flex-1 flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-neon-500" />
</div> </div>
</div> </div>
) )
} }
return ( return (
<div className={`bg-gray-800/50 rounded-xl border border-gray-700/50 flex flex-col ${className}`}> <div className={`glass rounded-2xl border border-dark-600 flex flex-col overflow-hidden ${className}`}>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700/50 flex-shrink-0"> <div className="flex items-center justify-between px-5 py-4 border-b border-dark-600 flex-shrink-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
<Bell className="w-5 h-5 text-primary-500" /> <div className="w-8 h-8 rounded-lg bg-neon-500/20 flex items-center justify-center">
<h3 className="font-medium text-white">Активность</h3> <Zap className="w-4 h-4 text-neon-400" />
</div> </div>
<div>
<h3 className="font-semibold text-white">Активность</h3>
{total > 0 && ( {total > 0 && (
<span className="text-xs text-gray-500">{total}</span> <p className="text-xs text-gray-500">{total} событий</p>
)} )}
</div> </div>
</div>
<div className="w-2 h-2 rounded-full bg-neon-500 animate-pulse" />
</div>
{/* Activity list */} {/* Activity list */}
<div className="flex-1 overflow-y-auto min-h-0 custom-scrollbar"> <div className="flex-1 overflow-y-auto min-h-0 custom-scrollbar">
{activities.length === 0 ? ( {activities.length === 0 ? (
<div className="px-4 py-8 text-center text-gray-500 text-sm"> <div className="px-5 py-12 text-center">
Пока нет активности <div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-dark-700 flex items-center justify-center">
<Sparkles className="w-6 h-6 text-gray-600" />
</div>
<p className="text-gray-400 text-sm">Пока нет активности</p>
</div> </div>
) : ( ) : (
<div className="divide-y divide-gray-700/30"> <div className="divide-y divide-dark-600/50">
{activities.map((activity) => ( {activities.map((activity, index) => (
<ActivityItem key={activity.id} activity={activity} /> <ActivityItem
key={activity.id}
activity={activity}
isNew={index === 0}
/>
))} ))}
</div> </div>
)} )}
{/* Load more button */} {/* Load more button */}
{hasMore && ( {hasMore && (
<div className="p-3 border-t border-gray-700/30"> <div className="p-4 border-t border-dark-600/50">
<button <button
onClick={handleLoadMore} onClick={handleLoadMore}
disabled={isLoadingMore} disabled={isLoadingMore}
className="w-full py-2 text-sm text-gray-400 hover:text-white transition-colors flex items-center justify-center gap-2" className="w-full py-2.5 text-sm text-gray-400 hover:text-neon-400 transition-colors flex items-center justify-center gap-2 rounded-lg hover:bg-neon-500/5"
> >
{isLoadingMore ? ( {isLoadingMore ? (
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
@@ -168,13 +181,13 @@ ActivityFeed.displayName = 'ActivityFeed'
interface ActivityItemProps { interface ActivityItemProps {
activity: Activity activity: Activity
isNew?: boolean
} }
function ActivityItem({ activity }: ActivityItemProps) { function ActivityItem({ activity, isNew }: ActivityItemProps) {
const navigate = useNavigate() const navigate = useNavigate()
const Icon = getActivityIcon(activity.type) const Icon = getActivityIcon(activity.type)
const iconColor = getActivityColor(activity.type) const iconColor = getActivityColor(activity.type)
const bgClass = getActivityBgClass(activity.type)
const isEvent = isEventActivity(activity.type) const isEvent = isEventActivity(activity.type)
const { title, details, extra } = formatActivityMessage(activity) const { title, details, extra } = formatActivityMessage(activity)
@@ -187,21 +200,58 @@ function ActivityItem({ activity }: ActivityItemProps) {
? activityData.dispute_status ? activityData.dispute_status
: null : null
// Determine accent color based on activity type
const getAccentConfig = () => {
switch (activity.type) {
case 'spin':
return { border: 'border-l-accent-500', bg: 'bg-accent-500/5' }
case 'complete':
return { border: 'border-l-green-500', bg: 'bg-green-500/5' }
case 'drop':
return { border: 'border-l-red-500', bg: 'bg-red-500/5' }
case 'start_marathon':
case 'event_start':
return { border: 'border-l-yellow-500', bg: 'bg-yellow-500/5' }
case 'finish_marathon':
case 'event_end':
return { border: 'border-l-gray-500', bg: 'bg-gray-500/5' }
case 'swap':
case 'rematch':
return { border: 'border-l-neon-500', bg: 'bg-neon-500/5' }
default:
return { border: 'border-l-dark-600', bg: '' }
}
}
const accent = getAccentConfig()
if (isEvent) { if (isEvent) {
return ( return (
<div className={`px-4 py-3 ${bgClass} border-l-2 ${activity.type === 'event_start' ? 'border-l-yellow-500' : 'border-l-gray-600'}`}> <div className={`
<div className="flex items-center gap-2 mb-1"> px-5 py-4 border-l-2 transition-colors
<Icon className={`w-4 h-4 ${iconColor}`} /> ${accent.border} ${accent.bg}
<span className={`text-sm font-medium ${activity.type === 'event_start' ? 'text-yellow-400' : 'text-gray-400'}`}> hover:bg-dark-700/30
`}>
<div className="flex items-center gap-2 mb-1.5">
<div className={`w-6 h-6 rounded-md flex items-center justify-center ${
activity.type === 'event_start' ? 'bg-yellow-500/20' : 'bg-gray-500/20'
}`}>
<Icon className={`w-3.5 h-3.5 ${iconColor}`} />
</div>
<span className={`text-sm font-semibold ${
activity.type === 'event_start' ? 'text-yellow-400' : 'text-gray-400'
}`}>
{title} {title}
</span> </span>
</div> </div>
{details && ( {details && (
<div className={`text-sm ${activity.type === 'event_start' ? 'text-yellow-200' : 'text-gray-500'}`}> <div className={`text-sm ml-8 ${
activity.type === 'event_start' ? 'text-yellow-200/80' : 'text-gray-500'
}`}>
{details} {details}
</div> </div>
)} )}
<div className="text-xs text-gray-500 mt-1"> <div className="text-xs text-gray-600 mt-2 ml-8">
{formatRelativeTime(activity.created_at)} {formatRelativeTime(activity.created_at)}
</div> </div>
</div> </div>
@@ -209,39 +259,57 @@ function ActivityItem({ activity }: ActivityItemProps) {
} }
return ( return (
<div className={`px-4 py-3 hover:bg-gray-700/20 transition-colors ${bgClass}`}> <div className={`
px-5 py-4 border-l-2 transition-all duration-200
${accent.border} ${isNew ? accent.bg : ''}
hover:bg-dark-700/30 group
`}>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{/* Avatar */} {/* Avatar */}
<div className="flex-shrink-0"> <Link to={`/users/${activity.user.id}`} className="flex-shrink-0 relative" onClick={(e) => e.stopPropagation()}>
<UserAvatar <UserAvatar
userId={activity.user.id} userId={activity.user.id}
hasAvatar={!!activity.user.avatar_url} hasAvatar={!!activity.user.avatar_url}
nickname={activity.user.nickname} nickname={activity.user.nickname}
size="sm" size="sm"
/> />
{/* Activity type badge */}
<div className={`
absolute -bottom-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center
border-2 border-dark-800
${activity.type === 'complete' ? 'bg-green-500' :
activity.type === 'drop' ? 'bg-red-500' :
activity.type === 'spin' ? 'bg-accent-500' :
'bg-neon-500'}
`}>
<Icon className="w-2.5 h-2.5 text-white" />
</div> </div>
</Link>
{/* Content */} {/* Content */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-white truncate"> <Link
to={`/users/${activity.user.id}`}
className="text-sm font-semibold text-white hover:text-neon-400 transition-colors"
onClick={(e) => e.stopPropagation()}
>
{activity.user.nickname} {activity.user.nickname}
</span> </Link>
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-600">
{formatRelativeTime(activity.created_at)} {formatRelativeTime(activity.created_at)}
</span> </span>
</div> </div>
<div className="flex items-center gap-1.5 mt-0.5"> <div className="flex items-center gap-1.5 mt-1">
<Icon className={`w-3.5 h-3.5 flex-shrink-0 ${iconColor}`} />
<span className="text-sm text-gray-300">{title}</span> <span className="text-sm text-gray-300">{title}</span>
</div> </div>
{details && ( {details && (
<div className="text-sm text-gray-400 mt-1"> <div className="text-sm text-gray-500 mt-1">
{details} {details}
</div> </div>
)} )}
{extra && ( {extra && (
<div className="text-xs text-gray-500 mt-0.5"> <div className="text-xs text-gray-600 mt-1">
{extra} {extra}
</div> </div>
)} )}
@@ -250,19 +318,19 @@ function ActivityItem({ activity }: ActivityItemProps) {
<div className="flex items-center gap-3 mt-2"> <div className="flex items-center gap-3 mt-2">
<button <button
onClick={() => navigate(`/assignments/${assignmentId}`)} onClick={() => navigate(`/assignments/${assignmentId}`)}
className="text-xs text-primary-400 hover:text-primary-300 flex items-center gap-1" className="text-xs text-neon-400 hover:text-neon-300 flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-neon-500/10 transition-colors"
> >
<ExternalLink className="w-3 h-3" /> <ExternalLink className="w-3 h-3" />
Детали Детали
</button> </button>
{disputeStatus === 'open' && ( {disputeStatus === 'open' && (
<span className="text-xs text-orange-400 flex items-center gap-1"> <span className="text-xs text-orange-400 flex items-center gap-1.5 px-2 py-1 rounded-md bg-orange-500/10">
<AlertTriangle className="w-3 h-3" /> <AlertTriangle className="w-3 h-3" />
Оспаривается Оспаривается
</span> </span>
)} )}
{disputeStatus === 'valid' && ( {disputeStatus === 'valid' && (
<span className="text-xs text-red-400 flex items-center gap-1"> <span className="text-xs text-red-400 flex items-center gap-1.5 px-2 py-1 rounded-md bg-red-500/10">
<AlertTriangle className="w-3 h-3" /> <AlertTriangle className="w-3 h-3" />
Отклонено Отклонено
</span> </span>

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Clock } from 'lucide-react' import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Clock, Sparkles } from 'lucide-react'
import type { ActiveEvent, EventType } from '@/types' import type { ActiveEvent, EventType } from '@/types'
import { EVENT_INFO } from '@/types' import { EVENT_INFO } from '@/types'
@@ -17,13 +17,55 @@ const EVENT_ICONS: Record<EventType, React.ReactNode> = {
game_choice: <Gamepad2 className="w-5 h-5" />, game_choice: <Gamepad2 className="w-5 h-5" />,
} }
const EVENT_COLORS: Record<EventType, string> = { const EVENT_COLORS: Record<EventType, {
golden_hour: 'from-yellow-500/20 to-yellow-600/20 border-yellow-500/50 text-yellow-400', gradient: string
common_enemy: 'from-red-500/20 to-red-600/20 border-red-500/50 text-red-400', border: string
double_risk: 'from-purple-500/20 to-purple-600/20 border-purple-500/50 text-purple-400', text: string
jackpot: 'from-green-500/20 to-green-600/20 border-green-500/50 text-green-400', glow: string
swap: 'from-blue-500/20 to-blue-600/20 border-blue-500/50 text-blue-400', iconBg: string
game_choice: 'from-orange-500/20 to-orange-600/20 border-orange-500/50 text-orange-400', }> = {
golden_hour: {
gradient: 'from-yellow-500/20 via-yellow-500/10 to-transparent',
border: 'border-yellow-500/50',
text: 'text-yellow-400',
glow: 'shadow-[0_0_30px_rgba(234,179,8,0.3)]',
iconBg: 'bg-yellow-500/20',
},
common_enemy: {
gradient: 'from-red-500/20 via-red-500/10 to-transparent',
border: 'border-red-500/50',
text: 'text-red-400',
glow: 'shadow-[0_0_30px_rgba(239,68,68,0.3)]',
iconBg: 'bg-red-500/20',
},
double_risk: {
gradient: 'from-purple-500/20 via-purple-500/10 to-transparent',
border: 'border-purple-500/50',
text: 'text-purple-400',
glow: 'shadow-[0_0_20px_rgba(139,92,246,0.25)]',
iconBg: 'bg-purple-500/20',
},
jackpot: {
gradient: 'from-green-500/20 via-green-500/10 to-transparent',
border: 'border-green-500/50',
text: 'text-green-400',
glow: 'shadow-[0_0_30px_rgba(34,197,94,0.3)]',
iconBg: 'bg-green-500/20',
},
swap: {
gradient: 'from-neon-500/20 via-neon-500/10 to-transparent',
border: 'border-neon-500/50',
text: 'text-neon-400',
glow: 'shadow-[0_0_20px_rgba(34,211,238,0.25)]',
iconBg: 'bg-neon-500/20',
},
game_choice: {
gradient: 'from-orange-500/20 via-orange-500/10 to-transparent',
border: 'border-orange-500/50',
text: 'text-orange-400',
glow: 'shadow-[0_0_30px_rgba(249,115,22,0.3)]',
iconBg: 'bg-orange-500/20',
},
} }
function formatTime(seconds: number): string { function formatTime(seconds: number): string {
@@ -68,42 +110,53 @@ export function EventBanner({ activeEvent, onRefresh }: EventBannerProps) {
const event = activeEvent.event const event = activeEvent.event
const info = EVENT_INFO[event.type] const info = EVENT_INFO[event.type]
const icon = EVENT_ICONS[event.type] const icon = EVENT_ICONS[event.type]
const colorClass = EVENT_COLORS[event.type] const colors = EVENT_COLORS[event.type]
return ( return (
<div <div
className={` className={`
relative overflow-hidden rounded-xl border p-4 relative overflow-hidden rounded-2xl border p-5
bg-gradient-to-r ${colorClass} glass ${colors.border} ${colors.glow}
animate-pulse-slow animate-pulse-slow
`} `}
> >
{/* Animated background effect */} {/* Background gradient */}
<div className={`absolute inset-0 bg-gradient-to-r ${colors.gradient}`} />
{/* Animated shimmer effect */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent -translate-x-full animate-shimmer" /> <div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent -translate-x-full animate-shimmer" />
<div className="relative flex items-center justify-between"> {/* Grid pattern */}
<div className="flex items-center gap-3"> <div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.02)_1px,transparent_1px)] bg-[size:20px_20px]" />
<div className="p-2 rounded-lg bg-white/10">
<div className="relative flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-4">
<div className={`p-3 rounded-xl ${colors.iconBg} ${colors.text}`}>
{icon} {icon}
</div> </div>
<div> <div>
<h3 className="font-bold text-lg">{info.name}</h3> <div className="flex items-center gap-2 mb-1">
<p className="text-sm opacity-80">{info.description}</p> <h3 className={`font-bold text-lg ${colors.text}`}>{info.name}</h3>
<Sparkles className={`w-4 h-4 ${colors.text} animate-pulse`} />
</div>
<p className="text-sm text-gray-400">{info.description}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-4">
{activeEvent.effects.points_multiplier !== 1.0 && (
<div className={`px-4 py-2 rounded-xl ${colors.iconBg} font-bold ${colors.text} border ${colors.border}`}>
x{activeEvent.effects.points_multiplier}
</div>
)}
{timeRemaining !== null && timeRemaining > 0 && ( {timeRemaining !== null && timeRemaining > 0 && (
<div className="flex items-center gap-2 text-lg font-mono font-bold"> <div className={`flex items-center gap-2 px-4 py-2 rounded-xl bg-dark-700/50 border border-dark-600 font-mono font-bold ${colors.text}`}>
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
{formatTime(timeRemaining)} {formatTime(timeRemaining)}
</div> </div>
)} )}
{activeEvent.effects.points_multiplier !== 1.0 && (
<div className="px-3 py-1 rounded-full bg-white/10 font-bold">
x{activeEvent.effects.points_multiplier}
</div> </div>
)}
</div> </div>
</div> </div>
) )

View File

@@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Play, Square } from 'lucide-react' import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Play, Square, Sparkles } from 'lucide-react'
import { Button } from '@/components/ui' import { NeonButton } from '@/components/ui'
import { eventsApi } from '@/api' import { eventsApi } from '@/api'
import type { ActiveEvent, EventType, Challenge } from '@/types' import type { ActiveEvent, EventType, Challenge } from '@/types'
import { EVENT_INFO } from '@/types' import { EVENT_INFO } from '@/types'
@@ -24,12 +24,21 @@ const EVENT_TYPES: EventType[] = [
] ]
const EVENT_ICONS: Record<EventType, React.ReactNode> = { const EVENT_ICONS: Record<EventType, React.ReactNode> = {
golden_hour: <Zap className="w-4 h-4" />, golden_hour: <Zap className="w-5 h-5" />,
common_enemy: <Users className="w-4 h-4" />, common_enemy: <Users className="w-5 h-5" />,
double_risk: <Shield className="w-4 h-4" />, double_risk: <Shield className="w-5 h-5" />,
jackpot: <Gift className="w-4 h-4" />, jackpot: <Gift className="w-5 h-5" />,
swap: <ArrowLeftRight className="w-4 h-4" />, swap: <ArrowLeftRight className="w-5 h-5" />,
game_choice: <Gamepad2 className="w-4 h-4" />, game_choice: <Gamepad2 className="w-5 h-5" />,
}
const EVENT_COLORS: Record<EventType, { selected: string; icon: string }> = {
golden_hour: { selected: 'border-yellow-500/50 bg-yellow-500/10', icon: 'text-yellow-400' },
common_enemy: { selected: 'border-red-500/50 bg-red-500/10', icon: 'text-red-400' },
double_risk: { selected: 'border-purple-500/50 bg-purple-500/10', icon: 'text-purple-400' },
jackpot: { selected: 'border-green-500/50 bg-green-500/10', icon: 'text-green-400' },
swap: { selected: 'border-neon-500/50 bg-neon-500/10', icon: 'text-neon-400' },
game_choice: { selected: 'border-orange-500/50 bg-orange-500/10', icon: 'text-orange-400' },
} }
// Default durations for events (in minutes) // Default durations for events (in minutes)
@@ -107,54 +116,81 @@ export function EventControl({
} }
if (activeEvent.event) { if (activeEvent.event) {
const colors = EVENT_COLORS[activeEvent.event.type]
return ( return (
<div className="p-4 bg-gray-800 rounded-xl"> <div className={`glass rounded-xl p-4 border ${colors.selected}`}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
<div className={`p-2 rounded-lg bg-white/10 ${colors.icon}`}>
{EVENT_ICONS[activeEvent.event.type]} {EVENT_ICONS[activeEvent.event.type]}
<span className="font-medium">
Активно: {EVENT_INFO[activeEvent.event.type].name}
</span>
</div> </div>
<Button <div>
variant="danger" <span className="font-semibold text-white">
{EVENT_INFO[activeEvent.event.type].name}
</span>
<span className="text-gray-400 text-sm ml-2">активно</span>
</div>
</div>
<NeonButton
variant="outline"
size="sm" size="sm"
onClick={handleStop} onClick={handleStop}
isLoading={isStopping} isLoading={isStopping}
className="border-red-500/50 text-red-400 hover:bg-red-500/10"
icon={<Square className="w-4 h-4" />}
> >
<Square className="w-4 h-4 mr-1" />
Остановить Остановить
</Button> </NeonButton>
</div> </div>
</div> </div>
) )
} }
return ( return (
<div className="p-4 bg-gray-800 rounded-xl space-y-4"> <div className="glass rounded-xl p-5 space-y-5">
<h3 className="font-bold text-white">Запустить событие</h3> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
<Sparkles className="w-5 h-5 text-accent-400" />
</div>
<div>
<h3 className="font-semibold text-white">Запустить событие</h3>
<p className="text-sm text-gray-400">Выберите тип и настройте параметры</p>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{EVENT_TYPES.map((type) => ( {EVENT_TYPES.map((type) => {
const colors = EVENT_COLORS[type]
const isSelected = selectedType === type
return (
<button <button
key={type} key={type}
onClick={() => handleTypeChange(type)} onClick={() => handleTypeChange(type)}
className={` className={`
p-3 rounded-lg border-2 transition-all text-left relative p-4 rounded-xl border-2 transition-all duration-300 text-left
${selectedType === type ${isSelected
? 'border-primary-500 bg-primary-500/10' ? `${colors.selected} shadow-lg`
: 'border-gray-700 hover:border-gray-600'} : 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
}
`} `}
> >
<div className="flex items-center gap-2 mb-1"> <div className={`flex items-center gap-2 mb-2 ${isSelected ? colors.icon : 'text-gray-400'}`}>
{EVENT_ICONS[type]} {EVENT_ICONS[type]}
<span className="font-medium text-sm">{EVENT_INFO[type].name}</span> <span className={`font-semibold text-sm ${isSelected ? 'text-white' : 'text-gray-300'}`}>
{EVENT_INFO[type].name}
</span>
</div> </div>
<p className="text-xs text-gray-400 line-clamp-2"> <p className="text-xs text-gray-500 line-clamp-2">
{EVENT_INFO[type].description} {EVENT_INFO[type].description}
</p> </p>
{isSelected && (
<div className="absolute top-2 right-2">
<div className={`w-2 h-2 rounded-full ${colors.icon.replace('text-', 'bg-')} animate-pulse`} />
</div>
)}
</button> </button>
))} )
})}
</div> </div>
{/* Duration setting */} {/* Duration setting */}
@@ -170,9 +206,9 @@ export function EventControl({
min={1} min={1}
max={480} max={480}
placeholder={`По умолчанию: ${DEFAULT_DURATIONS[selectedType]}`} placeholder={`По умолчанию: ${DEFAULT_DURATIONS[selectedType]}`}
className="w-full p-2 bg-gray-700 border border-gray-600 rounded-lg text-white" className="input w-full"
/> />
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1.5">
Оставьте пустым для значения по умолчанию ({DEFAULT_DURATIONS[selectedType]} мин) Оставьте пустым для значения по умолчанию ({DEFAULT_DURATIONS[selectedType]} мин)
</p> </p>
</div> </div>
@@ -186,7 +222,7 @@ export function EventControl({
<select <select
value={selectedChallengeId || ''} value={selectedChallengeId || ''}
onChange={(e) => setSelectedChallengeId(e.target.value ? Number(e.target.value) : null)} onChange={(e) => setSelectedChallengeId(e.target.value ? Number(e.target.value) : null)}
className="w-full p-2 bg-gray-700 border border-gray-600 rounded-lg text-white" className="input w-full"
> >
<option value=""> Выберите челлендж </option> <option value=""> Выберите челлендж </option>
{challenges.map((c) => ( {challenges.map((c) => (
@@ -198,15 +234,15 @@ export function EventControl({
</div> </div>
)} )}
<Button <NeonButton
onClick={handleStart} onClick={handleStart}
isLoading={isStarting} isLoading={isStarting}
disabled={selectedType === 'common_enemy' && !selectedChallengeId} disabled={selectedType === 'common_enemy' && !selectedChallengeId}
className="w-full" className="w-full"
icon={<Play className="w-4 h-4" />}
> >
<Play className="w-4 h-4 mr-2" />
Запустить {EVENT_INFO[selectedType].name} Запустить {EVENT_INFO[selectedType].name}
</Button> </NeonButton>
</div> </div>
) )
} }

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useCallback, useMemo } from 'react'
import type { Game } from '@/types' import type { Game } from '@/types'
import { Gamepad2, Loader2 } from 'lucide-react'
interface SpinWheelProps { interface SpinWheelProps {
games: Game[] games: Game[]
@@ -8,33 +9,80 @@ interface SpinWheelProps {
disabled?: boolean disabled?: boolean
} }
const ITEM_HEIGHT = 100 const SPIN_DURATION = 5000 // ms
const VISIBLE_ITEMS = 5 const EXTRA_ROTATIONS = 5
const SPIN_DURATION = 4000
const EXTRA_ROTATIONS = 3 // Цветовая палитра секторов
const SECTOR_COLORS = [
{ bg: '#0d9488', border: '#14b8a6' }, // teal
{ bg: '#7c3aed', border: '#8b5cf6' }, // violet
{ bg: '#0891b2', border: '#06b6d4' }, // cyan
{ bg: '#c026d3', border: '#d946ef' }, // fuchsia
{ bg: '#059669', border: '#10b981' }, // emerald
{ bg: '#7c2d12', border: '#ea580c' }, // orange
{ bg: '#1d4ed8', border: '#3b82f6' }, // blue
{ bg: '#be123c', border: '#e11d48' }, // rose
]
export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheelProps) { export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheelProps) {
const [isSpinning, setIsSpinning] = useState(false) const [isSpinning, setIsSpinning] = useState(false)
const [offset, setOffset] = useState(0) const [rotation, setRotation] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const animationRef = useRef<number | null>(null)
// Create extended list for seamless looping // Размеры колеса
const extendedGames = [...games, ...games, ...games, ...games, ...games] const wheelSize = 400
const centerX = wheelSize / 2
const centerY = wheelSize / 2
const radius = wheelSize / 2 - 10
// Рассчитываем углы секторов
const sectorAngle = games.length > 0 ? 360 / games.length : 360
// Создаём path для сектора
const createSectorPath = useCallback((index: number, total: number) => {
const angle = 360 / total
const startAngle = index * angle - 90 // Начинаем сверху
const endAngle = startAngle + angle
const startRad = (startAngle * Math.PI) / 180
const endRad = (endAngle * Math.PI) / 180
const x1 = centerX + radius * Math.cos(startRad)
const y1 = centerY + radius * Math.sin(startRad)
const x2 = centerX + radius * Math.cos(endRad)
const y2 = centerY + radius * Math.sin(endRad)
const largeArcFlag = angle > 180 ? 1 : 0
return `M ${centerX} ${centerY} L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${x2} ${y2} Z`
}, [centerX, centerY, radius])
// Позиция текста в секторе
const getTextPosition = useCallback((index: number, total: number) => {
const angle = 360 / total
const midAngle = index * angle + angle / 2 - 90
const midRad = (midAngle * Math.PI) / 180
const textRadius = radius * 0.65
return {
x: centerX + textRadius * Math.cos(midRad),
y: centerY + textRadius * Math.sin(midRad),
rotation: midAngle + 90, // Текст читается от центра к краю
}
}, [centerX, centerY, radius])
const handleSpin = useCallback(async () => { const handleSpin = useCallback(async () => {
if (isSpinning || disabled || games.length === 0) return if (isSpinning || disabled || games.length === 0) return
setIsSpinning(true) setIsSpinning(true)
// Get result from API first // Получаем результат от API
const resultGame = await onSpin() const resultGame = await onSpin()
if (!resultGame) { if (!resultGame) {
setIsSpinning(false) setIsSpinning(false)
return return
} }
// Find target index // Находим индекс выигравшей игры
const targetIndex = games.findIndex(g => g.id === resultGame.id) const targetIndex = games.findIndex(g => g.id === resultGame.id)
if (targetIndex === -1) { if (targetIndex === -1) {
setIsSpinning(false) setIsSpinning(false)
@@ -42,168 +90,245 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
return return
} }
// Calculate animation // Рассчитываем угол для остановки
const totalItems = games.length // Указатель находится сверху (на 0°/360°)
const fullRotations = EXTRA_ROTATIONS * totalItems // Нам нужно чтобы нужный сектор оказался под указателем
const finalPosition = (fullRotations + targetIndex) * ITEM_HEIGHT const targetSectorMidAngle = targetIndex * sectorAngle + sectorAngle / 2
// Animate // Полные обороты + угол до центра сектора
const startTime = Date.now() // Колесо крутится по часовой стрелке, указатель сверху
const startOffset = offset % (totalItems * ITEM_HEIGHT) // Чтобы сектор оказался сверху, нужно повернуть на (360 - targetSectorMidAngle)
const baseRotation = rotation % 360
const fullRotations = EXTRA_ROTATIONS * 360
const finalAngle = fullRotations + (360 - targetSectorMidAngle) + baseRotation
const animate = () => { setRotation(rotation + finalAngle)
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / SPIN_DURATION, 1)
// Easing function - starts fast, slows down at end // Ждём окончания анимации
const easeOut = 1 - Math.pow(1 - progress, 4) setTimeout(() => {
const currentOffset = startOffset + (finalPosition - startOffset) * easeOut
setOffset(currentOffset)
if (progress < 1) {
animationRef.current = requestAnimationFrame(animate)
} else {
setIsSpinning(false) setIsSpinning(false)
onSpinComplete(resultGame) onSpinComplete(resultGame)
} }, SPIN_DURATION)
}, [isSpinning, disabled, games, rotation, sectorAngle, onSpin, onSpinComplete])
// Сокращаем название игры для отображения
const truncateText = (text: string, maxLength: number) => {
if (text.length <= maxLength) return text
return text.slice(0, maxLength - 2) + '...'
} }
animationRef.current = requestAnimationFrame(animate) // Мемоизируем секторы для производительности
}, [isSpinning, disabled, games, offset, onSpin, onSpinComplete]) const sectors = useMemo(() => {
return games.map((game, index) => {
const color = SECTOR_COLORS[index % SECTOR_COLORS.length]
const path = createSectorPath(index, games.length)
const textPos = getTextPosition(index, games.length)
const maxTextLength = games.length > 8 ? 10 : games.length > 5 ? 14 : 18
useEffect(() => { return { game, color, path, textPos, maxTextLength }
return () => { })
if (animationRef.current) { }, [games, createSectorPath, getTextPosition])
cancelAnimationFrame(animationRef.current)
}
}
}, [])
if (games.length === 0) { if (games.length === 0) {
return ( return (
<div className="text-center py-12 text-gray-400"> <div className="glass rounded-2xl p-12 text-center">
Нет доступных игр для прокрутки <div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-700 flex items-center justify-center">
<Gamepad2 className="w-8 h-8 text-gray-600" />
</div>
<p className="text-gray-400">Нет доступных игр для прокрутки</p>
</div> </div>
) )
} }
const containerHeight = VISIBLE_ITEMS * ITEM_HEIGHT
const currentIndex = Math.round(offset / ITEM_HEIGHT) % games.length
// Calculate opacity based on distance from center
const getItemOpacity = (itemIndex: number) => {
const itemPosition = itemIndex * ITEM_HEIGHT - offset
const centerPosition = containerHeight / 2 - ITEM_HEIGHT / 2
const distanceFromCenter = Math.abs(itemPosition - centerPosition)
const maxDistance = containerHeight / 2
const opacity = Math.max(0, 1 - (distanceFromCenter / maxDistance) * 0.8)
return opacity
}
return ( return (
<div className="flex flex-col items-center gap-6"> <div className="flex flex-col items-center gap-6">
{/* Wheel container */} {/* Контейнер колеса */}
<div className="relative w-full max-w-md"> <div className="relative">
{/* Selection indicator */} {/* Внешнее свечение */}
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-[100px] border-2 border-primary-500 rounded-lg bg-primary-500/10 z-20 pointer-events-none"> <div className={`
<div className="absolute -left-3 top-1/2 -translate-y-1/2 w-0 h-0 border-t-8 border-b-8 border-r-8 border-t-transparent border-b-transparent border-r-primary-500" /> absolute -inset-4 rounded-full transition-all duration-500
<div className="absolute -right-3 top-1/2 -translate-y-1/2 w-0 h-0 border-t-8 border-b-8 border-l-8 border-t-transparent border-b-transparent border-l-primary-500" /> ${isSpinning
? 'bg-neon-500/30 blur-2xl animate-pulse'
: 'bg-neon-500/10 blur-xl'
}
`} />
{/* Указатель (стрелка сверху) */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-2 z-20">
<div className={`
relative transition-all duration-300
${isSpinning ? 'scale-110' : ''}
`}>
{/* Свечение указателя */}
<div className={`
absolute inset-0 blur-sm transition-opacity duration-300
${isSpinning ? 'opacity-100' : 'opacity-50'}
`}>
<svg width="40" height="50" viewBox="0 0 40 50">
<path
d="M20 50 L5 15 L20 0 L35 15 Z"
fill="#22d3ee"
/>
</svg>
</div>
{/* Указатель */}
<svg width="40" height="50" viewBox="0 0 40 50" className="relative">
<defs>
<linearGradient id="pointerGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#22d3ee" />
<stop offset="100%" stopColor="#0891b2" />
</linearGradient>
</defs>
<path
d="M20 50 L5 15 L20 0 L35 15 Z"
fill="url(#pointerGradient)"
stroke="#67e8f9"
strokeWidth="2"
/>
</svg>
</div>
</div> </div>
{/* Items container */} {/* Колесо */}
<div <div
ref={containerRef} className="relative"
className="relative overflow-hidden" style={{ width: wheelSize, height: wheelSize }}
style={{ height: containerHeight }}
> >
<div {/* Внешний ободок */}
className="absolute w-full transition-none" <div className={`
absolute inset-0 rounded-full
border-4 transition-all duration-300
${isSpinning
? 'border-neon-400 shadow-[0_0_30px_rgba(34,211,238,0.5),inset_0_0_30px_rgba(34,211,238,0.1)]'
: 'border-neon-500/50 shadow-[0_0_15px_rgba(34,211,238,0.2)]'
}
`} />
{/* SVG колесо */}
<svg
width={wheelSize}
height={wheelSize}
className="relative z-10 transition-transform"
style={{ style={{
transform: `translateY(${containerHeight / 2 - ITEM_HEIGHT / 2 - offset}px)`, transform: `rotate(${rotation}deg)`,
transitionProperty: isSpinning ? 'transform' : 'none',
transitionDuration: isSpinning ? `${SPIN_DURATION}ms` : '0ms',
transitionTimingFunction: 'cubic-bezier(0.17, 0.67, 0.12, 0.99)',
}} }}
> >
{extendedGames.map((game, index) => { <defs>
const realIndex = index % games.length {/* Тени для секторов */}
const isSelected = !isSpinning && realIndex === currentIndex <filter id="sectorShadow" x="-20%" y="-20%" width="140%" height="140%">
const opacity = getItemOpacity(index) <feDropShadow dx="0" dy="0" stdDeviation="2" floodColor="#000" floodOpacity="0.3" />
</filter>
</defs>
return ( {/* Секторы */}
<div {sectors.map(({ game, color, path, textPos, maxTextLength }, index) => (
key={`${game.id}-${index}`} <g key={game.id}>
className={`flex items-center gap-4 px-4 transition-transform duration-200 ${ {/* Сектор */}
isSelected ? 'scale-105' : '' <path
}`} d={path}
style={{ height: ITEM_HEIGHT, opacity }} fill={color.bg}
> stroke={color.border}
{/* Game cover */} strokeWidth="2"
<div className="w-16 h-16 rounded-lg overflow-hidden bg-gray-700 flex-shrink-0"> filter="url(#sectorShadow)"
{game.cover_url ? (
<img
src={game.cover_url}
alt={game.title}
className="w-full h-full object-cover"
/> />
) : (
<div className="w-full h-full flex items-center justify-center text-2xl">
🎮
</div>
)}
</div>
{/* Game info */} {/* Текст названия игры */}
<div className="flex-1 min-w-0"> <text
<h3 className="font-bold text-white truncate text-lg"> x={textPos.x}
{game.title} y={textPos.y}
</h3> transform={`rotate(${textPos.rotation}, ${textPos.x}, ${textPos.y})`}
{game.genre && ( textAnchor="middle"
<p className="text-sm text-gray-400 truncate">{game.genre}</p> dominantBaseline="middle"
)} fill="white"
</div> fontSize={games.length > 10 ? "10" : games.length > 6 ? "11" : "13"}
</div> fontWeight="bold"
) style={{
})} textShadow: '0 1px 3px rgba(0,0,0,0.8)',
</div> pointerEvents: 'none',
</div> }}
</div> >
{truncateText(game.title, maxTextLength)}
</text>
{/* Spin button */} {/* Разделительная линия */}
<line
x1={centerX}
y1={centerY}
x2={centerX + radius * Math.cos((index * sectorAngle - 90) * Math.PI / 180)}
y2={centerY + radius * Math.sin((index * sectorAngle - 90) * Math.PI / 180)}
stroke="rgba(255,255,255,0.3)"
strokeWidth="1"
/>
</g>
))}
{/* Центральный круг */}
<circle
cx={centerX}
cy={centerY}
r="50"
fill="url(#centerGradient)"
stroke="#22d3ee"
strokeWidth="3"
/>
<defs>
<radialGradient id="centerGradient" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#1e293b" />
<stop offset="100%" stopColor="#0f172a" />
</radialGradient>
</defs>
</svg>
{/* Кнопка КРУТИТЬ в центре */}
<button <button
onClick={handleSpin} onClick={handleSpin}
disabled={isSpinning || disabled} disabled={isSpinning || disabled}
className={` className={`
relative px-12 py-4 text-xl font-bold rounded-full absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
transition-all duration-300 transform w-24 h-24 rounded-full z-20
${isSpinning || disabled flex flex-col items-center justify-center gap-1
? 'bg-gray-700 text-gray-400 cursor-not-allowed' font-bold text-sm uppercase tracking-wider
: 'bg-gradient-to-r from-primary-500 to-primary-600 text-white hover:scale-105 hover:shadow-lg hover:shadow-primary-500/30 active:scale-95' transition-all duration-300
disabled:cursor-not-allowed
${isSpinning
? 'bg-dark-800 text-neon-400 shadow-[0_0_20px_rgba(34,211,238,0.4)]'
: 'bg-gradient-to-br from-neon-500 to-cyan-600 text-white hover:shadow-[0_0_30px_rgba(34,211,238,0.6)] hover:scale-105 active:scale-95'
} }
${disabled && !isSpinning ? 'opacity-50' : ''}
`} `}
> >
{isSpinning ? ( {isSpinning ? (
<span className="flex items-center gap-2"> <Loader2 className="w-8 h-8 animate-spin" />
<svg className="animate-spin w-6 h-6" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Крутится...
</span>
) : ( ) : (
'КРУТИТЬ!' <>
<span className="text-xs">КРУТИТЬ</span>
</>
)} )}
</button> </button>
</div> </div>
{/* Декоративные элементы при вращении */}
{isSpinning && (
<>
<div className="absolute inset-0 rounded-full border-2 border-neon-400/30 animate-ping" />
<div
className="absolute inset-0 rounded-full border border-accent-400/20"
style={{ animation: 'ping 1s cubic-bezier(0, 0, 0.2, 1) infinite 0.5s' }}
/>
</>
)}
</div>
{/* Подсказка */}
<p className={`
text-sm transition-all duration-300
${isSpinning ? 'text-neon-400 animate-pulse' : 'text-gray-500'}
`}>
{isSpinning ? 'Колесо вращается...' : 'Нажмите на колесо, чтобы крутить!'}
</p>
</div>
) )
} }

View File

@@ -125,8 +125,8 @@ export function TelegramLink() {
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
className={`p-2 rounded-lg transition-colors ${ className={`p-2 rounded-lg transition-colors ${
isLinked isLinked
? 'text-blue-400 hover:text-blue-300 hover:bg-gray-700' ? 'text-blue-400 hover:text-blue-300 hover:bg-dark-700'
: 'text-gray-400 hover:text-white hover:bg-gray-700' : 'text-gray-400 hover:text-white hover:bg-dark-700'
}`} }`}
title={isLinked ? 'Telegram привязан' : 'Привязать Telegram'} title={isLinked ? 'Telegram привязан' : 'Привязать Telegram'}
> >
@@ -134,17 +134,17 @@ export function TelegramLink() {
</button> </button>
{isOpen && ( {isOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-xl max-w-md w-full p-6 relative"> <div className="glass rounded-xl max-w-md w-full p-6 relative border border-dark-600">
<button <button
onClick={handleClose} onClick={handleClose}
className="absolute top-4 right-4 text-gray-400 hover:text-white" className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
> >
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-blue-500/20 rounded-full flex items-center justify-center"> <div className="w-12 h-12 bg-blue-500/10 rounded-full flex items-center justify-center border border-blue-500/30">
<MessageCircle className="w-6 h-6 text-blue-400" /> <MessageCircle className="w-6 h-6 text-blue-400" />
</div> </div>
<div> <div>
@@ -171,7 +171,7 @@ export function TelegramLink() {
)} )}
{/* User Profile Card */} {/* User Profile Card */}
<div className="p-4 bg-gradient-to-br from-gray-700/50 to-gray-800/50 rounded-xl border border-gray-600/50"> <div className="p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Avatar - Telegram avatar */} {/* Avatar - Telegram avatar */}
<div className="relative"> <div className="relative">
@@ -182,12 +182,12 @@ export function TelegramLink() {
className="w-12 h-12 rounded-full object-cover border-2 border-blue-500/50" className="w-12 h-12 rounded-full object-cover border-2 border-blue-500/50"
/> />
) : ( ) : (
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center border-2 border-blue-500/50"> <div className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-accent-500 flex items-center justify-center border-2 border-blue-500/50">
<User className="w-6 h-6 text-white" /> <User className="w-6 h-6 text-white" />
</div> </div>
)} )}
{/* Link indicator */} {/* Link indicator */}
<div className="absolute -bottom-1 -right-1 w-5 h-5 bg-green-500 rounded-full flex items-center justify-center border-2 border-gray-800"> <div className="absolute -bottom-1 -right-1 w-5 h-5 bg-green-500 rounded-full flex items-center justify-center border-2 border-dark-800">
<Link2 className="w-2.5 h-2.5 text-white" /> <Link2 className="w-2.5 h-2.5 text-white" />
</div> </div>
</div> </div>
@@ -205,7 +205,7 @@ export function TelegramLink() {
</div> </div>
{/* Notifications Info */} {/* Notifications Info */}
<div className="p-4 bg-gray-700/30 rounded-lg"> <div className="p-4 bg-dark-700/30 rounded-lg border border-dark-600/50">
<p className="text-sm text-gray-300 font-medium mb-3">Уведомления включены:</p> <p className="text-sm text-gray-300 font-medium mb-3">Уведомления включены:</p>
<div className="grid grid-cols-1 gap-2"> <div className="grid grid-cols-1 gap-2">
<div className="flex items-center gap-2 text-sm text-gray-400"> <div className="flex items-center gap-2 text-sm text-gray-400">
@@ -254,7 +254,7 @@ export function TelegramLink() {
<button <button
onClick={handleOpenBot} onClick={handleOpenBot}
className="w-full py-3 px-4 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2" className="w-full py-3 px-4 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 border border-blue-500/50 hover:border-blue-500 rounded-lg font-medium transition-all duration-200 flex items-center justify-center gap-2"
> >
<ExternalLink className="w-5 h-5" /> <ExternalLink className="w-5 h-5" />
Открыть Telegram снова Открыть Telegram снова
@@ -268,13 +268,13 @@ export function TelegramLink() {
<button <button
onClick={handleOpenBot} onClick={handleOpenBot}
className="w-full py-3 px-4 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2" className="w-full py-3 px-4 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 border border-blue-500/50 hover:border-blue-500 rounded-lg font-medium transition-all duration-200 flex items-center justify-center gap-2"
> >
<ExternalLink className="w-5 h-5" /> <ExternalLink className="w-5 h-5" />
Открыть Telegram Открыть Telegram
</button> </button>
<p className="text-sm text-gray-500 text-center"> <p className="text-sm text-gray-400 text-center">
Ссылка действительна 10 минут Ссылка действительна 10 минут
</p> </p>
</> </>
@@ -304,7 +304,7 @@ export function TelegramLink() {
<button <button
onClick={handleGenerateLink} onClick={handleGenerateLink}
disabled={loading} disabled={loading}
className="w-full py-3 px-4 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50 flex items-center justify-center gap-2" className="w-full py-3 px-4 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 border border-blue-500/50 hover:border-blue-500 rounded-lg font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
{loading ? ( {loading ? (
<Loader2 className="w-5 h-5 animate-spin" /> <Loader2 className="w-5 h-5 animate-spin" />

View File

@@ -1,42 +1,88 @@
import { Outlet, Link, useNavigate } from 'react-router-dom' import { useState, useEffect } from 'react'
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { Gamepad2, LogOut, Trophy, User } from 'lucide-react' import { Gamepad2, LogOut, Trophy, User, Menu, X } from 'lucide-react'
import { TelegramLink } from '@/components/TelegramLink' import { TelegramLink } from '@/components/TelegramLink'
import { clsx } from 'clsx'
export function Layout() { export function Layout() {
const { user, isAuthenticated, logout } = useAuthStore() const { user, isAuthenticated, logout } = useAuthStore()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const [isScrolled, setIsScrolled] = useState(false)
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 10)
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
// Close mobile menu on route change
useEffect(() => {
setIsMobileMenuOpen(false)
}, [location])
const handleLogout = () => { const handleLogout = () => {
logout() logout()
navigate('/login') navigate('/login')
} }
const isActiveLink = (path: string) => location.pathname === path
return ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
{/* Header */} {/* Header */}
<header className="bg-gray-800 border-b border-gray-700"> <header
className={clsx(
'fixed top-0 left-0 right-0 z-50 transition-all duration-300',
isScrolled
? 'bg-dark-900/80 backdrop-blur-lg border-b border-dark-600/50 shadow-lg'
: 'bg-transparent'
)}
>
<div className="container mx-auto px-4 py-4 flex items-center justify-between"> <div className="container mx-auto px-4 py-4 flex items-center justify-between">
<Link to="/" className="flex items-center gap-2 text-xl font-bold text-white"> {/* Logo */}
<Gamepad2 className="w-8 h-8 text-primary-500" /> <Link
<span>Игровой Марафон</span> to="/"
className="flex items-center gap-3 group"
>
<div className="relative">
<Gamepad2 className="w-8 h-8 text-neon-500 transition-all duration-300 group-hover:text-neon-400 group-hover:drop-shadow-[0_0_8px_rgba(34,211,238,0.6)]" />
</div>
<span className="text-xl font-bold text-white font-display tracking-wider glitch-hover">
МАРАФОН
</span>
</Link> </Link>
<nav className="flex items-center gap-4"> {/* Desktop Navigation */}
<nav className="hidden md:flex items-center gap-6">
{isAuthenticated ? ( {isAuthenticated ? (
<> <>
<Link <Link
to="/marathons" to="/marathons"
className="flex items-center gap-2 text-gray-300 hover:text-white transition-colors" className={clsx(
'flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200',
isActiveLink('/marathons')
? 'text-neon-400 bg-neon-500/10'
: 'text-gray-300 hover:text-white hover:bg-dark-700'
)}
> >
<Trophy className="w-5 h-5" /> <Trophy className="w-5 h-5" />
<span>Марафоны</span> <span>Марафоны</span>
</Link> </Link>
<div className="flex items-center gap-3 ml-4 pl-4 border-l border-gray-700"> <div className="flex items-center gap-3 ml-2 pl-4 border-l border-dark-600">
<Link <Link
to="/profile" to="/profile"
className="flex items-center gap-2 text-gray-300 hover:text-white transition-colors" className={clsx(
'flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200',
isActiveLink('/profile')
? 'text-neon-400 bg-neon-500/10'
: 'text-gray-300 hover:text-white hover:bg-dark-700'
)}
> >
<User className="w-5 h-5" /> <User className="w-5 h-5" />
<span>{user?.nickname}</span> <span>{user?.nickname}</span>
@@ -46,7 +92,7 @@ export function Layout() {
<button <button
onClick={handleLogout} onClick={handleLogout}
className="p-2 text-gray-400 hover:text-white transition-colors" className="p-2 text-gray-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-all duration-200"
title="Выйти" title="Выйти"
> >
<LogOut className="w-5 h-5" /> <LogOut className="w-5 h-5" />
@@ -55,27 +101,114 @@ export function Layout() {
</> </>
) : ( ) : (
<> <>
<Link to="/login" className="text-gray-300 hover:text-white transition-colors"> <Link
to="/login"
className="text-gray-300 hover:text-white transition-colors px-4 py-2"
>
Войти Войти
</Link> </Link>
<Link to="/register" className="btn btn-primary"> <Link
to="/register"
className="px-4 py-2 bg-neon-500 hover:bg-neon-400 text-dark-900 font-semibold rounded-lg transition-all duration-200 shadow-[0_0_10px_rgba(34,211,238,0.25)] hover:shadow-[0_0_16px_rgba(34,211,238,0.4)]"
>
Регистрация Регистрация
</Link> </Link>
</> </>
)} )}
</nav> </nav>
{/* Mobile Menu Button */}
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className="md:hidden p-2 text-gray-300 hover:text-white rounded-lg hover:bg-dark-700 transition-colors"
>
{isMobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
</div> </div>
{/* Mobile Menu */}
{isMobileMenuOpen && (
<div className="md:hidden bg-dark-800/95 backdrop-blur-lg border-t border-dark-600 animate-slide-in-down">
<div className="container mx-auto px-4 py-4 space-y-2">
{isAuthenticated ? (
<>
<Link
to="/marathons"
className={clsx(
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
isActiveLink('/marathons')
? 'text-neon-400 bg-neon-500/10'
: 'text-gray-300 hover:text-white hover:bg-dark-700'
)}
>
<Trophy className="w-5 h-5" />
<span>Марафоны</span>
</Link>
<Link
to="/profile"
className={clsx(
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
isActiveLink('/profile')
? 'text-neon-400 bg-neon-500/10'
: 'text-gray-300 hover:text-white hover:bg-dark-700'
)}
>
<User className="w-5 h-5" />
<span>{user?.nickname}</span>
</Link>
<div className="pt-2 border-t border-dark-600">
<button
onClick={handleLogout}
className="flex items-center gap-3 w-full px-4 py-3 text-red-400 hover:bg-red-500/10 rounded-lg transition-all"
>
<LogOut className="w-5 h-5" />
<span>Выйти</span>
</button>
</div>
</>
) : (
<>
<Link
to="/login"
className="block px-4 py-3 text-gray-300 hover:text-white hover:bg-dark-700 rounded-lg transition-all"
>
Войти
</Link>
<Link
to="/register"
className="block px-4 py-3 text-center bg-neon-500 hover:bg-neon-400 text-dark-900 font-semibold rounded-lg transition-all"
>
Регистрация
</Link>
</>
)}
</div>
</div>
)}
</header> </header>
{/* Spacer for fixed header */}
<div className="h-[72px]" />
{/* Main content */} {/* Main content */}
<main className="flex-1 container mx-auto px-4 py-8"> <main className="flex-1 container mx-auto px-4 py-8">
<Outlet /> <Outlet />
</main> </main>
{/* Footer */} {/* Footer */}
<footer className="bg-gray-800 border-t border-gray-700 py-4"> <footer className="bg-dark-800/50 border-t border-dark-600/50 py-6">
<div className="container mx-auto px-4 text-center text-gray-500 text-sm"> <div className="container mx-auto px-4">
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2 text-gray-500">
<Gamepad2 className="w-5 h-5 text-neon-500/50" />
<span className="text-sm">
Игровой Марафон &copy; {new Date().getFullYear()} Игровой Марафон &copy; {new Date().getFullYear()}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span className="text-neon-500/50">v1.0</span>
</div>
</div>
</div> </div>
</footer> </footer>
</div> </div>

View File

@@ -15,13 +15,13 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
ref={ref} ref={ref}
disabled={disabled || isLoading} disabled={disabled || isLoading}
className={clsx( className={clsx(
'inline-flex items-center justify-center font-medium rounded-lg transition-colors', 'inline-flex items-center justify-center font-medium rounded-lg transition-all duration-200',
'disabled:opacity-50 disabled:cursor-not-allowed', 'disabled:opacity-50 disabled:cursor-not-allowed',
{ {
'bg-primary-600 hover:bg-primary-700 text-white': variant === 'primary', 'bg-neon-500 hover:bg-neon-400 text-dark-900 font-semibold shadow-[0_0_8px_rgba(34,211,238,0.25)] hover:shadow-[0_0_14px_rgba(34,211,238,0.4)]': variant === 'primary',
'bg-gray-700 hover:bg-gray-600 text-white': variant === 'secondary', 'bg-dark-600 hover:bg-dark-500 text-white border border-dark-500': variant === 'secondary',
'bg-red-600 hover:bg-red-700 text-white': variant === 'danger', 'bg-red-600 hover:bg-red-700 text-white': variant === 'danger',
'bg-transparent hover:bg-gray-800 text-gray-300': variant === 'ghost', 'bg-transparent hover:bg-dark-700 text-gray-300 hover:text-white': variant === 'ghost',
'px-3 py-1.5 text-sm': size === 'sm', 'px-3 py-1.5 text-sm': size === 'sm',
'px-4 py-2 text-base': size === 'md', 'px-4 py-2 text-base': size === 'md',
'px-6 py-3 text-lg': size === 'lg', 'px-6 py-3 text-lg': size === 'lg',

View File

@@ -4,11 +4,18 @@ import { clsx } from 'clsx'
interface CardProps { interface CardProps {
children: ReactNode children: ReactNode
className?: string className?: string
hover?: boolean
} }
export function Card({ children, className }: CardProps) { export function Card({ children, className, hover = false }: CardProps) {
return ( return (
<div className={clsx('bg-gray-800 rounded-xl p-6 shadow-lg', className)}> <div
className={clsx(
'bg-dark-800 rounded-xl p-6 border border-dark-600',
hover && 'transition-all duration-300 hover:-translate-y-1 hover:border-neon-500/30 hover:shadow-[0_10px_40px_rgba(34,211,238,0.08)]',
className
)}
>
{children} {children}
</div> </div>
) )

View File

@@ -2,7 +2,7 @@ import { useEffect } from 'react'
import { AlertTriangle, Info, Trash2, X } from 'lucide-react' import { AlertTriangle, Info, Trash2, X } from 'lucide-react'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { useConfirmStore, type ConfirmVariant } from '@/store/confirm' import { useConfirmStore, type ConfirmVariant } from '@/store/confirm'
import { Button } from './Button' import { NeonButton } from './NeonButton'
const icons: Record<ConfirmVariant, React.ReactNode> = { const icons: Record<ConfirmVariant, React.ReactNode> = {
danger: <Trash2 className="w-6 h-6" />, danger: <Trash2 className="w-6 h-6" />,
@@ -11,15 +11,15 @@ const icons: Record<ConfirmVariant, React.ReactNode> = {
} }
const iconStyles: Record<ConfirmVariant, string> = { const iconStyles: Record<ConfirmVariant, string> = {
danger: 'bg-red-500/20 text-red-500', danger: 'bg-red-500/10 text-red-400 border border-red-500/30',
warning: 'bg-yellow-500/20 text-yellow-500', warning: 'bg-yellow-500/10 text-yellow-400 border border-yellow-500/30',
info: 'bg-blue-500/20 text-blue-500', info: 'bg-neon-500/10 text-neon-400 border border-neon-500/30',
} }
const buttonVariants: Record<ConfirmVariant, 'danger' | 'primary' | 'secondary'> = { const confirmButtonStyles: Record<ConfirmVariant, string> = {
danger: 'danger', danger: 'border-red-500/50 text-red-400 hover:bg-red-500/10 hover:border-red-500',
warning: 'primary', warning: 'border-yellow-500/50 text-yellow-400 hover:bg-yellow-500/10 hover:border-yellow-500',
info: 'primary', info: '', // Will use NeonButton default
} }
export function ConfirmModal() { export function ConfirmModal() {
@@ -62,7 +62,7 @@ export function ConfirmModal() {
/> />
{/* Modal */} {/* Modal */}
<div className="relative bg-gray-800 rounded-xl shadow-2xl max-w-md w-full animate-in zoom-in-95 fade-in duration-200 border border-gray-700"> <div className="relative glass rounded-xl shadow-2xl max-w-md w-full animate-in zoom-in-95 fade-in duration-200 border border-dark-600">
{/* Close button */} {/* Close button */}
<button <button
onClick={handleCancel} onClick={handleCancel}
@@ -89,20 +89,31 @@ export function ConfirmModal() {
{/* Actions */} {/* Actions */}
<div className="flex gap-3"> <div className="flex gap-3">
<Button <NeonButton
variant="secondary" variant="secondary"
className="flex-1" className="flex-1"
onClick={handleCancel} onClick={handleCancel}
> >
{options.cancelText || 'Отмена'} {options.cancelText || 'Отмена'}
</Button> </NeonButton>
<Button {variant === 'info' ? (
variant={buttonVariants[variant]} <NeonButton
className="flex-1" className="flex-1"
onClick={handleConfirm} onClick={handleConfirm}
> >
{options.confirmText || 'Подтвердить'} {options.confirmText || 'Подтвердить'}
</Button> </NeonButton>
) : (
<button
className={clsx(
'flex-1 px-4 py-2.5 rounded-lg font-semibold transition-all duration-200 border-2 bg-transparent',
confirmButtonStyles[variant]
)}
onClick={handleConfirm}
>
{options.confirmText || 'Подтвердить'}
</button>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,215 @@
import { type ReactNode, type HTMLAttributes } from 'react'
import { clsx } from 'clsx'
interface GlassCardProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode
variant?: 'default' | 'dark' | 'neon' | 'gradient'
hover?: boolean
glow?: boolean
className?: string
}
export function GlassCard({
children,
variant = 'default',
hover = false,
glow = false,
className,
...props
}: GlassCardProps) {
const variantClasses = {
default: 'glass',
dark: 'glass-dark',
neon: 'glass-neon',
gradient: 'gradient-border',
}
return (
<div
className={clsx(
'rounded-xl p-6',
variantClasses[variant],
hover && 'card-hover cursor-pointer',
glow && 'neon-glow-pulse',
className
)}
{...props}
>
{children}
</div>
)
}
// Stats card variant
interface StatsCardProps {
label: string
value: string | number
icon?: ReactNode
trend?: {
value: number
isPositive: boolean
}
color?: 'neon' | 'purple' | 'pink' | 'default'
className?: string
}
export function StatsCard({
label,
value,
icon,
trend,
color = 'default',
className,
}: StatsCardProps) {
const colorClasses = {
neon: 'border-neon-500/30 hover:border-neon-500/50',
purple: 'border-accent-500/30 hover:border-accent-500/50',
pink: 'border-pink-500/30 hover:border-pink-500/50',
default: 'border-dark-600 hover:border-dark-500',
}
const iconColorClasses = {
neon: 'text-neon-500 bg-neon-500/10',
purple: 'text-accent-500 bg-accent-500/10',
pink: 'text-pink-500 bg-pink-500/10',
default: 'text-gray-400 bg-dark-700',
}
const valueColorClasses = {
neon: 'text-neon-400',
purple: 'text-accent-400',
pink: 'text-pink-400',
default: 'text-white',
}
return (
<div
className={clsx(
'glass rounded-xl p-4 border transition-all duration-300',
colorClasses[color],
'hover:-translate-y-0.5',
className
)}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="text-sm text-gray-400 mb-1">{label}</p>
<p className={clsx(
'font-bold',
typeof value === 'number' ? 'text-2xl' : 'text-lg',
valueColorClasses[color]
)}>
{value}
</p>
{trend && (
<p
className={clsx(
'text-xs mt-1',
trend.isPositive ? 'text-green-400' : 'text-red-400'
)}
>
{trend.isPositive ? '+' : ''}{trend.value}%
</p>
)}
</div>
{icon && (
<div
className={clsx(
'w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0',
iconColorClasses[color]
)}
>
{icon}
</div>
)}
</div>
</div>
)
}
// Feature card variant
interface FeatureCardProps {
title: string
description: string
icon: ReactNode
color?: 'neon' | 'purple' | 'pink'
className?: string
}
export function FeatureCard({
title,
description,
icon,
color = 'neon',
className,
}: FeatureCardProps) {
const colorClasses = {
neon: {
icon: 'text-neon-500 bg-neon-500/10 group-hover:bg-neon-500/20',
border: 'group-hover:border-neon-500/50',
glow: 'group-hover:shadow-[0_0_20px_rgba(34,211,238,0.12)]',
},
purple: {
icon: 'text-accent-500 bg-accent-500/10 group-hover:bg-accent-500/20',
border: 'group-hover:border-accent-500/50',
glow: 'group-hover:shadow-[0_0_20px_rgba(139,92,246,0.12)]',
},
pink: {
icon: 'text-pink-500 bg-pink-500/10 group-hover:bg-pink-500/20',
border: 'group-hover:border-pink-500/50',
glow: 'group-hover:shadow-[0_0_20px_rgba(244,114,182,0.12)]',
},
}
const colors = colorClasses[color]
return (
<div
className={clsx(
'group glass rounded-xl p-6 border border-dark-600 transition-all duration-300',
'hover:-translate-y-1',
colors.border,
colors.glow,
className
)}
>
<div
className={clsx(
'w-14 h-14 rounded-xl flex items-center justify-center mb-4 transition-colors',
colors.icon
)}
>
{icon}
</div>
<h3 className="text-lg font-semibold text-white mb-2">{title}</h3>
<p className="text-gray-400 text-sm">{description}</p>
</div>
)
}
// Interactive card with animated border
interface AnimatedBorderCardProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode
className?: string
}
export function AnimatedBorderCard({
children,
className,
...props
}: AnimatedBorderCardProps) {
return (
<div className={clsx('relative group', className)} {...props}>
{/* Animated gradient border */}
<div
className="absolute -inset-0.5 bg-gradient-to-r from-neon-500 via-accent-500 to-pink-500 rounded-xl opacity-30 group-hover:opacity-60 blur transition-opacity duration-300"
style={{
backgroundSize: '200% 200%',
animation: 'gradient-flow 3s linear infinite',
}}
/>
{/* Card content */}
<div className="relative glass-dark rounded-xl p-6">{children}</div>
</div>
)
}

View File

@@ -0,0 +1,116 @@
import { type ReactNode, type HTMLAttributes } from 'react'
import { clsx } from 'clsx'
interface GlitchTextProps extends HTMLAttributes<HTMLSpanElement> {
children: ReactNode
as?: 'span' | 'h1' | 'h2' | 'h3' | 'h4' | 'p'
intensity?: 'low' | 'medium' | 'high'
color?: 'neon' | 'purple' | 'pink' | 'white'
hover?: boolean
className?: string
}
export function GlitchText({
children,
as: Component = 'span',
intensity = 'medium',
color = 'neon',
hover = false,
className,
...props
}: GlitchTextProps) {
const text = typeof children === 'string' ? children : ''
const colorClasses = {
neon: 'text-neon-500',
purple: 'text-accent-500',
pink: 'text-pink-500',
white: 'text-white',
}
const glowClasses = {
neon: 'neon-text',
purple: 'neon-text-purple',
pink: '[text-shadow:0_0_8px_rgba(244,114,182,0.5),0_0_16px_rgba(244,114,182,0.25)]',
white: '[text-shadow:0_0_8px_rgba(255,255,255,0.4),0_0_16px_rgba(255,255,255,0.2)]',
}
const intensityClasses = {
low: 'animate-[glitch-skew_2s_infinite_linear_alternate-reverse]',
medium: '',
high: 'animate-glitch',
}
if (hover) {
return (
<Component
className={clsx(
colorClasses[color],
'relative inline-block cursor-pointer transition-all duration-300',
'hover:' + glowClasses[color],
'glitch-hover',
className
)}
{...props}
>
{children}
</Component>
)
}
return (
<Component
className={clsx(
'glitch relative inline-block',
colorClasses[color],
glowClasses[color],
intensityClasses[intensity],
className
)}
data-text={text}
{...props}
>
{children}
</Component>
)
}
// Simpler glitch effect for headings
interface GlitchHeadingProps {
children: ReactNode
level?: 1 | 2 | 3 | 4
className?: string
gradient?: boolean
}
export function GlitchHeading({
children,
level = 1,
className,
gradient = false,
}: GlitchHeadingProps) {
const text = typeof children === 'string' ? children : ''
const sizeClasses = {
1: 'text-4xl md:text-5xl lg:text-6xl font-bold',
2: 'text-3xl md:text-4xl font-bold',
3: 'text-2xl md:text-3xl font-semibold',
4: 'text-xl md:text-2xl font-semibold',
}
const Component = `h${level}` as keyof JSX.IntrinsicElements
return (
<Component
className={clsx(
'glitch relative inline-block',
sizeClasses[level],
gradient ? 'gradient-neon-text' : 'text-white neon-text',
className
)}
data-text={text}
>
{children}
</Component>
)
}

View File

@@ -11,7 +11,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
return ( return (
<div className="w-full"> <div className="w-full">
{label && ( {label && (
<label htmlFor={id} className="block text-sm font-medium text-gray-300 mb-1"> <label htmlFor={id} className="block text-sm font-medium text-gray-300 mb-1.5">
{label} {label}
</label> </label>
)} )}
@@ -19,15 +19,16 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
ref={ref} ref={ref}
id={id} id={id}
className={clsx( className={clsx(
'w-full px-4 py-2 bg-gray-800 border rounded-lg text-white placeholder-gray-500', 'w-full px-4 py-3 bg-dark-800 border rounded-lg text-white placeholder-gray-500',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent', 'focus:outline-none focus:border-neon-500',
'transition-colors', 'focus:shadow-[0_0_0_3px_rgba(34,211,238,0.1),0_0_8px_rgba(34,211,238,0.15)]',
error ? 'border-red-500' : 'border-gray-700', 'transition-all duration-200',
error ? 'border-red-500' : 'border-dark-600',
className className
)} )}
{...props} {...props}
/> />
{error && <p className="mt-1 text-sm text-red-500">{error}</p>} {error && <p className="mt-1.5 text-sm text-red-400">{error}</p>}
</div> </div>
) )
} }

View File

@@ -0,0 +1,174 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'
import { clsx } from 'clsx'
import { Loader2 } from 'lucide-react'
interface NeonButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
size?: 'sm' | 'md' | 'lg'
color?: 'neon' | 'purple' | 'pink'
isLoading?: boolean
icon?: ReactNode
iconPosition?: 'left' | 'right'
glow?: boolean
pulse?: boolean
}
export const NeonButton = forwardRef<HTMLButtonElement, NeonButtonProps>(
(
{
className,
variant = 'primary',
size = 'md',
color = 'neon',
isLoading,
icon,
iconPosition = 'left',
glow = true,
pulse = false,
children,
disabled,
...props
},
ref
) => {
const colorMap = {
neon: {
primary: 'bg-neon-500 hover:bg-neon-400 text-dark-900',
secondary: 'bg-dark-600 hover:bg-dark-500 text-neon-400 border border-neon-500/30',
outline: 'bg-transparent border-2 border-neon-500 text-neon-500 hover:bg-neon-500 hover:text-dark-900',
ghost: 'bg-transparent hover:bg-neon-500/10 text-neon-400',
danger: 'bg-red-600 hover:bg-red-700 text-white',
glow: '0 0 12px rgba(34, 211, 238, 0.4)',
glowHover: '0 0 18px rgba(34, 211, 238, 0.55)',
glowDanger: '0 0 12px rgba(239, 68, 68, 0.4)',
glowDangerHover: '0 0 18px rgba(239, 68, 68, 0.55)',
},
purple: {
primary: 'bg-accent-500 hover:bg-accent-400 text-white',
secondary: 'bg-dark-600 hover:bg-dark-500 text-accent-400 border border-accent-500/30',
outline: 'bg-transparent border-2 border-accent-500 text-accent-500 hover:bg-accent-500 hover:text-white',
ghost: 'bg-transparent hover:bg-accent-500/10 text-accent-400',
danger: 'bg-red-600 hover:bg-red-700 text-white',
glow: '0 0 12px rgba(139, 92, 246, 0.4)',
glowHover: '0 0 18px rgba(139, 92, 246, 0.55)',
glowDanger: '0 0 12px rgba(239, 68, 68, 0.4)',
glowDangerHover: '0 0 18px rgba(239, 68, 68, 0.55)',
},
pink: {
primary: 'bg-pink-500 hover:bg-pink-400 text-white',
secondary: 'bg-dark-600 hover:bg-dark-500 text-pink-400 border border-pink-500/30',
outline: 'bg-transparent border-2 border-pink-500 text-pink-500 hover:bg-pink-500 hover:text-white',
ghost: 'bg-transparent hover:bg-pink-500/10 text-pink-400',
danger: 'bg-red-600 hover:bg-red-700 text-white',
glow: '0 0 12px rgba(244, 114, 182, 0.4)',
glowHover: '0 0 18px rgba(244, 114, 182, 0.55)',
glowDanger: '0 0 12px rgba(239, 68, 68, 0.4)',
glowDangerHover: '0 0 18px rgba(239, 68, 68, 0.55)',
},
}
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm gap-1.5',
md: 'px-4 py-2.5 text-base gap-2',
lg: 'px-6 py-3 text-lg gap-2.5',
}
const iconSizes = {
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6',
}
const colors = colorMap[color]
return (
<button
ref={ref}
disabled={disabled || isLoading}
className={clsx(
'inline-flex items-center justify-center font-semibold rounded-lg',
'transition-all duration-300 ease-out',
'disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-dark-900',
color === 'neon' && 'focus:ring-neon-500',
color === 'purple' && 'focus:ring-accent-500',
color === 'pink' && 'focus:ring-pink-500',
colors[variant],
sizeClasses[size],
pulse && 'neon-glow-pulse',
className
)}
style={{
boxShadow: glow && !disabled && variant !== 'ghost'
? (variant === 'danger' ? colors.glowDanger : colors.glow)
: undefined,
}}
onMouseEnter={(e) => {
if (glow && !disabled && variant !== 'ghost') {
e.currentTarget.style.boxShadow = variant === 'danger' ? colors.glowDangerHover : colors.glowHover
}
props.onMouseEnter?.(e)
}}
onMouseLeave={(e) => {
if (glow && !disabled && variant !== 'ghost') {
e.currentTarget.style.boxShadow = variant === 'danger' ? colors.glowDanger : colors.glow
}
props.onMouseLeave?.(e)
}}
{...props}
>
{isLoading && <Loader2 className={clsx(iconSizes[size], 'animate-spin')} />}
{!isLoading && icon && iconPosition === 'left' && (
<span className={iconSizes[size]}>{icon}</span>
)}
{children}
{!isLoading && icon && iconPosition === 'right' && (
<span className={iconSizes[size]}>{icon}</span>
)}
</button>
)
}
)
NeonButton.displayName = 'NeonButton'
// Gradient button variant
interface GradientButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
size?: 'sm' | 'md' | 'lg'
isLoading?: boolean
icon?: ReactNode
}
export const GradientButton = forwardRef<HTMLButtonElement, GradientButtonProps>(
({ className, size = 'md', isLoading, icon, children, disabled, ...props }, ref) => {
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm gap-1.5',
md: 'px-4 py-2.5 text-base gap-2',
lg: 'px-6 py-3 text-lg gap-2.5',
}
return (
<button
ref={ref}
disabled={disabled || isLoading}
className={clsx(
'relative inline-flex items-center justify-center font-semibold rounded-lg',
'bg-gradient-to-r from-neon-500 via-accent-500 to-pink-500',
'text-white transition-all duration-300',
'hover:shadow-[0_0_20px_rgba(139,92,246,0.35)]',
'disabled:opacity-50 disabled:cursor-not-allowed',
'focus:outline-none focus:ring-2 focus:ring-accent-500 focus:ring-offset-2 focus:ring-offset-dark-900',
sizeClasses[size],
className
)}
{...props}
>
{isLoading && <Loader2 className="w-5 h-5 animate-spin" />}
{!isLoading && icon && <span className="w-5 h-5">{icon}</span>}
{children}
</button>
)
}
)
GradientButton.displayName = 'GradientButton'

View File

@@ -3,6 +3,8 @@ import { usersApi } from '@/api'
// Глобальный кэш для blob URL аватарок // Глобальный кэш для blob URL аватарок
const avatarCache = new Map<number, string>() const avatarCache = new Map<number, string>()
// Пользователи, для которых нужно сбросить HTTP-кэш при следующем запросе
const needsCacheBust = new Set<number>()
interface UserAvatarProps { interface UserAvatarProps {
userId: number userId: number
@@ -10,6 +12,7 @@ interface UserAvatarProps {
nickname: string nickname: string
size?: 'sm' | 'md' | 'lg' size?: 'sm' | 'md' | 'lg'
className?: string className?: string
version?: number // Для принудительного обновления при смене аватара
} }
const sizeClasses = { const sizeClasses = {
@@ -18,7 +21,7 @@ const sizeClasses = {
lg: 'w-24 h-24 text-xl', lg: 'w-24 h-24 text-xl',
} }
export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className = '' }: UserAvatarProps) { export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className = '', version = 0 }: UserAvatarProps) {
const [blobUrl, setBlobUrl] = useState<string | null>(null) const [blobUrl, setBlobUrl] = useState<string | null>(null)
const [failed, setFailed] = useState(false) const [failed, setFailed] = useState(false)
@@ -28,16 +31,31 @@ export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className
return return
} }
// Проверяем кэш // Если version > 0, значит аватар обновился - сбрасываем кэш
const shouldBustCache = version > 0 || needsCacheBust.has(userId)
// Проверяем кэш только если не нужен bust
if (!shouldBustCache) {
const cached = avatarCache.get(userId) const cached = avatarCache.get(userId)
if (cached) { if (cached) {
setBlobUrl(cached) setBlobUrl(cached)
return return
} }
}
// Очищаем старый кэш если bust
if (shouldBustCache) {
const cached = avatarCache.get(userId)
if (cached) {
URL.revokeObjectURL(cached)
avatarCache.delete(userId)
}
needsCacheBust.delete(userId)
}
// Загружаем аватарку // Загружаем аватарку
let cancelled = false let cancelled = false
usersApi.getAvatarUrl(userId) usersApi.getAvatarUrl(userId, shouldBustCache)
.then(url => { .then(url => {
if (!cancelled) { if (!cancelled) {
avatarCache.set(userId, url) avatarCache.set(userId, url)
@@ -53,7 +71,7 @@ export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className
return () => { return () => {
cancelled = true cancelled = true
} }
}, [userId, hasAvatar]) }, [userId, hasAvatar, version])
const sizeClass = sizeClasses[size] const sizeClass = sizeClasses[size]
@@ -84,4 +102,6 @@ export function clearAvatarCache(userId: number) {
URL.revokeObjectURL(cached) URL.revokeObjectURL(cached)
avatarCache.delete(userId) avatarCache.delete(userId)
} }
// Помечаем, что при следующем запросе нужно сбросить HTTP-кэш браузера
needsCacheBust.add(userId)
} }

View File

@@ -4,3 +4,8 @@ export { Card, CardHeader, CardTitle, CardContent } from './Card'
export { ToastContainer } from './Toast' export { ToastContainer } from './Toast'
export { ConfirmModal } from './ConfirmModal' export { ConfirmModal } from './ConfirmModal'
export { UserAvatar, clearAvatarCache } from './UserAvatar' export { UserAvatar, clearAvatarCache } from './UserAvatar'
// New design system components
export { GlitchText, GlitchHeading } from './GlitchText'
export { NeonButton, GradientButton } from './NeonButton'
export { GlassCard, StatsCard, FeatureCard, AnimatedBorderCard } from './GlassCard'

View File

@@ -2,11 +2,129 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
body { /* ========================================
@apply bg-gray-900 text-gray-100 min-h-screen; CSS Variables
======================================== */
:root {
/* Base colors - slightly warmer dark tones */
--color-dark-950: #08090d;
--color-dark-900: #0d0e14;
--color-dark-800: #14161e;
--color-dark-700: #1c1e28;
--color-dark-600: #252732;
--color-dark-500: #2e313d;
/* Soft cyan (primary) - gentler on eyes */
--color-neon-500: #22d3ee;
--color-neon-400: #67e8f9;
--color-neon-600: #06b6d4;
/* Soft violet accent */
--color-accent-500: #8b5cf6;
--color-accent-600: #7c3aed;
--color-accent-700: #6d28d9;
/* Soft pink highlight - used sparingly */
--color-pink-500: #f472b6;
/* Glow colors - reduced intensity */
--glow-neon: 0 0 8px rgba(34, 211, 238, 0.4), 0 0 16px rgba(34, 211, 238, 0.2);
--glow-neon-lg: 0 0 12px rgba(34, 211, 238, 0.5), 0 0 24px rgba(34, 211, 238, 0.3);
--glow-purple: 0 0 8px rgba(139, 92, 246, 0.4), 0 0 16px rgba(139, 92, 246, 0.2);
--glow-pink: 0 0 8px rgba(244, 114, 182, 0.4), 0 0 16px rgba(244, 114, 182, 0.2);
/* Text glow - subtle */
--text-glow-neon: 0 0 8px rgba(34, 211, 238, 0.5), 0 0 16px rgba(34, 211, 238, 0.25);
--text-glow-purple: 0 0 8px rgba(139, 92, 246, 0.5), 0 0 16px rgba(139, 92, 246, 0.25);
} }
/* Custom scrollbar styles */ /* ========================================
Base Styles
======================================== */
html {
scroll-behavior: smooth;
}
body {
@apply bg-dark-900 text-gray-100 min-h-screen antialiased;
font-family: 'Inter', system-ui, sans-serif;
background-image:
linear-gradient(rgba(34, 211, 238, 0.015) 1px, transparent 1px),
linear-gradient(90deg, rgba(34, 211, 238, 0.015) 1px, transparent 1px);
background-size: 50px 50px;
background-attachment: fixed;
}
/* Noise overlay - can be added to any element */
.noise-overlay::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.03;
pointer-events: none;
z-index: 9999;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
}
/* Autofill styles - override browser defaults */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 30px #14161e inset !important;
-webkit-text-fill-color: #fff !important;
caret-color: #fff;
transition: background-color 5000s ease-in-out 0s;
}
/* ========================================
Selection Styles
======================================== */
::selection {
background: rgba(34, 211, 238, 0.25);
color: #fff;
}
::-moz-selection {
background: rgba(34, 211, 238, 0.25);
color: #fff;
}
/* ========================================
Custom Scrollbar (Neon Style)
======================================== */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-dark-800);
}
::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, var(--color-neon-500), var(--color-accent-500));
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, var(--color-neon-400), var(--color-accent-600));
}
::-webkit-scrollbar-corner {
background: var(--color-dark-800);
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-neon-500) var(--color-dark-800);
}
/* Custom scrollbar class for specific elements */
.custom-scrollbar::-webkit-scrollbar { .custom-scrollbar::-webkit-scrollbar {
width: 6px; width: 6px;
} }
@@ -16,46 +134,450 @@ body {
} }
.custom-scrollbar::-webkit-scrollbar-thumb { .custom-scrollbar::-webkit-scrollbar-thumb {
background: #4b5563; background: var(--color-dark-500);
border-radius: 3px; border-radius: 3px;
} }
.custom-scrollbar::-webkit-scrollbar-thumb:hover { .custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #6b7280; background: var(--color-neon-500);
} }
/* Firefox */ /* ========================================
.custom-scrollbar { Glitch Effect
scrollbar-width: thin; ======================================== */
scrollbar-color: #4b5563 transparent; .glitch {
position: relative;
animation: glitch-skew 1s infinite linear alternate-reverse;
} }
.glitch::before,
.glitch::after {
content: attr(data-text);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.glitch::before {
left: 2px;
text-shadow: -2px 0 rgba(139, 92, 246, 0.7);
clip: rect(44px, 450px, 56px, 0);
animation: glitch-anim 5s infinite linear alternate-reverse;
}
.glitch::after {
left: -2px;
text-shadow: -2px 0 rgba(34, 211, 238, 0.7), 2px 2px rgba(139, 92, 246, 0.7);
clip: rect(44px, 450px, 56px, 0);
animation: glitch-anim2 5s infinite linear alternate-reverse;
}
@keyframes glitch-anim {
0% { clip: rect(31px, 9999px, 94px, 0); transform: skew(0.85deg); }
5% { clip: rect(70px, 9999px, 71px, 0); transform: skew(0.07deg); }
10% { clip: rect(29px, 9999px, 24px, 0); transform: skew(0.22deg); }
15% { clip: rect(69px, 9999px, 63px, 0); transform: skew(0.52deg); }
20% { clip: rect(13px, 9999px, 71px, 0); transform: skew(0.72deg); }
25% { clip: rect(39px, 9999px, 89px, 0); transform: skew(0.24deg); }
30% { clip: rect(87px, 9999px, 98px, 0); transform: skew(0.63deg); }
35% { clip: rect(63px, 9999px, 16px, 0); transform: skew(0.15deg); }
40% { clip: rect(92px, 9999px, 4px, 0); transform: skew(0.83deg); }
45% { clip: rect(67px, 9999px, 72px, 0); transform: skew(0.19deg); }
50% { clip: rect(43px, 9999px, 21px, 0); transform: skew(0.74deg); }
55% { clip: rect(75px, 9999px, 54px, 0); transform: skew(0.28deg); }
60% { clip: rect(17px, 9999px, 86px, 0); transform: skew(0.91deg); }
65% { clip: rect(51px, 9999px, 32px, 0); transform: skew(0.46deg); }
70% { clip: rect(29px, 9999px, 69px, 0); transform: skew(0.38deg); }
75% { clip: rect(84px, 9999px, 11px, 0); transform: skew(0.67deg); }
80% { clip: rect(38px, 9999px, 82px, 0); transform: skew(0.12deg); }
85% { clip: rect(61px, 9999px, 47px, 0); transform: skew(0.54deg); }
90% { clip: rect(22px, 9999px, 91px, 0); transform: skew(0.33deg); }
95% { clip: rect(79px, 9999px, 28px, 0); transform: skew(0.79deg); }
100% { clip: rect(56px, 9999px, 65px, 0); transform: skew(0.41deg); }
}
@keyframes glitch-anim2 {
0% { clip: rect(65px, 9999px, 100px, 0); transform: skew(0.63deg); }
5% { clip: rect(52px, 9999px, 74px, 0); transform: skew(0.29deg); }
10% { clip: rect(79px, 9999px, 85px, 0); transform: skew(0.84deg); }
15% { clip: rect(43px, 9999px, 27px, 0); transform: skew(0.17deg); }
20% { clip: rect(16px, 9999px, 92px, 0); transform: skew(0.56deg); }
25% { clip: rect(88px, 9999px, 36px, 0); transform: skew(0.39deg); }
30% { clip: rect(32px, 9999px, 68px, 0); transform: skew(0.71deg); }
35% { clip: rect(71px, 9999px, 13px, 0); transform: skew(0.23deg); }
40% { clip: rect(24px, 9999px, 57px, 0); transform: skew(0.92deg); }
45% { clip: rect(83px, 9999px, 41px, 0); transform: skew(0.48deg); }
50% { clip: rect(19px, 9999px, 79px, 0); transform: skew(0.35deg); }
55% { clip: rect(67px, 9999px, 23px, 0); transform: skew(0.76deg); }
60% { clip: rect(45px, 9999px, 96px, 0); transform: skew(0.14deg); }
65% { clip: rect(91px, 9999px, 51px, 0); transform: skew(0.58deg); }
70% { clip: rect(28px, 9999px, 83px, 0); transform: skew(0.87deg); }
75% { clip: rect(76px, 9999px, 19px, 0); transform: skew(0.26deg); }
80% { clip: rect(53px, 9999px, 67px, 0); transform: skew(0.69deg); }
85% { clip: rect(14px, 9999px, 89px, 0); transform: skew(0.43deg); }
90% { clip: rect(62px, 9999px, 34px, 0); transform: skew(0.81deg); }
95% { clip: rect(37px, 9999px, 76px, 0); transform: skew(0.52deg); }
100% { clip: rect(86px, 9999px, 48px, 0); transform: skew(0.31deg); }
}
@keyframes glitch-skew {
0% { transform: skew(-2deg); }
10% { transform: skew(1deg); }
20% { transform: skew(-1deg); }
30% { transform: skew(0deg); }
40% { transform: skew(2deg); }
50% { transform: skew(-1deg); }
60% { transform: skew(1deg); }
70% { transform: skew(0deg); }
80% { transform: skew(-2deg); }
90% { transform: skew(1deg); }
100% { transform: skew(0deg); }
}
/* Simpler glitch for hover states */
.glitch-hover:hover {
animation: glitch-simple 0.3s ease;
}
@keyframes glitch-simple {
0%, 100% { transform: translate(0); }
20% { transform: translate(-2px, 2px); }
40% { transform: translate(-2px, -2px); }
60% { transform: translate(2px, 2px); }
80% { transform: translate(2px, -2px); }
}
/* ========================================
Neon Glow Effects
======================================== */
.neon-glow {
box-shadow: var(--glow-neon);
}
.neon-glow-lg {
box-shadow: var(--glow-neon-lg);
}
.neon-glow-purple {
box-shadow: var(--glow-purple);
}
.neon-glow-pink {
box-shadow: var(--glow-pink);
}
.neon-text {
text-shadow: var(--text-glow-neon);
}
.neon-text-purple {
text-shadow: var(--text-glow-purple);
}
/* Animated glow */
.neon-glow-pulse {
animation: neon-pulse 2s ease-in-out infinite;
}
@keyframes neon-pulse {
0%, 100% {
box-shadow: 0 0 6px rgba(34, 211, 238, 0.4), 0 0 12px rgba(34, 211, 238, 0.2);
}
50% {
box-shadow: 0 0 10px rgba(34, 211, 238, 0.5), 0 0 20px rgba(34, 211, 238, 0.3);
}
}
/* ========================================
Glass Effect (Glassmorphism)
======================================== */
.glass {
background: rgba(18, 18, 26, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.glass-dark {
background: rgba(10, 10, 15, 0.8);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.glass-neon {
background: rgba(20, 22, 30, 0.6);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(34, 211, 238, 0.15);
box-shadow: inset 0 0 20px rgba(34, 211, 238, 0.03);
}
/* ========================================
Gradient Utilities
======================================== */
.gradient-neon {
background: linear-gradient(135deg, #22d3ee, #8b5cf6);
}
.gradient-neon-text {
background: linear-gradient(135deg, #22d3ee, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.gradient-pink-purple {
background: linear-gradient(135deg, #f472b6, #8b5cf6);
}
.gradient-dark {
background: linear-gradient(180deg, var(--color-dark-900), var(--color-dark-950));
}
/* Animated gradient border */
.gradient-border {
position: relative;
background: var(--color-dark-800);
border-radius: 12px;
}
.gradient-border::before {
content: '';
position: absolute;
inset: -2px;
background: linear-gradient(90deg, #22d3ee, #8b5cf6, #f472b6, #22d3ee);
background-size: 300% 300%;
border-radius: 14px;
z-index: -1;
animation: gradient-flow 3s linear infinite;
}
@keyframes gradient-flow {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
/* ========================================
Shimmer Effect
======================================== */
.shimmer {
position: relative;
overflow: hidden;
}
.shimmer::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.1),
transparent
);
animation: shimmer 2s infinite;
}
@keyframes shimmer {
100% { left: 100%; }
}
/* ========================================
Component Layer
======================================== */
@layer components { @layer components {
/* Buttons */
.btn { .btn {
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed; @apply px-4 py-2 rounded-lg font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
} }
.btn-primary { .btn-primary {
@apply bg-primary-600 hover:bg-primary-700 text-white; @apply bg-neon-500 hover:bg-neon-400 text-dark-900 font-semibold;
box-shadow: 0 0 8px rgba(34, 211, 238, 0.25);
}
.btn-primary:hover {
box-shadow: 0 0 14px rgba(34, 211, 238, 0.4);
} }
.btn-secondary { .btn-secondary {
@apply bg-gray-700 hover:bg-gray-600 text-white; @apply bg-dark-600 hover:bg-dark-500 text-white border border-dark-500;
} }
.btn-danger { .btn-danger {
@apply bg-red-600 hover:bg-red-700 text-white; @apply bg-red-600 hover:bg-red-700 text-white;
} }
.btn-ghost {
@apply bg-transparent hover:bg-dark-700 text-gray-300 hover:text-white;
}
.btn-neon {
@apply relative bg-transparent border-2 border-neon-500 text-neon-500 font-semibold overflow-hidden;
transition: all 0.3s ease;
}
.btn-neon:hover {
@apply text-dark-900;
background: var(--color-neon-500);
box-shadow: 0 0 14px rgba(34, 211, 238, 0.4);
}
/* Inputs */
.input { .input {
@apply w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent; @apply w-full px-4 py-3 bg-dark-800 border border-dark-600 rounded-lg text-white placeholder-gray-500 transition-all duration-200;
} }
.input:focus {
@apply outline-none border-neon-500;
box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.1), 0 0 8px rgba(34, 211, 238, 0.15);
}
/* Cards */
.card { .card {
@apply bg-gray-800 rounded-xl p-6 shadow-lg; @apply bg-dark-800 rounded-xl p-6 border border-dark-600;
} }
.card-glass {
@apply rounded-xl p-6;
background: rgba(20, 22, 30, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.card-hover {
@apply transition-all duration-300;
}
.card-hover:hover {
@apply -translate-y-1;
box-shadow: 0 10px 40px rgba(34, 211, 238, 0.08);
border-color: rgba(34, 211, 238, 0.25);
}
/* Links */
.link { .link {
@apply text-primary-400 hover:text-primary-300 transition-colors; @apply text-neon-500 hover:text-neon-400 transition-colors;
}
/* Badges */
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-neon {
@apply bg-neon-500/20 text-neon-400 border border-neon-500/30;
}
.badge-purple {
@apply bg-accent-500/20 text-accent-400 border border-accent-500/30;
}
.badge-pink {
@apply bg-pink-500/20 text-pink-400 border border-pink-500/30;
}
/* Dividers */
.divider {
@apply border-t border-dark-600;
}
.divider-glow {
@apply border-t border-neon-500/30;
box-shadow: 0 0 8px rgba(34, 211, 238, 0.15);
}
}
/* ========================================
Utility Animations
======================================== */
.hover-lift {
@apply transition-transform duration-300;
}
.hover-lift:hover {
@apply -translate-y-1;
}
.hover-glow {
@apply transition-shadow duration-300;
}
.hover-glow:hover {
box-shadow: 0 0 14px rgba(34, 211, 238, 0.25);
}
.hover-border-glow {
@apply transition-all duration-300;
}
.hover-border-glow:hover {
border-color: rgba(34, 211, 238, 0.4);
box-shadow: 0 0 12px rgba(34, 211, 238, 0.15);
}
/* Stagger children animations */
.stagger-children > * {
@apply animate-slide-in-up;
animation-fill-mode: both;
}
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
.stagger-children > *:nth-child(2) { animation-delay: 50ms; }
.stagger-children > *:nth-child(3) { animation-delay: 100ms; }
.stagger-children > *:nth-child(4) { animation-delay: 150ms; }
.stagger-children > *:nth-child(5) { animation-delay: 200ms; }
.stagger-children > *:nth-child(6) { animation-delay: 250ms; }
.stagger-children > *:nth-child(7) { animation-delay: 300ms; }
.stagger-children > *:nth-child(8) { animation-delay: 350ms; }
/* ========================================
Skeleton Loading
======================================== */
.skeleton {
@apply relative overflow-hidden bg-dark-700 rounded;
}
.skeleton::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.05),
transparent
);
animation: skeleton-pulse 1.5s infinite;
}
@keyframes skeleton-pulse {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
/* ========================================
Focus States (Accessibility)
======================================== */
.focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-neon-500 focus:ring-offset-2 focus:ring-offset-dark-900;
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
} }
} }

View File

@@ -2,13 +2,13 @@ import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { assignmentsApi } from '@/api' import { assignmentsApi } from '@/api'
import type { AssignmentDetail } from '@/types' import type { AssignmentDetail } from '@/types'
import { Card, CardContent, Button } from '@/components/ui' import { GlassCard, NeonButton } from '@/components/ui'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { import {
ArrowLeft, Loader2, ExternalLink, Image, MessageSquare, ArrowLeft, Loader2, ExternalLink, Image, MessageSquare,
ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle, ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle,
Send, Flag Send, Flag, Gamepad2, Zap, Trophy
} from 'lucide-react' } from 'lucide-react'
export function AssignmentDetailPage() { export function AssignmentDetailPage() {
@@ -142,137 +142,167 @@ export function AssignmentDetailPage() {
return `${hours}ч ${minutes}м` return `${hours}ч ${minutes}м`
} }
const getStatusBadge = (status: string) => { const getStatusConfig = (status: string) => {
switch (status) { switch (status) {
case 'completed': case 'completed':
return ( return {
<span className="px-3 py-1 bg-green-500/20 text-green-400 rounded-full text-sm flex items-center gap-1"> color: 'bg-green-500/20 text-green-400 border-green-500/30',
<CheckCircle className="w-4 h-4" /> Выполнено icon: <CheckCircle className="w-4 h-4" />,
</span> text: 'Выполнено',
) }
case 'dropped': case 'dropped':
return ( return {
<span className="px-3 py-1 bg-red-500/20 text-red-400 rounded-full text-sm flex items-center gap-1"> color: 'bg-red-500/20 text-red-400 border-red-500/30',
<XCircle className="w-4 h-4" /> Пропущено icon: <XCircle className="w-4 h-4" />,
</span> text: 'Пропущено',
) }
case 'returned': case 'returned':
return ( return {
<span className="px-3 py-1 bg-orange-500/20 text-orange-400 rounded-full text-sm flex items-center gap-1"> color: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
<AlertTriangle className="w-4 h-4" /> Возвращено icon: <AlertTriangle className="w-4 h-4" />,
</span> text: 'Возвращено',
) }
default: default:
return ( return {
<span className="px-3 py-1 bg-blue-500/20 text-blue-400 rounded-full text-sm"> color: 'bg-neon-500/20 text-neon-400 border-neon-500/30',
Активно icon: <Zap className="w-4 h-4" />,
</span> text: 'Активно',
) }
} }
} }
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex justify-center py-12"> <div className="flex flex-col items-center justify-center py-24">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" /> <Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
<p className="text-gray-400">Загрузка...</p>
</div> </div>
) )
} }
if (error || !assignment) { if (error || !assignment) {
return ( return (
<div className="max-w-2xl mx-auto text-center py-12"> <div className="max-w-2xl mx-auto">
<p className="text-red-400 mb-4">{error || 'Задание не найдено'}</p> <GlassCard className="text-center py-12">
<Button onClick={() => navigate(-1)}>Назад</Button> <div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-red-500/10 border border-red-500/30 flex items-center justify-center">
<AlertTriangle className="w-8 h-8 text-red-400" />
</div>
<p className="text-gray-400 mb-6">{error || 'Задание не найдено'}</p>
<NeonButton variant="outline" onClick={() => navigate(-1)}>
Назад
</NeonButton>
</GlassCard>
</div> </div>
) )
} }
const dispute = assignment.dispute const dispute = assignment.dispute
const status = getStatusConfig(assignment.status)
return ( return (
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center gap-4 mb-6"> <div className="flex items-center gap-4">
<button onClick={() => navigate(-1)} className="text-gray-400 hover:text-white"> <button
<ArrowLeft className="w-6 h-6" /> onClick={() => navigate(-1)}
className="w-10 h-10 rounded-xl bg-dark-700 border border-dark-600 flex items-center justify-center text-gray-400 hover:text-white hover:border-neon-500/30 transition-all"
>
<ArrowLeft className="w-5 h-5" />
</button> </button>
<div>
<h1 className="text-xl font-bold text-white">Детали выполнения</h1> <h1 className="text-xl font-bold text-white">Детали выполнения</h1>
<p className="text-sm text-gray-400">Просмотр доказательства</p>
</div>
</div> </div>
{/* Challenge info */} {/* Challenge info */}
<Card className="mb-6"> <GlassCard variant="neon">
<CardContent>
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 border border-neon-500/20 flex items-center justify-center">
<Gamepad2 className="w-7 h-7 text-neon-400" />
</div>
<div> <div>
<p className="text-gray-400 text-sm">{assignment.challenge.game.title}</p> <p className="text-gray-400 text-sm">{assignment.challenge.game.title}</p>
<h2 className="text-xl font-bold text-white">{assignment.challenge.title}</h2> <h2 className="text-xl font-bold text-white">{assignment.challenge.title}</h2>
</div> </div>
{getStatusBadge(assignment.status)} </div>
<span className={`px-3 py-1.5 rounded-full text-sm font-medium border flex items-center gap-1.5 ${status.color}`}>
{status.icon}
{status.text}
</span>
</div> </div>
<p className="text-gray-300 mb-4">{assignment.challenge.description}</p> <p className="text-gray-300 mb-4">{assignment.challenge.description}</p>
<div className="flex flex-wrap gap-2 mb-4"> <div className="flex flex-wrap gap-2 mb-4">
<span className="px-3 py-1 bg-green-500/20 text-green-400 rounded-full text-sm"> <span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30 flex items-center gap-1.5">
<Trophy className="w-4 h-4" />
+{assignment.challenge.points} очков +{assignment.challenge.points} очков
</span> </span>
<span className="px-3 py-1 bg-gray-700 text-gray-300 rounded-full text-sm"> <span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
{assignment.challenge.difficulty} {assignment.challenge.difficulty}
</span> </span>
{assignment.challenge.estimated_time && ( {assignment.challenge.estimated_time && (
<span className="px-3 py-1 bg-gray-700 text-gray-300 rounded-full text-sm"> <span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600 flex items-center gap-1.5">
<Clock className="w-4 h-4" />
~{assignment.challenge.estimated_time} мин ~{assignment.challenge.estimated_time} мин
</span> </span>
)} )}
</div> </div>
<div className="text-sm text-gray-400 space-y-1"> <div className="pt-4 border-t border-dark-600 text-sm text-gray-400 space-y-1">
<p> <p>
<strong>Выполнил:</strong> {assignment.participant.nickname} <span className="text-gray-500">Выполнил:</span>{' '}
<span className="text-white">{assignment.participant.nickname}</span>
</p> </p>
{assignment.completed_at && ( {assignment.completed_at && (
<p> <p>
<strong>Дата:</strong> {formatDate(assignment.completed_at)} <span className="text-gray-500">Дата:</span>{' '}
<span className="text-white">{formatDate(assignment.completed_at)}</span>
</p> </p>
)} )}
{assignment.points_earned > 0 && ( {assignment.points_earned > 0 && (
<p> <p>
<strong>Получено очков:</strong> {assignment.points_earned} <span className="text-gray-500">Получено очков:</span>{' '}
<span className="text-neon-400 font-semibold">{assignment.points_earned}</span>
</p> </p>
)} )}
</div> </div>
</CardContent> </GlassCard>
</Card>
{/* Proof section */} {/* Proof section */}
<Card className="mb-6"> <GlassCard>
<CardContent> <div className="flex items-center gap-3 mb-4">
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2"> <div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
<Image className="w-5 h-5" /> <Image className="w-5 h-5 text-accent-400" />
Доказательство </div>
</h3> <div>
<h3 className="font-semibold text-white">Доказательство</h3>
<p className="text-sm text-gray-400">Пруф выполнения задания</p>
</div>
</div>
{/* Proof media (image or video) */} {/* Proof media (image or video) */}
{assignment.proof_image_url && ( {assignment.proof_image_url && (
<div className="mb-4"> <div className="mb-4 rounded-xl overflow-hidden border border-dark-600">
{proofMediaBlobUrl ? ( {proofMediaBlobUrl ? (
proofMediaType === 'video' ? ( proofMediaType === 'video' ? (
<video <video
src={proofMediaBlobUrl} src={proofMediaBlobUrl}
controls controls
className="w-full rounded-lg max-h-96 bg-gray-900" className="w-full max-h-96 bg-dark-900"
preload="metadata" preload="metadata"
/> />
) : ( ) : (
<img <img
src={proofMediaBlobUrl} src={proofMediaBlobUrl}
alt="Proof" alt="Proof"
className="w-full rounded-lg max-h-96 object-contain bg-gray-900" className="w-full max-h-96 object-contain bg-dark-900"
/> />
) )
) : ( ) : (
<div className="w-full h-48 bg-gray-900 rounded-lg flex items-center justify-center"> <div className="w-full h-48 bg-dark-900 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-gray-500" /> <Loader2 className="w-8 h-8 animate-spin text-gray-500" />
</div> </div>
)} )}
@@ -286,7 +316,7 @@ export function AssignmentDetailPage() {
href={assignment.proof_url} href={assignment.proof_url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-2 text-primary-400 hover:text-primary-300" className="flex items-center gap-2 text-neon-400 hover:text-neon-300 transition-colors"
> >
<ExternalLink className="w-4 h-4" /> <ExternalLink className="w-4 h-4" />
{assignment.proof_url} {assignment.proof_url}
@@ -296,42 +326,45 @@ export function AssignmentDetailPage() {
{/* Proof comment */} {/* Proof comment */}
{assignment.proof_comment && ( {assignment.proof_comment && (
<div className="p-3 bg-gray-900 rounded-lg"> <div className="p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<p className="text-sm text-gray-400 mb-1">Комментарий:</p> <p className="text-sm text-gray-400 mb-1">Комментарий:</p>
<p className="text-white">{assignment.proof_comment}</p> <p className="text-white">{assignment.proof_comment}</p>
</div> </div>
)} )}
{!assignment.proof_image_url && !assignment.proof_url && ( {!assignment.proof_image_url && !assignment.proof_url && (
<p className="text-gray-500 text-center py-4">Пруф не предоставлен</p> <div className="text-center py-8">
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-dark-700 flex items-center justify-center">
<Image className="w-6 h-6 text-gray-600" />
</div>
<p className="text-gray-500">Пруф не предоставлен</p>
</div>
)} )}
</CardContent> </GlassCard>
</Card>
{/* Dispute button */} {/* Dispute button */}
{assignment.can_dispute && !dispute && !showDisputeForm && ( {assignment.can_dispute && !dispute && !showDisputeForm && (
<Button <button
variant="danger" className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-semibold transition-all duration-200 border-2 border-red-500/50 text-red-400 bg-transparent hover:bg-red-500/10 hover:border-red-500"
className="w-full mb-6"
onClick={() => setShowDisputeForm(true)} onClick={() => setShowDisputeForm(true)}
> >
<Flag className="w-4 h-4 mr-2" /> <Flag className="w-4 h-4" />
Оспорить выполнение Оспорить выполнение
</Button> </button>
)} )}
{/* Dispute creation form */} {/* Dispute creation form */}
{showDisputeForm && !dispute && ( {showDisputeForm && !dispute && (
<Card className="mb-6 border-red-500/50"> <GlassCard className="border-red-500/30">
<CardContent> <div className="flex items-center gap-3 mb-4">
<h3 className="text-lg font-bold text-red-400 mb-4 flex items-center gap-2"> <div className="w-10 h-10 rounded-xl bg-red-500/20 flex items-center justify-center">
<AlertTriangle className="w-5 h-5" /> <AlertTriangle className="w-5 h-5 text-red-400" />
Оспорить выполнение </div>
</h3> <div>
<h3 className="font-semibold text-red-400">Оспорить выполнение</h3>
<p className="text-gray-400 text-sm mb-4"> <p className="text-sm text-gray-400">У участников будет 24 часа для голосования</p>
Опишите причину оспаривания. После создания у участников будет 24 часа для голосования. </div>
</p> </div>
<textarea <textarea
className="input w-full min-h-[100px] resize-none mb-4" className="input w-full min-h-[100px] resize-none mb-4"
@@ -341,115 +374,120 @@ export function AssignmentDetailPage() {
/> />
<div className="flex gap-3"> <div className="flex gap-3">
<Button <NeonButton
variant="danger" className="flex-1 border-red-500/50 bg-red-500/10 hover:bg-red-500/20 text-red-400"
className="flex-1"
onClick={handleCreateDispute} onClick={handleCreateDispute}
isLoading={isCreatingDispute} isLoading={isCreatingDispute}
disabled={disputeReason.trim().length < 10} disabled={disputeReason.trim().length < 10}
> >
Оспорить Оспорить
</Button> </NeonButton>
<Button <NeonButton
variant="secondary" variant="outline"
onClick={() => { onClick={() => {
setShowDisputeForm(false) setShowDisputeForm(false)
setDisputeReason('') setDisputeReason('')
}} }}
> >
Отмена Отмена
</Button> </NeonButton>
</div> </div>
</CardContent> </GlassCard>
</Card>
)} )}
{/* Dispute section */} {/* Dispute section */}
{dispute && ( {dispute && (
<Card className={`mb-6 ${dispute.status === 'open' ? 'border-yellow-500/50' : ''}`}> <GlassCard className={dispute.status === 'open' ? 'border-yellow-500/30' : ''}>
<CardContent>
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between mb-4">
<h3 className="text-lg font-bold text-yellow-400 flex items-center gap-2"> <div className="flex items-center gap-3">
<AlertTriangle className="w-5 h-5" /> <div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center">
Оспаривание <AlertTriangle className="w-5 h-5 text-yellow-400" />
</h3> </div>
<h3 className="font-semibold text-yellow-400">Оспаривание</h3>
</div>
{dispute.status === 'open' ? ( {dispute.status === 'open' ? (
<span className="px-3 py-1 bg-yellow-500/20 text-yellow-400 rounded-full text-sm flex items-center gap-1"> <span className="px-3 py-1.5 bg-yellow-500/20 text-yellow-400 rounded-lg text-sm font-medium border border-yellow-500/30 flex items-center gap-1.5">
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
{getTimeRemaining(dispute.expires_at)} {getTimeRemaining(dispute.expires_at)}
</span> </span>
) : dispute.status === 'valid' ? ( ) : dispute.status === 'valid' ? (
<span className="px-3 py-1 bg-green-500/20 text-green-400 rounded-full text-sm flex items-center gap-1"> <span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30 flex items-center gap-1.5">
<CheckCircle className="w-4 h-4" /> <CheckCircle className="w-4 h-4" />
Пруф валиден Пруф валиден
</span> </span>
) : ( ) : (
<span className="px-3 py-1 bg-red-500/20 text-red-400 rounded-full text-sm flex items-center gap-1"> <span className="px-3 py-1.5 bg-red-500/20 text-red-400 rounded-lg text-sm font-medium border border-red-500/30 flex items-center gap-1.5">
<XCircle className="w-4 h-4" /> <XCircle className="w-4 h-4" />
Пруф невалиден Пруф невалиден
</span> </span>
)} )}
</div> </div>
<div className="mb-4"> <div className="mb-4 text-sm text-gray-400">
<p className="text-sm text-gray-400"> <p>
Открыл: <span className="text-white">{dispute.raised_by.nickname}</span> Открыл: <span className="text-white">{dispute.raised_by.nickname}</span>
</p> </p>
<p className="text-sm text-gray-400"> <p>
Дата: <span className="text-white">{formatDate(dispute.created_at)}</span> Дата: <span className="text-white">{formatDate(dispute.created_at)}</span>
</p> </p>
</div> </div>
<div className="p-3 bg-gray-900 rounded-lg mb-4"> <div className="p-4 bg-dark-700/50 rounded-xl border border-dark-600 mb-4">
<p className="text-sm text-gray-400 mb-1">Причина:</p> <p className="text-sm text-gray-400 mb-1">Причина:</p>
<p className="text-white">{dispute.reason}</p> <p className="text-white">{dispute.reason}</p>
</div> </div>
{/* Voting section */} {/* Voting section */}
{dispute.status === 'open' && ( {dispute.status === 'open' && (
<div className="mb-4"> <div className="mb-6 p-4 bg-dark-700/30 rounded-xl border border-dark-600">
<h4 className="text-sm font-medium text-gray-300 mb-3">Голосование</h4> <h4 className="text-sm font-semibold text-white mb-4">Голосование</h4>
<div className="flex items-center gap-4 mb-3"> <div className="flex items-center gap-6 mb-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ThumbsUp className="w-5 h-5 text-green-500" /> <div className="w-8 h-8 rounded-lg bg-green-500/20 flex items-center justify-center">
<span className="text-green-400 font-medium">{dispute.votes_valid}</span> <ThumbsUp className="w-4 h-4 text-green-400" />
</div>
<span className="text-green-400 font-bold text-lg">{dispute.votes_valid}</span>
<span className="text-gray-500 text-sm">валидно</span> <span className="text-gray-500 text-sm">валидно</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ThumbsDown className="w-5 h-5 text-red-500" /> <div className="w-8 h-8 rounded-lg bg-red-500/20 flex items-center justify-center">
<span className="text-red-400 font-medium">{dispute.votes_invalid}</span> <ThumbsDown className="w-4 h-4 text-red-400" />
</div>
<span className="text-red-400 font-bold text-lg">{dispute.votes_invalid}</span>
<span className="text-gray-500 text-sm">невалидно</span> <span className="text-gray-500 text-sm">невалидно</span>
</div> </div>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<Button <NeonButton
variant={dispute.my_vote === true ? 'primary' : 'secondary'} className={`flex-1 ${dispute.my_vote === true ? 'bg-green-500/20 border-green-500/50 text-green-400' : ''}`}
className="flex-1" variant="outline"
onClick={() => handleVote(true)} onClick={() => handleVote(true)}
isLoading={isVoting} isLoading={isVoting}
disabled={isVoting} disabled={isVoting}
icon={<ThumbsUp className="w-4 h-4" />}
> >
<ThumbsUp className="w-4 h-4 mr-2" />
Валидно Валидно
</Button> </NeonButton>
<Button <NeonButton
variant={dispute.my_vote === false ? 'danger' : 'secondary'} className={`flex-1 ${dispute.my_vote === false ? 'bg-red-500/20 border-red-500/50 text-red-400' : ''}`}
className="flex-1" variant="outline"
onClick={() => handleVote(false)} onClick={() => handleVote(false)}
isLoading={isVoting} isLoading={isVoting}
disabled={isVoting} disabled={isVoting}
icon={<ThumbsDown className="w-4 h-4" />}
> >
<ThumbsDown className="w-4 h-4 mr-2" />
Невалидно Невалидно
</Button> </NeonButton>
</div> </div>
{dispute.my_vote !== null && ( {dispute.my_vote !== null && (
<p className="text-sm text-gray-500 mt-2 text-center"> <p className="text-sm text-gray-500 mt-3 text-center">
Вы проголосовали: {dispute.my_vote ? 'валидно' : 'невалидно'} Вы проголосовали: <span className={dispute.my_vote ? 'text-green-400' : 'text-red-400'}>
{dispute.my_vote ? 'валидно' : 'невалидно'}
</span>
</p> </p>
)} )}
</div> </div>
@@ -457,17 +495,19 @@ export function AssignmentDetailPage() {
{/* Comments section */} {/* Comments section */}
<div> <div>
<h4 className="text-sm font-medium text-gray-300 mb-3 flex items-center gap-2"> <div className="flex items-center gap-2 mb-4">
<MessageSquare className="w-4 h-4" /> <MessageSquare className="w-4 h-4 text-gray-400" />
<h4 className="text-sm font-semibold text-white">
Обсуждение ({dispute.comments.length}) Обсуждение ({dispute.comments.length})
</h4> </h4>
</div>
{dispute.comments.length > 0 && ( {dispute.comments.length > 0 && (
<div className="space-y-3 mb-4 max-h-60 overflow-y-auto"> <div className="space-y-3 mb-4 max-h-60 overflow-y-auto custom-scrollbar">
{dispute.comments.map((comment) => ( {dispute.comments.map((comment) => (
<div key={comment.id} className="p-3 bg-gray-900 rounded-lg"> <div key={comment.id} className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<span className={`font-medium ${comment.user.id === user?.id ? 'text-primary-400' : 'text-white'}`}> <span className={`font-medium ${comment.user.id === user?.id ? 'text-neon-400' : 'text-white'}`}>
{comment.user.nickname} {comment.user.nickname}
{comment.user.id === user?.id && ' (Вы)'} {comment.user.id === user?.id && ' (Вы)'}
</span> </span>
@@ -497,18 +537,16 @@ export function AssignmentDetailPage() {
} }
}} }}
/> />
<Button <NeonButton
onClick={handleAddComment} onClick={handleAddComment}
isLoading={isAddingComment} isLoading={isAddingComment}
disabled={!commentText.trim()} disabled={!commentText.trim()}
> icon={<Send className="w-4 h-4" />}
<Send className="w-4 h-4" /> />
</Button>
</div> </div>
)} )}
</div> </div>
</CardContent> </GlassCard>
</Card>
)} )}
</div> </div>
) )

View File

@@ -4,8 +4,8 @@ import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
import { marathonsApi } from '@/api' import { marathonsApi } from '@/api'
import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui' import { NeonButton, Input, GlassCard } from '@/components/ui'
import { Globe, Lock, Users, UserCog, ArrowLeft } from 'lucide-react' import { Globe, Lock, Users, UserCog, ArrowLeft, Gamepad2, AlertCircle, Sparkles, Calendar, Clock } from 'lucide-react'
import type { GameProposalMode } from '@/types' import type { GameProposalMode } from '@/types'
const createSchema = z.object({ const createSchema = z.object({
@@ -64,25 +64,38 @@ export function CreateMarathonPage() {
} }
return ( return (
<div className="max-w-lg mx-auto"> <div className="max-w-xl mx-auto">
{/* Back button */} {/* Back button */}
<Link to="/marathons" className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors"> <Link
<ArrowLeft className="w-4 h-4" /> to="/marathons"
className="inline-flex items-center gap-2 text-gray-400 hover:text-neon-400 mb-6 transition-colors group"
>
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
К списку марафонов К списку марафонов
</Link> </Link>
<Card> <GlassCard variant="neon">
<CardHeader> {/* Header */}
<CardTitle>Создать марафон</CardTitle> <div className="flex items-center gap-4 mb-8">
</CardHeader> <div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 flex items-center justify-center border border-neon-500/30">
<CardContent> <Gamepad2 className="w-7 h-7 text-neon-400" />
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> </div>
<div>
<h1 className="text-2xl font-bold text-white">Создать марафон</h1>
<p className="text-gray-400 text-sm">Настройте свой игровой марафон</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{error && ( {error && (
<div className="p-3 bg-red-500/20 border border-red-500 rounded-lg text-red-500 text-sm"> <div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm animate-shake">
{error} <AlertCircle className="w-5 h-5 flex-shrink-0" />
<span>{error}</span>
</div> </div>
)} )}
{/* Basic info */}
<div className="space-y-4">
<Input <Input
label="Название" label="Название"
placeholder="Введите название марафона" placeholder="Введите название марафона"
@@ -91,132 +104,209 @@ export function CreateMarathonPage() {
/> />
<div> <div>
<label className="block text-sm font-medium text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-300 mb-2">
Описание (необязательно) Описание (необязательно)
</label> </label>
<textarea <textarea
className="input min-h-[100px] resize-none" className="input min-h-[100px] resize-none"
placeholder="Введите описание" placeholder="Расскажите о вашем марафоне..."
{...register('description')} {...register('description')}
/> />
</div> </div>
</div>
<Input {/* Date and duration */}
label="Дата начала" <div className="grid grid-cols-2 gap-4">
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-2">
<Calendar className="w-4 h-4 text-neon-400" />
Дата начала
</label>
<input
type="datetime-local" type="datetime-local"
error={errors.start_date?.message} className="input w-full"
{...register('start_date')} {...register('start_date')}
/> />
{errors.start_date && (
<p className="text-red-400 text-xs mt-1">{errors.start_date.message}</p>
)}
</div>
<Input <div>
label="Длительность (дней)" <label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-2">
<Clock className="w-4 h-4 text-accent-400" />
Длительность (дней)
</label>
<input
type="number" type="number"
error={errors.duration_days?.message} className="input w-full"
min={1}
max={365}
{...register('duration_days', { valueAsNumber: true })} {...register('duration_days', { valueAsNumber: true })}
/> />
{errors.duration_days && (
<p className="text-red-400 text-xs mt-1">{errors.duration_days.message}</p>
)}
</div>
</div>
{/* Тип марафона */} {/* Marathon type */}
<div> <div>
<label className="block text-sm font-medium text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-300 mb-3">
Тип марафона Тип марафона
</label> </label>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<button <button
type="button" type="button"
onClick={() => setValue('is_public', false)} onClick={() => setValue('is_public', false)}
className={`p-3 rounded-lg border-2 transition-all ${ className={`
!isPublic relative p-4 rounded-xl border-2 transition-all duration-300 text-left group
? 'border-primary-500 bg-primary-500/10' ${!isPublic
: 'border-gray-700 bg-gray-800 hover:border-gray-600' ? 'border-neon-500/50 bg-neon-500/10 shadow-[0_0_14px_rgba(34,211,238,0.08)]'
}`} : 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
}
`}
> >
<Lock className={`w-5 h-5 mx-auto mb-1 ${!isPublic ? 'text-primary-400' : 'text-gray-400'}`} /> <div className={`
<div className={`text-sm font-medium ${!isPublic ? 'text-white' : 'text-gray-300'}`}> w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
${!isPublic ? 'bg-neon-500/20' : 'bg-dark-600'}
`}>
<Lock className={`w-5 h-5 ${!isPublic ? 'text-neon-400' : 'text-gray-400'}`} />
</div>
<div className={`font-semibold mb-1 ${!isPublic ? 'text-white' : 'text-gray-300'}`}>
Закрытый Закрытый
</div> </div>
<div className="text-xs text-gray-500 mt-1"> <div className="text-xs text-gray-500">
Вход по коду Вход только по коду приглашения
</div> </div>
{!isPublic && (
<div className="absolute top-3 right-3">
<Sparkles className="w-4 h-4 text-neon-400" />
</div>
)}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setValue('is_public', true)} onClick={() => setValue('is_public', true)}
className={`p-3 rounded-lg border-2 transition-all ${ className={`
isPublic relative p-4 rounded-xl border-2 transition-all duration-300 text-left group
? 'border-primary-500 bg-primary-500/10' ${isPublic
: 'border-gray-700 bg-gray-800 hover:border-gray-600' ? 'border-accent-500/50 bg-accent-500/10 shadow-[0_0_14px_rgba(139,92,246,0.08)]'
}`} : 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
}
`}
> >
<Globe className={`w-5 h-5 mx-auto mb-1 ${isPublic ? 'text-primary-400' : 'text-gray-400'}`} /> <div className={`
<div className={`text-sm font-medium ${isPublic ? 'text-white' : 'text-gray-300'}`}> w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
${isPublic ? 'bg-accent-500/20' : 'bg-dark-600'}
`}>
<Globe className={`w-5 h-5 ${isPublic ? 'text-accent-400' : 'text-gray-400'}`} />
</div>
<div className={`font-semibold mb-1 ${isPublic ? 'text-white' : 'text-gray-300'}`}>
Открытый Открытый
</div> </div>
<div className="text-xs text-gray-500 mt-1"> <div className="text-xs text-gray-500">
Виден всем Виден всем пользователям
</div> </div>
{isPublic && (
<div className="absolute top-3 right-3">
<Sparkles className="w-4 h-4 text-accent-400" />
</div>
)}
</button> </button>
</div> </div>
</div> </div>
{/* Кто может предлагать игры */} {/* Game proposal mode */}
<div> <div>
<label className="block text-sm font-medium text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-300 mb-3">
Кто может предлагать игры Кто может предлагать игры
</label> </label>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<button <button
type="button" type="button"
onClick={() => setValue('game_proposal_mode', 'all_participants')} onClick={() => setValue('game_proposal_mode', 'all_participants')}
className={`p-3 rounded-lg border-2 transition-all ${ className={`
gameProposalMode === 'all_participants' relative p-4 rounded-xl border-2 transition-all duration-300 text-left
? 'border-primary-500 bg-primary-500/10' ${gameProposalMode === 'all_participants'
: 'border-gray-700 bg-gray-800 hover:border-gray-600' ? 'border-neon-500/50 bg-neon-500/10 shadow-[0_0_14px_rgba(34,211,238,0.08)]'
}`} : 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
}
`}
> >
<Users className={`w-5 h-5 mx-auto mb-1 ${gameProposalMode === 'all_participants' ? 'text-primary-400' : 'text-gray-400'}`} /> <div className={`
<div className={`text-sm font-medium ${gameProposalMode === 'all_participants' ? 'text-white' : 'text-gray-300'}`}> w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
${gameProposalMode === 'all_participants' ? 'bg-neon-500/20' : 'bg-dark-600'}
`}>
<Users className={`w-5 h-5 ${gameProposalMode === 'all_participants' ? 'text-neon-400' : 'text-gray-400'}`} />
</div>
<div className={`font-semibold mb-1 ${gameProposalMode === 'all_participants' ? 'text-white' : 'text-gray-300'}`}>
Все участники Все участники
</div> </div>
<div className="text-xs text-gray-500 mt-1"> <div className="text-xs text-gray-500">
С модерацией С модерацией организатором
</div> </div>
{gameProposalMode === 'all_participants' && (
<div className="absolute top-3 right-3">
<Sparkles className="w-4 h-4 text-neon-400" />
</div>
)}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setValue('game_proposal_mode', 'organizer_only')} onClick={() => setValue('game_proposal_mode', 'organizer_only')}
className={`p-3 rounded-lg border-2 transition-all ${ className={`
gameProposalMode === 'organizer_only' relative p-4 rounded-xl border-2 transition-all duration-300 text-left
? 'border-primary-500 bg-primary-500/10' ${gameProposalMode === 'organizer_only'
: 'border-gray-700 bg-gray-800 hover:border-gray-600' ? 'border-accent-500/50 bg-accent-500/10 shadow-[0_0_14px_rgba(139,92,246,0.08)]'
}`} : 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
}
`}
> >
<UserCog className={`w-5 h-5 mx-auto mb-1 ${gameProposalMode === 'organizer_only' ? 'text-primary-400' : 'text-gray-400'}`} /> <div className={`
<div className={`text-sm font-medium ${gameProposalMode === 'organizer_only' ? 'text-white' : 'text-gray-300'}`}> w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
${gameProposalMode === 'organizer_only' ? 'bg-accent-500/20' : 'bg-dark-600'}
`}>
<UserCog className={`w-5 h-5 ${gameProposalMode === 'organizer_only' ? 'text-accent-400' : 'text-gray-400'}`} />
</div>
<div className={`font-semibold mb-1 ${gameProposalMode === 'organizer_only' ? 'text-white' : 'text-gray-300'}`}>
Только организатор Только организатор
</div> </div>
<div className="text-xs text-gray-500 mt-1"> <div className="text-xs text-gray-500">
Без модерации Без модерации
</div> </div>
{gameProposalMode === 'organizer_only' && (
<div className="absolute top-3 right-3">
<Sparkles className="w-4 h-4 text-accent-400" />
</div>
)}
</button> </button>
</div> </div>
</div> </div>
<div className="flex gap-3 pt-4"> {/* Actions */}
<Button <div className="flex gap-3 pt-4 border-t border-dark-600">
<NeonButton
type="button" type="button"
variant="secondary" variant="outline"
className="flex-1" className="flex-1"
onClick={() => navigate('/marathons')} onClick={() => navigate('/marathons')}
> >
Отмена Отмена
</Button> </NeonButton>
<Button type="submit" className="flex-1" isLoading={isLoading}> <NeonButton
type="submit"
className="flex-1"
isLoading={isLoading}
icon={<Sparkles className="w-4 h-4" />}
>
Создать Создать
</Button> </NeonButton>
</div> </div>
</form> </form>
</CardContent> </GlassCard>
</Card>
</div> </div>
) )
} }

View File

@@ -1,113 +1,251 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { Button } from '@/components/ui' import { NeonButton, GradientButton, FeatureCard } from '@/components/ui'
import { Gamepad2, Users, Trophy, Sparkles } from 'lucide-react' import { Gamepad2, Users, Trophy, Sparkles, Zap, Target, Crown, ArrowRight } from 'lucide-react'
export function HomePage() { export function HomePage() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated) const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
return ( return (
<div className="max-w-4xl mx-auto text-center"> <div className="-mt-8 relative">
{/* Hero */} {/* Global animated background - covers entire page */}
<div className="py-12"> <div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="flex justify-center mb-6"> {/* Gradient orbs */}
<Gamepad2 className="w-20 h-20 text-primary-500" /> <div className="absolute top-[10%] left-[10%] w-[500px] h-[500px] bg-neon-500/20 rounded-full blur-[120px] animate-float" />
<div className="absolute top-[40%] right-[10%] w-[600px] h-[600px] bg-accent-500/20 rounded-full blur-[120px] animate-float" style={{ animationDelay: '-3s' }} />
<div className="absolute top-[60%] left-[30%] w-[700px] h-[700px] bg-pink-500/10 rounded-full blur-[150px]" />
<div className="absolute bottom-[10%] right-[30%] w-[400px] h-[400px] bg-neon-500/15 rounded-full blur-[100px] animate-float" style={{ animationDelay: '-1.5s' }} />
<div className="absolute bottom-[30%] left-[5%] w-[450px] h-[450px] bg-accent-500/15 rounded-full blur-[100px] animate-float" style={{ animationDelay: '-4.5s' }} />
</div> </div>
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
Игровой Марафон {/* Hero Section */}
<section className="relative min-h-[80vh] flex items-center justify-center overflow-hidden">
{/* Content */}
<div className="relative z-10 max-w-5xl mx-auto text-center px-4">
{/* Logo */}
<div className="flex justify-center mb-8">
<div className="relative">
<Gamepad2 className="w-24 h-24 text-neon-500 animate-float drop-shadow-[0_0_20px_rgba(34,211,238,0.4)]" />
<div className="absolute inset-0 bg-neon-500/20 blur-2xl rounded-full" />
</div>
</div>
{/* Title with glitch effect */}
<h1 className="relative mb-6">
<span className="block text-5xl md:text-7xl font-bold font-display tracking-wider text-white">
ИГРОВОЙ
</span>
<span
className="glitch block text-5xl md:text-7xl font-bold font-display tracking-wider text-neon-500 neon-text"
data-text="МАРАФОН"
>
МАРАФОН
</span>
</h1> </h1>
<p className="text-xl text-gray-400 mb-8 max-w-2xl mx-auto">
Соревнуйтесь с друзьями в игровых челленджах. Крутите колесо, выполняйте задания, зарабатывайте очки и станьте чемпионом! {/* Subtitle with typing effect */}
<p className="text-xl md:text-2xl text-gray-300 mb-10 max-w-2xl mx-auto leading-relaxed">
Соревнуйтесь с друзьями в{' '}
<span className="text-neon-400">игровых челленджах</span>.
<br className="hidden md:block" />
Крутите колесо, выполняйте задания, станьте{' '}
<span className="text-accent-400">чемпионом</span>!
</p> </p>
<div className="flex gap-4 justify-center"> {/* CTA Buttons */}
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
{isAuthenticated ? ( {isAuthenticated ? (
<Link to="/marathons"> <Link to="/marathons">
<Button size="lg">К марафонам</Button> <GradientButton size="lg" icon={<ArrowRight className="w-5 h-5" />}>
К марафонам
</GradientButton>
</Link> </Link>
) : ( ) : (
<> <>
<Link to="/register"> <Link to="/register">
<Button size="lg">Начать</Button> <GradientButton size="lg" icon={<Zap className="w-5 h-5" />}>
Начать играть
</GradientButton>
</Link> </Link>
<Link to="/login"> <Link to="/login">
<Button size="lg" variant="secondary">Войти</Button> <NeonButton size="lg" variant="outline" color="neon">
Войти
</NeonButton>
</Link> </Link>
</> </>
)} )}
</div> </div>
</div> </div>
{/* Features */} {/* Scroll indicator */}
<div className="grid md:grid-cols-3 gap-8 py-12"> <div className="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce">
<div className="card text-center"> <div className="w-6 h-10 border-2 border-gray-600 rounded-full flex justify-center pt-2">
<div className="flex justify-center mb-4"> <div className="w-1 h-2 bg-neon-500 rounded-full animate-pulse" />
<Sparkles className="w-12 h-12 text-yellow-500" />
</div> </div>
<h3 className="text-xl font-bold text-white mb-2">Случайные челленджи</h3> </div>
<p className="text-gray-400"> </section>
Крутите колесо, чтобы получить случайную игру и задание. Проверьте свои навыки неожиданным способом!
{/* Features Section */}
<section className="py-24 relative">
<div className="max-w-6xl mx-auto px-4">
<div className="text-center mb-16">
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
Почему <span className="gradient-neon-text">Игровой Марафон</span>?
</h2>
<p className="text-gray-400 max-w-2xl mx-auto">
Уникальный способ играть с друзьями. Случайные челленджи, честная конкуренция, незабываемые моменты.
</p> </p>
</div> </div>
<div className="card text-center"> <div className="grid md:grid-cols-3 gap-6 stagger-children">
<div className="flex justify-center mb-4"> <FeatureCard
<Users className="w-12 h-12 text-green-500" /> icon={<Sparkles className="w-7 h-7" />}
</div> title="Случайные челленджи"
<h3 className="text-xl font-bold text-white mb-2">Играйте с друзьями</h3> description="Крутите колесо и получайте уникальные задания. ИИ генерирует челленджи специально под ваши игры."
<p className="text-gray-400"> color="neon"
Создавайте приватные марафоны и приглашайте друзей. Каждый добавляет свои любимые игры. />
</p> <FeatureCard
</div> icon={<Users className="w-7 h-7" />}
title="Играйте с друзьями"
<div className="card text-center"> description="Создавайте приватные марафоны. Каждый добавляет свои игры, все соревнуются на равных."
<div className="flex justify-center mb-4"> color="purple"
<Trophy className="w-12 h-12 text-primary-500" /> />
</div> <FeatureCard
<h3 className="text-xl font-bold text-white mb-2">Соревнуйтесь за очки</h3> icon={<Trophy className="w-7 h-7" />}
<p className="text-gray-400"> title="Зарабатывайте очки"
Выполняйте задания, чтобы зарабатывать очки. Собирайте серии для бонусных множителей! description="Выполняйте задания, собирайте серии побед. Бонусные множители за стрики!"
</p> color="pink"
/>
</div> </div>
</div> </div>
</section>
{/* How it works */} {/* How it works */}
<div className="py-12"> <section className="py-24 relative">
<h2 className="text-2xl font-bold text-white mb-8">Как это работает</h2> <div className="max-w-6xl mx-auto px-4 relative z-10">
<div className="grid md:grid-cols-4 gap-6 text-left"> <div className="text-center mb-16">
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
Как это работает
</h2>
<p className="text-gray-400">
Четыре простых шага до победы
</p>
</div>
{/* Timeline */}
<div className="relative"> <div className="relative">
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">1</div> {/* Connection line */}
<div className="relative z-10 pt-6"> <div className="hidden md:block absolute top-12 left-0 right-0 h-0.5 bg-gradient-to-r from-neon-500 via-accent-500 to-pink-500" />
<h4 className="font-bold text-white mb-2">Создайте марафон</h4>
<p className="text-gray-400 text-sm">Начните новый марафон и пригласите друзей по уникальному коду</p> <div className="grid md:grid-cols-4 gap-8">
{[
{
step: 1,
icon: <Gamepad2 className="w-6 h-6" />,
title: 'Создайте марафон',
desc: 'Начните новый марафон и пригласите друзей по коду',
color: 'neon',
},
{
step: 2,
icon: <Target className="w-6 h-6" />,
title: 'Добавьте игры',
desc: 'Каждый добавляет игры. ИИ генерирует задания',
color: 'neon',
},
{
step: 3,
icon: <Zap className="w-6 h-6" />,
title: 'Крутите и играйте',
desc: 'Крутите колесо, выполняйте задания',
color: 'accent',
},
{
step: 4,
icon: <Crown className="w-6 h-6" />,
title: 'Победите!',
desc: 'Зарабатывайте очки и станьте чемпионом',
color: 'pink',
},
].map((item, index) => (
<div key={item.step} className="relative text-center group">
{/* Step circle */}
<div
className={`
relative z-10 w-24 h-24 mx-auto mb-6 rounded-2xl
bg-dark-800 border-2 transition-all duration-300
flex items-center justify-center
group-hover:-translate-y-2
${item.color === 'neon' ? 'border-neon-500/50 group-hover:border-neon-500 group-hover:shadow-[0_0_20px_rgba(34,211,238,0.25)]' : ''}
${item.color === 'accent' ? 'border-accent-500/50 group-hover:border-accent-500 group-hover:shadow-[0_0_20px_rgba(139,92,246,0.25)]' : ''}
${item.color === 'pink' ? 'border-pink-500/50 group-hover:border-pink-500 group-hover:shadow-[0_0_20px_rgba(244,114,182,0.25)]' : ''}
`}
style={{ animationDelay: `${index * 100}ms` }}
>
<div className={`
${item.color === 'neon' ? 'text-neon-500' : ''}
${item.color === 'accent' ? 'text-accent-500' : ''}
${item.color === 'pink' ? 'text-pink-500' : ''}
`}>
{item.icon}
</div>
<div className={`
absolute -top-2 -right-2 w-8 h-8 rounded-full
flex items-center justify-center text-sm font-bold
${item.color === 'neon' ? 'bg-neon-500 text-dark-900' : ''}
${item.color === 'accent' ? 'bg-accent-500 text-white' : ''}
${item.color === 'pink' ? 'bg-pink-500 text-white' : ''}
`}>
{item.step}
</div> </div>
</div> </div>
<div className="relative"> <h4 className="text-lg font-semibold text-white mb-2">
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">2</div> {item.title}
<div className="relative z-10 pt-6"> </h4>
<h4 className="font-bold text-white mb-2">Добавьте игры</h4> <p className="text-gray-400 text-sm">
<p className="text-gray-400 text-sm">Все добавляют игры, в которые хотят играть. ИИ генерирует задания</p> {item.desc}
</p>
</div>
))}
</div> </div>
</div> </div>
</div>
</section>
<div className="relative"> {/* CTA Section */}
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">3</div> <section className="py-24 relative">
<div className="relative z-10 pt-6"> <div className="max-w-4xl mx-auto px-4 text-center">
<h4 className="font-bold text-white mb-2">Крутите и играйте</h4> <div className="glass-neon rounded-2xl p-12 relative overflow-hidden">
<p className="text-gray-400 text-sm">Крутите колесо, получите задание, выполните его и отправьте доказательство</p> {/* Background glow */}
</div> <div className="absolute inset-0 bg-gradient-to-r from-neon-500/5 via-accent-500/5 to-pink-500/5" />
</div>
<div className="relative"> <div className="relative z-10">
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">4</div> <h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
<div className="relative z-10 pt-6"> Готовы к соревнованиям?
<h4 className="font-bold text-white mb-2">Победите!</h4> </h2>
<p className="text-gray-400 text-sm">Зарабатывайте очки, поднимайтесь в таблице лидеров, станьте чемпионом!</p> <p className="text-gray-300 mb-8 max-w-xl mx-auto">
</div> Создавайте марафоны, приглашайте друзей и соревнуйтесь в игровых челленджах
</p>
{isAuthenticated ? (
<Link to="/marathons">
<GradientButton size="lg" icon={<ArrowRight className="w-5 h-5" />}>
Перейти к марафонам
</GradientButton>
</Link>
) : (
<Link to="/register">
<GradientButton size="lg" icon={<Zap className="w-5 h-5" />}>
Создать аккаунт бесплатно
</GradientButton>
</Link>
)}
</div> </div>
</div> </div>
</div> </div>
</section>
</div> </div>
) )
} }

View File

@@ -3,8 +3,8 @@ import { useParams, useNavigate, Link } from 'react-router-dom'
import { marathonsApi } from '@/api' import { marathonsApi } from '@/api'
import type { MarathonPublicInfo } from '@/types' import type { MarathonPublicInfo } from '@/types'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { Button, Card, CardContent, CardHeader, CardTitle } from '@/components/ui' import { NeonButton, GlassCard } from '@/components/ui'
import { Users, Loader2, Trophy, UserPlus, LogIn } from 'lucide-react' import { Users, Loader2, Trophy, UserPlus, LogIn, Gamepad2, AlertCircle, Sparkles, Crown } from 'lucide-react'
export function InvitePage() { export function InvitePage() {
const { code } = useParams<{ code: string }>() const { code } = useParams<{ code: string }>()
@@ -63,8 +63,9 @@ export function InvitePage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex justify-center py-12"> <div className="flex flex-col items-center justify-center py-24">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" /> <Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
<p className="text-gray-400">Загрузка приглашения...</p>
</div> </div>
) )
} }
@@ -72,97 +73,154 @@ export function InvitePage() {
if (error || !marathon) { if (error || !marathon) {
return ( return (
<div className="max-w-md mx-auto"> <div className="max-w-md mx-auto">
<Card> <GlassCard className="text-center py-12">
<CardContent className="text-center py-8"> <div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-red-500/10 border border-red-500/30 flex items-center justify-center">
<div className="text-red-400 mb-4">{error || 'Марафон не найден'}</div> <AlertCircle className="w-8 h-8 text-red-400" />
</div>
<h2 className="text-xl font-bold text-white mb-2">Ошибка</h2>
<p className="text-gray-400 mb-6">{error || 'Марафон не найден'}</p>
<Link to="/marathons"> <Link to="/marathons">
<Button variant="secondary">К списку марафонов</Button> <NeonButton variant="outline">К списку марафонов</NeonButton>
</Link> </Link>
</CardContent> </GlassCard>
</Card>
</div> </div>
) )
} }
const statusText = { const getStatusConfig = (status: string) => {
preparing: 'Подготовка', switch (status) {
active: 'Активен', case 'preparing':
finished: 'Завершён', return {
}[marathon.status] color: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
text: 'Подготовка',
dot: 'bg-yellow-500',
}
case 'active':
return {
color: 'bg-neon-500/20 text-neon-400 border-neon-500/30',
text: 'Активен',
dot: 'bg-neon-500 animate-pulse',
}
case 'finished':
return {
color: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
text: 'Завершён',
dot: 'bg-gray-500',
}
default:
return {
color: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
text: status,
dot: 'bg-gray-500',
}
}
}
const status = getStatusConfig(marathon.status)
return ( return (
<div className="max-w-md mx-auto"> <div className="min-h-[70vh] flex items-center justify-center px-4">
<Card> {/* Background effects */}
<CardHeader className="text-center"> <div className="fixed inset-0 overflow-hidden pointer-events-none">
<CardTitle className="flex items-center justify-center gap-2"> <div className="absolute top-1/4 -left-32 w-96 h-96 bg-neon-500/10 rounded-full blur-[100px]" />
<Trophy className="w-6 h-6 text-primary-500" /> <div className="absolute bottom-1/4 -right-32 w-96 h-96 bg-accent-500/10 rounded-full blur-[100px]" />
Приглашение в марафон
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Marathon info */}
<div className="text-center">
<h2 className="text-2xl font-bold text-white mb-2">{marathon.title}</h2>
{marathon.description && (
<p className="text-gray-400 text-sm mb-4">{marathon.description}</p>
)}
<div className="flex items-center justify-center gap-4 text-sm text-gray-400">
<span className="flex items-center gap-1">
<Users className="w-4 h-4" />
{marathon.participants_count} участников
</span>
<span className={`px-2 py-0.5 rounded text-xs ${
marathon.status === 'active' ? 'bg-green-900/50 text-green-400' :
marathon.status === 'preparing' ? 'bg-yellow-900/50 text-yellow-400' :
'bg-gray-700 text-gray-400'
}`}>
{statusText}
</span>
</div>
<p className="text-gray-500 text-xs mt-2">
Организатор: {marathon.creator_nickname}
</p>
</div> </div>
<div className="relative w-full max-w-md">
<GlassCard variant="neon" className="animate-scale-in">
{/* Header */}
<div className="text-center mb-8">
<div className="w-20 h-20 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 border border-neon-500/30 flex items-center justify-center">
<Trophy className="w-10 h-10 text-neon-400" />
</div>
<h1 className="text-xl font-bold text-white mb-1">Приглашение в марафон</h1>
<p className="text-gray-400 text-sm">Вас пригласили присоединиться</p>
</div>
{/* Marathon info */}
<div className="glass rounded-xl p-5 mb-6">
<div className="flex items-start gap-4">
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 flex items-center justify-center border border-neon-500/20 flex-shrink-0">
<Gamepad2 className="w-7 h-7 text-neon-400" />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-xl font-bold text-white mb-1 truncate">{marathon.title}</h2>
{marathon.description && (
<p className="text-gray-400 text-sm line-clamp-2 mb-3">{marathon.description}</p>
)}
<div className="flex flex-wrap items-center gap-3">
<span className={`px-2.5 py-1 rounded-full text-xs font-medium border flex items-center gap-1.5 ${status.color}`}>
<span className={`w-1.5 h-1.5 rounded-full ${status.dot}`} />
{status.text}
</span>
<span className="flex items-center gap-1.5 text-sm text-gray-400">
<Users className="w-4 h-4" />
{marathon.participants_count}
</span>
</div>
</div>
</div>
{/* Organizer */}
<div className="mt-4 pt-4 border-t border-dark-600 flex items-center gap-2 text-sm text-gray-500">
<Crown className="w-4 h-4 text-yellow-500" />
<span>Организатор:</span>
<span className="text-gray-300">{marathon.creator_nickname}</span>
</div>
</div>
{/* Actions */}
{marathon.status === 'finished' ? ( {marathon.status === 'finished' ? (
<div className="text-center text-gray-400"> <div className="text-center">
Этот марафон уже завершён <div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-gray-500/10 flex items-center justify-center">
<AlertCircle className="w-6 h-6 text-gray-400" />
</div>
<p className="text-gray-400 mb-4">Этот марафон уже завершён</p>
<Link to="/marathons">
<NeonButton variant="outline" className="w-full">
К списку марафонов
</NeonButton>
</Link>
</div> </div>
) : isAuthenticated ? ( ) : isAuthenticated ? (
/* Authenticated - show join button */ <NeonButton
<Button
className="w-full" className="w-full"
size="lg"
onClick={handleJoin} onClick={handleJoin}
isLoading={isJoining} isLoading={isJoining}
icon={<Sparkles className="w-5 h-5" />}
> >
<UserPlus className="w-4 h-4 mr-2" /> Присоединиться
Присоединиться к марафону </NeonButton>
</Button>
) : ( ) : (
/* Not authenticated - show login/register options */ <div className="space-y-4">
<div className="space-y-3">
<p className="text-center text-gray-400 text-sm"> <p className="text-center text-gray-400 text-sm">
Чтобы присоединиться, войдите или зарегистрируйтесь Чтобы присоединиться, войдите или зарегистрируйтесь
</p> </p>
<Button <NeonButton
className="w-full" className="w-full"
size="lg"
onClick={() => handleAuthRedirect('/login')} onClick={() => handleAuthRedirect('/login')}
icon={<LogIn className="w-5 h-5" />}
> >
<LogIn className="w-4 h-4 mr-2" />
Войти Войти
</Button> </NeonButton>
<Button <NeonButton
variant="secondary" variant="outline"
className="w-full" className="w-full"
onClick={() => handleAuthRedirect('/register')} onClick={() => handleAuthRedirect('/register')}
icon={<UserPlus className="w-5 h-5" />}
> >
<UserPlus className="w-4 h-4 mr-2" />
Зарегистрироваться Зарегистрироваться
</Button> </NeonButton>
</div> </div>
)} )}
</CardContent> </GlassCard>
</Card>
{/* Decorative elements */}
<div className="absolute -top-4 -left-4 w-24 h-24 border border-neon-500/20 rounded-2xl -z-10" />
<div className="absolute -bottom-4 -right-4 w-32 h-32 border border-accent-500/20 rounded-2xl -z-10" />
</div>
</div> </div>
) )
} }

View File

@@ -2,9 +2,9 @@ import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { marathonsApi } from '@/api' import { marathonsApi } from '@/api'
import type { LeaderboardEntry } from '@/types' import type { LeaderboardEntry } from '@/types'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui' import { GlassCard } from '@/components/ui'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { Trophy, Flame, ArrowLeft, Loader2 } from 'lucide-react' import { Trophy, Flame, ArrowLeft, Loader2, Crown, Medal, Award, Star, Zap, Target } from 'lucide-react'
export function LeaderboardPage() { export function LeaderboardPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
@@ -28,92 +28,257 @@ export function LeaderboardPage() {
} }
} }
const getRankIcon = (rank: number) => { const getRankConfig = (rank: number) => {
switch (rank) { switch (rank) {
case 1: case 1:
return <Trophy className="w-6 h-6 text-yellow-500" /> return {
icon: <Crown className="w-6 h-6" />,
color: 'text-yellow-400',
bg: 'bg-yellow-500/20',
border: 'border-yellow-500/30',
glow: 'shadow-[0_0_20px_rgba(234,179,8,0.3)]',
gradient: 'from-yellow-500/20 via-transparent to-transparent',
}
case 2: case 2:
return <Trophy className="w-6 h-6 text-gray-400" /> return {
icon: <Medal className="w-6 h-6" />,
color: 'text-gray-300',
bg: 'bg-gray-400/20',
border: 'border-gray-400/30',
glow: 'shadow-[0_0_15px_rgba(156,163,175,0.2)]',
gradient: 'from-gray-400/10 via-transparent to-transparent',
}
case 3: case 3:
return <Trophy className="w-6 h-6 text-amber-700" /> return {
icon: <Award className="w-6 h-6" />,
color: 'text-amber-600',
bg: 'bg-amber-600/20',
border: 'border-amber-600/30',
glow: 'shadow-[0_0_15px_rgba(217,119,6,0.2)]',
gradient: 'from-amber-600/10 via-transparent to-transparent',
}
default: default:
return <span className="text-gray-500 font-mono w-6 text-center">{rank}</span> return {
icon: <span className="text-gray-500 font-mono font-bold">{rank}</span>,
color: 'text-gray-500',
bg: 'bg-dark-700',
border: 'border-dark-600',
glow: '',
gradient: '',
}
} }
} }
// Top 3 for podium
const topThree = leaderboard.slice(0, 3)
// Calculate stats
const totalPoints = leaderboard.reduce((acc, e) => acc + e.total_points, 0)
const maxStreak = Math.max(...leaderboard.map(e => e.current_streak), 0)
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex justify-center py-12"> <div className="flex flex-col items-center justify-center py-24">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" /> <Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
<p className="text-gray-400">Загрузка рейтинга...</p>
</div> </div>
) )
} }
return ( return (
<div className="max-w-2xl mx-auto"> <div className="max-w-3xl mx-auto">
{/* Header */}
<div className="flex items-center gap-4 mb-8"> <div className="flex items-center gap-4 mb-8">
<Link to={`/marathons/${id}`} className="text-gray-400 hover:text-white"> <Link
<ArrowLeft className="w-6 h-6" /> to={`/marathons/${id}`}
className="w-10 h-10 rounded-xl bg-dark-700 border border-dark-600 flex items-center justify-center text-gray-400 hover:text-white hover:border-neon-500/30 transition-all"
>
<ArrowLeft className="w-5 h-5" />
</Link> </Link>
<div>
<h1 className="text-2xl font-bold text-white">Таблица лидеров</h1> <h1 className="text-2xl font-bold text-white">Таблица лидеров</h1>
<p className="text-gray-400 text-sm">Рейтинг участников марафона</p>
</div>
</div> </div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="w-5 h-5 text-yellow-500" />
Рейтинг
</CardTitle>
</CardHeader>
<CardContent>
{leaderboard.length === 0 ? ( {leaderboard.length === 0 ? (
<p className="text-center text-gray-400 py-8">Пока нет участников</p> <GlassCard className="text-center py-16">
<div className="w-20 h-20 mx-auto mb-6 rounded-2xl bg-dark-700 flex items-center justify-center">
<Trophy className="w-10 h-10 text-gray-600" />
</div>
<h3 className="text-xl font-semibold text-white mb-2">Пока нет участников</h3>
<p className="text-gray-400">Станьте первым в рейтинге!</p>
</GlassCard>
) : ( ) : (
<>
{/* Podium for top 3 */}
{topThree.length >= 3 && (
<div className="mb-8">
<div className="flex items-end justify-center gap-4 mb-4">
{/* 2nd place */}
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '100ms' }}>
<div className={`
w-20 h-20 rounded-2xl mb-3 flex items-center justify-center
bg-gray-400/10 border border-gray-400/30
shadow-[0_0_20px_rgba(156,163,175,0.2)]
`}>
<span className="text-3xl font-bold text-gray-300">2</span>
</div>
<Link to={`/users/${topThree[1].user.id}`} className="glass rounded-xl p-4 text-center w-28 hover:border-neon-500/30 transition-colors border border-transparent">
<Medal className="w-6 h-6 text-gray-300 mx-auto mb-2" />
<p className="text-sm font-medium text-white truncate hover:text-neon-400 transition-colors">{topThree[1].user.nickname}</p>
<p className="text-xs text-gray-400">{topThree[1].total_points} очков</p>
</Link>
</div>
{/* 1st place */}
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '0ms' }}>
<div className={`
w-24 h-24 rounded-2xl mb-3 flex items-center justify-center
bg-yellow-500/20 border border-yellow-500/30
shadow-[0_0_30px_rgba(234,179,8,0.4)]
`}>
<Crown className="w-10 h-10 text-yellow-400" />
</div>
<Link to={`/users/${topThree[0].user.id}`} className="glass-neon rounded-xl p-4 text-center w-32 hover:shadow-[0_0_20px_rgba(34,211,238,0.3)] transition-shadow">
<Star className="w-6 h-6 text-yellow-400 mx-auto mb-2" />
<p className="font-semibold text-white truncate hover:text-neon-400 transition-colors">{topThree[0].user.nickname}</p>
<p className="text-sm text-neon-400 font-bold">{topThree[0].total_points} очков</p>
</Link>
</div>
{/* 3rd place */}
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '200ms' }}>
<div className={`
w-20 h-20 rounded-2xl mb-3 flex items-center justify-center
bg-amber-600/10 border border-amber-600/30
shadow-[0_0_20px_rgba(217,119,6,0.2)]
`}>
<span className="text-3xl font-bold text-amber-600">3</span>
</div>
<Link to={`/users/${topThree[2].user.id}`} className="glass rounded-xl p-4 text-center w-28 hover:border-neon-500/30 transition-colors border border-transparent">
<Award className="w-6 h-6 text-amber-600 mx-auto mb-2" />
<p className="text-sm font-medium text-white truncate hover:text-neon-400 transition-colors">{topThree[2].user.nickname}</p>
<p className="text-xs text-gray-400">{topThree[2].total_points} очков</p>
</Link>
</div>
</div>
</div>
)}
{/* Stats row */}
<div className="grid grid-cols-3 gap-4 mb-8">
<div className="glass rounded-xl p-4 text-center">
<Trophy className="w-6 h-6 text-neon-400 mx-auto mb-2" />
<p className="text-2xl font-bold text-white">{leaderboard.length}</p>
<p className="text-xs text-gray-400">Участников</p>
</div>
<div className="glass rounded-xl p-4 text-center">
<Zap className="w-6 h-6 text-accent-400 mx-auto mb-2" />
<p className="text-2xl font-bold text-white">{totalPoints}</p>
<p className="text-xs text-gray-400">Всего очков</p>
</div>
<div className="glass rounded-xl p-4 text-center">
<Flame className="w-6 h-6 text-orange-400 mx-auto mb-2" />
<p className="text-2xl font-bold text-white">{maxStreak}</p>
<p className="text-xs text-gray-400">Макс. серия</p>
</div>
</div>
{/* Full leaderboard */}
<GlassCard>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-neon-500/20 flex items-center justify-center">
<Target className="w-5 h-5 text-neon-400" />
</div>
<div>
<h3 className="font-semibold text-white">Полный рейтинг</h3>
<p className="text-sm text-gray-400">Все участники марафона</p>
</div>
</div>
<div className="space-y-2"> <div className="space-y-2">
{leaderboard.map((entry) => ( {leaderboard.map((entry, index) => {
const isCurrentUser = entry.user.id === user?.id
const rankConfig = getRankConfig(entry.rank)
return (
<div <div
key={entry.user.id} key={entry.user.id}
className={`flex items-center gap-4 p-4 rounded-lg ${ className={`
entry.user.id === user?.id relative flex items-center gap-4 p-4 rounded-xl
? 'bg-primary-500/20 border border-primary-500/50' transition-all duration-300 group
: 'bg-gray-900' ${isCurrentUser
}`} ? 'bg-neon-500/10 border border-neon-500/30 shadow-[0_0_14px_rgba(34,211,238,0.08)]'
: `${rankConfig.bg} border ${rankConfig.border} hover:border-neon-500/20`
}
`}
style={{ animationDelay: `${index * 50}ms` }}
> >
<div className="flex items-center justify-center w-8"> {/* Gradient overlay for top 3 */}
{getRankIcon(entry.rank)} {entry.rank <= 3 && (
<div className={`absolute inset-0 bg-gradient-to-r ${rankConfig.gradient} rounded-xl pointer-events-none`} />
)}
{/* Rank */}
<div className={`
relative w-10 h-10 rounded-xl flex items-center justify-center
${rankConfig.bg} ${rankConfig.color} ${rankConfig.glow}
`}>
{rankConfig.icon}
</div> </div>
<div className="flex-1"> {/* User info */}
<div className="font-medium text-white"> <div className="relative flex-1 min-w-0">
<div className="flex items-center gap-2">
<Link
to={`/users/${entry.user.id}`}
className={`font-semibold truncate hover:text-neon-400 transition-colors ${isCurrentUser ? 'text-neon-400' : 'text-white'}`}
>
{entry.user.nickname} {entry.user.nickname}
{entry.user.id === user?.id && ( </Link>
<span className="ml-2 text-xs text-primary-400">(Вы)</span> {isCurrentUser && (
<span className="px-2 py-0.5 text-xs font-medium bg-neon-500/20 text-neon-400 rounded-full border border-neon-500/30">
Вы
</span>
)} )}
</div> </div>
<div className="text-sm text-gray-400"> <div className="flex items-center gap-3 text-sm text-gray-400">
{entry.completed_count} выполнено, {entry.dropped_count} пропущено <span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 bg-green-500 rounded-full" />
{entry.completed_count} выполнено
</span>
{entry.dropped_count > 0 && (
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 bg-red-500 rounded-full" />
{entry.dropped_count} пропущено
</span>
)}
</div> </div>
</div> </div>
{/* Streak */}
{entry.current_streak > 0 && ( {entry.current_streak > 0 && (
<div className="flex items-center gap-1 text-yellow-500"> <div className="relative flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-orange-500/10 border border-orange-500/20">
<Flame className="w-4 h-4" /> <Flame className="w-4 h-4 text-orange-400" />
<span className="text-sm">{entry.current_streak}</span> <span className="text-sm font-semibold text-orange-400">{entry.current_streak}</span>
</div> </div>
)} )}
<div className="text-right"> {/* Points */}
<div className="text-xl font-bold text-primary-400"> <div className="relative text-right">
<div className={`text-xl font-bold ${isCurrentUser ? 'text-neon-400' : 'text-white'}`}>
{entry.total_points} {entry.total_points}
</div> </div>
<div className="text-xs text-gray-500">очков</div> <div className="text-xs text-gray-500">очков</div>
</div> </div>
</div> </div>
))} )
})}
</div> </div>
</GlassCard>
</>
)} )}
</CardContent>
</Card>
</div> </div>
) )
} }

View File

@@ -2,13 +2,13 @@ import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom' import { useParams, useNavigate, Link } from 'react-router-dom'
import { marathonsApi, gamesApi } from '@/api' import { marathonsApi, gamesApi } from '@/api'
import type { Marathon, Game, Challenge, ChallengePreview } from '@/types' import type { Marathon, Game, Challenge, ChallengePreview } from '@/types'
import { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@/components/ui' import { NeonButton, Input, GlassCard, StatsCard } from '@/components/ui'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm' import { useConfirm } from '@/store/confirm'
import { import {
Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye, Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye,
ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft, Zap
} from 'lucide-react' } from 'lucide-react'
export function LobbyPage() { export function LobbyPage() {
@@ -39,6 +39,8 @@ export function LobbyPage() {
const [previewChallenges, setPreviewChallenges] = useState<ChallengePreview[] | null>(null) const [previewChallenges, setPreviewChallenges] = useState<ChallengePreview[] | null>(null)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [editingIndex, setEditingIndex] = useState<number | null>(null) const [editingIndex, setEditingIndex] = useState<number | null>(null)
const [showGenerateSelection, setShowGenerateSelection] = useState(false)
const [selectedGamesForGeneration, setSelectedGamesForGeneration] = useState<number[]>([])
// View existing challenges // View existing challenges
const [expandedGameId, setExpandedGameId] = useState<number | null>(null) const [expandedGameId, setExpandedGameId] = useState<number | null>(null)
@@ -72,17 +74,14 @@ export function LobbyPage() {
const marathonData = await marathonsApi.get(parseInt(id)) const marathonData = await marathonsApi.get(parseInt(id))
setMarathon(marathonData) setMarathon(marathonData)
// Load games - organizers see all, participants see approved + own
const gamesData = await gamesApi.list(parseInt(id)) const gamesData = await gamesApi.list(parseInt(id))
setGames(gamesData) setGames(gamesData)
// If organizer, load pending games separately
if (marathonData.my_participation?.role === 'organizer' || user?.role === 'admin') { if (marathonData.my_participation?.role === 'organizer' || user?.role === 'admin') {
try { try {
const pending = await gamesApi.listPending(parseInt(id)) const pending = await gamesApi.listPending(parseInt(id))
setPendingGames(pending) setPendingGames(pending)
} catch { } catch {
// If not authorized, just ignore
setPendingGames([]) setPendingGames([])
} }
} }
@@ -175,7 +174,6 @@ export function LobbyPage() {
setExpandedGameId(gameId) setExpandedGameId(gameId)
// Load challenges if we haven't loaded them yet
if (!gameChallenges[gameId]) { if (!gameChallenges[gameId]) {
setLoadingChallenges(gameId) setLoadingChallenges(gameId)
try { try {
@@ -183,7 +181,6 @@ export function LobbyPage() {
setGameChallenges(prev => ({ ...prev, [gameId]: challenges })) setGameChallenges(prev => ({ ...prev, [gameId]: challenges }))
} catch (error) { } catch (error) {
console.error('Failed to load challenges:', error) console.error('Failed to load challenges:', error)
// Set empty array to prevent repeated attempts
setGameChallenges(prev => ({ ...prev, [gameId]: [] })) setGameChallenges(prev => ({ ...prev, [gameId]: [] }))
} finally { } finally {
setLoadingChallenges(null) setLoadingChallenges(null)
@@ -210,7 +207,6 @@ export function LobbyPage() {
proof_hint: newChallenge.proof_hint.trim() || undefined, proof_hint: newChallenge.proof_hint.trim() || undefined,
}) })
toast.success('Задание добавлено') toast.success('Задание добавлено')
// Reset form
setNewChallenge({ setNewChallenge({
title: '', title: '',
description: '', description: '',
@@ -222,7 +218,6 @@ export function LobbyPage() {
proof_hint: '', proof_hint: '',
}) })
setAddingChallengeToGameId(null) setAddingChallengeToGameId(null)
// Refresh challenges
const challenges = await gamesApi.getChallenges(gameId) const challenges = await gamesApi.getChallenges(gameId)
setGameChallenges(prev => ({ ...prev, [gameId]: challenges })) setGameChallenges(prev => ({ ...prev, [gameId]: challenges }))
await loadData() await loadData()
@@ -246,10 +241,9 @@ export function LobbyPage() {
try { try {
await gamesApi.deleteChallenge(challengeId) await gamesApi.deleteChallenge(challengeId)
// Refresh challenges for this game
const challenges = await gamesApi.getChallenges(gameId) const challenges = await gamesApi.getChallenges(gameId)
setGameChallenges(prev => ({ ...prev, [gameId]: challenges })) setGameChallenges(prev => ({ ...prev, [gameId]: challenges }))
await loadData() // Refresh game counts await loadData()
} catch (error) { } catch (error) {
console.error('Failed to delete challenge:', error) console.error('Failed to delete challenge:', error)
} }
@@ -261,11 +255,14 @@ export function LobbyPage() {
setIsGenerating(true) setIsGenerating(true)
setGenerateMessage(null) setGenerateMessage(null)
try { try {
const result = await gamesApi.previewChallenges(parseInt(id)) // Pass selected games if any, otherwise generate for all games without challenges
const gameIds = selectedGamesForGeneration.length > 0 ? selectedGamesForGeneration : undefined
const result = await gamesApi.previewChallenges(parseInt(id), gameIds)
if (result.challenges.length === 0) { if (result.challenges.length === 0) {
setGenerateMessage('Все игры уже имеют задания') setGenerateMessage('Нет игр для генерации заданий')
} else { } else {
setPreviewChallenges(result.challenges) setPreviewChallenges(result.challenges)
setShowGenerateSelection(false)
} }
} catch (error) { } catch (error) {
console.error('Failed to generate challenges:', error) console.error('Failed to generate challenges:', error)
@@ -275,6 +272,22 @@ export function LobbyPage() {
} }
} }
const toggleGameSelection = (gameId: number) => {
setSelectedGamesForGeneration(prev =>
prev.includes(gameId)
? prev.filter(id => id !== gameId)
: [...prev, gameId]
)
}
const selectAllGamesForGeneration = () => {
setSelectedGamesForGeneration(approvedGames.map(g => g.id))
}
const clearGameSelection = () => {
setSelectedGamesForGeneration([])
}
const handleSaveChallenges = async () => { const handleSaveChallenges = async () => {
if (!id || !previewChallenges) return if (!id || !previewChallenges) return
@@ -283,7 +296,7 @@ export function LobbyPage() {
const result = await gamesApi.saveChallenges(parseInt(id), previewChallenges) const result = await gamesApi.saveChallenges(parseInt(id), previewChallenges)
setGenerateMessage(result.message) setGenerateMessage(result.message)
setPreviewChallenges(null) setPreviewChallenges(null)
setGameChallenges({}) // Clear cache to reload setGameChallenges({})
await loadData() await loadData()
} catch (error) { } catch (error) {
console.error('Failed to save challenges:', error) console.error('Failed to save challenges:', error)
@@ -337,8 +350,9 @@ export function LobbyPage() {
if (isLoading || !marathon) { if (isLoading || !marathon) {
return ( return (
<div className="flex justify-center py-12"> <div className="flex flex-col items-center justify-center py-24">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" /> <Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
<p className="text-gray-400">Загрузка лобби...</p>
</div> </div>
) )
} }
@@ -351,21 +365,21 @@ export function LobbyPage() {
switch (status) { switch (status) {
case 'approved': case 'approved':
return ( return (
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-green-900/50 text-green-400"> <span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-lg bg-green-500/20 text-green-400 border border-green-500/30">
<CheckCircle className="w-3 h-3" /> <CheckCircle className="w-3 h-3" />
Одобрено Одобрено
</span> </span>
) )
case 'pending': case 'pending':
return ( return (
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-yellow-900/50 text-yellow-400"> <span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-lg bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">
<Clock className="w-3 h-3" /> <Clock className="w-3 h-3" />
На модерации На модерации
</span> </span>
) )
case 'rejected': case 'rejected':
return ( return (
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-red-900/50 text-red-400"> <span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-lg bg-red-500/20 text-red-400 border border-red-500/30">
<XCircle className="w-3 h-3" /> <XCircle className="w-3 h-3" />
Отклонено Отклонено
</span> </span>
@@ -376,11 +390,11 @@ export function LobbyPage() {
} }
const renderGameCard = (game: Game, showModeration = false) => ( const renderGameCard = (game: Game, showModeration = false) => (
<div key={game.id} className="bg-gray-900 rounded-lg overflow-hidden"> <div key={game.id} className="glass rounded-xl overflow-hidden border border-dark-600">
{/* Game header */} {/* Game header */}
<div <div
className={`flex items-center justify-between p-4 ${ className={`flex items-center justify-between p-4 ${
(game.status === 'approved') ? 'cursor-pointer hover:bg-gray-800/50' : '' (game.status === 'approved') ? 'cursor-pointer hover:bg-dark-700/50 transition-colors' : ''
}`} }`}
onClick={() => game.status === 'approved' && handleToggleGameChallenges(game.id)} onClick={() => game.status === 'approved' && handleToggleGameChallenges(game.id)}
> >
@@ -394,14 +408,22 @@ export function LobbyPage() {
)} )}
</span> </span>
)} )}
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-neon-500/20 to-accent-500/20 border border-neon-500/20 flex items-center justify-center flex-shrink-0">
<Gamepad2 className="w-5 h-5 text-neon-400" />
</div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h4 className="font-medium text-white">{game.title}</h4> <h4 className="font-semibold text-white">{game.title}</h4>
{getStatusBadge(game.status)} {getStatusBadge(game.status)}
</div> </div>
<div className="text-sm text-gray-400 flex items-center gap-3 flex-wrap"> <div className="text-sm text-gray-400 flex items-center gap-3 flex-wrap">
{game.genre && <span>{game.genre}</span>} {game.genre && <span>{game.genre}</span>}
{game.status === 'approved' && <span>{game.challenges_count} заданий</span>} {game.status === 'approved' && (
<span className="flex items-center gap-1">
<Sparkles className="w-3 h-3 text-accent-400" />
{game.challenges_count} заданий
</span>
)}
{game.proposed_by && ( {game.proposed_by && (
<span className="flex items-center gap-1 text-gray-500"> <span className="flex items-center gap-1 text-gray-500">
<User className="w-3 h-3" /> <User className="w-3 h-3" />
@@ -414,49 +436,43 @@ export function LobbyPage() {
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}> <div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
{showModeration && game.status === 'pending' && ( {showModeration && game.status === 'pending' && (
<> <>
<Button <button
variant="ghost"
size="sm"
onClick={() => handleApproveGame(game.id)} onClick={() => handleApproveGame(game.id)}
disabled={moderatingGameId === game.id} disabled={moderatingGameId === game.id}
className="text-green-400 hover:text-green-300" className="p-2 rounded-lg text-green-400 hover:bg-green-500/10 transition-colors disabled:opacity-50"
> >
{moderatingGameId === game.id ? ( {moderatingGameId === game.id ? (
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
) : ( ) : (
<CheckCircle className="w-4 h-4" /> <CheckCircle className="w-4 h-4" />
)} )}
</Button> </button>
<Button <button
variant="ghost"
size="sm"
onClick={() => handleRejectGame(game.id)} onClick={() => handleRejectGame(game.id)}
disabled={moderatingGameId === game.id} disabled={moderatingGameId === game.id}
className="text-red-400 hover:text-red-300" className="p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-50"
> >
<XCircle className="w-4 h-4" /> <XCircle className="w-4 h-4" />
</Button> </button>
</> </>
)} )}
{(isOrganizer || game.proposed_by?.id === user?.id) && ( {(isOrganizer || game.proposed_by?.id === user?.id) && (
<Button <button
variant="ghost"
size="sm"
onClick={() => handleDeleteGame(game.id)} onClick={() => handleDeleteGame(game.id)}
className="text-red-400 hover:text-red-300" className="p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors"
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</Button> </button>
)} )}
</div> </div>
</div> </div>
{/* Expanded challenges list */} {/* Expanded challenges list */}
{expandedGameId === game.id && ( {expandedGameId === game.id && (
<div className="border-t border-gray-800 p-4 space-y-2"> <div className="border-t border-dark-600 p-4 space-y-2 bg-dark-800/30">
{loadingChallenges === game.id ? ( {loadingChallenges === game.id ? (
<div className="flex justify-center py-4"> <div className="flex justify-center py-4">
<Loader2 className="w-5 h-5 animate-spin text-gray-400" /> <Loader2 className="w-5 h-5 animate-spin text-neon-400" />
</div> </div>
) : ( ) : (
<> <>
@@ -464,24 +480,24 @@ export function LobbyPage() {
gameChallenges[game.id].map((challenge) => ( gameChallenges[game.id].map((challenge) => (
<div <div
key={challenge.id} key={challenge.id}
className="flex items-start justify-between gap-3 p-3 bg-gray-800 rounded-lg" className="flex items-start justify-between gap-3 p-3 bg-dark-700/50 rounded-lg border border-dark-600"
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1 flex-wrap">
<span className={`text-xs px-2 py-0.5 rounded ${ <span className={`text-xs px-2 py-0.5 rounded-lg border ${
challenge.difficulty === 'easy' ? 'bg-green-900/50 text-green-400' : challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
challenge.difficulty === 'medium' ? 'bg-yellow-900/50 text-yellow-400' : challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
'bg-red-900/50 text-red-400' 'bg-red-500/20 text-red-400 border-red-500/30'
}`}> }`}>
{challenge.difficulty === 'easy' ? 'Легко' : {challenge.difficulty === 'easy' ? 'Легко' :
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'} challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
</span> </span>
<span className="text-xs text-primary-400 font-medium"> <span className="text-xs text-neon-400 font-semibold">
+{challenge.points} +{challenge.points}
</span> </span>
{challenge.is_generated && ( {challenge.is_generated && (
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500 flex items-center gap-1">
<Sparkles className="w-3 h-3 inline" /> ИИ <Sparkles className="w-3 h-3" /> ИИ
</span> </span>
)} )}
</div> </div>
@@ -489,19 +505,17 @@ export function LobbyPage() {
<p className="text-xs text-gray-400 mt-1">{challenge.description}</p> <p className="text-xs text-gray-400 mt-1">{challenge.description}</p>
</div> </div>
{isOrganizer && ( {isOrganizer && (
<Button <button
variant="ghost"
size="sm"
onClick={() => handleDeleteChallenge(challenge.id, game.id)} onClick={() => handleDeleteChallenge(challenge.id, game.id)}
className="text-red-400 hover:text-red-300 shrink-0" className="p-1.5 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors shrink-0"
> >
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3 h-3" />
</Button> </button>
)} )}
</div> </div>
)) ))
) : ( ) : (
<p className="text-center text-gray-500 py-2 text-sm"> <p className="text-center text-gray-500 py-4 text-sm">
Нет заданий Нет заданий
</p> </p>
)} )}
@@ -509,8 +523,11 @@ export function LobbyPage() {
{/* Add challenge form */} {/* Add challenge form */}
{isOrganizer && game.status === 'approved' && ( {isOrganizer && game.status === 'approved' && (
addingChallengeToGameId === game.id ? ( addingChallengeToGameId === game.id ? (
<div className="mt-4 p-4 bg-gray-800 rounded-lg space-y-3 border border-gray-700"> <div className="mt-4 p-4 glass rounded-xl space-y-3 border border-neon-500/20">
<h4 className="font-medium text-white text-sm">Новое задание</h4> <h4 className="font-semibold text-white text-sm flex items-center gap-2">
<Plus className="w-4 h-4 text-neon-400" />
Новое задание
</h4>
<Input <Input
placeholder="Название задания" placeholder="Название задания"
value={newChallenge.title} value={newChallenge.title}
@@ -520,7 +537,7 @@ export function LobbyPage() {
placeholder="Описание (что нужно сделать)" placeholder="Описание (что нужно сделать)"
value={newChallenge.description} value={newChallenge.description}
onChange={(e) => setNewChallenge(prev => ({ ...prev, description: e.target.value }))} onChange={(e) => setNewChallenge(prev => ({ ...prev, description: e.target.value }))}
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm resize-none" className="input w-full resize-none"
rows={2} rows={2}
/> />
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
@@ -529,7 +546,7 @@ export function LobbyPage() {
<select <select
value={newChallenge.type} value={newChallenge.type}
onChange={(e) => setNewChallenge(prev => ({ ...prev, type: e.target.value }))} onChange={(e) => setNewChallenge(prev => ({ ...prev, type: e.target.value }))}
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm" className="input w-full"
> >
<option value="completion">Прохождение</option> <option value="completion">Прохождение</option>
<option value="no_death">Без смертей</option> <option value="no_death">Без смертей</option>
@@ -544,7 +561,7 @@ export function LobbyPage() {
<select <select
value={newChallenge.difficulty} value={newChallenge.difficulty}
onChange={(e) => setNewChallenge(prev => ({ ...prev, difficulty: e.target.value }))} onChange={(e) => setNewChallenge(prev => ({ ...prev, difficulty: e.target.value }))}
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm" className="input w-full"
> >
<option value="easy">Легко (20-40 очков)</option> <option value="easy">Легко (20-40 очков)</option>
<option value="medium">Средне (45-75 очков)</option> <option value="medium">Средне (45-75 очков)</option>
@@ -575,11 +592,11 @@ export function LobbyPage() {
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<label className="text-xs text-gray-400 mb-1 block">Тип доказательства</label> <label className="text-xs text-gray-400 mb-1 block">Тип пруфа</label>
<select <select
value={newChallenge.proof_type} value={newChallenge.proof_type}
onChange={(e) => setNewChallenge(prev => ({ ...prev, proof_type: e.target.value }))} onChange={(e) => setNewChallenge(prev => ({ ...prev, proof_type: e.target.value }))}
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm" className="input w-full"
> >
<option value="screenshot">Скриншот</option> <option value="screenshot">Скриншот</option>
<option value="video">Видео</option> <option value="video">Видео</option>
@@ -589,44 +606,42 @@ export function LobbyPage() {
<div> <div>
<label className="text-xs text-gray-400 mb-1 block">Подсказка</label> <label className="text-xs text-gray-400 mb-1 block">Подсказка</label>
<Input <Input
placeholder="Что должно быть на пруфе" placeholder="Что на пруфе"
value={newChallenge.proof_hint} value={newChallenge.proof_hint}
onChange={(e) => setNewChallenge(prev => ({ ...prev, proof_hint: e.target.value }))} onChange={(e) => setNewChallenge(prev => ({ ...prev, proof_hint: e.target.value }))}
/> />
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <NeonButton
size="sm" size="sm"
onClick={() => handleCreateChallenge(game.id)} onClick={() => handleCreateChallenge(game.id)}
isLoading={isCreatingChallenge} isLoading={isCreatingChallenge}
disabled={!newChallenge.title || !newChallenge.description} disabled={!newChallenge.title || !newChallenge.description}
icon={<Plus className="w-4 h-4" />}
> >
<Plus className="w-4 h-4 mr-1" />
Добавить Добавить
</Button> </NeonButton>
<Button <NeonButton
variant="ghost" variant="outline"
size="sm" size="sm"
onClick={() => setAddingChallengeToGameId(null)} onClick={() => setAddingChallengeToGameId(null)}
> >
Отмена Отмена
</Button> </NeonButton>
</div> </div>
</div> </div>
) : ( ) : (
<Button <button
variant="ghost"
size="sm"
onClick={() => { onClick={() => {
setAddingChallengeToGameId(game.id) setAddingChallengeToGameId(game.id)
setExpandedGameId(game.id) setExpandedGameId(game.id)
}} }}
className="w-full mt-2 border border-dashed border-gray-700 text-gray-400 hover:text-white hover:border-gray-600" className="w-full mt-2 p-3 rounded-lg border-2 border-dashed border-dark-600 text-gray-400 hover:text-neon-400 hover:border-neon-500/30 transition-all flex items-center justify-center gap-2"
> >
<Plus className="w-4 h-4 mr-1" /> <Plus className="w-4 h-4" />
Добавить задание вручную Добавить задание вручную
</Button> </button>
) )
)} )}
</> </>
@@ -639,14 +654,18 @@ export function LobbyPage() {
return ( return (
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
{/* Back button */} {/* Back button */}
<Link to={`/marathons/${id}`} className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors"> <Link
<ArrowLeft className="w-4 h-4" /> to={`/marathons/${id}`}
className="inline-flex items-center gap-2 text-gray-400 hover:text-neon-400 mb-6 transition-colors group"
>
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
К марафону К марафону
</Link> </Link>
<div className="flex justify-between items-center mb-8"> {/* Header */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
<div> <div>
<h1 className="text-2xl font-bold text-white">{marathon.title}</h1> <h1 className="text-2xl font-bold text-white mb-1">{marathon.title}</h1>
<p className="text-gray-400"> <p className="text-gray-400">
{isOrganizer {isOrganizer
? 'Настройка - Добавьте игры и сгенерируйте задания' ? 'Настройка - Добавьте игры и сгенерируйте задания'
@@ -655,130 +674,216 @@ export function LobbyPage() {
</div> </div>
{isOrganizer && ( {isOrganizer && (
<Button onClick={handleStartMarathon} isLoading={isStarting} disabled={approvedGames.length === 0}> <NeonButton
<Play className="w-4 h-4 mr-2" /> onClick={handleStartMarathon}
isLoading={isStarting}
disabled={approvedGames.length === 0}
icon={<Play className="w-4 h-4" />}
>
Запустить марафон Запустить марафон
</Button> </NeonButton>
)} )}
</div> </div>
{/* Stats - только для организаторов */} {/* Stats */}
{isOrganizer && ( {isOrganizer && (
<div className="grid grid-cols-2 gap-4 mb-8"> <div className="grid grid-cols-2 gap-4 mb-8">
<Card> <StatsCard
<CardContent className="text-center py-4"> label="Игр одобрено"
<div className="text-2xl font-bold text-white">{approvedGames.length}</div> value={approvedGames.length}
<div className="text-sm text-gray-400 flex items-center justify-center gap-1"> icon={<Gamepad2 className="w-6 h-6" />}
<Gamepad2 className="w-4 h-4" /> color="neon"
Игр одобрено />
</div> <StatsCard
</CardContent> label="Заданий"
</Card> value={totalChallenges}
icon={<Sparkles className="w-6 h-6" />}
<Card> color="purple"
<CardContent className="text-center py-4"> />
<div className="text-2xl font-bold text-white">{totalChallenges}</div>
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
<Sparkles className="w-4 h-4" />
Заданий
</div>
</CardContent>
</Card>
</div> </div>
)} )}
{/* Pending games for moderation (organizers only) */} {/* Pending games for moderation */}
{isOrganizer && pendingGames.length > 0 && ( {isOrganizer && pendingGames.length > 0 && (
<Card className="mb-8 border-yellow-900/50"> <GlassCard className="mb-8 border-yellow-500/30">
<CardHeader> <div className="flex items-center gap-3 mb-4">
<CardTitle className="flex items-center gap-2 text-yellow-400"> <div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center">
<Clock className="w-5 h-5" /> <Clock className="w-5 h-5 text-yellow-400" />
На модерации ({pendingGames.length}) </div>
</CardTitle> <div>
</CardHeader> <h3 className="font-semibold text-yellow-400">На модерации</h3>
<CardContent> <p className="text-sm text-gray-400">{pendingGames.length} игр ожидают</p>
</div>
</div>
<div className="space-y-3"> <div className="space-y-3">
{pendingGames.map((game) => renderGameCard(game, true))} {pendingGames.map((game) => renderGameCard(game, true))}
</div> </div>
</CardContent> </GlassCard>
</Card>
)} )}
{/* Generate challenges button */} {/* Generate challenges */}
{isOrganizer && approvedGames.length > 0 && !previewChallenges && ( {isOrganizer && approvedGames.length > 0 && !previewChallenges && (
<Card className="mb-8"> <GlassCard className="mb-8">
<CardContent> <div className="flex items-center justify-between gap-4 flex-wrap mb-4">
<div className="flex items-center justify-between"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
<Zap className="w-5 h-5 text-accent-400" />
</div>
<div> <div>
<h3 className="font-medium text-white">Генерация заданий</h3> <h3 className="font-semibold text-white">Генерация заданий</h3>
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">
Используйте ИИ для генерации заданий для одобренных игр без заданий {showGenerateSelection
? `Выбрано: ${selectedGamesForGeneration.length} из ${approvedGames.length}`
: 'Выберите игры для генерации'}
</p> </p>
</div> </div>
<Button onClick={handleGenerateChallenges} isLoading={isGenerating} variant="secondary">
<Sparkles className="w-4 h-4 mr-2" />
Сгенерировать
</Button>
</div>
{generateMessage && (
<p className="mt-3 text-sm text-primary-400">{generateMessage}</p>
)}
</CardContent>
</Card>
)}
{/* Challenge preview with editing */}
{previewChallenges && previewChallenges.length > 0 && (
<Card className="mb-8">
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<Eye className="w-5 h-5 text-primary-400" />
<CardTitle>Предпросмотр заданий ({previewChallenges.length})</CardTitle>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={handleCancelPreview} variant="ghost" size="sm"> {showGenerateSelection ? (
<X className="w-4 h-4 mr-1" /> <>
<NeonButton
onClick={() => {
setShowGenerateSelection(false)
clearGameSelection()
}}
variant="secondary"
size="sm"
>
Отмена Отмена
</Button> </NeonButton>
<Button onClick={handleSaveChallenges} isLoading={isSaving} size="sm"> <NeonButton
<Save className="w-4 h-4 mr-1" /> onClick={handleGenerateChallenges}
Сохранить все isLoading={isGenerating}
</Button> color="purple"
size="sm"
icon={<Sparkles className="w-4 h-4" />}
disabled={selectedGamesForGeneration.length === 0}
>
Сгенерировать ({selectedGamesForGeneration.length})
</NeonButton>
</>
) : (
<NeonButton
onClick={() => setShowGenerateSelection(true)}
variant="outline"
color="purple"
icon={<Sparkles className="w-4 h-4" />}
>
Выбрать игры
</NeonButton>
)}
</div> </div>
</CardHeader> </div>
<CardContent>
<div className="space-y-3 max-h-[60vh] overflow-y-auto"> {/* Game selection */}
{showGenerateSelection && (
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<button
onClick={selectAllGamesForGeneration}
className="text-neon-400 hover:text-neon-300 transition-colors"
>
Выбрать все
</button>
<button
onClick={clearGameSelection}
className="text-gray-400 hover:text-gray-300 transition-colors"
>
Снять выбор
</button>
</div>
<div className="grid gap-2">
{approvedGames.map((game) => {
const isSelected = selectedGamesForGeneration.includes(game.id)
const challengeCount = gameChallenges[game.id]?.length ?? game.challenges_count
return (
<button
key={game.id}
onClick={() => toggleGameSelection(game.id)}
className={`flex items-center gap-3 p-3 rounded-lg border transition-all text-left ${
isSelected
? 'bg-accent-500/20 border-accent-500/50'
: 'bg-dark-700/50 border-dark-600 hover:border-dark-500'
}`}
>
<div className={`w-5 h-5 rounded flex items-center justify-center border-2 transition-colors ${
isSelected
? 'bg-accent-500 border-accent-500'
: 'border-gray-500'
}`}>
{isSelected && <Check className="w-3 h-3 text-white" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate">{game.title}</p>
<p className="text-xs text-gray-400">
{challengeCount > 0 ? `${challengeCount} заданий` : 'Нет заданий'}
</p>
</div>
</button>
)
})}
</div>
</div>
)}
{generateMessage && (
<p className="mt-4 text-sm text-neon-400 p-3 bg-neon-500/10 rounded-lg border border-neon-500/20">
{generateMessage}
</p>
)}
</GlassCard>
)}
{/* Challenge preview */}
{previewChallenges && previewChallenges.length > 0 && (
<GlassCard className="mb-8 border-accent-500/30">
<div className="flex items-center justify-between mb-4 flex-wrap gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
<Eye className="w-5 h-5 text-accent-400" />
</div>
<div>
<h3 className="font-semibold text-white">Предпросмотр заданий</h3>
<p className="text-sm text-gray-400">{previewChallenges.length} заданий</p>
</div>
</div>
<div className="flex gap-2">
<NeonButton onClick={handleCancelPreview} variant="outline" size="sm" icon={<X className="w-4 h-4" />}>
Отмена
</NeonButton>
<NeonButton onClick={handleSaveChallenges} isLoading={isSaving} size="sm" icon={<Save className="w-4 h-4" />}>
Сохранить все
</NeonButton>
</div>
</div>
<div className="space-y-3 max-h-[60vh] overflow-y-auto custom-scrollbar">
{previewChallenges.map((challenge, index) => ( {previewChallenges.map((challenge, index) => (
<div <div
key={index} key={index}
className="p-4 bg-gray-900 rounded-lg border border-gray-800" className="p-4 bg-dark-700/50 rounded-xl border border-dark-600"
> >
{editingIndex === index ? ( {editingIndex === index ? (
// Edit mode
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2"> <span className="text-xs px-2 py-0.5 rounded-lg bg-dark-600 text-gray-400">
<span className="text-xs px-2 py-0.5 rounded bg-gray-800 text-gray-400">
{challenge.game_title} {challenge.game_title}
</span> </span>
</div>
<Input <Input
value={challenge.title} value={challenge.title}
onChange={(e) => handleUpdatePreviewChallenge(index, 'title', e.target.value)} onChange={(e) => handleUpdatePreviewChallenge(index, 'title', e.target.value)}
placeholder="Название" placeholder="Название"
className="bg-gray-800"
/> />
<textarea <textarea
value={challenge.description} value={challenge.description}
onChange={(e) => handleUpdatePreviewChallenge(index, 'description', e.target.value)} onChange={(e) => handleUpdatePreviewChallenge(index, 'description', e.target.value)}
placeholder="Описание" placeholder="Описание"
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm resize-none" className="input w-full resize-none"
rows={2} rows={2}
/> />
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
<select <select
value={challenge.difficulty} value={challenge.difficulty}
onChange={(e) => handleUpdatePreviewChallenge(index, 'difficulty', e.target.value)} onChange={(e) => handleUpdatePreviewChallenge(index, 'difficulty', e.target.value)}
className="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm" className="input"
> >
<option value="easy">Легко</option> <option value="easy">Легко</option>
<option value="medium">Средне</option> <option value="medium">Средне</option>
@@ -789,12 +894,11 @@ export function LobbyPage() {
value={challenge.points} value={challenge.points}
onChange={(e) => handleUpdatePreviewChallenge(index, 'points', parseInt(e.target.value) || 0)} onChange={(e) => handleUpdatePreviewChallenge(index, 'points', parseInt(e.target.value) || 0)}
placeholder="Очки" placeholder="Очки"
className="bg-gray-800"
/> />
<select <select
value={challenge.proof_type} value={challenge.proof_type}
onChange={(e) => handleUpdatePreviewChallenge(index, 'proof_type', e.target.value)} onChange={(e) => handleUpdatePreviewChallenge(index, 'proof_type', e.target.value)}
className="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm" className="input"
> >
<option value="screenshot">Скриншот</option> <option value="screenshot">Скриншот</option>
<option value="video">Видео</option> <option value="video">Видео</option>
@@ -804,42 +908,39 @@ export function LobbyPage() {
<Input <Input
value={challenge.proof_hint || ''} value={challenge.proof_hint || ''}
onChange={(e) => handleUpdatePreviewChallenge(index, 'proof_hint', e.target.value)} onChange={(e) => handleUpdatePreviewChallenge(index, 'proof_hint', e.target.value)}
placeholder="Подсказка для подтверждения" placeholder="Подсказка для пруфа"
className="bg-gray-800"
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<Button size="sm" onClick={() => setEditingIndex(null)}> <NeonButton size="sm" onClick={() => setEditingIndex(null)} icon={<Check className="w-4 h-4" />}>
<Check className="w-4 h-4 mr-1" />
Готово Готово
</Button> </NeonButton>
<Button <NeonButton
variant="ghost" variant="outline"
size="sm" size="sm"
onClick={() => handleRemovePreviewChallenge(index)} onClick={() => handleRemovePreviewChallenge(index)}
className="text-red-400 hover:text-red-300" className="text-red-400 border-red-500/30 hover:bg-red-500/10"
icon={<Trash2 className="w-4 h-4" />}
> >
<Trash2 className="w-4 h-4 mr-1" />
Удалить Удалить
</Button> </NeonButton>
</div> </div>
</div> </div>
) : ( ) : (
// View mode
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="text-xs px-2 py-0.5 rounded bg-gray-800 text-gray-400"> <span className="text-xs px-2 py-0.5 rounded-lg bg-dark-600 text-gray-400">
{challenge.game_title} {challenge.game_title}
</span> </span>
<span className={`text-xs px-2 py-0.5 rounded ${ <span className={`text-xs px-2 py-0.5 rounded-lg border ${
challenge.difficulty === 'easy' ? 'bg-green-900/50 text-green-400' : challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
challenge.difficulty === 'medium' ? 'bg-yellow-900/50 text-yellow-400' : challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
'bg-red-900/50 text-red-400' 'bg-red-500/20 text-red-400 border-red-500/30'
}`}> }`}>
{challenge.difficulty === 'easy' ? 'Легко' : {challenge.difficulty === 'easy' ? 'Легко' :
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'} challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
</span> </span>
<span className="text-xs text-primary-400 font-medium"> <span className="text-xs text-neon-400 font-semibold">
+{challenge.points} очков +{challenge.points} очков
</span> </span>
</div> </div>
@@ -847,53 +948,55 @@ export function LobbyPage() {
<p className="text-sm text-gray-400">{challenge.description}</p> <p className="text-sm text-gray-400">{challenge.description}</p>
{challenge.proof_hint && ( {challenge.proof_hint && (
<p className="text-xs text-gray-500 mt-2"> <p className="text-xs text-gray-500 mt-2">
Подтверждение: {challenge.proof_hint} Пруф: {challenge.proof_hint}
</p> </p>
)} )}
</div> </div>
<div className="flex gap-1 shrink-0"> <div className="flex gap-1 shrink-0">
<Button <button
variant="ghost"
size="sm"
onClick={() => setEditingIndex(index)} onClick={() => setEditingIndex(index)}
className="text-gray-400 hover:text-white" className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
> >
<Edit2 className="w-4 h-4" /> <Edit2 className="w-4 h-4" />
</Button> </button>
<Button <button
variant="ghost"
size="sm"
onClick={() => handleRemovePreviewChallenge(index)} onClick={() => handleRemovePreviewChallenge(index)}
className="text-red-400 hover:text-red-300" className="p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</Button> </button>
</div> </div>
</div> </div>
)} )}
</div> </div>
))} ))}
</div> </div>
</CardContent> </GlassCard>
</Card>
)} )}
{/* Games list */} {/* Games list */}
<Card> <GlassCard>
<CardHeader className="flex flex-row items-center justify-between"> <div className="flex items-center justify-between mb-6">
<CardTitle>Игры</CardTitle> <div className="flex items-center gap-3">
{/* Показываем кнопку если: all_participants ИЛИ (organizer_only И isOrganizer) */} <div className="w-10 h-10 rounded-xl bg-neon-500/20 flex items-center justify-center">
<Gamepad2 className="w-5 h-5 text-neon-400" />
</div>
<h3 className="font-semibold text-white">Игры</h3>
</div>
{(marathon.game_proposal_mode === 'all_participants' || isOrganizer) && ( {(marathon.game_proposal_mode === 'all_participants' || isOrganizer) && (
<Button size="sm" onClick={() => setShowAddGame(!showAddGame)}> <NeonButton
<Plus className="w-4 h-4 mr-1" /> size="sm"
{isOrganizer ? 'Добавить игру' : 'Предложить игру'} onClick={() => setShowAddGame(!showAddGame)}
</Button> icon={<Plus className="w-4 h-4" />}
>
{isOrganizer ? 'Добавить' : 'Предложить'}
</NeonButton>
)} )}
</CardHeader> </div>
<CardContent>
{/* Add game form */} {/* Add game form */}
{showAddGame && ( {showAddGame && (
<div className="mb-6 p-4 bg-gray-900 rounded-lg space-y-3"> <div className="mb-6 p-4 glass rounded-xl space-y-3 border border-neon-500/20">
<Input <Input
placeholder="Название игры" placeholder="Название игры"
value={gameTitle} value={gameTitle}
@@ -910,16 +1013,20 @@ export function LobbyPage() {
onChange={(e) => setGameGenre(e.target.value)} onChange={(e) => setGameGenre(e.target.value)}
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={handleAddGame} isLoading={isAddingGame} disabled={!gameTitle || !gameUrl}> <NeonButton
onClick={handleAddGame}
isLoading={isAddingGame}
disabled={!gameTitle || !gameUrl}
>
{isOrganizer ? 'Добавить' : 'Предложить'} {isOrganizer ? 'Добавить' : 'Предложить'}
</Button> </NeonButton>
<Button variant="ghost" onClick={() => setShowAddGame(false)}> <NeonButton variant="outline" onClick={() => setShowAddGame(false)}>
Отмена Отмена
</Button> </NeonButton>
</div> </div>
{!isOrganizer && ( {!isOrganizer && (
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
Ваша игра будет отправлена на модерацию организаторам Игра будет отправлена на модерацию организаторам
</p> </p>
)} )}
</div> </div>
@@ -927,24 +1034,26 @@ export function LobbyPage() {
{/* Games */} {/* Games */}
{(() => { {(() => {
// Организаторы: показываем только одобренные (pending в секции модерации)
// Участники: показываем одобренные + свои pending
const visibleGames = isOrganizer const visibleGames = isOrganizer
? games.filter(g => g.status !== 'pending') ? games.filter(g => g.status !== 'pending')
: games.filter(g => g.status === 'approved' || (g.status === 'pending' && g.proposed_by?.id === user?.id)) : games.filter(g => g.status === 'approved' || (g.status === 'pending' && g.proposed_by?.id === user?.id))
return visibleGames.length === 0 ? ( return visibleGames.length === 0 ? (
<p className="text-center text-gray-400 py-8"> <div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-700 flex items-center justify-center">
<Gamepad2 className="w-8 h-8 text-gray-600" />
</div>
<p className="text-gray-400">
{isOrganizer ? 'Пока нет игр. Добавьте игры, чтобы начать!' : 'Пока нет одобренных игр. Предложите свою!'} {isOrganizer ? 'Пока нет игр. Добавьте игры, чтобы начать!' : 'Пока нет одобренных игр. Предложите свою!'}
</p> </p>
</div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{visibleGames.map((game) => renderGameCard(game, false))} {visibleGames.map((game) => renderGameCard(game, false))}
</div> </div>
) )
})()} })()}
</CardContent> </GlassCard>
</Card>
</div> </div>
) )
} }

View File

@@ -5,7 +5,8 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { marathonsApi } from '@/api' import { marathonsApi } from '@/api'
import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui' import { NeonButton, Input, GlassCard } from '@/components/ui'
import { Gamepad2, LogIn, AlertCircle, Trophy, Users, Zap, Target } from 'lucide-react'
const loginSchema = z.object({ const loginSchema = z.object({
login: z.string().min(3, 'Логин должен быть не менее 3 символов'), login: z.string().min(3, 'Логин должен быть не менее 3 символов'),
@@ -51,17 +52,79 @@ export function LoginPage() {
} }
} }
const features = [
{ icon: <Trophy className="w-5 h-5" />, text: 'Соревнуйтесь с друзьями' },
{ icon: <Target className="w-5 h-5" />, text: 'Выполняйте челленджи' },
{ icon: <Zap className="w-5 h-5" />, text: 'Зарабатывайте очки' },
{ icon: <Users className="w-5 h-5" />, text: 'Создавайте марафоны' },
]
return ( return (
<div className="max-w-md mx-auto"> <div className="min-h-[80vh] flex items-center justify-center px-4 -mt-8">
<Card> {/* Background effects */}
<CardHeader> <div className="fixed inset-0 overflow-hidden pointer-events-none">
<CardTitle className="text-center">Вход</CardTitle> <div className="absolute top-1/4 -left-32 w-96 h-96 bg-neon-500/10 rounded-full blur-[100px]" />
</CardHeader> <div className="absolute bottom-1/4 -right-32 w-96 h-96 bg-accent-500/10 rounded-full blur-[100px]" />
<CardContent> </div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Bento Grid */}
<div className="relative w-full max-w-4xl">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-scale-in">
{/* Branding Block (left) */}
<GlassCard className="p-8 flex flex-col justify-center relative overflow-hidden" variant="neon">
{/* Background decoration */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-20 -left-20 w-48 h-48 bg-neon-500/20 rounded-full blur-[60px]" />
<div className="absolute -bottom-20 -right-20 w-48 h-48 bg-accent-500/20 rounded-full blur-[60px]" />
</div>
<div className="relative">
{/* Logo */}
<div className="flex justify-center md:justify-start mb-6">
<div className="w-20 h-20 rounded-2xl bg-neon-500/10 border border-neon-500/30 flex items-center justify-center shadow-[0_0_24px_rgba(34,211,238,0.25)]">
<Gamepad2 className="w-10 h-10 text-neon-500" />
</div>
</div>
{/* Title */}
<h1 className="text-3xl md:text-4xl font-bold text-white mb-2 text-center md:text-left">
Game Marathon
</h1>
<p className="text-gray-400 mb-8 text-center md:text-left">
Платформа для игровых соревнований
</p>
{/* Features */}
<div className="grid grid-cols-2 gap-3">
{features.map((feature, index) => (
<div
key={index}
className="flex items-center gap-2 p-3 rounded-xl bg-dark-700/50 border border-dark-600"
>
<div className="w-8 h-8 rounded-lg bg-neon-500/20 flex items-center justify-center text-neon-400">
{feature.icon}
</div>
<span className="text-sm text-gray-300">{feature.text}</span>
</div>
))}
</div>
</div>
</GlassCard>
{/* Form Block (right) */}
<GlassCard className="p-8">
{/* Header */}
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-white mb-2">Добро пожаловать!</h2>
<p className="text-gray-400">Войдите, чтобы продолжить</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
{(submitError || error) && ( {(submitError || error) && (
<div className="p-3 bg-red-500/20 border border-red-500 rounded-lg text-red-500 text-sm"> <div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm animate-shake">
{submitError || error} <AlertCircle className="w-5 h-5 flex-shrink-0" />
<span>{submitError || error}</span>
</div> </div>
)} )}
@@ -69,6 +132,7 @@ export function LoginPage() {
label="Логин" label="Логин"
placeholder="Введите логин" placeholder="Введите логин"
error={errors.login?.message} error={errors.login?.message}
autoComplete="username"
{...register('login')} {...register('login')}
/> />
@@ -77,22 +141,40 @@ export function LoginPage() {
type="password" type="password"
placeholder="Введите пароль" placeholder="Введите пароль"
error={errors.password?.message} error={errors.password?.message}
autoComplete="current-password"
{...register('password')} {...register('password')}
/> />
<Button type="submit" className="w-full" isLoading={isLoading}> <NeonButton
type="submit"
className="w-full"
size="lg"
isLoading={isLoading}
icon={<LogIn className="w-5 h-5" />}
>
Войти Войти
</Button> </NeonButton>
</form>
<p className="text-center text-gray-400 text-sm"> {/* Footer */}
<div className="mt-6 pt-6 border-t border-dark-600 text-center">
<p className="text-gray-400 text-sm">
Нет аккаунта?{' '} Нет аккаунта?{' '}
<Link to="/register" className="link"> <Link
to="/register"
className="text-neon-400 hover:text-neon-300 transition-colors font-medium"
>
Зарегистрироваться Зарегистрироваться
</Link> </Link>
</p> </p>
</form> </div>
</CardContent> </GlassCard>
</Card> </div>
{/* Decorative elements */}
<div className="absolute -top-4 -right-4 w-24 h-24 border border-neon-500/20 rounded-2xl -z-10 hidden md:block" />
<div className="absolute -bottom-4 -left-4 w-32 h-32 border border-accent-500/20 rounded-2xl -z-10 hidden md:block" />
</div>
</div> </div>
) )
} }

View File

@@ -2,15 +2,20 @@ import { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom' import { useParams, useNavigate, Link } from 'react-router-dom'
import { marathonsApi, eventsApi, challengesApi } from '@/api' import { marathonsApi, eventsApi, challengesApi } from '@/api'
import type { Marathon, ActiveEvent, Challenge } from '@/types' import type { Marathon, ActiveEvent, Challenge } from '@/types'
import { Button, Card, CardContent } from '@/components/ui' import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm' import { useConfirm } from '@/store/confirm'
import { EventBanner } from '@/components/EventBanner' import { EventBanner } from '@/components/EventBanner'
import { EventControl } from '@/components/EventControl' import { EventControl } from '@/components/EventControl'
import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed' import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed'
import { Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2, Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag } from 'lucide-react' import {
Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2,
Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag,
Star, Target, TrendingDown, ChevronDown, ChevronUp, Link2, Sparkles
} from 'lucide-react'
import { format } from 'date-fns' import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
export function MarathonPage() { export function MarathonPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
@@ -27,6 +32,8 @@ export function MarathonPage() {
const [isJoining, setIsJoining] = useState(false) const [isJoining, setIsJoining] = useState(false)
const [isFinishing, setIsFinishing] = useState(false) const [isFinishing, setIsFinishing] = useState(false)
const [showEventControl, setShowEventControl] = useState(false) const [showEventControl, setShowEventControl] = useState(false)
const [showChallenges, setShowChallenges] = useState(false)
const [expandedGameId, setExpandedGameId] = useState<number | null>(null)
const activityFeedRef = useRef<ActivityFeedRef>(null) const activityFeedRef = useRef<ActivityFeedRef>(null)
useEffect(() => { useEffect(() => {
@@ -39,13 +46,11 @@ export function MarathonPage() {
const data = await marathonsApi.get(parseInt(id)) const data = await marathonsApi.get(parseInt(id))
setMarathon(data) setMarathon(data)
// Load event data if marathon is active
if (data.status === 'active' && data.my_participation) { if (data.status === 'active' && data.my_participation) {
const eventData = await eventsApi.getActive(parseInt(id)) const eventData = await eventsApi.getActive(parseInt(id))
setActiveEvent(eventData) setActiveEvent(eventData)
// Load challenges for event control if organizer // Load challenges for all participants
if (data.my_participation.role === 'organizer') {
try { try {
const challengesData = await challengesApi.list(parseInt(id)) const challengesData = await challengesApi.list(parseInt(id))
setChallenges(challengesData) setChallenges(challengesData)
@@ -53,7 +58,6 @@ export function MarathonPage() {
// Ignore if no challenges // Ignore if no challenges
} }
} }
}
} catch (error) { } catch (error) {
console.error('Failed to load marathon:', error) console.error('Failed to load marathon:', error)
navigate('/marathons') navigate('/marathons')
@@ -67,7 +71,6 @@ export function MarathonPage() {
try { try {
const eventData = await eventsApi.getActive(parseInt(id)) const eventData = await eventsApi.getActive(parseInt(id))
setActiveEvent(eventData) setActiveEvent(eventData)
// Refresh activity feed when event changes
activityFeedRef.current?.refresh() activityFeedRef.current?.refresh()
} catch (error) { } catch (error) {
console.error('Failed to refresh event:', error) console.error('Failed to refresh event:', error)
@@ -153,8 +156,9 @@ export function MarathonPage() {
if (isLoading || !marathon) { if (isLoading || !marathon) {
return ( return (
<div className="flex justify-center py-12"> <div className="flex flex-col items-center justify-center py-24">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" /> <Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
<p className="text-gray-400">Загрузка марафона...</p>
</div> </div>
) )
} }
@@ -164,265 +168,358 @@ export function MarathonPage() {
const isCreator = marathon.creator.id === user?.id const isCreator = marathon.creator.id === user?.id
const canDelete = isCreator || user?.role === 'admin' const canDelete = isCreator || user?.role === 'admin'
const statusConfig = {
active: { color: 'text-neon-400', bg: 'bg-neon-500/20', border: 'border-neon-500/30', label: 'Активен' },
preparing: { color: 'text-yellow-400', bg: 'bg-yellow-500/20', border: 'border-yellow-500/30', label: 'Подготовка' },
finished: { color: 'text-gray-400', bg: 'bg-gray-500/20', border: 'border-gray-500/30', label: 'Завершён' },
}
const status = statusConfig[marathon.status as keyof typeof statusConfig] || statusConfig.finished
return ( return (
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
{/* Back button */} {/* Back button */}
<Link to="/marathons" className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors"> <Link
<ArrowLeft className="w-4 h-4" /> to="/marathons"
className="inline-flex items-center gap-2 text-gray-400 hover:text-neon-400 mb-6 transition-colors group"
>
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
К списку марафонов К списку марафонов
</Link> </Link>
<div className="flex flex-col lg:flex-row gap-6"> {/* Hero Banner */}
{/* Main content */} <div className="relative rounded-2xl overflow-hidden mb-8">
<div className="flex-1 min-w-0"> {/* Background */}
{/* Header */} <div className="absolute inset-0 bg-gradient-to-br from-neon-500/10 via-dark-800 to-accent-500/10" />
<div className="flex justify-between items-start mb-8"> <div className="absolute inset-0 bg-[linear-gradient(rgba(34,211,238,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(34,211,238,0.02)_1px,transparent_1px)] bg-[size:50px_50px]" />
<div>
<div className="flex items-center gap-3 mb-2"> <div className="relative p-8">
<h1 className="text-3xl font-bold text-white">{marathon.title}</h1> <div className="flex flex-col md:flex-row justify-between items-start gap-6">
<span className={`flex items-center gap-1 text-xs px-2 py-1 rounded ${ {/* Title & Description */}
<div className="flex-1">
<div className="flex flex-wrap items-center gap-3 mb-3">
<h1 className="text-3xl md:text-4xl font-bold text-white">{marathon.title}</h1>
<span className={`flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full border ${
marathon.is_public marathon.is_public
? 'bg-green-900/50 text-green-400' ? 'bg-green-500/20 text-green-400 border-green-500/30'
: 'bg-gray-700 text-gray-300' : 'bg-dark-700 text-gray-300 border-dark-600'
}`}> }`}>
{marathon.is_public ? ( {marathon.is_public ? <Globe className="w-3 h-3" /> : <Lock className="w-3 h-3" />}
<><Globe className="w-3 h-3" /> Открытый</> {marathon.is_public ? 'Открытый' : 'Закрытый'}
) : ( </span>
<><Lock className="w-3 h-3" /> Закрытый</> <span className={`flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full border ${status.bg} ${status.color} ${status.border}`}>
)} <span className={`w-2 h-2 rounded-full ${marathon.status === 'active' ? 'bg-neon-500 animate-pulse' : marathon.status === 'preparing' ? 'bg-yellow-500' : 'bg-gray-500'}`} />
{status.label}
</span> </span>
</div> </div>
{marathon.description && ( {marathon.description && (
<p className="text-gray-400">{marathon.description}</p> <p className="text-gray-400 max-w-2xl">{marathon.description}</p>
)} )}
</div> </div>
<div className="flex gap-2 flex-wrap justify-end"> {/* Action Buttons */}
{/* Кнопка присоединиться для открытых марафонов */} <div className="flex flex-wrap gap-2">
{marathon.is_public && !isParticipant && marathon.status !== 'finished' && ( {marathon.is_public && !isParticipant && marathon.status !== 'finished' && (
<Button onClick={handleJoinPublic} isLoading={isJoining}> <NeonButton onClick={handleJoinPublic} isLoading={isJoining} icon={<UserPlus className="w-4 h-4" />}>
<UserPlus className="w-4 h-4 mr-2" />
Присоединиться Присоединиться
</Button> </NeonButton>
)} )}
{/* Настройка для организаторов */}
{marathon.status === 'preparing' && isOrganizer && ( {marathon.status === 'preparing' && isOrganizer && (
<Link to={`/marathons/${id}/lobby`}> <Link to={`/marathons/${id}/lobby`}>
<Button variant="secondary"> <NeonButton variant="secondary" icon={<Settings className="w-4 h-4" />}>
<Settings className="w-4 h-4 mr-2" />
Настройка Настройка
</Button> </NeonButton>
</Link> </Link>
)} )}
{/* Предложить игру для участников (не организаторов) если разрешено */}
{marathon.status === 'preparing' && isParticipant && !isOrganizer && marathon.game_proposal_mode === 'all_participants' && ( {marathon.status === 'preparing' && isParticipant && !isOrganizer && marathon.game_proposal_mode === 'all_participants' && (
<Link to={`/marathons/${id}/lobby`}> <Link to={`/marathons/${id}/lobby`}>
<Button variant="secondary"> <NeonButton variant="secondary" icon={<Gamepad2 className="w-4 h-4" />}>
<Gamepad2 className="w-4 h-4 mr-2" />
Предложить игру Предложить игру
</Button> </NeonButton>
</Link> </Link>
)} )}
{marathon.status === 'active' && isParticipant && ( {marathon.status === 'active' && isParticipant && (
<Link to={`/marathons/${id}/play`}> <Link to={`/marathons/${id}/play`}>
<Button> <NeonButton icon={<Play className="w-4 h-4" />}>
<Play className="w-4 h-4 mr-2" />
Играть Играть
</Button> </NeonButton>
</Link> </Link>
)} )}
<Link to={`/marathons/${id}/leaderboard`}> <Link to={`/marathons/${id}/leaderboard`}>
<Button variant="secondary"> <NeonButton variant="outline" icon={<Trophy className="w-4 h-4" />}>
<Trophy className="w-4 h-4 mr-2" />
Рейтинг Рейтинг
</Button> </NeonButton>
</Link> </Link>
{marathon.status === 'active' && isOrganizer && ( {marathon.status === 'active' && isOrganizer && (
<Button <button
variant="secondary"
onClick={handleFinish} onClick={handleFinish}
isLoading={isFinishing} disabled={isFinishing}
className="text-yellow-400 hover:text-yellow-300 hover:bg-yellow-900/20" className="inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-semibold transition-all duration-200 border border-yellow-500/30 bg-dark-600 text-yellow-400 hover:bg-yellow-500/10 hover:border-yellow-500/50 disabled:opacity-50 disabled:cursor-not-allowed"
> >
<Flag className="w-4 h-4 mr-2" /> {isFinishing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Flag className="w-4 h-4" />}
Завершить Завершить
</Button> </button>
)} )}
{canDelete && ( {canDelete && (
<Button <NeonButton
variant="ghost" variant="ghost"
onClick={handleDelete} onClick={handleDelete}
isLoading={isDeleting} isLoading={isDeleting}
className="text-red-400 hover:text-red-300 hover:bg-red-900/20" className="!text-red-400 hover:!bg-red-500/10"
> icon={<Trash2 className="w-4 h-4" />}
<Trash2 className="w-4 h-4" /> />
</Button>
)} )}
</div> </div>
</div> </div>
</div>
</div>
<div className="flex flex-col lg:flex-row gap-6">
{/* Main content */}
<div className="flex-1 min-w-0 space-y-6">
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8"> <div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<Card> <StatsCard
<CardContent className="text-center py-4"> label="Участников"
<div className="text-2xl font-bold text-white">{marathon.participants_count}</div> value={marathon.participants_count}
<div className="text-sm text-gray-400 flex items-center justify-center gap-1"> icon={<Users className="w-5 h-5" />}
<Users className="w-4 h-4" /> color="neon"
Участников />
</div> <StatsCard
</CardContent> label="Игр"
</Card> value={marathon.games_count}
icon={<Gamepad2 className="w-5 h-5" />}
<Card> color="purple"
<CardContent className="text-center py-4"> />
<div className="text-2xl font-bold text-white">{marathon.games_count}</div> <StatsCard
<div className="text-sm text-gray-400">Игр</div> label="Начало"
</CardContent> value={marathon.start_date ? format(new Date(marathon.start_date), 'd MMM', { locale: ru }) : '-'}
</Card> icon={<Calendar className="w-5 h-5" />}
color="default"
<Card> />
<CardContent className="text-center py-4"> <StatsCard
<div className="text-2xl font-bold text-white"> label="Конец"
{marathon.start_date ? format(new Date(marathon.start_date), 'd MMM') : '-'} value={marathon.end_date ? format(new Date(marathon.end_date), 'd MMM', { locale: ru }) : '-'}
</div> icon={<CalendarCheck className="w-5 h-5" />}
<div className="text-sm text-gray-400 flex items-center justify-center gap-1"> color="default"
<Calendar className="w-4 h-4" /> />
Начало <StatsCard
</div> label="Статус"
</CardContent> value={status.label}
</Card> icon={<Target className="w-5 h-5" />}
color={marathon.status === 'active' ? 'neon' : marathon.status === 'preparing' ? 'default' : 'default'}
<Card> />
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">
{marathon.end_date ? format(new Date(marathon.end_date), 'd MMM') : '-'}
</div>
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
<CalendarCheck className="w-4 h-4" />
Конец
</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-4">
<div className={`text-2xl font-bold ${
marathon.status === 'active' ? 'text-green-500' :
marathon.status === 'preparing' ? 'text-yellow-500' : 'text-gray-400'
}`}>
{marathon.status === 'active' ? 'Активен' : marathon.status === 'preparing' ? 'Подготовка' : 'Завершён'}
</div>
<div className="text-sm text-gray-400">Статус</div>
</CardContent>
</Card>
</div> </div>
{/* Active event banner */} {/* Active event banner */}
{marathon.status === 'active' && activeEvent?.event && ( {marathon.status === 'active' && activeEvent?.event && (
<div className="mb-8">
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} /> <EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
</div>
)} )}
{/* Event control for organizers */} {/* Event control for organizers */}
{marathon.status === 'active' && isOrganizer && ( {marathon.status === 'active' && isOrganizer && (
<Card className="mb-8"> <GlassCard>
<CardContent> <button
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium text-white flex items-center gap-2">
<Zap className="w-5 h-5 text-yellow-500" />
Управление событиями
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowEventControl(!showEventControl)} onClick={() => setShowEventControl(!showEventControl)}
className="w-full flex items-center justify-between"
> >
{showEventControl ? 'Скрыть' : 'Показать'} <div className="flex items-center gap-3">
</Button> <div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center">
<Zap className="w-5 h-5 text-yellow-400" />
</div> </div>
<div className="text-left">
<h3 className="font-semibold text-white">Управление событиями</h3>
<p className="text-sm text-gray-400">Активируйте бонусы для участников</p>
</div>
</div>
{showEventControl ? (
<ChevronUp className="w-5 h-5 text-gray-400" />
) : (
<ChevronDown className="w-5 h-5 text-gray-400" />
)}
</button>
{showEventControl && activeEvent && ( {showEventControl && activeEvent && (
<div className="mt-6 pt-6 border-t border-dark-600">
<EventControl <EventControl
marathonId={marathon.id} marathonId={marathon.id}
activeEvent={activeEvent} activeEvent={activeEvent}
challenges={challenges} challenges={challenges}
onEventChange={refreshEvent} onEventChange={refreshEvent}
/> />
</div>
)} )}
</CardContent> </GlassCard>
</Card>
)} )}
{/* Invite link */} {/* Invite link */}
{marathon.status !== 'finished' && ( {marathon.status !== 'finished' && (
<Card className="mb-8"> <GlassCard>
<CardContent> <div className="flex items-center gap-3 mb-4">
<h3 className="font-medium text-white mb-3">Ссылка для приглашения</h3> <div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
<Link2 className="w-5 h-5 text-accent-400" />
</div>
<div>
<h3 className="font-semibold text-white">Пригласить друзей</h3>
<p className="text-sm text-gray-400">Поделитесь ссылкой</p>
</div>
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<code className="flex-1 px-4 py-2 bg-gray-900 rounded-lg text-primary-400 font-mono text-sm overflow-hidden text-ellipsis"> <code className="flex-1 px-4 py-3 bg-dark-700 rounded-xl text-neon-400 font-mono text-sm overflow-hidden text-ellipsis border border-dark-600">
{getInviteLink()} {getInviteLink()}
</code> </code>
<Button variant="secondary" onClick={copyInviteLink}> <NeonButton variant="secondary" onClick={copyInviteLink} icon={copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}>
{copied ? ( {copied ? 'Скопировано!' : 'Копировать'}
<> </NeonButton>
<Check className="w-4 h-4 mr-2" />
Скопировано!
</>
) : (
<>
<Copy className="w-4 h-4 mr-2" />
Копировать
</>
)}
</Button>
</div> </div>
<p className="text-sm text-gray-500 mt-2"> </GlassCard>
Поделитесь этой ссылкой с друзьями, чтобы они могли присоединиться к марафону
</p>
</CardContent>
</Card>
)} )}
{/* My stats */} {/* My stats */}
{marathon.my_participation && ( {marathon.my_participation && (
<Card> <GlassCard variant="neon">
<CardContent> <h3 className="font-semibold text-white mb-4 flex items-center gap-2">
<h3 className="font-medium text-white mb-4">Ваша статистика</h3> <Star className="w-5 h-5 text-yellow-500" />
<div className="grid grid-cols-3 gap-4 text-center"> Ваша статистика
<div> </h3>
<div className="text-2xl font-bold text-primary-500"> <div className="grid grid-cols-3 gap-4">
<div className="text-center p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="text-3xl font-bold text-neon-400">
{marathon.my_participation.total_points} {marathon.my_participation.total_points}
</div> </div>
<div className="text-sm text-gray-400">Очков</div> <div className="text-sm text-gray-400 mt-1">Очков</div>
</div> </div>
<div> <div className="text-center p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="text-2xl font-bold text-yellow-500"> <div className="text-3xl font-bold text-yellow-400 flex items-center justify-center gap-1">
{marathon.my_participation.current_streak} {marathon.my_participation.current_streak}
{marathon.my_participation.current_streak > 0 && (
<span className="text-lg">🔥</span>
)}
</div> </div>
<div className="text-sm text-gray-400">Серия</div> <div className="text-sm text-gray-400 mt-1">Серия</div>
</div> </div>
<div> <div className="text-center p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="text-2xl font-bold text-gray-400"> <div className="text-3xl font-bold text-gray-400 flex items-center justify-center gap-1">
{marathon.my_participation.drop_count} {marathon.my_participation.drop_count}
<TrendingDown className="w-5 h-5" />
</div> </div>
<div className="text-sm text-gray-400">Пропусков</div> <div className="text-sm text-gray-400 mt-1">Пропусков</div>
</div> </div>
</div> </div>
</CardContent> </GlassCard>
</Card> )}
{/* All challenges viewer */}
{marathon.status === 'active' && isParticipant && challenges.length > 0 && (
<GlassCard>
<button
onClick={() => setShowChallenges(!showChallenges)}
className="w-full flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
<Sparkles className="w-5 h-5 text-accent-400" />
</div>
<div className="text-left">
<h3 className="font-semibold text-white">Все задания</h3>
<p className="text-sm text-gray-400">{challenges.length} заданий для {new Set(challenges.map(c => c.game.id)).size} игр</p>
</div>
</div>
{showChallenges ? (
<ChevronUp className="w-5 h-5 text-gray-400" />
) : (
<ChevronDown className="w-5 h-5 text-gray-400" />
)}
</button>
{showChallenges && (
<div className="mt-6 pt-6 border-t border-dark-600 space-y-4">
{/* Group challenges by game */}
{Array.from(new Set(challenges.map(c => c.game.id))).map(gameId => {
const gameChallenges = challenges.filter(c => c.game.id === gameId)
const game = gameChallenges[0]?.game
if (!game) return null
const isExpanded = expandedGameId === gameId
return (
<div key={gameId} className="glass rounded-xl overflow-hidden border border-dark-600">
<button
onClick={() => setExpandedGameId(isExpanded ? null : gameId)}
className="w-full flex items-center justify-between p-4 hover:bg-dark-700/50 transition-colors"
>
<div className="flex items-center gap-3">
<span className="text-gray-400">
{isExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</span>
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-neon-500/20 to-accent-500/20 border border-neon-500/20 flex items-center justify-center">
<Gamepad2 className="w-4 h-4 text-neon-400" />
</div>
<div className="text-left">
<h4 className="font-semibold text-white">{game.title}</h4>
<span className="text-xs text-gray-400">{gameChallenges.length} заданий</span>
</div>
</div>
</button>
{isExpanded && (
<div className="border-t border-dark-600 p-4 space-y-2 bg-dark-800/30">
{gameChallenges.map(challenge => (
<div
key={challenge.id}
className="p-3 bg-dark-700/50 rounded-lg border border-dark-600"
>
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
'bg-red-500/20 text-red-400 border-red-500/30'
}`}>
{challenge.difficulty === 'easy' ? 'Легко' :
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
</span>
<span className="text-xs text-neon-400 font-semibold">
+{challenge.points}
</span>
<span className="text-xs text-gray-500">
{challenge.type === 'completion' ? 'Прохождение' :
challenge.type === 'no_death' ? 'Без смертей' :
challenge.type === 'speedrun' ? 'Спидран' :
challenge.type === 'collection' ? 'Коллекция' :
challenge.type === 'achievement' ? 'Достижение' : 'Челлендж-ран'}
</span>
</div>
<h5 className="text-sm font-medium text-white">{challenge.title}</h5>
<p className="text-xs text-gray-400 mt-1">{challenge.description}</p>
{challenge.proof_hint && (
<p className="text-xs text-gray-500 mt-2 flex items-center gap-1">
<Target className="w-3 h-3" />
Пруф: {challenge.proof_hint}
</p>
)}
</div>
))}
</div>
)}
</div>
)
})}
</div>
)}
</GlassCard>
)} )}
</div> </div>
{/* Activity Feed - right sidebar */} {/* Activity Feed - right sidebar */}
{isParticipant && ( {isParticipant && (
<div className="lg:w-96 flex-shrink-0"> <div className="lg:w-96 flex-shrink-0">
<div className="lg:sticky lg:top-4"> <div className="lg:sticky lg:top-24">
<ActivityFeed <ActivityFeed
ref={activityFeedRef} ref={activityFeedRef}
marathonId={marathon.id} marathonId={marathon.id}

View File

@@ -2,9 +2,10 @@ import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { marathonsApi } from '@/api' import { marathonsApi } from '@/api'
import type { MarathonListItem } from '@/types' import type { MarathonListItem } from '@/types'
import { Button, Card, CardContent } from '@/components/ui' import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
import { Plus, Users, Calendar, Loader2 } from 'lucide-react' import { Plus, Users, Calendar, Loader2, Trophy, Gamepad2, ChevronRight, Hash, Sparkles } from 'lucide-react'
import { format } from 'date-fns' import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
export function MarathonsPage() { export function MarathonsPage() {
const [marathons, setMarathons] = useState<MarathonListItem[]>([]) const [marathons, setMarathons] = useState<MarathonListItem[]>([])
@@ -12,6 +13,7 @@ export function MarathonsPage() {
const [joinCode, setJoinCode] = useState('') const [joinCode, setJoinCode] = useState('')
const [joinError, setJoinError] = useState<string | null>(null) const [joinError, setJoinError] = useState<string | null>(null)
const [isJoining, setIsJoining] = useState(false) const [isJoining, setIsJoining] = useState(false)
const [showJoinSection, setShowJoinSection] = useState(false)
useEffect(() => { useEffect(() => {
loadMarathons() loadMarathons()
@@ -36,6 +38,7 @@ export function MarathonsPage() {
try { try {
await marathonsApi.join(joinCode.trim()) await marathonsApi.join(joinCode.trim())
setJoinCode('') setJoinCode('')
setShowJoinSection(false)
await loadMarathons() await loadMarathons()
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
@@ -45,112 +48,217 @@ export function MarathonsPage() {
} }
} }
const getStatusColor = (status: string) => { const getStatusConfig = (status: string) => {
switch (status) { switch (status) {
case 'preparing': case 'preparing':
return 'bg-yellow-500/20 text-yellow-500' return {
color: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
text: 'Подготовка',
dot: 'bg-yellow-500',
}
case 'active': case 'active':
return 'bg-green-500/20 text-green-500' return {
color: 'bg-neon-500/20 text-neon-400 border-neon-500/30',
text: 'Активен',
dot: 'bg-neon-500 animate-pulse',
}
case 'finished': case 'finished':
return 'bg-gray-500/20 text-gray-400' return {
color: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
text: 'Завершён',
dot: 'bg-gray-500',
}
default: default:
return 'bg-gray-500/20 text-gray-400' return {
color: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
text: status,
dot: 'bg-gray-500',
}
} }
} }
const getStatusText = (status: string) => { // Stats
switch (status) { const activeCount = marathons.filter(m => m.status === 'active').length
case 'preparing': const completedCount = marathons.filter(m => m.status === 'finished').length
return 'Подготовка' const totalParticipants = marathons.reduce((acc, m) => acc + m.participants_count, 0)
case 'active':
return 'Активен'
case 'finished':
return 'Завершён'
default:
return status
}
}
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex justify-center py-12"> <div className="flex flex-col items-center justify-center py-24">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" /> <Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
<p className="text-gray-400">Загрузка марафонов...</p>
</div> </div>
) )
} }
return ( return (
<div className="max-w-4xl mx-auto"> <div className="max-w-5xl mx-auto">
<div className="flex justify-between items-center mb-8"> {/* Header */}
<h1 className="text-2xl font-bold text-white">Мои марафоны</h1> <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Мои марафоны</h1>
<p className="text-gray-400">Управляйте своими игровыми соревнованиями</p>
</div>
<div className="flex gap-3">
<NeonButton
variant="outline"
onClick={() => setShowJoinSection(!showJoinSection)}
icon={<Hash className="w-4 h-4" />}
>
По коду
</NeonButton>
<Link to="/marathons/create"> <Link to="/marathons/create">
<Button> <NeonButton icon={<Plus className="w-4 h-4" />}>
<Plus className="w-4 h-4 mr-2" /> Создать
Создать марафон </NeonButton>
</Button>
</Link> </Link>
</div> </div>
</div>
{/* Stats */}
{marathons.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<StatsCard
label="Всего"
value={marathons.length}
icon={<Gamepad2 className="w-6 h-6" />}
color="default"
/>
<StatsCard
label="Активных"
value={activeCount}
icon={<Sparkles className="w-6 h-6" />}
color="neon"
/>
<StatsCard
label="Завершено"
value={completedCount}
icon={<Trophy className="w-6 h-6" />}
color="purple"
/>
<StatsCard
label="Участников"
value={totalParticipants}
icon={<Users className="w-6 h-6" />}
color="pink"
/>
</div>
)}
{/* Join marathon */} {/* Join marathon */}
<Card className="mb-8"> {showJoinSection && (
<CardContent> <GlassCard className="mb-8 animate-slide-in-down" variant="neon">
<h3 className="font-medium text-white mb-3">Присоединиться к марафону</h3> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
<Hash className="w-5 h-5 text-accent-400" />
</div>
<div>
<h3 className="font-semibold text-white">Присоединиться к марафону</h3>
<p className="text-sm text-gray-400">Введите код приглашения</p>
</div>
</div>
<div className="flex gap-3"> <div className="flex gap-3">
<input <input
type="text" type="text"
value={joinCode} value={joinCode}
onChange={(e) => setJoinCode(e.target.value)} onChange={(e) => setJoinCode(e.target.value.toUpperCase())}
placeholder="Введите код приглашения" onKeyDown={(e) => e.key === 'Enter' && handleJoin()}
className="input flex-1" placeholder="XXXXXX"
className="input flex-1 font-mono text-center tracking-widest uppercase"
maxLength={10}
/> />
<Button onClick={handleJoin} isLoading={isJoining}> <NeonButton
onClick={handleJoin}
isLoading={isJoining}
color="purple"
>
Присоединиться Присоединиться
</Button> </NeonButton>
</div> </div>
{joinError && <p className="mt-2 text-sm text-red-500">{joinError}</p>} {joinError && (
</CardContent> <p className="mt-3 text-sm text-red-400 flex items-center gap-2">
</Card> <span className="w-1.5 h-1.5 bg-red-500 rounded-full" />
{joinError}
</p>
)}
</GlassCard>
)}
{/* Marathon list */} {/* Marathon list */}
{marathons.length === 0 ? ( {marathons.length === 0 ? (
<Card> <GlassCard className="text-center py-16">
<CardContent className="text-center py-8"> <div className="w-20 h-20 mx-auto mb-6 rounded-2xl bg-dark-700 flex items-center justify-center">
<p className="text-gray-400 mb-4">У вас пока нет марафонов</p> <Gamepad2 className="w-10 h-10 text-gray-600" />
</div>
<h3 className="text-xl font-semibold text-white mb-2">Нет марафонов</h3>
<p className="text-gray-400 mb-6 max-w-sm mx-auto">
Создайте свой первый марафон или присоединитесь к существующему по коду
</p>
<div className="flex gap-3 justify-center">
<NeonButton
variant="outline"
onClick={() => setShowJoinSection(true)}
icon={<Hash className="w-4 h-4" />}
>
Ввести код
</NeonButton>
<Link to="/marathons/create"> <Link to="/marathons/create">
<Button>Создать первый марафон</Button> <NeonButton icon={<Plus className="w-4 h-4" />}>
Создать марафон
</NeonButton>
</Link> </Link>
</CardContent> </div>
</Card> </GlassCard>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{marathons.map((marathon) => ( {marathons.map((marathon, index) => {
const status = getStatusConfig(marathon.status)
return (
<Link key={marathon.id} to={`/marathons/${marathon.id}`}> <Link key={marathon.id} to={`/marathons/${marathon.id}`}>
<Card className="hover:bg-gray-700/50 transition-colors cursor-pointer"> <div
<CardContent className="flex items-center justify-between"> className="group glass rounded-xl p-5 border border-dark-600 transition-all duration-300 hover:border-neon-500/30 hover:-translate-y-0.5 hover:shadow-[0_10px_40px_rgba(34,211,238,0.08)]"
style={{ animationDelay: `${index * 50}ms` }}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{/* Icon */}
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 flex items-center justify-center border border-neon-500/20 group-hover:border-neon-500/40 transition-colors">
<Gamepad2 className="w-7 h-7 text-neon-400" />
</div>
{/* Info */}
<div> <div>
<h3 className="text-lg font-medium text-white mb-1"> <h3 className="text-lg font-semibold text-white group-hover:text-neon-400 transition-colors mb-1">
{marathon.title} {marathon.title}
</h3> </h3>
<div className="flex items-center gap-4 text-sm text-gray-400"> <div className="flex flex-wrap items-center gap-4 text-sm text-gray-400">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1.5">
<Users className="w-4 h-4" /> <Users className="w-4 h-4" />
{marathon.participants_count} участников {marathon.participants_count}
</span> </span>
{marathon.start_date && ( {marathon.start_date && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1.5">
<Calendar className="w-4 h-4" /> <Calendar className="w-4 h-4" />
{format(new Date(marathon.start_date), 'MMM d, yyyy')} {format(new Date(marathon.start_date), 'd MMM yyyy', { locale: ru })}
</span> </span>
)} )}
</div> </div>
</div> </div>
<span className={`px-3 py-1 rounded-full text-sm ${getStatusColor(marathon.status)}`}> </div>
{getStatusText(marathon.status)}
{/* Status & Arrow */}
<div className="flex items-center gap-4">
<span className={`px-3 py-1.5 rounded-full text-sm font-medium border flex items-center gap-2 ${status.color}`}>
<span className={`w-2 h-2 rounded-full ${status.dot}`} />
{status.text}
</span> </span>
</CardContent> <ChevronRight className="w-5 h-5 text-gray-600 group-hover:text-neon-400 transition-colors" />
</Card> </div>
</div>
</div>
</Link> </Link>
))} )
})}
</div> </div>
)} )}
</div> </div>

View File

@@ -1,33 +1,62 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Button } from '@/components/ui' import { NeonButton } from '@/components/ui'
import { Gamepad2, Home, Ghost } from 'lucide-react' import { Gamepad2, Home, Ghost, Sparkles } from 'lucide-react'
export function NotFoundPage() { export function NotFoundPage() {
return ( return (
<div className="min-h-[60vh] flex flex-col items-center justify-center text-center px-4"> <div className="min-h-[70vh] flex flex-col items-center justify-center text-center px-4">
{/* Иконка с анимацией */} {/* Background effects */}
<div className="relative mb-8"> <div className="fixed inset-0 overflow-hidden pointer-events-none">
<Ghost className="w-32 h-32 text-gray-700 animate-bounce" /> <div className="absolute top-1/3 -left-32 w-96 h-96 bg-accent-500/5 rounded-full blur-[100px]" />
<Gamepad2 className="w-12 h-12 text-primary-500 absolute -bottom-2 -right-2" /> <div className="absolute bottom-1/3 -right-32 w-96 h-96 bg-neon-500/5 rounded-full blur-[100px]" />
</div> </div>
{/* Заголовок */} {/* Icon */}
<h1 className="text-7xl font-bold text-white mb-4">404</h1> <div className="relative mb-8 animate-float">
<h2 className="text-2xl font-semibold text-gray-400 mb-2"> <div className="w-32 h-32 rounded-3xl bg-dark-700/50 border border-dark-600 flex items-center justify-center">
<Ghost className="w-20 h-20 text-gray-600" />
</div>
<div className="absolute -bottom-2 -right-2 w-12 h-12 rounded-xl bg-neon-500/20 border border-neon-500/30 flex items-center justify-center">
<Gamepad2 className="w-6 h-6 text-neon-400" />
</div>
{/* Glitch effect dots */}
<div className="absolute -top-2 -left-2 w-3 h-3 rounded-full bg-accent-500/50 animate-pulse" />
<div className="absolute top-4 -right-4 w-2 h-2 rounded-full bg-neon-500/50 animate-pulse" style={{ animationDelay: '0.5s' }} />
</div>
{/* 404 text with glitch effect */}
<div className="relative mb-4">
<h1 className="text-8xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-neon-400 via-accent-400 to-pink-400">
404
</h1>
<div className="absolute inset-0 text-8xl font-bold text-neon-500/20 blur-xl">
404
</div>
</div>
<h2 className="text-2xl font-bold text-white mb-3">
Страница не найдена Страница не найдена
</h2> </h2>
<p className="text-gray-500 mb-8 max-w-md"> <p className="text-gray-400 mb-8 max-w-md">
Похоже, эта страница ушла на марафон и не вернулась. Похоже, эта страница ушла на марафон и не вернулась.
Попробуй начать с главной. <br />
<span className="text-gray-500">Попробуй начать с главной.</span>
</p> </p>
{/* Кнопка */} {/* Button */}
<Link to="/"> <Link to="/">
<Button size="lg" className="flex items-center gap-2"> <NeonButton size="lg" icon={<Home className="w-5 h-5" />}>
<Home className="w-5 h-5" />
На главную На главную
</Button> </NeonButton>
</Link> </Link>
{/* Decorative sparkles */}
<div className="absolute top-1/4 left-1/4 opacity-20">
<Sparkles className="w-6 h-6 text-neon-400 animate-pulse" />
</div>
<div className="absolute bottom-1/3 right-1/4 opacity-20">
<Sparkles className="w-4 h-4 text-accent-400 animate-pulse" style={{ animationDelay: '1s' }} />
</div>
</div> </div>
) )
} }

File diff suppressed because it is too large Load Diff

View File

@@ -7,15 +7,15 @@ import { usersApi, telegramApi, authApi } from '@/api'
import type { UserStats } from '@/types' import type { UserStats } from '@/types'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { import {
Button, Input, Card, CardHeader, CardTitle, CardContent, clearAvatarCache NeonButton, Input, GlassCard, StatsCard, clearAvatarCache
} from '@/components/ui' } from '@/components/ui'
import { import {
User, Camera, Trophy, Target, CheckCircle, Flame, User, Camera, Trophy, Target, CheckCircle, Flame,
Loader2, MessageCircle, Link2, Link2Off, ExternalLink, Loader2, MessageCircle, Link2, Link2Off, ExternalLink,
Eye, EyeOff, Save, KeyRound Eye, EyeOff, Save, KeyRound, Shield
} from 'lucide-react' } from 'lucide-react'
// Схемы валидации // Schemas
const nicknameSchema = z.object({ const nicknameSchema = z.object({
nickname: z.string().min(2, 'Минимум 2 символа').max(50, 'Максимум 50 символов'), nickname: z.string().min(2, 'Минимум 2 символа').max(50, 'Максимум 50 символов'),
}) })
@@ -33,10 +33,10 @@ type NicknameForm = z.infer<typeof nicknameSchema>
type PasswordForm = z.infer<typeof passwordSchema> type PasswordForm = z.infer<typeof passwordSchema>
export function ProfilePage() { export function ProfilePage() {
const { user, updateUser } = useAuthStore() const { user, updateUser, avatarVersion, bumpAvatarVersion } = useAuthStore()
const toast = useToast() const toast = useToast()
// Состояние // State
const [stats, setStats] = useState<UserStats | null>(null) const [stats, setStats] = useState<UserStats | null>(null)
const [isLoadingStats, setIsLoadingStats] = useState(true) const [isLoadingStats, setIsLoadingStats] = useState(true)
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false) const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
@@ -53,7 +53,7 @@ export function ProfilePage() {
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
// Формы // Forms
const nicknameForm = useForm<NicknameForm>({ const nicknameForm = useForm<NicknameForm>({
resolver: zodResolver(nicknameSchema), resolver: zodResolver(nicknameSchema),
defaultValues: { nickname: user?.nickname || '' }, defaultValues: { nickname: user?.nickname || '' },
@@ -64,7 +64,7 @@ export function ProfilePage() {
defaultValues: { current_password: '', new_password: '', confirm_password: '' }, defaultValues: { current_password: '', new_password: '', confirm_password: '' },
}) })
// Загрузка статистики // Load stats
useEffect(() => { useEffect(() => {
loadStats() loadStats()
return () => { return () => {
@@ -72,33 +72,59 @@ export function ProfilePage() {
} }
}, []) }, [])
// Загрузка аватарки через API // Ref для отслеживания текущего blob URL
const avatarBlobRef = useRef<string | null>(null)
// Load avatar via API
useEffect(() => { useEffect(() => {
if (user?.id && user?.avatar_url) { if (!user?.id || !user?.avatar_url) {
loadAvatar(user.id)
} else {
setIsLoadingAvatar(false) setIsLoadingAvatar(false)
return
} }
return () => {
if (avatarBlobUrl) {
URL.revokeObjectURL(avatarBlobUrl)
}
}
}, [user?.id, user?.avatar_url])
const loadAvatar = async (userId: number) => { let cancelled = false
const bustCache = avatarVersion > 0
setIsLoadingAvatar(true) setIsLoadingAvatar(true)
try { usersApi.getAvatarUrl(user.id, bustCache)
const url = await usersApi.getAvatarUrl(userId) .then(url => {
if (cancelled) {
URL.revokeObjectURL(url)
return
}
// Очищаем старый blob URL
if (avatarBlobRef.current) {
URL.revokeObjectURL(avatarBlobRef.current)
}
avatarBlobRef.current = url
setAvatarBlobUrl(url) setAvatarBlobUrl(url)
} catch { })
.catch(() => {
if (!cancelled) {
setAvatarBlobUrl(null) setAvatarBlobUrl(null)
} finally { }
})
.finally(() => {
if (!cancelled) {
setIsLoadingAvatar(false) setIsLoadingAvatar(false)
} }
} })
// Обновляем форму никнейма при изменении user return () => {
cancelled = true
}
}, [user?.id, user?.avatar_url, avatarVersion])
// Cleanup blob URL on unmount
useEffect(() => {
return () => {
if (avatarBlobRef.current) {
URL.revokeObjectURL(avatarBlobRef.current)
}
}
}, [])
// Update nickname form when user changes
useEffect(() => { useEffect(() => {
if (user?.nickname) { if (user?.nickname) {
nicknameForm.reset({ nickname: user.nickname }) nicknameForm.reset({ nickname: user.nickname })
@@ -116,7 +142,7 @@ export function ProfilePage() {
} }
} }
// Обновление никнейма // Update nickname
const onNicknameSubmit = async (data: NicknameForm) => { const onNicknameSubmit = async (data: NicknameForm) => {
try { try {
const updatedUser = await usersApi.updateNickname(data) const updatedUser = await usersApi.updateNickname(data)
@@ -127,7 +153,7 @@ export function ProfilePage() {
} }
} }
// Загрузка аватара // Upload avatar
const handleAvatarClick = () => { const handleAvatarClick = () => {
fileInputRef.current?.click() fileInputRef.current?.click()
} }
@@ -136,7 +162,6 @@ export function ProfilePage() {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) return if (!file) return
// Валидация
if (!file.type.startsWith('image/')) { if (!file.type.startsWith('image/')) {
toast.error('Файл должен быть изображением') toast.error('Файл должен быть изображением')
return return
@@ -150,15 +175,11 @@ export function ProfilePage() {
try { try {
const updatedUser = await usersApi.uploadAvatar(file) const updatedUser = await usersApi.uploadAvatar(file)
updateUser({ avatar_url: updatedUser.avatar_url }) updateUser({ avatar_url: updatedUser.avatar_url })
// Перезагружаем аватарку через API
if (user?.id) { if (user?.id) {
// Очищаем старый blob URL и глобальный кэш
if (avatarBlobUrl) {
URL.revokeObjectURL(avatarBlobUrl)
}
clearAvatarCache(user.id) clearAvatarCache(user.id)
await loadAvatar(user.id)
} }
// Bump version - это вызовет перезагрузку через useEffect
bumpAvatarVersion()
toast.success('Аватар обновлен') toast.success('Аватар обновлен')
} catch { } catch {
toast.error('Не удалось загрузить аватар') toast.error('Не удалось загрузить аватар')
@@ -167,7 +188,7 @@ export function ProfilePage() {
} }
} }
// Смена пароля // Change password
const onPasswordSubmit = async (data: PasswordForm) => { const onPasswordSubmit = async (data: PasswordForm) => {
try { try {
await usersApi.changePassword({ await usersApi.changePassword({
@@ -184,7 +205,7 @@ export function ProfilePage() {
} }
} }
// Telegram функции // Telegram functions
const startPolling = () => { const startPolling = () => {
setIsPolling(true) setIsPolling(true)
let attempts = 0 let attempts = 0
@@ -245,26 +266,28 @@ export function ProfilePage() {
} }
const isLinked = !!user?.telegram_id const isLinked = !!user?.telegram_id
// Приоритет: загруженная аватарка (blob) > телеграм аватарка
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
return ( return (
<div className="max-w-2xl mx-auto space-y-6"> <div className="max-w-3xl mx-auto space-y-6">
<h1 className="text-2xl font-bold text-white">Мой профиль</h1> {/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Мой профиль</h1>
<p className="text-gray-400">Настройки вашего аккаунта</p>
</div>
{/* Карточка профиля */} {/* Profile Card */}
<Card> <GlassCard variant="neon">
<CardContent className="pt-6"> <div className="flex flex-col sm:flex-row items-center sm:items-start gap-6">
<div className="flex items-start gap-6"> {/* Avatar */}
{/* Аватар */}
<div className="relative group flex-shrink-0"> <div className="relative group flex-shrink-0">
{isLoadingAvatar ? ( {isLoadingAvatar ? (
<div className="w-24 h-24 rounded-full bg-gray-700 animate-pulse" /> <div className="w-28 h-28 rounded-2xl bg-dark-700 skeleton" />
) : ( ) : (
<button <button
onClick={handleAvatarClick} onClick={handleAvatarClick}
disabled={isUploadingAvatar} disabled={isUploadingAvatar}
className="relative w-24 h-24 rounded-full overflow-hidden bg-gray-700 hover:opacity-80 transition-opacity" className="relative w-28 h-28 rounded-2xl overflow-hidden bg-dark-700 border-2 border-neon-500/30 hover:border-neon-500 transition-all group-hover:shadow-[0_0_20px_rgba(34,211,238,0.25)]"
> >
{displayAvatar ? ( {displayAvatar ? (
<img <img
@@ -273,15 +296,15 @@ export function ProfilePage() {
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
) : ( ) : (
<div className="w-full h-full flex items-center justify-center"> <div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-neon-500/20 to-accent-500/20">
<User className="w-12 h-12 text-gray-500" /> <User className="w-12 h-12 text-gray-500" />
</div> </div>
)} )}
<div className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"> <div className="absolute inset-0 bg-dark-900/70 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
{isUploadingAvatar ? ( {isUploadingAvatar ? (
<Loader2 className="w-6 h-6 text-white animate-spin" /> <Loader2 className="w-8 h-8 text-neon-500 animate-spin" />
) : ( ) : (
<Camera className="w-6 h-6 text-white" /> <Camera className="w-8 h-8 text-neon-500" />
)} )}
</div> </div>
</button> </button>
@@ -295,90 +318,96 @@ export function ProfilePage() {
/> />
</div> </div>
{/* Форма никнейма */} {/* Nickname Form */}
<div className="flex-1"> <div className="flex-1 w-full sm:w-auto">
<form onSubmit={nicknameForm.handleSubmit(onNicknameSubmit)} className="space-y-4"> <form onSubmit={nicknameForm.handleSubmit(onNicknameSubmit)} className="space-y-4">
<Input <Input
label="Никнейм" label="Никнейм"
{...nicknameForm.register('nickname')} {...nicknameForm.register('nickname')}
error={nicknameForm.formState.errors.nickname?.message} error={nicknameForm.formState.errors.nickname?.message}
/> />
<Button <NeonButton
type="submit" type="submit"
size="sm" size="sm"
isLoading={nicknameForm.formState.isSubmitting} isLoading={nicknameForm.formState.isSubmitting}
disabled={!nicknameForm.formState.isDirty} disabled={!nicknameForm.formState.isDirty}
icon={<Save className="w-4 h-4" />}
> >
<Save className="w-4 h-4 mr-2" />
Сохранить Сохранить
</Button> </NeonButton>
</form> </form>
</div> </div>
</div> </div>
</CardContent> </GlassCard>
</Card>
{/* Статистика */} {/* Stats */}
<Card> <div>
<CardHeader> <h2 className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
<CardTitle className="flex items-center gap-2">
<Trophy className="w-5 h-5 text-yellow-500" /> <Trophy className="w-5 h-5 text-yellow-500" />
Статистика Статистика
</CardTitle> </h2>
</CardHeader>
<CardContent>
{isLoadingStats ? ( {isLoadingStats ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => ( {[...Array(4)].map((_, i) => (
<div key={i} className="bg-gray-900 rounded-lg p-4 text-center"> <div key={i} className="glass rounded-xl p-4">
<div className="w-6 h-6 bg-gray-700 rounded mx-auto mb-2 animate-pulse" /> <div className="w-12 h-12 bg-dark-700 rounded-lg mb-3 skeleton" />
<div className="h-8 w-12 bg-gray-700 rounded mx-auto mb-2 animate-pulse" /> <div className="h-8 w-16 bg-dark-700 rounded mb-2 skeleton" />
<div className="h-4 w-16 bg-gray-700 rounded mx-auto animate-pulse" /> <div className="h-4 w-20 bg-dark-700 rounded skeleton" />
</div> </div>
))} ))}
</div> </div>
) : stats ? ( ) : stats ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-900 rounded-lg p-4 text-center"> <StatsCard
<Target className="w-6 h-6 text-blue-400 mx-auto mb-2" /> label="Марафонов"
<div className="text-2xl font-bold text-white">{stats.marathons_count}</div> value={stats.marathons_count}
<div className="text-sm text-gray-400">Марафонов</div> icon={<Target className="w-6 h-6" />}
</div> color="neon"
<div className="bg-gray-900 rounded-lg p-4 text-center"> />
<Trophy className="w-6 h-6 text-yellow-500 mx-auto mb-2" /> <StatsCard
<div className="text-2xl font-bold text-white">{stats.wins_count}</div> label="Побед"
<div className="text-sm text-gray-400">Побед</div> value={stats.wins_count}
</div> icon={<Trophy className="w-6 h-6" />}
<div className="bg-gray-900 rounded-lg p-4 text-center"> color="purple"
<CheckCircle className="w-6 h-6 text-green-500 mx-auto mb-2" /> />
<div className="text-2xl font-bold text-white">{stats.completed_assignments}</div> <StatsCard
<div className="text-sm text-gray-400">Заданий</div> label="Заданий"
</div> value={stats.completed_assignments}
<div className="bg-gray-900 rounded-lg p-4 text-center"> icon={<CheckCircle className="w-6 h-6" />}
<Flame className="w-6 h-6 text-orange-500 mx-auto mb-2" /> color="neon"
<div className="text-2xl font-bold text-white">{stats.total_points_earned}</div> />
<div className="text-sm text-gray-400">Очков</div> <StatsCard
</div> label="Очков"
value={stats.total_points_earned}
icon={<Flame className="w-6 h-6" />}
color="pink"
/>
</div> </div>
) : ( ) : (
<p className="text-gray-400 text-center">Не удалось загрузить статистику</p> <GlassCard className="text-center py-8">
<p className="text-gray-400">Не удалось загрузить статистику</p>
</GlassCard>
)} )}
</CardContent> </div>
</Card>
{/* Telegram */} {/* Telegram */}
<Card> <GlassCard>
<CardHeader> <div className="flex items-center gap-3 mb-6">
<CardTitle className="flex items-center gap-2"> <div className="w-12 h-12 rounded-xl bg-blue-500/20 flex items-center justify-center">
<MessageCircle className="w-5 h-5 text-blue-400" /> <MessageCircle className="w-6 h-6 text-blue-400" />
Telegram </div>
</CardTitle> <div>
</CardHeader> <h2 className="text-lg font-semibold text-white">Telegram</h2>
<CardContent> <p className="text-sm text-gray-400">
{isLinked ? 'Аккаунт привязан' : 'Привяжите для уведомлений'}
</p>
</div>
</div>
{isLinked ? ( {isLinked ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-4 p-4 bg-gray-900 rounded-lg"> <div className="flex items-center gap-4 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="w-12 h-12 rounded-full bg-blue-500/20 flex items-center justify-center overflow-hidden"> <div className="w-14 h-14 rounded-xl bg-blue-500/20 flex items-center justify-center overflow-hidden border border-blue-500/30">
{user?.telegram_avatar_url ? ( {user?.telegram_avatar_url ? (
<img <img
src={user.telegram_avatar_url} src={user.telegram_avatar_url}
@@ -386,7 +415,7 @@ export function ProfilePage() {
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
) : ( ) : (
<Link2 className="w-6 h-6 text-blue-400" /> <Link2 className="w-7 h-7 text-blue-400" />
)} )}
</div> </div>
<div className="flex-1"> <div className="flex-1">
@@ -397,53 +426,61 @@ export function ProfilePage() {
<p className="text-blue-400 text-sm">@{user.telegram_username}</p> <p className="text-blue-400 text-sm">@{user.telegram_username}</p>
)} )}
</div> </div>
<Button <NeonButton
variant="danger" variant="danger"
size="sm" size="sm"
onClick={handleUnlinkTelegram} onClick={handleUnlinkTelegram}
isLoading={telegramLoading} isLoading={telegramLoading}
icon={<Link2Off className="w-4 h-4" />}
> >
<Link2Off className="w-4 h-4 mr-2" />
Отвязать Отвязать
</Button> </NeonButton>
</div> </div>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-gray-400"> <p className="text-gray-400">
Привяжи Telegram для получения уведомлений о событиях и марафонах. Привяжите Telegram для получения уведомлений о событиях и марафонах.
</p> </p>
{isPolling ? ( {isPolling ? (
<div className="p-4 bg-blue-500/20 border border-blue-500/50 rounded-lg"> <div className="p-4 bg-blue-500/10 border border-blue-500/30 rounded-xl">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Loader2 className="w-5 h-5 text-blue-400 animate-spin" /> <Loader2 className="w-5 h-5 text-blue-400 animate-spin" />
<p className="text-blue-400">Ожидание привязки...</p> <p className="text-blue-400">Ожидание привязки...</p>
</div> </div>
</div> </div>
) : ( ) : (
<Button onClick={handleLinkTelegram} isLoading={telegramLoading}> <NeonButton
<ExternalLink className="w-4 h-4 mr-2" /> onClick={handleLinkTelegram}
isLoading={telegramLoading}
icon={<ExternalLink className="w-4 h-4" />}
>
Привязать Telegram Привязать Telegram
</Button> </NeonButton>
)} )}
</div> </div>
)} )}
</CardContent> </GlassCard>
</Card>
{/* Security */}
<GlassCard>
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 rounded-xl bg-accent-500/20 flex items-center justify-center">
<Shield className="w-6 h-6 text-accent-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Безопасность</h2>
<p className="text-sm text-gray-400">Управление паролем</p>
</div>
</div>
{/* Смена пароля */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="w-5 h-5 text-gray-400" />
Безопасность
</CardTitle>
</CardHeader>
<CardContent>
{!showPasswordForm ? ( {!showPasswordForm ? (
<Button variant="secondary" onClick={() => setShowPasswordForm(true)}> <NeonButton
onClick={() => setShowPasswordForm(true)}
icon={<KeyRound className="w-4 h-4" />}
>
Сменить пароль Сменить пароль
</Button> </NeonButton>
) : ( ) : (
<form onSubmit={passwordForm.handleSubmit(onPasswordSubmit)} className="space-y-4"> <form onSubmit={passwordForm.handleSubmit(onPasswordSubmit)} className="space-y-4">
<div className="relative"> <div className="relative">
@@ -456,7 +493,7 @@ export function ProfilePage() {
<button <button
type="button" type="button"
onClick={() => setShowCurrentPassword(!showCurrentPassword)} onClick={() => setShowCurrentPassword(!showCurrentPassword)}
className="absolute right-3 top-8 text-gray-400 hover:text-white" className="absolute right-3 top-9 text-gray-400 hover:text-white transition-colors"
> >
{showCurrentPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />} {showCurrentPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button> </button>
@@ -472,7 +509,7 @@ export function ProfilePage() {
<button <button
type="button" type="button"
onClick={() => setShowNewPassword(!showNewPassword)} onClick={() => setShowNewPassword(!showNewPassword)}
className="absolute right-3 top-8 text-gray-400 hover:text-white" className="absolute right-3 top-9 text-gray-400 hover:text-white transition-colors"
> >
{showNewPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />} {showNewPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button> </button>
@@ -485,11 +522,15 @@ export function ProfilePage() {
error={passwordForm.formState.errors.confirm_password?.message} error={passwordForm.formState.errors.confirm_password?.message}
/> />
<div className="flex gap-2"> <div className="flex gap-3">
<Button type="submit" isLoading={passwordForm.formState.isSubmitting}> <NeonButton
type="submit"
isLoading={passwordForm.formState.isSubmitting}
icon={<Save className="w-4 h-4" />}
>
Сменить пароль Сменить пароль
</Button> </NeonButton>
<Button <NeonButton
type="button" type="button"
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
@@ -498,12 +539,11 @@ export function ProfilePage() {
}} }}
> >
Отмена Отмена
</Button> </NeonButton>
</div> </div>
</form> </form>
)} )}
</CardContent> </GlassCard>
</Card>
</div> </div>
) )
} }

View File

@@ -5,7 +5,8 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { marathonsApi } from '@/api' import { marathonsApi } from '@/api'
import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui' import { NeonButton, Input, GlassCard } from '@/components/ui'
import { Gamepad2, UserPlus, AlertCircle, Trophy, Users, Zap, Target, Sparkles } from 'lucide-react'
const registerSchema = z.object({ const registerSchema = z.object({
login: z login: z
@@ -67,17 +68,106 @@ export function RegisterPage() {
} }
} }
const features = [
{ icon: <Trophy className="w-5 h-5" />, text: 'Соревнуйтесь с друзьями' },
{ icon: <Target className="w-5 h-5" />, text: 'Выполняйте челленджи' },
{ icon: <Zap className="w-5 h-5" />, text: 'Зарабатывайте очки' },
{ icon: <Users className="w-5 h-5" />, text: 'Создавайте марафоны' },
]
return ( return (
<div className="max-w-md mx-auto"> <div className="min-h-[80vh] flex items-center justify-center px-4 py-8">
<Card> {/* Background effects */}
<CardHeader> <div className="fixed inset-0 overflow-hidden pointer-events-none">
<CardTitle className="text-center">Регистрация</CardTitle> <div className="absolute top-1/3 -right-32 w-96 h-96 bg-accent-500/10 rounded-full blur-[100px]" />
</CardHeader> <div className="absolute bottom-1/3 -left-32 w-96 h-96 bg-neon-500/10 rounded-full blur-[100px]" />
<CardContent> </div>
{/* Bento Grid */}
<div className="relative w-full max-w-4xl">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-scale-in">
{/* Branding Block (left) */}
<GlassCard className="p-8 flex flex-col justify-center relative overflow-hidden order-2 md:order-1" variant="neon">
{/* Background decoration */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-20 -left-20 w-48 h-48 bg-accent-500/20 rounded-full blur-[60px]" />
<div className="absolute -bottom-20 -right-20 w-48 h-48 bg-neon-500/20 rounded-full blur-[60px]" />
</div>
<div className="relative">
{/* Logo */}
<div className="flex justify-center md:justify-start mb-6">
<div className="w-20 h-20 rounded-2xl bg-accent-500/10 border border-accent-500/30 flex items-center justify-center shadow-[0_0_40px_rgba(147,51,234,0.3)]">
<Gamepad2 className="w-10 h-10 text-accent-500" />
</div>
</div>
{/* Title */}
<h1 className="text-3xl md:text-4xl font-bold text-white mb-2 text-center md:text-left">
Game Marathon
</h1>
<p className="text-gray-400 mb-6 text-center md:text-left">
Присоединяйтесь к игровому сообществу
</p>
{/* Benefits */}
<div className="p-4 rounded-xl bg-dark-700/50 border border-dark-600 mb-6">
<div className="flex items-center gap-2 mb-3">
<Sparkles className="w-5 h-5 text-accent-400" />
<span className="text-white font-semibold">Что вас ждет:</span>
</div>
<ul className="space-y-2 text-sm text-gray-400">
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-neon-500" />
Создавайте игровые марафоны
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-accent-500" />
Выполняйте уникальные челленджи
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-pink-500" />
Соревнуйтесь за первое место
</li>
</ul>
</div>
{/* Features */}
<div className="grid grid-cols-2 gap-3">
{features.map((feature, index) => (
<div
key={index}
className="flex items-center gap-2 p-3 rounded-xl bg-dark-700/50 border border-dark-600"
>
<div className="w-8 h-8 rounded-lg bg-accent-500/20 flex items-center justify-center text-accent-400">
{feature.icon}
</div>
<span className="text-sm text-gray-300">{feature.text}</span>
</div>
))}
</div>
</div>
</GlassCard>
{/* Form Block (right) */}
<GlassCard className="p-8 order-1 md:order-2">
{/* Header */}
<div className="text-center mb-6">
<div className="flex justify-center mb-4 md:hidden">
<div className="w-16 h-16 rounded-2xl bg-accent-500/10 border border-accent-500/30 flex items-center justify-center">
<Gamepad2 className="w-8 h-8 text-accent-500" />
</div>
</div>
<h2 className="text-2xl font-bold text-white mb-2">Создать аккаунт</h2>
<p className="text-gray-400">Начните играть уже сегодня</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{(submitError || error) && ( {(submitError || error) && (
<div className="p-3 bg-red-500/20 border border-red-500 rounded-lg text-red-500 text-sm"> <div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm animate-shake">
{submitError || error} <AlertCircle className="w-5 h-5 flex-shrink-0" />
<span>{submitError || error}</span>
</div> </div>
)} )}
@@ -85,12 +175,13 @@ export function RegisterPage() {
label="Логин" label="Логин"
placeholder="Придумайте логин" placeholder="Придумайте логин"
error={errors.login?.message} error={errors.login?.message}
autoComplete="username"
{...register('login')} {...register('login')}
/> />
<Input <Input
label="Никнейм" label="Никнейм"
placeholder="Придумайте никнейм" placeholder="Как вас называть?"
error={errors.nickname?.message} error={errors.nickname?.message}
{...register('nickname')} {...register('nickname')}
/> />
@@ -100,6 +191,7 @@ export function RegisterPage() {
type="password" type="password"
placeholder="Придумайте пароль" placeholder="Придумайте пароль"
error={errors.password?.message} error={errors.password?.message}
autoComplete="new-password"
{...register('password')} {...register('password')}
/> />
@@ -108,22 +200,41 @@ export function RegisterPage() {
type="password" type="password"
placeholder="Повторите пароль" placeholder="Повторите пароль"
error={errors.confirmPassword?.message} error={errors.confirmPassword?.message}
autoComplete="new-password"
{...register('confirmPassword')} {...register('confirmPassword')}
/> />
<Button type="submit" className="w-full" isLoading={isLoading}> <NeonButton
type="submit"
className="w-full"
size="lg"
color="purple"
isLoading={isLoading}
icon={<UserPlus className="w-5 h-5" />}
>
Зарегистрироваться Зарегистрироваться
</Button> </NeonButton>
</form>
<p className="text-center text-gray-400 text-sm"> {/* Footer */}
<div className="mt-6 pt-6 border-t border-dark-600 text-center">
<p className="text-gray-400 text-sm">
Уже есть аккаунт?{' '} Уже есть аккаунт?{' '}
<Link to="/login" className="link"> <Link
to="/login"
className="text-accent-400 hover:text-accent-300 transition-colors font-medium"
>
Войти Войти
</Link> </Link>
</p> </p>
</form> </div>
</CardContent> </GlassCard>
</Card> </div>
{/* Decorative elements */}
<div className="absolute -top-4 -left-4 w-24 h-24 border border-accent-500/20 rounded-2xl -z-10 hidden md:block" />
<div className="absolute -bottom-4 -right-4 w-32 h-32 border border-neon-500/20 rounded-2xl -z-10 hidden md:block" />
</div>
</div> </div>
) )
} }

View File

@@ -0,0 +1,143 @@
import { Link } from 'react-router-dom'
import { NeonButton } from '@/components/ui'
import { Home, Sparkles, RefreshCw, ServerCrash, Flame, Zap } from 'lucide-react'
export function ServerErrorPage() {
const handleRefresh = () => {
window.location.href = '/'
}
return (
<div className="min-h-[70vh] flex flex-col items-center justify-center text-center px-4">
{/* Background effects */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/4 -left-32 w-96 h-96 bg-red-500/5 rounded-full blur-[100px]" />
<div className="absolute bottom-1/4 -right-32 w-96 h-96 bg-orange-500/5 rounded-full blur-[100px]" />
</div>
{/* Server icon */}
<div className="relative mb-8">
{/* Smoke/fire effect */}
<div className="absolute -top-6 left-1/2 -translate-x-1/2 flex gap-3">
<Flame className="w-6 h-6 text-orange-500/60 animate-flicker" style={{ animationDelay: '0s' }} />
<Flame className="w-5 h-5 text-red-500/50 animate-flicker" style={{ animationDelay: '0.2s' }} />
<Flame className="w-6 h-6 text-orange-500/60 animate-flicker" style={{ animationDelay: '0.4s' }} />
</div>
{/* Server with error */}
<div className="relative">
<div className="w-32 h-32 rounded-2xl bg-dark-700/80 border-2 border-red-500/30 flex items-center justify-center shadow-[0_0_30px_rgba(239,68,68,0.2)]">
<ServerCrash className="w-16 h-16 text-red-400" />
</div>
{/* Error indicator */}
<div className="absolute -bottom-2 -right-2 w-10 h-10 rounded-xl bg-red-500/20 border border-red-500/40 flex items-center justify-center animate-pulse">
<Zap className="w-5 h-5 text-red-400" />
</div>
{/* Sparks */}
<div className="absolute top-2 -left-3 w-2 h-2 rounded-full bg-yellow-400 animate-spark" style={{ animationDelay: '0s' }} />
<div className="absolute top-6 -right-2 w-1.5 h-1.5 rounded-full bg-orange-400 animate-spark" style={{ animationDelay: '0.3s' }} />
<div className="absolute bottom-4 -left-2 w-1.5 h-1.5 rounded-full bg-red-400 animate-spark" style={{ animationDelay: '0.6s' }} />
</div>
{/* Glow effect */}
<div className="absolute inset-0 bg-red-500/20 rounded-full blur-3xl -z-10" />
</div>
{/* 500 text */}
<div className="relative mb-4">
<h1 className="text-8xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-red-400 via-orange-400 to-yellow-400">
500
</h1>
<div className="absolute inset-0 text-8xl font-bold text-red-500/20 blur-xl">
500
</div>
</div>
<h2 className="text-2xl font-bold text-white mb-3">
Ошибка сервера
</h2>
<p className="text-gray-400 mb-2 max-w-md">
Что-то пошло не так на нашей стороне.
</p>
<p className="text-gray-500 text-sm mb-8 max-w-md">
Мы уже работаем над решением проблемы. Попробуйте обновить страницу.
</p>
{/* Status info */}
<div className="glass rounded-xl p-4 mb-8 max-w-md border border-red-500/20">
<div className="flex items-center gap-2 text-red-400 mb-2">
<ServerCrash className="w-4 h-4" />
<span className="text-sm font-semibold">Internal Server Error</span>
</div>
<p className="text-gray-400 text-sm">
Сервер временно недоступен или перегружен. Обычно это быстро исправляется.
</p>
</div>
{/* Buttons */}
<div className="flex gap-4">
<NeonButton
size="lg"
icon={<RefreshCw className="w-5 h-5" />}
onClick={handleRefresh}
>
Обновить
</NeonButton>
<Link to="/">
<NeonButton size="lg" variant="secondary" icon={<Home className="w-5 h-5" />}>
На главную
</NeonButton>
</Link>
</div>
{/* Decorative sparkles */}
<div className="absolute top-1/4 left-1/4 opacity-20">
<Sparkles className="w-6 h-6 text-red-400 animate-pulse" />
</div>
<div className="absolute bottom-1/3 right-1/4 opacity-20">
<Sparkles className="w-4 h-4 text-orange-400 animate-pulse" style={{ animationDelay: '1s' }} />
</div>
{/* Custom animations */}
<style>{`
@keyframes flicker {
0%, 100% {
transform: translateY(0) scale(1);
opacity: 0.6;
}
25% {
transform: translateY(-3px) scale(1.1);
opacity: 0.8;
}
50% {
transform: translateY(-1px) scale(0.9);
opacity: 0.5;
}
75% {
transform: translateY(-4px) scale(1.05);
opacity: 0.7;
}
}
.animate-flicker {
animation: flicker 0.8s ease-in-out infinite;
}
@keyframes spark {
0%, 100% {
opacity: 0;
transform: scale(0);
}
50% {
opacity: 1;
transform: scale(1);
}
}
.animate-spark {
animation: spark 1.5s ease-in-out infinite;
}
`}</style>
</div>
)
}

View File

@@ -0,0 +1,241 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { NeonButton } from '@/components/ui'
import { Home, Sparkles, Coffee } from 'lucide-react'
export function TeapotPage() {
const [isPoured, setIsPoured] = useState(false)
return (
<div className="min-h-[70vh] flex flex-col items-center justify-center text-center px-4">
{/* Background effects */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/4 -left-32 w-96 h-96 bg-amber-500/5 rounded-full blur-[100px]" />
<div className="absolute bottom-1/4 -right-32 w-96 h-96 bg-orange-500/5 rounded-full blur-[100px]" />
</div>
{/* Teapot and Cup container */}
<div className="relative mb-8 flex items-start">
{/* Teapot */}
<div
className="relative cursor-pointer transition-transform duration-500 ease-out"
style={{
transform: isPoured ? 'rotate(15deg)' : 'rotate(0deg)',
transformOrigin: '80px 130px'
}}
onClick={() => setIsPoured(!isPoured)}
>
{/* Steam animation */}
<div className={`absolute -top-8 left-1/2 -translate-x-1/2 flex gap-2 transition-opacity duration-500 ${isPoured ? 'opacity-0' : 'opacity-50'}`}>
<div className="w-2 h-8 bg-gradient-to-t from-gray-400/50 to-transparent rounded-full animate-steam" style={{ animationDelay: '0s' }} />
<div className="w-2 h-10 bg-gradient-to-t from-gray-400/50 to-transparent rounded-full animate-steam" style={{ animationDelay: '0.3s' }} />
<div className="w-2 h-6 bg-gradient-to-t from-gray-400/50 to-transparent rounded-full animate-steam" style={{ animationDelay: '0.6s' }} />
</div>
{/* Teapot SVG - expanded viewBox to show full handle */}
<svg width="180" height="140" viewBox="-15 0 175 140" className="drop-shadow-2xl overflow-visible">
{/* Gradients */}
<defs>
<linearGradient id="teapotGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#fde047" />
<stop offset="50%" stopColor="#fbbf24" />
<stop offset="100%" stopColor="#f59e0b" />
</linearGradient>
<linearGradient id="lidGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#fef08a" />
<stop offset="100%" stopColor="#fbbf24" />
</linearGradient>
</defs>
{/* Handle - behind body */}
<path
d="M 25 70 Q -5 70 -5 90 Q -5 110 25 110"
fill="none"
stroke="#f59e0b"
strokeWidth="8"
strokeLinecap="round"
/>
<path
d="M 25 70 Q -5 70 -5 90 Q -5 110 25 110"
fill="none"
stroke="url(#teapotGradient)"
strokeWidth="5"
strokeLinecap="round"
/>
{/* Body */}
<ellipse cx="80" cy="90" rx="55" ry="40" fill="url(#teapotGradient)" stroke="#f59e0b" strokeWidth="3" />
{/* Lid */}
<ellipse cx="80" cy="55" rx="35" ry="10" fill="url(#lidGradient)" stroke="#f59e0b" strokeWidth="2" />
<ellipse cx="80" cy="50" rx="25" ry="7" fill="url(#lidGradient)" stroke="#f59e0b" strokeWidth="2" />
<circle cx="80" cy="42" r="8" fill="#fbbf24" stroke="#f59e0b" strokeWidth="2" />
{/* Spout */}
<path
d="M 135 85 Q 150 75 155 60 Q 158 50 150 45"
fill="none"
stroke="#f59e0b"
strokeWidth="8"
strokeLinecap="round"
/>
<path
d="M 135 85 Q 150 75 155 60 Q 158 50 150 45"
fill="none"
stroke="url(#teapotGradient)"
strokeWidth="5"
strokeLinecap="round"
/>
{/* Face */}
<circle cx="65" cy="85" r="5" fill="#292524" />
<circle cx="95" cy="85" r="5" fill="#292524" />
<circle cx="67" cy="83" r="2" fill="white" />
<circle cx="97" cy="83" r="2" fill="white" />
<path d="M 70 100 Q 80 110 90 100" fill="none" stroke="#292524" strokeWidth="3" strokeLinecap="round" />
{/* Blush */}
<ellipse cx="55" cy="95" rx="8" ry="5" fill="#fca5a5" opacity="0.5" />
<ellipse cx="105" cy="95" rx="8" ry="5" fill="#fca5a5" opacity="0.5" />
</svg>
{/* Glow effect */}
<div className="absolute inset-0 bg-amber-400/20 rounded-full blur-3xl -z-10" />
</div>
{/* Cup - positioned to the right and below */}
<div className="relative ml-[20px] mt-[125px]">
<svg width="100" height="70" viewBox="0 0 95 70" className="drop-shadow-xl">
<defs>
<linearGradient id="cupGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#fef3c7" />
<stop offset="100%" stopColor="#fde68a" />
</linearGradient>
<linearGradient id="teaInCupGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#d97706" />
<stop offset="100%" stopColor="#92400e" />
</linearGradient>
</defs>
{/* Cup body */}
<path
d="M 10 15 L 15 60 Q 20 68 40 68 Q 60 68 65 60 L 70 15 Z"
fill="url(#cupGradient)"
stroke="#f59e0b"
strokeWidth="2"
/>
{/* Cup rim */}
<ellipse cx="40" cy="15" rx="30" ry="8" fill="url(#cupGradient)" stroke="#f59e0b" strokeWidth="2" />
{/* Tea in cup - fills up when pouring */}
<ellipse
cx="40"
cy="20"
rx="25"
ry="6"
fill="url(#teaInCupGradient)"
className={`transition-all duration-1000 ${isPoured ? 'opacity-100' : 'opacity-30'}`}
style={{
transform: isPoured ? 'translateY(0)' : 'translateY(15px)',
transformOrigin: 'center'
}}
/>
{/* Handle */}
<path
d="M 70 25 Q 85 25 85 40 Q 85 55 70 55"
fill="none"
stroke="#f59e0b"
strokeWidth="5"
strokeLinecap="round"
/>
<path
d="M 70 25 Q 85 25 85 40 Q 85 55 70 55"
fill="none"
stroke="url(#cupGradient)"
strokeWidth="3"
strokeLinecap="round"
/>
</svg>
{/* Steam from cup when filled */}
<div className={`absolute -top-4 left-1/2 -translate-x-1/2 flex gap-1 transition-opacity duration-1000 ${isPoured ? 'opacity-60' : 'opacity-0'}`}>
<div className="w-1 h-4 bg-gradient-to-t from-gray-400/40 to-transparent rounded-full animate-steam" style={{ animationDelay: '0.5s' }} />
<div className="w-1 h-5 bg-gradient-to-t from-gray-400/40 to-transparent rounded-full animate-steam" style={{ animationDelay: '0.8s' }} />
<div className="w-1 h-3 bg-gradient-to-t from-gray-400/40 to-transparent rounded-full animate-steam" style={{ animationDelay: '1.1s' }} />
</div>
</div>
</div>
{/* 418 text */}
<div className="relative mb-4">
<h1 className="text-8xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-amber-400 via-orange-400 to-red-400">
418
</h1>
<div className="absolute inset-0 text-8xl font-bold text-amber-500/20 blur-xl">
418
</div>
</div>
<h2 className="text-2xl font-bold text-white mb-3">
I'm a teapot
</h2>
<p className="text-gray-400 mb-2 max-w-md">
Сервер отказывается варить кофе, потому что он чайник.
</p>
<p className="text-gray-500 text-sm mb-8 max-w-md">
RFC 2324, Hyper Text Coffee Pot Control Protocol
</p>
{/* Fun fact */}
<div className="glass rounded-xl p-4 mb-8 max-w-md border border-amber-500/20">
<div className="flex items-center gap-2 text-amber-400 mb-2">
<Coffee className="w-4 h-4" />
<span className="text-sm font-semibold">Fun fact</span>
</div>
<p className="text-gray-400 text-sm">
Это настоящий HTTP-код ответа из первоапрельской шутки 1998 года.
Нажми на чайник!
</p>
</div>
{/* Button */}
<Link to="/">
<NeonButton size="lg" icon={<Home className="w-5 h-5" />}>
На главную
</NeonButton>
</Link>
{/* Decorative sparkles */}
<div className="absolute top-1/4 left-1/4 opacity-20">
<Sparkles className="w-6 h-6 text-amber-400 animate-pulse" />
</div>
<div className="absolute bottom-1/3 right-1/4 opacity-20">
<Sparkles className="w-4 h-4 text-orange-400 animate-pulse" style={{ animationDelay: '1s' }} />
</div>
{/* Custom animations */}
<style>{`
@keyframes steam {
0% {
transform: translateY(0) scaleX(1);
opacity: 0.5;
}
50% {
transform: translateY(-10px) scaleX(1.2);
opacity: 0.3;
}
100% {
transform: translateY(-20px) scaleX(0.8);
opacity: 0;
}
}
.animate-steam {
animation: steam 2s ease-in-out infinite;
}
`}</style>
</div>
)
}

View File

@@ -3,10 +3,10 @@ import { useParams, useNavigate, Link } from 'react-router-dom'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { usersApi } from '@/api' import { usersApi } from '@/api'
import type { UserProfilePublic } from '@/types' import type { UserProfilePublic } from '@/types'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui' import { GlassCard, StatsCard } from '@/components/ui'
import { import {
User, Trophy, Target, CheckCircle, Flame, User, Trophy, Target, CheckCircle, Flame,
Loader2, ArrowLeft, Calendar Loader2, ArrowLeft, Calendar, Zap
} from 'lucide-react' } from 'lucide-react'
export function UserProfilePage() { export function UserProfilePage() {
@@ -82,8 +82,9 @@ export function UserProfilePage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex justify-center py-12"> <div className="flex flex-col items-center justify-center py-24">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" /> <Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
<p className="text-gray-400">Загрузка профиля...</p>
</div> </div>
) )
} }
@@ -91,17 +92,17 @@ export function UserProfilePage() {
if (error || !profile) { if (error || !profile) {
return ( return (
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
<Card> <GlassCard className="py-12 text-center">
<CardContent className="py-12 text-center"> <div className="w-20 h-20 mx-auto mb-4 rounded-2xl bg-dark-700 flex items-center justify-center">
<User className="w-16 h-16 text-gray-600 mx-auto mb-4" /> <User className="w-10 h-10 text-gray-600" />
</div>
<h2 className="text-xl font-bold text-white mb-2"> <h2 className="text-xl font-bold text-white mb-2">
{error || 'Пользователь не найден'} {error || 'Пользователь не найден'}
</h2> </h2>
<Link to="/" className="text-primary-400 hover:text-primary-300"> <Link to="/" className="text-neon-400 hover:text-neon-300 transition-colors">
Вернуться на главную Вернуться на главную
</Link> </Link>
</CardContent> </GlassCard>
</Card>
</div> </div>
) )
} }
@@ -111,18 +112,18 @@ export function UserProfilePage() {
{/* Кнопка назад */} {/* Кнопка назад */}
<button <button
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors" className="flex items-center gap-2 text-gray-400 hover:text-neon-400 transition-colors group"
> >
<ArrowLeft className="w-5 h-5" /> <ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
Назад Назад
</button> </button>
{/* Профиль */} {/* Профиль */}
<Card> <GlassCard variant="neon">
<CardContent className="pt-6">
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
{/* Аватар */} {/* Аватар */}
<div className="w-24 h-24 rounded-full overflow-hidden bg-gray-700 flex-shrink-0"> <div className="relative">
<div className="w-24 h-24 rounded-2xl overflow-hidden bg-dark-700 border-2 border-neon-500/30 shadow-[0_0_14px_rgba(34,211,238,0.15)]">
{avatarBlobUrl ? ( {avatarBlobUrl ? (
<img <img
src={avatarBlobUrl} src={avatarBlobUrl}
@@ -130,11 +131,16 @@ export function UserProfilePage() {
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
) : ( ) : (
<div className="w-full h-full flex items-center justify-center"> <div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-dark-700 to-dark-800">
<User className="w-12 h-12 text-gray-500" /> <User className="w-12 h-12 text-gray-500" />
</div> </div>
)} )}
</div> </div>
{/* Online indicator effect */}
<div className="absolute -bottom-1 -right-1 w-6 h-6 rounded-lg bg-neon-500/20 border border-neon-500/30 flex items-center justify-center">
<Zap className="w-3 h-3 text-neon-400" />
</div>
</div>
{/* Инфо */} {/* Инфо */}
<div> <div>
@@ -142,55 +148,52 @@ export function UserProfilePage() {
{profile.nickname} {profile.nickname}
</h1> </h1>
<div className="flex items-center gap-2 text-gray-400 text-sm"> <div className="flex items-center gap-2 text-gray-400 text-sm">
<Calendar className="w-4 h-4" /> <Calendar className="w-4 h-4 text-accent-400" />
<span>Зарегистрирован {formatDate(profile.created_at)}</span> <span>Зарегистрирован {formatDate(profile.created_at)}</span>
</div> </div>
</div> </div>
</div> </div>
</CardContent> </GlassCard>
</Card>
{/* Статистика */} {/* Статистика */}
<Card> <GlassCard>
<CardHeader> <div className="flex items-center gap-3 mb-6">
<CardTitle className="flex items-center gap-2"> <div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center">
<Trophy className="w-5 h-5 text-yellow-500" /> <Trophy className="w-5 h-5 text-yellow-400" />
Статистика </div>
</CardTitle> <div>
</CardHeader> <h2 className="font-semibold text-white">Статистика</h2>
<CardContent> <p className="text-sm text-gray-400">Достижения игрока</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-900 rounded-lg p-4 text-center"> <StatsCard
<Target className="w-6 h-6 text-blue-400 mx-auto mb-2" /> label="Марафонов"
<div className="text-2xl font-bold text-white"> value={profile.stats.marathons_count}
{profile.stats.marathons_count} icon={<Target className="w-6 h-6" />}
color="neon"
/>
<StatsCard
label="Побед"
value={profile.stats.wins_count}
icon={<Trophy className="w-6 h-6" />}
color="purple"
/>
<StatsCard
label="Заданий"
value={profile.stats.completed_assignments}
icon={<CheckCircle className="w-6 h-6" />}
color="default"
/>
<StatsCard
label="Очков"
value={profile.stats.total_points_earned}
icon={<Flame className="w-6 h-6" />}
color="pink"
/>
</div> </div>
<div className="text-sm text-gray-400">Марафонов</div> </GlassCard>
</div>
<div className="bg-gray-900 rounded-lg p-4 text-center">
<Trophy className="w-6 h-6 text-yellow-500 mx-auto mb-2" />
<div className="text-2xl font-bold text-white">
{profile.stats.wins_count}
</div>
<div className="text-sm text-gray-400">Побед</div>
</div>
<div className="bg-gray-900 rounded-lg p-4 text-center">
<CheckCircle className="w-6 h-6 text-green-500 mx-auto mb-2" />
<div className="text-2xl font-bold text-white">
{profile.stats.completed_assignments}
</div>
<div className="text-sm text-gray-400">Заданий</div>
</div>
<div className="bg-gray-900 rounded-lg p-4 text-center">
<Flame className="w-6 h-6 text-orange-500 mx-auto mb-2" />
<div className="text-2xl font-bold text-white">
{profile.stats.total_points_earned}
</div>
<div className="text-sm text-gray-400">Очков</div>
</div>
</div>
</CardContent>
</Card>
</div> </div>
) )
} }

View File

@@ -10,3 +10,5 @@ export { LeaderboardPage } from './LeaderboardPage'
export { ProfilePage } from './ProfilePage' export { ProfilePage } from './ProfilePage'
export { UserProfilePage } from './UserProfilePage' export { UserProfilePage } from './UserProfilePage'
export { NotFoundPage } from './NotFoundPage' export { NotFoundPage } from './NotFoundPage'
export { TeapotPage } from './TeapotPage'
export { ServerErrorPage } from './ServerErrorPage'

View File

@@ -10,6 +10,7 @@ interface AuthState {
isLoading: boolean isLoading: boolean
error: string | null error: string | null
pendingInviteCode: string | null pendingInviteCode: string | null
avatarVersion: number
login: (data: LoginData) => Promise<void> login: (data: LoginData) => Promise<void>
register: (data: RegisterData) => Promise<void> register: (data: RegisterData) => Promise<void>
@@ -18,6 +19,7 @@ interface AuthState {
setPendingInviteCode: (code: string | null) => void setPendingInviteCode: (code: string | null) => void
consumePendingInviteCode: () => string | null consumePendingInviteCode: () => string | null
updateUser: (updates: Partial<User>) => void updateUser: (updates: Partial<User>) => void
bumpAvatarVersion: () => void
} }
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
@@ -29,6 +31,7 @@ export const useAuthStore = create<AuthState>()(
isLoading: false, isLoading: false,
error: null, error: null,
pendingInviteCode: null, pendingInviteCode: null,
avatarVersion: 0,
login: async (data) => { login: async (data) => {
set({ isLoading: true, error: null }) set({ isLoading: true, error: null })
@@ -97,6 +100,10 @@ export const useAuthStore = create<AuthState>()(
set({ user: { ...currentUser, ...updates } }) set({ user: { ...currentUser, ...updates } })
} }
}, },
bumpAvatarVersion: () => {
set({ avatarVersion: get().avatarVersion + 1 })
},
}), }),
{ {
name: 'auth-storage', name: 'auth-storage',

View File

@@ -1,20 +1,25 @@
// User types // User types
export type UserRole = 'user' | 'admin' export type UserRole = 'user' | 'admin'
export interface User { // Public user info (visible to other users)
export interface UserPublic {
id: number id: number
login: string
nickname: string nickname: string
avatar_url: string | null avatar_url: string | null
role: UserRole role: UserRole
telegram_id: number | null
telegram_username: string | null
telegram_first_name: string | null
telegram_last_name: string | null
telegram_avatar_url: string | null telegram_avatar_url: string | null
created_at: string created_at: string
} }
// Full user info (only for own profile from /auth/me)
export interface User extends UserPublic {
login?: string // Only visible to self
telegram_id?: number | null // Only visible to self
telegram_username?: string | null // Only visible to self
telegram_first_name?: string | null // Only visible to self
telegram_last_name?: string | null // Only visible to self
}
export interface TokenResponse { export interface TokenResponse {
access_token: string access_token: string
token_type: string token_type: string

View File

@@ -7,25 +7,91 @@ export default {
theme: { theme: {
extend: { extend: {
colors: { colors: {
primary: { // Base dark colors - slightly warmer tones
50: '#f0f9ff', dark: {
100: '#e0f2fe', 950: '#08090d',
200: '#bae6fd', 900: '#0d0e14',
300: '#7dd3fc', 800: '#14161e',
400: '#38bdf8', 700: '#1c1e28',
500: '#0ea5e9', 600: '#252732',
600: '#0284c7', 500: '#2e313d',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
950: '#082f49',
}, },
// Soft cyan (primary) - gentler on eyes
neon: {
50: '#ecfeff',
100: '#cffafe',
200: '#a5f3fc',
300: '#67e8f9',
400: '#67e8f9',
500: '#22d3ee',
600: '#06b6d4',
700: '#0891b2',
800: '#155e75',
900: '#164e63',
},
// Soft violet accent
accent: {
50: '#f5f3ff',
100: '#ede9fe',
200: '#ddd6fe',
300: '#c4b5fd',
400: '#a78bfa',
500: '#8b5cf6',
600: '#7c3aed',
700: '#6d28d9',
800: '#5b21b6',
900: '#4c1d95',
},
// Soft pink highlight - used sparingly
pink: {
400: '#f472b6',
500: '#ec4899',
600: '#db2777',
},
// Keep primary for backwards compatibility
primary: {
50: '#ecfeff',
100: '#cffafe',
200: '#a5f3fc',
300: '#67e8f9',
400: '#67e8f9',
500: '#22d3ee',
600: '#06b6d4',
700: '#0891b2',
800: '#155e75',
900: '#164e63',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
display: ['Orbitron', 'sans-serif'],
}, },
animation: { animation: {
// Existing
'spin-slow': 'spin 3s linear infinite', 'spin-slow': 'spin 3s linear infinite',
'wheel-spin': 'wheel-spin 4s cubic-bezier(0.17, 0.67, 0.12, 0.99) forwards', 'wheel-spin': 'wheel-spin 4s cubic-bezier(0.17, 0.67, 0.12, 0.99) forwards',
'fade-in': 'fade-in 0.3s ease-out', 'fade-in': 'fade-in 0.3s ease-out forwards',
'slide-up': 'slide-up 0.3s ease-out', 'slide-up': 'slide-up 0.3s ease-out forwards',
// New animations
'glitch': 'glitch 1s linear infinite',
'glitch-1': 'glitch-1 0.5s infinite linear alternate-reverse',
'glitch-2': 'glitch-2 0.5s infinite linear alternate-reverse',
'glow-pulse': 'glow-pulse 2s ease-in-out infinite',
'float': 'float 6s ease-in-out infinite',
'shimmer': 'shimmer 2s linear infinite',
'slide-in-right': 'slide-in-right 0.3s ease-out forwards',
'slide-in-left': 'slide-in-left 0.3s ease-out forwards',
'slide-in-up': 'slide-in-up 0.4s ease-out forwards',
'slide-in-down': 'slide-in-down 0.3s ease-out forwards',
'scale-in': 'scale-in 0.2s ease-out forwards',
'bounce-in': 'bounce-in 0.5s ease-out forwards',
'pulse-neon': 'pulse-neon 2s ease-in-out infinite',
'border-flow': 'border-flow 3s linear infinite',
'typing': 'typing 3.5s steps(40, end), blink-caret 0.75s step-end infinite',
'counter': 'counter 2s ease-out forwards',
'shake': 'shake 0.5s ease-in-out',
'confetti': 'confetti 1s ease-out forwards',
}, },
keyframes: { keyframes: {
'wheel-spin': { 'wheel-spin': {
@@ -40,6 +106,119 @@ export default {
'0%': { opacity: '0', transform: 'translateY(10px)' }, '0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' }, '100%': { opacity: '1', transform: 'translateY(0)' },
}, },
'glitch': {
'0%, 100%': { transform: 'translate(0)' },
'20%': { transform: 'translate(-2px, 2px)' },
'40%': { transform: 'translate(-2px, -2px)' },
'60%': { transform: 'translate(2px, 2px)' },
'80%': { transform: 'translate(2px, -2px)' },
},
'glitch-1': {
'0%': { clipPath: 'inset(20% 0 60% 0)' },
'100%': { clipPath: 'inset(50% 0 30% 0)' },
},
'glitch-2': {
'0%': { clipPath: 'inset(60% 0 20% 0)' },
'100%': { clipPath: 'inset(30% 0 50% 0)' },
},
'glow-pulse': {
'0%, 100%': {
boxShadow: '0 0 6px rgba(34, 211, 238, 0.4), 0 0 12px rgba(34, 211, 238, 0.2)'
},
'50%': {
boxShadow: '0 0 10px rgba(34, 211, 238, 0.5), 0 0 20px rgba(34, 211, 238, 0.3)'
},
},
'float': {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-10px)' },
},
'shimmer': {
'0%': { backgroundPosition: '-200% 0' },
'100%': { backgroundPosition: '200% 0' },
},
'slide-in-right': {
'0%': { opacity: '0', transform: 'translateX(20px)' },
'100%': { opacity: '1', transform: 'translateX(0)' },
},
'slide-in-left': {
'0%': { opacity: '0', transform: 'translateX(-20px)' },
'100%': { opacity: '1', transform: 'translateX(0)' },
},
'slide-in-up': {
'0%': { opacity: '0', transform: 'translateY(30px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
'slide-in-down': {
'0%': { opacity: '0', transform: 'translateY(-20px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
'scale-in': {
'0%': { opacity: '0', transform: 'scale(0.9)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
'bounce-in': {
'0%': { opacity: '0', transform: 'scale(0.3)' },
'50%': { transform: 'scale(1.05)' },
'70%': { transform: 'scale(0.9)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
'pulse-neon': {
'0%, 100%': {
textShadow: '0 0 6px rgba(34, 211, 238, 0.5), 0 0 12px rgba(34, 211, 238, 0.25)'
},
'50%': {
textShadow: '0 0 10px rgba(34, 211, 238, 0.6), 0 0 18px rgba(34, 211, 238, 0.35)'
},
},
'border-flow': {
'0%': { backgroundPosition: '0% 50%' },
'50%': { backgroundPosition: '100% 50%' },
'100%': { backgroundPosition: '0% 50%' },
},
'typing': {
'from': { width: '0' },
'to': { width: '100%' },
},
'blink-caret': {
'from, to': { borderColor: 'transparent' },
'50%': { borderColor: '#22d3ee' },
},
'shake': {
'0%, 100%': { transform: 'translateX(0)' },
'10%, 30%, 50%, 70%, 90%': { transform: 'translateX(-5px)' },
'20%, 40%, 60%, 80%': { transform: 'translateX(5px)' },
},
'confetti': {
'0%': { transform: 'translateY(0) rotate(0deg)', opacity: '1' },
'100%': { transform: 'translateY(100vh) rotate(720deg)', opacity: '0' },
},
},
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
'neon-glow': 'linear-gradient(90deg, #22d3ee, #8b5cf6, #22d3ee)',
'cyber-grid': `
linear-gradient(rgba(34, 211, 238, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(34, 211, 238, 0.02) 1px, transparent 1px)
`,
},
backgroundSize: {
'grid': '50px 50px',
},
boxShadow: {
'neon': '0 0 8px rgba(34, 211, 238, 0.4), 0 0 16px rgba(34, 211, 238, 0.2)',
'neon-lg': '0 0 12px rgba(34, 211, 238, 0.5), 0 0 24px rgba(34, 211, 238, 0.3)',
'neon-purple': '0 0 8px rgba(139, 92, 246, 0.4), 0 0 16px rgba(139, 92, 246, 0.2)',
'neon-pink': '0 0 8px rgba(244, 114, 182, 0.4), 0 0 16px rgba(244, 114, 182, 0.2)',
'inner-glow': 'inset 0 0 20px rgba(34, 211, 238, 0.06)',
'glass': '0 8px 32px 0 rgba(0, 0, 0, 0.37)',
},
backdropBlur: {
'xs': '2px',
},
transitionDuration: {
'400': '400ms',
}, },
}, },
}, },

View File

@@ -17,6 +17,10 @@ http {
# File upload limit (15 MB) # File upload limit (15 MB)
client_max_body_size 15M; client_max_body_size 15M;
# Rate limiting zones
limit_req_zone $binary_remote_addr zone=api_auth:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=api_general:10m rate=60r/m;
upstream backend { upstream backend {
server backend:8000; server backend:8000;
} }
@@ -37,8 +41,22 @@ http {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
} }
# Backend API # Auth API - strict rate limit (10 req/min with burst of 5)
location /api/v1/auth {
limit_req zone=api_auth burst=5 nodelay;
limit_req_status 429;
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Backend API - general rate limit (60 req/min with burst of 20)
location /api { location /api {
limit_req zone=api_general burst=20 nodelay;
limit_req_status 429;
proxy_pass http://backend; proxy_pass http://backend;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;

16
status-service/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Create data directory for SQLite
RUN mkdir -p /app/data
# Copy application
COPY . .
# Run the application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]

85
status-service/alerts.py Normal file
View File

@@ -0,0 +1,85 @@
"""Telegram alerting for status changes."""
import os
from datetime import datetime
from typing import Optional
import httpx
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
TELEGRAM_ADMIN_ID = os.getenv("TELEGRAM_ADMIN_ID", "")
async def send_telegram_alert(message: str, is_recovery: bool = False) -> bool:
"""Send alert to Telegram."""
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_ADMIN_ID:
print("Telegram alerting not configured")
return False
emoji = "\u2705" if is_recovery else "\u26a0\ufe0f"
text = f"{emoji} *Status Alert*\n\n{message}"
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
data = {
"chat_id": TELEGRAM_ADMIN_ID,
"text": text,
"parse_mode": "Markdown",
}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, json=data)
response.raise_for_status()
print(f"Telegram alert sent: {message[:50]}...")
return True
except Exception as e:
print(f"Failed to send Telegram alert: {e}")
return False
async def alert_service_down(service_name: str, display_name: str, message: Optional[str]):
"""Alert when service goes down."""
now = datetime.now().strftime("%d.%m.%Y %H:%M:%S")
text = (
f"*{display_name}* is DOWN\n\n"
f"Time: `{now}`\n"
)
if message:
text += f"Error: `{message}`"
await send_telegram_alert(text, is_recovery=False)
async def alert_service_recovered(service_name: str, display_name: str, downtime_minutes: int):
"""Alert when service recovers."""
now = datetime.now().strftime("%d.%m.%Y %H:%M:%S")
text = (
f"*{display_name}* is back ONLINE\n\n"
f"Time: `{now}`\n"
f"Downtime: `{downtime_minutes} min`"
)
await send_telegram_alert(text, is_recovery=True)
async def alert_ssl_expiring(domain: str, days_left: int):
"""Alert when SSL certificate is expiring soon."""
text = (
f"*SSL Certificate Expiring*\n\n"
f"Domain: `{domain}`\n"
f"Days left: `{days_left}`\n\n"
f"Please renew the certificate!"
)
await send_telegram_alert(text, is_recovery=False)
async def alert_ssl_expired(domain: str):
"""Alert when SSL certificate has expired."""
text = (
f"*SSL Certificate EXPIRED*\n\n"
f"Domain: `{domain}`\n\n"
f"Certificate has expired! Site may show security warnings."
)
await send_telegram_alert(text, is_recovery=False)

261
status-service/database.py Normal file
View File

@@ -0,0 +1,261 @@
"""SQLite database for storing metrics history."""
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
import json
DB_PATH = Path("/app/data/metrics.db")
def get_connection() -> sqlite3.Connection:
"""Get database connection."""
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
return conn
def init_db():
"""Initialize database tables."""
conn = get_connection()
cursor = conn.cursor()
# Metrics history table
cursor.execute("""
CREATE TABLE IF NOT EXISTS metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_name TEXT NOT NULL,
status TEXT NOT NULL,
latency_ms REAL,
message TEXT,
checked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Incidents table
cursor.execute("""
CREATE TABLE IF NOT EXISTS incidents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_name TEXT NOT NULL,
status TEXT NOT NULL,
message TEXT,
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
resolved_at TIMESTAMP,
notified BOOLEAN DEFAULT FALSE
)
""")
# SSL certificates table
cursor.execute("""
CREATE TABLE IF NOT EXISTS ssl_certificates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL UNIQUE,
issuer TEXT,
expires_at TIMESTAMP,
days_until_expiry INTEGER,
checked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Create indexes
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_metrics_service_time
ON metrics(service_name, checked_at DESC)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_incidents_service
ON incidents(service_name, started_at DESC)
""")
conn.commit()
conn.close()
def save_metric(service_name: str, status: str, latency_ms: Optional[float], message: Optional[str]):
"""Save a metric record."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute(
"INSERT INTO metrics (service_name, status, latency_ms, message) VALUES (?, ?, ?, ?)",
(service_name, status, latency_ms, message)
)
conn.commit()
conn.close()
def get_latency_history(service_name: str, hours: int = 24) -> list[dict]:
"""Get latency history for a service."""
conn = get_connection()
cursor = conn.cursor()
since = datetime.now() - timedelta(hours=hours)
cursor.execute("""
SELECT latency_ms, status, checked_at
FROM metrics
WHERE service_name = ? AND checked_at > ? AND latency_ms IS NOT NULL
ORDER BY checked_at ASC
""", (service_name, since.isoformat()))
rows = cursor.fetchall()
conn.close()
return [
{
"latency_ms": row["latency_ms"],
"status": row["status"],
"checked_at": row["checked_at"]
}
for row in rows
]
def get_uptime_stats(service_name: str, hours: int = 24) -> dict:
"""Calculate uptime statistics for a service."""
conn = get_connection()
cursor = conn.cursor()
since = datetime.now() - timedelta(hours=hours)
cursor.execute("""
SELECT COUNT(*) as total,
SUM(CASE WHEN status = 'operational' THEN 1 ELSE 0 END) as successful
FROM metrics
WHERE service_name = ? AND checked_at > ?
""", (service_name, since.isoformat()))
row = cursor.fetchone()
conn.close()
total = row["total"] or 0
successful = row["successful"] or 0
return {
"total_checks": total,
"successful_checks": successful,
"uptime_percent": (successful / total * 100) if total > 0 else 100.0
}
def get_avg_latency(service_name: str, hours: int = 24) -> Optional[float]:
"""Get average latency for a service."""
conn = get_connection()
cursor = conn.cursor()
since = datetime.now() - timedelta(hours=hours)
cursor.execute("""
SELECT AVG(latency_ms) as avg_latency
FROM metrics
WHERE service_name = ? AND checked_at > ? AND latency_ms IS NOT NULL
""", (service_name, since.isoformat()))
row = cursor.fetchone()
conn.close()
return row["avg_latency"]
def create_incident(service_name: str, status: str, message: Optional[str]) -> int:
"""Create a new incident."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute(
"INSERT INTO incidents (service_name, status, message) VALUES (?, ?, ?)",
(service_name, status, message)
)
incident_id = cursor.lastrowid
conn.commit()
conn.close()
return incident_id
def resolve_incident(service_name: str):
"""Resolve open incidents for a service."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
UPDATE incidents
SET resolved_at = CURRENT_TIMESTAMP
WHERE service_name = ? AND resolved_at IS NULL
""", (service_name,))
conn.commit()
conn.close()
def get_open_incident(service_name: str) -> Optional[dict]:
"""Get open incident for a service."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM incidents
WHERE service_name = ? AND resolved_at IS NULL
ORDER BY started_at DESC LIMIT 1
""", (service_name,))
row = cursor.fetchone()
conn.close()
if row:
return dict(row)
return None
def mark_incident_notified(incident_id: int):
"""Mark incident as notified."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("UPDATE incidents SET notified = TRUE WHERE id = ?", (incident_id,))
conn.commit()
conn.close()
def get_recent_incidents(limit: int = 10) -> list[dict]:
"""Get recent incidents."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM incidents
ORDER BY started_at DESC
LIMIT ?
""", (limit,))
rows = cursor.fetchall()
conn.close()
return [dict(row) for row in rows]
def save_ssl_info(domain: str, issuer: str, expires_at: datetime, days_until_expiry: int):
"""Save SSL certificate info."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
INSERT OR REPLACE INTO ssl_certificates
(domain, issuer, expires_at, days_until_expiry, checked_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
""", (domain, issuer, expires_at.isoformat(), days_until_expiry))
conn.commit()
conn.close()
def get_ssl_info(domain: str) -> Optional[dict]:
"""Get SSL certificate info."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("SELECT * FROM ssl_certificates WHERE domain = ?", (domain,))
row = cursor.fetchone()
conn.close()
if row:
return dict(row)
return None
def cleanup_old_metrics(days: int = 7):
"""Delete metrics older than specified days."""
conn = get_connection()
cursor = conn.cursor()
cutoff = datetime.now() - timedelta(days=days)
cursor.execute("DELETE FROM metrics WHERE checked_at < ?", (cutoff.isoformat(),))
deleted = cursor.rowcount
conn.commit()
conn.close()
return deleted

165
status-service/main.py Normal file
View File

@@ -0,0 +1,165 @@
"""Status monitoring service with persistence and alerting."""
import os
import asyncio
from datetime import datetime
from typing import Optional
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from monitors import ServiceMonitor
from database import init_db, get_recent_incidents, get_latency_history, cleanup_old_metrics
# Configuration
BACKEND_URL = os.getenv("BACKEND_URL", "http://backend:8000")
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://frontend:80")
BOT_URL = os.getenv("BOT_URL", "http://bot:8080")
EXTERNAL_URL = os.getenv("EXTERNAL_URL", "") # Public URL for external checks
PUBLIC_URL = os.getenv("PUBLIC_URL", "") # Public HTTPS URL for SSL checks
CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "30"))
# Initialize monitor
monitor = ServiceMonitor()
# Background task reference
background_task: Optional[asyncio.Task] = None
cleanup_task: Optional[asyncio.Task] = None
async def periodic_health_check():
"""Background task to check services periodically."""
while True:
try:
await monitor.check_all_services(
backend_url=BACKEND_URL,
frontend_url=FRONTEND_URL,
bot_url=BOT_URL,
external_url=EXTERNAL_URL,
public_url=PUBLIC_URL
)
except Exception as e:
print(f"Health check error: {e}")
await asyncio.sleep(CHECK_INTERVAL)
async def periodic_cleanup():
"""Background task to cleanup old metrics (daily)."""
while True:
await asyncio.sleep(86400) # 24 hours
try:
deleted = cleanup_old_metrics(days=7)
print(f"Cleaned up {deleted} old metrics")
except Exception as e:
print(f"Cleanup error: {e}")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup and shutdown events."""
global background_task, cleanup_task
# Initialize database
init_db()
print("Database initialized")
# Start background health checks
background_task = asyncio.create_task(periodic_health_check())
cleanup_task = asyncio.create_task(periodic_cleanup())
yield
# Cancel background tasks on shutdown
for task in [background_task, cleanup_task]:
if task:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
app = FastAPI(
title="Status Monitor",
description="Service health monitoring with persistence and alerting",
lifespan=lifespan
)
templates = Jinja2Templates(directory="templates")
@app.get("/", response_class=HTMLResponse)
async def status_page(request: Request):
"""Main status page."""
services = monitor.get_all_statuses()
overall_status = monitor.get_overall_status()
ssl_status = monitor.get_ssl_status()
incidents = get_recent_incidents(limit=5)
return templates.TemplateResponse(
"index.html",
{
"request": request,
"services": services,
"overall_status": overall_status,
"ssl_status": ssl_status,
"incidents": incidents,
"last_check": monitor.last_check,
"check_interval": CHECK_INTERVAL
}
)
@app.get("/api/status")
async def api_status():
"""API endpoint for service statuses."""
services = monitor.get_all_statuses()
overall_status = monitor.get_overall_status()
ssl_status = monitor.get_ssl_status()
return {
"overall_status": overall_status.value,
"services": {name: status.to_dict() for name, status in services.items()},
"ssl": ssl_status,
"last_check": monitor.last_check.isoformat() if monitor.last_check else None,
"check_interval_seconds": CHECK_INTERVAL
}
@app.get("/api/history/{service_name}")
async def api_history(service_name: str, hours: int = 24):
"""API endpoint for service latency history."""
history = get_latency_history(service_name, hours=hours)
return {
"service": service_name,
"hours": hours,
"data": history
}
@app.get("/api/incidents")
async def api_incidents(limit: int = 20):
"""API endpoint for recent incidents."""
incidents = get_recent_incidents(limit=limit)
return {"incidents": incidents}
@app.get("/api/health")
async def health():
"""Health check for this service."""
return {"status": "ok", "service": "status-monitor"}
@app.post("/api/refresh")
async def refresh_status():
"""Force refresh all service statuses."""
await monitor.check_all_services(
backend_url=BACKEND_URL,
frontend_url=FRONTEND_URL,
bot_url=BOT_URL,
external_url=EXTERNAL_URL,
public_url=PUBLIC_URL
)
return {"status": "refreshed"}

305
status-service/monitors.py Normal file
View File

@@ -0,0 +1,305 @@
"""Service monitoring with persistence and alerting."""
import asyncio
from datetime import datetime, timedelta
from dataclasses import dataclass
from typing import Optional
from enum import Enum
import httpx
from database import (
save_metric, get_latency_history, get_uptime_stats, get_avg_latency,
create_incident, resolve_incident, get_open_incident, mark_incident_notified
)
from alerts import alert_service_down, alert_service_recovered
from ssl_monitor import check_and_alert_ssl, SSLInfo
class Status(str, Enum):
OPERATIONAL = "operational"
DEGRADED = "degraded"
DOWN = "down"
UNKNOWN = "unknown"
@dataclass
class ServiceStatus:
name: str
display_name: str
status: Status = Status.UNKNOWN
latency_ms: Optional[float] = None
last_check: Optional[datetime] = None
last_incident: Optional[datetime] = None
uptime_percent: float = 100.0
message: Optional[str] = None
version: Optional[str] = None
avg_latency_24h: Optional[float] = None
latency_history: list = None
# For uptime calculation (in-memory, backed by DB)
total_checks: int = 0
successful_checks: int = 0
def __post_init__(self):
if self.latency_history is None:
self.latency_history = []
def to_dict(self) -> dict:
return {
"name": self.name,
"display_name": self.display_name,
"status": self.status.value,
"latency_ms": round(self.latency_ms, 2) if self.latency_ms else None,
"last_check": self.last_check.isoformat() if self.last_check else None,
"last_incident": self.last_incident.isoformat() if self.last_incident else None,
"uptime_percent": round(self.uptime_percent, 2),
"message": self.message,
"version": self.version,
"avg_latency_24h": round(self.avg_latency_24h, 2) if self.avg_latency_24h else None,
}
def update_uptime(self, is_success: bool):
self.total_checks += 1
if is_success:
self.successful_checks += 1
if self.total_checks > 0:
self.uptime_percent = (self.successful_checks / self.total_checks) * 100
class ServiceMonitor:
def __init__(self):
self.services: dict[str, ServiceStatus] = {
"backend": ServiceStatus(
name="backend",
display_name="Backend API"
),
"database": ServiceStatus(
name="database",
display_name="Database"
),
"frontend": ServiceStatus(
name="frontend",
display_name="Frontend"
),
"bot": ServiceStatus(
name="bot",
display_name="Telegram Bot"
),
"external": ServiceStatus(
name="external",
display_name="External Access"
),
}
self.last_check: Optional[datetime] = None
self.ssl_info: Optional[SSLInfo] = None
async def check_backend(self, url: str) -> tuple[Status, Optional[float], Optional[str], Optional[str]]:
"""Check backend API health."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
start = datetime.now()
response = await client.get(f"{url}/health")
latency = (datetime.now() - start).total_seconds() * 1000
if response.status_code == 200:
data = response.json()
return Status.OPERATIONAL, latency, None, data.get("version")
else:
return Status.DEGRADED, latency, f"HTTP {response.status_code}", None
except httpx.TimeoutException:
return Status.DOWN, None, "Timeout", None
except Exception as e:
return Status.DOWN, None, str(e)[:100], None
async def check_database(self, backend_url: str) -> tuple[Status, Optional[float], Optional[str]]:
"""Check database through backend."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
start = datetime.now()
response = await client.get(f"{backend_url}/health")
latency = (datetime.now() - start).total_seconds() * 1000
if response.status_code == 200:
return Status.OPERATIONAL, latency, None
else:
return Status.DOWN, latency, "Backend reports unhealthy"
except Exception as e:
return Status.DOWN, None, "Cannot reach backend"
async def check_frontend(self, url: str) -> tuple[Status, Optional[float], Optional[str]]:
"""Check frontend availability."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
start = datetime.now()
response = await client.get(url)
latency = (datetime.now() - start).total_seconds() * 1000
if response.status_code == 200:
return Status.OPERATIONAL, latency, None
else:
return Status.DEGRADED, latency, f"HTTP {response.status_code}"
except httpx.TimeoutException:
return Status.DOWN, None, "Timeout"
except Exception as e:
return Status.DOWN, None, str(e)[:100]
async def check_bot(self, url: str) -> tuple[Status, Optional[float], Optional[str]]:
"""Check Telegram bot health."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
start = datetime.now()
response = await client.get(f"{url}/health")
latency = (datetime.now() - start).total_seconds() * 1000
if response.status_code == 200:
return Status.OPERATIONAL, latency, None
else:
return Status.DEGRADED, latency, f"HTTP {response.status_code}"
except httpx.TimeoutException:
return Status.DOWN, None, "Timeout"
except Exception as e:
return Status.DOWN, None, str(e)[:100]
async def check_external(self, url: str) -> tuple[Status, Optional[float], Optional[str]]:
"""Check external (public) URL availability."""
if not url:
return Status.UNKNOWN, None, "Not configured"
try:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
start = datetime.now()
response = await client.get(url)
latency = (datetime.now() - start).total_seconds() * 1000
if response.status_code == 200:
return Status.OPERATIONAL, latency, None
else:
return Status.DEGRADED, latency, f"HTTP {response.status_code}"
except httpx.TimeoutException:
return Status.DOWN, None, "Timeout"
except Exception as e:
return Status.DOWN, None, str(e)[:100]
async def _process_check_result(
self,
service_name: str,
result: tuple,
now: datetime
):
"""Process check result with DB persistence and alerting."""
if isinstance(result, Exception):
return
if len(result) == 4:
status, latency, message, version = result
else:
status, latency, message = result
version = None
svc = self.services[service_name]
was_down = svc.status in (Status.DOWN, Status.DEGRADED)
is_down = status in (Status.DOWN, Status.DEGRADED)
# Update service status
svc.status = status
svc.latency_ms = latency
svc.message = message
if version:
svc.version = version
svc.last_check = now
svc.update_uptime(status == Status.OPERATIONAL)
# Save metric to database
save_metric(service_name, status.value, latency, message)
# Load historical data
svc.latency_history = get_latency_history(service_name, hours=24)
svc.avg_latency_24h = get_avg_latency(service_name, hours=24)
# Update uptime from DB
stats = get_uptime_stats(service_name, hours=24)
if stats["total_checks"] > 0:
svc.uptime_percent = stats["uptime_percent"]
# Handle incident tracking and alerting
if is_down and not was_down:
# Service just went down
svc.last_incident = now
incident_id = create_incident(service_name, status.value, message)
await alert_service_down(service_name, svc.display_name, message)
mark_incident_notified(incident_id)
elif not is_down and was_down:
# Service recovered
open_incident = get_open_incident(service_name)
if open_incident:
started_at = datetime.fromisoformat(open_incident["started_at"])
downtime_minutes = int((now - started_at).total_seconds() / 60)
resolve_incident(service_name)
await alert_service_recovered(service_name, svc.display_name, downtime_minutes)
async def check_all_services(
self,
backend_url: str,
frontend_url: str,
bot_url: str,
external_url: str = "",
public_url: str = ""
):
"""Check all services concurrently."""
now = datetime.now()
# Run all checks concurrently
results = await asyncio.gather(
self.check_backend(backend_url),
self.check_database(backend_url),
self.check_frontend(frontend_url),
self.check_bot(bot_url),
self.check_external(external_url),
return_exceptions=True
)
# Process results
service_names = ["backend", "database", "frontend", "bot", "external"]
for i, service_name in enumerate(service_names):
await self._process_check_result(service_name, results[i], now)
# Check SSL certificate (if public URL is HTTPS)
if public_url and public_url.startswith("https://"):
self.ssl_info = await check_and_alert_ssl(public_url)
self.last_check = now
def get_all_statuses(self) -> dict[str, ServiceStatus]:
return self.services
def get_overall_status(self) -> Status:
"""Get overall system status based on all services."""
# Exclude external from overall status if not configured
statuses = [
svc.status for name, svc in self.services.items()
if name != "external" or svc.status != Status.UNKNOWN
]
if all(s == Status.OPERATIONAL for s in statuses):
return Status.OPERATIONAL
elif any(s == Status.DOWN for s in statuses):
return Status.DOWN
elif any(s == Status.DEGRADED for s in statuses):
return Status.DEGRADED
else:
return Status.UNKNOWN
def get_ssl_status(self) -> Optional[dict]:
"""Get SSL certificate status."""
if not self.ssl_info:
return None
return {
"domain": self.ssl_info.domain,
"issuer": self.ssl_info.issuer,
"expires_at": self.ssl_info.expires_at.isoformat(),
"days_until_expiry": self.ssl_info.days_until_expiry,
"is_valid": self.ssl_info.is_valid,
"error": self.ssl_info.error
}

View File

@@ -0,0 +1,5 @@
fastapi==0.109.0
uvicorn==0.27.0
httpx==0.26.0
jinja2==3.1.3
python-dotenv==1.0.0

View File

@@ -0,0 +1,140 @@
"""SSL certificate monitoring."""
import ssl
import socket
from datetime import datetime, timezone
from dataclasses import dataclass
from typing import Optional
from urllib.parse import urlparse
from database import save_ssl_info, get_ssl_info
from alerts import alert_ssl_expiring, alert_ssl_expired
@dataclass
class SSLInfo:
domain: str
issuer: str
expires_at: datetime
days_until_expiry: int
is_valid: bool
error: Optional[str] = None
def check_ssl_certificate(url: str) -> Optional[SSLInfo]:
"""Check SSL certificate for a URL."""
try:
parsed = urlparse(url)
hostname = parsed.hostname
if not hostname:
return None
# Skip non-HTTPS or localhost
if parsed.scheme != "https" or hostname in ("localhost", "127.0.0.1"):
return None
context = ssl.create_default_context()
conn = context.wrap_socket(
socket.socket(socket.AF_INET),
server_hostname=hostname
)
conn.settimeout(10.0)
try:
conn.connect((hostname, parsed.port or 443))
cert = conn.getpeercert()
finally:
conn.close()
if not cert:
return SSLInfo(
domain=hostname,
issuer="Unknown",
expires_at=datetime.now(timezone.utc),
days_until_expiry=0,
is_valid=False,
error="No certificate found"
)
# Parse expiry date
not_after = cert.get("notAfter", "")
expires_at = datetime.strptime(not_after, "%b %d %H:%M:%S %Y %Z")
expires_at = expires_at.replace(tzinfo=timezone.utc)
# Calculate days until expiry
now = datetime.now(timezone.utc)
days_until_expiry = (expires_at - now).days
# Get issuer
issuer_parts = cert.get("issuer", ())
issuer = "Unknown"
for part in issuer_parts:
for key, value in part:
if key == "organizationName":
issuer = value
break
return SSLInfo(
domain=hostname,
issuer=issuer,
expires_at=expires_at,
days_until_expiry=days_until_expiry,
is_valid=days_until_expiry > 0
)
except ssl.SSLCertVerificationError as e:
hostname = urlparse(url).hostname or url
return SSLInfo(
domain=hostname,
issuer="Invalid",
expires_at=datetime.now(timezone.utc),
days_until_expiry=0,
is_valid=False,
error=f"SSL verification failed: {str(e)[:100]}"
)
except Exception as e:
hostname = urlparse(url).hostname or url
return SSLInfo(
domain=hostname,
issuer="Unknown",
expires_at=datetime.now(timezone.utc),
days_until_expiry=0,
is_valid=False,
error=str(e)[:100]
)
async def check_and_alert_ssl(url: str, warn_days: int = 14) -> Optional[SSLInfo]:
"""Check SSL and send alerts if needed."""
ssl_info = check_ssl_certificate(url)
if not ssl_info:
return None
# Save to database
save_ssl_info(
domain=ssl_info.domain,
issuer=ssl_info.issuer,
expires_at=ssl_info.expires_at,
days_until_expiry=ssl_info.days_until_expiry
)
# Check if we need to alert
prev_info = get_ssl_info(ssl_info.domain)
if ssl_info.days_until_expiry <= 0:
# Certificate expired
await alert_ssl_expired(ssl_info.domain)
elif ssl_info.days_until_expiry <= warn_days:
# Certificate expiring soon - alert once per day
should_alert = True
if prev_info and prev_info.get("checked_at"):
# Check if we already alerted today
last_check = datetime.fromisoformat(prev_info["checked_at"])
if (datetime.now() - last_check).days < 1:
should_alert = False
if should_alert:
await alert_ssl_expiring(ssl_info.domain, ssl_info.days_until_expiry)
return ssl_info

View File

@@ -0,0 +1,643 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>System Status</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%);
min-height: 100vh;
color: #e0e0e0;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 40px 20px;
}
header {
text-align: center;
margin-bottom: 40px;
}
h1 {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 10px;
background: linear-gradient(135deg, #00d4ff, #a855f7);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
h2 {
font-size: 1.3rem;
font-weight: 600;
margin: 30px 0 16px 0;
color: #94a3b8;
}
.overall-status {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 12px 24px;
border-radius: 50px;
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 10px;
}
.overall-status.operational {
background: rgba(34, 197, 94, 0.15);
border: 1px solid rgba(34, 197, 94, 0.3);
color: #22c55e;
box-shadow: 0 0 20px rgba(34, 197, 94, 0.2);
}
.overall-status.degraded {
background: rgba(250, 204, 21, 0.15);
border: 1px solid rgba(250, 204, 21, 0.3);
color: #facc15;
box-shadow: 0 0 20px rgba(250, 204, 21, 0.2);
}
.overall-status.down {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
box-shadow: 0 0 20px rgba(239, 68, 68, 0.2);
}
.overall-status.unknown {
background: rgba(148, 163, 184, 0.15);
border: 1px solid rgba(148, 163, 184, 0.3);
color: #94a3b8;
}
.status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
animation: pulse 2s infinite;
}
.status-dot.operational { background: #22c55e; }
.status-dot.degraded { background: #facc15; }
.status-dot.down { background: #ef4444; }
.status-dot.unknown { background: #94a3b8; }
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.7; transform: scale(1.1); }
}
.last-update {
color: #64748b;
font-size: 0.9rem;
}
.services-grid {
display: grid;
gap: 16px;
}
.service-card {
background: rgba(30, 41, 59, 0.5);
border: 1px solid rgba(100, 116, 139, 0.2);
border-radius: 16px;
padding: 24px;
backdrop-filter: blur(10px);
transition: all 0.3s ease;
}
.service-card:hover {
border-color: rgba(0, 212, 255, 0.3);
box-shadow: 0 0 30px rgba(0, 212, 255, 0.1);
}
.service-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.service-name {
font-size: 1.25rem;
font-weight: 600;
color: #f1f5f9;
}
.service-status {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.service-status.operational {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.service-status.degraded {
background: rgba(250, 204, 21, 0.15);
color: #facc15;
}
.service-status.down {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.service-status.unknown {
background: rgba(148, 163, 184, 0.15);
color: #94a3b8;
}
.service-status .dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.service-status.operational .dot { background: #22c55e; }
.service-status.degraded .dot { background: #facc15; }
.service-status.down .dot { background: #ef4444; }
.service-status.unknown .dot { background: #94a3b8; }
.service-metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.metric {
background: rgba(15, 23, 42, 0.5);
padding: 12px;
border-radius: 10px;
}
.metric-label {
font-size: 0.75rem;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.metric-value {
font-size: 1.1rem;
font-weight: 600;
color: #e2e8f0;
}
.metric-value.good { color: #22c55e; }
.metric-value.warning { color: #facc15; }
.metric-value.bad { color: #ef4444; }
.service-message {
margin-top: 12px;
padding: 10px 14px;
background: rgba(239, 68, 68, 0.1);
border-left: 3px solid #ef4444;
border-radius: 0 8px 8px 0;
font-size: 0.9rem;
color: #fca5a5;
}
/* Latency chart */
.latency-chart {
height: 60px;
margin-top: 12px;
}
/* SSL Card */
.ssl-card {
background: rgba(30, 41, 59, 0.5);
border: 1px solid rgba(100, 116, 139, 0.2);
border-radius: 16px;
padding: 20px;
margin-bottom: 20px;
}
.ssl-card.warning {
border-color: rgba(250, 204, 21, 0.3);
}
.ssl-card.danger {
border-color: rgba(239, 68, 68, 0.3);
}
.ssl-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.ssl-title {
font-size: 1.1rem;
font-weight: 600;
color: #f1f5f9;
}
.ssl-badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 500;
}
.ssl-badge.valid {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.ssl-badge.expiring {
background: rgba(250, 204, 21, 0.15);
color: #facc15;
}
.ssl-badge.expired {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.ssl-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
}
/* Incidents */
.incidents-list {
background: rgba(30, 41, 59, 0.5);
border: 1px solid rgba(100, 116, 139, 0.2);
border-radius: 16px;
overflow: hidden;
}
.incident-item {
padding: 16px 20px;
border-bottom: 1px solid rgba(100, 116, 139, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.incident-item:last-child {
border-bottom: none;
}
.incident-info {
display: flex;
align-items: center;
gap: 12px;
}
.incident-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.incident-dot.resolved {
background: #22c55e;
}
.incident-dot.open {
background: #ef4444;
animation: pulse 2s infinite;
}
.incident-service {
font-weight: 500;
color: #f1f5f9;
}
.incident-message {
font-size: 0.85rem;
color: #94a3b8;
}
.incident-time {
font-size: 0.85rem;
color: #64748b;
}
.no-incidents {
padding: 30px;
text-align: center;
color: #64748b;
}
.refresh-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
background: linear-gradient(135deg, rgba(0, 212, 255, 0.2), rgba(168, 85, 247, 0.2));
border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 10px;
color: #00d4ff;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 30px;
}
.refresh-btn:hover {
background: linear-gradient(135deg, rgba(0, 212, 255, 0.3), rgba(168, 85, 247, 0.3));
box-shadow: 0 0 20px rgba(0, 212, 255, 0.3);
transform: translateY(-2px);
}
.refresh-btn:active {
transform: translateY(0);
}
.refresh-btn.loading svg {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
footer {
text-align: center;
margin-top: 50px;
padding-top: 30px;
border-top: 1px solid rgba(100, 116, 139, 0.2);
color: #64748b;
font-size: 0.85rem;
}
footer a {
color: #00d4ff;
text-decoration: none;
}
footer a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>System Status</h1>
<div class="overall-status {{ overall_status.value }}">
<span class="status-dot {{ overall_status.value }}"></span>
{% if overall_status.value == 'operational' %}
All Systems Operational
{% elif overall_status.value == 'degraded' %}
Partial System Outage
{% elif overall_status.value == 'down' %}
Major System Outage
{% else %}
Status Unknown
{% endif %}
</div>
<p class="last-update">
{% if last_check %}
Last updated: {{ last_check.strftime('%d.%m.%Y %H:%M:%S') }}
{% else %}
Checking services...
{% endif %}
&bull; Auto-refresh every {{ check_interval }}s
</p>
</header>
{% if ssl_status %}
<div class="ssl-card {% if ssl_status.days_until_expiry <= 0 %}danger{% elif ssl_status.days_until_expiry <= 14 %}warning{% endif %}">
<div class="ssl-header">
<span class="ssl-title">SSL Certificate</span>
<span class="ssl-badge {% if ssl_status.days_until_expiry <= 0 %}expired{% elif ssl_status.days_until_expiry <= 14 %}expiring{% else %}valid{% endif %}">
{% if ssl_status.days_until_expiry <= 0 %}
Expired
{% elif ssl_status.days_until_expiry <= 14 %}
Expiring Soon
{% else %}
Valid
{% endif %}
</span>
</div>
<div class="ssl-info">
<div class="metric">
<div class="metric-label">Domain</div>
<div class="metric-value">{{ ssl_status.domain }}</div>
</div>
<div class="metric">
<div class="metric-label">Issuer</div>
<div class="metric-value">{{ ssl_status.issuer }}</div>
</div>
<div class="metric">
<div class="metric-label">Days Left</div>
<div class="metric-value {% if ssl_status.days_until_expiry <= 0 %}bad{% elif ssl_status.days_until_expiry <= 14 %}warning{% else %}good{% endif %}">
{{ ssl_status.days_until_expiry }}
</div>
</div>
</div>
</div>
{% endif %}
<div class="services-grid">
{% for name, service in services.items() %}
{% if service.status.value != 'unknown' or name != 'external' %}
<div class="service-card">
<div class="service-header">
<span class="service-name">{{ service.display_name }}</span>
<span class="service-status {{ service.status.value }}">
<span class="dot"></span>
{% if service.status.value == 'operational' %}
Operational
{% elif service.status.value == 'degraded' %}
Degraded
{% elif service.status.value == 'down' %}
Down
{% else %}
Unknown
{% endif %}
</span>
</div>
<div class="service-metrics">
<div class="metric">
<div class="metric-label">Latency</div>
<div class="metric-value {% if service.latency_ms and service.latency_ms < 200 %}good{% elif service.latency_ms and service.latency_ms < 500 %}warning{% elif service.latency_ms %}bad{% endif %}">
{% if service.latency_ms %}
{{ "%.0f"|format(service.latency_ms) }} ms
{% else %}
{% endif %}
</div>
</div>
<div class="metric">
<div class="metric-label">Avg 24h</div>
<div class="metric-value {% if service.avg_latency_24h and service.avg_latency_24h < 200 %}good{% elif service.avg_latency_24h and service.avg_latency_24h < 500 %}warning{% elif service.avg_latency_24h %}bad{% endif %}">
{% if service.avg_latency_24h %}
{{ "%.0f"|format(service.avg_latency_24h) }} ms
{% else %}
{% endif %}
</div>
</div>
<div class="metric">
<div class="metric-label">Uptime 24h</div>
<div class="metric-value {% if service.uptime_percent >= 99 %}good{% elif service.uptime_percent >= 95 %}warning{% else %}bad{% endif %}">
{{ "%.1f"|format(service.uptime_percent) }}%
</div>
</div>
{% if service.version %}
<div class="metric">
<div class="metric-label">Version</div>
<div class="metric-value">{{ service.version }}</div>
</div>
{% endif %}
</div>
{% if service.latency_history and service.latency_history|length > 1 %}
<div class="latency-chart">
<canvas id="chart-{{ name }}"></canvas>
</div>
{% endif %}
{% if service.message %}
<div class="service-message">{{ service.message }}</div>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
<h2>Recent Incidents</h2>
<div class="incidents-list">
{% if incidents and incidents|length > 0 %}
{% for incident in incidents %}
<div class="incident-item">
<div class="incident-info">
<span class="incident-dot {% if incident.resolved_at %}resolved{% else %}open{% endif %}"></span>
<div>
<div class="incident-service">{{ incident.service_name | title }}</div>
<div class="incident-message">{{ incident.message or 'Service unavailable' }}</div>
</div>
</div>
<div class="incident-time">
{{ incident.started_at[:16].replace('T', ' ') }}
{% if incident.resolved_at %}
- Resolved
{% else %}
- Ongoing
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="no-incidents">
No recent incidents
</div>
{% endif %}
</div>
<center>
<button class="refresh-btn" onclick="refreshStatus(this)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M23 4v6h-6M1 20v-6h6"/>
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>
</svg>
Refresh
</button>
</center>
<footer>
<p>Game Marathon Status Monitor</p>
</footer>
</div>
<script>
// Initialize latency charts
{% for name, service in services.items() %}
{% if service.latency_history and service.latency_history|length > 1 %}
(function() {
const ctx = document.getElementById('chart-{{ name }}').getContext('2d');
const data = {{ service.latency_history | tojson }};
new Chart(ctx, {
type: 'line',
data: {
labels: data.map(d => ''),
datasets: [{
data: data.map(d => d.latency_ms),
borderColor: '#00d4ff',
backgroundColor: 'rgba(0, 212, 255, 0.1)',
fill: true,
tension: 0.4,
pointRadius: 0,
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => ctx.raw.toFixed(0) + ' ms'
}
}
},
scales: {
x: { display: false },
y: {
display: false,
beginAtZero: true
}
},
interaction: {
intersect: false,
mode: 'index'
}
}
});
})();
{% endif %}
{% endfor %}
async function refreshStatus(btn) {
btn.classList.add('loading');
btn.disabled = true;
try {
await fetch('/api/refresh', { method: 'POST' });
window.location.reload();
} catch (e) {
console.error('Refresh failed:', e);
btn.classList.remove('loading');
btn.disabled = false;
}
}
// Auto-refresh page
setTimeout(() => {
window.location.reload();
}, {{ check_interval }} * 1000);
</script>
</body>
</html>