feat: Init commit

This commit is contained in:
n.tolstov 2025-11-30 19:55:50 +03:00
commit 0aed8f5494
104 changed files with 13699 additions and 0 deletions

162
.gitignore vendored Normal file
View File

@ -0,0 +1,162 @@
# ===================
# Claude Code
# ===================
.claude/
# ===================
# Python
# ===================
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
ENV/
env/
.venv/
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre
.pyre/
# pytype
.pytype/
# Cython
cython_debug/
# ===================
# Next.js / Node.js
# ===================
node_modules/
.next/
out/
.nuxt/
dist/
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Testing
coverage/
.nyc_output/
# Dependency directories
jspm_packages/
# TypeScript
*.tsbuildinfo
next-env.d.ts
# Vercel
.vercel
# Build output
*.js.map
# ===================
# IDEs and Editors
# ===================
.idea/
.vscode/
*.swp
*.swo
*~
.project
.classpath
.settings/
*.sublime-workspace
*.sublime-project
# ===================
# OS Files
# ===================
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
desktop.ini
# ===================
# Environment files
# ===================
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env*.local
# ===================
# Project specific
# ===================
# Uploaded files (keep directory structure)
backend/uploads/
!backend/uploads/.gitkeep
# Local database
*.db
*.sqlite
*.sqlite3
# Temporary files
tmp/
temp/
*.tmp
*.temp
*.log

24
backend/Dockerfile Normal file
View File

@ -0,0 +1,24 @@
FROM python:3.12-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Expose port
EXPOSE 8000
# Run the application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

41
backend/alembic.ini Normal file
View File

@ -0,0 +1,41 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

63
backend/alembic/env.py Normal file
View File

@ -0,0 +1,63 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.config import settings
from app.database import Base
from app.models import * # noqa: Import all models
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,113 @@
"""Initial migration
Revision ID: 001
Revises:
Create Date: 2025-11-30
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '001'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Users table
op.create_table(
'users',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('email', sa.String(255), nullable=False, unique=True, index=True),
sa.Column('username', sa.String(100), nullable=False),
sa.Column('password_hash', sa.String(255), nullable=False),
sa.Column('role', sa.String(20), default='participant'),
sa.Column('is_active', sa.Boolean(), default=True),
sa.Column('created_at', sa.DateTime(), default=sa.func.now()),
)
# Contests table
op.create_table(
'contests',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('title', sa.String(255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('start_time', sa.DateTime(), nullable=False),
sa.Column('end_time', sa.DateTime(), nullable=False),
sa.Column('is_active', sa.Boolean(), default=False),
sa.Column('created_by', sa.Integer(), sa.ForeignKey('users.id'), nullable=True),
sa.Column('created_at', sa.DateTime(), default=sa.func.now()),
)
# Problems table
op.create_table(
'problems',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('contest_id', sa.Integer(), sa.ForeignKey('contests.id', ondelete='CASCADE'), nullable=False),
sa.Column('title', sa.String(255), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('input_format', sa.Text(), nullable=True),
sa.Column('output_format', sa.Text(), nullable=True),
sa.Column('constraints', sa.Text(), nullable=True),
sa.Column('time_limit_ms', sa.Integer(), default=1000),
sa.Column('memory_limit_kb', sa.Integer(), default=262144),
sa.Column('total_points', sa.Integer(), default=100),
sa.Column('order_index', sa.Integer(), default=0),
sa.Column('created_at', sa.DateTime(), default=sa.func.now()),
)
# Test cases table
op.create_table(
'test_cases',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('problem_id', sa.Integer(), sa.ForeignKey('problems.id', ondelete='CASCADE'), nullable=False),
sa.Column('input', sa.Text(), nullable=False),
sa.Column('expected_output', sa.Text(), nullable=False),
sa.Column('is_sample', sa.Boolean(), default=False),
sa.Column('points', sa.Integer(), default=0),
sa.Column('order_index', sa.Integer(), default=0),
)
# Submissions table
op.create_table(
'submissions',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
sa.Column('problem_id', sa.Integer(), sa.ForeignKey('problems.id'), nullable=False),
sa.Column('contest_id', sa.Integer(), sa.ForeignKey('contests.id'), nullable=False),
sa.Column('source_code', sa.Text(), nullable=False),
sa.Column('language_id', sa.Integer(), nullable=False),
sa.Column('language_name', sa.String(50), nullable=True),
sa.Column('status', sa.String(50), default='pending'),
sa.Column('score', sa.Integer(), default=0),
sa.Column('total_points', sa.Integer(), default=0),
sa.Column('tests_passed', sa.Integer(), default=0),
sa.Column('tests_total', sa.Integer(), default=0),
sa.Column('execution_time_ms', sa.Integer(), nullable=True),
sa.Column('memory_used_kb', sa.Integer(), nullable=True),
sa.Column('judge_response', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), default=sa.func.now()),
)
# Contest participants table
op.create_table(
'contest_participants',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('contest_id', sa.Integer(), sa.ForeignKey('contests.id'), nullable=False),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
sa.Column('joined_at', sa.DateTime(), default=sa.func.now()),
sa.UniqueConstraint('contest_id', 'user_id', name='unique_contest_participant'),
)
def downgrade() -> None:
op.drop_table('contest_participants')
op.drop_table('submissions')
op.drop_table('test_cases')
op.drop_table('problems')
op.drop_table('contests')
op.drop_table('users')

View File

@ -0,0 +1,98 @@
"""add timezone to datetime columns
Revision ID: 002
Revises: 001
Create Date: 2025-11-30
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '002'
down_revision: Union[str, None] = '001'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Users table
op.alter_column('users', 'created_at',
type_=sa.DateTime(timezone=True),
existing_type=sa.DateTime(),
existing_nullable=False)
# Contests table
op.alter_column('contests', 'start_time',
type_=sa.DateTime(timezone=True),
existing_type=sa.DateTime(),
existing_nullable=False)
op.alter_column('contests', 'end_time',
type_=sa.DateTime(timezone=True),
existing_type=sa.DateTime(),
existing_nullable=False)
op.alter_column('contests', 'created_at',
type_=sa.DateTime(timezone=True),
existing_type=sa.DateTime(),
existing_nullable=False)
# Contest participants table
op.alter_column('contest_participants', 'joined_at',
type_=sa.DateTime(timezone=True),
existing_type=sa.DateTime(),
existing_nullable=False)
# Problems table
op.alter_column('problems', 'created_at',
type_=sa.DateTime(timezone=True),
existing_type=sa.DateTime(),
existing_nullable=False)
# Submissions table
op.alter_column('submissions', 'created_at',
type_=sa.DateTime(timezone=True),
existing_type=sa.DateTime(),
existing_nullable=False)
def downgrade() -> None:
# Submissions table
op.alter_column('submissions', 'created_at',
type_=sa.DateTime(),
existing_type=sa.DateTime(timezone=True),
existing_nullable=False)
# Problems table
op.alter_column('problems', 'created_at',
type_=sa.DateTime(),
existing_type=sa.DateTime(timezone=True),
existing_nullable=False)
# Contest participants table
op.alter_column('contest_participants', 'joined_at',
type_=sa.DateTime(),
existing_type=sa.DateTime(timezone=True),
existing_nullable=False)
# Contests table
op.alter_column('contests', 'created_at',
type_=sa.DateTime(),
existing_type=sa.DateTime(timezone=True),
existing_nullable=False)
op.alter_column('contests', 'end_time',
type_=sa.DateTime(),
existing_type=sa.DateTime(timezone=True),
existing_nullable=False)
op.alter_column('contests', 'start_time',
type_=sa.DateTime(),
existing_type=sa.DateTime(timezone=True),
existing_nullable=False)
# Users table
op.alter_column('users', 'created_at',
type_=sa.DateTime(),
existing_type=sa.DateTime(timezone=True),
existing_nullable=False)

View File

@ -0,0 +1,83 @@
"""Add CASCADE delete to foreign keys
Revision ID: 003
Revises: 002_add_timezone_to_datetime
Create Date: 2025-11-30
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '003'
down_revision: Union[str, None] = '002'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Submissions table - add CASCADE delete
op.drop_constraint('submissions_user_id_fkey', 'submissions', type_='foreignkey')
op.drop_constraint('submissions_problem_id_fkey', 'submissions', type_='foreignkey')
op.drop_constraint('submissions_contest_id_fkey', 'submissions', type_='foreignkey')
op.create_foreign_key(
'submissions_user_id_fkey', 'submissions', 'users',
['user_id'], ['id'], ondelete='CASCADE'
)
op.create_foreign_key(
'submissions_problem_id_fkey', 'submissions', 'problems',
['problem_id'], ['id'], ondelete='CASCADE'
)
op.create_foreign_key(
'submissions_contest_id_fkey', 'submissions', 'contests',
['contest_id'], ['id'], ondelete='CASCADE'
)
# ContestParticipants table - add CASCADE delete
op.drop_constraint('contest_participants_contest_id_fkey', 'contest_participants', type_='foreignkey')
op.drop_constraint('contest_participants_user_id_fkey', 'contest_participants', type_='foreignkey')
op.create_foreign_key(
'contest_participants_contest_id_fkey', 'contest_participants', 'contests',
['contest_id'], ['id'], ondelete='CASCADE'
)
op.create_foreign_key(
'contest_participants_user_id_fkey', 'contest_participants', 'users',
['user_id'], ['id'], ondelete='CASCADE'
)
def downgrade() -> None:
# Submissions table - remove CASCADE delete
op.drop_constraint('submissions_user_id_fkey', 'submissions', type_='foreignkey')
op.drop_constraint('submissions_problem_id_fkey', 'submissions', type_='foreignkey')
op.drop_constraint('submissions_contest_id_fkey', 'submissions', type_='foreignkey')
op.create_foreign_key(
'submissions_user_id_fkey', 'submissions', 'users',
['user_id'], ['id']
)
op.create_foreign_key(
'submissions_problem_id_fkey', 'submissions', 'problems',
['problem_id'], ['id']
)
op.create_foreign_key(
'submissions_contest_id_fkey', 'submissions', 'contests',
['contest_id'], ['id']
)
# ContestParticipants table - remove CASCADE delete
op.drop_constraint('contest_participants_contest_id_fkey', 'contest_participants', type_='foreignkey')
op.drop_constraint('contest_participants_user_id_fkey', 'contest_participants', type_='foreignkey')
op.create_foreign_key(
'contest_participants_contest_id_fkey', 'contest_participants', 'contests',
['contest_id'], ['id']
)
op.create_foreign_key(
'contest_participants_user_id_fkey', 'contest_participants', 'users',
['user_id'], ['id']
)

View File

@ -0,0 +1,33 @@
"""Add user profile fields
Revision ID: 004
Revises: 003
Create Date: 2025-11-30
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '004'
down_revision: Union[str, None] = '003'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('users', sa.Column('full_name', sa.String(255), nullable=True))
op.add_column('users', sa.Column('telegram', sa.String(100), nullable=True))
op.add_column('users', sa.Column('vk', sa.String(100), nullable=True))
op.add_column('users', sa.Column('study_group', sa.String(50), nullable=True))
op.add_column('users', sa.Column('avatar_url', sa.String(500), nullable=True))
def downgrade() -> None:
op.drop_column('users', 'avatar_url')
op.drop_column('users', 'study_group')
op.drop_column('users', 'vk')
op.drop_column('users', 'telegram')
op.drop_column('users', 'full_name')

0
backend/app/__init__.py Normal file
View File

32
backend/app/config.py Normal file
View File

@ -0,0 +1,32 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from functools import lru_cache
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
extra="ignore",
case_sensitive=False,
)
# Database
database_url: str = "postgresql+asyncpg://sport_prog:secret@localhost:5432/sport_programming"
# JWT
secret_key: str = "your-super-secret-key-change-in-production"
algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24 # 24 hours
# Piston (code execution engine)
piston_url: str = "http://localhost:2000"
# CORS
cors_origins: str = "http://localhost:3000"
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()

29
backend/app/database.py Normal file
View File

@ -0,0 +1,29 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(
settings.database_url,
echo=False,
pool_pre_ping=True,
)
async_session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
async with async_session_maker() as session:
try:
yield session
finally:
await session.close()

View File

@ -0,0 +1,60 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models.user import User
from app.services.auth import decode_token
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db),
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
payload = decode_token(token)
if payload is None:
raise credentials_exception
user_id_str: str | None = payload.get("sub")
if user_id_str is None:
raise credentials_exception
try:
user_id = int(user_id_str)
except (ValueError, TypeError):
raise credentials_exception
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise credentials_exception
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user",
)
return user
async def get_current_admin(
current_user: User = Depends(get_current_user),
) -> User:
if current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return current_user

81
backend/app/main.py Normal file
View File

@ -0,0 +1,81 @@
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware
import traceback
from app.config import settings
from app.routers import auth, contests, problems, submissions, leaderboard, languages
# Ensure uploads directory exists
UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
(UPLOAD_DIR / "avatars").mkdir(parents=True, exist_ok=True)
class CatchAllMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
try:
return await call_next(request)
except Exception as exc:
traceback.print_exc()
origin = request.headers.get("origin", "http://localhost:3000")
return JSONResponse(
status_code=500,
content={"detail": str(exc)},
headers={
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
},
)
app = FastAPI(
title="Sport Programming Platform",
description="Platform for competitive programming contests",
version="1.0.0",
)
# Add error catching middleware first
app.add_middleware(CatchAllMiddleware)
# CORS middleware
origins = [
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:8000",
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["*"],
)
# Include routers
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(contests.router, prefix="/api/contests", tags=["contests"])
app.include_router(problems.router, prefix="/api/problems", tags=["problems"])
app.include_router(submissions.router, prefix="/api/submissions", tags=["submissions"])
app.include_router(leaderboard.router, prefix="/api/leaderboard", tags=["leaderboard"])
app.include_router(languages.router, prefix="/api/languages", tags=["languages"])
# Mount static files for uploads
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
@app.get("/")
async def root():
return {"message": "Sport Programming Platform API", "version": "1.0.0"}
@app.get("/health")
async def health():
return {"status": "healthy"}

View File

@ -0,0 +1,14 @@
from app.models.user import User
from app.models.contest import Contest, ContestParticipant
from app.models.problem import Problem
from app.models.test_case import TestCase
from app.models.submission import Submission
__all__ = [
"User",
"Contest",
"ContestParticipant",
"Problem",
"TestCase",
"Submission",
]

View File

@ -0,0 +1,58 @@
from datetime import datetime, timezone
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class Contest(Base):
__tablename__ = "contests"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
start_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
end_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=False)
created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
# Relationships
creator = relationship("User", back_populates="created_contests")
problems = relationship("Problem", back_populates="contest", cascade="all, delete-orphan")
participants = relationship("ContestParticipant", back_populates="contest", cascade="all, delete-orphan")
submissions = relationship("Submission", back_populates="contest", cascade="all, delete-orphan", passive_deletes=True)
@property
def is_running(self) -> bool:
now = datetime.now(timezone.utc)
start = self.start_time if self.start_time.tzinfo else self.start_time.replace(tzinfo=timezone.utc)
end = self.end_time if self.end_time.tzinfo else self.end_time.replace(tzinfo=timezone.utc)
return self.is_active and start <= now <= end
@property
def has_ended(self) -> bool:
now = datetime.now(timezone.utc)
end = self.end_time if self.end_time.tzinfo else self.end_time.replace(tzinfo=timezone.utc)
return now > end
class ContestParticipant(Base):
__tablename__ = "contest_participants"
id: Mapped[int] = mapped_column(primary_key=True)
contest_id: Mapped[int] = mapped_column(ForeignKey("contests.id", ondelete="CASCADE"), nullable=False)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
joined_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
# Relationships
contest = relationship("Contest", back_populates="participants")
user = relationship("User", back_populates="participations")
__table_args__ = (
UniqueConstraint("contest_id", "user_id", name="unique_contest_participant"),
)

View File

