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