From 6c82958c796801803a83de4abae5d487961dcd12 Mon Sep 17 00:00:00 2001 From: paul Date: Sun, 6 Jul 2025 20:23:13 +0200 Subject: [PATCH] Add complete frontend implementation and Docker deployment setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement React frontend with TypeScript and Tailwind CSS - Add scrappbook.de-inspired UI design with photo galleries - Implement authentication, photo viewing, and download features - Add Docker Swarm configuration with Traefik reverse proxy - Set up Drone CI/CD pipeline for automated deployments - Add monitoring stack with Prometheus and Grafana - Create comprehensive deployment documentation - Add simple local development setup with docker-compose.local.yml Features: - Password-protected galleries with expiration warnings - Responsive photo grid with lightbox viewer - Bulk download functionality - Hot reload development environment - Email testing with Mailhog - Production-ready deployment scripts 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .drone.yml | 311 ++ .env.production.example | 60 + CLAUDE.md | 149 + DEPLOYMENT.md | 483 ++ README-LOCAL.md | 130 + README.md | 33 +- backend/migrations/init.js | 37 + backend/src/services/imageProcessor.js | 28 + backend/src/utils/logger.js | 31 + data/.gitkeep | 0 deploy/docker-stack.yml | 265 + .../monitoring/docker-compose.monitoring.yml | 189 + deploy/monitoring/prometheus.yml | 65 + deploy/scripts/backup.sh | 134 + deploy/scripts/create-secrets.sh | 111 + deploy/scripts/deploy.sh | 196 + deploy/scripts/init-swarm.sh | 70 + deploy/traefik/docker-compose.traefik.yml | 124 + deploy/traefik/traefik.yml | 93 + docker-compose.local.yml | 99 + frontend/.dockerignore | 18 + frontend/.gitignore | 24 + frontend/Dockerfile | 54 + frontend/README.md | 69 + frontend/eslint.config.js | 23 + frontend/index.html | 13 + frontend/nginx.conf | 77 + frontend/nginx.dev.conf | 34 + frontend/package-lock.json | 4876 +++++++++++++++++ frontend/package.json | 44 + frontend/postcss.config.js | 6 + frontend/public/vite.svg | 1 + frontend/src/App.tsx | 76 + frontend/src/assets/react.svg | 1 + frontend/src/components/common/Button.tsx | 68 + frontend/src/components/common/Card.tsx | 106 + frontend/src/components/common/Input.tsx | 81 + frontend/src/components/common/Loading.tsx | 77 + frontend/src/components/common/index.ts | 4 + .../components/gallery/ExpirationBanner.tsx | 53 + .../src/components/gallery/GalleryView.tsx | 181 + frontend/src/components/gallery/PhotoGrid.tsx | 213 + .../src/components/gallery/PhotoLightbox.tsx | 205 + frontend/src/components/gallery/index.ts | 4 + frontend/src/config/api.ts | 79 + frontend/src/contexts/AdminAuthContext.tsx | 81 + frontend/src/contexts/GalleryAuthContext.tsx | 90 + frontend/src/contexts/index.ts | 2 + frontend/src/hooks/useGallery.ts | 64 + frontend/src/index.css | 140 + frontend/src/main.tsx | 10 + frontend/src/pages/GalleryPage.tsx | 176 + frontend/src/services/auth.service.ts | 35 + frontend/src/services/events.service.ts | 85 + frontend/src/services/gallery.service.ts | 56 + frontend/src/services/index.ts | 3 + frontend/src/types/index.ts | 94 + frontend/src/vite-env.d.ts | 1 + frontend/tailwind.config.js | 83 + frontend/tsconfig.app.json | 27 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 25 + frontend/vite.config.ts | 21 + logs/.gitkeep | 0 nginx/nginx.conf | 40 + photo-sharing-prd.md | 372 ++ scripts/install.sh | 73 + setup-remaining-files.sh | 0 start-local.sh | 111 + stop-local.sh | 22 + storage/events/active/.gitkeep | 0 storage/events/archived/.gitkeep | 0 storage/thumbnails/.gitkeep | 0 73 files changed, 10611 insertions(+), 2 deletions(-) create mode 100644 .drone.yml create mode 100644 .env.production.example create mode 100644 CLAUDE.md create mode 100644 DEPLOYMENT.md create mode 100644 README-LOCAL.md create mode 100644 backend/migrations/init.js create mode 100644 backend/src/services/imageProcessor.js create mode 100644 backend/src/utils/logger.js create mode 100644 data/.gitkeep create mode 100644 deploy/docker-stack.yml create mode 100644 deploy/monitoring/docker-compose.monitoring.yml create mode 100644 deploy/monitoring/prometheus.yml create mode 100755 deploy/scripts/backup.sh create mode 100755 deploy/scripts/create-secrets.sh create mode 100755 deploy/scripts/deploy.sh create mode 100755 deploy/scripts/init-swarm.sh create mode 100644 deploy/traefik/docker-compose.traefik.yml create mode 100644 deploy/traefik/traefik.yml create mode 100644 docker-compose.local.yml create mode 100644 frontend/.dockerignore create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/nginx.dev.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/vite.svg create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/components/common/Button.tsx create mode 100644 frontend/src/components/common/Card.tsx create mode 100644 frontend/src/components/common/Input.tsx create mode 100644 frontend/src/components/common/Loading.tsx create mode 100644 frontend/src/components/common/index.ts create mode 100644 frontend/src/components/gallery/ExpirationBanner.tsx create mode 100644 frontend/src/components/gallery/GalleryView.tsx create mode 100644 frontend/src/components/gallery/PhotoGrid.tsx create mode 100644 frontend/src/components/gallery/PhotoLightbox.tsx create mode 100644 frontend/src/components/gallery/index.ts create mode 100644 frontend/src/config/api.ts create mode 100644 frontend/src/contexts/AdminAuthContext.tsx create mode 100644 frontend/src/contexts/GalleryAuthContext.tsx create mode 100644 frontend/src/contexts/index.ts create mode 100644 frontend/src/hooks/useGallery.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/GalleryPage.tsx create mode 100644 frontend/src/services/auth.service.ts create mode 100644 frontend/src/services/events.service.ts create mode 100644 frontend/src/services/gallery.service.ts create mode 100644 frontend/src/services/index.ts create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 logs/.gitkeep create mode 100644 nginx/nginx.conf create mode 100644 photo-sharing-prd.md create mode 100755 scripts/install.sh mode change 100644 => 100755 setup-remaining-files.sh create mode 100755 start-local.sh create mode 100755 stop-local.sh create mode 100644 storage/events/active/.gitkeep create mode 100644 storage/events/archived/.gitkeep create mode 100644 storage/thumbnails/.gitkeep diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..7ffc9f2 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,311 @@ +kind: pipeline +type: docker +name: default + +trigger: + branch: + - main + - develop + - feature/* + event: + - push + - pull_request + - tag + +volumes: + - name: docker + host: + path: /var/run/docker.sock + +steps: + # Frontend Tests + - name: frontend-test + image: node:18-alpine + commands: + - cd frontend + - npm ci --legacy-peer-deps + - npm run lint + - npm run build + when: + event: + - push + - pull_request + + # Backend Tests + - name: backend-test + image: node:18-alpine + commands: + - cd backend + - npm ci + - npm run lint + - npm test + environment: + NODE_ENV: test + JWT_SECRET: test-secret + when: + event: + - push + - pull_request + + # Build Frontend Docker Image + - name: build-frontend + image: plugins/docker + settings: + repo: ${DRONE_REPO_NAMESPACE}/photo-sharing-frontend + tags: + - latest + - ${DRONE_COMMIT_SHA:0:8} + - ${DRONE_TAG} + dockerfile: frontend/Dockerfile + context: frontend + username: + from_secret: docker_username + password: + from_secret: docker_password + registry: + from_secret: docker_registry + when: + branch: + - main + event: + - push + - tag + + # Build Backend Docker Image + - name: build-backend + image: plugins/docker + settings: + repo: ${DRONE_REPO_NAMESPACE}/photo-sharing-backend + tags: + - latest + - ${DRONE_COMMIT_SHA:0:8} + - ${DRONE_TAG} + dockerfile: backend/Dockerfile + context: backend + username: + from_secret: docker_username + password: + from_secret: docker_password + registry: + from_secret: docker_registry + when: + branch: + - main + event: + - push + - tag + + # Security Scan + - name: security-scan + image: aquasec/trivy:latest + commands: + - trivy image --exit-code 0 --no-progress ${DRONE_REPO_NAMESPACE}/photo-sharing-frontend:${DRONE_COMMIT_SHA:0:8} + - trivy image --exit-code 0 --no-progress ${DRONE_REPO_NAMESPACE}/photo-sharing-backend:${DRONE_COMMIT_SHA:0:8} + environment: + DOCKER_HOST: tcp://docker:2375 + volumes: + - name: docker + path: /var/run/docker.sock + when: + branch: + - main + event: + - push + + # Deploy to Staging + - name: deploy-staging + image: alpine:latest + environment: + SWARM_HOST: + from_secret: staging_swarm_host + SWARM_USER: + from_secret: staging_swarm_user + SWARM_KEY: + from_secret: staging_swarm_key + REGISTRY_URL: + from_secret: docker_registry + VERSION: ${DRONE_COMMIT_SHA:0:8} + commands: + - apk add --no-cache openssh-client + - mkdir -p ~/.ssh + - echo "$SWARM_KEY" > ~/.ssh/id_rsa + - chmod 600 ~/.ssh/id_rsa + - ssh-keyscan -H $SWARM_HOST >> ~/.ssh/known_hosts + - | + ssh $SWARM_USER@$SWARM_HOST << EOF + cd /opt/photo-sharing + export REGISTRY_URL=$REGISTRY_URL + export VERSION=$VERSION + docker stack deploy -c deploy/docker-stack.yml photo-sharing + EOF + when: + branch: + - develop + event: + - push + + # Deploy to Production + - name: deploy-production + image: alpine:latest + environment: + SWARM_HOST: + from_secret: prod_swarm_host + SWARM_USER: + from_secret: prod_swarm_user + SWARM_KEY: + from_secret: prod_swarm_key + REGISTRY_URL: + from_secret: docker_registry + VERSION: ${DRONE_TAG:-latest} + commands: + - apk add --no-cache openssh-client + - mkdir -p ~/.ssh + - echo "$SWARM_KEY" > ~/.ssh/id_rsa + - chmod 600 ~/.ssh/id_rsa + - ssh-keyscan -H $SWARM_HOST >> ~/.ssh/known_hosts + - | + ssh $SWARM_USER@$SWARM_HOST << EOF + cd /opt/photo-sharing + export REGISTRY_URL=$REGISTRY_URL + export VERSION=$VERSION + + # Backup database before deployment + docker exec \$(docker ps -q -f name=photo-sharing_db) pg_dump -U postgres photo_sharing > /backup/db-backup-\$(date +%Y%m%d-%H%M%S).sql + + # Deploy stack + docker stack deploy -c deploy/docker-stack.yml photo-sharing --with-registry-auth + + # Wait for services to be ready + sleep 30 + + # Run migrations if needed + docker exec \$(docker ps -q -f name=photo-sharing_backend) npm run migrate + EOF + when: + event: + - tag + + # Health Check + - name: health-check + image: alpine:latest + commands: + - apk add --no-cache curl + - sleep 30 + - curl -f https://${FRONTEND_HOST}/health || exit 1 + - curl -f https://${BACKEND_HOST}/api/health || exit 1 + when: + branch: + - main + event: + - push + - tag + + # Notification - Success + - name: notify-success + image: plugins/slack + settings: + webhook: + from_secret: slack_webhook + channel: deployments + template: | + ✅ *Build {{build.number}} succeeded* for {{repo.name}} + + Branch: {{build.branch}} + Commit: {{build.commit}} + Author: {{build.author}} + + {{#if build.tag}} + 🏷️ Tag: {{build.tag}} + 🚀 Deployed to *PRODUCTION* + {{else}} + 📦 Deployed to *{{build.branch}}* + {{/if}} + + 🔗 {{build.link}} + when: + status: + - success + + # Notification - Failure + - name: notify-failure + image: plugins/slack + settings: + webhook: + from_secret: slack_webhook + channel: deployments + template: | + ❌ *Build {{build.number}} failed* for {{repo.name}} + + Branch: {{build.branch}} + Commit: {{build.commit}} + Author: {{build.author}} + + 🔗 {{build.link}} + when: + status: + - failure + +--- +kind: pipeline +type: docker +name: rollback + +trigger: + event: + - rollback + +steps: + - name: rollback-production + image: alpine:latest + environment: + SWARM_HOST: + from_secret: prod_swarm_host + SWARM_USER: + from_secret: prod_swarm_user + SWARM_KEY: + from_secret: prod_swarm_key + REGISTRY_URL: + from_secret: docker_registry + commands: + - apk add --no-cache openssh-client + - mkdir -p ~/.ssh + - echo "$SWARM_KEY" > ~/.ssh/id_rsa + - chmod 600 ~/.ssh/id_rsa + - ssh-keyscan -H $SWARM_HOST >> ~/.ssh/known_hosts + - | + ssh $SWARM_USER@$SWARM_HOST << EOF + cd /opt/photo-sharing + export REGISTRY_URL=$REGISTRY_URL + export VERSION=${DRONE_ROLLBACK_TO} + + # Deploy previous version + docker stack deploy -c deploy/docker-stack.yml photo-sharing --with-registry-auth + EOF + +--- +kind: secret +name: docker_username +get: + path: drone/docker + name: username + +--- +kind: secret +name: docker_password +get: + path: drone/docker + name: password + +--- +kind: secret +name: docker_registry +get: + path: drone/docker + name: registry + +--- +kind: secret +name: slack_webhook +get: + path: drone/slack + name: webhook \ No newline at end of file diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..8bb4757 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,60 @@ +# Application URLs +FRONTEND_HOST=photos.yourdomain.com +BACKEND_HOST=api.photos.yourdomain.com +ADMIN_URL=https://admin.photos.yourdomain.com +FRONTEND_URL=https://photos.yourdomain.com + +# Database Configuration +DB_NAME=photo_sharing +DB_USER=photoapp +DB_PASSWORD=your-secure-password-here + +# JWT Configuration +JWT_SECRET=your-jwt-secret-here + +# Email Configuration +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER=your-email@gmail.com +SMTP_PASS=your-app-password +EMAIL_FROM=noreply@yourdomain.com + +# Umami Analytics +UMAMI_URL=https://analytics.yourdomain.com +UMAMI_HOST=analytics.yourdomain.com +UMAMI_WEBSITE_ID=your-website-id +UMAMI_HASH_SALT=your-random-salt +UMAMI_DB_PASSWORD=umami-db-password + +# Traefik Configuration +TRAEFIK_HOST=traefik.yourdomain.com +ACME_EMAIL=admin@yourdomain.com +TRAEFIK_DASHBOARD_AUTH=admin:$2y$10$... # Use htpasswd to generate + +# Docker Registry (optional) +REGISTRY_URL=registry.yourdomain.com +VERSION=latest + +# Monitoring +DOMAIN=yourdomain.com +GRAFANA_USER=admin +GRAFANA_PASSWORD=your-grafana-password + +# OAuth Configuration (optional) +OAUTH_AUTH_URL=https://auth.yourdomain.com/oauth2/auth +OAUTH_TOKEN_URL=https://auth.yourdomain.com/oauth2/token +OAUTH_USER_URL=https://auth.yourdomain.com/oauth2/userinfo +OAUTH_CLIENT_ID=photo-sharing +OAUTH_CLIENT_SECRET=your-oauth-secret +OAUTH_SECRET=your-random-secret +COOKIE_DOMAIN=.yourdomain.com +OAUTH_WHITELIST=admin@yourdomain.com + +# Backup Configuration (optional) +S3_BACKUP_BUCKET=your-backup-bucket + +# Drone CI Configuration +DRONE_RPC_SECRET=your-drone-secret +DRONE_GITHUB_CLIENT_ID=your-github-client-id +DRONE_GITHUB_CLIENT_SECRET=your-github-client-secret \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..376c457 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,149 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Product Overview + +A secure photo sharing platform designed for weddings and events, enabling photographers to share time-limited, password-protected galleries. The platform features automatic expiration, archiving, and a scrappbook.de-inspired modern, minimalist UI. + +## Architecture Overview + +- **Backend**: Node.js/Express API with SQLite/PostgreSQL, file-based photo storage +- **Frontend**: React SPA with scrappbook.de-style design (requires implementation) +- **Storage**: File-based with active/archived separation +- **Services**: Background workers for email, archiving, file watching, and expiration monitoring +- **Analytics**: Umami integration for engagement tracking + +## Essential Commands + +### Backend Development +```bash +cd backend +npm install # Install dependencies +npm run migrate # Initialize database schema +npm run dev # Start with hot-reload (port 3001) +npm test # Run Jest tests +npm run lint # ESLint checks +``` + +### Running a Single Test +```bash +cd backend +npm test -- path/to/test.test.js +npm test -- --testNamePattern="test name" +``` + +### Production +```bash +docker-compose -f docker-compose.prod.yml up -d # Production deployment +pm2 start ecosystem.config.js # Alternative: PM2 deployment +``` + +## Key Product Requirements (from PRD) + +### Core Features +1. **File-Based System**: Drop photos in folders → automatic gallery creation +2. **Automatic Expiration**: Default 30 days, with 7-day warning emails +3. **Password Protection**: Secure access with customizable passwords +4. **Automatic Archiving**: ZIP compression and storage after expiration +5. **Email Notifications**: Creation, warning, and expiration notifications +6. **Analytics**: Umami tracking for views, downloads, and engagement + +### Folder Structure +``` +/events/ + ├── active/ + │ ├── wedding-smith-jones-2024-06-15/ + │ │ ├── collages/ + │ │ └── individual/ + │ └── birthday-emma-2024-07-20/ + └── archived/ + └── wedding-smith-jones-2024-06-15.zip +``` + +## Frontend Implementation Requirements + +### Design Style (scrappbook.de-inspired) +- **Color Palette**: Primary green (#5C8762), neutral backgrounds +- **Typography**: Clean, modern sans-serif (Noto Sans or similar) +- **Layout**: Minimalist, modular sections with grid-based photo displays +- **Aesthetic**: Professional yet approachable, photographer-focused + +### Key Frontend Components to Build +1. **Landing Page**: Password entry with event preview +2. **Gallery View**: + - Responsive photo grid with lazy loading + - Toggle between collages/individual photos + - Prominent expiration banner + - Download urgency indicators +3. **Photo Lightbox**: Full-screen viewing with zoom +4. **Mobile-First**: Responsive design with touch gestures +5. **Personalization**: Dynamic theming per event type + +### User Experience Priorities +- Clear expiration warnings (sticky banner) +- One-click "Download All" for urgent galleries +- Smooth image loading with skeleton screens +- Intuitive navigation between photo categories +- Professional presentation matching photographer branding + +## Key Architecture Patterns + +### Authentication Flow +- JWT-based with separate tokens for admin and gallery access +- Gallery tokens include event-specific claims +- Auth middleware: `backend/src/middleware/auth.js` + - `adminAuth` - Admin panel protection + - `photoAuth` - Protected photo access + - `verifyGalleryAccess` - Gallery-specific validation + +### Database Schema (Knex/SQLite) +Main tables: +- `events` - Gallery metadata with expiration, custom messages, themes +- `photos` - Photo records linked to events +- `access_logs` - IP-based usage tracking +- `email_queue` - Async email processing +- `admin_users` - Admin authentication + +### Service Architecture +Background services run as separate processes: +- **emailService**: Processes email queue with retry logic +- **archiveService**: Creates ZIP archives of expired events +- **expirationChecker**: Cron job for expiration warnings +- **fileWatcher**: Monitors for new photo uploads + +### API Structure +- `/api/admin/*` - Admin panel endpoints (requires adminAuth) +- `/api/gallery/*` - Public gallery endpoints +- `/api/auth/*` - Authentication endpoints +- Rate limiting: 100 req/15min (general), 5 req/15min (auth) + +## Critical Implementation Notes + +1. **Security**: All gallery access requires valid JWT with event-specific claims +2. **Expiration**: Events auto-expire based on `expires_at`, with 7-day email warnings +3. **Email Queue**: Async processing with retry logic, check `email_queue` table +4. **File Processing**: Sharp library for thumbnail generation (300x300) +5. **Frontend Status**: Only skeleton exists - requires full implementation based on PRD +6. **Umami Analytics**: Track password entries, downloads, views, expiration warnings + +## Environment Variables +Required in `.env`: +- `JWT_SECRET` - Token signing +- `ADMIN_URL`, `FRONTEND_URL` - CORS origins +- `SMTP_*` - Email configuration +- `DB_*` - PostgreSQL credentials (production) +- `UMAMI_*` - Analytics configuration + +## Testing Approach +- Jest with Supertest for API testing +- Test files in `__tests__` directories +- Database migrations run before tests +- Mock email sending in tests + +## Success Metrics (from PRD) +- Time to generate gallery: <2 minutes +- Guest satisfaction: >90% +- System uptime: 99.9% +- Email delivery rate: >98% +- Successful archiving: 100% \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..622d6b9 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,483 @@ +# Photo Sharing Platform - Production Deployment Guide + +This guide covers deploying the photo sharing platform using Docker Swarm, Traefik, and Drone CI/CD. + +## Table of Contents +- [Prerequisites](#prerequisites) +- [Infrastructure Setup](#infrastructure-setup) +- [Docker Swarm Setup](#docker-swarm-setup) +- [Traefik Setup](#traefik-setup) +- [Application Deployment](#application-deployment) +- [CI/CD with Drone](#cicd-with-drone) +- [Monitoring](#monitoring) +- [Backup and Recovery](#backup-and-recovery) +- [Troubleshooting](#troubleshooting) + +## Prerequisites + +### Hardware Requirements +- **Manager Node**: 2 CPU cores, 4GB RAM, 50GB storage +- **Worker Nodes**: 2 CPU cores, 2GB RAM, 20GB storage +- **Storage**: SSD recommended for database and photo storage + +### Software Requirements +- Ubuntu 20.04+ or similar Linux distribution +- Docker Engine 20.10+ +- Docker Compose 2.0+ +- Git +- SSL certificates (automated with Let's Encrypt) + +### Network Requirements +- Ports 80, 443 open for web traffic +- Port 2377 for Swarm management +- Ports 7946, 4789 for Swarm networking +- Static IP or reliable dynamic DNS + +## Infrastructure Setup + +### 1. Install Docker + +```bash +# Install Docker +curl -fsSL https://get.docker.com | sh + +# Add user to docker group +sudo usermod -aG docker $USER + +# Enable Docker service +sudo systemctl enable docker +sudo systemctl start docker +``` + +### 2. Configure Firewall + +```bash +# Allow Docker Swarm ports +sudo ufw allow 2377/tcp +sudo ufw allow 7946/tcp +sudo ufw allow 7946/udp +sudo ufw allow 4789/udp + +# Allow web traffic +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +``` + +## Docker Swarm Setup + +### 1. Initialize Swarm + +On the manager node: + +```bash +cd deploy/scripts +sudo ./init-swarm.sh +``` + +This script will: +- Initialize Docker Swarm +- Create overlay networks +- Label nodes for service placement +- Create required directories + +### 2. Join Worker Nodes + +On each worker node, run the join command displayed by the init script: + +```bash +docker swarm join --token SWMTKN-1-xxx... manager-ip:2377 +``` + +### 3. Verify Swarm + +```bash +docker node ls +``` + +## Application Configuration + +### 1. Environment Setup + +```bash +# Copy environment template +cp .env.production.example .env.production + +# Edit with your values +nano .env.production +``` + +Required configurations: +- Domain names for frontend, backend, and services +- SMTP credentials for email +- Database passwords +- JWT secrets + +### 2. Create Docker Secrets + +```bash +cd deploy/scripts +./create-secrets.sh +``` + +This will create all required secrets in Docker Swarm. Save the generated passwords! + +## Traefik Setup + +### 1. Deploy Traefik + +```bash +cd deploy/traefik + +# Create traefik network +docker network create --driver overlay traefik-public + +# Deploy Traefik stack +docker stack deploy -c docker-compose.traefik.yml traefik +``` + +### 2. Verify Traefik + +```bash +# Check service status +docker service ls | grep traefik + +# View logs +docker service logs traefik_traefik +``` + +Access Traefik dashboard at: `https://traefik.yourdomain.com/dashboard/` + +## Application Deployment + +### 1. Build Images (if using local registry) + +```bash +# Build frontend +cd frontend +docker build -t photo-sharing-frontend:latest . + +# Build backend +cd ../backend +docker build -t photo-sharing-backend:latest . +``` + +### 2. Deploy Application Stack + +```bash +cd deploy/scripts +./deploy.sh +``` + +Options: +- `--env FILE`: Specify environment file +- `--registry URL`: Docker registry URL +- `--version VERSION`: Image version to deploy + +### 3. Verify Deployment + +```bash +# Check all services +docker service ls + +# Check specific service +docker service ps photo-sharing_backend + +# View logs +docker service logs photo-sharing_backend -f +``` + +### 4. Run Database Migrations + +The deploy script automatically runs migrations, but you can run manually: + +```bash +docker exec $(docker ps -q -f name=photo-sharing_backend) npm run migrate +``` + +## CI/CD with Drone + +### 1. Drone Server Setup + +Deploy Drone server on your CI infrastructure: + +```bash +docker run \ + --volume=/var/lib/drone:/data \ + --env=DRONE_GITHUB_CLIENT_ID=your-id \ + --env=DRONE_GITHUB_CLIENT_SECRET=your-secret \ + --env=DRONE_RPC_SECRET=your-rpc-secret \ + --env=DRONE_SERVER_HOST=drone.yourdomain.com \ + --env=DRONE_SERVER_PROTO=https \ + --publish=80:80 \ + --publish=443:443 \ + --restart=always \ + --detach=true \ + --name=drone \ + drone/drone:2 +``` + +### 2. Drone Runner Setup + +On build servers: + +```bash +docker run -d \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -e DRONE_RPC_PROTO=https \ + -e DRONE_RPC_HOST=drone.yourdomain.com \ + -e DRONE_RPC_SECRET=your-rpc-secret \ + -e DRONE_RUNNER_CAPACITY=2 \ + -e DRONE_RUNNER_NAME=runner-1 \ + -p 3000:3000 \ + --restart always \ + --name runner \ + drone/drone-runner-docker:1 +``` + +### 3. Repository Setup + +1. Enable repository in Drone UI +2. Add secrets in Drone: + - `docker_username` + - `docker_password` + - `docker_registry` + - `staging_swarm_host` + - `staging_swarm_user` + - `staging_swarm_key` + - `prod_swarm_host` + - `prod_swarm_user` + - `prod_swarm_key` + - `slack_webhook` + +### 4. Deployment Workflow + +- Push to `develop` → Deploy to staging +- Create tag → Deploy to production +- Automatic rollback on failure + +## Monitoring + +### 1. Deploy Monitoring Stack + +```bash +cd deploy/monitoring + +# Deploy monitoring services +docker stack deploy -c docker-compose.monitoring.yml monitoring +``` + +### 2. Access Services + +- Grafana: `https://grafana.yourdomain.com` +- Prometheus: `https://prometheus.yourdomain.com` +- Alertmanager: `https://alerts.yourdomain.com` + +### 3. Configure Alerts + +Create alert rules in `deploy/monitoring/alerts/`: + +```yaml +groups: + - name: photo-sharing + rules: + - alert: ServiceDown + expr: up{job="photo-sharing-backend"} == 0 + for: 5m + annotations: + summary: "Photo sharing backend is down" +``` + +## Backup and Recovery + +### 1. Automated Backups + +Set up cron job for automated backups: + +```bash +# Edit crontab +crontab -e + +# Add daily backup at 2 AM +0 2 * * * /opt/photo-sharing/deploy/scripts/backup.sh +``` + +### 2. Manual Backup + +```bash +cd deploy/scripts +./backup.sh +``` + +### 3. Restore from Backup + +```bash +# Extract backup +tar -xzf backup-20240615-020000.tar.gz + +# Restore database +docker exec -i $(docker ps -q -f name=photo-sharing_db) \ + psql -U postgres photo_sharing < backup-20240615-020000/database.sql + +# Restore photos +tar -xzf backup-20240615-020000/photos.tar.gz -C /opt/photo-sharing/ + +# Restore volumes +docker run --rm \ + -v photo-sharing_app-data:/data \ + -v $(pwd)/backup-20240615-020000:/backup \ + alpine tar -xzf /backup/volume-photo-sharing_app-data.tar.gz -C /data +``` + +## Maintenance + +### 1. Scaling Services + +```bash +# Scale backend to 5 replicas +docker service scale photo-sharing_backend=5 + +# Scale frontend to 3 replicas +docker service scale photo-sharing_frontend=3 +``` + +### 2. Rolling Updates + +```bash +# Update backend image +docker service update \ + --image registry.yourdomain.com/photo-sharing-backend:v2.0 \ + photo-sharing_backend +``` + +### 3. Drain Node for Maintenance + +```bash +# Drain node +docker node update --availability drain worker-1 + +# Perform maintenance... + +# Activate node +docker node update --availability active worker-1 +``` + +## Troubleshooting + +### Common Issues + +#### 1. Service Won't Start +```bash +# Check service status +docker service ps photo-sharing_backend --no-trunc + +# View detailed logs +docker service logs photo-sharing_backend --details +``` + +#### 2. Database Connection Issues +```bash +# Check database logs +docker service logs photo-sharing_db + +# Test connection +docker exec $(docker ps -q -f name=photo-sharing_db) \ + pg_isready -U postgres +``` + +#### 3. Traefik Certificate Issues +```bash +# Check Traefik logs +docker service logs traefik_traefik | grep acme + +# Remove and regenerate certificates +rm -rf /opt/traefik/letsencrypt/acme.json +docker service update --force traefik_traefik +``` + +#### 4. Storage Issues +```bash +# Check disk usage +df -h + +# Clean up Docker +docker system prune -a +``` + +### Debug Mode + +Enable debug logging: + +```bash +# Update service with debug logging +docker service update \ + --env-add LOG_LEVEL=debug \ + photo-sharing_backend +``` + +### Health Checks + +```bash +# Check all endpoints +curl -f https://photos.yourdomain.com/health +curl -f https://api.photos.yourdomain.com/api/health +curl -f https://traefik.yourdomain.com/ping +``` + +## Security Best Practices + +1. **Regular Updates** + - Keep Docker and system packages updated + - Update application dependencies regularly + - Monitor security advisories + +2. **Access Control** + - Use strong passwords for all services + - Enable 2FA where possible + - Restrict SSH access to specific IPs + - Use Docker secrets for sensitive data + +3. **Network Security** + - Use internal networks for service communication + - Enable firewall rules + - Use TLS for all external communication + - Regular security scans with Trivy + +4. **Backup Security** + - Encrypt backups at rest + - Test restore procedures regularly + - Store backups in multiple locations + - Rotate old backups + +## Performance Tuning + +1. **Database Optimization** + ```sql + -- Add indexes for common queries + CREATE INDEX idx_photos_event_id ON photos(event_id); + CREATE INDEX idx_access_logs_event_id ON access_logs(event_id); + ``` + +2. **Image Optimization** + - Use CDN for static assets + - Enable aggressive caching + - Optimize image sizes before upload + +3. **Service Limits** + ```yaml + deploy: + resources: + limits: + cpus: '2' + memory: 1G + reservations: + cpus: '0.5' + memory: 256M + ``` + +## Support + +For issues and questions: +- Check logs: `docker service logs ` +- Review documentation: [README.md](README.md) +- Check monitoring dashboards +- Contact: admin@yourdomain.com \ No newline at end of file diff --git a/README-LOCAL.md b/README-LOCAL.md new file mode 100644 index 0000000..863e3c6 --- /dev/null +++ b/README-LOCAL.md @@ -0,0 +1,130 @@ +# 🚀 Quick Local Development Setup + +Get the photo sharing platform running locally in under 2 minutes! + +## Prerequisites +- Docker Desktop installed and running +- Git +- 4GB RAM available + +## Quick Start + +```bash +# 1. Clone the repository +git clone +cd wedding-photo-sharing + +# 2. Start everything +./start-local.sh +``` + +That's it! 🎉 + +## What You Get + +| Service | URL | Description | +|---------|-----|-------------| +| Frontend (Dev) | http://localhost:3002 | React app with hot reload | +| Frontend (Prod) | http://localhost:3000 | Production build | +| Backend API | http://localhost:3001 | Express API | +| Mailhog | http://localhost:8025 | Email testing UI | + +## Default Credentials + +- **Admin Login**: admin / admin123 +- **Test Gallery**: + - Create via Admin Panel + - Password: test123 + +## Common Tasks + +### View Logs +```bash +docker-compose -f docker-compose.local.yml logs -f +``` + +### Stop Everything +```bash +./stop-local.sh +``` + +### Reset Database +```bash +docker-compose -f docker-compose.local.yml exec backend npm run migrate +``` + +### Add Test Photos +1. Create a gallery in the admin panel +2. Get the gallery slug (e.g., `wedding-smith-2024`) +3. Add photos to: `./storage/events/active/wedding-smith-2024/` +4. Photos appear automatically! + +### Access Backend Shell +```bash +docker-compose -f docker-compose.local.yml exec backend sh +``` + +## Development Workflow + +1. **Frontend Development** (Port 3002) + - Hot reload enabled + - Edit files in `./frontend/src` + - Changes appear instantly + +2. **Backend Development** (Port 3001) + - Nodemon watches for changes + - Edit files in `./backend/src` + - Server restarts automatically + +3. **Email Testing** + - All emails go to Mailhog + - View at http://localhost:8025 + - No real emails sent! + +## Troubleshooting + +### Backend won't start +```bash +# Check logs +docker-compose -f docker-compose.local.yml logs backend + +# Rebuild +docker-compose -f docker-compose.local.yml build backend +``` + +### Frontend build issues +```bash +# Clear cache and rebuild +docker-compose -f docker-compose.local.yml exec frontend-dev npm run build +``` + +### Port conflicts +Edit `docker-compose.local.yml` and change the port mappings: +- Backend: Change `3001:3000` to `XXXX:3000` +- Frontend: Change `3002:5173` to `YYYY:5173` + +### Reset everything +```bash +# Stop and remove all data +docker-compose -f docker-compose.local.yml down -v +rm -rf data storage logs +./start-local.sh +``` + +## Tips + +- 📧 Check Mailhog for all emails +- 🔄 Frontend auto-refreshes on save +- 📁 SQLite DB at `./data/photo_sharing.db` +- 🖼️ Photos in `./storage/events/active/` +- 📝 Logs in `./logs/` + +## Next Steps + +1. Create your first gallery via Admin Panel +2. Upload some test photos +3. Test the gallery with password +4. Check expiration warnings +5. View emails in Mailhog + +Happy coding! 🎨 \ No newline at end of file diff --git a/README.md b/README.md index 97c0f1f..e9042aa 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,32 @@ -# wedding-photo-sharing +# Photo Sharing Platform -Secure photo sharing platform for weddings and events with automatic expiration and email notifications \ No newline at end of file +A secure, self-hosted photo sharing platform designed for weddings and events. Features automatic expiration, email notifications, and simple file-based management. + +## Features + +- 🔒 Password Protected Galleries +- ⏰ Automatic Expiration +- 📧 Email Notifications +- 📁 Simple File Management +- 📊 Analytics Integration +- 🎨 Customizable Themes +- 📱 Mobile Responsive +- ⚡ Docker Ready + +## Quick Start + +1. Clone the repository +2. Run `./scripts/install.sh` +3. Configure `.env` file +4. Setup SSL: `./scripts/setup-ssl.sh` +5. Start: `docker-compose -f docker-compose.prod.yml up -d` + +Default credentials: admin / admin123 (change immediately!) + +## Documentation + +See DEPLOYMENT.md for detailed deployment instructions. + +## License + +MIT License diff --git a/backend/migrations/init.js b/backend/migrations/init.js new file mode 100644 index 0000000..79b623a --- /dev/null +++ b/backend/migrations/init.js @@ -0,0 +1,37 @@ +const bcrypt = require('bcrypt'); +const { db, initializeDatabase } = require('../src/database/db'); + +async function runMigrations() { + console.log('Running database migrations...'); + + try { + // Initialize tables + await initializeDatabase(); + + // Create default admin user if none exists + const adminExists = await db('admin_users').first(); + if (!adminExists) { + const defaultPassword = 'admin123'; // Change this! + const passwordHash = await bcrypt.hash(defaultPassword, 10); + + await db('admin_users').insert({ + username: 'admin', + email: 'admin@example.com', + password_hash: passwordHash + }); + + console.log('Default admin user created:'); + console.log('Username: admin'); + console.log('Password: admin123'); + console.log('⚠️ Please change this password immediately!'); + } + + console.log('Migrations completed successfully'); + process.exit(0); + } catch (error) { + console.error('Migration failed:', error); + process.exit(1); + } +} + +runMigrations(); diff --git a/backend/src/services/imageProcessor.js b/backend/src/services/imageProcessor.js new file mode 100644 index 0000000..030e438 --- /dev/null +++ b/backend/src/services/imageProcessor.js @@ -0,0 +1,28 @@ +const sharp = require('sharp'); +const path = require('path'); +const fs = require('fs').promises; + +const THUMBNAIL_WIDTH = 300; +const THUMBNAIL_PATH = path.join(__dirname, '../../../storage/thumbnails'); + +async function generateThumbnail(imagePath) { + const filename = path.basename(imagePath); + const thumbnailFilename = `thumb_${filename}`; + const thumbnailPath = path.join(THUMBNAIL_PATH, thumbnailFilename); + + // Ensure thumbnail directory exists + await fs.mkdir(THUMBNAIL_PATH, { recursive: true }); + + // Generate thumbnail + await sharp(imagePath) + .resize(THUMBNAIL_WIDTH, null, { + withoutEnlargement: true, + fit: 'inside' + }) + .jpeg({ quality: 80 }) + .toFile(thumbnailPath); + + return path.relative(path.join(__dirname, '../../../storage'), thumbnailPath); +} + +module.exports = { generateThumbnail }; diff --git a/backend/src/utils/logger.js b/backend/src/utils/logger.js new file mode 100644 index 0000000..cd7c65f --- /dev/null +++ b/backend/src/utils/logger.js @@ -0,0 +1,31 @@ +const winston = require('winston'); +const path = require('path'); + +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: winston.format.combine( + winston.format.timestamp(), + winston.format.errors({ stack: true }), + winston.format.json() + ), + transports: [ + new winston.transports.File({ + filename: path.join(__dirname, '../../../logs/error.log'), + level: 'error' + }), + new winston.transports.File({ + filename: path.join(__dirname, '../../../logs/combined.log') + }) + ] +}); + +if (process.env.NODE_ENV !== 'production') { + logger.add(new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.simple() + ) + })); +} + +module.exports = logger; diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/deploy/docker-stack.yml b/deploy/docker-stack.yml new file mode 100644 index 0000000..69ebb71 --- /dev/null +++ b/deploy/docker-stack.yml @@ -0,0 +1,265 @@ +version: '3.8' + +services: + backend: + image: ${REGISTRY_URL}/photo-sharing-backend:${VERSION:-latest} + networks: + - photo-sharing + - traefik-public + environment: + - NODE_ENV=production + - PORT=3000 + - JWT_SECRET_FILE=/run/secrets/jwt_secret + - ADMIN_URL=${ADMIN_URL} + - FRONTEND_URL=${FRONTEND_URL} + - SMTP_HOST=${SMTP_HOST} + - SMTP_PORT=${SMTP_PORT} + - SMTP_SECURE=${SMTP_SECURE} + - SMTP_USER_FILE=/run/secrets/smtp_user + - SMTP_PASS_FILE=/run/secrets/smtp_pass + - EMAIL_FROM=${EMAIL_FROM} + - UMAMI_URL=${UMAMI_URL} + - UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID} + - DB_HOST=db + - DB_PORT=5432 + - DB_NAME=${DB_NAME:-photo_sharing} + - DB_USER_FILE=/run/secrets/db_user + - DB_PASSWORD_FILE=/run/secrets/db_password + secrets: + - jwt_secret + - smtp_user + - smtp_pass + - db_user + - db_password + volumes: + - photo-storage:/app/storage + - app-data:/app/data + - app-logs:/app/logs + deploy: + replicas: 3 + update_config: + parallelism: 1 + delay: 10s + failure_action: rollback + max_failure_ratio: 0.3 + restart_policy: + condition: on-failure + delay: 5s + max_attempts: 3 + resources: + limits: + cpus: '1' + memory: 512M + reservations: + cpus: '0.25' + memory: 128M + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik-public" + - "traefik.constraint-label=traefik-public" + - "traefik.http.routers.backend.rule=Host(`${BACKEND_HOST}`) && PathPrefix(`/api`)" + - "traefik.http.routers.backend.entrypoints=https" + - "traefik.http.routers.backend.tls=true" + - "traefik.http.routers.backend.tls.certresolver=letsencrypt" + - "traefik.http.services.backend.loadbalancer.server.port=3000" + - "traefik.http.services.backend.loadbalancer.healthcheck.path=/api/health" + - "traefik.http.services.backend.loadbalancer.healthcheck.interval=10s" + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + frontend: + image: ${REGISTRY_URL}/photo-sharing-frontend:${VERSION:-latest} + networks: + - photo-sharing + - traefik-public + deploy: + replicas: 2 + update_config: + parallelism: 1 + delay: 10s + failure_action: rollback + restart_policy: + condition: on-failure + resources: + limits: + cpus: '0.5' + memory: 256M + reservations: + cpus: '0.1' + memory: 64M + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik-public" + - "traefik.constraint-label=traefik-public" + - "traefik.http.routers.frontend.rule=Host(`${FRONTEND_HOST}`)" + - "traefik.http.routers.frontend.entrypoints=https" + - "traefik.http.routers.frontend.tls=true" + - "traefik.http.routers.frontend.tls.certresolver=letsencrypt" + - "traefik.http.services.frontend.loadbalancer.server.port=80" + - "traefik.http.middlewares.frontend-compress.compress=true" + - "traefik.http.routers.frontend.middlewares=frontend-compress" + + db: + image: postgres:14-alpine + networks: + - photo-sharing + environment: + - POSTGRES_USER_FILE=/run/secrets/db_user + - POSTGRES_PASSWORD_FILE=/run/secrets/db_password + - POSTGRES_DB=${DB_NAME:-photo_sharing} + secrets: + - db_user + - db_password + volumes: + - postgres-data:/var/lib/postgresql/data + deploy: + placement: + constraints: + - node.labels.db == true + restart_policy: + condition: on-failure + resources: + limits: + cpus: '2' + memory: 1G + reservations: + cpus: '0.5' + memory: 256M + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + # Background workers as separate services for better control + email-worker: + image: ${REGISTRY_URL}/photo-sharing-backend:${VERSION:-latest} + command: ["node", "src/services/emailService.js"] + networks: + - photo-sharing + environment: + - NODE_ENV=production + - JWT_SECRET_FILE=/run/secrets/jwt_secret + - SMTP_HOST=${SMTP_HOST} + - SMTP_PORT=${SMTP_PORT} + - SMTP_SECURE=${SMTP_SECURE} + - SMTP_USER_FILE=/run/secrets/smtp_user + - SMTP_PASS_FILE=/run/secrets/smtp_pass + - EMAIL_FROM=${EMAIL_FROM} + secrets: + - jwt_secret + - smtp_user + - smtp_pass + volumes: + - app-data:/app/data + - app-logs:/app/logs + deploy: + replicas: 1 + restart_policy: + condition: on-failure + delay: 5s + resources: + limits: + cpus: '0.5' + memory: 256M + + expiration-checker: + image: ${REGISTRY_URL}/photo-sharing-backend:${VERSION:-latest} + command: ["node", "src/services/expirationChecker.js"] + networks: + - photo-sharing + environment: + - NODE_ENV=production + volumes: + - photo-storage:/app/storage + - app-data:/app/data + - app-logs:/app/logs + deploy: + replicas: 1 + restart_policy: + condition: on-failure + delay: 5s + resources: + limits: + cpus: '0.5' + memory: 256M + + archive-worker: + image: ${REGISTRY_URL}/photo-sharing-backend:${VERSION:-latest} + command: ["node", "src/services/archiveService.js"] + networks: + - photo-sharing + environment: + - NODE_ENV=production + volumes: + - photo-storage:/app/storage + - app-data:/app/data + - app-logs:/app/logs + deploy: + replicas: 1 + restart_policy: + condition: on-failure + delay: 5s + resources: + limits: + cpus: '1' + memory: 512M + + # Umami Analytics + umami: + image: ghcr.io/umami-software/umami:postgresql-latest + networks: + - photo-sharing + - traefik-public + environment: + DATABASE_URL: postgresql://umami:${UMAMI_DB_PASSWORD}@db:5432/umami + DATABASE_TYPE: postgresql + HASH_SALT: ${UMAMI_HASH_SALT} + depends_on: + - db + deploy: + replicas: 1 + restart_policy: + condition: on-failure + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik-public" + - "traefik.constraint-label=traefik-public" + - "traefik.http.routers.umami.rule=Host(`${UMAMI_HOST}`)" + - "traefik.http.routers.umami.entrypoints=https" + - "traefik.http.routers.umami.tls=true" + - "traefik.http.routers.umami.tls.certresolver=letsencrypt" + - "traefik.http.services.umami.loadbalancer.server.port=3000" + +networks: + photo-sharing: + driver: overlay + attachable: true + traefik-public: + external: true + +volumes: + postgres-data: + driver: local + photo-storage: + driver: local + app-data: + driver: local + app-logs: + driver: local + +secrets: + jwt_secret: + external: true + smtp_user: + external: true + smtp_pass: + external: true + db_user: + external: true + db_password: + external: true \ No newline at end of file diff --git a/deploy/monitoring/docker-compose.monitoring.yml b/deploy/monitoring/docker-compose.monitoring.yml new file mode 100644 index 0000000..83dc70c --- /dev/null +++ b/deploy/monitoring/docker-compose.monitoring.yml @@ -0,0 +1,189 @@ +version: '3.8' + +services: + prometheus: + image: prom/prometheus:latest + networks: + - monitoring + - traefik-public + volumes: + - prometheus-data:/prometheus + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.console.libraries=/usr/share/prometheus/console_libraries' + - '--web.console.templates=/usr/share/prometheus/consoles' + - '--web.enable-lifecycle' + - '--storage.tsdb.retention.time=30d' + deploy: + replicas: 1 + placement: + constraints: + - node.labels.monitoring == true + resources: + limits: + memory: 1G + cpus: '1' + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik-public" + - "traefik.http.routers.prometheus.rule=Host(`prometheus.${DOMAIN}`)" + - "traefik.http.routers.prometheus.entrypoints=https" + - "traefik.http.routers.prometheus.tls=true" + - "traefik.http.routers.prometheus.tls.certresolver=letsencrypt" + - "traefik.http.routers.prometheus.middlewares=admin-auth" + - "traefik.http.services.prometheus.loadbalancer.server.port=9090" + + grafana: + image: grafana/grafana:latest + networks: + - monitoring + - traefik-public + volumes: + - grafana-data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning:ro + environment: + - GF_SECURITY_ADMIN_USER=${GRAFANA_USER:-admin} + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD} + - GF_USERS_ALLOW_SIGN_UP=false + - GF_SERVER_ROOT_URL=https://grafana.${DOMAIN} + - GF_SMTP_ENABLED=true + - GF_SMTP_HOST=${SMTP_HOST}:${SMTP_PORT} + - GF_SMTP_USER=${SMTP_USER} + - GF_SMTP_PASSWORD=${SMTP_PASS} + - GF_SMTP_FROM_ADDRESS=${EMAIL_FROM} + deploy: + replicas: 1 + placement: + constraints: + - node.labels.monitoring == true + resources: + limits: + memory: 512M + cpus: '0.5' + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik-public" + - "traefik.http.routers.grafana.rule=Host(`grafana.${DOMAIN}`)" + - "traefik.http.routers.grafana.entrypoints=https" + - "traefik.http.routers.grafana.tls=true" + - "traefik.http.routers.grafana.tls.certresolver=letsencrypt" + - "traefik.http.services.grafana.loadbalancer.server.port=3000" + + loki: + image: grafana/loki:latest + networks: + - monitoring + volumes: + - loki-data:/loki + - ./loki-config.yml:/etc/loki/config.yml:ro + command: -config.file=/etc/loki/config.yml + deploy: + replicas: 1 + placement: + constraints: + - node.labels.monitoring == true + resources: + limits: + memory: 1G + cpus: '1' + + promtail: + image: grafana/promtail:latest + networks: + - monitoring + volumes: + - /var/log:/var/log:ro + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - ./promtail-config.yml:/etc/promtail/config.yml:ro + command: -config.file=/etc/promtail/config.yml + deploy: + mode: global + resources: + limits: + memory: 256M + cpus: '0.25' + + node-exporter: + image: prom/node-exporter:latest + networks: + - monitoring + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/rootfs:ro + command: + - '--path.procfs=/host/proc' + - '--path.sysfs=/host/sys' + - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)' + deploy: + mode: global + resources: + limits: + memory: 128M + cpus: '0.1' + + cadvisor: + image: gcr.io/cadvisor/cadvisor:latest + networks: + - monitoring + volumes: + - /:/rootfs:ro + - /var/run:/var/run:ro + - /sys:/sys:ro + - /var/lib/docker/:/var/lib/docker:ro + - /dev/disk/:/dev/disk:ro + privileged: true + deploy: + mode: global + resources: + limits: + memory: 256M + cpus: '0.25' + + alertmanager: + image: prom/alertmanager:latest + networks: + - monitoring + - traefik-public + volumes: + - alertmanager-data:/alertmanager + - ./alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro + command: + - '--config.file=/etc/alertmanager/alertmanager.yml' + - '--storage.path=/alertmanager' + deploy: + replicas: 1 + placement: + constraints: + - node.labels.monitoring == true + resources: + limits: + memory: 256M + cpus: '0.25' + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik-public" + - "traefik.http.routers.alertmanager.rule=Host(`alerts.${DOMAIN}`)" + - "traefik.http.routers.alertmanager.entrypoints=https" + - "traefik.http.routers.alertmanager.tls=true" + - "traefik.http.routers.alertmanager.tls.certresolver=letsencrypt" + - "traefik.http.routers.alertmanager.middlewares=admin-auth" + - "traefik.http.services.alertmanager.loadbalancer.server.port=9093" + +networks: + monitoring: + external: true + traefik-public: + external: true + +volumes: + prometheus-data: + driver: local + grafana-data: + driver: local + loki-data: + driver: local + alertmanager-data: + driver: local \ No newline at end of file diff --git a/deploy/monitoring/prometheus.yml b/deploy/monitoring/prometheus.yml new file mode 100644 index 0000000..df78b22 --- /dev/null +++ b/deploy/monitoring/prometheus.yml @@ -0,0 +1,65 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + external_labels: + monitor: 'photo-sharing' + environment: 'production' + +alerting: + alertmanagers: + - static_configs: + - targets: ['alertmanager:9093'] + +rule_files: + - '/etc/prometheus/alerts/*.yml' + +scrape_configs: + # Prometheus itself + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + + # Node Exporter + - job_name: 'node-exporter' + dns_sd_configs: + - names: + - 'tasks.node-exporter' + type: 'A' + port: 9100 + + # Docker containers + - job_name: 'cadvisor' + dns_sd_configs: + - names: + - 'tasks.cadvisor' + type: 'A' + port: 8080 + + # Traefik + - job_name: 'traefik' + static_configs: + - targets: ['traefik:8082'] + + # Photo Sharing Backend + - job_name: 'photo-sharing-backend' + dns_sd_configs: + - names: + - 'tasks.photo-sharing_backend' + type: 'A' + port: 3000 + metrics_path: '/api/metrics' + + # PostgreSQL + - job_name: 'postgres' + static_configs: + - targets: ['photo-sharing_db:9187'] + + # Loki + - job_name: 'loki' + static_configs: + - targets: ['loki:3100'] + + # Grafana + - job_name: 'grafana' + static_configs: + - targets: ['grafana:3000'] \ No newline at end of file diff --git a/deploy/scripts/backup.sh b/deploy/scripts/backup.sh new file mode 100755 index 0000000..20abbf2 --- /dev/null +++ b/deploy/scripts/backup.sh @@ -0,0 +1,134 @@ +#!/bin/bash +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}Photo Sharing Platform Backup Script${NC}" +echo "====================================" + +# Configuration +BACKUP_DIR="/opt/photo-sharing/backup" +STACK_NAME="photo-sharing" +RETENTION_DAYS=30 +TIMESTAMP=$(date +%Y%m%d-%H%M%S) +BACKUP_NAME="backup-${TIMESTAMP}" + +# Create backup directory +mkdir -p $BACKUP_DIR/$BACKUP_NAME + +# Function to check if service is running +check_service() { + local service=$1 + if docker service ps ${STACK_NAME}_${service} --format "{{.CurrentState}}" | grep -q "Running"; then + return 0 + else + return 1 + fi +} + +# Backup database +echo -e "${GREEN}Backing up database...${NC}" +if check_service "db"; then + DB_CONTAINER=$(docker ps -q -f name=${STACK_NAME}_db -f status=running | head -1) + if [ ! -z "$DB_CONTAINER" ]; then + docker exec $DB_CONTAINER pg_dumpall -U postgres > $BACKUP_DIR/$BACKUP_NAME/database.sql + echo -e "${GREEN}Database backup completed${NC}" + else + echo -e "${RED}Database container not found${NC}" + fi +else + echo -e "${YELLOW}Database service not running, skipping...${NC}" +fi + +# Backup photos +echo -e "${GREEN}Backing up photos...${NC}" +if [ -d "/opt/photo-sharing/storage" ]; then + tar -czf $BACKUP_DIR/$BACKUP_NAME/photos.tar.gz -C /opt/photo-sharing storage/ + echo -e "${GREEN}Photos backup completed${NC}" +else + echo -e "${YELLOW}Photos directory not found, skipping...${NC}" +fi + +# Backup application data +echo -e "${GREEN}Backing up application data...${NC}" +if [ -d "/opt/photo-sharing/data" ]; then + tar -czf $BACKUP_DIR/$BACKUP_NAME/app-data.tar.gz -C /opt/photo-sharing data/ + echo -e "${GREEN}Application data backup completed${NC}" +else + echo -e "${YELLOW}Application data directory not found, skipping...${NC}" +fi + +# Backup Docker volumes +echo -e "${GREEN}Backing up Docker volumes...${NC}" +for volume in $(docker volume ls -q | grep ${STACK_NAME}); do + echo "Backing up volume: $volume" + docker run --rm \ + -v $volume:/data \ + -v $BACKUP_DIR/$BACKUP_NAME:/backup \ + alpine tar -czf /backup/volume-${volume}.tar.gz -C /data . +done + +# Backup configurations +echo -e "${GREEN}Backing up configurations...${NC}" +if [ -f "../../.env.production" ]; then + cp ../../.env.production $BACKUP_DIR/$BACKUP_NAME/ +fi + +# Export Docker secrets (encrypted) +echo -e "${GREEN}Exporting Docker secrets info...${NC}" +docker secret ls --filter "label=com.docker.stack.namespace=$STACK_NAME" > $BACKUP_DIR/$BACKUP_NAME/secrets-list.txt + +# Create backup manifest +echo -e "${GREEN}Creating backup manifest...${NC}" +cat > $BACKUP_DIR/$BACKUP_NAME/manifest.json << EOF +{ + "timestamp": "$TIMESTAMP", + "stack_name": "$STACK_NAME", + "hostname": "$(hostname)", + "docker_version": "$(docker version --format '{{.Server.Version}}')", + "services": $(docker service ls --filter "label=com.docker.stack.namespace=$STACK_NAME" --format '{{json .}}' | jq -s .), + "backup_contents": [ + "database.sql", + "photos.tar.gz", + "app-data.tar.gz", + "volume-*.tar.gz", + ".env.production", + "secrets-list.txt" + ] +} +EOF + +# Compress entire backup +echo -e "${GREEN}Compressing backup...${NC}" +cd $BACKUP_DIR +tar -czf ${BACKUP_NAME}.tar.gz $BACKUP_NAME/ +rm -rf $BACKUP_NAME/ + +# Upload to S3 (optional) +if [ ! -z "$S3_BACKUP_BUCKET" ] && command -v aws &> /dev/null; then + echo -e "${GREEN}Uploading to S3...${NC}" + aws s3 cp ${BACKUP_NAME}.tar.gz s3://${S3_BACKUP_BUCKET}/photo-sharing/ +fi + +# Clean up old backups +echo -e "${GREEN}Cleaning up old backups...${NC}" +find $BACKUP_DIR -name "backup-*.tar.gz" -mtime +$RETENTION_DAYS -delete + +# Show backup summary +BACKUP_SIZE=$(du -h $BACKUP_DIR/${BACKUP_NAME}.tar.gz | cut -f1) +echo "" +echo -e "${GREEN}Backup completed successfully!${NC}" +echo -e "Backup file: $BACKUP_DIR/${BACKUP_NAME}.tar.gz" +echo -e "Backup size: $BACKUP_SIZE" +echo -e "Retention: $RETENTION_DAYS days" + +# Verify backup +echo "" +echo -e "${GREEN}Verifying backup...${NC}" +tar -tzf $BACKUP_DIR/${BACKUP_NAME}.tar.gz | head -10 +echo "..." +echo -e "${GREEN}Backup verification complete${NC}" \ No newline at end of file diff --git a/deploy/scripts/create-secrets.sh b/deploy/scripts/create-secrets.sh new file mode 100755 index 0000000..4bf0c56 --- /dev/null +++ b/deploy/scripts/create-secrets.sh @@ -0,0 +1,111 @@ +#!/bin/bash +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}Docker Secrets Creation Script${NC}" +echo "===============================" + +# Function to create or update a secret +create_secret() { + local secret_name=$1 + local secret_value=$2 + + # Check if secret exists + if docker secret ls | grep -q $secret_name; then + echo -e "${YELLOW}Secret '$secret_name' already exists. Skipping...${NC}" + else + echo "$secret_value" | docker secret create $secret_name - + echo -e "${GREEN}Created secret: $secret_name${NC}" + fi +} + +# Function to generate random password +generate_password() { + openssl rand -base64 32 | tr -d "=+/" | cut -c1-25 +} + +# Check if in swarm mode +if ! docker info | grep -q "Swarm: active"; then + echo -e "${RED}Docker is not in swarm mode. Run init-swarm.sh first.${NC}" + exit 1 +fi + +# Load environment variables if .env.production exists +if [ -f "../../.env.production" ]; then + echo -e "${GREEN}Loading environment variables from .env.production${NC}" + source ../../.env.production +fi + +# JWT Secret +if [ -z "$JWT_SECRET" ]; then + JWT_SECRET=$(generate_password) + echo -e "${YELLOW}Generated JWT_SECRET: $JWT_SECRET${NC}" +fi +create_secret "jwt_secret" "$JWT_SECRET" + +# Database credentials +if [ -z "$DB_USER" ]; then + DB_USER="photoapp" +fi +if [ -z "$DB_PASSWORD" ]; then + DB_PASSWORD=$(generate_password) + echo -e "${YELLOW}Generated DB_PASSWORD: $DB_PASSWORD${NC}" +fi +create_secret "db_user" "$DB_USER" +create_secret "db_password" "$DB_PASSWORD" + +# SMTP credentials +if [ -z "$SMTP_USER" ]; then + read -p "Enter SMTP username: " SMTP_USER +fi +if [ -z "$SMTP_PASS" ]; then + read -sp "Enter SMTP password: " SMTP_PASS + echo +fi +create_secret "smtp_user" "$SMTP_USER" +create_secret "smtp_pass" "$SMTP_PASS" + +# Traefik dashboard auth (username:password) +if [ -z "$TRAEFIK_USER" ]; then + TRAEFIK_USER="admin" +fi +if [ -z "$TRAEFIK_PASSWORD" ]; then + TRAEFIK_PASSWORD=$(generate_password) + echo -e "${YELLOW}Generated TRAEFIK_PASSWORD: $TRAEFIK_PASSWORD${NC}" +fi +# Generate htpasswd format +TRAEFIK_AUTH=$(docker run --rm httpd:alpine htpasswd -nb $TRAEFIK_USER $TRAEFIK_PASSWORD) +create_secret "traefik_dashboard_auth" "$TRAEFIK_AUTH" + +# OAuth secrets (optional) +if [ ! -z "$OAUTH_CLIENT_SECRET" ]; then + create_secret "oauth_client_secret" "$OAUTH_CLIENT_SECRET" +fi + +if [ ! -z "$OAUTH_SECRET" ]; then + create_secret "oauth_secret" "$OAUTH_SECRET" +fi + +# Drone CI secrets +if [ ! -z "$DRONE_RPC_SECRET" ]; then + create_secret "drone_rpc_secret" "$DRONE_RPC_SECRET" +fi + +echo "" +echo -e "${GREEN}Secrets creation complete!${NC}" +echo "" +echo -e "${YELLOW}Important: Save these generated values in a secure location:${NC}" +echo "JWT_SECRET=$JWT_SECRET" +echo "DB_PASSWORD=$DB_PASSWORD" +echo "TRAEFIK_USER=$TRAEFIK_USER" +echo "TRAEFIK_PASSWORD=$TRAEFIK_PASSWORD" +echo "" +echo -e "${GREEN}Next steps:${NC}" +echo "1. Update .env.production with the generated values" +echo "2. Deploy Traefik: ./deploy-traefik.sh" +echo "3. Deploy the application: ./deploy.sh" \ No newline at end of file diff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh new file mode 100755 index 0000000..f47d81b --- /dev/null +++ b/deploy/scripts/deploy.sh @@ -0,0 +1,196 @@ +#!/bin/bash +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo -e "${GREEN}Photo Sharing Platform Deployment Script${NC}" +echo "========================================" + +# Default values +STACK_NAME="photo-sharing" +ENV_FILE="../../.env.production" +REGISTRY_URL="${REGISTRY_URL:-}" +VERSION="${VERSION:-latest}" + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + --env) + ENV_FILE="$2" + shift 2 + ;; + --registry) + REGISTRY_URL="$2" + shift 2 + ;; + --version) + VERSION="$2" + shift 2 + ;; + --stack-name) + STACK_NAME="$2" + shift 2 + ;; + --help) + echo "Usage: $0 [options]" + echo "Options:" + echo " --env FILE Path to environment file (default: ../../.env.production)" + echo " --registry URL Docker registry URL" + echo " --version VERSION Image version to deploy (default: latest)" + echo " --stack-name NAME Stack name (default: photo-sharing)" + exit 0 + ;; + *) + echo -e "${RED}Unknown option: $1${NC}" + exit 1 + ;; + esac +done + +# Check if Docker is in swarm mode +if ! docker info | grep -q "Swarm: active"; then + echo -e "${RED}Docker is not in swarm mode. Run init-swarm.sh first.${NC}" + exit 1 +fi + +# Check if environment file exists +if [ ! -f "$ENV_FILE" ]; then + echo -e "${RED}Environment file not found: $ENV_FILE${NC}" + echo "Please create it from .env.production.example" + exit 1 +fi + +# Load environment variables +echo -e "${GREEN}Loading environment variables...${NC}" +set -a +source "$ENV_FILE" +set +a + +# Export deployment variables +export REGISTRY_URL +export VERSION + +# Validate required environment variables +required_vars=( + "FRONTEND_HOST" + "BACKEND_HOST" + "ADMIN_URL" + "FRONTEND_URL" + "ACME_EMAIL" + "DB_NAME" + "EMAIL_FROM" +) + +echo -e "${GREEN}Validating configuration...${NC}" +for var in "${required_vars[@]}"; do + if [ -z "${!var}" ]; then + echo -e "${RED}Missing required environment variable: $var${NC}" + exit 1 + fi +done + +# Check if Traefik is running +if ! docker service ls | grep -q "traefik_traefik"; then + echo -e "${YELLOW}Traefik is not running. Deploy it first with:${NC}" + echo "cd ../traefik && docker stack deploy -c docker-compose.traefik.yml traefik" + exit 1 +fi + +# Check if secrets exist +echo -e "${GREEN}Checking Docker secrets...${NC}" +required_secrets=( + "jwt_secret" + "db_user" + "db_password" + "smtp_user" + "smtp_pass" +) + +for secret in "${required_secrets[@]}"; do + if ! docker secret ls | grep -q $secret; then + echo -e "${RED}Missing required secret: $secret${NC}" + echo "Run create-secrets.sh first" + exit 1 + fi +done + +# Pull latest images if registry is specified +if [ ! -z "$REGISTRY_URL" ]; then + echo -e "${GREEN}Pulling latest images...${NC}" + docker pull ${REGISTRY_URL}/photo-sharing-backend:${VERSION} || true + docker pull ${REGISTRY_URL}/photo-sharing-frontend:${VERSION} || true +fi + +# Deploy the stack +echo -e "${GREEN}Deploying stack: $STACK_NAME${NC}" +echo -e "${BLUE}Version: $VERSION${NC}" +echo -e "${BLUE}Registry: ${REGISTRY_URL:-local}${NC}" + +cd .. +docker stack deploy \ + -c docker-stack.yml \ + --with-registry-auth \ + $STACK_NAME + +# Wait for services to start +echo -e "${GREEN}Waiting for services to start...${NC}" +sleep 10 + +# Check service status +echo -e "${GREEN}Service status:${NC}" +docker service ls --filter "label=com.docker.stack.namespace=$STACK_NAME" + +# Wait for database to be ready +echo -e "${GREEN}Waiting for database to be ready...${NC}" +max_attempts=30 +attempt=1 +while [ $attempt -le $max_attempts ]; do + if docker exec $(docker ps -q -f name=${STACK_NAME}_db) pg_isready -U postgres > /dev/null 2>&1; then + echo -e "${GREEN}Database is ready!${NC}" + break + fi + echo -n "." + sleep 2 + attempt=$((attempt + 1)) +done + +if [ $attempt -gt $max_attempts ]; then + echo -e "${RED}Database failed to start in time${NC}" + exit 1 +fi + +# Run database migrations +echo -e "${GREEN}Running database migrations...${NC}" +sleep 5 +docker exec $(docker ps -q -f name=${STACK_NAME}_backend -f status=running | head -1) npm run migrate || { + echo -e "${YELLOW}Migration failed. This might be normal if migrations already ran.${NC}" +} + +# Show deployment information +echo "" +echo -e "${GREEN}Deployment complete!${NC}" +echo "" +echo -e "${BLUE}Access URLs:${NC}" +echo "Frontend: https://${FRONTEND_HOST}" +echo "Backend API: https://${BACKEND_HOST}/api" +if [ ! -z "$UMAMI_HOST" ]; then + echo "Analytics: https://${UMAMI_HOST}" +fi +if [ ! -z "$TRAEFIK_HOST" ]; then + echo "Traefik Dashboard: https://${TRAEFIK_HOST}/dashboard/" +fi +echo "" +echo -e "${BLUE}Useful commands:${NC}" +echo "View logs: docker service logs ${STACK_NAME}_backend" +echo "Scale service: docker service scale ${STACK_NAME}_backend=5" +echo "Update service: docker service update ${STACK_NAME}_backend" +echo "Remove stack: docker stack rm $STACK_NAME" +echo "" +echo -e "${GREEN}Health check:${NC}" +curl -s -o /dev/null -w "Frontend: %{http_code}\n" https://${FRONTEND_HOST}/health || true +curl -s -o /dev/null -w "Backend: %{http_code}\n" https://${BACKEND_HOST}/api/health || true \ No newline at end of file diff --git a/deploy/scripts/init-swarm.sh b/deploy/scripts/init-swarm.sh new file mode 100755 index 0000000..36fa359 --- /dev/null +++ b/deploy/scripts/init-swarm.sh @@ -0,0 +1,70 @@ +#!/bin/bash +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}Docker Swarm Initialization Script${NC}" +echo "======================================" + +# Check if running as root +if [[ $EUID -ne 0 ]]; then + echo -e "${RED}This script must be run as root${NC}" + exit 1 +fi + +# Check if Docker is installed +if ! command -v docker &> /dev/null; then + echo -e "${RED}Docker is not installed. Please install Docker first.${NC}" + exit 1 +fi + +# Check if already in swarm mode +if docker info | grep -q "Swarm: active"; then + echo -e "${YELLOW}This node is already part of a swarm.${NC}" + docker node ls + exit 0 +fi + +# Initialize swarm +echo -e "${GREEN}Initializing Docker Swarm...${NC}" +ADVERTISE_ADDR=${1:-$(hostname -I | awk '{print $1}')} +docker swarm init --advertise-addr $ADVERTISE_ADDR + +# Create overlay networks +echo -e "${GREEN}Creating overlay networks...${NC}" +docker network create --driver overlay --attachable traefik-public || true +docker network create --driver overlay --attachable monitoring || true + +# Label the node +echo -e "${GREEN}Labeling manager node...${NC}" +NODE_ID=$(docker info -f '{{.Swarm.NodeID}}') +docker node update --label-add db=true $NODE_ID +docker node update --label-add monitoring=true $NODE_ID + +# Create required directories +echo -e "${GREEN}Creating required directories...${NC}" +mkdir -p /opt/photo-sharing/{storage,data,logs,backup} +mkdir -p /opt/traefik/letsencrypt +mkdir -p /opt/monitoring/{prometheus,grafana,loki} + +# Set permissions +chown -R 1000:1000 /opt/photo-sharing +chmod -R 755 /opt/photo-sharing + +echo -e "${GREEN}Swarm initialization complete!${NC}" +echo "" +echo "Manager join token:" +docker swarm join-token manager +echo "" +echo "Worker join token:" +docker swarm join-token worker +echo "" +echo -e "${GREEN}Next steps:${NC}" +echo "1. Join worker nodes using the token above" +echo "2. Create secrets using create-secrets.sh" +echo "3. Deploy Traefik using deploy-traefik.sh" +echo "4. Deploy the application stack using deploy.sh" \ No newline at end of file diff --git a/deploy/traefik/docker-compose.traefik.yml b/deploy/traefik/docker-compose.traefik.yml new file mode 100644 index 0000000..660e38e --- /dev/null +++ b/deploy/traefik/docker-compose.traefik.yml @@ -0,0 +1,124 @@ +version: '3.8' + +services: + traefik: + image: traefik:v2.10 + ports: + - target: 80 + published: 80 + mode: host + - target: 443 + published: 443 + mode: host + networks: + - traefik-public + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - traefik-certificates:/letsencrypt + environment: + - TRAEFIK_API=true + - TRAEFIK_API_DASHBOARD=true + - TRAEFIK_API_DEBUG=false + - TRAEFIK_LOG_LEVEL=INFO + - TRAEFIK_PROVIDERS_DOCKER=true + - TRAEFIK_PROVIDERS_DOCKER_SWARMMODE=true + - TRAEFIK_PROVIDERS_DOCKER_EXPOSEDBYDEFAULT=false + - TRAEFIK_PROVIDERS_DOCKER_NETWORK=traefik-public + - TRAEFIK_ENTRYPOINTS_HTTP_ADDRESS=:80 + - TRAEFIK_ENTRYPOINTS_HTTPS_ADDRESS=:443 + # Redirect HTTP to HTTPS + - TRAEFIK_ENTRYPOINTS_HTTP_HTTP_REDIRECTIONS_ENTRYPOINT_TO=https + - TRAEFIK_ENTRYPOINTS_HTTP_HTTP_REDIRECTIONS_ENTRYPOINT_SCHEME=https + # Let's Encrypt + - TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_EMAIL=${ACME_EMAIL} + - TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_STORAGE=/letsencrypt/acme.json + - TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_HTTPCHALLENGE=true + - TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_HTTPCHALLENGE_ENTRYPOINT=http + # Enable metrics + - TRAEFIK_METRICS_PROMETHEUS=true + - TRAEFIK_METRICS_PROMETHEUS_ENTRYPOINT=metrics + - TRAEFIK_ENTRYPOINTS_METRICS_ADDRESS=:8082 + deploy: + mode: global + placement: + constraints: + - node.role == manager + update_config: + parallelism: 1 + delay: 10s + restart_policy: + condition: on-failure + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik-public" + - "traefik.constraint-label=traefik-public" + # Dashboard + - "traefik.http.routers.traefik-dashboard.rule=Host(`${TRAEFIK_HOST}`) && (PathPrefix(`/api`) || PathPrefix(`/dashboard`))" + - "traefik.http.routers.traefik-dashboard.entrypoints=https" + - "traefik.http.routers.traefik-dashboard.tls=true" + - "traefik.http.routers.traefik-dashboard.tls.certresolver=letsencrypt" + - "traefik.http.routers.traefik-dashboard.service=api@internal" + - "traefik.http.routers.traefik-dashboard.middlewares=admin-auth" + # Basic auth for dashboard + - "traefik.http.middlewares.admin-auth.basicauth.users=${TRAEFIK_DASHBOARD_AUTH}" + # Global redirect to https + - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" + # Security headers + - "traefik.http.middlewares.security-headers.headers.frameDeny=true" + - "traefik.http.middlewares.security-headers.headers.contentTypeNosniff=true" + - "traefik.http.middlewares.security-headers.headers.browserXssFilter=true" + - "traefik.http.middlewares.security-headers.headers.stsSeconds=31536000" + - "traefik.http.middlewares.security-headers.headers.stsIncludeSubdomains=true" + - "traefik.http.middlewares.security-headers.headers.stsPreload=true" + # Rate limiting + - "traefik.http.middlewares.rate-limit.ratelimit.average=100" + - "traefik.http.middlewares.rate-limit.ratelimit.burst=50" + # API service + - "traefik.http.services.traefik.loadbalancer.server.port=8080" + healthcheck: + test: ["CMD", "traefik", "healthcheck"] + interval: 30s + timeout: 3s + retries: 3 + + # Traefik Forward Auth for advanced authentication (optional) + traefik-forward-auth: + image: thomseddon/traefik-forward-auth:latest + networks: + - traefik-public + environment: + - DEFAULT_PROVIDER=generic-oauth + - PROVIDERS_GENERIC_OAUTH_AUTH_URL=${OAUTH_AUTH_URL} + - PROVIDERS_GENERIC_OAUTH_TOKEN_URL=${OAUTH_TOKEN_URL} + - PROVIDERS_GENERIC_OAUTH_USER_URL=${OAUTH_USER_URL} + - PROVIDERS_GENERIC_OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID} + - PROVIDERS_GENERIC_OAUTH_CLIENT_SECRET=${OAUTH_CLIENT_SECRET} + - SECRET=${OAUTH_SECRET} + - COOKIE_DOMAIN=${COOKIE_DOMAIN} + - INSECURE_COOKIE=false + - LOG_LEVEL=info + - URL_PATH=/_oauth + - WHITELIST=${OAUTH_WHITELIST} + deploy: + replicas: 2 + restart_policy: + condition: on-failure + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik-public" + - "traefik.http.routers.traefik-forward-auth.rule=Host(`${FRONTEND_HOST}`) && PathPrefix(`/_oauth`)" + - "traefik.http.routers.traefik-forward-auth.entrypoints=https" + - "traefik.http.routers.traefik-forward-auth.tls=true" + - "traefik.http.routers.traefik-forward-auth.tls.certresolver=letsencrypt" + - "traefik.http.routers.traefik-forward-auth.middlewares=auth-verify" + - "traefik.http.services.traefik-forward-auth.loadbalancer.server.port=4181" + - "traefik.http.middlewares.auth-verify.forwardauth.address=http://traefik-forward-auth:4181" + - "traefik.http.middlewares.auth-verify.forwardauth.authResponseHeaders=X-Forwarded-User" + +networks: + traefik-public: + external: true + +volumes: + traefik-certificates: + driver: local \ No newline at end of file diff --git a/deploy/traefik/traefik.yml b/deploy/traefik/traefik.yml new file mode 100644 index 0000000..ceeabe0 --- /dev/null +++ b/deploy/traefik/traefik.yml @@ -0,0 +1,93 @@ +# Static configuration +global: + checkNewVersion: true + sendAnonymousUsage: false + +api: + dashboard: true + debug: false + +# Entry Points +entryPoints: + http: + address: ":80" + http: + redirections: + entryPoint: + to: https + scheme: https + priority: 1000 + https: + address: ":443" + http: + tls: + certResolver: letsencrypt + domains: + - main: "${FRONTEND_HOST}" + - main: "${BACKEND_HOST}" + - main: "${UMAMI_HOST}" + forwardedHeaders: + trustedIPs: + - "127.0.0.1/32" + - "10.0.0.0/8" + - "172.16.0.0/12" + - "192.168.0.0/16" + metrics: + address: ":8082" + +# Providers +providers: + docker: + swarmMode: true + exposedByDefault: false + network: traefik-public + watch: true + file: + directory: /etc/traefik/dynamic + watch: true + +# Certificate Resolvers +certificatesResolvers: + letsencrypt: + acme: + email: ${ACME_EMAIL} + storage: /letsencrypt/acme.json + httpChallenge: + entryPoint: http + # Staging server for testing + # caServer: https://acme-staging-v02.api.letsencrypt.org/directory + +# Logs +log: + level: INFO + format: json + +accessLog: + format: json + filters: + statusCodes: + - "200-299" + - "400-499" + - "500-599" + retryAttempts: true + minDuration: "10ms" + +# Metrics +metrics: + prometheus: + entryPoint: metrics + addEntryPointsLabels: true + addServicesLabels: true + buckets: + - 0.1 + - 0.3 + - 1.2 + - 5.0 + +# Ping +ping: + entryPoint: traefik + +# Pilot +pilot: + enabled: false \ No newline at end of file diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..9d83eb6 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,99 @@ +version: '3.8' + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "3001:3000" + environment: + - NODE_ENV=development + - PORT=3000 + - JWT_SECRET=local-dev-secret-key-123 + - ADMIN_URL=http://localhost:3001 + - FRONTEND_URL=http://localhost:3000 + # Email - uses Mailhog + - SMTP_HOST=mailhog + - SMTP_PORT=1025 + - SMTP_SECURE=false + - SMTP_USER= + - SMTP_PASS= + - EMAIL_FROM=noreply@photo-sharing.local + # Umami Analytics (optional) + - UMAMI_URL= + - UMAMI_WEBSITE_ID= + volumes: + - ./backend:/app + - /app/node_modules + - ./storage:/app/storage + - ./data:/app/data + - ./logs:/app/logs + depends_on: + - mailhog + command: sh -c "npm install && npm run migrate && npm run dev" + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/api/health"] + interval: 30s + timeout: 10s + retries: 3 + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "3000:80" + environment: + - NODE_ENV=development + volumes: + - ./frontend/dist:/usr/share/nginx/html + - ./frontend/nginx.dev.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - backend + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost/health"] + interval: 30s + timeout: 10s + retries: 3 + + # Development frontend with hot reload + frontend-dev: + image: node:18-alpine + working_dir: /app + ports: + - "3002:5173" + environment: + - NODE_ENV=development + volumes: + - ./frontend:/app + - /app/node_modules + command: sh -c "npm install --legacy-peer-deps && npm run dev -- --host" + depends_on: + - backend + + mailhog: + image: mailhog/mailhog:latest + ports: + - "1025:1025" # SMTP + - "8025:8025" # Web UI + + # Optional: File watcher service + file-watcher: + build: + context: ./backend + dockerfile: Dockerfile + environment: + - NODE_ENV=development + volumes: + - ./backend:/app + - /app/node_modules + - ./storage:/app/storage + - ./data:/app/data + command: node src/services/fileWatcher.js + depends_on: + - backend + +volumes: + node_modules_backend: + node_modules_frontend: \ No newline at end of file diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..3413618 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,18 @@ +node_modules +dist +.git +.gitignore +.env* +.DS_Store +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.vscode +.idea +*.swp +*.swo +README.md +.eslintcache +coverage +.nyc_output \ 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/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..88e09f3 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,54 @@ +# Build stage +FROM node:18-alpine AS builder + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci --legacy-peer-deps + +# Copy source files +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Install runtime dependencies +RUN apk add --no-cache curl + +# Remove default nginx config +RUN rm -rf /etc/nginx/conf.d/* + +# Copy custom nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Copy built application from builder stage +COPY --from=builder /app/dist /usr/share/nginx/html + +# Create non-root user +RUN addgroup -g 101 -S nginx && \ + adduser -S -D -H -u 101 -h /var/cache/nginx -s /sbin/nologin -G nginx -g nginx nginx && \ + chown -R nginx:nginx /usr/share/nginx/html && \ + chown -R nginx:nginx /var/cache/nginx && \ + chown -R nginx:nginx /var/log/nginx && \ + touch /var/run/nginx.pid && \ + chown -R nginx:nginx /var/run/nginx.pid + +# Expose port +EXPOSE 80 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost/health || exit 1 + +# Switch to non-root user +USER nginx + +# Start nginx +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..7959ce4 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,69 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + ...tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + ...tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + ...tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..d94e7de --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { globalIgnores } from 'eslint/config' + +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e4b78ea --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..6f90df8 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,77 @@ +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" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline' 'unsafe-eval'" always; + + # Health check endpoint + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Cache index.html with revalidation + location = /index.html { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + } + + # API proxy + location /api { + proxy_pass http://backend:3000; + 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 86400; + } + + # Photo serving proxy + location /photos { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Cache photos + proxy_cache_valid 200 302 1d; + proxy_cache_valid 404 1m; + } + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } + + # Deny access to hidden files + location ~ /\. { + deny all; + } +} \ No newline at end of file diff --git a/frontend/nginx.dev.conf b/frontend/nginx.dev.conf new file mode 100644 index 0000000..498492b --- /dev/null +++ b/frontend/nginx.dev.conf @@ -0,0 +1,34 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + + # API proxy to backend + location /api { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Photos proxy to backend + location /photos { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + } + + # Health check + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..ac27b58 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4876 @@ +{ + "name": "photo-sharing-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "photo-sharing-frontend", + "version": "1.0.0", + "dependencies": { + "@tanstack/react-query": "^5.0.0", + "axios": "^1.3.2", + "clsx": "^2.0.0", + "date-fns": "^2.29.3", + "js-cookie": "^3.0.5", + "lucide-react": "^0.292.0", + "react": "^19.1.0", + "react-countdown": "^2.3.5", + "react-dom": "^19.1.0", + "react-image-gallery": "^1.2.11", + "react-intersection-observer": "^9.4.3", + "react-router-dom": "^6.8.0", + "react-toastify": "^9.1.1" + }, + "devDependencies": { + "@eslint/js": "^9.29.0", + "@types/js-cookie": "^3.0.6", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.5.2", + "autoprefixer": "^10.4.13", + "eslint": "^9.29.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.2.0", + "postcss": "^8.4.21", + "tailwindcss": "^3.3.0", + "typescript": "~5.8.3", + "typescript-eslint": "^8.34.1", + "vite": "^7.0.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", + "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", + "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", + "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.30.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz", + "integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", + "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", + "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.19", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz", + "integrity": "sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.2.tgz", + "integrity": "sha512-g0dF8P1e2QYPOj1gu7s/3LVP6kze9A7m6x0BZ9iTdXK8N5c2V7cpBKHV3/9A4Zd8xxavdhK0t4PnqjkqVmUc9Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.2.tgz", + "integrity": "sha512-Yt5MKrOosSbSaAK5Y4J+vSiID57sOvpBNBR6K7xAaQvk3MkcNVV0f9fE20T+41WYN8hDn6SGFlFrKudtx4EoxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.2.tgz", + "integrity": "sha512-EsnFot9ZieM35YNA26nhbLTJBHD0jTwWpPwmRVDzjylQT6gkar+zenfb8mHxWpRrbn+WytRRjE0WKsfaxBkVUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.2.tgz", + "integrity": "sha512-dv/t1t1RkCvJdWWxQ2lWOO+b7cMsVw5YFaS04oHpZRWehI1h0fV1gF4wgGCTyQHHjJDfbNpwOi6PXEafRBBezw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.2.tgz", + "integrity": "sha512-W4tt4BLorKND4qeHElxDoim0+BsprFTwb+vriVQnFFtT/P6v/xO5I99xvYnVzKWrK6j7Hb0yp3x7V5LUbaeOMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.2.tgz", + "integrity": "sha512-tdT1PHopokkuBVyHjvYehnIe20fxibxFCEhQP/96MDSOcyjM/shlTkZZLOufV3qO6/FQOSiJTBebhVc12JyPTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.2.tgz", + "integrity": "sha512-+xmiDGGaSfIIOXMzkhJ++Oa0Gwvl9oXUeIiwarsdRXSe27HUIvjbSIpPxvnNsRebsNdUo7uAiQVgBD1hVriwSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.2.tgz", + "integrity": "sha512-bDHvhzOfORk3wt8yxIra8N4k/N0MnKInCW5OGZaeDYa/hMrdPaJzo7CSkjKZqX4JFUWjUGm88lI6QJLCM7lDrA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.2.tgz", + "integrity": "sha512-NMsDEsDiYghTbeZWEGnNi4F0hSbGnsuOG+VnNvxkKg0IGDvFh7UVpM/14mnMwxRxUf9AdAVJgHPvKXf6FpMB7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.2.tgz", + "integrity": "sha512-lb5bxXnxXglVq+7imxykIp5xMq+idehfl+wOgiiix0191av84OqbjUED+PRC5OA8eFJYj5xAGcpAZ0pF2MnW+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.2.tgz", + "integrity": "sha512-Yl5Rdpf9pIc4GW1PmkUGHdMtbx0fBLE1//SxDmuf3X0dUC57+zMepow2LK0V21661cjXdTn8hO2tXDdAWAqE5g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.2.tgz", + "integrity": "sha512-03vUDH+w55s680YYryyr78jsO1RWU9ocRMaeV2vMniJJW/6HhoTBwyyiiTPVHNWLnhsnwcQ0oH3S9JSBEKuyqw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.2.tgz", + "integrity": "sha512-iYtAqBg5eEMG4dEfVlkqo05xMOk6y/JXIToRca2bAWuqjrJYJlx/I7+Z+4hSrsWU8GdJDFPL4ktV3dy4yBSrzg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.2.tgz", + "integrity": "sha512-e6vEbgaaqz2yEHqtkPXa28fFuBGmUJ0N2dOJK8YUfijejInt9gfCSA7YDdJ4nYlv67JfP3+PSWFX4IVw/xRIPg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.2.tgz", + "integrity": "sha512-evFOtkmVdY3udE+0QKrV5wBx7bKI0iHz5yEVx5WqDJkxp9YQefy4Mpx3RajIVcM6o7jxTvVd/qpC1IXUhGc1Mw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.2.tgz", + "integrity": "sha512-/bXb0bEsWMyEkIsUL2Yt5nFB5naLAwyOWMEviQfQY1x3l5WsLKgvZf66TM7UTfED6erckUVUJQ/jJ1FSpm3pRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.2.tgz", + "integrity": "sha512-3D3OB1vSSBXmkGEZR27uiMRNiwN08/RVAcBKwhUYPaiZ8bcvdeEwWPvbnXvvXHY+A/7xluzcN+kaiOFNiOZwWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.2.tgz", + "integrity": "sha512-VfU0fsMK+rwdK8mwODqYeM2hDrF2WiHaSmCBrS7gColkQft95/8tphyzv2EupVxn3iE0FI78wzffoULH1G+dkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.2.tgz", + "integrity": "sha512-+qMUrkbUurpE6DVRjiJCNGZBGo9xM4Y0FXU5cjgudWqIBWbcLkjE3XprJUsOFgC6xjBClwVa9k6O3A7K3vxb5Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.2.tgz", + "integrity": "sha512-3+QZROYfJ25PDcxFF66UEk8jGWigHJeecZILvkPkyQN7oc5BvFo4YEXFkOs154j3FTMp9mn9Ky8RCOwastduEA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.81.5", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.81.5.tgz", + "integrity": "sha512-ZJOgCy/z2qpZXWaj/oxvodDx07XcQa9BF92c0oINjHkoqUPsmm3uG08HpTaviviZ/N9eP1f9CM7mKSEkIo7O1Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.81.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.81.5.tgz", + "integrity": "sha512-lOf2KqRRiYWpQT86eeeftAGnjuTR35myTP8MXyvHa81VlomoAWNEd8x5vkcAfQefu0qtYCvyqLropFZqgI2EQw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.81.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.1.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", + "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.6", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", + "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz", + "integrity": "sha512-9XNTlo7P7RJxbVeICaIIIEipqxLKguyh+3UbXuT2XQuFp6d8VOeDEGuz5IiX0dgZo8CiI6aOFLg4e8cF71SFVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.35.1", + "@typescript-eslint/type-utils": "8.35.1", + "@typescript-eslint/utils": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.35.1", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.1.tgz", + "integrity": "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.35.1", + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/typescript-estree": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.1.tgz", + "integrity": "sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.35.1", + "@typescript-eslint/types": "^8.35.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.1.tgz", + "integrity": "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.1.tgz", + "integrity": "sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.1.tgz", + "integrity": "sha512-HOrUBlfVRz5W2LIKpXzZoy6VTZzMu2n8q9C2V/cFngIC5U1nStJgv0tMV4sZPzdf4wQm9/ToWUFPMN9Vq9VJQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.35.1", + "@typescript-eslint/utils": "8.35.1", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.1.tgz", + "integrity": "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.1.tgz", + "integrity": "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.35.1", + "@typescript-eslint/tsconfig-utils": "8.35.1", + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.1.tgz", + "integrity": "sha512-lhnwatFmOFcazAsUm3ZnZFpXSxiwoa1Lj50HphnDe1Et01NF4+hrdXONSUHIcbVu2eFb1bAf+5yjXkGVkXBKAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.35.1", + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/typescript-estree": "8.35.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.1.tgz", + "integrity": "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.35.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz", + "integrity": "sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.19", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.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/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", + "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "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/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001726", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz", + "integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "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/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "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/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "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/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.179", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.179.tgz", + "integrity": "sha512-UWKi/EbBopgfFsc5k61wFpV7WrnnSlSzW/e2XcBmS6qKYTivZlLtoll5/rdqRTxGglGHkmkW0j0pFNJG10EUIQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "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.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "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/esbuild": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.30.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.1.tgz", + "integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.0", + "@eslint/core": "^0.14.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.30.1", + "@eslint/plugin-kit": "^0.3.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.20", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", + "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", + "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "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/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/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/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", + "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.292.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.292.0.tgz", + "integrity": "sha512-rRgUkpEHWpa5VCT66YscInCQmQuPCB1RFRzkkxMxg4b+jaL0V12E3riWWR2Sh5OIiUhCwGW/ZExuEO4Az32E6Q==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.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/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "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/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "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": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-countdown": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/react-countdown/-/react-countdown-2.3.6.tgz", + "integrity": "sha512-ZfX6S08Hb6x6W6eCn1hMDvxPICI/T30fd+gaeVTCR/2cGZ2WJ3f26e4ImNIMX1fHkopJrUdnRpWXP13/D39+gg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "react": ">= 15", + "react-dom": ">= 15" + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/react-image-gallery": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/react-image-gallery/-/react-image-gallery-1.4.0.tgz", + "integrity": "sha512-m7xLq7+g6/xh+BhVMAxvRU0132sNcEFglYsVsgthrnItl9VtLE7MuVvVWD9pvzcI+WBP5+p9HvnRwIiyhPkBDg==", + "license": "MIT", + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-intersection-observer": { + "version": "9.16.0", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.16.0.tgz", + "integrity": "sha512-w9nJSEp+DrW9KmQmeWHQyfaP6b03v+TdXynaoA964Wxt7mdR3An11z4NNCQgL4gKSK7y1ver2Fq+JKH6CWEzUA==", + "license": "MIT", + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", + "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", + "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0", + "react-router": "6.30.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-toastify": { + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-9.1.3.tgz", + "integrity": "sha512-fPfb8ghtn/XMxw3LkxQBk3IyagNpF/LIKjOBflbexr2AWxAH1MJgvnESwEwBn9liLFXgTKWgBSdZpw9m4OTHTg==", + "license": "MIT", + "dependencies": { + "clsx": "^1.1.1" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/react-toastify/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.2.tgz", + "integrity": "sha512-PVoapzTwSEcelaWGth3uR66u7ZRo6qhPHc0f2uRO9fX6XDVNrIiGYS0Pj9+R8yIIYSD/mCx2b16Ws9itljKSPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.44.2", + "@rollup/rollup-android-arm64": "4.44.2", + "@rollup/rollup-darwin-arm64": "4.44.2", + "@rollup/rollup-darwin-x64": "4.44.2", + "@rollup/rollup-freebsd-arm64": "4.44.2", + "@rollup/rollup-freebsd-x64": "4.44.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.44.2", + "@rollup/rollup-linux-arm-musleabihf": "4.44.2", + "@rollup/rollup-linux-arm64-gnu": "4.44.2", + "@rollup/rollup-linux-arm64-musl": "4.44.2", + "@rollup/rollup-linux-loongarch64-gnu": "4.44.2", + "@rollup/rollup-linux-powerpc64le-gnu": "4.44.2", + "@rollup/rollup-linux-riscv64-gnu": "4.44.2", + "@rollup/rollup-linux-riscv64-musl": "4.44.2", + "@rollup/rollup-linux-s390x-gnu": "4.44.2", + "@rollup/rollup-linux-x64-gnu": "4.44.2", + "@rollup/rollup-linux-x64-musl": "4.44.2", + "@rollup/rollup-win32-arm64-msvc": "4.44.2", + "@rollup/rollup-win32-ia32-msvc": "4.44.2", + "@rollup/rollup-win32-x64-msvc": "4.44.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.35.1.tgz", + "integrity": "sha512-xslJjFzhOmHYQzSB/QTeASAHbjmxOGEP6Coh93TXmUBFQoJ1VU35UHIDmG06Jd6taf3wqqC1ntBnCMeymy5Ovw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.35.1", + "@typescript-eslint/parser": "8.35.1", + "@typescript-eslint/utils": "8.35.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.2.tgz", + "integrity": "sha512-hxdyZDY1CM6SNpKI4w4lcUc3Mtkd9ej4ECWVHSMrOdSinVc2zYOAppHeGc/hzmRo3pxM5blMzkuWHOJA/3NiFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.6", + "picomatch": "^4.0.2", + "postcss": "^8.5.6", + "rollup": "^4.40.0", + "tinyglobby": "^0.2.14" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..2e5350d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,44 @@ +{ + "name": "photo-sharing-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^6.8.0", + "axios": "^1.3.2", + "@tanstack/react-query": "^5.0.0", + "date-fns": "^2.29.3", + "react-toastify": "^9.1.1", + "react-countdown": "^2.3.5", + "react-image-gallery": "^1.2.11", + "react-intersection-observer": "^9.4.3", + "clsx": "^2.0.0", + "lucide-react": "^0.292.0", + "js-cookie": "^3.0.5" + }, + "devDependencies": { + "@eslint/js": "^9.29.0", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@types/js-cookie": "^3.0.6", + "@vitejs/plugin-react": "^4.5.2", + "eslint": "^9.29.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.2.0", + "typescript": "~5.8.3", + "typescript-eslint": "^8.34.1", + "vite": "^7.0.0", + "tailwindcss": "^3.3.0", + "autoprefixer": "^10.4.13", + "postcss": "^8.4.21" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..15ffb76 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,76 @@ +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ToastContainer } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; + +import { GalleryAuthProvider, AdminAuthProvider } from './contexts'; +import { GalleryPage } from './pages/GalleryPage'; + +// Page imports (to be created) +// import { AdminLoginPage } from './pages/admin/AdminLoginPage'; +// import { AdminDashboard } from './pages/admin/AdminDashboard'; + +// Create a client +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1, + }, + }, +}); + +function App() { + return ( + + + + {/* Public gallery routes */} + + + + } /> + + {/* Admin routes */} + + + +

