commit 9e5e074bbe7ec0b00a86d65e6e7c79ff0e514556 Author: tech08mag Date: Sun Sep 13 12:59:16 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e472ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv +downloads/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c6fb7c --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# MangaDL + +A web UI for browsing and downloading manga using [ifetch](https://github.com/FelixSiegel/ifetch) as the backend. + +## Quick Start + +### Using Docker Compose (Recommended) + +```bash +docker-compose up -d +``` + +This will start: +- **ifetch backend** on port 8080 +- **Frontend** on port 3000 + +Access the web UI at `http://localhost:3000` + +### Manual Setup + +#### 1. Start the Backend (ifetch) + +```bash +# Using Docker +docker run -d \ + --name ifetch \ + -p 8080:8080 \ + -v ./downloads:/app/downloads \ + -v ./config:/app/config \ + ghcr.io/felixsiegel/ifetch:latest + +# Or build from source +git clone https://github.com/FelixSiegel/ifetch.git +cd ifetch +cargo build --release +./target/release/ifetch --server --port 8080 --output ./downloads --config ./config +``` + +#### 2. Start the Frontend + +```bash +cd frontend +npm install +npm run dev +``` + +Access the web UI at `http://localhost:3000` + +## Features + +- 🔍 **Search manga** from MangaKatana +- 📚 **Browse manga details** - cover, description, genres, authors, status +- 📖 **Read chapters** with a full-screen reader +- ⬇️ **Download chapters** via the ifetch backend +- 🌓 **Dark/Light mode** support +- 📱 **Responsive design** - works on mobile and desktop +- ⌨️ **Keyboard navigation** in reader (←/→ for pages, F for fullscreen, Esc to exit) + +## Project Structure + +``` +MangaDL/ +├── docker-compose.yml # Docker Compose for full stack +├── downloads/ # Manga downloads (mounted to ifetch) +├── config/ # ifetch config (mounted to ifetch) +├── frontend/ # React + TypeScript frontend +│ ├── src/ +│ │ ├── api/ # API client +│ │ ├── components/ # React components +│ │ ├── pages/ # Page components +│ │ ├── store/ # Zustand state management +│ │ └── types/ # TypeScript types +│ ├── Dockerfile # Frontend Dockerfile +│ ├── nginx.conf # Nginx config for production +│ └── package.json +└── README.md +``` + +## API Endpoints Used + +The frontend consumes these ifetch API endpoints: + +- `GET /api/search?q={query}` - Search manga +- `GET /api/manga/{id}` - Get manga details +- `GET /api/manga/{id}/chapters` - Get chapters (triggers background download) +- `GET /api/chapter/{id}::{number}/pages` - Get page URLs for a chapter +- `GET /api/image/{id}::{number}/{filename}` - Serve chapter images + +## Keyboard Shortcuts (Reader) + +| Key | Action | +|-----|--------| +| ← / → | Previous / Next page | +| F | Toggle fullscreen | +| Esc | Exit fullscreen / Close reader | + +## Configuration + +### Backend (ifetch) + +Environment variables for ifetch: +- `IFETCH_PORT` - Server port (default: 8080) +- `IFETCH_THREADS` - Download threads (default: 1) +- `IFETCH_CRON_HOURS` - Auto-update interval in hours (default: 12) +- `DISCORD_WEBHOOK_URL` - Optional Discord notifications + +### Frontend + +Environment variables (`.env`): +- `VITE_API_BASE` - Backend API base URL (default: `http://localhost:8080`) + +## Development + +```bash +# Frontend development +cd frontend +npm run dev + +# Build for production +npm run build + +# Preview production build +npm run preview +``` + +## License + +MIT \ No newline at end of file diff --git a/config/library.db b/config/library.db new file mode 100644 index 0000000..4a3492c Binary files /dev/null and b/config/library.db differ diff --git a/config/library.db-shm b/config/library.db-shm new file mode 100644 index 0000000..59b903f Binary files /dev/null and b/config/library.db-shm differ diff --git a/config/library.db-wal b/config/library.db-wal new file mode 100644 index 0000000..0cf4b7c Binary files /dev/null and b/config/library.db-wal differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..19abd9f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +version: '3.8' + +services: + ifetch: + image: ghcr.io/felixsiegel/ifetch:latest + container_name: mangadl-ifetch + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - ./downloads:/app/downloads + - ./config:/app/config + environment: + - IFETCH_PORT=8080 + - IFETCH_THREADS=2 + - IFETCH_CRON_HOURS=12 + - DISCORD_WEBHOOK_URL= + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: mangadl-frontend + restart: unless-stopped + ports: + - "3000:80" + depends_on: + - ifetch + environment: + - VITE_API_BASE=http://ifetch:8080 + +volumes: + downloads: + config: \ No newline at end of file diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..7000834 --- /dev/null +++ b/frontend/.env @@ -0,0 +1 @@ +VITE_API_BASE= \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..1b40457 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,20 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +# Production stage +FROM nginx:alpine + +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..76cd8ce --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,114 @@ +# MangaDL Frontend + +A React + TypeScript web UI for browsing and reading manga using the [ifetch](https://github.com/FelixSiegel/ifetch) backend. + +## Features + +- 🔍 Search manga from MangaKatana +- 📚 Browse manga details, chapters, and metadata +- 📖 Read manga chapters with full-screen reader +- ⬇️ Download chapters (via ifetch backend) +- 🌓 Dark/Light mode support +- 📱 Responsive design for mobile and desktop +- ⌨️ Keyboard navigation in reader (←/→ for pages, F for fullscreen, Esc to exit) + +## Tech Stack + +- React 18 + TypeScript +- Vite for development and building +- React Router for navigation +- Zustand for state management +- Axios for API calls +- Lucide React for icons + +## Getting Started + +### Prerequisites + +1. **Node.js 18+** and npm +2. **ifetch backend** running on port 8080 + +### Start the Backend (ifetch) + +```bash +# Using Docker (recommended) +docker run -d \ + --name ifetch \ + -p 8080:8080 \ + -v ./downloads:/app/downloads \ + -v ./config:/app/config \ + ghcr.io/felixsiegel/ifetch:latest + +# Or build from source +git clone https://github.com/FelixSiegel/ifetch.git +cd ifetch +cargo build --release +./target/release/ifetch --server --port 8080 --output ./downloads --config ./config +``` + +### Start the Frontend + +```bash +cd frontend +npm install +npm run dev +``` + +The frontend will be available at `http://localhost:3000` with API calls proxied to `http://localhost:8080`. + +## Project Structure + +``` +frontend/ +├── src/ +│ ├── api/ # API client functions +│ ├── components/ # Reusable UI components +│ ├── pages/ # Page components +│ ├── store/ # Zustand state management +│ ├── types/ # TypeScript types +│ ├── App.tsx # Main app with routing +│ ├── main.tsx # Entry point +│ └── index.css # Global styles +├── index.html +├── package.json +├── tsconfig.json +├── vite.config.ts +└── .env +``` + +## Available Scripts + +- `npm run dev` - Start development server +- `npm run build` - Build for production +- `npm run preview` - Preview production build +- `npm run lint` - Run ESLint + +## API Endpoints Used + +The frontend consumes these ifetch API endpoints: + +- `GET /api/search?q={query}` - Search manga +- `GET /api/manga/{id}` - Get manga details +- `GET /api/manga/{id}/chapters` - Get chapters (triggers background download) +- `GET /api/chapter/{id}::{number}/pages` - Get page URLs for a chapter +- `GET /api/image/{id}::{number}/{filename}` - Serve chapter images + +## Keyboard Shortcuts (Reader) + +| Key | Action | +|-----|--------| +| ← / → | Previous / Next page | +| F | Toggle fullscreen | +| Esc | Exit fullscreen / Close reader | + +## Configuration + +Environment variables (`.env`): + +| Variable | Default | Description | +|----------|---------|-------------| +| `VITE_API_BASE` | `http://localhost:8080` | Backend API base URL | + +## License + +MIT \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0fca6f0 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..25ead4b --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,50 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN"; + add_header X-Content-Type-Options "nosniff"; + add_header X-XSS-Protection "1; mode=block"; + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy API requests to ifetch backend + location /api/ { + proxy_pass http://ifetch:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + 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_cache_bypass $http_upgrade; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + } + + # Health check + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..846e6cd --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1725 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "axios": "^1.20.0", + "lucide-react": "^1.45.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.3", + "zustand": "^5.0.15" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "oxlint": "^1.81.0", + "typescript": "~6.0.2", + "vite": "^8.3.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.82.0.tgz", + "integrity": "sha512-a3LB+C5Dsj5b/qtmG/mv5WrzuiXEpg1KF5nXWcEvaoN5TYAqkIvxPOwTPp3Jy/FoGpRo8zsTFhMElMXfeoOEzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.82.0.tgz", + "integrity": "sha512-OBlhRgNqFblGpGenno/aqOfJLOkQ2B8Ig3iDAalfn0H8hJGZKXPeexCRTDm6uwv6YUjSA9Xnwt1y/Bgj5ZH8uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.82.0.tgz", + "integrity": "sha512-dsopxqtY5ZdyT9uLHyGt1SyiLop6hi7hWI3PKpePodkRQOkLaCm+OE4fR9CAz9qdfjiFO8531tX/QDyP/psjFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.82.0.tgz", + "integrity": "sha512-94Lu0SgTClKColU66g1VDuigV3HkcbkJBnTtZjGYfE8UPugaWDgKrm2icjC6HJVUYler2OXaHP/X0TBy8+CowQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.82.0.tgz", + "integrity": "sha512-hne/V06ewhh1i0w8+l7GDNROAGCGPmyFuOwiP7YTRu0JycyStJ4785dmF8xU5p0uUwt2emvIF9vc7Xjis+cJ0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.82.0.tgz", + "integrity": "sha512-aWY2xtbZf1LneW9Qsv/n2Sp8gOu74JrlQzEtj4coHX2SHFrCfhmAumaU+sI/A5nr+yoTRTSmI/pL2s6ADlNSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.82.0.tgz", + "integrity": "sha512-Fe+TtXCXMh/5f7kWlZ2VAwsMumZWtraFlKVk1NJlL52/beGwfDE7ov+/8gVirHzWokzGu7X65hSPq0ucPDskWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.82.0.tgz", + "integrity": "sha512-6azCZ6OJudlvipNttXCCQcyeFfcJ/NvUZdSN1z8elo73kCHtyQC7WTiUcSjWYvJ1jaq9KDUyMAoAS/vNzhBomA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.82.0.tgz", + "integrity": "sha512-PLEaSD8IAIIlwW4dwOd9YaxuxeOpwiXL4J24rcnE4iNtyM5j9Q9/3+gti08oXpx0u2ygNjRDx9xjWWpQonuJEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.82.0.tgz", + "integrity": "sha512-D94em/BwknNTn4vqxjHh5wb2oL566eFhArabqKIr0cNZMHOJuiraFp1A8tXpH05bbE5tqwEfLXTI0MWEGtn3Dw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.82.0.tgz", + "integrity": "sha512-MOprxBaoYU2D4VgxXCl3ghydThWtx7Um1lL51kGYNeQ5Al7WzsH7/tqGdNtbLrIWnjq3bsm13+nz/gRIxjrOXw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.82.0.tgz", + "integrity": "sha512-5h55QsfJ/luDXZzC20k6SNOY1Az+dCP9WvntKtcUWh2JhckAdwApY2ZusaBTwLENnReXU+A2fJtSrYvZJNKNPg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.82.0.tgz", + "integrity": "sha512-IE8NJNLlHr0CaXyGJPGVn0eTkUyoj1I2UfA8x7I4PSOYKsQ/6btVC7Pywrj5onk0cMH25r6Z38SoN3AvE5Zuog==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.82.0.tgz", + "integrity": "sha512-XUUUxaBo9XKl+J1B9EmP1cTGQPddzeURvoGkfwh/94PGnbW+hBprDljneoI2M1jzC1bzrIV3ihc7iM9UXl8+tg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.82.0.tgz", + "integrity": "sha512-SWLSFulX9TDuH6yvbPYp4+VNn6jkkIvvI+KiujDM5rWBRHEfkesCC/pCneIIUr6ovkxZ5fRtpi2v5Cz5FrMJZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.82.0.tgz", + "integrity": "sha512-BQy35f6ZUdNr9a6c7B7orxQTcLjByGT2z3WAgmRovpRwmPYAaJ+NTplmMzhdjdJ4qSchfMNZy/Ukg+qRg6zseQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.82.0.tgz", + "integrity": "sha512-V4QhSTg5gctZue8RJjsGi7NpQPThr/p1/HfmiMC5kfe1KFEup9SQRVub4A6kijQjdHfxj7bLL1KO3QO7/5bwMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.82.0.tgz", + "integrity": "sha512-TUSCLaKB2yktpFAJ/r3HAUYsaV/3DT7JS4iNKyoh3a9YNwD0UG7Ezh4D8m23654vQcU6P/RQrCAjRPKe4peP/A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.82.0.tgz", + "integrity": "sha512-VTVoRIWJTb+wvUX8EYoPArfFH02whuR10goFXE/LHRRX33ajRrFgqbcONXZMiF4C5rnattfkm87HqYn8jb8hmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", + "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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==", + "devOptional": 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==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.45.0.tgz", + "integrity": "sha512-yH1ubCAduho9UR7oJhRXIQXogksRILBiTuZC4/bQIGeB9JOkxMlSuEHyyZpo1Z3S0yWJO2KTSUZbjiNvVxeOUw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "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/oxlint": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.82.0.tgz", + "integrity": "sha512-+iFM1BGw1ntYJt3QngbJmjbrGxPaKMUADOXOijpWGnYcBPq8YZnQftSS1C+pVcDYy9YxqDVJKQqQkTazTQMboQ==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.82.0", + "@oxlint/binding-android-arm64": "1.82.0", + "@oxlint/binding-darwin-arm64": "1.82.0", + "@oxlint/binding-darwin-x64": "1.82.0", + "@oxlint/binding-freebsd-x64": "1.82.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.82.0", + "@oxlint/binding-linux-arm-musleabihf": "1.82.0", + "@oxlint/binding-linux-arm64-gnu": "1.82.0", + "@oxlint/binding-linux-arm64-musl": "1.82.0", + "@oxlint/binding-linux-ppc64-gnu": "1.82.0", + "@oxlint/binding-linux-riscv64-gnu": "1.82.0", + "@oxlint/binding-linux-riscv64-musl": "1.82.0", + "@oxlint/binding-linux-s390x-gnu": "1.82.0", + "@oxlint/binding-linux-x64-gnu": "1.82.0", + "@oxlint/binding-linux-x64-musl": "1.82.0", + "@oxlint/binding-openharmony-arm64": "1.82.0", + "@oxlint/binding-win32-arm64-msvc": "1.82.0", + "@oxlint/binding-win32-ia32-msvc": "1.82.0", + "@oxlint/binding-win32-x64-msvc": "1.82.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "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.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-router": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", + "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz", + "integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.149.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "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/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.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" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..d24c38f --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.20.0", + "lucide-react": "^1.45.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.3", + "zustand": "^5.0.15" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "oxlint": "^1.81.0", + "typescript": "~6.0.2", + "vite": "^8.3.0" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..f90339d --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,184 @@ +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..6b9d610 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,48 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { Header } from './components/Header'; +import { HomePage } from './pages/HomePage'; +import { SearchPage } from './pages/SearchPage'; +import { MangaDetailPage } from './pages/MangaDetailPage'; +import { ReaderPage } from './pages/ReaderPage'; +import { useStore } from './store/useStore'; +import { checkServerHealth } from './api/client'; +import { useEffect } from 'react'; + +function AppRoutes() { + const { setServerConnected } = useStore(); + + useEffect(() => { + const checkConnection = async () => { + const connected = await checkServerHealth(); + setServerConnected(connected); + }; + checkConnection(); + const interval = setInterval(checkConnection, 30000); + return () => clearInterval(interval); + }, [setServerConnected]); + + return ( + + } /> + } /> + } /> + } /> + } /> + + ); +} + +function App() { + return ( + +
+
+
+ +
+
+
+ ); +} + +export default App; \ No newline at end of file diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..ac59811 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,44 @@ +import axios from 'axios'; +import type { Manga, Chapter, SearchResult, ChapterPages } from '../types'; + +const API_BASE = import.meta.env.VITE_API_BASE; +const baseURL = API_BASE && API_BASE !== '' ? API_BASE : (typeof window !== 'undefined' ? window.location.origin : ''); + +const api = axios.create({ + baseURL, + timeout: 30000, +}); + +export const searchManga = async (query: string): Promise => { + const response = await api.get('/api/search', { params: { q: query } }); + return response.data; +}; + +export const getMangaDetails = async (id: string): Promise => { + const response = await api.get(`/api/manga/${id}`); + return response.data; +}; + +export const getMangaChapters = async (id: string): Promise => { + const response = await api.get(`/api/manga/${id}/chapters`); + return response.data; +}; + +export const getChapterPages = async (chapterId: string): Promise => { + const response = await api.get(`/api/chapter/${chapterId}/pages`); + return response.data; +}; + +export const getImageUrl = (chapterId: string, filename: string): string => { + const base = API_BASE && API_BASE !== '' ? API_BASE : (typeof window !== 'undefined' ? window.location.origin : ''); + return `${base}/api/image/${chapterId}/${filename}`; +}; + +export const checkServerHealth = async (): Promise => { + try { + await api.get('/api/search', { params: { q: 'test' }, timeout: 5000 }); + return true; + } catch { + return false; + } +}; \ No newline at end of file diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/ChapterList.tsx b/frontend/src/components/ChapterList.tsx new file mode 100644 index 0000000..4cc9462 --- /dev/null +++ b/frontend/src/components/ChapterList.tsx @@ -0,0 +1,69 @@ +import { useStore } from '../store/useStore'; +import type { Chapter } from '../types'; +import { Download, Check, Loader2 } from 'lucide-react'; + +interface ChapterListProps { + chapters: Chapter[]; + mangaId: string; + existingChapters: Chapter[]; + isDownloading: boolean; +} + +export function ChapterList({ chapters, mangaId, existingChapters, isDownloading }: ChapterListProps) { + const { setSelectedChapter, setChapterPages, setCurrentPageIndex, selectedChapter } = useStore(); + + const existingChapterNumbers = new Set(existingChapters.map((c) => c.number)); + const isExisting = (chapter: Chapter) => existingChapterNumbers.has(chapter.number); + + const handleChapterClick = async (chapter: Chapter) => { + if (!isExisting(chapter)) return; + + setSelectedChapter(chapter); + setCurrentPageIndex(0); + + try { + const apiBase = import.meta.env.VITE_API_BASE; + const base = apiBase && apiBase !== '' ? apiBase : window.location.origin; + const response = await fetch(`${base}/api/chapter/${mangaId}::${chapter.number}/pages`); + const data = await response.json(); + setChapterPages(data.pages || []); + window.location.href = `/reader/${mangaId}/${chapter.number}`; + } catch (error) { + console.error('Failed to load chapter pages:', error); + } + }; + + const sortedChapters = [...chapters].sort((a, b) => a.number - b.number); + + return ( +
+

Chapters ({chapters.length})

+
+ {sortedChapters.map((chapter) => ( + + ))} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/Feedback.tsx b/frontend/src/components/Feedback.tsx new file mode 100644 index 0000000..b9fe067 --- /dev/null +++ b/frontend/src/components/Feedback.tsx @@ -0,0 +1,21 @@ +export function Loading() { + return ( +
+
+

Loading...

+
+ ); +} + +export function Error({ message, onRetry }: { message: string; onRetry?: () => void }) { + return ( +
+

{message}

+ {onRetry && ( + + )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx new file mode 100644 index 0000000..44f2a34 --- /dev/null +++ b/frontend/src/components/Header.tsx @@ -0,0 +1,50 @@ +import { useStore } from '../store/useStore'; +import { Search, Server, ServerOff, Loader2 } from 'lucide-react'; +import { useEffect } from 'react'; +import { checkServerHealth } from '../api/client'; + +export function Header() { + const { searchQuery, setSearchQuery, isSearching, serverConnected, setServerConnected } = useStore(); + + useEffect(() => { + const checkConnection = async () => { + const connected = await checkServerHealth(); + setServerConnected(connected); + }; + checkConnection(); + const interval = setInterval(checkConnection, 30000); + return () => clearInterval(interval); + }, [setServerConnected]); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + if (searchQuery.trim()) { + window.location.href = `/search?q=${encodeURIComponent(searchQuery.trim())}`; + } + }; + + return ( +
+
+

MangaDL

+
+ + setSearchQuery(e.target.value)} + placeholder="Search manga..." + className="search-input" + disabled={isSearching} + /> + {isSearching && } + +
+ + {serverConnected ? : } + +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/MangaCard.tsx b/frontend/src/components/MangaCard.tsx new file mode 100644 index 0000000..dd9623c --- /dev/null +++ b/frontend/src/components/MangaCard.tsx @@ -0,0 +1,27 @@ +import { Link } from 'react-router-dom'; +import type { SearchResult } from '../types'; +import { BookOpen } from 'lucide-react'; + +interface MangaCardProps { + manga: SearchResult; +} + +export function MangaCard({ manga }: MangaCardProps) { + return ( + +
+ {manga.cover_url ? ( + {manga.title} + ) : ( +
+ +
+ )} + {manga.status} +
+
+

{manga.title}

+
+ + ); +} \ No newline at end of file diff --git a/frontend/src/components/MangaGrid.tsx b/frontend/src/components/MangaGrid.tsx new file mode 100644 index 0000000..23688b4 --- /dev/null +++ b/frontend/src/components/MangaGrid.tsx @@ -0,0 +1,29 @@ +import type { SearchResult } from '../types'; +import { MangaCard } from './MangaCard'; + +interface MangaGridProps { + mangas: SearchResult[]; + title?: string; + emptyMessage?: string; +} + +export function MangaGrid({ mangas, title, emptyMessage = 'No manga found' }: MangaGridProps) { + if (mangas.length === 0) { + return ( +
+

{emptyMessage}

+
+ ); + } + + return ( +
+ {title &&

{title}

} +
+ {mangas.map((manga) => ( + + ))} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/Reader.tsx b/frontend/src/components/Reader.tsx new file mode 100644 index 0000000..07a5a3e --- /dev/null +++ b/frontend/src/components/Reader.tsx @@ -0,0 +1,161 @@ +import { useStore } from '../store/useStore'; +import { ChevronLeft, ChevronRight, Home, Fullscreen, X } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; + +export function Reader() { + const { chapterPages, currentPageIndex, setCurrentPageIndex, selectedChapter, selectedManga } = useStore(); + const [isFullscreen, setIsFullscreen] = useState(false); + const [showControls, setShowControls] = useState(true); + const controlsTimeoutRef = useRef | null>(null); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'ArrowLeft') goToPrevious(); + if (e.key === 'ArrowRight') goToNext(); + if (e.key === 'Escape') exitFullscreen(); + if (e.key === 'f') toggleFullscreen(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, []); + + const goToPrevious = () => { + if (currentPageIndex > 0) { + setCurrentPageIndex(currentPageIndex - 1); + } + }; + + const goToNext = () => { + if (currentPageIndex < chapterPages.length - 1) { + setCurrentPageIndex(currentPageIndex + 1); + } + }; + + const toggleFullscreen = async () => { + if (!isFullscreen) { + try { + await document.documentElement.requestFullscreen(); + setIsFullscreen(true); + } catch (err) { + console.error('Fullscreen failed:', err); + } + } else { + exitFullscreen(); + } + }; + + const exitFullscreen = () => { + if (document.fullscreenElement) { + document.exitFullscreen(); + } + setIsFullscreen(false); + }; + + const resetControlsTimeout = () => { + setShowControls(true); + if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current); + controlsTimeoutRef.current = setTimeout(() => setShowControls(false), 3000); + }; + + useEffect(() => { + resetControlsTimeout(); + return () => { + if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current); + }; + }, [currentPageIndex, chapterPages.length]); + + if (chapterPages.length === 0 || !selectedChapter) { + return ( +
+
+

Loading chapter...

+
+ ); + } + + const currentPage = chapterPages[currentPageIndex]; + const apiBase = import.meta.env.VITE_API_BASE; + const base = apiBase && apiBase !== '' ? apiBase : window.location.origin; + const imageUrl = `${base}${currentPage}`; + + return ( +
+
+
+ + {selectedManga?.title} + Ch. {selectedChapter.number} +
+
+ + {currentPageIndex + 1} / {chapterPages.length} + + +
+
+ +
e.stopPropagation()}> + + +
+ {`Page +
+ + +
+ +
+
+ + +
+
+ {chapterPages.map((_, index) => ( + + ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..bfee4b8 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,1010 @@ +:root { + --primary: #6366f1; + --primary-hover: #4f46e5; + --primary-light: #eef2ff; + --secondary: #64748b; + --success: #10b981; + --warning: #f59e0b; + --error: #ef4444; + --bg: #f8fafc; + --bg-card: #ffffff; + --text: #1e293b; + --text-muted: #64748b; + --border: #e2e8f0; + --shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --radius: 8px; + --radius-lg: 12px; + --transition: 150ms ease; +} + +@media (prefers-color-scheme: dark) { + :root { + --primary: #818cf8; + --primary-hover: #6366f1; + --primary-light: #1e1b4b; + --secondary: #94a3b8; + --success: #34d399; + --warning: #fbbf24; + --error: #f87171; + --bg: #0f172a; + --bg-card: #1e293b; + --text: #f1f5f9; + --text-muted: #94a3b8; + --border: #334155; + --shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.2); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3); + } +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.5; + font-size: 14px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +#root { + height: 100%; + width: 100%; + max-width: none; + border: none; + text-align: left; +} + +.app { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.main-content { + flex: 1; + padding: 24px; + max-width: 1400px; + margin: 0 auto; + width: 100%; +} + +/* Header */ +.header { + background: var(--bg-card); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 100; + box-shadow: var(--shadow); +} + +.header-content { + max-width: 1400px; + margin: 0 auto; + padding: 16px 24px; + display: flex; + align-items: center; + gap: 24px; +} + +.logo { + font-size: 1.5rem; + font-weight: 700; + color: var(--primary); + letter-spacing: -0.5px; +} + +.search-form { + flex: 1; + max-width: 500px; + position: relative; +} + +.search-icon { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: var(--text-muted); + pointer-events: none; + width: 18px; + height: 18px; +} + +.search-input { + width: 100%; + padding: 10px 16px 10px 44px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + font-size: 14px; + transition: border-color var(--transition), box-shadow var(--transition); +} + +.search-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--primary-light); +} + +.search-input::placeholder { + color: var(--text-muted); +} + +.search-loader { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + color: var(--primary); + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: translateY(-50%) rotate(0deg); } + to { transform: translateY(-50%) rotate(360deg); } +} + +.server-status { + display: flex; + align-items: center; + gap: 8px; +} + +.status-online { + color: var(--success); +} + +.status-offline { + color: var(--error); +} + +/* Page */ +.page { + animation: fadeIn 200ms ease; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.page-header { + margin-bottom: 24px; +} + +.page-header h1 { + font-size: 1.75rem; + font-weight: 600; + color: var(--text); +} + +.search-query { + color: var(--text-muted); + margin-top: 4px; +} + +/* Manga Grid */ +.manga-grid-container { + margin-bottom: 32px; +} + +.grid-title { + font-size: 1.125rem; + font-weight: 600; + margin-bottom: 16px; + color: var(--text); +} + +.manga-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 16px; +} + +.manga-card { + display: block; + text-decoration: none; + color: inherit; + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--bg-card); + border: 1px solid var(--border); + transition: transform var(--transition), box-shadow var(--transition), border-color var(--transition); +} + +.manga-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-lg); + border-color: var(--primary); +} + +.manga-card-cover { + position: relative; + aspect-ratio: 2/3; + overflow: hidden; + background: var(--bg); +} + +.manga-card-cover img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 300ms ease; +} + +.manga-card:hover .manga-card-cover img { + transform: scale(1.05); +} + +.placeholder-cover { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-muted); +} + +.placeholder-cover-large { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-muted); +} + +.manga-status { + position: absolute; + top: 8px; + right: 8px; + padding: 2px 8px; + border-radius: 9999px; + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + background: rgba(0, 0, 0, 0.7); + color: white; + backdrop-filter: blur(4px); +} + +.manga-card-info { + padding: 12px; +} + +.manga-title { + font-size: 0.875rem; + font-weight: 500; + color: var(--text); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Empty State */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 64px 24px; + color: var(--text-muted); + text-align: center; +} + +.empty-state p { + margin: 0; +} + +/* Manga Detail */ +.manga-detail-header { + margin-bottom: 24px; +} + +.back-link { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text-muted); + text-decoration: none; + font-size: 0.875rem; + font-weight: 500; + padding: 8px 12px; + border-radius: var(--radius); + transition: color var(--transition), background var(--transition); +} + +.back-link:hover { + color: var(--text); + background: var(--bg); +} + +.manga-detail-content { + display: grid; + grid-template-columns: 280px 1fr; + gap: 32px; + margin-bottom: 32px; +} + +.manga-cover-large { + border-radius: var(--radius-lg); + overflow: hidden; + aspect-ratio: 2/3; + background: var(--bg); + box-shadow: var(--shadow-lg); +} + +.manga-cover-large img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.manga-info { + display: flex; + flex-direction: column; +} + +.manga-title-large { + font-size: 2rem; + font-weight: 700; + margin-bottom: 8px; + line-height: 1.2; +} + +.manga-alt-names { + color: var(--text-muted); + font-size: 0.9375rem; + margin-bottom: 16px; +} + +.manga-meta { + display: flex; + flex-wrap: wrap; + gap: 16px; + margin-bottom: 24px; +} + +.meta-item { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text-muted); + font-size: 0.875rem; +} + +.meta-item svg { + color: var(--secondary); +} + +.manga-description { + margin-bottom: 24px; +} + +.manga-description h3 { + font-size: 1rem; + font-weight: 600; + margin-bottom: 8px; +} + +.manga-description p { + color: var(--text-muted); + line-height: 1.6; +} + +.manga-actions { + display: flex; + gap: 12px; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 24px; + border-radius: var(--radius); + font-size: 0.875rem; + font-weight: 500; + text-decoration: none; + border: none; + cursor: pointer; + transition: all var(--transition); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: var(--primary); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: var(--primary-hover); +} + +.btn-secondary { + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--bg-card); + border-color: var(--primary); +} + +/* Chapter List */ +.chapter-list { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.chapter-list-title { + padding: 16px 20px; + font-size: 1rem; + font-weight: 600; + border-bottom: 1px solid var(--border); + background: var(--bg); +} + +.chapters { + max-height: 500px; + overflow-y: auto; +} + +.chapter-item { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 12px 20px; + border: none; + background: transparent; + text-align: left; + cursor: pointer; + transition: background var(--transition); + border-bottom: 1px solid var(--border); +} + +.chapter-item:last-child { + border-bottom: none; +} + +.chapter-item:hover:not(.unavailable):not(:disabled) { + background: var(--bg); +} + +.chapter-item.unavailable { + opacity: 0.5; + cursor: not-allowed; +} + +.chapter-item.active { + background: var(--primary-light); +} + +.chapter-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.chapter-number { + font-weight: 500; + color: var(--text); +} + +.chapter-label { + font-size: 0.75rem; + color: var(--text-muted); +} + +.chapter-status { + display: flex; + align-items: center; +} + +.status-icon { + color: var(--text-muted); + transition: color var(--transition); +} + +.chapter-item.available .status-icon { + color: var(--success); +} + +.chapter-item.unavailable .status-icon { + color: var(--warning); +} + +.status-icon.spinning { + animation: spin 1s linear infinite; +} + +/* Reader */ +.reader-container { + height: 100vh; + width: 100vw; + background: #000; + color: white; + position: fixed; + top: 0; + left: 0; + z-index: 200; + display: flex; + flex-direction: column; +} + +.reader-header { + position: absolute; + top: 0; + left: 0; + right: 0; + padding: 16px 24px; + display: flex; + align-items: center; + justify-content: space-between; + background: linear-gradient(to bottom, rgba(0,0,0,0.7), transparent); + transition: opacity 200ms ease; + z-index: 10; +} + +.reader-header-left { + display: flex; + align-items: center; + gap: 12px; +} + +.reader-header-right { + display: flex; + align-items: center; + gap: 16px; +} + +.manga-title { + font-size: 1rem; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; +} + +.chapter-title { + font-size: 0.875rem; + color: rgba(255,255,255,0.7); +} + +.page-indicator { + font-size: 0.875rem; + font-variant-numeric: tabular-nums; + color: rgba(255,255,255,0.8); +} + +.icon-btn { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: none; + border-radius: 50%; + background: rgba(255,255,255,0.1); + color: white; + cursor: pointer; + transition: background var(--transition); + backdrop-filter: blur(8px); +} + +.icon-btn:hover:not(:disabled) { + background: rgba(255,255,255,0.2); +} + +.icon-btn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.reader-content { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + position: relative; +} + +.nav-btn { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 60px; + height: 60px; + border: none; + border-radius: 50%; + background: rgba(0,0,0,0.4); + color: white; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background var(--transition), transform var(--transition); + z-index: 5; +} + +.nav-btn:hover:not(.disabled) { + background: rgba(0,0,0,0.6); + transform: translateY(-50%) scale(1.1); +} + +.nav-btn.prev { + left: 24px; +} + +.nav-btn.next { + right: 24px; +} + +.nav-btn.disabled { + opacity: 0; + pointer-events: none; +} + +.reader-image-wrapper { + max-width: 100%; + max-height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.reader-image { + max-width: 100%; + max-height: 90vh; + object-fit: contain; + border-radius: 4px; + box-shadow: 0 0 40px rgba(0,0,0,0.5); +} + +.reader-footer { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 16px 24px; + display: flex; + align-items: center; + justify-content: space-between; + background: linear-gradient(to top, rgba(0,0,0,0.7), transparent); + transition: opacity 200ms ease; + z-index: 10; +} + +.reader-controls { + display: flex; + gap: 8px; +} + +.page-thumbnails { + display: flex; + gap: 4px; + overflow-x: auto; + padding: 4px; + max-width: 60%; +} + +.thumbnail { + min-width: 36px; + height: 36px; + border: none; + border-radius: var(--radius); + background: rgba(255,255,255,0.1); + color: rgba(255,255,255,0.7); + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: all var(--transition); + flex-shrink: 0; +} + +.thumbnail:hover { + background: rgba(255,255,255,0.2); + color: white; +} + +.thumbnail.active { + background: var(--primary); + color: white; +} + +.reader-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + background: #000; + color: white; + gap: 16px; +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid rgba(255,255,255,0.1); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +/* Loading & Error */ +.loading-container, +.error-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 64px 24px; + gap: 16px; + color: var(--text-muted); +} + +.spinner { + width: 32px; + height: 32px; + border: 3px solid var(--border); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.error-message { + color: var(--error); +} + +.retry-btn { + padding: 10px 20px; + background: var(--primary); + color: white; + border: none; + border-radius: var(--radius); + font-weight: 500; + cursor: pointer; + transition: background var(--transition); +} + +.retry-btn:hover { + background: var(--primary-hover); +} + +/* Home Page */ +.hero { + text-align: center; + padding: 80px 24px; + max-width: 800px; + margin: 0 auto; +} + +.hero h1 { + font-size: 3.5rem; + font-weight: 800; + background: linear-gradient(135deg, var(--primary), #8b5cf6); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 16px; +} + +.hero-subtitle { + font-size: 1.25rem; + color: var(--text-muted); + margin-bottom: 40px; +} + +.hero-search { + position: relative; + max-width: 500px; + margin: 0 auto 48px; +} + +.search-icon-large { + position: absolute; + left: 16px; + top: 50%; + transform: translateY(-50%); + color: var(--text-muted); + width: 24px; + height: 24px; +} + +.search-placeholder { + display: block; + width: 100%; + padding: 16px 20px 16px 56px; + border: 2px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-card); + color: var(--text-muted); + font-size: 1rem; + text-decoration: none; + text-align: left; + transition: border-color var(--transition), box-shadow var(--transition); +} + +.search-placeholder:hover { + border-color: var(--primary); + box-shadow: 0 0 0 4px var(--primary-light); + color: var(--text); +} + +.hero-features { + display: flex; + justify-content: center; + gap: 32px; + flex-wrap: wrap; +} + +.feature { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 20px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + color: var(--text); + font-weight: 500; + transition: border-color var(--transition), box-shadow var(--transition); +} + +.feature:hover { + border-color: var(--primary); + box-shadow: var(--shadow); +} + +.feature svg { + color: var(--primary); +} + +.server-status-banner { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 16px; + margin: 0 auto 48px; + max-width: 800px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + font-size: 0.875rem; + color: var(--text-muted); +} + +.server-status-banner .online { + color: var(--success); +} + +.server-status-banner .offline { + color: var(--error); +} + +.getting-started { + max-width: 800px; + margin: 0 auto; + padding: 32px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); +} + +.getting-started h2 { + font-size: 1.25rem; + margin-bottom: 16px; +} + +.getting-started ol { + padding-left: 24px; + color: var(--text-muted); + line-height: 2; +} + +.getting-started code { + background: var(--bg); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.875rem; +} + +/* Responsive */ +@media (max-width: 1024px) { + .manga-detail-content { + grid-template-columns: 1fr; + max-width: 400px; + } + + .manga-cover-large { + max-width: 280px; + margin: 0 auto; + } + + .hero h1 { + font-size: 2.5rem; + } + + .hero-features { + gap: 16px; + } +} + +@media (max-width: 768px) { + .main-content { + padding: 16px; + } + + .header-content { + padding: 12px 16px; + gap: 16px; + } + + .search-form { + max-width: none; + } + + .manga-grid { + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: 12px; + } + + .nav-btn { + width: 44px; + height: 44px; + } + + .nav-btn.prev { + left: 12px; + } + + .nav-btn.next { + right: 12px; + } + + .page-thumbnails { + max-width: 100%; + } +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg); +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx new file mode 100644 index 0000000..71af4d5 --- /dev/null +++ b/frontend/src/pages/HomePage.tsx @@ -0,0 +1,61 @@ +import { Link } from 'react-router-dom'; +import { Search, BookOpen, Download, Server, Library } from 'lucide-react'; +import { useStore } from '../store/useStore'; + +export function HomePage() { + const { serverConnected } = useStore(); + + return ( +
+
+
+

MangaDL

+

Browse, read, and download manga from MangaKatana

+ +
+ + + Search for manga... + +
+ +
+
+ + Browse Manga +
+
+ + Download Chapters +
+
+ + Read Online +
+
+ + Self-hosted Backend +
+
+
+
+ +
+ + + Backend: {serverConnected ? 'Connected' : 'Disconnected'} + {!serverConnected && ' - Start ifetch server on port 8080'} + +
+ +
+

Getting Started

+
    +
  1. Run ifetch --server --port 8080 to start the backend server
  2. +
  3. Search for manga using the search bar above
  4. +
  5. Click on a manga to view chapters and start reading
  6. +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/MangaDetailPage.tsx b/frontend/src/pages/MangaDetailPage.tsx new file mode 100644 index 0000000..d267ebf --- /dev/null +++ b/frontend/src/pages/MangaDetailPage.tsx @@ -0,0 +1,139 @@ +import { useEffect, useState } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { useStore } from '../store/useStore'; +import { getMangaDetails, getMangaChapters, getImageUrl } from '../api/client'; +import { ChapterList } from '../components/ChapterList'; +import { Loading, Error } from '../components/Feedback'; +import { BookOpen, Calendar, Tag, User, ArrowLeft } from 'lucide-react'; +import type { Manga, Chapter } from '../types'; + +export function MangaDetailPage() { + const { id } = useParams<{ id: string }>(); + const { + setSelectedManga, + setChapters, + chapters, + isLoading, setIsLoading, + setError, + serverConnected + } = useStore(); + + const [manga, setManga] = useState(null); + const [existingChapters, setExistingChapters] = useState([]); + const [isDownloading] = useState(false); + const [localError, setLocalError] = useState(null); + + useEffect(() => { + if (!id) return; + + const fetchManga = async () => { + setIsLoading(true); + setLocalError(null); + setError(null); + + try { + const [mangaData, chaptersData] = await Promise.all([ + getMangaDetails(id), + getMangaChapters(id) + ]); + + setManga(mangaData); + setSelectedManga(mangaData); + setChapters(chaptersData); + setExistingChapters(chaptersData); + } catch { + const message = 'Failed to load manga'; + setLocalError(message); + setError(message); + } finally { + setIsLoading(false); + } + }; + + fetchManga(); + }, [id, setSelectedManga, setChapters, setIsLoading, setError]); + + if (!id) return null; + + if (isLoading && !manga) return ; + if (localError && !manga) return window.location.reload()} />; + + if (!manga) return null; + + const coverUrl = manga.cover_url ? getImageUrl(manga.id, manga.cover_url.split('/').pop() || '') : null; + + return ( +
+
+ + + Back + +
+ +
+
+ {coverUrl ? ( + {manga.title} + ) : ( +
+ +
+ )} +
+ +
+

{manga.title}

+ + {manga.alt_names.length > 0 && ( +

{manga.alt_names.join(', ')}

+ )} + +
+ {manga.status && ( + + + {manga.status} + + )} + {manga.genres.length > 0 && ( + + + {manga.genres.join(', ')} + + )} + {manga.authors.length > 0 && ( + + + {manga.authors.join(', ')} + + )} +
+ + {manga.description && ( +
+

Description

+

{manga.description}

+
+ )} + +
+ + + View All Chapters + +
+
+
+ + +
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/ReaderPage.tsx b/frontend/src/pages/ReaderPage.tsx new file mode 100644 index 0000000..303db03 --- /dev/null +++ b/frontend/src/pages/ReaderPage.tsx @@ -0,0 +1,33 @@ +import { useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { useStore } from '../store/useStore'; +import { getChapterPages } from '../api/client'; +import { Reader } from '../components/Reader'; +import { Loading } from '../components/Feedback'; + +export function ReaderPage() { + const { mangaId, chapterNum } = useParams<{ mangaId: string; chapterNum: string }>(); + const { setChapterPages, setCurrentPageIndex, selectedManga, selectedChapter } = useStore(); + + useEffect(() => { + if (!mangaId || !chapterNum) return; + + const fetchPages = async () => { + try { + const chapterId = `${mangaId}::${chapterNum}`; + const data = await getChapterPages(chapterId); + setChapterPages(data.pages || []); + setCurrentPageIndex(0); + } catch (error) { + console.error('Failed to load chapter pages:', error); + } + }; + + fetchPages(); + }, [mangaId, chapterNum, setChapterPages, setCurrentPageIndex]); + + if (!mangaId || !chapterNum) return null; + if (!selectedManga || !selectedChapter) return ; + + return ; +} \ No newline at end of file diff --git a/frontend/src/pages/SearchPage.tsx b/frontend/src/pages/SearchPage.tsx new file mode 100644 index 0000000..c4925df --- /dev/null +++ b/frontend/src/pages/SearchPage.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useStore } from '../store/useStore'; +import { searchManga } from '../api/client'; +import { MangaGrid } from '../components/MangaGrid'; +import { Loading, Error } from '../components/Feedback'; +import type { SearchResult } from '../types'; + +export function SearchPage() { + const [searchParams] = useSearchParams(); + const query = searchParams.get('q') || ''; + const { setSearchResults, setIsSearching, setError, setSearchQuery } = useStore(); + const [localResults, setLocalResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setLocalError] = useState(null); + + useEffect(() => { + setSearchQuery(query); + }, [query, setSearchQuery]); + + const performSearch = async (searchQuery: string) => { + if (!searchQuery.trim()) return; + + setIsLoading(true); + setIsSearching(true); + setLocalError(null); + setError(null); + + try { + const results = await searchManga(searchQuery); + setLocalResults(results); + setSearchResults(results); + } catch { + const message = 'Search failed'; + setLocalError(message); + setError(message); + } finally { + setIsLoading(false); + setIsSearching(false); + } + }; + + useEffect(() => { + if (query) { + performSearch(query); + } else { + setLocalResults([]); + setSearchResults([]); + } + }, [query]); + + return ( +
+
+

Search Results

+ {query &&

for “{query}”

} +
+ + {isLoading && } + {error && performSearch(query)} />} + + {!isLoading && !error && ( + + )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/store/useStore.ts b/frontend/src/store/useStore.ts new file mode 100644 index 0000000..c7d7c2f --- /dev/null +++ b/frontend/src/store/useStore.ts @@ -0,0 +1,52 @@ +import { create } from 'zustand'; +import type { Manga, Chapter, SearchResult } from '../types'; + +interface AppState { + searchQuery: string; + setSearchQuery: (query: string) => void; + searchResults: SearchResult[]; + setSearchResults: (results: SearchResult[]) => void; + isSearching: boolean; + setIsSearching: (loading: boolean) => void; + selectedManga: Manga | null; + setSelectedManga: (manga: Manga | null) => void; + chapters: Chapter[]; + setChapters: (chapters: Chapter[]) => void; + selectedChapter: Chapter | null; + setSelectedChapter: (chapter: Chapter | null) => void; + chapterPages: string[]; + setChapterPages: (pages: string[]) => void; + currentPageIndex: number; + setCurrentPageIndex: (index: number) => void; + isLoading: boolean; + setIsLoading: (loading: boolean) => void; + error: string | null; + setError: (error: string | null) => void; + serverConnected: boolean; + setServerConnected: (connected: boolean) => void; +} + +export const useStore = create((set) => ({ + searchQuery: '', + setSearchQuery: (query) => set({ searchQuery: query }), + searchResults: [], + setSearchResults: (results) => set({ searchResults: results }), + isSearching: false, + setIsSearching: (loading) => set({ isSearching: loading }), + selectedManga: null, + setSelectedManga: (manga) => set({ selectedManga: manga }), + chapters: [], + setChapters: (chapters) => set({ chapters }), + selectedChapter: null, + setSelectedChapter: (chapter) => set({ selectedChapter: chapter }), + chapterPages: [], + setChapterPages: (pages) => set({ chapterPages: pages }), + currentPageIndex: 0, + setCurrentPageIndex: (index) => set({ currentPageIndex: index }), + isLoading: false, + setIsLoading: (loading) => set({ isLoading: loading }), + error: null, + setError: (error) => set({ error }), + serverConnected: false, + setServerConnected: (connected) => set({ serverConnected: connected }), +})); \ No newline at end of file diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..a757c3f --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,36 @@ +export interface Manga { + id: string; + title: string; + url: string; + cover_url: string; + status: string; + description: string; + genres: string[]; + authors: string[]; + alt_names: string[]; +} + +export interface Chapter { + number: number; + label: string; + url: string; +} + +export interface SearchResult { + id: string; + title: string; + cover_url: string; + status: string; +} + +export interface MangaWithChapters { + manga: Manga; + chapters: Chapter[]; + existingChapters: Chapter[]; + missingChapters: Chapter[]; + isDownloading: boolean; +} + +export interface ChapterPages { + pages: string[]; +} \ No newline at end of file diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..bab40a0 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + "useUnknownInCatchVariables": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..6661e50 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,18 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [react()], + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + }, + }, + define: { + 'import.meta.env.VITE_API_BASE': JSON.stringify(''), + }, +}) \ No newline at end of file