diff --git a/docker-compose.yml b/docker-compose.yml index 6fb2905..48305b4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,27 +20,23 @@ services: - REDIS_PORT=6379 - REDIS_DB=0 - REDIS_PASSWORD=CNXpuhMdxXHo7ZK8bhtXDvgXVZcjRn - # JWT — замените на длинную случайную строку (python -c "import secrets; print(secrets.token_hex(32))") - SECRET_KEY=e8fe23297b4664a78ba8e8ebe34bac158398ac4d7dc6b664fd8d52b0094ee67b - # Ключ для admin-операций - ADMIN_KEY=b00ac081344a99bf50dffeb942e7f4f1 - # Логин и пароль для доступа к /docs и /redoc - DOCS_USERNAME=admin - DOCS_PASSWORD=2cbae90b3d9f6710cf51354f volumes: - ./data/radar:/app/data/radar + restart: unless-stopped - web: + web-react: build: - context: ./web - dockerfile: ../Dockerfile.web + context: ./web-react + dockerfile: Dockerfile ports: - "80:80" - environment: - - API_URL=http://api:8000 depends_on: - api - + restart: unless-stopped stats: build: context: . @@ -51,3 +47,4 @@ services: - API_URL=http://api:8000 depends_on: - api + restart: unless-stopped diff --git a/route/transfer_crud.py b/route/transfer_crud.py index ce613c0..691793f 100644 --- a/route/transfer_crud.py +++ b/route/transfer_crud.py @@ -309,7 +309,11 @@ def backup_restore(file: UploadFile = File(...)): # 3. SVG-диаграммы: полностью очищаем каталог и восстанавливаем svg_names = [n for n in zf.namelist() if n.startswith("radar_svgs/") and n != "radar_svgs/"] if svg_names and _RADAR_DIR.exists(): - shutil.rmtree(_RADAR_DIR) + for child in _RADAR_DIR.iterdir(): + if child.is_dir(): + shutil.rmtree(child) + else: + child.unlink() _RADAR_DIR.mkdir(parents=True, exist_ok=True) svg_written = 0 diff --git a/web-react/.dockerignore b/web-react/.dockerignore new file mode 100644 index 0000000..fe8741c --- /dev/null +++ b/web-react/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.vite +*.local +Dockerfile +.dockerignore diff --git a/web-react/.gitignore b/web-react/.gitignore new file mode 100644 index 0000000..631f355 --- /dev/null +++ b/web-react/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +*.local +.vite diff --git a/web-react/Dockerfile b/web-react/Dockerfile new file mode 100644 index 0000000..ef580e5 --- /dev/null +++ b/web-react/Dockerfile @@ -0,0 +1,14 @@ +# --- Стадия 1: сборка React-приложения --- +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install +COPY . . +RUN npm run build + +# --- Стадия 2: раздача статики через nginx + прокси /api --- +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/web-react/README.md b/web-react/README.md new file mode 100644 index 0000000..9b7ebd9 --- /dev/null +++ b/web-react/README.md @@ -0,0 +1,58 @@ +# web-react — фронтенд на React (Vite + TypeScript) + +Замена Flet-интерфейса (`../web`). Обычный SPA: грузится в браузер один раз и работает +локально, обращаясь к API по HTTP. **Нет WebSocket и серверного UI-стейта** — это убирает +зависания, которые были у Flet (он рендерил UI на сервере и держал WS на каждого юзера). + +Flet-сервис `web` намеренно оставлен в `docker-compose.yml` как запасной. Этот новый +фронтенд публикуется на **порту 3000**. + +## Архитектура + +``` +Браузер ──▶ [ nginx :80 (контейнер web-react) ] + ├── / → статика React (SPA, fallback на index.html) + └── /api/* → proxy_pass http://api:8000/ (включая SVG-диаграммы) +``` + +API-контейнер доступен только внутри Docker-сети, поэтому браузер ходит к нему через +nginx-прокси на том же origin (`/api/...`). SVG-диаграмма тянется напрямую ``/``. + +## Разработка (локально) + +```bash +# 1. Поднять API + БД (из корня репозитория) +docker compose up -d api postgres redis # postgres/redis — из docker-compose_db.yml + +# 2. Dev-сервер фронтенда +cd web-react +npm install +npm run dev # http://localhost:5173, /api проксируется на http://localhost:8000 +``` + +Адрес API для dev-прокси переопределяется переменной `API_URL` (см. `vite.config.ts`). + +## Сборка / прод + +```bash +# из корня репозитория +docker compose up -d --build web-react # http://localhost:3000 +``` + +Либо вручную: `npm run build` → статика в `dist/`, раздаётся nginx (см. `Dockerfile`, `nginx.conf`). + +## Структура + +| Путь | Назначение | +|---|---| +| `src/api/client.ts` | все запросы к API (порт `web/api_client.py`) | +| `src/auth/` | `AuthContext` — токен в localStorage + `/users/me` | +| `src/theme/` | 5 тем + слайдер масштаба через CSS-переменные | +| `src/components/` | переиспользуемые компоненты (Button, TextField, SearchableDropdown, Layout, Modal, RadarBlock…) | +| `src/pages/` | экраны: Login, Register, Home, TestList, Poll, MyTests, Response, Profile | +| `src/styles/global.css` | стили на CSS-переменных | + +## Маршруты + +`/login`, `/register`, `/` (домашняя), `/tests`, `/poll/:pollId`, `/my-tests`, +`/response/:responseId`, `/profile`. Защищённые маршруты требуют токен (`ProtectedRoute`). diff --git a/web-react/index.html b/web-react/index.html new file mode 100644 index 0000000..46b12a1 --- /dev/null +++ b/web-react/index.html @@ -0,0 +1,13 @@ + + + + + + + ЦОПП + + +
+ + + diff --git a/web-react/nginx.conf b/web-react/nginx.conf new file mode 100644 index 0000000..b867eed --- /dev/null +++ b/web-react/nginx.conf @@ -0,0 +1,36 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Статика SPA: всё неизвестное отдаём index.html (клиентский роутинг) + location / { + try_files $uri $uri/ /index.html; + } + + # Прокси на внутренний API-контейнер (браузер к api:8000 напрямую не ходит). + # Префикс /api срезается: /api/auth/login -> http://api:8000/auth/login + # + # Используем встроенный DNS Docker (127.0.0.11) и переменную в proxy_pass, + # чтобы nginx переразрешал имя `api` на каждый запрос. Иначе nginx кэширует + # IP при старте и ломается (502), если контейнер api перезапустился и сменил IP. + location /api/ { + resolver 127.0.0.11 valid=10s ipv6=off; + set $api_backend api; + rewrite ^/api/(.*)$ /$1 break; + proxy_pass http://$api_backend:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + 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; + proxy_read_timeout 60s; + } + + # Кэш статических ассетов с хэшем в имени + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/web-react/package-lock.json b/web-react/package-lock.json new file mode 100644 index 0000000..1f82e15 --- /dev/null +++ b/web-react/package-lock.json @@ -0,0 +1,1774 @@ +{ + "name": "copp-web-react", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "copp-web-react", + "version": "1.0.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2" + }, + "devDependencies": { + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.4", + "vite": "^5.4.6" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@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.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@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.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "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.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "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/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.0.tgz", + "integrity": "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.0.tgz", + "integrity": "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.0.tgz", + "integrity": "sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.0.tgz", + "integrity": "sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.0.tgz", + "integrity": "sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.0.tgz", + "integrity": "sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.0.tgz", + "integrity": "sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.0.tgz", + "integrity": "sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.0.tgz", + "integrity": "sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.0.tgz", + "integrity": "sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.0.tgz", + "integrity": "sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.0.tgz", + "integrity": "sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.0.tgz", + "integrity": "sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.0.tgz", + "integrity": "sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.0.tgz", + "integrity": "sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.0.tgz", + "integrity": "sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.0.tgz", + "integrity": "sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.0.tgz", + "integrity": "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.0.tgz", + "integrity": "sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.0.tgz", + "integrity": "sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.0.tgz", + "integrity": "sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.0.tgz", + "integrity": "sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.0.tgz", + "integrity": "sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.0.tgz", + "integrity": "sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.0.tgz", + "integrity": "sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==", + "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.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "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, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.30", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.30.tgz", + "integrity": "sha512-3ek6mwJL5/VBewBcY4S66cqlCtK3qi4WIq37Z0m/NHw1hjhI7274Mx1qz/+ggSzyBCOEf7eHjBN6INjPAWYfYw==", + "dev": true, + "license": "MIT", + "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, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "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.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "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/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/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "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/electron-to-chromium": { + "version": "1.5.365", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.365.tgz", + "integrity": "sha512-xfip4u1QF1s+URFqpA6N+OeFpDGpN7VJz1f3MO3bVL0QYBjpGiZ5/Of7kugvM+o8TTqmanUlviHN3c8M9vYWCw==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "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/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/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/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/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.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "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.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "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-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-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz", + "integrity": "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.0", + "@rollup/rollup-android-arm64": "4.61.0", + "@rollup/rollup-darwin-arm64": "4.61.0", + "@rollup/rollup-darwin-x64": "4.61.0", + "@rollup/rollup-freebsd-arm64": "4.61.0", + "@rollup/rollup-freebsd-x64": "4.61.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.0", + "@rollup/rollup-linux-arm-musleabihf": "4.61.0", + "@rollup/rollup-linux-arm64-gnu": "4.61.0", + "@rollup/rollup-linux-arm64-musl": "4.61.0", + "@rollup/rollup-linux-loong64-gnu": "4.61.0", + "@rollup/rollup-linux-loong64-musl": "4.61.0", + "@rollup/rollup-linux-ppc64-gnu": "4.61.0", + "@rollup/rollup-linux-ppc64-musl": "4.61.0", + "@rollup/rollup-linux-riscv64-gnu": "4.61.0", + "@rollup/rollup-linux-riscv64-musl": "4.61.0", + "@rollup/rollup-linux-s390x-gnu": "4.61.0", + "@rollup/rollup-linux-x64-gnu": "4.61.0", + "@rollup/rollup-linux-x64-musl": "4.61.0", + "@rollup/rollup-openbsd-x64": "4.61.0", + "@rollup/rollup-openharmony-arm64": "4.61.0", + "@rollup/rollup-win32-arm64-msvc": "4.61.0", + "@rollup/rollup-win32-ia32-msvc": "4.61.0", + "@rollup/rollup-win32-x64-gnu": "4.61.0", + "@rollup/rollup-win32-x64-msvc": "4.61.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/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/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "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": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "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/web-react/package.json b/web-react/package.json new file mode 100644 index 0000000..d700a52 --- /dev/null +++ b/web-react/package.json @@ -0,0 +1,23 @@ +{ + "name": "copp-web-react", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2" + }, + "devDependencies": { + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.4", + "vite": "^5.4.6" + } +} diff --git a/web-react/public/favicon.png b/web-react/public/favicon.png new file mode 100644 index 0000000..8ecde27 Binary files /dev/null and b/web-react/public/favicon.png differ diff --git a/web-react/public/icons/clipboard-question_16542596.svg b/web-react/public/icons/clipboard-question_16542596.svg new file mode 100644 index 0000000..803761e --- /dev/null +++ b/web-react/public/icons/clipboard-question_16542596.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web-react/public/icons/leaderboard-trophy_14227576.svg b/web-react/public/icons/leaderboard-trophy_14227576.svg new file mode 100644 index 0000000..011f4e3 --- /dev/null +++ b/web-react/public/icons/leaderboard-trophy_14227576.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web-react/src/App.tsx b/web-react/src/App.tsx new file mode 100644 index 0000000..faa2a74 --- /dev/null +++ b/web-react/src/App.tsx @@ -0,0 +1,38 @@ +import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; +import { AuthProvider } from "./auth/AuthContext"; +import { ThemeProvider } from "./theme/ThemeContext"; +import { ProtectedRoute } from "./components/ProtectedRoute"; +import { LoginPage } from "./pages/LoginPage"; +import { RegisterPage } from "./pages/RegisterPage"; +import { HomePage } from "./pages/HomePage"; +import { TestListPage } from "./pages/TestListPage"; +import { PollPage } from "./pages/PollPage"; +import { MyTestsPage } from "./pages/MyTestsPage"; +import { ResponsePage } from "./pages/ResponsePage"; +import { ProfilePage } from "./pages/ProfilePage"; + +function protect(el: React.ReactNode) { + return {el}; +} + +export default function App() { + return ( + + + + + } /> + } /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + } /> + + + + + ); +} diff --git a/web-react/src/api/client.ts b/web-react/src/api/client.ts new file mode 100644 index 0000000..a0b05e9 --- /dev/null +++ b/web-react/src/api/client.ts @@ -0,0 +1,283 @@ +// Единое место для всех HTTP-запросов к бэкенду. +// Порт web/api_client.py. Базовый путь /api проксируется nginx (прод) или Vite (dev) на api:8000. + +const BASE = "/api"; + +// --------------------------------------------------------------------------- +// Типы +// --------------------------------------------------------------------------- +export interface Me { + id: string; + first_name?: string; + last_name?: string; + group_id?: string | null; + organization_id?: string | null; +} + +export interface Organization { + id: string; + name_organization: string; + group_id?: string | null; +} + +export interface Group { + id: string; + name_group: string; +} + +export interface Choice { + id: string; + text: string; +} + +export interface Question { + id: string; + text: string; + choices: Choice[]; +} + +export interface Poll { + id: string; + title: string; + description?: string | null; + questions?: Question[]; +} + +export interface Answer { + question_id: string; + choice_id: string; + text?: string | null; +} + +export interface ResponseDetail { + id: string; + poll_id: string; + submitted_at: string; + answers: Answer[]; +} + +export interface UserResponse { + id: string; + poll_id: string; + submitted_at: string; +} + +export interface RadarResult { + image_url?: string | null; + [k: string]: unknown; +} + +export type Result = { data: T | null; error: string | null }; + +// --------------------------------------------------------------------------- +// Низкоуровневые помощники +// --------------------------------------------------------------------------- +function authHeaders(token?: string | null): Record { + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +async function safeJson(resp: Response): Promise { + try { + return await resp.json(); + } catch { + return null; + } +} + +// GET, возвращает распарсенный JSON или null при любой ошибке (как в api_client.py) +async function getJson(path: string, token?: string | null): Promise { + try { + const resp = await fetch(BASE + path, { headers: authHeaders(token) }); + if (resp.ok) return (await resp.json()) as T; + } catch { + /* network error -> null */ + } + return null; +} + +// --------------------------------------------------------------------------- +// Auth +// --------------------------------------------------------------------------- +export async function login(username: string, password: string): Promise> { + try { + const body = new URLSearchParams({ username, password }); + const resp = await fetch(BASE + "/auth/login", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + if (resp.ok) { + const json = await resp.json(); + return { data: json.access_token, error: null }; + } + if (resp.status === 401) return { data: null, error: "Неверный логин или пароль" }; + return { data: null, error: `Ошибка сервера: ${resp.status}` }; + } catch { + return { data: null, error: "Не удалось подключиться к серверу" }; + } +} + +export async function logout(token: string): Promise { + try { + await fetch(BASE + "/auth/logout", { method: "POST", headers: authHeaders(token) }); + } catch { + /* локальный logout произойдёт в любом случае */ + } +} + +export async function getMe(token: string): Promise { + return getJson("/users/me", token); +} + +const FIELD_NAMES: Record = { password: "Пароль", username: "Логин" }; +const MSG_MAP: Record = { + "Password must be at least 8 characters": "Пароль должен быть не менее 8 символов", + "Username must be at least 3 characters": "Логин должен быть не менее 3 символов", +}; + +export interface RegisterPayload { + username: string; + password: string; + first_name: string; + last_name: string; + group_id?: string | null; + organization_id?: string | null; +} + +// Возвращает строку с ошибкой или null при успехе (как register в api_client.py) +export async function register(p: RegisterPayload): Promise { + const payload: Record = { + username: p.username, + password: p.password, + first_name: p.first_name, + last_name: p.last_name, + }; + if (p.group_id) payload.group_id = p.group_id; + if (p.organization_id) payload.organization_id = p.organization_id; + + try { + const resp = await fetch(BASE + "/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (resp.status === 201) return null; + const json = await safeJson(resp); + if (resp.status === 400) return json?.detail ?? "Ошибка регистрации"; + if (resp.status === 422) { + const errors: any[] = json?.detail ?? []; + for (const err of errors) { + const raw = err?.ctx?.error || err?.msg || ""; + if (raw in MSG_MAP) return MSG_MAP[raw]; + const loc: any[] = err?.loc ?? []; + const field = FIELD_NAMES[loc[loc.length - 1]] ?? ""; + const msg = (err?.msg ?? "Ошибка валидации").replace(/^Value error, /, ""); + return `${field}: ${msg}`.replace(/^: |:?\s*$/g, "").trim() || "Проверьте правильность введённых данных"; + } + return "Проверьте правильность введённых данных"; + } + return `Ошибка сервера: ${resp.status}`; + } catch { + return "Не удалось подключиться к серверу"; + } +} + +// --------------------------------------------------------------------------- +// Organizations & Groups +// --------------------------------------------------------------------------- +export async function getOrganizations(): Promise { + return (await getJson("/organizations/")) ?? []; +} + +export async function getGroups(): Promise { + return (await getJson("/groups/")) ?? []; +} + +// --------------------------------------------------------------------------- +// Polls +// --------------------------------------------------------------------------- +export async function getPolls(): Promise { + return (await getJson("/polls/")) ?? []; +} + +export async function getPoll(pollId: string): Promise { + return getJson(`/polls/${pollId}`); +} + +export async function getResponse(responseId: string): Promise { + return getJson(`/polls/responses/${responseId}`); +} + +export async function getRadarResult(responseId: string): Promise { + return getJson(`/responses/${responseId}/radar`); +} + +// URL SVG-диаграммы — браузер тянет напрямую с того же origin (через nginx-прокси). +export function radarImageUrl(responseId: string): string { + return `${BASE}/responses/${responseId}/radar/image`; +} + +export interface SubmitPayload { + user_id?: string | null; + group_id?: string | null; + organization_id?: string | null; +} + +export async function submitResponse( + pollId: string, + answers: Answer[], + extra: SubmitPayload = {}, +): Promise> { + const payload: Record = { answers }; + if (extra.user_id) payload.user_id = extra.user_id; + if (extra.group_id) payload.group_id = extra.group_id; + if (extra.organization_id) payload.organization_id = extra.organization_id; + try { + const resp = await fetch(BASE + `/polls/${pollId}/responses`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (resp.status === 201) { + const json = await resp.json(); + return { data: json.id, error: null }; + } + return { data: null, error: `Ошибка сервера: ${resp.status}` }; + } catch { + return { data: null, error: "Не удалось подключиться к серверу" }; + } +} + +export async function getUserResponses(userId: string): Promise { + return (await getJson(`/polls/users/${userId}/responses`)) ?? []; +} + +export interface UpdateUserPayload { + first_name?: string | null; + last_name?: string | null; + group_id?: string | null; + organization_id?: string | null; +} + +export async function updateUser( + userId: string, + token: string, + payload: UpdateUserPayload, +): Promise> { + const body: Record = {}; + if (payload.first_name !== undefined) body.first_name = payload.first_name; + if (payload.last_name !== undefined) body.last_name = payload.last_name; + if (payload.group_id !== undefined) body.group_id = payload.group_id; + if (payload.organization_id !== undefined) body.organization_id = payload.organization_id; + try { + const resp = await fetch(BASE + `/users/${userId}`, { + method: "PUT", + headers: { "Content-Type": "application/json", ...authHeaders(token) }, + body: JSON.stringify(body), + }); + if (resp.ok) return { data: await resp.json(), error: null }; + return { data: null, error: `Ошибка сервера: ${resp.status}` }; + } catch { + return { data: null, error: "Не удалось подключиться к серверу" }; + } +} diff --git a/web-react/src/auth/AuthContext.tsx b/web-react/src/auth/AuthContext.tsx new file mode 100644 index 0000000..321f0f0 --- /dev/null +++ b/web-react/src/auth/AuthContext.tsx @@ -0,0 +1,95 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; +import * as api from "../api/client"; + +const TOKEN_KEY = "auth_token"; + +export interface AuthUser { + id: string; + groupId: string; + orgId: string; + firstName: string; + lastName: string; +} + +interface AuthCtx { + token: string | null; + user: AuthUser | null; + ready: boolean; // завершена ли первичная проверка токена + login: (username: string, password: string) => Promise; + logout: () => Promise; + setUserFromMe: (me: api.Me) => void; +} + +const Ctx = createContext(null); + +function meToUser(me: api.Me): AuthUser { + return { + id: me.id, + groupId: me.group_id ?? "", + orgId: me.organization_id ?? "", + firstName: me.first_name ?? "", + lastName: me.last_name ?? "", + }; +} + +export function AuthProvider({ children }: { children: ReactNode }) { + const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY)); + const [user, setUser] = useState(null); + const [ready, setReady] = useState(false); + + // При старте: если есть токен — проверяем его через /users/me + useEffect(() => { + let cancelled = false; + (async () => { + if (!token) { + setReady(true); + return; + } + const me = await api.getMe(token); + if (cancelled) return; + if (me) { + setUser(meToUser(me)); + } else { + // токен невалиден/просрочен + localStorage.removeItem(TOKEN_KEY); + setToken(null); + } + setReady(true); + })(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const login = async (username: string, password: string): Promise => { + const { data, error } = await api.login(username, password); + if (error || !data) return error ?? "Ошибка входа"; + localStorage.setItem(TOKEN_KEY, data); + setToken(data); + const me = await api.getMe(data); + if (me) setUser(meToUser(me)); + return null; + }; + + const logout = async () => { + if (token) await api.logout(token); + localStorage.removeItem(TOKEN_KEY); + setToken(null); + setUser(null); + }; + + const setUserFromMe = (me: api.Me) => setUser(meToUser(me)); + + return ( + + {children} + + ); +} + +export function useAuth(): AuthCtx { + const ctx = useContext(Ctx); + if (!ctx) throw new Error("useAuth must be used within AuthProvider"); + return ctx; +} diff --git a/web-react/src/components/AgreementDialog.tsx b/web-react/src/components/AgreementDialog.tsx new file mode 100644 index 0000000..1fe0eab --- /dev/null +++ b/web-react/src/components/AgreementDialog.tsx @@ -0,0 +1,193 @@ +import { useState } from "react"; +import { Button } from "./ui"; + +// Текст пользовательского соглашения (перенос из AlterDiaalogApproval в web/designer.py). +const AGREEMENT = `# ПОЛЬЗОВАТЕЛЬСКОЕ СОГЛАШЕНИЕ + +## 1. ОБЩИЕ ПОЛОЖЕНИЯ + +Настоящее Пользовательское соглашение (далее — «Соглашение») регулирует отношения между администрацией сайта https://copp74.ru (далее — «Сайт», «Мы», «Администрация») и пользователем (далее — «Пользователь», «Вы»). + +Используя Сайт, Вы автоматически соглашаетесь со всеми условиями настоящего Соглашения. Если Вы не согласны с этими условиями, пожалуйста, не используйте Сайт. + +## 2. ОПИСАНИЕ САЙТА + +Сайт https://copp74.ru представляет собой онлайн-платформу для прохождения тестов и проверки знаний. Основная функция Сайта — предоставление Пользователям возможности проходить различные тесты и получать результаты. + +## 3. РЕГИСТРАЦИЯ И ЛИЧНЫЕ ДАННЫЕ + +### 3.1 Регистрация + +Для использования Сайта Пользователь должен зарегистрироваться и указать следующую информацию: +- Фамилию +- Имя +- Учебное заведение (образовательную организацию) + +### 3.2 Назначение личных данных + +Указанные фамилия, имя и учебное заведение используются исключительно для: +- Идентификации Пользователя в системе +- Отображения результатов тестирования +- Ведения статистики прохождения тестов +- Организации пользователей по учебным заведениям для аналитики +- Внутреннего анализа и улучшения качества тестирования + +### 3.3 Защита личных данных + +**Мы гарантируем, что Ваши персональные данные (фамилия, имя и информация об учебном заведении):** +- Не будут переданы третьим лицам или организациям без Вашего согласия +- Не будут использованы в коммерческих целях +- Не будут распространяться без Вашего согласия +- Хранятся в защищенной базе данных + +Данные будут удалены в соответствии с законодательством Российской Федерации по защите персональных данных при закрытии аккаунта или по запросу Пользователя. + +## 4. ПРАВА И ОБЯЗАННОСТИ ПОЛЬЗОВАТЕЛЯ + +### 4.1 Права Пользователя + +Пользователь имеет право: +- Проходить тесты, размещенные на Сайте +- Получать результаты своего тестирования +- Запросить удаление своего аккаунта и данных +- Обратиться в поддержку по вопросам использования Сайта +- Получить информацию о том, как его данные обрабатываются + +### 4.2 Обязанности Пользователя + +Пользователь обязуется: +- Предоставлять достоверную информацию при регистрации +- Указывать корректное название учебного заведения +- Не использовать Сайт в противоправных целях +- Не пытаться получить несанкционированный доступ к системам Сайта +- Не воспроизводить, не копировать и не распространять содержимое Сайта без разрешения +- Соблюдать все применимые законы Российской Федерации + +## 5. ОГРАНИЧЕНИЕ ОТВЕТСТВЕННОСТИ + +### 5.1 Предоставление Сайта «Как есть» + +Сайт предоставляется в режиме «как есть» без каких-либо явных или подразумеваемых гарантий. Администрация не гарантирует: +- Непрерывную работу Сайта +- Отсутствие ошибок в тестах или результатах +- Соответствие Сайта конкретным целям Пользователя + +### 5.2 Ограничение ответственности + +В максимальной степени, допускаемой законодательством Российской Федерации, Администрация не несет ответственность за: +- Убытки, возникшие в результате использования или невозможности использования Сайта +- Технические сбои, потерю данных +- Ущерб, причиненный прямо или косвенно в связи с использованием Сайта + +## 6. ИНТЕЛЛЕКТУАЛЬНАЯ СОБСТВЕННОСТЬ + +Все содержимое Сайта, включая тесты, вопросы, задания, дизайн, логотипы и тексты, защищено авторским правом и законами об интеллектуальной собственности Российской Федерации. Пользователь не имеет права использовать это содержимое в коммерческих целях без письменного разрешения Администрации. + +## 7. ИЗМЕНЕНИЕ СОГЛАШЕНИЯ + +Администрация оставляет за собой право изменять условия настоящего Соглашения в любое время. Изменения вступают в силе с момента их публикации на Сайте. Продолжение использования Сайта после публикации изменений означает согласие Пользователя с новыми условиями. + +## 8. ПРЕКРАЩЕНИЕ ДОСТУПА + +Администрация может заблокировать доступ Пользователя к Сайту без предварительного уведомления, если: +- Пользователь нарушает условия настоящего Соглашения +- Пользователь использует Сайт в противоправных целях +- Пользователь предоставил поддельную информацию при регистрации + +## 9. КОНФИДЕНЦИАЛЬНОСТЬ + +Пожалуйста, ознакомьтесь с нашей Политикой конфиденциальности, которая описывает, как мы обращаемся с Вашими личными данными и какие меры безопасности мы применяем. + +## 10. ПРИМЕНИМОЕ ПРАВО + +Настоящее Соглашение регулируется законодательством Российской Федерации. Все споры, возникающие в связи с Соглашением, подлежат разрешению в судах Российской Федерации. + +## 11. КОНТАКТНАЯ ИНФОРМАЦИЯ + +Если у Вас есть вопросы по данному Соглашению или функционированию Сайта https://copp74.ru, свяжитесь с нами через форму обратной связи на Сайте. + +## 12. ЗАКЛЮЧИТЕЛЬНЫЕ ПОЛОЖЕНИЯ + +Если какая-либо часть настоящего Соглашения будет признана недействительной, остальные положения остаются в полной силе. + +Пользователь подтверждает, что прочитал, понял и согласен со всеми условиями настоящего Соглашения перед использованием Сайта. + +**Дата последнего обновления:** 19.03.2026 + +--- + +*Соглашение соответствует законодательству Российской Федерации, включая Федеральный закон «О защите персональных данных» и Федеральный закон «О защите прав потребителей».*`; + +// Рендер inline-жирного (**...**) внутри строки. +function inline(text: string): React.ReactNode { + const parts = text.split(/\*\*(.+?)\*\*/g); + return parts.map((p, i) => (i % 2 === 1 ? {p} : p)); +} + +// Мини-рендер markdown-подмножества (заголовки/списки/абзацы/hr). +function renderMarkdown(src: string) { + const lines = src.split("\n"); + const out: React.ReactNode[] = []; + let list: string[] = []; + const flushList = (key: number) => { + if (list.length) { + out.push( +
    + {list.map((it, i) => ( +
  • {inline(it)}
  • + ))} +