Admin Login (To be implemented)

+ + } /> + +

Admin Dashboard (To be implemented)

+ + } /> + } /> +
+ + } /> + + {/* Default redirect */} + } /> +
+
+ + {/* Toast notifications */} + +
+ ); +} + +export default App; 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/components/common/Button.tsx b/frontend/src/components/common/Button.tsx new file mode 100644 index 0000000..ff9aae7 --- /dev/null +++ b/frontend/src/components/common/Button.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { clsx } from 'clsx'; +import { Loader2 } from 'lucide-react'; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'outline' | 'ghost'; + size?: 'sm' | 'md' | 'lg'; + isLoading?: boolean; + leftIcon?: React.ReactNode; + rightIcon?: React.ReactNode; + children: React.ReactNode; +} + +export const Button = React.forwardRef( + ( + { + className, + variant = 'primary', + size = 'md', + isLoading = false, + disabled, + leftIcon, + rightIcon, + children, + ...props + }, + ref + ) => { + const baseStyles = 'btn'; + + const variants = { + primary: 'btn-primary', + secondary: 'btn-secondary', + outline: 'btn-outline', + ghost: 'bg-transparent hover:bg-neutral-100 text-neutral-700', + }; + + const sizes = { + sm: 'btn-sm', + md: 'btn-md', + lg: 'btn-lg', + }; + + return ( + + ); + } +); + +Button.displayName = 'Button'; \ No newline at end of file diff --git a/frontend/src/components/common/Card.tsx b/frontend/src/components/common/Card.tsx new file mode 100644 index 0000000..4b6c5d6 --- /dev/null +++ b/frontend/src/components/common/Card.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { clsx } from 'clsx'; + +interface CardProps extends React.HTMLAttributes { + variant?: 'default' | 'hover'; + padding?: 'none' | 'sm' | 'md' | 'lg'; + children: React.ReactNode; +} + +export const Card: React.FC = ({ + className, + variant = 'default', + padding = 'md', + children, + ...props +}) => { + const paddingStyles = { + none: '', + sm: 'p-4', + md: 'p-6', + lg: 'p-8', + }; + + return ( +
+ {children} +
+ ); +}; + +interface CardHeaderProps extends React.HTMLAttributes { + title: string; + subtitle?: string; + action?: React.ReactNode; +} + +export const CardHeader: React.FC = ({ + title, + subtitle, + action, + className, + ...props +}) => { + return ( +
+
+

{title}

+ {subtitle && ( +

{subtitle}

+ )} +
+ {action &&
{action}
} +
+ ); +}; + +interface CardContentProps extends React.HTMLAttributes { + children: React.ReactNode; +} + +export const CardContent: React.FC = ({ + className, + children, + ...props +}) => { + return ( +
+ {children} +
+ ); +}; + +interface CardFooterProps extends React.HTMLAttributes { + children: React.ReactNode; +} + +export const CardFooter: React.FC = ({ + className, + children, + ...props +}) => { + return ( +
+ {children} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/common/Input.tsx b/frontend/src/components/common/Input.tsx new file mode 100644 index 0000000..67f18b0 --- /dev/null +++ b/frontend/src/components/common/Input.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { clsx } from 'clsx'; + +interface InputProps extends React.InputHTMLAttributes { + label?: string; + error?: string; + helperText?: string; + leftIcon?: React.ReactNode; + rightIcon?: React.ReactNode; +} + +export const Input = React.forwardRef( + ( + { + className, + label, + error, + helperText, + leftIcon, + rightIcon, + id, + ...props + }, + ref + ) => { + const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`; + + return ( +
+ {label && ( + + )} +
+ {leftIcon && ( +
+ {leftIcon} +
+ )} + + {rightIcon && ( +
+ {rightIcon} +
+ )} +
+ {error && ( +

+ {error} +

+ )} + {helperText && !error && ( +

+ {helperText} +

+ )} +
+ ); + } +); + +Input.displayName = 'Input'; \ No newline at end of file diff --git a/frontend/src/components/common/Loading.tsx b/frontend/src/components/common/Loading.tsx new file mode 100644 index 0000000..8e74e84 --- /dev/null +++ b/frontend/src/components/common/Loading.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { Loader2 } from 'lucide-react'; +import { clsx } from 'clsx'; + +interface LoadingProps { + size?: 'sm' | 'md' | 'lg'; + text?: string; + fullScreen?: boolean; + className?: string; +} + +export const Loading: React.FC = ({ + size = 'md', + text, + fullScreen = false, + className, +}) => { + const sizeStyles = { + sm: 'h-4 w-4', + md: 'h-8 w-8', + lg: 'h-12 w-12', + }; + + const content = ( +
+ + {text && ( +

{text}

+ )} +
+ ); + + if (fullScreen) { + return ( +
+ {content} +
+ ); + } + + return content; +}; + +interface LoadingSkeletonProps { + className?: string; + count?: number; + type?: 'text' | 'card' | 'image'; +} + +export const LoadingSkeleton: React.FC = ({ + className, + count = 1, + type = 'text', +}) => { + const baseStyles = 'skeleton'; + + const typeStyles = { + text: 'h-4 w-full rounded', + card: 'h-32 w-full rounded-xl', + image: 'aspect-square w-full rounded-lg', + }; + + return ( + <> + {Array.from({ length: count }).map((_, index) => ( +
+ ))} + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts new file mode 100644 index 0000000..ba4afe2 --- /dev/null +++ b/frontend/src/components/common/index.ts @@ -0,0 +1,4 @@ +export { Button } from './Button'; +export { Input } from './Input'; +export { Card, CardHeader, CardContent, CardFooter } from './Card'; +export { Loading, LoadingSkeleton } from './Loading'; \ No newline at end of file diff --git a/frontend/src/components/gallery/ExpirationBanner.tsx b/frontend/src/components/gallery/ExpirationBanner.tsx new file mode 100644 index 0000000..da8eb21 --- /dev/null +++ b/frontend/src/components/gallery/ExpirationBanner.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { AlertTriangle, Download } from 'lucide-react'; +import Countdown from 'react-countdown'; +import { parseISO } from 'date-fns'; + +interface ExpirationBannerProps { + daysRemaining: number; + expiresAt: string; +} + +export const ExpirationBanner: React.FC = ({ + daysRemaining, + expiresAt +}) => { + const expirationDate = parseISO(expiresAt); + + const countdownRenderer = ({ days, hours, minutes, completed }: any) => { + if (completed) { + return Gallery has expired; + } else { + return ( + + {days}d {hours}h {minutes}m + + ); + } + }; + + const getBannerColor = () => { + if (daysRemaining <= 1) return 'bg-red-600'; + if (daysRemaining <= 3) return 'bg-amber-600'; + return 'bg-amber-500'; + }; + + return ( +
+
+
+
+ + + Gallery expires in + +
+
+ + Download your photos now! +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx new file mode 100644 index 0000000..f14f389 --- /dev/null +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -0,0 +1,181 @@ +import React, { useState } from 'react'; +import { Download, Grid, Square, LogOut, Calendar, Clock } from 'lucide-react'; +import { format, differenceInDays, parseISO } from 'date-fns'; + +import { Button, Loading } from '../common'; +import { useGalleryAuth } from '../../contexts'; +import { useGalleryPhotos, useDownloadAllPhotos } from '../../hooks/useGallery'; +import { PhotoGrid } from './PhotoGrid'; +import { ExpirationBanner } from './ExpirationBanner'; + +interface GalleryViewProps { + slug: string; + event: { + id: number; + event_name: string; + event_type: string; + event_date: string; + welcome_message?: string; + color_theme?: string; + expires_at: string; + }; +} + +export const GalleryView: React.FC = ({ slug, event }) => { + const { logout } = useGalleryAuth(); + const [viewMode, setViewMode] = useState<'all' | 'collages' | 'individual'>('all'); + + // Fetch photos + const { data, isLoading, error } = useGalleryPhotos(slug); + const downloadAllMutation = useDownloadAllPhotos(); + + // Calculate days until expiration + const daysUntilExpiration = differenceInDays(parseISO(event.expires_at), new Date()); + const showUrgentWarning = daysUntilExpiration <= 7; + + // Filter photos based on view mode + const filteredPhotos = data?.photos.filter(photo => { + if (viewMode === 'all') return true; + if (viewMode === 'collages') return photo.type === 'collage'; + if (viewMode === 'individual') return photo.type === 'individual'; + return true; + }) || []; + + const handleDownloadAll = () => { + downloadAllMutation.mutate(slug); + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error || !data) { + return ( +
+
+

Failed to load photos

+ +
+
+ ); + } + + return ( +
+ {/* Expiration Banner */} + {showUrgentWarning && ( + + )} + + {/* Header */} +
+
+
+
+

{event.event_name}

+
+ + + {format(parseISO(event.event_date), 'MMMM d, yyyy')} + + + + Expires {format(parseISO(event.expires_at), 'MMM d')} + +
+
+ +
+ + +
+
+
+
+ + {/* Welcome Message */} + {event.welcome_message && ( +
+
+

{event.welcome_message}

+
+
+ )} + + {/* View Mode Toggle */} +
+
+
+ + + +
+ +

+ {filteredPhotos.length} {filteredPhotos.length === 1 ? 'photo' : 'photos'} +

+
+ + {/* Photo Grid */} + +
+ + {/* Footer */} + +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/gallery/PhotoGrid.tsx b/frontend/src/components/gallery/PhotoGrid.tsx new file mode 100644 index 0000000..cd9df01 --- /dev/null +++ b/frontend/src/components/gallery/PhotoGrid.tsx @@ -0,0 +1,213 @@ +import React, { useState } from 'react'; +import { Download, Maximize2, Check } from 'lucide-react'; +import { useInView } from 'react-intersection-observer'; + +import type { Photo } from '../../types'; +import { useDownloadPhoto } from '../../hooks/useGallery'; +import { PhotoLightbox } from './PhotoLightbox'; +import { Button } from '../common'; + +interface PhotoGridProps { + photos: Photo[]; + slug: string; +} + +export const PhotoGrid: React.FC = ({ photos, slug }) => { + const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(null); + const [selectedPhotos, setSelectedPhotos] = useState>(new Set()); + const [isSelectionMode, setIsSelectionMode] = useState(false); + const downloadPhotoMutation = useDownloadPhoto(); + + const handlePhotoClick = (index: number) => { + if (isSelectionMode) { + const newSelected = new Set(selectedPhotos); + if (newSelected.has(photos[index].id)) { + newSelected.delete(photos[index].id); + } else { + newSelected.add(photos[index].id); + } + setSelectedPhotos(newSelected); + } else { + setSelectedPhotoIndex(index); + } + }; + + const handleDownload = (photo: Photo, e: React.MouseEvent) => { + e.stopPropagation(); + downloadPhotoMutation.mutate({ + slug, + photoId: photo.id, + filename: photo.filename, + }); + }; + + const toggleSelectionMode = () => { + setIsSelectionMode(!isSelectionMode); + setSelectedPhotos(new Set()); + }; + + const selectAll = () => { + setSelectedPhotos(new Set(photos.map(p => p.id))); + }; + + const deselectAll = () => { + setSelectedPhotos(new Set()); + }; + + if (photos.length === 0) { + return ( +
+

No photos found

+
+ ); + } + + return ( + <> + {/* Selection Mode Controls */} + {photos.length > 1 && ( +
+ + + {isSelectionMode && ( +
+ + {selectedPhotos.size} selected + + + + {selectedPhotos.size > 0 && ( + + )} +
+ )} +
+ )} + + {/* Photo Grid */} +
+ {photos.map((photo, index) => ( + handlePhotoClick(index)} + onDownload={(e) => handleDownload(photo, e)} + /> + ))} +
+ + {/* Lightbox */} + {selectedPhotoIndex !== null && ( + setSelectedPhotoIndex(null)} + slug={slug} + /> + )} + + ); +}; + +interface PhotoThumbnailProps { + photo: Photo; + isSelected: boolean; + isSelectionMode: boolean; + onClick: () => void; + onDownload: (e: React.MouseEvent) => void; +} + +const PhotoThumbnail: React.FC = ({ + photo, + isSelected, + isSelectionMode, + onClick, + onDownload, +}) => { + const { ref, inView } = useInView({ + triggerOnce: true, + threshold: 0.1, + }); + + return ( +
+ {inView ? ( + <> + {photo.filename} + + {/* Overlay on hover */} +
+ {!isSelectionMode && ( + <> + + + + )} +
+ + {/* Selection checkbox */} + {isSelectionMode && ( +
+
+ {isSelected && } +
+
+ )} + + {/* Photo type badge */} + {photo.type === 'collage' && ( +
+ + Collage + +
+ )} + + ) : ( +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx new file mode 100644 index 0000000..ad02a71 --- /dev/null +++ b/frontend/src/components/gallery/PhotoLightbox.tsx @@ -0,0 +1,205 @@ +import React, { useState, useEffect } from 'react'; +import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut } from 'lucide-react'; +import type { Photo } from '../../types'; +import { useDownloadPhoto } from '../../hooks/useGallery'; + +interface PhotoLightboxProps { + photos: Photo[]; + initialIndex: number; + onClose: () => void; + slug: string; +} + +export const PhotoLightbox: React.FC = ({ + photos, + initialIndex, + onClose, + slug, +}) => { + const [currentIndex, setCurrentIndex] = useState(initialIndex); + const [zoom, setZoom] = useState(1); + const [isDragging, setIsDragging] = useState(false); + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); + + const downloadPhotoMutation = useDownloadPhoto(); + const currentPhoto = photos[currentIndex]; + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + if (e.key === 'ArrowLeft') goToPrevious(); + if (e.key === 'ArrowRight') goToNext(); + }; + + document.addEventListener('keydown', handleKeyDown); + document.body.style.overflow = 'hidden'; + + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.body.style.overflow = ''; + }; + }, [currentIndex]); + + const goToPrevious = () => { + setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1)); + resetZoom(); + }; + + const goToNext = () => { + setCurrentIndex((prev) => (prev < photos.length - 1 ? prev + 1 : 0)); + resetZoom(); + }; + + const resetZoom = () => { + setZoom(1); + setDragOffset({ x: 0, y: 0 }); + }; + + const handleZoomIn = () => { + setZoom((prev) => Math.min(prev + 0.5, 3)); + }; + + const handleZoomOut = () => { + setZoom((prev) => Math.max(prev - 0.5, 1)); + if (zoom - 0.5 <= 1) { + setDragOffset({ x: 0, y: 0 }); + } + }; + + const handleDownload = () => { + downloadPhotoMutation.mutate({ + slug, + photoId: currentPhoto.id, + filename: currentPhoto.filename, + }); + }; + + const handleMouseDown = (e: React.MouseEvent) => { + if (zoom > 1) { + setIsDragging(true); + setDragStart({ x: e.clientX - dragOffset.x, y: e.clientY - dragOffset.y }); + } + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (isDragging && zoom > 1) { + setDragOffset({ + x: e.clientX - dragStart.x, + y: e.clientY - dragStart.y, + }); + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + const handleImageClick = (e: React.MouseEvent) => { + // Only close if clicking the background, not the image + if (e.target === e.currentTarget) { + onClose(); + } + }; + + return ( +
+ {/* Close button */} + + + {/* Navigation buttons */} + + + + + {/* Bottom toolbar */} +
+
+
+

+ {currentIndex + 1} / {photos.length} +

+

{currentPhoto.filename}

+
+ +
+ + + {Math.round(zoom * 100)}% + + + +
+ + +
+
+
+ + {/* Image container */} +
1 ? (isDragging ? 'grabbing' : 'grab') : 'default' }} + > + {currentPhoto.filename} +
+ + {/* Touch/swipe indicators for mobile */} +
+ Swipe to navigate +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/gallery/index.ts b/frontend/src/components/gallery/index.ts new file mode 100644 index 0000000..292d7f0 --- /dev/null +++ b/frontend/src/components/gallery/index.ts @@ -0,0 +1,4 @@ +export { GalleryView } from './GalleryView'; +export { PhotoGrid } from './PhotoGrid'; +export { PhotoLightbox } from './PhotoLightbox'; +export { ExpirationBanner } from './ExpirationBanner'; \ No newline at end of file diff --git a/frontend/src/config/api.ts b/frontend/src/config/api.ts new file mode 100644 index 0000000..1e34602 --- /dev/null +++ b/frontend/src/config/api.ts @@ -0,0 +1,79 @@ +import axios from 'axios'; +import Cookies from 'js-cookie'; + +// Cookie keys +export const ADMIN_TOKEN_KEY = 'admin_token'; +export const GALLERY_TOKEN_KEY = 'gallery_token'; + +// Create axios instance +export const api = axios.create({ + baseURL: '', + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Request interceptor to add auth token +api.interceptors.request.use( + (config) => { + // Check if it's an admin route or gallery route + const isAdminRoute = config.url?.includes('/admin'); + const token = isAdminRoute + ? Cookies.get(ADMIN_TOKEN_KEY) + : Cookies.get(GALLERY_TOKEN_KEY); + + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +// Response interceptor to handle errors +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + // Clear tokens on unauthorized + Cookies.remove(ADMIN_TOKEN_KEY); + Cookies.remove(GALLERY_TOKEN_KEY); + + // Redirect to appropriate login + const isAdminRoute = error.config?.url?.includes('/admin'); + if (isAdminRoute) { + window.location.href = '/admin/login'; + } else { + // For gallery routes, redirect to the gallery password page + const currentPath = window.location.pathname; + const gallerySlug = currentPath.split('/')[2]; + if (gallerySlug) { + window.location.href = `/gallery/${gallerySlug}`; + } + } + } + + return Promise.reject(error); + } +); + +// Helper to set auth tokens +export const setAuthToken = (token: string, isAdmin: boolean = false) => { + const key = isAdmin ? ADMIN_TOKEN_KEY : GALLERY_TOKEN_KEY; + Cookies.set(key, token, { expires: 1 }); // 1 day expiry +}; + +// Helper to clear auth tokens +export const clearAuthToken = (isAdmin: boolean = false) => { + const key = isAdmin ? ADMIN_TOKEN_KEY : GALLERY_TOKEN_KEY; + Cookies.remove(key); +}; + +// Helper to get auth tokens +export const getAuthToken = (isAdmin: boolean = false) => { + const key = isAdmin ? ADMIN_TOKEN_KEY : GALLERY_TOKEN_KEY; + return Cookies.get(key); +}; \ No newline at end of file diff --git a/frontend/src/contexts/AdminAuthContext.tsx b/frontend/src/contexts/AdminAuthContext.tsx new file mode 100644 index 0000000..9e4ee0e --- /dev/null +++ b/frontend/src/contexts/AdminAuthContext.tsx @@ -0,0 +1,81 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +import type { ReactNode } from 'react'; +import { getAuthToken } from '../config/api'; +import { authService } from '../services'; +import type { AdminUser } from '../types'; + +interface AdminAuthContextType { + isAuthenticated: boolean; + user: AdminUser | null; + login: (username: string, password: string) => Promise; + logout: () => void; + isLoading: boolean; + error: string | null; +} + +const AdminAuthContext = createContext(undefined); + +export const useAdminAuth = () => { + const context = useContext(AdminAuthContext); + if (!context) { + throw new Error('useAdminAuth must be used within an AdminAuthProvider'); + } + return context; +}; + +interface AdminAuthProviderProps { + children: ReactNode; +} + +export const AdminAuthProvider: React.FC = ({ children }) => { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + // Check if user has a valid token on mount + const token = getAuthToken(true); + if (token) { + // TODO: Validate token with backend and get user info + setIsAuthenticated(true); + } + setIsLoading(false); + }, []); + + const login = async (username: string, password: string) => { + try { + setError(null); + setIsLoading(true); + const response = await authService.adminLogin(username, password); + setUser(response.user); + setIsAuthenticated(true); + } catch (err: any) { + setError(err.response?.data?.error || 'Invalid credentials'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const logout = () => { + authService.adminLogout(); + setIsAuthenticated(false); + setUser(null); + }; + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/frontend/src/contexts/GalleryAuthContext.tsx b/frontend/src/contexts/GalleryAuthContext.tsx new file mode 100644 index 0000000..19f8eb8 --- /dev/null +++ b/frontend/src/contexts/GalleryAuthContext.tsx @@ -0,0 +1,90 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +import type { ReactNode } from 'react'; +import { getAuthToken } from '../config/api'; +import { authService } from '../services'; + +interface GalleryEvent { + id: number; + event_name: string; + event_type: string; + event_date: string; + welcome_message?: string; + color_theme?: string; + expires_at: string; +} + +interface GalleryAuthContextType { + isAuthenticated: boolean; + event: GalleryEvent | null; + login: (slug: string, password: string) => Promise; + logout: () => void; + isLoading: boolean; + error: string | null; +} + +const GalleryAuthContext = createContext(undefined); + +export const useGalleryAuth = () => { + const context = useContext(GalleryAuthContext); + if (!context) { + throw new Error('useGalleryAuth must be used within a GalleryAuthProvider'); + } + return context; +}; + +interface GalleryAuthProviderProps { + children: ReactNode; +} + +export const GalleryAuthProvider: React.FC = ({ children }) => { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [event, setEvent] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + // Check if user has a valid token on mount + const token = getAuthToken(false); + if (token) { + // TODO: Validate token with backend + setIsAuthenticated(true); + } + setIsLoading(false); + }, []); + + const login = async (slug: string, password: string) => { + try { + setError(null); + setIsLoading(true); + const response = await authService.verifyGalleryPassword(slug, password); + setEvent(response.event); + setIsAuthenticated(true); + } catch (err: any) { + setError(err.response?.data?.error || 'Invalid password'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const logout = () => { + authService.galleryLogout(); + setIsAuthenticated(false); + setEvent(null); + }; + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/frontend/src/contexts/index.ts b/frontend/src/contexts/index.ts new file mode 100644 index 0000000..757f73e --- /dev/null +++ b/frontend/src/contexts/index.ts @@ -0,0 +1,2 @@ +export { GalleryAuthProvider, useGalleryAuth } from './GalleryAuthContext'; +export { AdminAuthProvider, useAdminAuth } from './AdminAuthContext'; \ No newline at end of file diff --git a/frontend/src/hooks/useGallery.ts b/frontend/src/hooks/useGallery.ts new file mode 100644 index 0000000..2ed610e --- /dev/null +++ b/frontend/src/hooks/useGallery.ts @@ -0,0 +1,64 @@ +import { useQuery, useMutation } from '@tanstack/react-query'; +import { galleryService } from '../services'; +import { toast } from 'react-toastify'; + +export const useGalleryInfo = (slug: string) => { + return useQuery({ + queryKey: ['gallery-info', slug], + queryFn: () => galleryService.getGalleryInfo(slug), + retry: 1, + staleTime: 5 * 60 * 1000, // 5 minutes + }); +}; + +export const useGalleryPhotos = (slug: string, enabled: boolean = true) => { + return useQuery({ + queryKey: ['gallery-photos', slug], + queryFn: () => galleryService.getGalleryPhotos(slug), + enabled, + retry: 1, + staleTime: 5 * 60 * 1000, // 5 minutes + }); +}; + +export const useGalleryStats = (slug: string, enabled: boolean = true) => { + return useQuery({ + queryKey: ['gallery-stats', slug], + queryFn: () => galleryService.getGalleryStats(slug), + enabled, + retry: 1, + staleTime: 60 * 1000, // 1 minute + }); +}; + +export const useDownloadPhoto = () => { + return useMutation({ + mutationFn: ({ + slug, + photoId, + filename, + }: { + slug: string; + photoId: number; + filename: string; + }) => galleryService.downloadPhoto(slug, photoId, filename), + onSuccess: () => { + toast.success('Photo downloaded successfully'); + }, + onError: () => { + toast.error('Failed to download photo'); + }, + }); +}; + +export const useDownloadAllPhotos = () => { + return useMutation({ + mutationFn: (slug: string) => galleryService.downloadAllPhotos(slug), + onSuccess: () => { + toast.success('Download started'); + }, + onError: () => { + toast.error('Failed to download photos'); + }, + }); +}; \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..e010f2f --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,140 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans:wght@300;400;500;600;700&display=swap'); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --color-primary: 92 135 98; + --radius: 0.5rem; + } + + + body { + @apply bg-neutral-50 text-neutral-900 antialiased; + } + + /* Custom scrollbar */ + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + ::-webkit-scrollbar-track { + @apply bg-neutral-100; + } + + ::-webkit-scrollbar-thumb { + @apply bg-neutral-300 rounded-full; + } + + ::-webkit-scrollbar-thumb:hover { + @apply bg-neutral-400; + } +} + +@layer components { + /* Button styles */ + .btn { + @apply inline-flex items-center justify-center rounded-lg font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50; + } + + .btn-primary { + @apply bg-primary-600 text-white hover:bg-primary-700 focus-visible:ring-primary-600; + } + + .btn-secondary { + @apply bg-neutral-200 text-neutral-900 hover:bg-neutral-300 focus-visible:ring-neutral-400; + } + + .btn-outline { + @apply border border-neutral-300 bg-transparent text-neutral-700 hover:bg-neutral-100 focus-visible:ring-neutral-400; + } + + .btn-sm { + @apply h-9 px-3 text-sm; + } + + .btn-md { + @apply h-10 px-4 py-2; + } + + .btn-lg { + @apply h-11 px-8 text-lg; + } + + /* Input styles */ + .input { + @apply flex h-10 w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-neutral-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50; + } + + /* Card styles */ + .card { + @apply rounded-xl border border-neutral-200 bg-white shadow-soft; + } + + .card-hover { + @apply card transition-all duration-200 hover:shadow-medium hover:translate-y-[-2px]; + } + + /* Container */ + .container { + @apply mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl; + } + + /* Image loading skeleton */ + .skeleton { + @apply animate-pulse bg-neutral-200 rounded-lg; + } + + /* Gallery grid */ + .gallery-grid { + @apply grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6; + } + + /* Modal overlay */ + .modal-overlay { + @apply fixed inset-0 bg-black/50 backdrop-blur-sm animate-fade-in; + } + + /* Badge */ + .badge { + @apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium; + } + + .badge-success { + @apply bg-green-100 text-green-800; + } + + .badge-warning { + @apply bg-amber-100 text-amber-800; + } + + .badge-danger { + @apply bg-red-100 text-red-800; + } +} + +@layer utilities { + /* Hide scrollbar for Chrome, Safari and Opera */ + .no-scrollbar::-webkit-scrollbar { + display: none; + } + + /* Hide scrollbar for IE, Edge and Firefox */ + .no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + } + + /* Text gradient */ + .text-gradient { + @apply bg-clip-text text-transparent bg-gradient-to-r from-primary-600 to-primary-800; + } + + /* Smooth scroll */ + .smooth-scroll { + scroll-behavior: smooth; + } +} 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/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx new file mode 100644 index 0000000..eae2489 --- /dev/null +++ b/frontend/src/pages/GalleryPage.tsx @@ -0,0 +1,176 @@ +import React, { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { Camera, Calendar, AlertCircle, Clock } from 'lucide-react'; +import { format, differenceInDays, parseISO } from 'date-fns'; + +import { Card, CardContent, Input, Button, Loading } from '../components/common'; +import { useGalleryAuth } from '../contexts'; +import { useGalleryInfo } from '../hooks/useGallery'; +import { GalleryView } from '../components/gallery/GalleryView'; + +export const GalleryPage: React.FC = () => { + const { slug } = useParams<{ slug: string }>(); + const { isAuthenticated, login, event } = useGalleryAuth(); + const [password, setPassword] = useState(''); + const [isLoggingIn, setIsLoggingIn] = useState(false); + const [loginError, setLoginError] = useState(null); + + // Fetch gallery info (public data) + const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!); + + // Calculate days until expiration + const daysUntilExpiration = galleryInfo + ? differenceInDays(parseISO(galleryInfo.expires_at), new Date()) + : null; + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + if (!password.trim()) { + setLoginError('Please enter a password'); + return; + } + + try { + setIsLoggingIn(true); + setLoginError(null); + await login(slug!, password); + } catch (error: any) { + setLoginError(error.response?.data?.error || 'Invalid password'); + } finally { + setIsLoggingIn(false); + } + }; + + // Show loading state + if (isLoadingInfo) { + return ( +
+ +
+ ); + } + + // Show error state + if (infoError) { + return ( +
+ + + +

Gallery Not Found

+

+ This gallery does not exist or has been removed. +

+
+
+
+ ); + } + + // Show expired state + if (galleryInfo?.is_expired) { + return ( +
+ + + +

Gallery Expired

+

+ This gallery expired on {format(parseISO(galleryInfo.expires_at), 'MMMM d, yyyy')}. +

+

+ Please contact the event organizer if you need access to these photos. +

+
+
+
+ ); + } + + // Show gallery view if authenticated + if (isAuthenticated && event) { + return ; + } + + // Show login form + return ( +
+
+
+ {/* Logo/Header */} +
+
+ +
+

+ {galleryInfo?.event_name} +

+
+ + {format(parseISO(galleryInfo!.event_date), 'MMMM d, yyyy')} +
+
+ + {/* Expiration Warning */} + {daysUntilExpiration !== null && daysUntilExpiration <= 7 && ( +
+
+ +
+

+ Gallery expires in {daysUntilExpiration} {daysUntilExpiration === 1 ? 'day' : 'days'} +

+

+ Download your photos before they're no longer available. +

+
+
+
+ )} + + {/* Login Card */} + + +

Enter Gallery Password

+ +
+ setPassword(e.target.value)} + error={loginError || undefined} + autoFocus + /> + + +
+ +

+ The password was provided by the event organizer. + Contact them if you don't have it. +

+
+
+ + {/* Event Type Badge */} +
+ + {galleryInfo?.event_type} + +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/services/auth.service.ts b/frontend/src/services/auth.service.ts new file mode 100644 index 0000000..63c7520 --- /dev/null +++ b/frontend/src/services/auth.service.ts @@ -0,0 +1,35 @@ +import { api, setAuthToken, clearAuthToken } from '../config/api'; +import type { LoginResponse, GalleryAuthResponse } from '../types'; + +export const authService = { + // Admin authentication + async adminLogin(username: string, password: string): Promise { + const response = await api.post('/api/auth/admin/login', { + username, + password, + }); + + setAuthToken(response.data.token, true); + return response.data; + }, + + adminLogout() { + clearAuthToken(true); + window.location.href = '/admin/login'; + }, + + // Gallery authentication + async verifyGalleryPassword(slug: string, password: string): Promise { + const response = await api.post('/api/auth/gallery/verify', { + slug, + password, + }); + + setAuthToken(response.data.token, false); + return response.data; + }, + + galleryLogout() { + clearAuthToken(false); + }, +}; \ No newline at end of file diff --git a/frontend/src/services/events.service.ts b/frontend/src/services/events.service.ts new file mode 100644 index 0000000..1059970 --- /dev/null +++ b/frontend/src/services/events.service.ts @@ -0,0 +1,85 @@ +import { api } from '../config/api'; +import type { Event } from '../types'; + +interface CreateEventData { + event_type: string; + event_name: string; + event_date: string; + host_email: string; + admin_email: string; + password: string; + welcome_message?: string; + color_theme?: string; + expires_at: string; +} + +interface UpdateEventData { + welcome_message?: string; + color_theme?: string; + expires_at?: string; + is_active?: boolean; +} + +interface EventsListResponse { + events: Event[]; + total: number; + page: number; + limit: number; +} + +export const eventsService = { + // Get all events (admin) + async getEvents( + page: number = 1, + limit: number = 20, + status?: 'active' | 'inactive' | 'archived' + ): Promise { + const params = new URLSearchParams({ + page: page.toString(), + limit: limit.toString(), + }); + + if (status) { + params.append('status', status); + } + + const response = await api.get(`/api/admin/events?${params}`); + return response.data; + }, + + // Get single event details (admin) + async getEvent(id: number): Promise { + const response = await api.get(`/api/admin/events/${id}`); + return response.data; + }, + + // Create new event (admin) + async createEvent(data: CreateEventData): Promise { + const response = await api.post('/api/admin/events', data); + return response.data; + }, + + // Update event (admin) + async updateEvent(id: number, data: UpdateEventData): Promise { + const response = await api.patch(`/api/admin/events/${id}`, data); + return response.data; + }, + + // Delete/deactivate event (admin) + async deleteEvent(id: number): Promise { + await api.delete(`/api/admin/events/${id}`); + }, + + // Force archive event (admin) + async archiveEvent(id: number): Promise { + await api.post(`/api/admin/events/${id}/archive`); + }, + + // Extend event expiration (admin) + async extendExpiration(id: number, newExpiryDate: string): Promise { + const response = await api.patch(`/api/admin/events/${id}`, { + expires_at: newExpiryDate, + }); + return response.data; + }, +}; \ No newline at end of file diff --git a/frontend/src/services/gallery.service.ts b/frontend/src/services/gallery.service.ts new file mode 100644 index 0000000..875fc37 --- /dev/null +++ b/frontend/src/services/gallery.service.ts @@ -0,0 +1,56 @@ +import { api } from '../config/api'; +import type { GalleryInfo, GalleryData, GalleryStats } from '../types'; + +export const galleryService = { + // Get basic gallery info (no auth required) + async getGalleryInfo(slug: string): Promise { + const response = await api.get(`/api/gallery/${slug}/info`); + return response.data; + }, + + // Get gallery photos (requires auth) + async getGalleryPhotos(slug: string): Promise { + const response = await api.get(`/api/gallery/${slug}/photos`); + return response.data; + }, + + // Download single photo + async downloadPhoto(slug: string, photoId: number, filename: string): Promise { + const response = await api.get(`/api/gallery/${slug}/download/${photoId}`, { + responseType: 'blob', + }); + + // Create download link + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + }, + + // Download all photos as ZIP + async downloadAllPhotos(slug: string): Promise { + const response = await api.get(`/api/gallery/${slug}/download-all`, { + responseType: 'blob', + }); + + // Create download link + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `${slug}.zip`); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + }, + + // Get gallery statistics + async getGalleryStats(slug: string): Promise { + const response = await api.get(`/api/gallery/${slug}/stats`); + return response.data; + }, +}; \ No newline at end of file diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts new file mode 100644 index 0000000..b0d1dac --- /dev/null +++ b/frontend/src/services/index.ts @@ -0,0 +1,3 @@ +export { authService } from './auth.service'; +export { galleryService } from './gallery.service'; +export { eventsService } from './events.service'; \ 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..7a24e23 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,94 @@ +// Event/Gallery types +export interface Event { + id: number; + slug: string; + event_type: string; + event_name: string; + event_date: string; + host_email: string; + admin_email: string; + welcome_message?: string; + color_theme?: string; + share_link: string; + created_at: string; + expires_at: string; + is_active: boolean; + is_archived: boolean; + archive_path?: string; + archived_at?: string; +} + +export interface GalleryInfo { + event_name: string; + event_type: string; + event_date: string; + expires_at: string; + is_active: boolean; + is_expired: boolean; +} + +export interface Photo { + id: number; + filename: string; + url: string; + thumbnail_url?: string; + type: 'collage' | 'individual'; + size: number; + uploaded_at: string; +} + +export interface GalleryData { + event: { + id: number; + event_name: string; + event_type: string; + event_date: string; + welcome_message?: string; + color_theme?: string; + expires_at: string; + }; + photos: Photo[]; +} + +export interface GalleryStats { + total_photos: number; + total_views: number; + total_downloads: number; + unique_visitors: number; +} + +// Auth types +export interface AdminUser { + id: number; + username: string; + email: string; +} + +export interface LoginResponse { + token: string; + user: AdminUser; +} + +export interface GalleryAuthResponse { + token: string; + event: { + id: number; + event_name: string; + event_type: string; + event_date: string; + welcome_message?: string; + color_theme?: string; + expires_at: string; + }; +} + +// API Error type +export interface ApiError { + error: string; + errors?: Array<{ + type: string; + msg: string; + path: string; + location: string; + }>; +} \ No newline at end of file diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..3362420 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,83 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + primary: { + 50: '#f0fdf4', + 100: '#dcfce7', + 200: '#bbf7d0', + 300: '#86efac', + 400: '#4ade80', + 500: '#22c55e', + 600: '#5C8762', // Main brand color from scrappbook.de + 700: '#4a6f4f', + 800: '#3f5d42', + 900: '#365238', + }, + sand: { + 50: '#fdfcfb', + 100: '#f7f5f2', + 200: '#f0ebe5', + 300: '#e6ddd4', + 400: '#d4c2b0', + 500: '#c2a68c', + 600: '#b18b68', + }, + neutral: { + 50: '#fafafa', + 100: '#f5f5f5', + 200: '#e5e5e5', + 300: '#d4d4d4', + 400: '#a3a3a3', + 500: '#737373', + 600: '#525252', + 700: '#404040', + 800: '#262626', + 900: '#171717', + } + }, + fontFamily: { + sans: ['Inter', 'Noto Sans', 'system-ui', '-apple-system', 'sans-serif'], + }, + animation: { + 'fade-in': 'fadeIn 0.5s ease-in-out', + 'slide-up': 'slideUp 0.3s ease-out', + 'scale-in': 'scaleIn 0.2s ease-out', + }, + keyframes: { + fadeIn: { + '0%': { opacity: '0' }, + '100%': { opacity: '1' }, + }, + slideUp: { + '0%': { transform: 'translateY(10px)', opacity: '0' }, + '100%': { transform: 'translateY(0)', opacity: '1' }, + }, + scaleIn: { + '0%': { transform: 'scale(0.95)', opacity: '0' }, + '100%': { transform: 'scale(1)', opacity: '1' }, + }, + }, + spacing: { + '18': '4.5rem', + '88': '22rem', + }, + borderRadius: { + 'xl': '1rem', + '2xl': '1.25rem', + }, + boxShadow: { + 'soft': '0 2px 8px rgba(0, 0, 0, 0.04)', + 'medium': '0 4px 16px rgba(0, 0, 0, 0.08)', + 'large': '0 8px 32px rgba(0, 0, 0, 0.12)', + }, + }, + }, + plugins: [], +} + diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..227a6c6 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": 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..f85a399 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..e5d2b95 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + host: true, + proxy: { + '/api': { + target: 'http://backend:3000', + changeOrigin: true, + }, + '/photos': { + target: 'http://backend:3000', + changeOrigin: true, + }, + }, + }, +}) diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..494d68b --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,40 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + 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" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + + # Rate limiting + limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s; + limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/m; + + include /etc/nginx/sites-enabled/*.conf; +} diff --git a/photo-sharing-prd.md b/photo-sharing-prd.md new file mode 100644 index 0000000..81c1a35 --- /dev/null +++ b/photo-sharing-prd.md @@ -0,0 +1,372 @@ +# Product Requirements Document: Event Photo Sharing Platform + +## 1. Executive Summary + +### 1.1 Product Overview +A secure, customizable photo sharing platform designed primarily for wedding photo booths but adaptable for any event type. The platform enables event organizers to easily share photos with guests through password-protected, time-limited links while maintaining a simple file-based backend system with automatic archiving. + +### 1.2 Key Value Propositions +- **Simple Backend Management**: Drop photos in folders, generate links instantly +- **Secure Sharing**: Password-protected access with expiration dates +- **Automated Lifecycle**: Automatic archiving and storage optimization +- **Proactive Communication**: Email notifications for key events +- **Personalized Experience**: Custom branding for each event +- **Analytics Integration**: Track engagement through Umami +- **Versatile Use Cases**: Optimized for weddings but suitable for any event + +## 2. Product Goals & Objectives + +### 2.1 Primary Goals +- Provide a seamless, time-limited photo sharing experience for event guests +- Minimize technical complexity for administrators +- Ensure photo privacy through password protection and link expiration +- Automate storage management through intelligent archiving +- Enable detailed analytics on photo access and engagement +- Keep stakeholders informed through automated notifications + +### 2.2 Success Metrics +- Time to generate new event gallery (<2 minutes) +- Guest satisfaction score (>90%) +- Photo view/download rates +- System uptime (99.9%) +- Successful automatic archiving rate (100%) +- Email delivery rate (>98%) + +## 3. User Personas + +### 3.1 Administrator (Event Organizer/Photographer) +- **Background**: Professional photographer or event organizer +- **Technical Skills**: Basic to intermediate +- **Needs**: Quick photo upload, easy link generation, access analytics, automated cleanup +- **Pain Points**: Complex upload processes, managing multiple events, storage management + +### 3.2 End User (Event Guest) +- **Background**: Wedding guest or event attendee +- **Technical Skills**: Varies widely +- **Needs**: Easy photo viewing, downloading, sharing within timeframe +- **Pain Points**: Complicated interfaces, slow loading, expired links + +### 3.3 Event Host (Bride/Groom/Celebrant) +- **Background**: Person celebrating the event +- **Technical Skills**: Basic +- **Needs**: Notification of gallery availability, awareness of expiration +- **Pain Points**: Missing the opportunity to save photos, not knowing when gallery is ready + +## 4. Functional Requirements + +### 4.1 Backend Administration + +#### 4.1.1 File Management System +- **Photo Upload**: Direct file system access via designated folders +- **Folder Structure**: + ``` + /events/ + ├── active/ + │ ├── wedding-smith-jones-2024-06-15/ + │ │ ├── collages/ + │ │ │ ├── collage_001.jpg + │ │ │ └── collage_002.jpg + │ │ └── individual/ + │ │ ├── photo_001.jpg + │ │ └── photo_002.jpg + │ └── birthday-emma-2024-07-20/ + │ └── photos/ + └── archived/ + └── wedding-smith-jones-2024-06-15.zip + ``` +- **Supported Formats**: JPEG, PNG, WebP +- **Auto-detection**: System monitors folders for new photos +- **Automatic Archiving**: Upon expiration, compress folder to ZIP and move to archive + +#### 4.1.2 Link Generation +- **Unique URL Generation**: Automatic creation of shareable links +- **Password Setting**: Admin sets password during link creation +- **Expiration Date**: Mandatory expiration date selection (default: 30 days) +- **Event Metadata**: + - Event type (wedding, birthday, corporate, etc.) + - Names (couple names for weddings, celebrant for others) + - Event date + - Host email address (for notifications) + - Admin notification email + - Custom welcome message + - Color theme selection + - Link validity period + +#### 4.1.3 Email Notification System +- **Trigger Events**: + - Link creation: Notify host with access details + - Link expiration warning: 7 days before expiration + - Link expiration: Notify both host and admin + - Archive completion: Confirm to admin +- **Email Templates**: Customizable, branded email templates +- **Configuration**: SMTP settings, from address, reply-to address + +#### 4.1.4 Admin Dashboard +- **Event Management**: List all events, active/inactive/archived status +- **Expiration Overview**: Timeline view of upcoming expirations +- **Analytics Overview**: Quick stats per event +- **Link Management**: Copy links, reset passwords, extend expiration, deactivate events +- **Bulk Operations**: Archive old events, batch photo operations +- **Email Configuration**: Template management, SMTP settings +- **Archive Management**: View and download archived ZIPs + +### 4.2 Frontend Guest Experience + +#### 4.2.1 Landing Page +- **Password Entry**: Clean, intuitive password input +- **Event Preview**: Show event name, date, and expiration notice +- **Expiration Warning**: Prominent display if <7 days remaining +- **Expired State**: Clear message with contact information if expired +- **Responsive Design**: Mobile-first approach + +#### 4.2.2 Gallery View +- **Expiration Banner**: Sticky banner showing days remaining +- **Grid Layout**: Responsive photo grid with lazy loading +- **View Toggle**: Switch between collages and individual photos +- **Sorting Options**: By date, name, or custom order +- **Search**: Basic filename or date search +- **Download Urgency**: Prominent "Download All" for soon-to-expire galleries + +#### 4.2.3 Photo Interactions +- **Lightbox View**: Full-screen photo viewing with navigation +- **Zoom**: Pinch-to-zoom on mobile, mouse wheel on desktop +- **Download Options**: + - Single photo download + - Bulk download (selected photos) + - Download all (ZIP file) +- **Sharing**: Direct link to specific photos (respects expiration) + +#### 4.2.4 Personalization +- **Dynamic Theming**: Based on event type and admin preferences +- **Custom Headers**: Event names, dates, and messages +- **Branded Elements**: Optional logo upload +- **Expiration Messaging**: Customizable expiration notices + +### 4.3 Analytics Integration + +#### 4.3.1 Umami Analytics +- **Page Views**: Track gallery visits +- **User Actions**: Photo views, downloads, time spent +- **Device/Browser Stats**: Understand user base +- **Geographic Data**: Guest locations +- **Custom Events**: + - Password entries (successful/failed) + - Photo downloads + - Share button clicks + - Expiration warning views + - Last-minute download spikes + +### 4.4 Archiving System + +#### 4.4.1 Automatic Archiving Process +- **Trigger**: Activated upon link expiration +- **Process**: + 1. Create ZIP file with folder structure preserved + 2. Verify ZIP integrity + 3. Move ZIP to archive location + 4. Delete original files + 5. Update database with archive location + 6. Send confirmation emails + +#### 4.4.2 Archive Management +- **Storage Optimization**: Compression settings for long-term storage +- **Retrieval System**: Admin can restore archives if needed +- **Retention Policy**: Configurable long-term retention rules + +## 5. Technical Requirements + +### 5.1 Architecture + +#### 5.1.1 Infrastructure +- **Backend Access**: Dedicated FQDN (e.g., admin.photos.domain.com) +- **Frontend Access**: Public FQDN (e.g., photos.domain.com) +- **File Storage**: Local file system or network-attached storage +- **Archive Storage**: Separate location for long-term ZIP storage +- **Database**: Lightweight database for metadata (SQLite or PostgreSQL) +- **Email Service**: SMTP integration or email service provider + +#### 5.1.2 Security +- **HTTPS**: Required for both frontend and backend +- **Password Hashing**: Bcrypt or similar for stored passwords +- **Rate Limiting**: Prevent brute force attacks +- **Access Logs**: Track all access attempts +- **Expiration Enforcement**: Server-side validation of link validity + +### 5.2 Performance Requirements +- **Page Load Time**: <3 seconds on 4G connection +- **Image Optimization**: Automatic thumbnail generation +- **Caching**: CDN integration for static assets +- **Concurrent Users**: Support 100+ simultaneous users per event +- **Archive Generation**: Complete within 10 minutes for 1000 photos + +### 5.3 Technology Stack (Recommended) +- **Backend**: Node.js with Express or Python with FastAPI +- **Frontend**: React or Vue.js for dynamic interactions +- **Image Processing**: Sharp (Node.js) or Pillow (Python) +- **File Monitoring**: Chokidar or Watchdog +- **Analytics**: Umami self-hosted or cloud +- **Email Service**: Nodemailer or SendGrid +- **Job Queue**: Bull (Node.js) or Celery (Python) for archiving tasks +- **Scheduler**: Node-cron or APScheduler for expiration checks + +## 6. User Interface Requirements + +### 6.1 Design Principles +- **Modern Aesthetic**: Clean, minimalist design +- **Wedding-Optimized**: Elegant typography, romantic color options +- **Urgency Communication**: Clear expiration indicators +- **Accessibility**: WCAG 2.1 AA compliant +- **Responsive**: Mobile, tablet, and desktop optimized + +### 6.2 UI Components +- **Photo Grid**: Masonry or uniform grid layout +- **Navigation**: Sticky header with view toggles +- **Expiration Timer**: Countdown display for urgent galleries +- **Loading States**: Skeleton screens for better UX +- **Error Handling**: Friendly error messages +- **Email Status**: Indicators for sent notifications + +### 6.3 Branding Options +- **Color Schemes**: Pre-defined themes plus custom colors +- **Font Selection**: Google Fonts integration +- **Layout Templates**: Multiple gallery layout options +- **Email Templates**: Matching email designs + +## 7. Non-Functional Requirements + +### 7.1 Scalability +- Horizontal scaling capability +- Support for 10,000+ photos per event +- Efficient handling of high-resolution images +- Queue system for archiving operations + +### 7.2 Reliability +- 99.9% uptime SLA +- Automated backups (including archives) +- Graceful error handling +- Failed job retry mechanisms + +### 7.3 Maintainability +- Clear code documentation +- Modular architecture +- Automated testing suite +- Monitoring for failed archiving jobs + +### 7.4 Compliance +- GDPR compliance for EU users +- Copyright considerations +- Privacy policy and terms of service +- Data retention policies + +## 8. Email Templates + +### 8.1 Link Creation Email (to Host) +- Subject: "Your [Event Name] Photos Are Ready!" +- Content: Access details, password, expiration date +- Call-to-action: View gallery button + +### 8.2 Expiration Warning Email +- Subject: "Your [Event Name] Photos Expire in 7 Days" +- Content: Urgency message, download instructions +- Call-to-action: Download all photos button + +### 8.3 Expiration Notification Email +- To Host: "Your [Event Name] Photo Gallery Has Expired" +- To Admin: "[Event Name] Gallery Archived Successfully" +- Content: Confirmation of archiving, contact for retrieval + +## 9. Future Enhancements + +### 9.1 Phase 2 Features +- **Flexible Expiration**: Extend expiration for individual users +- **Partial Downloads**: Resume interrupted downloads +- **AI-Powered Features**: Face recognition for automatic grouping +- **Social Integration**: Direct sharing to social media +- **Guest Uploads**: Allow guests to add their photos +- **Video Support**: Basic video playback + +### 9.2 Phase 3 Features +- **Mobile Apps**: Native iOS/Android applications +- **Print Integration**: Direct ordering of prints +- **Event Packages**: Bundled services with photographers +- **Multi-language Support**: Internationalization +- **Cloud Archive**: Optional cloud storage for archives + +## 10. Success Criteria + +### 10.1 Launch Criteria +- Successfully handle 10 concurrent events +- Process 1,000 photos in <5 minutes +- 100% successful archiving rate +- Achieve 95% positive user feedback in beta +- Email delivery rate >98% + +### 10.2 Post-Launch Metrics +- Monthly active events: 100+ +- Average photos per event: 200+ +- Guest engagement rate: 70%+ +- Download rate: 50%+ of guests +- On-time archiving: 99%+ + +## 11. Risks & Mitigation + +### 11.1 Technical Risks +- **Storage Limitations**: Implement automated archiving and cloud storage +- **Performance Issues**: Progressive loading and CDN usage +- **Security Breaches**: Regular security audits +- **Archive Failures**: Redundant archiving with verification +- **Email Delivery**: Multiple SMTP providers, delivery monitoring + +### 11.2 Business Risks +- **Low Adoption**: Marketing partnerships with photographers +- **Feature Creep**: Strict MVP scope adherence +- **Support Burden**: Comprehensive documentation and FAQs +- **Expired Link Complaints**: Clear communication, grace period + +## 12. Timeline & Milestones + +### 12.1 Development Phases +- **Phase 1 (MVP)**: 10-12 weeks + - Core functionality + - Basic UI + - Expiration system + - Email notifications + - Archiving system + - Umami integration +- **Phase 2 (Enhancement)**: 4-6 weeks + - Advanced features + - Performance optimization +- **Phase 3 (Polish)**: 2-4 weeks + - UI refinements + - Beta testing + +### 12.2 Key Milestones +- Week 2: Technical architecture finalized +- Week 4: Backend functionality complete +- Week 6: Frontend gallery functional +- Week 7: Email system integrated +- Week 8: Archiving system complete +- Week 9: Analytics integrated +- Week 12: Beta launch + +## 13. Appendices + +### 13.1 Technical Specifications +- Detailed API documentation +- Database schema (including expiration tracking) +- File naming conventions +- Archive format specifications + +### 13.2 Design Mockups +- UI wireframes +- Email template designs +- Expiration state displays +- Style guide +- Component library + +### 13.3 Testing Plan +- Unit test coverage +- Integration testing +- Archiving system testing +- Email delivery testing +- User acceptance criteria \ No newline at end of file diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..047ca3c --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,73 @@ +#!/bin/bash +set -e + +echo "Photo Sharing Platform - Docker Installation" +echo "===========================================" + +# Check if running as root +if [[ $EUID -ne 0 ]]; then + echo "This script must be run as root" + exit 1 +fi + +# Function to check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Check prerequisites +echo "Checking prerequisites..." + +# Install Docker if not present +if ! command_exists docker; then + echo "Installing Docker..." + curl -fsSL https://get.docker.com -o get-docker.sh + sh get-docker.sh + rm get-docker.sh +fi + +# Install Docker Compose if not present +if ! command_exists docker-compose; then + echo "Installing Docker Compose..." + curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + chmod +x /usr/local/bin/docker-compose +fi + +# Create necessary directories +echo "Creating directory structure..." +mkdir -p storage/events/{active,archived} +mkdir -p storage/thumbnails +mkdir -p data +mkdir -p logs +mkdir -p nginx/sites-enabled +mkdir -p certbot/{conf,www} + +# Set permissions +chmod -R 755 storage +chmod -R 755 data +chmod -R 755 logs + +# Copy environment file +if [ ! -f .env ]; then + cp .env.example .env + echo "Created .env file. Please edit it with your configuration." +fi + +# Generate secure passwords +echo "Generating secure passwords..." +JWT_SECRET=$(openssl rand -base64 32) +DB_PASSWORD=$(openssl rand -base64 32) +UMAMI_HASH_SALT=$(openssl rand -base64 32) + +# Update .env file with generated values +sed -i "s/JWT_SECRET=.*/JWT_SECRET=$JWT_SECRET/" .env +sed -i "s/DB_PASSWORD=.*/DB_PASSWORD=$DB_PASSWORD/" .env +sed -i "s/UMAMI_HASH_SALT=.*/UMAMI_HASH_SALT=$UMAMI_HASH_SALT/" .env + +echo "" +echo "Installation complete!" +echo "Next steps:" +echo "1. Edit .env file with your domain names and SMTP settings" +echo "2. Run: ./scripts/setup-ssl.sh to configure SSL certificates" +echo "3. Run: docker-compose -f docker-compose.prod.yml up -d" +echo "4. Run: docker-compose -f docker-compose.prod.yml exec backend npm run migrate" diff --git a/setup-remaining-files.sh b/setup-remaining-files.sh old mode 100644 new mode 100755 diff --git a/start-local.sh b/start-local.sh new file mode 100755 index 0000000..5cf1283 --- /dev/null +++ b/start-local.sh @@ -0,0 +1,111 @@ +#!/bin/bash + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +echo -e "${GREEN}🚀 Photo Sharing Platform - Local Development Setup${NC}" +echo "==================================================" + +# Check if Docker is installed +if ! command -v docker &> /dev/null; then + echo -e "${RED}❌ Docker is not installed. Please install Docker Desktop first.${NC}" + echo " Visit: https://www.docker.com/products/docker-desktop" + exit 1 +fi + +# Check if Docker is running +if ! docker info &> /dev/null; then + echo -e "${RED}❌ Docker is not running. Please start Docker Desktop.${NC}" + exit 1 +fi + +# Create necessary directories +echo -e "${YELLOW}📁 Creating directories...${NC}" +mkdir -p storage/events/{active,archived} +mkdir -p storage/thumbnails +mkdir -p data +mkdir -p logs +mkdir -p backend/node_modules +mkdir -p frontend/node_modules + +# Copy local environment file if it doesn't exist +if [ ! -f .env ]; then + echo -e "${YELLOW}📋 Setting up environment...${NC}" + cp .env.local .env +fi + +# Stop any existing containers +echo -e "${YELLOW}🛑 Stopping existing containers...${NC}" +docker-compose -f docker-compose.local.yml down 2>/dev/null || true + +# Build images +echo -e "${YELLOW}🔨 Building Docker images...${NC}" +docker-compose -f docker-compose.local.yml build + +# Start services +echo -e "${YELLOW}🚀 Starting services...${NC}" +docker-compose -f docker-compose.local.yml up -d + +# Wait for backend to be ready +echo -e "${YELLOW}⏳ Waiting for backend to start...${NC}" +max_attempts=30 +attempt=1 +while [ $attempt -le $max_attempts ]; do + if curl -s http://localhost:3001/api/health > /dev/null 2>&1; then + echo -e "${GREEN}✅ Backend is ready!${NC}" + break + fi + echo -n "." + sleep 2 + attempt=$((attempt + 1)) +done + +if [ $attempt -gt $max_attempts ]; then + echo -e "${RED}❌ Backend failed to start. Check logs with: docker-compose -f docker-compose.local.yml logs backend${NC}" + exit 1 +fi + +# Build frontend for production-like testing +echo -e "${YELLOW}📦 Building frontend...${NC}" +docker-compose -f docker-compose.local.yml exec frontend-dev npm run build + +# Show status +echo "" +echo -e "${GREEN}✅ Local development environment is ready!${NC}" +echo "" +echo -e "${GREEN}🌐 Access Points:${NC}" +echo " Frontend (Production Build): http://localhost:3000" +echo " Frontend (Dev with Hot Reload): http://localhost:3002" +echo " Backend API: http://localhost:3001/api" +echo " Mailhog (Email Testing): http://localhost:8025" +echo "" +echo -e "${GREEN}🔑 Default Admin Credentials:${NC}" +echo " Username: admin" +echo " Password: admin123" +echo "" +echo -e "${GREEN}📝 Useful Commands:${NC}" +echo " View logs: docker-compose -f docker-compose.local.yml logs -f" +echo " Stop all: ./stop-local.sh" +echo " Backend shell: docker-compose -f docker-compose.local.yml exec backend sh" +echo " Reset database: docker-compose -f docker-compose.local.yml exec backend npm run migrate" +echo "" +echo -e "${GREEN}💡 Tips:${NC}" +echo " - Frontend dev server (port 3002) has hot reload enabled" +echo " - All emails are caught by Mailhog - check http://localhost:8025" +echo " - SQLite database is stored in ./data/photo_sharing.db" +echo " - Upload photos to ./storage/events/active/{event-name}/" +echo "" + +# Open browser +if command -v xdg-open &> /dev/null; then + xdg-open http://localhost:3002 +elif command -v open &> /dev/null; then + open http://localhost:3002 +fi + +# Show logs +echo -e "${YELLOW}📋 Showing logs (Ctrl+C to exit)...${NC}" +docker-compose -f docker-compose.local.yml logs -f \ No newline at end of file diff --git a/stop-local.sh b/stop-local.sh new file mode 100755 index 0000000..edc95d3 --- /dev/null +++ b/stop-local.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +echo -e "${YELLOW}🛑 Stopping Photo Sharing Platform...${NC}" + +# Stop all containers +docker-compose -f docker-compose.local.yml down + +# Optional: Remove volumes (uncomment if you want to reset data) +# docker-compose -f docker-compose.local.yml down -v + +echo -e "${GREEN}✅ All services stopped${NC}" +echo "" +echo -e "${YELLOW}💡 Tips:${NC}" +echo " - Your data is preserved in ./data and ./storage" +echo " - To completely reset, run: docker-compose -f docker-compose.local.yml down -v" +echo " - To restart, run: ./start-local.sh" \ No newline at end of file diff --git a/storage/events/active/.gitkeep b/storage/events/active/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/storage/events/archived/.gitkeep b/storage/events/archived/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/storage/thumbnails/.gitkeep b/storage/thumbnails/.gitkeep new file mode 100644 index 0000000..e69de29