diff --git a/.env.example b/.env.example index c5916a6..0e47379 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,35 @@ -# Copy to .env and fill in values +# VerseLab Environment Variables Template +# Copy this file to .env and fill in the values for production deployment. -# Database credentials (used by both postgres container and backend) +# --- API Versioning --- +# The prefix for all API endpoints. Must match on both frontend and backend. +# Default is /api/v1. Changing this will update both services. +API_V1_STR=/api/v1 + +# --- Database --- +# Credentials used by both the PostgreSQL container and the Backend service. DB_USER=postgres DB_PASSWORD=change-me-strong-password -# JWT secret (generate with: python -c "import secrets; print(secrets.token_urlsafe(64))") +# --- Security --- +# JWT signing key. CRITICAL: Generate a unique random string for production! +# Command: python3 -c "import secrets; print(secrets.token_urlsafe(64))" SECRET_KEY=change-me-generate-random-string + +# --- CORS (Cross-Origin Resource Sharing) --- +# List of allowed origins for the API. +# In production, set this to your domain (e.g., https://verselab.io). +# You can use a comma-separated list or a JSON array. +CORS_ORIGINS=https://your-domain.com + +# --- Rate Limiting --- +# General limit for all endpoints per IP. +RATE_LIMIT_DEFAULT=60/minute + +# Stricter limit for authentication endpoints (login/register). +RATE_LIMIT_AUTH=10/minute + +# --- Frontend specific (passed during build) --- +# For Docker/Nginx, these usually use the same API_V1_STR prefix. +VITE_API_URL=${API_V1_STR} +VITE_WS_URL= diff --git a/.gitignore b/.gitignore index bb90e65..3768a20 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ Thumbs.db .agent CLAUDE.md GEMINI.md +.playwright-mcp \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..384026d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,77 @@ +> [!NOTE] +> Please prefer English language for all communication. + +# Contributing to VerseLab + +Thank you for considering contributing to VerseLab! Before opening an issue, please check that a similar one hasn't already been reported. + +## How to Contribute + +1. **Fork** the repository +2. **Clone** your fork: `git clone https://github.com/your-username/VerseLab.git && cd VerseLab` +3. **Create** a feature branch: `git checkout -b feat/my-feature` +4. **Install dependencies:** + + ```bash + # Backend + cd backend && uv sync + + # Frontend + cd frontend && npm install + ``` + +5. **Make your changes** and test them locally + +6. **Format and lint** your code to ensure it follows the project style: + ```bash + # Frontend + cd frontend && npm run format && npm run lint + ``` + +7. **Commit** your changes using the conventional commit format (see below). + +8. **Submit a pull request** with a clear description of what you changed and why + +## Commit Messages + +We follow [Conventional Commits](https://www.conventionalcommits.org/): + +``` +[optional scope]: +``` + +Allowed types: + +| Type | Description | +| ---------- | ------------------------------------ | +| `feat` | New feature | +| `fix` | Bug fix | +| `refactor` | Code change (no new feature or fix) | +| `perf` | Performance improvement | +| `docs` | Documentation only | +| `style` | Formatting, missing semicolons, etc. | +| `test` | Adding or correcting tests | +| `ci` | CI/CD changes | +| `chore` | Repository maintenance | +| `revert` | Revert a previous commit | + +## Development + +```bash +# Run backend +cd backend && uv run uvicorn app.main:app --reload + +# Run frontend +cd frontend && npm run dev + +# Run tests +cd backend && uv run pytest +``` + +## Guidelines + +- Keep pull requests focused — one feature or fix per PR +- Add tests for new backend functionality when possible +- Ensure `tsc --noEmit` passes before submitting frontend changes + +If you have questions, feel free to open an issue or start a discussion. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b66a10b --- /dev/null +++ b/LICENSE @@ -0,0 +1,32 @@ +Creative Commons Attribution-NonCommercial 4.0 International License + +Copyright (c) 2026 mvoof + +This work is licensed under the Creative Commons Attribution-NonCommercial 4.0 +International License. To view a copy of this license, visit +https://creativecommons.org/licenses/by-nc/4.0/ or send a letter to Creative +Commons, PO Box 1866, Mountain View, CA 94042, USA. + +You are free to: + + Share — copy and redistribute the material in any medium or format + Adapt — remix, transform, and build upon the material + +Under the following terms: + + Attribution — You must give appropriate credit, provide a link to the license, + and indicate if changes were made. You may do so in any reasonable manner, but + not in any way that suggests the licensor endorses you or your use. + + NonCommercial — You may not use the material for commercial purposes. + + No additional restrictions — You may not apply legal terms or technological + measures that legally restrict others from doing anything the license permits. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 0c9fd07..7aec8c9 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ cd frontend && npm install && npm run dev ### Production (VPS / remote server) +The production setup uses a "Single Source of Truth" architecture. All configuration for Database, Backend, and Frontend is managed through a single `.env` file in the project root. + ```bash # 1. Clone the repo git clone && cd VerseLab @@ -45,32 +47,23 @@ cp .env.example .env nano .env ``` -**.env contents:** - -```env -DB_PASSWORD=your-strong-database-password -SECRET_KEY=generate-with-python-see-below -``` - -Generate a secret key: +**Key Production Variables in root `.env`:** -```bash -python3 -c "import secrets; print(secrets.token_urlsafe(64))" -``` +| Variable | Description | +|----------|-------------| +| `API_V1_STR` | **API Version Contract.** Defines the prefix for all routes (e.g., `/api/v1`). Synced across both services. | +| `DB_PASSWORD` | Strong password for PostgreSQL. | +| `SECRET_KEY` | JWT signing key (generate with: `python3 -c "import secrets; print(secrets.token_urlsafe(64))"`) | +| `CORS_ORIGINS` | Set to your domain (e.g., `https://verselab.io`) to restrict API access. | +| `VITE_API_URL` | Set to `${API_V1_STR}` to ensure the frontend matches the backend version. | ```bash -# 4. Build and start all services (first time) +# 4. Build and start all services +# Note: Changing VITE_* variables requires a rebuild: docker compose up -d --build - -# Restart after code changes (rebuild without recreating network) -docker compose build && docker compose up -d - -# Stop / start without destroying network -docker compose stop -docker compose start ``` -The app will be available at `http://` (port 80). +The app will be available at `http://` (port 80). Nginx handles the routing and security headers. ### Accessing from other machines diff --git a/backend/.env.example b/backend/.env.example index 873d0a6..7b82c95 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,3 +1,24 @@ -# Copy to .env and fill in your values -DATABASE_URL=postgresql+asyncpg://postgres:YOUR_PASSWORD@localhost:5432/verselab_db -SECRET_KEY=generate-a-strong-random-string-here +# VerseLab Backend Environment Variables Template +# Copy this file to .env and fill in the values. + +# --- Database --- +# Replace with your actual database credentials. +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/verselab_db + +# --- Security --- +# JWT signing key. CRITICAL: Generate a unique random string for production! +# Command: python3 -c "import secrets; print(secrets.token_urlsafe(64))" +SECRET_KEY=change-me-generate-random-string + +# --- CORS (Cross-Origin Resource Sharing) --- +# List of allowed origins for the API. +# You can use a comma-separated list or a JSON array. +# If empty, the backend defaults to allowing localhost (dev mode). +# CORS_ORIGINS=https://your-domain.com + +# --- Rate Limiting --- +# General limit for all endpoints per IP. +RATE_LIMIT_DEFAULT=60/minute + +# Stricter limit for authentication endpoints (login/register). +RATE_LIMIT_AUTH=10/minute diff --git a/backend/app/config.py b/backend/app/config.py index fc25d2b..0ca529b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,3 +1,4 @@ +import json import re import secrets @@ -12,6 +13,9 @@ class Settings(BaseSettings): extra="ignore", ) + # API Versioning + API_V1_STR: str = "/api/v1" + # Database DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/verselab_db" @@ -21,13 +25,22 @@ class Settings(BaseSettings): ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 REFRESH_TOKEN_EXPIRE_DAYS: int = 30 - # CORS - CORS_ORIGINS: list[str] = [ - "http://localhost:3000", - "http://localhost:5173", - "http://127.0.0.1:3000", - "http://127.0.0.1:5173", - ] + # CORS — in dev, allow any localhost port via regex (Vite picks a free port). + # In prod, set CORS_ORIGINS to explicit origins (comma-separated or JSON array). + CORS_ORIGINS: list[str] = [] + CORS_ORIGIN_REGEX: str = r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$" + + @field_validator("CORS_ORIGINS", mode="before") + @classmethod + def parse_cors_origins(cls, v: str | list[str]) -> list[str]: + if isinstance(v, list): + return v + if not v or not v.strip(): + return [] + v = v.strip() + if v.startswith("["): + return json.loads(v) + return [origin.strip() for origin in v.split(",") if origin.strip()] # Rate limiting RATE_LIMIT_DEFAULT: str = "60/minute" diff --git a/backend/app/exceptions.py b/backend/app/exceptions.py index e2d8a52..90e3796 100644 --- a/backend/app/exceptions.py +++ b/backend/app/exceptions.py @@ -10,26 +10,6 @@ def __init__(self, message: str, status_code: int = 400, details: dict | None = super().__init__(message) -class NotFoundError(AppError): - def __init__(self, resource: str = "Resource"): - super().__init__(f"{resource} not found", status_code=404) - - -class ForbiddenError(AppError): - def __init__(self, message: str = "Access denied"): - super().__init__(message, status_code=403) - - -class ConflictError(AppError): - def __init__(self, message: str = "Conflict"): - super().__init__(message, status_code=409) - - -class ValidationError(AppError): - def __init__(self, message: str = "Validation error"): - super().__init__(message, status_code=422) - - async def app_error_handler(request: Request, exc: AppError) -> JSONResponse: return JSONResponse( status_code=exc.status_code, diff --git a/backend/app/limiter.py b/backend/app/limiter.py new file mode 100644 index 0000000..0e12b3d --- /dev/null +++ b/backend/app/limiter.py @@ -0,0 +1,9 @@ +from slowapi import Limiter +from slowapi.util import get_remote_address + +from .config import settings + +# Global limiter instance +limiter = Limiter( + key_func=get_remote_address, default_limits=[settings.RATE_LIMIT_DEFAULT] +) diff --git a/backend/app/main.py b/backend/app/main.py index 0b8713c..52870de 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,6 +11,7 @@ from .database import engine, Base from .exceptions import AppError, app_error_handler from .logging_config import setup_logging, get_logger +from .limiter import limiter from prometheus_fastapi_instrumentator import Instrumentator from .routers import auth, projects, tasks, users, annotations, comments, audit, stats, notifications, invitations, glossary, ws @@ -18,14 +19,13 @@ setup_logging() logger = get_logger("verselab") -limiter = Limiter(key_func=get_remote_address, default_limits=[settings.RATE_LIMIT_DEFAULT]) - @asynccontextmanager async def lifespan(app: FastAPI): logger.info("starting_up") async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + yield logger.info("shutting_down") @@ -33,8 +33,9 @@ async def lifespan(app: FastAPI): app = FastAPI( title="VerseLab API", description="Backend for VerseLab", - version="0.3.0", + version="0.1.0", lifespan=lifespan, + redirect_slashes=False, ) app.state.limiter = limiter @@ -43,37 +44,27 @@ async def lifespan(app: FastAPI): app.add_middleware( CORSMiddleware, - allow_origins=settings.CORS_ORIGINS, + allow_origins=settings.CORS_ORIGINS or [], + allow_origin_regex=settings.CORS_ORIGIN_REGEX if not settings.CORS_ORIGINS else None, allow_credentials=True, allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], allow_headers=["Authorization", "Content-Type"], ) # --- API v1 routes --- -app.include_router(auth.router, prefix="/api/v1/auth", tags=["Auth"]) -app.include_router(users.router, prefix="/api/v1/users", tags=["Users"]) -app.include_router(projects.router, prefix="/api/v1/projects", tags=["Projects"]) -app.include_router(tasks.router, prefix="/api/v1/tasks", tags=["Tasks"]) -app.include_router(annotations.router, prefix="/api/v1", tags=["Annotations"]) -app.include_router(comments.router, prefix="/api/v1", tags=["Comments"]) -app.include_router(audit.router, prefix="/api/v1", tags=["Audit"]) -app.include_router(stats.router, prefix="/api/v1", tags=["Stats"]) -app.include_router(notifications.router, prefix="/api/v1", tags=["Notifications"]) -app.include_router(invitations.router, prefix="/api/v1", tags=["Invitations"]) -app.include_router(glossary.router, prefix="/api/v1", tags=["Glossary"]) - -# --- Legacy route compatibility (will be removed in future) --- -app.include_router(auth.router, prefix="/auth", tags=["Auth (legacy)"], include_in_schema=False) -app.include_router(users.router, prefix="/users", tags=["Users (legacy)"], include_in_schema=False) -app.include_router(projects.router, prefix="/projects", tags=["Projects (legacy)"], include_in_schema=False) -app.include_router(tasks.router, prefix="/tasks", tags=["Tasks (legacy)"], include_in_schema=False) -app.include_router(annotations.router, prefix="/api", tags=["Annotations (legacy)"], include_in_schema=False) -app.include_router(comments.router, prefix="/api", tags=["Comments (legacy)"], include_in_schema=False) -app.include_router(audit.router, prefix="/api", tags=["Audit (legacy)"], include_in_schema=False) -app.include_router(stats.router, prefix="/api", tags=["Stats (legacy)"], include_in_schema=False) -app.include_router(notifications.router, prefix="/api", tags=["Notifications (legacy)"], include_in_schema=False) -app.include_router(invitations.router, prefix="/api", tags=["Invitations (legacy)"], include_in_schema=False) -app.include_router(glossary.router, prefix="/api", tags=["Glossary (legacy)"], include_in_schema=False) +app.include_router(auth.router, prefix=f"{settings.API_V1_STR}/auth", tags=["Auth"]) +app.include_router(users.router, prefix=f"{settings.API_V1_STR}/users", tags=["Users"]) +app.include_router(projects.router, prefix=f"{settings.API_V1_STR}/projects", tags=["Projects"]) +app.include_router(tasks.router, prefix=f"{settings.API_V1_STR}/tasks", tags=["Tasks"]) +app.include_router(annotations.router, prefix=settings.API_V1_STR, tags=["Annotations"]) +app.include_router(comments.router, prefix=settings.API_V1_STR, tags=["Comments"]) +app.include_router(audit.router, prefix=settings.API_V1_STR, tags=["Audit"]) +app.include_router(stats.router, prefix=settings.API_V1_STR, tags=["Stats"]) +app.include_router(notifications.router, prefix=settings.API_V1_STR, tags=["Notifications"]) +app.include_router(invitations.router, prefix=settings.API_V1_STR, tags=["Invitations"]) +app.include_router(glossary.router, prefix=settings.API_V1_STR, tags=["Glossary"]) + + # WebSocket (no prefix versioning) diff --git a/backend/app/middleware/audit.py b/backend/app/middleware/audit.py index c678f6a..2f29e01 100644 --- a/backend/app/middleware/audit.py +++ b/backend/app/middleware/audit.py @@ -1,7 +1,19 @@ -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from ..models import AuditLog from ..database import AsyncSessionLocal +# Overridable session factory for testing +_session_factory: async_sessionmaker | None = None + + +def set_audit_session_factory(factory: async_sessionmaker | None): + global _session_factory + _session_factory = factory + + +def get_audit_session_factory() -> async_sessionmaker: + return _session_factory or AsyncSessionLocal + async def log_audit( user_id: int | None, @@ -11,7 +23,8 @@ async def log_audit( details: dict | None = None, ): """Log an audit event. Designed to be called via BackgroundTasks.""" - async with AsyncSessionLocal() as db: + factory = get_audit_session_factory() + async with factory() as db: log = AuditLog( user_id=user_id, action=action, diff --git a/backend/app/models.py b/backend/app/models.py index 9b1e716..31c808b 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,15 +1,20 @@ from enum import Enum from datetime import datetime -from sqlalchemy import String, ForeignKey, DateTime, func, Boolean, Integer, UniqueConstraint, CheckConstraint, Text +from sqlalchemy import String, ForeignKey, DateTime, func, Boolean, Integer, UniqueConstraint, CheckConstraint, Text, LargeBinary from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.dialects.postgresql import JSONB from .database import Base # --- ENUMS --- +class GlobalRole(str, Enum): + ADMIN = "ADMIN" + USER = "USER" + + class RoleProject(str, Enum): MANAGER = "MANAGER" - MEMBER = "MEMBER" + EDITOR = "EDITOR" class ProjectType(str, Enum): @@ -24,6 +29,12 @@ class AnnotationStatus(str, Enum): REJECTED = "REJECTED" +class ImportStatus(str, Enum): + PENDING = "PENDING" + APPROVED = "APPROVED" + REJECTED = "REJECTED" + + # --- TABLES --- @@ -35,7 +46,7 @@ class User(Base): full_name: Mapped[str] = mapped_column(String, nullable=True) password_hash: Mapped[str] = mapped_column(String) is_active: Mapped[bool] = mapped_column(Boolean, default=True) - is_admin: Mapped[bool] = mapped_column(Boolean, default=False) + role: Mapped[GlobalRole] = mapped_column(String, default=GlobalRole.USER) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) # Relationships @@ -208,3 +219,29 @@ class GlossaryTerm(Base): created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) project = relationship("Project") + + +class PendingImport(Base): + __tablename__ = "pending_imports" + + id: Mapped[int] = mapped_column(primary_key=True) + project_id: Mapped[int] = mapped_column( + ForeignKey("projects.id", ondelete="CASCADE") + ) + uploaded_by: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE") + ) + filename: Mapped[str] = mapped_column(String) + file_data: Mapped[bytes] = mapped_column(LargeBinary) + replace_existing: Mapped[bool] = mapped_column(Boolean, default=False) + status: Mapped[ImportStatus] = mapped_column(String, default=ImportStatus.PENDING) + reviewed_by: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + review_note: Mapped[str | None] = mapped_column(Text, nullable=True) + task_count: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + project = relationship("Project") + uploader = relationship("User", foreign_keys=[uploaded_by]) + reviewer = relationship("User", foreign_keys=[reviewed_by]) diff --git a/backend/app/permissions.py b/backend/app/permissions.py index a85ad78..7f9cd23 100644 --- a/backend/app/permissions.py +++ b/backend/app/permissions.py @@ -1,84 +1,126 @@ +from enum import Enum from fastapi import Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select -from typing import List, Optional from .database import get_db -from .models import User, ProjectMember, RoleProject, Project +from .models import User, ProjectMember, RoleProject, Project, Task, Annotation, GlobalRole from .security import get_current_user -# Role hierarchy: MANAGER > MEMBER -PROJECT_ROLE_HIERARCHY = { - RoleProject.MANAGER: 2, - RoleProject.MEMBER: 1, + +class ProjectPermission(str, Enum): + VIEW = "VIEW" + ANNOTATE = "ANNOTATE" + REVIEW = "REVIEW" + MANAGE = "MANAGE" + + +PROJECT_ROLE_PERMISSIONS: dict[RoleProject, set[ProjectPermission]] = { + RoleProject.MANAGER: { + ProjectPermission.VIEW, + ProjectPermission.ANNOTATE, + ProjectPermission.REVIEW, + ProjectPermission.MANAGE, + }, + RoleProject.EDITOR: { + ProjectPermission.VIEW, + ProjectPermission.ANNOTATE, + }, } -def has_project_role_or_higher(member_role: RoleProject, required_role: RoleProject) -> bool: - return PROJECT_ROLE_HIERARCHY.get(member_role, 0) >= PROJECT_ROLE_HIERARCHY.get(required_role, 0) +async def _check_project_membership( + user: User, + project_id: int, + permission: ProjectPermission, + db: AsyncSession, +) -> None: + """Core check: ADMIN bypasses, otherwise verify membership + permission.""" + if user.role == GlobalRole.ADMIN: + return + + result = await db.execute( + select(ProjectMember).where( + ProjectMember.user_id == user.id, + ProjectMember.project_id == project_id, + ) + ) + member = result.scalar_one_or_none() + if not member: + raise HTTPException(status_code=403, detail="Not a member of this project") + + allowed = PROJECT_ROLE_PERMISSIONS.get(member.role, set()) + if permission not in allowed: + raise HTTPException(status_code=403, detail="Insufficient project permissions") + +class RequireAdmin: + """Platform-level operations: users, invitations, audit, project creation/deletion.""" -class RoleChecker: - def __init__( + async def __call__( self, - allowed_project_roles: List[RoleProject] | None = None, - ): - self.allowed_project_roles = allowed_project_roles or [] + user: User = Depends(get_current_user), + ) -> User: + if user.role != GlobalRole.ADMIN: + raise HTTPException(status_code=403, detail="Admin access required") + return user + + +class RequireProjectMember: + """Project endpoints with project_id in path.""" + + def __init__(self, permission: ProjectPermission): + self.permission = permission async def __call__( self, - project_id: Optional[int] = None, + project_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> User: - # Admin bypasses all permission checks - if user.is_admin: - return user - - if project_id: - project_res = await db.execute( - select(Project).where(Project.id == project_id) - ) - project = project_res.scalar_one_or_none() - if not project: - raise HTTPException(status_code=404, detail="Project not found") - - proj_member_res = await db.execute( - select(ProjectMember).where( - ProjectMember.user_id == user.id, - ProjectMember.project_id == project_id, - ) - ) - proj_member = proj_member_res.scalar_one_or_none() - - if not proj_member: - raise HTTPException( - status_code=403, detail="Not a member of this project" - ) - - if self.allowed_project_roles: - # Check if user's role is in allowed roles or higher - has_permission = any( - has_project_role_or_higher(proj_member.role, required) - for required in self.allowed_project_roles - ) - if not has_permission: - raise HTTPException( - status_code=403, detail="Insufficient project permissions" - ) + project = await db.get(Project, project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + await _check_project_membership(user, project_id, self.permission, db) + return user + +class RequireProjectMemberViaTask: + """Endpoints with task_id — resolve project_id via task.""" + + def __init__(self, permission: ProjectPermission): + self.permission = permission + + async def __call__( + self, + task_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + ) -> User: + task = await db.get(Task, task_id) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + await _check_project_membership(user, task.project_id, self.permission, db) return user -class AdminRequired: - """Dependency that requires the user to be an admin.""" +class RequireProjectMemberViaAnnotation: + """Endpoints with annotation_id — resolve via annotation → task → project.""" + + def __init__(self, permission: ProjectPermission): + self.permission = permission async def __call__( self, + annotation_id: int, user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), ) -> User: - if not user.is_admin: - raise HTTPException( - status_code=403, detail="Admin access required" - ) + annotation = await db.get(Annotation, annotation_id) + if not annotation: + raise HTTPException(status_code=404, detail="Annotation not found") + task = await db.get(Task, annotation.task_id) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + await _check_project_membership(user, task.project_id, self.permission, db) return user diff --git a/backend/app/routers/annotations.py b/backend/app/routers/annotations.py index 3b32858..6f331ba 100644 --- a/backend/app/routers/annotations.py +++ b/backend/app/routers/annotations.py @@ -5,9 +5,14 @@ from typing import Optional from .. import schemas, models, database, security -from ..permissions import RoleChecker +from ..permissions import ( + RequireProjectMember, + RequireProjectMemberViaTask, + RequireProjectMemberViaAnnotation, + ProjectPermission, +) from ..security import get_current_user -from ..models import RoleProject, AnnotationStatus +from ..models import RoleProject, AnnotationStatus, GlobalRole router = APIRouter() @@ -19,7 +24,7 @@ async def list_project_annotations( page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), db: AsyncSession = Depends(database.get_db), - user: models.User = Depends(security.get_current_user), + user: models.User = Depends(RequireProjectMember(ProjectPermission.VIEW)), ): project = await db.get(models.Project, project_id) if not project: @@ -84,7 +89,7 @@ async def list_project_annotations( async def list_task_annotations( task_id: int, db: AsyncSession = Depends(database.get_db), - user: models.User = Depends(security.get_current_user), + user: models.User = Depends(RequireProjectMemberViaTask(ProjectPermission.VIEW)), ): """Get all annotations for a specific task, with vote counts.""" task = await db.get(models.Task, task_id) @@ -140,14 +145,13 @@ async def translation_memory( query: str = Query("", min_length=1), limit: int = Query(5, ge=1, le=20), db: AsyncSession = Depends(database.get_db), - user: models.User = Depends(security.get_current_user), + user: models.User = Depends(RequireProjectMember(ProjectPermission.VIEW)), ): """Fuzzy search approved translations for translation memory suggestions.""" project = await db.get(models.Project, project_id) if not project: raise HTTPException(404, "Project not found") - # Find approved annotations in this project where the source text is similar stmt = ( select(models.Task, models.Annotation) .join(models.Annotation, models.Task.id == models.Annotation.task_id) @@ -156,7 +160,7 @@ async def translation_memory( models.Annotation.status == AnnotationStatus.APPROVED, ) .order_by(models.Annotation.updated_at.desc()) - .limit(200) # search window + .limit(200) ) result = await db.execute(stmt) rows = result.all() @@ -168,11 +172,9 @@ async def translation_memory( if not source: continue source_lower = source.lower() - # Simple similarity: substring match ratio if q_lower in source_lower or source_lower in q_lower: score = len(q_lower) / max(len(source_lower), 1) else: - # Word overlap q_words = set(q_lower.split()) s_words = set(source_lower.split()) overlap = len(q_words & s_words) @@ -190,7 +192,7 @@ async def create_annotation( task_id: int, payload: schemas.AnnotationSubmit, db: AsyncSession = Depends(database.get_db), - user: models.User = Depends(security.get_current_user), + user: models.User = Depends(RequireProjectMemberViaTask(ProjectPermission.ANNOTATE)), ): task = await db.get(models.Task, task_id) if not task: @@ -212,7 +214,7 @@ async def create_annotation( async def submit_annotation( annotation_id: int, db: AsyncSession = Depends(database.get_db), - user: models.User = Depends(security.get_current_user), + user: models.User = Depends(RequireProjectMemberViaAnnotation(ProjectPermission.ANNOTATE)), ): annotation = await db.get(models.Annotation, annotation_id) if not annotation: @@ -232,8 +234,8 @@ async def submit_annotation( VALID_TRANSITIONS = { AnnotationStatus.DRAFT: {AnnotationStatus.SUBMITTED}, AnnotationStatus.SUBMITTED: {AnnotationStatus.APPROVED, AnnotationStatus.REJECTED}, - AnnotationStatus.REJECTED: {AnnotationStatus.DRAFT}, # Can re-draft after rejection - AnnotationStatus.APPROVED: set(), # Terminal state + AnnotationStatus.REJECTED: {AnnotationStatus.DRAFT}, + AnnotationStatus.APPROVED: set(), } @@ -242,7 +244,7 @@ async def review_annotation( annotation_id: int, review: schemas.AnnotationReview, db: AsyncSession = Depends(database.get_db), - user: models.User = Depends(get_current_user), + user: models.User = Depends(RequireProjectMemberViaAnnotation(ProjectPermission.REVIEW)), ): if review.status not in (AnnotationStatus.APPROVED, AnnotationStatus.REJECTED): raise HTTPException(400, "Review status must be APPROVED or REJECTED") @@ -251,19 +253,6 @@ async def review_annotation( if not annotation: raise HTTPException(404, "Annotation not found") - # Check MANAGER permission via annotation's task's project - if not user.is_admin: - task = await db.get(models.Task, annotation.task_id) - member_res = await db.execute( - select(models.ProjectMember).where( - models.ProjectMember.user_id == user.id, - models.ProjectMember.project_id == task.project_id, - ) - ) - member = member_res.scalar_one_or_none() - if not member or member.role != RoleProject.MANAGER: - raise HTTPException(403, "Requires MANAGER role") - allowed = VALID_TRANSITIONS.get(annotation.status, set()) if review.status not in allowed: raise HTTPException( @@ -301,7 +290,7 @@ async def vote_annotation( annotation_id: int, vote_data: schemas.VoteCreate, db: AsyncSession = Depends(database.get_db), - user: models.User = Depends(security.get_current_user), + user: models.User = Depends(RequireProjectMemberViaAnnotation(ProjectPermission.ANNOTATE)), ): if vote_data.value not in (1, -1): raise HTTPException(400, "Vote value must be +1 or -1") diff --git a/backend/app/routers/audit.py b/backend/app/routers/audit.py index 941d284..4256be6 100644 --- a/backend/app/routers/audit.py +++ b/backend/app/routers/audit.py @@ -4,14 +4,14 @@ from typing import Optional from .. import schemas, models, database -from ..permissions import AdminRequired +from ..permissions import RequireAdmin router = APIRouter() @router.get("/audit-log", response_model=schemas.AuditLogListResponse) async def list_audit_logs( - user: models.User = Depends(AdminRequired()), + user: models.User = Depends(RequireAdmin()), action: Optional[str] = Query(None), resource_type: Optional[str] = Query(None), user_id: Optional[int] = Query(None), diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index db440fb..5b4d3ac 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, Request, status, BackgroundTasks, Query from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func @@ -8,6 +8,8 @@ from .. import schemas, models, security, database from ..config import settings, validate_password +from ..middleware.audit import log_audit +from ..limiter import limiter router = APIRouter() @@ -20,9 +22,34 @@ async def setup_status(db: AsyncSession = Depends(database.get_db)): return {"is_setup": count > 0} +@router.get("/verify-invitation", response_model=schemas.InvitationVerify) +async def verify_invitation( + token: str = Query(...), + db: AsyncSession = Depends(database.get_db), +): + """Verify if an invitation token is valid and return associated email.""" + result = await db.execute( + select(models.Invitation).where(models.Invitation.token == token) + ) + invitation = result.scalar_one_or_none() + + if not invitation: + raise HTTPException(400, "Invalid invitation token") + if invitation.used_by is not None: + raise HTTPException(400, "Invitation already used") + if invitation.expires_at.replace(tzinfo=timezone.utc) < datetime.now(timezone.utc): + raise HTTPException(400, "Invitation expired") + + return {"email": invitation.email, "is_valid": True} + + @router.post("/setup", response_model=schemas.TokenPairResponse) +@limiter.limit(settings.RATE_LIMIT_AUTH) async def setup( - data: schemas.SetupCreate, db: AsyncSession = Depends(database.get_db) + request: Request, + data: schemas.SetupCreate, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(database.get_db), ): """First-time setup: creates the admin user. Only works when no users exist yet.""" result = await db.execute(select(func.count(models.User.id))) @@ -37,12 +64,16 @@ async def setup( email=data.email, password_hash=security.get_password_hash(data.password), full_name=data.full_name, - is_admin=True, + role=models.GlobalRole.ADMIN, ) db.add(user) await db.commit() await db.refresh(user) + background_tasks.add_task( + log_audit, user.id, "setup", "user", user.id, {"email": user.email} + ) + token_data = {"sub": user.email, "id": user.id} return { "access_token": security.create_access_token(data=token_data), @@ -52,8 +83,11 @@ async def setup( @router.post("/register", response_model=schemas.TokenPairResponse) +@limiter.limit(settings.RATE_LIMIT_AUTH) async def register( + request: Request, data: schemas.UserCreateWithToken, + background_tasks: BackgroundTasks, db: AsyncSession = Depends(database.get_db), ): """Register via invitation token. Only invited users can register.""" @@ -91,6 +125,10 @@ async def register( invitation.used_by = user.id await db.commit() + background_tasks.add_task( + log_audit, user.id, "register", "user", user.id, {"email": user.email} + ) + token_data = {"sub": user.email, "id": user.id} return { "access_token": security.create_access_token(data=token_data), @@ -100,8 +138,11 @@ async def register( @router.post("/login", response_model=schemas.TokenPairResponse) +@limiter.limit(settings.RATE_LIMIT_AUTH) async def login( + request: Request, form_data: Annotated[OAuth2PasswordRequestForm, Depends()], + background_tasks: BackgroundTasks, db: AsyncSession = Depends(database.get_db), ): res = await db.execute( @@ -115,6 +156,10 @@ async def login( if not user.is_active: raise HTTPException(status_code=403, detail="Account is deactivated") + background_tasks.add_task( + log_audit, user.id, "login", "user", user.id + ) + token_data = {"sub": user.email, "id": user.id} return { "access_token": security.create_access_token(data=token_data), diff --git a/backend/app/routers/comments.py b/backend/app/routers/comments.py index 31eec8a..cb46c16 100644 --- a/backend/app/routers/comments.py +++ b/backend/app/routers/comments.py @@ -3,6 +3,7 @@ from sqlalchemy import select from .. import schemas, models, database, security +from ..permissions import RequireProjectMemberViaAnnotation, ProjectPermission router = APIRouter() @@ -13,7 +14,7 @@ async def create_comment( payload: schemas.CommentCreate, background_tasks: BackgroundTasks, db: AsyncSession = Depends(database.get_db), - user: models.User = Depends(security.get_current_user), + user: models.User = Depends(RequireProjectMemberViaAnnotation(ProjectPermission.ANNOTATE)), ): annotation = await db.get(models.Annotation, annotation_id) if not annotation: @@ -63,7 +64,7 @@ async def create_comment( async def list_comments( annotation_id: int, db: AsyncSession = Depends(database.get_db), - user: models.User = Depends(security.get_current_user), + user: models.User = Depends(RequireProjectMemberViaAnnotation(ProjectPermission.VIEW)), ): annotation = await db.get(models.Annotation, annotation_id) if not annotation: diff --git a/backend/app/routers/glossary.py b/backend/app/routers/glossary.py index dd491f3..2c9258e 100644 --- a/backend/app/routers/glossary.py +++ b/backend/app/routers/glossary.py @@ -3,8 +3,8 @@ from sqlalchemy import select from .. import schemas, models, database, security -from ..permissions import RoleChecker -from ..models import RoleProject +from ..permissions import RequireProjectMember, ProjectPermission, _check_project_membership +from ..models import GlobalRole router = APIRouter() @@ -13,7 +13,7 @@ async def list_glossary( project_id: int, db: AsyncSession = Depends(database.get_db), - user: models.User = Depends(security.get_current_user), + user: models.User = Depends(RequireProjectMember(ProjectPermission.VIEW)), ): result = await db.execute( select(models.GlossaryTerm) @@ -28,9 +28,7 @@ async def create_glossary_term( project_id: int, data: schemas.GlossaryTermCreate, db: AsyncSession = Depends(database.get_db), - user: models.User = Depends( - RoleChecker(allowed_project_roles=[RoleProject.MANAGER]) - ), + user: models.User = Depends(RequireProjectMember(ProjectPermission.MANAGE)), ): existing = await db.execute( select(models.GlossaryTerm).where( @@ -64,6 +62,9 @@ async def update_glossary_term( if not term: raise HTTPException(404, "Glossary term not found") + # Check project membership with MANAGE permission + await _check_project_membership(user, term.project_id, ProjectPermission.MANAGE, db) + if data.source_term is not None: term.source_term = data.source_term if data.translations is not None: @@ -85,5 +86,9 @@ async def delete_glossary_term( term = await db.get(models.GlossaryTerm, term_id) if not term: raise HTTPException(404, "Glossary term not found") + + # Check project membership with MANAGE permission + await _check_project_membership(user, term.project_id, ProjectPermission.MANAGE, db) + await db.delete(term) await db.commit() diff --git a/backend/app/routers/invitations.py b/backend/app/routers/invitations.py index 2e8d1f3..33f25c6 100644 --- a/backend/app/routers/invitations.py +++ b/backend/app/routers/invitations.py @@ -1,12 +1,14 @@ import uuid from datetime import datetime, timedelta, timezone +from typing import Optional -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import select, func from .. import schemas, models, database, security -from ..permissions import AdminRequired +from ..permissions import RequireAdmin +from ..middleware.audit import log_audit router = APIRouter() @@ -14,7 +16,8 @@ @router.post("/invitations", response_model=schemas.InvitationResponse) async def create_invitation( data: schemas.InvitationCreate, - user: models.User = Depends(AdminRequired()), + background_tasks: BackgroundTasks, + user: models.User = Depends(RequireAdmin()), db: AsyncSession = Depends(database.get_db), ): """Create an invitation link for a specific email. Admin only.""" @@ -36,29 +39,80 @@ async def create_invitation( invitation = models.Invitation( email=data.email, token=str(uuid.uuid4()), - expires_at=datetime.now(timezone.utc) + timedelta(days=7), + expires_at=datetime.now(timezone.utc)+ timedelta(days=7), ) db.add(invitation) await db.commit() await db.refresh(invitation) + + background_tasks.add_task( + log_audit, user.id, "create", "invitation", invitation.id, + {"email": data.email}, + ) + return invitation @router.get("/invitations", response_model=list[schemas.InvitationResponse]) async def list_invitations( - user: models.User = Depends(AdminRequired()), + status: Optional[str] = Query(None, regex="^(pending|expired|used)$"), + user: models.User = Depends(RequireAdmin()), + db: AsyncSession = Depends(database.get_db), +): + query = select(models.Invitation) + + if status == "pending": + query = query.where( + models.Invitation.used_by.is_(None), + models.Invitation.expires_at > func.now(), + ) + elif status == "expired": + query = query.where( + models.Invitation.used_by.is_(None), + models.Invitation.expires_at <= func.now(), + ) + elif status == "used": + query = query.where(models.Invitation.used_by.isnot(None)) + + result = await db.execute(query.order_by(models.Invitation.created_at.desc())) + return result.scalars().all() + + +@router.post("/invitations/{invitation_id}/regenerate", response_model=schemas.InvitationResponse) +async def regenerate_invitation( + invitation_id: int, + background_tasks: BackgroundTasks, + user: models.User = Depends(RequireAdmin()), db: AsyncSession = Depends(database.get_db), ): + """Regenerate an expired/pending invitation with a new token and expiry.""" result = await db.execute( - select(models.Invitation).order_by(models.Invitation.created_at.desc()) + select(models.Invitation).where(models.Invitation.id == invitation_id) ) - return result.scalars().all() + invitation = result.scalar_one_or_none() + if not invitation: + raise HTTPException(404, "Invitation not found") + if invitation.used_by is not None: + raise HTTPException(400, "Cannot regenerate a used invitation") + + invitation.token = str(uuid.uuid4()) + invitation.expires_at = datetime.now(timezone.utc) + timedelta(days=7) + await db.commit() + await db.refresh(invitation) + + background_tasks.add_task( + log_audit, user.id, "regenerate", "invitation", invitation.id, + {"email": invitation.email}, + ) + + return invitation @router.delete("/invitations/{invitation_id}", status_code=204) async def delete_invitation( invitation_id: int, - user: models.User = Depends(AdminRequired()), + background_tasks: BackgroundTasks, + user: models.User = Depends(RequireAdmin()), db: AsyncSession = Depends(database.get_db), ): result = await db.execute( @@ -69,5 +123,11 @@ async def delete_invitation( raise HTTPException(404, "Invitation not found") if invitation.used_by is not None: raise HTTPException(400, "Cannot delete used invitation") + inv_email = invitation.email await db.delete(invitation) await db.commit() + + background_tasks.add_task( + log_audit, user.id, "delete", "invitation", invitation_id, + {"email": inv_email}, + ) diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index 1d20a3f..8d74300 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -1,12 +1,13 @@ -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Response, Query +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Response, Query, BackgroundTasks from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select import json from .. import schemas, models, database, security -from ..permissions import RoleChecker, AdminRequired -from ..models import RoleProject +from ..permissions import RequireAdmin, RequireProjectMember, ProjectPermission +from ..models import RoleProject, GlobalRole, ImportStatus from ..config import settings +from ..middleware.audit import log_audit router = APIRouter() @@ -14,29 +15,50 @@ # --- CRUD --- -@router.get("/", response_model=list[schemas.ProjectResponse]) +@router.get("", response_model=list[schemas.ProjectListResponse]) async def list_projects( user: models.User = Depends(security.get_current_user), db: AsyncSession = Depends(database.get_db), ): - """List projects. Admin sees all; others see only assigned projects.""" - if user.is_admin: + """List projects. Admin sees all; others see only assigned projects. + Includes caller's project role as my_role.""" + if user.role == GlobalRole.ADMIN: result = await db.execute(select(models.Project)) - return result.scalars().all() + projects = result.scalars().all() + # Fetch admin's project roles (they may also be a member) + memberships = await db.execute( + select(models.ProjectMember.project_id, models.ProjectMember.role) + .where(models.ProjectMember.user_id == user.id) + ) + role_map = {pid: role for pid, role in memberships.all()} + return [ + schemas.ProjectListResponse( + **schemas.ProjectResponse.model_validate(p).model_dump(), + my_role=role_map.get(p.id), + ) + for p in projects + ] # Non-admin: only projects they're a member of result = await db.execute( - select(models.Project) + select(models.Project, models.ProjectMember.role) .join(models.ProjectMember, models.Project.id == models.ProjectMember.project_id) .where(models.ProjectMember.user_id == user.id) ) - return result.scalars().all() + return [ + schemas.ProjectListResponse( + **schemas.ProjectResponse.model_validate(p).model_dump(), + my_role=role, + ) + for p, role in result.all() + ] -@router.post("/", response_model=schemas.ProjectResponse, status_code=201) +@router.post("", response_model=schemas.ProjectResponse, status_code=201) async def create_project( data: schemas.ProjectCreate, - user: models.User = Depends(AdminRequired()), + background_tasks: BackgroundTasks, + user: models.User = Depends(RequireAdmin()), db: AsyncSession = Depends(database.get_db), ): """Create a project. Admin only.""" @@ -44,17 +66,23 @@ async def create_project( name=data.name, type=data.type, created_by=user.id, + config=data.config or {}, ) db.add(project) + await db.flush() + await db.commit() await db.refresh(project) + background_tasks.add_task( + log_audit, user.id, "create", "project", project.id, {"name": project.name} + ) return project @router.get("/{project_id}", response_model=schemas.ProjectResponse) async def get_project( project_id: int, - user: models.User = Depends(security.get_current_user), + user: models.User = Depends(RequireProjectMember(ProjectPermission.VIEW)), db: AsyncSession = Depends(database.get_db), ): project = await db.get(models.Project, project_id) @@ -67,9 +95,7 @@ async def get_project( async def update_project( project_id: int, data: schemas.ProjectUpdate, - user: models.User = Depends( - RoleChecker(allowed_project_roles=[RoleProject.MANAGER]) - ), + user: models.User = Depends(RequireProjectMember(ProjectPermission.MANAGE)), db: AsyncSession = Depends(database.get_db), ): project = await db.get(models.Project, project_id) @@ -87,15 +113,50 @@ async def update_project( @router.delete("/{project_id}", status_code=204) async def delete_project( project_id: int, - user: models.User = Depends(AdminRequired()), + background_tasks: BackgroundTasks, + user: models.User = Depends(RequireAdmin()), db: AsyncSession = Depends(database.get_db), ): """Delete a project and all its tasks/annotations. Admin only.""" project = await db.get(models.Project, project_id) if not project: raise HTTPException(404, "Project not found") + project_name = project.name await db.delete(project) await db.commit() + background_tasks.add_task( + log_audit, user.id, "delete", "project", project_id, {"name": project_name} + ) + + +# --- MY ROLE --- + + +@router.get("/{project_id}/my-role", response_model=schemas.ProjectRoleResponse) +async def get_my_project_role( + project_id: int, + user: models.User = Depends(security.get_current_user), + db: AsyncSession = Depends(database.get_db), +): + """Get the current user's role in a project.""" + project = await db.get(models.Project, project_id) + if not project: + raise HTTPException(404, "Project not found") + + if user.role == GlobalRole.ADMIN: + return schemas.ProjectRoleResponse(role="ADMIN") + + result = await db.execute( + select(models.ProjectMember).where( + models.ProjectMember.user_id == user.id, + models.ProjectMember.project_id == project_id, + ) + ) + member = result.scalar_one_or_none() + if not member: + raise HTTPException(403, "Not a member of this project") + + return schemas.ProjectRoleResponse(role=member.role) # --- PROJECT MEMBERS --- @@ -104,9 +165,7 @@ async def delete_project( @router.get("/{project_id}/members", response_model=list[schemas.ProjectMemberResponse]) async def list_project_members( project_id: int, - user: models.User = Depends( - RoleChecker(allowed_project_roles=[RoleProject.MANAGER]) - ), + user: models.User = Depends(RequireProjectMember(ProjectPermission.MANAGE)), db: AsyncSession = Depends(database.get_db), ): result = await db.execute( @@ -130,22 +189,23 @@ async def list_project_members( async def add_project_member( project_id: int, data: schemas.ProjectMemberAdd, - user: models.User = Depends( - RoleChecker(allowed_project_roles=[RoleProject.MANAGER]) - ), + background_tasks: BackgroundTasks, + user: models.User = Depends(RequireProjectMember(ProjectPermission.MANAGE)), db: AsyncSession = Depends(database.get_db), ): """Add a user to a project with a role.""" + # Only ADMIN can assign MANAGER role + if data.role == RoleProject.MANAGER and user.role != GlobalRole.ADMIN: + raise HTTPException(403, "Only admin can assign MANAGER role") + project = await db.get(models.Project, project_id) if not project: raise HTTPException(404, "Project not found") - # Check target user exists target_user = await db.get(models.User, data.user_id) if not target_user: raise HTTPException(400, "User not found") - # Check not already in project existing = await db.execute( select(models.ProjectMember).where( models.ProjectMember.user_id == data.user_id, @@ -163,6 +223,11 @@ async def add_project_member( db.add(pm) await db.commit() + background_tasks.add_task( + log_audit, user.id, "add_member", "project", project_id, + {"target_user_id": data.user_id, "target_email": target_user.email, "role": data.role.value}, + ) + return schemas.ProjectMemberResponse( user_id=data.user_id, email=target_user.email, @@ -171,15 +236,68 @@ async def add_project_member( ) +@router.patch("/{project_id}/members/{user_id}", response_model=schemas.ProjectMemberResponse) +async def update_project_member( + project_id: int, + user_id: int, + data: schemas.ProjectMemberUpdate, + background_tasks: BackgroundTasks, + user: models.User = Depends(RequireProjectMember(ProjectPermission.MANAGE)), + db: AsyncSession = Depends(database.get_db), +): + """Update a project member's role.""" + # Anti-self-modification + if user.id == user_id: + raise HTTPException(403, "Cannot change your own project role") + + # Only ADMIN can assign MANAGER role + if data.role == RoleProject.MANAGER and user.role != GlobalRole.ADMIN: + raise HTTPException(403, "Only admin can assign MANAGER role") + + result = await db.execute( + select(models.ProjectMember).where( + models.ProjectMember.user_id == user_id, + models.ProjectMember.project_id == project_id, + ) + ) + pm = result.scalar_one_or_none() + if not pm: + raise HTTPException(404, "Project member not found") + + # Only ADMIN can modify another MANAGER's role + if pm.role == RoleProject.MANAGER and user.role != GlobalRole.ADMIN: + raise HTTPException(403, "Only admin can modify another MANAGER's role") + + old_role = pm.role + pm.role = data.role + await db.commit() + + target_user = await db.get(models.User, user_id) + background_tasks.add_task( + log_audit, user.id, "update_member_role", "project", project_id, + {"target_user_id": user_id, "target_email": target_user.email if target_user else None, "old_role": old_role, "new_role": data.role.value}, + ) + + return schemas.ProjectMemberResponse( + user_id=pm.user_id, + email=target_user.email if target_user else "", + full_name=target_user.full_name if target_user else None, + role=pm.role, + ) + + @router.delete("/{project_id}/members/{user_id}", status_code=204) async def remove_project_member( project_id: int, user_id: int, - user: models.User = Depends( - RoleChecker(allowed_project_roles=[RoleProject.MANAGER]) - ), + background_tasks: BackgroundTasks, + user: models.User = Depends(RequireProjectMember(ProjectPermission.MANAGE)), db: AsyncSession = Depends(database.get_db), ): + # Anti-self-removal + if user.id == user_id: + raise HTTPException(400, "Cannot remove yourself from a project") + result = await db.execute( select(models.ProjectMember).where( models.ProjectMember.user_id == user_id, @@ -189,8 +307,17 @@ async def remove_project_member( pm = result.scalar_one_or_none() if not pm: raise HTTPException(404, "Project member not found") + + # Only ADMIN can remove a MANAGER from the project + if pm.role == RoleProject.MANAGER and user.role != GlobalRole.ADMIN: + raise HTTPException(403, "Only admin can remove a MANAGER from the project") + await db.delete(pm) await db.commit() + background_tasks.add_task( + log_audit, user.id, "remove_member", "project", project_id, + {"target_user_id": user_id}, + ) # --- IMPORT PREVIEW --- @@ -198,9 +325,7 @@ async def remove_project_member( async def import_preview( project_id: int, file: UploadFile = File(...), - user: models.User = Depends( - RoleChecker(allowed_project_roles=[RoleProject.MANAGER]) - ), + user: models.User = Depends(RequireProjectMember(ProjectPermission.MANAGE)), db: AsyncSession = Depends(database.get_db), ): """Preview import without committing: returns count, sample tasks, and entities count.""" @@ -270,15 +395,74 @@ def _parse_ini_lines(text: str) -> list[tuple[str, str]]: } +# --- Helper: process import data --- +def _parse_import_file(content: bytes, project_type: str, project_id: int) -> list[models.Task]: + """Parse file content and return Task objects.""" + def _decode(raw: bytes) -> str: + try: + return raw.decode("utf-8-sig") + except UnicodeDecodeError: + return raw.decode("latin-1") + + def _parse_ini_lines(text: str) -> list[tuple[str, str]]: + pairs = [] + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith(";") or line.startswith("["): + continue + if "=" in line: + key, value = line.split("=", 1) + pairs.append((key.strip(), value.strip())) + return pairs + + tasks_to_add = [] + + if project_type == "NER": + text_content = _decode(content) + try: + data = json.loads(text_content) + if not isinstance(data, list): + raise ValueError("Expected list of objects") + for item in data: + tasks_to_add.append( + models.Task( + project_id=project_id, + data={"text": item.get("text"), "original_id": item.get("id")}, + ) + ) + except (json.JSONDecodeError, ValueError): + pairs = _parse_ini_lines(text_content) + if not pairs: + raise ValueError("File is neither valid JSON nor INI") + for key, value in pairs: + tasks_to_add.append( + models.Task( + project_id=project_id, + data={"text": value, "original_id": key}, + ) + ) + + elif project_type == "TRANSLATION": + text_content = _decode(content) + for key, value in _parse_ini_lines(text_content): + tasks_to_add.append( + models.Task( + project_id=project_id, + data={"source": value, "key": key}, + ) + ) + + return tasks_to_add + + # --- IMPORT DATA --- @router.post("/{project_id}/import", status_code=201) async def import_data( project_id: int, + background_tasks: BackgroundTasks, replace: bool = False, file: UploadFile = File(...), - user: models.User = Depends( - RoleChecker(allowed_project_roles=[RoleProject.MANAGER]) - ), + user: models.User = Depends(RequireProjectMember(ProjectPermission.MANAGE)), db: AsyncSession = Depends(database.get_db), ): project = await db.get(models.Project, project_id) @@ -288,7 +472,7 @@ async def import_data( # File upload validation allowed_types = { "application/json", "text/plain", "text/csv", - "application/octet-stream", # common for .ini files + "application/octet-stream", } if file.content_type and file.content_type not in allowed_types: raise HTTPException(400, f"Unsupported file type: {file.content_type}") @@ -298,86 +482,151 @@ async def import_data( if len(content) > max_bytes: raise HTTPException(400, f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE_MB}MB") + # Non-admin MANAGER creates a pending import for admin approval + if user.role != GlobalRole.ADMIN: + try: + tasks = _parse_import_file(content, project.type, project_id) + task_count = len(tasks) + except Exception as e: + raise HTTPException(400, f"Failed to parse file: {str(e)}") + + pending = models.PendingImport( + project_id=project_id, + uploaded_by=user.id, + filename=file.filename or "unknown", + file_data=content, + replace_existing=replace, + status=ImportStatus.PENDING, + task_count=task_count, + ) + db.add(pending) + await db.commit() + await db.refresh(pending) + + background_tasks.add_task( + log_audit, user.id, "import_pending", "project", project_id, + {"count": task_count, "filename": file.filename, "pending_import_id": pending.id}, + ) + return {"status": "pending_approval", "count": task_count, "pending_import_id": pending.id} + + # Admin imports directly if replace: from sqlalchemy import delete as sa_delete await db.execute( sa_delete(models.Task).where(models.Task.project_id == project_id) ) - tasks_to_add = [] - - def _decode(raw: bytes) -> str: - """Decode with utf-8-sig first, fallback to latin-1.""" - try: - return raw.decode("utf-8-sig") - except UnicodeDecodeError: - return raw.decode("latin-1") - - def _parse_ini_lines(text: str) -> list[tuple[str, str]]: - """Parse INI key=value pairs, matching VerseBridge logic.""" - pairs = [] - for line in text.splitlines(): - line = line.strip() - if not line or line.startswith(";") or line.startswith("["): - continue - if "=" in line: - key, value = line.split("=", 1) - pairs.append((key.strip(), value.strip())) - return pairs try: - if project.type == "NER": - text_content = _decode(content) - # Try JSON first - try: - data = json.loads(text_content) - if not isinstance(data, list): - raise ValueError("Expected list of objects") - for item in data: - tasks_to_add.append( - models.Task( - project_id=project_id, - data={"text": item.get("text"), "original_id": item.get("id")}, - ) - ) - except (json.JSONDecodeError, ValueError): - # Fallback: parse as INI — each value becomes a text to annotate - pairs = _parse_ini_lines(text_content) - if not pairs: - raise ValueError("File is neither valid JSON nor INI") - for key, value in pairs: - tasks_to_add.append( - models.Task( - project_id=project_id, - data={"text": value, "original_id": key}, - ) - ) - - elif project.type == "TRANSLATION": - text_content = _decode(content) - for key, value in _parse_ini_lines(text_content): - tasks_to_add.append( - models.Task( - project_id=project_id, - data={"source": value, "key": key}, - ) - ) + tasks_to_add = _parse_import_file(content, project.type, project_id) except Exception as e: raise HTTPException(400, f"Failed to parse file: {str(e)}") db.add_all(tasks_to_add) await db.commit() + background_tasks.add_task( + log_audit, user.id, "import", "project", project_id, + {"count": len(tasks_to_add), "filename": file.filename, "replace": replace}, + ) return {"status": "success", "count": len(tasks_to_add)} +# --- PENDING IMPORTS --- + +@router.get("/pending-imports", response_model=list[schemas.PendingImportResponse]) +async def list_pending_imports( + user: models.User = Depends(RequireAdmin()), + db: AsyncSession = Depends(database.get_db), +): + """List all pending imports. Admin only.""" + result = await db.execute( + select(models.PendingImport, models.User, models.Project) + .join(models.User, models.PendingImport.uploaded_by == models.User.id) + .join(models.Project, models.PendingImport.project_id == models.Project.id) + .where(models.PendingImport.status == ImportStatus.PENDING) + .order_by(models.PendingImport.created_at.desc()) + ) + rows = result.all() + return [ + schemas.PendingImportResponse( + id=pi.id, + project_id=pi.project_id, + project_name=proj.name, + uploaded_by=pi.uploaded_by, + uploader_email=u.email, + filename=pi.filename, + replace_existing=pi.replace_existing, + status=pi.status, + task_count=pi.task_count, + review_note=pi.review_note, + created_at=pi.created_at, + ) + for pi, u, proj in rows + ] + + +@router.post("/pending-imports/{import_id}/review") +async def review_pending_import( + import_id: int, + review: schemas.PendingImportReview, + background_tasks: BackgroundTasks, + user: models.User = Depends(RequireAdmin()), + db: AsyncSession = Depends(database.get_db), +): + """Approve or reject a pending import. Admin only.""" + pending = await db.get(models.PendingImport, import_id) + if not pending: + raise HTTPException(404, "Pending import not found") + if pending.status != ImportStatus.PENDING: + raise HTTPException(400, "Import already reviewed") + + if review.status not in (ImportStatus.APPROVED, ImportStatus.REJECTED): + raise HTTPException(400, "Status must be APPROVED or REJECTED") + + pending.status = review.status + pending.reviewed_by = user.id + pending.review_note = review.review_note + + if review.status == ImportStatus.APPROVED: + project = await db.get(models.Project, pending.project_id) + if not project: + raise HTTPException(404, "Project not found") + + if pending.replace_existing: + from sqlalchemy import delete as sa_delete + await db.execute( + sa_delete(models.Task).where(models.Task.project_id == pending.project_id) + ) + + try: + tasks_to_add = _parse_import_file(pending.file_data, project.type, pending.project_id) + except Exception as e: + raise HTTPException(400, f"Failed to parse file: {str(e)}") + + db.add_all(tasks_to_add) + pending.task_count = len(tasks_to_add) + + background_tasks.add_task( + log_audit, user.id, "import_approved", "project", pending.project_id, + {"count": len(tasks_to_add), "filename": pending.filename, "pending_import_id": pending.id}, + ) + else: + background_tasks.add_task( + log_audit, user.id, "import_rejected", "project", pending.project_id, + {"filename": pending.filename, "pending_import_id": pending.id, "reason": review.review_note}, + ) + + await db.commit() + return {"status": review.status, "task_count": pending.task_count} + + # --- EXPORT DATA (VERSEBRIDGE FORMAT) --- @router.get("/{project_id}/export") async def export_data( project_id: int, + background_tasks: BackgroundTasks, format: str = "json", - language: str = Query("ru", description="Target language code for translation export"), - user: models.User = Depends( - RoleChecker(allowed_project_roles=[RoleProject.MANAGER]) - ), + language: str = Query("en", description="Target language code for translation export"), + user: models.User = Depends(RequireProjectMember(ProjectPermission.MANAGE)), db: AsyncSession = Depends(database.get_db), ): stmt = ( @@ -405,6 +654,10 @@ async def export_data( ) file_content = json.dumps(export_list, ensure_ascii=False, indent=2) + background_tasks.add_task( + log_audit, user.id, "export", "project", project_id, + {"format": "json", "count": len(export_list)}, + ) return Response( content=file_content, media_type="application/json", @@ -420,6 +673,10 @@ async def export_data( lines.append(f"{key}={target_text}") file_content = "\n".join(lines) + background_tasks.add_task( + log_audit, user.id, "export", "project", project_id, + {"format": "ini", "language": language, "count": len(lines)}, + ) return Response( content=file_content, media_type="text/plain", diff --git a/backend/app/routers/stats.py b/backend/app/routers/stats.py index 407654a..c2a6cf7 100644 --- a/backend/app/routers/stats.py +++ b/backend/app/routers/stats.py @@ -4,8 +4,8 @@ from cachetools import TTLCache from .. import schemas, models, database, security -from ..permissions import RoleChecker -from ..models import RoleProject, AnnotationStatus +from ..permissions import RequireProjectMember, RequireAdmin, ProjectPermission +from ..models import AnnotationStatus router = APIRouter() @@ -16,7 +16,7 @@ @router.get("/projects/{project_id}/stats", response_model=schemas.ProjectStatsResponse) async def project_stats( project_id: int, - user: models.User = Depends(security.get_current_user), + user: models.User = Depends(RequireProjectMember(ProjectPermission.VIEW)), db: AsyncSession = Depends(database.get_db), ): cache_key = f"project_stats_{project_id}" @@ -31,7 +31,6 @@ async def project_stats( select(func.count()).select_from(models.Task).where(models.Task.project_id == project_id) )).scalar() or 0 - # Tasks with at least one annotation annotated_tasks = (await db.execute( select(func.count(func.distinct(models.Annotation.task_id))) .join(models.Task, models.Annotation.task_id == models.Task.id) @@ -92,16 +91,13 @@ async def project_stats( @router.get("/projects/{project_id}/annotator-stats", response_model=list[schemas.AnnotatorStatsItem]) async def annotator_stats( project_id: int, - user: models.User = Depends( - RoleChecker(allowed_project_roles=[RoleProject.MANAGER]) - ), + user: models.User = Depends(RequireProjectMember(ProjectPermission.MANAGE)), db: AsyncSession = Depends(database.get_db), ): cache_key = f"annotator_stats_{project_id}" if cache_key in _stats_cache: return _stats_cache[cache_key] - # Get all annotations for this project grouped by user query = ( select( models.Annotation.user_id, @@ -140,7 +136,7 @@ async def annotator_stats( @router.get("/projects/{project_id}/stats/timeline") async def stats_timeline( project_id: int, - user: models.User = Depends(security.get_current_user), + user: models.User = Depends(RequireProjectMember(ProjectPermission.VIEW)), db: AsyncSession = Depends(database.get_db), ): """Annotation activity over time (daily counts for last 30 days).""" @@ -179,7 +175,7 @@ async def stats_timeline( @router.get("/projects/{project_id}/stats/labels") async def stats_labels( project_id: int, - user: models.User = Depends(security.get_current_user), + user: models.User = Depends(RequireProjectMember(ProjectPermission.VIEW)), db: AsyncSession = Depends(database.get_db), ): """Per-label entity counts for NER projects.""" @@ -189,7 +185,6 @@ async def stats_labels( if project.type != "NER": return [] - # Get all approved annotations and count entities by label query = ( select(models.Annotation.result) .join(models.Task, models.Annotation.task_id == models.Task.id) @@ -214,7 +209,7 @@ async def stats_labels( @router.get("/overview-stats", response_model=schemas.OverviewStatsResponse) async def overview_stats( - user: models.User = Depends(security.get_current_user), + user: models.User = Depends(RequireAdmin()), db: AsyncSession = Depends(database.get_db), ): cache_key = "overview_stats" diff --git a/backend/app/routers/tasks.py b/backend/app/routers/tasks.py index ce002f6..3363845 100644 --- a/backend/app/routers/tasks.py +++ b/backend/app/routers/tasks.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import selectinload from .. import schemas, models, database, security -from ..permissions import RoleChecker +from ..permissions import RequireProjectMember, ProjectPermission from ..models import AnnotationStatus router = APIRouter() @@ -16,7 +16,7 @@ async def list_project_tasks( page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), db: AsyncSession = Depends(database.get_db), - user: models.User = Depends(security.get_current_user), + user: models.User = Depends(RequireProjectMember(ProjectPermission.VIEW)), ): project = await db.get(models.Project, project_id) if not project: @@ -59,7 +59,7 @@ async def list_project_tasks( async def get_next_task( project_id: int, db: AsyncSession = Depends(database.get_db), - user: models.User = Depends(security.get_current_user), + user: models.User = Depends(RequireProjectMember(ProjectPermission.ANNOTATE)), ): # Find a task without an annotation from the current user subq = select(models.Annotation.task_id).where(models.Annotation.user_id == user.id) @@ -77,5 +77,3 @@ async def get_next_task( return None return {"id": task.id, "data": task.data, "project_id": project_id} - - diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 56d645c..58c19d4 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -1,9 +1,12 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select +from sqlalchemy.orm import selectinload from .. import schemas, models, security, database -from ..permissions import AdminRequired +from ..permissions import RequireAdmin +from ..models import GlobalRole +from ..middleware.audit import log_audit router = APIRouter() @@ -17,13 +20,14 @@ async def read_users_me( id=current_user.id, email=current_user.email, full_name=current_user.full_name, - is_admin=current_user.is_admin, + role=current_user.role, ) @router.patch("/me", response_model=schemas.UserResponse) async def update_me( payload: schemas.UserUpdate, + background_tasks: BackgroundTasks, current_user: models.User = Depends(security.get_current_user), db: AsyncSession = Depends(database.get_db), ): @@ -35,6 +39,9 @@ async def update_me( if pw_error: raise HTTPException(400, pw_error) current_user.password_hash = security.get_password_hash(payload.password) + background_tasks.add_task( + log_audit, current_user.id, "password_change", "user", current_user.id + ) await db.commit() await db.refresh(current_user) @@ -43,15 +50,68 @@ async def update_me( id=current_user.id, email=current_user.email, full_name=current_user.full_name, - is_admin=current_user.is_admin, + role=current_user.role, ) -@router.get("/", response_model=list[schemas.UserListItem]) +@router.get("", response_model=list[schemas.UserListItemWithProjects]) async def list_users( - user: models.User = Depends(AdminRequired()), + user: models.User = Depends(RequireAdmin()), db: AsyncSession = Depends(database.get_db), ): - """List all users. Admin only.""" - result = await db.execute(select(models.User).order_by(models.User.id)) - return result.scalars().all() + """List all users with project assignments. Admin only.""" + result = await db.execute( + select(models.User) + .options( + selectinload(models.User.project_assignments) + .selectinload(models.ProjectMember.project) + ) + .order_by(models.User.id) + ) + users = result.scalars().all() + return [ + schemas.UserListItemWithProjects( + id=u.id, + email=u.email, + full_name=u.full_name, + role=u.role, + is_active=u.is_active, + created_at=u.created_at, + project_assignments=[ + schemas.UserProjectAssignment( + project_id=pa.project_id, + project_name=pa.project.name, + role=pa.role, + ) + for pa in u.project_assignments + ], + ) + for u in users + ] + + +@router.patch("/{user_id}/toggle-active") +async def toggle_user_active( + user_id: int, + background_tasks: BackgroundTasks, + user: models.User = Depends(RequireAdmin()), + db: AsyncSession = Depends(database.get_db), +): + """Toggle a user's active status. Admin only. Cannot deactivate self.""" + if user.id == user_id: + raise HTTPException(400, "Cannot deactivate yourself") + + target = await db.get(models.User, user_id) + if not target: + raise HTTPException(404, "User not found") + + target.is_active = not target.is_active + await db.commit() + await db.refresh(target) + + background_tasks.add_task( + log_audit, user.id, "toggle_active", "user", user_id, + {"is_active": target.is_active, "target_email": target.email}, + ) + + return {"id": target.id, "is_active": target.is_active} diff --git a/backend/app/schemas.py b/backend/app/schemas.py index f5776d2..c50cf6e 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,17 +1,12 @@ from datetime import datetime from pydantic import BaseModel, EmailStr from typing import Optional -from .models import RoleProject, AnnotationStatus - - -class UserCreate(BaseModel): - email: EmailStr - password: str - full_name: str +from .models import RoleProject, AnnotationStatus, GlobalRole, ImportStatus class UserCreateWithToken(BaseModel): """Registration via invitation token.""" + email: EmailStr password: str full_name: str @@ -19,17 +14,13 @@ class UserCreateWithToken(BaseModel): class SetupCreate(BaseModel): - """First user (admin) setup — creates user with is_admin=True.""" + """First user (admin) setup — creates user with role=ADMIN.""" + email: EmailStr password: str full_name: str -class Token(BaseModel): - access_token: str - token_type: str - - class TokenPairResponse(BaseModel): access_token: str refresh_token: str @@ -43,12 +34,14 @@ class RefreshTokenRequest(BaseModel): class ProjectCreate(BaseModel): name: str type: str # "NER" or "TRANSLATION" + config: Optional[dict] = None class ProjectResponse(BaseModel): id: int name: str type: str + config: Optional[dict] = None created_by: int created_at: datetime @@ -56,15 +49,17 @@ class Config: from_attributes = True +class ProjectListResponse(ProjectResponse): + """ProjectResponse + caller's role in the project (None for admins without membership).""" + + my_role: Optional[str] = None + + class ProjectUpdate(BaseModel): name: Optional[str] = None config: Optional[dict] = None -class TaskCreate(BaseModel): - data: dict - - class TaskResponse(BaseModel): id: int data: dict @@ -78,7 +73,7 @@ class UserResponse(BaseModel): id: int email: str full_name: Optional[str] - is_admin: bool = False + role: GlobalRole = GlobalRole.USER class Config: from_attributes = True @@ -86,6 +81,7 @@ class Config: # --- Annotation schemas --- + class AnnotationResponse(BaseModel): id: int task_id: int @@ -112,6 +108,7 @@ class AnnotationReview(BaseModel): # --- Vote schemas --- + class VoteCreate(BaseModel): value: int # +1 or -1 @@ -128,6 +125,12 @@ class Config: # --- Invitation schemas --- + +class InvitationVerify(BaseModel): + email: EmailStr + is_valid: bool + + class InvitationCreate(BaseModel): email: EmailStr @@ -145,9 +148,14 @@ class Config: # --- Member role update --- + class ProjectMemberAdd(BaseModel): user_id: int - role: RoleProject = RoleProject.MEMBER + role: RoleProject = RoleProject.EDITOR + + +class ProjectMemberUpdate(BaseModel): + role: RoleProject class ProjectMemberResponse(BaseModel): @@ -162,6 +170,7 @@ class Config: # --- User update --- + class UserUpdate(BaseModel): full_name: Optional[str] = None password: Optional[str] = None @@ -169,6 +178,7 @@ class UserUpdate(BaseModel): # --- Task list with stats --- + class TaskListItem(BaseModel): id: int data: dict @@ -187,6 +197,7 @@ class TaskListResponse(BaseModel): # --- Annotation list item --- + class AnnotationListItem(BaseModel): id: int task_id: int @@ -207,6 +218,7 @@ class Config: # --- Comment schemas --- + class CommentCreate(BaseModel): text: str parent_id: Optional[int] = None @@ -228,6 +240,7 @@ class Config: # --- AuditLog schemas --- + class AuditLogResponse(BaseModel): id: int user_id: Optional[int] = None @@ -249,6 +262,7 @@ class AuditLogListResponse(BaseModel): # --- Notification schemas --- + class NotificationResponse(BaseModel): id: int type: str @@ -271,6 +285,7 @@ class NotificationListResponse(BaseModel): # --- Stats schemas --- + class ProjectStatsResponse(BaseModel): total_tasks: int annotated_tasks: int @@ -300,11 +315,18 @@ class OverviewStatsResponse(BaseModel): # --- User list for admin --- + +class UserProjectAssignment(BaseModel): + project_id: int + project_name: str + role: RoleProject + + class UserListItem(BaseModel): id: int email: str full_name: Optional[str] = None - is_admin: bool + role: GlobalRole is_active: bool created_at: datetime @@ -312,8 +334,13 @@ class Config: from_attributes = True +class UserListItemWithProjects(UserListItem): + project_assignments: list[UserProjectAssignment] = [] + + # --- Glossary schemas --- + class GlossaryTermCreate(BaseModel): source_term: str translations: dict = {} @@ -336,3 +363,32 @@ class GlossaryTermResponse(BaseModel): class Config: from_attributes = True + + +class ProjectRoleResponse(BaseModel): + role: str + + +# --- Pending import schemas --- + + +class PendingImportResponse(BaseModel): + id: int + project_id: int + project_name: Optional[str] = None + uploaded_by: int + uploader_email: Optional[str] = None + filename: str + replace_existing: bool + status: ImportStatus + task_count: int + review_note: Optional[str] = None + created_at: datetime + + class Config: + from_attributes = True + + +class PendingImportReview(BaseModel): + status: ImportStatus + review_note: Optional[str] = None diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index ab73549..1ba31a1 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -10,6 +10,7 @@ from app.database import Base, get_db from app.main import app from app.security import get_password_hash, create_access_token +from app.middleware.audit import set_audit_session_factory from app import models @@ -69,10 +70,12 @@ async def override_get_db(): yield session app.dependency_overrides[get_db] = override_get_db + set_audit_session_factory(session_factory) transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as c: yield c app.dependency_overrides.clear() + set_audit_session_factory(None) @pytest_asyncio.fixture @@ -81,7 +84,7 @@ async def admin_user(db_session: AsyncSession): email="admin@test.com", password_hash=get_password_hash(TEST_PASSWORD), full_name="Admin User", - is_admin=True, + role=models.GlobalRole.ADMIN, ) db_session.add(user) await db_session.commit() @@ -95,7 +98,21 @@ async def member_user(db_session: AsyncSession): email="member@test.com", password_hash=get_password_hash(TEST_PASSWORD), full_name="Member User", - is_admin=False, + role=models.GlobalRole.USER, + ) + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + return user + + +@pytest_asyncio.fixture +async def second_user(db_session: AsyncSession): + user = models.User( + email="second@test.com", + password_hash=get_password_hash(TEST_PASSWORD), + full_name="Second User", + role=models.GlobalRole.USER, ) db_session.add(user) await db_session.commit() @@ -148,14 +165,45 @@ async def ner_task(db_session: AsyncSession, ner_project: models.Project): return task +@pytest_asyncio.fixture +async def project_editor( + db_session: AsyncSession, ner_project: models.Project, member_user: models.User +): + """member_user as EDITOR in ner_project.""" + pm = models.ProjectMember( + user_id=member_user.id, + project_id=ner_project.id, + role=models.RoleProject.EDITOR, + ) + db_session.add(pm) + await db_session.commit() + return pm + + @pytest_asyncio.fixture async def project_member( db_session: AsyncSession, ner_project: models.Project, member_user: models.User ): + """Alias: member_user as EDITOR in ner_project (backward compat).""" pm = models.ProjectMember( user_id=member_user.id, project_id=ner_project.id, - role=models.RoleProject.MEMBER, + role=models.RoleProject.EDITOR, + ) + db_session.add(pm) + await db_session.commit() + return pm + + +@pytest_asyncio.fixture +async def project_manager( + db_session: AsyncSession, ner_project: models.Project, second_user: models.User +): + """second_user as MANAGER in ner_project.""" + pm = models.ProjectMember( + user_id=second_user.id, + project_id=ner_project.id, + role=models.RoleProject.MANAGER, ) db_session.add(pm) await db_session.commit() diff --git a/backend/tests/test_permissions.py b/backend/tests/test_permissions.py index 371285d..9f165f8 100644 --- a/backend/tests/test_permissions.py +++ b/backend/tests/test_permissions.py @@ -1,4 +1,5 @@ -"""Tests for RBAC permissions: admin, manager, member access control.""" +"""Tests for RBAC v2: ADMIN/USER global roles, MANAGER/EDITOR project roles.""" +import io import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession @@ -6,24 +7,27 @@ from app import models +# ===== Platform-level access ===== + + @pytest.mark.asyncio async def test_admin_can_create_project(client: AsyncClient, admin_user): - res = await client.post("/api/v1/projects/", json={ + res = await client.post("/api/v1/projects", json={ "name": "Admin Project", "type": "NER", }, headers=auth_headers(admin_user)) assert res.status_code == 201 @pytest.mark.asyncio -async def test_member_cannot_create_project(client: AsyncClient, member_user): - res = await client.post("/api/v1/projects/", json={ - "name": "Member Project", "type": "NER", +async def test_user_cannot_create_project(client: AsyncClient, member_user): + res = await client.post("/api/v1/projects", json={ + "name": "User Project", "type": "NER", }, headers=auth_headers(member_user)) assert res.status_code == 403 @pytest.mark.asyncio -async def test_member_cannot_delete_project(client: AsyncClient, member_user, ner_project): +async def test_user_cannot_delete_project(client: AsyncClient, member_user, ner_project): res = await client.delete( f"/api/v1/projects/{ner_project.id}", headers=auth_headers(member_user), @@ -32,38 +36,86 @@ async def test_member_cannot_delete_project(client: AsyncClient, member_user, ne @pytest.mark.asyncio -async def test_member_cannot_list_members(client: AsyncClient, member_user, ner_project, project_member): +async def test_admin_can_delete_project(client: AsyncClient, admin_user, ner_project): + res = await client.delete( + f"/api/v1/projects/{ner_project.id}", + headers=auth_headers(admin_user), + ) + assert res.status_code == 204 + + +# ===== User without membership cannot access project ===== + + +@pytest.mark.asyncio +async def test_non_member_cannot_view_project(client: AsyncClient, member_user, ner_project): + """USER without membership cannot view project.""" res = await client.get( - f"/api/v1/projects/{ner_project.id}/members", + f"/api/v1/projects/{ner_project.id}", headers=auth_headers(member_user), ) - # Members can't list project members (requires MANAGER) assert res.status_code == 403 @pytest.mark.asyncio -async def test_member_cannot_import(client: AsyncClient, member_user, ner_project, project_member): - import io - res = await client.post( - f"/api/v1/projects/{ner_project.id}/import", - files={"file": ("test.json", io.BytesIO(b"[]"), "application/json")}, +async def test_non_member_cannot_view_tasks(client: AsyncClient, member_user, ner_project): + res = await client.get( + f"/api/v1/tasks/projects/{ner_project.id}/tasks", headers=auth_headers(member_user), ) assert res.status_code == 403 @pytest.mark.asyncio -async def test_admin_bypasses_project_role(client: AsyncClient, admin_user, ner_project): - """Admin can access project endpoints even without being a member.""" +async def test_non_member_cannot_view_annotations(client: AsyncClient, member_user, ner_project): res = await client.get( - f"/api/v1/projects/{ner_project.id}/members", - headers=auth_headers(admin_user), + f"/api/v1/projects/{ner_project.id}/annotations", + headers=auth_headers(member_user), + ) + assert res.status_code == 403 + + +@pytest.mark.asyncio +async def test_non_member_cannot_view_stats(client: AsyncClient, member_user, ner_project): + res = await client.get( + f"/api/v1/projects/{ner_project.id}/stats", + headers=auth_headers(member_user), + ) + assert res.status_code == 403 + + +@pytest.mark.asyncio +async def test_non_member_cannot_view_glossary(client: AsyncClient, member_user, ner_project): + res = await client.get( + f"/api/v1/projects/{ner_project.id}/glossary", + headers=auth_headers(member_user), + ) + assert res.status_code == 403 + + +# ===== EDITOR can view and annotate ===== + + +@pytest.mark.asyncio +async def test_editor_can_view_project(client: AsyncClient, member_user, ner_project, project_editor): + res = await client.get( + f"/api/v1/projects/{ner_project.id}", + headers=auth_headers(member_user), ) assert res.status_code == 200 @pytest.mark.asyncio -async def test_member_can_annotate(client: AsyncClient, member_user, ner_task, project_member): +async def test_editor_can_view_tasks(client: AsyncClient, member_user, ner_project, project_editor): + res = await client.get( + f"/api/v1/tasks/projects/{ner_project.id}/tasks", + headers=auth_headers(member_user), + ) + assert res.status_code == 200 + + +@pytest.mark.asyncio +async def test_editor_can_annotate(client: AsyncClient, member_user, ner_task, project_editor): res = await client.post( f"/api/v1/tasks/{ner_task.id}/annotations", json={"result": []}, @@ -73,7 +125,7 @@ async def test_member_can_annotate(client: AsyncClient, member_user, ner_task, p @pytest.mark.asyncio -async def test_member_can_vote(client: AsyncClient, admin_user, member_user, ner_task, project_member): +async def test_editor_can_vote(client: AsyncClient, admin_user, member_user, ner_task, project_editor): # Create annotation as admin create_res = await client.post( f"/api/v1/tasks/{ner_task.id}/annotations", @@ -82,7 +134,6 @@ async def test_member_can_vote(client: AsyncClient, admin_user, member_user, ner ) ann_id = create_res.json()["id"] - # Member votes res = await client.post( f"/api/v1/annotations/{ann_id}/vote", json={"value": 1}, @@ -92,8 +143,27 @@ async def test_member_can_vote(client: AsyncClient, admin_user, member_user, ner @pytest.mark.asyncio -async def test_member_cannot_review(client: AsyncClient, admin_user, member_user, ner_task, project_member): - # Create and submit annotation +async def test_editor_can_comment(client: AsyncClient, admin_user, member_user, ner_task, project_editor): + create_res = await client.post( + f"/api/v1/tasks/{ner_task.id}/annotations", + json={"result": []}, + headers=auth_headers(admin_user), + ) + ann_id = create_res.json()["id"] + + res = await client.post( + f"/api/v1/annotations/{ann_id}/comments", + json={"text": "Test comment"}, + headers=auth_headers(member_user), + ) + assert res.status_code == 201 + + +# ===== EDITOR cannot do manager-level things ===== + + +@pytest.mark.asyncio +async def test_editor_cannot_review(client: AsyncClient, admin_user, member_user, ner_task, project_editor): create_res = await client.post( f"/api/v1/tasks/{ner_task.id}/annotations", json={"result": []}, @@ -102,7 +172,6 @@ async def test_member_cannot_review(client: AsyncClient, admin_user, member_user ann_id = create_res.json()["id"] await client.post(f"/api/v1/annotations/{ann_id}/submit", headers=auth_headers(admin_user)) - # Member tries to review res = await client.post( f"/api/v1/annotations/{ann_id}/review", json={"status": "APPROVED"}, @@ -111,15 +180,260 @@ async def test_member_cannot_review(client: AsyncClient, admin_user, member_user assert res.status_code == 403 +@pytest.mark.asyncio +async def test_editor_cannot_import(client: AsyncClient, member_user, ner_project, project_editor): + res = await client.post( + f"/api/v1/projects/{ner_project.id}/import", + files={"file": ("test.json", io.BytesIO(b"[]"), "application/json")}, + headers=auth_headers(member_user), + ) + assert res.status_code == 403 + + +@pytest.mark.asyncio +async def test_editor_cannot_export(client: AsyncClient, member_user, ner_project, project_editor): + res = await client.get( + f"/api/v1/projects/{ner_project.id}/export", + headers=auth_headers(member_user), + ) + assert res.status_code == 403 + + +@pytest.mark.asyncio +async def test_editor_cannot_list_members(client: AsyncClient, member_user, ner_project, project_editor): + res = await client.get( + f"/api/v1/projects/{ner_project.id}/members", + headers=auth_headers(member_user), + ) + assert res.status_code == 403 + + +@pytest.mark.asyncio +async def test_editor_cannot_update_config(client: AsyncClient, member_user, ner_project, project_editor): + res = await client.patch( + f"/api/v1/projects/{ner_project.id}", + json={"config": {"labels": ["PER"]}}, + headers=auth_headers(member_user), + ) + assert res.status_code == 403 + + +@pytest.mark.asyncio +async def test_editor_cannot_add_members(client: AsyncClient, member_user, admin_user, ner_project, project_editor): + res = await client.post( + f"/api/v1/projects/{ner_project.id}/members", + json={"user_id": admin_user.id, "role": "EDITOR"}, + headers=auth_headers(member_user), + ) + assert res.status_code == 403 + + +@pytest.mark.asyncio +async def test_editor_cannot_create_glossary(client: AsyncClient, member_user, ner_project, project_editor): + res = await client.post( + f"/api/v1/projects/{ner_project.id}/glossary", + json={"source_term": "test", "translations": {}}, + headers=auth_headers(member_user), + ) + assert res.status_code == 403 + + +# ===== MANAGER can do project management ===== + + +@pytest.mark.asyncio +async def test_manager_can_review( + client: AsyncClient, admin_user, second_user, ner_task, project_manager +): + create_res = await client.post( + f"/api/v1/tasks/{ner_task.id}/annotations", + json={"result": []}, + headers=auth_headers(admin_user), + ) + ann_id = create_res.json()["id"] + await client.post(f"/api/v1/annotations/{ann_id}/submit", headers=auth_headers(admin_user)) + + res = await client.post( + f"/api/v1/annotations/{ann_id}/review", + json={"status": "APPROVED"}, + headers=auth_headers(second_user), + ) + assert res.status_code == 200 + + +@pytest.mark.asyncio +async def test_manager_import_creates_pending( + client: AsyncClient, second_user, ner_project, project_manager +): + """Non-admin MANAGER importing data creates a pending import.""" + res = await client.post( + f"/api/v1/projects/{ner_project.id}/import", + files={"file": ("test.json", io.BytesIO(b"[]"), "application/json")}, + headers=auth_headers(second_user), + ) + assert res.status_code == 201 + assert res.json()["status"] == "pending_approval" + + +@pytest.mark.asyncio +async def test_manager_can_add_editor( + client: AsyncClient, db_session, second_user, member_user, ner_project, project_manager +): + """Project MANAGER can add EDITOR but not MANAGER.""" + # Can add EDITOR + res = await client.post( + f"/api/v1/projects/{ner_project.id}/members", + json={"user_id": member_user.id, "role": "EDITOR"}, + headers=auth_headers(second_user), + ) + assert res.status_code == 201 + + # Cannot add MANAGER (only admin can) + third = models.User( + email="third@test.com", + password_hash="x", + full_name="Third", + role=models.GlobalRole.USER, + ) + db_session.add(third) + await db_session.commit() + await db_session.refresh(third) + + res = await client.post( + f"/api/v1/projects/{ner_project.id}/members", + json={"user_id": third.id, "role": "MANAGER"}, + headers=auth_headers(second_user), + ) + assert res.status_code == 403 + + +# ===== ADMIN bypasses all project checks ===== + + +@pytest.mark.asyncio +async def test_admin_bypasses_project_membership(client: AsyncClient, admin_user, ner_project): + """Admin can access project endpoints without being a member.""" + res = await client.get( + f"/api/v1/projects/{ner_project.id}/members", + headers=auth_headers(admin_user), + ) + assert res.status_code == 200 + + +@pytest.mark.asyncio +async def test_admin_import_is_direct(client: AsyncClient, admin_user, ner_project): + """Admin importing data goes through directly.""" + res = await client.post( + f"/api/v1/projects/{ner_project.id}/import", + files={"file": ("test.json", io.BytesIO(b"[]"), "application/json")}, + headers=auth_headers(admin_user), + ) + assert res.status_code == 201 + assert res.json()["status"] == "success" + + +@pytest.mark.asyncio +async def test_admin_can_approve_pending_import( + client: AsyncClient, admin_user, second_user, ner_project, project_manager +): + """Admin can approve a pending import.""" + res = await client.post( + f"/api/v1/projects/{ner_project.id}/import", + files={"file": ("test.json", io.BytesIO(b'[{"text": "hello", "id": "1"}]'), "application/json")}, + headers=auth_headers(second_user), + ) + assert res.status_code == 201 + pending_id = res.json()["pending_import_id"] + + res = await client.post( + f"/api/v1/projects/pending-imports/{pending_id}/review", + json={"status": "APPROVED"}, + headers=auth_headers(admin_user), + ) + assert res.status_code == 200 + assert res.json()["status"] == "APPROVED" + assert res.json()["task_count"] == 1 + + +# ===== Anti-self-modification ===== + + +@pytest.mark.asyncio +async def test_cannot_change_own_project_role( + client: AsyncClient, second_user, ner_project, project_manager +): + res = await client.patch( + f"/api/v1/projects/{ner_project.id}/members/{second_user.id}", + json={"role": "EDITOR"}, + headers=auth_headers(second_user), + ) + assert res.status_code == 403 + + +@pytest.mark.asyncio +async def test_cannot_remove_self_from_project( + client: AsyncClient, second_user, ner_project, project_manager +): + res = await client.delete( + f"/api/v1/projects/{ner_project.id}/members/{second_user.id}", + headers=auth_headers(second_user), + ) + assert res.status_code == 400 + + +# ===== My-role endpoint ===== + + +@pytest.mark.asyncio +async def test_my_role_returns_project_role( + client: AsyncClient, member_user, ner_project, project_editor +): + res = await client.get( + f"/api/v1/projects/{ner_project.id}/my-role", + headers=auth_headers(member_user), + ) + assert res.status_code == 200 + assert res.json()["role"] == "EDITOR" + + +@pytest.mark.asyncio +async def test_my_role_returns_admin_for_admin(client: AsyncClient, admin_user, ner_project): + res = await client.get( + f"/api/v1/projects/{ner_project.id}/my-role", + headers=auth_headers(admin_user), + ) + assert res.status_code == 200 + assert res.json()["role"] == "ADMIN" + + +@pytest.mark.asyncio +async def test_my_role_403_for_non_member(client: AsyncClient, member_user, ner_project): + res = await client.get( + f"/api/v1/projects/{ner_project.id}/my-role", + headers=auth_headers(member_user), + ) + assert res.status_code == 403 + + +# ===== Deactivated user blocked ===== + + @pytest.mark.asyncio async def test_deactivated_user_blocked(client: AsyncClient, db_session: AsyncSession, admin_user): - """Deactivated users should be blocked from accessing endpoints.""" admin_user.is_active = False await db_session.commit() res = await client.get("/api/v1/users/me", headers=auth_headers(admin_user)) assert res.status_code == 403 - # Restore admin_user.is_active = True await db_session.commit() + + +# ===== Overview stats admin only ===== + + +@pytest.mark.asyncio +async def test_overview_stats_admin_only(client: AsyncClient, member_user): + res = await client.get("/api/v1/overview-stats", headers=auth_headers(member_user)) + assert res.status_code == 403 diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index 25ab0ad..666f3db 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -9,7 +9,7 @@ @pytest.mark.asyncio async def test_create_project(client: AsyncClient, admin_user): - res = await client.post("/api/v1/projects/", json={ + res = await client.post("/api/v1/projects", json={ "name": "My NER Project", "type": "NER", }, headers=auth_headers(admin_user)) @@ -21,7 +21,7 @@ async def test_create_project(client: AsyncClient, admin_user): @pytest.mark.asyncio async def test_create_project_non_admin_fails(client: AsyncClient, member_user): - res = await client.post("/api/v1/projects/", json={ + res = await client.post("/api/v1/projects", json={ "name": "Unauthorized", "type": "NER", }, headers=auth_headers(member_user)) @@ -30,7 +30,7 @@ async def test_create_project_non_admin_fails(client: AsyncClient, member_user): @pytest.mark.asyncio async def test_list_projects(client: AsyncClient, admin_user, ner_project): - res = await client.get("/api/v1/projects/", headers=auth_headers(admin_user)) + res = await client.get("/api/v1/projects", headers=auth_headers(admin_user)) assert res.status_code == 200 projects = res.json() assert len(projects) >= 1 @@ -55,7 +55,7 @@ async def test_update_project(client: AsyncClient, admin_user, ner_project): @pytest.mark.asyncio async def test_delete_project(client: AsyncClient, admin_user): - create_res = await client.post("/api/v1/projects/", json={ + create_res = await client.post("/api/v1/projects", json={ "name": "To Delete", "type": "NER", }, headers=auth_headers(admin_user)) pid = create_res.json()["id"] @@ -68,10 +68,10 @@ async def test_delete_project(client: AsyncClient, admin_user): async def test_add_member(client: AsyncClient, admin_user, member_user, ner_project): res = await client.post(f"/api/v1/projects/{ner_project.id}/members", json={ "user_id": member_user.id, - "role": "MEMBER", + "role": "EDITOR", }, headers=auth_headers(admin_user)) assert res.status_code == 201 - assert res.json()["role"] == "MEMBER" + assert res.json()["role"] == "EDITOR" @pytest.mark.asyncio diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 64d8973..d22be50 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -32,5 +32,17 @@ services: - ./backend/app:/app/app command: uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + frontend: + build: + context: ./frontend + target: dev + ports: + - "5173:5173" + volumes: + - ./frontend:/app + - /app/node_modules + environment: + - VITE_BACKEND_HOST=backend + volumes: pgdata_dev: diff --git a/docker-compose.yml b/docker-compose.yml index 93cf395..23d3de4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,7 +22,12 @@ services: condition: service_healthy environment: DATABASE_URL: postgresql+asyncpg://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@db:5432/verselab_db - SECRET_KEY: ${SECRET_KEY:-dev-secret-key-not-for-production} + SECRET_KEY: ${SECRET_KEY} + API_V1_STR: ${API_V1_STR:-/api/v1} + CORS_ORIGINS: ${CORS_ORIGINS:-} + CORS_ORIGIN_REGEX: ${CORS_ORIGIN_REGEX:-} + RATE_LIMIT_DEFAULT: ${RATE_LIMIT_DEFAULT:-60/minute} + RATE_LIMIT_AUTH: ${RATE_LIMIT_AUTH:-10/minute} expose: - "8000" healthcheck: @@ -33,7 +38,11 @@ services: retries: 3 frontend: - build: ./frontend + build: + context: ./frontend + args: + - VITE_API_URL=${VITE_API_URL:-} + - VITE_WS_URL=${VITE_WS_URL:-} restart: unless-stopped depends_on: backend: diff --git a/frontend/.env.development b/frontend/.env.development index ad82eb2..9be4915 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -1 +1,3 @@ -VITE_API_URL=http://127.0.0.1:8000 +# Development environment settings +VITE_API_URL=/api/v1 +VITE_WS_URL=ws://localhost:8000/ws diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..df9544e --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,16 @@ +# VerseLab Frontend Environment Variables Template +# Copy this file to .env and fill in the values. + +# --- API Configuration --- +# The base URL for the backend API. +# Set to /api/v1 to match the backend's API_V1_STR setting. +VITE_API_URL=/api/v1 + +# --- WebSocket Configuration --- +# The base URL for the backend WebSocket server. +# For production, set to wss://your-domain.com/ws +# For local dev, you can use ws://localhost:8000/ws +VITE_WS_URL= + +# --- Development Settings --- +# VITE_PORT=5173 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index d17b67b..164a757 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -4,9 +4,25 @@ FROM node:22-alpine AS build WORKDIR /app COPY package.json package-lock.json* ./ RUN npm ci + +# Pass build arguments to Vite +ARG VITE_API_URL +ARG VITE_WS_URL +ENV VITE_API_URL=$VITE_API_URL +ENV VITE_WS_URL=$VITE_WS_URL + COPY . . RUN npm run build +# --- Development stage --- +FROM node:22-alpine AS dev +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install +COPY . . +EXPOSE 5173 +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] + # --- Production stage --- FROM nginx:alpine diff --git a/frontend/e2e/full-flow.spec.ts b/frontend/e2e/full-flow.spec.ts deleted file mode 100644 index c3c2373..0000000 --- a/frontend/e2e/full-flow.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { test, expect } from "@playwright/test"; - -test.describe("Full Flow: Setup -> Login -> Create -> Import -> Annotate -> Review -> Vote -> Export", () => { - test.describe("Setup", () => { - test.skip("first user registration creates admin account", async ({ page }) => { - // TODO: Navigate to /register, fill form, verify redirect to dashboard - }); - }); - - test.describe("Login", () => { - test.skip("admin can log in with valid credentials", async ({ page }) => { - // TODO: Navigate to /login, enter credentials, verify dashboard loads - }); - - test.skip("login fails with invalid credentials", async ({ page }) => { - // TODO: Navigate to /login, enter wrong password, verify error message - }); - }); - - test.describe("Create Project", () => { - test.skip("create a new NER project", async ({ page }) => { - // TODO: Navigate to project creation, fill name/type, submit, verify project appears - }); - - test.skip("create a new TRANSLATION project", async ({ page }) => { - // TODO: Same flow but with TRANSLATION type - }); - }); - - test.describe("Import", () => { - test.skip("import VerseBridge JSON into NER project", async ({ page }) => { - // TODO: Open project, go to import, upload ner_unannotated.json, verify tasks created - }); - - test.skip("import preview shows sample and counts before commit", async ({ page }) => { - // TODO: Upload file, verify preview modal, confirm import - }); - }); - - test.describe("Annotate", () => { - test.skip("annotate a task with NER entities", async ({ page }) => { - // TODO: Open task, select text, assign label, save annotation - }); - - test.skip("undo/redo entity selection", async ({ page }) => { - // TODO: Add entity, undo, verify removed, redo, verify restored - }); - }); - - test.describe("Review", () => { - test.skip("reviewer can approve an annotation", async ({ page }) => { - // TODO: Switch to reviewer, open review tab, approve annotation - }); - - test.skip("reviewer can reject with reason", async ({ page }) => { - // TODO: Open review tab, reject annotation, provide reason - }); - }); - - test.describe("Vote", () => { - test.skip("vote on competing annotations", async ({ page }) => { - // TODO: Open voting panel, compare annotations, cast vote - }); - }); - - test.describe("Export", () => { - test.skip("export reviewed annotations as VerseBridge JSON", async ({ page }) => { - // TODO: Navigate to export, download, verify JSON format matches JSONDataNERType - }); - - test.skip("export translation as INI format", async ({ page }) => { - // TODO: Export translation project in INI-game format - }); - }); -}); diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 28237a5..dc2cd9d 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -1,3 +1,4 @@ +import { defineConfig, globalIgnores } from 'eslint/config'; import globals from 'globals'; import js from '@eslint/js'; import tseslint from '@typescript-eslint/eslint-plugin'; @@ -8,18 +9,20 @@ import jsxA11y from 'eslint-plugin-jsx-a11y'; import prettier from 'eslint-plugin-prettier'; import pluginMobx from 'eslint-plugin-mobx'; -export default [ - // 1. Базовые правила JS +export default defineConfig([ + // 1. Base JS rules { ...js.configs.recommended, }, - // 2. TypeScript + // 2. TypeScript (strict + stylistic) { files: ['**/*.{ts,tsx}'], languageOptions: { parser, parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, sourceType: 'module', ecmaVersion: 'latest', }, @@ -28,7 +31,14 @@ export default [ '@typescript-eslint': tseslint, }, rules: { - ...tseslint.configs.recommended.rules, + ...tseslint.configs['strict-type-checked'].rules, + ...tseslint.configs['stylistic-type-checked'].rules, + // TypeScript handles no-undef better than ESLint for TS files + 'no-undef': 'off', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-non-null-assertion': 'warn', + '@typescript-eslint/no-confusing-void-expression': 'off', + '@typescript-eslint/no-floating-promises': 'warn', }, }, @@ -48,13 +58,17 @@ export default [ ...jsxA11y.configs.recommended.rules, 'react-hooks/rules-of-hooks': 'error', 'react-hooks/exhaustive-deps': 'warn', + 'react/react-in-jsx-scope': 'off', }, }, // 4. MobX - pluginMobx.flatConfigs.recommended, + { + ...pluginMobx.flatConfigs.recommended, + files: ['**/*.{js,jsx,ts,tsx}'], + }, - // 5. Среда выполнения - браузер + ES2020 + // 5. Global language options { languageOptions: { ecmaVersion: 'latest', @@ -72,8 +86,18 @@ export default [ rules: { 'prettier/prettier': 'error', 'react/jsx-uses-react': 'off', - 'react/react-in-jsx-scope': 'off', 'react/prop-types': 'off', }, }, -]; + + // 7. Ignores + globalIgnores([ + 'dist/', + 'node_modules/', + 'build/', + '.vite/', + '.eslintcache/', + 'coverage/', + '*.config.js', + ]), +]); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c7decea..b219a08 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,7 +11,7 @@ "@ant-design/charts": "^2.6.7", "@ant-design/icons": "^6.1.0", "@tanstack/react-query": "^5.90.21", - "antd": "^5.29.3", + "antd": "^6.3.3", "axios": "^1.13.2", "framer-motion": "^12.36.0", "mobx": "^6.15.0", @@ -23,7 +23,7 @@ }, "devDependencies": { "@eslint/js": "^9.36.0", - "@playwright/test": "^1.52.0", + "@playwright/test": "^1.58.2", "@types/eslint-plugin-mobx": "^0.0.0", "@types/node": "^24.6.0", "@types/react": "^19.1.16", @@ -40,6 +40,7 @@ "eslint-plugin-react-refresh": "^0.4.22", "globals": "^16.4.0", "prettier": "3.6.2", + "sass": "^1.98.0", "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.7" @@ -83,17 +84,17 @@ } }, "node_modules/@ant-design/cssinjs": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", - "integrity": "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz", + "integrity": "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", - "classnames": "^2.3.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1", "csstype": "^3.1.3", - "rc-util": "^5.35.0", "stylis": "^4.3.4" }, "peerDependencies": { @@ -102,18 +103,18 @@ } }, "node_modules/@ant-design/cssinjs-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz", - "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-2.1.2.tgz", + "integrity": "sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA==", "license": "MIT", "dependencies": { - "@ant-design/cssinjs": "^1.21.0", + "@ant-design/cssinjs": "^2.1.2", "@babel/runtime": "^7.23.2", - "rc-util": "^5.38.0" + "@rc-component/util": "^1.4.0" }, "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "react": ">=18", + "react-dom": ">=18" } }, "node_modules/@ant-design/fast-color": { @@ -200,19 +201,19 @@ } }, "node_modules/@ant-design/react-slick": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz", - "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-2.0.0.tgz", + "integrity": "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.10.4", - "classnames": "^2.2.5", + "@babel/runtime": "^7.28.4", + "clsx": "^2.1.1", "json2mq": "^0.2.0", - "resize-observer-polyfill": "^1.5.1", "throttle-debounce": "^5.0.0" }, "peerDependencies": { - "react": ">=16.9.0" + "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/@antv/algorithm": { @@ -1648,219 +1649,152 @@ "node": ">= 8" } }, - "node_modules/@pkgr/core": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", - "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/pkgr" - } - }, - "node_modules/@playwright/test": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", - "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.58.2" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@rc-component/async-validator": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.0.tgz", - "integrity": "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.24.4" - }, - "engines": { - "node": ">=14.x" - } - }, - "node_modules/@rc-component/color-picker": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-2.0.1.tgz", - "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==", - "license": "MIT", - "dependencies": { - "@ant-design/fast-color": "^2.0.6", - "@babel/runtime": "^7.23.6", - "classnames": "^2.2.6", - "rc-util": "^5.38.1" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/@rc-component/color-picker/node_modules/@ant-design/fast-color": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", - "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "hasInstallScript": true, "license": "MIT", + "optional": true, "dependencies": { - "@babel/runtime": "^7.24.7" + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" }, "engines": { - "node": ">=8.x" - } - }, - "node_modules/@rc-component/context": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz", - "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "rc-util": "^5.27.0" + "node": ">= 10.0.0" }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/@rc-component/mini-decimal": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.1.tgz", - "integrity": "sha512-tqYY/QVH0ok3+m7vPVx+TZ3ptR+XJotu9Zz2RPpts899oUNRQjb5oKyVVXA5CoQ+fVHvzwCcuIPz9PVYhsXcqQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" }, - "engines": { - "node": ">=8.x" - } - }, - "node_modules/@rc-component/mutate-observer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", - "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==", + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.0", - "classnames": "^2.3.2", - "rc-util": "^5.24.4" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=8.x" + "node": ">= 10.0.0" }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@rc-component/portal": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz", - "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.0", - "classnames": "^2.3.2", - "rc-util": "^5.24.4" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=8.x" + "node": ">= 10.0.0" }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@rc-component/qrcode": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.1.tgz", - "integrity": "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA==", + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.24.7" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=8.x" + "node": ">= 10.0.0" }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@rc-component/tour": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.1.tgz", - "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==", + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.0", - "@rc-component/portal": "^1.0.0-9", - "@rc-component/trigger": "^2.0.0", - "classnames": "^2.3.2", - "rc-util": "^5.24.4" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=8.x" + "node": ">= 10.0.0" }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@rc-component/trigger": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.3.1.tgz", - "integrity": "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==", + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.23.2", - "@rc-component/portal": "^1.1.0", - "classnames": "^2.3.2", - "rc-motion": "^2.0.0", - "rc-resize-observer": "^1.3.1", - "rc-util": "^5.44.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/@rc-component/util": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.9.0.tgz", - "integrity": "sha512-5uW6AfhIigCWeEQDthTozlxiT4Prn6xYQWeO0xokjcaa186OtwPRHBZJ2o0T0FhbjGhZ3vXdbkv0sx3gAYW7Vg==", - "license": "MIT", - "dependencies": { - "is-mobile": "^5.0.0", - "react-is": "^18.2.0" + "node": ">= 10.0.0" }, - "peerDependencies": { - "react": ">=18.0.0", - "react-dom": ">=18.0.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.38", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz", - "integrity": "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", - "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", "cpu": [ "arm" ], @@ -1868,13 +1802,20 @@ "license": "MIT", "optional": true, "os": [ - "android" - ] + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", - "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", "cpu": [ "arm64" ], @@ -1882,13 +1823,20 @@ "license": "MIT", "optional": true, "os": [ - "android" - ] + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", - "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", "cpu": [ "arm64" ], @@ -1896,13 +1844,20 @@ "license": "MIT", "optional": true, "os": [ - "darwin" - ] + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", - "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", "cpu": [ "x64" ], @@ -1910,14 +1865,924 @@ "license": "MIT", "optional": true, "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", - "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", - "cpu": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rc-component/async-validator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.0.tgz", + "integrity": "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/cascader": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@rc-component/cascader/-/cascader-1.14.0.tgz", + "integrity": "sha512-Ip9356xwZUR2nbW5PRVGif4B/bDve4pLa/N+PGbvBaTnjbvmN4PFMBGQSmlDlzKP1ovxaYMvwF/dI9lXNLT4iQ==", + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.6.0", + "@rc-component/tree": "~1.2.0", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/checkbox": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/checkbox/-/checkbox-2.0.0.tgz", + "integrity": "sha512-3CXGPpAR9gsPKeO2N78HAPOzU30UdemD6HGJoWVJOpa6WleaGB5kzZj3v6bdTZab31YuWgY/RxV3VKPctn0DwQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/collapse": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/collapse/-/collapse-1.2.0.tgz", + "integrity": "sha512-ZRYSKSS39qsFx93p26bde7JUZJshsUBEQRlRXPuJYlAiNX0vyYlF5TsAm8JZN3LcF8XvKikdzPbgAtXSbkLUkw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-3.1.1.tgz", + "integrity": "sha512-OHaCHLHszCegdXmIq2ZRIZBN/EtpT6Wm8SG/gpzLATHbVKc/avvuKi+zlOuk05FTWvgaMmpxAko44uRJ3M+2pg==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.1", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-2.0.1.tgz", + "integrity": "sha512-HyZbYm47s/YqtP6pKXNMjPEMaukyg7P0qVfgMLzr7YiFNMHbK2fKTAGzms9ykfGHSfyf75nBbgWw+hHkp+VImw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/dialog": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/@rc-component/dialog/-/dialog-1.8.4.tgz", + "integrity": "sha512-Ay6PM7phkTkquplG8fWfUGFZ2GTLx9diTl4f0d8Eqxd7W1u1KjE9AQooFQHOHnhZf0Ya3z51+5EKCWHmt/dNEw==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.3", + "@rc-component/portal": "^2.1.0", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/drawer": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@rc-component/drawer/-/drawer-1.4.2.tgz", + "integrity": "sha512-1ib+fZEp6FBu+YvcIktm+nCQ+Q+qIpwpoaJH6opGr4ofh2QMq+qdr5DLC4oCf5qf3pcWX9lUWPYX652k4ini8Q==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.1.3", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/dropdown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rc-component/dropdown/-/dropdown-1.0.2.tgz", + "integrity": "sha512-6PY2ecUSYhDPhkNHHb4wfeAya04WhpmUSKzdR60G+kMNVUCX2vjT/AgTS0Lz0I/K6xrPMJ3enQbwVpeN3sHCgg==", + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/@rc-component/form": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.7.2.tgz", + "integrity": "sha512-5C90rXH7aZvvvxB4M5ew+QxROvimdL/lqhSshR8NsyiR7HKOoGQYSitxdfENnH6/0KNFxEy2ranVe2LrTnHZIw==", + "license": "MIT", + "dependencies": { + "@rc-component/async-validator": "^5.1.0", + "@rc-component/util": "^1.6.2", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/image": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@rc-component/image/-/image-1.6.0.tgz", + "integrity": "sha512-tSfn2ZE/oP082g4QIOxeehkmgnXB7R+5AFj/lIFr4k7pEuxHBdyGIq9axoCY9qea8NN0DY6p4IB/F07tLqaT5A==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/portal": "^2.1.2", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/input": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/input/-/input-1.1.2.tgz", + "integrity": "sha512-Q61IMR47piUBudgixJ30CciKIy9b1H95qe7GgEKOmSJVJXvFRWJllJfQry9tif+MX2cWFXWJf/RXz4kaCeq/Fg==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/input-number": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@rc-component/input-number/-/input-number-1.6.2.tgz", + "integrity": "sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w==", + "license": "MIT", + "dependencies": { + "@rc-component/mini-decimal": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mentions": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@rc-component/mentions/-/mentions-1.6.0.tgz", + "integrity": "sha512-KIkQNP6habNuTsLhUv0UGEOwG67tlmE7KNIJoQZZNggEZl5lQJTytFDb69sl5CK3TDdISCTjKP3nGEBKgT61CQ==", + "license": "MIT", + "dependencies": { + "@rc-component/input": "~1.1.0", + "@rc-component/menu": "~1.2.0", + "@rc-component/textarea": "~1.1.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/menu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/menu/-/menu-1.2.0.tgz", + "integrity": "sha512-VWwDuhvYHSnTGj4n6bV3ISrLACcPAzdPOq3d0BzkeiM5cve8BEYfvkEhNoM0PLzv51jpcejeyrLXeMVIJ+QJlg==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.3.tgz", + "integrity": "sha512-bk/FJ09fLf+NLODMAFll6CfYrHPBioTedhW6lxDBuuWucJEqFUd4l/D/5JgIi3dina6sYahB8iuPAZTNz2pMxw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/motion": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@rc-component/motion/-/motion-1.3.1.tgz", + "integrity": "sha512-Wo1mkd0tCcHtvYvpPOmlYJz546z16qlsiwaygmW7NPJpOZOF9GBjhGzdzZSsC2lEJ1IUkWLF4gMHlRA1aSA+Yw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-2.0.1.tgz", + "integrity": "sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/notification": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/notification/-/notification-1.2.0.tgz", + "integrity": "sha512-OX3J+zVU7rvoJCikjrfW7qOUp7zlDeFBK2eA3SFbGSkDqo63Sl4Ss8A04kFP+fxHSxMDIS9jYVEZtU1FNCFuBA==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/overflow": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/overflow/-/overflow-1.0.0.tgz", + "integrity": "sha512-GSlBeoE0XTBi5cf3zl8Qh7Uqhn7v8RrlJ8ajeVpEkNe94HWy5l5BQ0Mwn2TVUq9gdgbfEMUmTX7tJFAg7mz0Rw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/pagination": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/pagination/-/pagination-1.2.0.tgz", + "integrity": "sha512-YcpUFE8dMLfSo6OARJlK6DbHHvrxz7pMGPGmC/caZSJJz6HRKHC1RPP001PRHCvG9Z/veD039uOQmazVuLJzlw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/picker": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@rc-component/picker/-/picker-1.9.1.tgz", + "integrity": "sha512-9FBYYsvH3HMLICaPDA/1Th5FLaDkFa7qAtangIdlhKb3ZALaR745e9PsOhheJb6asS4QXc12ffiAcjdkZ4C5/g==", + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/trigger": "^3.6.15", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/@rc-component/portal": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-2.2.0.tgz", + "integrity": "sha512-oc6FlA+uXCMiwArHsJyHcIkX4q6uKyndrPol2eWX8YPkAnztHOPsFIRtmWG4BMlGE5h7YIRE3NiaJ5VS8Lb1QQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/progress": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rc-component/progress/-/progress-1.0.2.tgz", + "integrity": "sha512-WZUnH9eGxH1+xodZKqdrHke59uyGZSWgj5HBM5Kwk5BrTMuAORO7VJ2IP5Qbm9aH3n9x3IcesqHHR0NWPBC7fQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.1.tgz", + "integrity": "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/rate": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/rate/-/rate-1.0.1.tgz", + "integrity": "sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/resize-observer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/resize-observer/-/resize-observer-1.1.1.tgz", + "integrity": "sha512-NfXXMmiR+SmUuKE1NwJESzEUYUFWIDUn2uXpxCTOLwiRUUakd62DRNFjRJArgzyFW8S5rsL4aX5XlyIXyC/vRA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/segmented": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@rc-component/segmented/-/segmented-1.3.0.tgz", + "integrity": "sha512-5J/bJ01mbDnoA6P/FW8SxUvKn+OgUSTZJPzCNnTBntG50tzoP7DydGhqxp7ggZXZls7me3mc2EQDXakU3iTVFg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/select": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.6.15.tgz", + "integrity": "sha512-SyVCWnqxCQZZcQvQJ/CxSjx2bGma6ds/HtnpkIfZVnt6RoEgbqUmHgD6vrzNarNXwbLXerwVzWwq8F3d1sst7g==", + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.3.0", + "@rc-component/virtual-list": "^1.0.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/slider": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/slider/-/slider-1.0.1.tgz", + "integrity": "sha512-uDhEPU1z3WDfCJhaL9jfd2ha/Eqpdfxsn0Zb0Xcq1NGQAman0TWaR37OWp2vVXEOdV2y0njSILTMpTfPV1454g==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/steps": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rc-component/steps/-/steps-1.2.2.tgz", + "integrity": "sha512-/yVIZ00gDYYPHSY0JP+M+s3ZvuXLu2f9rEjQqiUDs7EcYsUYrpJ/1bLj9aI9R7MBR3fu/NGh6RM9u2qGfqp+Nw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/switch": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rc-component/switch/-/switch-1.0.3.tgz", + "integrity": "sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/table": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@rc-component/table/-/table-1.9.1.tgz", + "integrity": "sha512-FVI5ZS/GdB3BcgexfCYKi3iHhZS3Fr59EtsxORszYGrfpH1eWr33eDNSYkVfLI6tfJ7vftJDd9D5apfFWqkdJg==", + "license": "MIT", + "dependencies": { + "@rc-component/context": "^2.0.1", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.1.0", + "@rc-component/virtual-list": "^1.0.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tabs": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@rc-component/tabs/-/tabs-1.7.0.tgz", + "integrity": "sha512-J48cs2iBi7Ho3nptBxxIqizEliUC+ExE23faspUQKGQ550vaBlv3aGF8Epv/UB1vFWeoJDTW/dNzgIU0Qj5i/w==", + "license": "MIT", + "dependencies": { + "@rc-component/dropdown": "~1.0.0", + "@rc-component/menu": "~1.2.0", + "@rc-component/motion": "^1.1.3", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/textarea": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/textarea/-/textarea-1.1.2.tgz", + "integrity": "sha512-9rMUEODWZDMovfScIEHXWlVZuPljZ2pd1LKNjslJVitn4SldEzq5vO1CL3yy3Dnib6zZal2r2DPtjy84VVpF6A==", + "license": "MIT", + "dependencies": { + "@rc-component/input": "~1.1.0", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tooltip": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/tooltip/-/tooltip-1.4.0.tgz", + "integrity": "sha512-8Rx5DCctIlLI4raR0I0xHjVTf1aF48+gKCNeAAo5bmF5VoR5YED+A/XEqzXv9KKqrJDRcd3Wndpxh2hyzrTtSg==", + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.7.1", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-2.3.0.tgz", + "integrity": "sha512-K04K9r32kUC+auBSQfr+Fss4SpSIS9JGe56oq/ALAX0p+i2ylYOI1MgR83yBY7v96eO6ZFXcM/igCQmubps0Ow==", + "license": "MIT", + "dependencies": { + "@rc-component/portal": "^2.2.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.7.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tree": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rc-component/tree/-/tree-1.2.4.tgz", + "integrity": "sha512-5Gli43+m4R7NhpYYz3Z61I6LOw9yI6CNChxgVtvrO6xB1qML7iE6QMLVMB3+FTjo2yF6uFdAHtqWPECz/zbX5w==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/util": "^1.8.1", + "@rc-component/virtual-list": "^1.0.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/tree-select": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@rc-component/tree-select/-/tree-select-1.8.0.tgz", + "integrity": "sha512-iYsPq3nuLYvGqdvFAW+l+I9ASRIOVbMXyA8FGZg2lGym/GwkaWeJGzI4eJ7c9IOEhRj0oyfIN4S92Fl3J05mjQ==", + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.6.0", + "@rc-component/tree": "~1.2.0", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/trigger": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-3.9.0.tgz", + "integrity": "sha512-X8btpwfrT27AgrZVOz4swclhEHTZcqaHeQMXXBgveagOiakTa36uObXbdwerXffgV8G9dH1fAAE0DHtVQs8EHg==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.2.0", + "@rc-component/resize-observer": "^1.1.1", + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/upload": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/upload/-/upload-1.1.0.tgz", + "integrity": "sha512-LIBV90mAnUE6VK5N4QvForoxZc4XqEYZimcp7fk+lkE4XwHHyJWxpIXQQwMU8hJM+YwBbsoZkGksL1sISWHQxw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/util": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.9.0.tgz", + "integrity": "sha512-5uW6AfhIigCWeEQDthTozlxiT4Prn6xYQWeO0xokjcaa186OtwPRHBZJ2o0T0FhbjGhZ3vXdbkv0sx3gAYW7Vg==", + "license": "MIT", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/virtual-list": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.0.2.tgz", + "integrity": "sha512-uvTol/mH74FYsn5loDGJxo+7kjkO4i+y4j87Re1pxJBs0FaeuMuLRzQRGaXwnMcV1CxpZLi2Z56Rerj2M00fjQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz", + "integrity": "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", + "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", + "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", + "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", + "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", + "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", + "cpu": [ "arm64" ], "dev": true, @@ -3028,58 +3893,57 @@ } }, "node_modules/antd": { - "version": "5.29.3", - "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz", - "integrity": "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==", - "license": "MIT", - "dependencies": { - "@ant-design/colors": "^7.2.1", - "@ant-design/cssinjs": "^1.23.0", - "@ant-design/cssinjs-utils": "^1.1.3", - "@ant-design/fast-color": "^2.0.6", - "@ant-design/icons": "^5.6.1", - "@ant-design/react-slick": "~1.1.2", - "@babel/runtime": "^7.26.0", - "@rc-component/color-picker": "~2.0.1", - "@rc-component/mutate-observer": "^1.1.0", - "@rc-component/qrcode": "~1.1.0", - "@rc-component/tour": "~1.15.1", - "@rc-component/trigger": "^2.3.0", - "classnames": "^2.5.1", - "copy-to-clipboard": "^3.3.3", + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/antd/-/antd-6.3.3.tgz", + "integrity": "sha512-T8FAQelw36zS96cZw2U/qEjpYny5yFc7hg+1W7DvVr8xMoSXWvyB8WvmiDVH0nS0LPYV4y2sxetsJoGZt7rhhw==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/cssinjs": "^2.1.2", + "@ant-design/cssinjs-utils": "^2.1.2", + "@ant-design/fast-color": "^3.0.1", + "@ant-design/icons": "^6.1.0", + "@ant-design/react-slick": "~2.0.0", + "@babel/runtime": "^7.28.4", + "@rc-component/cascader": "~1.14.0", + "@rc-component/checkbox": "~2.0.0", + "@rc-component/collapse": "~1.2.0", + "@rc-component/color-picker": "~3.1.1", + "@rc-component/dialog": "~1.8.4", + "@rc-component/drawer": "~1.4.2", + "@rc-component/dropdown": "~1.0.2", + "@rc-component/form": "~1.7.2", + "@rc-component/image": "~1.6.0", + "@rc-component/input": "~1.1.2", + "@rc-component/input-number": "~1.6.2", + "@rc-component/mentions": "~1.6.0", + "@rc-component/menu": "~1.2.0", + "@rc-component/motion": "^1.3.1", + "@rc-component/mutate-observer": "^2.0.1", + "@rc-component/notification": "~1.2.0", + "@rc-component/pagination": "~1.2.0", + "@rc-component/picker": "~1.9.1", + "@rc-component/progress": "~1.0.2", + "@rc-component/qrcode": "~1.1.1", + "@rc-component/rate": "~1.0.1", + "@rc-component/resize-observer": "^1.1.1", + "@rc-component/segmented": "~1.3.0", + "@rc-component/select": "~1.6.14", + "@rc-component/slider": "~1.0.1", + "@rc-component/steps": "~1.2.2", + "@rc-component/switch": "~1.0.3", + "@rc-component/table": "~1.9.1", + "@rc-component/tabs": "~1.7.0", + "@rc-component/textarea": "~1.1.2", + "@rc-component/tooltip": "~1.4.0", + "@rc-component/tour": "~2.3.0", + "@rc-component/tree": "~1.2.4", + "@rc-component/tree-select": "~1.8.0", + "@rc-component/trigger": "^3.9.0", + "@rc-component/upload": "~1.1.0", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1", "dayjs": "^1.11.11", - "rc-cascader": "~3.34.0", - "rc-checkbox": "~3.5.0", - "rc-collapse": "~3.9.0", - "rc-dialog": "~9.6.0", - "rc-drawer": "~7.3.0", - "rc-dropdown": "~4.2.1", - "rc-field-form": "~2.7.1", - "rc-image": "~7.12.0", - "rc-input": "~1.8.0", - "rc-input-number": "~9.5.0", - "rc-mentions": "~2.20.0", - "rc-menu": "~9.16.1", - "rc-motion": "^2.9.5", - "rc-notification": "~5.6.4", - "rc-pagination": "~5.1.0", - "rc-picker": "~4.11.3", - "rc-progress": "~4.0.0", - "rc-rate": "~2.13.1", - "rc-resize-observer": "^1.4.3", - "rc-segmented": "~2.7.0", - "rc-select": "~14.16.8", - "rc-slider": "~11.1.9", - "rc-steps": "~6.0.1", - "rc-switch": "~4.1.0", - "rc-table": "~7.54.0", - "rc-tabs": "~15.7.0", - "rc-textarea": "~1.10.2", - "rc-tooltip": "~6.4.0", - "rc-tree": "~5.13.1", - "rc-tree-select": "~5.27.0", - "rc-upload": "~4.11.0", - "rc-util": "^5.44.4", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, @@ -3088,49 +3952,8 @@ "url": "https://opencollective.com/ant-design" }, "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/antd/node_modules/@ant-design/colors": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", - "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", - "license": "MIT", - "dependencies": { - "@ant-design/fast-color": "^2.0.6" - } - }, - "node_modules/antd/node_modules/@ant-design/fast-color": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", - "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.24.7" - }, - "engines": { - "node": ">=8.x" - } - }, - "node_modules/antd/node_modules/@ant-design/icons": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", - "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", - "license": "MIT", - "dependencies": { - "@ant-design/colors": "^7.0.0", - "@ant-design/icons-svg": "^4.4.0", - "@babel/runtime": "^7.24.8", - "classnames": "^2.2.6", - "rc-util": "^5.31.1" - }, - "engines": { - "node": ">=8" - }, - "peerDependencies": { - "react": ">=16.0.0", - "react-dom": ">=16.0.0" + "react": ">=18.0.0", + "react-dom": ">=18.0.0" } }, "node_modules/argparse": { @@ -3571,6 +4394,22 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -3581,12 +4420,6 @@ "node": ">=6.0" } }, - "node_modules/classnames": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "license": "MIT" - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3681,15 +4514,6 @@ "node": ">=18" } }, - "node_modules/copy-to-clipboard": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", - "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", - "license": "MIT", - "dependencies": { - "toggle-selection": "^1.0.6" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4175,6 +4999,17 @@ "node": ">=0.4.0" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -5274,247 +6109,18 @@ "node_modules/graphlib": { "version": "2.1.8", "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", - "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", - "license": "MIT", - "dependencies": { - "lodash": "^4.17.15" - } - }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/html2canvas": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", - "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", - "license": "MIT", - "dependencies": { - "css-line-break": "^2.1.0", - "text-segmentation": "^1.0.3" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/is-any-array": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-any-array/-/is-any-array-2.0.1.tgz", - "integrity": "sha512-UtilS7hLRu++wb/WBAw9bNuP1Eg04Ivn1vERJck8zJthEvXCBEBpGR/33u/xLKWEQf95803oalHrVDptcAvFdQ==", - "license": "MIT" - }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-arrayish": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", - "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", - "license": "MIT" - }, - "node_modules/is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15" } }, - "node_modules/is-bigint": { + "node_modules/has-bigints": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", "dev": true, "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, "engines": { "node": ">= 0.4" }, @@ -5522,44 +6128,36 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "dev": true, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "es-define-property": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "dunder-proto": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -5568,17 +6166,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", - "dev": true, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" - }, "engines": { "node": ">= 0.4" }, @@ -5586,15 +6178,13 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "dev": true, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" + "has-symbols": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -5603,181 +6193,127 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", - "dev": true, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.3" + "function-bind": "^1.1.2" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "dev": true, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8.0.0" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "license": "MIT", "dependencies": { - "is-extglob": "^2.1.1" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" } }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 4" } }, - "node_modules/is-mobile": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz", - "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", + "node_modules/immutable": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "dev": true, "license": "MIT" }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, "engines": { - "node": ">= 0.4" + "node": ">=6" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.8.19" } }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-set": { + "node_modules/internmap": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, - "license": "MIT", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=12" } }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "node_modules/is-any-array": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-any-array/-/is-any-array-2.0.1.tgz", + "integrity": "sha512-UtilS7hLRu++wb/WBAw9bNuP1Eg04Ivn1vERJck8zJthEvXCBEBpGR/33u/xLKWEQf95803oalHrVDptcAvFdQ==", + "license": "MIT" }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "dev": true, "license": "MIT", "dependencies": { + "call-bind": "^1.0.8", "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" + "get-intrinsic": "^1.2.6" }, "engines": { "node": ">= 0.4" @@ -5786,15 +6322,23 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" }, "engines": { @@ -5804,14 +6348,14 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", "dev": true, "license": "MIT", "dependencies": { - "which-typed-array": "^1.1.16" + "has-bigints": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -5820,12 +6364,16 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, "engines": { "node": ">= 0.4" }, @@ -5833,15 +6381,12 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, "engines": { "node": ">= 0.4" }, @@ -5849,15 +6394,14 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -5866,668 +6410,546 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/iterator.prototype": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", - "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", "dev": true, "license": "MIT", "dependencies": { - "define-data-property": "^1.1.4", - "es-object-atoms": "^1.0.0", + "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", - "get-proto": "^1.0.0", - "has-symbols": "^1.1.0", - "set-function-name": "^2.0.2" + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" - } - }, - "node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" }, - "engines": { - "node": ">= 10.13.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "has-flag": "^4.0.0" + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, "engines": { - "node": ">=6" + "node": ">=0.10.0" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT", - "peer": true - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", "dev": true, - "license": "MIT" - }, - "node_modules/json2mq": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", - "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", "license": "MIT", "dependencies": { - "string-convert": "^0.2.0" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "license": "MIT", - "bin": { - "json5": "lib/cli.js" + "call-bound": "^1.0.3" }, "engines": { - "node": ">=6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jsx-ast-utils": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", - "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "dev": true, "license": "MIT", "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "object.assign": "^4.1.4", - "object.values": "^1.1.6" + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { - "node": ">=4.0" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/language-subtag-registry": { - "version": "0.3.23", - "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", - "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/language-tags": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", - "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", "dependencies": { - "language-subtag-registry": "^0.3.20" + "is-extglob": "^2.1.1" }, "engines": { - "node": ">=0.10" + "node": ">=0.10.0" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", "dev": true, "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, "engines": { - "node": ">= 0.8.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/loader-runner": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", - "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", + "node_modules/is-mobile": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz", + "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", + "license": "MIT" + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=6.11.5" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/loader-utils": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", - "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^2.1.2" - }, "engines": { - "node": ">=8.9.0" + "node": ">=0.12.0" } }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", "dev": true, "license": "MIT", "dependencies": { - "p-locate": "^5.0.0" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "dev": true, "license": "MIT", "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "license": "MIT", - "peer": true - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "dev": true, "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "call-bound": "^1.0.3" }, "engines": { - "node": ">=8.6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, "engines": { - "node": ">= 0.6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "brace-expansion": "^1.1.7" + "which-typed-array": "^1.1.16" }, "engines": { - "node": "*" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/ml-array-max": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/ml-array-max/-/ml-array-max-1.2.4.tgz", - "integrity": "sha512-BlEeg80jI0tW6WaPyGxf5Sa4sqvcyY6lbSn5Vcv44lp1I2GR6AWojfUvLnGTNsIXrZ8uqWmo8VcG1WpkI2ONMQ==", + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, "license": "MIT", - "dependencies": { - "is-any-array": "^2.0.0" + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/ml-array-min": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/ml-array-min/-/ml-array-min-1.2.3.tgz", - "integrity": "sha512-VcZ5f3VZ1iihtrGvgfh/q0XlMobG6GQ8FsNyQXD3T+IlstDv85g8kfV0xUG1QPRO/t21aukaJowDzMTc7j5V6Q==", + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, "license": "MIT", "dependencies": { - "is-any-array": "^2.0.0" + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/ml-array-rescale": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/ml-array-rescale/-/ml-array-rescale-1.3.7.tgz", - "integrity": "sha512-48NGChTouvEo9KBctDfHC3udWnQKNKEWN0ziELvY3KG25GR5cA8K8wNVzracsqSW1QEkAXjTNx+ycgAv06/1mQ==", + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, "license": "MIT", "dependencies": { - "is-any-array": "^2.0.0", - "ml-array-max": "^1.2.4", - "ml-array-min": "^1.2.3" + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/ml-matrix": { - "version": "6.12.1", - "resolved": "https://registry.npmjs.org/ml-matrix/-/ml-matrix-6.12.1.tgz", - "integrity": "sha512-TJ+8eOFdp+INvzR4zAuwBQJznDUfktMtOB6g/hUcGh3rcyjxbz4Te57Pgri8Q9bhSQ7Zys4IYOGhFdnlgeB6Lw==", + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, "license": "MIT", "dependencies": { - "is-any-array": "^2.0.1", - "ml-array-rescale": "^1.3.7" + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" } }, - "node_modules/mobx": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/mobx/-/mobx-6.15.0.tgz", - "integrity": "sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g==", + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mobx" + "peer": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" } }, - "node_modules/mobx-react-lite": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-4.1.1.tgz", - "integrity": "sha512-iUxiMpsvNraCKXU+yPotsOncNNmyeS2B5DKL+TL6Tar/xm+wwNJAubJmtRSeAoYawdZqwv8Z/+5nPRHeQxTiXg==", + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", + "peer": true, "dependencies": { - "use-sync-external-store": "^1.4.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mobx" + "has-flag": "^4.0.0" }, - "peerDependencies": { - "mobx": "^6.9.0", - "react": "^16.8.0 || ^17 || ^18 || ^19" + "engines": { + "node": ">=10" }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - } + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/motion-dom": { - "version": "12.36.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.36.0.tgz", - "integrity": "sha512-Ep1pq8P88rGJ75om8lTCA13zqd7ywPGwCqwuWwin6BKc0hMLkVfcS6qKlRqEo2+t0DwoUcgGJfXwaiFn4AOcQA==", + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, "license": "MIT", "dependencies": { - "motion-utils": "^12.36.0" + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/motion-utils": { - "version": "12.36.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", - "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", "bin": { - "nanoid": "bin/nanoid.cjs" + "jsesc": "bin/jsesc" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=6" } }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, "license": "MIT" }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT", "peer": true }, - "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", - "license": "MIT" - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } + "license": "MIT" }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "license": "MIT" }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "string-convert": "^0.2.0" } }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" + "bin": { + "json5": "lib/cli.js" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=6" } }, - "node_modules/object.entries": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", - "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.1.1" + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" }, "engines": { - "node": ">= 0.4" + "node": ">=4.0" } }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "json-buffer": "3.0.1" } }, - "node_modules/object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" + "language-subtag-registry": "^0.3.20" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10" } }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" + "type-check": "~0.4.0" }, "engines": { "node": ">= 0.8.0" } }, - "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", - "dev": true, + "node_modules/loader-runner": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.6", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" - }, + "peer": true, "engines": { - "node": ">= 0.4" + "node": ">=6.11.5" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", "license": "MIT", + "peer": true, "dependencies": { - "yocto-queue": "^0.1.0" + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8.9.0" } }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { - "p-limit": "^3.0.2" + "p-locate": "^5.0.0" }, "engines": { "node": ">=10" @@ -6536,864 +6958,686 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, - "node_modules/pdfast": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/pdfast/-/pdfast-0.2.0.tgz", - "integrity": "sha512-cq6TTu6qKSFUHwEahi68k/kqN2mfepjkGrG9Un70cgdRRKLKY6Rf8P8uvP2NvZktaQZNF3YE7agEkLj0vGK9bA==", + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, "license": "MIT" }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", - "dev": true, - "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.2" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" + "js-tokens": "^3.0.0 || ^4.0.0" }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", - "dev": true, - "license": "Apache-2.0", "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" + "loose-envify": "cli.js" } }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" } }, - "node_modules/possible-typed-array-names": { + "node_modules/math-intrinsics": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "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" - }, + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", "engines": { - "node": "^10 || ^12 || >=14" + "node": ">= 0.4" } }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "license": "MIT" + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT", + "peer": true }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.8.0" + "node": ">= 8" } }, - "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" }, "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "node": ">=8.6" } }, - "node_modules/prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", - "dev": true, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", - "dependencies": { - "fast-diff": "^1.1.2" - }, "engines": { - "node": ">=6.0.0" + "node": ">= 0.6" } }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" } }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, - "license": "MIT" - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, + "node_modules/ml-array-max": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/ml-array-max/-/ml-array-max-1.2.4.tgz", + "integrity": "sha512-BlEeg80jI0tW6WaPyGxf5Sa4sqvcyY6lbSn5Vcv44lp1I2GR6AWojfUvLnGTNsIXrZ8uqWmo8VcG1WpkI2ONMQ==", "license": "MIT", - "engines": { - "node": ">=6" + "dependencies": { + "is-any-array": "^2.0.0" } }, - "node_modules/queue-microtask": { + "node_modules/ml-array-min": { "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/rc-cascader": { - "version": "3.34.0", - "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz", - "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==", + "resolved": "https://registry.npmjs.org/ml-array-min/-/ml-array-min-1.2.3.tgz", + "integrity": "sha512-VcZ5f3VZ1iihtrGvgfh/q0XlMobG6GQ8FsNyQXD3T+IlstDv85g8kfV0xUG1QPRO/t21aukaJowDzMTc7j5V6Q==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.25.7", - "classnames": "^2.3.1", - "rc-select": "~14.16.2", - "rc-tree": "~5.13.0", - "rc-util": "^5.43.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "is-any-array": "^2.0.0" } }, - "node_modules/rc-checkbox": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.5.0.tgz", - "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==", + "node_modules/ml-array-rescale": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/ml-array-rescale/-/ml-array-rescale-1.3.7.tgz", + "integrity": "sha512-48NGChTouvEo9KBctDfHC3udWnQKNKEWN0ziELvY3KG25GR5cA8K8wNVzracsqSW1QEkAXjTNx+ycgAv06/1mQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "^2.3.2", - "rc-util": "^5.25.2" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "is-any-array": "^2.0.0", + "ml-array-max": "^1.2.4", + "ml-array-min": "^1.2.3" } }, - "node_modules/rc-collapse": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.9.0.tgz", - "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==", + "node_modules/ml-matrix": { + "version": "6.12.1", + "resolved": "https://registry.npmjs.org/ml-matrix/-/ml-matrix-6.12.1.tgz", + "integrity": "sha512-TJ+8eOFdp+INvzR4zAuwBQJznDUfktMtOB6g/hUcGh3rcyjxbz4Te57Pgri8Q9bhSQ7Zys4IYOGhFdnlgeB6Lw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "2.x", - "rc-motion": "^2.3.4", - "rc-util": "^5.27.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "is-any-array": "^2.0.1", + "ml-array-rescale": "^1.3.7" } }, - "node_modules/rc-dialog": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.6.0.tgz", - "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==", + "node_modules/mobx": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/mobx/-/mobx-6.15.0.tgz", + "integrity": "sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g==", "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "@rc-component/portal": "^1.0.0-8", - "classnames": "^2.2.6", - "rc-motion": "^2.3.0", - "rc-util": "^5.21.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mobx" } }, - "node_modules/rc-drawer": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.3.0.tgz", - "integrity": "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==", + "node_modules/mobx-react-lite": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-4.1.1.tgz", + "integrity": "sha512-iUxiMpsvNraCKXU+yPotsOncNNmyeS2B5DKL+TL6Tar/xm+wwNJAubJmtRSeAoYawdZqwv8Z/+5nPRHeQxTiXg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", - "@rc-component/portal": "^1.1.1", - "classnames": "^2.2.6", - "rc-motion": "^2.6.1", - "rc-util": "^5.38.1" + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mobx" }, "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "mobx": "^6.9.0", + "react": "^16.8.0 || ^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } } }, - "node_modules/rc-dropdown": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.2.1.tgz", - "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==", + "node_modules/motion-dom": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.36.0.tgz", + "integrity": "sha512-Ep1pq8P88rGJ75om8lTCA13zqd7ywPGwCqwuWwin6BKc0hMLkVfcS6qKlRqEo2+t0DwoUcgGJfXwaiFn4AOcQA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.18.3", - "@rc-component/trigger": "^2.0.0", - "classnames": "^2.2.6", - "rc-util": "^5.44.1" + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" }, - "peerDependencies": { - "react": ">=16.11.0", - "react-dom": ">=16.11.0" + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/rc-field-form": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-2.7.1.tgz", - "integrity": "sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==", + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT", + "peer": true + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.0", - "@rc-component/async-validator": "^5.0.3", - "rc-util": "^5.32.2" - }, "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "node": ">=0.10.0" } }, - "node_modules/rc-image": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz", - "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==", + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.11.2", - "@rc-component/portal": "^1.0.2", - "classnames": "^2.2.6", - "rc-dialog": "~9.6.0", - "rc-motion": "^2.6.2", - "rc-util": "^5.34.1" + "engines": { + "node": ">= 0.4" }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/rc-input": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.8.0.tgz", - "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==", + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.11.1", - "classnames": "^2.2.1", - "rc-util": "^5.18.1" - }, - "peerDependencies": { - "react": ">=16.0.0", - "react-dom": ">=16.0.0" + "engines": { + "node": ">= 0.4" } }, - "node_modules/rc-input-number": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.5.0.tgz", - "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==", + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.10.1", - "@rc-component/mini-decimal": "^1.0.1", - "classnames": "^2.2.5", - "rc-input": "~1.8.0", - "rc-util": "^5.40.1" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } - }, - "node_modules/rc-mentions": { - "version": "2.20.0", - "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.20.0.tgz", - "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.22.5", - "@rc-component/trigger": "^2.0.0", - "classnames": "^2.2.6", - "rc-input": "~1.8.0", - "rc-menu": "~9.16.0", - "rc-textarea": "~1.10.0", - "rc-util": "^5.34.1" + "engines": { + "node": ">= 0.4" }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/rc-menu": { - "version": "9.16.1", - "resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.16.1.tgz", - "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==", + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.10.1", - "@rc-component/trigger": "^2.0.0", - "classnames": "2.x", - "rc-motion": "^2.4.3", - "rc-overflow": "^1.3.1", - "rc-util": "^5.27.0" + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "engines": { + "node": ">= 0.4" } }, - "node_modules/rc-motion": { - "version": "2.9.5", - "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz", - "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.11.1", - "classnames": "^2.2.1", - "rc-util": "^5.44.0" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/rc-notification": { - "version": "5.6.4", - "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.4.tgz", - "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==", + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "2.x", - "rc-motion": "^2.9.0", - "rc-util": "^5.20.1" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" }, "engines": { - "node": ">=8.x" + "node": ">= 0.4" }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/rc-overflow": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.5.0.tgz", - "integrity": "sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==", + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.11.1", - "classnames": "^2.2.1", - "rc-resize-observer": "^1.0.0", - "rc-util": "^5.37.0" + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "engines": { + "node": ">= 0.8.0" } }, - "node_modules/rc-pagination": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-5.1.0.tgz", - "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==", + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "^2.3.2", - "rc-util": "^5.38.0" + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/rc-picker": { - "version": "4.11.3", - "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.11.3.tgz", - "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==", + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.24.7", - "@rc-component/trigger": "^2.0.0", - "classnames": "^2.2.1", - "rc-overflow": "^1.3.2", - "rc-resize-observer": "^1.4.0", - "rc-util": "^5.43.0" + "yocto-queue": "^0.1.0" }, "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "date-fns": ">= 2.x", - "dayjs": ">= 1.x", - "luxon": ">= 3.x", - "moment": ">= 2.x", - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "node": ">=10" }, - "peerDependenciesMeta": { - "date-fns": { - "optional": true - }, - "dayjs": { - "optional": true - }, - "luxon": { - "optional": true - }, - "moment": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/rc-progress": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-4.0.0.tgz", - "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==", + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "^2.2.6", - "rc-util": "^5.16.1" + "p-limit": "^3.0.2" }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/rc-rate": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.1.tgz", - "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==", + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "^2.2.5", - "rc-util": "^5.0.1" + "callsites": "^3.0.0" }, "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "node": ">=6" } }, - "node_modules/rc-resize-observer": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz", - "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==", + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.20.7", - "classnames": "^2.2.1", - "rc-util": "^5.44.1", - "resize-observer-polyfill": "^1.5.1" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "engines": { + "node": ">=8" } }, - "node_modules/rc-segmented": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.7.1.tgz", - "integrity": "sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==", + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.11.1", - "classnames": "^2.2.1", - "rc-motion": "^2.4.4", - "rc-util": "^5.17.0" - }, - "peerDependencies": { - "react": ">=16.0.0", - "react-dom": ">=16.0.0" + "engines": { + "node": ">=8" } }, - "node_modules/rc-select": { - "version": "14.16.8", - "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz", - "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==", + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pdfast": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/pdfast/-/pdfast-0.2.0.tgz", + "integrity": "sha512-cq6TTu6qKSFUHwEahi68k/kqN2mfepjkGrG9Un70cgdRRKLKY6Rf8P8uvP2NvZktaQZNF3YE7agEkLj0vGK9bA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "@rc-component/trigger": "^2.1.1", - "classnames": "2.x", - "rc-motion": "^2.0.1", - "rc-overflow": "^1.3.1", - "rc-util": "^5.16.1", - "rc-virtual-list": "^3.5.2" - }, "engines": { - "node": ">=8.x" + "node": ">=8.6" }, - "peerDependencies": { - "react": "*", - "react-dom": "*" + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/rc-slider": { - "version": "11.1.9", - "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.9.tgz", - "integrity": "sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==", - "license": "MIT", + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "^2.2.5", - "rc-util": "^5.36.0" + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" }, "engines": { - "node": ">=8.x" + "node": ">=18" }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "optionalDependencies": { + "fsevents": "2.3.2" } }, - "node_modules/rc-steps": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/rc-steps/-/rc-steps-6.0.1.tgz", - "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.16.7", - "classnames": "^2.2.3", - "rc-util": "^5.16.1" + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" }, "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "node": ">=18" } }, - "node_modules/rc-switch": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-4.1.0.tgz", - "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==", + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.21.0", - "classnames": "^2.2.1", - "rc-util": "^5.30.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/rc-table": { - "version": "7.54.0", - "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.54.0.tgz", - "integrity": "sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==", + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "@rc-component/context": "^1.4.0", - "classnames": "^2.2.5", - "rc-resize-observer": "^1.1.0", - "rc-util": "^5.44.3", - "rc-virtual-list": "^3.14.2" - }, "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "node": ">= 0.4" } }, - "node_modules/rc-tabs": { - "version": "15.7.0", - "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.7.0.tgz", - "integrity": "sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==", + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "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": { - "@babel/runtime": "^7.11.2", - "classnames": "2.x", - "rc-dropdown": "~4.2.0", - "rc-menu": "~9.16.0", - "rc-motion": "^2.6.2", - "rc-resize-observer": "^1.0.0", - "rc-util": "^5.34.1" + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "node": "^10 || ^12 || >=14" } }, - "node_modules/rc-textarea": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.10.2.tgz", - "integrity": "sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "^2.2.1", - "rc-input": "~1.8.0", - "rc-resize-observer": "^1.0.0", - "rc-util": "^5.27.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" }, - "node_modules/rc-tooltip": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.4.0.tgz", - "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==", + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.11.2", - "@rc-component/trigger": "^2.0.0", - "classnames": "^2.3.1", - "rc-util": "^5.44.3" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "engines": { + "node": ">= 0.8.0" } }, - "node_modules/rc-tree": { - "version": "5.13.1", - "resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.13.1.tgz", - "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==", + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.10.1", - "classnames": "2.x", - "rc-motion": "^2.0.1", - "rc-util": "^5.16.1", - "rc-virtual-list": "^3.5.1" + "bin": { + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">=10.x" + "node": ">=14" }, - "peerDependencies": { - "react": "*", - "react-dom": "*" + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/rc-tree-select": { - "version": "5.27.0", - "resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.27.0.tgz", - "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==", + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.25.7", - "classnames": "2.x", - "rc-select": "~14.16.2", - "rc-tree": "~5.13.0", - "rc-util": "^5.43.0" + "fast-diff": "^1.1.2" }, - "peerDependencies": { - "react": "*", - "react-dom": "*" + "engines": { + "node": ">=6.0.0" } }, - "node_modules/rc-upload": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.11.0.tgz", - "integrity": "sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==", + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.18.3", - "classnames": "^2.2.5", - "rc-util": "^5.2.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" } }, - "node_modules/rc-util": { - "version": "5.44.4", - "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", - "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "react-is": "^18.2.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - } + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" }, - "node_modules/rc-virtual-list": { - "version": "3.19.2", - "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.19.2.tgz", - "integrity": "sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==", + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.20.0", - "classnames": "^2.2.6", - "rc-resize-observer": "^1.0.0", - "rc-util": "^5.36.0" - }, "engines": { - "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "node": ">=6" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -7469,6 +7713,20 @@ "react-dom": ">=18" } }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -7523,12 +7781,6 @@ "node": ">=0.10.0" } }, - "node_modules/resize-observer-polyfill": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", - "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", - "license": "MIT" - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -7683,6 +7935,27 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sass": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz", + "integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -8384,12 +8657,6 @@ "node": ">=8.0" } }, - "node_modules/toggle-selection": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", - "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", - "license": "MIT" - }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index cc6225b..d126f40 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,8 +6,8 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "lint": "eslint .", - "lint:fix": "eslint . --fix", + "lint": "eslint src", + "lint:fix": "eslint src --fix", "format": "prettier --write \"./**/*.{js,jsx,ts,tsx,json,css,scss,md}\"", "preview": "vite preview" }, @@ -15,9 +15,8 @@ "@ant-design/charts": "^2.6.7", "@ant-design/icons": "^6.1.0", "@tanstack/react-query": "^5.90.21", - "antd": "^5.29.3", + "antd": "^6.3.3", "axios": "^1.13.2", - "framer-motion": "^12.36.0", "mobx": "^6.15.0", "mobx-react-lite": "^4.1.1", "react": "^19.1.1", @@ -27,6 +26,7 @@ }, "devDependencies": { "@eslint/js": "^9.36.0", + "@playwright/test": "^1.58.2", "@types/eslint-plugin-mobx": "^0.0.0", "@types/node": "^24.6.0", "@types/react": "^19.1.16", @@ -43,9 +43,9 @@ "eslint-plugin-react-refresh": "^0.4.22", "globals": "^16.4.0", "prettier": "3.6.2", + "sass": "^1.98.0", "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", - "@playwright/test": "^1.52.0", "vite": "^7.1.7" } } diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts deleted file mode 100644 index 04c3971..0000000 --- a/frontend/playwright.config.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; - -export default defineConfig({ - testDir: './e2e', - fullyParallel: false, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: 1, - reporter: 'html', - use: { - baseURL: 'http://localhost:5173', - trace: 'on-first-retry', - }, - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, - ], -}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index acc397b..382ca2a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,12 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { RouterProvider } from 'react-router'; -import router from './routes'; import { observer } from 'mobx-react-lite'; import { ConfigProvider, theme as antTheme, App as AntApp } from 'antd'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { themeStore } from './store/themeStore'; -import { tokens } from './styles/design-tokens'; +import router from './routes'; import ErrorBoundary from './components/ErrorBoundary'; +import './styles/index.scss'; const queryClient = new QueryClient({ defaultOptions: { @@ -15,50 +15,52 @@ const queryClient = new QueryClient({ }); const App: React.FC = observer(function App() { - const [systemDark, setSystemDark] = useState( - window.matchMedia('(prefers-color-scheme: dark)').matches - ); + const isDarkTheme = themeStore.mode === 'dark'; + const { defaultAlgorithm, darkAlgorithm } = antTheme; + + const algorithm = isDarkTheme ? darkAlgorithm : defaultAlgorithm; + + // Define theme tokens for grayscale look with centralized accents + const themeConfig = { + algorithm, + token: { + // Base colors (Grayscale) + colorPrimary: isDarkTheme ? '#595959' : '#595959', + colorLink: isDarkTheme ? '#7a7a7a' : '#3a3a3a', + colorBgBase: isDarkTheme ? '#0e0e0e' : '#f5f5f5', - useEffect(() => { - const mq = window.matchMedia('(prefers-color-scheme: dark)'); - const handler = (e: MediaQueryListEvent) => setSystemDark(e.matches); - mq.addEventListener('change', handler); - return () => mq.removeEventListener('change', handler); - }, []); + // Accent colors (Centralized here) + // These will affect all components using color="blue", color="magenta", etc. + blue: '#1677ff', + magenta: '#eb2f96', + green: '#52c41a', + red: '#ff4d4f', + orange: '#faad14', - const isDark = - themeStore.mode === 'dark' || (themeStore.mode === 'auto' && systemDark); + // Semantic colors + colorInfo: '#1677ff', + colorSuccess: '#52c41a', + colorWarning: '#faad14', + colorError: '#ff4d4f', - useEffect(() => { - document.body.style.backgroundColor = isDark ? tokens.colors.darkBg : tokens.colors.lightBg; - }, [isDark]); + borderRadius: 4, + motion: false, + }, + components: { + Layout: { + headerBg: isDarkTheme ? '#141414' : '#ffffff', + }, + // You can also fine-tune specific components here + Tag: { + borderRadiusSM: 2, + }, + }, + }; return ( - - + + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c1af720..bf24f94 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,7 +1,20 @@ -import axios from 'axios'; +import axios, { type AxiosError, type AxiosRequestConfig } from 'axios'; import { authStore } from '../store/authStore'; -const API_URL = import.meta.env.VITE_API_URL || ''; +const API_URL = import.meta.env.VITE_API_URL as string; + +if (!API_URL) { + console.error('VITE_API_URL is not defined! API requests will likely fail.'); +} + +interface RefreshTokenResponse { + access_token: string; + refresh_token: string; +} + +interface OriginalRequestConfig extends AxiosRequestConfig { + _retry?: boolean; +} const api = axios.create({ baseURL: API_URL, @@ -19,39 +32,47 @@ api.interceptors.request.use((config) => { }); let isRefreshing = false; -let failedQueue: Array<{ +let failedQueue: { resolve: (token: string) => void; - reject: (error: unknown) => void; -}> = []; + reject: (error: Error) => void; +}[] = []; -const processQueue = (error: unknown, token: string | null = null) => { +const processQueue = (error: Error | null, token: string | null = null) => { failedQueue.forEach((prom) => { if (token) prom.resolve(token); - else prom.reject(error); + else prom.reject(error ?? new Error('Token refresh failed')); }); failedQueue = []; }; api.interceptors.response.use( (response) => response, - async (error) => { - const originalRequest = error.config; - const url = originalRequest?.url || ''; + async (error: AxiosError & { config?: OriginalRequestConfig }) => { + const axiosError = error; + const originalRequest = axiosError.config; + const url = originalRequest?.url ?? ''; const isAuthRoute = url.includes('/auth/'); - if (error.response?.status === 401 && !isAuthRoute && !originalRequest._retry) { + if ( + axiosError.response?.status === 401 && + !isAuthRoute && + originalRequest && + !originalRequest._retry + ) { const refreshToken = localStorage.getItem('refreshToken'); if (!refreshToken) { authStore.logout(); - return Promise.reject(error); + return Promise.reject( + error instanceof Error ? error : new Error(String(error)) + ); } if (isRefreshing) { return new Promise((resolve, reject) => { failedQueue.push({ resolve, reject }); }).then((token) => { - originalRequest.headers.Authorization = `Bearer ${token}`; + originalRequest.headers.Authorization = `Bearer ${String(token)}`; return api(originalRequest); }); } @@ -60,30 +81,39 @@ api.interceptors.response.use( isRefreshing = true; try { - const res = await axios.post(`${API_URL}/api/v1/auth/refresh`, { - refresh_token: refreshToken, - }); + const res = await axios.post( + `${API_URL}/auth/refresh`, + { + refresh_token: refreshToken, + } + ); const newAccessToken = res.data.access_token; const newRefreshToken = res.data.refresh_token; localStorage.setItem('authToken', newAccessToken); localStorage.setItem('refreshToken', newRefreshToken); processQueue(null, newAccessToken); originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; - return api(originalRequest); + return await api(originalRequest); } catch (refreshError) { - processQueue(refreshError, null); + const refreshErr = + refreshError instanceof Error + ? refreshError + : new Error(String(refreshError)); + processQueue(refreshErr, null); authStore.logout(); - return Promise.reject(refreshError); + throw refreshErr; } finally { isRefreshing = false; } } - if (error.response?.status === 401 && !isAuthRoute) { + if (axiosError.response?.status === 401 && !isAuthRoute) { authStore.logout(); } - return Promise.reject(error); + return Promise.reject( + error instanceof Error ? error : new Error(String(error)) + ); } ); diff --git a/frontend/src/components/AuthForm.tsx b/frontend/src/components/AuthForm.tsx index 422db41..56e2cc7 100644 --- a/frontend/src/components/AuthForm.tsx +++ b/frontend/src/components/AuthForm.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import { observer } from 'mobx-react-lite'; import { useNavigate, useSearchParams } from 'react-router'; import { authStore } from '../store/authStore'; @@ -7,239 +7,310 @@ import { Input, Button, Typography, - Alert, Spin, + App, + Card, + Result, + Flex, } from 'antd'; import { UserOutlined, LockOutlined, MailOutlined, + CloseCircleOutlined, } from '@ant-design/icons'; -import { tokens } from '../styles/design-tokens'; +import { toUserError } from '../types/api'; + +const { Paragraph, Text, Title, Link } = Typography; const AuthForm: React.FC = observer(() => { + const { notification } = App.useApp(); const [searchParams] = useSearchParams(); const inviteToken = searchParams.get('token'); - const isSetupMode = authStore.isSetup === false; - const isInviteMode = !!inviteToken && !isSetupMode; - - const [mode, setMode] = useState<'login' | 'register'>( - isInviteMode ? 'register' : 'login' - ); - const isLogin = mode === 'login' && !isSetupMode; + const { + formMode, + invitedEmail, + isValidatingToken, + isLoading: loading, + setupError, + } = authStore; - const [error, setError] = useState(null); const navigate = useNavigate(); - const loading = authStore.isLoading; + const [form] = Form.useForm(); useEffect(() => { - authStore.checkSetupStatus(); + const checkSetup = async () => { + await authStore.checkSetupStatus(); + }; + + void checkSetup(); }, []); + useEffect(() => { + const init = async () => { + try { + await authStore.initializeAuthForm(inviteToken); + } catch (error) { + notification.error({ + message: 'Invalid or expired invitation', + description: toUserError(error), + }); + } + }; + + void init(); + }, [inviteToken, notification]); + + useEffect(() => { + if (invitedEmail) { + form.setFieldsValue({ email: invitedEmail }); + } + }, [invitedEmail, form]); + const handleSubmit = async (values: Record) => { - setError(null); - - if (isSetupMode) { - const err = await authStore.setup( - values.email, - values.password, - values.fullName - ); - if (err) setError(err); - else navigate('/dashboard'); - } else if (isLogin) { - const err = await authStore.login(values.email, values.password); - if (err) setError(err); - else navigate('/dashboard'); - } else { - if (!inviteToken) { - setError('No invitation token. You need an invite link to register.'); - return; + try { + if (formMode === 'setup') { + await authStore.setup(values.email, values.password, values.fullName); + void navigate('/dashboard'); + } else if (formMode === 'login') { + await authStore.login(values.email, values.password); + void navigate('/dashboard'); + } else { + if (!inviteToken || authStore.tokenError) { + notification.error({ + message: 'Invitation Required', + description: + authStore.tokenError ?? + 'No invitation token. You need an invite link to register.', + }); + + authStore.setMode('login'); + + return; + } + + await authStore.register( + values.email, + values.password, + values.fullName, + inviteToken + ); + + void navigate('/dashboard'); } - const err = await authStore.register( - values.email, - values.password, - values.fullName, - inviteToken - ); - if (err) setError(err); - else navigate('/dashboard'); + } catch (error) { + notification.error({ + message: + formMode === 'setup' + ? 'Setup failed' + : formMode === 'login' + ? 'Login failed' + : 'Registration failed', + description: toUserError(error), + }); } }; - const title = isSetupMode - ? 'Setup VerseLab' - : isLogin - ? 'Welcome back' - : 'Create account'; + const title = + formMode === 'setup' + ? 'Setup VerseLab' + : formMode === 'login' + ? 'Welcome back' + : 'Create account'; + + const subtitle = + formMode === 'setup' + ? 'Create the admin account' + : formMode === 'login' + ? 'Sign in to VerseLab' + : 'Join via invitation'; + + if (setupError) { + return ( + + window.location.reload()}> + Retry + + } + > + + + + Technical details: + + - const subtitle = isSetupMode - ? 'Create the admin account' - : isLogin - ? 'Sign in to VerseLab' - : 'Join via invitation'; + + {' '} + {setupError || 'Unknown error'} + + + + + ); + } - if (authStore.isSetup === null) { + if (authStore.isSetup === null || isValidatingToken) { return ( -
- -
+ + + ); } + const cardHeaderTextEl = ( + + + {title} + + + {subtitle} + + ); + return ( -
-
) => { + void handleSubmit(values); }} + layout="vertical" + size="large" + style={{ padding: '32px' }} > - + } placeholder="Full name" /> + + )} + + - {title} - - - {subtitle} - -
- -
- {error && ( - } + placeholder="Email" + disabled={!!invitedEmail} /> - )} + -
- {(isSetupMode || !isLogin) && ( - - } - placeholder="Full name" - /> - - )} + + } placeholder="Password" /> + + {formMode !== 'login' && ( - } placeholder="Email" /> - + { required: true, message: 'Confirm your password' }, + ({ getFieldValue }) => ({ + validator(_, value) { + if (!value || getFieldValue('password') === value) { + return Promise.resolve(); + } - } - placeholder="Password" + placeholder="Confirm password" /> + )} - {(isSetupMode || !isLogin) && ( - ({ - validator(_, value) { - if (!value || getFieldValue('password') === value) { - return Promise.resolve(); - } - return Promise.reject(new Error('Passwords do not match')); - }, - }), - ]} - > - } - placeholder="Confirm password" - /> - - )} - - - - + + + - {!isSetupMode && !isInviteMode && ( -
- - {isLogin + {formMode !== 'setup' && ( + + + {formMode === 'login' + ? inviteToken && !authStore.tokenError ? 'Have an invite link? Use it to ' - : 'Already have an account? '} - { - setMode(isLogin ? 'register' : 'login'); - setError(null); + if (formMode === 'invite' || formMode === 'register') { + authStore.setMode('login'); + form.setFieldsValue({ email: '' }); + } else { + authStore.setMode('register'); + } }} > - {isLogin ? 'register' : 'Sign in'} - - -
- )} - - {!isSetupMode && !isLogin && !inviteToken && ( - - )} - -
-
+ {formMode === 'login' ? 'register' : 'Sign in'} + + )} + + + )} + + {(formMode === 'register' || formMode === 'invite') && !inviteToken && ( + + + Registration requires an invitation link from your admin. + + + )} + + ); }); diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index 84f85b4..cd46510 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -1,6 +1,8 @@ -import { useState, useEffect, useMemo, useRef } from 'react'; +import React, { useState, useEffect, useMemo, useRef } from 'react'; +import { observer } from 'mobx-react-lite'; import { useNavigate } from 'react-router'; import { Modal, Input, Typography, Tag, theme } from 'antd'; +import type { InputRef } from 'antd'; import { HomeOutlined, AppstoreOutlined, @@ -27,17 +29,17 @@ interface CommandItem { section: string; } -const CommandPalette: React.FC = () => { +const CommandPalette: React.FC = observer(() => { const [open, setOpen] = useState(false); const [search, setSearch] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); const navigate = useNavigate(); - const inputRef = useRef(null); + const inputRef = useRef(null); const { token: themeToken } = theme.useToken(); const { data: projects = [] } = useQuery({ queryKey: ['projects'], - queryFn: () => api.get('/projects').then((r) => r.data), + queryFn: () => api.get('/projects').then((r) => r.data), enabled: open, }); @@ -64,7 +66,9 @@ const CommandPalette: React.FC = () => { id: 'dashboard', title: 'Dashboard', icon: , - action: () => navigate('/dashboard'), + action: () => { + void navigate('/dashboard'); + }, keywords: ['home', 'projects'], section: 'Navigation', }, @@ -72,7 +76,9 @@ const CommandPalette: React.FC = () => { id: 'profile', title: 'Profile', icon: , - action: () => navigate('/profile'), + action: () => { + void navigate('/profile'); + }, keywords: ['account', 'settings'], section: 'Navigation', }, @@ -81,27 +87,33 @@ const CommandPalette: React.FC = () => { // Project commands for (const p of projects) { items.push({ - id: `project-${p.id}-workspace`, + id: `project-${String(p.id)}-workspace`, title: `${p.name} - Workspace`, description: p.type, icon: , - action: () => navigate(`/projects/${p.id}/workspace`), + action: () => { + void navigate(`/projects/${String(p.id)}/workspace`); + }, keywords: [p.name.toLowerCase(), 'workspace', p.type.toLowerCase()], section: 'Projects', }); items.push({ - id: `project-${p.id}-stats`, + id: `project-${String(p.id)}-stats`, title: `${p.name} - Stats`, icon: , - action: () => navigate(`/projects/${p.id}/stats`), + action: () => { + void navigate(`/projects/${String(p.id)}/stats`); + }, keywords: [p.name.toLowerCase(), 'stats', 'statistics'], section: 'Projects', }); items.push({ - id: `project-${p.id}-settings`, + id: `project-${String(p.id)}-settings`, title: `${p.name} - Settings`, icon: , - action: () => navigate(`/projects/${p.id}/settings`), + action: () => { + void navigate(`/projects/${String(p.id)}/settings`); + }, keywords: [p.name.toLowerCase(), 'settings', 'config'], section: 'Projects', }); @@ -114,7 +126,9 @@ const CommandPalette: React.FC = () => { id: 'admin-members', title: 'Manage Members', icon: , - action: () => navigate('/admin/members'), + action: () => { + void navigate('/admin/members'); + }, keywords: ['users', 'members', 'admin'], section: 'Admin', }, @@ -122,7 +136,9 @@ const CommandPalette: React.FC = () => { id: 'admin-invitations', title: 'Invitations', icon: , - action: () => navigate('/admin/invitations'), + action: () => { + void navigate('/admin/invitations'); + }, keywords: ['invite', 'invitations'], section: 'Admin', }, @@ -130,7 +146,9 @@ const CommandPalette: React.FC = () => { id: 'admin-audit', title: 'Audit Log', icon: , - action: () => navigate('/admin/audit-log'), + action: () => { + void navigate('/admin/audit-log'); + }, keywords: ['audit', 'log', 'history'], section: 'Admin', } @@ -146,8 +164,8 @@ const CommandPalette: React.FC = () => { return commands.filter( (cmd) => cmd.title.toLowerCase().includes(q) || - cmd.description?.toLowerCase().includes(q) || - cmd.keywords?.some((k) => k.includes(q)) + (cmd.description?.toLowerCase().includes(q) ?? false) || + (cmd.keywords?.some((k) => k.includes(q)) ?? false) ); }, [commands, search]); @@ -177,7 +195,7 @@ const CommandPalette: React.FC = () => { const sections = useMemo(() => { const map = new Map(); for (const cmd of filtered) { - const list = map.get(cmd.section) || []; + const list = map.get(cmd.section) ?? []; list.push(cmd); map.set(cmd.section, list); } @@ -195,13 +213,19 @@ const CommandPalette: React.FC = () => { width={560} styles={{ body: { padding: 0 }, - content: { borderRadius: 12, overflow: 'hidden' }, + container: { borderRadius: 12, overflow: 'hidden' }, }} style={{ top: '15%' }} > -
+
} placeholder="Search commands, projects..." @@ -240,7 +264,13 @@ const CommandPalette: React.FC = () => { return (
handleSelect(cmd)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSelect(cmd); + }} onMouseEnter={() => setSelectedIndex(idx)} style={{ padding: '8px 16px', @@ -248,18 +278,29 @@ const CommandPalette: React.FC = () => { display: 'flex', alignItems: 'center', gap: 10, - background: isSelected ? themeToken.colorBgTextHover : 'transparent', + background: isSelected + ? themeToken.colorBgTextHover + : 'transparent', transition: 'background 0.1s', }} > - + {cmd.icon}
- {cmd.title} + + {cmd.title} +
{cmd.description && ( - + {cmd.description} )} @@ -288,6 +329,6 @@ const CommandPalette: React.FC = () => {
); -}; +}); export default CommandPalette; diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index 6d01bed..00be2c5 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -10,6 +10,7 @@ interface State { error: Error | null; } +// eslint-disable-next-line mobx/missing-observer class ErrorBoundary extends Component { state: State = { hasError: false, error: null }; @@ -27,12 +28,15 @@ class ErrorBoundary extends Component { Try Again , - , ]} diff --git a/frontend/src/components/editors/NEREditor.tsx b/frontend/src/components/editors/NEREditor.tsx index e733446..2e813f6 100644 --- a/frontend/src/components/editors/NEREditor.tsx +++ b/frontend/src/components/editors/NEREditor.tsx @@ -1,4 +1,5 @@ -import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; +import React, { useRef, useEffect, useMemo } from 'react'; +import { observer } from 'mobx-react-lite'; import { Typography, Tag, Button, Space, Tooltip, Input } from 'antd'; import { UndoOutlined, @@ -9,14 +10,17 @@ import { DeleteOutlined, SearchOutlined, } from '@ant-design/icons'; -import { motion } from 'framer-motion'; import type { NEREntity, NERAnnotationResult, + NERTaskData, Task, ProjectConfig, } from '../../types/api'; -import { tokens } from '../../styles/design-tokens'; +import { isNERTaskData } from '../../types/api'; +import { LABEL_COLORS } from '../../types'; +import { nerEditorStore } from '../../store/nerEditorStore'; +import { theme } from 'antd'; interface Props { task: Task; @@ -24,91 +28,41 @@ interface Props { onSubmit: (result: NERAnnotationResult) => void; } -const LABEL_COLORS: Record = { - PER: '#3b82f6', - ORG: '#8b5cf6', - LOC: '#10b981', - GPE: '#06b6d4', - FAC: '#f59e0b', - MISC: '#6b7280', - PRODUCT: '#ec4899', - EVENT: '#f97316', - SHIP: '#0ea5e9', - ARMOR: '#84cc16', - WEAPON: '#ef4444', - QUANTITY: '#14b8a6', - DATE: '#a855f7', - MONEY: '#eab308', -}; - -interface HistoryState { - entities: NEREntity[]; -} - -const NEREditor: React.FC = ({ task, config, onSubmit }) => { - const labels = config?.labels || [ - 'PER', 'LOC', 'ORG', 'MISC', 'GPE', 'FAC', 'PRODUCT', - 'EVENT', 'SHIP', 'ARMOR', 'WEAPON', - ]; - - const [selectedLabel, setSelectedLabel] = useState(labels[0]); - const [entities, setEntities] = useState([]); - const [labelSearch, setLabelSearch] = useState(''); - - // Undo/Redo - const [history, setHistory] = useState([{ entities: [] }]); - const [historyIndex, setHistoryIndex] = useState(0); +const DEFAULT_LABELS = [ + 'PER', + 'LOC', + 'ORG', + 'MISC', + 'GPE', + 'FAC', + 'PRODUCT', + 'EVENT', + 'SHIP', + 'ARMOR', + 'WEAPON', +]; + +const NEREditor: React.FC = observer(({ task, config, onSubmit }) => { + const { token: themeToken } = theme.useToken(); + const labels = useMemo( + () => config.labels ?? DEFAULT_LABELS, + [config.labels] + ); - // Drag selection state - const [isDragging, setIsDragging] = useState(false); - const [dragStart, setDragStart] = useState(null); - const [dragEnd, setDragEnd] = useState(null); const textContainerRef = useRef(null); - const text = 'text' in task.data ? (task.data as { text: string }).text : ''; + const text = isNERTaskData(task.data) ? task.data.text : ''; const tokensArr = useMemo(() => text.split(/(\s+)/), [text]); // Pre-populated entities from task data const preEntities = useMemo(() => { - if ('entities' in task.data && Array.isArray((task.data as Record).entities)) { - return (task.data as { entities: NEREntity[] }).entities; + if (isNERTaskData(task.data)) { + const data = task.data as NERTaskData & { entities?: NEREntity[] }; + if (Array.isArray(data.entities)) return data.entities; } return []; }, [task.data]); - // Reset on task change - useEffect(() => { - const initial = preEntities.length > 0 ? [...preEntities] : []; - setEntities(initial); - setHistory([{ entities: initial }]); - setHistoryIndex(0); - }, [task.id, preEntities]); - - const pushHistory = useCallback((newEntities: NEREntity[]) => { - setHistory((prev) => { - const sliced = prev.slice(0, historyIndex + 1); - return [...sliced, { entities: newEntities }]; - }); - setHistoryIndex((prev) => prev + 1); - setEntities(newEntities); - }, [historyIndex]); - - const undo = useCallback(() => { - if (historyIndex > 0) { - const newIdx = historyIndex - 1; - setHistoryIndex(newIdx); - setEntities(history[newIdx].entities); - } - }, [history, historyIndex]); - - const redo = useCallback(() => { - if (historyIndex < history.length - 1) { - const newIdx = historyIndex + 1; - setHistoryIndex(newIdx); - setEntities(history[newIdx].entities); - } - }, [history, historyIndex]); - // Compute char offsets for tokens const tokenOffsets = useMemo(() => { const offsets: { start: number; end: number }[] = []; @@ -120,135 +74,41 @@ const NEREditor: React.FC = ({ task, config, onSubmit }) => { return offsets; }, [tokensArr]); - // Mouse drag selection + // Reset on task change + useEffect(() => { + nerEditorStore.initForTask(preEntities, labels); + }, [task.id, preEntities, labels]); + const handleMouseDown = (tokenIdx: number) => { if (!tokensArr[tokenIdx].trim()) return; - setIsDragging(true); - setDragStart(tokenIdx); - setDragEnd(tokenIdx); + nerEditorStore.startDrag(tokenIdx); }; const handleMouseEnter = (tokenIdx: number) => { - if (isDragging && tokensArr[tokenIdx].trim()) { - setDragEnd(tokenIdx); + if (nerEditorStore.isDragging && tokensArr[tokenIdx].trim()) { + nerEditorStore.updateDrag(tokenIdx); } }; const handleMouseUp = () => { - if (!isDragging || dragStart === null || dragEnd === null) { - setIsDragging(false); - return; - } - setIsDragging(false); - - const start = Math.min(dragStart, dragEnd); - const end = Math.max(dragStart, dragEnd); - - // Check if single click on existing entity -> remove it - if (start === end) { - const charStart = tokenOffsets[start].start; - const charEnd = tokenOffsets[start].end; - const existing = entities.findIndex( - (e) => e.start <= charStart && e.end >= charEnd - ); - if (existing >= 0) { - pushHistory(entities.filter((_, i) => i !== existing)); - setDragStart(null); - setDragEnd(null); - return; - } - } - - // Build entity spanning all selected tokens (including whitespace between) - const charStart = tokenOffsets[start].start; - const charEnd = tokenOffsets[end].end; - const selectedText = text.slice(charStart, charEnd).trim(); - - if (!selectedText) { - setDragStart(null); - setDragEnd(null); - return; - } - - // Adjust for leading/trailing whitespace - const trimmedStart = charStart + (text.slice(charStart, charEnd).length - text.slice(charStart, charEnd).trimStart().length); - const trimmedEnd = charEnd - (text.slice(charStart, charEnd).length - text.slice(charStart, charEnd).trimEnd().length); - - // Remove overlapping entities - const filtered = entities.filter( - (e) => e.end <= trimmedStart || e.start >= trimmedEnd - ); - - pushHistory([ - ...filtered, - { - start: trimmedStart, - end: trimmedEnd, - label: selectedLabel, - text: selectedText, - }, - ]); - - setDragStart(null); - setDragEnd(null); + nerEditorStore.endDrag(text, tokenOffsets); }; - const handleAcceptAll = () => { - if (preEntities.length > 0) { - pushHistory([...preEntities]); - } - }; - - const handleEntityClick = (entity: NEREntity) => { - pushHistory(entities.filter((e) => !(e.start === entity.start && e.end === entity.end))); - }; - - const handleEntityLabelChange = (entity: NEREntity, newLabel: string) => { - pushHistory( - entities.map((e) => - e.start === entity.start && e.end === entity.end - ? { ...e, label: newLabel } - : e + const filteredLabels = nerEditorStore.labelSearch + ? labels.filter((l) => + l.toLowerCase().includes(nerEditorStore.labelSearch.toLowerCase()) ) - ); - }; - - // Keyboard shortcuts - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.target instanceof HTMLInputElement) return; - if (e.ctrlKey && e.key === 'z' && !e.shiftKey) { - e.preventDefault(); - undo(); - return; - } - if (e.ctrlKey && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { - e.preventDefault(); - redo(); - return; - } - const num = parseInt(e.key); - if (num >= 1 && num <= labels.length) { - setSelectedLabel(labels[num - 1]); - } - }, - [labels, undo, redo] - ); - - const filteredLabels = labelSearch - ? labels.filter((l) => l.toLowerCase().includes(labelSearch.toLowerCase())) : labels; - // Determine drag highlight range - const dragRange = isDragging && dragStart !== null && dragEnd !== null - ? { start: Math.min(dragStart, dragEnd), end: Math.max(dragStart, dragEnd) } - : null; + const { dragRange, entities } = nerEditorStore; + /* eslint-disable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */ return (
nerEditorStore.handleKeyboardShortcut(e, labels)} > {/* Labels sidebar */}
= ({ task, config, onSubmit }) => { }} >
- + Labels {labels.length > 10 && ( @@ -269,8 +132,8 @@ const NEREditor: React.FC = ({ task, config, onSubmit }) => { size="small" prefix={} placeholder="Filter..." - value={labelSearch} - onChange={(e) => setLabelSearch(e.target.value)} + value={nerEditorStore.labelSearch} + onChange={(e) => nerEditorStore.setLabelSearch(e.target.value)} allowClear style={{ marginBottom: 8 }} /> @@ -284,13 +147,20 @@ const NEREditor: React.FC = ({ task, config, onSubmit }) => { return ( setSelectedLabel(label)} + color={ + nerEditorStore.selectedLabel === label + ? LABEL_COLORS[label] || themeToken.colorPrimary + : undefined + } + onClick={() => nerEditorStore.setSelectedLabel(label)} style={{ cursor: 'pointer', padding: '3px 8px', fontSize: 12, - border: selectedLabel === label ? 'none' : undefined, + border: + nerEditorStore.selectedLabel === label + ? 'none' + : undefined, margin: 0, }} > @@ -313,12 +183,15 @@ const NEREditor: React.FC = ({ task, config, onSubmit }) => { overflow: 'auto', }} > - + Entities ({entities.length}) {entities.map((e, idx) => (
= ({ task, config, onSubmit }) => { > { - const idx = labels.indexOf(e.label); - const nextLabel = labels[(idx + 1) % labels.length]; - handleEntityLabelChange(e, nextLabel); + const i = labels.indexOf(e.label); + const nextLabel = labels[(i + 1) % labels.length]; + nerEditorStore.changeEntityLabel(e, nextLabel); }} > {e.label} @@ -346,7 +224,7 @@ const NEREditor: React.FC = ({ task, config, onSubmit }) => { size="small" danger icon={} - onClick={() => handleEntityClick(e)} + onClick={() => nerEditorStore.removeEntity(e)} style={{ width: 20, height: 20, minWidth: 20 }} />
@@ -354,9 +232,21 @@ const NEREditor: React.FC = ({ task, config, onSubmit }) => {
{/* Actions */} -
+
{preEntities.length > 0 && ( - )} @@ -365,16 +255,16 @@ const NEREditor: React.FC = ({ task, config, onSubmit }) => {
{/* Text area */} + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
= ({ task, config, onSubmit }) => { }} onMouseUp={handleMouseUp} onMouseLeave={() => { - if (isDragging) handleMouseUp(); + if (nerEditorStore.isDragging) handleMouseUp(); }} > {tokensArr.map((token, i) => { @@ -420,15 +311,18 @@ const NEREditor: React.FC = ({ task, config, onSubmit }) => { ); const isInDragRange = - dragRange && i >= dragRange.start && i <= dragRange.end && token.trim(); + dragRange && + i >= dragRange.start && + i <= dragRange.end && + token.trim(); if (activeEntity) { - const color = LABEL_COLORS[activeEntity.label] || tokens.colors.primary; + const color = + LABEL_COLORS[activeEntity.label] || themeToken.colorPrimary; return ( - = ({ task, config, onSubmit }) => { > {activeEntity.label} - + ); } return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions handleMouseDown(i)} onMouseEnter={() => handleMouseEnter(i)} style={{ cursor: token.trim() ? 'text' : undefined, - backgroundColor: isInDragRange ? 'rgba(59, 130, 246, 0.15)' : undefined, + backgroundColor: isInDragRange + ? 'rgba(59, 130, 246, 0.15)' + : undefined, borderRadius: isInDragRange ? 3 : undefined, }} > @@ -475,6 +372,6 @@ const NEREditor: React.FC = ({ task, config, onSubmit }) => {
); -}; +}); export default NEREditor; diff --git a/frontend/src/components/editors/TranslationEditor.tsx b/frontend/src/components/editors/TranslationEditor.tsx index 17d0aa8..74b8ef2 100644 --- a/frontend/src/components/editors/TranslationEditor.tsx +++ b/frontend/src/components/editors/TranslationEditor.tsx @@ -1,14 +1,24 @@ -import { useState, useEffect, useMemo } from 'react'; -import { Typography, Tabs, Button, Input, Space, Tooltip, Tag } from 'antd'; -import { SendOutlined, CopyOutlined, ForwardOutlined } from '@ant-design/icons'; +import React, { useState, useEffect, useMemo } from 'react'; +import { Tabs, Button, Input, Space, Tooltip, Tag, theme } from 'antd'; +import { + SendOutlined, + CopyOutlined, + ForwardOutlined, + UserOutlined, +} from '@ant-design/icons'; import { useQuery } from '@tanstack/react-query'; import api from '../../api/client'; +import { authStore } from '../../store/authStore'; import type { TranslationAnnotationResult, Task, ProjectConfig, GlossaryTerm, + TaskAnnotation, } from '../../types/api'; +import { isTranslationTaskData } from '../../types/api'; +import { annotationStatusColor } from '../../types'; +import { observer } from 'mobx-react-lite'; interface Props { task: Task; @@ -16,246 +26,419 @@ interface Props { onSubmit: (result: TranslationAnnotationResult) => void; } -const TranslationEditor: React.FC = ({ task, config, onSubmit }) => { - const targetLangs = useMemo( - () => config?.target_languages || [], - [config?.target_languages] - ); - const [activeTab, setActiveTab] = useState(targetLangs[0]); - const [translations, setTranslations] = useState>({}); +const TranslationEditor: React.FC = observer( + ({ task, config, onSubmit }) => { + const { token: themeToken } = theme.useToken(); + const targetLangs = useMemo( + () => config.target_languages ?? [], + [config.target_languages] + ); + const [activeTab, setActiveTab] = useState(targetLangs[0]); + const [translations, setTranslations] = useState>( + {} + ); - const sourceText = 'source' in task.data ? (task.data as { source: string }).source : ''; - const iniKey = 'key' in task.data ? (task.data as { key: string }).key : ''; + const translationData = isTranslationTaskData(task.data) ? task.data : null; + const sourceText = translationData?.source ?? ''; + const iniKey = translationData?.key ?? ''; - // Fetch glossary - const { data: glossary = [] } = useQuery({ - queryKey: ['glossary', task.project_id], - queryFn: () => api.get(`/api/projects/${task.project_id}/glossary`).then((r) => r.data), - staleTime: 60_000, - }); + // Fetch existing annotations for this task + const { data: existingAnnotations = [] } = useQuery({ + queryKey: ['task-annotations', task.id], + queryFn: () => + api + .get(`/tasks/${String(task.id)}/annotations`) + .then((r) => r.data as TaskAnnotation[]), + enabled: !!task.id, + }); - const matchedTerms = useMemo( - () => glossary.filter((g) => sourceText.toLowerCase().includes(g.source_term.toLowerCase())), - [glossary, sourceText] - ); + // Other users' suggestions (exclude current user) + const otherSuggestions = useMemo( + () => existingAnnotations.filter((a) => a.user_id !== authStore.user?.id), + [existingAnnotations] + ); - useEffect(() => { - const initial: Record = {}; - targetLangs.forEach((lang) => (initial[lang] = '')); - setTranslations(initial); - }, [task, targetLangs]); + // Fetch glossary + const { data: glossary = [] } = useQuery({ + queryKey: ['glossary', task.project_id], + queryFn: () => + api + .get(`/projects/${String(task.project_id)}/glossary`) + .then((r) => r.data as GlossaryTerm[]), + staleTime: 60_000, + }); - const handleTextChange = (text: string) => { - setTranslations((prev) => ({ ...prev, [activeTab]: text })); - }; + const matchedTerms = useMemo( + () => + glossary.filter((g) => + sourceText.toLowerCase().includes(g.source_term.toLowerCase()) + ), + [glossary, sourceText] + ); - const handleCopySource = () => { - setTranslations((prev) => ({ ...prev, [activeTab]: sourceText })); - }; + // Pre-fill with existing translation (own first, then latest) + useEffect(() => { + const initial: Record = {}; + targetLangs.forEach((lang) => (initial[lang] = '')); - const handleInsertTerm = (term: GlossaryTerm) => { - const translation = term.translations[activeTab] || Object.values(term.translations)[0] || term.source_term; - setTranslations((prev) => ({ - ...prev, - [activeTab]: (prev[activeTab] || '') + translation, - })); - }; + if (existingAnnotations.length > 0) { + const own = existingAnnotations.find( + (a) => a.user_id === authStore.user?.id + ); + const source = own ?? existingAnnotations[0]; + const result = source.result as Record; + if (typeof result === 'object') { + targetLangs.forEach((lang) => { + if (result[lang]) initial[lang] = result[lang]; + }); + } + } - const currentText = translations[activeTab] || ''; - const sourceWordCount = sourceText.split(/\s+/).filter(Boolean).length; - const targetWordCount = currentText.split(/\s+/).filter(Boolean).length; + setTranslations(initial); + }, [task.id, targetLangs, existingAnnotations]); - // Highlight glossary terms in source text - const renderSourceText = () => { - if (matchedTerms.length === 0) { - return ( - - {sourceText} - - ); - } + const handleTextChange = (text: string) => { + setTranslations((prev) => ({ ...prev, [activeTab]: text })); + }; - // Sort terms by length (longest first) to avoid partial matches - const sortedTerms = [...matchedTerms].sort((a, b) => b.source_term.length - a.source_term.length); + const handleCopySource = () => { + setTranslations((prev) => ({ ...prev, [activeTab]: sourceText })); + }; - // Simple split approach - const regex = new RegExp( - `(${sortedTerms.map((t) => t.source_term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, - 'gi' - ); - const segments = sourceText.split(regex); + const handleInsertTerm = (term: GlossaryTerm) => { + const translation = + term.translations[activeTab] || + Object.values(term.translations)[0] || + term.source_term; + setTranslations((prev) => ({ + ...prev, + [activeTab]: (prev[activeTab] || '') + translation, + })); + }; - return ( - - {segments.map((seg, i) => { - const matchedTerm = sortedTerms.find( - (t) => t.source_term.toLowerCase() === seg.toLowerCase() - ); - if (matchedTerm) { - return ( - - {Object.entries(matchedTerm.translations).map(([lang, trans]) => ( -
- {lang}: {trans} + const currentText = translations[activeTab] || ''; + const sourceWordCount = sourceText.split(/\s+/).filter(Boolean).length; + const targetWordCount = currentText.split(/\s+/).filter(Boolean).length; + + // Highlight glossary terms in source text + const renderSourceText = () => { + if (matchedTerms.length === 0) { + return ( +
+ {sourceText} +
+ ); + } + + // Sort terms by length (longest first) to avoid partial matches + const sortedTerms = [...matchedTerms].sort( + (a, b) => b.source_term.length - a.source_term.length + ); + + // Simple split approach + const regex = new RegExp( + `(${sortedTerms.map((t) => t.source_term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, + 'gi' + ); + const segments = sourceText.split(regex); + + return ( +
+ {segments.map((seg, i) => { + const matchedTerm = sortedTerms.find( + (t) => t.source_term.toLowerCase() === seg.toLowerCase() + ); + if (matchedTerm) { + return ( + + {Object.entries(matchedTerm.translations).map( + ([lang, trans]) => ( +
+ {lang}: {trans} +
+ ) + )} + {matchedTerm.notes && ( +
+ {matchedTerm.notes} +
+ )} +
+ Click to insert
- ))} - {matchedTerm.notes &&
{matchedTerm.notes}
} -
Click to insert
-
- } - > - handleInsertTerm(matchedTerm)} - style={{ - backgroundColor: 'rgba(59, 130, 246, 0.15)', - borderBottom: '2px dotted #3b82f6', - cursor: 'pointer', - padding: '1px 2px', - borderRadius: 2, - }} +
+ } > - {seg} - -
- ); - } - return {seg}; - })} -
- ); - }; + handleInsertTerm(matchedTerm)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleInsertTerm(matchedTerm); + }} + style={{ + backgroundColor: 'rgba(59, 130, 246, 0.15)', + borderBottom: '2px dotted #3b82f6', + cursor: 'pointer', + padding: '1px 2px', + borderRadius: 2, + }} + > + {seg} + + + ); + } + return {seg}; + })} +
+ ); + }; - return ( -
- {/* Source panel */} -
+ return ( +
+ {/* Source panel */}
- - - SOURCE ({config?.source_language || 'en'}) - - - {sourceWordCount} words - - -
- {iniKey && ( -
- - {iniKey} - -
- )} -
- {renderSourceText()} -
- - {/* Glossary terms bar */} - {matchedTerms.length > 0 && (
- {matchedTerms.map((term) => ( - handleInsertTerm(term)} + + + SOURCE ({config.source_language ?? 'en'}) + + - {term.source_term} - - ))} + {sourceWordCount} words + + +
+ {iniKey && ( +
+ + {iniKey} + +
+ )} +
+ {renderSourceText()}
- )} -
- {/* Target panel */} -
- ({ - key: lang, - label: lang.toUpperCase(), - }))} - tabBarExtraContent={ - -
-
- handleTextChange(e.target.value)} - placeholder={`Enter translation for ${activeTab}...`} - style={{ - height: '100%', - resize: 'none', - fontSize: '1.05rem', - }} - autoSize={false} + {/* Target panel */} +
+ ({ + key: lang, + label: lang.toUpperCase(), + }))} + tabBarExtraContent={ + +
-
- - - {targetWordCount} words - - - - - - + + Other suggestions ({otherSuggestions.length}) + +
+ {otherSuggestions.map((ann) => { + const result = ann.result as Record; + const text = result[activeTab]; + if (!text) return null; + return ( +
handleTextChange(text)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleTextChange(text); + }} + title="Click to use this translation" + > + + + {ann.user_full_name ?? ann.user_email} + + + {ann.status} + + +
{text}
+
+ ); + })} +
+
+ )} + +
+ + + {targetWordCount} words + + + + + + +
-
- ); -}; + ); + } +); export default TranslationEditor; diff --git a/frontend/src/components/layout/AppHeader.module.scss b/frontend/src/components/layout/AppHeader.module.scss new file mode 100644 index 0000000..6986a13 --- /dev/null +++ b/frontend/src/components/layout/AppHeader.module.scss @@ -0,0 +1,24 @@ +@use '../../styles/variables' as *; + +.header { + padding: 0 24px; + display: flex; + align-items: center; + justify-content: space-between; + height: $header-height; + line-height: $header-height; + position: sticky; + top: 0; + z-index: 99; +} + +.headerMinimal { + padding: 0 24px; + height: $header-height; + line-height: $header-height; +} + +.inboxList { + max-height: 360px; + overflow: auto; +} diff --git a/frontend/src/components/layout/AppHeader.tsx b/frontend/src/components/layout/AppHeader.tsx new file mode 100644 index 0000000..bead377 --- /dev/null +++ b/frontend/src/components/layout/AppHeader.tsx @@ -0,0 +1,244 @@ +import React from 'react'; +import { useNavigate, useLocation } from 'react-router'; +import { observer } from 'mobx-react-lite'; +import { + Layout, + Typography, + Space, + Switch, + Tooltip, + Popover, + Badge, + Button, + Dropdown, + Avatar, + Tag, + Breadcrumb, + List, + Empty, + Flex, + theme, +} from 'antd'; +import { + SunOutlined, + MoonOutlined, + BellOutlined, + UserOutlined, + LogoutOutlined, +} from '@ant-design/icons'; +import { themeStore } from '../../store/themeStore'; +import { inboxStore } from '../../store/inboxStore'; +import { authStore } from '../../store/authStore'; +import { GlobalRoles } from '../../types'; +import styles from './AppHeader.module.scss'; + +const { Header } = Layout; + +interface AppHeaderProps { + mode: 'minimal' | 'full'; + projectId?: string | null; +} + +const AppHeader: React.FC = observer(({ mode, projectId }) => { + const { token: themeToken } = theme.useToken(); + const isDark = themeStore.mode === 'dark'; + + if (mode === 'minimal') { + return ( +
+ + + VerseLab + + + themeStore.toggle()} + checkedChildren={} + unCheckedChildren={} + /> + + +
+ ); + } + + // Full mode + const navigate = useNavigate(); + const location = useLocation(); + const user = authStore.user; + + const handleLogout = () => { + authStore.logout(); + void navigate('/auth'); + }; + + // Breadcrumbs + const breadcrumbItems: { title: React.ReactNode }[] = [ + { + title: ( + { + void navigate('/dashboard'); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') void navigate('/dashboard'); + }} + > + Projects + + ), + }, + ]; + if (projectId) { + breadcrumbItems.push({ title: Project #{projectId} }); + if (location.pathname.includes('/workspace')) + breadcrumbItems.push({ title: Workspace }); + else if (location.pathname.includes('/stats')) + breadcrumbItems.push({ title: Stats }); + else if (location.pathname.includes('/glossary')) + breadcrumbItems.push({ title: Glossary }); + else if (location.pathname.includes('/review')) + breadcrumbItems.push({ title: Review }); + else if (location.pathname.includes('/settings')) + breadcrumbItems.push({ title: Settings }); + } + + const inboxContent = ( + + + Inbox + {inboxStore.unreadCount > 0 && ( + + )} + + {inboxStore.notifications.length === 0 ? ( + + ) : ( + ( + + + {item.title} + + } + description={ + + {item.message} + + } + /> + + )} + className={styles.inboxList} + /> + )} + + ); + + const userMenuItems = [ + { + key: 'profile', + icon: , + label: 'Profile', + onClick: () => navigate('/profile'), + }, + { type: 'divider' as const }, + { + key: 'logout', + icon: , + label: 'Logout', + danger: true, + onClick: handleLogout, + }, + ]; + + return ( +
+ + + + + themeStore.toggle()} + checkedChildren={} + unCheckedChildren={} + /> + + + + +
+ ); +}); + +export default AppHeader; diff --git a/frontend/src/components/layout/AppLayout.module.scss b/frontend/src/components/layout/AppLayout.module.scss new file mode 100644 index 0000000..8e08888 --- /dev/null +++ b/frontend/src/components/layout/AppLayout.module.scss @@ -0,0 +1,28 @@ +@use '../../styles/variables' as *; + +.sider { + position: fixed; + left: 0; + top: 0; + bottom: 0; + z-index: 100; +} + +.siderHeader { + height: $header-height; +} + +.siderMenuWrapper { + flex: 1; + overflow: auto; +} + +.siderMenu { + border: none; + padding: 8px 4px; +} + +.content { + padding: 0; + min-height: calc(100vh - $header-height); +} diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index dd02e90..4d3e7ff 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -1,92 +1,52 @@ import { useState, useEffect } from 'react'; import { Outlet, useNavigate, useLocation } from 'react-router'; import { observer } from 'mobx-react-lite'; -import { - Layout, - Menu, - Avatar, - Dropdown, - Badge, - Button, - Typography, - Space, - Popover, - List, - Empty, - Tag, - Breadcrumb, - Tooltip, - theme, -} from 'antd'; +import { Layout, Menu, Button, Typography, Flex, theme } from 'antd'; import { TeamOutlined, MailOutlined, UserOutlined, - LogoutOutlined, - BellOutlined, - AuditOutlined, MenuFoldOutlined, MenuUnfoldOutlined, - BulbOutlined, - BulbFilled, + AuditOutlined, AppstoreOutlined, BarChartOutlined, SettingOutlined, HomeOutlined, + BookOutlined, + FileSearchOutlined, } from '@ant-design/icons'; import { authStore } from '../../store/authStore'; -import { themeStore } from '../../store/themeStore'; -import api from '../../api/client'; -import type { Notification } from '../../types/api'; +import { projectRoleStore } from '../../store/projectRoleStore'; +import { inboxStore } from '../../store/inboxStore'; import CommandPalette from '../CommandPalette'; +import AppHeader from './AppHeader'; +import styles from './AppLayout.module.scss'; -const { Header, Sider, Content } = Layout; +const { Sider, Content } = Layout; const AppLayout: React.FC = observer(() => { const navigate = useNavigate(); const location = useLocation(); const { token: themeToken } = theme.useToken(); const [collapsed, setCollapsed] = useState(false); - const [notifications, setNotifications] = useState([]); - const [unreadCount, setUnreadCount] = useState(0); - const user = authStore.user; - // Detect project context - const projectMatch = location.pathname.match(/\/projects\/(\d+)/); - const projectId = projectMatch ? projectMatch[1] : null; + // Detect project context (deferred to avoid antd Menu layout thrashing) + const projectMatch = /\/projects\/(\d+)/.exec(location.pathname); + const currentProjectId = projectMatch ? projectMatch[1] : null; + const [projectId, setProjectId] = useState(null); + useEffect(() => { + // Defer menu item changes to next frame to prevent Typography ellipsis loop + const id = requestAnimationFrame(() => setProjectId(currentProjectId)); + return () => cancelAnimationFrame(id); + }, [currentProjectId]); useEffect(() => { - fetchNotifications(); - const interval = setInterval(fetchNotifications, 30000); - return () => clearInterval(interval); + inboxStore.startPolling(); + return () => inboxStore.stopPolling(); }, []); - const fetchNotifications = async () => { - try { - const res = await api.get('/api/notifications?page_size=10'); - setNotifications(res.data.items); - setUnreadCount(res.data.unread_count); - } catch { - // silent - } - }; - - const markAllRead = async () => { - try { - await api.patch('/api/notifications/read-all'); - setUnreadCount(0); - setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true }))); - } catch { - // silent - } - }; - - const handleLogout = () => { - authStore.logout(); - navigate('/auth'); - }; - - const isDark = themeStore.resolvedMode === 'dark'; + const canManageProject = projectRoleStore.canManageProject; const menuItems = [ { @@ -109,10 +69,25 @@ const AppLayout: React.FC = observer(() => { label: 'Stats', }, { - key: `/projects/${projectId}/settings`, - icon: , - label: 'Settings', + key: `/projects/${projectId}/glossary`, + icon: , + label: 'Glossary', }, + // Review and Settings: only MANAGER/ADMIN + ...(canManageProject + ? [ + { + key: `/projects/${projectId}/review`, + icon: , + label: 'Review', + }, + { + key: `/projects/${projectId}/settings`, + icon: , + label: 'Settings', + }, + ] + : []), ] : []), // Admin items @@ -144,114 +119,25 @@ const AppLayout: React.FC = observer(() => { }, ]; - // Breadcrumbs - const breadcrumbItems: Array<{ title: React.ReactNode }> = [ - { title: navigate('/dashboard')}>Projects }, - ]; - if (projectId) { - breadcrumbItems.push({ title: Project #{projectId} }); - if (location.pathname.includes('/workspace')) - breadcrumbItems.push({ title: Workspace }); - else if (location.pathname.includes('/stats')) - breadcrumbItems.push({ title: Stats }); - else if (location.pathname.includes('/settings')) - breadcrumbItems.push({ title: Settings }); - } - - const notificationContent = ( -
-
- Notifications - {unreadCount > 0 && ( - - )} -
- {notifications.length === 0 ? ( - - ) : ( - ( - - - {item.title} - - } - description={ - - {item.message} - - } - /> - - )} - style={{ maxHeight: 360, overflow: 'auto' }} - /> - )} -
- ); - - const userMenuItems = [ - { - key: 'profile', - icon: , - label: 'Profile', - onClick: () => navigate('/profile'), - }, - { type: 'divider' as const }, - { - key: 'logout', - icon: , - label: 'Logout', - danger: true, - onClick: handleLogout, - }, - ]; - return ( -
{ onClick={() => setCollapsed(!collapsed)} size="small" /> -
- navigate(key)} - style={{ border: 'none', padding: '8px 4px' }} - /> + + + { + void navigate(key); + }} + className={styles.siderMenu} + /> + - -
- - - - -
+ + - + diff --git a/frontend/src/components/shared/NEREntityTags.tsx b/frontend/src/components/shared/NEREntityTags.tsx new file mode 100644 index 0000000..15b8879 --- /dev/null +++ b/frontend/src/components/shared/NEREntityTags.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { Typography, Tag } from 'antd'; +import type { NERAnnotationResult } from '../../types/api'; +import { LABEL_COLORS } from '../../types'; + +interface Props { + result: NERAnnotationResult; +} + +const NEREntityTags: React.FC = observer(({ result }) => { + const entities = result; + if (!Array.isArray(entities) || entities.length === 0) { + return ( + + No entities + + ); + } + + return ( +
+ {entities.map((e, i) => ( + + {e.text} {e.label} + + ))} +
+ ); +}); + +export default NEREntityTags; diff --git a/frontend/src/components/shared/TranslationResultDisplay.tsx b/frontend/src/components/shared/TranslationResultDisplay.tsx new file mode 100644 index 0000000..a0bea52 --- /dev/null +++ b/frontend/src/components/shared/TranslationResultDisplay.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { Typography, Tag } from 'antd'; +import type { TranslationAnnotationResult } from '../../types/api'; + +interface Props { + result: TranslationAnnotationResult; +} + +const TranslationResultDisplay: React.FC = observer(({ result }) => { + return ( +
+ {Object.entries(result).map(([lang, text]) => ( +
+ + {lang} + + + {text} +
+ ))} +
+ ); +}); + +export default TranslationResultDisplay; diff --git a/frontend/src/components/workspace/ContextPanel.tsx b/frontend/src/components/workspace/ContextPanel.tsx index 7551fec..a146d08 100644 --- a/frontend/src/components/workspace/ContextPanel.tsx +++ b/frontend/src/components/workspace/ContextPanel.tsx @@ -1,7 +1,15 @@ +import React from 'react'; import { Typography, Divider, Tag, Spin } from 'antd'; +import { observer } from 'mobx-react-lite'; import { useQuery } from '@tanstack/react-query'; import api from '../../api/client'; import type { TaskListItem, Project, GlossaryTerm } from '../../types/api'; +import { + isNERTaskData, + isTranslationTaskData, + getTaskText, +} from '../../types/api'; +import { ProjectTypes } from '../../types'; import type { GlobalToken } from 'antd'; interface TranslationMemoryMatch { @@ -17,186 +25,278 @@ interface Props { } function parseIniKey(key: string): string { - return key - .replace(/_/g, ' > ') - .replace(/\b\w/g, (c) => c.toUpperCase()); + return key.replace(/_/g, ' > ').replace(/\b\w/g, (c) => c.toUpperCase()); } -const ContextPanel: React.FC = ({ task, project, themeToken }) => { - const iniKey = - 'original_id' in task.data - ? (task.data as { original_id?: string }).original_id - : 'key' in task.data - ? (task.data as { key?: string }).key +const ContextPanel: React.FC = observer( + ({ task, project, themeToken }) => { + const iniKey = isNERTaskData(task.data) + ? ((task.data as { original_id?: string }).original_id ?? null) + : isTranslationTaskData(task.data) + ? task.data.key : null; - const { data: glossary = [] } = useQuery({ - queryKey: ['glossary', project.id], - queryFn: () => api.get(`/api/projects/${project.id}/glossary`).then((r) => r.data), - staleTime: 60_000, - }); - - const sourceText = - 'text' in task.data - ? (task.data as { text: string }).text - : 'source' in task.data - ? (task.data as { source: string }).source - : ''; - - // Translation memory - fuzzy search similar approved translations - const { data: tmMatches = [], isLoading: tmLoading } = useQuery({ - queryKey: ['translation-memory', project.id, sourceText.slice(0, 50)], - queryFn: () => - api.get(`/api/projects/${project.id}/translation-memory`, { - params: { query: sourceText.slice(0, 100), limit: 5 }, - }).then((r) => r.data), - enabled: !!sourceText && project.type === 'TRANSLATION', - staleTime: 120_000, - }); - - const matchedTerms = glossary.filter((g) => - sourceText.toLowerCase().includes(g.source_term.toLowerCase()) - ); - - return ( -
- {/* INI Key */} - {iniKey && ( - <> - - Key - -
- - {iniKey} - -
- - {parseIniKey(iniKey)} - - - - )} - - {/* Glossary matches */} - - Glossary - -
- {matchedTerms.length === 0 ? ( - - No matching terms - - ) : ( - matchedTerms.map((term) => ( -
({ + queryKey: ['glossary', project.id], + queryFn: () => + api + .get(`/projects/${String(project.id)}/glossary`) + .then((r) => r.data as GlossaryTerm[]), + staleTime: 60_000, + }); + + const sourceText = getTaskText(task.data); + + // Translation memory - fuzzy search similar approved translations + const { data: tmMatches = [], isLoading: tmLoading } = useQuery< + TranslationMemoryMatch[] + >({ + queryKey: ['translation-memory', project.id, sourceText.slice(0, 50)], + queryFn: () => + api + .get(`/projects/${String(project.id)}/translation-memory`, { + params: { query: sourceText.slice(0, 100), limit: 5 }, + }) + .then((r) => r.data as TranslationMemoryMatch[]), + enabled: !!sourceText && project.type === ProjectTypes.TRANSLATION, + staleTime: 120_000, + }); + + const matchedTerms = glossary.filter((g) => + sourceText.toLowerCase().includes(g.source_term.toLowerCase()) + ); + + return ( +
+ {/* INI Key */} + {iniKey && ( + <> + - - {term.source_term} + Key + + +
+ + {iniKey} - {Object.entries(term.translations).map(([lang, trans]) => ( -
- - {lang} - - {trans} -
- ))} - {term.notes && ( - - {term.notes} +
+ + + {parseIniKey(iniKey)} + + + + + )} + + {/* Glossary matches */} + + Glossary + + +
+ {matchedTerms.length === 0 ? ( + + No matching terms + + ) : ( + matchedTerms.map((term) => ( +
+ + {term.source_term} + {Object.entries(term.translations).map(([lang, trans]) => ( +
+ + {lang} + + + + {trans} + +
+ ))} + + {term.notes && ( + + {term.notes} + + )} +
+ )) + )} +
+ + {/* Translation Memory */} + {project.type === ProjectTypes.TRANSLATION && ( + <> + + + + Translation Memory + + +
+ {tmLoading ? ( + + ) : tmMatches.length === 0 ? ( + + No similar translations + + ) : ( + tmMatches.map((match, i) => ( +
+ + {match.source.slice(0, 80)} + {match.source.length > 80 ? '...' : ''} + + + {Object.entries(match.result).map(([lang, text]) => ( +
+ + {lang} + + + + {text} + +
+ ))} + + + Match: {Math.round(match.score * 100)}% + +
+ )) )}
- )) + )} -
- {/* Translation Memory */} - {project.type === 'TRANSLATION' && ( - <> - - - Translation Memory - -
- {tmLoading ? ( - - ) : tmMatches.length === 0 ? ( - - No similar translations - - ) : ( - tmMatches.map((match, i) => ( -
- - {match.source.slice(0, 80)}{match.source.length > 80 ? '...' : ''} - - {Object.entries(match.result).map(([lang, text]) => ( -
- {lang} - {String(text)} -
- ))} - - Match: {Math.round(match.score * 100)}% - -
- )) - )} + + + {/* Task metadata */} + + Task Info + + +
+
+ + ID:{' '} + + + + #{task.id} + +
+ +
+ + Annotations:{' '} + + + {task.annotation_count} + +
+
+ + Final:{' '} + + + + {task.has_final ? 'Yes' : 'No'} +
- - )} - - - - {/* Task metadata */} - - Task Info - -
-
- ID: - #{task.id} -
-
- Annotations: - {task.annotation_count} -
-
- Final: - - {task.has_final ? 'Yes' : 'No'} -
-
- ); -}; + ); + } +); export default ContextPanel; diff --git a/frontend/src/components/workspace/VotingPanel.tsx b/frontend/src/components/workspace/VotingPanel.tsx index 99232a7..5ff4ce7 100644 --- a/frontend/src/components/workspace/VotingPanel.tsx +++ b/frontend/src/components/workspace/VotingPanel.tsx @@ -1,5 +1,16 @@ -import { useState } from 'react'; -import { Typography, Button, Space, Tag, Input, Tooltip, Empty, Spin, theme } from 'antd'; +import React, { useState } from 'react'; +import { observer } from 'mobx-react-lite'; +import { + Typography, + Button, + Space, + Tag, + Input, + Tooltip, + Empty, + Spin, + theme, +} from 'antd'; import { LikeOutlined, LikeFilled, @@ -10,7 +21,16 @@ import { } from '@ant-design/icons'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import api from '../../api/client'; -import type { TaskAnnotation, NEREntity, ProjectType } from '../../types/api'; +import type { + TaskAnnotation, + NERAnnotationResult, + TranslationAnnotationResult, + ProjectType, + VoteValue, +} from '../../types/api'; +import { annotationStatusColor, ProjectTypes } from '../../types'; +import NEREntityTags from '../shared/NEREntityTags'; +import TranslationResultDisplay from '../shared/TranslationResultDisplay'; interface Props { taskId: number; @@ -18,14 +38,7 @@ interface Props { labels?: string[]; } -const LABEL_COLORS: Record = { - PER: '#3b82f6', ORG: '#8b5cf6', LOC: '#10b981', GPE: '#06b6d4', - FAC: '#f59e0b', MISC: '#6b7280', PRODUCT: '#ec4899', EVENT: '#f97316', - SHIP: '#0ea5e9', ARMOR: '#84cc16', WEAPON: '#ef4444', - QUANTITY: '#14b8a6', DATE: '#a855f7', MONEY: '#eab308', -}; - -const VotingPanel: React.FC = ({ taskId, projectType }) => { +const VotingPanel: React.FC = observer(({ taskId, projectType }) => { const { token: themeToken } = theme.useToken(); const queryClient = useQueryClient(); const [commentingId, setCommentingId] = useState(null); @@ -33,18 +46,25 @@ const VotingPanel: React.FC = ({ taskId, projectType }) => { const { data: annotations = [], isLoading } = useQuery({ queryKey: ['task-annotations', taskId], - queryFn: () => api.get(`/api/tasks/${taskId}/annotations`).then((r) => r.data), + queryFn: () => + api + .get(`/tasks/${String(taskId)}/annotations`) + .then((r) => r.data as TaskAnnotation[]), enabled: !!taskId, }); - const handleVote = async (annotationId: number, value: 1 | -1) => { - await api.post(`/api/annotations/${annotationId}/vote`, { value }); - queryClient.invalidateQueries({ queryKey: ['task-annotations', taskId] }); + const handleVote = async (annotationId: number, value: VoteValue) => { + await api.post(`/annotations/${String(annotationId)}/vote`, { value }); + await queryClient.invalidateQueries({ + queryKey: ['task-annotations', taskId], + }); }; const handleComment = async (annotationId: number) => { if (!commentText.trim()) return; - await api.post(`/api/annotations/${annotationId}/comments`, { text: commentText }); + await api.post(`/annotations/${String(annotationId)}/comments`, { + text: commentText, + }); setCommentText(''); setCommentingId(null); }; @@ -52,36 +72,6 @@ const VotingPanel: React.FC = ({ taskId, projectType }) => { if (isLoading) return ; if (annotations.length <= 1) return null; - const renderNERResult = (result: unknown) => { - const entities = result as NEREntity[]; - if (!Array.isArray(entities) || entities.length === 0) { - return No entities; - } - return ( -
- {entities.map((e, i) => ( - - {e.text} {e.label} - - ))} -
- ); - }; - - const renderTranslationResult = (result: unknown) => { - const translations = result as Record; - return ( -
- {Object.entries(translations).map(([lang, text]) => ( -
- {lang} - {text} -
- ))} -
- ); - }; - return (
= ({ taskId, projectType }) => { background: themeToken.colorBgContainer, }} > - + Annotations ({annotations.length}) {annotations.length === 0 ? ( - + ) : (
{annotations.map((ann) => ( @@ -105,29 +101,39 @@ const VotingPanel: React.FC = ({ taskId, projectType }) => { padding: 10, borderRadius: 8, border: `1px solid ${ann.is_final ? themeToken.colorSuccess : themeToken.colorBorderSecondary}`, - background: ann.is_final ? `${themeToken.colorSuccess}08` : themeToken.colorBgLayout, + background: ann.is_final + ? `${themeToken.colorSuccess}08` + : themeToken.colorBgLayout, }} > {/* Header */} -
+
- {ann.user_full_name || ann.user_email} + {ann.user_full_name ?? ann.user_email} + {ann.status} + {ann.is_final && ( - + )} + {new Date(ann.created_at).toLocaleString()} @@ -135,15 +141,25 @@ const VotingPanel: React.FC = ({ taskId, projectType }) => { {/* Content */}
- {projectType === 'NER' - ? renderNERResult(ann.result) - : renderTranslationResult(ann.result) - } + {projectType === ProjectTypes.NER ? ( + + ) : ( + + )}
{/* Review note */} {ann.review_note && ( -
+
Review: {ann.review_note} @@ -156,30 +172,50 @@ const VotingPanel: React.FC = ({ taskId, projectType }) => { + +
@@ -192,10 +228,14 @@ const VotingPanel: React.FC = ({ taskId, projectType }) => { placeholder="Add comment..." value={commentText} onChange={(e) => setCommentText(e.target.value)} - onPressEnter={() => handleComment(ann.id)} + onPressEnter={() => void handleComment(ann.id)} style={{ fontSize: 12 }} /> -
@@ -206,6 +246,6 @@ const VotingPanel: React.FC = ({ taskId, projectType }) => { )}
); -}; +}); export default VotingPanel; diff --git a/frontend/src/constants/languages.ts b/frontend/src/constants/languages.ts new file mode 100644 index 0000000..fac11fb --- /dev/null +++ b/frontend/src/constants/languages.ts @@ -0,0 +1,26 @@ +import type { LanguageCode } from '../types'; + +export const LANGUAGE_OPTIONS: { value: LanguageCode; label: string }[] = [ + { value: 'en', label: 'English' }, + { value: 'ru', label: 'Russian' }, + { value: 'fr', label: 'French' }, + { value: 'de', label: 'German' }, + { value: 'es', label: 'Spanish' }, + { value: 'it', label: 'Italian' }, + { value: 'pt', label: 'Portuguese' }, + { value: 'zh', label: 'Chinese' }, + { value: 'ja', label: 'Japanese' }, + { value: 'ko', label: 'Korean' }, + { value: 'pl', label: 'Polish' }, + { value: 'tr', label: 'Turkish' }, + { value: 'uk', label: 'Ukrainian' }, + { value: 'ar', label: 'Arabic' }, + { value: 'nl', label: 'Dutch' }, + { value: 'sv', label: 'Swedish' }, + { value: 'cs', label: 'Czech' }, + { value: 'da', label: 'Danish' }, + { value: 'fi', label: 'Finnish' }, + { value: 'hu', label: 'Hungarian' }, + { value: 'no', label: 'Norwegian' }, + { value: 'ro', label: 'Romanian' }, +]; diff --git a/frontend/src/hooks/usePresence.ts b/frontend/src/hooks/usePresence.ts index cad6848..de58dfc 100644 --- a/frontend/src/hooks/usePresence.ts +++ b/frontend/src/hooks/usePresence.ts @@ -1,17 +1,17 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { authStore } from '../store/authStore'; - -interface PresenceUser { - id: number; - name: string; - task_id: number | null; -} +import type { PresenceUser } from '../types/api'; interface PresenceState { users: PresenceUser[]; connected: boolean; } +interface PresenceMessage { + type: string; + users?: PresenceUser[]; +} + export function usePresence(projectId: string | undefined): PresenceState & { sendEditing: (taskId: number) => void; sendSubmitted: (taskId: number) => void; @@ -23,34 +23,54 @@ export function usePresence(projectId: string | undefined): PresenceState & { useEffect(() => { if (!projectId || !authStore.user) return; - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const host = window.location.host; - const ws = new WebSocket(`${protocol}//${host}/ws/projects/${projectId}`); + const currentUser = authStore.user; + + // Use VITE_WS_URL if set, otherwise construct from current location + let wsUrl = import.meta.env.VITE_WS_URL as string | undefined; + if (!wsUrl) { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = window.location.host; + wsUrl = `${protocol}//${host}/ws/projects/${projectId}`; + } else { + // If VITE_WS_URL is set, ensure it includes the project path + wsUrl = wsUrl.endsWith('/') ? wsUrl : `${wsUrl}/`; + wsUrl = `${wsUrl}projects/${projectId}`; + } + + const ws = new WebSocket(wsUrl); wsRef.current = ws; ws.onopen = () => { setConnected(true); - ws.send(JSON.stringify({ - user: { - id: authStore.user!.id, - name: authStore.user!.full_name || authStore.user!.email, - }, - })); + ws.send( + JSON.stringify({ + user: { + id: currentUser.id, + name: currentUser.full_name ?? currentUser.email, + }, + }) + ); }; ws.onmessage = (event) => { try { - const data = JSON.parse(event.data); + const data = JSON.parse(String(event.data)) as PresenceMessage; if (data.type === 'presence') { - setUsers(data.users.filter((u: PresenceUser) => u.id !== authStore.user?.id)); + setUsers( + (data.users ?? []).filter((u) => u.id !== authStore.user?.id) + ); } } catch { // ignore } }; - ws.onclose = () => setConnected(false); - ws.onerror = () => setConnected(false); + ws.onclose = () => { + setConnected(false); + }; + ws.onerror = () => { + setConnected(false); + }; return () => { ws.close(); @@ -66,7 +86,9 @@ export function usePresence(projectId: string | undefined): PresenceState & { const sendSubmitted = useCallback((taskId: number) => { if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify({ type: 'submitted', task_id: taskId })); + wsRef.current.send( + JSON.stringify({ type: 'submitted', task_id: taskId }) + ); } }, []); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 5fe1091..532b3cd 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,7 +2,8 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import App from './App'; -const container = document.getElementById('root')!; +const container = document.getElementById('root'); +if (!container) throw new Error('Root element not found'); const root = createRoot(container); root.render( diff --git a/frontend/src/pages/Auth.tsx b/frontend/src/pages/Auth.tsx index 6b81048..c6855e4 100644 --- a/frontend/src/pages/Auth.tsx +++ b/frontend/src/pages/Auth.tsx @@ -1,28 +1,25 @@ import React from 'react'; import AuthForm from '../components/AuthForm'; import { observer } from 'mobx-react-lite'; -import { themeStore } from '../store/themeStore'; -import { tokens } from '../styles/design-tokens'; +import { Flex, Layout } from 'antd'; +import AppHeader from '../components/layout/AppHeader'; const Auth: React.FC = observer(() => { - const isDark = themeStore.resolvedMode === 'dark'; - return ( -
- -
+ + + + + + + + + ); }); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx deleted file mode 100644 index c4d0054..0000000 --- a/frontend/src/pages/Dashboard.tsx +++ /dev/null @@ -1,360 +0,0 @@ -import { useState } from 'react'; -import { observer } from 'mobx-react-lite'; -import { useNavigate } from 'react-router'; -import { - Typography, - Button, - Card, - Row, - Col, - Tag, - Modal, - Form, - Input, - Select, - Skeleton, - Empty, - Statistic, - Space, - Progress, - Tooltip, -} from 'antd'; -import { - PlusOutlined, - FolderOutlined, - SettingOutlined, - BarChartOutlined, - AppstoreOutlined, - SearchOutlined, -} from '@ant-design/icons'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { authStore } from '../store/authStore'; -import api from '../api/client'; -import type { Project, ProjectType, OverviewStats, ProjectStats } from '../types/api'; -import { tokens } from '../styles/design-tokens'; -import { motion } from 'framer-motion'; - -const Dashboard: React.FC = observer(() => { - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const [modalOpen, setModalOpen] = useState(false); - const [form] = Form.useForm(); - const [searchText, setSearchText] = useState(''); - const [typeFilter, setTypeFilter] = useState(''); - - const { data: projects = [], isLoading } = useQuery({ - queryKey: ['projects'], - queryFn: () => api.get('/projects').then((r) => r.data), - }); - - const { data: overview } = useQuery({ - queryKey: ['overview-stats'], - queryFn: () => api.get('/api/overview-stats').then((r) => r.data), - }); - - // Fetch stats for each project for progress bars - const { data: projectStats = {} } = useQuery>({ - queryKey: ['all-project-stats', projects.map((p) => p.id).join(',')], - queryFn: async () => { - const stats: Record = {}; - await Promise.all( - projects.map(async (p) => { - try { - const res = await api.get(`/api/projects/${p.id}/stats`); - stats[p.id] = res.data; - } catch { - // ignore - } - }) - ); - return stats; - }, - enabled: projects.length > 0, - staleTime: 30_000, - }); - - const createProject = useMutation({ - mutationFn: (values: { name: string; type: ProjectType }) => - api.post('/projects', values), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['projects'] }); - setModalOpen(false); - form.resetFields(); - }, - }); - - const filteredProjects = projects.filter((p) => { - if (searchText && !p.name.toLowerCase().includes(searchText.toLowerCase())) return false; - if (typeFilter && p.type !== typeFilter) return false; - return true; - }); - - const totalTasks = Object.values(projectStats).reduce((sum, s) => sum + s.total_tasks, 0); - const totalPending = Object.values(projectStats).reduce((sum, s) => sum + s.pending_review, 0); - - // Find the project with the most pending work for "Continue" button - const lastActiveProject = projects.length > 0 - ? projects.reduce((best, p) => { - const s = projectStats[p.id]; - const bS = projectStats[best.id]; - if (!s) return best; - if (!bS) return p; - if (s.total_tasks - s.approved_tasks > bS.total_tasks - bS.approved_tasks) return p; - return best; - }) - : null; - - return ( -
- {/* Continue button */} - {lastActiveProject && ( -
- -
- )} - - {/* Personal metrics */} - {overview && ( - - {[ - ...(authStore.isAdmin - ? [ - { title: 'Users', value: overview.total_users, color: tokens.colors.primary }, - ] - : []), - { title: 'Projects', value: projects.length, color: tokens.colors.accent }, - { title: 'Tasks', value: totalTasks, color: tokens.colors.success }, - { title: 'Pending Review', value: totalPending, color: tokens.colors.warning }, - ].map((stat, i) => ( - - - - - {stat.title} - - } - value={stat.value} - valueStyle={{ color: 'white', fontWeight: 700 }} - /> - - - - ))} - - )} - - {/* Header with search and filters */} -
- - Projects - - - } - placeholder="Search projects..." - value={searchText} - onChange={(e) => setSearchText(e.target.value)} - allowClear - style={{ width: 200 }} - /> - - - - - - - -
- ); -}); - -export default Dashboard; diff --git a/frontend/src/pages/Dashboard/Dashboard.tsx b/frontend/src/pages/Dashboard/Dashboard.tsx new file mode 100644 index 0000000..78b01be --- /dev/null +++ b/frontend/src/pages/Dashboard/Dashboard.tsx @@ -0,0 +1,246 @@ +import { useState } from 'react'; +import { observer } from 'mobx-react-lite'; +import { useNavigate } from 'react-router'; +import { + Typography, + Button, + Card, + Row, + Col, + Input, + Select, + Skeleton, + Empty, + Space, + Flex, +} from 'antd'; +import { + PlusOutlined, + AppstoreOutlined, + SearchOutlined, +} from '@ant-design/icons'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { authStore } from '../../store/authStore'; +import api from '../../api/client'; +import type { + Project, + ProjectType, + OverviewStats, + ProjectStats, +} from '../../types/api'; +import StatsCards from './StatsCards'; +import ProjectCard from './ProjectCard'; +import NewProjectModal from './NewProjectModal'; + +const Dashboard: React.FC = observer(() => { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [modalOpen, setModalOpen] = useState(false); + const [searchText, setSearchText] = useState(''); + const [typeFilter, setTypeFilter] = useState(''); + + const { data: projects = [], isLoading } = useQuery({ + queryKey: ['projects'], + queryFn: () => api.get('/projects').then((r) => r.data), + }); + + const { data: overview } = useQuery({ + queryKey: ['overview-stats'], + queryFn: () => + api.get('/overview-stats').then((r) => r.data), + }); + + const { data: projectStats = {} } = useQuery>({ + queryKey: ['all-project-stats', projects.map((p) => p.id).join(',')], + queryFn: async () => { + const stats: Record = {}; + + await Promise.all( + projects.map(async (p) => { + try { + const res = await api.get( + `/projects/${String(p.id)}/stats` + ); + stats[p.id] = res.data; + } catch { + // ignore + } + }) + ); + + return stats; + }, + enabled: projects.length > 0, + staleTime: 30_000, + }); + + const createProject = useMutation({ + mutationFn: (values: { + name: string; + type: ProjectType; + source_language?: string; + target_languages?: string[]; + }) => { + const { source_language, target_languages, ...rest } = values; + const config: Record = {}; + + if (source_language) config.source_language = source_language; + if (target_languages?.length) config.target_languages = target_languages; + + return api.post('/projects', { ...rest, config }); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['projects'] }); + }, + }); + + const filteredProjects = projects.filter((p) => { + if (searchText && !p.name.toLowerCase().includes(searchText.toLowerCase())) + return false; + + if (typeFilter && p.type !== typeFilter) return false; + + return true; + }); + + // Find the project with the most pending work for "Continue" button + const lastActiveProject = + projects.length > 0 + ? projects.reduce((best, p) => { + const s = (projectStats as Record)[ + p.id + ]; + + const bS = (projectStats as Record)[ + best.id + ]; + + if (!s) return best; + + if (!bS) return p; + + if ( + s.total_tasks - s.approved_tasks > + bS.total_tasks - bS.approved_tasks + ) + return p; + + return best; + }) + : null; + + return ( + + {/* Continue button */} + {lastActiveProject && ( + + )} + + {/* Personal metrics */} + {overview && ( + + )} + + {/* Header with search and filters */} + + + Projects + + + + } + placeholder="Search projects..." + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + allowClear + style={{ width: 200 }} + /> + + + + + + + + + + @@ -77,7 +86,23 @@ const Profile: React.FC = observer(() => { Change Password -
+ {passwordChanged && ( + setPasswordChanged(false) }} + style={{ marginBottom: 16 }} + /> + )} + + + void handleChangePassword(values) + } + > { > + { > + diff --git a/frontend/src/pages/ProjectGlossary.tsx b/frontend/src/pages/ProjectGlossary.tsx new file mode 100644 index 0000000..011fa8d --- /dev/null +++ b/frontend/src/pages/ProjectGlossary.tsx @@ -0,0 +1,250 @@ +import { useState } from 'react'; +import { useParams } from 'react-router'; +import { + Typography, + Card, + Button, + Table, + Space, + Modal, + Form, + Input, + Tag, + Popconfirm, + App, +} from 'antd'; +import { PlusOutlined, DeleteOutlined } from '@ant-design/icons'; +import { observer } from 'mobx-react-lite'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import api from '../api/client'; +import type { GlossaryTerm } from '../types/api'; + +const ProjectGlossary: React.FC = observer(() => { + const { projectId } = useParams(); + const { notification } = App.useApp(); + const queryClient = useQueryClient(); + + const [glossaryForm] = Form.useForm(); + const [glossaryModalOpen, setGlossaryModalOpen] = useState(false); + const [editingTerm, setEditingTerm] = useState(null); + + const { data: glossary = [] } = useQuery({ + queryKey: ['glossary', projectId], + queryFn: () => + api + .get(`/projects/${String(projectId)}/glossary`) + .then((r) => r.data), + enabled: !!projectId, + }); + + const handleSaveGlossaryTerm = async (values: { + source_term: string; + translations: string; + notes?: string; + }) => { + const translationsMap: Record = {}; + (values.translations || '').split('\n').forEach((line: string) => { + const [lang, ...rest] = line.split(':'); + + if (lang && rest.length > 0) + translationsMap[lang.trim()] = rest.join(':').trim(); + }); + + try { + if (editingTerm) { + await api.patch( + `/projects/${String(projectId)}/glossary/${String(editingTerm.id)}`, + { + translations: translationsMap, + notes: values.notes ?? null, + } + ); + } else { + await api.post(`/projects/${String(projectId)}/glossary`, { + source_term: values.source_term, + translations: translationsMap, + notes: values.notes ?? null, + }); + } + setGlossaryModalOpen(false); + setEditingTerm(null); + glossaryForm.resetFields(); + void queryClient.invalidateQueries({ queryKey: ['glossary', projectId] }); + } catch { + notification.error({ message: 'Failed to save glossary term' }); + } + }; + + const handleDeleteGlossaryTerm = async (termId: number) => { + try { + await api.delete( + `/projects/${String(projectId)}/glossary/${String(termId)}` + ); + void queryClient.invalidateQueries({ queryKey: ['glossary', projectId] }); + } catch { + notification.error({ message: 'Failed to delete term' }); + } + }; + + return ( +
+
+ + Glossary + + + +
+ + + + {glossary.length} term{glossary.length !== 1 ? 's' : ''} + + + ( + {v} + ), + }, + { + title: 'Translations', + dataIndex: 'translations', + render: (t: Record) => ( + + {Object.entries(t).map(([lang, trans]) => ( + + + {lang} + + + {trans} + + + ))} + + ), + }, + { + title: 'Notes', + dataIndex: 'notes', + render: (v: string | null) => v ?? '\u2014', + }, + { + title: '', + width: 80, + render: (_value: string | undefined, record: GlossaryTerm) => ( + + + void handleDeleteGlossaryTerm(record.id)} + > +
reviewStore.setSelectedRowKeys(keys), + getCheckboxProps: (record: AnnotationListItem) => ({ + disabled: record.status !== AnnotationStatuses.SUBMITTED, + }), + }} + expandable={{ + expandedRowRender: (record: AnnotationListItem) => ( +
+ + Content: + + {project?.type === ProjectTypes.NER ? ( + + ) : ( + + )} + {record.task_data && ( +
+ + Source text: + + + + {getTaskText(record.task_data)} + +
+ )} + {record.review_note && ( +
+ + Review note: {record.review_note} + +
+ )} +
+ ), + }} + columns={[ + { title: 'ID', dataIndex: 'id', width: 60 }, + { + title: 'Task', + dataIndex: 'task_id', + width: 80, + render: (v: number) => `#${String(v)}`, + }, + { + title: 'Annotator', + render: (_value: string | undefined, r: AnnotationListItem) => + r.user_full_name ?? r.user_email, + }, + { + title: 'Preview', + render: (_value: string | undefined, r: AnnotationListItem) => { + if (project?.type === ProjectTypes.NER) { + const entities = r.result as NEREntity[]; + return Array.isArray(entities) ? ( + + {entities.slice(0, 3).map((e, i) => ( + + {e.text} + + ))} + {entities.length > 3 && ( + + +{entities.length - 3} + + )} + + ) : null; + } + const vals = Object.values(r.result).filter(Boolean); + return vals.length > 0 ? ( + + {String(vals[0]).slice(0, 60)} + + ) : null; + }, + }, + { + title: 'Status', + dataIndex: 'status', + width: 120, + render: (s: AnnotationStatus) => ( + {s} + ), + }, + { + title: 'Updated', + dataIndex: 'updated_at', + render: (v: string) => new Date(v).toLocaleString(), + }, + { + title: 'Actions', + width: 120, + render: (_value: string | undefined, r: AnnotationListItem) => + r.status === AnnotationStatuses.SUBMITTED ? ( + + + - - - - {/* Label config (NER projects) */} - {project?.type === 'NER' && ( - -
- setNewLabel(e.target.value)} - onPressEnter={handleAddLabel} - style={{ width: 160 }} - /> - -
-
- {(project?.config?.labels || []).map((label: string, idx: number) => ( -
- {label} - - Hotkey: {idx + 1 <= 9 ? idx + 1 : '-'} - -
- ))} -
- {(project?.config?.labels || []).length === 0 && ( - No labels configured. Default labels will be used. - )} -
- )} - - ), - }, - - // --- Import/Export --- - { - key: 'import-export', - label: 'Import / Export', - children: ( -
- - - Upload INI files for Translation or JSON for NER. - - setReplaceOnImport(e.target.checked)} - style={{ marginBottom: 12 }} - > - Replace existing tasks - - -

-

Click or drag file to upload

-
- {uploading && } - - {/* Import preview */} - { setImportPreview(null); setPendingFile(null); }} - onOk={handleConfirmImport} - okText={`Import ${importPreview?.total_tasks || 0} tasks`} - confirmLoading={uploading} - > - {importPreview && ( -
- - File: {importPreview.filename} - - - {importPreview.total_tasks} tasks found - {importPreview.with_entities > 0 && ( - {importPreview.with_entities} tasks with entities ({importPreview.entities_count} total) - )} - - - Sample (first {Math.min(10, importPreview.sample.length)}): - -
- {importPreview.sample.map((s, i) => ( -
- {s.id && {s.id}} - {s.key && {s.key}} - {s.text} - {(s.entities_count ?? 0) > 0 && ( - {s.entities_count} entities - )} -
- ))} -
- setReplaceOnImport(e.target.checked)} - style={{ marginTop: 12 }} - > - Replace existing tasks - -
- )} -
-
- - - Download approved annotations for VerseBridge training. - - - - - - -
- ), - }, - - // --- Members --- - { - key: 'members', - label: 'Members', - children: ( - <> -
- -
-
v || '\u2014' }, - { title: 'Email', dataIndex: 'email' }, - { - title: 'Role', dataIndex: 'role', - render: (role: string) => {role}, - }, - { - title: '', width: 60, - render: (_: unknown, record: ProjectMember) => ( - handleRemoveMember(record.user_id)}> - - - - )} - -
({ - disabled: record.status !== 'SUBMITTED', - }), - }} - expandable={{ - expandedRowRender: (record: AnnotationListItem) => ( -
- - Content: - - {project?.type === 'NER' - ? renderNERAnnotation(record.result) - : renderTranslationAnnotation(record.result) - } - {record.task_data && ( -
- - Source text: - - - {'text' in record.task_data ? (record.task_data as { text: string }).text - : 'source' in record.task_data ? (record.task_data as { source: string }).source - : ''} - -
- )} - {record.review_note && ( -
- - Review note: {record.review_note} - -
- )} -
- ), - }} - columns={[ - { title: 'ID', dataIndex: 'id', width: 60 }, - { title: 'Task', dataIndex: 'task_id', width: 80, render: (v: number) => `#${v}` }, - { - title: 'Annotator', - render: (_: unknown, r: AnnotationListItem) => r.user_full_name || r.user_email, - }, - { - title: 'Preview', - render: (_: unknown, r: AnnotationListItem) => { - if (project?.type === 'NER') { - const entities = r.result as unknown as NEREntity[]; - return Array.isArray(entities) ? ( - - {entities.slice(0, 3).map((e, i) => ( - - {e.text} - - ))} - {entities.length > 3 && +{entities.length - 3}} - - ) : null; - } - const vals = Object.values(r.result).filter(Boolean); - return vals.length > 0 ? ( - - {String(vals[0]).slice(0, 60)} - - ) : null; - }, - }, - { - title: 'Status', dataIndex: 'status', width: 120, - render: (s: string) => ( - {s} - ), - }, - { title: 'Updated', dataIndex: 'updated_at', render: (v: string) => new Date(v).toLocaleString() }, - { - title: 'Actions', width: 120, - render: (_: unknown, r: AnnotationListItem) => - r.status === 'SUBMITTED' ? ( - - - - -
{v} }, - { - title: 'Translations', - dataIndex: 'translations', - render: (t: Record) => ( - - {Object.entries(t).map(([lang, trans]) => ( - - {lang} - {trans} - - ))} - - ), - }, - { title: 'Notes', dataIndex: 'notes', render: (v: string | null) => v || '\u2014' }, - { - title: '', width: 80, - render: (_: unknown, record: GlossaryTerm) => ( - - - handleDeleteGlossaryTerm(record.id)}> - - - - ), - }, - ]; - - return ( -
- - Project Settings - - - { - if (key === 'members') fetchMembers(); - if (key === 'review') fetchAnnotations(); - }} - /> -
- ); -}); - -export default ProjectSettings; diff --git a/frontend/src/pages/ProjectSettings/DangerZoneTab.tsx b/frontend/src/pages/ProjectSettings/DangerZoneTab.tsx new file mode 100644 index 0000000..a6dc2e4 --- /dev/null +++ b/frontend/src/pages/ProjectSettings/DangerZoneTab.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { Typography, Card, Button, Space, App } from 'antd'; +import { DeleteOutlined } from '@ant-design/icons'; +import { useNavigate } from 'react-router'; +import api from '../../api/client'; + +interface Props { + projectId: string; +} + +const DangerZoneTab: React.FC = observer(({ projectId }) => { + const navigate = useNavigate(); + const { notification, modal } = App.useApp(); + + const handleDeleteProject = () => { + modal.confirm({ + title: 'Delete project?', + content: + 'This will permanently delete the project and all its tasks and annotations.', + okText: 'Delete', + okButtonProps: { danger: true }, + onOk: async () => { + try { + await api.delete(`/projects/${projectId}`); + notification.success({ message: 'Project deleted' }); + void navigate('/'); + } catch { + notification.error({ message: 'Failed to delete project' }); + } + }, + }); + }; + + return ( + + + + Deleting a project removes all tasks, annotations, and members + permanently. + + + + + + ); +}); + +export default DangerZoneTab; diff --git a/frontend/src/pages/ProjectSettings/GeneralTab.tsx b/frontend/src/pages/ProjectSettings/GeneralTab.tsx new file mode 100644 index 0000000..605c418 --- /dev/null +++ b/frontend/src/pages/ProjectSettings/GeneralTab.tsx @@ -0,0 +1,326 @@ +import React, { useState } from 'react'; +import { + Typography, + Card, + Button, + Tag, + Space, + Popconfirm, + Input, + Select, + theme, + App, +} from 'antd'; +import { + DeleteOutlined, + PlusOutlined, + ArrowUpOutlined, + ArrowDownOutlined, +} from '@ant-design/icons'; +import { useQueryClient } from '@tanstack/react-query'; +import { observer } from 'mobx-react-lite'; +import { projectRoleStore } from '../../store/projectRoleStore'; +import api from '../../api/client'; +import { LANGUAGE_OPTIONS } from '../../constants/languages'; +import type { Project, ProjectConfig } from '../../types/api'; +import { LABEL_COLORS, ProjectTypes, projectTypeColor } from '../../types'; + +interface Props { + project: Project; + projectId: string; + refetchProject: () => void; +} + +const GeneralTab: React.FC = observer( + ({ project, projectId, refetchProject }) => { + const { notification } = App.useApp(); + const { token: themeToken } = theme.useToken(); + const canManage = projectRoleStore.canManageProject; + const queryClient = useQueryClient(); + + const [newLabel, setNewLabel] = useState(''); + const [autoApproveThreshold, setAutoApproveThreshold] = useState(0); + + const updateProjectConfig = async (newConfig: ProjectConfig) => { + try { + await api.patch(`/projects/${projectId}`, { config: newConfig }); + refetchProject(); + void queryClient.invalidateQueries({ + queryKey: ['project', projectId], + }); + } catch { + notification.error({ message: 'Failed to update project' }); + } + }; + + const handleAddLabel = () => { + const label = newLabel.trim().toUpperCase(); + + if (!label) return; + + const current = project.config.labels ?? []; + + if (current.includes(label)) { + notification.warning({ message: 'Label already exists' }); + + return; + } + + void updateProjectConfig({ + ...project.config, + labels: [...current, label], + }); + + setNewLabel(''); + }; + + const handleRemoveLabel = (label: string) => { + const current = project.config.labels ?? []; + + void updateProjectConfig({ + ...project.config, + labels: current.filter((l: string) => l !== label), + }); + }; + + const handleMoveLabel = (label: string, direction: 'up' | 'down') => { + const current = [...(project.config.labels ?? [])]; + const idx = current.indexOf(label); + + if (direction === 'up' && idx > 0) { + [current[idx - 1], current[idx]] = [current[idx], current[idx - 1]]; + } else if (direction === 'down' && idx < current.length - 1) { + [current[idx], current[idx + 1]] = [current[idx + 1], current[idx]]; + } + + void updateProjectConfig({ ...project.config, labels: current }); + }; + + return ( +
+ +
+ + Name + + + {project.name} +
+ +
+ + Type + + + {project.type} +
+ + {project.type === ProjectTypes.TRANSLATION && ( + <> +
+ + Source Language + + + + void updateProjectConfig({ + ...project.config, + target_languages: val, + }) + } + options={LANGUAGE_OPTIONS} + /> +
+ + )} +
+ + {/* Auto-approve threshold */} + + + Automatically approve annotations when they receive enough upvotes. + Set to 0 to disable auto-approve. + + + + + setAutoApproveThreshold(parseInt(e.target.value) || 0) + } + style={{ width: 80 }} + size="small" + /> + + + upvotes to auto-approve + + + + + + + {/* Label config (NER projects) */} + {project.type === ProjectTypes.NER && ( + + {canManage && ( +
+ setNewLabel(e.target.value)} + onPressEnter={handleAddLabel} + style={{ width: 160 }} + /> + + +
+ )} + +
+ {(project.config.labels ?? []).map( + (label: string, idx: number) => ( +
+ + {label} + + + + Hotkey: {idx + 1 <= 9 ? idx + 1 : '-'} + + + {canManage && ( + <> +
+ ) + )} +
+ + {(project.config.labels ?? []).length === 0 && ( + + No labels configured. Default labels will be used. + + )} +
+ )} +
+ ); + } +); + +export default GeneralTab; diff --git a/frontend/src/pages/ProjectSettings/ImportExportTab.tsx b/frontend/src/pages/ProjectSettings/ImportExportTab.tsx new file mode 100644 index 0000000..eae2617 --- /dev/null +++ b/frontend/src/pages/ProjectSettings/ImportExportTab.tsx @@ -0,0 +1,249 @@ +import React, { useState } from 'react'; +import { + Typography, + Card, + Button, + Upload, + Progress, + Tag, + Space, + Modal, + Checkbox, + App, + theme, +} from 'antd'; +import { CloudUploadOutlined, DownloadOutlined } from '@ant-design/icons'; +import { observer } from 'mobx-react-lite'; +import { authStore } from '../../store/authStore'; +import { projectRoleStore } from '../../store/projectRoleStore'; +import { projectSettingsStore } from '../../store/projectSettingsStore'; + +interface Props { + projectId: string; +} + +const ImportExportTab: React.FC = observer(({ projectId }) => { + const { notification } = App.useApp(); + const { token: themeToken } = theme.useToken(); + const [replaceOnImport, setReplaceOnImport] = useState(false); + + const { importPreview, uploading } = projectSettingsStore; + + const handleFilePreview = async (file: File) => { + try { + await projectSettingsStore.importPreviewFile(projectId, file); + } catch (err) { + const errObj = err as { response?: { data?: { detail?: string } } }; + const detail = errObj.response?.data?.detail; + + notification.error({ message: detail ?? 'Failed to preview file' }); + } + + return false; + }; + + const handleConfirmImport = async () => { + try { + const count = await projectSettingsStore.confirmImport( + projectId, + replaceOnImport + ); + + notification.success({ + message: `Import successful! ${String(count)} tasks imported.`, + }); + } catch (err) { + const errObj = err as { response?: { data?: { detail?: string } } }; + const detail = errObj.response?.data?.detail; + + notification.error({ message: detail ?? 'Import failed' }); + } + }; + + const handleExport = async (format: 'json' | 'ini') => { + try { + await projectSettingsStore.exportData(projectId, format); + } catch { + notification.error({ message: 'Export failed' }); + } + }; + + return ( +
+ + + Upload INI files for Translation or JSON for NER. + + + {projectRoleStore.isManager && !authStore.isAdmin && ( + + Note: As a Manager, your imports will require admin approval before + being applied. + + )} + + setReplaceOnImport(e.target.checked)} + style={{ marginBottom: 12 }} + > + Replace existing tasks + + + +

+ +

+

Click or drag file to upload

+
+ + {uploading && ( + + )} + + {/* Import preview */} + projectSettingsStore.clearImportPreview()} + onOk={() => void handleConfirmImport()} + okText={`Import ${String(importPreview?.total_tasks ?? 0)} tasks`} + confirmLoading={uploading} + > + {importPreview && ( +
+ + File: {importPreview.filename} + + + + + {importPreview.total_tasks} tasks found + + + {importPreview.with_entities > 0 && ( + + {importPreview.with_entities} tasks with entities ( + {importPreview.entities_count} total) + + )} + + + + Sample (first {Math.min(10, importPreview.sample.length)}): + + +
+ {importPreview.sample.map((s, i) => ( +
+ {s.id && ( + + {s.id} + + )} + {s.key && ( + + {s.key} + + )} + + + {s.text} + + + {(s.entities_count ?? 0) > 0 && ( + + {s.entities_count} entities + + )} +
+ ))} +
+ + setReplaceOnImport(e.target.checked)} + style={{ marginTop: 12 }} + > + Replace existing tasks + +
+ )} +
+
+ + + + Download approved annotations for VerseBridge training. + + + + + + + + +
+ ); +}); + +export default ImportExportTab; diff --git a/frontend/src/pages/ProjectSettings/MembersTab.tsx b/frontend/src/pages/ProjectSettings/MembersTab.tsx new file mode 100644 index 0000000..2044410 --- /dev/null +++ b/frontend/src/pages/ProjectSettings/MembersTab.tsx @@ -0,0 +1,178 @@ +import React, { useState } from 'react'; +import { Button, Table, Modal, Form, Select, Popconfirm, App } from 'antd'; +import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'; +import { observer } from 'mobx-react-lite'; +import { authStore } from '../../store/authStore'; +import { projectSettingsStore } from '../../store/projectSettingsStore'; +import type { ProjectMember, RoleProject } from '../../types/api'; +import { ProjectRoles } from '../../types'; + +interface Props { + projectId: string; +} + +const MembersTab: React.FC = observer(({ projectId }) => { + const { notification } = App.useApp(); + const [openAddMember, setOpenAddMember] = useState(false); + const [addForm] = Form.useForm(); + + const handleAddMember = async (values: { + user_id: number; + role: RoleProject; + }) => { + try { + await projectSettingsStore.addMember( + projectId, + values.user_id, + values.role + ); + setOpenAddMember(false); + addForm.resetFields(); + } catch { + notification.error({ message: 'Failed to add member' }); + } + }; + + const handleRemoveMember = async (userId: number) => { + try { + await projectSettingsStore.removeMember(projectId, userId); + } catch { + notification.error({ message: 'Failed to remove member' }); + } + }; + + const handleChangeRole = async (userId: number, role: RoleProject) => { + try { + await projectSettingsStore.changeRole(projectId, userId, role); + } catch { + notification.error({ message: 'Failed to update role' }); + } + }; + + return ( + <> +
+ +
+
v ?? '\u2014', + }, + { title: 'Email', dataIndex: 'email' }, + { + title: 'Role', + dataIndex: 'role', + render: (_value: RoleProject, record: ProjectMember) => { + const isTargetManager = record.role === ProjectRoles.MANAGER; + const locked = isTargetManager && !authStore.isAdmin; + return ( + ({ + key: u.id, + value: u.id, + label: `${u.full_name ?? u.email} (${u.email})`, + }))} + /> + + +
r.full_name || r.email, - }, - { title: 'Total', dataIndex: 'total_annotations', width: 60, align: 'center' }, - { - title: 'Status', width: 180, - render: (_, r: AnnotatorStats) => ( - <> - {r.approved} - {r.rejected} - {r.pending} - - ), - }, - ]} - /> - - - - - {/* Timeline chart (simple CSS bar chart) */} - {timeline.length > 0 && ( - -
- {timeline.map((entry) => ( -
-
-
0 ? 2 : 0, - }} - /> -
0 ? 2 : 0, - }} - /> -
- - {entry.date.slice(5)} - -
- ))} -
-
-
-
- Approved -
-
-
- Other -
-
- - )} - - {/* Label distribution (NER) */} - {labelStats.length > 0 && ( - -
- {labelStats.map((entry) => { - const maxCount = labelStats[0]?.count || 1; - return ( -
- - {entry.label} - -
-
-
- - {entry.count} - -
- ); - })} -
- - )} -
- ); -}; - -export default ProjectStats; diff --git a/frontend/src/pages/ProjectStats/KPICards.tsx b/frontend/src/pages/ProjectStats/KPICards.tsx new file mode 100644 index 0000000..f4a86a1 --- /dev/null +++ b/frontend/src/pages/ProjectStats/KPICards.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { Card, Row, Col, Statistic, theme } from 'antd'; +import { + CheckCircleOutlined, + CloseCircleOutlined, + ClockCircleOutlined, + FileTextOutlined, +} from '@ant-design/icons'; +import type { ProjectStats } from '../../types/api'; + +interface Props { + stats: ProjectStats; +} + +const KPICards: React.FC = observer(({ stats }) => { + const { token: themeToken } = theme.useToken(); + + const kpis = [ + { + title: 'Total Tasks', + value: stats.total_tasks, + icon: , + color: themeToken.colorPrimary, + }, + { + title: 'Approved', + value: stats.approved_tasks, + icon: , + color: themeToken.colorSuccess, + }, + { + title: 'Rejected', + value: stats.rejected_tasks, + icon: , + color: themeToken.colorError, + }, + { + title: 'Pending Review', + value: stats.pending_review, + icon: , + color: themeToken.colorWarning, + }, + ]; + + return ( + + {kpis.map((kpi) => ( +
+ + {kpi.icon}} + styles={{ content: { fontWeight: 700 } }} + /> + + + ))} + + ); +}); + +export default KPICards; diff --git a/frontend/src/pages/ProjectStats/LabelDistribution.tsx b/frontend/src/pages/ProjectStats/LabelDistribution.tsx new file mode 100644 index 0000000..bdfd6c5 --- /dev/null +++ b/frontend/src/pages/ProjectStats/LabelDistribution.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { Typography, Card, Tag, theme } from 'antd'; +import { LABEL_COLORS } from '../../types'; + +interface LabelEntry { + label: string; + count: number; +} + +interface Props { + labelStats: LabelEntry[]; +} + +const LabelDistribution: React.FC = observer(({ labelStats }) => { + const { token: themeToken } = theme.useToken(); + const maxCount = labelStats[0]?.count ?? 1; + + return ( + +
+ {labelStats.map((entry) => ( +
+ + {entry.label} + + +
+
+
+ + + {entry.count} + +
+ ))} +
+ + ); +}); + +export default LabelDistribution; diff --git a/frontend/src/pages/ProjectStats/ProjectStats.tsx b/frontend/src/pages/ProjectStats/ProjectStats.tsx new file mode 100644 index 0000000..e0e3306 --- /dev/null +++ b/frontend/src/pages/ProjectStats/ProjectStats.tsx @@ -0,0 +1,183 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { useParams } from 'react-router'; +import { + Typography, + Card, + Row, + Col, + Progress, + Table, + Tag, + Skeleton, + theme, +} from 'antd'; +import { useQuery } from '@tanstack/react-query'; +import api from '../../api/client'; +import type { + ProjectStats as ProjectStatsType, + AnnotatorStats, +} from '../../types/api'; +import KPICards from './KPICards'; +import TimelineChart from './TimelineChart'; +import LabelDistribution from './LabelDistribution'; + +interface TimelineEntry { + date: string; + total: number; + approved: number; + rejected: number; +} + +interface LabelEntry { + label: string; + count: number; +} + +const ProjectStats: React.FC = observer(() => { + const { projectId } = useParams(); + const { token: themeToken } = theme.useToken(); + + const { data: stats, isLoading } = useQuery({ + queryKey: ['project-stats', projectId], + queryFn: () => + api + .get(`/projects/${String(projectId)}/stats`) + .then((r) => r.data), + }); + + const { data: annotatorStats = [] } = useQuery({ + queryKey: ['annotator-stats', projectId], + queryFn: () => + api + .get(`/projects/${String(projectId)}/annotator-stats`) + .then((r) => r.data), + }); + + const { data: timeline = [] } = useQuery({ + queryKey: ['stats-timeline', projectId], + queryFn: () => + api + .get(`/projects/${String(projectId)}/stats/timeline`) + .then((r) => r.data), + }); + + const { data: labelStats = [] } = useQuery({ + queryKey: ['stats-labels', projectId], + queryFn: () => + api + .get(`/projects/${String(projectId)}/stats/labels`) + .then((r) => r.data), + }); + + if (isLoading) { + return ; + } + + if (!stats) return null; + + // ETA calculation + const remainingTasks = stats.total_tasks - stats.approved_tasks; + const recentDays = timeline.filter((t) => t.approved > 0); + const avgApprovedPerDay = + recentDays.length > 0 + ? recentDays.reduce((sum, t) => sum + t.approved, 0) / recentDays.length + : 0; + const etaDays = + avgApprovedPerDay > 0 + ? Math.ceil(remainingTasks / avgApprovedPerDay) + : null; + + return ( +
+ + Project Statistics + + + + + +
+ + `${String(pct)}%`} + strokeColor={themeToken.colorPrimary} + size={160} + /> + +
+ + {stats.annotated_tasks} of {stats.total_tasks} tasks annotated + + +
+ + + Avg. {stats.avg_annotations_per_task} annotations per task + + + {etaDays !== null && ( + <> +
+ + ETA: ~{etaDays} day{etaDays !== 1 ? 's' : ''} remaining + + + + ({avgApprovedPerDay.toFixed(1)} approved/day avg) + + + )} +
+
+ + + + +
+ r.full_name ?? r.email, + }, + { + title: 'Total', + dataIndex: 'total_annotations', + width: 60, + align: 'center', + }, + { + title: 'Status', + width: 180, + render: (_value: string | undefined, r: AnnotatorStats) => ( + <> + {r.approved} + {r.rejected} + {r.pending} + + ), + }, + ]} + /> + + + + + {timeline.length > 0 && } + + {labelStats.length > 0 && } + + ); +}); + +export default ProjectStats; diff --git a/frontend/src/pages/ProjectStats/TimelineChart.tsx b/frontend/src/pages/ProjectStats/TimelineChart.tsx new file mode 100644 index 0000000..12466fb --- /dev/null +++ b/frontend/src/pages/ProjectStats/TimelineChart.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { Typography, Card, theme } from 'antd'; + +interface TimelineEntry { + date: string; + total: number; + approved: number; + rejected: number; +} + +interface Props { + timeline: TimelineEntry[]; +} + +const TimelineChart: React.FC = observer(({ timeline }) => { + const { token: themeToken } = theme.useToken(); + const timelineMax = Math.max(...timeline.map((t) => t.total), 1); + + return ( + +
+ {timeline.map((entry) => ( +
+
+
0 ? 2 : 0, + }} + /> + +
0 ? 2 : 0, + }} + /> +
+ + + {entry.date.slice(5)} + +
+ ))} +
+ +
+
+
+ + + Approved + +
+ +
+
+ + + Other + +
+
+ + ); +}); + +export default TimelineChart; diff --git a/frontend/src/pages/ProjectStats/index.ts b/frontend/src/pages/ProjectStats/index.ts new file mode 100644 index 0000000..ac05817 --- /dev/null +++ b/frontend/src/pages/ProjectStats/index.ts @@ -0,0 +1 @@ +export { default } from './ProjectStats'; diff --git a/frontend/src/pages/TaskBrowser.tsx b/frontend/src/pages/TaskBrowser.tsx index e559c12..4beb63e 100644 --- a/frontend/src/pages/TaskBrowser.tsx +++ b/frontend/src/pages/TaskBrowser.tsx @@ -1,11 +1,12 @@ -import { useState } from 'react'; +import React, { useState } from 'react'; +import { observer } from 'mobx-react-lite'; import { useParams } from 'react-router'; import { Typography, Tag, Card, Table } from 'antd'; import { useQuery } from '@tanstack/react-query'; import api from '../api/client'; -import type { TaskListItem, TaskListResponse } from '../types/api'; +import type { TaskData, TaskListItem, TaskListResponse } from '../types/api'; -const TaskBrowser: React.FC = () => { +const TaskBrowser: React.FC = observer(() => { const { projectId } = useParams(); const [page, setPage] = useState(1); const pageSize = 50; @@ -14,7 +15,9 @@ const TaskBrowser: React.FC = () => { queryKey: ['tasks', projectId, page], queryFn: () => api - .get(`/tasks/projects/${projectId}/tasks?page=${page}&page_size=${pageSize}`) + .get( + `/tasks/projects/${String(projectId)}/tasks?page=${String(page)}&page_size=${String(pageSize)}` + ) .then((r) => r.data), }); @@ -25,13 +28,13 @@ const TaskBrowser: React.FC = () => { - dataSource={data?.items || []} + dataSource={data?.items ?? []} rowKey="id" loading={isLoading} pagination={{ current: page, pageSize, - total: data?.total || 0, + total: data?.total ?? 0, onChange: setPage, showSizeChanger: false, }} @@ -60,7 +63,7 @@ const TaskBrowser: React.FC = () => { title: 'Content', dataIndex: 'data', ellipsis: true, - render: (_: unknown, record: TaskListItem) => { + render: (_value: TaskData, record: TaskListItem) => { const text = 'text' in record.data ? (record.data as { text: string }).text @@ -88,7 +91,7 @@ const TaskBrowser: React.FC = () => { dataIndex: 'has_final', width: 120, align: 'center', - render: (_: unknown, record: TaskListItem) => { + render: (_value: boolean, record: TaskListItem) => { if (record.has_final) return Final; if (record.annotation_count > 0) return In Progress; @@ -99,6 +102,6 @@ const TaskBrowser: React.FC = () => { />
); -}; +}); export default TaskBrowser; diff --git a/frontend/src/pages/Workspace.tsx b/frontend/src/pages/Workspace.tsx deleted file mode 100644 index 5312c23..0000000 --- a/frontend/src/pages/Workspace.tsx +++ /dev/null @@ -1,453 +0,0 @@ -import { useEffect, useState, useMemo } from 'react'; -import { observer } from 'mobx-react-lite'; -import { useParams } from 'react-router'; -import { - Card, - Typography, - Spin, - Empty, - Input, - Select, - Tag, - Badge, - Button, - Space, - Tooltip, - Pagination, - theme, - Drawer, -} from 'antd'; -import { - SearchOutlined, - LeftOutlined, - RightOutlined, - MenuOutlined, - InfoCircleOutlined, -} from '@ant-design/icons'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import TranslationEditor from '../components/editors/TranslationEditor'; -import NEREditor from '../components/editors/NEREditor'; -import ContextPanel from '../components/workspace/ContextPanel'; -import VotingPanel from '../components/workspace/VotingPanel'; -import { usePresence } from '../hooks/usePresence'; -import api from '../api/client'; -import { - isProject, - type Project, - type TaskListItem, - type TaskListResponse, -} from '../types/api'; - -type StatusFilter = '' | 'pending' | 'in_progress' | 'final'; - -const Workspace: React.FC = observer(() => { - const { projectId } = useParams(); - const { token: themeToken } = theme.useToken(); - const queryClient = useQueryClient(); - - const [selectedTaskId, setSelectedTaskId] = useState(null); - const [page, setPage] = useState(1); - const [search, setSearch] = useState(''); - const [statusFilter, setStatusFilter] = useState(''); - const [taskListOpen, setTaskListOpen] = useState(true); - const [contextOpen, setContextOpen] = useState(true); - const pageSize = 50; - const { users: presenceUsers, sendEditing, sendSubmitted } = usePresence(projectId); - - // Responsive - const [isNarrow, setIsNarrow] = useState(window.innerWidth < 1024); - useEffect(() => { - const handler = () => setIsNarrow(window.innerWidth < 1024); - window.addEventListener('resize', handler); - return () => window.removeEventListener('resize', handler); - }, []); - - // Fetch project - const { data: project } = useQuery({ - queryKey: ['project', projectId], - queryFn: async () => { - const res = await api.get(`/projects/${projectId}`); - if (!isProject(res.data)) throw new Error('Invalid project'); - return res.data; - }, - }); - - // Fetch tasks - const { data: tasksData, isLoading: tasksLoading } = useQuery({ - queryKey: ['workspace-tasks', projectId, page], - queryFn: () => - api.get(`/tasks/projects/${projectId}/tasks?page=${page}&page_size=${pageSize}`).then((r) => r.data), - enabled: !!projectId, - }); - - const tasks = tasksData?.items || []; - const totalTasks = tasksData?.total || 0; - - // Filter tasks client-side for search and status - const filteredTasks = useMemo(() => { - let result = tasks; - if (search) { - const q = search.toLowerCase(); - result = result.filter((t) => { - const text = 'text' in t.data ? (t.data as { text: string }).text : 'source' in t.data ? (t.data as { source: string }).source : ''; - return text.toLowerCase().includes(q) || String(t.id).includes(q); - }); - } - if (statusFilter === 'pending') result = result.filter((t) => t.annotation_count === 0); - else if (statusFilter === 'in_progress') result = result.filter((t) => t.annotation_count > 0 && !t.has_final); - else if (statusFilter === 'final') result = result.filter((t) => t.has_final); - return result; - }, [tasks, search, statusFilter]); - - // Auto-select first task - useEffect(() => { - if (filteredTasks.length > 0 && !selectedTaskId) { - setSelectedTaskId(filteredTasks[0].id); - } - }, [filteredTasks, selectedTaskId]); - - // Send presence when editing a task - useEffect(() => { - if (selectedTaskId) sendEditing(selectedTaskId); - }, [selectedTaskId, sendEditing]); - - const selectedTask = useMemo( - () => tasks.find((t) => t.id === selectedTaskId) || null, - [tasks, selectedTaskId] - ); - - // Stats - const completedCount = tasks.filter((t) => t.has_final).length; - const progressPercent = totalTasks > 0 ? Math.round((completedCount / totalTasks) * 100) : 0; - - const handleSubmit = async (result: unknown) => { - if (!selectedTask) return; - await api.post(`/api/tasks/${selectedTask.id}/annotations`, { result }); - await api.post(`/api/annotations/${selectedTask.id}/submit`).catch(() => {}); - sendSubmitted(selectedTask.id); - queryClient.invalidateQueries({ queryKey: ['workspace-tasks', projectId] }); - // Move to next task - const idx = filteredTasks.findIndex((t) => t.id === selectedTaskId); - if (idx >= 0 && idx < filteredTasks.length - 1) { - setSelectedTaskId(filteredTasks[idx + 1].id); - } - }; - - const handlePrev = () => { - const idx = filteredTasks.findIndex((t) => t.id === selectedTaskId); - if (idx > 0) setSelectedTaskId(filteredTasks[idx - 1].id); - }; - - const handleNext = () => { - const idx = filteredTasks.findIndex((t) => t.id === selectedTaskId); - if (idx >= 0 && idx < filteredTasks.length - 1) { - setSelectedTaskId(filteredTasks[idx + 1].id); - } - }; - - // Keyboard navigation - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; - if (e.key === 'ArrowUp' || (e.key === 'k' && !e.ctrlKey)) { - e.preventDefault(); - handlePrev(); - } else if (e.key === 'ArrowDown' || (e.key === 'j' && !e.ctrlKey)) { - e.preventDefault(); - handleNext(); - } - }; - window.addEventListener('keydown', handler); - return () => window.removeEventListener('keydown', handler); - }); - - const getTaskStatus = (t: TaskListItem) => { - if (t.has_final) return { color: 'success' as const, text: 'Approved' }; - if (t.annotation_count > 0) return { color: 'processing' as const, text: 'In Progress' }; - return { color: 'default' as const, text: 'Pending' }; - }; - - const getTaskPreview = (t: TaskListItem) => { - if ('text' in t.data) return (t.data as { text: string }).text; - if ('source' in t.data) return (t.data as { source: string }).source; - return JSON.stringify(t.data).slice(0, 80); - }; - - if (!project) { - return ( -
- -
- ); - } - - // --- Task List Panel --- - const taskListPanel = ( -
- {/* Progress bar */} -
-
- - {completedCount} / {totalTasks} - - - {progressPercent}% - -
-
-
-
-
- - {/* Search & Filter */} -
- } - value={search} - onChange={(e) => setSearch(e.target.value)} - allowClear - style={{ flex: 1 }} - /> - } + value={workspaceStore.search} + onChange={(e) => workspaceStore.setSearch(e.target.value)} + allowClear + style={{ flex: 1 }} + /> + + { + setActionFilter(v); + setPage(1); + }} + options={ACTION_OPTIONS.map((a) => ({ value: a, label: a }))} + /> + +
{ { title: 'Status', width: 100, - render: (_: unknown, inv: Invitation) => { - if (inv.used_by) return Used; - if (new Date(inv.expires_at) < new Date()) - return Expired; - return Pending; - }, + render: () => + statusFilter === 'pending' ? ( + Pending + ) : ( + Expired + ), }, { title: 'Expires', @@ -110,11 +145,13 @@ const InvitationsManagement: React.FC = () => { }, { title: '', - width: 100, - render: (_: unknown, inv: Invitation) => - !inv.used_by ? ( - - + width: 120, + render: (_value: string | undefined, inv: Invitation) => ( + + {statusFilter === 'pending' && ( + + + ) : ( + + + + ), + }, ]} /> + + { + setModalOpen(false); + setSelectedUser(null); + form.resetFields(); + }} + onOk={() => form.submit()} + confirmLoading={addToProject.isPending} + > + + addToProject.mutate(v) + } + > + + + + + ); -}; +}); export default MembersManagement; diff --git a/frontend/src/routes/AdminRoute.tsx b/frontend/src/routes/AdminRoute.tsx index 0ffb8ab..dba1e4c 100644 --- a/frontend/src/routes/AdminRoute.tsx +++ b/frontend/src/routes/AdminRoute.tsx @@ -6,6 +6,7 @@ const AdminRoute = observer(() => { if (!authStore.isAdmin) { return ; } + return ; }); diff --git a/frontend/src/routes/ManagerRoute.tsx b/frontend/src/routes/ManagerRoute.tsx new file mode 100644 index 0000000..e3b1daf --- /dev/null +++ b/frontend/src/routes/ManagerRoute.tsx @@ -0,0 +1,21 @@ +import { Navigate, Outlet } from 'react-router'; +import { useParams } from 'react-router'; +import { observer } from 'mobx-react-lite'; +import { projectRoleStore } from '../store/projectRoleStore'; +import { authStore } from '../store/authStore'; + +/** + * Route guard that only allows MANAGER role or global ADMIN. + * Must be nested inside ProjectRoute. + */ +const ManagerRoute: React.FC = observer(() => { + const { projectId } = useParams(); + + if (!projectRoleStore.isManager && !authStore.isAdmin) { + return ; + } + + return ; +}); + +export default ManagerRoute; diff --git a/frontend/src/routes/ProjectRoute.tsx b/frontend/src/routes/ProjectRoute.tsx new file mode 100644 index 0000000..656769e --- /dev/null +++ b/frontend/src/routes/ProjectRoute.tsx @@ -0,0 +1,48 @@ +import { useEffect } from 'react'; +import { useParams, Outlet, Navigate } from 'react-router'; +import { useQuery } from '@tanstack/react-query'; +import { observer } from 'mobx-react-lite'; +import { Skeleton } from 'antd'; +import api from '../api/client'; +import { projectRoleStore } from '../store/projectRoleStore'; +import type { ProjectRoleInfo } from '../types/api'; + +const ProjectRoute: React.FC = observer(() => { + const { projectId } = useParams(); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['project-role', projectId], + queryFn: () => + api + .get(`/projects/${String(projectId)}/my-role`) + .then((r) => r.data as ProjectRoleInfo), + enabled: !!projectId, + retry: false, + }); + + useEffect(() => { + if (data) { + projectRoleStore.setRole(data.role); + } + + return () => { + projectRoleStore.clear(); + }; + }, [data]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError || !data) { + return ; + } + + return ; +}); + +export default ProjectRoute; diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index 1cb1729..d15e8ec 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -4,6 +4,7 @@ import Dashboard from '../pages/Dashboard'; import Workspace from '../pages/Workspace'; import ProtectedRoute from './ProtectedRoute'; import AdminRoute from './AdminRoute'; +import ProjectRoute from './ProjectRoute'; import ProjectSettings from '../pages/ProjectSettings'; import Profile from '../pages/Profile'; import MembersManagement from '../pages/admin/MembersManagement'; @@ -11,6 +12,9 @@ import InvitationsManagement from '../pages/admin/InvitationsManagement'; import AuditLog from '../pages/admin/AuditLog'; import TaskBrowser from '../pages/TaskBrowser'; import ProjectStats from '../pages/ProjectStats'; +import ProjectGlossary from '../pages/ProjectGlossary'; +import ProjectReview from '../pages/ProjectReview'; +import ManagerRoute from './ManagerRoute'; const router = createBrowserRouter([ { path: '/auth', element: }, @@ -21,10 +25,26 @@ const router = createBrowserRouter([ { path: '/', element: }, { path: '/dashboard', element: }, { path: '/profile', element: }, - { path: '/projects/:projectId/workspace', element: }, - { path: '/projects/:projectId/settings', element: }, - { path: '/projects/:projectId/tasks', element: }, - { path: '/projects/:projectId/stats', element: }, + + // Project-scoped routes (wrapped with ProjectRoute for role checking) + { + path: '/projects/:projectId', + element: , + children: [ + { path: 'workspace', element: }, + { path: 'tasks', element: }, + { path: 'stats', element: }, + { path: 'glossary', element: }, + // Manager-only routes (settings, review) + { + element: , + children: [ + { path: 'settings', element: }, + { path: 'review', element: }, + ], + }, + ], + }, // Admin routes { diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index 0755782..4c37d8e 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -1,152 +1,279 @@ -import { makeAutoObservable } from 'mobx'; +import { makeAutoObservable, runInAction } from 'mobx'; import api from '../api/client'; import { - toUserError, isTokenResponse, isSetupStatusResponse, + isInvitationVerifyResponse, isUserProfile, + toUserError, type UserProfile, + type SetupStatusResponse, + type TokenResponse, + type InvitationVerifyResponse, } from '../types/api'; +import { + GlobalRoles, + type AuthFormMode, + type AuthFormToggleMode, +} from '../types'; + +export interface VerifyInvitationResult { + email: string; +} class AuthStore { - isAuthenticated = !!localStorage.getItem('authToken'); - user: UserProfile | null = null; - isLoading = false; - isSetup: boolean | null = null; + public isAuthenticated = !!localStorage.getItem('authToken'); + public user: UserProfile | null = null; + public isLoading = false; + public isSetup: boolean | null = null; + public setupError: string | null = null; + + // Form-related state + public formModeToggle: AuthFormToggleMode = 'login'; + public inviteToken: string | null = null; + public invitedEmail: string | null = null; + public tokenError: string | null = null; + public isValidatingToken = false; constructor() { makeAutoObservable(this); - this.checkSetupStatus(); + if (this.isAuthenticated) { - this.fetchProfile(); + void this.fetchProfile(); } } - private setLoading(v: boolean) { - this.isLoading = v; + // --- Private Helpers & Setters --- + + private setIsSetup(value: boolean) { + this.isSetup = value; } - private setAuthenticated(token: string, refreshToken?: string) { - localStorage.setItem('authToken', token); - if (refreshToken) { - localStorage.setItem('refreshToken', refreshToken); - } - this.isAuthenticated = true; + private setAuthenticated(value: boolean) { + this.isAuthenticated = value; } - private setUser(user: UserProfile | null) { + private setAuthData(user: UserProfile) { this.user = user; + this.isAuthenticated = true; } - private setSetup(v: boolean) { - this.isSetup = v; + private setLoading(value: boolean) { + this.isLoading = value; } - logout() { - localStorage.removeItem('authToken'); - localStorage.removeItem('refreshToken'); - this.isAuthenticated = false; - this.user = null; + private handleTokenResponse(data: TokenResponse) { + const { access_token, refresh_token } = data; + localStorage.setItem('authToken', access_token); + + if (refresh_token) { + localStorage.setItem('refreshToken', refresh_token); + } + + this.setAuthenticated(true); } - async checkSetupStatus() { + private verifyInvitation = async ( + token: string + ): Promise => { + const res = await api.get( + `/auth/verify-invitation?token=${token}` + ); + + if (!isInvitationVerifyResponse(res.data)) { + throw new Error('Invalid invitation response.'); + } + + return { email: res.data.email }; + }; + + // --- Public Actions --- + + public checkSetupStatus = async () => { + this.setupError = null; + try { - const res = await api.get('/auth/setup-status'); - if (!isSetupStatusResponse(res.data)) return; - this.setSetup(res.data.is_setup); - } catch (e: unknown) { - console.error('Failed to check setup status', e); + const res = await api.get('/auth/setup-status'); + + runInAction(() => { + if (isSetupStatusResponse(res.data)) { + this.setIsSetup(res.data.is_setup); + } + }); + } catch (error) { + runInAction(() => { + this.setupError = toUserError(error); + }); + console.error('Failed to check setup status', error); } - } + }; - async setup( - email: string, - password: string, - fullName: string - ): Promise { - this.setLoading(true); + public fetchProfile = async () => { try { - const res = await api.post('/auth/setup', { - email, - password, - full_name: fullName, + const res = await api.get('/users/me'); + runInAction(() => { + if (isUserProfile(res.data)) { + this.setAuthData(res.data); + } else { + this.logout(); + } + }); + } catch (e) { + console.error('Failed to fetch profile', e); + runInAction(() => { + this.logout(); }); - if (!isTokenResponse(res.data)) return 'Invalid server response.'; - this.setAuthenticated(res.data.access_token, res.data.refresh_token); - this.setSetup(true); - await this.fetchProfile(); - return null; - } catch (e: unknown) { - return toUserError(e); - } finally { - this.setLoading(false); } - } + }; + + public initializeAuthForm = async (token: string | null) => { + if (this.inviteToken === token) return; + + this.inviteToken = token; + + if (token) { + this.formModeToggle = 'register'; + this.isValidatingToken = true; + this.tokenError = null; - async login(email: string, password: string): Promise { + try { + const { email } = await this.verifyInvitation(token); + runInAction(() => { + this.invitedEmail = email; + }); + } catch (e) { + runInAction(() => { + this.tokenError = toUserError(e); + this.formModeToggle = 'login'; + }); + throw e; + } finally { + runInAction(() => { + this.isValidatingToken = false; + }); + } + } + }; + + public login = async (email: string, password: string): Promise => { this.setLoading(true); try { const params = new URLSearchParams(); params.append('username', email); params.append('password', password); - const res = await api.post('/auth/login', params, { + const res = await api.post('/auth/login', params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }); - if (!isTokenResponse(res.data)) return 'Invalid server response.'; - this.setAuthenticated(res.data.access_token, res.data.refresh_token); + if (!isTokenResponse(res.data)) { + throw new Error('Invalid server response.'); + } + + runInAction(() => { + this.handleTokenResponse(res.data); + }); await this.fetchProfile(); - return null; - } catch (e: unknown) { - return toUserError(e); } finally { - this.setLoading(false); + runInAction(() => { + this.setLoading(false); + }); } - } + }; - async register( + public register = async ( email: string, password: string, name: string, invitationToken: string - ): Promise { + ): Promise => { this.setLoading(true); try { - const res = await api.post('/auth/register', { + const res = await api.post('/auth/register', { email, password, full_name: name, invitation_token: invitationToken, }); - if (!isTokenResponse(res.data)) return 'Invalid server response.'; - this.setAuthenticated(res.data.access_token, res.data.refresh_token); + if (!isTokenResponse(res.data)) { + throw new Error('Invalid server response.'); + } + + runInAction(() => { + this.handleTokenResponse(res.data); + }); await this.fetchProfile(); - return null; - } catch (e: unknown) { - return toUserError(e); } finally { - this.setLoading(false); + runInAction(() => { + this.setLoading(false); + }); } - } + }; - async fetchProfile() { + public setup = async ( + email: string, + password: string, + fullName: string + ): Promise => { + this.setLoading(true); try { - const res = await api.get('/users/me'); - if (!isUserProfile(res.data)) { - this.logout(); - return; + const res = await api.post('/auth/setup', { + email, + password, + full_name: fullName, + }); + if (!isTokenResponse(res.data)) { + throw new Error('Invalid server response.'); } - this.setUser(res.data); - } catch (e: unknown) { - console.error('Failed to fetch profile', e); - this.logout(); + + runInAction(() => { + this.handleTokenResponse(res.data); + this.setIsSetup(true); + }); + await this.fetchProfile(); + } finally { + runInAction(() => { + this.setLoading(false); + }); } + }; + + public logout = () => { + localStorage.removeItem('authToken'); + localStorage.removeItem('refreshToken'); + + this.isAuthenticated = false; + this.user = null; + }; + + public setMode(mode: AuthFormToggleMode) { + this.formModeToggle = mode; + + if (mode === 'login') { + this.invitedEmail = null; + this.tokenError = null; + } + } + + // --- Public Computed Getters --- + + public get formMode(): AuthFormMode { + if (this.isSetup === false) return 'setup'; + + if ( + this.inviteToken && + this.formModeToggle === 'register' && + !this.tokenError + ) { + return 'invite'; + } + + return this.formModeToggle; } - get isAdmin() { - return this.user?.is_admin === true; + public get isAdmin() { + return this.user?.role === GlobalRoles.ADMIN; } } diff --git a/frontend/src/store/inboxStore.ts b/frontend/src/store/inboxStore.ts new file mode 100644 index 0000000..d5ab4ca --- /dev/null +++ b/frontend/src/store/inboxStore.ts @@ -0,0 +1,68 @@ +import { makeAutoObservable } from 'mobx'; +import api from '../api/client'; +import type { Notification, NotificationListResponse } from '../types/api'; + +class InboxStore { + notifications: Notification[] = []; + unreadCount = 0; + private intervalId: ReturnType | null = null; + + constructor() { + makeAutoObservable(this); + } + + setNotificationData(items: Notification[], unreadCount: number) { + this.notifications = items; + this.unreadCount = unreadCount; + } + + setUnreadCount(count: number) { + this.unreadCount = count; + } + + setNotifications(items: Notification[]) { + this.notifications = items; + } + + fetchNotifications = async () => { + try { + const res = await api.get( + '/notifications?page_size=10' + ); + this.setNotificationData(res.data.items, res.data.unread_count); + } catch { + // silent + } + }; + + markAllRead = async () => { + try { + await api.patch('/notifications/read-all'); + this.setUnreadCount(0); + this.setNotifications( + this.notifications.map((n) => ({ + ...n, + is_read: true, + })) + ); + } catch { + // silent + } + }; + + startPolling = () => { + void this.fetchNotifications(); + this.intervalId = setInterval(() => { + void this.fetchNotifications(); + }, 30000); + }; + + stopPolling = () => { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + }; +} + +export const inboxStore = new InboxStore(); diff --git a/frontend/src/store/nerEditorStore.ts b/frontend/src/store/nerEditorStore.ts new file mode 100644 index 0000000..af0b372 --- /dev/null +++ b/frontend/src/store/nerEditorStore.ts @@ -0,0 +1,200 @@ +import { makeAutoObservable } from 'mobx'; +import type { NEREntity } from '../types/api'; + +interface HistoryState { + entities: NEREntity[]; +} + +class NEREditorStore { + entities: NEREntity[] = []; + selectedLabel = ''; + labelSearch = ''; + history: HistoryState[] = [{ entities: [] }]; + historyIndex = 0; + isDragging = false; + dragStart: number | null = null; + dragEnd: number | null = null; + + constructor() { + makeAutoObservable(this); + } + + initForTask = (preEntities: NEREntity[], labels: string[]) => { + const initial = preEntities.length > 0 ? [...preEntities] : []; + this.entities = initial; + this.history = [{ entities: initial }]; + this.historyIndex = 0; + this.selectedLabel = labels[0] ?? ''; + this.labelSearch = ''; + this.isDragging = false; + this.dragStart = null; + this.dragEnd = null; + }; + + pushHistory = (newEntities: NEREntity[]) => { + this.history = [ + ...this.history.slice(0, this.historyIndex + 1), + { entities: newEntities }, + ]; + this.historyIndex += 1; + this.entities = newEntities; + }; + + undo = () => { + if (this.historyIndex > 0) { + this.historyIndex -= 1; + this.entities = this.history[this.historyIndex].entities; + } + }; + + redo = () => { + if (this.historyIndex < this.history.length - 1) { + this.historyIndex += 1; + this.entities = this.history[this.historyIndex].entities; + } + }; + + startDrag = (idx: number) => { + this.isDragging = true; + this.dragStart = idx; + this.dragEnd = idx; + }; + + updateDrag = (idx: number) => { + if (this.isDragging) { + this.dragEnd = idx; + } + }; + + endDrag = (text: string, tokenOffsets: { start: number; end: number }[]) => { + if (!this.isDragging || this.dragStart === null || this.dragEnd === null) { + this.isDragging = false; + return; + } + this.isDragging = false; + + const start = Math.min(this.dragStart, this.dragEnd); + const end = Math.max(this.dragStart, this.dragEnd); + + // Single click on existing entity -> remove it + if (start === end) { + const charStart = tokenOffsets[start].start; + const charEnd = tokenOffsets[start].end; + const existing = this.entities.findIndex( + (e) => e.start <= charStart && e.end >= charEnd + ); + if (existing >= 0) { + this.pushHistory(this.entities.filter((_, i) => i !== existing)); + this.dragStart = null; + this.dragEnd = null; + return; + } + } + + const charStart = tokenOffsets[start].start; + const charEnd = tokenOffsets[end].end; + const selectedText = text.slice(charStart, charEnd).trim(); + + if (!selectedText) { + this.dragStart = null; + this.dragEnd = null; + return; + } + + const trimmedStart = + charStart + + (text.slice(charStart, charEnd).length - + text.slice(charStart, charEnd).trimStart().length); + const trimmedEnd = + charEnd - + (text.slice(charStart, charEnd).length - + text.slice(charStart, charEnd).trimEnd().length); + + const filtered = this.entities.filter( + (e) => e.end <= trimmedStart || e.start >= trimmedEnd + ); + + this.pushHistory([ + ...filtered, + { + start: trimmedStart, + end: trimmedEnd, + label: this.selectedLabel, + text: selectedText, + }, + ]); + + this.dragStart = null; + this.dragEnd = null; + }; + + removeEntity = (entity: NEREntity) => { + this.pushHistory( + this.entities.filter( + (e) => !(e.start === entity.start && e.end === entity.end) + ) + ); + }; + + changeEntityLabel = (entity: NEREntity, newLabel: string) => { + this.pushHistory( + this.entities.map((e) => + e.start === entity.start && e.end === entity.end + ? { ...e, label: newLabel } + : e + ) + ); + }; + + acceptAllPre = (preEntities: NEREntity[]) => { + if (preEntities.length > 0) { + this.pushHistory([...preEntities]); + } + }; + + setSelectedLabel = (label: string) => { + this.selectedLabel = label; + }; + + setLabelSearch = (text: string) => { + this.labelSearch = text; + }; + + handleKeyboardShortcut = (e: React.KeyboardEvent, labels: string[]) => { + if (e.target instanceof HTMLInputElement) return; + if (e.ctrlKey && e.key === 'z' && !e.shiftKey) { + e.preventDefault(); + this.undo(); + return; + } + if (e.ctrlKey && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { + e.preventDefault(); + this.redo(); + return; + } + const num = parseInt(e.key); + if (num >= 1 && num <= labels.length) { + this.setSelectedLabel(labels[num - 1]); + } + }; + + get canUndo(): boolean { + return this.historyIndex > 0; + } + + get canRedo(): boolean { + return this.historyIndex < this.history.length - 1; + } + + get dragRange(): { start: number; end: number } | null { + if (this.isDragging && this.dragStart !== null && this.dragEnd !== null) { + return { + start: Math.min(this.dragStart, this.dragEnd), + end: Math.max(this.dragStart, this.dragEnd), + }; + } + return null; + } +} + +export const nerEditorStore = new NEREditorStore(); diff --git a/frontend/src/store/projectRoleStore.ts b/frontend/src/store/projectRoleStore.ts new file mode 100644 index 0000000..98c8ec1 --- /dev/null +++ b/frontend/src/store/projectRoleStore.ts @@ -0,0 +1,44 @@ +import { makeAutoObservable } from 'mobx'; +import type { RoleProject, GlobalRole } from '../types'; +import { isAdmin, isManagerOrAdmin, ProjectRoles } from '../types'; +import { authStore } from './authStore'; + +class ProjectRoleStore { + role: RoleProject | GlobalRole | null = null; + isLoading = false; + + constructor() { + makeAutoObservable(this); + } + + setRole = (role: RoleProject | GlobalRole) => { + this.role = role; + }; + + setLoading = (v: boolean) => { + this.isLoading = v; + }; + + clear = () => { + this.role = null; + this.isLoading = false; + }; + + get isManager(): boolean { + return isManagerOrAdmin(this.role); + } + + get isEditor(): boolean { + return this.role === ProjectRoles.EDITOR; + } + + get isAdmin(): boolean { + return isAdmin(this.role); + } + + get canManageProject(): boolean { + return this.isManager || authStore.isAdmin; + } +} + +export const projectRoleStore = new ProjectRoleStore(); diff --git a/frontend/src/store/projectSettingsStore.ts b/frontend/src/store/projectSettingsStore.ts new file mode 100644 index 0000000..67d1301 --- /dev/null +++ b/frontend/src/store/projectSettingsStore.ts @@ -0,0 +1,163 @@ +import { makeAutoObservable } from 'mobx'; +import api from '../api/client'; +import type { ProjectMember, UserListItem, RoleProject } from '../types/api'; + +interface ImportPreviewData { + total_tasks: number; + with_entities: number; + entities_count: number; + sample: { + text: string; + id?: string; + key?: string; + entities_count?: number; + }[]; + filename: string; +} + +class ProjectSettingsStore { + members: ProjectMember[] = []; + allUsers: UserListItem[] = []; + membersLoading = false; + importPreview: ImportPreviewData | null = null; + pendingFile: File | null = null; + uploading = false; + + constructor() { + makeAutoObservable(this); + } + + get availableUsers(): UserListItem[] { + return this.allUsers.filter( + (u) => !this.members.some((m) => m.user_id === u.id) + ); + } + + setMembers(members: ProjectMember[]) { + this.members = members; + } + + setMembersLoading(value: boolean) { + this.membersLoading = value; + } + + setAllUsers(users: UserListItem[]) { + this.allUsers = users; + } + + setImportPreview( + preview: ImportPreviewData | null, + file: File | null = null + ) { + this.importPreview = preview; + this.pendingFile = file; + } + + setUploading(value: boolean) { + this.uploading = value; + } + + fetchMembers = async (projectId: string) => { + this.setMembersLoading(true); + try { + const res = await api.get( + `/projects/${projectId}/members` + ); + this.setMembers(res.data); + } finally { + this.setMembersLoading(false); + } + }; + + fetchUsers = async () => { + try { + const res = await api.get('/users'); + this.setAllUsers(res.data); + } catch { + /* only admins */ + } + }; + + addMember = async (projectId: string, userId: number, role: RoleProject) => { + await api.post(`/projects/${projectId}/members`, { + user_id: userId, + role, + }); + await this.fetchMembers(projectId); + }; + + removeMember = async (projectId: string, userId: number) => { + await api.delete(`/projects/${projectId}/members/${String(userId)}`); + await this.fetchMembers(projectId); + }; + + changeRole = async (projectId: string, userId: number, role: RoleProject) => { + await api.patch(`/projects/${projectId}/members/${String(userId)}`, { + role, + }); + await this.fetchMembers(projectId); + }; + + importPreviewFile = async (projectId: string, file: File) => { + const formData = new FormData(); + formData.append('file', file); + const res = await api.post( + `/projects/${projectId}/import-preview`, + formData, + { headers: { 'Content-Type': 'multipart/form-data' } } + ); + this.setImportPreview(res.data, file); + }; + + confirmImport = async (projectId: string, replace: boolean) => { + if (!this.pendingFile) return 0; + const formData = new FormData(); + formData.append('file', this.pendingFile); + this.setUploading(true); + try { + const url = replace + ? `/projects/${projectId}/import?replace=true` + : `/projects/${projectId}/import`; + const response = await api.post<{ count: number }>(url, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + this.setImportPreview(null, null); + return response.data.count; + } finally { + this.setUploading(false); + } + }; + + exportData = async (projectId: string, format: 'json' | 'ini') => { + const response = await api.get( + `/projects/${projectId}/export?format=${format}`, + { responseType: 'blob' } + ); + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute( + 'download', + format === 'json' ? 'export.json' : 'export.ini' + ); + document.body.appendChild(link); + link.click(); + link.remove(); + }; + + clearImportPreview = () => { + this.importPreview = null; + this.pendingFile = null; + }; + + reset = () => { + this.members = []; + this.allUsers = []; + this.membersLoading = false; + this.importPreview = null; + this.pendingFile = null; + this.uploading = false; + }; +} + +export const projectSettingsStore = new ProjectSettingsStore(); diff --git a/frontend/src/store/reviewStore.ts b/frontend/src/store/reviewStore.ts new file mode 100644 index 0000000..2f6e18a --- /dev/null +++ b/frontend/src/store/reviewStore.ts @@ -0,0 +1,96 @@ +import { makeAutoObservable } from 'mobx'; +import api from '../api/client'; +import type { + AnnotationListItem, + AnnotationListResponse, + AnnotationStatus, +} from '../types/api'; +import { AnnotationStatuses } from '../types'; + +class ReviewStore { + annotations: AnnotationListItem[] = []; + annotationsTotal = 0; + isLoading = false; + statusFilter: AnnotationStatus | '' = AnnotationStatuses.SUBMITTED; + selectedRowKeys: React.Key[] = []; + + constructor() { + makeAutoObservable(this); + } + + setAnnotationData(items: AnnotationListItem[], total: number) { + this.annotations = items; + this.annotationsTotal = total; + } + + setLoading(value: boolean) { + this.isLoading = value; + } + + clearSelectedKeys() { + this.selectedRowKeys = []; + } + + removeSelectedKey(id: number) { + this.selectedRowKeys = this.selectedRowKeys.filter((k) => k !== id); + } + + fetchAnnotations = async (projectId: string) => { + this.setLoading(true); + try { + const params = this.statusFilter ? `?status=${this.statusFilter}` : ''; + const res = await api.get( + `/projects/${projectId}/annotations${params}` + ); + this.setAnnotationData(res.data.items, res.data.total); + } catch { + // error handling done in component via message + throw new Error('Failed to load annotations'); + } finally { + this.setLoading(false); + } + }; + + setStatusFilter = (f: AnnotationStatus | '') => { + this.statusFilter = f; + }; + + reviewAnnotation = async ( + id: number, + status: 'APPROVED' | 'REJECTED', + projectId: string, + reviewNote?: string + ) => { + await api.post(`/annotations/${String(id)}/review`, { + status, + review_note: reviewNote ?? null, + }); + this.removeSelectedKey(id); + await this.fetchAnnotations(projectId); + }; + + batchReview = async (status: 'APPROVED' | 'REJECTED', projectId: string) => { + for (const id of this.selectedRowKeys) { + await api.post(`/annotations/${String(id as number)}/review`, { + status, + review_note: null, + }); + } + this.clearSelectedKeys(); + await this.fetchAnnotations(projectId); + }; + + setSelectedRowKeys = (keys: React.Key[]) => { + this.selectedRowKeys = keys; + }; + + reset = () => { + this.annotations = []; + this.annotationsTotal = 0; + this.isLoading = false; + this.statusFilter = AnnotationStatuses.SUBMITTED; + this.selectedRowKeys = []; + }; +} + +export const reviewStore = new ReviewStore(); diff --git a/frontend/src/store/themeStore.ts b/frontend/src/store/themeStore.ts index 0e23b0b..77f84e8 100644 --- a/frontend/src/store/themeStore.ts +++ b/frontend/src/store/themeStore.ts @@ -1,25 +1,25 @@ import { makeAutoObservable } from 'mobx'; -export type ThemeMode = 'light' | 'dark' | 'auto'; +export type ThemeMode = 'light' | 'dark'; class ThemeStore { - mode: ThemeMode = (localStorage.getItem('themeMode') as ThemeMode) || 'auto'; + mode: ThemeMode = + (localStorage.getItem('themeMode') as ThemeMode | null) ?? 'dark'; constructor() { makeAutoObservable(this); } - setMode(mode: ThemeMode) { + setMode = (mode: ThemeMode) => { this.mode = mode; localStorage.setItem('themeMode', mode); - } + }; + + toggle = () => { + this.setMode(this.mode === 'dark' ? 'light' : 'dark'); + }; - get resolvedMode(): 'light' | 'dark' { - if (this.mode === 'auto') { - return window.matchMedia('(prefers-color-scheme: dark)').matches - ? 'dark' - : 'light'; - } + get resolvedMode(): ThemeMode { return this.mode; } } diff --git a/frontend/src/store/workspaceStore.ts b/frontend/src/store/workspaceStore.ts new file mode 100644 index 0000000..b8a47fa --- /dev/null +++ b/frontend/src/store/workspaceStore.ts @@ -0,0 +1,122 @@ +import { makeAutoObservable } from 'mobx'; +import type { QueryClient } from '@tanstack/react-query'; +import api from '../api/client'; +import { getTaskText, type TaskListItem } from '../types/api'; +import type { TaskStatusFilter } from '../types'; + +class WorkspaceStore { + selectedTaskId: number | null = null; + page = 1; + search = ''; + statusFilter: TaskStatusFilter = ''; + tasks: TaskListItem[] = []; + totalTasks = 0; + + constructor() { + makeAutoObservable(this); + } + + setTasks = (items: TaskListItem[], total: number) => { + this.tasks = items; + this.totalTasks = total; + }; + + selectTask = (id: number) => { + this.selectedTaskId = id; + }; + + selectPrev = () => { + const filtered = this.filteredTasks; + const idx = filtered.findIndex((t) => t.id === this.selectedTaskId); + if (idx > 0) this.selectedTaskId = filtered[idx - 1].id; + }; + + selectNext = () => { + const filtered = this.filteredTasks; + const idx = filtered.findIndex((t) => t.id === this.selectedTaskId); + if (idx >= 0 && idx < filtered.length - 1) { + this.selectedTaskId = filtered[idx + 1].id; + } + }; + + autoSelectFirst = () => { + if (this.filteredTasks.length > 0 && !this.selectedTaskId) { + this.selectedTaskId = this.filteredTasks[0].id; + } + }; + + setSearch = (text: string) => { + this.search = text; + }; + + setStatusFilter = (f: TaskStatusFilter) => { + this.statusFilter = f; + }; + + setPage = (p: number) => { + this.page = p; + }; + + submitAnnotation = async ( + result: unknown, + queryClient: QueryClient, + projectId: string, + sendSubmitted: (taskId: number) => void + ) => { + const task = this.selectedTask; + if (!task) return; + await api.post(`/tasks/${String(task.id)}/annotations`, { result }); + await api + .post(`/annotations/${String(task.id)}/submit`) + .catch(() => undefined); + sendSubmitted(task.id); + void queryClient.invalidateQueries({ + queryKey: ['workspace-tasks', projectId], + }); + // Move to next task + this.selectNext(); + }; + + reset = () => { + this.selectedTaskId = null; + this.page = 1; + this.search = ''; + this.statusFilter = ''; + this.tasks = []; + this.totalTasks = 0; + }; + + get filteredTasks(): TaskListItem[] { + let result = this.tasks; + if (this.search) { + const q = this.search.toLowerCase(); + result = result.filter((t) => { + const text = getTaskText(t.data); + return text.toLowerCase().includes(q) || String(t.id).includes(q); + }); + } + if (this.statusFilter === 'pending') + result = result.filter((t) => t.annotation_count === 0); + else if (this.statusFilter === 'in_progress') + result = result.filter((t) => t.annotation_count > 0 && !t.has_final); + else if (this.statusFilter === 'final') + result = result.filter((t) => t.has_final); + return result; + } + + get selectedTask(): TaskListItem | null { + return this.tasks.find((t) => t.id === this.selectedTaskId) ?? null; + } + + get completedCount(): number { + return this.tasks.filter((t) => t.has_final).length; + } + + get progressPercent(): number { + return this.totalTasks > 0 + ? Math.round((this.completedCount / this.totalTasks) * 100) + : 0; + } +} + +export const workspaceStore = new WorkspaceStore(); diff --git a/frontend/src/styles/_reset.scss b/frontend/src/styles/_reset.scss new file mode 100644 index 0000000..d57ce6c --- /dev/null +++ b/frontend/src/styles/_reset.scss @@ -0,0 +1,73 @@ +// Modern CSS Reset (based on Andy Bell's modern reset) +// https://andy-bell.co.uk/a-more-modern-css-reset/ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +* { + margin: 0; +} + +html { + -moz-text-size-adjust: none; + -webkit-text-size-adjust: none; + text-size-adjust: none; +} + +body { + min-height: 100vh; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + transition: background-color 0.3s; +} + +img, +picture, +video, +canvas, +svg { + display: block; + max-width: 100%; +} + +input, +button, +textarea, +select { + font: inherit; +} + +p, +h1, +h2, +h3, +h4, +h5, +h6 { + overflow-wrap: break-word; +} + +a { + color: inherit; + text-decoration: inherit; +} + +ul, +ol { + list-style: none; + padding: 0; +} + +table { + border-collapse: collapse; + border-spacing: 0; +} + +#root { + min-height: 100vh; + isolation: isolate; +} diff --git a/frontend/src/styles/_variables.scss b/frontend/src/styles/_variables.scss new file mode 100644 index 0000000..dfad798 --- /dev/null +++ b/frontend/src/styles/_variables.scss @@ -0,0 +1,4 @@ +// Layout dimensions +$header-height: 56px; +$sider-width: 240px; +$sider-collapsed-width: 80px; diff --git a/frontend/src/styles/design-tokens.ts b/frontend/src/styles/design-tokens.ts deleted file mode 100644 index 5844170..0000000 --- a/frontend/src/styles/design-tokens.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Custom design tokens for VerseLab -export const tokens = { - colors: { - primary: '#1a1a1a', - primaryDark: '#e5e5e5', - primaryLight: '#404040', - accent: '#2d2d2d', - accentLight: '#525252', - success: '#22c55e', - warning: '#f59e0b', - error: '#ef4444', - // Dark theme surfaces - darkBg: '#0d0d0d', - darkSurface: '#151515', - darkSurfaceHover: '#1f1f1f', - darkBorder: '#262626', - // Light theme surfaces - lightBg: '#edeae5', - lightSurface: '#ffffff', - lightSurfaceHover: '#f5f3f0', - lightBorder: '#d9d5cf', - }, - gradients: { - primary: 'linear-gradient(135deg, #1a1a1a 0%, #333333 100%)', - primarySubtle: 'linear-gradient(135deg, #f5f3f0 0%, #edeae5 100%)', - dark: 'linear-gradient(135deg, #0d0d0d 0%, #151515 50%, #1f1f1f 100%)', - card: 'linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 50%, #404040 100%)', - }, - shadows: { - soft: '0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04)', - medium: '0 4px 12px rgba(0, 0, 0, 0.08)', - elevated: '0 8px 24px rgba(0, 0, 0, 0.1)', - }, - glass: { - // Only use on static elements (sidebar, modal backgrounds), NOT on editor panels - background: 'rgba(255, 255, 255, 0.75)', - backgroundDark: 'rgba(13, 13, 13, 0.85)', - backdropFilter: 'blur(12px)', - }, - radius: { - sm: 6, - md: 10, - lg: 16, - xl: 24, - }, -}; diff --git a/frontend/src/styles/index.scss b/frontend/src/styles/index.scss new file mode 100644 index 0000000..5cfeb30 --- /dev/null +++ b/frontend/src/styles/index.scss @@ -0,0 +1,2 @@ +@use 'reset'; +@use 'variables'; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 997320a..a5979a8 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -2,11 +2,29 @@ // Must match backend Pydantic schemas exactly. import type { AxiosError } from 'axios'; - -// Enums matching backend -export type RoleProject = 'MANAGER' | 'MEMBER'; -export type ProjectType = 'NER' | 'TRANSLATION'; -export type AnnotationStatus = 'DRAFT' | 'SUBMITTED' | 'APPROVED' | 'REJECTED'; +import type { + GlobalRole, + RoleProject, + ProjectType, + AnnotationStatus, + ImportStatus, + VoteValue, + NotificationType, + NotificationResourceType, + AuditAction, + AuditResourceType, +} from '.'; +import { ProjectTypes } from '.'; + +// Re-export enums for consumers that import from api.ts +export type { + GlobalRole, + RoleProject, + ProjectType, + AnnotationStatus, + ImportStatus, + VoteValue, +}; // --- Auth --- @@ -26,7 +44,7 @@ export interface UserProfile { id: number; email: string; full_name: string | null; - is_admin: boolean; + role: GlobalRole; } // --- Project --- @@ -45,6 +63,7 @@ export interface Project { created_by: number; config: ProjectConfig; created_at: string; + my_role?: RoleProject | null; } // --- Task --- @@ -78,9 +97,6 @@ export interface NEREntity { export type NERAnnotationResult = NEREntity[]; export type TranslationAnnotationResult = Record; -export type AnnotationResult = - | NERAnnotationResult - | TranslationAnnotationResult; // --- Invitation --- @@ -92,6 +108,11 @@ export interface Invitation { used_by: number | null; } +export interface InvitationVerifyResponse { + email: string; + is_valid: boolean; +} + // --- Project Member --- export interface ProjectMember { @@ -101,6 +122,20 @@ export interface ProjectMember { role: RoleProject; } +// --- Presence --- + +export interface PresenceUser { + id: number; + name: string; + task_id: number | null; +} + +// --- Project role (from my-role endpoint) --- + +export interface ProjectRoleInfo { + role: RoleProject | GlobalRole; +} + // --- Task list item --- export interface TaskListItem { @@ -124,7 +159,7 @@ export interface AnnotationListItem { user_id: number; user_email: string; user_full_name: string | null; - result: Record; + result: NERAnnotationResult | TranslationAnnotationResult; status: AnnotationStatus; review_note: string | null; is_final: boolean; @@ -140,7 +175,7 @@ export interface TaskAnnotation { user_id: number; user_email: string; user_full_name: string | null; - result: Record; + result: NERAnnotationResult | TranslationAnnotationResult; status: AnnotationStatus; review_note: string | null; is_final: boolean; @@ -148,7 +183,7 @@ export interface TaskAnnotation { updated_at: string; votes_up: number; votes_down: number; - user_vote: number | null; + user_vote: VoteValue | null; } export interface AnnotationListResponse { @@ -175,8 +210,8 @@ export interface AuditLogItem { id: number; user_id: number | null; user_email: string | null; - action: string; - resource_type: string; + action: AuditAction; + resource_type: AuditResourceType; resource_id: number | null; details: Record | null; created_at: string; @@ -191,10 +226,10 @@ export interface AuditLogListResponse { export interface Notification { id: number; - type: string; + type: NotificationType; title: string; message: string; - resource_type: string | null; + resource_type: NotificationResourceType | null; resource_id: number | null; is_read: boolean; created_at: string; @@ -237,15 +272,39 @@ export interface OverviewStats { // --- User list (admin) --- +export interface UserProjectAssignment { + project_id: number; + project_name: string; + role: RoleProject; +} + export interface UserListItem { id: number; email: string; full_name: string | null; - is_admin: boolean; + role: GlobalRole; is_active: boolean; created_at: string; } +export interface PendingImport { + id: number; + project_id: number; + project_name: string | null; + uploaded_by: number; + uploader_email: string | null; + filename: string; + replace_existing: boolean; + status: ImportStatus; + task_count: number; + review_note: string | null; + created_at: string; +} + +export interface UserListItemWithProjects extends UserListItem { + project_assignments: UserProjectAssignment[]; +} + // --- Glossary --- export interface GlossaryTerm { @@ -268,10 +327,28 @@ export function isAxiosError(e: unknown): e is AxiosError { typeof e === 'object' && e !== null && 'isAxiosError' in e && - (e as AxiosError).isAxiosError === true + (e as AxiosError).isAxiosError ); } +// --- TaskData type guards --- + +export function isNERTaskData(data: TaskData): data is NERTaskData { + return 'text' in data; +} + +export function isTranslationTaskData( + data: TaskData +): data is TranslationTaskData { + return 'source' in data; +} + +export function getTaskText(data: TaskData): string { + if (isNERTaskData(data)) return data.text; + if (isTranslationTaskData(data)) return data.source; + return ''; +} + // --- Type guards --- export function isTokenResponse(data: unknown): data is TokenResponse { @@ -294,6 +371,19 @@ export function isSetupStatusResponse( ); } +export function isInvitationVerifyResponse( + data: unknown +): data is InvitationVerifyResponse { + return ( + typeof data === 'object' && + data !== null && + 'email' in data && + 'is_valid' in data && + typeof (data as InvitationVerifyResponse).email === 'string' && + typeof (data as InvitationVerifyResponse).is_valid === 'boolean' + ); +} + export function isUserProfile(data: unknown): data is UserProfile { if (typeof data !== 'object' || data === null) return false; const d = data as Record; @@ -306,38 +396,33 @@ export function isProject(data: unknown): data is Project { return ( typeof d.id === 'number' && typeof d.type === 'string' && - (d.type === 'NER' || d.type === 'TRANSLATION') - ); -} - -export function isTask(data: unknown): data is Task { - if (typeof data !== 'object' || data === null) return false; - const d = data as Record; - return ( - typeof d.id === 'number' && typeof d.data === 'object' && d.data !== null + (d.type === ProjectTypes.NER || d.type === ProjectTypes.TRANSLATION) ); } -export function isNERTaskData(data: TaskData): data is NERTaskData { - return 'text' in data; -} - -export function isTranslationTaskData( - data: TaskData -): data is TranslationTaskData { - return 'source' in data; -} - // --- User-facing error mapper --- export function toUserError(e: unknown): string { if (!isAxiosError(e)) { + if (e instanceof Error) return e.message; return 'Connection error. Check if the server is running.'; } + const status = e.response?.status; + const rawDetail = (e.response?.data as Record | undefined) + ?.detail; + if (!status) return 'Connection error. Check if the server is running.'; + if (status >= 500) return 'Server error. Please try again later.'; - if (status === 401 || status === 400) return 'Invalid credentials or data.'; + + if (rawDetail && typeof rawDetail === 'string') return rawDetail; + + if (status === 401) return 'Invalid credentials.'; + if (status === 403) return 'Access denied.'; + + if (status === 400) return 'Invalid request.'; + return 'Something went wrong. Please try again.'; } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..7aa8bab --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,178 @@ +// ─── Domain types ─── +// Typed unions mirroring backend enums. Single source of truth for the frontend. + +// Role & project enums + +export type GlobalRole = 'ADMIN' | 'USER'; +export type RoleProject = 'MANAGER' | 'EDITOR'; +export type ProjectType = 'NER' | 'TRANSLATION'; +export type AnnotationStatus = 'DRAFT' | 'SUBMITTED' | 'APPROVED' | 'REJECTED'; +export type ImportStatus = 'PENDING' | 'APPROVED' | 'REJECTED'; + +// Vote value (up / down / no vote) + +export type VoteValue = 1 | -1; + +// Enum-like constants for type-safe comparisons (avoids string literals in code) + +export const GlobalRoles = { + ADMIN: 'ADMIN', + USER: 'USER', +} as const satisfies Record; + +export const ProjectRoles = { + MANAGER: 'MANAGER', + EDITOR: 'EDITOR', +} as const satisfies Record; + +export const ProjectTypes = { + NER: 'NER', + TRANSLATION: 'TRANSLATION', +} as const satisfies Record; + +export const AnnotationStatuses = { + DRAFT: 'DRAFT', + SUBMITTED: 'SUBMITTED', + APPROVED: 'APPROVED', + REJECTED: 'REJECTED', +} as const satisfies Record; + +export const ImportStatuses = { + PENDING: 'PENDING', + APPROVED: 'APPROVED', + REJECTED: 'REJECTED', +} as const satisfies Record; + +// Status → Ant Design color mapping (eliminates repeated ternary chains) + +const ANNOTATION_STATUS_COLOR: Record = { + APPROVED: 'success', + REJECTED: 'error', + SUBMITTED: 'warning', + DRAFT: 'default', +}; + +export function annotationStatusColor(status: AnnotationStatus): string { + return ANNOTATION_STATUS_COLOR[status]; +} + +const PROJECT_TYPE_TAG_COLOR: Record = { + NER: 'magenta', + TRANSLATION: 'blue', +}; + +export function projectTypeColor(type: ProjectType): string { + return PROJECT_TYPE_TAG_COLOR[type]; +} + +// Role checks (replaces scattered string comparisons) + +export function isAdmin( + role: GlobalRole | RoleProject | null | undefined +): boolean { + return role === GlobalRoles.ADMIN; +} + +export function isManagerOrAdmin( + role: GlobalRole | RoleProject | null | undefined +): boolean { + return role === GlobalRoles.ADMIN || role === ProjectRoles.MANAGER; +} + +// Workspace task status (discriminated union replaces derived booleans) + +export type TaskStatus = + | { kind: 'pending'; label: 'Pending'; color: 'default' } + | { kind: 'in_progress'; label: 'In Progress'; color: 'processing' } + | { kind: 'approved'; label: 'Approved'; color: 'success' }; + +export function taskStatus( + annotationCount: number, + hasFinal: boolean +): TaskStatus { + if (hasFinal) + return { kind: 'approved', label: 'Approved', color: 'success' }; + if (annotationCount > 0) + return { kind: 'in_progress', label: 'In Progress', color: 'processing' }; + return { kind: 'pending', label: 'Pending', color: 'default' }; +} + +export type TaskStatusFilter = '' | 'pending' | 'in_progress' | 'final'; + +//Notification typed strings + +export type NotificationType = 'review' | 'comment'; +export type NotificationResourceType = 'annotation'; + +// Audit log typed strings + +export type AuditAction = + | 'setup' + | 'register' + | 'login' + | 'password_change' + | 'create' + | 'delete' + | 'add_member' + | 'update_member_role' + | 'remove_member' + | 'import_pending' + | 'import' + | 'import_approved' + | 'import_rejected' + | 'export' + | 'toggle_active' + | 'change_role' + | 'regenerate'; + +export type AuditResourceType = 'user' | 'invitation' | 'project'; + +// Auth form mode (replaces boolean flags) + +export type AuthFormToggleMode = 'login' | 'register'; +export type AuthFormMode = 'setup' | 'login' | 'register' | 'invite'; + +// NER label colors + +export const LABEL_COLORS: Readonly> = { + PER: '#3b82f6', + ORG: '#8b5cf6', + LOC: '#10b981', + GPE: '#06b6d4', + FAC: '#f59e0b', + MISC: '#6b7280', + PRODUCT: '#ec4899', + EVENT: '#f97316', + SHIP: '#0ea5e9', + ARMOR: '#84cc16', + WEAPON: '#ef4444', + QUANTITY: '#14b8a6', + DATE: '#a855f7', + MONEY: '#eab308', +} as const; + +// Language codes + +export type LanguageCode = + | 'en' + | 'ru' + | 'de' + | 'fr' + | 'es' + | 'pt' + | 'it' + | 'zh' + | 'ja' + | 'ko' + | 'pl' + | 'nl' + | 'tr' + | 'cs' + | 'uk' + | 'ar' + | 'sv' + | 'da' + | 'fi' + | 'hu' + | 'no' + | 'ro'; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 4a5def4..e22cf7d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,7 +1,29 @@ -import { defineConfig } from 'vite'; +import { defineConfig, loadEnv } from 'vite'; import react from '@vitejs/plugin-react'; // https://vite.dev/config/ -export default defineConfig({ - plugins: [react()], +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ''); + const backendHost = env.VITE_BACKEND_HOST || 'backend'; + const backendUrl = `http://${backendHost}:8000`; + + return { + plugins: [react()], + server: { + host: '0.0.0.0', + port: 5173, + strictPort: true, + watch: { + usePolling: true, + }, + proxy: { + '/api': backendUrl, + '/ws': { + target: backendUrl, + ws: true, + }, + '/health': backendUrl, + }, + }, + }; });