commit 2e84c75035b11e173ec09d53542b7d7cfe88ad99 Author: Simon Gruber Date: Sun Mar 29 14:59:03 2026 +0200 init diff --git a/.gitea/workflows/build-and-push-images.yaml b/.gitea/workflows/build-and-push-images.yaml new file mode 100644 index 0000000..1a37965 --- /dev/null +++ b/.gitea/workflows/build-and-push-images.yaml @@ -0,0 +1,80 @@ +name: Build and Push Lunchtime Images (Kaniko) + +on: + push: + branches: + - main + - master + tags: + - "v*" + workflow_dispatch: + +env: + REGISTRY: git.sgruber.at + IMAGE_NAMESPACE: lunchtime + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Compute image tags and auth + id: meta + shell: bash + run: | + SHORT_SHA=$(echo $GITHUB_SHA | cut -c1-7) + IMAGE_REPO="$REGISTRY/$IMAGE_NAMESPACE/lunchtime-web" + + # Prepare Kaniko destination arguments + FRONTEND_DESTS="--destination $IMAGE_REPO:frontend-$SHORT_SHA" + BACKEND_DESTS="--destination $IMAGE_REPO:backend-$SHORT_SHA" + + if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then + FRONTEND_DESTS="$FRONTEND_DESTS --destination $IMAGE_REPO:frontend-${GITHUB_REF_NAME}" + BACKEND_DESTS="$BACKEND_DESTS --destination $IMAGE_REPO:backend-${GITHUB_REF_NAME}" + fi + + if [[ "$GITHUB_REF_NAME" == "main" || "$GITHUB_REF_NAME" == "master" ]]; then + FRONTEND_DESTS="$FRONTEND_DESTS --destination $IMAGE_REPO:frontend-latest" + BACKEND_DESTS="$BACKEND_DESTS --destination $IMAGE_REPO:backend-latest" + fi + + # Create the auth string + AUTH_B64=$(echo -n "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_TOKEN }}" | base64 -w 0) + CONFIG_JSON="{\"auths\":{\"$REGISTRY\":{\"auth\":\"$AUTH_B64\"}}}" + CONFIG_B64=$(echo -n "$CONFIG_JSON" | base64 -w 0) + + echo "frontend_dests=$FRONTEND_DESTS" >> "$GITHUB_OUTPUT" + echo "backend_dests=$BACKEND_DESTS" >> "$GITHUB_OUTPUT" + echo "config_b64=$CONFIG_B64" >> "$GITHUB_OUTPUT" + + - name: Build and Push Frontend + uses: docker://gcr.io/kaniko-project/executor:debug + env: + DOCKER_CONFIG_B64: ${{ steps.meta.outputs.config_b64 }} + with: + entrypoint: /busybox/sh + args: >- + -c "mkdir -p /kaniko/.docker && + echo $DOCKER_CONFIG_B64 | base64 -d > /kaniko/.docker/config.json && + /kaniko/executor + --context=$GITHUB_WORKSPACE/src/frontend + --dockerfile=$GITHUB_WORKSPACE/src/frontend/Containerfile + ${{ steps.meta.outputs.frontend_dests }}" + + - name: Build and Push Backend + uses: docker://gcr.io/kaniko-project/executor:debug + env: + DOCKER_CONFIG_B64: ${{ steps.meta.outputs.config_b64 }} + with: + entrypoint: /busybox/sh + args: >- + -c "mkdir -p /kaniko/.docker && + echo $DOCKER_CONFIG_B64 | base64 -d > /kaniko/.docker/config.json && + /kaniko/executor + --context=$GITHUB_WORKSPACE/src/backend + --dockerfile=$GITHUB_WORKSPACE/src/backend/Containerfile + ${{ steps.meta.outputs.backend_dests }}" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3ed6d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +*.sqlite3 +*.db +.venv/ +.env +.DS_Store +.data/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d84b820 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# Lunchtime + +Lunchtime is a self-hosted lunch order app with a React frontend, a Python backend, and nginx for reverse proxying. + +## Getting Started + +1. Copy and adjust `config.yaml` for your environment. +2. Create a writable data folder (for SQLite and uploads), for example `.data`. +3. Start the stack with Docker Compose. + +Example `compose.yml` (inspired by `src/compose.yml`) using images from the Gitea registry: + +```yaml +services: + nginx: + image: nginx:alpine + ports: + - "8080:8080" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - backend + - frontend + restart: unless-stopped + + backend: + image: git.sgruber.at/lunchtime/lunchtime-api:latest + volumes: + - ./.data:/app/data + - ./config.yaml:/app/config.yaml:ro + environment: + - APP_ENV=production + - LOG_LEVEL=info + - DB_PATH=/app/data + restart: unless-stopped + + frontend: + image: git.sgruber.at/lunchtime/lunchtime-web:latest + restart: unless-stopped +``` + +Then run: + +```bash +docker compose up -d +``` + +Open `http://localhost:8080`. + +## CI/CD Registry Publish + +The workflow in `.gitea/workflows/build-and-push-images.yaml` builds and pushes: + +- `git.sgruber.at/lunchtime/lunchtime-web` +- `git.sgruber.at/lunchtime/lunchtime-api` + +It tags images with the commit SHA and also `latest` on `main`/`master`. + +Required repository secrets: + +- `GITEA_REGISTRY_USER` +- `GITEA_REGISTRY_TOKEN` diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..4441929 --- /dev/null +++ b/TODO.md @@ -0,0 +1,10 @@ +Add admin view: + +Users tab: + - Show registered users and their orders (tree view? table with expandable groupings?) meaning what they created and what they participated in + - Allow deleting users (and their orders), also add switch to make them admin (popconfirm) + - View and set user email and confirmed state + +E-Mail configuration tab: + - View email configuration (without password) + - Ability to send test email diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..f381bc4 --- /dev/null +++ b/config.yaml @@ -0,0 +1,17 @@ +app: + public_base_url: "http://localhost:8080/" + +email: + enabled: true + smtp_host: "mail.example.com" + smtp_port: 465 + smtp_username: "lunchtime@example.com" + smtp_password: "" + from_address: "lunchtime@example.com" + use_tls: false + use_ssl: true + +announcements: + # - type: info + # title: Info + # message: Welcome to Lunchtime! diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..73d8b83 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,33 @@ +upstream backend { + server backend:5000; +} + +upstream frontend { + server frontend:8000; +} + +server { + listen 8080; + server_name _; + + location /api/ { + proxy_pass http://backend/api/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + proxy_pass http://frontend/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } +} diff --git a/src/.gitignore b/src/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/.dockerignore b/src/backend/.dockerignore new file mode 100644 index 0000000..afad092 --- /dev/null +++ b/src/backend/.dockerignore @@ -0,0 +1,11 @@ +__pycache__/ +*.pyc +*.pyo +*.db +*.sqlite3 +.venv/ +.env +.DS_Store +.git/ +.github/ +*.md diff --git a/src/backend/Containerfile b/src/backend/Containerfile new file mode 100644 index 0000000..f044037 --- /dev/null +++ b/src/backend/Containerfile @@ -0,0 +1,22 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +RUN mkdir -p /app/data && chmod 755 /app/data && \ + chmod +x /app/entrypoint.sh 2>/dev/null || true + +EXPOSE 5000 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:5000/api/config || exit 1 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5000"] diff --git a/src/backend/app/__init__.py b/src/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/app/db.py b/src/backend/app/db.py new file mode 100644 index 0000000..d6812e8 --- /dev/null +++ b/src/backend/app/db.py @@ -0,0 +1,147 @@ +import json +import os +import sqlite3 +from pathlib import Path + +data_dir = Path(os.getenv("DB_PATH", "/app/data")) +data_dir.mkdir(parents=True, exist_ok=True) +DB_PATH = data_dir / "burger_orders.db" + + +def get_connection() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +def init_db() -> None: + with get_connection() as conn: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS group_orders ( + id TEXT PRIMARY KEY, + admin_token TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + description TEXT NOT NULL, + image_url TEXT, + closed INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS submissions ( + id TEXT PRIMARY KEY, + group_order_id TEXT NOT NULL, + submission_token TEXT NOT NULL UNIQUE, + email TEXT NOT NULL, + choices_json TEXT NOT NULL, + accepted INTEGER NOT NULL DEFAULT 0, + paid INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(group_order_id) REFERENCES group_orders(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS user_order_tokens ( + user_id TEXT NOT NULL, + group_order_id TEXT NOT NULL, + admin_token TEXT, + submission_token TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY(user_id, group_order_id), + FOREIGN KEY(group_order_id) REFERENCES group_orders(id) ON DELETE CASCADE, + FOREIGN KEY(admin_token) REFERENCES group_orders(admin_token) ON DELETE SET NULL, + FOREIGN KEY(submission_token) REFERENCES submissions(submission_token) ON DELETE SET NULL + ); + + CREATE TABLE IF NOT EXISTS user_profiles ( + user_id TEXT PRIMARY KEY, + email TEXT NOT NULL, + email_confirmed INTEGER NOT NULL DEFAULT 0, + pending_email TEXT, + pending_email_old_confirmed INTEGER NOT NULL DEFAULT 0, + pending_email_new_confirmed INTEGER NOT NULL DEFAULT 0, + pending_user_id TEXT, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS account_confirmation_tokens ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + action TEXT NOT NULL, + process_id TEXT, + email TEXT, + new_email TEXT, + new_user_id TEXT, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + consumed_at TEXT + ); + """ + ) + + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_account_confirmation_tokens_user_id + ON account_confirmation_tokens(user_id) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_account_confirmation_tokens_process_id + ON account_confirmation_tokens(process_id) + """ + ) + + +def row_to_submission(row: sqlite3.Row) -> dict: + choices: dict = {} + raw_choices = row["choices_json"] + if raw_choices: + try: + parsed = json.loads(raw_choices) + if isinstance(parsed, dict): + choices = parsed + except json.JSONDecodeError: + choices = {} + + return { + "id": row["id"], + "group_order_id": row["group_order_id"], + "submission_token": row["submission_token"], + "email": row["email"], + "choices": choices, + "accepted": bool(row["accepted"]), + "paid": bool(row["paid"]), + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + + +def row_to_my_order(row: sqlite3.Row) -> dict: + submission_choices: dict = {} + raw_choices = row["choices_json"] + if raw_choices: + try: + parsed = json.loads(raw_choices) + if isinstance(parsed, dict): + submission_choices = parsed + except json.JSONDecodeError: + submission_choices = {} + + return { + "id": row["id"], + "title": row["title"], + "description": row["description"], + "image_url": row["image_url"], + "closed": bool(row["closed"]), + "created_at": row["created_at"], + "is_owner": bool(row["admin_token"]), + "is_participant": bool(row["submission_token"]), + "submission": { + "choices": submission_choices, + "accepted": bool(row["accepted"]) if row["accepted"] is not None else False, + "paid": bool(row["paid"]) if row["paid"] is not None else False, + }, + } diff --git a/src/backend/app/main.py b/src/backend/app/main.py new file mode 100644 index 0000000..a60e764 --- /dev/null +++ b/src/backend/app/main.py @@ -0,0 +1,1140 @@ +import json +import sqlite3 +from typing import Any +from uuid import uuid4 + +from fastapi import FastAPI, Header, HTTPException, UploadFile, File +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field + +from .db import get_connection, init_db, row_to_my_order, row_to_submission +from .services.account_service import ( + choose_new_user_id, + consume_confirmation_token, + create_confirmation_token, + ensure_owner_access, + get_order_creator_info, + get_user_order_tokens, + get_user_profile, + get_user_profile_email, + migrate_user_id, + resolve_submission_email, + upsert_user_order_tokens, + upsert_user_profile_email, +) +from .services.common_service import clean_image_url, clean_user_email, clean_user_id, now_iso +from .services.config_service import load_config +from .services.email_service import ( + send_email_change_new_email_confirmation, + send_migration_email, + send_registration_email, + send_user_id_change_email, +) +from .services.order_file_service import ( + IMAGES_DIR, + delete_order_config, + delete_order_description, + delete_order_images, + load_order_config, + save_order_config, + save_order_description, + save_uploaded_image, +) +from .services.order_service import ensure_order_exists, ensure_order_open +from .services.submission_service import ( + build_formatted_submission_string, + normalize_submission_choices, + validate_required_submission_choices, + with_submission_display, +) + +app = FastAPI(title="Lunchtime") + +# Mount static files for serving uploaded images +app.mount("/api/images", StaticFiles(directory=IMAGES_DIR), name="images") + +# CORS configuration for frontend communication +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://localhost:3000", + "http://localhost:8080", + "http://localhost", + "http://frontend:3000", + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +class OrderCreateRequest(BaseModel): + title: str = Field(min_length=1, max_length=200) + description: str = Field(default="") + image_url: str | None = Field(default=None, max_length=2048) + + +class SubmissionPayload(BaseModel): + """Flexible submission payload that matches the order's custom form configuration.""" + model_config = {"extra": "allow"} # Allow any fields beyond what's defined + choices: dict[str, Any] = Field(default_factory=dict) + + +class SubmissionStatusUpdateRequest(BaseModel): + accepted: bool + paid: bool + + +class OrderStatusUpdateRequest(BaseModel): + closed: bool + + +class OrderDescriptionUpdateRequest(BaseModel): + description: str = Field(default="") + image_url: str | None = Field(default=None, max_length=2048) + + +class OrderConfigCategory(BaseModel): + """Represents a category in the order form.""" + model_config = {"extra": "allow"} + id: str = Field(min_length=1) # Unique identifier for the category + label: str = Field(min_length=1) # Display label + required: bool = Field(default=False) # Whether user must select something + multiple: bool = Field(default=False) # Whether user can select multiple items + custom: bool = Field(default=False) # Whether user can provide custom entries + formattedSnippet: str = Field(default="{label}: {name}") + multipleSeparator: str = Field(default=", ") + optionalFallback: str = Field(default="") + items: list[dict[str, Any]] = Field(default_factory=list) # List of selection options + + +class OrderConfig(BaseModel): + """Order-specific form configuration.""" + model_config = {"extra": "allow"} + categories: list[OrderConfigCategory] = Field(default_factory=list) + + +class UserProfileUpdateRequest(BaseModel): + email: str = Field(min_length=3, max_length=320) + + +class RegisterAccountRequest(BaseModel): + email: str = Field(min_length=3, max_length=320) + + +class RequestUserIdChangeRequest(BaseModel): + # Optional because the backend generates a new one by default. + requested_user_id: str | None = Field(default=None, max_length=200) + + +class RequestEmailChangeRequest(BaseModel): + new_email: str = Field(min_length=3, max_length=320) + + +class RequestMigrationByEmailRequest(BaseModel): + email: str = Field(min_length=3, max_length=320) + + +class AccountLookupResponse(BaseModel): + exists: bool + + +@app.on_event("startup") +def startup() -> None: + init_db() + + +@app.get("/api/config") +def get_config() -> dict[str, Any]: + return load_config() + + +@app.get("/api/me/profile") +def get_my_profile(user_id: str = Header(alias="X-User-Id")) -> dict[str, Any]: + clean_id = clean_user_id(user_id) + + with get_connection() as conn: + profile = get_user_profile(conn, clean_id) + email = str(profile["email"]).strip().lower() if profile and profile["email"] else None + if not email: + fallback = conn.execute( + """ + SELECT s.email + FROM user_order_tokens uot + JOIN submissions s + ON s.submission_token = uot.submission_token + WHERE uot.user_id = ? + ORDER BY s.updated_at DESC + LIMIT 1 + """, + (clean_id,), + ).fetchone() + if fallback and fallback["email"]: + email = str(fallback["email"]).strip().lower() + if email: + upsert_user_profile_email(conn, clean_id, email) + + profile = get_user_profile(conn, clean_id) + + return { + "email": email, + "email_confirmed": bool(profile and profile["email_confirmed"]), + "pending_email": str(profile["pending_email"]).strip().lower() if profile and profile["pending_email"] else None, + "pending_email_old_confirmed": bool(profile and profile["pending_email_old_confirmed"]), + "pending_email_new_confirmed": bool(profile and profile["pending_email_new_confirmed"]), + "pending_user_id": str(profile["pending_user_id"]).strip() if profile and profile["pending_user_id"] else None, + } + + +@app.put("/api/me/profile") +def update_my_profile( + payload: UserProfileUpdateRequest, + user_id: str = Header(alias="X-User-Id"), +) -> dict[str, Any]: + clean_id = clean_user_id(user_id) + email = clean_user_email(payload.email) + if not email: + raise HTTPException(status_code=422, detail="Email is required") + + with get_connection() as conn: + upsert_user_profile_email(conn, clean_id, email) + conn.execute( + "UPDATE user_profiles SET email_confirmed = 1, pending_email = NULL, pending_email_old_confirmed = 0, pending_email_new_confirmed = 0, updated_at = ? WHERE user_id = ?", + (now_iso(), clean_id), + ) + + return {"email": email} + + +@app.post("/api/account/register") +def register_account(payload: RegisterAccountRequest) -> dict[str, Any]: + email = clean_user_email(payload.email) + if not email: + raise HTTPException(status_code=422, detail="Email is required") + + with get_connection() as conn: + new_user_id = choose_new_user_id(conn, None) + upsert_user_profile_email(conn, new_user_id, email) + conn.execute( + """ + UPDATE user_profiles + SET + email_confirmed = 0, + pending_email = NULL, + pending_email_old_confirmed = 0, + pending_email_new_confirmed = 0, + pending_user_id = NULL, + updated_at = ? + WHERE user_id = ? + """, + (now_iso(), new_user_id), + ) + + token = create_confirmation_token( + conn, + user_id=new_user_id, + action="register_confirm", + email=email, + ) + + send_registration_email(new_user_id, email, token) + return { + "status": "pending_email_confirmation", + "message": "Registration email sent", + } + + +@app.get("/api/account/lookup", response_model=AccountLookupResponse) +def lookup_account_by_email(email: str) -> AccountLookupResponse: + clean_email = clean_user_email(email) + if not clean_email: + raise HTTPException(status_code=422, detail="Email is required") + + with get_connection() as conn: + match = conn.execute( + """ + SELECT 1 + FROM user_profiles + WHERE lower(trim(email)) = ? + LIMIT 1 + """, + (clean_email,), + ).fetchone() + + return AccountLookupResponse(exists=bool(match)) + + +@app.post("/api/me/user-id/change/request") +def request_user_id_change( + payload: RequestUserIdChangeRequest, + user_id: str = Header(alias="X-User-Id"), +) -> dict[str, Any]: + clean_id = clean_user_id(user_id) + + with get_connection() as conn: + profile = get_user_profile(conn, clean_id) + if not profile: + raise HTTPException(status_code=404, detail="Profile not found") + + current_email = get_user_profile_email(conn, clean_id) + if not current_email: + raise HTTPException(status_code=422, detail="No email associated with this user") + + new_user_id = choose_new_user_id(conn, payload.requested_user_id) + conn.execute( + "UPDATE user_profiles SET pending_user_id = ?, updated_at = ? WHERE user_id = ?", + (new_user_id, now_iso(), clean_id), + ) + token = create_confirmation_token( + conn, + user_id=clean_id, + action="user_id_change_confirm", + email=current_email, + new_user_id=new_user_id, + ) + + send_user_id_change_email(current_email, new_user_id, token) + return { + "status": "pending_email_confirmation", + "message": "User ID confirmation email sent", + } + + +@app.post("/api/me/email/change/request") +def request_email_change( + payload: RequestEmailChangeRequest, + user_id: str = Header(alias="X-User-Id"), +) -> dict[str, Any]: + clean_id = clean_user_id(user_id) + new_email = clean_user_email(payload.new_email) + if not new_email: + raise HTTPException(status_code=422, detail="New email is required") + + with get_connection() as conn: + profile = get_user_profile(conn, clean_id) + if not profile: + raise HTTPException(status_code=404, detail="Profile not found") + + current_email = get_user_profile_email(conn, clean_id) + if current_email == new_email: + raise HTTPException(status_code=409, detail="New email is the same as current email") + + process_id = str(uuid4()) + conn.execute( + """ + UPDATE user_profiles + SET + pending_email = ?, + pending_email_old_confirmed = 0, + pending_email_new_confirmed = 0, + updated_at = ? + WHERE user_id = ? + """, + (new_email, now_iso(), clean_id), + ) + + token = create_confirmation_token( + conn, + user_id=clean_id, + action="email_change_new_confirm", + process_id=process_id, + new_email=new_email, + ) + + send_email_change_new_email_confirmation(new_email, token) + return { + "status": "pending_new_email_confirmation", + "message": "Confirmation link sent to your new email", + } + + +@app.post("/api/account/migrate/request") +def request_account_migration(payload: RequestMigrationByEmailRequest) -> dict[str, Any]: + email = clean_user_email(payload.email) + if not email: + raise HTTPException(status_code=422, detail="Email is required") + + with get_connection() as conn: + matches = conn.execute( + """ + SELECT user_id + FROM user_profiles + WHERE lower(trim(email)) = ? + ORDER BY updated_at DESC + """, + (email,), + ).fetchall() + + if not matches: + raise HTTPException(status_code=404, detail="No account found for this email") + if len(matches) > 1: + raise HTTPException( + status_code=409, + detail="Multiple accounts found for this email. Please use your user ID token instead.", + ) + + target_user_id = str(matches[0]["user_id"]).strip() + token = create_confirmation_token( + conn, + user_id=target_user_id, + action="migration_confirm", + email=email, + ) + + send_migration_email(email, target_user_id, token) + return { + "status": "pending_email_confirmation", + "message": "Migration confirmation email sent", + } + + +@app.get("/api/account/confirm") +def confirm_account_action(token: str) -> dict[str, Any]: + if not token.strip(): + raise HTTPException(status_code=422, detail="Token is required") + + with get_connection() as conn: + confirmation = consume_confirmation_token(conn, token.strip()) + action = str(confirmation["action"]) + confirmed_user_id = str(confirmation["user_id"]) + + if action == "register_confirm": + conn.execute( + "UPDATE user_profiles SET email_confirmed = 1, updated_at = ? WHERE user_id = ?", + (now_iso(), confirmed_user_id), + ) + return { + "status": "confirmed", + "action": action, + "user_id": confirmed_user_id, + } + + if action == "user_id_change_confirm": + new_user_id = str(confirmation["new_user_id"] or "").strip() + if not new_user_id: + raise HTTPException(status_code=500, detail="Missing new user ID on confirmation token") + + migrate_user_id(conn, confirmed_user_id, new_user_id) + return { + "status": "confirmed", + "action": action, + "user_id": new_user_id, + } + + if action == "email_change_new_confirm": + profile = get_user_profile(conn, confirmed_user_id) + if not profile: + raise HTTPException(status_code=404, detail="Profile not found") + + pending_email = str(profile["pending_email"] or "").strip().lower() + if not pending_email: + raise HTTPException(status_code=409, detail="No pending email change request") + + token_target_email = str(confirmation["new_email"] or "").strip().lower() + if token_target_email and token_target_email != pending_email: + raise HTTPException(status_code=409, detail="Pending email does not match confirmation token") + + conn.execute( + """ + UPDATE user_profiles + SET + email = ?, + email_confirmed = 1, + pending_email = NULL, + pending_email_old_confirmed = 0, + pending_email_new_confirmed = 1, + updated_at = ? + WHERE user_id = ? + """, + (pending_email, now_iso(), confirmed_user_id), + ) + return { + "status": "confirmed", + "action": action, + "user_id": confirmed_user_id, + } + + if action == "migration_confirm": + return { + "status": "confirmed", + "action": action, + "user_id": confirmed_user_id, + } + + raise HTTPException(status_code=400, detail="Unsupported confirmation action") + + +@app.post("/api/orders") +def create_order(payload: OrderCreateRequest, user_id: str = Header(alias="X-User-Id")) -> dict[str, Any]: + clean_id = clean_user_id(user_id) + order_id = str(uuid4()) + admin_token = str(uuid4()) + created_at = now_iso() + image_url = clean_image_url(payload.image_url) + + with get_connection() as conn: + conn.execute( + "INSERT INTO group_orders (id, admin_token, title, description, image_url, closed, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + (order_id, admin_token, payload.title.strip(), payload.description, image_url, 0, created_at), + ) + upsert_user_order_tokens(conn, clean_id, order_id, admin_token=admin_token) + + # Save description as markdown file + if payload.description: + save_order_description(admin_token, payload.description) + + return { + "id": order_id, + "admin_token": admin_token, + "title": payload.title.strip(), + "description": payload.description, + "image_url": image_url, + "closed": False, + "created_at": created_at, + "creator_user_id": clean_id, + "creator_email": None, + } + + +@app.post("/api/orders/{order_id}/admin/image") +def upload_order_image( + order_id: str, + file: UploadFile = File(...), + user_id: str = Header(alias="X-User-Id"), +) -> dict[str, str]: + """Upload an image for an order. Requires admin access.""" + clean_id = clean_user_id(user_id) + + with get_connection() as conn: + auth = ensure_owner_access(conn, clean_id, order_id) + admin_token = auth["admin_token"] + + # Save the uploaded image + image_path = save_uploaded_image(file, order_id, admin_token) + + # Update the order with the new image path + conn.execute( + "UPDATE group_orders SET image_url = ? WHERE id = ?", + (image_path, order_id), + ) + + return { + "image_url": image_path, + "message": "Image uploaded successfully" + } + + +@app.delete("/api/orders/{order_id}/admin/image") +def delete_order_image( + order_id: str, + user_id: str = Header(alias="X-User-Id"), +) -> dict[str, str]: + """Delete the image for an order. Requires admin access.""" + clean_id = clean_user_id(user_id) + + with get_connection() as conn: + auth = ensure_owner_access(conn, clean_id, order_id) + + # Get current image_url + order = conn.execute( + "SELECT image_url FROM group_orders WHERE id = ?", + (order_id,), + ).fetchone() + + if order and order["image_url"]: + # Try to delete the file if it's a relative path (uploaded image) + if not order["image_url"].startswith("http"): + try: + file_path = IMAGES_DIR / order["image_url"] + if file_path.exists(): + file_path.unlink() + except Exception: + pass # Log but don't fail if file deletion fails + + # Clear the image_url in database + conn.execute( + "UPDATE group_orders SET image_url = NULL WHERE id = ?", + (order_id,), + ) + + return {"message": "Image deleted successfully"} + + +@app.get("/api/orders/me") +def get_my_orders( + user_id: str = Header(alias="X-User-Id"), + skip: int = 0, + limit: int = 10, + state: str | None = None, + role: str | None = None, +) -> dict[str, Any]: + clean_id = clean_user_id(user_id) + + # Validate pagination parameters + skip = max(0, skip) + limit = max(1, min(100, limit)) # Limit between 1 and 100 + + # Validate filter parameters + valid_states = {"pending", "unpaid", "paid"} + valid_roles = {"participant", "owner"} + + filters = [] + params: list[Any] = [clean_id] + + if state and state.lower() in valid_states: + state_lower = state.lower() + filters.append("uot.submission_token IS NOT NULL") + if state_lower == "pending": + filters.append("(s.accepted IS NULL OR s.accepted = 0) AND (s.paid IS NULL OR s.paid = 0)") + elif state_lower == "unpaid": + filters.append("s.accepted = 1 AND (s.paid IS NULL OR s.paid = 0)") + elif state_lower == "paid": + filters.append("s.paid = 1") + + if role and role.lower() in valid_roles: + role_lower = role.lower() + if role_lower == "owner": + filters.append("uot.admin_token IS NOT NULL") + elif role_lower == "participant": + filters.append("uot.submission_token IS NOT NULL") + + where_clause = "uot.user_id = ?" + ("" if not filters else " AND " + " AND ".join(filters)) + + with get_connection() as conn: + # Get total count + count_result = conn.execute( + f""" + SELECT COUNT(*) as total + FROM user_order_tokens uot + LEFT JOIN submissions s ON s.submission_token = uot.submission_token + WHERE {where_clause} + """, + params, + ).fetchone() + total = count_result["total"] if count_result else 0 + + # Get paginated results + rows = conn.execute( + f""" + SELECT + go.id, + go.title, + go.description, + go.image_url, + go.closed, + go.created_at, + go.admin_token AS order_admin_token, + uot.admin_token, + uot.submission_token, + s.choices_json, + s.accepted, + s.paid + FROM user_order_tokens uot + JOIN group_orders go ON go.id = uot.group_order_id + LEFT JOIN submissions s ON s.submission_token = uot.submission_token + WHERE {where_clause} + ORDER BY go.created_at DESC + LIMIT ? OFFSET ? + """, + params + [limit, skip], + ).fetchall() + + orders: list[dict[str, Any]] = [] + for row in rows: + order_payload = row_to_my_order(row) + if order_payload["is_participant"]: + order_config = load_order_config(row["order_admin_token"]) + order_payload["submission"] = with_submission_display( + order_payload["submission"], + order_config, + ) + orders.append(order_payload) + + return { + "orders": orders, + "pagination": { + "total": total, + "skip": skip, + "limit": limit, + "hasMore": skip + limit < total, + }, + } + + +@app.get("/api/orders/{order_id}/me") +def get_my_order_access(order_id: str, user_id: str = Header(alias="X-User-Id")) -> dict[str, Any]: + clean_id = clean_user_id(user_id) + ensure_order_exists(order_id) + + with get_connection() as conn: + auth = get_user_order_tokens(conn, clean_id, order_id) + + return { + "is_owner": bool(auth and auth["admin_token"]), + "is_participant": bool(auth and auth["submission_token"]), + } + + +@app.get("/api/orders/{order_id}") +def get_order(order_id: str) -> dict[str, Any]: + row = ensure_order_exists(order_id) + with get_connection() as conn: + creator_user_id, creator_email = get_order_creator_info(conn, order_id) + + return { + "id": row["id"], + "title": row["title"], + "description": row["description"], + "image_url": row["image_url"], + "closed": bool(row["closed"]), + "created_at": row["created_at"], + "creator_user_id": creator_user_id, + "creator_email": creator_email, + } + + +@app.delete("/api/orders/{order_id}") +def delete_order(order_id: str, user_id: str = Header(alias="X-User-Id")) -> dict[str, bool]: + clean_id = clean_user_id(user_id) + + with get_connection() as conn: + auth = ensure_owner_access(conn, clean_id, order_id) + admin_token = auth["admin_token"] + + order = conn.execute( + "SELECT image_url FROM group_orders WHERE id = ?", + (order_id,), + ).fetchone() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + + cursor = conn.execute( + "DELETE FROM group_orders WHERE id = ?", + (order_id,), + ) + + if cursor.rowcount == 0: + raise HTTPException(status_code=404, detail="Order not found") + + delete_order_description(admin_token) + delete_order_config(admin_token) + delete_order_images(admin_token, order["image_url"]) + + return {"deleted": True} + + +@app.get("/api/orders/{order_id}/config") +def get_order_config(order_id: str) -> dict[str, Any]: + """Get order-specific form configuration for participants (public endpoint).""" + order = ensure_order_exists(order_id) + + # Get admin token from order - we need this to find the config file + with get_connection() as conn: + order_row = conn.execute( + "SELECT admin_token FROM group_orders WHERE id = ?", + (order_id,), + ).fetchone() + + if not order_row: + raise HTTPException(status_code=404, detail="Order not found") + + config = load_order_config(order_row["admin_token"]) + return config + + +@app.post("/api/orders/{order_id}/submissions/me") +def create_submission( + order_id: str, + payload: SubmissionPayload, + user_id: str = Header(alias="X-User-Id"), + user_email: str | None = Header(default=None, alias="X-User-Email"), +) -> dict[str, Any]: + clean_id = clean_user_id(user_id) + order = ensure_order_exists(order_id) + ensure_order_open(order) + + submission_id = str(uuid4()) + submission_token = str(uuid4()) + timestamp = now_iso() + order_config = load_order_config(order["admin_token"]) + normalized_choices = normalize_submission_choices(payload.choices or {}, order_config) + validate_required_submission_choices(normalized_choices, order_config) + + with get_connection() as conn: + submission_email = resolve_submission_email(conn, clean_id, user_email) + existing_auth = get_user_order_tokens(conn, clean_id, order_id) + if existing_auth and existing_auth["submission_token"]: + existing_row = conn.execute( + "SELECT id FROM submissions WHERE group_order_id = ? AND submission_token = ?", + (order_id, existing_auth["submission_token"]), + ).fetchone() + if existing_row: + raise HTTPException(status_code=409, detail="Submission already exists") + + conn.execute( + """ + INSERT INTO submissions ( + id, group_order_id, submission_token, email, choices_json, accepted, paid, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + submission_id, + order_id, + submission_token, + submission_email, + json.dumps(normalized_choices, ensure_ascii=False), + 0, + 0, + timestamp, + timestamp, + ), + ) + upsert_user_order_tokens(conn, clean_id, order_id, submission_token=submission_token) + + return { + "id": submission_id, + "submission_token": submission_token, + "email": submission_email, + "choices": normalized_choices, + "formatted_string": build_formatted_submission_string(normalized_choices, order_config), + "accepted": False, + "paid": False, + "created_at": timestamp, + "updated_at": timestamp, + } + + +@app.get("/api/orders/{order_id}/submissions/me") +def get_my_submission(order_id: str, user_id: str = Header(alias="X-User-Id")) -> dict[str, Any]: + clean_id = clean_user_id(user_id) + order = ensure_order_exists(order_id) + order_config = load_order_config(order["admin_token"]) + + with get_connection() as conn: + auth = get_user_order_tokens(conn, clean_id, order_id) + if not auth or not auth["submission_token"]: + raise HTTPException(status_code=404, detail="Submission not found") + + row = conn.execute( + "SELECT * FROM submissions WHERE group_order_id = ? AND submission_token = ?", + (order_id, auth["submission_token"]), + ).fetchone() + + if not row: + raise HTTPException(status_code=404, detail="Submission not found") + + return with_submission_display(row_to_submission(row), order_config) + + +@app.put("/api/orders/{order_id}/submissions/me") +def update_submission( + order_id: str, + payload: SubmissionPayload, + user_id: str = Header(alias="X-User-Id"), + user_email: str | None = Header(default=None, alias="X-User-Email"), +) -> dict[str, Any]: + clean_id = clean_user_id(user_id) + order = ensure_order_exists(order_id) + ensure_order_open(order) + + order_config = load_order_config(order["admin_token"]) + normalized_choices = normalize_submission_choices(payload.choices or {}, order_config) + validate_required_submission_choices(normalized_choices, order_config) + updated_at = now_iso() + + with get_connection() as conn: + submission_email = resolve_submission_email(conn, clean_id, user_email) + auth = get_user_order_tokens(conn, clean_id, order_id) + if not auth or not auth["submission_token"]: + raise HTTPException(status_code=404, detail="Submission not found") + + token = auth["submission_token"] + existing = conn.execute( + "SELECT id, accepted, paid, created_at FROM submissions WHERE group_order_id = ? AND submission_token = ?", + (order_id, token), + ).fetchone() + + if not existing: + raise HTTPException(status_code=404, detail="Submission not found") + + conn.execute( + """ + UPDATE submissions + SET email = ?, choices_json = ?, accepted = ?, updated_at = ? + WHERE group_order_id = ? AND submission_token = ? + """, + ( + submission_email, + json.dumps(normalized_choices, ensure_ascii=False), + 0, + updated_at, + order_id, + token, + ), + ) + + return { + "id": existing["id"], + "submission_token": token, + "email": submission_email, + "choices": normalized_choices, + "formatted_string": build_formatted_submission_string(normalized_choices, order_config), + "accepted": False, + "paid": bool(existing["paid"]), + "created_at": existing["created_at"], + "updated_at": updated_at, + } + + +@app.delete("/api/orders/{order_id}/submissions/me") +def delete_submission(order_id: str, user_id: str = Header(alias="X-User-Id")) -> dict[str, bool]: + clean_id = clean_user_id(user_id) + order = ensure_order_exists(order_id) + ensure_order_open(order) + + with get_connection() as conn: + auth = get_user_order_tokens(conn, clean_id, order_id) + if not auth or not auth["submission_token"]: + raise HTTPException(status_code=404, detail="Submission not found") + + cursor = conn.execute( + "DELETE FROM submissions WHERE group_order_id = ? AND submission_token = ?", + (order_id, auth["submission_token"]), + ) + + if cursor.rowcount == 0: + raise HTTPException(status_code=404, detail="Submission not found") + + return {"deleted": True} + + +@app.delete("/api/orders/{order_id}/admin/submissions/{submission_id}") +def admin_delete_submission(order_id: str, submission_id: str, user_id: str = Header(alias="X-User-Id")) -> dict[str, bool]: + clean_id = clean_user_id(user_id) + + with get_connection() as conn: + ensure_owner_access(conn, clean_id, order_id) + + cursor = conn.execute( + "DELETE FROM submissions WHERE id = ? AND group_order_id = ?", + (submission_id, order_id), + ) + + if cursor.rowcount == 0: + raise HTTPException(status_code=404, detail="Submission not found") + + return {"deleted": True} + + +@app.get("/api/orders/{order_id}/admin") +def get_admin_view(order_id: str, user_id: str = Header(alias="X-User-Id")) -> dict[str, Any]: + clean_id = clean_user_id(user_id) + + with get_connection() as conn: + ensure_owner_access(conn, clean_id, order_id) + order = conn.execute( + "SELECT id, admin_token, title, description, image_url, closed, created_at FROM group_orders WHERE id = ?", + (order_id,), + ).fetchone() + + if not order: + raise HTTPException(status_code=404, detail="Order not found") + + creator_user_id, creator_email = get_order_creator_info(conn, order_id) + + rows = conn.execute( + "SELECT * FROM submissions WHERE group_order_id = ? ORDER BY created_at ASC", + (order_id,), + ).fetchall() + + order_config = load_order_config(order["admin_token"]) + submissions = [with_submission_display(row_to_submission(row), order_config) for row in rows] + + return { + "order": { + "id": order["id"], + "title": order["title"], + "description": order["description"], + "image_url": order["image_url"], + "closed": bool(order["closed"]), + "created_at": order["created_at"], + "creator_user_id": creator_user_id, + "creator_email": creator_email, + }, + "submissions": submissions, + } + + +@app.put("/api/orders/{order_id}/admin/submissions/{submission_id}/status") +def admin_update_submission_status( + order_id: str, + submission_id: str, + payload: SubmissionStatusUpdateRequest, + user_id: str = Header(alias="X-User-Id"), +) -> dict[str, Any]: + clean_id = clean_user_id(user_id) + + with get_connection() as conn: + ensure_owner_access(conn, clean_id, order_id) + + order = conn.execute( + "SELECT admin_token FROM group_orders WHERE id = ?", + (order_id,), + ).fetchone() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + + cursor = conn.execute( + """ + UPDATE submissions + SET accepted = ?, paid = ?, updated_at = ? + WHERE group_order_id = ? AND id = ? + """, + ( + int(payload.accepted), + int(payload.paid), + now_iso(), + order_id, + submission_id, + ), + ) + + if cursor.rowcount == 0: + raise HTTPException(status_code=404, detail="Submission not found") + + row = conn.execute( + "SELECT * FROM submissions WHERE group_order_id = ? AND id = ?", + (order_id, submission_id), + ).fetchone() + + if not row: + raise HTTPException(status_code=404, detail="Submission not found") + + order_config = load_order_config(order["admin_token"]) + return with_submission_display(row_to_submission(row), order_config) + + +@app.put("/api/orders/{order_id}/admin/status") +def admin_update_order_status( + order_id: str, + payload: OrderStatusUpdateRequest, + user_id: str = Header(alias="X-User-Id"), +) -> dict[str, Any]: + clean_id = clean_user_id(user_id) + + with get_connection() as conn: + ensure_owner_access(conn, clean_id, order_id) + + conn.execute( + "UPDATE group_orders SET closed = ? WHERE id = ?", + (int(payload.closed), order_id), + ) + + updated = conn.execute( + "SELECT id, title, description, image_url, closed, created_at FROM group_orders WHERE id = ?", + (order_id,), + ).fetchone() + + if not updated: + raise HTTPException(status_code=404, detail="Order not found") + + return { + "id": updated["id"], + "title": updated["title"], + "description": updated["description"], + "image_url": updated["image_url"], + "closed": bool(updated["closed"]), + "created_at": updated["created_at"], + } + + +@app.put("/api/orders/{order_id}/admin/description") +def admin_update_order_description( + order_id: str, + payload: OrderDescriptionUpdateRequest, + user_id: str = Header(alias="X-User-Id"), +) -> dict[str, Any]: + clean_id = clean_user_id(user_id) + + with get_connection() as conn: + ensure_owner_access(conn, clean_id, order_id) + order = conn.execute( + "SELECT id, image_url FROM group_orders WHERE id = ?", + (order_id,), + ).fetchone() + + if not order: + raise HTTPException(status_code=404, detail="Order not found") + + fields_set = getattr(payload, "model_fields_set", None) + if fields_set is None: + fields_set = getattr(payload, "__fields_set__", set()) + + next_image_url = order["image_url"] + if "image_url" in fields_set: + next_image_url = clean_image_url(payload.image_url) + + conn.execute( + "UPDATE group_orders SET description = ?, image_url = ? WHERE id = ?", + (payload.description, next_image_url, order_id), + ) + + updated = conn.execute( + "SELECT id, title, description, image_url, closed, created_at FROM group_orders WHERE id = ?", + (order_id,), + ).fetchone() + + # Get admin token to save description file + auth = conn.execute( + "SELECT admin_token FROM group_orders WHERE id = ?", + (order_id,), + ).fetchone() + + # Save description as markdown file + if auth and payload.description is not None: + save_order_description(auth["admin_token"], payload.description) + + if not updated: + raise HTTPException(status_code=404, detail="Order not found") + + return { + "id": updated["id"], + "title": updated["title"], + "description": updated["description"], + "image_url": updated["image_url"], + "closed": bool(updated["closed"]), + "created_at": updated["created_at"], + } + + +@app.get("/api/orders/{order_id}/admin/config") +def admin_get_order_config(order_id: str, user_id: str = Header(alias="X-User-Id")) -> dict[str, Any]: + """Get order-specific form configuration.""" + clean_id = clean_user_id(user_id) + + with get_connection() as conn: + auth = ensure_owner_access(conn, clean_id, order_id) + admin_token = auth["admin_token"] + + config = load_order_config(admin_token) + return config + + +@app.put("/api/orders/{order_id}/admin/config") +def admin_update_order_config( + order_id: str, + payload: OrderConfig, + user_id: str = Header(alias="X-User-Id"), +) -> dict[str, Any]: + """Update order-specific form configuration.""" + clean_id = clean_user_id(user_id) + + with get_connection() as conn: + auth = ensure_owner_access(conn, clean_id, order_id) + admin_token = auth["admin_token"] + + config_dict = payload.model_dump() + save_order_config(admin_token, config_dict) + + return config_dict diff --git a/src/backend/app/services/__init__.py b/src/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/app/services/account_service.py b/src/backend/app/services/account_service.py new file mode 100644 index 0000000..cb219f3 --- /dev/null +++ b/src/backend/app/services/account_service.py @@ -0,0 +1,227 @@ +import secrets +import sqlite3 +from datetime import datetime, timedelta, timezone +from typing import Any +from uuid import uuid4 + +from fastapi import HTTPException + +from .common_service import clean_user_email, clean_user_id, now_iso +from ..db import row_to_submission + + +def upsert_user_order_tokens( + conn: sqlite3.Connection, + user_id: str, + order_id: str, + *, + admin_token: str | None = None, + submission_token: str | None = None, +) -> None: + timestamp = now_iso() + conn.execute( + """ + INSERT INTO user_order_tokens ( + user_id, group_order_id, admin_token, submission_token, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, group_order_id) + DO UPDATE SET + admin_token = COALESCE(excluded.admin_token, user_order_tokens.admin_token), + submission_token = COALESCE(excluded.submission_token, user_order_tokens.submission_token), + updated_at = excluded.updated_at + """, + (user_id, order_id, admin_token, submission_token, timestamp, timestamp), + ) + + +def get_user_order_tokens(conn: sqlite3.Connection, user_id: str, order_id: str) -> sqlite3.Row | None: + return conn.execute( + "SELECT user_id, group_order_id, admin_token, submission_token FROM user_order_tokens WHERE user_id = ? AND group_order_id = ?", + (user_id, order_id), + ).fetchone() + + +def upsert_user_profile_email(conn: sqlite3.Connection, user_id: str, email: str) -> None: + conn.execute( + """ + INSERT INTO user_profiles (user_id, email, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(user_id) + DO UPDATE SET + email = excluded.email, + updated_at = excluded.updated_at + """, + (user_id, email, now_iso()), + ) + + +def get_user_profile_email(conn: sqlite3.Connection, user_id: str) -> str | None: + row = conn.execute( + "SELECT email FROM user_profiles WHERE user_id = ?", + (user_id,), + ).fetchone() + return str(row["email"]).strip().lower() if row and row["email"] else None + + +def get_user_profile(conn: sqlite3.Connection, user_id: str) -> sqlite3.Row | None: + return conn.execute( + """ + SELECT + user_id, + email, + email_confirmed, + pending_email, + pending_email_old_confirmed, + pending_email_new_confirmed, + pending_user_id, + updated_at + FROM user_profiles + WHERE user_id = ? + """, + (user_id,), + ).fetchone() + + +def create_confirmation_token( + conn: sqlite3.Connection, + *, + user_id: str, + action: str, + email: str | None = None, + new_email: str | None = None, + new_user_id: str | None = None, + process_id: str | None = None, + expires_in_hours: int = 24, +) -> str: + token = secrets.token_urlsafe(32) + created_at = now_iso() + expires_at = (datetime.now(timezone.utc) + timedelta(hours=expires_in_hours)).isoformat() + conn.execute( + """ + INSERT INTO account_confirmation_tokens ( + token, user_id, action, process_id, email, new_email, new_user_id, created_at, expires_at, consumed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL) + """, + (token, user_id, action, process_id, email, new_email, new_user_id, created_at, expires_at), + ) + return token + + +def consume_confirmation_token(conn: sqlite3.Connection, token: str) -> sqlite3.Row: + row = conn.execute( + """ + SELECT token, user_id, action, process_id, email, new_email, new_user_id, created_at, expires_at, consumed_at + FROM account_confirmation_tokens + WHERE token = ? + """, + (token,), + ).fetchone() + + if not row: + raise HTTPException(status_code=404, detail="Confirmation token not found") + if row["consumed_at"]: + raise HTTPException(status_code=409, detail="Confirmation token already used") + + expires_at = datetime.fromisoformat(str(row["expires_at"])) + if datetime.now(timezone.utc) > expires_at: + raise HTTPException(status_code=410, detail="Confirmation token has expired") + + conn.execute( + "UPDATE account_confirmation_tokens SET consumed_at = ? WHERE token = ?", + (now_iso(), token), + ) + return row + + +def choose_new_user_id(conn: sqlite3.Connection, requested_user_id: str | None) -> str: + if requested_user_id: + candidate = clean_user_id(requested_user_id) + existing = conn.execute( + "SELECT user_id FROM user_profiles WHERE user_id = ?", + (candidate,), + ).fetchone() + if existing: + raise HTTPException(status_code=409, detail="Requested user ID is already in use") + return candidate + + for _ in range(5): + candidate = str(uuid4()) + existing = conn.execute( + "SELECT user_id FROM user_profiles WHERE user_id = ?", + (candidate,), + ).fetchone() + if not existing: + return candidate + + raise HTTPException(status_code=500, detail="Could not generate a unique user ID") + + +def migrate_user_id(conn: sqlite3.Connection, old_user_id: str, new_user_id: str) -> None: + exists = conn.execute( + "SELECT user_id FROM user_profiles WHERE user_id = ?", + (new_user_id,), + ).fetchone() + if exists: + raise HTTPException(status_code=409, detail="New user ID already exists") + + conn.execute( + "UPDATE user_profiles SET user_id = ?, pending_user_id = NULL, updated_at = ? WHERE user_id = ?", + (new_user_id, now_iso(), old_user_id), + ) + conn.execute( + "UPDATE user_order_tokens SET user_id = ?, updated_at = ? WHERE user_id = ?", + (new_user_id, now_iso(), old_user_id), + ) + conn.execute( + "UPDATE account_confirmation_tokens SET user_id = ? WHERE user_id = ? AND consumed_at IS NULL", + (new_user_id, old_user_id), + ) + + +def resolve_submission_email(conn: sqlite3.Connection, user_id: str, user_email_header: str | None) -> str: + header_email = clean_user_email(user_email_header) + if header_email: + upsert_user_profile_email(conn, user_id, header_email) + return header_email + + saved_email = get_user_profile_email(conn, user_id) + if saved_email: + return saved_email + + raise HTTPException(status_code=422, detail="No email associated with current user token") + + +def ensure_owner_access(conn: sqlite3.Connection, user_id: str, order_id: str) -> sqlite3.Row: + row = get_user_order_tokens(conn, user_id, order_id) + if not row or not row["admin_token"]: + raise HTTPException(status_code=404, detail="Admin order view not found") + return row + + +def build_submission_payload(row: sqlite3.Row | None) -> dict[str, Any] | None: + return row_to_submission(row) if row else None + + +def get_order_creator_info(conn: sqlite3.Connection, order_id: str) -> tuple[str | None, str | None]: + row = conn.execute( + """ + SELECT uot.user_id AS creator_user_id, up.email AS creator_email + FROM group_orders go + LEFT JOIN user_order_tokens uot + ON uot.group_order_id = go.id + AND uot.admin_token = go.admin_token + LEFT JOIN user_profiles up + ON up.user_id = uot.user_id + WHERE go.id = ? + ORDER BY uot.created_at ASC + LIMIT 1 + """, + (order_id,), + ).fetchone() + + if not row: + return None, None + + creator_user_id = str(row["creator_user_id"]).strip() if row["creator_user_id"] else None + creator_email = str(row["creator_email"]).strip().lower() if row["creator_email"] else None + return creator_user_id, creator_email diff --git a/src/backend/app/services/common_service.py b/src/backend/app/services/common_service.py new file mode 100644 index 0000000..bd7e330 --- /dev/null +++ b/src/backend/app/services/common_service.py @@ -0,0 +1,51 @@ +from datetime import datetime, timezone +from urllib.parse import urlparse + +from fastapi import HTTPException +from pydantic import EmailStr, TypeAdapter + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def clean_optional(value: str | None) -> str | None: + if value is None: + return None + trimmed = value.strip() + return trimmed if trimmed else None + + +def clean_user_id(raw_user_id: str | None) -> str: + clean_value = (raw_user_id or "").strip() + if not clean_value: + raise HTTPException(status_code=401, detail="Missing user ID") + if len(clean_value) > 200: + raise HTTPException(status_code=422, detail="User ID is too long") + return clean_value + + +def clean_user_email(raw_user_email: str | None) -> str | None: + clean_value = clean_optional(raw_user_email) + if not clean_value: + return None + + try: + validated = TypeAdapter(EmailStr).validate_python(clean_value) + except Exception as exc: # pragma: no cover - defensive against validator changes + raise HTTPException(status_code=422, detail="User email header is invalid") from exc + + return str(validated).lower() + + +def clean_image_url(value: str | None) -> str | None: + clean_url = clean_optional(value) + if not clean_url: + return None + + if clean_url.startswith("http"): + parsed = urlparse(clean_url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise HTTPException(status_code=422, detail="image_url must be a valid http(s) URL or relative path") + + return clean_url diff --git a/src/backend/app/services/config_service.py b/src/backend/app/services/config_service.py new file mode 100644 index 0000000..651c21c --- /dev/null +++ b/src/backend/app/services/config_service.py @@ -0,0 +1,83 @@ +from pathlib import Path +from typing import Any + +import yaml +from fastapi import HTTPException + +BASE_DIR = Path(__file__).resolve().parents[2] +CONFIG_PATH = BASE_DIR / "config.yaml" + + +def normalize_announcement(item: Any) -> dict[str, str] | None: + allowed_types = {"info", "warning", "error", "success"} + + if isinstance(item, str): + message = item.strip() + if not message: + return None + return {"type": "info", "message": message} + + if not isinstance(item, dict): + return None + + message = str(item.get("message", "")).strip() + if not message: + return None + + announcement_type = str(item.get("type", "info")).strip().lower() + if announcement_type not in allowed_types: + announcement_type = "info" + + title = str(item.get("title", "")).strip() + normalized = {"type": announcement_type, "message": message} + if title: + normalized["title"] = title + + return normalized + + +def normalize_announcements(items: Any) -> list[dict[str, str]]: + if not isinstance(items, list): + return [] + + normalized: list[dict[str, str]] = [] + for item in items: + clean_item = normalize_announcement(item) + if clean_item: + normalized.append(clean_item) + return normalized + + +def load_raw_config() -> dict[str, Any]: + if not CONFIG_PATH.exists(): + raise HTTPException(status_code=500, detail="config.yaml is missing") + + with CONFIG_PATH.open("r", encoding="utf-8") as file: + return yaml.safe_load(file) or {} + + +def load_config() -> dict[str, Any]: + data = load_raw_config() + return { + "announcements": normalize_announcements(data.get("announcements", [])), + } + + +def get_email_settings() -> dict[str, Any]: + data = load_raw_config() + email = data.get("email") if isinstance(data.get("email"), dict) else {} + app = data.get("app") if isinstance(data.get("app"), dict) else {} + + return { + "enabled": bool(email.get("enabled", False)), + "smtp_host": str(email.get("smtp_host", "") or "").strip(), + "smtp_port": int(email.get("smtp_port", 587) or 587), + "smtp_username": str(email.get("smtp_username", "") or "").strip(), + "smtp_password": str(email.get("smtp_password", "") or ""), + "from_address": str(email.get("from_address", "") or "").strip(), + "use_tls": bool(email.get("use_tls", True)), + "use_ssl": bool(email.get("use_ssl", False)), + "confirmation_link_base": str( + email.get("confirmation_link_base", app.get("public_base_url", "")) or "" + ).strip(), + } diff --git a/src/backend/app/services/email_service.py b/src/backend/app/services/email_service.py new file mode 100644 index 0000000..1ed8aa7 --- /dev/null +++ b/src/backend/app/services/email_service.py @@ -0,0 +1,152 @@ +import logging +import smtplib +from email.message import EmailMessage +from urllib.parse import urlencode, urlparse + +from fastapi import HTTPException + +from .config_service import get_email_settings + +logger = logging.getLogger(__name__) + + +def build_confirmation_link(base_url: str, token: str) -> str: + if not base_url: + raise HTTPException(status_code=500, detail="Missing confirmation link base URL in config.yaml") + + parsed = urlparse(base_url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise HTTPException(status_code=500, detail="confirmation_link_base must be a valid http(s) URL") + + sep = "&" if "?" in base_url else "?" + return f"{base_url}{sep}{urlencode({'token': token})}" + + +def mask_email_for_log(value: str) -> str: + local, sep, domain = value.partition("@") + if not sep: + return "***" + if len(local) <= 1: + return f"*@{domain}" + return f"{local[0]}***@{domain}" + + +def send_email_message(*, to_email: str, subject: str, body: str) -> None: + settings = get_email_settings() + masked_recipient = mask_email_for_log(to_email) + + if not settings["enabled"]: + logger.warning("Email send skipped because email delivery is disabled") + raise HTTPException(status_code=503, detail="Email delivery is disabled") + + required = ["smtp_host", "smtp_port", "from_address"] + missing = [name for name in required if not settings.get(name)] + if missing: + logger.error("Email send blocked by missing email settings: %s", ", ".join(missing)) + raise HTTPException(status_code=500, detail=f"Missing email settings: {', '.join(missing)}") + + logger.info( + "Sending email to=%s subject=%s smtp_host=%s smtp_port=%s tls=%s ssl=%s", + masked_recipient, + subject, + settings["smtp_host"], + settings["smtp_port"], + settings["use_tls"], + settings["use_ssl"], + ) + + msg = EmailMessage() + msg["From"] = settings["from_address"] + msg["To"] = to_email + msg["Subject"] = f"[Lunchtime] {subject}".strip() + msg.set_content(body) + + try: + if settings["use_ssl"]: + server = smtplib.SMTP_SSL(settings["smtp_host"], settings["smtp_port"], timeout=15) + else: + server = smtplib.SMTP(settings["smtp_host"], settings["smtp_port"], timeout=15) + + with server: + if settings["use_tls"] and not settings["use_ssl"]: + server.starttls() + + username = settings["smtp_username"] + password = settings["smtp_password"] + if username: + server.login(username, password) + + server.send_message(msg) + + logger.info("Email sent successfully to=%s subject=%s", masked_recipient, subject) + except HTTPException: + raise + except Exception as exc: # pragma: no cover - external SMTP/network dependency + logger.exception( + "Email send failed to=%s subject=%s smtp_host=%s smtp_port=%s", + masked_recipient, + subject, + settings["smtp_host"], + settings["smtp_port"], + ) + raise HTTPException(status_code=502, detail="Failed to send email") from exc + + +def send_registration_email(user_id: str, email: str, token: str) -> None: + settings = get_email_settings() + link = build_confirmation_link(settings["confirmation_link_base"], token) + send_email_message( + to_email=email, + subject="Setup your Lunchtime account", + body=( + "Welcome to Lunchtime!\n\n" + "Click this link to confirm your email address:\n" + f"{link}\n\n" + "If this wasn't you, you can safely ignore this email." + ), + ) + + +def send_user_id_change_email(current_email: str, new_user_id: str, token: str) -> None: + settings = get_email_settings() + link = build_confirmation_link(settings["confirmation_link_base"], token) + send_email_message( + to_email=current_email, + subject="Confirm your user ID change", + body=( + "You requested a user ID change.\n\n" + "Confirm this change by clicking this link:\n" + f"{link}\n\n" + "If this wasn't you, you can safely ignore this email." + ), + ) + + +def send_email_change_new_email_confirmation(new_email: str, token: str) -> None: + settings = get_email_settings() + link = build_confirmation_link(settings["confirmation_link_base"], token) + send_email_message( + to_email=new_email, + subject="Confirm your new email address", + body=( + "You requested to change your account email address.\n\n" + "Confirm ownership of this new email by clicking this link:\n" + f"{link}\n\n" + "If this wasn't you, you can safely ignore this email." + ), + ) + + +def send_migration_email(email: str, user_id: str, token: str) -> None: + settings = get_email_settings() + link = build_confirmation_link(settings["confirmation_link_base"], token) + send_email_message( + to_email=email, + subject="Confirm account migration", + body=( + "You requested account migration on a new device.\n\n" + "Confirm migration by clicking this link:\n" + f"{link}\n\n" + "If this wasn't you, you can safely ignore this email." + ), + ) diff --git a/src/backend/app/services/order_file_service.py b/src/backend/app/services/order_file_service.py new file mode 100644 index 0000000..81e5d65 --- /dev/null +++ b/src/backend/app/services/order_file_service.py @@ -0,0 +1,123 @@ +import json +import shutil +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from fastapi import HTTPException, UploadFile + +BASE_DIR = Path(__file__).resolve().parents[2] +IMAGES_DIR = BASE_DIR / "data" / "images" +CONFIGS_DIR = BASE_DIR / "data" / "configs" +DESCRIPTIONS_DIR = BASE_DIR / "data" / "descriptions" + +IMAGES_DIR.mkdir(parents=True, exist_ok=True) +CONFIGS_DIR.mkdir(parents=True, exist_ok=True) +DESCRIPTIONS_DIR.mkdir(parents=True, exist_ok=True) + + +def load_order_config(admin_token: str) -> dict[str, Any]: + config_path = CONFIGS_DIR / f"{admin_token}.json" + if not config_path.exists(): + return { + "categories": [], + } + + try: + with config_path.open("r", encoding="utf-8") as file: + return json.load(file) + except Exception: + return {"categories": []} + + +def save_order_config(admin_token: str, config: dict[str, Any]) -> None: + config_path = CONFIGS_DIR / f"{admin_token}.json" + try: + with config_path.open("w", encoding="utf-8") as file: + json.dump(config, file, indent=2, ensure_ascii=False) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Failed to save configuration: {str(exc)}") + + +def load_order_description(admin_token: str) -> str: + desc_path = DESCRIPTIONS_DIR / f"{admin_token}.md" + if not desc_path.exists(): + return "" + + try: + with desc_path.open("r", encoding="utf-8") as file: + return file.read() + except Exception: + return "" + + +def save_order_description(admin_token: str, description: str) -> None: + desc_path = DESCRIPTIONS_DIR / f"{admin_token}.md" + try: + with desc_path.open("w", encoding="utf-8") as file: + file.write(description) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Failed to save description: {str(exc)}") + + +def delete_order_description(admin_token: str) -> None: + desc_path = DESCRIPTIONS_DIR / f"{admin_token}.md" + try: + if desc_path.exists(): + desc_path.unlink() + except Exception: + pass + + +def delete_order_config(admin_token: str) -> None: + config_path = CONFIGS_DIR / f"{admin_token}.json" + try: + if config_path.exists(): + config_path.unlink() + except Exception: + pass + + +def delete_order_images(admin_token: str, image_url: str | None) -> None: + try: + if image_url and not image_url.startswith("http"): + file_path = IMAGES_DIR / image_url + if file_path.exists(): + file_path.unlink() + + order_images_dir = IMAGES_DIR / admin_token + if order_images_dir.exists(): + shutil.rmtree(order_images_dir, ignore_errors=True) + except Exception: + pass + + +def save_uploaded_image(file: UploadFile, order_id: str, admin_token: str) -> str: + if not file.filename: + raise HTTPException(status_code=422, detail="No filename provided") + + allowed_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"} + file_ext = Path(file.filename).suffix.lower() + + if file_ext not in allowed_extensions: + raise HTTPException(status_code=422, detail=f"File type not allowed. Allowed: {', '.join(allowed_extensions)}") + + order_images_dir = IMAGES_DIR / admin_token + order_images_dir.mkdir(parents=True, exist_ok=True) + + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + new_filename = f"{timestamp}_{file.filename.replace(' ', '_')}" + file_path = order_images_dir / new_filename + + try: + contents = file.file.read() + + if not contents: + raise HTTPException(status_code=422, detail="Uploaded file is empty") + + with open(file_path, "wb") as output_file: + output_file.write(contents) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Failed to save image: {str(exc)}") + + return f"{admin_token}/{new_filename}" diff --git a/src/backend/app/services/order_service.py b/src/backend/app/services/order_service.py new file mode 100644 index 0000000..b5de0e6 --- /dev/null +++ b/src/backend/app/services/order_service.py @@ -0,0 +1,23 @@ +import sqlite3 + +from fastapi import HTTPException + +from ..db import get_connection + + +def ensure_order_exists(order_id: str) -> sqlite3.Row: + with get_connection() as conn: + row = conn.execute( + "SELECT id, admin_token, title, description, image_url, closed, created_at FROM group_orders WHERE id = ?", + (order_id,), + ).fetchone() + + if not row: + raise HTTPException(status_code=404, detail="Order not found") + + return row + + +def ensure_order_open(order: sqlite3.Row) -> None: + if bool(order["closed"]): + raise HTTPException(status_code=409, detail="Order is closed for submissions") diff --git a/src/backend/app/services/submission_service.py b/src/backend/app/services/submission_service.py new file mode 100644 index 0000000..65bb49e --- /dev/null +++ b/src/backend/app/services/submission_service.py @@ -0,0 +1,178 @@ +from math import isfinite +from typing import Any + +from fastapi import HTTPException +from .common_service import clean_optional + +def get_category_snippet(category: dict[str, Any]) -> str: + raw_snippet = str(category.get("formattedSnippet", "") or "").strip() + if not raw_snippet or "{name}" not in raw_snippet: + return "{label}: {name}" + return raw_snippet + + +def get_multiple_separator(category: dict[str, Any]) -> str: + raw_separator = category.get("multipleSeparator") + if isinstance(raw_separator, str): + return raw_separator + return ", " + + +def normalize_submission_choices(choices: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]: + categories = config.get("categories") or [] + normalized: dict[str, Any] = {} + + for category in categories: + category_id = str(category.get("id", "")).strip() + if not category_id: + continue + + raw_value = choices.get(category_id) + if bool(category.get("multiple")): + raw_values = raw_value if isinstance(raw_value, list) else [] + normalized_values = sorted( + { + value + for value in [clean_optional(str(entry)) for entry in raw_values] + if value + } + ) + normalized[category_id] = normalized_values + continue + + single_raw = raw_value[0] if isinstance(raw_value, list) and raw_value else raw_value + normalized[category_id] = clean_optional(str(single_raw)) if single_raw is not None else None + + return normalized + + +def validate_required_submission_choices(choices: dict[str, Any], config: dict[str, Any]) -> None: + categories = config.get("categories") or [] + for category in categories: + if not bool(category.get("required")): + continue + + category_id = str(category.get("id", "")).strip() + if not category_id: + continue + + value = choices.get(category_id) + if isinstance(value, list): + if value: + continue + elif isinstance(value, str): + if value.strip(): + continue + elif value: + continue + + label = str(category.get("label", category_id)).strip() or category_id + raise HTTPException(status_code=422, detail=f"{label} is required") + + +def resolve_category_item_display_name(item_name: str, category: dict[str, Any]) -> str: + for item in category.get("items") or []: + if str(item.get("name", "")) != item_name: + continue + + return item_name + + return item_name + + +def calculate_estimated_submission_total(choices: dict[str, Any], config: dict[str, Any]) -> float | None: + categories = config.get("categories") or [] + total = 0.0 + has_priced_items = False + + for category in categories: + category_id = str(category.get("id", "")).strip() + if not category_id: + continue + + value = choices.get(category_id) + items = category.get("items") or [] + + if bool(category.get("multiple")): + selected_values = value if isinstance(value, list) else [] + for selected in selected_values: + selected_name = str(selected) + for item in items: + if str(item.get("name", "")) != selected_name: + continue + + price = item.get("price") + if isinstance(price, (int, float)) and isfinite(float(price)): + total += float(price) + has_priced_items = True + break + continue + + if not isinstance(value, str) or not value.strip(): + continue + + selected_name = value.strip() + for item in items: + if str(item.get("name", "")) != selected_name: + continue + + price = item.get("price") + if isinstance(price, (int, float)) and isfinite(float(price)): + total += float(price) + has_priced_items = True + break + + if not has_priced_items: + return None + + return total + + +def build_formatted_submission_string(choices: dict[str, Any], config: dict[str, Any]) -> str: + categories = config.get("categories") or [] + parts: list[str] = [] + + for category in categories: + category_id = str(category.get("id", "")).strip() + if not category_id: + continue + + value = choices.get(category_id) + category_part = "" + + if bool(category.get("multiple")): + values = value if isinstance(value, list) else [] + if values: + labels = [resolve_category_item_display_name(str(entry), category) for entry in values] + category_part = get_multiple_separator(category).join(labels) + elif isinstance(value, str) and value.strip(): + category_part = resolve_category_item_display_name(value.strip(), category) + + if category_part: + snippet = get_category_snippet(category) + label = str(category.get("label", category_id)) + parts.append(snippet.replace("{name}", category_part).replace("{label}", label)) + continue + + if not bool(category.get("required")): + fallback = str(category.get("optionalFallback", "") or "").strip() + if fallback: + parts.append(fallback) + + if parts: + return " ".join(parts) + + return "No items selected" + + +def with_submission_display(submission: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]: + submission_with_display = dict(submission) + submission_with_display["formatted_string"] = build_formatted_submission_string( + submission_with_display.get("choices") or {}, + config, + ) + submission_with_display["estimated_total"] = calculate_estimated_submission_total( + submission_with_display.get("choices") or {}, + config, + ) + return submission_with_display diff --git a/src/backend/create-order-config.json b/src/backend/create-order-config.json new file mode 100644 index 0000000..b05db03 --- /dev/null +++ b/src/backend/create-order-config.json @@ -0,0 +1,3 @@ +{ + "categories": [] +} diff --git a/src/backend/requirements.txt b/src/backend/requirements.txt new file mode 100644 index 0000000..a84a88f --- /dev/null +++ b/src/backend/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.12 +uvicorn[standard]==0.34.0 +PyYAML==6.0.2 +python-multipart==0.0.20 +email-validator==2.2.0 diff --git a/src/compose.yml b/src/compose.yml new file mode 100644 index 0000000..cc0cb81 --- /dev/null +++ b/src/compose.yml @@ -0,0 +1,47 @@ +services: + nginx: + image: nginx:alpine + container_name: lunchtime-nginx + ports: + - "8080:8080" + volumes: + - ../nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - backend + - frontend + restart: unless-stopped + networks: + - burger-network + + backend: + build: + context: backend + dockerfile: Containerfile + container_name: lunchtime-backend + volumes: + - ../.data:/app/data + - ../config.yaml:/app/config.yaml:ro + environment: + - APP_ENV=production + - LOG_LEVEL=info + - DB_PATH=/app/data + restart: unless-stopped + networks: + burger-network: + aliases: + - backend + + frontend: + build: + context: frontend + dockerfile: Containerfile + container_name: lunchtime-frontend + restart: unless-stopped + networks: + burger-network: + aliases: + - frontend + +networks: + burger-network: + driver: bridge diff --git a/src/frontend/.dockerignore b/src/frontend/.dockerignore new file mode 100644 index 0000000..763301f --- /dev/null +++ b/src/frontend/.dockerignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ \ No newline at end of file diff --git a/src/frontend/.gitignore b/src/frontend/.gitignore new file mode 100644 index 0000000..763301f --- /dev/null +++ b/src/frontend/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ \ No newline at end of file diff --git a/src/frontend/Containerfile b/src/frontend/Containerfile new file mode 100644 index 0000000..539ec3e --- /dev/null +++ b/src/frontend/Containerfile @@ -0,0 +1,24 @@ +FROM node:20-slim AS build + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . ./ +RUN npm run build + +FROM node:20-slim + +WORKDIR /app + +RUN npm install -g http-server + +COPY --from=build /app/dist ./ + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget -q -O- http://localhost:8000/index.html || exit 1 + +CMD ["http-server", ".", "-p", "8000", "-c-1", "--gzip", "-P", "http://localhost:8000?"] diff --git a/src/frontend/index.html b/src/frontend/index.html new file mode 100644 index 0000000..2631326 --- /dev/null +++ b/src/frontend/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + + Lunchtime + + +
+ + + diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json new file mode 100644 index 0000000..100dd09 --- /dev/null +++ b/src/frontend/package-lock.json @@ -0,0 +1,2992 @@ +{ + "name": "lunchtime-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lunchtime-frontend", + "version": "1.0.0", + "dependencies": { + "@ant-design/icons": "^6.1.0", + "antd": "^5.27.3", + "dompurify": "^3.3.3", + "marked": "^16.0.0", + "react": "^18.3.1", + "react-copy-to-clipboard": "^5.1.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "^5.8.3", + "vite": "^7.0.0" + } + }, + "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/@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==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "classnames": "^2.3.1", + "csstype": "^3.1.3", + "rc-util": "^5.35.0", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.0", + "@babel/runtime": "^7.23.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "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/@ant-design/icons": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.1.0.tgz", + "integrity": "sha512-KrWMu1fIg3w/1F2zfn+JlfNDU8dDqILfA5Tg85iqs1lf8ooyGlbkA+TkwfOKKgqpUmAiRY1PTFpuOU2DAIgSUg==", + "dependencies": { + "@ant-design/colors": "^8.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", + "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", + "license": "MIT" + }, + "node_modules/@ant-design/icons/node_modules/@ant-design/colors": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-8.0.1.tgz", + "integrity": "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==", + "dependencies": { + "@ant-design/fast-color": "^3.0.0" + } + }, + "node_modules/@ant-design/icons/node_modules/@ant-design/fast-color": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-3.0.1.tgz", + "integrity": "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==", + "engines": { + "node": ">=8.x" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.4", + "classnames": "^2.2.5", + "json2mq": "^0.2.0", + "resize-observer-polyfill": "^1.5.1", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": ">=16.9.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "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/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" + }, + "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/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==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "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/tour": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.1.tgz", + "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==", + "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" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "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==", + "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" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/util": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.10.0.tgz", + "integrity": "sha512-aY9GLBuiUdpyfIUpAWSYer4Tu3mVaZCo5A0q9NtXcazT3MRiI3/WNHCR+DUn5VAtR6iRRf0ynCqQUcHli5UdYw==", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "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", + "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" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "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==", + "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" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", + "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001781", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", + "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/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", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "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/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.323", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.323.tgz", + "integrity": "sha512-oQm+FxbazvN2WICCbvJgj3IYPKV8awip57+W5VP+Aatk4kFU4pDYCPHZOX22Z27zpw8uttBehEqgK+VTJAYrVw==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "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/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "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", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "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==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "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/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "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==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "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" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "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==", + "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==", + "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" + } + }, + "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==", + "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" + } + }, + "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==", + "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" + } + }, + "node_modules/rc-dialog": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.6.0.tgz", + "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==", + "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" + } + }, + "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==", + "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" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-util": "^5.44.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "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==", + "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_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==", + "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" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "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==", + "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" + } + }, + "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==", + "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" + }, + "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" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "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==", + "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" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.44.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.9.0", + "rc-util": "^5.20.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.37.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.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==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "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==", + "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" + }, + "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" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "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==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.6", + "rc-util": "^5.16.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.0.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "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==", + "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" + } + }, + "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==", + "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" + } + }, + "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==", + "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" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "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", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "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" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "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==", + "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" + } + }, + "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==", + "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_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==", + "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" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "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/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==", + "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" + } + }, + "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==", + "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" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "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==", + "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" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "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==", + "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" + } + }, + "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/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==", + "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_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-copy-to-clipboard": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.1.tgz", + "integrity": "sha512-s+HrzLyJBxrpGTYXF15dTgMjAJpEPZT/Yp6NytAtZMRngejxt6Pt5WrfFxLAcsqUDU6sY1Jz6tyHwIicE1U2Xg==", + "license": "MIT", + "dependencies": { + "copy-to-clipboard": "^3.3.3", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">=15.3.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "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/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "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/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/src/frontend/package.json b/src/frontend/package.json new file mode 100644 index 0000000..2ebca02 --- /dev/null +++ b/src/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "lunchtime-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0 --port 3000", + "build": "vite build", + "preview": "vite preview --host 0.0.0.0 --port 3000" + }, + "dependencies": { + "@ant-design/icons": "^6.1.0", + "antd": "^5.27.3", + "dompurify": "^3.3.3", + "marked": "^16.0.0", + "react": "^18.3.1", + "react-copy-to-clipboard": "^5.1.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "^5.8.3", + "vite": "^7.0.0" + } +} diff --git a/src/frontend/public/android-chrome-192x192.png b/src/frontend/public/android-chrome-192x192.png new file mode 100644 index 0000000..e1a42f1 Binary files /dev/null and b/src/frontend/public/android-chrome-192x192.png differ diff --git a/src/frontend/public/android-chrome-512x512.png b/src/frontend/public/android-chrome-512x512.png new file mode 100644 index 0000000..940294e Binary files /dev/null and b/src/frontend/public/android-chrome-512x512.png differ diff --git a/src/frontend/public/apple-touch-icon.png b/src/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000..92cc640 Binary files /dev/null and b/src/frontend/public/apple-touch-icon.png differ diff --git a/src/frontend/public/favicon-16x16.png b/src/frontend/public/favicon-16x16.png new file mode 100644 index 0000000..bdfac17 Binary files /dev/null and b/src/frontend/public/favicon-16x16.png differ diff --git a/src/frontend/public/favicon-32x32.png b/src/frontend/public/favicon-32x32.png new file mode 100644 index 0000000..f2aa203 Binary files /dev/null and b/src/frontend/public/favicon-32x32.png differ diff --git a/src/frontend/public/favicon.ico b/src/frontend/public/favicon.ico new file mode 100644 index 0000000..2c76725 Binary files /dev/null and b/src/frontend/public/favicon.ico differ diff --git a/src/frontend/public/site.webmanifest b/src/frontend/public/site.webmanifest new file mode 100644 index 0000000..45dc8a2 --- /dev/null +++ b/src/frontend/public/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx new file mode 100644 index 0000000..775e430 --- /dev/null +++ b/src/frontend/src/App.tsx @@ -0,0 +1,420 @@ +import React, { + memo, + useEffect, + useMemo, + useSyncExternalStore, + useState, +} from "react"; +import { + Button, + ConfigProvider, + Divider, + Layout, + Typography, + Result, + Space, + message, + theme as antdTheme, +} from "antd"; +import Announcements from "./components/common/Announcements"; +import TopNav from "./components/common/TopNav"; +import WelcomeOnboardingModal from "./components/modals/WelcomeOnboardingModal"; +import { navigateTo, parseRoute, subscribeToRouteChange } from "./lib/routing"; +import { apiService } from "./lib/services"; +import { + ensureUserId, + hasStoredUserId, + updateUserId, +} from "./lib/userIdentity"; +import { THEME_MODE_KEY } from "./lib/constants"; +import { getStoredValue, setStoredValue } from "./lib/storage"; +import AdminView from "./views/AdminView"; +import CreateOrderView from "./views/CreateOrderView"; +import HomeView from "./views/HomeView"; +import ParticipantView from "./views/ParticipantView"; + +const { defaultAlgorithm, darkAlgorithm } = antdTheme; +const { Content, Footer } = Layout; +const { Text, Link, Paragraph } = Typography; + +type ThemeMode = "light" | "dark"; + +type AppAnnouncement = { + message?: string; + title?: string; + type?: string; +}; + +const warmLightTokens = { + colorPrimary: "#c84f2a", + colorInfo: "#d97706", + colorSuccess: "#5f8f2d", + colorWarning: "#c87b16", + colorError: "#b63d26", + colorLink: "#a85a1c", + colorBgLayout: "#f8f1e6", + colorBgContainer: "#fffaf2", + colorBgElevated: "#fff6ea", + colorText: "#3c2c1f", + colorTextSecondary: "#6a5643", + colorBorder: "#e7d3ba", + borderRadius: 12, + wireframe: false, +}; + +const warmDarkTokens = { + colorPrimary: "#e28743", + colorInfo: "#f59e0b", + colorSuccess: "#9bc067", + colorWarning: "#e9ad4c", + colorError: "#df6a4f", + colorLink: "#f2a65a", + colorBgLayout: "#21160f", + colorBgContainer: "#2a1d14", + colorBgElevated: "#322419", + colorText: "#f4e8d7", + colorTextSecondary: "#d9c3ab", + colorBorder: "#5c422d", + borderRadius: 12, + wireframe: false, +}; + +const warmComponentTokens = { + Button: { + borderRadius: 999, + controlHeight: 40, + }, + Card: { + borderRadiusLG: 16, + headerBg: "transparent", + }, + Input: { + borderRadius: 10, + }, + InputNumber: { + borderRadius: 10, + }, + Select: { + borderRadius: 10, + }, + Layout: { + bodyBg: "transparent", + headerBg: "transparent", + siderBg: "transparent", + triggerBg: "transparent", + }, +}; + +function RouteOutlet({ isLocked }: { isLocked: boolean }) { + if (isLocked) { + return null; + } + + const pathname = useSyncExternalStore( + subscribeToRouteChange, + () => window.location.pathname, + () => "/", + ); + const route = useMemo(() => parseRoute(pathname), [pathname]); + + if (route.type === "home") { + return ; + } + + if (route.type === "create") { + return ; + } + + if (route.type === "order") { + return ; + } + + if (route.type === "admin") { + return ; + } + + return ( + { + navigateTo("/"); + }} + > + Back Home + + } + /> + ); +} + +const MemoTopNav = memo(TopNav); +const MemoAnnouncements = memo(Announcements); + +function AppContent({ + themeMode, + setThemeMode, + announcements, + isLocked, + userId, + userEmail, + onUserEmailChange, +}: { + themeMode: ThemeMode; + setThemeMode: (mode: ThemeMode) => void; + announcements: AppAnnouncement[]; + isLocked: boolean; + userId: string; + userEmail: string; + onUserEmailChange: (nextUserEmail: string) => void | Promise; +}) { + const appBackground = + themeMode === "dark" + ? "radial-gradient(1200px 600px at -10% -10%, #5b3118 0%, rgba(91, 49, 24, 0) 60%), radial-gradient(900px 480px at 110% 0%, #4b2b1a 0%, rgba(75, 43, 26, 0) 55%), #21160f" + : "radial-gradient(1200px 600px at -10% -10%, #f8d5a7 0%, rgba(248, 213, 167, 0) 60%), radial-gradient(900px 480px at 110% 0%, #f3c389 0%, rgba(243, 195, 137, 0) 55%), #f8f1e6"; + + return ( + + + + { + navigateTo("/"); + }} + userEmail={userEmail} + onUserEmailChange={onUserEmailChange} + /> + + + + + +
+
+ + + Feedback •{" "} + + Source Code + + +
+
+
+ ); +} + +function getInitialThemeMode(): ThemeMode { + const stored = getStoredValue(THEME_MODE_KEY); + if (stored === "light" || stored === "dark") { + return stored; + } + + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} + +export default function App() { + const [userId, setUserId] = useState(() => ensureUserId()); + const [isOnboardingOpen, setIsOnboardingOpen] = useState( + () => !hasStoredUserId(), + ); + const [themeMode, setThemeMode] = useState(() => + getInitialThemeMode(), + ); + const [userEmail, setUserEmail] = useState(""); + + const [announcements, setAnnouncements] = useState([]); + + useEffect(() => { + document.documentElement.setAttribute("data-theme-mode", themeMode); + setStoredValue(THEME_MODE_KEY, themeMode); + }, [themeMode]); + + useEffect(() => { + let mounted = true; + + async function loadUserProfileEmail() { + if (!userId) { + setUserEmail(""); + return; + } + + try { + const profile = await apiService.profile.getMine(); + if (mounted) { + setUserEmail( + String(profile?.email || "") + .trim() + .toLowerCase(), + ); + } + } catch (_error) { + if (mounted) { + setUserEmail(""); + } + } + } + + loadUserProfileEmail(); + + return () => { + mounted = false; + }; + }, [userId]); + + useEffect(() => { + let mounted = true; + + async function loadConfig() { + try { + const config = (await apiService.config.get()) as { + announcements?: AppAnnouncement[]; + }; + if (mounted) { + setAnnouncements(config.announcements || []); + } + } catch (error) { + // Silently fail if config cannot be loaded + console.error("Failed to load config:", error); + } + } + + loadConfig(); + return () => { + mounted = false; + }; + }, []); + + useEffect(() => { + let cancelled = false; + + async function handleConfirmationFromLink() { + const params = new URLSearchParams(window.location.search); + const token = String(params.get("token") || "").trim(); + if (!token) { + return; + } + + try { + const result = await apiService.account.confirm(token); + if (cancelled) { + return; + } + + if (result?.user_id) { + const normalized = updateUserId(result.user_id); + setUserId(normalized); + setIsOnboardingOpen(false); + } + + if (result?.action === "email_change_new_confirm") { + message.success("New email address confirmed and updated."); + } else { + message.success("Account confirmation succeeded."); + } + } catch (error: any) { + if (!cancelled) { + message.error(error?.message || "Could not confirm account action."); + } + } finally { + params.delete("token"); + const nextQuery = params.toString(); + const nextUrl = `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ""}${window.location.hash}`; + window.history.replaceState({}, "", nextUrl); + } + } + + void handleConfirmationFromLink(); + + return () => { + cancelled = true; + }; + }, []); + + const themeConfig = useMemo( + () => ({ + algorithm: themeMode === "dark" ? darkAlgorithm : defaultAlgorithm, + token: themeMode === "dark" ? warmDarkTokens : warmLightTokens, + components: warmComponentTokens, + }), + [themeMode], + ); + + const handleUserEmailChange = async (nextUserEmail: string) => { + await apiService.account.requestEmailChange(nextUserEmail); + message.info("Check your new email to confirm this change."); + return userEmail; + }; + + const completeCreateAccount = async (email: string) => { + try { + await apiService.account.register(email); + message.success("Registration email sent. Open the link in your inbox to finish setup."); + } catch (error: any) { + message.error(error?.message || "Could not create account."); + } + }; + + const checkAccountExists = async (email: string) => { + const result = await apiService.account.lookupByEmail(email); + return !!result.exists; + }; + + const completeMigrateAccount = async (email: string) => { + try { + await apiService.account.requestMigration(email); + message.success("Migration email sent. Open the link in your inbox to complete migration."); + } catch (error: any) { + message.error(error?.message || "Could not migrate account."); + } + }; + + const isAppLocked = isOnboardingOpen || !userId; + + return ( + + + + + ); +} diff --git a/src/frontend/src/components/TopNav.tsx b/src/frontend/src/components/TopNav.tsx new file mode 100644 index 0000000..397d6fc --- /dev/null +++ b/src/frontend/src/components/TopNav.tsx @@ -0,0 +1,69 @@ +import React, { useState } from "react"; +import { UserOutlined } from "@ant-design/icons"; +import { Button, Card, Flex, Popover, Space, Tooltip, Typography } from "antd"; +import AccountSettingsPopoverContent from "./AccountSettingsPopoverContent"; +import ThemeModeToggle from "./utils/ThemeModeToggle"; + +const { Text } = Typography; + +export default function TopNav({ + themeMode, + onThemeChange, + onHome, + userEmail, + onUserEmailChange, +}: { + themeMode: "light" | "dark"; + onThemeChange: (mode: "light" | "dark") => void; + onHome: () => void; + userEmail: string; + onUserEmailChange: (nextUserEmail: string) => void | Promise; +}) { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + return ( + + + + + + + + + } + > + + + + + + ); +} diff --git a/src/frontend/src/components/account/AccountSettingsPopoverContent.tsx b/src/frontend/src/components/account/AccountSettingsPopoverContent.tsx new file mode 100644 index 0000000..ae51d27 --- /dev/null +++ b/src/frontend/src/components/account/AccountSettingsPopoverContent.tsx @@ -0,0 +1,104 @@ +import React, { useEffect } from "react"; +import { CheckOutlined } from "@ant-design/icons"; +import { + Alert, + Button, + Flex, + Form, + Input, + Space, + Typography, + message, +} from "antd"; + +export default function AccountSettingsPopoverContent({ + userEmail, + onUserEmailChange, +}: { + userEmail: string; + onUserEmailChange: (nextUserEmail: string) => void | Promise; +}) { + const [form] = Form.useForm<{ email: string }>(); + const watchedEmail = Form.useWatch("email", form) || ""; + + useEffect(() => { + form.setFieldsValue({ email: userEmail }); + }, [form, userEmail]); + + const hasChanges = watchedEmail.trim().toLowerCase() !== userEmail; + + const saveAccountSettings = async () => { + try { + const values = await form.validateFields(); + + const nextEmail = values.email.trim().toLowerCase(); + + if (nextEmail !== userEmail) { + await onUserEmailChange(nextEmail); + } + + message.success("Account settings saved"); + } catch (error: any) { + if (error?.errorFields) { + return; + } + + message.error(error?.message || "Could not save account settings."); + // Validation errors are shown by Form.Item rules. + } + }; + + const discardAccountChanges = () => { + form.setFieldsValue({ email: userEmail }); + message.info("Changes discarded"); + }; + + return ( + + Account Settings + +
+ + + + + {watchedEmail.trim().toLowerCase() !== userEmail && ( + + )} + + + + + + +
+ ); +} diff --git a/src/frontend/src/components/common/Announcements.tsx b/src/frontend/src/components/common/Announcements.tsx new file mode 100644 index 0000000..ff7fd96 --- /dev/null +++ b/src/frontend/src/components/common/Announcements.tsx @@ -0,0 +1,90 @@ +import React, { memo, useMemo, useState } from "react"; +import { Alert, Space } from "antd"; + +const ALERT_TYPES = new Set(["success", "info", "warning", "error"]); + +type Announcement = { + type?: string; + title?: string; + message?: string; +}; + +function toAlertType(rawType: unknown) { + const normalized = String(rawType || "info").trim().toLowerCase(); + + if (ALERT_TYPES.has(normalized)) { + return normalized; + } + + if (normalized === "warn") { + return "warning"; + } + + if (normalized === "danger" || normalized === "critical") { + return "error"; + } + + return "info"; +} + +export default memo(function Announcements({ announcements }: { announcements?: Announcement[] }) { + const [closedIds, setClosedIds] = useState(() => new Set()); + + const normalizedAnnouncements = useMemo( + () => + (announcements || []) + .map((announcement, idx) => { + if (!announcement || typeof announcement !== "object") { + return null; + } + + const message = String(announcement.message || "").trim(); + if (!message) { + return null; + } + + const type = toAlertType(announcement.type); + const title = String(announcement.title || "").trim(); + + return { + id: `${type}-${title}-${message}-${idx}`, + type, + title, + message, + }; + }) + .filter(Boolean), + [announcements], + ); + + const visibleAnnouncements = useMemo( + () => normalizedAnnouncements.filter((announcement) => !closedIds.has((announcement as any).id)), + [closedIds, normalizedAnnouncements], + ); + + if (visibleAnnouncements.length === 0) { + return null; + } + + return ( + + {visibleAnnouncements.map((announcement: any) => ( + { + setClosedIds((prev) => { + const next = new Set(prev); + next.add(announcement.id); + return next; + }); + }} + /> + ))} + + ); +}); diff --git a/src/frontend/src/components/common/TopNav.tsx b/src/frontend/src/components/common/TopNav.tsx new file mode 100644 index 0000000..50affbc --- /dev/null +++ b/src/frontend/src/components/common/TopNav.tsx @@ -0,0 +1,69 @@ +import React, { useState } from "react"; +import { UserOutlined } from "@ant-design/icons"; +import { Button, Card, Flex, Popover, Space, Tooltip, Typography } from "antd"; +import AccountSettingsPopoverContent from "../account/AccountSettingsPopoverContent"; +import ThemeModeToggle from "../utils/ThemeModeToggle"; + +const { Text } = Typography; + +export default function TopNav({ + themeMode, + onThemeChange, + onHome, + userEmail, + onUserEmailChange, +}: { + themeMode: "light" | "dark"; + onThemeChange: (mode: "light" | "dark") => void; + onHome: () => void; + userEmail: string; + onUserEmailChange: (nextUserEmail: string) => void | Promise; +}) { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + return ( + + + + + + + + + } + > + + + )} + {showExport && ( + + )} + + + setIsExportModalOpen(false)} + onConfirm={handleExportJson} + onSelectedChange={setExportSelection} + /> + + ); +} \ No newline at end of file diff --git a/src/frontend/src/components/forms/OrderFormConfigBuilder.tsx b/src/frontend/src/components/forms/OrderFormConfigBuilder.tsx new file mode 100644 index 0000000..6413039 --- /dev/null +++ b/src/frontend/src/components/forms/OrderFormConfigBuilder.tsx @@ -0,0 +1,595 @@ +import React, { useEffect } from "react"; +import { + Button, + Collapse, + Form, + Input, + List, + Space, + Tooltip, + InputNumber, + message, + Popconfirm, + Checkbox, + Divider, + Typography, + Alert, +} from "antd"; +import { + buildExampleFormattedOrderString, + DEFAULT_CATEGORY_SNIPPET, + DEFAULT_MULTIPLE_SEPARATOR, + ORDER_FORMAT_VALUE_PLACEHOLDER, +} from "../../lib/orderFormatting"; +import type { + OrderFormItem, + OrderFormCategory, + OrderFormConfig, +} from "../../lib/types"; + +import { + PlusOutlined, + DeleteOutlined, + CopyOutlined, + CloseOutlined, + UpOutlined, + DownOutlined, +} from "@ant-design/icons"; +import MenuConfigImportExport from "./MenuConfigImportExport"; + +const { Title, Text } = Typography; + +interface OrderFormConfigBuilderProps { + config: OrderFormConfig; + onChange: (config: OrderFormConfig) => void; + onImportConfig?: (config: OrderFormConfig) => void | Promise; + editable?: boolean; + showFormatPreview?: boolean; + showImportExport?: boolean; + categoriesExpandedByDefault?: boolean; +} + +// Re-export types for backward compatibility +export type { OrderFormItem, OrderFormCategory, OrderFormConfig }; + +export default function OrderFormConfigBuilder({ + config, + onChange, + onImportConfig, + editable = true, + showFormatPreview = true, + showImportExport = editable, + categoriesExpandedByDefault = true, +}: OrderFormConfigBuilderProps) { + const [form] = Form.useForm(); + + useEffect(() => { + form.setFieldsValue({ categories: config.categories ?? [] }); + }, [config, form]); + + const handleValuesChange = (_: unknown, allValues: OrderFormConfig) => { + onChange({ categories: allValues.categories ?? [] }); + }; + + const createNewCategory = (): OrderFormCategory => { + const now = Date.now(); + return { + id: `cat_${now}`, + label: "New Category", + required: false, + multiple: false, + custom: false, + formattedSnippet: DEFAULT_CATEGORY_SNIPPET, + multipleSeparator: DEFAULT_MULTIPLE_SEPARATOR, + optionalFallback: "", + items: [], + }; + }; + + const createNewItem = (): OrderFormItem => { + return { + name: "", + price: undefined, + }; + }; + + const buildDuplicatedItem = (item?: OrderFormItem): OrderFormItem => { + const now = Date.now(); + return { + name: item?.name ? `${item.name}_copy` : `item_${now}`, + price: item?.price, + }; + }; + + return ( +
+ + {(categoryFields, categoryOps) => ( + + {showImportExport && ( + { + form.setFieldsValue({ categories: nextConfig.categories ?? [] }); + onChange({ categories: nextConfig.categories ?? [] }); + await onImportConfig?.(nextConfig); + } + : undefined + } + fileNameBase="order" + buttonType="default" + /> + )} + + {categoryFields.map((categoryField) => ( + + + event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + /> + + + ), + extra: editable ? ( + event.stopPropagation()} + > + + + )} + + )} + + + + ), + }, + ]} + /> + ))} + + + {editable && ( + + )} + + {showFormatPreview && ( + + {({ getFieldValue }) => { + const categories = getFieldValue("categories") || []; + const exampleOutput = buildExampleFormattedOrderString({ categories }); + + return ( + + {exampleOutput} + + } + type="info" + showIcon + /> + ); + }} + + )} + + )} + +
+ ); +} diff --git a/src/frontend/src/components/modals/ExportSelectionModal.tsx b/src/frontend/src/components/modals/ExportSelectionModal.tsx new file mode 100644 index 0000000..5582f24 --- /dev/null +++ b/src/frontend/src/components/modals/ExportSelectionModal.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import { Checkbox, Modal, Space, Typography } from "antd"; + +const { Text } = Typography; + +export type ExportSelectionState = Record; + +export type ExportSelectionOption = { + key: string; + label: string; +}; + +export default function ExportSelectionModal({ + open, + title, + description, + options, + selected, + okText = "Export", + onCancel, + onConfirm, + onSelectedChange, +}: { + open: boolean; + title: string; + description?: string; + options: ExportSelectionOption[]; + selected: ExportSelectionState; + okText?: string; + onCancel: () => void; + onConfirm: () => void; + onSelectedChange: (next: ExportSelectionState) => void; +}) { + return ( + + + {description ? {description} : null} + + {options.map((option) => ( + + onSelectedChange({ + ...selected, + [option.key]: event.target.checked, + }) + } + > + {option.label} + + ))} + + + ); +} diff --git a/src/frontend/src/components/modals/WelcomeOnboardingModal.tsx b/src/frontend/src/components/modals/WelcomeOnboardingModal.tsx new file mode 100644 index 0000000..2aa407f --- /dev/null +++ b/src/frontend/src/components/modals/WelcomeOnboardingModal.tsx @@ -0,0 +1,184 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Button, Flex, Form, Input, Modal, Space, Typography } from "antd"; +import ThemeModeToggle from "../utils/ThemeModeToggle"; + +type ThemeMode = "light" | "dark"; +type EmailLookupState = "idle" | "checking" | "exists" | "new"; + +export default function WelcomeOnboardingModal({ + open, + themeMode, + onThemeChange, + onCheckAccountExists, + onCreateAccount, + onMigrateAccount, +}: { + open: boolean; + themeMode: ThemeMode; + onThemeChange: (mode: ThemeMode) => void; + onCheckAccountExists: (email: string) => Promise; + onCreateAccount: (email: string) => void | Promise; + onMigrateAccount: (email: string) => void | Promise; +}) { + const [form] = Form.useForm<{ email: string }>(); + const [lookupState, setLookupState] = useState("idle"); + const [isSubmitting, setIsSubmitting] = useState(false); + const lookupTimerRef = useRef(null); + const lookupRequestIdRef = useRef(0); + + const normalizeEmail = (email: string) => email.trim().toLowerCase(); + + const isValidEmail = (email: string) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + + const resolveAccountExists = async (email: string) => { + const normalized = normalizeEmail(email); + if (!normalized || !isValidEmail(normalized)) { + setLookupState("idle"); + return null; + } + + const requestId = ++lookupRequestIdRef.current; + setLookupState("checking"); + + try { + const exists = await onCheckAccountExists(normalized); + if (requestId !== lookupRequestIdRef.current) { + return null; + } + + setLookupState(exists ? "exists" : "new"); + return exists; + } catch { + if (requestId === lookupRequestIdRef.current) { + setLookupState("idle"); + } + return null; + } + }; + + useEffect(() => { + if (!open) { + form.resetFields(); + setLookupState("idle"); + setIsSubmitting(false); + lookupRequestIdRef.current += 1; + if (lookupTimerRef.current) { + window.clearTimeout(lookupTimerRef.current); + lookupTimerRef.current = null; + } + } + }, [form, open]); + + useEffect( + () => () => { + if (lookupTimerRef.current) { + window.clearTimeout(lookupTimerRef.current); + } + }, + [], + ); + + const buttonLabel = useMemo(() => { + if (lookupState === "exists") { + return "Migrate account"; + } + if (lookupState === "checking") { + return "Checking account..."; + } + if (lookupState === "new") { + return "Create new account"; + } + return "Continue"; + }, [lookupState]); + + const helperText = useMemo(() => { + if (lookupState === "exists") { + return "An account was found for this email. We'll send a migration confirmation link."; + } + if (lookupState === "new") { + return "No account found for this email. We'll create a new account."; + } + return ""; + }, [lookupState]); + + return ( + + + + + Welcome to Lunchtime + + + + + + Enter your account email to continue. We'll automatically detect whether to create a new account or migrate an existing one. + + +
{ + const currentEmail = String(values.email || ""); + if (lookupTimerRef.current) { + window.clearTimeout(lookupTimerRef.current); + lookupTimerRef.current = null; + } + + lookupTimerRef.current = window.setTimeout(() => { + void resolveAccountExists(currentEmail); + }, 350); + }} + onFinish={async (values) => { + const normalizedEmail = normalizeEmail(values.email); + setIsSubmitting(true); + try { + let exists = lookupState === "exists"; + if (lookupState !== "exists" && lookupState !== "new") { + const checked = await resolveAccountExists(normalizedEmail); + exists = checked === true; + } + + if (exists) { + await onMigrateAccount(normalizedEmail); + } else { + await onCreateAccount(normalizedEmail); + } + } finally { + setIsSubmitting(false); + } + }} + > + + + + + +
+
+
+ ); +} diff --git a/src/frontend/src/components/orders/OrderDescriptionCard.tsx b/src/frontend/src/components/orders/OrderDescriptionCard.tsx new file mode 100644 index 0000000..c64b623 --- /dev/null +++ b/src/frontend/src/components/orders/OrderDescriptionCard.tsx @@ -0,0 +1,3 @@ +import ReadOnlyOrderOverviewCard from "./ReadOnlyOrderOverviewCard"; + +export default ReadOnlyOrderOverviewCard; diff --git a/src/frontend/src/components/orders/ReadOnlyOrderOverviewCard.tsx b/src/frontend/src/components/orders/ReadOnlyOrderOverviewCard.tsx new file mode 100644 index 0000000..96d5382 --- /dev/null +++ b/src/frontend/src/components/orders/ReadOnlyOrderOverviewCard.tsx @@ -0,0 +1,154 @@ +import React from "react"; +import { Card, Col, Empty, Flex, Image, Row, Space, Tabs, Typography } from "antd"; +import { markdownToHtml } from "../../lib/markdown"; +import MenuConfigImportExport from "../forms/MenuConfigImportExport"; +import OrderFormConfigBuilder, { + OrderFormConfig, +} from "../forms/OrderFormConfigBuilder"; + +type Order = { + id?: string; + title?: string; + description?: string | null; + image_url?: string | null; + created_at?: string | null; + creator_user_id?: string | null; + creator_email?: string | null; +}; + +function formatTimestamp(value: string | null | undefined): string { + if (!value) { + return "Unknown"; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return date.toLocaleString(); +} + +function getImageUrl(imageUrl: string | null | undefined): string { + if (!imageUrl) { + return ""; + } + + if (imageUrl.startsWith("http")) { + return imageUrl; + } + + return `/api/images/${imageUrl}`; +} + +export default function ReadOnlyOrderOverviewCard({ + order, + config, + onImportConfig, +}: { + order: Order | null; + config?: OrderFormConfig | null; + onImportConfig?: (config: OrderFormConfig) => void | Promise; +}) { + if (!order) { + return null; + } + + const currentDescription = order.description || ""; + const currentImageUrl = order.image_url || ""; + const currentConfig: OrderFormConfig = { + categories: Array.isArray(config?.categories) ? config.categories : [], + }; + const creatorLabel = + order.creator_email || order.creator_user_id || "Unknown"; + + return ( + + {order.title} + + by {creatorLabel} ({formatTimestamp(order.created_at)}) + + +
+ } + extra={ + + } + > + + + {!!currentDescription ? ( + + + + ) : ( + + )} + + {order.image_url && ( + + {currentImageUrl ? ( + {`${order.title} document.body, + zIndex: 3000, + }} + /> + ) : ( + + )} + + )} + + ), + }, + { + key: "menu", + label: "Menu", + children: ( + {}} + /> + ), + }, + ]} + > +
+ ); +} diff --git a/src/frontend/src/components/utils/AsyncContent.tsx b/src/frontend/src/components/utils/AsyncContent.tsx new file mode 100644 index 0000000..d485b8a --- /dev/null +++ b/src/frontend/src/components/utils/AsyncContent.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import ErrorResult from "./ErrorResult"; +import LoadingSkeleton from "./LoadingSkeleton"; + +export default function AsyncContent({ + loading, + error, + onRetry, + errorTitle = "Something went wrong", + errorSubtitle, + loadingRows = 4, + loadingSections = 2, + isEmpty = false, + emptyState = null, + children, +}: { + loading: boolean; + error?: string | null; + onRetry?: () => void; + errorTitle?: string; + errorSubtitle?: string; + loadingRows?: number; + loadingSections?: number; + isEmpty?: boolean; + emptyState?: React.ReactNode; + children?: React.ReactNode; +}) { + if (loading) { + return ; + } + + if (error) { + return ( + + ); + } + + if (isEmpty) { + return emptyState; + } + + return children; +} diff --git a/src/frontend/src/components/utils/ErrorResult.tsx b/src/frontend/src/components/utils/ErrorResult.tsx new file mode 100644 index 0000000..669a25a --- /dev/null +++ b/src/frontend/src/components/utils/ErrorResult.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { Button, Result } from "antd"; + +export default function ErrorResult({ + title = "Something went wrong", + subtitle = "Please try again in a moment.", + onRetry, +}: { + title?: string; + subtitle?: string; + onRetry?: () => void; +}) { + return ( + + Try Again + + ) : null + } + /> + ); +} diff --git a/src/frontend/src/components/utils/LoadingSkeleton.tsx b/src/frontend/src/components/utils/LoadingSkeleton.tsx new file mode 100644 index 0000000..70e1e03 --- /dev/null +++ b/src/frontend/src/components/utils/LoadingSkeleton.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import { Card, Skeleton, Space } from "antd"; + +export default function LoadingSkeleton({ rows = 4, sections = 2 }: { rows?: number; sections?: number }) { + return ( + + {Array.from({ length: sections }).map((_, index) => ( + + + + ))} + + ); +} diff --git a/src/frontend/src/components/utils/ThemeModeToggle.tsx b/src/frontend/src/components/utils/ThemeModeToggle.tsx new file mode 100644 index 0000000..5d02164 --- /dev/null +++ b/src/frontend/src/components/utils/ThemeModeToggle.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { MoonOutlined, SunOutlined } from "@ant-design/icons"; +import { Button, Tooltip } from "antd"; + +type ThemeMode = "light" | "dark"; + +export default function ThemeModeToggle({ + themeMode, + onThemeChange, +}: { + themeMode: ThemeMode; + onThemeChange: (mode: ThemeMode) => void; +}) { + return ( + + , + ] + : [ + + + , + ] + } + > + Order availability + ) : ( + Order deletion + ) + } + description={ + item.key === "availability" ? ( + + {item.isClosed + ? "Order is currently closed to new submissions." + : "Order is currently open for participant submissions."} + + ) : ( + + Deleting an order is permanent and cannot be undone. + + ) + } + /> + + )} + /> +
+ ); +} diff --git a/src/frontend/src/components/views/AdminParticipantShareAlert.tsx b/src/frontend/src/components/views/AdminParticipantShareAlert.tsx new file mode 100644 index 0000000..0573d45 --- /dev/null +++ b/src/frontend/src/components/views/AdminParticipantShareAlert.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { Alert, Typography } from "antd"; +import { navigateTo } from "../../lib/routing"; + +const { Text, Link } = Typography; + +export default function AdminParticipantShareAlert({ + orderId, +}: { + orderId: string; +}) { + const participantUrl = `${window.location.origin}/order/${orderId}`; + + return ( + + Participant page:{" "} + Share{" "} + { + event.preventDefault(); + navigateTo(`/order/${orderId}`); + }} + > + this page + {" "} + so people can submit their order:{" "} + + {participantUrl} + + + } + /> + ); +} diff --git a/src/frontend/src/components/views/AdminSubmissionsCard.tsx b/src/frontend/src/components/views/AdminSubmissionsCard.tsx new file mode 100644 index 0000000..49e21a6 --- /dev/null +++ b/src/frontend/src/components/views/AdminSubmissionsCard.tsx @@ -0,0 +1,243 @@ +import React from "react"; +import { CopyToClipboard } from "react-copy-to-clipboard"; +import { + CopyOutlined, + DeleteOutlined, + ReloadOutlined, +} from "@ant-design/icons"; +import { + Button, + Card, + Flex, + Input, + Pagination, + Popconfirm, + Select, + Space, + Switch, + Table, + Tooltip, + Typography, +} from "antd"; +import { formatEstimatedTotal, stripPriceDecorations } from "../../lib/orderFormatting"; + +const { Text } = Typography; + +function getEstimatedTotalText(rawValue: unknown): string | null { + if (typeof rawValue === "number" && Number.isFinite(rawValue)) { + return formatEstimatedTotal(rawValue); + } + + if (typeof rawValue === "string") { + const parsed = Number(rawValue); + if (Number.isFinite(parsed)) { + return formatEstimatedTotal(parsed); + } + } + + return null; +} + +export default function AdminSubmissionsCard({ + searchQuery, + selectedSubmissionState, + onSearchQueryChange, + onSelectedSubmissionStateChange, + onReload, + copyText, + onCopy, + selectedRowKeys, + onSelectedRowKeysChange, + pagedSubmissions, + deletingId, + savingStatusKey, + onDeleteSubmission, + onUpdateSubmissionStatus, + totalEstimatedText, + selectedCount, + submissionsPage, + pageSize, + totalCount, + onSubmissionsPageChange, +}: { + searchQuery: string; + selectedSubmissionState: "all" | "pending" | "unpaid" | "paid"; + onSearchQueryChange: (value: string) => void; + onSelectedSubmissionStateChange: (value: "all" | "pending" | "unpaid" | "paid") => void; + onReload: () => void; + copyText: string; + onCopy: (text: string, didCopy: boolean) => void; + selectedRowKeys: Array; + onSelectedRowKeysChange: (keys: Array) => void; + pagedSubmissions: any[]; + deletingId: string | number | null; + savingStatusKey: string | null; + onDeleteSubmission: (id: string | number) => void; + onUpdateSubmissionStatus: (submission: any, changes: { accepted?: boolean; paid?: boolean }) => void; + totalEstimatedText: string | null; + selectedCount: number; + submissionsPage: number; + pageSize: number; + totalCount: number; + onSubmissionsPageChange: (page: number) => void; +}) { + const columns = [ + { + title: "Submission", + dataIndex: "formatted_string", + key: "submission", + render: (value: string, record: any) => { + const cleanValue = stripPriceDecorations(String(value || "")).trim(); + const estimateText = getEstimatedTotalText(record.estimated_total); + const emailText = String(record.email || "").trim() || "-"; + const secondaryText = `${emailText} • ${estimateText || "-"}`; + + return ( + + {cleanValue || "-"} + + {secondaryText} + + + ); + }, + }, + { + title: "Accepted", + key: "accepted", + width: 120, + render: (_: any, record: any) => { + const isSaving = savingStatusKey === `${record.id}:accepted`; + return ( + onUpdateSubmissionStatus(record, { accepted: checked })} + /> + ); + }, + }, + { + title: "Paid", + key: "paid", + width: 100, + render: (_: any, record: any) => { + const isSaving = savingStatusKey === `${record.id}:paid`; + return ( + onUpdateSubmissionStatus(record, { paid: checked })} + /> + ); + }, + }, + { + title: "Actions", + key: "actions", + width: 1, + render: (_: any, record: any) => ( + onDeleteSubmission(record.id)} + > + + + ), + }, + ]; + + return ( + + 0 + ? "Copy selected submissions as list to clipboard" + : "Copy submissions as list to clipboard" + } + > + + + + + +