From 2e84c75035b11e173ec09d53542b7d7cfe88ad99 Mon Sep 17 00:00:00 2001 From: Simon Gruber Date: Sun, 29 Mar 2026 14:59:03 +0200 Subject: [PATCH] init --- .gitea/workflows/build-and-push-images.yaml | 80 + .gitignore | 10 + README.md | 62 + TODO.md | 10 + config.yaml | 17 + nginx.conf | 33 + src/.gitignore | 0 src/backend/.dockerignore | 11 + src/backend/Containerfile | 22 + src/backend/app/__init__.py | 0 src/backend/app/db.py | 147 + src/backend/app/main.py | 1140 +++++++ src/backend/app/services/__init__.py | 0 src/backend/app/services/account_service.py | 227 ++ src/backend/app/services/common_service.py | 51 + src/backend/app/services/config_service.py | 83 + src/backend/app/services/email_service.py | 152 + .../app/services/order_file_service.py | 123 + src/backend/app/services/order_service.py | 23 + .../app/services/submission_service.py | 178 + src/backend/create-order-config.json | 3 + src/backend/requirements.txt | 5 + src/compose.yml | 47 + src/frontend/.dockerignore | 2 + src/frontend/.gitignore | 2 + src/frontend/Containerfile | 24 + src/frontend/index.html | 17 + src/frontend/package-lock.json | 2992 +++++++++++++++++ src/frontend/package.json | 27 + .../public/android-chrome-192x192.png | Bin 0 -> 16142 bytes .../public/android-chrome-512x512.png | Bin 0 -> 51854 bytes src/frontend/public/apple-touch-icon.png | Bin 0 -> 14518 bytes src/frontend/public/favicon-16x16.png | Bin 0 -> 773 bytes src/frontend/public/favicon-32x32.png | Bin 0 -> 1850 bytes src/frontend/public/favicon.ico | Bin 0 -> 15406 bytes src/frontend/public/site.webmanifest | 1 + src/frontend/src/App.tsx | 420 +++ src/frontend/src/components/TopNav.tsx | 69 + .../src/components/WelcomeOnboardingModal.tsx | 184 + .../account/AccountSettingsPopoverContent.tsx | 104 + .../src/components/common/Announcements.tsx | 90 + src/frontend/src/components/common/TopNav.tsx | 69 + .../forms/MenuConfigImportExport.tsx | 174 + .../forms/OrderFormConfigBuilder.tsx | 595 ++++ .../modals/ExportSelectionModal.tsx | 62 + .../modals/WelcomeOnboardingModal.tsx | 184 + .../orders/OrderDescriptionCard.tsx | 3 + .../orders/ReadOnlyOrderOverviewCard.tsx | 154 + .../src/components/utils/AsyncContent.tsx | 47 + .../src/components/utils/ErrorResult.tsx | 27 + .../src/components/utils/LoadingSkeleton.tsx | 14 + .../src/components/utils/ThemeModeToggle.tsx | 27 + .../views/AdminControlCenterCard.tsx | 93 + .../views/AdminParticipantShareAlert.tsx | 39 + .../components/views/AdminSubmissionsCard.tsx | 243 ++ .../views/CreateOrderHeaderActions.tsx | 42 + .../components/views/HomeOrdersFilters.tsx | 75 + .../src/components/views/HomeOrdersTable.tsx | 138 + .../views/ParticipantCategoryFields.tsx | 78 + .../views/ParticipantDraftSummaryAlert.tsx | 51 + .../views/ParticipantSubmissionSummary.tsx | 82 + src/frontend/src/hooks/useApiRequest.ts | 73 + src/frontend/src/lib/api.ts | 54 + src/frontend/src/lib/apiService.ts | 1 + src/frontend/src/lib/constants.ts | 2 + src/frontend/src/lib/markdown.ts | 10 + src/frontend/src/lib/orderFormatting.ts | 212 ++ src/frontend/src/lib/routing.ts | 61 + .../src/lib/services/accountService.ts | 29 + .../src/lib/services/configService.ts | 6 + src/frontend/src/lib/services/index.ts | 13 + .../src/lib/services/ordersService.ts | 73 + .../src/lib/services/profileService.ts | 11 + .../src/lib/services/submissionsService.ts | 43 + src/frontend/src/lib/storage.ts | 78 + src/frontend/src/lib/types.ts | 227 ++ src/frontend/src/lib/userIdentity.ts | 74 + src/frontend/src/main.tsx | 17 + src/frontend/src/styles.css | 45 + src/frontend/src/views/AdminView.tsx | 365 ++ src/frontend/src/views/CreateOrderView.tsx | 495 +++ src/frontend/src/views/HomeView.tsx | 111 + src/frontend/src/views/ParticipantView.tsx | 546 +++ src/frontend/src/vite-env.d.ts | 1 + src/frontend/tsconfig.json | 18 + src/frontend/tsconfig.node.json | 9 + src/frontend/vite.config.ts | 6 + 87 files changed, 11133 insertions(+) create mode 100644 .gitea/workflows/build-and-push-images.yaml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 TODO.md create mode 100644 config.yaml create mode 100644 nginx.conf create mode 100644 src/.gitignore create mode 100644 src/backend/.dockerignore create mode 100644 src/backend/Containerfile create mode 100644 src/backend/app/__init__.py create mode 100644 src/backend/app/db.py create mode 100644 src/backend/app/main.py create mode 100644 src/backend/app/services/__init__.py create mode 100644 src/backend/app/services/account_service.py create mode 100644 src/backend/app/services/common_service.py create mode 100644 src/backend/app/services/config_service.py create mode 100644 src/backend/app/services/email_service.py create mode 100644 src/backend/app/services/order_file_service.py create mode 100644 src/backend/app/services/order_service.py create mode 100644 src/backend/app/services/submission_service.py create mode 100644 src/backend/create-order-config.json create mode 100644 src/backend/requirements.txt create mode 100644 src/compose.yml create mode 100644 src/frontend/.dockerignore create mode 100644 src/frontend/.gitignore create mode 100644 src/frontend/Containerfile create mode 100644 src/frontend/index.html create mode 100644 src/frontend/package-lock.json create mode 100644 src/frontend/package.json create mode 100644 src/frontend/public/android-chrome-192x192.png create mode 100644 src/frontend/public/android-chrome-512x512.png create mode 100644 src/frontend/public/apple-touch-icon.png create mode 100644 src/frontend/public/favicon-16x16.png create mode 100644 src/frontend/public/favicon-32x32.png create mode 100644 src/frontend/public/favicon.ico create mode 100644 src/frontend/public/site.webmanifest create mode 100644 src/frontend/src/App.tsx create mode 100644 src/frontend/src/components/TopNav.tsx create mode 100644 src/frontend/src/components/WelcomeOnboardingModal.tsx create mode 100644 src/frontend/src/components/account/AccountSettingsPopoverContent.tsx create mode 100644 src/frontend/src/components/common/Announcements.tsx create mode 100644 src/frontend/src/components/common/TopNav.tsx create mode 100644 src/frontend/src/components/forms/MenuConfigImportExport.tsx create mode 100644 src/frontend/src/components/forms/OrderFormConfigBuilder.tsx create mode 100644 src/frontend/src/components/modals/ExportSelectionModal.tsx create mode 100644 src/frontend/src/components/modals/WelcomeOnboardingModal.tsx create mode 100644 src/frontend/src/components/orders/OrderDescriptionCard.tsx create mode 100644 src/frontend/src/components/orders/ReadOnlyOrderOverviewCard.tsx create mode 100644 src/frontend/src/components/utils/AsyncContent.tsx create mode 100644 src/frontend/src/components/utils/ErrorResult.tsx create mode 100644 src/frontend/src/components/utils/LoadingSkeleton.tsx create mode 100644 src/frontend/src/components/utils/ThemeModeToggle.tsx create mode 100644 src/frontend/src/components/views/AdminControlCenterCard.tsx create mode 100644 src/frontend/src/components/views/AdminParticipantShareAlert.tsx create mode 100644 src/frontend/src/components/views/AdminSubmissionsCard.tsx create mode 100644 src/frontend/src/components/views/CreateOrderHeaderActions.tsx create mode 100644 src/frontend/src/components/views/HomeOrdersFilters.tsx create mode 100644 src/frontend/src/components/views/HomeOrdersTable.tsx create mode 100644 src/frontend/src/components/views/ParticipantCategoryFields.tsx create mode 100644 src/frontend/src/components/views/ParticipantDraftSummaryAlert.tsx create mode 100644 src/frontend/src/components/views/ParticipantSubmissionSummary.tsx create mode 100644 src/frontend/src/hooks/useApiRequest.ts create mode 100644 src/frontend/src/lib/api.ts create mode 100644 src/frontend/src/lib/apiService.ts create mode 100644 src/frontend/src/lib/constants.ts create mode 100644 src/frontend/src/lib/markdown.ts create mode 100644 src/frontend/src/lib/orderFormatting.ts create mode 100644 src/frontend/src/lib/routing.ts create mode 100644 src/frontend/src/lib/services/accountService.ts create mode 100644 src/frontend/src/lib/services/configService.ts create mode 100644 src/frontend/src/lib/services/index.ts create mode 100644 src/frontend/src/lib/services/ordersService.ts create mode 100644 src/frontend/src/lib/services/profileService.ts create mode 100644 src/frontend/src/lib/services/submissionsService.ts create mode 100644 src/frontend/src/lib/storage.ts create mode 100644 src/frontend/src/lib/types.ts create mode 100644 src/frontend/src/lib/userIdentity.ts create mode 100644 src/frontend/src/main.tsx create mode 100644 src/frontend/src/styles.css create mode 100644 src/frontend/src/views/AdminView.tsx create mode 100644 src/frontend/src/views/CreateOrderView.tsx create mode 100644 src/frontend/src/views/HomeView.tsx create mode 100644 src/frontend/src/views/ParticipantView.tsx create mode 100644 src/frontend/src/vite-env.d.ts create mode 100644 src/frontend/tsconfig.json create mode 100644 src/frontend/tsconfig.node.json create mode 100644 src/frontend/vite.config.ts 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 0000000000000000000000000000000000000000..e1a42f157f4f25f6dc35fac944c49d688d9d012a GIT binary patch literal 16142 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 literal 0 HcmV?d00001 diff --git a/src/frontend/public/apple-touch-icon.png b/src/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..92cc6400d8ca9fa9ffef595273050206bb12520a GIT binary patch literal 14518 zcmV;nI7!EeP)0n{i$5|RJ|#x|~J+=i5XiOIbIYlPX2yp2^B<)#%GW-~WA!rx`?$yuR z`ws^W25|2H@S8D|0~(SKNF?v=5%iS{mOtJcf{{h+Ekd&}VV?d6ADFe%j*&?cDa-B< zz-_5WkcD}oD9-~RW?*XbsM*Umd$3ddeMN4O9et9pW6u3QZo(;1YfXttK+BMctE%)f1)cp~ zC~n`t0_64^3u4U=3NVO|lOX)o;dH*#R7c06QRa>s5L7?&jKLP)UaA%8V(^daApI`F z0)P|8S|BL^jt#4%)C^#Mf^YBiIptj^EG?Bc{L8bDP9ncWJfP(b8+3;>%@+!S^IxFg zTndu1QrGXOh_zr-fI-}bx8Fxjr~IFdGwF-PizC}o5|MxgixBqxZcV1aPK!hmzaJvrphiykny@X*3MrV+P~I6&9GY|iMi zXzqfU=`M_RE{^0#BU(_$1C+!iCs0yuIvEm;3ZNy2lX^~fQs;?ga@yfg3}XXe+F&X9 z3d4H2&!BsC?l*q~4jwrzA^^=s)7qDwNa^meU#-&$EAjubiKyg>S`DQYOrg~2li)ue zxiq~kUDUAmC^@VV;q7KG=6)#UD>Vk^hcoXsGY1bl#|}H7>o=I=JGA0bJn{EnFf1c$ zBIkJz(MT|HW2%WVigGC~H9_IZqqWZZ7HZsg9KNEkiy+Dn;hA@cB5i|#`*#T)iLd27 z{IDfNRC9Zqumbw<=CyO2GCc#LTmq|wHk^bA=e-R{@sxdT0qKkeRl_*>MGf^dAyz4Z z8f0sBt8Wt=^7o1#G*^UcXkh_#&F{=Joi=d=K)f96DAS){9|7z^pI{(u91I_rjib*_ z_MSdP?u>?}H^tGE`2~u+thTisZKTr`bs$^V-|)kS%mb>&nm#X)^M#US=3{E-6sUe7 z1@spi9?&Jn<=rYd<)1*>D)W~si#jc3&dQ~P%p_O@AgK%}P<`z^)QknU^;iQ%r=VO% z)F4`F_EbvB@y(pxS>Fn=<0!+>C=^e~p-+nBZAc4VDVuW+`w6!+FV=G2Y*jw#lGfe_4~fYP6fvZ#@>aAyD8#fR2< zq=8OX)S?R>(yNi8Q>=fWn6$M`L=p) zCdd|m3~=VuX_GuopE)#J1+3uMk=WwXlN8xmdAq1=%$4gtt0GHlm&!B3-i@6zpMa;W zFP`rFSO~IXCa8cuYBtB&r;3QTp!EQ!1a9&ONV)SpM|Z7}Ry2Rz-@bYsheQ zko~jIok<3hO8FVDx{kWj)O4UW66I&lOCeAbWd6sTwFh1*Gj9!=-7#o@u6TakbR;D` z3bFQ^m}#On;Q8`9D^PhC&Qi_U+H0Gs`Opc);}%g3#9{fLZO<%zuuMH#ULb2DWZ%AQWv*E88R*q+b-?ZHCMg_!bmES8RMLo2YLyXmKTm ztp0tfa(;#+0D6&nKr;a9ch^L$=)od~5%%YVNvULnPQc18CnmDQ$)3T!&R%#4Pm`s+ zJLrm_24l%6%_GyKlz|5f{%zR2Ev?{IM;210CC*v&!-)IWN;&*E7FcpctO`A8A742hq z+jaX%B4l+Eav!zwcZQg^2cINbe0D8-oC7QC1ITvX(^m|+|KTw17bRFg_~qKU1LzE&X`PzQ&K%Eh!_~hK(Glf*4$G-QM6ev(Wy{* zK+g_D{xt zYwHojl(5G#APt0NSE>r%>%tz?3g@vIvJAVRh{40)l0{>m#gaQLnp|Eg4tnm{>NBqh4>6; z#n#+&XgR6|JcB{1Gz9xFoha+dKu*fdfX6RcQK;CX$FU>qqK-zHT@R5hZRQk8%ua(Y z;@OSRc``_fwjh8zOW!FvF2Cff<-=z{S8e|3JY;Ncg~U8F%CHyk;c_g=q&d@+h`-@k zCVgEN#j3(yzG!Pa7xu<-SStrmu}M!+wAnlgNtbn#zRn(MI(Q;@A-b|$E=cw}i|$|V zlk>nQK%e;e_l=g6)E6P>+<`?3iWe|i7GDEy%wT5A%4p5rYbDMu;W&t>YydF3!nH;n zY4n2>Z5xs(IJP6Wu`v$Z{`V)rsTVIZt0tgoKy%rxZ{}+r#Pj+8g5}bVrbXvh@?^M) zy}gz>yQI0|LBz4xC37h9 z(uY(}i|3HHs2HM#`n0&fUwUN^neI<(x$wtawR6@wa@VflgjvlIq*b(fI zPp<=s(heM*2kyMCc=@WeYNntH(EES8u0SWt@54&ybDRci{+wCDwI+sVX0SDYbN`|! z`$C~Zt+)`>D^?d(s!D&0+n;Fj|pbwhOdTqX724nt_uXAPbV#@p(io?NMvm0Dpu9G{*@*$WFj=E=FP$&p(pKd+A16sf^;XQu;~PcTj9s=n?UD}W7O9YtmbN+`ry zz$gGIVW+Is-db|c<1M3I)FYq|ZCZB|V%GjYPWEn}8nWuBL_RYc#}rk;_f8n-ySbfOYZ_u=MsOCgLnF(#&s?z@;mYpYsDS2h(5lVrInni7 zzJ`Xfvmtu{#Eq=cx!B<0u(gA0O+-uiF1FmTmoO1Ytqf|;`Ri%!r1oQt%3CJL9<<;< zB|5Iky9@ks)(Z3YeFpU57oSAnzU?!d=((DUn=7;Y%w-1D)N5l9bBo1_PV@E zyaS@rGQF^h-FyyZ2z&WtoFb zyQB;;0G^8^F_>FwPEkAD!x30`Fa)a;yTuqNfQ^6UTTAa<{TJ1M=Aw#;`n+EOW%rNa zhKiXk|DstQ%M7rGu&Mkwb)Q1*k(iBkEM$(FbIwHS@rVba_52w;@ba_b6Y^v3LQCCp z=t^u0L4D%u;LsP3<;FaLtg@+;j5y)pij&>aM?H=0)Lwf!rZ|m-xY2SBL^2L@NXSSg zW4ej-@o`EBD%<4Q+2m&~;`@Ep-aF@h_sJ1*s#`!;Z!(ukBr;2%Hdgv+Q4A)%a0=pS z`>TL)krI}Zdzw0wxQ1wOqKtL!6a72^^(->5_KDahMoC&8R%W87xr6Ev4lvfzY=~{E z)`-IOh4-#JJe<~U0bQ|i&0=h!_9{*sJ=j;xstxSq#B5r2I%6PKL$fIE!@xDBB~nIl z4mLhQ8rLZGQdohmMbRHW_KIl&aBnGEKC(cKdy!+)ntPC9wE;L(w?{%0FG6hSfujIN zQ*xPOuHEgavHnzxo=HfzPIEvh&)EF6zPeqN8xzK4nijy|y~MG*zK zHVVgxt0~^joVlMvkImEx$-%CXh3^-xSjjtW$+_d(O=(seHbB*X@tFRuDE8FI#z^T0pafo|c3Irda!S?PtiEHkTRTg5uHM*kQp%;D^i&76dpC!uOs@3!MJG4)&&mJW&7{Yxmge=Qy;PzbFT>nuZ@FdV*P=5C&Re-MAwEnNC1bKC&0Megva6uT(FyRGv*X%GMaWHHNGmGY?)*UU{R0{A4(a8^k`=4&Q3dF#O>2IQjS?RW!Tf`I3l5Iq zn#WxG*jwYEDc;X88uA)>F1&j|{zWCKD(?4nTEMBRF}DyGd}l95aQ2Rc2u)8EuUPeC zRe-MExb`t@Qxx@{Lgp0NNYqueQ!8Y=wqs3^x+(mK=aG|-bIwz_0|?KJOOlPBate0r zWbsJRvenP30(9l(_5XxOqUX@r=h6koTJdgsewhyR=%bR(1$jzy%6H(8|bWl-!!$94Pien37dm0l+}nF zu3@zv9@rQ@gyS5`t@=d{-sDGpy?JF@<^9L~^U#fDuz@~Dppy=#{pPa!)_ti8(B;2b zKUFKre}s0~z6V!oDPf6Jb^?8JAF&K*z%xh}nbjqult2n7?T=b@P zXL(dCIy+LbQ6q#4uB$21)*t0o;kTDg4IP8o2b+I9XE!+>a<^s0=t;7_L6WQ}{2pp* z)Q6CA_Bq$m;Iaye^I^2c#{UKw|D&WV8Bc@fmcny`zTi!?>OscPDcIaY=0OYss<|2| zf|~9qxo`EG?k+z%pf^07K|0G00J=B`;|lpD756Z_YDqa6%Es(mm0m=ukgLf~8MQUq z;hL=tnDIYUljOOvOw9!(`JrXc=-}d*?AYjm9hlwDV~s%$)m*&ZM-V>ivgxi__~2t5 zJ^`9LBc|i~kfWaj>OH3*cpXJ#bx`&*9Ej^+~7b zssMdx*5%U-M*SXV$)r^2N^BtOAzabh4P(E@D4?1Gu+V7UtstT1bdTO{pO<&=!y0er zsseP??5j#N0_}mLB~F?%nWRP2h~i_M+8c3WzxzRf9&qLzM@gta)m&M&%OozCx$mzB zyj@WhppVYGe4ZrfcH_z-!Gn+bqP#&+Xr7t&PX8Xib9enBejV)HjlL!1^lG|z$?^ul4e^q zQQOcwPn#fITeA0WfAw|?RY*KG_nIr5ByAgUu_WvYTRMrP*^{vgORNl@H)6+r_dtQJ zJ`(rUlX$3+sM}3ujUeA#uy@;^z0pt=ppTYad7Y#Y-X85@a+ZmtOJ|Uri!Iz@3Zplg z$9^A70p`lbTS?q`jD*uYqtiyBe=Iog-iUzy;U}m9^zqr(+~g1?x6w^!E~wX#G$S3M zOqnE40kGrL2cE}Pcmh%Y$-6>bhoba}$5FQ&88x073Lh<*v2WWe-YBIC(ADQ$b*(7V zJKip)xM>icDP<;*JU5%?4;2MO&K z5^CE>sA^WepRr!3n<|3xrK_gxd;fiJ-Boeu!(~_H3mV#kp{0Af;E3O00g>6em||39 zY&i!dPdh6oJ+ z@ria4>)MGrtpOT_uv_2SeQ93ZF0We+tK!f+s>5@y#0K`l8hj0$i;%$!kE0CpAK{SG z;vuF$G=T|FP5{W6i)$I^>Lr~3Q80Fy;S^vwS4Lq=2fkRqeY^W$Uh9MSU?rgi)`?Dh zX8_|jE)5#M=D_N&pT6%s#-7)v8qj+$UR-RnX#T2kP`=lzj9t9 z#j}%0hS;JgnF=?@G-D2(u~ORedCYOK-Lt}V`&j;klcTQKl= zfEwIePJ}au9Xb$MWL7wlPDR+3nRNV#19jG99LjeFKiu}pp?n`a^+DggQp7t^L)v4= zQVjYJgHaI$7??QtJ4y!&HdqzdvcP48m81W=!26v*WMHR;04E3W|1AJB$_|IC+2RD+ zb)B)fdcNUD)=Bj8FD}~ZpUAOf%MwjNoNycR3V)7CHES2YERT< zpbj|@cXEcC4{(2%oX&rD0iGWK1?U400{3w}1pLYNNdDl5Wl)n9aVWq~06QA^wLZ8p z=#((=Q{k>I`BwSF>F5K-)ZbU(!h?=*p>~w}+QF7TzsgAE z7Z^y=5MB-P8=_Wt_=y{~HhUWCsH^e7n>WWN%W+FZ5`Kn@VN2NlCW5wecUWoOb}Q+v z5eVuCTbS5ze5ZijAke;x4fNHeapcsUg(%SWXW1!!?`OCF@ksyEJ+iq4^bfZz0hWay ziGutcJlsjcPt)Kj&#$o4!fjScZSw5l@qIe6GbSzCR;D8-zX69fe!qMs zIOx%*Zr{3-_j4WIVZ<2^y>-1I-jVPmHs1VaoZ!)-k<{v>%UUZIPDR zvb5mHV6nb%_d$KT>*-rw;d@0i3t{rRRkOIg&CAK5mz>OQA=pCJ{qf=9LWK9S-w3vE*vLLSb_nZIt(pe5| zhBt7*foo-1_%?hDm%}!a6bxpnfx{ra3o^}VCuP++phP+;t-(nNo$%Piz#Y}lmH^`3 zM3Gu2Yp6L#Lru9_ViR0%qTtT_=J#CLJZ%)IqeqgT>C*H({p<~IE8Bbxd1PcLhKZj?b86XZ47gh$$U`^7ZGGO$#I#m=K_{ap1u-EJxxd&?DQsKNmzcm8T z@CXXD`{wTeF8_&+XUuk}@Ilpm8hWEcOC8Rj$UyE_2lv%Ls235MfCxX*0nF^kVMQ~m zuK$xo6@Cb`_&p|%`v-ECdmV`HV(*-VMF)VjDNjRflQh(krcvy}PW;XQcXAv|h@ik= zo(~{jo9LiK0D0JL*`4=eI`_?ghpAaX`I+Bk1R~K4%YWcc|1w+@;f|G7kzVi6QClcM z?PZM^SCLvz3KqcMgN)2ZBHO?@C|phx6P5-}NB)I3|C(4`DmQfXdvI!N*4*ED9Ew!q~2B z#V1=B*yh^@&B=kVa<)1s2m12=9XOW*e*$AV^Jj+c&t=ZMD8WfrgENl;=Kd1}dXwVf z?i9+lD#bj80-C10j6dD!X?Rrt^=klidjxUnK$DX>x!x*~0m`WkhXIKg3YTXY9#6LN zpy2III)#M-98P4w+Oj!~F~~S! z!-5rc_kD?5%hbwrVS+;uHB^9^WvgwJ;<#}HpqUEtXE)xBAot(!*t}7N2YlKg&=;_R z)BxBGu!y)!$sF~U4gr3WR-zm|yfx5J?VXB-000*R^`9Q2iGzEBt^7N8oLYs~?PQzL=sE4mFgSi{)Sq~;y2aKGw3)BSM^bT6% z1e`Qo@1!!L1SsTeSm>?z&07qgLU2Xtz(4#n zGNkF@(Am_!|mnguXF| z`6>Q0Wc6xr?O2Np3g`j68(?u@Gn$Gh&tMLkEgFZwPB&7Q_61?_>!_K~-`p}#nJpfU z+`{c2)c-Oy({R<4oOnu1(h-Vt1apuhuH6DqvsL97Kppdphm`aqZf3$;_r_$0qO1iv zjndQ1I33c`>!9u~2c0_6OD(5uyRNt)=OIdvs)SkpgIvR*&7(x{?HTF+rH|8mCpBG9 zQ)ifHW@!=?mLyT;q&U(H$(jxxu^H5VYS&Q>WUyEZ4+U5(xFN|-7l1Q|XxXW&!%nqF zd+2awCmlcBO%3%{>TI`>!zTaX*GEo|x(B^SKtC`&Z7nt=s1Mbmu)o`e=a000d$enNM96*9(;vr#e(LUI=Fll=dYWF8NYiJU zXa+tf=NQQtuMIf3S==aRPbD~Y4Ag32;(tE|^BgFEccj>ZA~Rch9MpKqN=K@@6fS(M zx|>>0_mRyux_}8?@~P*Kv^+K@K;J(t6Doj zhYi#N$5Ht?{iH`j?{w-E?KswgPl3rW#oe7|rwo`}1J$@BNYg0`?ftxkzWl6>8cz0- ztxr-ow2FoZXZZ)<(Cja9+dLxf{%L8~!Vvc=RL2ArH#^fM!K9_>g^5&pP73AENKiO; ze1g_*xtq&?yxpm#&wEgV+Lr+h>&wqs5|o@t1NQM$*v#QER?J82_UU1wLw2u+-LxKd z(@3z3vkjGjl>^)F6nv(#U0+G7LmV7Uhy9^+ zzbMh&X@?AKp;~a^L;E`vZp=!auMNv)k(|QP=W9=ox;2GIKrf%3at>g~8?|KldUNj2 zVo5x}odIrLSei)l&rhe^sR@2rOBmGe1JpZVr&jk+pB*_*mYqBoAVy#+7Ag^-X2Xp; zA~3&TFBb#kGdPd-BZM|wZw7WsT?#q25TTPi=FwzXrH&%T%n##!*z)(mNj2Dkp>_@V zek^AA17@qmNym?LgA=z>Re7f(5BEaEkbGUVL4(HhTJd;Yzwv0xXJg{f4@}LNg5ZUB zvE*~q62ntJEmk5sKb|hQG!p<%qKwQq$lMyAMH6^bds$Px(AeaGfjADaC*TY=KZxLTxBi=6C@isdQvu79R$;YcV*>Pd%Q8(4 zi}X7T>NZ~{=dCYWL=qBpl#>?^(Zob$^HV8rS^~uxHL4X#UYz4F<*~BJ@!D;Jb~fPG zGmJ3`i1Wdbr(tpUH68Ch1ku}YmERdoY(m@#Ii9_k!A^L{!g#Mtw8ORRyEks|gm~P| z7qV3BPSzj|e~DIiOQ1?!^K0$toWnwoWi7X4BfCeeHCls+$@iS}?GK z(q#E<6rcUtvo)1%M}<3SnVz(pD0Y}>y0ib-_AWf_!|qDM#x zq<0^5N>+@m1Fm-tHw>1EiLFRG0P6RkoVwY3%x^NW_MH!L=q|{wW8unb2Y@mv0JWev zN%%omYvot!K7t!}z$U!?Z;kZHhfUPp8VpLfnTa6(C#AMN`rK|(;x>E+wETft`}}Wq zuH3!r%T;n<3lBhUwSmrjwsR}DmyOw_U2O6CRUhgEH||onb0}NsoRXEC&yE-q_=6?BDd` zJX^Q(jX%Dyb$)y68FzU|=D-{}4)KF&83sBZGWVj(GATJVa<*c2i6F1RHqNhd-zD6k zWaRXso8i1Dg=;xjZx>$|j(Hf{g+JZiM7wvjQcI(i`e0%k?(FdOx?|}dpAb*qTz=Ol zaf$jn=G^~p*qw6>9J+Gjy8B_sdUp5U_ZU9jwgXj{{9U%OOr^h|zaIM_&R&p0bI(mv zOo8c{hKS(Y9KZiQltoS^9*fh5O^9rH;mnMgJ}P2kqo(Jx*s0M9#qL;57ajProeqB0 zu2?6+*|4_{ExPo4FtrPMA&4%UwPMv4p8@^Fh9{FvI@{ks@AG=Pd+84^Y=x{8S}yHw z&}=DTPMtF~p2}e7o&$h07xzWp4)o%%g2+h^Yy~;y1Gi3q?0PZGaSP*NjvJW(8t7qq zyj~|_Ck}oImov<4Ct;mv17~)4GmC_1go(+C^!0m|5Pzb*muQ!6&3@24bTjaQv5YEm z9NK8UMi9impo6k==Jstn>GNH?!^WjYqn!y+fO8k7(!vWf6iZ3GNkdwt>VD`OJ7hkA znastl-QhtoBSKcrh2po^% z^192xv=q47uzAsy#VgD^+@0qZ&|hrWpi9y>{0IDuoQ|cm##2r7`XAqfhexT27jcah z$E?Y5G^03?X3b8fnX?k{MC(J&xmjUsg_(@yYE~Fe!+VGHL`5!UF3edxQ<343kAv;Z zp>8hau3}#Y93CB1f5M`KXNF0d^g8_nQ#{?e(eYW!puY(A82UaMPQgn(5@Ipidvg>`|=??kOhC?=R zHWA;Z!rZrfZyUY;Rs)?l)(w}mZ|8@r_k#21(LY>$Da^QM-(#TZM@ED0_Nm|e5jF`Q z+T7B&;)S2i6&#Mg00p^&yIXoK^yaH?fgoopFNJHKs_x5gD31P{T97~`b5f`X-pQ!NQPh;U5(G$1%8sLRFUp{K=cQ3<8kEU! z2(Uq87*oJSX2d>)cV+&R^RK7pj~6fIx9 zSrwowHmzBY9F!k{R&H0gudk1`zrU0A?KuF2GxUm0AQy_`N7$y#%H;g>(rJ3JiQ;)< z&u|I5DksO*2oAyI7>X|e;@V@q3O9B}h^bBGG)8h?$cbfhOX%`zE`y(8)M^?IK^7h_ zTCoya4i0T@m#uQsy5AynpFY0~%)*&YaSGZEt^wxYy(32Gl%fmz9Lm%4R_Q+^IQq^$k~0YI>?S=z+<|FBL6YeYYw= zS8iPU9$vXudc7(-*lad>_l@_cy0T_mf!Z~92Dc8CI%m&Mp=)lnn{-|z6ccr6xX)e_IB~|RX3>P(3{rn#QW?bwO8hT%>(d60H} z{3*0%Y8Cz+UH%8K?`99^w+k{R*8MPTWRO}jo`|bh;%~-dO=xEM!*rx zoj)6CdFnXxnK8)pPVtIW*Q*5d+V|0Mv9A-5V6{J7bwuIT9LU7~p9qb;k{sT8F5*ep z{mwDnY8r$1bhDNA?{1?V+ZvG#jk>)Pps`slIW?IszWhRP=Yk<#K(*6{g8Vw7?rv8F zXqfHZLYUqN^sBiUyU1Bt-1F&|bYO4!xE4W%(}TT|ERvjeafVXLflY$$8q3D)z56?? z6qN@%P!Y%;b&d};Sa8=ztJT8(KMTQR3*gvF8FNSi-jXjDEnD?XRe(kuG`xiJsO6T_ zOoq90EnMUu{q&R!3C_J0m7M zQ_Xt%+lTd!)btd({OZe4RxW=`TzSYCWcm5x6{{am1?WSY*8E%)#P0!xez_O8%0*Xu z7aclKNfiex;mvBBFk%Q3nhWhDCTS6`-Cql^12*F}I6oZWs)yh&D#vg>TZz)r)2OJd zh)QM`L)1uu0nIO1@hBuXR?Kj$tWyQ(s!i*EfD$H8ptCw*(91-bTXXEk-rf6^!)lor zvf>!le_Wlyp1!#YX4AZdbIFuw@}Dc8rH)N@%HNxH|LXr%3+S(dyP^b`Yz!oY+5FVe z2Is)uLyCv7qn%&76LpNUa|5SXR`E^x* zuHLxzLewSU&A>B*K}Zg8>gw!*dFm9zkqW9ieiHGyQ4*jV41$bNQ3mjC30DnM6k zd@4~C`(AVF8c?M}2o>BAOPfJV%V0Mp$M5CDRL=izDWLa#r03U=Bm1V5cn zM3jl*s0k0nN53mOLK2fqls`S6ipvTqJ7)^wTSCBzM;Vgjqz6mxU-g{3pGUQyly5RG z(2xcmLpeFz1m@JQmgZJEb+Un4TUy~=Yk@NYm3yq#s7^8nH(lM`pTQdk>qJrt7t>5v zBHt$GOhN_O42nk&+3Ys1!sAFQHm$o8#bbYq(fCyGJbgn%Sopk-KGHBV1PA(_ri%J z&Z`0v0=7Z6Nnb;@_vtYKddrq2nt9!Gz7If;$lX$L`>5!`V7ByFs2iS4_GWeh=-3_lMvZmSj!{QF-K=G&Y0PxrT@2<=dcJ&YW&Jav>9p!EjQ_LDCIXzwHvm-wIaT=^iw2Nq*#ySsF)cnd1E2hzK|WC%9_T$fa^5(7 znYqCe4ZH%HPv$yz;2i8`j0iP%qAuzLo_7a;dTr72RiApJfhs`D8y?VA>T>@JotNQ) zLGx#N8*J?N2}uD5{2{+8OelFoa8t`}I7L(enomN#yHdP(UxzeUI3dXzn{;?6Aj|t; zI=rLs-j#>d3_vxYAx8=bTDn0Jg%@yAx|)l}`ot*U4+Vg;MmSNvHf!0+kMLbp(FWCk z=7GsuwrDE458Ven{7L*b#h<~(&W%yPGX;cZgz-PJH)-$7p7vB3v&i8(@!%G-UfV60 zp_Bgr5D%Y2KZb{KO98NA-im2`m%Z+Zg%3XGQ|P5n9Qq8Xe`Pl5;)L(Zg7`=z#CnX| zm7RbSgB7z4=~e5rg7d#&qVqYmX-t4tl*DJAF*r@_|BA2ca1kGiDontYicQ%o1;Cjt zqAdTg&@uVf!h;X`#FBhO(l1*(J3hMT;a`kMr5fv; z0}ALf+*&d1X&ym&{~sWthKv0M#C-a;R z+t={kxe0$SgYuXfQy!xUoT|pkxeu)X)?OTMNV4<_wlO}XHWowG9Xxaj8K7Nbz^g{Q zC10OzlB5z;+`CB>1sMgHDG`1R-D@y?66){Zxo z;4r{^NH2&-C_&dWW62LYLoE-Fg&t7>=;0G%#d1k0Y2(`BjE*>miTb2$(I98bk~j^f z!6IzvT#RL!3UMV3i^UsxOx$$op~U0#^md4Htt8P=1Ort90M(FzYqg?K=Wt4=GzRLn z>B(BUWVJmS06ZKUQ3+^oPcA!kj!mT`?EH|1x znDO{-AQ{}9WE>2BnH5q*GNMcdF2LWt-7`eLb7>F>W5OXg`8&pnLx3w8TvjG*>P|i2 zv~Bp_fwBqh=+cg`ng;Z(L%D<_l3hNbF*;jbs-f;+IX6N?3%9o!mw+CPplnsCdG;Z_ zGr_2rwJCbZYD*v;nM9{()M-Qm)b==tG6@<{W5Vm!1h6Cm>?VLC84p4{a1zu4-bilhVxj#I$PykknQ-J4W&7H!7(j(!1{1qiy%8K zf}pX;veb*~b^y%X05D3iQaAEDyK#wLRPeB3YE~QBt=OHs%ib&YN?A7EEYeoG)Xdo* zAvK-F03A*W#O^(=C@`To^tf8N*kocNM+^hwp8_!sJ^t5tLd`G+=n1vlrN_nyahzH`2N2^l8KGP_oSwm5K}h}D2# zBY+dgA6Mfu9C%ZGOMXWLaZH(rr)=g~Z-)px%5hjKL%v`d)@U`sXfwm_{qfi5C8&2R zZ_1--fZ3tGPEq_g0hsG7z}{K_v4{euk-$|I#6(FTK^+O|-Hg%0Y=;G*jh(~EN8Ix3 zO5yW-2al`w$UD?4gUFScTYS0_YvT)vgru8kB@{F{vF4sn-V-|s`*#yZ7k!nxnqv-= z8+uI|LlWnBR4~jiNv{gU5$29|M@34#IA=T}-Ipdii2RUYuV_ zcGKw@8e8(vdd`lDdP`FFxyc=jz4c+zy#+-{v2q?S$EB+mTmqo)zRK19iSa2+ex8Qk z=T9#o&#Ds|61Fy@X`e0MhK7^1Xl!nPhFCYvJ(r!FvkB2ECy}+kAHjBD8{tqG%0Wst ztxgMLwh_73To?>ui^4ePzo4>)q)xL#ax)PP2;I(#Yx;v7p$8Y$A5@h(tMBxTA4>KP zKPZM0YZ0`|MOL2RpK1p+B5@WFqb@_MxKG^ZPk+td;dU82=l{I-00000NkvXXu0mjf D>RD)I literal 0 HcmV?d00001 diff --git a/src/frontend/public/favicon-32x32.png b/src/frontend/public/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..f2aa203570552d36e40192c78bd47af175fe3a5e GIT binary patch literal 1850 zcmV-A2gUe_P)%dQELA4&mZh^#Tj&Bh1^ zqwXe~{QqkjX$c1J)uDs;odwD|s_iwxSB9wSI&LM-l2D$;fHCu5E2-mEJH5*!c79 zA*nL0bA9x&Sa8^2vGP|%lkLSq(Jmv}6X9tyxXsfi~+Hv2#ap5qnr;TIvOqrAn%X5_I!P-^ppwUOy@HL z^l!O=cFBfDDx@GL&jkt_HZ*hN%c*Cu=BzBCI-(+__M)f1zI9(cJMq^iio`N^gtWM8 z3+2aY)ogAunH_w;2b!W9xmN|IIazPJRLXN)Xg)LLtE!QnP7*UTWAYrMb__X#!FbS! z>GG+d@8zV7@W5;_>bz|AFkOu#uW&n{Nika~M9-YXZr2Y{f;C!Ukxv ziop+h=8Gv*_t0M7ObvAGKS=%mXdw-Yjfc(cKq3^)TbzczK<#Nzod`z@0-$+tfabwt zCV+vK-!`k8>$>1~&xiW@{5~=TOILqOZPzkr>G0lfU=oom;1r$4u*KwFhM#ODP3F6gBWz-5E(umY3<2-GN4 z58z0RU9m=t)-vXXu`gtLQVLj{#&492hT_1*2v{&?PCl7To?62--TSZiPO>Ndd(SFP zh+I2%VMy~g2Q(^Fz)9ERYh%KFaGbo$EP*?Z9=8UD2nn6DymPV z;XkLK?4k-m{%Lq*0SHxEFuIh-aHAE0#kA*$J)cE`N6;Q&`WJWYNNH$$Z9+hQii3l2 zh?Iyam6>chCpEYxG&mv}>~S5|gbrS&hjtLVG^%wh_K6~FT+;2CxkfL|7W+8ym$Qd9IZf3<_sOIv}0j!`BKg#}!YXE*aT zQL)0R;(<~b;tatQl#cU*(-=7)JF#ht|4y@#b}cP^iD=p7s}ym|ZKYVV-izwxMTipO z&X5&Bg`w7_p~+6D$jIRsk1=KL3BLa_ug>Jx~8^A-ss-Nh!)n@A&tM%{(5O}EccDa z0C4LjulbbuF3*TC*)$SVO!?28z{LcPeH_GRhl3aoBjmy$|+4{u!%GEkxHrf@@NR3bO&(uG%cY5Yr5D| zjNfm&7sVxRJu1gM6#bJf#+-&%mQ1E_R21=89I0d)vYdu` z>6*^bc(w`xrNjnDk;8Q4Df7Tr?uFGxV?4(p$4}R7-}|fyVB{sq5#iZULfmvs&AwnI zy~p8nZCHH&$<0(7Yw2k;ij>}nD`}{? z2zFNIUk;yUibBAnWPOzC?_0Xo_mS-Jw3_k literal 0 HcmV?d00001 diff --git a/src/frontend/public/favicon.ico b/src/frontend/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..2c767258bc4d18a508cb585b96797b77c61ac4a4 GIT binary patch literal 15406 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 literal 0 HcmV?d00001 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" + } + > + + + + + +