diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3c08aa4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +name: CI + +on: + push: + branches: [dev] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + changes: + name: Detect changed folders + runs-on: ubuntu-latest + outputs: + backend: ${{ steps.filter.outputs.backend }} + rag: ${{ steps.filter.outputs.rag }} + model: ${{ steps.filter.outputs.model }} + steps: + - uses: actions/checkout@v4 + + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + backend: + - 'server/**' + rag: + - 'scan_report/**' + model: + - 'scan_model/**' + + test-backend: + name: Backend - Jest tests + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.backend == 'true' + defaults: + run: + working-directory: server + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: server/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm run test + + lint-rag: + name: RAG - Ruff lint + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.rag == 'true' + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/ruff-action@v3 + with: + src: ./scan_report + + lint-model: + name: Model - Ruff lint + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.model == 'true' + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/ruff-action@v3 + with: + src: ./scan_model diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c6d88ba..b96c084 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: inputs: force_build: - description: 'Force rebuild all services (backend/rag/model)' + description: 'Force rebuild all services' required: false default: 'false' @@ -36,11 +36,58 @@ jobs: model: - 'scan_model/**' - build-backend: - name: Build & push backend image + test-backend: + name: Backend — Jest tests runs-on: ubuntu-latest needs: changes if: needs.changes.outputs.backend == 'true' + defaults: + run: + working-directory: server + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: server/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm run test + + lint-rag: + name: RAG — Ruff lint + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.rag == 'true' + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/ruff-action@v3 + with: + src: ./scan_report + + lint-model: + name: Model — Ruff lint + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.model == 'true' + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/ruff-action@v3 + with: + src: ./scan_model + + build-backend: + name: Build & push backend image + runs-on: ubuntu-latest + needs: [changes, test-backend] + if: needs.test-backend.result == 'success' steps: - uses: actions/checkout@v4 @@ -58,8 +105,8 @@ jobs: build-rag: name: Build & push rag image runs-on: ubuntu-latest - needs: changes - if: needs.changes.outputs.rag == 'true' + needs: [changes, lint-rag] + if: needs.lint-rag.result == 'success' steps: - uses: actions/checkout@v4 @@ -77,8 +124,8 @@ jobs: build-model: name: Build & push model image runs-on: ubuntu-latest - needs: changes - if: needs.changes.outputs.model == 'true' + needs: [changes, lint-model] + if: needs.lint-model.result == 'success' steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 5cbe69b..f003e2d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ doc *.env +RPT # compiled output */dist diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..46311e0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,125 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +TumorLens is a full-stack medical SaaS for brain tumor detection and reporting. It is a **monorepo** with four independent services: + +| Service | Stack | Port | +|---------|-------|------| +| `client/` | React 19 + Vite + TanStack Router/Query + Ant Design | 5173 | +| `server/` | NestJS 11 + Prisma 7 + PostgreSQL | 3000 | +| `scan_model/` | Python (Gradio) — brain tumor ML model | 7860 | +| `scan_report/` | Python (FastAPI) — RAG report generation | 8000 | + +There is no root `package.json`. Each service is managed independently. + +## Development Commands + +### Client (`cd client`) +```bash +npm run dev # Vite dev server with HMR +npm run build # tsc -b && vite build +npm run lint # ESLint +npm run preview # Preview production build +``` + +### Server (`cd server`) +```bash +npm run start:dev # NestJS watch mode +npm run build # NestJS compile to dist/ +npm run start # Run compiled dist/main.js +npm run test # Jest unit tests (*.spec.ts) +npm run test:e2e # Jest e2e tests +npm run lint # ESLint --fix +npm run format # Prettier +``` + +### Prisma (`cd server`) +```bash +npx prisma migrate dev # Apply migrations in development +npx prisma generate # Regenerate Prisma client after schema changes +npx prisma studio # Visual DB browser +``` + +### Running a single test +```bash +# From server/ +npx jest src/auth/auth.service.spec.ts +npx jest --testPathPattern=auth +``` + +### Full stack via Docker +```bash +docker-compose up # All services (backend, frontend, ML, RAG, postgres, redis) +docker-compose up backend # Single service +``` + +## Architecture + +### Client + +- **Routing**: TanStack Router (`client/src/router.ts`). All protected routes live under the dashboard layout. +- **Data fetching**: TanStack Query wraps all API calls. No direct `axios` calls in components — queries/mutations are co-located in `hooks.ts` files next to each feature. +- **Auth state**: Zustand store (`client/src/store/auth.store.ts`). Tokens are managed via HTTP-only cookies; the store holds user profile data. +- **HTTP client**: Single Axios instance at `client/src/lib/axios.ts` with interceptors for auth headers and 401 handling. +- **Features** live in `client/src/features/` — each folder is a route segment with its own pages, components, and hooks. + +### Server + +- **NestJS modules** are organized by domain in `server/src/`. Each module owns its controller, service, DTOs, and guards. +- **Database access** is exclusively via the Prisma client. No raw SQL outside of Prisma `$queryRaw`. +- **Authentication flow**: JWT access + refresh tokens delivered as HTTP-only cookies. Google OAuth via Passport strategy. 2FA via TOTP (otplib). Password reset via Resend email. +- **Async jobs**: Report generation is queued via BullMQ (Redis). The processor lives in `server/src/Report/`. +- **Real-time**: Server-Sent Events (SSE) for in-app notifications — see `server/src/Notifications/notifications.stream.ts`. +- **Storage**: DigitalOcean Spaces (S3-compatible) via AWS SDK — see `server/src/Storage/`. +- **Plan guards**: `@RequirePlan(Plan.DOCTOR)` decorator/guard enforces subscription tier on routes. +- **Public API**: API key authentication for external integrations in `server/src/Public-API/`. + +### Key data models (Prisma schema in `server/prisma/schema.prisma`) + +- `User` — roles: `USER | ADMIN`; plans: `FREE | DOCTOR | ORGANIZATION`; supports OTP/2FA +- `Organization` → `OrgMember` (multi-tenant hospital/clinic support with audit logs) +- `Patient` → `Scan` → `ScanResult` (core clinical data chain) +- `Subscription` + `Payment` (Stripe billing) +- `Session` (refresh token store) +- `ApiKey` (public API access) + +### ML services + +- `scan_model/` exposes a Gradio API; the NestJS server calls it via HTTP at `ML_MODEL_URL`. +- `scan_report/` exposes a FastAPI endpoint; the server calls it to generate RAG-based PDF reports. + +## Environment Setup + +**Server** — create `server/.env`: +``` +DATABASE_URL=postgresql://... +JWT_ACCESS_SECRET=... +JWT_REFRESH_SECRET=... +GOOGLE_CLIENT_ID=... +GOOGLE_CLIENT_SECRET=... +ML_MODEL_URL=http://localhost:7860 +ML_MODEL_API_KEY=... +DIGITAL_OCEAN_SPACE_KEY=... +DIGITAL_OCEAN_SPACE_SECRET=... +DIGITAL_OCEAN_SPACE_ENDPOINT=... +DIGITAL_OCEAN_SPACE_BUCKET=... +RESEND_API_KEY=... +STRIPE_SECRET_KEY=... +STRIPE_WEBHOOK_SECRET=... +REDIS_HOST=localhost +REDIS_PORT=6379 +CLIENT_URL=http://localhost:5173 +PORT=3000 +``` + +**Client** — create `client/.env`: +``` +VITE_API_URL=http://localhost:3000 +``` + +## CI/CD + +GitHub Actions (`.github/workflows/deploy.yml`) builds Docker images and deploys to a DigitalOcean Droplet on push to `main`. Path filters ensure only changed services are rebuilt (`server/`, `scan_report/`, `scan_model/`). Images are pushed to GHCR. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7cd75b9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2026 Osama. All Rights Reserved. + +TumorLens - Proprietary Software License + +This software and its associated source code, documentation, models, datasets, +and assets (collectively, the "Software") are the proprietary and confidential +property of the copyright holder. + +NO PERMISSION is granted to any person to use, copy, modify, merge, publish, +distribute, sublicense, sell, or otherwise exploit the Software, in whole or in +part, by any means, without the prior written permission of the copyright +holder. + +The Software is provided for viewing and evaluation purposes only, as an +academic final-year graduation project. Any other use is strictly prohibited. + +MEDICAL DISCLAIMER + +The Software is a research and educational project. It is NOT a certified +medical device and must NOT be used for actual clinical diagnosis, treatment, +or any real-world medical decision-making. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, +OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..aaac79f --- /dev/null +++ b/README.md @@ -0,0 +1,223 @@ +
+ TumorLens logo + + # TumorLens +
+ +> A full-stack medical SaaS platform for **brain tumor detection and AI-assisted clinical reporting** from MRI scans. + +TumorLens lets clinicians upload a brain MRI scan, runs it through a deep-learning classifier, and generates a retrieval-augmented (RAG) clinical report - all wrapped in a multi-tenant SaaS with subscriptions, organizations, and a public API. + +> 🎓 **This project was developed as a final-year graduation project.** + +--- + +## ✨ Features + +- **AI tumor classification** - EfficientNet-B0 model detects `glioma`, `meningioma`, `pituitary`, or `no tumor` from MRI scans, with a heuristic guard that rejects non-MRI images. +- **RAG clinical reports** - Generates PDF reports grounded in medical literature using OpenAI embeddings and a Qdrant vector database. +- **Authentication & security** - JWT access/refresh tokens (HTTP-only cookies), Google OAuth, TOTP-based 2FA, and password reset via email. +- **Multi-tenancy** - Organizations with members, roles, and audit logs for hospitals and clinics. +- **Subscriptions & billing** - Stripe-powered plans (`FREE`, `DOCTOR`, `ORGANIZATION`) with route-level plan guards. +- **Patient management** - Full clinical data chain: `Patient → Scan → ScanResult`. +- **Real-time notifications** - Server-Sent Events (SSE) for in-app updates. +- **Async processing** - Report generation queued with BullMQ + Redis. +- **Cloud storage** - DigitalOcean Spaces (S3-compatible) for scans and reports. +- **Public API** - API-key authentication for external integrations. + +--- + +## 🏗️ Architecture + +TumorLens is a **monorepo** of four independent services: + +| Service | Stack | Port | Description | +|---------|-------|------|-------------| +| [`client/`](client/) | React 19 · Vite · TanStack Router/Query · Ant Design | 5173 | Web frontend | +| [`server/`](server/) | NestJS 11 · Prisma 7 · PostgreSQL · Redis | 3000 | Core API & business logic | +| [`scan_model/`](scan_model/) | Python · FastAPI · PyTorch · timm | 7860 | Brain tumor ML inference | +| [`scan_report/`](scan_report/) | Python · FastAPI · OpenAI · Qdrant | 8000 | RAG-based report generation | + +``` + ┌─────────────┐ + │ client/ │ React 19 SPA (5173) + └──────┬──────┘ + │ HTTP / cookies + ┌──────▼──────┐ + │ server/ │ NestJS API (3000) + │ Prisma → │──── PostgreSQL + │ BullMQ → │──── Redis + └──┬───────┬──┘ + │ │ + ML_MODEL_URL│ │RAG_REPORT_URL + ┌───────▼─┐ ┌─▼──────────┐ + │scan_model│ │scan_report │ + │ (7860) │ │ (8000) │ + └──────────┘ └────────────┘ +``` + +--- + +## 🚀 Getting Started + +### Prerequisites + +- [Docker](https://www.docker.com/) & Docker Compose (recommended), **or** +- Node.js 20+, Python 3.11+ with [uv](https://github.com/astral-sh/uv), PostgreSQL, and Redis for local development. + +### Run the full stack with Docker + +```bash +git clone tumorlens +cd tumorlens + +# Create the required .env files (see Environment Setup below) +docker-compose up +``` + +Services will be available at: + +- Frontend → http://localhost:5173 +- API → http://localhost:3000 +- ML model → http://localhost:7860 +- RAG reports → http://localhost:8000 + +--- + +## 🛠️ Local Development + +### Client (`cd client`) + +```bash +npm install +npm run dev # Vite dev server with HMR +npm run build # tsc -b && vite build +npm run lint # ESLint +``` + +### Server (`cd server`) + +```bash +npm install +npm run start:dev # NestJS watch mode +npm run test # Jest unit tests +npm run lint # ESLint --fix + +# Prisma +npx prisma migrate dev # Apply migrations +npx prisma generate # Regenerate client +npx prisma studio # Visual DB browser +``` + +### ML services (`cd scan_model` / `cd scan_report`) + +```bash +uv sync # Install dependencies +uv run uvicorn main:app --reload # (scan_report) +uv run python app.py # (scan_model) +``` + +--- + +## ⚙️ Environment Setup + +**`server/.env`** + +```env +DATABASE_URL=postgresql://... +JWT_ACCESS_SECRET=... +JWT_REFRESH_SECRET=... +GOOGLE_CLIENT_ID=... +GOOGLE_CLIENT_SECRET=... +ML_MODEL_URL=http://localhost:7860 +ML_MODEL_API_KEY=... +RAG_REPORT_URL=http://localhost:8000 +DIGITAL_OCEAN_SPACE_KEY=... +DIGITAL_OCEAN_SPACE_SECRET=... +DIGITAL_OCEAN_SPACE_ENDPOINT=... +DIGITAL_OCEAN_SPACE_BUCKET=... +RESEND_API_KEY=... +STRIPE_SECRET_KEY=... +STRIPE_WEBHOOK_SECRET=... +REDIS_HOST=localhost +REDIS_PORT=6379 +CLIENT_URL=http://localhost:5173 +PORT=3000 +``` + +**`client/.env`** + +```env +VITE_API_URL=http://localhost:3000 +``` + +**`scan_report/.env`** + +```env +OPENAI_API_KEY=... +API_KEY=... +QDRANT_URL=... +QDRANT_API_KEY=... +``` + +--- + +## 🧬 Core Data Models + +The Prisma schema lives in [`server/prisma/schema.prisma`](server/prisma/schema.prisma): + +- **User** - roles (`USER`, `ADMIN`), plans (`FREE`, `DOCTOR`, `ORGANIZATION`), OTP/2FA support +- **Organization → OrgMember** - multi-tenant clinics with audit logs +- **Patient → Scan → ScanResult** - core clinical data chain +- **Subscription + Payment** - Stripe billing +- **Session** - refresh token store +- **ApiKey** - public API access + +--- + +## 🤖 The ML Model + +- Architecture: **EfficientNet-B0** (via [`timm`](https://github.com/huggingface/pytorch-image-models)), 4-class classifier. +- Classes: `glioma`, `meningioma`, `notumor`, `pituitary`. +- Trained on the **Brain Tumor MRI Dataset** (see [`doc/`](doc/)). +- Includes a lightweight heuristic check to reject obviously non-MRI input (e.g. colored or non-scan images) before inference. + +--- + +## 🔄 CI/CD + +GitHub Actions ([`.github/workflows/`](.github/workflows/)) runs tests and linting (Jest + Ruff) and, on push to `main`, builds Docker images and deploys to a DigitalOcean Droplet. Path filters ensure only changed services are rebuilt. Images are published to GHCR. + +--- + +## 📂 Repository Structure + +``` +TumorLens/ +├── client/ # React 19 frontend +├── server/ # NestJS API + Prisma +├── scan_model/ # FastAPI ML inference service +├── scan_report/ # FastAPI RAG report service +├── doc/ # Documentation, dataset, diagrams +├── docker-compose.yml +└── CLAUDE.md # Project guidance +``` + +--- + +## ⚠️ Medical Disclaimer + +TumorLens is a **research and educational project**. It is **not** a certified medical +device and must **not** be used for actual clinical diagnosis, treatment, or any +real-world medical decision-making. + +--- + +## 📜 License + +**Copyright © 2026 Osama. All Rights Reserved.** + +This is **proprietary software** developed as a final-year graduation project. +No permission is granted to use, copy, modify, distribute, or otherwise exploit +the code, models, or assets without the prior written consent of the author. +See the [LICENSE](LICENSE) file for full terms. diff --git a/client/.dockerignore b/client/.dockerignore new file mode 100644 index 0000000..64e3522 --- /dev/null +++ b/client/.dockerignore @@ -0,0 +1,12 @@ +node_modules +dist +.env +.env.* +.git +.gitignore +.vscode +.idea +npm-debug.log +Dockerfile +.dockerignore +README.md diff --git a/client/Dockerfile b/client/Dockerfile new file mode 100644 index 0000000..c3adc5e --- /dev/null +++ b/client/Dockerfile @@ -0,0 +1,28 @@ +# ---------- Stage 1: build ---------- +FROM node:22-alpine AS builder + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY tsconfig*.json vite.config.ts index.html ./ +COPY public ./public +COPY src ./src + +ARG VITE_API_URL +ENV VITE_API_URL=${VITE_API_URL} + +RUN npm run build + +# ---------- Stage 2: runtime ---------- +FROM nginx:1.27-alpine AS runner + +RUN rm /etc/nginx/conf.d/default.conf +COPY nginx.conf /etc/nginx/conf.d/default.conf + +COPY --from=builder /app/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/client/bun.lock b/client/bun.lock index c19abf4..60de920 100644 --- a/client/bun.lock +++ b/client/bun.lock @@ -5,11 +5,16 @@ "": { "name": "client", "dependencies": { + "@marsidev/react-turnstile": "^1.5.3", "@tailwindcss/vite": "^4.2.2", "@tanstack/react-query": "^5.96.2", "@tanstack/react-router": "^1.168.7", "antd": "^6.3.4", + "antd-img-crop": "^4.30.0", "axios": "1.14.0", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.1", + "qrcode.react": "^4.2.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-icons": "^5.6.0", @@ -132,6 +137,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@marsidev/react-turnstile": ["@marsidev/react-turnstile@1.5.3", "", { "peerDependencies": { "react": "^17.0.2 || ^18.0.0 || ^19.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" } }, "sha512-8Dij2jiNGNczq1U4EKpO4do2XepcTPxSMc2ZzvHndO+gcp68tvMULm27z2P99rGkdB89hc3452NZeu2Rti4g6A=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], "@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="], @@ -344,10 +351,16 @@ "@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + "@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="], + + "@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/type-utils": "8.57.2", "@typescript-eslint/utils": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA=="], @@ -380,6 +393,8 @@ "antd": ["antd@6.3.4", "", { "dependencies": { "@ant-design/colors": "^8.0.1", "@ant-design/cssinjs": "^2.1.2", "@ant-design/cssinjs-utils": "^2.1.2", "@ant-design/fast-color": "^3.0.1", "@ant-design/icons": "^6.1.0", "@ant-design/react-slick": "~2.0.0", "@babel/runtime": "^7.28.4", "@rc-component/cascader": "~1.14.0", "@rc-component/checkbox": "~2.0.0", "@rc-component/collapse": "~1.2.0", "@rc-component/color-picker": "~3.1.1", "@rc-component/dialog": "~1.8.4", "@rc-component/drawer": "~1.4.2", "@rc-component/dropdown": "~1.0.2", "@rc-component/form": "~1.8.0", "@rc-component/image": "~1.8.0", "@rc-component/input": "~1.1.2", "@rc-component/input-number": "~1.6.2", "@rc-component/mentions": "~1.6.0", "@rc-component/menu": "~1.2.0", "@rc-component/motion": "^1.3.1", "@rc-component/mutate-observer": "^2.0.1", "@rc-component/notification": "~1.2.0", "@rc-component/pagination": "~1.2.0", "@rc-component/picker": "~1.9.1", "@rc-component/progress": "~1.0.2", "@rc-component/qrcode": "~1.1.1", "@rc-component/rate": "~1.0.1", "@rc-component/resize-observer": "^1.1.1", "@rc-component/segmented": "~1.3.0", "@rc-component/select": "~1.6.15", "@rc-component/slider": "~1.0.1", "@rc-component/steps": "~1.2.2", "@rc-component/switch": "~1.0.3", "@rc-component/table": "~1.9.1", "@rc-component/tabs": "~1.7.0", "@rc-component/textarea": "~1.1.2", "@rc-component/tooltip": "~1.4.0", "@rc-component/tour": "~2.3.0", "@rc-component/tree": "~1.2.4", "@rc-component/tree-select": "~1.8.0", "@rc-component/trigger": "^3.9.0", "@rc-component/upload": "~1.1.0", "@rc-component/util": "^1.10.0", "clsx": "^2.1.1", "dayjs": "^1.11.11", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-Bu6JivPP7bFfYIdVj+61dxhwSOz+A3m0W7PlDasFGC3H3sNMYQ9gJXZoo11/rQh7pTlOQa351q5Ig/zjI98XYw=="], + "antd-img-crop": ["antd-img-crop@4.30.0", "", { "dependencies": { "react-easy-crop": "^5.5.6", "tslib": "^2.8.1" }, "peerDependencies": { "antd": ">=4.0.0", "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-fjpwyCNKtKr22AQcENBix5Y+P8ECM80Ivk/Yn7kF9D0XltYBGJHg+3yycxcGq7ATgnl7pgPYDvN2VzKleogCLQ=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], @@ -390,6 +405,8 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg=="], "brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], @@ -402,6 +419,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001781", "", {}, "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw=="], + "canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], @@ -422,8 +441,12 @@ "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="], + "core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="], @@ -436,6 +459,8 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "dompurify": ["dompurify@3.4.8", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "electron-to-chromium": ["electron-to-chromium@1.5.328", "", {}, "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w=="], @@ -480,8 +505,12 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-png": ["fast-png@6.4.0", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } }, "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], @@ -524,6 +553,8 @@ "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + "html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "immutable": ["immutable@5.1.5", "", {}, "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A=="], @@ -532,6 +563,8 @@ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], @@ -560,6 +593,8 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jspdf": ["jspdf@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.3.1", "html2canvas": "^1.0.0-rc.5" } }, "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], @@ -614,18 +649,24 @@ "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="], + "normalize-wheel": ["normalize-wheel@1.0.1", "", {}, "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "pako": ["pako@2.1.0", "", {}, "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], @@ -638,18 +679,28 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="], + + "raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="], + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + "react-easy-crop": ["react-easy-crop@5.5.7", "", { "dependencies": { "normalize-wheel": "^1.0.1", "tslib": "^2.0.1" }, "peerDependencies": { "react": ">=16.4.0", "react-dom": ">=16.4.0" } }, "sha512-kYo4NtMeXFQB7h1U+h5yhUkE46WQbQdq7if54uDlbMdZHdRgNehfvaFrXnFw5NR1PNoUOJIfTwLnWmEx/MaZnA=="], + "react-icons": ["react-icons@5.6.0", "", { "peerDependencies": { "react": "*" } }, "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA=="], "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="], + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="], + "rolldown": ["rolldown@1.0.0-rc.12", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.12" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="], "sass": ["sass@1.98.0", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A=="], @@ -670,6 +721,8 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="], + "string-convert": ["string-convert@0.2.1", "", {}, "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A=="], "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], @@ -678,10 +731,14 @@ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "svg-pathdata": ["svg-pathdata@6.0.3", "", {}, "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw=="], + "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], + "text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="], + "throttle-debounce": ["throttle-debounce@5.0.2", "", {}, "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], @@ -704,6 +761,8 @@ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], + "vite": ["vite@8.0.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], diff --git a/client/index.html b/client/index.html index 54cd8c2..c151f60 100644 --- a/client/index.html +++ b/client/index.html @@ -2,9 +2,14 @@ - TumorLens + + + + + + diff --git a/client/nginx.conf b/client/nginx.conf new file mode 100644 index 0000000..6dd99eb --- /dev/null +++ b/client/nginx.conf @@ -0,0 +1,21 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml; + gzip_min_length 1024; + + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/client/package.json b/client/package.json index a5c1067..5c48a33 100644 --- a/client/package.json +++ b/client/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@marsidev/react-turnstile": "^1.5.3", "@tailwindcss/vite": "^4.2.2", "@tanstack/react-query": "^5.96.2", "@tanstack/react-router": "^1.168.7", diff --git a/client/public/favicon_io/android-chrome-512x512.png b/client/public/android-chrome-512x512.png similarity index 100% rename from client/public/favicon_io/android-chrome-512x512.png rename to client/public/android-chrome-512x512.png diff --git a/client/public/favicon_io/apple-touch-icon.png b/client/public/apple-touch-icon.png similarity index 100% rename from client/public/favicon_io/apple-touch-icon.png rename to client/public/apple-touch-icon.png diff --git a/client/public/favicon_io/favicon-16x16.png b/client/public/favicon-16x16.png similarity index 100% rename from client/public/favicon_io/favicon-16x16.png rename to client/public/favicon-16x16.png diff --git a/client/public/favicon_io/favicon-32x32.png b/client/public/favicon-32x32.png similarity index 100% rename from client/public/favicon_io/favicon-32x32.png rename to client/public/favicon-32x32.png diff --git a/client/public/favicon.svg b/client/public/favicon.svg deleted file mode 100644 index 6893eb1..0000000 --- a/client/public/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/client/public/favicon_io.zip b/client/public/favicon_io.zip deleted file mode 100644 index ef9ddbd..0000000 Binary files a/client/public/favicon_io.zip and /dev/null differ diff --git a/client/public/favicon_io/site.webmanifest b/client/public/favicon_io/site.webmanifest deleted file mode 100644 index 45dc8a2..0000000 --- a/client/public/favicon_io/site.webmanifest +++ /dev/null @@ -1 +0,0 @@ -{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/client/public/icons.svg b/client/public/icons.svg deleted file mode 100644 index e952219..0000000 --- a/client/public/icons.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/client/public/site.webmanifest b/client/public/site.webmanifest new file mode 100644 index 0000000..bd50e81 --- /dev/null +++ b/client/public/site.webmanifest @@ -0,0 +1 @@ +{"name":"TumorLens","short_name":"TumorLens","icons":[{"src":"/favicon_io/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} diff --git a/client/src/features/auth/google-callback.tsx b/client/src/features/auth/google-callback.tsx index e2fd926..186e3fe 100644 --- a/client/src/features/auth/google-callback.tsx +++ b/client/src/features/auth/google-callback.tsx @@ -31,9 +31,7 @@ export const GoogleCallbackPage = () => { data.user, data.notificationSettings ); - navigate({ - to: "/" - }); + navigate({ to: "/" }); }) .catch(() => navigate({ to: "/login" })); }, [navigate, setAuth, token]); diff --git a/client/src/features/auth/hooks.ts b/client/src/features/auth/hooks.ts index 2223306..c163656 100644 --- a/client/src/features/auth/hooks.ts +++ b/client/src/features/auth/hooks.ts @@ -5,7 +5,7 @@ import { queryClient } from "../../main"; // Login export const useSignin = () => useMutation({ - mutationFn: async (data: { email: string; password: string; twoFactorCode?: string }) => { + mutationFn: async (data: { email: string; password: string; twoFactorCode?: string; turnstileToken?: string | null }) => { const response = await axiosInstance.post("/auth/login", data); return response.data; }, @@ -13,7 +13,7 @@ export const useSignin = () => useMutation({ // Register export const useSignup = () => useMutation({ - mutationFn: async (data: { email: string; password: string; username: string }) => { + mutationFn: async (data: { email: string; password: string; username: string; turnstileToken?: string | null }) => { const response = await axiosInstance.post("/auth/register", data); return response.data; }, @@ -27,6 +27,7 @@ export const useLogout = () => useMutation({ }, onSettled: () => { queryClient.clear(); + window.location.reload(); }, }); diff --git a/client/src/features/auth/login.tsx b/client/src/features/auth/login.tsx index ac3da9a..3a1bf98 100644 --- a/client/src/features/auth/login.tsx +++ b/client/src/features/auth/login.tsx @@ -14,11 +14,12 @@ export const LoginPage = () => { const [errorMsg, setErrorMsg] = useState(null); const [twoFactorStep, setTwoFactorStep] = useState(false); const [credentials, setCredentials] = useState<{ email: string; password: string } | null>(null); + const [turnstileReset, setTurnstileReset] = useState(0); - const handleSubmit = (values: { email: string; password: string; remember: boolean }) => { + const handleSubmit = (values: { email: string; password: string; remember: boolean; turnstileToken: string | null }) => { setErrorMsg(null); signin( - { email: values.email, password: values.password }, + { email: values.email, password: values.password, turnstileToken: values.turnstileToken }, { onSuccess: data => { if (data?.twoFactorRequired) { @@ -30,6 +31,7 @@ export const LoginPage = () => { navigate({ to: "/" }); }, onError: error => { + setTurnstileReset(n => n + 1); const e = error as { response?: { data?: { message?: string } } }; setErrorMsg(e.response?.data?.message ?? "Invalid email or password. Please try again."); }, @@ -86,6 +88,7 @@ export const LoginPage = () => { errorMsg={errorMsg} onSubmit={handleSubmit} onValuesChange={() => setErrorMsg(null)} + resetSignal={turnstileReset} /> )} diff --git a/client/src/features/auth/login/LoginForm.tsx b/client/src/features/auth/login/LoginForm.tsx index 535a341..e6c9d9c 100644 --- a/client/src/features/auth/login/LoginForm.tsx +++ b/client/src/features/auth/login/LoginForm.tsx @@ -1,11 +1,14 @@ +import { useState } from "react"; import { Button, Checkbox, Form, Input } from "antd"; import { Link } from "@tanstack/react-router"; +import { TurnstileWidget, turnstileEnabled } from "../shared/TurnstileWidget"; interface Props { loading: boolean; errorMsg: string | null; - onSubmit: (values: { email: string; password: string; remember: boolean }) => void; + onSubmit: (values: { email: string; password: string; remember: boolean; turnstileToken: string | null }) => void; onValuesChange: () => void; + resetSignal?: number; } const GoogleSvg = () => ( @@ -33,8 +36,9 @@ const labelStyle = { letterSpacing: "0.05em", }; -export const LoginForm = ({ loading, errorMsg, onSubmit, onValuesChange }: Props) => { +export const LoginForm = ({ loading, errorMsg, onSubmit, onValuesChange, resetSignal }: Props) => { const [form] = Form.useForm(); + const [turnstileToken, setTurnstileToken] = useState(null); return ( <> @@ -52,7 +56,7 @@ export const LoginForm = ({ loading, errorMsg, onSubmit, onValuesChange }: Props
onSubmit({ ...values, turnstileToken })} onValuesChange={onValuesChange} requiredMark={false} > @@ -110,13 +114,15 @@ export const LoginForm = ({ loading, errorMsg, onSubmit, onValuesChange }: Props )} + +