feat: Init commit
This commit is contained in:
commit
0aed8f5494
162
.gitignore
vendored
Normal file
162
.gitignore
vendored
Normal 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
24
backend/Dockerfile
Normal 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
41
backend/alembic.ini
Normal 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
63
backend/alembic/env.py
Normal 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()
|
||||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal 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"}
|
||||||
113
backend/alembic/versions/001_initial.py
Normal file
113
backend/alembic/versions/001_initial.py
Normal 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')
|
||||||
98
backend/alembic/versions/002_add_timezone_to_datetime.py
Normal file
98
backend/alembic/versions/002_add_timezone_to_datetime.py
Normal 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)
|
||||||
83
backend/alembic/versions/003_add_cascade_delete.py
Normal file
83
backend/alembic/versions/003_add_cascade_delete.py
Normal 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']
|
||||||
|
)
|
||||||
33
backend/alembic/versions/004_add_user_profile_fields.py
Normal file
33
backend/alembic/versions/004_add_user_profile_fields.py
Normal 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
0
backend/app/__init__.py
Normal file
32
backend/app/config.py
Normal file
32
backend/app/config.py
Normal 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
29
backend/app/database.py
Normal 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()
|
||||||
60
backend/app/dependencies.py
Normal file
60
backend/app/dependencies.py
Normal 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
81
backend/app/main.py
Normal 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"}
|
||||||
14
backend/app/models/__init__.py
Normal file
14
backend/app/models/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
58
backend/app/models/contest.py
Normal file
58
backend/app/models/contest.py
Normal 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"),
|
||||||
|
)
|
||||||
31
backend/app/models/problem.py
Normal file
31
backend/app/models/problem.py
Normal 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)
|
||||||
35
backend/app/models/submission.py
Normal file
35
backend/app/models/submission.py
Normal 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")
|
||||||
19
backend/app/models/test_case.py
Normal file
19
backend/app/models/test_case.py
Normal 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")
|
||||||
33
backend/app/models/user.py
Normal file
33
backend/app/models/user.py
Normal 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)
|
||||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
179
backend/app/routers/auth.py
Normal file
179
backend/app/routers/auth.py
Normal 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
|
||||||
205
backend/app/routers/contests.py
Normal file
205
backend/app/routers/contests.py
Normal 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"}
|
||||||
41
backend/app/routers/languages.py
Normal file
41
backend/app/routers/languages.py
Normal 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)}"
|
||||||
|
)
|
||||||
143
backend/app/routers/leaderboard.py
Normal file
143
backend/app/routers/leaderboard.py
Normal 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,
|
||||||
|
)
|
||||||
288
backend/app/routers/problems.py
Normal file
288
backend/app/routers/problems.py
Normal 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()
|
||||||
172
backend/app/routers/submissions.py
Normal file
172
backend/app/routers/submissions.py
Normal 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()
|
||||||
47
backend/app/schemas/__init__.py
Normal file
47
backend/app/schemas/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
52
backend/app/schemas/contest.py
Normal file
52
backend/app/schemas/contest.py
Normal 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
|
||||||
86
backend/app/schemas/problem.py
Normal file
86
backend/app/schemas/problem.py
Normal 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
|
||||||
45
backend/app/schemas/submission.py
Normal file
45
backend/app/schemas/submission.py
Normal 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
|
||||||
43
backend/app/schemas/user.py
Normal file
43
backend/app/schemas/user.py
Normal 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"
|
||||||
17
backend/app/services/__init__.py
Normal file
17
backend/app/services/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
38
backend/app/services/auth.py
Normal file
38
backend/app/services/auth.py
Normal 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
|
||||||
245
backend/app/services/judge.py
Normal file
245
backend/app/services/judge.py
Normal 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()
|
||||||
141
backend/app/services/scoring.py
Normal file
141
backend/app/services/scoring.py
Normal 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
11
backend/pytest.ini
Normal 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
28
backend/requirements.txt
Normal 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
|
||||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
187
backend/tests/conftest.py
Normal file
187
backend/tests/conftest.py
Normal 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
133
backend/tests/test_auth.py
Normal 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
|
||||||
196
backend/tests/test_contests.py
Normal file
196
backend/tests/test_contests.py
Normal 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]
|
||||||
249
backend/tests/test_problems.py
Normal file
249
backend/tests/test_problems.py
Normal 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
|
||||||
85
backend/tests/test_services.py
Normal file
85
backend/tests/test_services.py
Normal 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
74
docker-compose.yml
Normal 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
18
frontend/Dockerfile
Normal 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
7
frontend/next.config.ts
Normal 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
2038
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
frontend/package.json
Normal file
45
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
frontend/postcss.config.mjs
Normal file
7
frontend/postcss.config.mjs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
194
frontend/src/app/admin/contests/[id]/edit/page.tsx
Normal file
194
frontend/src/app/admin/contests/[id]/edit/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
226
frontend/src/app/admin/contests/[id]/problems/new/page.tsx
Normal file
226
frontend/src/app/admin/contests/[id]/problems/new/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
frontend/src/app/admin/contests/[id]/problems/page.tsx
Normal file
161
frontend/src/app/admin/contests/[id]/problems/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
frontend/src/app/admin/contests/new/page.tsx
Normal file
151
frontend/src/app/admin/contests/new/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
258
frontend/src/app/admin/contests/page.tsx
Normal file
258
frontend/src/app/admin/contests/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
frontend/src/app/admin/page.tsx
Normal file
134
frontend/src/app/admin/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
frontend/src/app/admin/problems/page.tsx
Normal file
163
frontend/src/app/admin/problems/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
277
frontend/src/app/admin/users/page.tsx
Normal file
277
frontend/src/app/admin/users/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
272
frontend/src/app/contests/[id]/page.tsx
Normal file
272
frontend/src/app/contests/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
489
frontend/src/app/contests/[id]/problems/[problemId]/page.tsx
Normal file
489
frontend/src/app/contests/[id]/problems/[problemId]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
frontend/src/app/contests/page.tsx
Normal file
145
frontend/src/app/contests/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
189
frontend/src/app/globals.css
Normal file
189
frontend/src/app/globals.css
Normal 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;
|
||||||
|
}
|
||||||
36
frontend/src/app/layout.tsx
Normal file
36
frontend/src/app/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
404
frontend/src/app/leaderboard/[contestId]/page.tsx
Normal file
404
frontend/src/app/leaderboard/[contestId]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
frontend/src/app/login/page.tsx
Normal file
141
frontend/src/app/login/page.tsx
Normal 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
261
frontend/src/app/page.tsx
Normal 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>ВолГУ.Контесты © {new Date().getFullYear()} — Волгоградский государственный университет</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
336
frontend/src/app/profile/page.tsx
Normal file
336
frontend/src/app/profile/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
frontend/src/app/register/page.tsx
Normal file
212
frontend/src/app/register/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
354
frontend/src/app/submissions/page.tsx
Normal file
354
frontend/src/app/submissions/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
frontend/src/components/CodeEditor.tsx
Normal file
97
frontend/src/components/CodeEditor.tsx
Normal 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",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
198
frontend/src/components/Navbar.tsx
Normal file
198
frontend/src/components/Navbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
frontend/src/components/SampleTests.tsx
Normal file
68
frontend/src/components/SampleTests.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
frontend/src/components/SubmissionResult.tsx
Normal file
60
frontend/src/components/SubmissionResult.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
frontend/src/components/VolguLogo.tsx
Normal file
23
frontend/src/components/VolguLogo.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
frontend/src/components/domain/contest-card.tsx
Normal file
137
frontend/src/components/domain/contest-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
frontend/src/components/domain/contest-timer.tsx
Normal file
114
frontend/src/components/domain/contest-timer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
frontend/src/components/domain/language-select.tsx
Normal file
49
frontend/src/components/domain/language-select.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
frontend/src/components/domain/problem-status-badge.tsx
Normal file
81
frontend/src/components/domain/problem-status-badge.tsx
Normal 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";
|
||||||
|
}
|
||||||
146
frontend/src/components/domain/submission-status.tsx
Normal file
146
frontend/src/components/domain/submission-status.tsx
Normal 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";
|
||||||
|
}
|
||||||
111
frontend/src/components/ui/alert.tsx
Normal file
111
frontend/src/components/ui/alert.tsx
Normal 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 };
|
||||||
50
frontend/src/components/ui/badge.tsx
Normal file
50
frontend/src/components/ui/badge.tsx
Normal 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 };
|
||||||
80
frontend/src/components/ui/button.tsx
Normal file
80
frontend/src/components/ui/button.tsx
Normal 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 };
|
||||||
75
frontend/src/components/ui/card.tsx
Normal file
75
frontend/src/components/ui/card.tsx
Normal 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 };
|
||||||
32
frontend/src/components/ui/checkbox.tsx
Normal file
32
frontend/src/components/ui/checkbox.tsx
Normal 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 };
|
||||||
123
frontend/src/components/ui/dialog.tsx
Normal file
123
frontend/src/components/ui/dialog.tsx
Normal 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,
|
||||||
|
};
|
||||||
200
frontend/src/components/ui/dropdown-menu.tsx
Normal file
200
frontend/src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||||
|
};
|
||||||
26
frontend/src/components/ui/index.ts
Normal file
26
frontend/src/components/ui/index.ts
Normal 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";
|
||||||
42
frontend/src/components/ui/input.tsx
Normal file
42
frontend/src/components/ui/input.tsx
Normal 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 };
|
||||||
25
frontend/src/components/ui/label.tsx
Normal file
25
frontend/src/components/ui/label.tsx
Normal 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 };
|
||||||
35
frontend/src/components/ui/progress.tsx
Normal file
35
frontend/src/components/ui/progress.tsx
Normal 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 };
|
||||||
167
frontend/src/components/ui/select.tsx
Normal file
167
frontend/src/components/ui/select.tsx
Normal 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,
|
||||||
|
};
|
||||||
33
frontend/src/components/ui/skeleton.tsx
Normal file
33
frontend/src/components/ui/skeleton.tsx
Normal 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 };
|
||||||
21
frontend/src/components/ui/spinner.tsx
Normal file
21
frontend/src/components/ui/spinner.tsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
frontend/src/components/ui/table.tsx
Normal file
119
frontend/src/components/ui/table.tsx
Normal 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,
|
||||||
|
};
|
||||||
57
frontend/src/components/ui/tabs.tsx
Normal file
57
frontend/src/components/ui/tabs.tsx
Normal 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 };
|
||||||
29
frontend/src/components/ui/textarea.tsx
Normal file
29
frontend/src/components/ui/textarea.tsx
Normal 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 };
|
||||||
29
frontend/src/components/ui/tooltip.tsx
Normal file
29
frontend/src/components/ui/tooltip.tsx
Normal 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
351
frontend/src/lib/api.ts
Normal 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
Loading…
Reference in New Issue
Block a user