@ -0,0 +1,31 @@
from datetime import datetime, timezone
from sqlalchemy import String, Text, Integer, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class Problem(Base):
__tablename__ = "problems"
id: Mapped[int] = mapped_column(primary_key=True)
contest_id: Mapped[int] = mapped_column(ForeignKey("contests.id", ondelete="CASCADE"), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
input_format: Mapped[str | None] = mapped_column(Text, nullable=True)
output_format: Mapped[str | None] = mapped_column(Text, nullable=True)
constraints: Mapped[str | None] = mapped_column(Text, nullable=True)
time_limit_ms: Mapped[int] = mapped_column(Integer, default=1000)
memory_limit_kb: Mapped[int] = mapped_column(Integer, default=262144) # 256 MB
total_points: Mapped[int] = mapped_column(Integer, default=100)
order_index: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
# Relationships
contest = relationship("Contest", back_populates="problems")
test_cases = relationship("TestCase", back_populates="problem", cascade="all, delete-orphan")
submissions = relationship("Submission", back_populates="problem", cascade="all, delete-orphan", passive_deletes=True)

View File

@ -0,0 +1,35 @@
from datetime import datetime, timezone
from sqlalchemy import String, Text, Integer, DateTime, ForeignKey, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class Submission(Base):
__tablename__ = "submissions"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
problem_id: Mapped[int] = mapped_column(ForeignKey("problems.id", ondelete="CASCADE"), nullable=False)
contest_id: Mapped[int] = mapped_column(ForeignKey("contests.id", ondelete="CASCADE"), nullable=False)
source_code: Mapped[str] = mapped_column(Text, nullable=False)
language_id: Mapped[int] = mapped_column(Integer, nullable=False) # Judge0 language ID
language_name: Mapped[str | None] = mapped_column(String(50), nullable=True)
status: Mapped[str] = mapped_column(String(50), default="pending") # pending, judging, accepted, wrong_answer, etc.
score: Mapped[int] = mapped_column(Integer, default=0)
total_points: Mapped[int] = mapped_column(Integer, default=0)
tests_passed: Mapped[int] = mapped_column(Integer, default=0)
tests_total: Mapped[int] = mapped_column(Integer, default=0)
execution_time_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
memory_used_kb: Mapped[int | None] = mapped_column(Integer, nullable=True)
judge_response: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
# Relationships
user = relationship("User", back_populates="submissions")
problem = relationship("Problem", back_populates="submissions")
contest = relationship("Contest", back_populates="submissions")

View File

@ -0,0 +1,19 @@
from sqlalchemy import Text, Integer, Boolean, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class TestCase(Base):
__tablename__ = "test_cases"
id: Mapped[int] = mapped_column(primary_key=True)
problem_id: Mapped[int] = mapped_column(ForeignKey("problems.id", ondelete="CASCADE"), nullable=False)
input: Mapped[str] = mapped_column(Text, nullable=False)
expected_output: Mapped[str] = mapped_column(Text, nullable=False)
is_sample: Mapped[bool] = mapped_column(Boolean, default=False) # Show in problem description
points: Mapped[int] = mapped_column(Integer, default=0)
order_index: Mapped[int] = mapped_column(Integer, default=0)
# Relationships
problem = relationship("Problem", back_populates="test_cases")

View File

@ -0,0 +1,33 @@
from datetime import datetime, timezone
from sqlalchemy import String, Boolean, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
username: Mapped[str] = mapped_column(String(100), nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(20), default="participant") # admin | participant
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
# Profile fields
full_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
telegram: Mapped[str | None] = mapped_column(String(100), nullable=True)
vk: Mapped[str | None] = mapped_column(String(100), nullable=True)
study_group: Mapped[str | None] = mapped_column(String(50), nullable=True)
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Relationships
submissions = relationship("Submission", back_populates="user", cascade="all, delete-orphan", passive_deletes=True)
created_contests = relationship("Contest", back_populates="creator")
participations = relationship("ContestParticipant", back_populates="user", cascade="all, delete-orphan", passive_deletes=True)

View File

179
backend/app/routers/auth.py Normal file
View File

@ -0,0 +1,179 @@
import os
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate, UserResponse, Token
from app.services.auth import get_password_hash, verify_password, create_access_token
from app.dependencies import get_current_user
# Avatar upload directory
UPLOAD_DIR = Path("uploads/avatars")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
router = APIRouter()
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(
user_data: UserCreate,
db: AsyncSession = Depends(get_db),
):
# Check if email already exists
result = await db.execute(select(User).where(User.email == user_data.email))
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
# Create user
user = User(
email=user_data.email,
username=user_data.username,
password_hash=get_password_hash(user_data.password),
role="participant",
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@router.post("/login", response_model=Token)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db),
):
# Find user by email (username field contains email)
result = await db.execute(select(User).where(User.email == form_data.username))
user = result.scalar_one_or_none()
if not user or not verify_password(form_data.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user",
)
access_token = create_access_token(data={"sub": str(user.id)})
return Token(access_token=access_token)
@router.get("/me", response_model=UserResponse)
async def get_me(
current_user: User = Depends(get_current_user),
):
return current_user
@router.put("/me", response_model=UserResponse)
async def update_profile(
user_data: UserUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
update_data = user_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(current_user, field, value)
await db.commit()
await db.refresh(current_user)
return current_user
@router.post("/me/avatar", response_model=UserResponse)
async def upload_avatar(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Validate file extension
ext = Path(file.filename).suffix.lower() if file.filename else ""
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File type not allowed. Allowed types: {', '.join(ALLOWED_EXTENSIONS)}",
)
# Read file content
content = await file.read()
# Validate file size
if len(content) > MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File too large. Maximum size: {MAX_FILE_SIZE // 1024 // 1024}MB",
)
# Delete old avatar if exists
if current_user.avatar_url:
old_path = UPLOAD_DIR / current_user.avatar_url.split("/")[-1]
if old_path.exists():
old_path.unlink()
# Save new avatar
filename = f"{current_user.id}_{uuid.uuid4().hex}{ext}"
file_path = UPLOAD_DIR / filename
with open(file_path, "wb") as f:
f.write(content)
# Update user avatar URL
current_user.avatar_url = f"/uploads/avatars/{filename}"
await db.commit()
await db.refresh(current_user)
return current_user
@router.delete("/me/avatar", response_model=UserResponse)
async def delete_avatar(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if current_user.avatar_url:
old_path = UPLOAD_DIR / current_user.avatar_url.split("/")[-1]
if old_path.exists():
old_path.unlink()
current_user.avatar_url = None
await db.commit()
await db.refresh(current_user)
return current_user
@router.get("/users", response_model=list[UserResponse])
async def get_all_users(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get all users (admin only)"""
if current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
result = await db.execute(select(User).order_by(User.created_at.desc()))
users = result.scalars().all()
return users

View File

@ -0,0 +1,205 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.user import User
from app.models.contest import Contest, ContestParticipant
from app.models.problem import Problem
from app.schemas.contest import ContestCreate, ContestUpdate, ContestResponse, ContestListResponse
from app.dependencies import get_current_user, get_current_admin
router = APIRouter()
@router.get("/", response_model=list[ContestListResponse])
async def get_contests(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Contest)
.options(selectinload(Contest.problems), selectinload(Contest.participants))
.order_by(Contest.start_time.desc())
)
contests = result.scalars().all()
return [
ContestListResponse(
id=c.id,
title=c.title,
start_time=c.start_time,
end_time=c.end_time,
is_active=c.is_active,
is_running=c.is_running,
has_ended=c.has_ended,
problems_count=len(c.problems),
participants_count=len(c.participants),
)
for c in contests
]
@router.get("/{contest_id}", response_model=ContestResponse)
async def get_contest(
contest_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Contest)
.options(selectinload(Contest.problems), selectinload(Contest.participants))
.where(Contest.id == contest_id)
)
contest = result.scalar_one_or_none()
if not contest:
raise HTTPException(status_code=404, detail="Contest not found")
# Check if current user is participating
is_participating = any(p.user_id == current_user.id for p in contest.participants)
return ContestResponse(
id=contest.id,
title=contest.title,
description=contest.description,
start_time=contest.start_time,
end_time=contest.end_time,
is_active=contest.is_active,
created_by=contest.created_by,
created_at=contest.created_at,
is_running=contest.is_running,
has_ended=contest.has_ended,
problems_count=len(contest.problems),
participants_count=len(contest.participants),
is_participating=is_participating,
)
@router.post("/", response_model=ContestResponse, status_code=status.HTTP_201_CREATED)
async def create_contest(
contest_data: ContestCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_admin),
):
contest = Contest(
title=contest_data.title,
description=contest_data.description,
start_time=contest_data.start_time,
end_time=contest_data.end_time,
is_active=contest_data.is_active,
created_by=current_user.id,
)
db.add(contest)
await db.commit()
await db.refresh(contest)
return ContestResponse(
id=contest.id,
title=contest.title,
description=contest.description,
start_time=contest.start_time,
end_time=contest.end_time,
is_active=contest.is_active,
created_by=contest.created_by,
created_at=contest.created_at,
is_running=contest.is_running,
has_ended=contest.has_ended,
problems_count=0,
participants_count=0,
)
@router.put("/{contest_id}", response_model=ContestResponse)
async def update_contest(
contest_id: int,
contest_data: ContestUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_admin),
):
result = await db.execute(
select(Contest)
.options(selectinload(Contest.problems), selectinload(Contest.participants))
.where(Contest.id == contest_id)
)
contest = result.scalar_one_or_none()
if not contest:
raise HTTPException(status_code=404, detail="Contest not found")
update_data = contest_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(contest, field, value)
await db.commit()
await db.refresh(contest)
return ContestResponse(
id=contest.id,
title=contest.title,
description=contest.description,
start_time=contest.start_time,
end_time=contest.end_time,
is_active=contest.is_active,
created_by=contest.created_by,
created_at=contest.created_at,
is_running=contest.is_running,
has_ended=contest.has_ended,
problems_count=len(contest.problems),
participants_count=len(contest.participants),
)
@router.delete("/{contest_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_contest(
contest_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_admin),
):
result = await db.execute(select(Contest).where(Contest.id == contest_id))
contest = result.scalar_one_or_none()
if not contest:
raise HTTPException(status_code=404, detail="Contest not found")
await db.delete(contest)
await db.commit()
@router.post("/{contest_id}/join", status_code=status.HTTP_201_CREATED)
async def join_contest(
contest_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Check if contest exists
result = await db.execute(select(Contest).where(Contest.id == contest_id))
contest = result.scalar_one_or_none()
if not contest:
raise HTTPException(status_code=404, detail="Contest not found")
if not contest.is_active:
raise HTTPException(status_code=400, detail="Contest is not active")
# Check if already joined
result = await db.execute(
select(ContestParticipant).where(
ContestParticipant.contest_id == contest_id,
ContestParticipant.user_id == current_user.id,
)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Already joined this contest")
# Join contest
participant = ContestParticipant(
contest_id=contest_id,
user_id=current_user.id,
)
db.add(participant)
await db.commit()
return {"message": "Successfully joined the contest"}

View File

@ -0,0 +1,41 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.services.judge import judge_service
router = APIRouter()
class LanguageResponse(BaseModel):
id: int
name: str
# Popular languages to show at the top
POPULAR_LANGUAGE_IDS = [71, 54, 62, 63, 50, 51, 52, 60, 68, 78] # Python, C++, Java, JS, C, C#, etc.
@router.get("/", response_model=list[LanguageResponse])
async def get_languages():
try:
languages = await judge_service.get_languages()
# Sort: popular first, then alphabetically
def sort_key(lang):
lang_id = lang.get("id", 0)
if lang_id in POPULAR_LANGUAGE_IDS:
return (0, POPULAR_LANGUAGE_IDS.index(lang_id))
return (1, lang.get("name", ""))
sorted_languages = sorted(languages, key=sort_key)
return [
LanguageResponse(id=lang["id"], name=lang["name"])
for lang in sorted_languages
]
except Exception as e:
raise HTTPException(
status_code=503,
detail=f"Judge service unavailable: {str(e)}"
)

View File

@ -0,0 +1,143 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from pydantic import BaseModel
from app.database import get_db
from app.models.user import User
from app.models.contest import Contest
from app.models.problem import Problem
from app.models.submission import Submission
from app.dependencies import get_current_user
router = APIRouter()
class LeaderboardEntry(BaseModel):
rank: int
user_id: int
username: str
avatar_url: str | None
total_score: int
problems_solved: int
last_submission_time: datetime | None
class LeaderboardResponse(BaseModel):
contest_id: int
contest_title: str
is_hidden: bool
entries: list[LeaderboardEntry]
@router.get("/{contest_id}", response_model=LeaderboardResponse)
async def get_leaderboard(
contest_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Get contest
result = await db.execute(select(Contest).where(Contest.id == contest_id))
contest = result.scalar_one_or_none()
if not contest:
raise HTTPException(status_code=404, detail="Contest not found")
# Check if leaderboard should be hidden (during contest)
is_hidden = contest.is_running and current_user.role != "admin"
if is_hidden:
return LeaderboardResponse(
contest_id=contest.id,
contest_title=contest.title,
is_hidden=True,
entries=[],
)
# Get best scores for each user and problem
# Subquery to get max score per user per problem
best_scores = (
select(
Submission.user_id,
Submission.problem_id,
func.max(Submission.score).label("best_score"),
func.max(Submission.created_at).label("last_submission"),
)
.where(Submission.contest_id == contest_id)
.group_by(Submission.user_id, Submission.problem_id)
.subquery()
)
# Aggregate by user
result = await db.execute(
select(
best_scores.c.user_id,
func.sum(best_scores.c.best_score).label("total_score"),
func.count(best_scores.c.problem_id).label("problems_attempted"),
func.max(best_scores.c.last_submission).label("last_submission_time"),
)
.group_by(best_scores.c.user_id)
.order_by(func.sum(best_scores.c.best_score).desc())
)
rows = result.all()
# Get user info
user_ids = [row.user_id for row in rows]
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
users = {u.id: u for u in users_result.scalars().all()}
# Get problem count to determine "solved"
problems_result = await db.execute(
select(Problem).where(Problem.contest_id == contest_id)
)
problems = {p.id: p.total_points for p in problems_result.scalars().all()}
# Get best scores per problem per user to count solved problems
solved_counts = {}
for row in rows:
user_id = row.user_id
# Get individual problem scores for this user
user_scores_result = await db.execute(
select(
Submission.problem_id,
func.max(Submission.score).label("score"),
)
.where(
Submission.contest_id == contest_id,
Submission.user_id == user_id,
)
.group_by(Submission.problem_id)
)
user_scores = user_scores_result.all()
# Count problems where score equals total_points
solved = sum(
1 for ps in user_scores
if ps.problem_id in problems and ps.score == problems[ps.problem_id]
)
solved_counts[user_id] = solved
# Build leaderboard
entries = []
for rank, row in enumerate(rows, start=1):
user = users.get(row.user_id)
if user:
entries.append(LeaderboardEntry(
rank=rank,
user_id=row.user_id,
username=user.username,
avatar_url=user.avatar_url,
total_score=row.total_score or 0,
problems_solved=solved_counts.get(row.user_id, 0),
last_submission_time=row.last_submission_time,
))
return LeaderboardResponse(
contest_id=contest.id,
contest_title=contest.title,
is_hidden=False,
entries=entries,
)

View File

@ -0,0 +1,288 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.user import User
from app.models.problem import Problem
from app.models.test_case import TestCase
from app.schemas.problem import (
ProblemCreate,
ProblemUpdate,
ProblemResponse,
ProblemListResponse,
SampleTestResponse,
TestCaseCreate,
TestCaseResponse,
)
from app.dependencies import get_current_user, get_current_admin
router = APIRouter()
@router.get("/contest/{contest_id}", response_model=list[ProblemListResponse])
async def get_problems_by_contest(
contest_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Problem)
.where(Problem.contest_id == contest_id)
.order_by(Problem.order_index)
)
problems = result.scalars().all()
return problems
@router.get("/{problem_id}", response_model=ProblemResponse)
async def get_problem(
problem_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Problem)
.options(selectinload(Problem.test_cases))
.where(Problem.id == problem_id)
)
problem = result.scalar_one_or_none()
if not problem:
raise HTTPException(status_code=404, detail="Problem not found")
# Get only sample tests for participants
sample_tests = [
SampleTestResponse(input=tc.input, output=tc.expected_output)
for tc in problem.test_cases
if tc.is_sample
]
return ProblemResponse(
id=problem.id,
contest_id=problem.contest_id,
title=problem.title,
description=problem.description,
input_format=problem.input_format,
output_format=problem.output_format,
constraints=problem.constraints,
time_limit_ms=problem.time_limit_ms,
memory_limit_kb=problem.memory_limit_kb,
total_points=problem.total_points,
order_index=problem.order_index,
created_at=problem.created_at,
sample_tests=sample_tests,
)
@router.post("/", response_model=ProblemResponse, status_code=status.HTTP_201_CREATED)
async def create_problem(
problem_data: ProblemCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_admin),
):
problem = Problem(
contest_id=problem_data.contest_id,
title=problem_data.title,
description=problem_data.description,
input_format=problem_data.input_format,
output_format=problem_data.output_format,
constraints=problem_data.constraints,
time_limit_ms=problem_data.time_limit_ms,
memory_limit_kb=problem_data.memory_limit_kb,
total_points=problem_data.total_points,
order_index=problem_data.order_index,
)
db.add(problem)
await db.flush()
# Add test cases
for tc_data in problem_data.test_cases:
test_case = TestCase(
problem_id=problem.id,
input=tc_data.input,
expected_output=tc_data.expected_output,
is_sample=tc_data.is_sample,
points=tc_data.points,
order_index=tc_data.order_index,
)
db.add(test_case)
await db.commit()
await db.refresh(problem)
# Load test cases for response
result = await db.execute(
select(Problem)
.options(selectinload(Problem.test_cases))
.where(Problem.id == problem.id)
)
problem = result.scalar_one()
sample_tests = [
SampleTestResponse(input=tc.input, output=tc.expected_output)
for tc in problem.test_cases
if tc.is_sample
]
return ProblemResponse(
id=problem.id,
contest_id=problem.contest_id,
title=problem.title,
description=problem.description,
input_format=problem.input_format,
output_format=problem.output_format,
constraints=problem.constraints,
time_limit_ms=problem.time_limit_ms,
memory_limit_kb=problem.memory_limit_kb,
total_points=problem.total_points,
order_index=problem.order_index,
created_at=problem.created_at,
sample_tests=sample_tests,
)
@router.put("/{problem_id}", response_model=ProblemResponse)
async def update_problem(
problem_id: int,
problem_data: ProblemUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_admin),
):
result = await db.execute(
select(Problem)
.options(selectinload(Problem.test_cases))
.where(Problem.id == problem_id)
)
problem = result.scalar_one_or_none()
if not problem:
raise HTTPException(status_code=404, detail="Problem not found")
update_data = problem_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(problem, field, value)
await db.commit()
await db.refresh(problem)
sample_tests = [
SampleTestResponse(input=tc.input, output=tc.expected_output)
for tc in problem.test_cases
if tc.is_sample
]
return ProblemResponse(
id=problem.id,
contest_id=problem.contest_id,
title=problem.title,
description=problem.description,
input_format=problem.input_format,
output_format=problem.output_format,
constraints=problem.constraints,
time_limit_ms=problem.time_limit_ms,
memory_limit_kb=problem.memory_limit_kb,
total_points=problem.total_points,
order_index=problem.order_index,
created_at=problem.created_at,
sample_tests=sample_tests,
)
@router.delete("/{problem_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_problem(
problem_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_admin),
):
result = await db.execute(select(Problem).where(Problem.id == problem_id))
problem = result.scalar_one_or_none()
if not problem:
raise HTTPException(status_code=404, detail="Problem not found")
await db.delete(problem)
await db.commit()
# Test cases endpoints (admin only)
@router.get("/{problem_id}/test-cases", response_model=list[TestCaseResponse])
async def get_test_cases(
problem_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_admin),
):
result = await db.execute(
select(TestCase)
.where(TestCase.problem_id == problem_id)
.order_by(TestCase.order_index)
)
return result.scalars().all()
@router.post("/{problem_id}/test-cases", response_model=TestCaseResponse, status_code=status.HTTP_201_CREATED)
async def add_test_case(
problem_id: int,
test_case_data: TestCaseCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_admin),
):
# Check if problem exists
result = await db.execute(select(Problem).where(Problem.id == problem_id))
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Problem not found")
test_case = TestCase(
problem_id=problem_id,
input=test_case_data.input,
expected_output=test_case_data.expected_output,
is_sample=test_case_data.is_sample,
points=test_case_data.points,
order_index=test_case_data.order_index,
)
db.add(test_case)
await db.commit()
await db.refresh(test_case)
return test_case
@router.put("/test-cases/{test_case_id}", response_model=TestCaseResponse)
async def update_test_case(
test_case_id: int,
test_case_data: TestCaseCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_admin),
):
result = await db.execute(select(TestCase).where(TestCase.id == test_case_id))
test_case = result.scalar_one_or_none()
if not test_case:
raise HTTPException(status_code=404, detail="Test case not found")
update_data = test_case_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(test_case, field, value)
await db.commit()
await db.refresh(test_case)
return test_case
@router.delete("/test-cases/{test_case_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_test_case(
test_case_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_admin),
):
result = await db.execute(select(TestCase).where(TestCase.id == test_case_id))
test_case = result.scalar_one_or_none()
if not test_case:
raise HTTPException(status_code=404, detail="Test case not found")
await db.delete(test_case)
await db.commit()

View File

@ -0,0 +1,172 @@
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.user import User
from app.models.problem import Problem
from app.models.submission import Submission
from app.schemas.submission import SubmissionCreate, SubmissionResponse, SubmissionListResponse
from app.dependencies import get_current_user
from app.services.scoring import evaluate_submission
router = APIRouter()
async def process_submission(submission_id: int, db_url: str):
"""Background task to evaluate submission"""
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
from sqlalchemy.orm import selectinload
engine = create_async_engine(db_url)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as db:
# Get submission with problem and test cases
result = await db.execute(
select(Submission)
.options(selectinload(Submission.problem).selectinload(Problem.test_cases))
.where(Submission.id == submission_id)
)
submission = result.scalar_one_or_none()
if not submission:
return
# Update status to judging
submission.status = "judging"
await db.commit()
try:
# Evaluate submission
result = await evaluate_submission(
source_code=submission.source_code,
language_id=submission.language_id,
test_cases=submission.problem.test_cases,
total_points=submission.problem.total_points,
time_limit_ms=submission.problem.time_limit_ms,
memory_limit_kb=submission.problem.memory_limit_kb,
)
# Update submission with results
submission.status = result["status"]
submission.score = result["score"]
submission.total_points = result["total_points"]
submission.tests_passed = result["tests_passed"]
submission.tests_total = result["tests_total"]
submission.execution_time_ms = result["execution_time_ms"]
submission.memory_used_kb = result["memory_used_kb"]
submission.judge_response = result["details"]
await db.commit()
except Exception as e:
submission.status = "internal_error"
submission.judge_response = {"error": str(e)}
await db.commit()
await engine.dispose()
@router.post("/", response_model=SubmissionResponse, status_code=status.HTTP_201_CREATED)
async def create_submission(
submission_data: SubmissionCreate,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Check if problem exists
result = await db.execute(
select(Problem)
.options(selectinload(Problem.test_cases))
.where(Problem.id == submission_data.problem_id)
)
problem = result.scalar_one_or_none()
if not problem:
raise HTTPException(status_code=404, detail="Problem not found")
# Create submission
submission = Submission(
user_id=current_user.id,
problem_id=submission_data.problem_id,
contest_id=submission_data.contest_id,
source_code=submission_data.source_code,
language_id=submission_data.language_id,
language_name=submission_data.language_name,
status="pending",
total_points=problem.total_points,
tests_total=len(problem.test_cases),
)
db.add(submission)
await db.commit()
await db.refresh(submission)
# Add background task to process submission
from app.config import settings
background_tasks.add_task(process_submission, submission.id, settings.database_url)
return submission
@router.get("/{submission_id}", response_model=SubmissionResponse)
async def get_submission(
submission_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Submission).where(Submission.id == submission_id)
)
submission = result.scalar_one_or_none()
if not submission:
raise HTTPException(status_code=404, detail="Submission not found")
# Users can only see their own submissions
if submission.user_id != current_user.id and current_user.role != "admin":
raise HTTPException(status_code=403, detail="Access denied")
return submission
@router.get("/", response_model=list[SubmissionListResponse])
async def get_my_submissions(
problem_id: int | None = None,
contest_id: int | None = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
query = select(Submission).where(Submission.user_id == current_user.id)
if problem_id:
query = query.where(Submission.problem_id == problem_id)
if contest_id:
query = query.where(Submission.contest_id == contest_id)
query = query.order_by(Submission.created_at.desc())
result = await db.execute(query)
return result.scalars().all()
@router.get("/problem/{problem_id}", response_model=list[SubmissionListResponse])
async def get_submissions_by_problem(
problem_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Users can only see their own submissions
result = await db.execute(
select(Submission)
.where(
Submission.problem_id == problem_id,
Submission.user_id == current_user.id,
)
.order_by(Submission.created_at.desc())
)
return result.scalars().all()

View File

@ -0,0 +1,47 @@
from app.schemas.user import (
UserCreate,
UserLogin,
UserResponse,
Token,
)
from app.schemas.contest import (
ContestCreate,
ContestUpdate,
ContestResponse,
ContestListResponse,
)
from app.schemas.problem import (
ProblemCreate,
ProblemUpdate,
ProblemResponse,
ProblemListResponse,
TestCaseCreate,
TestCaseResponse,
SampleTestResponse,
)
from app.schemas.submission import (
SubmissionCreate,
SubmissionResponse,
SubmissionListResponse,
)
__all__ = [
"UserCreate",
"UserLogin",
"UserResponse",
"Token",
"ContestCreate",
"ContestUpdate",
"ContestResponse",
"ContestListResponse",
"ProblemCreate",
"ProblemUpdate",
"ProblemResponse",
"ProblemListResponse",
"TestCaseCreate",
"TestCaseResponse",
"SampleTestResponse",
"SubmissionCreate",
"SubmissionResponse",
"SubmissionListResponse",
]

View File

@ -0,0 +1,52 @@
from datetime import datetime
from pydantic import BaseModel
class ContestCreate(BaseModel):
title: str
description: str | None = None
start_time: datetime
end_time: datetime
is_active: bool = False
class ContestUpdate(BaseModel):
title: str | None = None
description: str | None = None
start_time: datetime | None = None
end_time: datetime | None = None
is_active: bool | None = None
class ContestResponse(BaseModel):
id: int
title: str
description: str | None
start_time: datetime
end_time: datetime
is_active: bool
created_by: int | None
created_at: datetime
is_running: bool
has_ended: bool
problems_count: int = 0
participants_count: int = 0
is_participating: bool = False
class Config:
from_attributes = True
class ContestListResponse(BaseModel):
id: int
title: str
start_time: datetime
end_time: datetime
is_active: bool
is_running: bool
has_ended: bool
problems_count: int = 0
participants_count: int = 0
class Config:
from_attributes = True

View File

@ -0,0 +1,86 @@
from datetime import datetime
from pydantic import BaseModel
class TestCaseCreate(BaseModel):
input: str
expected_output: str
is_sample: bool = False
points: int = 0
order_index: int = 0
class TestCaseResponse(BaseModel):
id: int
input: str
expected_output: str
is_sample: bool
points: int
order_index: int
class Config:
from_attributes = True
class SampleTestResponse(BaseModel):
"""Sample test shown to participants (without expected output hidden for non-samples)"""
input: str
output: str
class ProblemCreate(BaseModel):
contest_id: int
title: str
description: str
input_format: str | None = None
output_format: str | None = None
constraints: str | None = None
time_limit_ms: int = 1000
memory_limit_kb: int = 262144
total_points: int = 100
order_index: int = 0
test_cases: list[TestCaseCreate] = []
class ProblemUpdate(BaseModel):
title: str | None = None
description: str | None = None
input_format: str | None = None
output_format: str | None = None
constraints: str | None = None
time_limit_ms: int | None = None
memory_limit_kb: int | None = None
total_points: int | None = None
order_index: int | None = None
class ProblemResponse(BaseModel):
id: int
contest_id: int
title: str
description: str
input_format: str | None
output_format: str | None
constraints: str | None
time_limit_ms: int
memory_limit_kb: int
total_points: int
order_index: int
created_at: datetime
sample_tests: list[SampleTestResponse] = []
class Config:
from_attributes = True
class ProblemListResponse(BaseModel):
id: int
contest_id: int
title: str
total_points: int
order_index: int
time_limit_ms: int
memory_limit_kb: int
class Config:
from_attributes = True

View File

@ -0,0 +1,45 @@
from datetime import datetime
from pydantic import BaseModel
class SubmissionCreate(BaseModel):
problem_id: int
contest_id: int
source_code: str
language_id: int
language_name: str | None = None
class SubmissionResponse(BaseModel):
id: int
user_id: int
problem_id: int
contest_id: int
language_id: int
language_name: str | None
status: str
score: int
total_points: int
tests_passed: int
tests_total: int
execution_time_ms: int | None
memory_used_kb: int | None
created_at: datetime
class Config:
from_attributes = True
class SubmissionListResponse(BaseModel):
id: int
problem_id: int
language_name: str | None
status: str
score: int
total_points: int
tests_passed: int
tests_total: int
created_at: datetime
class Config:
from_attributes = True

View File

@ -0,0 +1,43 @@
from datetime import datetime
from pydantic import BaseModel, EmailStr
class UserCreate(BaseModel):
email: EmailStr
username: str
password: str
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserUpdate(BaseModel):
username: str | None = None
full_name: str | None = None
telegram: str | None = None
vk: str | None = None
study_group: str | None = None
class UserResponse(BaseModel):
id: int
email: str
username: str
role: str
is_active: bool
created_at: datetime
full_name: str | None = None
telegram: str | None = None
vk: str | None = None
study_group: str | None = None
avatar_url: str | None = None
class Config:
from_attributes = True
class Token(BaseModel):
access_token: str
token_type: str = "bearer"

View File

@ -0,0 +1,17 @@
from app.services.auth import (
get_password_hash,
verify_password,
create_access_token,
decode_token,
)
from app.services.judge import JudgeService
from app.services.scoring import evaluate_submission
__all__ = [
"get_password_hash",
"verify_password",
"create_access_token",
"decode_token",
"JudgeService",
"evaluate_submission",
]

View File

@ -0,0 +1,38 @@
from datetime import datetime, timedelta
import bcrypt
from jose import jwt, JWTError
from app.config import settings
def get_password_hash(password: str) -> str:
pwd_bytes = password.encode("utf-8")[:72]
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(pwd_bytes, salt)
return hashed.decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool:
pwd_bytes = plain_password.encode("utf-8")[:72]
hashed_bytes = hashed_password.encode("utf-8")
return bcrypt.checkpw(pwd_bytes, hashed_bytes)
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
return encoded_jwt
def decode_token(token: str) -> dict | None:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
return payload
except JWTError as e:
print(f"JWT decode error: {e}")
return None

View File

@ -0,0 +1,245 @@
import httpx
from app.config import settings
# Mapping Judge0 language_id to Piston language name and version
# Updated with latest available versions in Piston
LANGUAGE_MAP = {
# Python
71: ("python", "3.12.0"), # Python 3.12 LTS
70: ("python", "3.12.0"), # Python 2 -> redirect to Python 3
# C/C++
50: ("c", "10.2.0"), # C (GCC 10.2.0)
54: ("c++", "10.2.0"), # C++ (GCC 10.2.0)
# Java
62: ("java", "15.0.2"), # Java (OpenJDK 15.0.2)
# JavaScript/Node.js
63: ("javascript", "20.11.1"), # Node.js 20.11.1
# Go
60: ("go", "1.16.2"), # Go 1.16.2
# Rust
73: ("rust", "1.68.2"), # Rust 1.68.2
# Kotlin
78: ("kotlin", "1.8.20"), # Kotlin 1.8.20
}
class JudgeStatus:
"""Status codes compatible with Judge0 format"""
IN_QUEUE = 1
PROCESSING = 2
ACCEPTED = 3
WRONG_ANSWER = 4
TIME_LIMIT_EXCEEDED = 5
COMPILATION_ERROR = 6
RUNTIME_ERROR_SIGSEGV = 7
RUNTIME_ERROR_SIGXFSZ = 8
RUNTIME_ERROR_SIGFPE = 9
RUNTIME_ERROR_SIGABRT = 10
RUNTIME_ERROR_NZEC = 11
RUNTIME_ERROR_OTHER = 12
INTERNAL_ERROR = 13
EXEC_FORMAT_ERROR = 14
class JudgeService:
"""Code execution service using Piston API"""
def __init__(self):
self.base_url = settings.piston_url
async def get_languages(self) -> list[dict]:
"""Get list of supported languages from Piston"""
async with httpx.AsyncClient() as client:
response = await client.get(f"{self.base_url}/api/v2/runtimes")
response.raise_for_status()
runtimes = response.json()
# Create a set of available runtimes for quick lookup
available = {rt["language"] for rt in runtimes}
# Return only languages from LANGUAGE_MAP that are installed
result = []
seen_ids = set()
for lang_id, (piston_lang, piston_ver) in LANGUAGE_MAP.items():
if piston_lang in available and lang_id not in seen_ids:
seen_ids.add(lang_id)
# Find actual installed version
actual_version = piston_ver
for rt in runtimes:
if rt["language"] == piston_lang:
actual_version = rt["version"]
break
# Human-readable names
name_map = {
"python": "Python",
"c": "C (GCC)",
"c++": "C++ (GCC)",
"java": "Java",
"javascript": "JavaScript (Node.js)",
"go": "Go",
"rust": "Rust",
"kotlin": "Kotlin",
}
display_name = name_map.get(piston_lang, piston_lang.title())
result.append({
"id": lang_id,
"name": f"{display_name} ({actual_version})",
})
return result
def _normalize_output(self, output: str) -> str:
"""
Normalize output for comparison:
- Strip trailing whitespace from each line
- Ensure single trailing newline (as print() adds)
"""
if not output:
return ""
lines = output.splitlines()
normalized = "\n".join(line.rstrip() for line in lines)
if normalized:
normalized += "\n"
return normalized
def _get_piston_language(self, language_id: int) -> tuple[str, str]:
"""Convert Judge0 language_id to Piston language name and version"""
if language_id in LANGUAGE_MAP:
return LANGUAGE_MAP[language_id]
# Default to Python if unknown
return ("python", "3.10.0")
async def submit(
self,
source_code: str,
language_id: int,
stdin: str = "",
expected_output: str = "",
cpu_time_limit: float = 1.0,
memory_limit: int = 262144,
) -> dict:
"""
Execute code using Piston and return result in Judge0-compatible format.
"""
language, version = self._get_piston_language(language_id)
normalized_expected = self._normalize_output(expected_output)
# Determine file extension
ext_map = {
"python": "py", "c": "c", "c++": "cpp", "java": "java",
"javascript": "js", "go": "go", "rust": "rs", "kotlin": "kt",
}
ext = ext_map.get(language, "txt")
filename = f"main.{ext}"
# For Java, extract class name from source
if language == "java":
import re
match = re.search(r'public\s+class\s+(\w+)', source_code)
if match:
filename = f"{match.group(1)}.java"
async with httpx.AsyncClient(timeout=30.0) as client:
try:
response = await client.post(
f"{self.base_url}/api/v2/execute",
json={
"language": language,
"version": version,
"files": [
{
"name": filename,
"content": source_code,
}
],
"stdin": stdin,
"run_timeout": int(cpu_time_limit * 1000), # Convert to ms
"compile_timeout": 10000, # 10 seconds for compilation
},
)
response.raise_for_status()
result = response.json()
except httpx.HTTPStatusError as e:
return {
"status": {"id": JudgeStatus.INTERNAL_ERROR, "description": "Internal Error"},
"stdout": "",
"stderr": str(e),
"compile_output": "",
"message": f"Piston API error: {e.response.status_code}",
"time": None,
"memory": None,
}
except Exception as e:
return {
"status": {"id": JudgeStatus.INTERNAL_ERROR, "description": "Internal Error"},
"stdout": "",
"stderr": str(e),
"compile_output": "",
"message": str(e),
"time": None,
"memory": None,
}
# Convert Piston response to Judge0-compatible format
run_result = result.get("run", {})
compile_result = result.get("compile", {})
stdout = run_result.get("stdout", "") or ""
stderr = run_result.get("stderr", "") or ""
compile_output = compile_result.get("output", "") or ""
exit_code = run_result.get("code", 0)
signal = run_result.get("signal")
# Determine status
if compile_result.get("code") is not None and compile_result.get("code") != 0:
# Compilation error
status_id = JudgeStatus.COMPILATION_ERROR
status_desc = "Compilation Error"
elif signal == "SIGKILL":
# Usually means timeout or memory limit
status_id = JudgeStatus.TIME_LIMIT_EXCEEDED
status_desc = "Time Limit Exceeded"
elif signal is not None:
# Runtime error with signal
signal_map = {
"SIGSEGV": JudgeStatus.RUNTIME_ERROR_SIGSEGV,
"SIGFPE": JudgeStatus.RUNTIME_ERROR_SIGFPE,
"SIGABRT": JudgeStatus.RUNTIME_ERROR_SIGABRT,
}
status_id = signal_map.get(signal, JudgeStatus.RUNTIME_ERROR_OTHER)
status_desc = f"Runtime Error ({signal})"
elif exit_code != 0:
# Non-zero exit code
status_id = JudgeStatus.RUNTIME_ERROR_NZEC
status_desc = "Runtime Error (NZEC)"
else:
# Execution successful - compare output
actual_output = self._normalize_output(stdout)
if actual_output == normalized_expected:
status_id = JudgeStatus.ACCEPTED
status_desc = "Accepted"
else:
status_id = JudgeStatus.WRONG_ANSWER
status_desc = "Wrong Answer"
return {
"status": {"id": status_id, "description": status_desc},
"stdout": stdout,
"stderr": stderr,
"compile_output": compile_output,
"message": "",
"time": None, # Piston doesn't provide execution time in same format
"memory": None, # Piston doesn't provide memory usage
}
judge_service = JudgeService()

View File

@ -0,0 +1,141 @@
import math
from app.services.judge import judge_service, JudgeStatus
from app.models.test_case import TestCase
def distribute_points(total_points: int, num_tests: int) -> list[int]:
"""
Distribute total_points across num_tests.
If not divisible, round up (each test gets ceil(total/num) points,
but we cap so total doesn't exceed total_points).
"""
if num_tests == 0:
return []
points_per_test = math.ceil(total_points / num_tests)
distributed = []
remaining = total_points
for i in range(num_tests):
# Give each test ceil points, but don't exceed remaining
test_points = min(points_per_test, remaining)
distributed.append(test_points)
remaining -= test_points
return distributed
async def evaluate_submission(
source_code: str,
language_id: int,
test_cases: list[TestCase],
total_points: int = 100,
time_limit_ms: int = 1000,
memory_limit_kb: int = 262144,
) -> dict:
"""
Evaluate a submission against all test cases.
Returns score details with partial points (IOI style).
Points are auto-distributed from total_points across test cases.
"""
total_score = 0
tests_passed = 0
max_time_ms = 0
max_memory_kb = 0
results = []
time_limit_sec = time_limit_ms / 1000.0
# Auto-distribute points across tests
test_points_list = distribute_points(total_points, len(test_cases))
for idx, test_case in enumerate(test_cases):
test_max_points = test_points_list[idx] if idx < len(test_points_list) else 0
try:
result = await judge_service.submit(
source_code=source_code,
language_id=language_id,
stdin=test_case.input,
expected_output=test_case.expected_output,
cpu_time_limit=time_limit_sec,
memory_limit=memory_limit_kb,
)
status_id = result.get("status", {}).get("id", 0)
status_desc = result.get("status", {}).get("description", "Unknown")
time_ms = int(float(result.get("time", 0) or 0) * 1000)
memory_kb = result.get("memory", 0) or 0
passed = status_id == JudgeStatus.ACCEPTED
points = test_max_points if passed else 0
if passed:
tests_passed += 1
total_score += test_max_points
if time_ms > max_time_ms:
max_time_ms = time_ms
if memory_kb > max_memory_kb:
max_memory_kb = memory_kb
results.append({
"test_id": test_case.id,
"is_sample": test_case.is_sample,
"status": status_desc,
"status_id": status_id,
"passed": passed,
"points": points,
"max_points": test_max_points,
"time_ms": time_ms,
"memory_kb": memory_kb,
# Debug info
"stdout": result.get("stdout") or "",
"stderr": result.get("stderr") or "",
"compile_output": result.get("compile_output") or "",
"message": result.get("message") or "",
})
except Exception as e:
results.append({
"test_id": test_case.id,
"is_sample": test_case.is_sample,
"status": "Internal Error",
"status_id": JudgeStatus.INTERNAL_ERROR,
"passed": False,
"points": 0,
"max_points": test_max_points,
"error": str(e),
})
# Determine overall status
if tests_passed == len(test_cases):
overall_status = "accepted"
elif tests_passed > 0:
overall_status = "partial"
else:
# Check what kind of error
first_failed = next((r for r in results if not r["passed"]), None)
if first_failed:
status_id = first_failed.get("status_id", 0)
if status_id == JudgeStatus.COMPILATION_ERROR:
overall_status = "compilation_error"
elif status_id == JudgeStatus.TIME_LIMIT_EXCEEDED:
overall_status = "time_limit_exceeded"
elif status_id >= JudgeStatus.RUNTIME_ERROR_SIGSEGV:
# Runtime errors: SIGSEGV(7), SIGXFSZ(8), SIGFPE(9), SIGABRT(10), NZEC(11), OTHER(12), INTERNAL(13), EXEC_FORMAT(14)
overall_status = "runtime_error"
else:
overall_status = "wrong_answer"
else:
overall_status = "wrong_answer"
return {
"status": overall_status,
"score": total_score,
"total_points": total_points, # Use parameter, not sum of test_case.points
"tests_passed": tests_passed,
"tests_total": len(test_cases),
"execution_time_ms": max_time_ms,
"memory_used_kb": max_memory_kb,
"details": results,
}

11
backend/pytest.ini Normal file
View File

@ -0,0 +1,11 @@
[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
testpaths = tests
python_files = test_*.py
python_classes = Test[A-Z]*
python_functions = test_*
addopts = -v --tb=short
filterwarnings =
ignore::DeprecationWarning
ignore::pytest.PytestCollectionWarning

28
backend/requirements.txt Normal file
View File

@ -0,0 +1,28 @@
# FastAPI and server
fastapi[standard]>=0.122.0
uvicorn[standard]>=0.32.0
# Database
sqlalchemy>=2.0.44
asyncpg>=0.31.0
alembic>=1.17.2
# Pydantic
pydantic>=2.12.5
pydantic-settings>=2.6.0
# Authentication
python-jose[cryptography]>=3.3.0
bcrypt>=4.0.0
# HTTP client for Judge0
httpx>=0.28.1
# Form data
python-multipart>=0.0.18
# Testing
pytest>=8.0.0
pytest-asyncio>=0.24.0
pytest-cov>=6.0.0
aiosqlite>=0.20.0

View File

187
backend/tests/conftest.py Normal file
View File

@ -0,0 +1,187 @@
import pytest
from datetime import datetime, timezone, timedelta
from typing import AsyncGenerator
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from app.main import app
from app.database import Base, get_db
from app.models.user import User
from app.models.contest import Contest
from app.models.problem import Problem
from app.models.test_case import TestCase
from app.services.auth import get_password_hash, create_access_token
# Use SQLite for testing
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
engine = create_async_engine(
TEST_DATABASE_URL,
echo=False,
)
async_session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
async with async_session_maker() as session:
try:
yield session
finally:
await session.close()
app.dependency_overrides[get_db] = override_get_db
@pytest.fixture(autouse=True)
async def setup_database():
"""Create tables before each test and drop after."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest.fixture
async def db_session() -> AsyncGenerator[AsyncSession, None]:
"""Get a database session for direct database operations in tests."""
async with async_session_maker() as session:
yield session
@pytest.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
"""Get an async HTTP client for testing API endpoints."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest.fixture
async def test_user(db_session: AsyncSession) -> User:
"""Create a test user."""
user = User(
email="test@example.com",
username="testuser",
password_hash=get_password_hash("testpassword"),
role="participant",
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest.fixture
async def admin_user(db_session: AsyncSession) -> User:
"""Create an admin user."""
user = User(
email="admin@example.com",
username="adminuser",
password_hash=get_password_hash("adminpassword"),
role="admin",
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest.fixture
def user_token(test_user: User) -> str:
"""Get a JWT token for the test user."""
return create_access_token(data={"sub": str(test_user.id)})
@pytest.fixture
def admin_token(admin_user: User) -> str:
"""Get a JWT token for the admin user."""
return create_access_token(data={"sub": str(admin_user.id)})
@pytest.fixture
def auth_headers(user_token: str) -> dict:
"""Get authorization headers for the test user."""
return {"Authorization": f"Bearer {user_token}"}
@pytest.fixture
def admin_headers(admin_token: str) -> dict:
"""Get authorization headers for the admin user."""
return {"Authorization": f"Bearer {admin_token}"}
@pytest.fixture
async def test_contest(db_session: AsyncSession, admin_user: User) -> Contest:
"""Create a test contest."""
now = datetime.now(timezone.utc)
contest = Contest(
title="Test Contest",
description="A test contest",
start_time=now - timedelta(hours=1),
end_time=now + timedelta(hours=2),
is_active=True,
created_by=admin_user.id,
)
db_session.add(contest)
await db_session.commit()
await db_session.refresh(contest)
return contest
@pytest.fixture
async def test_problem(db_session: AsyncSession, test_contest: Contest) -> Problem:
"""Create a test problem."""
problem = Problem(
contest_id=test_contest.id,
title="Sum of Two Numbers",
description="Given two integers, find their sum.",
input_format="Two integers a and b",
output_format="Their sum",
constraints="1 <= a, b <= 1000",
time_limit_ms=1000,
memory_limit_kb=262144,
total_points=100,
order_index=0,
)
db_session.add(problem)
await db_session.commit()
await db_session.refresh(problem)
return problem
@pytest.fixture
async def test_cases(db_session: AsyncSession, test_problem: Problem) -> list[TestCase]:
"""Create test cases for the test problem."""
cases = [
TestCase(
problem_id=test_problem.id,
input="1 2",
expected_output="3",
is_sample=True,
points=50,
order_index=0,
),
TestCase(
problem_id=test_problem.id,
input="100 200",
expected_output="300",
is_sample=False,
points=50,
order_index=1,
),
]
for tc in cases:
db_session.add(tc)
await db_session.commit()
for tc in cases:
await db_session.refresh(tc)
return cases

133
backend/tests/test_auth.py Normal file
View File

@ -0,0 +1,133 @@
import pytest
from httpx import AsyncClient
from app.models.user import User
class TestRegister:
"""Tests for user registration endpoint."""
async def test_register_success(self, client: AsyncClient):
"""Test successful user registration."""
response = await client.post(
"/api/auth/register",
json={
"email": "newuser@example.com",
"username": "newuser",
"password": "securepassword123",
},
)
assert response.status_code == 201
data = response.json()
assert data["email"] == "newuser@example.com"
assert data["username"] == "newuser"
assert data["role"] == "participant"
assert "password" not in data
assert "password_hash" not in data
async def test_register_duplicate_email(self, client: AsyncClient, test_user: User):
"""Test registration with already existing email."""
response = await client.post(
"/api/auth/register",
json={
"email": test_user.email,
"username": "anotheruser",
"password": "password123",
},
)
assert response.status_code == 400
assert "already registered" in response.json()["detail"].lower()
async def test_register_invalid_email(self, client: AsyncClient):
"""Test registration with invalid email format."""
response = await client.post(
"/api/auth/register",
json={
"email": "not-an-email",
"username": "testuser",
"password": "password123",
},
)
assert response.status_code == 422
async def test_register_short_password(self, client: AsyncClient):
"""Test registration with too short password."""
response = await client.post(
"/api/auth/register",
json={
"email": "user@example.com",
"username": "testuser",
"password": "123",
},
)
# Depending on validation, this might be 422 or succeed
# If no password length validation, it will succeed
assert response.status_code in [201, 422]
class TestLogin:
"""Tests for user login endpoint."""
async def test_login_success(self, client: AsyncClient, test_user: User):
"""Test successful login."""
response = await client.post(
"/api/auth/login",
data={
"username": test_user.email,
"password": "testpassword",
},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
async def test_login_wrong_password(self, client: AsyncClient, test_user: User):
"""Test login with incorrect password."""
response = await client.post(
"/api/auth/login",
data={
"username": test_user.email,
"password": "wrongpassword",
},
)
assert response.status_code == 401
async def test_login_nonexistent_user(self, client: AsyncClient):
"""Test login with non-existent user."""
response = await client.post(
"/api/auth/login",
data={
"username": "nonexistent@example.com",
"password": "password123",
},
)
assert response.status_code == 401
class TestGetMe:
"""Tests for getting current user endpoint."""
async def test_get_me_authenticated(
self, client: AsyncClient, test_user: User, auth_headers: dict
):
"""Test getting current user when authenticated."""
response = await client.get("/api/auth/me", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["email"] == test_user.email
assert data["username"] == test_user.username
assert data["role"] == test_user.role
async def test_get_me_unauthenticated(self, client: AsyncClient):
"""Test getting current user without authentication."""
response = await client.get("/api/auth/me")
assert response.status_code == 401
async def test_get_me_invalid_token(self, client: AsyncClient):
"""Test getting current user with invalid token."""
response = await client.get(
"/api/auth/me",
headers={"Authorization": "Bearer invalid_token"},
)
assert response.status_code == 401

View File

@ -0,0 +1,196 @@
import pytest
from datetime import datetime, timezone, timedelta
from httpx import AsyncClient
from app.models.user import User
from app.models.contest import Contest
class TestListContests:
"""Tests for listing contests."""
async def test_list_contests_empty(self, client: AsyncClient, auth_headers: dict):
"""Test listing contests when none exist."""
response = await client.get("/api/contests/", headers=auth_headers)
assert response.status_code == 200
assert response.json() == []
async def test_list_contests_with_data(
self, client: AsyncClient, auth_headers: dict, test_contest: Contest
):
"""Test listing contests with existing data."""
response = await client.get("/api/contests/", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["title"] == test_contest.title
async def test_list_contests_unauthenticated(self, client: AsyncClient):
"""Test listing contests without authentication."""
response = await client.get("/api/contests/")
assert response.status_code == 401
class TestGetContest:
"""Tests for getting a single contest."""
async def test_get_contest_success(
self, client: AsyncClient, auth_headers: dict, test_contest: Contest
):
"""Test getting an existing contest."""
response = await client.get(
f"/api/contests/{test_contest.id}", headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["id"] == test_contest.id
assert data["title"] == test_contest.title
assert data["description"] == test_contest.description
async def test_get_contest_not_found(self, client: AsyncClient, auth_headers: dict):
"""Test getting a non-existent contest."""
response = await client.get("/api/contests/99999", headers=auth_headers)
assert response.status_code == 404
class TestCreateContest:
"""Tests for creating contests."""
async def test_create_contest_as_admin(
self, client: AsyncClient, admin_headers: dict
):
"""Test creating a contest as admin."""
now = datetime.now(timezone.utc)
response = await client.post(
"/api/contests/",
headers=admin_headers,
json={
"title": "New Contest",
"description": "A new test contest",
"start_time": (now + timedelta(hours=1)).isoformat(),
"end_time": (now + timedelta(hours=3)).isoformat(),
"is_active": False,
},
)
assert response.status_code == 201
data = response.json()
assert data["title"] == "New Contest"
assert data["is_active"] is False
async def test_create_contest_as_participant(
self, client: AsyncClient, auth_headers: dict
):
"""Test that participants cannot create contests."""
now = datetime.now(timezone.utc)
response = await client.post(
"/api/contests/",
headers=auth_headers,
json={
"title": "New Contest",
"description": "A new test contest",
"start_time": (now + timedelta(hours=1)).isoformat(),
"end_time": (now + timedelta(hours=3)).isoformat(),
"is_active": False,
},
)
assert response.status_code == 403
async def test_create_contest_without_title(
self, client: AsyncClient, admin_headers: dict
):
"""Test creating a contest without required fields."""
now = datetime.now(timezone.utc)
response = await client.post(
"/api/contests/",
headers=admin_headers,
json={
"description": "A new test contest",
"start_time": (now + timedelta(hours=1)).isoformat(),
"end_time": (now + timedelta(hours=3)).isoformat(),
},
)
assert response.status_code == 422
class TestUpdateContest:
"""Tests for updating contests."""
async def test_update_contest_as_admin(
self, client: AsyncClient, admin_headers: dict, test_contest: Contest
):
"""Test updating a contest as admin."""
response = await client.put(
f"/api/contests/{test_contest.id}",
headers=admin_headers,
json={"title": "Updated Title"},
)
assert response.status_code == 200
data = response.json()
assert data["title"] == "Updated Title"
async def test_update_contest_as_participant(
self, client: AsyncClient, auth_headers: dict, test_contest: Contest
):
"""Test that participants cannot update contests."""
response = await client.put(
f"/api/contests/{test_contest.id}",
headers=auth_headers,
json={"title": "Updated Title"},
)
assert response.status_code == 403
class TestDeleteContest:
"""Tests for deleting contests."""
async def test_delete_contest_as_admin(
self, client: AsyncClient, admin_headers: dict, test_contest: Contest
):
"""Test deleting a contest as admin."""
response = await client.delete(
f"/api/contests/{test_contest.id}", headers=admin_headers
)
assert response.status_code == 204
# Verify it's deleted
response = await client.get(
f"/api/contests/{test_contest.id}", headers=admin_headers
)
assert response.status_code == 404
async def test_delete_contest_as_participant(
self, client: AsyncClient, auth_headers: dict, test_contest: Contest
):
"""Test that participants cannot delete contests."""
response = await client.delete(
f"/api/contests/{test_contest.id}", headers=auth_headers
)
assert response.status_code == 403
class TestJoinContest:
"""Tests for joining contests."""
async def test_join_contest_success(
self, client: AsyncClient, auth_headers: dict, test_contest: Contest
):
"""Test successfully joining a contest."""
response = await client.post(
f"/api/contests/{test_contest.id}/join", headers=auth_headers
)
assert response.status_code in [200, 201]
async def test_join_contest_twice(
self, client: AsyncClient, auth_headers: dict, test_contest: Contest
):
"""Test joining a contest twice."""
# First join
await client.post(
f"/api/contests/{test_contest.id}/join", headers=auth_headers
)
# Second join should fail or return success depending on implementation
response = await client.post(
f"/api/contests/{test_contest.id}/join", headers=auth_headers
)
# Either 200 (idempotent) or 400 (already joined)
assert response.status_code in [200, 400]

View File

@ -0,0 +1,249 @@
import pytest
from httpx import AsyncClient
from app.models.contest import Contest
from app.models.problem import Problem
from app.models.test_case import TestCase
class TestListProblems:
"""Tests for listing problems by contest."""
async def test_list_problems_empty(
self, client: AsyncClient, auth_headers: dict, test_contest: Contest
):
"""Test listing problems when none exist."""
response = await client.get(
f"/api/problems/contest/{test_contest.id}", headers=auth_headers
)
assert response.status_code == 200
assert response.json() == []
async def test_list_problems_with_data(
self,
client: AsyncClient,
auth_headers: dict,
test_contest: Contest,
test_problem: Problem,
):
"""Test listing problems with existing data."""
response = await client.get(
f"/api/problems/contest/{test_contest.id}", headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["title"] == test_problem.title
class TestGetProblem:
"""Tests for getting a single problem."""
async def test_get_problem_success(
self, client: AsyncClient, auth_headers: dict, test_problem: Problem
):
"""Test getting an existing problem."""
response = await client.get(
f"/api/problems/{test_problem.id}", headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["id"] == test_problem.id
assert data["title"] == test_problem.title
assert data["description"] == test_problem.description
async def test_get_problem_not_found(self, client: AsyncClient, auth_headers: dict):
"""Test getting a non-existent problem."""
response = await client.get("/api/problems/99999", headers=auth_headers)
assert response.status_code == 404
async def test_get_problem_includes_sample_tests(
self,
client: AsyncClient,
auth_headers: dict,
test_problem: Problem,
test_cases: list[TestCase],
):
"""Test that getting a problem includes sample tests."""
response = await client.get(
f"/api/problems/{test_problem.id}", headers=auth_headers
)
assert response.status_code == 200
data = response.json()
# Should include sample tests
assert "sample_tests" in data
# Only sample tests should be visible (is_sample=True)
sample_count = sum(1 for tc in test_cases if tc.is_sample)
assert len(data["sample_tests"]) == sample_count
class TestCreateProblem:
"""Tests for creating problems."""
async def test_create_problem_as_admin(
self, client: AsyncClient, admin_headers: dict, test_contest: Contest
):
"""Test creating a problem as admin."""
response = await client.post(
"/api/problems/",
headers=admin_headers,
json={
"contest_id": test_contest.id,
"title": "New Problem",
"description": "Solve this problem",
"input_format": "One integer n",
"output_format": "One integer",
"constraints": "1 <= n <= 100",
"time_limit_ms": 2000,
"memory_limit_kb": 131072,
"total_points": 50,
},
)
assert response.status_code == 201
data = response.json()
assert data["title"] == "New Problem"
assert data["total_points"] == 50
async def test_create_problem_as_participant(
self, client: AsyncClient, auth_headers: dict, test_contest: Contest
):
"""Test that participants cannot create problems."""
response = await client.post(
"/api/problems/",
headers=auth_headers,
json={
"contest_id": test_contest.id,
"title": "New Problem",
"description": "Solve this problem",
},
)
assert response.status_code == 403
async def test_create_problem_with_test_cases(
self, client: AsyncClient, admin_headers: dict, test_contest: Contest
):
"""Test creating a problem with test cases."""
response = await client.post(
"/api/problems/",
headers=admin_headers,
json={
"contest_id": test_contest.id,
"title": "Problem with Tests",
"description": "A problem with test cases",
"test_cases": [
{
"input": "1 2",
"expected_output": "3",
"is_sample": True,
"points": 25,
},
{
"input": "5 5",
"expected_output": "10",
"is_sample": False,
"points": 75,
},
],
},
)
assert response.status_code == 201
data = response.json()
assert len(data["sample_tests"]) == 1 # Only sample tests in response
class TestUpdateProblem:
"""Tests for updating problems."""
async def test_update_problem_as_admin(
self, client: AsyncClient, admin_headers: dict, test_problem: Problem
):
"""Test updating a problem as admin."""
response = await client.put(
f"/api/problems/{test_problem.id}",
headers=admin_headers,
json={"title": "Updated Problem Title"},
)
assert response.status_code == 200
data = response.json()
assert data["title"] == "Updated Problem Title"
async def test_update_problem_as_participant(
self, client: AsyncClient, auth_headers: dict, test_problem: Problem
):
"""Test that participants cannot update problems."""
response = await client.put(
f"/api/problems/{test_problem.id}",
headers=auth_headers,
json={"title": "Updated Title"},
)
assert response.status_code == 403
class TestDeleteProblem:
"""Tests for deleting problems."""
async def test_delete_problem_as_admin(
self, client: AsyncClient, admin_headers: dict, test_problem: Problem
):
"""Test deleting a problem as admin."""
response = await client.delete(
f"/api/problems/{test_problem.id}", headers=admin_headers
)
assert response.status_code == 204
# Verify it's deleted
response = await client.get(
f"/api/problems/{test_problem.id}", headers=admin_headers
)
assert response.status_code == 404
class TestTestCases:
"""Tests for test case management."""
async def test_get_test_cases_as_admin(
self,
client: AsyncClient,
admin_headers: dict,
test_problem: Problem,
test_cases: list[TestCase],
):
"""Test getting all test cases as admin."""
response = await client.get(
f"/api/problems/{test_problem.id}/test-cases", headers=admin_headers
)
assert response.status_code == 200
data = response.json()
assert len(data) == len(test_cases)
async def test_add_test_case_as_admin(
self, client: AsyncClient, admin_headers: dict, test_problem: Problem
):
"""Test adding a test case as admin."""
response = await client.post(
f"/api/problems/{test_problem.id}/test-cases",
headers=admin_headers,
json={
"input": "10 20",
"expected_output": "30",
"is_sample": True,
"points": 25,
},
)
assert response.status_code == 201
data = response.json()
assert data["input"] == "10 20"
assert data["expected_output"] == "30"
async def test_delete_test_case_as_admin(
self,
client: AsyncClient,
admin_headers: dict,
test_cases: list[TestCase],
):
"""Test deleting a test case as admin."""
test_case = test_cases[0]
response = await client.delete(
f"/api/problems/test-cases/{test_case.id}", headers=admin_headers
)
assert response.status_code == 204

View File

@ -0,0 +1,85 @@
import pytest
from datetime import datetime, timezone, timedelta
from app.services.auth import (
get_password_hash,
verify_password,
create_access_token,
decode_token,
)
class TestPasswordHashing:
"""Tests for password hashing functions."""
def test_hash_password(self):
"""Test that password hashing works."""
password = "securepassword123"
hashed = get_password_hash(password)
assert hashed != password
assert len(hashed) > 0
def test_verify_correct_password(self):
"""Test verifying correct password."""
password = "securepassword123"
hashed = get_password_hash(password)
assert verify_password(password, hashed) is True
def test_verify_wrong_password(self):
"""Test verifying wrong password."""
password = "securepassword123"
hashed = get_password_hash(password)
assert verify_password("wrongpassword", hashed) is False
def test_hash_is_unique(self):
"""Test that same password produces different hashes (salt)."""
password = "securepassword123"
hash1 = get_password_hash(password)
hash2 = get_password_hash(password)
assert hash1 != hash2 # Different salts
def test_long_password_truncated(self):
"""Test that long passwords work (bcrypt 72 byte limit)."""
long_password = "a" * 100
hashed = get_password_hash(long_password)
assert verify_password(long_password, hashed) is True
class TestJWT:
"""Tests for JWT token functions."""
def test_create_token(self):
"""Test creating a JWT token."""
token = create_access_token(data={"sub": "123"})
assert token is not None
assert len(token) > 0
def test_decode_valid_token(self):
"""Test decoding a valid token."""
user_id = "123"
token = create_access_token(data={"sub": user_id})
payload = decode_token(token)
assert payload is not None
assert payload["sub"] == user_id
def test_decode_invalid_token(self):
"""Test decoding an invalid token."""
payload = decode_token("invalid.token.here")
assert payload is None
def test_decode_expired_token(self):
"""Test that expired tokens are rejected."""
# Create a token that expires immediately
token = create_access_token(
data={"sub": "123"},
expires_delta=timedelta(seconds=-1), # Already expired
)
payload = decode_token(token)
assert payload is None
def test_token_contains_expiry(self):
"""Test that token contains expiration claim."""
token = create_access_token(data={"sub": "123"})
payload = decode_token(token)
assert payload is not None
assert "exp" in payload

74
docker-compose.yml Normal file
View File

@ -0,0 +1,74 @@
services:
# Main application database
db:
image: postgres:16-alpine
container_name: sp-db
environment:
POSTGRES_USER: sport_prog
POSTGRES_PASSWORD: secret
POSTGRES_DB: sport_programming
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U sport_prog -d sport_programming"]
interval: 5s
timeout: 5s
retries: 5
# Piston - code execution engine (replaces Judge0)
piston:
image: ghcr.io/engineer-man/piston
container_name: sp-piston
ports:
- "2000:2000"
privileged: true
restart: unless-stopped
tmpfs:
- /piston/jobs:exec,mode=777
# FastAPI backend
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: sp-backend
environment:
- DATABASE_URL=postgresql+asyncpg://sport_prog:secret@db:5432/sport_programming
- PISTON_URL=http://piston:2000
- SECRET_KEY=your-super-secret-key-change-in-production
- CORS_ORIGINS=http://localhost:3000
volumes:
- ./backend:/app
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
piston:
condition: service_started
restart: unless-stopped
# Next.js frontend
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: sp-frontend
environment:
- NEXT_PUBLIC_API_URL=http://localhost:8000
- NEXT_PUBLIC_WS_URL=ws://localhost:8000
volumes:
- ./frontend:/app
- /app/node_modules
- /app/.next
ports:
- "3000:3000"
depends_on:
- backend
restart: unless-stopped
volumes:
postgres_data:

18
frontend/Dockerfile Normal file
View File

@ -0,0 +1,18 @@
FROM node:22-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy application code
COPY . .
# Expose port
EXPOSE 3000
# Run the development server
CMD ["npm", "run", "dev"]

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

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;

2038
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
frontend/package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "sport-programming-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/postcss": "^4.0.0",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.23.24",
"js-cookie": "^3.0.5",
"lucide-react": "^0.470.0",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-resizable-panels": "^3.0.6",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.0",
"tailwindcss": "^4.0.0"
},
"devDependencies": {
"@types/canvas-confetti": "^1.9.0",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.7.0"
}
}

View File

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

View File

@ -0,0 +1,194 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
import type { Contest } from "@/types";
export default function EditContestPage() {
const { user } = useAuth();
const router = useRouter();
const params = useParams();
const contestId = Number(params.id);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState("");
const [formData, setFormData] = useState({
title: "",
description: "",
start_time: "",
end_time: "",
is_active: false,
});
useEffect(() => {
if (!user || user.role !== "admin") {
router.push("/contests");
return;
}
api
.getContest(contestId)
.then((contest: Contest) => {
setFormData({
title: contest.title,
description: contest.description || "",
start_time: formatDateTimeLocal(contest.start_time),
end_time: formatDateTimeLocal(contest.end_time),
is_active: contest.is_active,
});
})
.catch((err) => setError(err.message))
.finally(() => setIsLoading(false));
}, [contestId, user, router]);
const formatDateTimeLocal = (isoString: string) => {
const date = new Date(isoString);
return date.toISOString().slice(0, 16);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setIsSaving(true);
try {
await api.updateContest(contestId, {
title: formData.title,
description: formData.description || undefined,
start_time: new Date(formData.start_time).toISOString(),
end_time: new Date(formData.end_time).toISOString(),
is_active: formData.is_active,
});
router.push("/admin/contests");
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка сохранения");
} finally {
setIsSaving(false);
}
};
if (!user || user.role !== "admin") {
return null;
}
if (isLoading) {
return (
<div className="container mx-auto px-4 py-8 max-w-2xl">
<div className="animate-pulse space-y-6">
<div className="h-8 bg-muted rounded w-1/3" />
<div className="h-12 bg-muted rounded" />
<div className="h-24 bg-muted rounded" />
<div className="h-12 bg-muted rounded" />
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8 max-w-2xl">
<h1 className="text-3xl font-bold mb-8">Редактировать контест</h1>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-4 bg-destructive/10 text-destructive rounded-lg">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium mb-2">Название</label>
<input
type="text"
value={formData.title}
onChange={(e) =>
setFormData({ ...formData, title: e.target.value })
}
required
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Описание (опционально)
</label>
<textarea
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
rows={4}
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">
Время начала
</label>
<input
type="datetime-local"
value={formData.start_time}
onChange={(e) =>
setFormData({ ...formData, start_time: e.target.value })
}
required
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Время окончания
</label>
<input
type="datetime-local"
value={formData.end_time}
onChange={(e) =>
setFormData({ ...formData, end_time: e.target.value })
}
required
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="is_active"
checked={formData.is_active}
onChange={(e) =>
setFormData({ ...formData, is_active: e.target.checked })
}
className="w-4 h-4"
/>
<label htmlFor="is_active" className="text-sm">
Контест активен
</label>
</div>
<div className="flex gap-4">
<button
type="submit"
disabled={isSaving}
className="px-6 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition disabled:opacity-50"
>
{isSaving ? "Сохранение..." : "Сохранить"}
</button>
<button
type="button"
onClick={() => router.back()}
className="px-6 py-2 border border-border rounded-lg font-medium hover:bg-muted transition"
>
Отмена
</button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,258 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
import type { Problem } from "@/types";
export default function EditProblemPage() {
const { user } = useAuth();
const router = useRouter();
const params = useParams();
const contestId = Number(params.id);
const problemId = Number(params.problemId);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState("");
const [formData, setFormData] = useState({
title: "",
description: "",
input_format: "",
output_format: "",
constraints: "",
time_limit_ms: 1000,
memory_limit_kb: 262144,
points: 100,
});
useEffect(() => {
if (!user || user.role !== "admin") {
router.push("/contests");
return;
}
api
.getProblem(problemId)
.then((problem: Problem) => {
setFormData({
title: problem.title,
description: problem.description,
input_format: problem.input_format || "",
output_format: problem.output_format || "",
constraints: problem.constraints || "",
time_limit_ms: problem.time_limit_ms,
memory_limit_kb: problem.memory_limit_kb,
points: problem.total_points,
});
})
.catch((err) => setError(err.message))
.finally(() => setIsLoading(false));
}, [problemId, user, router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setIsSaving(true);
try {
await api.updateProblem(problemId, {
title: formData.title,
description: formData.description,
input_format: formData.input_format || undefined,
output_format: formData.output_format || undefined,
constraints: formData.constraints || undefined,
time_limit_ms: formData.time_limit_ms,
memory_limit_kb: formData.memory_limit_kb,
total_points: formData.points,
});
router.push(`/admin/contests/${contestId}/problems`);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка сохранения");
} finally {
setIsSaving(false);
}
};
if (!user || user.role !== "admin") {
return null;
}
if (isLoading) {
return (
<div className="container mx-auto px-4 py-8 max-w-3xl">
<div className="animate-pulse space-y-6">
<div className="h-8 bg-muted rounded w-1/3" />
<div className="h-12 bg-muted rounded" />
<div className="h-32 bg-muted rounded" />
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8 max-w-3xl">
<div className="flex items-center gap-4 mb-8">
<Link
href={`/admin/contests/${contestId}/problems`}
className="text-muted-foreground hover:text-foreground transition"
>
Назад к задачам
</Link>
</div>
<h1 className="text-3xl font-bold mb-8">Редактировать задачу</h1>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-4 bg-destructive/10 text-destructive rounded-lg">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium mb-2">Название</label>
<input
type="text"
value={formData.title}
onChange={(e) =>
setFormData({ ...formData, title: e.target.value })
}
required
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Условие задачи
</label>
<textarea
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
required
rows={6}
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring font-mono text-sm"
/>
</div>
<div className="grid md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium mb-2">
Формат ввода
</label>
<textarea
value={formData.input_format}
onChange={(e) =>
setFormData({ ...formData, input_format: e.target.value })
}
rows={3}
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring font-mono text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Формат вывода
</label>
<textarea
value={formData.output_format}
onChange={(e) =>
setFormData({ ...formData, output_format: e.target.value })
}
rows={3}
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring font-mono text-sm"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">Ограничения</label>
<textarea
value={formData.constraints}
onChange={(e) =>
setFormData({ ...formData, constraints: e.target.value })
}
rows={2}
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring font-mono text-sm"
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium mb-2">
Лимит времени (мс)
</label>
<input
type="number"
value={formData.time_limit_ms}
onChange={(e) =>
setFormData({
...formData,
time_limit_ms: Number(e.target.value),
})
}
required
min={100}
max={30000}
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Лимит памяти (КБ)
</label>
<input
type="number"
value={formData.memory_limit_kb}
onChange={(e) =>
setFormData({
...formData,
memory_limit_kb: Number(e.target.value),
})
}
required
min={16384}
max={524288}
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Баллы</label>
<input
type="number"
value={formData.points}
onChange={(e) =>
setFormData({ ...formData, points: Number(e.target.value) })
}
required
min={1}
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<div className="flex gap-4">
<button
type="submit"
disabled={isSaving}
className="px-6 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition disabled:opacity-50"
>
{isSaving ? "Сохранение..." : "Сохранить"}
</button>
<button
type="button"
onClick={() => router.back()}
className="px-6 py-2 border border-border rounded-lg font-medium hover:bg-muted transition"
>
Отмена
</button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,305 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
import type { TestCase, Problem } from "@/types";
export default function ProblemTestsPage() {
const { user, isLoading: authLoading } = useAuth();
const router = useRouter();
const params = useParams();
const contestId = Number(params.id);
const problemId = Number(params.problemId);
const [problem, setProblem] = useState<Problem | null>(null);
const [testCases, setTestCases] = useState<TestCase[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState("");
const [showForm, setShowForm] = useState(false);
const [editingTest, setEditingTest] = useState<TestCase | null>(null);
const [formData, setFormData] = useState({
input: "",
expected_output: "",
is_sample: false,
});
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (!authLoading && (!user || user.role !== "admin")) {
router.push("/contests");
}
}, [user, authLoading, router]);
useEffect(() => {
if (user?.role === "admin") {
Promise.all([api.getProblem(problemId), api.getTestCases(problemId)])
.then(([problemData, testsData]) => {
setProblem(problemData);
setTestCases(testsData);
})
.catch((err) => setError(err.message))
.finally(() => setIsLoading(false));
}
}, [problemId, user]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setIsSaving(true);
try {
if (editingTest) {
const updated = await api.updateTestCase(editingTest.id, formData);
setTestCases((prev) =>
prev.map((t) => (t.id === editingTest.id ? updated : t))
);
} else {
const created = await api.createTestCase(problemId, formData);
setTestCases((prev) => [...prev, created]);
}
resetForm();
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка сохранения");
} finally {
setIsSaving(false);
}
};
const handleEdit = (test: TestCase) => {
setEditingTest(test);
setFormData({
input: test.input,
expected_output: test.expected_output,
is_sample: test.is_sample,
});
setShowForm(true);
};
const handleDelete = async (testId: number) => {
if (!confirm("Удалить этот тест?")) return;
try {
await api.deleteTestCase(testId);
setTestCases((prev) => prev.filter((t) => t.id !== testId));
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка удаления");
}
};
const resetForm = () => {
setShowForm(false);
setEditingTest(null);
setFormData({ input: "", expected_output: "", is_sample: false });
};
if (authLoading || isLoading) {
return (
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-muted rounded w-1/3" />
{[...Array(3)].map((_, i) => (
<div key={i} className="h-32 bg-muted rounded" />
))}
</div>
</div>
);
}
if (!user || user.role !== "admin") {
return null;
}
return (
<div className="container mx-auto px-4 py-8">
<div className="flex items-center gap-4 mb-4">
<Link
href={`/admin/contests/${contestId}/problems`}
className="text-muted-foreground hover:text-foreground transition"
>
Назад к задачам
</Link>
</div>
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold">Тесты задачи</h1>
{problem && (
<p className="text-muted-foreground mt-1">{problem.title}</p>
)}
</div>
{!showForm && (
<button
onClick={() => setShowForm(true)}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition"
>
Добавить тест
</button>
)}
</div>
{error && (
<div className="p-4 bg-destructive/10 text-destructive rounded-lg mb-4">
{error}
</div>
)}
{showForm && (
<div className="mb-8 p-6 border border-border rounded-lg bg-muted/30">
<h2 className="text-xl font-semibold mb-4">
{editingTest ? "Редактировать тест" : "Новый тест"}
</h2>
<div className="mb-4 p-4 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg text-sm">
<h3 className="font-semibold mb-2">Формат тестов:</h3>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Используйте <kbd className="px-1 py-0.5 bg-muted rounded text-xs">Enter</kbd> для переноса строки</li>
<li>Пример: для ввода двух чисел на разных строках введите первое число, нажмите Enter, введите второе</li>
<li>Trailing whitespace автоматически игнорируется при сравнении</li>
</ul>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">
Входные данные
</label>
<textarea
value={formData.input}
onChange={(e) =>
setFormData({ ...formData, input: e.target.value })
}
required
rows={6}
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring font-mono text-sm"
placeholder="1 2"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Ожидаемый вывод
</label>
<textarea
value={formData.expected_output}
onChange={(e) =>
setFormData({ ...formData, expected_output: e.target.value })
}
required
rows={6}
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring font-mono text-sm"
placeholder="3"
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="is_sample"
checked={formData.is_sample}
onChange={(e) =>
setFormData({ ...formData, is_sample: e.target.checked })
}
className="w-4 h-4"
/>
<label htmlFor="is_sample" className="text-sm">
Показывать как пример (виден участникам)
</label>
</div>
<div className="flex gap-4">
<button
type="submit"
disabled={isSaving}
className="px-6 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition disabled:opacity-50"
>
{isSaving
? "Сохранение..."
: editingTest
? "Сохранить"
: "Добавить"}
</button>
<button
type="button"
onClick={resetForm}
className="px-6 py-2 border border-border rounded-lg font-medium hover:bg-muted transition"
>
Отмена
</button>
</div>
</form>
</div>
)}
{testCases.length === 0 ? (
<div className="text-center py-16 border border-border rounded-lg">
<p className="text-muted-foreground mb-4">
Для этой задачи пока нет тестов
</p>
{!showForm && (
<button
onClick={() => setShowForm(true)}
className="text-primary hover:underline"
>
Добавить первый тест
</button>
)}
</div>
) : (
<div className="space-y-4">
{testCases.map((test, index) => (
<div
key={test.id}
className="p-4 border border-border rounded-lg"
>
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-3">
<span className="font-semibold">Тест #{index + 1}</span>
{test.is_sample && (
<span className="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded">
Пример
</span>
)}
</div>
<div className="flex gap-2">
<button
onClick={() => handleEdit(test)}
className="px-3 py-1 text-sm border border-border rounded hover:bg-muted transition"
>
Редактировать
</button>
<button
onClick={() => handleDelete(test.id)}
className="px-3 py-1 text-sm text-destructive border border-destructive/30 rounded hover:bg-destructive/10 transition"
>
Удалить
</button>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<div className="text-sm text-muted-foreground mb-1">Вход:</div>
<pre className="p-3 bg-muted rounded text-sm font-mono overflow-x-auto">
{test.input}
</pre>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1">
Ожидаемый вывод:
</div>
<pre className="p-3 bg-muted rounded text-sm font-mono overflow-x-auto">
{test.expected_output}
</pre>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,226 @@
"use client";
import { useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
export default function NewProblemPage() {
const { user } = useAuth();
const router = useRouter();
const params = useParams();
const contestId = Number(params.id);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [formData, setFormData] = useState({
title: "",
description: "",
input_format: "",
output_format: "",
constraints: "",
time_limit_ms: 1000,
memory_limit_kb: 262144,
points: 100,
});
if (!user || user.role !== "admin") {
router.push("/contests");
return null;
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setIsLoading(true);
try {
await api.createProblem({
contest_id: contestId,
title: formData.title,
description: formData.description,
input_format: formData.input_format || undefined,
output_format: formData.output_format || undefined,
constraints: formData.constraints || undefined,
time_limit_ms: formData.time_limit_ms,
memory_limit_kb: formData.memory_limit_kb,
total_points: formData.points,
});
router.push(`/admin/contests/${contestId}/problems`);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка создания");
} finally {
setIsLoading(false);
}
};
return (
<div className="container mx-auto px-4 py-8 max-w-3xl">
<div className="flex items-center gap-4 mb-8">
<Link
href={`/admin/contests/${contestId}/problems`}
className="text-muted-foreground hover:text-foreground transition"
>
Назад к задачам
</Link>
</div>
<h1 className="text-3xl font-bold mb-8">Добавить задачу</h1>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-4 bg-destructive/10 text-destructive rounded-lg">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium mb-2">Название</label>
<input
type="text"
value={formData.title}
onChange={(e) =>
setFormData({ ...formData, title: e.target.value })
}
required
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Например: Сумма двух чисел"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Условие задачи
</label>
<textarea
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
required
rows={6}
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring font-mono text-sm"
placeholder="Описание задачи (поддерживается Markdown)"
/>
</div>
<div className="grid md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium mb-2">
Формат ввода
</label>
<textarea
value={formData.input_format}
onChange={(e) =>
setFormData({ ...formData, input_format: e.target.value })
}
rows={3}
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring font-mono text-sm"
placeholder="Описание формата входных данных"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Формат вывода
</label>
<textarea
value={formData.output_format}
onChange={(e) =>
setFormData({ ...formData, output_format: e.target.value })
}
rows={3}
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring font-mono text-sm"
placeholder="Описание формата выходных данных"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">Ограничения</label>
<textarea
value={formData.constraints}
onChange={(e) =>
setFormData({ ...formData, constraints: e.target.value })
}
rows={2}
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring font-mono text-sm"
placeholder="1 ≤ n ≤ 10^5"
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium mb-2">
Лимит времени (мс)
</label>
<input
type="number"
value={formData.time_limit_ms}
onChange={(e) =>
setFormData({
...formData,
time_limit_ms: Number(e.target.value),
})
}
required
min={100}
max={30000}
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Лимит памяти (КБ)
</label>
<input
type="number"
value={formData.memory_limit_kb}
onChange={(e) =>
setFormData({
...formData,
memory_limit_kb: Number(e.target.value),
})
}
required
min={16384}
max={524288}
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Баллы</label>
<input
type="number"
value={formData.points}
onChange={(e) =>
setFormData({ ...formData, points: Number(e.target.value) })
}
required
min={1}
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<div className="flex gap-4">
<button
type="submit"
disabled={isLoading}
className="px-6 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition disabled:opacity-50"
>
{isLoading ? "Создание..." : "Создать задачу"}
</button>
<button
type="button"
onClick={() => router.back()}
className="px-6 py-2 border border-border rounded-lg font-medium hover:bg-muted transition"
>
Отмена
</button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,161 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
import type { ProblemListItem, Contest } from "@/types";
export default function ContestProblemsPage() {
const { user, isLoading: authLoading } = useAuth();
const router = useRouter();
const params = useParams();
const contestId = Number(params.id);
const [contest, setContest] = useState<Contest | null>(null);
const [problems, setProblems] = useState<ProblemListItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
if (!authLoading && (!user || user.role !== "admin")) {
router.push("/contests");
}
}, [user, authLoading, router]);
useEffect(() => {
if (user?.role === "admin") {
Promise.all([api.getContest(contestId), api.getContestProblems(contestId)])
.then(([contestData, problemsData]) => {
setContest(contestData);
setProblems(problemsData);
})
.catch((err) => setError(err.message))
.finally(() => setIsLoading(false));
}
}, [contestId, user]);
const handleDelete = async (problemId: number) => {
if (!confirm("Удалить эту задачу?")) return;
try {
await api.deleteProblem(problemId);
setProblems((prev) => prev.filter((p) => p.id !== problemId));
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка удаления");
}
};
if (authLoading || isLoading) {
return (
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-muted rounded w-1/3" />
{[...Array(3)].map((_, i) => (
<div key={i} className="h-20 bg-muted rounded" />
))}
</div>
</div>
);
}
if (!user || user.role !== "admin") {
return null;
}
return (
<div className="container mx-auto px-4 py-8">
<div className="flex items-center gap-4 mb-4">
<Link
href="/admin/contests"
className="text-muted-foreground hover:text-foreground transition"
>
Назад к контестам
</Link>
</div>
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold">Задачи контеста</h1>
{contest && (
<p className="text-muted-foreground mt-1">{contest.title}</p>
)}
</div>
<Link
href={`/admin/contests/${contestId}/problems/new`}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition"
>
Добавить задачу
</Link>
</div>
{error && (
<div className="p-4 bg-destructive/10 text-destructive rounded-lg mb-4">
{error}
</div>
)}
{problems.length === 0 ? (
<div className="text-center py-16 border border-border rounded-lg">
<p className="text-muted-foreground mb-4">В контесте пока нет задач</p>
<Link
href={`/admin/contests/${contestId}/problems/new`}
className="text-primary hover:underline"
>
Добавить первую задачу
</Link>
</div>
) : (
<div className="space-y-4">
{problems.map((problem, index) => (
<div
key={problem.id}
className="p-4 border border-border rounded-lg flex justify-between items-center"
>
<div>
<div className="flex items-center gap-3">
<span className="text-muted-foreground font-mono">
{String.fromCharCode(65 + index)}
</span>
<h3 className="font-semibold">{problem.title}</h3>
</div>
<div className="text-sm text-muted-foreground mt-1">
<span>{problem.total_points} баллов</span>
<span className="mx-2"></span>
<span>
Лимит времени: {problem.time_limit_ms}мс
</span>
<span className="mx-2"></span>
<span>
Лимит памяти: {problem.memory_limit_kb / 1024}МБ
</span>
</div>
</div>
<div className="flex gap-2">
<Link
href={`/admin/contests/${contestId}/problems/${problem.id}/edit`}
className="px-3 py-1 text-sm border border-border rounded hover:bg-muted transition"
>
Редактировать
</Link>
<Link
href={`/admin/contests/${contestId}/problems/${problem.id}/tests`}
className="px-3 py-1 text-sm border border-border rounded hover:bg-muted transition"
>
Тесты
</Link>
<button
onClick={() => handleDelete(problem.id)}
className="px-3 py-1 text-sm text-destructive border border-destructive/30 rounded hover:bg-destructive/10 transition"
>
Удалить
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,151 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
export default function NewContestPage() {
const { user } = useAuth();
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [formData, setFormData] = useState({
title: "",
description: "",
start_time: "",
end_time: "",
is_active: false,
});
if (!user || user.role !== "admin") {
router.push("/contests");
return null;
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setIsLoading(true);
try {
const contest = await api.createContest({
title: formData.title,
description: formData.description || undefined,
start_time: new Date(formData.start_time).toISOString(),
end_time: new Date(formData.end_time).toISOString(),
is_active: formData.is_active,
});
router.push(`/admin/contests/${contest.id}/problems`);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка создания");
} finally {
setIsLoading(false);
}
};
return (
<div className="container mx-auto px-4 py-8 max-w-2xl">
<h1 className="text-3xl font-bold mb-8">Создать контест</h1>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-4 bg-destructive/10 text-destructive rounded-lg">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium mb-2">Название</label>
<input
type="text"
value={formData.title}
onChange={(e) =>
setFormData({ ...formData, title: e.target.value })
}
required
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Описание (опционально)
</label>
<textarea
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
rows={4}
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">
Время начала
</label>
<input
type="datetime-local"
value={formData.start_time}
onChange={(e) =>
setFormData({ ...formData, start_time: e.target.value })
}
required
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Время окончания
</label>
<input
type="datetime-local"
value={formData.end_time}
onChange={(e) =>
setFormData({ ...formData, end_time: e.target.value })
}
required
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="is_active"
checked={formData.is_active}
onChange={(e) =>
setFormData({ ...formData, is_active: e.target.checked })
}
className="w-4 h-4"
/>
<label htmlFor="is_active" className="text-sm">
Активировать контест сразу
</label>
</div>
<div className="flex gap-4">
<button
type="submit"
disabled={isLoading}
className="px-6 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition disabled:opacity-50"
>
{isLoading ? "Создание..." : "Создать"}
</button>
<button
type="button"
onClick={() => router.back()}
className="px-6 py-2 border border-border rounded-lg font-medium hover:bg-muted transition"
>
Отмена
</button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,258 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
import { formatDate } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { AlertError } from "@/components/ui/alert";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Trophy,
Plus,
MoreHorizontal,
Pencil,
FileCode,
Trash2,
Calendar,
Users,
ArrowLeft,
} from "lucide-react";
import type { ContestListItem } from "@/types";
export default function AdminContestsPage() {
const { user, isLoading: authLoading } = useAuth();
const router = useRouter();
const [contests, setContests] = useState<ContestListItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
if (!authLoading && (!user || user.role !== "admin")) {
router.push("/contests");
}
}, [user, authLoading, router]);
useEffect(() => {
if (user?.role === "admin") {
api
.getContests()
.then(setContests)
.catch((err) => setError(err.message))
.finally(() => setIsLoading(false));
}
}, [user]);
const handleDelete = async (id: number, title: string) => {
if (!confirm(`Удалить контест "${title}"?`)) return;
try {
await api.deleteContest(id);
setContests((prev) => prev.filter((c) => c.id !== id));
toast.success("Контест удалён");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Ошибка удаления");
}
};
if (authLoading || isLoading) {
return (
<div className="container mx-auto px-4 py-8">
<Skeleton className="h-10 w-64 mb-8" />
<Skeleton className="h-64 rounded-xl" />
</div>
);
}
if (!user || user.role !== "admin") {
return null;
}
return (
<div className="container mx-auto px-4 py-8">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<div className="flex items-center gap-4 mb-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/admin">
<ArrowLeft className="h-4 w-4 mr-2" />
Назад
</Link>
</Button>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold flex items-center gap-3">
<Trophy className="h-8 w-8 text-yellow-500" />
Управление контестами
</h1>
<p className="text-muted-foreground mt-1">
{contests.length} контестов в системе
</p>
</div>
<Button asChild>
<Link href="/admin/contests/new">
<Plus className="h-4 w-4 mr-2" />
Создать контест
</Link>
</Button>
</div>
</motion.div>
{error && (
<AlertError className="mb-6">{error}</AlertError>
)}
{/* Content */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
{contests.length === 0 ? (
<Card className="text-center py-16">
<CardContent>
<Trophy className="h-16 w-16 mx-auto mb-4 text-muted-foreground opacity-50" />
<h2 className="text-xl font-semibold mb-2">
Контестов пока нет
</h2>
<p className="text-muted-foreground mb-6">
Создайте первый контест для начала работы
</p>
<Button asChild>
<Link href="/admin/contests/new">
<Plus className="h-4 w-4 mr-2" />
Создать контест
</Link>
</Button>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle className="text-lg">Все контесты</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Название</TableHead>
<TableHead>Даты</TableHead>
<TableHead className="text-center">Задачи</TableHead>
<TableHead className="text-center">Участники</TableHead>
<TableHead className="text-center">Статус</TableHead>
<TableHead className="w-12"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<AnimatePresence>
{contests.map((contest, index) => (
<motion.tr
key={contest.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ delay: index * 0.03 }}
className="border-b border-border hover:bg-muted/50"
>
<TableCell>
<div className="font-medium">{contest.title}</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
<span>
{formatDate(contest.start_time)}
</span>
</div>
</TableCell>
<TableCell className="text-center">
<Badge variant="secondary">
<FileCode className="h-3 w-3 mr-1" />
{contest.problems_count}
</Badge>
</TableCell>
<TableCell className="text-center">
<Badge variant="outline">
<Users className="h-3 w-3 mr-1" />
{contest.participants_count}
</Badge>
</TableCell>
<TableCell className="text-center">
{contest.is_running ? (
<Badge variant="success" pulse>LIVE</Badge>
) : contest.has_ended ? (
<Badge variant="secondary">Завершён</Badge>
) : contest.is_active ? (
<Badge variant="info">Скоро</Badge>
) : (
<Badge variant="outline">Черновик</Badge>
)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/contests/${contest.id}/edit`}>
<Pencil className="h-4 w-4 mr-2" />
Редактировать
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/contests/${contest.id}/problems`}>
<FileCode className="h-4 w-4 mr-2" />
Задачи
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(contest.id, contest.title)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</motion.tr>
))}
</AnimatePresence>
</TableBody>
</Table>
</CardContent>
</Card>
)}
</motion.div>
</div>
);
}

View File

@ -0,0 +1,134 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
import { useAuth } from "@/lib/auth-context";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
LayoutDashboard,
Trophy,
FileCode,
Settings,
ChevronRight,
Users,
} from "lucide-react";
const adminCards = [
{
href: "/admin/contests",
title: "Контесты",
description: "Создание и управление соревнованиями",
icon: Trophy,
color: "text-yellow-500",
bgColor: "bg-yellow-500/10",
},
{
href: "/admin/problems",
title: "Задачи",
description: "Просмотр всех задач в системе",
icon: FileCode,
color: "text-blue-500",
bgColor: "bg-blue-500/10",
},
{
href: "/admin/users",
title: "Пользователи",
description: "Список участников и их данные",
icon: Users,
color: "text-green-500",
bgColor: "bg-green-500/10",
},
];
export default function AdminPage() {
const { user, isLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && (!user || user.role !== "admin")) {
router.push("/contests");
}
}, [user, isLoading, router]);
if (isLoading) {
return (
<div className="container mx-auto px-4 py-8">
<Skeleton className="h-10 w-48 mb-8" />
<div className="grid md:grid-cols-2 gap-6">
{[...Array(2)].map((_, i) => (
<Skeleton key={i} className="h-36 rounded-xl" />
))}
</div>
</div>
);
}
if (!user || user.role !== "admin") {
return null;
}
return (
<div className="container mx-auto px-4 py-8">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<h1 className="text-3xl font-bold flex items-center gap-3">
<LayoutDashboard className="h-8 w-8 text-primary" />
Админ панель
</h1>
<p className="text-muted-foreground mt-1">
Управление контестами и задачами
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="grid md:grid-cols-2 gap-6"
>
{adminCards.map((card, index) => {
const Icon = card.icon;
return (
<motion.div
key={card.href}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 + index * 0.1 }}
>
<Link href={card.href}>
<Card className="h-full hover:shadow-lg hover:border-primary/30 transition-all cursor-pointer group">
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div
className={`p-3 rounded-lg ${card.bgColor}`}
>
<Icon className={`h-6 w-6 ${card.color}`} />
</div>
<div>
<h2 className="text-xl font-semibold mb-1">
{card.title}
</h2>
<p className="text-muted-foreground">
{card.description}
</p>
</div>
</div>
<ChevronRight className="h-5 w-5 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all" />
</div>
</CardContent>
</Card>
</Link>
</motion.div>
);
})}
</motion.div>
</div>
);
}

View File

@ -0,0 +1,163 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { AlertError } from "@/components/ui/alert";
import {
FileCode,
Trophy,
ArrowLeft,
ChevronRight,
Plus,
} from "lucide-react";
import type { ContestListItem } from "@/types";
export default function AdminProblemsPage() {
const { user, isLoading: authLoading } = useAuth();
const router = useRouter();
const [contests, setContests] = useState<ContestListItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
if (!authLoading && (!user || user.role !== "admin")) {
router.push("/contests");
}
}, [user, authLoading, router]);
useEffect(() => {
if (user?.role === "admin") {
api
.getContests()
.then(setContests)
.catch((err) => setError(err.message))
.finally(() => setIsLoading(false));
}
}, [user]);
if (authLoading || isLoading) {
return (
<div className="container mx-auto px-4 py-8">
<Skeleton className="h-10 w-64 mb-8" />
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-20 rounded-xl" />
))}
</div>
</div>
);
}
if (!user || user.role !== "admin") {
return null;
}
return (
<div className="container mx-auto px-4 py-8">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<div className="flex items-center gap-4 mb-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/admin">
<ArrowLeft className="h-4 w-4 mr-2" />
Назад
</Link>
</Button>
</div>
<h1 className="text-3xl font-bold flex items-center gap-3">
<FileCode className="h-8 w-8 text-blue-500" />
Управление задачами
</h1>
<p className="text-muted-foreground mt-1">
Выберите контест для управления его задачами
</p>
</motion.div>
{error && (
<AlertError className="mb-6">{error}</AlertError>
)}
{/* Content */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
{contests.length === 0 ? (
<Card className="text-center py-16">
<CardContent>
<Trophy className="h-16 w-16 mx-auto mb-4 text-muted-foreground opacity-50" />
<h2 className="text-xl font-semibold mb-2">
Контестов пока нет
</h2>
<p className="text-muted-foreground mb-6">
Создайте контест, чтобы добавлять в него задачи
</p>
<Button asChild>
<Link href="/admin/contests/new">
<Plus className="h-4 w-4 mr-2" />
Создать контест
</Link>
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{contests.map((contest, index) => (
<motion.div
key={contest.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
>
<Link href={`/admin/contests/${contest.id}/problems`}>
<Card className="hover:shadow-lg hover:border-primary/30 transition-all cursor-pointer group">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 rounded-lg bg-yellow-500/10">
<Trophy className="h-5 w-5 text-yellow-500" />
</div>
<div>
<h3 className="font-semibold">{contest.title}</h3>
<div className="flex items-center gap-3 mt-1">
<Badge variant="secondary">
<FileCode className="h-3 w-3 mr-1" />
{contest.problems_count} задач
</Badge>
{contest.is_running ? (
<Badge variant="success" pulse>LIVE</Badge>
) : contest.has_ended ? (
<Badge variant="secondary">Завершён</Badge>
) : contest.is_active ? (
<Badge variant="info">Скоро</Badge>
) : null}
</div>
</div>
</div>
<ChevronRight className="h-5 w-5 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all" />
</div>
</CardContent>
</Card>
</Link>
</motion.div>
))}
</div>
)}
</motion.div>
</div>
);
}

View File

@ -0,0 +1,277 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { motion } from "framer-motion";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
import { formatDate } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { AlertError } from "@/components/ui/alert";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Users,
ArrowLeft,
Search,
User,
Mail,
GraduationCap,
Calendar,
Shield,
} from "lucide-react";
import type { User as UserType } from "@/types";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
export default function AdminUsersPage() {
const { user, isLoading: authLoading } = useAuth();
const router = useRouter();
const [users, setUsers] = useState<UserType[]>([]);
const [filteredUsers, setFilteredUsers] = useState<UserType[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState("");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
if (!authLoading && (!user || user.role !== "admin")) {
router.push("/contests");
}
}, [user, authLoading, router]);
useEffect(() => {
const fetchUsers = async () => {
try {
const data = await api.getAllUsers();
setUsers(data);
setFilteredUsers(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка загрузки");
} finally {
setIsLoading(false);
}
};
if (user?.role === "admin") {
fetchUsers();
}
}, [user]);
useEffect(() => {
const query = searchQuery.toLowerCase();
const filtered = users.filter(
(u) =>
u.username.toLowerCase().includes(query) ||
u.email.toLowerCase().includes(query) ||
(u.full_name && u.full_name.toLowerCase().includes(query)) ||
(u.study_group && u.study_group.toLowerCase().includes(query)) ||
(u.telegram && u.telegram.toLowerCase().includes(query)) ||
(u.vk && u.vk.toLowerCase().includes(query))
);
setFilteredUsers(filtered);
}, [searchQuery, users]);
if (authLoading || isLoading) {
return (
<div className="container mx-auto px-4 py-8">
<Skeleton className="h-10 w-48 mb-4" />
<Skeleton className="h-6 w-64 mb-8" />
<Skeleton className="h-12 w-full mb-4" />
<Skeleton className="h-96 rounded-xl" />
</div>
);
}
if (!user || user.role !== "admin") {
return null;
}
if (error) {
return (
<div className="container mx-auto px-4 py-8">
<AlertError title="Ошибка">{error}</AlertError>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<div className="flex items-center gap-4 mb-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/admin">
<ArrowLeft className="h-4 w-4 mr-2" />
Назад
</Link>
</Button>
</div>
<h1 className="text-3xl font-bold flex items-center gap-3">
<Users className="h-8 w-8 text-green-500" />
Пользователи
</h1>
<p className="text-muted-foreground mt-1">
Всего: {users.length} пользователей
</p>
</motion.div>
{/* Search */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="mb-6"
>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Поиск по имени, email, группе, Telegram, VK..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
</motion.div>
{/* Users Table */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">#</TableHead>
<TableHead>Пользователь</TableHead>
<TableHead>Email</TableHead>
<TableHead>ФИО</TableHead>
<TableHead>Группа</TableHead>
<TableHead>Telegram</TableHead>
<TableHead>VK</TableHead>
<TableHead>Роль</TableHead>
<TableHead>Регистрация</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredUsers.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
{searchQuery ? "Пользователи не найдены" : "Нет пользователей"}
</TableCell>
</TableRow>
) : (
filteredUsers.map((u, index) => (
<TableRow key={u.id}>
<TableCell className="text-muted-foreground">
{index + 1}
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center overflow-hidden flex-shrink-0">
{u.avatar_url ? (
<img
src={`${API_URL}${u.avatar_url}`}
alt={u.username}
className="w-full h-full object-cover"
/>
) : (
<User className="h-4 w-4 text-muted-foreground" />
)}
</div>
<span className="font-medium">{u.username}</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{u.email}
</TableCell>
<TableCell>
{u.full_name || (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
{u.study_group ? (
<Badge variant="outline">{u.study_group}</Badge>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
{u.telegram ? (
<a
href={
u.telegram.startsWith("@")
? `https://t.me/${u.telegram.slice(1)}`
: u.telegram.startsWith("http")
? u.telegram
: `https://t.me/${u.telegram}`
}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{u.telegram}
</a>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
{u.vk ? (
<a
href={
u.vk.startsWith("http")
? u.vk
: `https://vk.com/${u.vk.replace("@", "")}`
}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{u.vk}
</a>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
<Badge variant={u.role === "admin" ? "default" : "secondary"}>
{u.role === "admin" ? "Админ" : "Участник"}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
{formatDate(u.created_at)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</motion.div>
</div>
);
}

View File

@ -0,0 +1,272 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import { motion } from "framer-motion";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { formatDate, formatDuration } from "@/lib/utils";
import { useAuth } from "@/lib/auth-context";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { AlertError } from "@/components/ui/alert";
import { ContestTimer } from "@/components/domain/contest-timer";
import { ProblemStatusBadge, getProblemStatus } from "@/components/domain/problem-status-badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Calendar,
Clock,
Users,
Trophy,
PlayCircle,
BarChart3,
FileCode,
} from "lucide-react";
import type { Contest, ProblemListItem } from "@/types";
export default function ContestPage() {
const params = useParams();
const contestId = Number(params.id);
const { user } = useAuth();
const [contest, setContest] = useState<Contest | null>(null);
const [problems, setProblems] = useState<ProblemListItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState("");
const [joining, setJoining] = useState(false);
useEffect(() => {
Promise.all([api.getContest(contestId), api.getProblemsByContest(contestId)])
.then(([contestData, problemsData]) => {
setContest(contestData);
setProblems(problemsData);
})
.catch((err) => setError(err.message))
.finally(() => setIsLoading(false));
}, [contestId]);
const handleJoin = async () => {
setJoining(true);
try {
await api.joinContest(contestId);
const contestData = await api.getContest(contestId);
setContest(contestData);
toast.success("Вы присоединились к контесту!");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Ошибка");
} finally {
setJoining(false);
}
};
if (isLoading) {
return (
<div className="container mx-auto px-4 py-8">
<Skeleton className="h-10 w-2/3 mb-4" />
<Skeleton className="h-6 w-1/2 mb-8" />
<div className="grid gap-4 md:grid-cols-3 mb-8">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
<Skeleton className="h-64 rounded-xl" />
</div>
);
}
if (error || !contest) {
return (
<div className="container mx-auto px-4 py-8">
<AlertError title="Ошибка">{error || "Контест не найден"}</AlertError>
</div>
);
}
const getContestStatus = () => {
if (contest.has_ended) return "ended";
if (contest.is_running) return "running";
return "upcoming";
};
const status = getContestStatus();
return (
<div className="container mx-auto px-4 py-8">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<div className="flex items-start justify-between gap-4 mb-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold">{contest.title}</h1>
{status === "running" && (
<Badge variant="success" pulse>
LIVE
</Badge>
)}
{status === "upcoming" && <Badge variant="info">Скоро</Badge>}
{status === "ended" && <Badge variant="secondary">Завершён</Badge>}
</div>
{contest.description && (
<p className="text-muted-foreground">{contest.description}</p>
)}
</div>
{status !== "ended" && (
<ContestTimer
startTime={new Date(contest.start_time)}
endTime={new Date(contest.end_time)}
size="lg"
/>
)}
</div>
{/* Stats cards */}
<div className="grid gap-4 md:grid-cols-4 mb-6">
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-3">
<Calendar className="h-5 w-5 text-muted-foreground" />
<div>
<div className="text-xs text-muted-foreground">Начало</div>
<div className="font-medium">{formatDate(contest.start_time)}</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-3">
<Clock className="h-5 w-5 text-muted-foreground" />
<div>
<div className="text-xs text-muted-foreground">Длительность</div>
<div className="font-medium">
{formatDuration(contest.start_time, contest.end_time)}
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-3">
<FileCode className="h-5 w-5 text-muted-foreground" />
<div>
<div className="text-xs text-muted-foreground">Задачи</div>
<div className="font-medium">{problems.length}</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-3">
<Users className="h-5 w-5 text-muted-foreground" />
<div>
<div className="text-xs text-muted-foreground">Участников</div>
<div className="font-medium">{contest.participants_count || 0}</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Actions */}
<div className="flex gap-3">
{contest.is_active && !contest.has_ended && !contest.is_participating && (
<Button onClick={handleJoin} loading={joining}>
<PlayCircle className="h-4 w-4 mr-2" />
Участвовать
</Button>
)}
{contest.is_participating && (
<Badge variant="success" className="h-10 px-4 text-sm">
Вы участвуете
</Badge>
)}
<Button variant="secondary" asChild>
<Link href={`/leaderboard/${contestId}`}>
<BarChart3 className="h-4 w-4 mr-2" />
Таблица лидеров
</Link>
</Button>
</div>
</motion.div>
{/* Problems table */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5" />
Задачи ({problems.length})
</CardTitle>
</CardHeader>
<CardContent>
{problems.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">#</TableHead>
<TableHead>Название</TableHead>
<TableHead className="w-24 text-center">Статус</TableHead>
<TableHead className="w-24 text-right">Баллы</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{problems.map((problem, index) => (
<TableRow key={problem.id}>
<TableCell className="font-mono font-bold text-lg">
{String.fromCharCode(65 + index)}
</TableCell>
<TableCell>
<Link
href={`/contests/${contestId}/problems/${problem.id}`}
className="text-primary hover:underline font-medium"
>
{problem.title}
</Link>
</TableCell>
<TableCell className="text-center">
<ProblemStatusBadge
status="not_attempted"
maxScore={problem.total_points}
/>
</TableCell>
<TableCell className="text-right">
<Badge variant="outline">{problem.total_points}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="text-center text-muted-foreground py-8">
<FileCode className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>Задачи пока не добавлены</p>
</div>
)}
</CardContent>
</Card>
</motion.div>
</div>
);
}

View File

@ -0,0 +1,489 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useParams } from "next/navigation";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { motion, AnimatePresence } from "framer-motion";
import { toast } from "sonner";
import confetti from "canvas-confetti";
import { api } from "@/lib/api";
import { CodeEditor } from "@/components/CodeEditor";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Skeleton } from "@/components/ui/skeleton";
import { LanguageSelect } from "@/components/domain/language-select";
import { SubmissionStatus } from "@/components/domain/submission-status";
import { cn } from "@/lib/utils";
import {
Clock,
MemoryStick,
Trophy,
Send,
RotateCcw,
Copy,
Check,
FileCode,
History,
TestTube2,
} from "lucide-react";
import type { Problem, Language, Submission, SubmissionListItem, SampleTest } from "@/types";
// Confetti celebration for accepted solutions
function celebrateAccepted() {
confetti({
particleCount: 100,
spread: 70,
origin: { y: 0.6 },
});
}
// Sample Tests Component (inline for this page)
function SampleTestsCard({ tests }: { tests: SampleTest[] }) {
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const copyToClipboard = async (text: string, index: number) => {
try {
await navigator.clipboard.writeText(text);
setCopiedIndex(index);
toast.success("Скопировано в буфер обмена");
setTimeout(() => setCopiedIndex(null), 2000);
} catch (err) {
toast.error("Не удалось скопировать");
}
};
if (tests.length === 0) return null;
return (
<div className="space-y-4">
{tests.map((test, index) => (
<Card key={index}>
<CardHeader className="py-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium">
Пример {index + 1}
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => copyToClipboard(test.input, index)}
>
{copiedIndex === index ? (
<Check className="h-4 w-4 text-success" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="grid md:grid-cols-2 gap-4">
<div>
<div className="text-xs text-muted-foreground mb-2 uppercase tracking-wide">
Ввод
</div>
<pre className="bg-muted p-3 rounded-lg text-sm font-mono whitespace-pre-wrap overflow-x-auto">
{test.input}
</pre>
</div>
<div>
<div className="text-xs text-muted-foreground mb-2 uppercase tracking-wide">
Вывод
</div>
<pre className="bg-muted p-3 rounded-lg text-sm font-mono whitespace-pre-wrap overflow-x-auto">
{test.output}
</pre>
</div>
</div>
</CardContent>
</Card>
))}
</div>
);
}
// Submission History Item
function SubmissionHistoryItem({ submission }: { submission: SubmissionListItem }) {
return (
<div className="flex items-center justify-between py-3 border-b border-border last:border-0">
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">#{submission.id}</span>
<SubmissionStatus
status={submission.status}
score={submission.score}
totalPoints={submission.total_points}
animated={false}
/>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>{submission.tests_passed}/{submission.tests_total} тестов</span>
<span>
{new Date(submission.created_at).toLocaleString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
</div>
);
}
// Result Panel Component
function ResultPanel({
submission,
isJudging,
}: {
submission: Submission | null;
isJudging: boolean;
}) {
if (!submission) {
return (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Send className="h-8 w-8 mb-2 opacity-50" />
<p className="text-sm">Отправьте решение для проверки</p>
</div>
);
}
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-4"
>
<div className="flex items-center justify-between">
<SubmissionStatus
status={submission.status}
score={submission.score}
totalPoints={submission.total_points}
/>
<span className="text-sm text-muted-foreground">#{submission.id}</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-muted/50 rounded-lg p-3">
<div className="text-xs text-muted-foreground mb-1">Баллы</div>
<div className="font-semibold">
{submission.score}/{submission.total_points}
</div>
</div>
<div className="bg-muted/50 rounded-lg p-3">
<div className="text-xs text-muted-foreground mb-1">Тесты</div>
<div className="font-semibold">
{submission.tests_passed}/{submission.tests_total}
</div>
</div>
{submission.execution_time_ms !== null && (
<div className="bg-muted/50 rounded-lg p-3">
<div className="text-xs text-muted-foreground mb-1">Время</div>
<div className="font-semibold">{submission.execution_time_ms} мс</div>
</div>
)}
{submission.memory_used_kb !== null && (
<div className="bg-muted/50 rounded-lg p-3">
<div className="text-xs text-muted-foreground mb-1">Память</div>
<div className="font-semibold">
{Math.round(submission.memory_used_kb / 1024)} МБ
</div>
</div>
)}
</div>
{isJudging && (
<div className="flex items-center gap-2 text-sm text-primary">
<div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<span>Проверяется...</span>
</div>
)}
</motion.div>
);
}
export default function ProblemPage() {
const params = useParams();
const contestId = Number(params.id);
const problemId = Number(params.problemId);
const [problem, setProblem] = useState<Problem | null>(null);
const [languages, setLanguages] = useState<Language[]>([]);
const [selectedLanguageId, setSelectedLanguageId] = useState<string>("");
const [code, setCode] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const [lastSubmission, setLastSubmission] = useState<Submission | null>(null);
const [submissions, setSubmissions] = useState<SubmissionListItem[]>([]);
const [activeTab, setActiveTab] = useState("result");
const selectedLanguage = languages.find((l) => l.id.toString() === selectedLanguageId);
const isJudging = lastSubmission?.status === "pending" || lastSubmission?.status === "judging";
useEffect(() => {
Promise.all([
api.getProblem(problemId),
api.getLanguages(),
api.getSubmissionsByProblem(problemId),
])
.then(([problemData, languagesData, submissionsData]) => {
setProblem(problemData);
setLanguages(languagesData);
setSubmissions(submissionsData);
// Default to Python
const python = languagesData.find((l) =>
l.name.toLowerCase().includes("python")
);
setSelectedLanguageId((python || languagesData[0])?.id.toString() || "");
})
.catch((err) => {
setError(err.message);
toast.error("Ошибка загрузки задачи");
})
.finally(() => setIsLoading(false));
}, [problemId]);
// Poll for submission status
useEffect(() => {
if (!lastSubmission || !isJudging) return;
const interval = setInterval(async () => {
try {
const updated = await api.getSubmission(lastSubmission.id);
setLastSubmission(updated);
if (updated.status !== "pending" && updated.status !== "judging") {
// Show result toast
if (updated.status === "accepted") {
toast.success(`Решение принято! ${updated.score}/${updated.total_points} баллов`);
celebrateAccepted();
} else if (updated.status === "partial") {
toast.warning(`Частичное решение: ${updated.score}/${updated.total_points} баллов`);
} else if (updated.status === "compilation_error") {
toast.error("Ошибка компиляции");
} else {
toast.error(`${updated.tests_passed}/${updated.tests_total} тестов пройдено`);
}
// Refresh submissions list
const submissionsData = await api.getSubmissionsByProblem(problemId);
setSubmissions(submissionsData);
}
} catch (err) {
console.error("Error polling submission:", err);
}
}, 2000);
return () => clearInterval(interval);
}, [lastSubmission, isJudging, problemId]);
const handleSubmit = async () => {
if (!selectedLanguage || !code.trim()) return;
setSubmitting(true);
const toastId = toast.loading("Отправка решения...");
try {
const submission = await api.createSubmission({
problem_id: problemId,
contest_id: contestId,
source_code: code,
language_id: selectedLanguage.id,
language_name: selectedLanguage.name,
});
setLastSubmission(submission);
setActiveTab("result");
toast.dismiss(toastId);
toast.info("Решение отправлено на проверку");
} catch (err) {
toast.dismiss(toastId);
toast.error(err instanceof Error ? err.message : "Ошибка отправки");
} finally {
setSubmitting(false);
}
};
const handleReset = () => {
setCode("");
toast.info("Код очищен");
};
if (isLoading) {
return (
<div className="h-[calc(100vh-4rem)] flex">
<div className="w-1/2 p-6 space-y-4">
<Skeleton className="h-8 w-1/2" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-32 w-full" />
</div>
<div className="w-1/2 bg-[#1e1e1e]" />
</div>
);
}
if (error || !problem) {
return (
<div className="container mx-auto px-4 py-8">
<Card className="border-destructive">
<CardContent className="pt-6">
<p className="text-destructive">{error || "Задача не найдена"}</p>
</CardContent>
</Card>
</div>
);
}
return (
<div className="h-[calc(100vh-4rem)]">
<PanelGroup direction="horizontal" className="h-full">
{/* Left panel - Problem description */}
<Panel defaultSize={40} minSize={25}>
<div className="h-full overflow-auto">
<div className="p-6 space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold mb-3">{problem.title}</h1>
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="gap-1">
<Clock className="h-3 w-3" />
{problem.time_limit_ms} мс
</Badge>
<Badge variant="outline" className="gap-1">
<MemoryStick className="h-3 w-3" />
{Math.round(problem.memory_limit_kb / 1024)} МБ
</Badge>
<Badge variant="default" className="gap-1">
<Trophy className="h-3 w-3" />
{problem.total_points} баллов
</Badge>
</div>
</div>
{/* Description */}
<div className="prose prose-sm dark:prose-invert max-w-none">
<div dangerouslySetInnerHTML={{ __html: problem.description }} />
</div>
{/* Input/Output format */}
{problem.input_format && (
<div>
<h4 className="font-semibold mb-2">Формат ввода</h4>
<p className="text-sm text-muted-foreground">{problem.input_format}</p>
</div>
)}
{problem.output_format && (
<div>
<h4 className="font-semibold mb-2">Формат вывода</h4>
<p className="text-sm text-muted-foreground">{problem.output_format}</p>
</div>
)}
{problem.constraints && (
<div>
<h4 className="font-semibold mb-2">Ограничения</h4>
<p className="text-sm text-muted-foreground">{problem.constraints}</p>
</div>
)}
{/* Sample Tests */}
<div>
<h3 className="text-lg font-semibold mb-4">Примеры</h3>
<SampleTestsCard tests={problem.sample_tests} />
</div>
</div>
</div>
</Panel>
{/* Resize handle */}
<PanelResizeHandle className="w-1.5 bg-border hover:bg-primary/50 transition-colors" />
{/* Right panel - Code editor and results */}
<Panel defaultSize={60} minSize={30}>
<div className="h-full flex flex-col">
{/* Toolbar */}
<div className="p-3 border-b border-border flex justify-between items-center bg-muted/30">
<div className="flex items-center gap-3">
<LanguageSelect
languages={languages}
value={selectedLanguageId}
onValueChange={setSelectedLanguageId}
/>
<Button
variant="ghost"
size="sm"
onClick={handleReset}
className="text-muted-foreground"
>
<RotateCcw className="h-4 w-4 mr-1" />
Сброс
</Button>
</div>
<Button
onClick={handleSubmit}
disabled={submitting || !code.trim()}
loading={submitting}
>
<Send className="h-4 w-4 mr-2" />
Отправить
</Button>
</div>
{/* Editor */}
<div className="flex-1 min-h-0">
<CodeEditor
value={code}
onChange={setCode}
language={selectedLanguage?.name || "python"}
/>
</div>
{/* Results panel with tabs */}
<div className="border-t border-border bg-card">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="w-full justify-start rounded-none border-b bg-transparent h-auto p-0">
<TabsTrigger
value="result"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent"
>
<FileCode className="h-4 w-4 mr-2" />
Результат
</TabsTrigger>
<TabsTrigger
value="history"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent"
>
<History className="h-4 w-4 mr-2" />
История ({submissions.length})
</TabsTrigger>
</TabsList>
<div className="p-4 max-h-48 overflow-auto">
<TabsContent value="result" className="m-0">
<ResultPanel submission={lastSubmission} isJudging={isJudging} />
</TabsContent>
<TabsContent value="history" className="m-0">
{submissions.length === 0 ? (
<div className="text-center py-4 text-muted-foreground text-sm">
Нет отправок
</div>
) : (
<div>
{submissions.slice(0, 10).map((sub) => (
<SubmissionHistoryItem key={sub.id} submission={sub} />
))}
</div>
)}
</TabsContent>
</div>
</Tabs>
</div>
</div>
</Panel>
</PanelGroup>
</div>
);
}

View File

@ -0,0 +1,145 @@
"use client";
import { useEffect, useState } from "react";
import { motion } from "framer-motion";
import { api } from "@/lib/api";
import { ContestCard } from "@/components/domain/contest-card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Skeleton } from "@/components/ui/skeleton";
import { AlertError } from "@/components/ui/alert";
import { Calendar, Clock, History } from "lucide-react";
import type { ContestListItem } from "@/types";
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
};
export default function ContestsPage() {
const [contests, setContests] = useState<ContestListItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState("");
const [activeTab, setActiveTab] = useState("all");
useEffect(() => {
api
.getContests()
.then(setContests)
.catch((err) => setError(err.message))
.finally(() => setIsLoading(false));
}, []);
const activeContests = contests.filter((c) => c.is_running);
const upcomingContests = contests.filter((c) => c.is_active && !c.is_running && !c.has_ended);
const pastContests = contests.filter((c) => c.has_ended);
const filteredContests = () => {
switch (activeTab) {
case "active":
return activeContests;
case "upcoming":
return upcomingContests;
case "past":
return pastContests;
default:
return contests;
}
};
if (isLoading) {
return (
<div className="container mx-auto px-4 py-8">
<Skeleton className="h-10 w-48 mb-8" />
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-32 w-full rounded-xl" />
))}
</div>
</div>
);
}
if (error) {
return (
<div className="container mx-auto px-4 py-8">
<AlertError title="Ошибка загрузки">{error}</AlertError>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold">Контесты</h1>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-success animate-pulse" />
{activeContests.length} активных
</span>
<span>{contests.length} всего</span>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="mb-6">
<TabsList>
<TabsTrigger value="all" className="gap-2">
<Calendar className="h-4 w-4" />
Все ({contests.length})
</TabsTrigger>
<TabsTrigger value="active" className="gap-2">
<Clock className="h-4 w-4" />
Активные ({activeContests.length})
</TabsTrigger>
<TabsTrigger value="upcoming" className="gap-2">
<Calendar className="h-4 w-4" />
Предстоящие ({upcomingContests.length})
</TabsTrigger>
<TabsTrigger value="past" className="gap-2">
<History className="h-4 w-4" />
Прошедшие ({pastContests.length})
</TabsTrigger>
</TabsList>
</Tabs>
{filteredContests().length > 0 ? (
<motion.div
className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"
variants={containerVariants}
initial="hidden"
animate="visible"
>
{filteredContests().map((contest) => (
<motion.div key={contest.id} variants={itemVariants}>
<ContestCard
contest={{
id: contest.id,
title: contest.title,
start_time: contest.start_time,
end_time: contest.end_time,
is_active: contest.is_active,
problems_count: contest.problems_count,
participants_count: contest.participants_count,
}}
/>
</motion.div>
))}
</motion.div>
) : (
<div className="text-center text-muted-foreground py-16">
<Calendar className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-lg">Контестов в этой категории пока нет</p>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,189 @@
@import "tailwindcss";
@theme {
/* Base colors */
--color-background: #ffffff;
--color-foreground: #0a0a0a;
--color-primary: #2563eb;
--color-primary-foreground: #ffffff;
--color-secondary: #f1f5f9;
--color-secondary-foreground: #0f172a;
--color-muted: #f1f5f9;
--color-muted-foreground: #64748b;
--color-accent: #f1f5f9;
--color-accent-foreground: #0f172a;
--color-destructive: #ef4444;
--color-destructive-foreground: #ffffff;
--color-popover: #ffffff;
--color-popover-foreground: #0a0a0a;
--color-card: #ffffff;
--color-card-foreground: #0a0a0a;
--color-border: #e2e8f0;
--color-input: #e2e8f0;
--color-ring: #2563eb;
--radius: 0.5rem;
/* Additional semantic colors */
--color-success: #22c55e;
--color-success-foreground: #ffffff;
--color-warning: #f59e0b;
--color-warning-foreground: #000000;
--color-info: #3b82f6;
--color-info-foreground: #ffffff;
/* Card shadow */
--shadow-card: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-card-hover: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
}
@media (prefers-color-scheme: dark) {
@theme {
--color-background: #0a0a0a;
--color-foreground: #fafafa;
--color-primary: #3b82f6;
--color-primary-foreground: #ffffff;
--color-secondary: #1e293b;
--color-secondary-foreground: #f8fafc;
--color-muted: #1e293b;
--color-muted-foreground: #94a3b8;
--color-accent: #1e293b;
--color-accent-foreground: #f8fafc;
--color-destructive: #dc2626;
--color-destructive-foreground: #ffffff;
--color-popover: #1c1c1c;
--color-popover-foreground: #fafafa;
--color-card: #1c1c1c;
--color-card-foreground: #fafafa;
--color-border: #334155;
--color-input: #334155;
--color-ring: #3b82f6;
/* Dark mode semantic colors */
--color-success: #4ade80;
--color-success-foreground: #000000;
--color-warning: #fbbf24;
--color-warning-foreground: #000000;
--color-info: #60a5fa;
--color-info-foreground: #000000;
/* Dark mode shadows */
--shadow-card: 0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.3);
--shadow-card-hover: 0 10px 15px -3px rgb(0 0 0 / 0.3), 0 4px 6px -4px rgb(0 0 0 / 0.3);
}
}
body {
background: var(--color-background);
color: var(--color-foreground);
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* Animations */
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
@keyframes shake {
0%, 100% {
transform: translateX(0);
}
10%, 30%, 50%, 70%, 90% {
transform: translateX(-4px);
}
20%, 40%, 60%, 80% {
transform: translateX(4px);
}
}
@keyframes pulse-ring {
0% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7);
}
70% {
box-shadow: 0 0 0 10px rgba(34, 197, 94, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Utility classes */
.animate-shimmer {
background: linear-gradient(
90deg,
var(--color-muted) 0%,
var(--color-muted-foreground) 50%,
var(--color-muted) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.animate-shake {
animation: shake 0.5s ease-in-out;
}
.animate-pulse-ring {
animation: pulse-ring 1.5s infinite;
}
.animate-spin {
animation: spin 1s linear infinite;
}
/* Focus visible styles for accessibility */
.focus-visible-ring:focus-visible {
outline: none;
ring: 2px;
ring-color: var(--color-ring);
ring-offset: 2px;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-muted);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--color-muted-foreground);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-foreground);
}
/* Panel resize handle styling */
[data-panel-resize-handle-enabled] {
transition: background-color 0.2s ease;
}
[data-panel-resize-handle-enabled]:hover {
background-color: var(--color-primary) !important;
opacity: 0.5;
}
[data-panel-resize-handle-enabled][data-resize-handle-active] {
background-color: var(--color-primary) !important;
opacity: 0.8;
}

View File

@ -0,0 +1,36 @@
import type { Metadata } from "next";
import "./globals.css";
import { Navbar } from "@/components/Navbar";
import { AuthProvider } from "@/lib/auth-context";
import { Toaster } from "sonner";
export const metadata: Metadata = {
title: "ВолГУ.Контесты — Соревнования по программированию",
description: "Платформа для проведения соревнований по олимпиадному программированию от Волгоградского государственного университета",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ru">
<body className="min-h-screen bg-background antialiased">
<AuthProvider>
<Navbar />
<main>{children}</main>
</AuthProvider>
<Toaster
position="bottom-right"
richColors
closeButton
toastOptions={{
duration: 4000,
className: "font-sans",
}}
/>
</body>
</html>
);
}

View File

@ -0,0 +1,404 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import { motion, AnimatePresence } from "framer-motion";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import { useAuth } from "@/lib/auth-context";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { AlertError } from "@/components/ui/alert";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Trophy,
Medal,
ArrowLeft,
RefreshCw,
Clock,
Users,
Lock,
User,
} from "lucide-react";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
import type { Leaderboard, LeaderboardEntry } from "@/types";
// Podium component for top 3
function Podium({ entries }: { entries: LeaderboardEntry[] }) {
const top3 = entries.slice(0, 3);
// Reorder for podium display: 2nd, 1st, 3rd
const podiumOrder = [
top3[1], // 2nd place (left)
top3[0], // 1st place (center)
top3[2], // 3rd place (right)
].filter(Boolean);
const podiumHeights = {
1: "h-32",
2: "h-24",
3: "h-20",
};
const podiumColors = {
1: "from-yellow-400 to-amber-500",
2: "from-slate-300 to-slate-400",
3: "from-amber-600 to-amber-700",
};
const medalColors = {
1: "text-yellow-500",
2: "text-slate-400",
3: "text-amber-600",
};
if (top3.length === 0) return null;
return (
<div className="flex items-end justify-center gap-4 mb-8">
{podiumOrder.map((entry, index) => {
if (!entry) return null;
const rank = entry.rank as 1 | 2 | 3;
const displayIndex = index === 1 ? 0 : index === 0 ? 1 : 2;
return (
<motion.div
key={entry.user_id}
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: displayIndex * 0.2, type: "spring", stiffness: 100 }}
className="flex flex-col items-center"
>
{/* Avatar */}
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: displayIndex * 0.2 + 0.3, type: "spring" }}
className={`w-16 h-16 rounded-full bg-gradient-to-br ${podiumColors[rank]} flex items-center justify-center mb-2 shadow-lg overflow-hidden ring-2 ring-offset-2 ring-offset-background ${rank === 1 ? "ring-yellow-400" : rank === 2 ? "ring-slate-400" : "ring-amber-600"}`}
>
{entry.avatar_url ? (
<img
src={`${API_URL}${entry.avatar_url}`}
alt={entry.username}
className="w-full h-full object-cover"
/>
) : (
<User className="h-8 w-8 text-white/80" />
)}
</motion.div>
{/* Username */}
<p className="font-semibold text-sm mb-1 text-center max-w-[100px] truncate">
{entry.username}
</p>
{/* Score */}
<Badge variant={rank === 1 ? "success" : "secondary"} className="mb-2">
{entry.total_score} баллов
</Badge>
{/* Podium stand */}
<motion.div
initial={{ height: 0 }}
animate={{ height: "auto" }}
transition={{ delay: displayIndex * 0.2 + 0.1 }}
className={`w-24 ${podiumHeights[rank]} bg-gradient-to-t ${podiumColors[rank]} rounded-t-lg flex items-start justify-center pt-2`}
>
<span className="text-2xl font-bold text-white drop-shadow-lg">
{rank}
</span>
</motion.div>
</motion.div>
);
})}
</div>
);
}
export default function LeaderboardPage() {
const params = useParams();
const contestId = Number(params.contestId);
const { user } = useAuth();
const [leaderboard, setLeaderboard] = useState<Leaderboard | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState("");
const [isRefreshing, setIsRefreshing] = useState(false);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const fetchLeaderboard = useCallback(async (showLoading = true) => {
if (showLoading) setIsRefreshing(true);
try {
const data = await api.getLeaderboard(contestId);
setLeaderboard(data);
setLastUpdated(new Date());
setError("");
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка загрузки");
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
}, [contestId]);
useEffect(() => {
fetchLeaderboard(false);
}, [fetchLeaderboard]);
// Auto-refresh every 30 seconds for active contests
useEffect(() => {
if (!leaderboard || leaderboard.is_hidden) return;
const interval = setInterval(() => {
fetchLeaderboard(false);
}, 30000);
return () => clearInterval(interval);
}, [leaderboard, fetchLeaderboard]);
if (isLoading) {
return (
<div className="container mx-auto px-4 py-8">
<Skeleton className="h-8 w-48 mb-4" />
<Skeleton className="h-6 w-64 mb-8" />
<div className="flex justify-center gap-4 mb-8">
{[...Array(3)].map((_, i) => (
<div key={i} className="flex flex-col items-center">
<Skeleton className="w-16 h-16 rounded-full mb-2" />
<Skeleton className="h-4 w-20 mb-1" />
<Skeleton className="h-6 w-16 mb-2" />
<Skeleton className={`w-24 ${i === 1 ? "h-32" : i === 0 ? "h-24" : "h-20"}`} />
</div>
))}
</div>
<Skeleton className="h-64 rounded-xl" />
</div>
);
}
if (error || !leaderboard) {
return (
<div className="container mx-auto px-4 py-8">
<AlertError title="Ошибка">{error || "Таблица лидеров не найдена"}</AlertError>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<div className="flex items-center gap-4 mb-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/contests/${contestId}`}>
<ArrowLeft className="h-4 w-4 mr-2" />
Назад к контесту
</Link>
</Button>
</div>
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-bold flex items-center gap-3">
<Trophy className="h-8 w-8 text-yellow-500" />
Таблица лидеров
</h1>
<p className="text-muted-foreground mt-1">{leaderboard.contest_title}</p>
</div>
<div className="flex items-center gap-4">
{lastUpdated && (
<span className="text-xs text-muted-foreground">
<Clock className="h-3 w-3 inline mr-1" />
Обновлено: {lastUpdated.toLocaleTimeString("ru-RU")}
</span>
)}
<Button
variant="outline"
size="sm"
onClick={() => fetchLeaderboard(true)}
disabled={isRefreshing}
>
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
Обновить
</Button>
</div>
</div>
{/* Stats */}
<div className="flex items-center gap-6 mt-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Users className="h-4 w-4" />
{leaderboard.entries.length} участников
</span>
{!leaderboard.is_hidden && (
<span className="flex items-center gap-1">
<RefreshCw className="h-4 w-4" />
Автообновление каждые 30 сек
</span>
)}
</div>
</motion.div>
{/* Content */}
{leaderboard.is_hidden ? (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
>
<Card className="text-center py-16">
<CardContent>
<Lock className="h-16 w-16 mx-auto mb-4 text-muted-foreground opacity-50" />
<h2 className="text-xl font-semibold mb-2">
Таблица скрыта во время контеста
</h2>
<p className="text-muted-foreground">
Результаты будут доступны после окончания соревнования
</p>
</CardContent>
</Card>
</motion.div>
) : leaderboard.entries.length === 0 ? (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
>
<Card className="text-center py-16">
<CardContent>
<Trophy className="h-16 w-16 mx-auto mb-4 text-muted-foreground opacity-50" />
<p className="text-lg text-muted-foreground">Пока нет результатов</p>
<p className="text-sm text-muted-foreground mt-2">
Станьте первым, кто решит задачу!
</p>
</CardContent>
</Card>
</motion.div>
) : (
<>
{/* Podium for top 3 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
>
<Podium entries={leaderboard.entries} />
</motion.div>
{/* Full table */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
>
<Card>
<CardHeader>
<CardTitle className="text-lg">Полная таблица результатов</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16 text-center">#</TableHead>
<TableHead>Участник</TableHead>
<TableHead className="text-right">Баллы</TableHead>
<TableHead className="text-right">Решено</TableHead>
<TableHead className="text-right hidden sm:table-cell">
Последняя отправка
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<AnimatePresence>
{leaderboard.entries.map((entry, index) => {
const isCurrentUser = user?.id === entry.user_id;
return (
<motion.tr
key={entry.user_id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
className={`border-b border-border ${
isCurrentUser
? "bg-primary/5 hover:bg-primary/10"
: "hover:bg-muted/50"
}`}
>
<TableCell className="text-center font-mono">
{entry.rank <= 3 ? (
<span className="text-xl">
{entry.rank === 1 && "🥇"}
{entry.rank === 2 && "🥈"}
{entry.rank === 3 && "🥉"}
</span>
) : (
<span className="text-muted-foreground">
{entry.rank}
</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center overflow-hidden flex-shrink-0">
{entry.avatar_url ? (
<img
src={`${API_URL}${entry.avatar_url}`}
alt={entry.username}
className="w-full h-full object-cover"
/>
) : (
<User className="h-4 w-4 text-muted-foreground" />
)}
</div>
<span className={`font-medium ${isCurrentUser ? "text-primary" : ""}`}>
{entry.username}
</span>
{isCurrentUser && (
<Badge variant="outline" className="text-xs">
Вы
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right font-medium">
{entry.total_score}
</TableCell>
<TableCell className="text-right">
<Badge variant="secondary">
{entry.problems_solved}
</Badge>
</TableCell>
<TableCell className="text-right text-sm text-muted-foreground hidden sm:table-cell">
{entry.last_submission_time
? formatDate(entry.last_submission_time)
: "-"}
</TableCell>
</motion.tr>
);
})}
</AnimatePresence>
</TableBody>
</Table>
</CardContent>
</Card>
</motion.div>
</>
)}
</div>
);
}

View File

@ -0,0 +1,141 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { motion } from "framer-motion";
import { toast } from "sonner";
import { useAuth } from "@/lib/auth-context";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AlertError } from "@/components/ui/alert";
import { LogIn, Mail, Lock, Eye, EyeOff, Trophy } from "lucide-react";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { login } = useAuth();
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setIsLoading(true);
try {
await login(email, password);
toast.success("Добро пожаловать!");
router.push("/contests");
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка входа");
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-[calc(100vh-4rem)] flex items-center justify-center px-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="w-full max-w-md"
>
<Card className="shadow-lg">
<CardHeader className="space-y-1 text-center pb-4">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
className="w-16 h-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center"
>
<Trophy className="h-8 w-8 text-primary" />
</motion.div>
<CardTitle className="text-2xl font-bold">Вход</CardTitle>
<p className="text-sm text-muted-foreground">
Войдите в свой аккаунт для участия в контестах
</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
>
<AlertError>{error}</AlertError>
</motion.div>
)}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="pl-10"
placeholder="email@example.com"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Пароль</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="pl-10 pr-10"
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
<Button type="submit" className="w-full" loading={isLoading}>
<LogIn className="h-4 w-4 mr-2" />
Войти
</Button>
</form>
<div className="mt-6 text-center">
<p className="text-sm text-muted-foreground">
Нет аккаунта?{" "}
<Link
href="/register"
className="text-primary hover:underline font-medium"
>
Зарегистрироваться
</Link>
</p>
</div>
</CardContent>
</Card>
</motion.div>
</div>
);
}

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

@ -0,0 +1,261 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useAuth } from "@/lib/auth-context";
import {
Trophy,
Zap,
Code2,
Users,
CheckCircle2,
ArrowRight,
Terminal,
Timer,
BarChart3,
} from "lucide-react";
import { VolguLogo } from "@/components/VolguLogo";
const features = [
{
icon: Trophy,
title: "Соревнования",
description: "Участвуйте в контестах и соревнуйтесь с другими программистами",
color: "text-yellow-500",
bgColor: "bg-yellow-500/10",
},
{
icon: Zap,
title: "Автопроверка",
description: "Мгновенная проверка решений с детальными результатами по каждому тесту",
color: "text-blue-500",
bgColor: "bg-blue-500/10",
},
{
icon: Code2,
title: "60+ языков",
description: "Python, C++, Java, JavaScript, Go, Rust и многие другие языки",
color: "text-green-500",
bgColor: "bg-green-500/10",
},
{
icon: Timer,
title: "Real-time таймеры",
description: "Следите за временем контеста и оставшимся временем в реальном времени",
color: "text-orange-500",
bgColor: "bg-orange-500/10",
},
{
icon: BarChart3,
title: "Рейтинг",
description: "Отслеживайте свой прогресс и соревнуйтесь в таблице лидеров",
color: "text-purple-500",
bgColor: "bg-purple-500/10",
},
{
icon: Terminal,
title: "Удобный редактор",
description: "Современный редактор кода с подсветкой синтаксиса и автодополнением",
color: "text-pink-500",
bgColor: "bg-pink-500/10",
},
];
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
};
export default function HomePage() {
const { user } = useAuth();
return (
<div className="min-h-[calc(100vh-4rem)]">
{/* Hero Section */}
<section className="relative overflow-hidden">
{/* Background gradient */}
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-primary/5" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_20%,rgba(120,119,198,0.1),transparent_50%)]" />
<div className="container mx-auto px-4 py-24 relative">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="text-center max-w-3xl mx-auto"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
className="inline-flex items-center gap-3 px-4 py-2 rounded-full bg-[#2B4B7C]/10 mb-6"
>
<VolguLogo className="h-6 w-6" />
<span className="text-sm font-medium text-[#2B4B7C]">Волгоградский государственный университет</span>
</motion.div>
<h1 className="text-4xl md:text-6xl font-bold mb-6">
<span className="text-[#2B4B7C]">ВолГУ</span>
<span className="text-muted-foreground">.</span>
<span className="text-primary">Контесты</span>
</h1>
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto">
Платформа для проведения соревнований по олимпиадному программированию
от Волгоградского государственного университета.
</p>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="flex flex-col sm:flex-row gap-4 justify-center"
>
<Link
href="/contests"
className="inline-flex items-center justify-center gap-2 h-11 rounded-lg px-8 text-lg font-medium bg-primary text-primary-foreground shadow hover:bg-primary/90 transition-all"
>
<Trophy className="h-5 w-5" />
Перейти к контестам
<ArrowRight className="h-5 w-5" />
</Link>
{!user && (
<Link
href="/register"
className="inline-flex items-center justify-center gap-2 h-11 rounded-lg px-8 text-lg font-medium border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground transition-all"
>
<Users className="h-5 w-5" />
Создать аккаунт
</Link>
)}
</motion.div>
</motion.div>
{/* Stats */}
<motion.div
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
className="mt-20 grid grid-cols-2 md:grid-cols-4 gap-8 max-w-4xl mx-auto"
>
{[
{ value: "60+", label: "Языков программирования" },
{ value: "∞", label: "Контестов" },
{ value: "100%", label: "Автопроверка" },
{ value: "24/7", label: "Доступность" },
].map((stat, index) => (
<div key={index} className="text-center">
<div className="text-3xl md:text-4xl font-bold text-primary mb-1">
{stat.value}
</div>
<div className="text-sm text-muted-foreground">{stat.label}</div>
</div>
))}
</motion.div>
</div>
</section>
{/* Features Section */}
<section className="py-24 bg-muted/30">
<div className="container mx-auto px-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="text-center mb-16"
>
<Badge variant="outline" className="mb-4">
Возможности
</Badge>
<h2 className="text-3xl md:text-4xl font-bold mb-4">
Всё, что нужно для соревнований
</h2>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
Наша платформа предоставляет все необходимые инструменты для проведения
и участия в соревнованиях по программированию
</p>
</motion.div>
<motion.div
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
className="grid md:grid-cols-2 lg:grid-cols-3 gap-6"
>
{features.map((feature, index) => {
const Icon = feature.icon;
return (
<motion.div key={index} variants={itemVariants}>
<Card className="h-full hover:shadow-lg transition-shadow">
<CardContent className="pt-6">
<div
className={`w-12 h-12 rounded-lg ${feature.bgColor} flex items-center justify-center mb-4`}
>
<Icon className={`h-6 w-6 ${feature.color}`} />
</div>
<h3 className="text-lg font-semibold mb-2">{feature.title}</h3>
<p className="text-muted-foreground">{feature.description}</p>
</CardContent>
</Card>
</motion.div>
);
})}
</motion.div>
</div>
</section>
{/* CTA Section */}
<section className="py-24">
<div className="container mx-auto px-4">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-primary to-primary/80 p-12 text-center text-primary-foreground"
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_70%_30%,rgba(255,255,255,0.1),transparent_50%)]" />
<div className="relative">
<CheckCircle2 className="h-12 w-12 mx-auto mb-6 opacity-90" />
<h2 className="text-3xl md:text-4xl font-bold mb-4">
Готовы начать?
</h2>
<p className="text-lg opacity-90 mb-8 max-w-xl mx-auto">
Присоединяйтесь к платформе ВолГУ.Контесты и участвуйте
в соревнованиях по олимпиадному программированию.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href={user ? "/contests" : "/register"}
className="inline-flex items-center justify-center gap-2 h-11 rounded-lg px-8 text-lg font-medium bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80 transition-all"
>
{user ? "Перейти к контестам" : "Начать бесплатно"}
<ArrowRight className="h-5 w-5" />
</Link>
</div>
</div>
</motion.div>
</div>
</section>
{/* Footer */}
<footer className="border-t border-border py-8">
<div className="container mx-auto px-4 text-center text-sm text-muted-foreground">
<p>ВолГУ.Контесты &copy; {new Date().getFullYear()} Волгоградский государственный университет</p>
</div>
</footer>
</div>
);
}

View File

@ -0,0 +1,336 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import {
User,
Mail,
AtSign,
GraduationCap,
Camera,
Trash2,
Save,
Loader2,
} from "lucide-react";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
export default function ProfilePage() {
const { user, isLoading: authLoading, setUser } = useAuth();
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
const [formData, setFormData] = useState({
username: "",
full_name: "",
telegram: "",
vk: "",
study_group: "",
});
const [saving, setSaving] = useState(false);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
useEffect(() => {
if (!authLoading && !user) {
router.push("/login");
}
}, [user, authLoading, router]);
useEffect(() => {
if (user) {
setFormData({
username: user.username || "",
full_name: user.full_name || "",
telegram: user.telegram || "",
vk: user.vk || "",
study_group: user.study_group || "",
});
}
}, [user]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate full_name has at least 2 words
if (formData.full_name) {
const words = formData.full_name.trim().split(/\s+/).filter(Boolean);
if (words.length < 2) {
toast.error("ФИО должно содержать как минимум имя и фамилию");
return;
}
}
setSaving(true);
try {
const updatedUser = await api.updateProfile(formData);
setUser(updatedUser);
toast.success("Профиль успешно обновлён");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Ошибка сохранения");
} finally {
setSaving(false);
}
};
const handleAvatarClick = () => {
fileInputRef.current?.click();
};
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingAvatar(true);
try {
const updatedUser = await api.uploadAvatar(file);
setUser(updatedUser);
toast.success("Аватар обновлён");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Ошибка загрузки");
} finally {
setUploadingAvatar(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
const handleDeleteAvatar = async () => {
if (!user?.avatar_url) return;
try {
const updatedUser = await api.deleteAvatar();
setUser(updatedUser);
toast.success("Аватар удалён");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Ошибка удаления");
}
};
if (authLoading) {
return (
<div className="container mx-auto px-4 py-8 max-w-2xl">
<Skeleton className="h-10 w-48 mb-8" />
<Skeleton className="h-96 rounded-xl" />
</div>
);
}
if (!user) {
return null;
}
const avatarUrl = user.avatar_url
? `${API_URL}${user.avatar_url}`
: null;
return (
<div className="container mx-auto px-4 py-8 max-w-2xl">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<h1 className="text-3xl font-bold flex items-center gap-3">
<User className="h-8 w-8 text-primary" />
Профиль
</h1>
<p className="text-muted-foreground mt-1">
Управляйте своими данными
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<Card>
<CardHeader>
<CardTitle>Личные данные</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Avatar Section */}
<div className="flex flex-col items-center gap-4 pb-6 border-b border-border">
<div className="relative group">
<div
className="w-32 h-32 rounded-full overflow-hidden bg-muted flex items-center justify-center cursor-pointer border-4 border-background shadow-lg"
onClick={handleAvatarClick}
>
{uploadingAvatar ? (
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
) : avatarUrl ? (
<img
src={avatarUrl}
alt="Avatar"
className="w-full h-full object-cover"
/>
) : (
<User className="h-16 w-16 text-muted-foreground" />
)}
</div>
<button
type="button"
onClick={handleAvatarClick}
className="absolute bottom-0 right-0 p-2 bg-primary text-primary-foreground rounded-full shadow-lg hover:bg-primary/90 transition-colors"
>
<Camera className="h-4 w-4" />
</button>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleAvatarChange}
className="hidden"
/>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAvatarClick}
disabled={uploadingAvatar}
>
<Camera className="h-4 w-4 mr-2" />
Загрузить фото
</Button>
{user.avatar_url && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleDeleteAvatar}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Удалить
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">
JPG, PNG или GIF. Максимум 5MB.
</p>
</div>
{/* Email (read-only) */}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
value={user.email}
disabled
icon={<Mail className="h-4 w-4" />}
/>
<p className="text-xs text-muted-foreground">
Email нельзя изменить
</p>
</div>
{/* Username */}
<div className="space-y-2">
<Label htmlFor="username">Имя пользователя</Label>
<Input
id="username"
name="username"
value={formData.username}
onChange={handleChange}
icon={<AtSign className="h-4 w-4" />}
placeholder="username"
/>
</div>
{/* Full Name */}
<div className="space-y-2">
<Label htmlFor="full_name">ФИО</Label>
<Input
id="full_name"
name="full_name"
value={formData.full_name}
onChange={handleChange}
icon={<User className="h-4 w-4" />}
placeholder="Иванов Иван Иванович"
/>
</div>
{/* Study Group */}
<div className="space-y-2">
<Label htmlFor="study_group">Учебная группа</Label>
<Input
id="study_group"
name="study_group"
value={formData.study_group}
onChange={handleChange}
icon={<GraduationCap className="h-4 w-4" />}
placeholder="ПМИб-241"
/>
</div>
{/* Social Links */}
<div className="space-y-4 pt-4 border-t border-border">
<h3 className="font-medium">Социальные сети</h3>
{/* Telegram */}
<div className="space-y-2">
<Label htmlFor="telegram">Telegram</Label>
<Input
id="telegram"
name="telegram"
value={formData.telegram}
onChange={handleChange}
placeholder="@username или ссылка"
/>
</div>
{/* VK */}
<div className="space-y-2">
<Label htmlFor="vk">VK</Label>
<Input
id="vk"
name="vk"
value={formData.vk}
onChange={handleChange}
placeholder="@username или ссылка"
/>
</div>
</div>
{/* Role Badge */}
<div className="pt-4 border-t border-border">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Роль</span>
<Badge variant={user.role === "admin" ? "default" : "secondary"}>
{user.role === "admin" ? "Администратор" : "Участник"}
</Badge>
</div>
</div>
{/* Submit Button */}
<Button type="submit" className="w-full" loading={saving}>
<Save className="h-4 w-4 mr-2" />
Сохранить изменения
</Button>
</form>
</CardContent>
</Card>
</motion.div>
</div>
);
}

View File

@ -0,0 +1,212 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { motion } from "framer-motion";
import { toast } from "sonner";
import { useAuth } from "@/lib/auth-context";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AlertError } from "@/components/ui/alert";
import { UserPlus, Mail, Lock, Eye, EyeOff, User, Trophy, Check, X } from "lucide-react";
export default function RegisterPage() {
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { register } = useAuth();
const router = useRouter();
// Password validation
const passwordChecks = {
length: password.length >= 6,
match: password === confirmPassword && confirmPassword.length > 0,
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (password !== confirmPassword) {
setError("Пароли не совпадают");
return;
}
if (password.length < 6) {
setError("Пароль должен быть не менее 6 символов");
return;
}
setIsLoading(true);
try {
await register(email, username, password);
toast.success("Регистрация успешна! Добро пожаловать!");
router.push("/contests");
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка регистрации");
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-[calc(100vh-4rem)] flex items-center justify-center px-4 py-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="w-full max-w-md"
>
<Card className="shadow-lg">
<CardHeader className="space-y-1 text-center pb-4">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
className="w-16 h-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center"
>
<Trophy className="h-8 w-8 text-primary" />
</motion.div>
<CardTitle className="text-2xl font-bold">Регистрация</CardTitle>
<p className="text-sm text-muted-foreground">
Создайте аккаунт для участия в соревнованиях
</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
>
<AlertError>{error}</AlertError>
</motion.div>
)}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="pl-10"
placeholder="email@example.com"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="username">Имя пользователя</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="pl-10"
placeholder="username"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Пароль</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="pl-10 pr-10"
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Подтвердите пароль</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="confirmPassword"
type={showPassword ? "text" : "password"}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="pl-10"
placeholder="••••••••"
/>
</div>
</div>
{/* Password requirements */}
{password.length > 0 && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
className="space-y-1"
>
<div className={`flex items-center gap-2 text-xs ${passwordChecks.length ? "text-success" : "text-muted-foreground"}`}>
{passwordChecks.length ? <Check className="h-3 w-3" /> : <X className="h-3 w-3" />}
Минимум 6 символов
</div>
{confirmPassword.length > 0 && (
<div className={`flex items-center gap-2 text-xs ${passwordChecks.match ? "text-success" : "text-destructive"}`}>
{passwordChecks.match ? <Check className="h-3 w-3" /> : <X className="h-3 w-3" />}
Пароли совпадают
</div>
)}
</motion.div>
)}
<Button type="submit" className="w-full" loading={isLoading}>
<UserPlus className="h-4 w-4 mr-2" />
Зарегистрироваться
</Button>
</form>
<div className="mt-6 text-center">
<p className="text-sm text-muted-foreground">
Уже есть аккаунт?{" "}
<Link
href="/login"
className="text-primary hover:underline font-medium"
>
Войти
</Link>
</p>
</div>
</CardContent>
</Card>
</motion.div>
</div>
);
}

View File

@ -0,0 +1,354 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { motion, AnimatePresence } from "framer-motion";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { AlertError } from "@/components/ui/alert";
import { Progress } from "@/components/ui/progress";
import { SubmissionStatus } from "@/components/domain/submission-status";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
FileCode,
Clock,
Trophy,
CheckCircle2,
XCircle,
ArrowRight,
Filter,
Hash,
} from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { SubmissionListItem } from "@/types";
// Stats card component
function StatsCard({
icon: Icon,
label,
value,
color,
}: {
icon: typeof Trophy;
label: string;
value: string | number;
color?: string;
}) {
return (
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-3">
<Icon className={`h-5 w-5 ${color || "text-muted-foreground"}`} />
<div>
<div className="text-xs text-muted-foreground">{label}</div>
<div className="font-semibold text-lg">{value}</div>
</div>
</div>
</CardContent>
</Card>
);
}
export default function SubmissionsPage() {
const [submissions, setSubmissions] = useState<SubmissionListItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
useEffect(() => {
api
.getMySubmissions()
.then(setSubmissions)
.catch((err) => setError(err.message))
.finally(() => setIsLoading(false));
}, []);
// Calculate stats
const stats = {
total: submissions.length,
accepted: submissions.filter((s) => s.status === "accepted").length,
partial: submissions.filter(
(s) => s.status !== "accepted" && s.score > 0
).length,
failed: submissions.filter(
(s) => s.status !== "accepted" && s.score === 0 && s.status !== "pending"
).length,
successRate:
submissions.length > 0
? Math.round(
(submissions.filter((s) => s.status === "accepted").length /
submissions.length) *
100
)
: 0,
};
// Filter submissions
const filteredSubmissions =
statusFilter === "all"
? submissions
: submissions.filter((s) => {
if (statusFilter === "accepted") return s.status === "accepted";
if (statusFilter === "partial")
return s.status !== "accepted" && s.score > 0;
if (statusFilter === "failed")
return s.status !== "accepted" && s.score === 0;
return true;
});
if (isLoading) {
return (
<div className="container mx-auto px-4 py-8">
<Skeleton className="h-10 w-48 mb-8" />
<div className="grid gap-4 md:grid-cols-4 mb-8">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-20 rounded-xl" />
))}
</div>
<Skeleton className="h-64 rounded-xl" />
</div>
);
}
if (error) {
return (
<div className="container mx-auto px-4 py-8">
<AlertError title="Ошибка загрузки">{error}</AlertError>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<h1 className="text-3xl font-bold flex items-center gap-3">
<FileCode className="h-8 w-8 text-primary" />
Мои решения
</h1>
<p className="text-muted-foreground mt-1">
История всех отправленных решений
</p>
</motion.div>
{/* Stats */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="grid gap-4 md:grid-cols-4 mb-8"
>
<StatsCard icon={Hash} label="Всего отправок" value={stats.total} />
<StatsCard
icon={CheckCircle2}
label="Принято"
value={stats.accepted}
color="text-success"
/>
<StatsCard
icon={Trophy}
label="Частично"
value={stats.partial}
color="text-warning"
/>
<StatsCard
icon={XCircle}
label="Не принято"
value={stats.failed}
color="text-destructive"
/>
</motion.div>
{/* Success rate */}
{submissions.length > 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="mb-8"
>
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-muted-foreground">
Процент успешных решений
</span>
<span className="font-semibold">{stats.successRate}%</span>
</div>
<Progress value={stats.successRate} className="h-2" />
</CardContent>
</Card>
</motion.div>
)}
{/* Content */}
{submissions.length === 0 ? (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
>
<Card className="text-center py-16">
<CardContent>
<FileCode className="h-16 w-16 mx-auto mb-4 text-muted-foreground opacity-50" />
<h2 className="text-xl font-semibold mb-2">
У вас пока нет решений
</h2>
<p className="text-muted-foreground mb-6">
Отправьте своё первое решение, чтобы увидеть его здесь
</p>
<Button asChild>
<Link href="/contests">
Перейти к контестам
<ArrowRight className="h-4 w-4 ml-2" />
</Link>
</Button>
</CardContent>
</Card>
</motion.div>
) : (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg">
История отправок ({filteredSubmissions.length})
</CardTitle>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-40">
<Filter className="h-4 w-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все статусы</SelectItem>
<SelectItem value="accepted">Принятые</SelectItem>
<SelectItem value="partial">Частичные</SelectItem>
<SelectItem value="failed">Не принятые</SelectItem>
</SelectContent>
</Select>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">ID</TableHead>
<TableHead>Время</TableHead>
<TableHead>Язык</TableHead>
<TableHead className="text-center">Статус</TableHead>
<TableHead className="text-right">Баллы</TableHead>
<TableHead className="text-right">Тесты</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<AnimatePresence>
{filteredSubmissions.map((submission, index) => {
const scorePercent =
submission.total_points > 0
? Math.round(
(submission.score / submission.total_points) * 100
)
: 0;
return (
<motion.tr
key={submission.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ delay: index * 0.03 }}
className="border-b border-border hover:bg-muted/50"
>
<TableCell className="font-mono text-muted-foreground">
#{submission.id}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">
{formatDate(submission.created_at)}
</span>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
{submission.language_name || "-"}
</Badge>
</TableCell>
<TableCell className="text-center">
<SubmissionStatus status={submission.status} />
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<span
className={`font-semibold ${
scorePercent === 100
? "text-success"
: scorePercent > 0
? "text-warning"
: "text-muted-foreground"
}`}
>
{submission.score}/{submission.total_points}
</span>
{scorePercent > 0 && scorePercent < 100 && (
<span className="text-xs text-muted-foreground">
({scorePercent}%)
</span>
)}
</div>
</TableCell>
<TableCell className="text-right">
<Badge
variant={
submission.tests_passed === submission.tests_total
? "success"
: submission.tests_passed > 0
? "warning"
: "secondary"
}
>
{submission.tests_passed}/{submission.tests_total}
</Badge>
</TableCell>
</motion.tr>
);
})}
</AnimatePresence>
</TableBody>
</Table>
{filteredSubmissions.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
Нет решений с выбранным статусом
</div>
)}
</CardContent>
</Card>
</motion.div>
)}
</div>
);
}

View File

@ -0,0 +1,97 @@
"use client";
import dynamic from "next/dynamic";
const Editor = dynamic(() => import("@monaco-editor/react"), {
ssr: false,
loading: () => (
<div className="h-full bg-[#1e1e1e] flex items-center justify-center">
<div className="text-white/50">Загрузка редактора...</div>
</div>
),
});
interface CodeEditorProps {
value: string;
onChange: (value: string) => void;
language: string;
readOnly?: boolean;
}
// Map Judge0 language names to Monaco language IDs
const languageMap: Record<string, string> = {
"Python (3.11.2)": "python",
"Python (3.8.1)": "python",
"C++ (GCC 9.2.0)": "cpp",
"C++ (GCC 11.2.0)": "cpp",
"C++ (GCC 7.4.0)": "cpp",
"C (GCC 9.2.0)": "c",
"C (GCC 7.4.0)": "c",
"Java (OpenJDK 13.0.1)": "java",
"Java (OpenJDK 17.0.6)": "java",
"JavaScript (Node.js 12.14.0)": "javascript",
"JavaScript (Node.js 18.15.0)": "javascript",
"TypeScript (5.0.3)": "typescript",
"Go (1.13.5)": "go",
"Go (1.18.5)": "go",
"Rust (1.40.0)": "rust",
"Ruby (2.7.0)": "ruby",
"PHP (7.4.1)": "php",
"C# (Mono 6.6.0.161)": "csharp",
"Kotlin (1.3.70)": "kotlin",
"Swift (5.2.3)": "swift",
};
function getMonacoLanguage(languageName: string): string {
// Check exact match first
if (languageMap[languageName]) {
return languageMap[languageName];
}
// Check partial match
const lowerName = languageName.toLowerCase();
if (lowerName.includes("python")) return "python";
if (lowerName.includes("c++") || lowerName.includes("cpp")) return "cpp";
if (lowerName.includes("java") && !lowerName.includes("javascript")) return "java";
if (lowerName.includes("javascript") || lowerName.includes("node")) return "javascript";
if (lowerName.includes("typescript")) return "typescript";
if (lowerName.includes("go ") || lowerName.startsWith("go")) return "go";
if (lowerName.includes("rust")) return "rust";
if (lowerName.includes("ruby")) return "ruby";
if (lowerName.includes("php")) return "php";
if (lowerName.includes("c#") || lowerName.includes("csharp")) return "csharp";
if (lowerName.includes("kotlin")) return "kotlin";
if (lowerName.includes("swift")) return "swift";
if (lowerName.includes(" c ") || lowerName.startsWith("c ")) return "c";
return "plaintext";
}
export function CodeEditor({
value,
onChange,
language,
readOnly = false,
}: CodeEditorProps) {
const monacoLanguage = getMonacoLanguage(language);
return (
<Editor
height="100%"
language={monacoLanguage}
value={value}
onChange={(v) => onChange(v || "")}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 14,
scrollBeyondLastLine: false,
automaticLayout: true,
readOnly,
tabSize: 4,
insertSpaces: true,
wordWrap: "on",
}}
/>
);
}

View File

@ -0,0 +1,198 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { motion } from "framer-motion";
import { useAuth } from "@/lib/auth-context";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Skeleton } from "@/components/ui/skeleton";
import {
Trophy,
FileCode,
LogOut,
User,
ChevronDown,
LayoutDashboard,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { VolguLogo } from "@/components/VolguLogo";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
const navLinks = [
{ href: "/contests", label: "Контесты", icon: Trophy },
{ href: "/submissions", label: "Мои решения", icon: FileCode },
];
export function Navbar() {
const { user, logout, isLoading } = useAuth();
const pathname = usePathname();
const isActive = (href: string) => {
if (href === "/") return pathname === href;
return pathname.startsWith(href);
};
return (
<nav className="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
{/* Logo and Nav Links */}
<div className="flex items-center gap-8">
<Link href="/" className="flex items-center gap-2 group">
<motion.div
whileHover={{ scale: 1.05 }}
transition={{ duration: 0.2 }}
>
<VolguLogo className="h-8 w-8" />
</motion.div>
<span className="text-xl font-bold">
<span className="text-[#2B4B7C]">ВолГУ</span>
<span className="text-muted-foreground">.</span>
<span className="text-primary">Контесты</span>
</span>
</Link>
{user && (
<div className="hidden md:flex items-center gap-1">
{navLinks.map((link) => {
const Icon = link.icon;
const active = isActive(link.href);
return (
<Link
key={link.href}
href={link.href}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
active
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
<Icon className="h-4 w-4" />
{link.label}
</Link>
);
})}
{user.role === "admin" && (
<Link
href="/admin"
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
isActive("/admin")
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
<LayoutDashboard className="h-4 w-4" />
Админ
</Link>
)}
</div>
)}
</div>
{/* Right side - User menu */}
<div className="flex items-center gap-4">
{isLoading ? (
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-4 w-20" />
</div>
) : user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-2 px-2">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center text-primary-foreground font-semibold text-sm overflow-hidden">
{user.avatar_url ? (
<img
src={`${API_URL}${user.avatar_url}`}
alt={user.username}
className="w-full h-full object-cover"
/>
) : (
user.username.charAt(0).toUpperCase()
)}
</div>
<span className="hidden sm:inline text-sm font-medium">
{user.username}
</span>
{user.role === "admin" && (
<Badge variant="secondary" className="hidden sm:inline-flex text-xs">
Admin
</Badge>
)}
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<div className="px-2 py-1.5">
<p className="text-sm font-medium">{user.username}</p>
<p className="text-xs text-muted-foreground">{user.email}</p>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/profile" className="flex items-center gap-2">
<User className="h-4 w-4" />
Профиль
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Mobile nav links */}
<div className="md:hidden">
{navLinks.map((link) => {
const Icon = link.icon;
return (
<DropdownMenuItem key={link.href} asChild>
<Link href={link.href} className="flex items-center gap-2">
<Icon className="h-4 w-4" />
{link.label}
</Link>
</DropdownMenuItem>
);
})}
{user.role === "admin" && (
<DropdownMenuItem asChild>
<Link href="/admin" className="flex items-center gap-2">
<LayoutDashboard className="h-4 w-4" />
Админ
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</div>
<DropdownMenuItem
onClick={logout}
className="text-destructive focus:text-destructive"
>
<LogOut className="h-4 w-4 mr-2" />
Выйти
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<div className="flex items-center gap-2">
<Button variant="ghost" asChild>
<Link href="/login">Войти</Link>
</Button>
<Button asChild>
<Link href="/register">Регистрация</Link>
</Button>
</div>
)}
</div>
</div>
</nav>
);
}

View File

@ -0,0 +1,68 @@
"use client";
import { useState } from "react";
import type { SampleTest } from "@/types";
interface SampleTestsProps {
tests: SampleTest[];
}
export function SampleTests({ tests }: SampleTestsProps) {
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const copyToClipboard = async (text: string, index: number) => {
try {
await navigator.clipboard.writeText(text);
setCopiedIndex(index);
setTimeout(() => setCopiedIndex(null), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
};
if (tests.length === 0) {
return null;
}
return (
<div className="mt-6">
<h3 className="text-lg font-semibold mb-4">Примеры</h3>
<div className="space-y-4">
{tests.map((test, index) => (
<div
key={index}
className="border border-border rounded-lg overflow-hidden"
>
<div className="bg-muted px-4 py-2 flex justify-between items-center">
<span className="font-medium text-sm">Пример {index + 1}</span>
<button
onClick={() => copyToClipboard(test.input, index)}
className="text-xs text-primary hover:text-primary/80 transition"
>
{copiedIndex === index ? "Скопировано!" : "Копировать ввод"}
</button>
</div>
<div className="grid md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-border">
<div className="p-4">
<div className="text-xs text-muted-foreground mb-2 uppercase tracking-wide">
Ввод
</div>
<pre className="bg-background p-3 rounded text-sm font-mono whitespace-pre-wrap overflow-x-auto">
{test.input}
</pre>
</div>
<div className="p-4">
<div className="text-xs text-muted-foreground mb-2 uppercase tracking-wide">
Вывод
</div>
<pre className="bg-background p-3 rounded text-sm font-mono whitespace-pre-wrap overflow-x-auto">
{test.output}
</pre>
</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,60 @@
"use client";
import { getStatusColor, getStatusText } from "@/lib/utils";
import type { Submission } from "@/types";
interface SubmissionResultProps {
submission: Submission;
}
export function SubmissionResult({ submission }: SubmissionResultProps) {
const statusColor = getStatusColor(submission.status);
const statusText = getStatusText(submission.status);
return (
<div className="border-t border-border p-4 bg-muted/30">
<div className="flex items-center justify-between mb-2">
<span className={`font-semibold ${statusColor}`}>{statusText}</span>
<span className="text-sm text-muted-foreground">
#{submission.id}
</span>
</div>
<div className="grid grid-cols-4 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Баллы:</span>{" "}
<span className="font-medium">
{submission.score}/{submission.total_points}
</span>
</div>
<div>
<span className="text-muted-foreground">Тесты:</span>{" "}
<span className="font-medium">
{submission.tests_passed}/{submission.tests_total}
</span>
</div>
{submission.execution_time_ms !== null && (
<div>
<span className="text-muted-foreground">Время:</span>{" "}
<span className="font-medium">{submission.execution_time_ms} мс</span>
</div>
)}
{submission.memory_used_kb !== null && (
<div>
<span className="text-muted-foreground">Память:</span>{" "}
<span className="font-medium">
{Math.round(submission.memory_used_kb / 1024)} МБ
</span>
</div>
)}
</div>
{(submission.status === "pending" || submission.status === "judging") && (
<div className="mt-3 flex items-center gap-2 text-sm text-primary">
<div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<span>Проверяется...</span>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,23 @@
export function VolguLogo({ className = "h-8 w-8" }: { className?: string }) {
return (
<svg
viewBox="0 0 198 209"
className={className}
xmlns="http://www.w3.org/2000/svg"
>
<g transform="translate(0,209) scale(0.1,-0.1)" fill="#2B4B7C" stroke="none">
<path d="M1940 2084 c-8 -2 -433 -51 -945 -109 -512 -58 -945 -108 -963 -111
-26 -4 -32 -10 -32 -29 0 -22 5 -25 35 -25 55 0 140 -37 193 -85 61 -55 99
-118 245 -412 144 -288 171 -317 102 -113 -38 113 -44 182 -19 218 19 27 39
28 82 2 18 -11 37 -18 41 -15 5 2 9 42 9 87 1 90 12 130 44 166 32 33 75 29
116 -10 27 -26 36 -30 40 -18 3 8 24 35 48 60 37 39 47 45 84 45 35 0 47 -6
72 -34 31 -35 33 -52 13 -148 l-7 -33 43 0 c54 0 104 -29 113 -65 10 -38 -25
-104 -91 -175 l-54 -58 31 -7 c40 -9 64 -45 55 -83 -10 -49 -60 -85 -174 -126
-94 -33 -129 -55 -110 -67 8 -5 22 -9 31 -9 21 0 41 -35 34 -58 -8 -24 -54
-50 -105 -58 -38 -6 -40 -8 -26 -24 8 -9 15 -26 15 -37 0 -26 -54 -73 -82 -73
-15 0 -19 -5 -15 -19 2 -11 65 -164 138 -340 l134 -321 30 0 c29 0 32 4 97
158 37 86 236 551 443 1032 206 481 375 880 375 887 0 13 -10 14 -40 7z"/>
</g>
</svg>
);
}

View File

@ -0,0 +1,137 @@
"use client";
import { motion } from "framer-motion";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ContestTimer } from "./contest-timer";
import { Progress } from "@/components/ui/progress";
import { Calendar, Users, FileCode2 } from "lucide-react";
import { cn } from "@/lib/utils";
interface Contest {
id: number;
title: string;
description?: string;
start_time: string;
end_time: string;
is_active: boolean;
problems_count?: number;
participants_count?: number;
user_solved?: number;
}
interface ContestCardProps {
contest: Contest;
className?: string;
}
function getContestStatus(contest: Contest): "upcoming" | "running" | "ended" {
const now = new Date();
const start = new Date(contest.start_time);
const end = new Date(contest.end_time);
if (now < start) return "upcoming";
if (now >= end) return "ended";
return "running";
}
function formatDate(date: string): string {
return new Date(date).toLocaleString("ru-RU", {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
}
export function ContestCard({ contest, className }: ContestCardProps) {
const status = getContestStatus(contest);
const progress =
contest.problems_count && contest.user_solved !== undefined
? (contest.user_solved / contest.problems_count) * 100
: 0;
return (
<motion.div
whileHover={{ y: -4 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
<Link href={`/contests/${contest.id}`}>
<Card
className={cn(
"cursor-pointer transition-shadow hover:shadow-lg",
status === "running" && "border-success/50",
className
)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<CardTitle className="line-clamp-1 text-lg">
{contest.title}
</CardTitle>
{status === "running" && (
<Badge variant="success" pulse className="shrink-0">
LIVE
</Badge>
)}
{status === "upcoming" && (
<Badge variant="info" className="shrink-0">
Скоро
</Badge>
)}
{status === "ended" && (
<Badge variant="secondary" className="shrink-0">
Завершён
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Timer */}
{status !== "ended" && (
<ContestTimer
startTime={new Date(contest.start_time)}
endTime={new Date(contest.end_time)}
size="sm"
/>
)}
{/* Stats */}
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
<span>{formatDate(contest.start_time)}</span>
</div>
{contest.problems_count !== undefined && (
<div className="flex items-center gap-1">
<FileCode2 className="h-4 w-4" />
<span>{contest.problems_count} задач</span>
</div>
)}
{contest.participants_count !== undefined && (
<div className="flex items-center gap-1">
<Users className="h-4 w-4" />
<span>{contest.participants_count}</span>
</div>
)}
</div>
{/* Progress (if user has participated) */}
{contest.user_solved !== undefined && contest.problems_count && (
<div className="space-y-1">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Прогресс</span>
<span>
{contest.user_solved}/{contest.problems_count}
</span>
</div>
<Progress value={progress} className="h-1" />
</div>
)}
</CardContent>
</Card>
</Link>
</motion.div>
);
}

View File

@ -0,0 +1,114 @@
"use client";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { Clock } from "lucide-react";
interface ContestTimerProps {
endTime: Date;
startTime?: Date;
className?: string;
showIcon?: boolean;
size?: "sm" | "default" | "lg";
}
function formatTimeLeft(ms: number): string {
if (ms <= 0) return "00:00:00";
const seconds = Math.floor((ms / 1000) % 60);
const minutes = Math.floor((ms / (1000 * 60)) % 60);
const hours = Math.floor((ms / (1000 * 60 * 60)) % 24);
const days = Math.floor(ms / (1000 * 60 * 60 * 24));
if (days > 0) {
return `${days}д ${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
function getTimerColor(ms: number): string {
const minutes = ms / (1000 * 60);
if (minutes > 60) return "text-success";
if (minutes > 30) return "text-warning";
if (minutes > 5) return "text-orange-500";
return "text-destructive";
}
function getTimerPulse(ms: number): boolean {
const minutes = ms / (1000 * 60);
return minutes <= 30;
}
const sizeClasses = {
sm: "text-sm",
default: "text-base",
lg: "text-xl font-bold",
};
export function ContestTimer({
endTime,
startTime,
className,
showIcon = true,
size = "default",
}: ContestTimerProps) {
const [timeLeft, setTimeLeft] = useState<number>(0);
const [status, setStatus] = useState<"upcoming" | "running" | "ended">("running");
useEffect(() => {
const calculateTime = () => {
const now = new Date();
if (startTime && now < startTime) {
setStatus("upcoming");
return startTime.getTime() - now.getTime();
}
if (now >= endTime) {
setStatus("ended");
return 0;
}
setStatus("running");
return endTime.getTime() - now.getTime();
};
setTimeLeft(calculateTime());
const interval = setInterval(() => {
const newTimeLeft = calculateTime();
setTimeLeft(newTimeLeft);
}, 1000);
return () => clearInterval(interval);
}, [endTime, startTime]);
const colorClass = status === "ended"
? "text-muted-foreground"
: status === "upcoming"
? "text-info"
: getTimerColor(timeLeft);
const shouldPulse = status === "running" && getTimerPulse(timeLeft);
return (
<div
className={cn(
"inline-flex items-center gap-2 font-mono",
sizeClasses[size],
colorClass,
shouldPulse && "animate-pulse",
className
)}
>
{showIcon && <Clock className="h-4 w-4" />}
<span>
{status === "ended" && "Завершён"}
{status === "upcoming" && `До начала: ${formatTimeLeft(timeLeft)}`}
{status === "running" && formatTimeLeft(timeLeft)}
</span>
</div>
);
}

View File

@ -0,0 +1,49 @@
"use client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Code2 } from "lucide-react";
interface Language {
id: number;
name: string;
}
interface LanguageSelectProps {
languages: Language[];
value?: string;
onValueChange: (value: string) => void;
disabled?: boolean;
placeholder?: string;
}
export function LanguageSelect({
languages,
value,
onValueChange,
disabled = false,
placeholder = "Выберите язык",
}: LanguageSelectProps) {
return (
<Select value={value} onValueChange={onValueChange} disabled={disabled}>
<SelectTrigger className="w-[200px]">
<div className="flex items-center gap-2">
<Code2 className="h-4 w-4 text-muted-foreground" />
<SelectValue placeholder={placeholder} />
</div>
</SelectTrigger>
<SelectContent>
{languages.map((lang) => (
<SelectItem key={lang.id} value={lang.id.toString()}>
{lang.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View File

@ -0,0 +1,81 @@
import { cn } from "@/lib/utils";
import { Check, Minus, X, Circle } from "lucide-react";
type ProblemStatus = "solved" | "partial" | "attempted" | "not_attempted";
interface ProblemStatusBadgeProps {
status: ProblemStatus;
score?: number;
maxScore?: number;
className?: string;
}
const statusConfig: Record<
ProblemStatus,
{
icon: typeof Check;
className: string;
bgClassName: string;
}
> = {
solved: {
icon: Check,
className: "text-success",
bgClassName: "bg-success/10",
},
partial: {
icon: Minus,
className: "text-warning",
bgClassName: "bg-warning/10",
},
attempted: {
icon: X,
className: "text-destructive",
bgClassName: "bg-destructive/10",
},
not_attempted: {
icon: Circle,
className: "text-muted-foreground",
bgClassName: "bg-muted",
},
};
export function ProblemStatusBadge({
status,
score,
maxScore,
className,
}: ProblemStatusBadgeProps) {
const config = statusConfig[status];
const Icon = config.icon;
return (
<div
className={cn(
"inline-flex items-center justify-center gap-1 rounded-full px-2 py-1 text-xs font-medium",
config.bgClassName,
config.className,
className
)}
>
<Icon className="h-3 w-3" />
{score !== undefined && maxScore !== undefined && status !== "not_attempted" && (
<span>
{score}/{maxScore}
</span>
)}
</div>
);
}
// Helper to determine status from score
export function getProblemStatus(
score: number | undefined,
maxScore: number,
hasAttempted: boolean
): ProblemStatus {
if (score === undefined || !hasAttempted) return "not_attempted";
if (score === maxScore) return "solved";
if (score > 0) return "partial";
return "attempted";
}

View File

@ -0,0 +1,146 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { Check, X, Clock, AlertTriangle, Loader2, Ban } from "lucide-react";
import { motion } from "framer-motion";
type SubmissionStatusType =
| "pending"
| "judging"
| "accepted"
| "wrong_answer"
| "partial"
| "time_limit_exceeded"
| "runtime_error"
| "compilation_error"
| "internal_error";
interface SubmissionStatusProps {
status: SubmissionStatusType | string;
score?: number;
totalPoints?: number;
className?: string;
showIcon?: boolean;
animated?: boolean;
}
const statusConfig: Record<
SubmissionStatusType,
{
label: string;
variant: "default" | "secondary" | "destructive" | "outline" | "success" | "warning" | "info";
icon: typeof Check;
}
> = {
pending: {
label: "В очереди",
variant: "secondary",
icon: Clock,
},
judging: {
label: "Проверяется",
variant: "info",
icon: Loader2,
},
accepted: {
label: "Принято",
variant: "success",
icon: Check,
},
wrong_answer: {
label: "Неверный ответ",
variant: "destructive",
icon: X,
},
partial: {
label: "Частично",
variant: "warning",
icon: AlertTriangle,
},
time_limit_exceeded: {
label: "Превышено время",
variant: "destructive",
icon: Clock,
},
runtime_error: {
label: "Ошибка выполнения",
variant: "destructive",
icon: Ban,
},
compilation_error: {
label: "Ошибка компиляции",
variant: "destructive",
icon: AlertTriangle,
},
internal_error: {
label: "Внутренняя ошибка",
variant: "destructive",
icon: AlertTriangle,
},
};
export function SubmissionStatus({
status,
score,
totalPoints,
className,
showIcon = true,
animated = true,
}: SubmissionStatusProps) {
const normalizedStatus = status.toLowerCase().replace(/ /g, "_") as SubmissionStatusType;
const config = statusConfig[normalizedStatus] || statusConfig.internal_error;
const Icon = config.icon;
const isJudging = normalizedStatus === "judging";
const isAccepted = normalizedStatus === "accepted";
const badge = (
<Badge
variant={config.variant}
className={cn(
"gap-1",
isAccepted && animated && "animate-pulse-ring",
className
)}
>
{showIcon && (
<Icon
className={cn(
"h-3 w-3",
isJudging && "animate-spin"
)}
/>
)}
<span>{config.label}</span>
{score !== undefined && totalPoints !== undefined && (
<span className="ml-1">
({score}/{totalPoints})
</span>
)}
</Badge>
);
if (animated && (isAccepted || normalizedStatus === "wrong_answer")) {
return (
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{
type: "spring",
stiffness: 300,
damping: 20,
}}
>
{badge}
</motion.div>
);
}
return badge;
}
// Helper function for external use
export function getStatusVariant(status: string): "default" | "secondary" | "destructive" | "outline" | "success" | "warning" | "info" {
const normalizedStatus = status.toLowerCase().replace(/ /g, "_") as SubmissionStatusType;
return statusConfig[normalizedStatus]?.variant || "secondary";
}

View File

@ -0,0 +1,111 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { AlertCircle, CheckCircle, Info, XCircle } from "lucide-react";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
success:
"border-success/50 text-success dark:border-success [&>svg]:text-success bg-success/10",
warning:
"border-warning/50 text-warning dark:border-warning [&>svg]:text-warning bg-warning/10",
info:
"border-info/50 text-info dark:border-info [&>svg]:text-info bg-info/10",
},
},
defaultVariants: {
variant: "default",
},
}
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
));
AlertDescription.displayName = "AlertDescription";
// Convenience components with icons
interface AlertWithIconProps extends React.HTMLAttributes<HTMLDivElement> {
title?: string;
children: React.ReactNode;
}
export function AlertError({ title, children, className, ...props }: AlertWithIconProps) {
return (
<Alert variant="destructive" className={className} {...props}>
<XCircle className="h-4 w-4" />
{title && <AlertTitle>{title}</AlertTitle>}
<AlertDescription>{children}</AlertDescription>
</Alert>
);
}
export function AlertSuccess({ title, children, className, ...props }: AlertWithIconProps) {
return (
<Alert variant="success" className={className} {...props}>
<CheckCircle className="h-4 w-4" />
{title && <AlertTitle>{title}</AlertTitle>}
<AlertDescription>{children}</AlertDescription>
</Alert>
);
}
export function AlertWarning({ title, children, className, ...props }: AlertWithIconProps) {
return (
<Alert variant="warning" className={className} {...props}>
<AlertCircle className="h-4 w-4" />
{title && <AlertTitle>{title}</AlertTitle>}
<AlertDescription>{children}</AlertDescription>
</Alert>
);
}
export function AlertInfo({ title, children, className, ...props }: AlertWithIconProps) {
return (
<Alert variant="info" className={className} {...props}>
<Info className="h-4 w-4" />
{title && <AlertTitle>{title}</AlertTitle>}
<AlertDescription>{children}</AlertDescription>
</Alert>
);
}
export { Alert, AlertTitle, AlertDescription };

View File

@ -0,0 +1,50 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow",
secondary:
"border-transparent bg-secondary text-secondary-foreground",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow",
outline: "text-foreground",
success:
"border-transparent bg-success text-success-foreground shadow",
warning:
"border-transparent bg-warning text-warning-foreground shadow",
info:
"border-transparent bg-info text-info-foreground shadow",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {
pulse?: boolean;
}
function Badge({ className, variant, pulse, ...props }: BadgeProps) {
return (
<div
className={cn(
badgeVariants({ variant }),
pulse && "animate-pulse",
className
)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@ -0,0 +1,80 @@
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Loader2 } from "lucide-react";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90 hover:shadow-md",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
success:
"bg-success text-success-foreground shadow-sm hover:bg-success/90",
warning:
"bg-warning text-warning-foreground shadow-sm hover:bg-warning/90",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3 text-xs",
lg: "h-11 rounded-lg px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
loading?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, loading = false, children, disabled, ...props }, ref) => {
// When using asChild with Slot, we can only pass a single child element
if (asChild && !loading) {
return (
<Slot
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
>
{children}
</Slot>
);
}
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={disabled || loading}
{...props}
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{children}
</button>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@ -0,0 +1,75 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border border-border bg-card text-card-foreground shadow-sm transition-shadow hover:shadow-md",
className
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@ -0,0 +1,32 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
"data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@ -0,0 +1,123 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
"sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@ -0,0 +1,200 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@ -0,0 +1,26 @@
// Base components
export * from "./button";
export * from "./input";
export * from "./label";
export * from "./textarea";
export * from "./checkbox";
// Display components
export * from "./badge";
export * from "./card";
export * from "./skeleton";
export * from "./spinner";
export * from "./alert";
export * from "./progress";
// Navigation components
export * from "./tabs";
export * from "./select";
export * from "./dropdown-menu";
// Overlay components
export * from "./dialog";
export * from "./tooltip";
// Data display
export * from "./table";

View File

@ -0,0 +1,42 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
error?: boolean;
icon?: React.ReactNode;
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, error, icon, ...props }, ref) => {
return (
<div className="relative">
{icon && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
{icon}
</div>
)}
<input
type={type}
className={cn(
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background transition-colors",
"file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground",
"placeholder:text-muted-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
error && "border-destructive focus-visible:ring-destructive",
icon && "pl-10",
className
)}
ref={ref}
{...props}
/>
</div>
);
}
);
Input.displayName = "Input";
export { Input };

View File

@ -0,0 +1,25 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@ -0,0 +1,35 @@
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
interface ProgressProps
extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
indicatorClassName?: string;
}
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
ProgressProps
>(({ className, value, indicatorClassName, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className={cn(
"h-full w-full flex-1 bg-primary transition-all duration-300 ease-in-out",
indicatorClassName
)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View File

@ -0,0 +1,167 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background transition-colors",
"placeholder:text-muted-foreground",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
"[&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none",
"focus:bg-accent focus:text-accent-foreground",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@ -0,0 +1,33 @@
import { cn } from "@/lib/utils";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"animate-pulse rounded-md bg-muted",
className
)}
{...props}
/>
);
}
function SkeletonShimmer({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"animate-shimmer rounded-md",
className
)}
{...props}
/>
);
}
export { Skeleton, SkeletonShimmer };

View File

@ -0,0 +1,21 @@
import { cn } from "@/lib/utils";
import { Loader2 } from "lucide-react";
interface SpinnerProps {
className?: string;
size?: "sm" | "default" | "lg";
}
const sizeClasses = {
sm: "h-4 w-4",
default: "h-6 w-6",
lg: "h-8 w-8",
};
export function Spinner({ className, size = "default" }: SpinnerProps) {
return (
<Loader2
className={cn("animate-spin text-muted-foreground", sizeClasses[size], className)}
/>
);
}

View File

@ -0,0 +1,119 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-4 align-middle [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@ -0,0 +1,57 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-all",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:pointer-events-none disabled:opacity-50",
"data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@ -0,0 +1,29 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
error?: boolean;
}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, error, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background transition-colors",
"placeholder:text-muted-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
error && "border-destructive focus-visible:ring-destructive",
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = "Textarea";
export { Textarea };

View File

@ -0,0 +1,29 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

351
frontend/src/lib/api.ts Normal file
View File

@ -0,0 +1,351 @@
import Cookies from "js-cookie";
import type {
User,
Contest,
ContestListItem,
Problem,
ProblemListItem,
Submission,
SubmissionListItem,
Language,
Leaderboard,
TestCase,
} from "@/types";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
class ApiClient {
private getHeaders(): HeadersInit {
const headers: HeadersInit = {
"Content-Type": "application/json",
};
const token = Cookies.get("token");
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
return headers;
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${API_URL}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
...this.getHeaders(),
...options.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
}
if (response.status === 204) {
return {} as T;
}
return response.json();
}
// Auth
async register(data: {
email: string;
username: string;
password: string;
}): Promise<User> {
return this.request<User>("/api/auth/register", {
method: "POST",
body: JSON.stringify(data),
});
}
async login(email: string, password: string): Promise<{ access_token: string }> {
const formData = new URLSearchParams();
formData.append("username", email);
formData.append("password", password);
const response = await fetch(`${API_URL}/api/auth/login`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || "Login failed");
}
return response.json();
}
async getMe(): Promise<User> {
return this.request<User>("/api/auth/me");
}
async updateProfile(data: {
username?: string;
full_name?: string;
telegram?: string;
vk?: string;
study_group?: string;
}): Promise<User> {
return this.request<User>("/api/auth/me", {
method: "PUT",
body: JSON.stringify(data),
});
}
async uploadAvatar(file: File): Promise<User> {
const formData = new FormData();
formData.append("file", file);
const token = Cookies.get("token");
const response = await fetch(`${API_URL}/api/auth/me/avatar`, {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || "Failed to upload avatar");
}
return response.json();
}
async deleteAvatar(): Promise<User> {
return this.request<User>("/api/auth/me/avatar", {
method: "DELETE",
});
}
// Contests
async getContests(): Promise<ContestListItem[]> {
return this.request<ContestListItem[]>("/api/contests/");
}
async getContest(id: number): Promise<Contest> {
return this.request<Contest>(`/api/contests/${id}`);
}
async createContest(data: {
title: string;
description?: string;
start_time: string;
end_time: string;
is_active?: boolean;
}): Promise<Contest> {
return this.request<Contest>("/api/contests/", {
method: "POST",
body: JSON.stringify(data),
});
}
async updateContest(
id: number,
data: Partial<{
title: string;
description: string;
start_time: string;
end_time: string;
is_active: boolean;
}>
): Promise<Contest> {
return this.request<Contest>(`/api/contests/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
async deleteContest(id: number): Promise<void> {
return this.request<void>(`/api/contests/${id}`, {
method: "DELETE",
});
}
async joinContest(id: number): Promise<void> {
return this.request<void>(`/api/contests/${id}/join`, {
method: "POST",
});
}
// Problems
async getProblemsByContest(contestId: number): Promise<ProblemListItem[]> {
return this.request<ProblemListItem[]>(`/api/problems/contest/${contestId}`);
}
async getProblem(id: number): Promise<Problem> {
return this.request<Problem>(`/api/problems/${id}`);
}
async createProblem(data: {
contest_id: number;
title: string;
description: string;
input_format?: string;
output_format?: string;
constraints?: string;
time_limit_ms?: number;
memory_limit_kb?: number;
total_points?: number;
order_index?: number;
test_cases?: Array<{
input: string;
expected_output: string;
is_sample?: boolean;
points?: number;
order_index?: number;
}>;
}): Promise<Problem> {
return this.request<Problem>("/api/problems/", {
method: "POST",
body: JSON.stringify(data),
});
}
async updateProblem(
id: number,
data: Partial<{
title: string;
description: string;
input_format: string;
output_format: string;
constraints: string;
time_limit_ms: number;
memory_limit_kb: number;
total_points: number;
order_index: number;
}>
): Promise<Problem> {
return this.request<Problem>(`/api/problems/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
async deleteProblem(id: number): Promise<void> {
return this.request<void>(`/api/problems/${id}`, {
method: "DELETE",
});
}
async getTestCases(problemId: number): Promise<TestCase[]> {
return this.request<TestCase[]>(`/api/problems/${problemId}/test-cases`);
}
async addTestCase(
problemId: number,
data: {
input: string;
expected_output: string;
is_sample?: boolean;
points?: number;
order_index?: number;
}
): Promise<TestCase> {
return this.request<TestCase>(`/api/problems/${problemId}/test-cases`, {
method: "POST",
body: JSON.stringify(data),
});
}
async createTestCase(
problemId: number,
data: {
input: string;
expected_output: string;
is_sample?: boolean;
points?: number;
order_index?: number;
}
): Promise<TestCase> {
return this.request<TestCase>(`/api/problems/${problemId}/test-cases`, {
method: "POST",
body: JSON.stringify(data),
});
}
async updateTestCase(
testCaseId: number,
data: Partial<{
input: string;
expected_output: string;
is_sample: boolean;
points: number;
order_index: number;
}>
): Promise<TestCase> {
return this.request<TestCase>(`/api/problems/test-cases/${testCaseId}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
async deleteTestCase(testCaseId: number): Promise<void> {
return this.request<void>(`/api/problems/test-cases/${testCaseId}`, {
method: "DELETE",
});
}
// Alias methods
async getContestProblems(contestId: number): Promise<ProblemListItem[]> {
return this.getProblemsByContest(contestId);
}
// Submissions
async createSubmission(data: {
problem_id: number;
contest_id: number;
source_code: string;
language_id: number;
language_name?: string;
}): Promise<Submission> {
return this.request<Submission>("/api/submissions/", {
method: "POST",
body: JSON.stringify(data),
});
}
async getSubmission(id: number): Promise<Submission> {
return this.request<Submission>(`/api/submissions/${id}`);
}
async getMySubmissions(params?: {
problem_id?: number;
contest_id?: number;
}): Promise<SubmissionListItem[]> {
const searchParams = new URLSearchParams();
if (params?.problem_id) searchParams.append("problem_id", String(params.problem_id));
if (params?.contest_id) searchParams.append("contest_id", String(params.contest_id));
const query = searchParams.toString();
return this.request<SubmissionListItem[]>(`/api/submissions/${query ? `?${query}` : ""}`);
}
async getSubmissionsByProblem(problemId: number): Promise<SubmissionListItem[]> {
return this.request<SubmissionListItem[]>(`/api/submissions/problem/${problemId}`);
}
// Languages
async getLanguages(): Promise<Language[]> {
return this.request<Language[]>("/api/languages/");
}
// Leaderboard
async getLeaderboard(contestId: number): Promise<Leaderboard> {
return this.request<Leaderboard>(`/api/leaderboard/${contestId}`);
}
// Admin - Users
async getAllUsers(): Promise<User[]> {
return this.request<User[]>("/api/auth/users");
}
}
export const api = new ApiClient();

Some files were not shown because too many files have changed in this diff Show More