, + ); + list = []; + } + }; + lines.forEach((raw, i) => { + const line = raw.trim(); + if (line.startsWith("- ")) { + list.push(line.slice(2)); + return; + } + flushList(i); + if (!line) return; + if (line === "---") out.push(
); + else if (line.startsWith("### ")) out.push(

{line.slice(4)}

); + else if (line.startsWith("## ")) out.push(

{line.slice(3)}

); + else if (line.startsWith("# ")) out.push(

{line.slice(2)}

); + else if (line.startsWith("*") && line.endsWith("*") && !line.startsWith("**")) + out.push(

{line.slice(1, -1)}

); + else out.push(

{inline(line)}

); + }); + flushList(lines.length); + return out; +} + +// Диалог пользовательского соглашения: текст + переключатель согласия + «Принять». +// «Принять» неактивна, пока не включён переключатель (как в оригинале). +export function AgreementDialog({ + onAccept, + onClose, +}: { + onAccept: () => void; + onClose: () => void; +}) { + const [agreed, setAgreed] = useState(false); + return ( +
+
e.stopPropagation()}> +
+ Пользовательское соглашение +
+
{renderMarkdown(AGREEMENT)}
+ +
+ +
+
+
+ ); +} diff --git a/web-react/src/components/Layout.tsx b/web-react/src/components/Layout.tsx new file mode 100644 index 0000000..75a6cd2 --- /dev/null +++ b/web-react/src/components/Layout.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from "react"; +import { useNavigate } from "react-router-dom"; +import { IconArrowBack, IconAccount } from "./icons"; + +// Оболочка экранов: заголовок + опциональная кнопка «назад» + кнопка профиля. +export function Layout({ + title, + children, + back, + showProfile = true, +}: { + title: string; + children: ReactNode; + back?: string; // путь для кнопки «назад» + showProfile?: boolean; +}) { + const navigate = useNavigate(); + return ( +
+
+
+ {back && ( + + )} + + {title} + +
+ {showProfile && ( + + )} +
+ {children} +
+ ); +} diff --git a/web-react/src/components/ProtectedRoute.tsx b/web-react/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..1c403cb --- /dev/null +++ b/web-react/src/components/ProtectedRoute.tsx @@ -0,0 +1,18 @@ +import { Navigate } from "react-router-dom"; +import type { ReactNode } from "react"; +import { useAuth } from "../auth/AuthContext"; +import { Spinner } from "./ui"; + +// Пускает дальше только при наличии токена. Пока идёт первичная проверка — спиннер. +export function ProtectedRoute({ children }: { children: ReactNode }) { + const { token, ready } = useAuth(); + if (!ready) { + return ( +
+ +
+ ); + } + if (!token) return ; + return <>{children}; +} diff --git a/web-react/src/components/RadarBlock.tsx b/web-react/src/components/RadarBlock.tsx new file mode 100644 index 0000000..ad6ba6e --- /dev/null +++ b/web-react/src/components/RadarBlock.tsx @@ -0,0 +1,19 @@ +import { radarImageUrl } from "../api/client"; +import { Button } from "./ui"; +import { IconDownload } from "./icons"; + +// Блок с паутинной диаграммой: картинка + кнопка скачивания. +// SVG тянется напрямую с того же origin через nginx-прокси (/api/.../radar/image). +export function RadarBlock({ responseId }: { responseId: string }) { + const url = radarImageUrl(responseId); + return ( +
+ Паутинная диаграмма результата + + + +
+ ); +} diff --git a/web-react/src/components/SearchableDropdown.tsx b/web-react/src/components/SearchableDropdown.tsx new file mode 100644 index 0000000..26b6eae --- /dev/null +++ b/web-react/src/components/SearchableDropdown.tsx @@ -0,0 +1,83 @@ +import { useMemo, useState } from "react"; +import { IconCaretDown } from "./icons"; + +export interface Option { + key: string; + text: string; +} + +// Выпадающий список с поиском — порт SearchableDropdown из web/designer.py. +// При клике открывается модальный диалог с полем поиска и списком (как в оригинале). +export function SearchableDropdown({ + label, + hint, + options, + value, + onChange, +}: { + label?: string; + hint?: string; + options: Option[]; + value: string | null; + onChange: (key: string | null) => void; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + + const selected = useMemo(() => options.find((o) => o.key === value) ?? null, [options, value]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return options; + return options.filter((o) => o.text.toLowerCase().includes(q)); + }, [options, query]); + + return ( +
+ {label && {label}} +
{ + setQuery(""); + setOpen(true); + }} + > + + {selected ? selected.text : hint ?? "Выберите..."} + + +
+ + {open && ( +
setOpen(false)}> +
e.stopPropagation()}> +
{label}
+ setQuery(e.target.value)} + /> +
+
+ {filtered.length === 0 &&
Ничего не найдено
} + {filtered.map((o) => ( +
{ + onChange(o.key); + setOpen(false); + }} + > + {o.text} +
+ ))} +
+
+
+ )} +
+ ); +} diff --git a/web-react/src/components/icons.tsx b/web-react/src/components/icons.tsx new file mode 100644 index 0000000..762a44f --- /dev/null +++ b/web-react/src/components/icons.tsx @@ -0,0 +1,90 @@ +// Небольшой набор inline-SVG иконок (замена Material Icons из Flet). +// Наследуют цвет через currentColor и размер через 1em. +import type { SVGProps } from "react"; + +type P = SVGProps; +const base = (p: P) => ({ + width: "1em", + height: "1em", + viewBox: "0 0 24 24", + fill: "currentColor", + ...p, +}); + +export const IconPerson = (p: P) => ( + + + +); + +export const IconLock = (p: P) => ( + + + +); + +export const IconAt = (p: P) => ( + + + +); + +export const IconCheckCircle = (p: P) => ( + + + +); + +export const IconRadioUnchecked = (p: P) => ( + + + +); + +export const IconDownload = (p: P) => ( + + + +); + +export const IconChevronRight = (p: P) => ( + + + +); + +export const IconArrowBack = (p: P) => ( + + + +); + +export const IconAccount = (p: P) => ( + + + +); + +export const IconStop = (p: P) => ( + + + +); + +export const IconError = (p: P) => ( + + + +); + +export const IconEye = (p: P) => ( + + + +); + +export const IconCaretDown = (p: P) => ( + + + +); diff --git a/web-react/src/components/ui.tsx b/web-react/src/components/ui.tsx new file mode 100644 index 0000000..52f276d --- /dev/null +++ b/web-react/src/components/ui.tsx @@ -0,0 +1,139 @@ +import { + useState, + type ButtonHTMLAttributes, + type InputHTMLAttributes, + type ReactNode, +} from "react"; +import { IconEye } from "./icons"; + +// --------------------------------------------------------------------------- +// Spinner +// --------------------------------------------------------------------------- +export function Spinner() { + return
; +} + +export function CenterSpinner() { + return ( +
+ +
+ ); +} + +// --------------------------------------------------------------------------- +// Button +// --------------------------------------------------------------------------- +type ButtonProps = ButtonHTMLAttributes & { + variant?: "primary" | "info" | "ghost"; + block?: boolean; + icon?: ReactNode; +}; + +export function Button({ + variant = "primary", + block, + icon, + children, + className = "", + ...rest +}: ButtonProps) { + const cls = [ + "btn", + variant === "info" ? "btn-info" : "", + variant === "ghost" ? "btn-ghost" : "", + block ? "btn-block" : "", + className, + ] + .filter(Boolean) + .join(" "); + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Card +// --------------------------------------------------------------------------- +export function Card({ + children, + alt, + onClick, + className = "", +}: { + children: ReactNode; + alt?: boolean; + onClick?: () => void; + className?: string; +}) { + const cls = ["card", alt ? "card-alt" : "", onClick ? "clickable" : "", className] + .filter(Boolean) + .join(" "); + return ( +
+ {children} +
+ ); +} + +// --------------------------------------------------------------------------- +// TextField +// --------------------------------------------------------------------------- +type FieldProps = Omit, "prefix"> & { + label?: string; + icon?: ReactNode; + password?: boolean; +}; + +export function TextField({ label, icon, password, ...rest }: FieldProps) { + const [revealed, setRevealed] = useState(false); + const type = password ? (revealed ? "text" : "password") : rest.type ?? "text"; + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Modal +// --------------------------------------------------------------------------- +export function Modal({ + title, + children, + actions, + onClose, +}: { + title: string; + children?: ReactNode; + actions: ReactNode; + onClose?: () => void; +}) { + return ( +
+
e.stopPropagation()}> +
{title}
+ {children &&
{children}
} +
{actions}
+
+
+ ); +} diff --git a/web-react/src/main.tsx b/web-react/src/main.tsx new file mode 100644 index 0000000..1155add --- /dev/null +++ b/web-react/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./styles/global.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/web-react/src/pages/HomePage.tsx b/web-react/src/pages/HomePage.tsx new file mode 100644 index 0000000..bb5bd94 --- /dev/null +++ b/web-react/src/pages/HomePage.tsx @@ -0,0 +1,20 @@ +import { useNavigate } from "react-router-dom"; +import { Layout } from "../components/Layout"; + +export function HomePage() { + const navigate = useNavigate(); + return ( + +
+
navigate("/tests")}> + + Все тесты +
+
navigate("/my-tests")}> + + Мои тесты +
+
+
+ ); +} diff --git a/web-react/src/pages/LoginPage.tsx b/web-react/src/pages/LoginPage.tsx new file mode 100644 index 0000000..4954355 --- /dev/null +++ b/web-react/src/pages/LoginPage.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "../auth/AuthContext"; +import { Button, TextField } from "../components/ui"; +import { AgreementDialog } from "../components/AgreementDialog"; +import { IconPerson, IconLock } from "../components/icons"; + +export function LoginPage() { + const { login } = useAuth(); + const navigate = useNavigate(); + + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const [askReg, setAskReg] = useState(false); + + const doLogin = async () => { + if (!username.trim() || !password) { + setError("Заполните логин и пароль"); + return; + } + setBusy(true); + setError(""); + const err = await login(username.trim(), password); + setBusy(false); + if (err) { + setError(err); + } else { + navigate("/"); + } + }; + + return ( +
+
ЦОПП
+
+ Это проект ЦОПП по проведению исследовательских тестирований +
+
{ + e.preventDefault(); + doLogin(); + }} + > + } + value={username} + onChange={(e) => setUsername(e.target.value)} + /> + } + password + value={password} + onChange={(e) => setPassword(e.target.value)} + /> + {error &&
{error}
} + + + + + {askReg && ( + setAskReg(false)} + onAccept={() => navigate("/register")} + /> + )} +
+ ); +} diff --git a/web-react/src/pages/MyTestsPage.tsx b/web-react/src/pages/MyTestsPage.tsx new file mode 100644 index 0000000..b60b6af --- /dev/null +++ b/web-react/src/pages/MyTestsPage.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import * as api from "../api/client"; +import { useAuth } from "../auth/AuthContext"; +import { Layout } from "../components/Layout"; +import { Card, CenterSpinner } from "../components/ui"; +import { IconChevronRight } from "../components/icons"; + +interface Row { + id: string; + title: string; + submittedAt: string; +} + +export function MyTestsPage() { + const navigate = useNavigate(); + const { user } = useAuth(); + const [rows, setRows] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + if (!user) return; + const responses = await api.getUserResponses(user.id); + if (cancelled) return; + if (responses.length === 0) { + setRows([]); + return; + } + // параллельно подгружаем названия опросов + const pollIds = [...new Set(responses.map((r) => r.poll_id))]; + const polls = await Promise.all(pollIds.map((id) => api.getPoll(id))); + if (cancelled) return; + const titleById = new Map(); + polls.forEach((p) => { + if (p) titleById.set(p.id, p.title); + }); + const mapped: Row[] = responses + .map((r) => ({ + id: r.id, + title: titleById.get(r.poll_id) ?? "Неизвестный тест", + submittedAt: r.submitted_at, + })) + .sort((a, b) => b.submittedAt.localeCompare(a.submittedAt)); + setRows(mapped); + })(); + return () => { + cancelled = true; + }; + }, [user]); + + return ( + + {rows === null ? ( + + ) : rows.length === 0 ? ( +

Вы ещё не проходили тесты

+ ) : ( +
+ {rows.map((r) => ( + navigate(`/response/${r.id}`)}> +
+
+ {r.title} + + Пройден: {r.submittedAt.slice(0, 19).replace("T", " ")} + +
+ +
+
+ ))} +
+ )} +
+ ); +} diff --git a/web-react/src/pages/PollPage.tsx b/web-react/src/pages/PollPage.tsx new file mode 100644 index 0000000..8f87e73 --- /dev/null +++ b/web-react/src/pages/PollPage.tsx @@ -0,0 +1,192 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import * as api from "../api/client"; +import { useAuth } from "../auth/AuthContext"; +import { Layout } from "../components/Layout"; +import { Button, Card, CenterSpinner, Modal } from "../components/ui"; +import { RadarBlock } from "../components/RadarBlock"; +import { IconCheckCircle, IconRadioUnchecked, IconStop, IconError } from "../components/icons"; + +type Phase = + | { kind: "loading" } + | { kind: "empty"; message: string } + | { kind: "running" } + | { kind: "submitting" } + | { kind: "error"; message: string } + | { kind: "success"; responseId: string; hasRadar: boolean }; + +export function PollPage() { + const { pollId = "" } = useParams(); + const navigate = useNavigate(); + const { user } = useAuth(); + + const [questions, setQuestions] = useState([]); + const [answers, setAnswers] = useState>({}); + const [current, setCurrent] = useState(0); + const [phase, setPhase] = useState({ kind: "loading" }); + const [askAbort, setAskAbort] = useState(false); + + useEffect(() => { + let cancelled = false; + (async () => { + const p = await api.getPoll(pollId); + if (cancelled) return; + if (!p) { + setPhase({ kind: "empty", message: "Тест не найден" }); + return; + } + const qs = p.questions ?? []; + setQuestions(qs); + if (qs.length === 0) { + setPhase({ kind: "empty", message: "В тесте нет вопросов" }); + } else { + setPhase({ kind: "running" }); + } + })(); + return () => { + cancelled = true; + }; + }, [pollId]); + + const total = questions.length; + const allAnswered = total > 0 && questions.every((q) => answers[q.id]); + + const selectAnswer = (questionId: string, choiceId: string) => { + setAnswers((prev) => ({ ...prev, [questionId]: choiceId })); + // авто-переход к следующему вопросу, если он не последний + if (current < total - 1) { + setCurrent((c) => c + 1); + } + }; + + const submit = async () => { + const firstUnanswered = questions.findIndex((q) => !answers[q.id]); + if (firstUnanswered !== -1) { + setCurrent(firstUnanswered); + return; + } + setPhase({ kind: "submitting" }); + const payload: api.Answer[] = Object.entries(answers).map(([question_id, choice_id]) => ({ + question_id, + choice_id, + text: null, + })); + const { data: responseId, error } = await api.submitResponse(pollId, payload, { + user_id: user?.id, + group_id: user?.groupId || null, + organization_id: user?.orgId || null, + }); + if (error || !responseId) { + setPhase({ kind: "error", message: error ?? "Не удалось отправить ответы" }); + return; + } + const radar = await api.getRadarResult(responseId); + setPhase({ kind: "success", responseId, hasRadar: !!radar?.image_url }); + }; + + // ── рендер по фазам ────────────────────────────────────────────────── + let body: React.ReactNode; + + if (phase.kind === "loading") { + body = ; + } else if (phase.kind === "empty") { + body =

{phase.message}

; + } else if (phase.kind === "submitting") { + body = ; + } else if (phase.kind === "error") { + body = ( +
+ + + + {phase.message} + +
+ ); + } else if (phase.kind === "success") { + body = ( + +
+ + + + Тест пройден! + {phase.hasRadar && } + +
+
+ ); + } else { + // running + const q = questions[current]; + const selected = answers[q.id]; + body = ( +
+
+ + Вопрос {current + 1} из {total} + +
+ + +
+
+ +
{q.text}
+ +
+ {q.choices.map((c) => { + const isSel = c.id === selected; + return ( +
selectAnswer(q.id, c.id)} + > + {isSel ? : } + {c.text} +
+ ); + })} +
+ + {current === total - 1 && allAnswered && ( +
+ +
+ )} +
+ ); + } + + return ( + + {body} + + {askAbort && ( + setAskAbort(false)} + actions={ + <> + + + + } + > + Все ответы будут потеряны. Вы уверены? + + )} + + ); +} diff --git a/web-react/src/pages/ProfilePage.tsx b/web-react/src/pages/ProfilePage.tsx new file mode 100644 index 0000000..be3ee8c --- /dev/null +++ b/web-react/src/pages/ProfilePage.tsx @@ -0,0 +1,133 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import * as api from "../api/client"; +import { useAuth } from "../auth/AuthContext"; +import { useTheme } from "../theme/ThemeContext"; +import { THEMES, SCALE_MIN, SCALE_MAX, SCALE_STEP } from "../theme/themes"; +import { Layout } from "../components/Layout"; +import { Button, TextField } from "../components/ui"; +import { SearchableDropdown, type Option } from "../components/SearchableDropdown"; +import { IconPerson } from "../components/icons"; + +export function ProfilePage() { + const navigate = useNavigate(); + const { token, user, logout, setUserFromMe } = useAuth(); + const { themeId, scale, setThemeId, setScale } = useTheme(); + + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [orgId, setOrgId] = useState(null); + const [groupId, setGroupId] = useState(null); + const [orgs, setOrgs] = useState([]); + const [groups, setGroups] = useState([]); + const [loaded, setLoaded] = useState(false); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState<{ text: string; error: boolean } | null>(null); + + useEffect(() => { + let cancelled = false; + (async () => { + if (!token) return; + const [me, o, g] = await Promise.all([ + api.getMe(token), + api.getOrganizations(), + api.getGroups(), + ]); + if (cancelled) return; + setOrgs(o); + setGroups(g); + if (me) { + setFirstName(me.first_name ?? ""); + setLastName(me.last_name ?? ""); + setOrgId(me.organization_id ?? null); + setGroupId(me.group_id ?? null); + } + setLoaded(true); + })(); + return () => { + cancelled = true; + }; + }, [token]); + + const onOrgChange = (id: string | null) => { + setOrgId(id); + const org = orgs.find((o) => o.id === id); + if (org?.group_id) setGroupId(org.group_id); + }; + + const orgOptions: Option[] = orgs.map((o) => ({ key: o.id, text: o.name_organization })); + const groupOptions: Option[] = groups.map((g) => ({ key: g.id, text: g.name_group })); + const themeOptions: Option[] = THEMES.map((t) => ({ key: t.id, text: t.name })); + + const save = async () => { + if (!token || !user) return; + setBusy(true); + setStatus(null); + const { data, error } = await api.updateUser(user.id, token, { + first_name: firstName.trim() || null, + last_name: lastName.trim() || null, + group_id: groupId, + organization_id: orgId, + }); + setBusy(false); + if (error) { + setStatus({ text: error, error: true }); + return; + } + if (data) setUserFromMe(data); + setStatus({ text: "Сохранено", error: false }); + }; + + const doLogout = async () => { + await logout(); + navigate("/login"); + }; + + return ( + + {!loaded ? null : ( +
+ Редактировать профиль + } value={firstName} onChange={(e) => setFirstName(e.target.value)} /> + } value={lastName} onChange={(e) => setLastName(e.target.value)} /> + + + +
+ Внешний вид + id && setThemeId(id)} + /> +
+ Масштаб: {scale.toFixed(1)}× + setScale(parseFloat(e.target.value))} + /> +
+ +
+ + + {status && ( + {status.text} + )} +
+
+ )} +
+ ); +} diff --git a/web-react/src/pages/RegisterPage.tsx b/web-react/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..9358766 --- /dev/null +++ b/web-react/src/pages/RegisterPage.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import * as api from "../api/client"; +import { Button, TextField } from "../components/ui"; +import { SearchableDropdown, type Option } from "../components/SearchableDropdown"; +import { IconPerson, IconAt, IconLock } from "../components/icons"; + +export function RegisterPage() { + const navigate = useNavigate(); + + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [orgId, setOrgId] = useState(null); + const [groupId, setGroupId] = useState(null); + const [orgs, setOrgs] = useState([]); + const [groups, setGroups] = useState([]); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let cancelled = false; + (async () => { + const [o, g] = await Promise.all([api.getOrganizations(), api.getGroups()]); + if (cancelled) return; + setOrgs(o); + setGroups(g); + })(); + return () => { + cancelled = true; + }; + }, []); + + // Автоподстановка группы по выбранной организации (как _on_org_change) + const onOrgChange = (id: string | null) => { + setOrgId(id); + const org = orgs.find((o) => o.id === id); + if (org?.group_id) setGroupId(org.group_id); + }; + + const orgOptions: Option[] = orgs.map((o) => ({ key: o.id, text: o.name_organization })); + const groupOptions: Option[] = groups.map((g) => ({ key: g.id, text: g.name_group })); + + const doRegister = async () => { + if (!firstName.trim() || !lastName.trim() || !username.trim() || !password) { + setError("Заполните все обязательные поля"); + return; + } + setBusy(true); + setError(""); + const err = await api.register({ + username: username.trim(), + password, + first_name: firstName.trim(), + last_name: lastName.trim(), + group_id: groupId, + organization_id: orgId, + }); + setBusy(false); + if (err) { + setError(err); + } else { + navigate("/login"); + } + }; + + return ( +
+
ЦОПП
+
Регистрация в системе
+
{ + e.preventDefault(); + doRegister(); + }} + > + } value={firstName} onChange={(e) => setFirstName(e.target.value)} /> + } value={lastName} onChange={(e) => setLastName(e.target.value)} /> + } value={username} onChange={(e) => setUsername(e.target.value)} /> + } password value={password} onChange={(e) => setPassword(e.target.value)} /> + + + {error &&
{error}
} +
+ + +
+ +
+ ); +} diff --git a/web-react/src/pages/ResponsePage.tsx b/web-react/src/pages/ResponsePage.tsx new file mode 100644 index 0000000..a87df61 --- /dev/null +++ b/web-react/src/pages/ResponsePage.tsx @@ -0,0 +1,102 @@ +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import * as api from "../api/client"; +import { Layout } from "../components/Layout"; +import { Card, CenterSpinner } from "../components/ui"; +import { RadarBlock } from "../components/RadarBlock"; +import { IconCheckCircle, IconRadioUnchecked } from "../components/icons"; + +interface State { + response: api.ResponseDetail; + poll: api.Poll; + hasRadar: boolean; +} + +export function ResponsePage() { + const { responseId = "" } = useParams(); + const [state, setState] = useState(null); + const [error, setError] = useState(""); + + useEffect(() => { + let cancelled = false; + (async () => { + const response = await api.getResponse(responseId); + if (cancelled) return; + if (!response) { + setError("Результат не найден"); + return; + } + const poll = await api.getPoll(response.poll_id); + if (cancelled) return; + if (!poll) { + setError("Тест не найден"); + return; + } + const radar = await api.getRadarResult(responseId); + if (cancelled) return; + setState({ response, poll, hasRadar: !!radar?.image_url }); + })(); + return () => { + cancelled = true; + }; + }, [responseId]); + + return ( + + {error ? ( +

{error}

+ ) : !state ? ( + + ) : ( + + )} +
+ ); +} + +function ResultBody({ response, poll, hasRadar, responseId }: State & { responseId: string }) { + const questions = new Map(poll.questions?.map((q) => [q.id, q]) ?? []); + const submittedAt = response.submitted_at.slice(0, 19).replace("T", " "); + + return ( +
+
+ {poll.title} + Пройден: {submittedAt} +
+ + {hasRadar && ( + +
+ Результат + +
+
+ )} + + Ответы + {response.answers.map((ans, i) => { + const q = questions.get(ans.question_id); + if (!q) return null; + return ( + +
+ {q.text} + {q.choices.map((c) => { + const selected = c.id === ans.choice_id; + return ( +
+ + {selected ? : } + + {c.text} +
+ ); + })} +
+
+ ); + })} +
+ ); +} diff --git a/web-react/src/pages/TestListPage.tsx b/web-react/src/pages/TestListPage.tsx new file mode 100644 index 0000000..f2c822f --- /dev/null +++ b/web-react/src/pages/TestListPage.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import * as api from "../api/client"; +import { Layout } from "../components/Layout"; +import { Card, CenterSpinner } from "../components/ui"; + +export function TestListPage() { + const navigate = useNavigate(); + const [polls, setPolls] = useState(null); + + useEffect(() => { + let cancelled = false; + api.getPolls().then((p) => { + if (!cancelled) setPolls(p); + }); + return () => { + cancelled = true; + }; + }, []); + + return ( + + {polls === null ? ( + + ) : polls.length === 0 ? ( +

Тестов пока нет

+ ) : ( +
+ {polls.map((poll) => ( + navigate(`/poll/${poll.id}`)}> +
+ {poll.title || "—"} + {poll.description && {poll.description}} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/web-react/src/styles/global.css b/web-react/src/styles/global.css new file mode 100644 index 0000000..6512114 --- /dev/null +++ b/web-react/src/styles/global.css @@ -0,0 +1,600 @@ +/* ============================================================ + Глобальные стили. Цвета и масштаб — через CSS-переменные, + которые задаёт ThemeContext. 1rem масштабируется слайдером. + ============================================================ */ + +:root { + /* статическая палитра (web/designer.py) */ + --primary: #f85a40; + --primary-dark: #d94530; + --primary-light: #ffeab7; + --accent: #ffc845; + --info: #007fbd; + --info-dark: #006aa3; + --success: #85c446; + + /* задаются ThemeContext, но даём дефолты */ + --bg: #f4f6f8; + --surface: #ffffff; + --surface-alt: #e4e8ed; + --text-primary: #1e2a3a; + --text-secondary: #5a6779; + --border: #dde3ea; + + --scale: 1.3; + font-size: calc(13px * var(--scale)); +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + margin: 0; + padding: 0; + min-height: 100%; +} + +body { + background: var(--bg); + color: var(--text-primary); + font-family: "Segoe UI", system-ui, -apple-system, Roboto, Arial, sans-serif; + font-size: 1rem; + line-height: 1.45; + -webkit-font-smoothing: antialiased; + transition: background 0.2s ease; +} + +#root { + display: flex; + flex-direction: column; +} + +button { + font-family: inherit; + cursor: pointer; +} + +a { + color: var(--primary); + text-decoration: none; +} + +/* ---------- Layout ---------- */ +.page { + width: 100%; + max-width: 56rem; + margin: 0 auto; + padding: 1rem 1.2rem 3rem; + flex: 1; +} + +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +.app-title { + color: var(--primary); + font-size: 1.4rem; + font-weight: 700; +} + +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: var(--primary); + padding: 0.4rem; + border-radius: 0.6rem; + font-size: 1.6rem; + line-height: 1; + transition: background 0.15s ease; +} +.icon-btn:hover { + background: var(--surface-alt); +} + +/* ---------- Card ---------- */ +.card { + background: var(--surface); + border-radius: 0.9rem; + padding: 1.1rem; + box-shadow: 0 1px 3px rgba(30, 42, 58, 0.06); +} +.card-alt { + background: var(--surface-alt); +} +.card.clickable { + cursor: pointer; + transition: transform 0.12s ease, box-shadow 0.12s ease; +} +.card.clickable:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(30, 42, 58, 0.12); +} + +/* ---------- Buttons ---------- */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + border: none; + border-radius: 0.7rem; + padding: 0.75rem 1.3rem; + font-size: 1rem; + font-weight: 600; + color: var(--text-on-primary, #fff); + background: var(--primary); + transition: background 0.15s ease, opacity 0.15s ease; +} +.btn:hover:not(:disabled) { + background: var(--primary-dark); +} +.btn:disabled { + opacity: 0.55; + cursor: default; +} +.btn-info { + background: var(--info); +} +.btn-info:hover:not(:disabled) { + background: var(--info-dark); +} +.btn-ghost { + background: var(--surface-alt); + color: var(--primary); +} +.btn-ghost:hover:not(:disabled) { + background: var(--surface-alt); + filter: brightness(0.95); +} +.btn-block { + width: 100%; +} +.link-btn { + background: none; + border: none; + color: var(--primary); + font-size: 0.95rem; + padding: 0.3rem; +} + +/* ---------- Fields ---------- */ +.field { + display: flex; + flex-direction: column; + gap: 0.3rem; +} +.field-label { + font-size: 0.85rem; + font-weight: 500; + color: var(--text-secondary); +} +.field-input-wrap { + display: flex; + align-items: center; + gap: 0.55rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 0.7rem; + padding: 0.7rem 0.9rem; + transition: border-color 0.15s ease; +} +.field-input-wrap:focus-within { + border-color: var(--primary); +} +.field-input-wrap .ficon { + color: var(--text-secondary); + font-size: 1.1rem; + flex-shrink: 0; +} +.field-input-wrap input { + border: none; + outline: none; + background: transparent; + color: var(--text-primary); + font-size: 1rem; + width: 100%; + font-family: inherit; +} +.field-input-wrap input::placeholder { + color: var(--border); + font-style: italic; +} +.reveal-btn { + background: none; + border: none; + color: var(--text-secondary); + padding: 0; + font-size: 1.05rem; + line-height: 1; +} + +/* ---------- Dropdown ---------- */ +.dropdown { + position: relative; +} +.dropdown-control { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 0.7rem; + padding: 0.7rem 0.9rem; + cursor: pointer; +} +.dropdown-value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.dropdown-value.placeholder { + color: var(--border); + font-style: italic; +} +.dropdown-menu { + position: absolute; + z-index: 30; + top: calc(100% + 0.3rem); + left: 0; + right: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 0.7rem; + box-shadow: 0 6px 20px rgba(30, 42, 58, 0.18); + max-height: 16rem; + overflow-y: auto; +} +.dropdown-search { + width: 100%; + border: none; + border-bottom: 1px solid var(--border); + padding: 0.6rem 0.9rem; + outline: none; + font-size: 0.95rem; + background: var(--surface); + color: var(--text-primary); + font-family: inherit; + position: sticky; + top: 0; +} +.dropdown-option { + padding: 0.6rem 0.9rem; + cursor: pointer; + color: var(--text-primary); +} +.dropdown-option:hover, +.dropdown-option.active { + background: var(--surface-alt); +} +.dropdown-empty { + padding: 0.7rem 0.9rem; + color: var(--text-secondary); + font-style: italic; +} + +/* ---------- Auth screens ---------- */ +.auth-wrap { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 1.5rem 1rem; +} +.auth-logo { + color: var(--primary); + font-size: 1.9rem; + font-weight: 700; + text-align: center; +} +.auth-sub { + color: var(--primary); + font-size: 1rem; + text-align: center; + margin: 0.5rem 0 1.4rem; + max-width: 28rem; +} +.auth-card { + width: 100%; + max-width: 26rem; + background: var(--surface-alt); + border-radius: 1rem; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +/* ---------- Misc ---------- */ +.error-text { + color: #e23b3b; + font-size: 0.9rem; +} +.muted { + color: var(--text-secondary); +} +.center-col { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.8rem; +} +.row { + display: flex; + align-items: center; +} +.row-gap { + gap: 0.6rem; +} +.col-gap { + display: flex; + flex-direction: column; + gap: 0.7rem; +} +.stack { + display: flex; + flex-direction: column; + gap: 0.9rem; +} +.wrap-row { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; +} +.spacer { + flex: 1; +} + +/* ---------- Spinner ---------- */ +.spinner { + width: 2.4rem; + height: 2.4rem; + border: 0.28rem solid var(--surface-alt); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +.spinner-center { + display: flex; + justify-content: center; + padding: 2rem 0; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* ---------- Modal ---------- */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 22, 33, 0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 50; + padding: 1rem; +} +.modal { + background: var(--surface); + border-radius: 1rem; + padding: 1.4rem; + max-width: 24rem; + width: 100%; + display: flex; + flex-direction: column; + gap: 1rem; +} +.modal-title { + font-size: 1.2rem; + font-weight: 700; +} +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; +} + +/* ---------- Home cards ---------- */ +.home-grid { + display: flex; + flex-wrap: wrap; + gap: 1rem; + justify-content: center; +} +.home-tile { + width: 11rem; + height: 11rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.6rem; + background: var(--surface); + border-radius: 1rem; + cursor: pointer; + transition: transform 0.12s ease, box-shadow 0.12s ease; +} +.home-tile:hover { + transform: translateY(-3px); + box-shadow: 0 6px 16px rgba(30, 42, 58, 0.14); +} +.home-tile img { + width: 5.5rem; + height: 5.5rem; +} +.home-tile span { + font-weight: 600; +} + +/* ---------- Poll / take test ---------- */ +.counter-badge { + display: inline-block; + background: var(--surface-alt); + color: var(--text-secondary); + font-weight: 700; + border-radius: 0.7rem; + padding: 0.5rem 1.2rem; + text-align: center; +} +.question-box { + background: var(--surface); + border-radius: 1rem; + padding: 1.4rem; + text-align: center; + font-weight: 700; + font-size: 1.1rem; +} +.choice-tile { + display: flex; + align-items: center; + gap: 0.6rem; + border-radius: 0.6rem; + padding: 0.7rem 0.9rem; + background: var(--surface-alt); + cursor: pointer; + transition: background 0.12s ease; +} +.choice-tile.selected { + background: var(--primary-light); +} +.choice-tile .check { + color: var(--primary); + font-size: 1.2rem; + flex-shrink: 0; +} +.radar-img { + width: 100%; + max-width: 30rem; + height: auto; + display: block; + margin: 0 auto; +} +.success-box { + background: var(--accent); + border-radius: 1.1rem; + padding: 1.4rem; +} + +/* ---------- Slider ---------- */ +.scale-slider { + width: 100%; + accent-color: var(--primary); +} + +/* ---------- List card row ---------- */ +.list-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.8rem; +} +.chevron { + color: var(--primary); + font-size: 1.4rem; +} +.bold { + font-weight: 700; +} +.title-lg { + font-size: 1.3rem; + font-weight: 700; +} +.title-md { + font-size: 1.05rem; + font-weight: 700; +} +.text-sm { + font-size: 0.85rem; +} +.divider { + height: 1px; + background: var(--border); + border: none; + margin: 0.4rem 0; +} + +/* ---------- Agreement dialog ---------- */ +.agr-modal { + max-width: 34rem; + max-height: 90vh; +} +.agr-body { + overflow-y: auto; + max-height: 50vh; + padding-right: 0.4rem; +} +.agr-h1 { + color: var(--primary); + font-size: 1.25rem; + font-weight: 700; + margin: 0.6rem 0 0.4rem; +} +.agr-h2 { + color: var(--info); + font-size: 1.05rem; + font-weight: 700; + margin: 0.7rem 0 0.3rem; +} +.agr-h3 { + color: var(--text-secondary); + font-size: 0.95rem; + font-weight: 700; + margin: 0.5rem 0 0.2rem; +} +.agr-p { + font-size: 0.85rem; + margin: 0.35rem 0; +} +.agr-ul { + margin: 0.2rem 0 0.4rem 1.1rem; + font-size: 0.85rem; +} +.agr-ul li { + margin: 0.15rem 0; +} +.agr-switch-row { + display: flex; + align-items: flex-start; + gap: 0.55rem; + font-size: 0.85rem; + cursor: pointer; +} +.agr-switch-row input { + width: 1.1rem; + height: 1.1rem; + accent-color: var(--primary); + flex-shrink: 0; + margin-top: 0.1rem; +} + +/* ---------- SearchableDropdown dialog ---------- */ +.sd-dialog { + max-width: 25rem; + height: 26rem; + max-height: 85vh; +} +.sd-list { + flex: 1; + overflow-y: auto; + margin: 0 -0.4rem; +} +.sd-dialog .dropdown-search { + position: static; + border: 1px solid var(--border); + border-radius: 0.6rem; +} diff --git a/web-react/src/theme/ThemeContext.tsx b/web-react/src/theme/ThemeContext.tsx new file mode 100644 index 0000000..662790c --- /dev/null +++ b/web-react/src/theme/ThemeContext.tsx @@ -0,0 +1,86 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; +import { + THEMES, + DEFAULT_THEME_ID, + SCALE_DEFAULT, + SCALE_MIN, + SCALE_MAX, + getTheme, + autoScale, +} from "./themes"; + +const THEME_KEY = "ui_theme"; +const SCALE_KEY = "ui_scale"; + +interface ThemeCtx { + themeId: string; + scale: number; + setThemeId: (id: string) => void; + setScale: (s: number) => void; +} + +const Ctx = createContext(null); + +function clampScale(s: number): number { + return Math.max(SCALE_MIN, Math.min(SCALE_MAX, s)); +} + +function applyTheme(themeId: string) { + const t = getTheme(themeId); + const root = document.documentElement; + root.style.setProperty("--bg", t.background); + root.style.setProperty("--surface", t.surface); + root.style.setProperty("--surface-alt", t.surfaceAlt); + // тёмная тема — светлый текст + const isDark = themeId === "dark"; + root.style.setProperty("--text-primary", isDark ? "#F4F6F8" : "#1E2A3A"); + root.style.setProperty("--text-secondary", isDark ? "#AEB9C7" : "#5A6779"); + root.style.setProperty("--border", isDark ? "#4A5C72" : "#DDE3EA"); +} + +function applyScale(scale: number) { + document.documentElement.style.setProperty("--scale", String(scale)); +} + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [themeId, setThemeIdState] = useState(() => { + const saved = localStorage.getItem(THEME_KEY); + return saved && THEMES.some((t) => t.id === saved) ? saved : DEFAULT_THEME_ID; + }); + + const [scale, setScaleState] = useState(() => { + const saved = localStorage.getItem(SCALE_KEY); + if (saved != null) { + const n = parseFloat(saved); + if (!Number.isNaN(n)) return clampScale(n); + } + return autoScale() || SCALE_DEFAULT; + }); + + useEffect(() => { + applyTheme(themeId); + }, [themeId]); + + useEffect(() => { + applyScale(scale); + }, [scale]); + + const setThemeId = (id: string) => { + setThemeIdState(id); + localStorage.setItem(THEME_KEY, id); + }; + + const setScale = (s: number) => { + const c = clampScale(s); + setScaleState(c); + localStorage.setItem(SCALE_KEY, String(c)); + }; + + return {children}; +} + +export function useTheme(): ThemeCtx { + const ctx = useContext(Ctx); + if (!ctx) throw new Error("useTheme must be used within ThemeProvider"); + return ctx; +} diff --git a/web-react/src/theme/themes.ts b/web-react/src/theme/themes.ts new file mode 100644 index 0000000..a9e3ada --- /dev/null +++ b/web-react/src/theme/themes.ts @@ -0,0 +1,58 @@ +// Темы и палитра. Перенос из web/theme_manager.py (THEMES) и web/designer.py (colors). +// Каждая тема переопределяет только фоновые цвета; остальная палитра общая. + +export interface Theme { + id: string; + name: string; + background: string; + surface: string; + surfaceAlt: string; +} + +export const THEMES: Theme[] = [ + { id: "default", name: "ЦОПП (оранжевая)", background: "#F4F6F8", surface: "#FFFFFF", surfaceAlt: "#E4E8ED" }, + { id: "warm", name: "Тёплая", background: "#FFF3E6", surface: "#FFFFFF", surfaceAlt: "#FFD9B3" }, + { id: "blue", name: "Синяя", background: "#E3F2FD", surface: "#FFFFFF", surfaceAlt: "#B8D9F5" }, + { id: "green", name: "Зелёная", background: "#EDF7E7", surface: "#FFFFFF", surfaceAlt: "#BEE3B0" }, + { id: "dark", name: "Тёмная", background: "#1E2A3A", surface: "#2D3F54", surfaceAlt: "#3D5068" }, +]; + +export const DEFAULT_THEME_ID = "default"; + +// Постоянная палитра (web/designer.py: class colors) +export const PALETTE = { + primary: "#F85A40", + primaryDark: "#D94530", + primaryLight: "#FFEAB7", + accent: "#FFC845", + accentLime: "#FFC845", + secondaryLight: "#FFEAB7", + info: "#007FBD", + infoDark: "#006AA3", + infoLight: "#D9F1F4", + success: "#85C446", + successLight: "#C0DE9E", + textPrimary: "#1E2A3A", + textSecondary: "#5A6779", + textOnPrimary: "#FFFFFF", + border: "#DDE3EA", +}; + +// Масштаб (web/theme_manager.py) +export const SCALE_MIN = 0.8; +export const SCALE_MAX = 2.5; +export const SCALE_STEP = 0.1; +export const SCALE_DEFAULT = 1.3; +// Автоопределение по ширине экрана +export const MOBILE_BREAKPOINT = 600; +export const SCALE_MOBILE = 1.0; +export const SCALE_DESKTOP = 1.5; + +export function getTheme(id: string | null | undefined): Theme { + return THEMES.find((t) => t.id === id) ?? THEMES[0]; +} + +export function autoScale(): number { + if (typeof window === "undefined") return SCALE_DEFAULT; + return window.innerWidth < MOBILE_BREAKPOINT ? SCALE_MOBILE : SCALE_DESKTOP; +} diff --git a/web-react/src/vite-env.d.ts b/web-react/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web-react/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web-react/tsconfig.json b/web-react/tsconfig.json new file mode 100644 index 0000000..d109555 --- /dev/null +++ b/web-react/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/web-react/vite.config.ts b/web-react/vite.config.ts new file mode 100644 index 0000000..c65b154 --- /dev/null +++ b/web-react/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// В dev-режиме проксируем /api на запущенный локально API (docker compose up -d api). +// В проде эту роль выполняет nginx (web-react/nginx.conf). +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + "/api": { + target: process.env.API_URL || "http://localhost:8000", + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ""), + }, + }, + }, +});