From 0aed8f54942654eab3d20b51e9868b0a2ad7e425 Mon Sep 17 00:00:00 2001 From: "n.tolstov" Date: Sun, 30 Nov 2025 19:55:50 +0300 Subject: [PATCH] feat: Init commit --- .gitignore | 162 ++ backend/Dockerfile | 24 + backend/alembic.ini | 41 + backend/alembic/env.py | 63 + backend/alembic/script.py.mako | 26 + backend/alembic/versions/001_initial.py | 113 + .../versions/002_add_timezone_to_datetime.py | 98 + .../versions/003_add_cascade_delete.py | 83 + .../versions/004_add_user_profile_fields.py | 33 + backend/app/__init__.py | 0 backend/app/config.py | 32 + backend/app/database.py | 29 + backend/app/dependencies.py | 60 + backend/app/main.py | 81 + backend/app/models/__init__.py | 14 + backend/app/models/contest.py | 58 + backend/app/models/problem.py | 31 + backend/app/models/submission.py | 35 + backend/app/models/test_case.py | 19 + backend/app/models/user.py | 33 + backend/app/routers/__init__.py | 0 backend/app/routers/auth.py | 179 ++ backend/app/routers/contests.py | 205 ++ backend/app/routers/languages.py | 41 + backend/app/routers/leaderboard.py | 143 ++ backend/app/routers/problems.py | 288 +++ backend/app/routers/submissions.py | 172 ++ backend/app/schemas/__init__.py | 47 + backend/app/schemas/contest.py | 52 + backend/app/schemas/problem.py | 86 + backend/app/schemas/submission.py | 45 + backend/app/schemas/user.py | 43 + backend/app/services/__init__.py | 17 + backend/app/services/auth.py | 38 + backend/app/services/judge.py | 245 ++ backend/app/services/scoring.py | 141 ++ backend/pytest.ini | 11 + backend/requirements.txt | 28 + backend/tests/__init__.py | 0 backend/tests/conftest.py | 187 ++ backend/tests/test_auth.py | 133 ++ backend/tests/test_contests.py | 196 ++ backend/tests/test_problems.py | 249 ++ backend/tests/test_services.py | 85 + docker-compose.yml | 74 + frontend/Dockerfile | 18 + frontend/next.config.ts | 7 + frontend/package-lock.json | 2038 +++++++++++++++++ frontend/package.json | 45 + frontend/postcss.config.mjs | 7 + .../src/app/admin/contests/[id]/edit/page.tsx | 194 ++ .../[id]/problems/[problemId]/edit/page.tsx | 258 +++ .../[id]/problems/[problemId]/tests/page.tsx | 305 +++ .../admin/contests/[id]/problems/new/page.tsx | 226 ++ .../app/admin/contests/[id]/problems/page.tsx | 161 ++ frontend/src/app/admin/contests/new/page.tsx | 151 ++ frontend/src/app/admin/contests/page.tsx | 258 +++ frontend/src/app/admin/page.tsx | 134 ++ frontend/src/app/admin/problems/page.tsx | 163 ++ frontend/src/app/admin/users/page.tsx | 277 +++ frontend/src/app/contests/[id]/page.tsx | 272 +++ .../[id]/problems/[problemId]/page.tsx | 489 ++++ frontend/src/app/contests/page.tsx | 145 ++ frontend/src/app/globals.css | 189 ++ frontend/src/app/layout.tsx | 36 + .../src/app/leaderboard/[contestId]/page.tsx | 404 ++++ frontend/src/app/login/page.tsx | 141 ++ frontend/src/app/page.tsx | 261 +++ frontend/src/app/profile/page.tsx | 336 +++ frontend/src/app/register/page.tsx | 212 ++ frontend/src/app/submissions/page.tsx | 354 +++ frontend/src/components/CodeEditor.tsx | 97 + frontend/src/components/Navbar.tsx | 198 ++ frontend/src/components/SampleTests.tsx | 68 + frontend/src/components/SubmissionResult.tsx | 60 + frontend/src/components/VolguLogo.tsx | 23 + .../src/components/domain/contest-card.tsx | 137 ++ .../src/components/domain/contest-timer.tsx | 114 + .../src/components/domain/language-select.tsx | 49 + .../domain/problem-status-badge.tsx | 81 + .../components/domain/submission-status.tsx | 146 ++ frontend/src/components/ui/alert.tsx | 111 + frontend/src/components/ui/badge.tsx | 50 + frontend/src/components/ui/button.tsx | 80 + frontend/src/components/ui/card.tsx | 75 + frontend/src/components/ui/checkbox.tsx | 32 + frontend/src/components/ui/dialog.tsx | 123 + frontend/src/components/ui/dropdown-menu.tsx | 200 ++ frontend/src/components/ui/index.ts | 26 + frontend/src/components/ui/input.tsx | 42 + frontend/src/components/ui/label.tsx | 25 + frontend/src/components/ui/progress.tsx | 35 + frontend/src/components/ui/select.tsx | 167 ++ frontend/src/components/ui/skeleton.tsx | 33 + frontend/src/components/ui/spinner.tsx | 21 + frontend/src/components/ui/table.tsx | 119 + frontend/src/components/ui/tabs.tsx | 57 + frontend/src/components/ui/textarea.tsx | 29 + frontend/src/components/ui/tooltip.tsx | 29 + frontend/src/lib/api.ts | 351 +++ frontend/src/lib/auth-context.tsx | 74 + frontend/src/lib/utils.ts | 67 + frontend/src/types/index.ts | 132 ++ frontend/tsconfig.json | 27 + 104 files changed, 13699 insertions(+) create mode 100644 .gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/001_initial.py create mode 100644 backend/alembic/versions/002_add_timezone_to_datetime.py create mode 100644 backend/alembic/versions/003_add_cascade_delete.py create mode 100644 backend/alembic/versions/004_add_user_profile_fields.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/dependencies.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/contest.py create mode 100644 backend/app/models/problem.py create mode 100644 backend/app/models/submission.py create mode 100644 backend/app/models/test_case.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/routers/contests.py create mode 100644 backend/app/routers/languages.py create mode 100644 backend/app/routers/leaderboard.py create mode 100644 backend/app/routers/problems.py create mode 100644 backend/app/routers/submissions.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/contest.py create mode 100644 backend/app/schemas/problem.py create mode 100644 backend/app/schemas/submission.py create mode 100644 backend/app/schemas/user.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/auth.py create mode 100644 backend/app/services/judge.py create mode 100644 backend/app/services/scoring.py create mode 100644 backend/pytest.ini create mode 100644 backend/requirements.txt create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_auth.py create mode 100644 backend/tests/test_contests.py create mode 100644 backend/tests/test_problems.py create mode 100644 backend/tests/test_services.py create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/next.config.ts create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.mjs create mode 100644 frontend/src/app/admin/contests/[id]/edit/page.tsx create mode 100644 frontend/src/app/admin/contests/[id]/problems/[problemId]/edit/page.tsx create mode 100644 frontend/src/app/admin/contests/[id]/problems/[problemId]/tests/page.tsx create mode 100644 frontend/src/app/admin/contests/[id]/problems/new/page.tsx create mode 100644 frontend/src/app/admin/contests/[id]/problems/page.tsx create mode 100644 frontend/src/app/admin/contests/new/page.tsx create mode 100644 frontend/src/app/admin/contests/page.tsx create mode 100644 frontend/src/app/admin/page.tsx create mode 100644 frontend/src/app/admin/problems/page.tsx create mode 100644 frontend/src/app/admin/users/page.tsx create mode 100644 frontend/src/app/contests/[id]/page.tsx create mode 100644 frontend/src/app/contests/[id]/problems/[problemId]/page.tsx create mode 100644 frontend/src/app/contests/page.tsx create mode 100644 frontend/src/app/globals.css create mode 100644 frontend/src/app/layout.tsx create mode 100644 frontend/src/app/leaderboard/[contestId]/page.tsx create mode 100644 frontend/src/app/login/page.tsx create mode 100644 frontend/src/app/page.tsx create mode 100644 frontend/src/app/profile/page.tsx create mode 100644 frontend/src/app/register/page.tsx create mode 100644 frontend/src/app/submissions/page.tsx create mode 100644 frontend/src/components/CodeEditor.tsx create mode 100644 frontend/src/components/Navbar.tsx create mode 100644 frontend/src/components/SampleTests.tsx create mode 100644 frontend/src/components/SubmissionResult.tsx create mode 100644 frontend/src/components/VolguLogo.tsx create mode 100644 frontend/src/components/domain/contest-card.tsx create mode 100644 frontend/src/components/domain/contest-timer.tsx create mode 100644 frontend/src/components/domain/language-select.tsx create mode 100644 frontend/src/components/domain/problem-status-badge.tsx create mode 100644 frontend/src/components/domain/submission-status.tsx create mode 100644 frontend/src/components/ui/alert.tsx create mode 100644 frontend/src/components/ui/badge.tsx create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/checkbox.tsx create mode 100644 frontend/src/components/ui/dialog.tsx create mode 100644 frontend/src/components/ui/dropdown-menu.tsx create mode 100644 frontend/src/components/ui/index.ts create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/label.tsx create mode 100644 frontend/src/components/ui/progress.tsx create mode 100644 frontend/src/components/ui/select.tsx create mode 100644 frontend/src/components/ui/skeleton.tsx create mode 100644 frontend/src/components/ui/spinner.tsx create mode 100644 frontend/src/components/ui/table.tsx create mode 100644 frontend/src/components/ui/tabs.tsx create mode 100644 frontend/src/components/ui/textarea.tsx create mode 100644 frontend/src/components/ui/tooltip.tsx create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/auth-context.tsx create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..35c0779 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..a7857d2 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..4481cf8 --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..ca5df56 --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/001_initial.py b/backend/alembic/versions/001_initial.py new file mode 100644 index 0000000..a51b537 --- /dev/null +++ b/backend/alembic/versions/001_initial.py @@ -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') diff --git a/backend/alembic/versions/002_add_timezone_to_datetime.py b/backend/alembic/versions/002_add_timezone_to_datetime.py new file mode 100644 index 0000000..62d448e --- /dev/null +++ b/backend/alembic/versions/002_add_timezone_to_datetime.py @@ -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) diff --git a/backend/alembic/versions/003_add_cascade_delete.py b/backend/alembic/versions/003_add_cascade_delete.py new file mode 100644 index 0000000..5a612a0 --- /dev/null +++ b/backend/alembic/versions/003_add_cascade_delete.py @@ -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'] + ) diff --git a/backend/alembic/versions/004_add_user_profile_fields.py b/backend/alembic/versions/004_add_user_profile_fields.py new file mode 100644 index 0000000..9378e79 --- /dev/null +++ b/backend/alembic/versions/004_add_user_profile_fields.py @@ -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') diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..0a351dd --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..237442f --- /dev/null +++ b/backend/app/database.py @@ -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() diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py new file mode 100644 index 0000000..085ea01 --- /dev/null +++ b/backend/app/dependencies.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..e79da08 --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..b083325 --- /dev/null +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/contest.py b/backend/app/models/contest.py new file mode 100644 index 0000000..4977eb4 --- /dev/null +++ b/backend/app/models/contest.py @@ -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"), + ) diff --git a/backend/app/models/problem.py b/backend/app/models/problem.py new file mode 100644 index 0000000..a09762e --- /dev/null +++ b/backend/app/models/problem.py @@ -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) diff --git a/backend/app/models/submission.py b/backend/app/models/submission.py new file mode 100644 index 0000000..b0e1dec --- /dev/null +++ b/backend/app/models/submission.py @@ -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") diff --git a/backend/app/models/test_case.py b/backend/app/models/test_case.py new file mode 100644 index 0000000..6835530 --- /dev/null +++ b/backend/app/models/test_case.py @@ -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") diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..ebf034e --- /dev/null +++ b/backend/app/models/user.py @@ -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) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..cf06038 --- /dev/null +++ b/backend/app/routers/auth.py @@ -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 diff --git a/backend/app/routers/contests.py b/backend/app/routers/contests.py new file mode 100644 index 0000000..d583257 --- /dev/null +++ b/backend/app/routers/contests.py @@ -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"} diff --git a/backend/app/routers/languages.py b/backend/app/routers/languages.py new file mode 100644 index 0000000..9c12ae7 --- /dev/null +++ b/backend/app/routers/languages.py @@ -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)}" + ) diff --git a/backend/app/routers/leaderboard.py b/backend/app/routers/leaderboard.py new file mode 100644 index 0000000..f06e5a2 --- /dev/null +++ b/backend/app/routers/leaderboard.py @@ -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, + ) diff --git a/backend/app/routers/problems.py b/backend/app/routers/problems.py new file mode 100644 index 0000000..bcca656 --- /dev/null +++ b/backend/app/routers/problems.py @@ -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() diff --git a/backend/app/routers/submissions.py b/backend/app/routers/submissions.py new file mode 100644 index 0000000..13de25f --- /dev/null +++ b/backend/app/routers/submissions.py @@ -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() diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..3abab7d --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -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", +] diff --git a/backend/app/schemas/contest.py b/backend/app/schemas/contest.py new file mode 100644 index 0000000..d2451cd --- /dev/null +++ b/backend/app/schemas/contest.py @@ -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 diff --git a/backend/app/schemas/problem.py b/backend/app/schemas/problem.py new file mode 100644 index 0000000..7aa665c --- /dev/null +++ b/backend/app/schemas/problem.py @@ -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 diff --git a/backend/app/schemas/submission.py b/backend/app/schemas/submission.py new file mode 100644 index 0000000..675f02b --- /dev/null +++ b/backend/app/schemas/submission.py @@ -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 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..ddabc62 --- /dev/null +++ b/backend/app/schemas/user.py @@ -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" diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..05e1606 --- /dev/null +++ b/backend/app/services/__init__.py @@ -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", +] diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 0000000..bc0a184 --- /dev/null +++ b/backend/app/services/auth.py @@ -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 diff --git a/backend/app/services/judge.py b/backend/app/services/judge.py new file mode 100644 index 0000000..5f73e6a --- /dev/null +++ b/backend/app/services/judge.py @@ -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() diff --git a/backend/app/services/scoring.py b/backend/app/services/scoring.py new file mode 100644 index 0000000..4f18898 --- /dev/null +++ b/backend/app/services/scoring.py @@ -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, + } diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..0753201 --- /dev/null +++ b/backend/pytest.ini @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..8f25e49 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..bfa146a --- /dev/null +++ b/backend/tests/conftest.py @@ -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 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..bc9c78f --- /dev/null +++ b/backend/tests/test_auth.py @@ -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 diff --git a/backend/tests/test_contests.py b/backend/tests/test_contests.py new file mode 100644 index 0000000..bf42e26 --- /dev/null +++ b/backend/tests/test_contests.py @@ -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] diff --git a/backend/tests/test_problems.py b/backend/tests/test_problems.py new file mode 100644 index 0000000..994183e --- /dev/null +++ b/backend/tests/test_problems.py @@ -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 diff --git a/backend/tests/test_services.py b/backend/tests/test_services.py new file mode 100644 index 0000000..e8eb3d3 --- /dev/null +++ b/backend/tests/test_services.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7a14ba3 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..47e9dc8 --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000..68a6c64 --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", +}; + +export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..1ba832e --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2038 @@ +{ + "name": "sport-programming-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sport-programming-frontend", + "version": "1.0.0", + "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" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@next/env": { + "version": "15.5.6", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.6.tgz", + "integrity": "sha512-ES3nRz7N+L5Umz4KoGfZ4XX6gwHplwPhioVRc25+QNsDa7RtUF/z8wJcbuQ2Tffm5RZwuN2A063eapoJ1u4nPg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.6.tgz", + "integrity": "sha512-JIGcytAyk9LQp2/nuVZPAtj8uaJ/zZhsKOASTjxDug0SPU9LAM3wy6nPU735M1OqacR4U20LHVF5v5Wnl9ptTA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.6.tgz", + "integrity": "sha512-qvz4SVKQ0P3/Im9zcS2RmfFL/UCQnsJKJwQSkissbngnB/12c6bZTCB0gHTexz1s6d/mD0+egPKXAIRFVS7hQg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.6.tgz", + "integrity": "sha512-FsbGVw3SJz1hZlvnWD+T6GFgV9/NYDeLTNQB2MXoPN5u9VA9OEDy6fJEfePfsUKAhJufFbZLgp0cPxMuV6SV0w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.6.tgz", + "integrity": "sha512-3QnHGFWlnvAgyxFxt2Ny8PTpXtQD7kVEeaFat5oPAHHI192WKYB+VIKZijtHLGdBBvc16tiAkPTDmQNOQ0dyrA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.6", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.6.tgz", + "integrity": "sha512-ONOMrqWxdzXDJNh2n60H6gGyKed42Ieu6UTVPZteXpuKbLZTH4G4eBMsr5qWgOBA+s7F+uB4OJbZnrkEDnZ5Fg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.6.tgz", + "integrity": "sha512-pxK4VIjFRx1MY92UycLOOw7dTdvccWsNETQ0kDHkBlcFH1GrTLUjSiHU1ohrznnux6TqRHgv5oflhfIWZwVROQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.8.tgz", + "integrity": "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.17", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.17", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-x64": "4.1.17", + "@tailwindcss/oxide-freebsd-x64": "4.1.17", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-x64-musl": "4.1.17", + "@tailwindcss/oxide-wasm32-wasi": "4.1.17", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.17", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.17", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.17", + "@tailwindcss/oxide": "4.1.17", + "postcss": "^8.4.41", + "tailwindcss": "4.1.17" + } + }, + "node_modules/@types/canvas-confetti": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz", + "integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.1", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.7", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001757", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvas-confetti": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz", + "integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "devOptional": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/dompurify": { + "version": "3.2.7", + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/framer-motion": { + "version": "12.23.24", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz", + "integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.23", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "0.470.0", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "14.0.0", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "license": "MIT", + "peer": true, + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, + "node_modules/motion-dom": { + "version": "12.23.23", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", + "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "15.5.6", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.6", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.6", + "@next/swc-darwin-x64": "15.5.6", + "@next/swc-linux-arm64-gnu": "15.5.6", + "@next/swc-linux-arm64-musl": "15.5.6", + "@next/swc-linux-x64-gnu": "15.5.6", + "@next/swc-linux-x64-musl": "15.5.6", + "@next/swc-win32-arm64-msvc": "15.5.6", + "@next/swc-win32-x64-msvc": "15.5.6", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.0", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-resizable-panels": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz", + "integrity": "sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew==", + "license": "MIT", + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/state-local": { + "version": "1.0.7", + "license": "MIT" + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tailwind-merge": { + "version": "3.4.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.17", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..755b002 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/frontend/src/app/admin/contests/[id]/edit/page.tsx b/frontend/src/app/admin/contests/[id]/edit/page.tsx new file mode 100644 index 0000000..8ca321e --- /dev/null +++ b/frontend/src/app/admin/contests/[id]/edit/page.tsx @@ -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 ( +
+
+
+
+
+
+
+
+ ); + } + + return ( +
+

Редактировать контест

+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + + 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" + /> +
+ +
+ +