diff --git a/.github/workflows/synkronus-docker.yml b/.github/workflows/synkronus-docker.yml index 8f8fe9fc0..c4dec91b3 100644 --- a/.github/workflows/synkronus-docker.yml +++ b/.github/workflows/synkronus-docker.yml @@ -84,7 +84,7 @@ jobs: with: context: ./synkronus file: ./synkronus/Dockerfile - platforms: linux/amd64,linux/arm64 + platforms: linux/amd64 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/synkronus-portal-docker.yml b/.github/workflows/synkronus-portal-docker.yml new file mode 100644 index 000000000..9c7591898 --- /dev/null +++ b/.github/workflows/synkronus-portal-docker.yml @@ -0,0 +1,100 @@ +name: Synkronus Portal Docker Build & Publish + +on: + push: + branches: + - main + - dev + paths: + - 'synkronus-portal/**' + - '.github/workflows/synkronus-portal-docker.yml' + pull_request: + paths: + - 'synkronus-portal/**' + - '.github/workflows/synkronus-portal-docker.yml' + workflow_dispatch: + inputs: + version: + description: 'Version tag (e.g., v1.0.0). If empty, tags will be derived from the current ref.' + required: false + type: string + release: + types: [published] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: opendataensemble/synkronus-portal + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + id-token: write + attestations: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: all + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + # For main branch: latest + version tag (manual dispatch) or release tag + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + # When triggered via manual dispatch with a version input + type=semver,pattern=v{{version}},enable=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version != '' }},value=${{ github.event.inputs.version }} + type=semver,pattern=v{{major}}.{{minor}},enable=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version != '' }},value=${{ github.event.inputs.version }} + # When triggered from a GitHub Release event, use the release tag name as the semver source + type=semver,pattern=v{{version}},enable=${{ github.event_name == 'release' }},value=${{ github.event.release.tag_name }} + type=semver,pattern=v{{major}}.{{minor}},enable=${{ github.event_name == 'release' }},value=${{ github.event.release.tag_name }} + # For other branches: branch name (pre-release) + type=ref,event=branch,enable=${{ github.event_name != 'release' && github.ref != 'refs/heads/main' }} + # For PRs: pr-number + type=ref,event=pr + # SHA for traceability (only for non-release events) + type=sha,prefix=sha-,enable=${{ github.event_name != 'release' && github.event_name != 'pull_request' }} + labels: | + org.opencontainers.image.title=Synkronus Portal + org.opencontainers.image.description=Synchronization API for offline-first applications + org.opencontainers.image.vendor=Open Data Ensemble + + - name: Build and push Docker image + id: build + uses: docker/build-push-action@v5 + with: + context: ./synkronus-portal + file: ./synkronus-portal/Dockerfile + platforms: linux/amd64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Generate artifact attestation + if: github.event_name != 'pull_request' + uses: actions/attest-build-provenance@v1 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true diff --git a/synkronus-portal/.dockerignore b/synkronus-portal/.dockerignore new file mode 100644 index 000000000..0cc13d927 --- /dev/null +++ b/synkronus-portal/.dockerignore @@ -0,0 +1,15 @@ +node_modules +npm-debug.log +dist +.git +.gitignore +.env +.env.local +.env.*.local +*.log +.DS_Store +coverage +.vscode +.idea + + diff --git a/synkronus-portal/.gitignore b/synkronus-portal/.gitignore new file mode 100644 index 000000000..77bc16ae4 --- /dev/null +++ b/synkronus-portal/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +docker-compose.dev.yml + +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/synkronus-portal/Dockerfile b/synkronus-portal/Dockerfile new file mode 100644 index 000000000..4a47f69be --- /dev/null +++ b/synkronus-portal/Dockerfile @@ -0,0 +1,53 @@ +# Multi-stage build for Synkronus Portal +# Stage 1: Build the React application +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Stage 2: Serve with nginx +FROM nginx:alpine + +# Copy built assets from builder +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy custom nginx configuration for SPA routing +RUN echo 'server { \ + listen 80; \ + server_name localhost; \ + root /usr/share/nginx/html; \ + index index.html; \ + location / { \ + try_files $uri $uri/ /index.html; \ + } \ + # Proxy API requests to backend \ + location /api { \ + rewrite ^/api(.*)$ $1 break; \ + proxy_pass http://synkronus:8080; \ + proxy_http_version 1.1; \ + proxy_set_header Upgrade $http_upgrade; \ + proxy_set_header Connection "upgrade"; \ + proxy_set_header Host $host; \ + proxy_set_header X-Real-IP $remote_addr; \ + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \ + proxy_set_header X-Forwarded-Proto $scheme; \ + client_max_body_size 100M; \ + } \ +}' > /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] + + diff --git a/synkronus-portal/LLM_context.md b/synkronus-portal/LLM_context.md new file mode 100644 index 000000000..4f14df3f1 --- /dev/null +++ b/synkronus-portal/LLM_context.md @@ -0,0 +1,262 @@ +# synkronus-portal – LLM Context for Frontend Development + +This file explains the architecture and patterns of the **synkronus-portal** project so future LLM agents can extend the frontend without re-analyzing the codebase. + +## High-Level Architecture + +- **Entry point**: `src/main.tsx` + - Renders the root `App` component with `React.StrictMode`. +- **App structure**: `src/App.tsx` + - Wraps the application with `AuthProvider` for authentication state management. + - Uses `ProtectedRoute` to guard authenticated pages. +- **Build tool**: Vite with React plugin + - Development server with HMR (Hot Module Replacement). + - Production builds output to `dist/` directory. +- **Tech stack**: + - React 19.2.0 with TypeScript + - Vite 7.2.4 for build tooling + - No routing library (simple conditional rendering based on auth state) + +## Project Structure + +``` +src/ +├── main.tsx # Application entry point +├── App.tsx # Root component with AuthProvider +├── App.css # App-specific styles +├── index.css # Global styles and CSS reset +├── types/ +│ └── auth.ts # TypeScript interfaces for authentication +├── services/ +│ └── api.ts # HTTP client for API requests +├── contexts/ +│ └── AuthContext.tsx # Authentication state management +├── components/ +│ ├── Login.tsx # Login page component +│ ├── Login.css # Login page styles +│ └── ProtectedRoute.tsx # Route protection wrapper +└── pages/ + ├── Dashboard.tsx # Main dashboard page (protected) + └── Dashboard.css # Dashboard styles +``` + +## Authentication System + +### AuthContext (`src/contexts/AuthContext.tsx`) + +The authentication system is built around React Context: + +- **State management**: Stores `user`, `token`, `refreshToken`, and `isAuthenticated` in state. +- **Persistence**: Tokens are stored in `localStorage` and automatically loaded on app mount. +- **Methods**: + - `login(credentials)` - Authenticates user and stores tokens + - `logout()` - Clears all auth state and tokens + - `refreshAuth()` - Refreshes expired tokens automatically + +### API Service (`src/services/api.ts`) + +Centralized HTTP client for all API requests: + +- **Base URL**: Uses `/api` prefix (proxied to backend in development, or `VITE_API_URL` env var). +- **Automatic token injection**: Adds `Authorization: Bearer ` header to authenticated requests. +- **Error handling**: Parses API error responses (`{ error: "...", message: "..." }`) and throws meaningful errors. +- **Methods**: + - `api.login()` - POST `/auth/login` + - `api.refreshToken()` - POST `/auth/refresh` + - `api.get()`, `api.post()`, `api.put()`, `api.delete()` - Generic HTTP methods + +### Protected Routes + +`ProtectedRoute` component: +- Checks `isAuthenticated` from `AuthContext`. +- Automatically refreshes tokens if they expire soon (< 5 minutes). +- Redirects to login if not authenticated. +- Renders children if authenticated. + +## Pattern: Adding a New API Endpoint + +When adding a new feature that calls the Synkronus API: + +1. **Add TypeScript types** (if needed) in `src/types/`: + ```typescript + // src/types/feature.ts + export interface FeatureRequest { + field: string + } + + export interface FeatureResponse { + result: string + } + ``` + +2. **Add API method** in `src/services/api.ts`: + ```typescript + async getFeature(id: string): Promise { + return this.get(`/feature/${id}`) + } + ``` + +3. **Use in component**: + ```typescript + import { api } from '../services/api' + + const data = await api.getFeature('123') + ``` + +## Pattern: Adding a New Page/Component + +1. **Create component file** in `src/pages/` or `src/components/`: + ```typescript + // src/pages/NewPage.tsx + import { useAuth } from '../contexts/AuthContext' + import './NewPage.css' + + export function NewPage() { + const { user } = useAuth() + return
New Page Content
+ } + ``` + +2. **Create CSS file** (if needed): + ```css + /* src/pages/NewPage.css */ + .new-page { + padding: 20px; + } + ``` + +3. **Add to App.tsx** (if it's a protected route): + ```typescript + import { NewPage } from './pages/NewPage' + + + + + ``` + +## Pattern: Using Authentication + +Any component can access auth state via the `useAuth()` hook: + +```typescript +import { useAuth } from '../contexts/AuthContext' + +function MyComponent() { + const { user, isAuthenticated, logout } = useAuth() + + if (!isAuthenticated) { + return
Not logged in
+ } + + return ( +
+

Welcome, {user?.username}

+ +
+ ) +} +``` + +## API Configuration + +### Development (Vite Proxy) + +The `vite.config.ts` proxies `/api/*` requests to the backend: +- **Docker**: `http://synkronus:8080` (service name) +- **Local**: `http://localhost:8080` + +The proxy automatically rewrites `/api/auth/login` → `/auth/login` before forwarding. + +### Production (Nginx) + +The `Dockerfile` includes nginx configuration that: +- Serves the built React app from `/usr/share/nginx/html` +- Proxies `/api/*` requests to `http://synkronus:8080` (Docker service name) + +### Environment Variables + +- `VITE_API_URL`: Override API base URL (bypasses proxy) + - Example: `VITE_API_URL=http://localhost:8080` + - If not set, uses `/api` proxy in development + +## Docker Setup + +### Development Mode (`docker-compose.dev.yml`) + +- **Frontend**: Runs `npm run dev` with hot reload +- **Backend**: Built from `../synkronus` +- **Database**: PostgreSQL with auto-initialization script + +### Production Mode (`docker-compose.yml`) + +- **Frontend**: Built and served via nginx (port 80, exposed as 5173) +- **Backend**: Built from `../synkronus` (port 8080) +- **Database**: PostgreSQL with persistent volumes + +### Database Initialization + +The `init-db.sh` script: +- Creates `synkronus_user` with password `dev_password_change_in_production` +- Grants all privileges on the `synkronus` database +- Runs automatically on first database initialization + +## Error Handling + +The API service handles errors consistently: + +1. **Network errors**: Shows "Network error: Unable to connect to the server" +2. **401 Unauthorized**: Shows "Invalid username or password" (or API error message) +3. **500+ errors**: Shows "Server error: Please check if the API is running" +4. **API errors**: Parses `{ error: "...", message: "..." }` format from backend + +Components should catch errors and display them to users: + +```typescript +try { + await api.someMethod() +} catch (error) { + setError(error instanceof Error ? error.message : 'An error occurred') +} +``` + +## Styling Patterns + +- **Global styles**: `src/index.css` - CSS reset and base styles +- **Component styles**: Each component has its own `.css` file +- **No CSS framework**: Uses plain CSS (can be extended with CSS modules or styled-components if needed) + +## TypeScript Configuration + +- **Strict mode**: Enabled in `tsconfig.json` +- **Path aliases**: Not currently configured (use relative imports) +- **Type definitions**: All API types in `src/types/` + +## Checklist for Adding a New Feature + +When extending the portal, LLM agents should: + +1. **Define TypeScript types** (if needed) + - Add interfaces in `src/types/` for request/response types + +2. **Add API methods** (if calling backend) + - Extend `src/services/api.ts` with new methods + - Use existing patterns for error handling + +3. **Create components/pages** + - Place in `src/components/` or `src/pages/` + - Use `useAuth()` hook if authentication is needed + - Add CSS file for styling + +4. **Update App.tsx** (if adding a new route) + - Wrap with `ProtectedRoute` if authentication required + - Import and render the new component + +5. **Test error handling** + - Ensure network errors are handled gracefully + - Show user-friendly error messages + +6. **Update README** (optional) + - Document new features or endpoints + +Following this guide should allow LLM agents to add new features that are consistent with the existing synkronus-portal architecture and patterns. + diff --git a/synkronus-portal/README.md b/synkronus-portal/README.md new file mode 100644 index 000000000..66444df3f --- /dev/null +++ b/synkronus-portal/README.md @@ -0,0 +1,494 @@ +# Synkronus Portal + +Frontend service for Synkronus, built with React + TypeScript + Vite. + +## Overview + +The Synkronus Portal provides a web-based interface for managing app bundles, users, observations, and data exports. It supports both development (hot reload) and production (optimized build) modes. + +## Quick Reference + +| Mode | Command | URL | Hot Reload | +|------|---------|-----|------------| +| **Production** | `docker compose up -d --build` | http://localhost:5173 | ❌ No | +| **Development** | `docker compose up -d postgres synkronus`
`npm run dev` | http://localhost:5174 | ✅ Yes | + +**Default Login Credentials:** +- Username: `admin` +- Password: `admin` + +## Quick Start + +### Production Mode (Optimized Build) + +**Step 1:** Navigate to the portal directory +```bash +cd synkronus-portal +``` + +**Step 2:** Build and start all services in production mode +```bash +# Option 1: Build and start in one command (recommended) +docker compose up -d --build + +# Option 2: Build first, then start (if you prefer separate steps) +docker compose build +docker compose up -d +``` + +**Note:** The `--build` flag ensures the frontend is built before starting. If you skip building, Docker will build automatically, but it's better to be explicit. + +**Step 3:** Wait for services to start (about 10-30 seconds) +```bash +# Check service status +docker compose ps + +# View logs if needed +docker compose logs -f +``` + +**Step 4:** Access the portal +- **Frontend Portal**: http://localhost:5173 (Nginx serving optimized production build) +- **Backend API**: http://localhost:8080 +- **PostgreSQL**: localhost:5432 +- **Swagger UI**: http://localhost:8080/openapi/swagger-ui.html + +**Production Mode Features:** +- ✅ Optimized production build (minified, tree-shaken) +- ✅ Static file serving via Nginx (fast, efficient) +- ✅ Persistent data storage (survives container restarts) +- ✅ Production-ready performance +- ❌ No hot reload (requires rebuild for changes) + +**To stop production mode:** +```bash +docker compose down +``` + +**Note:** Stopping containers with `docker compose down` does **NOT** delete your data. Volumes persist automatically. Your database and app bundles remain safe. + +**First Time Setup:** Use `docker compose up -d --build` to ensure the frontend is built before starting. This is the easiest and most reliable way to get started. + +--- + +### Development Mode (Hot Reload) + +**Step 1:** Navigate to the portal directory +```bash +cd synkronus-portal +``` + +**Step 2:** Start backend services (PostgreSQL + API) +```bash +# Start only backend services (postgres + synkronus API) +docker compose up -d postgres synkronus +``` + +**Step 3:** Wait for backend to be ready (about 10-20 seconds) +```bash +# Check backend health +curl http://localhost:8080/health +# Should return: OK +``` + +**Step 4:** Install dependencies (if not already done) +```bash +npm install +``` + +**Step 5:** Start the Vite dev server +```bash +npm run dev +``` + +**Step 6:** Access the portal +- **Frontend Portal**: http://localhost:5174 (Vite dev server with hot reload) +- **Backend API**: http://localhost:8080 (already running from Step 2) + +**Development Mode Features:** +- ✅ Hot Module Replacement (HMR) - instant code updates without page refresh +- ✅ Fast refresh - React components update instantly +- ✅ Source maps for debugging +- ✅ Same persistent storage as production - data is shared +- ✅ Full debugging support in browser DevTools +- ✅ Real-time error overlay in browser + +**To stop development mode:** +```bash +# Stop Vite dev server: Press Ctrl+C in the terminal running npm run dev + +# Stop backend services +docker compose down +``` + +**Note:** Stopping containers with `docker compose down` does **NOT** delete your data. Volumes persist automatically. Your database and app bundles remain safe. + +**Note:** The development setup uses the **same named volumes** as production, so your app bundles and database persist across dev/prod switches. You can run the backend in Docker while developing the frontend locally. + +**Volume Safety:** Named volumes persist even when containers are stopped. Your data is safe unless you explicitly use `docker compose down -v`. + +**App Bundle Persistence:** App bundles are stored in the `app-bundles` volume. If bundles disappear after restart, verify: +1. The volume exists: `docker volume ls | grep app-bundles` +2. The volume is mounted: Check `docker compose config` +3. You're not using `docker compose down -v` (which deletes volumes) + +## Architecture + +### Development Mode + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Development Environment (docker-compose.dev.yml)│ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ synkronus-portal │ │ synkronus-api │ │ +│ │ (Frontend) │ │ (Backend) │ │ +│ │ │ │ │ │ +│ │ • Vite Dev │◄────►│ • Go Server │ │ +│ │ • Port 5174 │ │ • Port 8080 │ │ +│ │ • Hot Reload │ │ • App Bundles │ │ +│ │ • Source Mounted │ │ • PostgreSQL │ │ +│ └──────────────────┘ └────────┬───────────┘ │ +│ │ │ │ +│ │ │ │ +│ └───────────────────────────┼───────────────────────┘ +│ │ │ +│ ┌────────▼──────────┐ │ +│ │ PostgreSQL │ │ +│ │ Port 5432 │ │ +│ │ Persistent DB │ │ +│ └───────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Production Mode + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Production Environment (docker-compose.yml) │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ synkronus-portal │ │ synkronus-api │ │ +│ │ (Frontend) │ │ (Backend) │ │ +│ │ │ │ │ │ +│ │ • Nginx │◄────►│ • Go Server │ │ +│ │ • Static Files │ │ • Port 8080 │ │ +│ │ • Port 5173 │ │ • App Bundles │ │ +│ │ • Optimized │ │ • PostgreSQL │ │ +│ └──────────────────┘ └────────┬───────────┘ │ +│ │ │ │ +│ │ │ │ +│ └───────────────────────────┼───────────────────────┘ +│ │ │ +│ ┌────────▼──────────┐ │ +│ │ PostgreSQL │ │ +│ │ Port 5432 │ │ +│ │ Persistent DB │ │ +│ └───────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## API Proxy Configuration + +### Development Mode + +The Vite dev server automatically proxies `/api/*` requests to the backend: + +- **Frontend → Backend**: `/api/*` → `http://synkronus:8080/*` (via Vite proxy) +- **Configuration**: See `vite.config.ts` + +### Production Mode + +Nginx proxies `/api/*` requests to the backend: + +- **Frontend → Backend**: `/api/*` → `http://synkronus:8080/*` (via Nginx) +- **Configuration**: See `Dockerfile` nginx config + +## Storage Persistence + +Both development and production modes use the **same named Docker volumes**, ensuring your data persists across: +- Container restarts +- Mode switches (dev ↔ prod) +- Container removal (with `docker compose down`) +- System reboots + +### Volumes + +- **postgres-data**: PostgreSQL database files (users, observations, app bundles metadata) +- **app-bundles**: App bundle ZIP files and versions (stored at `/app/data/app-bundles` in the container) + +**Important:** App bundles are stored in **both** places: +- **Files**: Actual ZIP files and extracted content in the `app-bundles` volume +- **Database**: Metadata about bundles (versions, manifest info) in the `postgres-data` volume + +Both volumes must persist for app bundles to work correctly after restart. + +### Volume Persistence Guarantee + +**✅ Volumes are NOT deleted when you:** +- Stop containers: `docker compose down` +- Restart containers: `docker compose restart` +- Switch between dev/prod modes +- Rebuild containers: `docker compose build` + +**⚠️ Volumes ARE deleted ONLY when you:** +- Explicitly use: `docker compose down -v` (the `-v` flag removes volumes) +- Manually delete: `docker volume rm ` + +### Checking Your Volumes + +```bash +# List all volumes +docker volume ls + +# Inspect a specific volume +docker volume inspect synkronus-portal_postgres-data +docker volume inspect synkronus-portal_app-bundles + +# Check volume size +docker system df -v +``` + +### Backup Volumes (Optional) + +To backup your data before making changes: + +```bash +# Backup postgres data +docker run --rm -v synkronus-portal_postgres-data:/data -v $(pwd):/backup alpine tar czf /backup/postgres-backup.tar.gz -C /data . + +# Backup app bundles +docker run --rm -v synkronus-portal_app-bundles:/data -v $(pwd):/backup alpine tar czf /backup/app-bundles-backup.tar.gz -C /data . +``` + +**Important:** Your data is safe! Volumes persist by default. Only use `docker compose down -v` if you intentionally want to delete all data. + +## Stopping Services + +### Safe Stop (Preserves Data) + +```bash +# Stop all services - VOLUMES ARE PRESERVED ✅ +docker compose down +``` + +This command: +- ✅ Stops all containers +- ✅ Removes containers +- ✅ **Keeps all volumes** (your data is safe!) +- ✅ Removes networks + +### Complete Removal (⚠️ DELETES ALL DATA) + +```bash +# Stop services AND delete volumes - ⚠️ THIS DELETES ALL DATA! +docker compose down -v +``` + +**⚠️ WARNING:** The `-v` flag removes volumes, which will: +- Delete all database data (users, observations, etc.) +- Delete all uploaded app bundles +- **This action cannot be undone!** + +### Restarting Services + +After stopping with `docker compose down`, simply start again: + +```bash +# Start services (volumes are automatically reattached) +docker compose up -d +``` + +Your data will be exactly as you left it! + +## Default Credentials + +- **Admin username**: `admin` +- **Admin password**: `admin` + +**⚠️ Warning**: These are development credentials only. Change them before production use. + +## Switching Between Modes + +### From Production to Development + +1. Stop production containers: + ```bash + docker compose down + ``` + +2. Start backend services: + ```bash + docker compose up -d postgres synkronus + ``` + +3. Start dev server: + ```bash + npm run dev + ``` + +### From Development to Production + +1. Stop Vite dev server (Ctrl+C) + +2. Stop backend containers: + ```bash + docker compose down + ``` + +3. Start production mode: + ```bash + docker compose up -d --build + ``` + +**Important:** Your data (database, app bundles) persists when switching between modes because both use the same Docker volumes. + +## Building for Production + +### First Time Setup + +For the first time, or after code changes: + +```bash +# Build and start (recommended - does both in one command) +docker compose up -d --build + +# Or build first, then start (if you prefer separate steps) +docker compose build +docker compose up -d +``` + +### Rebuilding After Code Changes + +If you've made changes to the frontend code: + +```bash +# Rebuild just the portal image +docker compose build synkronus-portal + +# Restart the portal service +docker compose up -d synkronus-portal +``` + +**Note:** The `--build` flag in `docker compose up -d --build` will: +- Build images if they don't exist +- Rebuild images if the Dockerfile or source code changed +- Start all services after building + +This is the easiest way to ensure everything is up-to-date! + +## Local Development (Without Docker) + +### Prerequisites + +- Node.js 20+ +- npm or yarn +- Backend API running (either in Docker or separately) + +### Setup + +```bash +# Install dependencies +npm install + +# Start Vite dev server (runs on port 5174) +npm run dev + +# Build for production +npm run build + +# Preview production build locally +npm run preview +``` + +**Note:** When running locally, ensure the backend API is accessible at `http://localhost:8080`. The Vite proxy will automatically route `/api/*` requests to the backend. + +## Environment Variables + +### Development + +- `VITE_API_URL`: Backend API URL (default: uses `/api` proxy) +- `DOCKER_ENV`: Set to `true` when running in Docker + +### Production + +- `VITE_API_URL`: Backend API URL (default: `http://localhost:8080`) + +## Troubleshooting + +### Port Already in Use + +**Production Mode (Port 5173):** +```bash +# Edit docker-compose.yml and change the port mapping: +ports: + - "5173:80" # Change 5173 to your desired port +``` + +**Development Mode (Port 5174):** +```bash +# Edit vite.config.ts and change the port: +server: { + port: 5174, # Change to your desired port +} +``` + +### Hot Reload Not Working (Development Mode) + +1. Ensure you're running `npm run dev` (not Docker for frontend) +2. Check that Vite is running on port 5174 +3. Verify the browser is connected to the correct port (http://localhost:5174) +4. Check browser console for HMR connection errors +5. Try hard refresh (Ctrl+Shift+R or Cmd+Shift+R) + +### API Connection Issues + +1. Verify backend is running: `docker compose ps` +2. Check backend logs: `docker compose logs synkronus` +3. Test API directly: `curl http://localhost:8080/health` + +### App Bundles Not Persisting + +If app bundles disappear after restarting containers: + +1. **Verify the volume exists:** + ```bash + docker volume ls | grep app-bundles + ``` + +2. **Check if bundles are in the volume:** + ```bash + # If containers are running + docker compose exec synkronus ls -la /app/data/app-bundles + + # If containers are stopped + docker run --rm -v synkronus-portal_app-bundles:/data alpine ls -la /data + ``` + +3. **Verify volume is mounted correctly:** + ```bash + docker compose config | grep -A 5 app-bundles + ``` + +4. **Check backend logs for app bundle initialization:** + ```bash + docker compose logs synkronus | grep -i "app bundle\|bundle path" + ``` + +5. **Ensure you're not using `docker compose down -v`:** + - Use `docker compose down` (preserves volumes) ✅ + - Avoid `docker compose down -v` (deletes volumes) ❌ + +**Note:** App bundles are stored in the `app-bundles` volume. This volume persists across restarts. If bundles are missing, check that: +- The volume wasn't accidentally deleted +- The backend has proper permissions to read/write to the volume +- The `APP_BUNDLE_PATH` environment variable is set correctly + +## See Also + +- [ARCHITECTURE.md](./ARCHITECTURE.md) - Detailed architecture documentation +- [../synkronus/README.md](../synkronus/README.md) - Backend API documentation diff --git a/synkronus-portal/docker-compose.yml b/synkronus-portal/docker-compose.yml new file mode 100644 index 000000000..a4fb17fc9 --- /dev/null +++ b/synkronus-portal/docker-compose.yml @@ -0,0 +1,107 @@ +# Docker Compose configuration for Synkronus Portal Development +# This setup includes: +# - Synkronus Portal (frontend) +# - Synkronus API server (backend) +# - PostgreSQL database +# +# Usage: +# docker compose up -d # Start all services +# docker compose down # Stop all services +# docker compose logs -f # View logs +# docker compose ps # Check service status + +services: + # PostgreSQL database + postgres: + image: postgres:17 + container_name: synkronus-postgres + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: dev_password_change_in_production + POSTGRES_DB: synkronus + volumes: + - postgres-data:/var/lib/postgresql/data + - ./init-db.sh:/docker-entrypoint-initdb.d/init-db.sh:ro + ports: + - "5432:5432" + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - synkronus-network + + # Synkronus API server (backend) + synkronus: + build: + context: ../synkronus + dockerfile: Dockerfile + container_name: synkronus-api + expose: + - "8080" + ports: + - "8080:8080" + environment: + PORT: "8080" + LOG_LEVEL: "debug" + DB_CONNECTION: "postgres://synkronus_user:dev_password_change_in_production@postgres:5432/synkronus?sslmode=disable" + # JWT Secret for development (CHANGE IN PRODUCTION!) + JWT_SECRET: "dev_jwt_secret_change_in_production_32chars" + ADMIN_USERNAME: "admin" + ADMIN_PASSWORD: "admin" + APP_BUNDLE_PATH: "/app/data/app-bundles" + MAX_VERSIONS_KEPT: "5" + volumes: + - app-bundles:/app/data/app-bundles + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 10s + networks: + - synkronus-network + + # Synkronus Portal (frontend) + synkronus-portal: + build: + context: . + dockerfile: Dockerfile + container_name: synkronus-portal + ports: + - "5173:80" + depends_on: + - synkronus + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--spider", "--quiet", "http://localhost/"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 10s + networks: + - synkronus-network + +# Named volumes for data persistence +# These volumes persist even when containers are stopped or removed +# They are only deleted if you explicitly use: docker compose down -v +volumes: + postgres-data: + driver: local + # This volume stores all PostgreSQL database data + # It persists across container restarts and mode switches + app-bundles: + driver: local + # This volume stores uploaded app bundle ZIP files + # It persists across container restarts and mode switches + +# Network for service communication +networks: + synkronus-network: + driver: bridge diff --git a/synkronus-portal/eslint.config.js b/synkronus-portal/eslint.config.js new file mode 100644 index 000000000..5e6b472f5 --- /dev/null +++ b/synkronus-portal/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 { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/synkronus-portal/index.html b/synkronus-portal/index.html new file mode 100644 index 000000000..121da84ab --- /dev/null +++ b/synkronus-portal/index.html @@ -0,0 +1,12 @@ + + + + + + Synkronus Portal + + +
+ + + diff --git a/synkronus-portal/init-db.sh b/synkronus-portal/init-db.sh new file mode 100755 index 000000000..e7549a098 --- /dev/null +++ b/synkronus-portal/init-db.sh @@ -0,0 +1,26 @@ +#!/bin/bash +set -e + +# Create synkronus_user and database if they don't exist +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + -- Create user if it doesn't exist + DO \$\$ + BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_user WHERE usename = 'synkronus_user') THEN + CREATE ROLE synkronus_user LOGIN PASSWORD 'dev_password_change_in_production'; + END IF; + END + \$\$; + + -- Grant privileges + GRANT ALL PRIVILEGES ON DATABASE synkronus TO synkronus_user; + + -- Connect to synkronus database and grant schema privileges + \c synkronus + GRANT ALL ON SCHEMA public TO synkronus_user; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO synkronus_user; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO synkronus_user; +EOSQL + +echo "Database initialization completed!" + diff --git a/synkronus-portal/package-lock.json b/synkronus-portal/package-lock.json new file mode 100644 index 000000000..6a71c7729 --- /dev/null +++ b/synkronus-portal/package-lock.json @@ -0,0 +1,3231 @@ +{ + "name": "synkronus-portal", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "synkronus-portal", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "babel-plugin-react-compiler": "^1.0.0", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4" + } + }, + "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.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@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.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.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.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "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/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.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "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.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "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.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "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.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "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.1", + "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.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "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.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "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.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "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/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/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/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz", + "integrity": "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/type-utils": "8.48.1", + "@typescript-eslint/utils": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.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.48.1", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.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.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.1.tgz", + "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.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 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.1.tgz", + "integrity": "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.48.1", + "@typescript-eslint/types": "^8.48.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 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz", + "integrity": "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.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.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz", + "integrity": "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==", + "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 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.1.tgz", + "integrity": "sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1", + "@typescript-eslint/utils": "8.48.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 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz", + "integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==", + "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.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz", + "integrity": "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.48.1", + "@typescript-eslint/tsconfig-utils": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "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 <6.0.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.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.1.tgz", + "integrity": "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.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 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz", + "integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.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": "5.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", + "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.53", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "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-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/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/babel-plugin-react-compiler": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.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/baseline-browser-mapping": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.4.tgz", + "integrity": "sha512-ZCQ9GEWl73BVm8bu5Fts8nt7MHdbt5vY9bP6WGnUh+r3l8M7CgfyTlwsgCbMC66BNxPr6Xoce3j66Ms5YUQTNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "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/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/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/caniuse-lite": { + "version": "1.0.30001759", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", + "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", + "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/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/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/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/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/electron-to-chromium": { + "version": "1.5.266", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.266.tgz", + "integrity": "sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "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.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "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": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "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.24", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", + "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", + "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-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/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/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/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/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/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/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "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-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-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/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/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "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/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/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/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/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/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.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "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/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/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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/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/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/react": { + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", + "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", + "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.1" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/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/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "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.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "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/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/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/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/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/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/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.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.1.tgz", + "integrity": "sha512-FbOKN1fqNoXp1hIl5KYpObVrp0mCn+CLgn479nmu2IsRMrx2vyv74MmsBLVlhg8qVwNFGbXSp8fh1zp8pEoC2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.48.1", + "@typescript-eslint/parser": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1", + "@typescript-eslint/utils": "8.48.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 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", + "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", + "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/vite": { + "version": "7.2.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", + "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/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/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/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" + } + }, + "node_modules/zod": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", + "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/synkronus-portal/package.json b/synkronus-portal/package.json new file mode 100644 index 000000000..d6f890756 --- /dev/null +++ b/synkronus-portal/package.json @@ -0,0 +1,31 @@ +{ + "name": "synkronus-portal", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "babel-plugin-react-compiler": "^1.0.0", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4" + } +} diff --git a/synkronus-portal/src/App.css b/synkronus-portal/src/App.css new file mode 100644 index 000000000..513781d14 --- /dev/null +++ b/synkronus-portal/src/App.css @@ -0,0 +1 @@ +/* App-specific styles */ diff --git a/synkronus-portal/src/App.tsx b/synkronus-portal/src/App.tsx new file mode 100644 index 000000000..21ff768d8 --- /dev/null +++ b/synkronus-portal/src/App.tsx @@ -0,0 +1,16 @@ +import { AuthProvider } from './contexts/AuthContext' +import { ProtectedRoute } from './components/ProtectedRoute' +import { Dashboard } from './pages/Dashboard' +import './App.css' + +function App() { + return ( + + + + + + ) +} + +export default App diff --git a/synkronus-portal/src/assets/ode_logo.png b/synkronus-portal/src/assets/ode_logo.png new file mode 100644 index 000000000..eb63645c1 Binary files /dev/null and b/synkronus-portal/src/assets/ode_logo.png differ diff --git a/synkronus-portal/src/components/Login.css b/synkronus-portal/src/components/Login.css new file mode 100644 index 000000000..5099dc434 --- /dev/null +++ b/synkronus-portal/src/components/Login.css @@ -0,0 +1,183 @@ +.login-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + background: linear-gradient(135deg, var(--color-neutral-900) 0%, var(--color-neutral-800) 50%, var(--color-neutral-900) 100%); + position: relative; + overflow: hidden; + padding: 20px; +} + +.login-container::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + radial-gradient(circle at 20% 50%, rgba(79, 127, 78, var(--opacity-15)) 0%, transparent 50%), + radial-gradient(circle at 80% 80%, rgba(233, 184, 91, var(--opacity-15)) 0%, transparent 50%); + pointer-events: none; +} + +.login-card { + background: rgba(33, 33, 33, var(--opacity-80)); + backdrop-filter: blur(30px); + -webkit-backdrop-filter: blur(30px); + border-radius: var(--border-radius-xl); + padding: 48px; + width: 100%; + max-width: 440px; + box-shadow: 0 20px 60px rgba(0, 0, 0, var(--opacity-30)); + border: var(--border-width-thin) solid rgba(79, 127, 78, var(--opacity-20)); + position: relative; + z-index: 1; + animation: slideUp var(--duration-slower) var(--easing-ease-out); +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.login-logo-section { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + margin-bottom: 8px; +} + +.login-logo { + width: 80px; + height: 80px; + object-fit: contain; + animation: float 3s ease-in-out infinite; + filter: drop-shadow(0 4px 12px rgba(79, 127, 78, var(--opacity-30))); +} + +@keyframes float { + 0%, 100% { + transform: translateY(0px); + } + 50% { + transform: translateY(-10px); + } +} + +.login-card h1 { + margin: 0; + color: var(--color-neutral-white); + font-size: 32px; + text-align: center; + font-weight: 700; + background: linear-gradient(135deg, var(--color-brand-primary-500) 0%, var(--color-brand-secondary-500) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: -0.5px; +} + +.login-card h2 { + margin: 0 0 40px 0; + color: rgba(255, 255, 255, var(--opacity-60)); + font-size: 18px; + font-weight: 400; + text-align: center; +} + +.form-group { + margin-bottom: 24px; +} + +.form-group label { + display: block; + margin-bottom: 10px; + color: rgba(255, 255, 255, var(--opacity-80)); + font-weight: 600; + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.form-group input { + width: 100%; + padding: 14px 18px; + background: rgba(255, 255, 255, var(--opacity-5)); + border: var(--border-width-thin) solid rgba(255, 255, 255, var(--opacity-10)); + border-radius: var(--border-radius-lg); + font-size: 16px; + color: var(--color-neutral-white); + transition: all var(--duration-normal) var(--easing-ease-in-out); + backdrop-filter: blur(10px); + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; +} + +.form-group input::placeholder { + color: rgba(255, 255, 255, var(--opacity-40)); +} + +.form-group input:focus { + outline: none; + border-color: var(--color-brand-primary-500); + background: rgba(255, 255, 255, var(--opacity-8)); + box-shadow: 0 0 0 3px rgba(79, 127, 78, var(--opacity-10)); +} + +.form-group input:disabled { + opacity: var(--opacity-50); + cursor: not-allowed; +} + +.error-message { + background: rgba(244, 67, 54, var(--opacity-15)); + border: var(--border-width-thin) solid rgba(244, 67, 54, var(--opacity-30)); + color: var(--color-semantic-error-50); + padding: 14px 18px; + border-radius: var(--border-radius-lg); + margin-bottom: 24px; + font-weight: 500; + backdrop-filter: blur(10px); + animation: slideDown var(--duration-normal) var(--easing-ease-out); +} + +.login-button { + width: 100%; + padding: 16px; + background: linear-gradient(135deg, var(--color-brand-primary-500) 0%, var(--color-brand-secondary-500) 100%); + color: var(--color-neutral-white); + border: none; + border-radius: var(--border-radius-lg); + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all var(--duration-normal) var(--easing-ease-in-out); + box-shadow: 0 4px 16px rgba(79, 127, 78, var(--opacity-30)); + text-transform: uppercase; + letter-spacing: 1px; +} + +.login-button:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(79, 127, 78, var(--opacity-40)); +} + +.login-button:active:not(:disabled) { + transform: translateY(0); +} + +.login-button:disabled { + opacity: var(--opacity-60); + cursor: not-allowed; + transform: none; +} diff --git a/synkronus-portal/src/components/Login.tsx b/synkronus-portal/src/components/Login.tsx new file mode 100644 index 000000000..0e2185943 --- /dev/null +++ b/synkronus-portal/src/components/Login.tsx @@ -0,0 +1,74 @@ +import { useState } from 'react' +import type { FormEvent } from 'react' +import { useAuth } from '../contexts/AuthContext' +import odeLogo from '../assets/ode_logo.png' +import './Login.css' + +export function Login() { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + const { login } = useAuth() + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + setError(null) + setLoading(true) + + try { + await login({ username, password }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Login failed') + } finally { + setLoading(false) + } + } + + return ( +
+
+
+ ODE Logo +

Synkronus Portal

+
+

Sign In

+ + {error &&
{error}
} + +
+
+ + setUsername(e.target.value)} + required + autoComplete="username" + disabled={loading} + /> +
+ +
+ + setPassword(e.target.value)} + required + autoComplete="current-password" + disabled={loading} + /> +
+ + +
+
+
+ ) +} + diff --git a/synkronus-portal/src/components/ProtectedRoute.tsx b/synkronus-portal/src/components/ProtectedRoute.tsx new file mode 100644 index 000000000..f3b9ebfcc --- /dev/null +++ b/synkronus-portal/src/components/ProtectedRoute.tsx @@ -0,0 +1,36 @@ +import { useEffect } from 'react' +import type { ReactNode } from 'react' +import { useAuth } from '../contexts/AuthContext' +import { Login } from './Login' + +interface ProtectedRouteProps { + children: ReactNode +} + +export function ProtectedRoute({ children }: ProtectedRouteProps) { + const { isAuthenticated, refreshAuth } = useAuth() + + useEffect(() => { + // Check if token is expired and refresh if needed + const expiresAt = localStorage.getItem('expiresAt') + if (expiresAt && isAuthenticated) { + const expirationTime = parseInt(expiresAt, 10) * 1000 + const now = Date.now() + const timeUntilExpiry = expirationTime - now + + // Refresh token if it expires in less than 5 minutes + if (timeUntilExpiry > 0 && timeUntilExpiry < 5 * 60 * 1000) { + refreshAuth().catch(() => { + // If refresh fails, logout will be handled in AuthContext + }) + } + } + }, [isAuthenticated, refreshAuth]) + + if (!isAuthenticated) { + return + } + + return <>{children} +} + diff --git a/synkronus-portal/src/contexts/AuthContext.tsx b/synkronus-portal/src/contexts/AuthContext.tsx new file mode 100644 index 000000000..60e303cc2 --- /dev/null +++ b/synkronus-portal/src/contexts/AuthContext.tsx @@ -0,0 +1,152 @@ +import { createContext, useContext, useState, useEffect } from 'react' +import type { ReactNode } from 'react' +import { api } from '../services/api' +import type { LoginRequest, User, AuthState } from '../types/auth' + +interface AuthContextType extends AuthState { + login: (credentials: LoginRequest) => Promise + logout: () => void + refreshAuth: () => Promise +} + +const AuthContext = createContext(undefined) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [authState, setAuthState] = useState({ + user: null, + token: null, + refreshToken: null, + isAuthenticated: false, + }) + + // Load auth state from localStorage on mount + useEffect(() => { + const token = localStorage.getItem('token') + const refreshToken = localStorage.getItem('refreshToken') + const userStr = localStorage.getItem('user') + + if (token && refreshToken && userStr) { + try { + const user = JSON.parse(userStr) + setAuthState({ + user, + token, + refreshToken, + isAuthenticated: true, + }) + } catch (error) { + // Invalid stored data, clear it + localStorage.removeItem('token') + localStorage.removeItem('refreshToken') + localStorage.removeItem('user') + } + } + }, []) + + const login = async (credentials: LoginRequest) => { + try { + const response = await api.login(credentials) + + // Decode JWT to get user info (simple base64 decode of payload) + const tokenParts = response.token.split('.') + if (tokenParts.length !== 3) { + throw new Error('Invalid token format') + } + + const payload = JSON.parse(atob(tokenParts[1])) + const user: User = { + username: payload.username || credentials.username, + role: payload.role || 'user', + } + + // Store tokens and user info + localStorage.setItem('token', response.token) + localStorage.setItem('refreshToken', response.refreshToken) + localStorage.setItem('user', JSON.stringify(user)) + localStorage.setItem('expiresAt', response.expiresAt.toString()) + + setAuthState({ + user, + token: response.token, + refreshToken: response.refreshToken, + isAuthenticated: true, + }) + } catch (error) { + throw error + } + } + + const logout = () => { + localStorage.removeItem('token') + localStorage.removeItem('refreshToken') + localStorage.removeItem('user') + localStorage.removeItem('expiresAt') + setAuthState({ + user: null, + token: null, + refreshToken: null, + isAuthenticated: false, + }) + } + + const refreshAuth = async () => { + const refreshToken = localStorage.getItem('refreshToken') + if (!refreshToken) { + logout() + return + } + + try { + const response = await api.refreshToken(refreshToken) + + const tokenParts = response.token.split('.') + if (tokenParts.length !== 3) { + throw new Error('Invalid token format') + } + + const payload = JSON.parse(atob(tokenParts[1])) + const user: User = { + username: payload.username || authState.user?.username || '', + role: payload.role || authState.user?.role || 'user', + } + + localStorage.setItem('token', response.token) + localStorage.setItem('refreshToken', response.refreshToken) + localStorage.setItem('user', JSON.stringify(user)) + localStorage.setItem('expiresAt', response.expiresAt.toString()) + + setAuthState({ + user, + token: response.token, + refreshToken: response.refreshToken, + isAuthenticated: true, + }) + } catch (error) { + // Refresh failed, logout + logout() + throw error + } + } + + return ( + + {children} + + ) +} + +export function useAuth() { + const context = useContext(AuthContext) + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider') + } + return context +} + diff --git a/synkronus-portal/src/index.css b/synkronus-portal/src/index.css new file mode 100644 index 000000000..e75d8d589 --- /dev/null +++ b/synkronus-portal/src/index.css @@ -0,0 +1,125 @@ +/* ODE Design Tokens - Inlined for synkronus-portal */ +:root { + /* Brand Colors - Green (Primary) and Gold (Secondary) */ + --color-brand-primary-50: #f0f7ef; + --color-brand-primary-100: #d9e9d8; + --color-brand-primary-200: #b9d5b8; + --color-brand-primary-300: #90bd8f; + --color-brand-primary-400: #6fa46e; + --color-brand-primary-500: #4f7f4e; + --color-brand-primary-600: #3f6a3e; + --color-brand-primary-700: #30552f; + --color-brand-primary-800: #224021; + --color-brand-primary-900: #173016; + --color-brand-secondary-50: #fef9ee; + --color-brand-secondary-100: #fcefd2; + --color-brand-secondary-200: #f9e0a8; + --color-brand-secondary-300: #f5cc75; + --color-brand-secondary-400: #f0b84d; + --color-brand-secondary-500: #e9b85b; + --color-brand-secondary-600: #d9a230; + --color-brand-secondary-700: #b8861c; + --color-brand-secondary-800: #976d1a; + --color-brand-secondary-900: #7c5818; + + /* Neutral Colors */ + --color-neutral-50: #fafafa; + --color-neutral-100: #f5f5f5; + --color-neutral-200: #eeeeee; + --color-neutral-300: #e0e0e0; + --color-neutral-400: #bdbdbd; + --color-neutral-500: #9e9e9e; + --color-neutral-600: #757575; + --color-neutral-700: #616161; + --color-neutral-800: #424242; + --color-neutral-900: #212121; + --color-neutral-white: #ffffff; + --color-neutral-black: #000000; + + /* Semantic Colors */ + --color-semantic-success-50: #f0f9f0; + --color-semantic-success-500: #34c759; + --color-semantic-success-600: #2e7d32; + --color-semantic-error-50: #fef2f2; + --color-semantic-error-500: #f44336; + --color-semantic-error-600: #dc2626; + --color-semantic-warning-50: #fffbeb; + --color-semantic-warning-500: #ff9500; + --color-semantic-warning-600: #d97706; + --color-semantic-info-50: #eff6ff; + --color-semantic-info-500: #2196f3; + --color-semantic-info-600: #2563eb; + + /* Spacing */ + --spacing-0: 0px; + --spacing-1: 4px; + --spacing-2: 8px; + --spacing-3: 12px; + --spacing-4: 16px; + --spacing-5: 20px; + --spacing-6: 24px; + --spacing-8: 32px; + --spacing-10: 40px; + --spacing-12: 48px; + --spacing-16: 64px; + --spacing-20: 80px; + --spacing-24: 96px; + + /* Border Radius */ + --border-radius-none: 0px; + --border-radius-sm: 4px; + --border-radius-md: 8px; + --border-radius-lg: 12px; + --border-radius-xl: 16px; + --border-radius-full: 9999px; + + /* Border Width */ + --border-width-none: 0px; + --border-width-hairline: 0.5px; + --border-width-thin: 1px; + --border-width-medium: 2px; + --border-width-thick: 3px; + + /* Opacity */ + --opacity-0: 0; + --opacity-10: 0.1; + --opacity-20: 0.2; + --opacity-30: 0.3; + --opacity-40: 0.4; + --opacity-50: 0.5; + --opacity-60: 0.6; + --opacity-70: 0.7; + --opacity-80: 0.8; + --opacity-90: 0.9; + --opacity-100: 1; + + /* Duration */ + --duration-instant: 0ms; + --duration-fast: 150ms; + --duration-normal: 250ms; + --duration-slow: 350ms; + --duration-slower: 500ms; + + /* Easing */ + --easing-linear: linear; + --easing-ease-in: cubic-bezier(0.4, 0, 1, 1); + --easing-ease-out: cubic-bezier(0, 0, 0.2, 1); + --easing-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: system-ui, -apple-system, sans-serif; + line-height: 1.5; + color: var(--color-neutral-900); + background-color: var(--color-neutral-white); +} + +#root { + min-height: 100vh; +} diff --git a/synkronus-portal/src/main.tsx b/synkronus-portal/src/main.tsx new file mode 100644 index 000000000..bef5202a3 --- /dev/null +++ b/synkronus-portal/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/synkronus-portal/src/pages/Dashboard.css b/synkronus-portal/src/pages/Dashboard.css new file mode 100644 index 000000000..ebcd5f813 --- /dev/null +++ b/synkronus-portal/src/pages/Dashboard.css @@ -0,0 +1,1882 @@ +/* ODE Design System - Using Design Tokens */ +/* Import tokens is handled in index.css */ + +:root { + /* Use design tokens - shadows can use opacity tokens */ + --shadow-sm: 0 2px 8px rgba(0, 0, 0, var(--opacity-20)); + --shadow-md: 0 4px 16px rgba(0, 0, 0, var(--opacity-30)); + --shadow-lg: 0 8px 32px rgba(0, 0, 0, var(--opacity-40)); +} + +* { + box-sizing: border-box; +} + +.dashboard { + min-height: 100vh; + background: linear-gradient(135deg, var(--color-neutral-900) 0%, var(--color-neutral-800) 50%, var(--color-neutral-900) 100%); + position: relative; + overflow-x: hidden; +} + +.dashboard::before { + content: ''; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + radial-gradient(circle at 20% 50%, rgba(79, 127, 78, var(--opacity-20)) 0%, transparent 50%), + radial-gradient(circle at 80% 80%, rgba(233, 184, 91, var(--opacity-20)) 0%, transparent 50%); + pointer-events: none; + z-index: 0; +} + +/* Header */ +.dashboard-header { + position: sticky; + top: 0; + z-index: 100; + background: rgba(33, 33, 33, var(--opacity-80)); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-bottom: var(--border-width-thin) solid rgba(79, 127, 78, var(--opacity-20)); + box-shadow: var(--shadow-md); + padding: 0; +} + +.header-content { + max-width: 1400px; + margin: 0 auto; + padding: 20px 40px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.logo-section { + display: flex; + align-items: center; + gap: 16px; +} + +.logo-icon { + width: 40px; + height: 40px; + object-fit: contain; + animation: float 3s ease-in-out infinite; +} + +@keyframes float { + 0%, 100% { transform: translateY(0px); } + 50% { transform: translateY(-10px); } +} + +.dashboard-header h1 { + margin: 0; + font-size: 28px; + font-weight: 700; + background: linear-gradient(135deg, var(--color-brand-primary-500) 0%, var(--color-brand-secondary-500) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: -0.5px; +} + +.user-info { + display: flex; + align-items: center; + gap: 20px; +} + +.user-details { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} + +.welcome-text { + font-size: 12px; + color: var(--color-neutral-500); + text-transform: uppercase; + letter-spacing: 1px; +} + +.username { + font-size: 16px; + font-weight: 600; + color: var(--color-neutral-white); +} + +.role-badge { + padding: 8px 16px; + border-radius: var(--border-radius-full); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + box-shadow: var(--shadow-sm); + transition: all var(--duration-normal) var(--easing-ease-in-out); +} + +.role-badge.role-admin { + background: linear-gradient(135deg, var(--color-semantic-error-500) 0%, var(--color-semantic-error-600) 100%); + color: var(--color-neutral-white); +} + +.role-badge.role-read-write { + background: linear-gradient(135deg, var(--color-semantic-success-500) 0%, var(--color-semantic-success-600) 100%); + color: var(--color-neutral-white); +} + +.role-badge.role-read-only { + background: linear-gradient(135deg, var(--color-semantic-info-500) 0%, var(--color-semantic-info-600) 100%); + color: var(--color-neutral-white); +} + +.logout-button { + padding: 10px 24px; + background: rgba(244, 67, 54, var(--opacity-10)); + border: var(--border-width-thin) solid rgba(244, 67, 54, var(--opacity-30)); + border-radius: var(--border-radius-lg); + color: var(--color-semantic-error-500); + cursor: pointer; + font-size: 14px; + font-weight: 600; + transition: all var(--duration-normal) var(--easing-ease-in-out); + backdrop-filter: blur(10px); +} + +.logout-button:hover { + background: rgba(244, 67, 54, var(--opacity-20)); + border-color: var(--color-semantic-error-500); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(244, 67, 54, var(--opacity-30)); +} + +/* Content */ +.dashboard-content { + max-width: 1400px; + margin: 0 auto; + padding: 40px; + position: relative; + z-index: 1; +} + +/* Tabs */ +.dashboard-tabs { + display: flex; + gap: 12px; + margin-bottom: 32px; + background: rgba(33, 33, 33, var(--opacity-60)); + backdrop-filter: blur(20px); + padding: 8px; + border-radius: var(--border-radius-xl); + border: var(--border-width-thin) solid rgba(79, 127, 78, var(--opacity-10)); + box-shadow: var(--shadow-md); +} + +.dashboard-tabs button { + flex: 1; + padding: 16px 24px; + background: transparent; + border: none; + border-radius: var(--border-radius-lg); + cursor: pointer; + font-size: 14px; + font-weight: 600; + color: rgba(255, 255, 255, var(--opacity-60)); + transition: all var(--duration-normal) var(--easing-ease-in-out); + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + position: relative; + overflow: hidden; +} + +.dashboard-tabs button::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, var(--opacity-10)), transparent); + transition: left var(--duration-slower) var(--easing-ease-in-out); +} + +.dashboard-tabs button:hover::before { + left: 100%; +} + +.dashboard-tabs button:hover { + color: var(--color-neutral-white); + background: rgba(79, 127, 78, var(--opacity-10)); + transform: translateY(-2px); +} + +.dashboard-tabs button.active { + color: var(--color-neutral-white); + background: linear-gradient(135deg, var(--color-brand-primary-500) 0%, var(--color-brand-secondary-500) 100%); + box-shadow: 0 0 20px rgba(79, 127, 78, var(--opacity-30)); + transform: translateY(-2px); +} + +.tab-icon { + font-size: 18px; +} + +/* Alerts */ +.alert-banner { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 20px; + border-radius: var(--border-radius-lg); + margin-bottom: 24px; + backdrop-filter: blur(20px); + animation: slideDown var(--duration-normal) var(--easing-ease-out); + box-shadow: var(--shadow-md); +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.alert-banner.error { + background: rgba(244, 67, 54, var(--opacity-15)); + border: var(--border-width-thin) solid rgba(244, 67, 54, var(--opacity-30)); + color: var(--color-semantic-error-50); +} + +.alert-banner.success { + background: rgba(52, 199, 89, var(--opacity-15)); + border: var(--border-width-thin) solid rgba(52, 199, 89, var(--opacity-30)); + color: var(--color-semantic-success-50); +} + +.alert-icon { + font-size: 20px; +} + +.alert-close { + margin-left: auto; + background: none; + border: none; + color: inherit; + font-size: 24px; + cursor: pointer; + opacity: var(--opacity-70); + transition: opacity var(--duration-fast) var(--easing-ease-in-out); + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +.alert-close:hover { + opacity: var(--opacity-100); +} + +/* Tab Content */ +.tab-content { + background: rgba(33, 33, 33, var(--opacity-60)); + backdrop-filter: blur(20px); + padding: 40px; + border-radius: var(--border-radius-xl); + border: var(--border-width-thin) solid rgba(79, 127, 78, var(--opacity-20)); + box-shadow: var(--shadow-lg); + min-height: 500px; +} + +.section-title { + margin-bottom: 32px; +} + +.section-title h2 { + margin: 0 0 8px 0; + font-size: 32px; + font-weight: 700; + color: var(--color-neutral-white); + letter-spacing: -0.5px; +} + +.section-subtitle { + margin: 0; + color: rgba(255, 255, 255, var(--opacity-60)); + font-size: 15px; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 32px; + flex-wrap: wrap; + gap: 20px; +} + +.section-actions { + display: flex; + gap: 12px; + align-items: center; +} + +/* Buttons */ +.refresh-button, +.upload-button, +.export-button { + padding: 12px 24px; + border: none; + border-radius: var(--border-radius-lg); + cursor: pointer; + font-size: 14px; + font-weight: 600; + transition: all var(--duration-normal) var(--easing-ease-in-out); + display: flex; + align-items: center; + gap: 8px; + box-shadow: var(--shadow-sm); + backdrop-filter: blur(10px); +} + +.refresh-button { + background: rgba(79, 127, 78, var(--opacity-20)); + border: var(--border-width-thin) solid rgba(79, 127, 78, var(--opacity-30)); + color: var(--color-brand-primary-300); +} + +.refresh-button:hover:not(:disabled) { + background: rgba(79, 127, 78, var(--opacity-30)); + border-color: var(--color-brand-primary-500); + transform: translateY(-2px); + box-shadow: 0 0 20px rgba(79, 127, 78, var(--opacity-30)); +} + +.upload-button { + background: linear-gradient(135deg, var(--color-semantic-success-500) 0%, var(--color-semantic-success-600) 100%); + border: none; + color: var(--color-neutral-white); + cursor: pointer; +} + +.upload-button:hover:not(:disabled):not(.uploading) { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(52, 199, 89, var(--opacity-40)); +} + +.upload-button:disabled, +.upload-button.uploading { + opacity: var(--opacity-70); + cursor: not-allowed; + transform: none; +} + +.export-button { + background: linear-gradient(135deg, var(--color-brand-primary-500) 0%, var(--color-brand-primary-600) 100%); + border: none; + color: var(--color-neutral-white); + padding: 16px 32px; + font-size: 16px; +} + +.export-button:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(79, 127, 78, var(--opacity-40)); +} + +.refresh-button:disabled, +.export-button:disabled { + opacity: var(--opacity-50); + cursor: not-allowed; + transform: none; +} + +.button-spinner { + width: 16px; + height: 16px; + border: var(--border-width-medium) solid rgba(255, 255, 255, var(--opacity-30)); + border-top-color: var(--color-neutral-white); + border-radius: var(--border-radius-full); + animation: spin var(--duration-slower) linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Upload Progress */ +.upload-progress { + margin-bottom: 24px; + padding: 20px; + background: rgba(79, 127, 78, var(--opacity-10)); + border-radius: var(--border-radius-lg); + border: var(--border-width-thin) solid rgba(79, 127, 78, var(--opacity-20)); +} + +.progress-bar { + width: 100%; + height: 8px; + background: rgba(255, 255, 255, var(--opacity-10)); + border-radius: var(--border-radius-sm); + overflow: hidden; + margin-bottom: 8px; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--color-brand-primary-500) 0%, var(--color-brand-secondary-500) 100%); + border-radius: var(--border-radius-sm); + transition: width var(--duration-normal) var(--easing-ease-in-out); + box-shadow: 0 0 10px rgba(79, 127, 78, var(--opacity-50)); +} + +.progress-text { + color: var(--color-brand-primary-300); + font-size: 14px; + font-weight: 600; +} + +/* Stats Grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 24px; + margin-bottom: 32px; +} + +.stat-card { + background: rgba(255, 255, 255, var(--opacity-5)); + backdrop-filter: blur(20px); + padding: 28px; + border-radius: var(--border-radius-xl); + border: var(--border-width-thin) solid rgba(255, 255, 255, var(--opacity-10)); + box-shadow: var(--shadow-md); + transition: all var(--duration-normal) var(--easing-ease-in-out); + display: flex; + align-items: center; + gap: 20px; + position: relative; + overflow: hidden; +} + +.stat-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient(90deg, var(--color-brand-primary-500), var(--color-brand-secondary-500)); +} + +.stat-card.primary::before { + background: linear-gradient(90deg, var(--color-brand-primary-500), var(--color-brand-secondary-500)); +} + +.stat-card.success::before { + background: linear-gradient(90deg, var(--color-semantic-success-500), var(--color-semantic-success-600)); +} + +.stat-card.info::before { + background: linear-gradient(90deg, var(--color-brand-primary-400), var(--color-brand-primary-600)); +} + +.stat-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-lg); + border-color: rgba(79, 127, 78, var(--opacity-40)); +} + +.stat-icon { + width: 56px; + height: 56px; + border-radius: var(--border-radius-xl); + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + background: rgba(79, 127, 78, var(--opacity-20)); + flex-shrink: 0; +} + +.stat-card.success .stat-icon { + background: rgba(52, 199, 89, var(--opacity-20)); +} + +.stat-card.info .stat-icon { + background: rgba(79, 127, 78, var(--opacity-20)); +} + +.stat-content h3 { + margin: 0 0 8px 0; + font-size: 13px; + color: rgba(255, 255, 255, var(--opacity-60)); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; +} + +.stat-value { + margin: 0; + font-size: 28px; + font-weight: 700; + color: var(--color-neutral-white); +} + +/* Welcome Card */ +.welcome-card { + background: linear-gradient(135deg, rgba(79, 127, 78, var(--opacity-10)) 0%, rgba(233, 184, 91, var(--opacity-10)) 100%); + backdrop-filter: blur(20px); + padding: 32px; + border-radius: var(--border-radius-xl); + border: var(--border-width-thin) solid rgba(79, 127, 78, var(--opacity-30)); + display: flex; + align-items: center; + gap: 24px; + box-shadow: var(--shadow-md); +} + +.welcome-icon { + font-size: 48px; + animation: float 3s ease-in-out infinite; +} + +.welcome-card h3 { + margin: 0 0 8px 0; + font-size: 20px; + color: var(--color-neutral-white); + font-weight: 600; +} + +.welcome-card p { + margin: 0; + color: rgba(255, 255, 255, var(--opacity-70)); + line-height: 1.6; +} + +/* Bundles Grid */ +.bundles-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 24px; +} + +.bundle-card { + background: rgba(255, 255, 255, var(--opacity-5)); + backdrop-filter: blur(20px); + padding: 24px; + border-radius: var(--border-radius-xl); + border: var(--border-width-thin) solid rgba(255, 255, 255, var(--opacity-10)); + box-shadow: var(--shadow-sm); + transition: all var(--duration-normal) var(--easing-ease-in-out); +} + +.bundle-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-md); + border-color: rgba(79, 127, 78, var(--opacity-30)); +} + +.bundle-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 12px; +} + +.bundle-icon { + font-size: 32px; +} + +.bundle-info h3 { + margin: 0 0 4px 0; + font-size: 18px; + color: var(--color-neutral-white); + font-weight: 600; +} + +.bundle-status { + font-size: 12px; + font-weight: 600; + padding: 4px 12px; + border-radius: var(--border-radius-full); + display: inline-block; +} + +.bundle-status.active { + background: rgba(52, 199, 89, var(--opacity-20)); + color: var(--color-semantic-success-50); +} + +.bundle-status.inactive { + background: rgba(255, 149, 0, var(--opacity-20)); + color: var(--color-semantic-warning-50); +} + +.bundle-meta { + color: rgba(255, 255, 255, var(--opacity-50)); + font-size: 13px; +} + +/* Users Search */ +.users-search-container { + margin-bottom: 24px; +} + +.search-input-wrapper { + position: relative; + display: flex; + align-items: center; + max-width: 500px; +} + +.search-icon { + position: absolute; + left: 16px; + font-size: 18px; + z-index: 1; + pointer-events: none; +} + +.search-input { + width: 100%; + padding: 12px 16px 12px 48px; + background: rgba(255, 255, 255, var(--opacity-5)); + border: var(--border-width-thin) solid rgba(255, 255, 255, var(--opacity-20)); + border-radius: var(--border-radius-lg); + color: var(--color-neutral-white); + font-size: 14px; + transition: all var(--duration-normal) var(--easing-ease-in-out); +} + +.search-input:focus { + outline: none; + border-color: var(--color-brand-primary-500); + background: rgba(255, 255, 255, var(--opacity-10)); + box-shadow: 0 0 0 3px rgba(79, 127, 78, var(--opacity-20)); +} + +.search-input::placeholder { + color: rgba(255, 255, 255, var(--opacity-50)); +} + +.search-clear { + position: absolute; + right: 12px; + background: none; + border: none; + color: rgba(255, 255, 255, var(--opacity-70)); + font-size: 24px; + cursor: pointer; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--border-radius-full); + transition: all var(--duration-normal) var(--easing-ease-in-out); + line-height: 1; +} + +.search-clear:hover { + color: var(--color-neutral-white); + background: rgba(255, 255, 255, var(--opacity-10)); +} + +/* Users Table */ +.users-table-container { + background: rgba(255, 255, 255, var(--opacity-5)); + backdrop-filter: blur(20px); + border-radius: var(--border-radius-xl); + border: var(--border-width-thin) solid rgba(255, 255, 255, var(--opacity-10)); + box-shadow: var(--shadow-md); + overflow: hidden; + overflow-x: auto; +} + +.users-table { + width: 100%; + border-collapse: collapse; + min-width: 800px; +} + +.users-table thead { + background: rgba(79, 127, 78, var(--opacity-10)); + border-bottom: var(--border-width-thin) solid rgba(255, 255, 255, var(--opacity-10)); +} + +.users-table th { + padding: 16px 20px; + text-align: left; + font-weight: 600; + font-size: 14px; + color: var(--color-neutral-white); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.users-table th.actions-column { + text-align: right; +} + +.users-table tbody tr { + border-bottom: var(--border-width-thin) solid rgba(255, 255, 255, var(--opacity-5)); + transition: all var(--duration-normal) var(--easing-ease-in-out); +} + +.users-table tbody tr:hover { + background: rgba(79, 127, 78, var(--opacity-5)); +} + +.users-table tbody tr:last-child { + border-bottom: none; +} + +.users-table td { + padding: 16px 20px; + color: rgba(255, 255, 255, var(--opacity-80)); + font-size: 14px; +} + +.user-cell { + display: flex; + align-items: center; + gap: 12px; +} + +.user-avatar-small { + width: 40px; + height: 40px; + border-radius: var(--border-radius-full); + background: linear-gradient(135deg, var(--color-brand-primary-500) 0%, var(--color-brand-secondary-500) 100%); + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + font-weight: 700; + color: var(--color-neutral-white); + flex-shrink: 0; +} + +.user-name { + font-weight: 600; + color: var(--color-neutral-white); +} + +.created-date { + color: rgba(255, 255, 255, var(--opacity-70)); + font-size: 13px; +} + +.table-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + align-items: center; +} + +.table-action-btn { + background: rgba(255, 255, 255, var(--opacity-10)); + border: var(--border-width-thin) solid rgba(255, 255, 255, var(--opacity-20)); + color: var(--color-neutral-white); + padding: 8px 16px; + border-radius: var(--border-radius-md); + cursor: pointer; + transition: all var(--duration-normal) var(--easing-ease-in-out); + font-size: 13px; + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +.table-action-btn:hover { + background: rgba(255, 255, 255, var(--opacity-20)); + transform: translateY(-2px); + box-shadow: var(--shadow-sm); +} + +.table-action-btn.reset-password-btn:hover { + background: rgba(79, 127, 78, var(--opacity-20)); + border-color: rgba(79, 127, 78, var(--opacity-50)); +} + +.table-action-btn.delete-btn:hover { + background: rgba(220, 53, 69, var(--opacity-20)); + border-color: rgba(220, 53, 69, var(--opacity-50)); +} + +.table-action-btn span:first-child { + font-size: 14px; +} + +/* Export Card */ +.export-card { + background: linear-gradient(135deg, rgba(79, 127, 78, var(--opacity-10)) 0%, rgba(233, 184, 91, var(--opacity-10)) 100%); + backdrop-filter: blur(20px); + padding: 48px; + border-radius: var(--border-radius-xl); + border: var(--border-width-thin) solid rgba(79, 127, 78, var(--opacity-30)); + box-shadow: var(--shadow-lg); + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; +} + +.export-icon { + font-size: 64px; + animation: float 3s ease-in-out infinite; +} + +.export-content h3 { + margin: 0 0 12px 0; + font-size: 24px; + color: var(--color-neutral-white); + font-weight: 600; +} + +.export-content p { + margin: 0 0 24px 0; + color: rgba(255, 255, 255, var(--opacity-70)); + font-size: 16px; + line-height: 1.6; + max-width: 500px; +} + +/* Info Cards */ +.info-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 24px; +} + +.info-card { + background: rgba(255, 255, 255, var(--opacity-5)); + backdrop-filter: blur(20px); + padding: 28px; + border-radius: var(--border-radius-xl); + border: var(--border-width-thin) solid rgba(255, 255, 255, var(--opacity-10)); + box-shadow: var(--shadow-md); + display: flex; + align-items: center; + gap: 20px; + transition: all var(--duration-normal) var(--easing-ease-in-out); +} + +.info-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-lg); + border-color: rgba(79, 127, 78, var(--opacity-30)); +} + +.info-icon { + font-size: 32px; + flex-shrink: 0; +} + +.info-content h3 { + margin: 0 0 8px 0; + font-size: 13px; + color: rgba(255, 255, 255, var(--opacity-60)); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; +} + +.info-content p { + margin: 0; + font-size: 18px; + color: var(--color-neutral-white); + font-weight: 600; +} + +.commit-hash { + font-family: 'Courier New', monospace; + font-size: 14px; + background: rgba(0, 0, 0, var(--opacity-20)); + padding: 6px 12px; + border-radius: var(--border-radius-md); + color: var(--color-brand-primary-300); +} + +/* Loading & Empty States */ +.loading-state, +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 80px 40px; + text-align: center; +} + +.spinner { + width: 48px; + height: 48px; + border: var(--border-width-medium) solid rgba(79, 127, 78, var(--opacity-20)); + border-top-color: var(--color-brand-primary-500); + border-radius: var(--border-radius-full); + animation: spin var(--duration-slower) linear infinite; + margin-bottom: 24px; +} + +.loading-state p, +.empty-state p { + color: rgba(255, 255, 255, var(--opacity-60)); + font-size: 16px; + margin: 0; +} + +.empty-icon { + font-size: 64px; + margin-bottom: 24px; + opacity: var(--opacity-50); +} + +.empty-state h3 { + margin: 0 0 8px 0; + font-size: 24px; + color: var(--color-neutral-white); + font-weight: 600; +} + +/* Responsive */ +@media (max-width: 1024px) { + .dashboard-content { + padding: 24px; + } + + .tab-content { + padding: 24px; + } + + .stats-grid, + .bundles-grid, + .users-grid, + .info-cards { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .header-content { + flex-direction: column; + gap: 16px; + align-items: flex-start; + padding: 16px 20px; + } + + .dashboard-tabs { + flex-wrap: wrap; + padding: 6px; + } + + .dashboard-tabs button { + flex: 1 1 calc(50% - 6px); + padding: 12px 16px; + font-size: 13px; + } + + .section-header { + flex-direction: column; + align-items: stretch; + } + + .section-actions { + width: 100%; + flex-direction: column; + } + + .section-actions button, + .section-actions label { + width: 100%; + justify-content: center; + } + + .users-table-container { + overflow-x: auto; + } + + .users-table { + min-width: 600px; + } + + .table-action-btn span:last-child { + display: none; + } + + .table-action-btn { + padding: 8px 12px; + min-width: 40px; + } + + .search-input-wrapper { + max-width: 100%; + } +} + +/* User Management Styles */ +.create-button { + background: linear-gradient(135deg, var(--color-brand-primary-500) 0%, var(--color-brand-secondary-500) 100%); + color: var(--color-neutral-white); + border: none; + padding: 12px 24px; + border-radius: var(--border-radius-lg); + font-weight: 600; + cursor: pointer; + transition: all var(--duration-normal) var(--easing-ease-in-out); + display: flex; + align-items: center; + gap: 8px; + box-shadow: var(--shadow-sm); +} + +.create-button:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.create-button:disabled { + opacity: var(--opacity-50); + cursor: not-allowed; +} + +.user-actions { + display: flex; + gap: 8px; + margin-left: auto; +} + +.action-button { + background: rgba(255, 255, 255, var(--opacity-10)); + border: var(--border-width-thin) solid rgba(255, 255, 255, var(--opacity-20)); + color: var(--color-neutral-white); + padding: 8px 12px; + border-radius: var(--border-radius-md); + cursor: pointer; + transition: all var(--duration-normal) var(--easing-ease-in-out); + font-size: 18px; + display: flex; + align-items: center; + justify-content: center; +} + +.action-button:hover { + background: rgba(255, 255, 255, var(--opacity-20)); + transform: translateY(-2px); +} + +.delete-btn:hover { + background: rgba(220, 53, 69, var(--opacity-20)); + border-color: rgba(220, 53, 69, var(--opacity-50)); +} + +.reset-password-btn:hover { + background: rgba(79, 127, 78, var(--opacity-20)); + border-color: rgba(79, 127, 78, var(--opacity-50)); +} + +.user-created { + font-size: 12px; + color: rgba(255, 255, 255, var(--opacity-50)); + margin-top: 4px; + display: block; +} + +.user-info-card { + background: rgba(255, 255, 255, var(--opacity-5)); + backdrop-filter: blur(20px); + padding: 48px; + border-radius: var(--border-radius-xl); + border: var(--border-width-thin) solid rgba(255, 255, 255, var(--opacity-10)); + box-shadow: var(--shadow-md); + display: flex; + align-items: center; + gap: 32px; + max-width: 600px; + margin: 0 auto; +} + +.user-avatar-large { + width: 120px; + height: 120px; + border-radius: var(--border-radius-full); + background: linear-gradient(135deg, var(--color-brand-primary-500) 0%, var(--color-brand-secondary-500) 100%); + display: flex; + align-items: center; + justify-content: center; + font-size: 48px; + font-weight: 700; + color: var(--color-neutral-white); + flex-shrink: 0; +} + +.user-info h3 { + margin: 0 0 12px 0; + font-size: 32px; + color: var(--color-neutral-white); + font-weight: 700; +} + +.change-password-button { + background: linear-gradient(135deg, var(--color-brand-primary-500) 0%, var(--color-brand-secondary-500) 100%); + color: var(--color-neutral-white); + border: none; + padding: 12px 24px; + border-radius: var(--border-radius-lg); + font-weight: 600; + cursor: pointer; + transition: all var(--duration-normal) var(--easing-ease-in-out); + display: flex; + align-items: center; + gap: 8px; + box-shadow: var(--shadow-sm); +} + +.change-password-button:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +/* Modal Styles */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, var(--opacity-70)); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 20px; + animation: fadeIn var(--duration-normal) var(--easing-ease-in-out); +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.modal-content { + background: linear-gradient(135deg, var(--color-neutral-800) 0%, var(--color-neutral-900) 100%); + border-radius: var(--border-radius-xl); + border: var(--border-width-thin) solid rgba(79, 127, 78, var(--opacity-30)); + box-shadow: var(--shadow-lg); + max-width: 500px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + animation: slideUp var(--duration-normal) var(--easing-ease-in-out); +} + +@keyframes slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 24px; + border-bottom: var(--border-width-thin) solid rgba(255, 255, 255, var(--opacity-10)); +} + +.modal-header h2 { + margin: 0; + font-size: 24px; + color: var(--color-neutral-white); + font-weight: 700; + background: linear-gradient(135deg, var(--color-brand-primary-500) 0%, var(--color-brand-secondary-500) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.modal-close { + background: none; + border: none; + color: var(--color-neutral-white); + font-size: 32px; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--border-radius-md); + transition: all var(--duration-normal) var(--easing-ease-in-out); + opacity: var(--opacity-70); +} + +.modal-close:hover { + opacity: 1; + background: rgba(255, 255, 255, var(--opacity-10)); +} + +.modal-body { + padding: 24px; +} + +.modal-body p { + margin: 0; + color: rgba(255, 255, 255, var(--opacity-80)); + line-height: 1.6; +} + +.modal-body strong { + color: var(--color-neutral-white); + font-weight: 600; +} + +.modal-form { + padding: 24px; +} + +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 8px; + color: var(--color-neutral-white); + font-weight: 600; + font-size: 14px; +} + +.form-group input, +.form-group select { + width: 100%; + padding: 12px 16px; + background: rgba(255, 255, 255, var(--opacity-5)); + border: var(--border-width-thin) solid rgba(255, 255, 255, var(--opacity-20)); + border-radius: var(--border-radius-md); + color: var(--color-neutral-white); + font-size: 16px; + transition: all var(--duration-normal) var(--easing-ease-in-out); +} + +.form-group input { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; +} + +.form-group select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background-color: rgba(255, 255, 255, var(--opacity-10)); + cursor: pointer; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23ffffff' d='M6 9L1 4h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 16px center; + background-size: 12px; + padding-right: 40px; +} + +.form-group select:hover { + background-color: rgba(255, 255, 255, var(--opacity-15)); + border-color: rgba(255, 255, 255, var(--opacity-30)); +} + +.form-group select option { + background: var(--color-neutral-800); + color: var(--color-neutral-white); + padding: 12px; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: var(--color-brand-primary-500); + background-color: rgba(255, 255, 255, var(--opacity-15)); + box-shadow: 0 0 0 3px rgba(79, 127, 78, var(--opacity-20)); +} + +.form-group input:disabled, +.form-group select:disabled { + opacity: var(--opacity-50); + cursor: not-allowed; +} + +.modal-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + padding-top: 24px; + border-top: var(--border-width-thin) solid rgba(255, 255, 255, var(--opacity-10)); + align-items: center; +} + +.modal-actions button { + padding: 12px 24px; + border-radius: var(--border-radius-md); + font-weight: 600; + cursor: pointer; + transition: all var(--duration-normal) var(--easing-ease-in-out); + border: none; + font-size: 14px; + min-width: 120px; + display: flex; + align-items: center; + justify-content: center; + white-space: nowrap; +} + +.modal-actions button[type="button"] { + background: rgba(255, 255, 255, var(--opacity-10)); + color: var(--color-neutral-white); + border: var(--border-width-thin) solid rgba(255, 255, 255, var(--opacity-20)); +} + +.modal-actions button[type="button"]:hover:not(:disabled) { + background: rgba(255, 255, 255, var(--opacity-20)); +} + +.modal-actions button[type="submit"] { + background: linear-gradient(135deg, var(--color-brand-primary-500) 0%, var(--color-brand-secondary-500) 100%); + color: var(--color-neutral-white); +} + +.modal-actions button[type="submit"]:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.modal-actions button:disabled { + opacity: var(--opacity-50); + cursor: not-allowed; +} + +.delete-confirm-btn { + background: linear-gradient(135deg, rgba(220, 53, 69, var(--opacity-80)) 0%, rgba(220, 53, 69, var(--opacity-100)) 100%) !important; + color: var(--color-neutral-white) !important; + min-width: 120px !important; +} + +.delete-confirm-btn:hover:not(:disabled) { + background: linear-gradient(135deg, rgba(220, 53, 69, var(--opacity-100)) 0%, rgba(220, 53, 69, var(--opacity-100)) 100%) !important; + box-shadow: 0 4px 16px rgba(220, 53, 69, var(--opacity-40)); + transform: translateY(-2px); +} + +/* Bundle Actions */ +.bundle-actions { + display: flex; + gap: 8px; + margin-top: 16px; + padding-top: 16px; + border-top: var(--border-width-thin) solid rgba(255, 255, 255, var(--opacity-10)); +} + +.bundle-action-btn { + flex: 1; + background: rgba(255, 255, 255, var(--opacity-10)); + border: var(--border-width-thin) solid rgba(255, 255, 255, var(--opacity-20)); + color: var(--color-neutral-white); + padding: 10px 16px; + border-radius: var(--border-radius-md); + cursor: pointer; + transition: all var(--duration-normal) var(--easing-ease-in-out); + font-size: 13px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.bundle-action-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, var(--opacity-20)); + transform: translateY(-2px); + box-shadow: var(--shadow-sm); +} + +.bundle-action-btn:disabled { + opacity: var(--opacity-50); + cursor: not-allowed; +} + +.bundle-action-btn.activate-btn:hover:not(:disabled) { + background: rgba(79, 127, 78, var(--opacity-20)); + border-color: rgba(79, 127, 78, var(--opacity-50)); +} + +.bundle-action-btn.changes-btn:hover:not(:disabled) { + background: rgba(233, 184, 91, var(--opacity-20)); + border-color: rgba(233, 184, 91, var(--opacity-50)); +} + +.manifest-section { + margin-top: 24px; + display: flex; + justify-content: center; +} + +.view-manifest-btn { + background: linear-gradient(135deg, var(--color-brand-primary-500) 0%, var(--color-brand-secondary-500) 100%); + color: var(--color-neutral-white); + border: none; + padding: 12px 24px; + border-radius: var(--border-radius-lg); + font-weight: 600; + cursor: pointer; + transition: all var(--duration-normal) var(--easing-ease-in-out); + display: flex; + align-items: center; + gap: 8px; + box-shadow: var(--shadow-sm); +} + +.view-manifest-btn:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.activate-confirm-btn { + background: linear-gradient(135deg, var(--color-brand-primary-500) 0%, var(--color-brand-secondary-500) 100%) !important; + color: var(--color-neutral-white) !important; +} + +.activate-confirm-btn:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +/* Modal Large */ +.modal-large { + max-width: 800px; + max-height: 90vh; +} + +.manifest-info { + margin-bottom: 24px; + padding-bottom: 24px; + border-bottom: var(--border-width-thin) solid rgba(255, 255, 255, var(--opacity-10)); +} + +.info-row { + display: flex; + gap: 12px; + margin-bottom: 12px; + color: rgba(255, 255, 255, var(--opacity-80)); +} + +.info-row strong { + color: var(--color-neutral-white); + min-width: 80px; +} + +.hash-value { + font-family: 'Courier New', monospace; + font-size: 12px; + word-break: break-all; +} + +.files-list h3 { + margin: 0 0 16px 0; + color: var(--color-neutral-white); + font-size: 18px; +} + +.files-table-container { + max-height: 400px; + overflow-y: auto; + border-radius: var(--border-radius-md); + border: var(--border-width-thin) solid rgba(255, 255, 255, var(--opacity-10)); + background: rgba(33, 33, 33, var(--opacity-95)); + backdrop-filter: blur(10px); +} + +.files-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + background: rgba(33, 33, 33, var(--opacity-95)); +} + +.files-table thead { + background: rgba(33, 33, 33, var(--opacity-100)); + position: sticky; + top: 0; + z-index: 1; + backdrop-filter: blur(10px); +} + +.files-table th { + padding: 12px; + text-align: left; + font-weight: 600; + color: var(--color-neutral-white); + font-size: 12px; + text-transform: uppercase; + background: rgba(33, 33, 33, var(--opacity-100)); + border-bottom: var(--border-width-thin) solid rgba(79, 127, 78, var(--opacity-30)); +} + +.files-table tbody { + background: rgba(33, 33, 33, var(--opacity-95)); +} + +.files-table tbody tr { + background: rgba(33, 33, 33, var(--opacity-95)); +} + +.files-table td { + padding: 10px 12px; + color: rgba(255, 255, 255, var(--opacity-80)); + border-top: var(--border-width-thin) solid rgba(255, 255, 255, var(--opacity-5)); + background: rgba(33, 33, 33, var(--opacity-95)); +} + +.files-table tbody tr:hover { + background: rgba(79, 127, 78, var(--opacity-20)); +} + +.files-table tbody tr:hover td { + background: rgba(79, 127, 78, var(--opacity-20)); +} + +.file-path { + font-family: 'Courier New', monospace; + font-size: 12px; +} + +.file-download-btn { + background: rgba(79, 127, 78, var(--opacity-20)); + border: var(--border-width-thin) solid rgba(79, 127, 78, var(--opacity-40)); + color: var(--color-neutral-white); + padding: 6px 12px; + border-radius: var(--border-radius-md); + cursor: pointer; + transition: all var(--duration-normal) var(--easing-ease-in-out); + font-size: 12px; + display: flex; + align-items: center; + gap: 4px; + white-space: nowrap; +} + +.file-download-btn:hover:not(:disabled) { + background: rgba(79, 127, 78, var(--opacity-30)); + border-color: rgba(79, 127, 78, var(--opacity-60)); + transform: translateY(-1px); +} + +.file-download-btn:disabled { + opacity: var(--opacity-50); + cursor: not-allowed; +} + +.files-table .actions-column { + width: 120px; + text-align: center; +} + +.changes-info { + margin-bottom: 24px; + padding-bottom: 24px; + border-bottom: var(--border-width-thin) solid rgba(255, 255, 255, var(--opacity-10)); +} + +.changes-section { + margin-bottom: 24px; +} + +.changes-section h3 { + margin: 0 0 12px 0; + font-size: 16px; + color: var(--color-neutral-white); +} + +.changes-added { + color: var(--color-semantic-success-500) !important; +} + +.changes-removed { + color: var(--color-semantic-error-500) !important; +} + +.changes-modified { + color: var(--color-semantic-warning-500) !important; +} + +.changes-list { + list-style: none; + padding: 0; + margin: 0; + background: rgba(255, 255, 255, var(--opacity-5)); + border-radius: var(--border-radius-md); + padding: 12px; + max-height: 300px; + overflow-y: auto; +} + +.changes-list li { + padding: 8px 0; + color: rgba(255, 255, 255, var(--opacity-80)); + font-family: 'Courier New', monospace; + font-size: 13px; + border-bottom: var(--border-width-thin) solid rgba(255, 255, 255, var(--opacity-5)); +} + +.changes-list li:last-child { + border-bottom: none; +} + +.no-changes { + text-align: center; + color: rgba(255, 255, 255, var(--opacity-60)); + font-style: italic; + padding: 24px; +} + +/* Observations Tab Styles */ +.observations-section { + padding: 32px; +} + +.observations-table-container { + margin-top: 24px; +} + +.search-bar { + position: relative; + margin-bottom: 24px; + display: flex; + align-items: center; + gap: 12px; +} + +.search-input { + flex: 1; + padding: 12px 16px; + background: rgba(255, 255, 255, var(--opacity-5)); + border: var(--border-width-thin) solid rgba(255, 255, 255, var(--opacity-10)); + border-radius: var(--border-radius-md); + color: var(--color-neutral-100); + font-size: 14px; + transition: all var(--duration-normal) var(--easing-ease-in-out); +} + +.search-input::placeholder { + color: var(--color-neutral-500); +} + +.search-input:focus { + outline: none; + border-color: rgba(79, 127, 78, var(--opacity-60)); + box-shadow: 0 0 0 3px rgba(79, 127, 78, var(--opacity-20)); + background: rgba(255, 255, 255, var(--opacity-8)); +} + +.clear-search-button { + padding: 8px 12px; + background: rgba(255, 255, 255, var(--opacity-10)); + border: var(--border-width-thin) solid rgba(255, 255, 255, var(--opacity-20)); + border-radius: var(--border-radius-md); + color: var(--color-neutral-300); + cursor: pointer; + font-size: 16px; + transition: all var(--duration-normal) var(--easing-ease-in-out); +} + +.clear-search-button:hover { + background: rgba(255, 255, 255, var(--opacity-20)); + color: var(--color-neutral-100); +} + +.observations-count { + margin-bottom: 16px; + color: var(--color-neutral-300); + font-size: 14px; +} + +.observations-table { + width: 100%; + border-collapse: collapse; + background: rgba(255, 255, 255, var(--opacity-5)); + border-radius: var(--border-radius-lg); + overflow: hidden; +} + +.observations-table thead { + background: rgba(79, 127, 78, var(--opacity-20)); +} + +.observations-table th { + padding: 16px; + text-align: left; + color: var(--color-neutral-100); + font-weight: 600; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: var(--border-width-thin) solid rgba(79, 127, 78, var(--opacity-30)); +} + +.observations-table th.actions-column { + text-align: right; +} + +.observations-table td { + padding: 16px; + border-bottom: 1px solid rgba(79, 127, 78, var(--opacity-10)); + color: var(--color-neutral-200); + font-size: 14px; + vertical-align: middle; +} + +.observations-table tr.deleted { + opacity: 0.6; +} + +.observations-table tr.deleted td { + text-decoration: line-through; +} + +.observations-table tbody tr:hover { + background: rgba(79, 127, 78, var(--opacity-10)); +} + +.observation-id-code { + display: inline-block; + font-size: 12px; + font-family: 'Courier New', monospace; + background: rgba(79, 127, 78, var(--opacity-20)); + color: #4f7f4e; + padding: 6px 12px; + border-radius: 6px; + border: 1px solid rgba(79, 127, 78, var(--opacity-40)); + word-break: break-all; + max-width: 100%; + white-space: normal; + line-height: 1.4; + font-weight: 600; +} + +.observation-id-code:hover { + background: rgba(79, 127, 78, var(--opacity-30)); + border-color: rgba(79, 127, 78, var(--opacity-60)); +} + +/* Auto-activate Toggle */ +.auto-activate-toggle { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: rgba(255, 255, 255, var(--opacity-5)); + border: var(--border-width-thin) solid rgba(255, 255, 255, var(--opacity-10)); + border-radius: var(--border-radius-md); + cursor: pointer; + transition: all var(--duration-normal) var(--easing-ease-in-out); + font-size: 14px; + color: var(--color-neutral-white); + user-select: none; +} + +.auto-activate-toggle:hover { + background: rgba(255, 255, 255, var(--opacity-10)); + border-color: rgba(79, 127, 78, var(--opacity-30)); +} + +.auto-activate-toggle input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: var(--color-brand-primary-500); +} + +.auto-activate-toggle input[type="checkbox"]:disabled { + opacity: var(--opacity-50); + cursor: not-allowed; +} + +/* Observation Details */ +.observation-details { + display: flex; + flex-direction: column; + gap: 20px; +} + +.observation-details .info-row { + display: flex; + flex-direction: column; + gap: 8px; + padding-bottom: 16px; + border-bottom: var(--border-width-thin) solid rgba(255, 255, 255, var(--opacity-10)); +} + +.observation-details .info-row:last-child { + border-bottom: none; +} + +.observation-details .info-row strong { + color: var(--color-neutral-white); + font-weight: 600; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.observation-details .info-row span { + color: var(--color-neutral-200); + font-size: 14px; +} + +/* Health Status Section */ +.health-status-section { + margin-bottom: 32px; +} + +.health-status-section h3 { + margin-bottom: 16px; + color: var(--color-neutral-white); + font-size: 18px; + font-weight: 600; +} + +/* View Button */ +.table-action-btn.view-btn { + background: rgba(79, 127, 78, var(--opacity-20)); + border-color: rgba(79, 127, 78, var(--opacity-40)); + color: var(--color-brand-primary-300); +} + +.table-action-btn.view-btn:hover:not(:disabled) { + background: rgba(79, 127, 78, var(--opacity-30)); + border-color: rgba(79, 127, 78, var(--opacity-60)); + color: var(--color-brand-primary-200); +} diff --git a/synkronus-portal/src/pages/Dashboard.tsx b/synkronus-portal/src/pages/Dashboard.tsx new file mode 100644 index 000000000..0794b431f --- /dev/null +++ b/synkronus-portal/src/pages/Dashboard.tsx @@ -0,0 +1,1798 @@ +import { useState, useRef } from 'react' +import { useAuth } from '../contexts/AuthContext' +import { api } from '../services/api' +import odeLogo from '../assets/ode_logo.png' +import './Dashboard.css' + +type TabType = 'overview' | 'app-bundles' | 'users' | 'observations' | 'data-export' | 'system' + +interface AppBundleVersion { + version: string + createdAt?: string + isActive?: boolean +} + +interface AppBundleManifest { + version: string + files: Array<{ path: string; hash: string; size: number }> + hash: string +} + +interface AppBundleChanges { + compare_version_a?: string + compare_version_b?: string + added?: Array<{ path: string; hash: string; size: number }> + removed?: Array<{ path: string; hash: string; size: number }> + modified?: Array<{ path: string; hash_a: string; hash_b: string; size_a: number; size_b: number }> +} + +interface AppBundleVersionsResponse { + versions: string[] +} + +interface User { + username: string + role: string + createdAt?: string +} + +interface SystemInfo { + server?: { + version: string + } + build?: { + commit?: string + build_time?: string + go_version?: string + } + version?: string + database?: { + type?: string + version?: string + database_name?: string + } + system?: { + os?: string + architecture?: string + cpus?: number + } +} + +interface HealthStatus { + status: string + timestamp?: string + database?: { + status: string + response_time?: number + } + api?: { + status: string + uptime?: number + } +} + +export function Dashboard() { + const { user, logout } = useAuth() + const [activeTab, setActiveTab] = useState('overview') + const [appBundles, setAppBundles] = useState([]) + const [users, setUsers] = useState([]) + const [systemInfo, setSystemInfo] = useState(null) + const [healthStatus, setHealthStatus] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [uploadProgress, setUploadProgress] = useState(0) + const fileInputRef = useRef(null) + + // User management modals + const [showCreateUserModal, setShowCreateUserModal] = useState(false) + const [showResetPasswordModal, setShowResetPasswordModal] = useState(false) + const [showChangePasswordModal, setShowChangePasswordModal] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(null) + + // Form states + const [createUserForm, setCreateUserForm] = useState({ username: '', password: '', role: 'read-only' }) + const [resetPasswordForm, setResetPasswordForm] = useState({ username: '', newPassword: '' }) + const [changePasswordForm, setChangePasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' }) + const [userSearchQuery, setUserSearchQuery] = useState('') + + // Observations state + const [observations, setObservations] = useState([]) + const [observationSearchQuery, setObservationSearchQuery] = useState('') + const [showObservationModal, setShowObservationModal] = useState(false) + const [selectedObservation, setSelectedObservation] = useState(null) + + // App bundle modals + const [autoActivate, setAutoActivate] = useState(false) + const [showManifestModal, setShowManifestModal] = useState(false) + const [showChangesModal, setShowChangesModal] = useState(false) + const [showSwitchConfirm, setShowSwitchConfirm] = useState(null) + const [currentManifest, setCurrentManifest] = useState(null) + const [bundleChanges, setBundleChanges] = useState(null) + const [activeVersion, setActiveVersion] = useState(null) + + const loadAppBundles = async () => { + setLoading(true) + setError(null) + try { + // Get versions and manifest to determine active version + const [versionsResponse, manifest] = await Promise.all([ + api.get('/app-bundle/versions'), + api.getAppBundleManifest().catch(() => null), // Manifest might not exist if no bundle is active + ]) + + const activeVer = manifest?.version || null + setActiveVersion(activeVer) + + const versions: AppBundleVersion[] = (versionsResponse.versions || []).map((v: string) => { + // Remove asterisk suffix if present (CLI marks active version with *) + const cleanVersion = v.replace(/\s*\*$/, '') + return { + version: cleanVersion, + isActive: cleanVersion === activeVer, + } + }) + setAppBundles(versions) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load app bundles') + } finally { + setLoading(false) + } + } + + const handleSwitchVersion = async (version: string) => { + setLoading(true) + setError(null) + setSuccess(null) + try { + await api.switchAppBundleVersion(version) + setSuccess(`Successfully switched to version ${version}`) + setShowSwitchConfirm(null) + await loadAppBundles() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to switch version') + } finally { + setLoading(false) + } + } + + const handleViewManifest = async () => { + setLoading(true) + setError(null) + try { + const manifest = await api.getAppBundleManifest() + setCurrentManifest(manifest) + setShowManifestModal(true) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load manifest') + } finally { + setLoading(false) + } + } + + const handleViewChanges = async (targetVersion: string) => { + setLoading(true) + setError(null) + try { + const changes = await api.getAppBundleChanges(activeVersion || undefined, targetVersion) + setBundleChanges(changes) + setShowChangesModal(true) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load changes') + } finally { + setLoading(false) + } + } + + const handleDownloadFile = async (filePath: string) => { + setLoading(true) + setError(null) + try { + const blob = await api.downloadAppBundleFile(filePath) + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + // Extract filename from path + const filename = filePath.split('/').pop() || filePath + a.download = filename + document.body.appendChild(a) + a.click() + window.URL.revokeObjectURL(url) + document.body.removeChild(a) + setSuccess(`File ${filename} downloaded successfully!`) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to download file') + } finally { + setLoading(false) + } + } + + const loadUsers = async () => { + if (user?.role !== 'admin') { + setError('Admin access required') + return + } + setLoading(true) + setError(null) + try { + const userList = await api.listUsers() + setUsers(Array.isArray(userList) ? userList : []) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load users') + } finally { + setLoading(false) + } + } + + const loadSystemInfo = async () => { + setLoading(true) + setError(null) + try { + const [info, healthResponse] = await Promise.all([ + api.get('/version').catch(() => null), + fetch(`${import.meta.env.DEV ? '/api' : 'http://localhost:8080'}/health`).catch(() => null) + ]) + if (info) setSystemInfo(info) + + // Health endpoint returns plain text "OK", so we create a health status object + if (healthResponse && healthResponse.ok) { + const healthText = await healthResponse.text() + setHealthStatus({ + status: healthText || 'OK', + timestamp: new Date().toISOString() + }) + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load system info') + } finally { + setLoading(false) + } + } + + const handleViewObservation = (observation: any) => { + setSelectedObservation(observation) + setShowObservationModal(true) + } + + const loadObservations = async () => { + setLoading(true) + setError(null) + try { + // Use sync/pull to get all observations + // Generate a temporary client_id for the portal + const clientId = `portal-${Date.now()}` + const response = await api.post('/sync/pull', { + client_id: clientId, + since: { version: 0 }, + limit: 1000 + }) + setObservations(response.records || []) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load observations') + } finally { + setLoading(false) + } + } + + const handleTabChange = (tab: TabType) => { + setActiveTab(tab) + setError(null) + setSuccess(null) + + if (tab === 'app-bundles' && appBundles.length === 0) { + loadAppBundles() + } else if (tab === 'users' && users.length === 0) { + loadUsers() + } else if (tab === 'observations' && observations.length === 0) { + loadObservations() + } else if (tab === 'system' && !systemInfo) { + loadSystemInfo() + } + } + + const handleUploadClick = () => { + if (loading) return + // Use setTimeout to ensure the click happens after any state updates + setTimeout(() => { + if (fileInputRef.current) { + fileInputRef.current.click() + } + }, 0) + } + + const handleFileUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + + if (!file.name.endsWith('.zip')) { + setError('Please upload a ZIP file') + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + return + } + + setLoading(true) + setError(null) + setSuccess(null) + setUploadProgress(0) + + try { + const formData = new FormData() + formData.append('bundle', file) + + const token = localStorage.getItem('token') + + // Use XMLHttpRequest for upload progress (don't set Content-Type - browser does it automatically with boundary) + const xhr = new XMLHttpRequest() + + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const percentComplete = (e.loaded / e.total) * 100 + setUploadProgress(percentComplete) + } + }) + + const result = await new Promise((resolve, reject) => { + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + const result = JSON.parse(xhr.responseText) + resolve(result) + } catch (e) { + reject(new Error('Invalid response from server')) + } + } else { + try { + const errorData = JSON.parse(xhr.responseText) + reject(new Error(errorData.message || errorData.error || `Upload failed: ${xhr.status}`)) + } catch (e) { + reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`)) + } + } + }) + + xhr.addEventListener('error', () => reject(new Error('Network error: Upload failed'))) + xhr.addEventListener('abort', () => reject(new Error('Upload was cancelled'))) + + const apiBaseUrl = import.meta.env.VITE_API_URL || (import.meta.env.DEV ? '/api' : 'http://localhost:8080') + const uploadUrl = `${apiBaseUrl}/app-bundle/push` + xhr.open('POST', uploadUrl) + if (token) { + xhr.setRequestHeader('Authorization', `Bearer ${token}`) + } + // Don't set Content-Type - browser sets it automatically with boundary for FormData + xhr.send(formData) + }) + setSuccess(`Bundle uploaded successfully! Version: ${result.manifest?.version || result.version || 'N/A'}`) + setUploadProgress(100) + + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + + await loadAppBundles() + + // Auto-activate if enabled + if (autoActivate && result.manifest?.version) { + try { + await api.switchAppBundleVersion(result.manifest.version) + setSuccess(`Bundle uploaded and activated! Version: ${result.manifest.version}`) + await loadAppBundles() + } catch (err) { + setError(`Bundle uploaded but failed to activate: ${err instanceof Error ? err.message : 'Unknown error'}`) + } + } + + setTimeout(() => { + setUploadProgress(0) + }, 2000) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to upload bundle') + setUploadProgress(0) + } finally { + setLoading(false) + } + } + + const handleExportData = async () => { + setLoading(true) + setError(null) + setSuccess(null) + try { + const apiBaseUrl = import.meta.env.VITE_API_URL || (import.meta.env.DEV ? '/api' : 'http://localhost:8080') + const token = localStorage.getItem('token') + + const response = await fetch(`${apiBaseUrl}/dataexport/parquet`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token || ''}` + } + }) + + if (!response.ok) { + let errorMessage = `Failed to export data (${response.status})` + try { + const errorData = await response.json() + errorMessage = errorData.message || errorData.error || errorMessage + } catch { + errorMessage = response.statusText || errorMessage + } + throw new Error(errorMessage) + } + + // Get the filename from Content-Disposition header, or use default + const contentDisposition = response.headers.get('Content-Disposition') + let filename = `observations_export_${new Date().toISOString().split('T')[0]}.zip` + + if (contentDisposition) { + const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/) + if (filenameMatch && filenameMatch[1]) { + filename = filenameMatch[1].replace(/['"]/g, '') + // Ensure it has .zip extension + if (!filename.endsWith('.zip')) { + filename = filename.replace(/\.parquet$/, '') + '.zip' + } + } + } + + const blob = await response.blob() + + // Verify it's actually a ZIP file (backend returns application/zip) + if (blob.size === 0) { + throw new Error('Export file is empty') + } + + // Create download link + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + + // Cleanup + setTimeout(() => { + window.URL.revokeObjectURL(url) + document.body.removeChild(a) + }, 100) + + setSuccess(`Data exported successfully! Downloaded ${filename}`) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to export data') + } finally { + setLoading(false) + } + } + + // User management handlers + const handleCreateUser = async (e: React.FormEvent) => { + e.preventDefault() + if (!createUserForm.username || !createUserForm.password) { + setError('Username and password are required') + return + } + setLoading(true) + setError(null) + setSuccess(null) + try { + await api.createUser(createUserForm) + setSuccess('User created successfully!') + setCreateUserForm({ username: '', password: '', role: 'read-only' }) + setShowCreateUserModal(false) + await loadUsers() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create user') + } finally { + setLoading(false) + } + } + + const handleDeleteUser = async (username: string) => { + if (!username) return + setLoading(true) + setError(null) + setSuccess(null) + try { + await api.deleteUser(username) + setSuccess(`User ${username} deleted successfully!`) + setShowDeleteConfirm(null) + await loadUsers() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete user') + } finally { + setLoading(false) + } + } + + const handleResetPassword = async (e: React.FormEvent) => { + e.preventDefault() + if (!resetPasswordForm.username || !resetPasswordForm.newPassword) { + setError('Username and new password are required') + return + } + setLoading(true) + setError(null) + setSuccess(null) + try { + await api.resetPassword(resetPasswordForm) + setSuccess(`Password reset successfully for ${resetPasswordForm.username}!`) + setResetPasswordForm({ username: '', newPassword: '' }) + setShowResetPasswordModal(false) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to reset password') + } finally { + setLoading(false) + } + } + + const handleChangePassword = async (e: React.FormEvent) => { + e.preventDefault() + if (!changePasswordForm.currentPassword || !changePasswordForm.newPassword) { + setError('Current password and new password are required') + return + } + if (changePasswordForm.newPassword !== changePasswordForm.confirmPassword) { + setError('New passwords do not match') + return + } + setLoading(true) + setError(null) + setSuccess(null) + try { + await api.changePassword({ + currentPassword: changePasswordForm.currentPassword, + newPassword: changePasswordForm.newPassword, + }) + setSuccess('Password changed successfully!') + setChangePasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' }) + setShowChangePasswordModal(false) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to change password') + } finally { + setLoading(false) + } + } + + return ( +
+
+
+
+ ODE Logo +

Synkronus Portal

+
+
+
+ Welcome back, + {user?.username} +
+ {user?.role} + +
+
+
+ +
+ + + {error && ( +
+ ⚠️ + {error} + +
+ )} + + {success && ( +
+ + {success} + +
+ )} + +
+ {activeTab === 'overview' && ( +
+
+

Dashboard Overview

+

Welcome to your Synkronus control center

+
+
+
+
+
+

System Status

+

Operational

+
+
+
+
👤
+
+

User Role

+

{user?.role || 'N/A'}

+
+
+
+
🔐
+
+

Username

+

{user?.username || 'N/A'}

+
+
+
+
+
🚀
+
+

Get Started

+

Use the navigation tabs above to manage app bundles, users, export data, and view system information.

+
+
+
+ )} + + {activeTab === 'app-bundles' && ( +
+
+
+

App Bundles

+

Manage your application bundles

+
+
+ {user?.role === 'admin' && ( + <> + + + + + )} + +
+
+ + {uploadProgress > 0 && uploadProgress < 100 && ( +
+
+
+
+ {Math.round(uploadProgress)}% +
+ )} + + {loading && appBundles.length === 0 ? ( +
+
+

Loading app bundles...

+
+ ) : appBundles.length === 0 ? ( +
+
📦
+

No App Bundles

+

Upload your first app bundle to get started

+
+ ) : ( +
+ {appBundles.map((bundle) => ( +
+
+
📦
+
+

Version {bundle.version}

+ + {bundle.isActive ? '● Active' : '○ Inactive'} + +
+
+ {bundle.createdAt && ( +
+ Created: {new Date(bundle.createdAt).toLocaleDateString()} +
+ )} +
+ {user?.role === 'admin' && !bundle.isActive && ( + + )} + +
+
+ ))} +
+ )} + + {/* View Manifest Button */} + {activeVersion && ( +
+ +
+ )} +
+ )} + + {activeTab === 'users' && user?.role === 'admin' && ( +
+
+
+

User Management

+

Manage system users and permissions

+
+
+ + +
+
+ + {/* Search Bar */} +
+
+ 🔍 + setUserSearchQuery(e.target.value)} + className="search-input" + /> + {userSearchQuery && ( + + )} +
+
+ + {/* Users Table */} + {loading && users.length === 0 ? ( +
+
+

Loading users...

+
+ ) : ( +
+ + + + + + + + + + + {users + .filter((u) => { + if (!userSearchQuery) return true + const query = userSearchQuery.toLowerCase() + return ( + u.username.toLowerCase().includes(query) || + u.role.toLowerCase().includes(query) + ) + }) + .map((u) => ( + + + + + + + ))} + +
UserRoleCreatedActions
+
+
{u.username.charAt(0).toUpperCase()}
+ {u.username} +
+
+ {u.role} + + + {u.createdAt + ? new Date(u.createdAt).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + : 'N/A'} + + +
+ + +
+
+ {users.filter((u) => { + if (!userSearchQuery) return true + const query = userSearchQuery.toLowerCase() + return ( + u.username.toLowerCase().includes(query) || + u.role.toLowerCase().includes(query) + ) + }).length === 0 && ( +
+
👥
+

{userSearchQuery ? 'No users found' : 'No Users Found'}

+

+ {userSearchQuery + ? 'Try adjusting your search query' + : 'Create your first user to get started'} +

+
+ )} +
+ )} +
+ )} + + {activeTab === 'users' && user?.role !== 'admin' && ( +
+
+
+

My Account

+

Manage your account settings

+
+ +
+
+
{user?.username.charAt(0).toUpperCase()}
+
+

{user?.username}

+ {user?.role} +
+
+
+ )} + + {activeTab === 'observations' && ( +
+
+
+

Observations

+

View and search all observations

+
+ +
+ + {loading && observations.length === 0 ? ( +
+
+

Loading observations...

+
+ ) : ( +
+
+ setObservationSearchQuery(e.target.value)} + className="search-input" + /> + {observationSearchQuery && ( + + )} +
+ + {observations.filter((obs) => { + if (!observationSearchQuery) return true + const query = observationSearchQuery.toLowerCase() + return ( + obs.observation_id?.toLowerCase().includes(query) || + obs.form_type?.toLowerCase().includes(query) || + obs.form_version?.toLowerCase().includes(query) || + obs.version?.toString().includes(query) + ) + }).length > 0 && ( +
+ Showing {observations.filter((obs) => { + if (!observationSearchQuery) return true + const query = observationSearchQuery.toLowerCase() + return ( + obs.observation_id?.toLowerCase().includes(query) || + obs.form_type?.toLowerCase().includes(query) || + obs.form_version?.toLowerCase().includes(query) || + obs.version?.toString().includes(query) + ) + }).length} of {observations.length} observations +
+ )} + +
+ + + + + + + + + + + + + + + + {observations.filter((obs) => { + if (!observationSearchQuery) return true + const query = observationSearchQuery.toLowerCase() + return ( + obs.observation_id?.toLowerCase().includes(query) || + obs.form_type?.toLowerCase().includes(query) || + obs.form_version?.toLowerCase().includes(query) || + obs.version?.toString().includes(query) + ) + }).map((obs) => ( + + + + + + + + + + + + ))} + +
Observation IDForm TypeForm VersionCreated AtUpdated AtSynced AtVersionStatusActions
+ + {obs.observation_id} + + {obs.form_type}{obs.form_version}{new Date(obs.created_at).toLocaleString()}{new Date(obs.updated_at).toLocaleString()}{obs.synced_at ? new Date(obs.synced_at).toLocaleString() : '—'}{obs.version} + {obs.deleted ? ( + Deleted + ) : ( + Active + )} + +
+ +
+
+
+ {observations.filter((obs) => { + if (!observationSearchQuery) return true + const query = observationSearchQuery.toLowerCase() + return ( + obs.observation_id?.toLowerCase().includes(query) || + obs.form_type?.toLowerCase().includes(query) || + obs.form_version?.toLowerCase().includes(query) || + obs.version?.toString().includes(query) + ) + }).length === 0 && observationSearchQuery && ( +
+
🔍
+

No observations found

+

+ No observations match your search query: "{observationSearchQuery}" +

+ +
+ )} + {observations.length === 0 && !loading && !observationSearchQuery && ( +
+
📋
+

No observations found

+
+ )} +
+ )} +
+ )} + + {activeTab === 'data-export' && ( +
+
+

Data Export

+

Export observation data for analysis

+
+
+
📊
+
+

Export to Parquet

+

Download all observation data as a ZIP archive containing Parquet files (one per form type) for analysis in Python, R, or other data science tools.

+

+ The ZIP file contains separate Parquet files for each form type, making it easy to analyze observations by form. +

+ +
+
+
+ )} + + {activeTab === 'system' && ( +
+
+
+

System Information

+

Server version and build details

+
+ +
+ {loading && !systemInfo ? ( +
+
+

Loading system information...

+
+ ) : systemInfo || healthStatus ? ( +
+ {healthStatus && ( +
+

Health Status

+
+
+
💚
+
+

Overall Status

+

+ {healthStatus.status || 'Unknown'} +

+
+
+ {healthStatus.database && ( +
+
🗄️
+
+

Database

+

+ {healthStatus.database.status || 'Unknown'} + {healthStatus.database.response_time && ` (${healthStatus.database.response_time}ms)`} +

+
+
+ )} + {healthStatus.api && ( +
+
🌐
+
+

API

+

+ {healthStatus.api.status || 'Unknown'} + {healthStatus.api.uptime && ` (${Math.floor(healthStatus.api.uptime / 3600)}h)`} +

+
+
+ )} + {healthStatus.timestamp && ( +
+
🕒
+
+

Last Check

+

{new Date(healthStatus.timestamp).toLocaleString()}

+
+
+ )} +
+
+ )} +
+

System Information

+
+
+
🔢
+
+

Server Version

+

{systemInfo?.server?.version || systemInfo?.version || 'N/A'}

+
+
+ {systemInfo?.build?.go_version && ( +
+
⚙️
+
+

Go Runtime

+

{systemInfo.build.go_version}

+
+
+ )} + {systemInfo?.system && ( +
+
💻
+
+

System

+

+ {systemInfo.system.os || 'N/A'} {systemInfo.system.architecture || ''} + {systemInfo.system.cpus && ` • ${systemInfo.system.cpus} CPUs`} +

+
+
+ )} + {systemInfo?.database?.database_name && ( +
+
📊
+
+

Database Name

+

{systemInfo.database.database_name}

+
+
+ )} + {systemInfo?.database?.type && ( +
+
🗄️
+
+

Database Type

+

{systemInfo.database.type}

+
+
+ )} + {systemInfo?.database?.version && ( +
+
🗄️
+
+

Database Version

+

{systemInfo.database.version}

+
+
+ )} + {systemInfo?.build?.build_time && ( +
+
🕒
+
+

Build Time

+

{new Date(systemInfo.build.build_time).toLocaleString()}

+
+
+ )} + {systemInfo?.build?.commit && ( +
+
🔗
+
+

Git Commit

+

{systemInfo.build.commit}

+
+
+ )} +
+
🌐
+
+

API Endpoint

+

+ {import.meta.env.VITE_API_URL || (import.meta.env.DEV ? '/api' : 'http://localhost:8080')} +

+
+
+
+
📅
+
+

Current Time

+

{new Date().toLocaleString()}

+
+
+
+
+
+ ) : ( +
+
⚙️
+

No System Info

+

Click refresh to load system information

+
+ )} +
+ )} +
+
+ + {/* Create User Modal */} + {showCreateUserModal && ( +
setShowCreateUserModal(false)}> +
e.stopPropagation()}> +
+

Create New User

+ +
+
+
+ + setCreateUserForm({ ...createUserForm, username: e.target.value })} + required + disabled={loading} + /> +
+
+ + setCreateUserForm({ ...createUserForm, password: e.target.value })} + required + disabled={loading} + /> +
+
+ + +
+
+ + +
+
+
+
+ )} + + {/* Reset Password Modal */} + {showResetPasswordModal && ( +
setShowResetPasswordModal(false)}> +
e.stopPropagation()}> +
+

Reset Password

+ +
+
+
+ + setResetPasswordForm({ ...resetPasswordForm, username: e.target.value })} + required + disabled={loading} + /> +
+
+ + setResetPasswordForm({ ...resetPasswordForm, newPassword: e.target.value })} + required + disabled={loading} + /> +
+
+ + +
+
+
+
+ )} + + {/* Change Password Modal */} + {showChangePasswordModal && ( +
setShowChangePasswordModal(false)}> +
e.stopPropagation()}> +
+

Change Password

+ +
+
+
+ + setChangePasswordForm({ ...changePasswordForm, currentPassword: e.target.value })} + required + disabled={loading} + /> +
+
+ + setChangePasswordForm({ ...changePasswordForm, newPassword: e.target.value })} + required + disabled={loading} + /> +
+
+ + setChangePasswordForm({ ...changePasswordForm, confirmPassword: e.target.value })} + required + disabled={loading} + /> +
+
+ + +
+
+
+
+ )} + + {/* Delete Confirmation Modal */} + {showDeleteConfirm && ( +
setShowDeleteConfirm(null)}> +
e.stopPropagation()}> +
+

Delete User

+ +
+
+

Are you sure you want to delete user {showDeleteConfirm}? This action cannot be undone.

+
+
+ + +
+
+
+ )} + + {/* Switch Version Confirmation Modal */} + {showSwitchConfirm && ( +
setShowSwitchConfirm(null)}> +
e.stopPropagation()}> +
+

Activate Bundle Version

+ +
+
+

Are you sure you want to activate version {showSwitchConfirm}? This will switch the active app bundle to this version.

+
+
+ + +
+
+
+ )} + + {/* Manifest Modal */} + {showManifestModal && currentManifest && ( +
setShowManifestModal(false)}> +
e.stopPropagation()}> +
+

App Bundle Manifest

+ +
+
+
+
+ Version: {currentManifest.version} +
+
+ Hash: {currentManifest.hash} +
+
+ Files: {currentManifest.files.length} +
+
+
+

Files

+
+ + + + + + + + + + + {currentManifest.files.map((file, idx) => ( + + + + + + + ))} + +
PathSizeHashActions
{file.path}{file.size} bytes{file.hash} + +
+
+
+
+
+ +
+
+
+ )} + + {/* Observation View Modal */} + {showObservationModal && selectedObservation && ( +
setShowObservationModal(false)}> +
e.stopPropagation()}> +
+

Observation Details

+ +
+
+
+
+ Observation ID: + + {selectedObservation.observation_id} + +
+
+ Form Type: + {selectedObservation.form_type || 'N/A'} +
+
+ Form Version: + {selectedObservation.form_version || 'N/A'} +
+
+ Version: + {selectedObservation.version || 'N/A'} +
+
+ Status: + + {selectedObservation.deleted ? ( + Deleted + ) : ( + Active + )} + +
+
+ Created At: + {selectedObservation.created_at ? new Date(selectedObservation.created_at).toLocaleString() : 'N/A'} +
+
+ Updated At: + {selectedObservation.updated_at ? new Date(selectedObservation.updated_at).toLocaleString() : 'N/A'} +
+
+ Synced At: + {selectedObservation.synced_at ? new Date(selectedObservation.synced_at).toLocaleString() : 'Not synced'} +
+ {selectedObservation.geolocation && ( +
+ Geolocation: +
+
Latitude: {selectedObservation.geolocation.latitude}
+
Longitude: {selectedObservation.geolocation.longitude}
+ {selectedObservation.geolocation.accuracy && ( +
Accuracy: {selectedObservation.geolocation.accuracy}m
+ )} +
+
+ )} + {selectedObservation.data && ( +
+ Data: +
+                      {typeof selectedObservation.data === 'string' 
+                        ? selectedObservation.data 
+                        : JSON.stringify(selectedObservation.data, null, 2)}
+                    
+
+ )} +
+
+
+ +
+
+
+ )} + + {/* Changes Modal */} + {showChangesModal && bundleChanges && ( +
setShowChangesModal(false)}> +
e.stopPropagation()}> +
+

Version Changes

+ +
+
+
+ {bundleChanges.compare_version_a && bundleChanges.compare_version_b && ( +
+ Comparing: {bundleChanges.compare_version_a} → {bundleChanges.compare_version_b} +
+ )} +
+ {bundleChanges.added && bundleChanges.added.length > 0 && ( +
+

➕ Added ({bundleChanges.added.length})

+
    + {bundleChanges.added.map((file, idx) => ( +
  • {file.path}
  • + ))} +
+
+ )} + {bundleChanges.removed && bundleChanges.removed.length > 0 && ( +
+

➖ Removed ({bundleChanges.removed.length})

+
    + {bundleChanges.removed.map((file, idx) => ( +
  • {file.path}
  • + ))} +
+
+ )} + {bundleChanges.modified && bundleChanges.modified.length > 0 && ( +
+

✏️ Modified ({bundleChanges.modified.length})

+
    + {bundleChanges.modified.map((file, idx) => ( +
  • {file.path}
  • + ))} +
+
+ )} + {(!bundleChanges.added || bundleChanges.added.length === 0) && + (!bundleChanges.removed || bundleChanges.removed.length === 0) && + (!bundleChanges.modified || bundleChanges.modified.length === 0) && ( +

No changes detected between versions.

+ )} +
+
+ +
+
+
+ )} +
+ ) +} diff --git a/synkronus-portal/src/services/api.ts b/synkronus-portal/src/services/api.ts new file mode 100644 index 000000000..6f5eb14d1 --- /dev/null +++ b/synkronus-portal/src/services/api.ts @@ -0,0 +1,203 @@ +import type { LoginRequest, LoginResponse, RefreshRequest } from '../types/auth' + +// Get API base URL from environment or use default +const getApiBaseUrl = () => { + // Check if we're in development (Vite proxy) or production + if (import.meta.env.VITE_API_URL) { + return import.meta.env.VITE_API_URL + } + // Use proxy in development, direct URL in production + return import.meta.env.DEV ? '/api' : 'http://localhost:8080' +} + +const API_BASE_URL = getApiBaseUrl() + +interface ApiErrorResponse { + error?: string + message?: string +} + +async function request( + endpoint: string, + options: RequestInit = {} +): Promise { + const token = localStorage.getItem('token') + + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record || {}), + } + + if (token) { + headers['Authorization'] = `Bearer ${token}` + } + + try { + const url = `${API_BASE_URL}${endpoint}` + const response = await fetch(url, { + ...options, + headers, + }) + + if (!response.ok) { + let errorMessage = `HTTP error! status: ${response.status}` + + try { + const errorData: ApiErrorResponse = await response.json() + // API returns { error: "...", message: "..." } format + errorMessage = errorData.message || errorData.error || errorMessage + } catch { + // If response is not JSON, use status text + errorMessage = response.statusText || errorMessage + } + + // Provide more specific error messages + if (response.status === 401) { + errorMessage = errorMessage || 'Invalid username or password' + } else if (response.status === 0 || response.status >= 500) { + errorMessage = 'Server error: Please check if the API is running' + } else if (!navigator.onLine) { + errorMessage = 'No internet connection' + } + + throw new Error(errorMessage) + } + + return response.json() + } catch (error) { + if (error instanceof Error) { + // Re-throw API errors as-is + throw error + } + // Network errors, CORS errors, etc. + throw new Error('Network error: Unable to connect to the server. Please check if the API is running at ' + API_BASE_URL) + } +} + +export const api = { + async login(credentials: LoginRequest): Promise { + return request('/auth/login', { + method: 'POST', + body: JSON.stringify(credentials), + }) + }, + + async refreshToken(refreshToken: string): Promise { + const payload: RefreshRequest = { refreshToken } + return request('/auth/refresh', { + method: 'POST', + body: JSON.stringify(payload), + }) + }, + + async get(endpoint: string): Promise { + return request(endpoint, { method: 'GET' }) + }, + + async post(endpoint: string, data?: unknown): Promise { + return request(endpoint, { + method: 'POST', + body: data ? JSON.stringify(data) : undefined, + }) + }, + + async put(endpoint: string, data?: unknown): Promise { + return request(endpoint, { + method: 'PUT', + body: data ? JSON.stringify(data) : undefined, + }) + }, + + async delete(endpoint: string): Promise { + return request(endpoint, { method: 'DELETE' }) + }, + + // User management endpoints + async createUser(data: { username: string; password: string; role: string }): Promise<{ username: string; role: string; createdAt: string }> { + return request<{ username: string; role: string; createdAt: string }>('/users/create', { + method: 'POST', + body: JSON.stringify(data), + }) + }, + + async listUsers(): Promise> { + return request>('/users', { + method: 'GET', + }) + }, + + async deleteUser(username: string): Promise<{ message: string }> { + return request<{ message: string }>(`/users/delete/${username}`, { + method: 'DELETE', + }) + }, + + async resetPassword(data: { username: string; newPassword: string }): Promise<{ message: string }> { + return request<{ message: string }>('/users/reset-password', { + method: 'POST', + body: JSON.stringify(data), + }) + }, + + async changePassword(data: { currentPassword: string; newPassword: string }): Promise<{ message: string }> { + return request<{ message: string }>('/users/change-password', { + method: 'POST', + body: JSON.stringify(data), + }) + }, + + // App bundle endpoints + async switchAppBundleVersion(version: string): Promise<{ message: string }> { + return request<{ message: string }>(`/app-bundle/switch/${version}`, { + method: 'POST', + }) + }, + + async getAppBundleManifest(): Promise<{ version: string; files: Array<{ path: string; hash: string; size: number }>; hash: string }> { + return request<{ version: string; files: Array<{ path: string; hash: string; size: number }>; hash: string }>('/app-bundle/manifest', { + method: 'GET', + }) + }, + + async getAppBundleChanges(current?: string, target?: string): Promise { + const params = new URLSearchParams() + if (current) params.append('current', current) + if (target) params.append('target', target) + const query = params.toString() + return request(`/app-bundle/changes${query ? `?${query}` : ''}`, { + method: 'GET', + }) + }, + + async downloadAppBundleFile(filePath: string, preview?: boolean): Promise { + const token = localStorage.getItem('token') + const params = new URLSearchParams() + if (preview) params.append('preview', 'true') + const query = params.toString() + const url = `${API_BASE_URL}/app-bundle/download/${encodeURIComponent(filePath)}${query ? `?${query}` : ''}` + + const headers: Record = {} + if (token) { + headers['Authorization'] = `Bearer ${token}` + } + + const response = await fetch(url, { + method: 'GET', + headers, + }) + + if (!response.ok) { + let errorMessage = `HTTP error! status: ${response.status}` + try { + const errorData: ApiErrorResponse = await response.json() + errorMessage = errorData.message || errorData.error || errorMessage + } catch { + errorMessage = response.statusText || errorMessage + } + throw new Error(errorMessage) + } + + return response.blob() + }, +} + diff --git a/synkronus-portal/src/types/auth.ts b/synkronus-portal/src/types/auth.ts new file mode 100644 index 000000000..453877c92 --- /dev/null +++ b/synkronus-portal/src/types/auth.ts @@ -0,0 +1,27 @@ +export interface LoginRequest { + username: string + password: string +} + +export interface LoginResponse { + token: string + refreshToken: string + expiresAt: number +} + +export interface RefreshRequest { + refreshToken: string +} + +export interface User { + username: string + role: string +} + +export interface AuthState { + user: User | null + token: string | null + refreshToken: string | null + isAuthenticated: boolean +} + diff --git a/synkronus-portal/tsconfig.app.json b/synkronus-portal/tsconfig.app.json new file mode 100644 index 000000000..a9b5a59ca --- /dev/null +++ b/synkronus-portal/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "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/synkronus-portal/tsconfig.json b/synkronus-portal/tsconfig.json new file mode 100644 index 000000000..1ffef600d --- /dev/null +++ b/synkronus-portal/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/synkronus-portal/tsconfig.node.json b/synkronus-portal/tsconfig.node.json new file mode 100644 index 000000000..8a67f62f4 --- /dev/null +++ b/synkronus-portal/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "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/synkronus-portal/vite.config.ts b/synkronus-portal/vite.config.ts new file mode 100644 index 000000000..1e2b4e786 --- /dev/null +++ b/synkronus-portal/vite.config.ts @@ -0,0 +1,73 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + react({ + babel: { + plugins: [['babel-plugin-react-compiler']], + }, + }), + ], + // Disable caching in development to prevent stale code issues + optimizeDeps: { + force: true, // Force re-optimization of dependencies + }, + server: { + host: '0.0.0.0', // Allow external connections (needed for Docker) + port: 5174, + strictPort: true, // Fail if port is already in use instead of trying next available port + // Disable caching in development + headers: { + 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0', + }, + hmr: { + // Hot Module Replacement configuration + host: 'localhost', + port: 5174, + protocol: 'ws', // WebSocket protocol for HMR + overlay: true, // Show HMR errors in browser + }, + watch: { + // Watch for file changes (enabled by default in dev mode) + usePolling: true, // Needed for Docker on Windows + interval: 100, // Polling interval in ms + }, + proxy: { + // Proxy API requests to the Synkronus backend + // In Docker: uses service name 'synkronus' + // Locally: uses 'localhost' + '/api': { + // In Docker, use service name; locally, use localhost + target: process.env.DOCKER_ENV === 'true' || process.env.VITE_API_URL?.includes('synkronus:') + ? 'http://synkronus:8080' + : process.env.API_URL || 'http://localhost:8080', + changeOrigin: true, + secure: false, + rewrite: (path) => { + const rewritten = path.replace(/^\/api/, '') + console.log(`[Vite Proxy] Rewriting ${path} -> ${rewritten}`) + return rewritten + }, + configure: (proxy, _options) => { + proxy.on('error', (err, req, _res) => { + console.error('[Vite Proxy] Error:', err) + console.error('[Vite Proxy] Request URL:', req.url) + console.error('[Vite Proxy] Target:', process.env.DOCKER_ENV === 'true' || process.env.VITE_API_URL?.includes('synkronus:') + ? 'http://synkronus:8080' + : process.env.API_URL || 'http://localhost:8080') + }) + proxy.on('proxyReq', (proxyReq, req, _res) => { + console.log('[Vite Proxy] Proxying request:', req.method, req.url, '->', proxyReq.path) + }) + proxy.on('proxyRes', (proxyRes, req, _res) => { + console.log('[Vite Proxy] Response:', proxyRes.statusCode, req.url) + }) + }, + }, + }, + }, +}) diff --git a/synkronus/docker-compose.dev.yml b/synkronus/docker-compose.dev.yml new file mode 100644 index 000000000..cb734b3be --- /dev/null +++ b/synkronus/docker-compose.dev.yml @@ -0,0 +1,27 @@ +# Docker Compose for local development +# This sets up just PostgreSQL for local development +# Run Synkronus directly with: go run cmd/synkronus/main.go + +services: + postgres: + image: postgres:17 + container_name: synkronus-postgres-dev + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: synkronus + ports: + - "5432:5432" + volumes: + - postgres-dev-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + postgres-dev-data: + + + diff --git a/synkronus/internal/handlers/attachment.go b/synkronus/internal/handlers/attachment.go index 766744c9e..de88303dc 100644 --- a/synkronus/internal/handlers/attachment.go +++ b/synkronus/internal/handlers/attachment.go @@ -28,7 +28,7 @@ func (h *AttachmentHandler) RegisterRoutes(r chi.Router, manifestHandler func(ht r.Route("/attachments", func(r chi.Router) { // Manifest endpoint r.Post("/manifest", manifestHandler) - + // Individual attachment routes r.Route("/{attachment_id}", func(r chi.Router) { r.Put("/", h.UploadAttachment) diff --git a/synkronus/internal/handlers/attachment_manifest_test.go b/synkronus/internal/handlers/attachment_manifest_test.go index eb2a9e724..dc7aa6c71 100644 --- a/synkronus/internal/handlers/attachment_manifest_test.go +++ b/synkronus/internal/handlers/attachment_manifest_test.go @@ -36,8 +36,8 @@ func TestAttachmentManifestHandler(t *testing.T) { mockAttachmentManifestService := &mocks.MockAttachmentManifestService{ GetManifestFunc: func(ctx context.Context, req attachment.AttachmentManifestRequest) (*attachment.AttachmentManifestResponse, error) { return &attachment.AttachmentManifestResponse{ - CurrentVersion: 45, - Operations: []attachment.AttachmentOperation{ + CurrentVersion: 45, + Operations: []attachment.AttachmentOperation{ { Operation: "download", AttachmentID: "test-attachment-123.jpg", diff --git a/synkronus/internal/handlers/dataexport_test.go b/synkronus/internal/handlers/dataexport_test.go index ee6c239e0..abee4d51f 100644 --- a/synkronus/internal/handlers/dataexport_test.go +++ b/synkronus/internal/handlers/dataexport_test.go @@ -48,7 +48,7 @@ func TestHandler_ParquetExportHandler(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Create handler with mock services h, _ := createTestHandler() - + // Setup mock data export service mockDataExportService := mocks.NewMockDataExportService() tt.setupMock(mockDataExportService) @@ -97,7 +97,7 @@ func TestHandler_ParquetExportHandler(t *testing.T) { func TestHandler_ParquetExportHandler_Integration(t *testing.T) { // This test verifies the handler works with a more realistic mock h, _ := createTestHandler() - + // Setup mock data export service with realistic behavior mockDataExportService := mocks.NewMockDataExportService() mockDataExportService.ExportParquetZipFunc = func(ctx context.Context) (io.ReadCloser, error) { diff --git a/synkronus/internal/handlers/sync.go b/synkronus/internal/handlers/sync.go index 44fef9dd4..4cc9d842a 100644 --- a/synkronus/internal/handlers/sync.go +++ b/synkronus/internal/handlers/sync.go @@ -23,11 +23,11 @@ type SyncPullRequestSince struct { // SyncPullResponse represents the sync pull response payload according to OpenAPI spec type SyncPullResponse struct { - CurrentVersion int64 `json:"current_version"` - Records []sync.Observation `json:"records"` - ChangeCutoff int64 `json:"change_cutoff"` - HasMore *bool `json:"has_more,omitempty"` - SyncFormatVersion *string `json:"sync_format_version,omitempty"` + CurrentVersion int64 `json:"current_version"` + Records []sync.Observation `json:"records"` + ChangeCutoff int64 `json:"change_cutoff"` + HasMore *bool `json:"has_more,omitempty"` + SyncFormatVersion *string `json:"sync_format_version,omitempty"` } // Pull handles the /sync/pull endpoint @@ -68,7 +68,7 @@ func (h *Handler) Pull(w http.ResponseWriter, r *http.Request) { // Determine starting version and cursor var sinceVersion int64 = 0 var cursor *sync.SyncPullCursor - + if req.Since != nil { sinceVersion = req.Since.Version cursor = &sync.SyncPullCursor{ @@ -97,7 +97,7 @@ func (h *Handler) Pull(w http.ResponseWriter, r *http.Request) { // Note: Clients should use change_cutoff as the next since.version for pagination - h.log.Info("Sync pull request processed", + h.log.Info("Sync pull request processed", "clientId", req.ClientID, "sinceVersion", sinceVersion, "currentVersion", result.CurrentVersion, @@ -117,10 +117,10 @@ type SyncPushRequest struct { // SyncPushResponse represents the sync push response payload according to OpenAPI spec type SyncPushResponse struct { - CurrentVersion int64 `json:"current_version"` - SuccessCount int `json:"success_count"` - FailedRecords []map[string]interface{} `json:"failed_records,omitempty"` - Warnings []sync.SyncWarning `json:"warnings,omitempty"` + CurrentVersion int64 `json:"current_version"` + SuccessCount int `json:"success_count"` + FailedRecords []map[string]interface{} `json:"failed_records,omitempty"` + Warnings []sync.SyncWarning `json:"warnings,omitempty"` } // Push handles the /sync/push endpoint @@ -167,9 +167,9 @@ func (h *Handler) Push(w http.ResponseWriter, r *http.Request) { Warnings: result.Warnings, } - h.log.Info("Sync push request processed", + h.log.Info("Sync push request processed", "transmissionId", req.TransmissionID, - "clientId", req.ClientID, + "clientId", req.ClientID, "recordCount", len(req.Records), "successCount", result.SuccessCount, "failedCount", len(result.FailedRecords), diff --git a/synkronus/internal/handlers/sync_push_pull_test.go b/synkronus/internal/handlers/sync_push_pull_test.go index 572de8229..b08781521 100644 --- a/synkronus/internal/handlers/sync_push_pull_test.go +++ b/synkronus/internal/handlers/sync_push_pull_test.go @@ -21,21 +21,21 @@ func TestPushThenPull(t *testing.T) { Records: []sync.Observation{ { ObservationID: "test-obs-1", - FormType: "test_form", - FormVersion: "1.0", - Data: json.RawMessage(`{"field1":"value1"}`), - CreatedAt: "2025-06-25T12:00:00Z", - UpdatedAt: "2025-06-25T12:00:00Z", - Deleted: false, + FormType: "test_form", + FormVersion: "1.0", + Data: json.RawMessage(`{"field1":"value1"}`), + CreatedAt: "2025-06-25T12:00:00Z", + UpdatedAt: "2025-06-25T12:00:00Z", + Deleted: false, }, { ObservationID: "test-obs-2", - FormType: "test_form", - FormVersion: "1.0", - Data: json.RawMessage(`{"field2":"value2"}`), - CreatedAt: "2025-06-25T12:00:00Z", - UpdatedAt: "2025-06-25T12:00:00Z", - Deleted: false, + FormType: "test_form", + FormVersion: "1.0", + Data: json.RawMessage(`{"field2":"value2"}`), + CreatedAt: "2025-06-25T12:00:00Z", + UpdatedAt: "2025-06-25T12:00:00Z", + Deleted: false, }, }, } @@ -125,12 +125,12 @@ func TestPushThenPull(t *testing.T) { Records: []sync.Observation{ { ObservationID: "test-obs-1", - FormType: "test_form", - FormVersion: "1.0", - Data: json.RawMessage(`{"field1":"updated_value1"}`), - CreatedAt: "2025-06-25T12:00:00Z", - UpdatedAt: "2025-06-25T12:05:00Z", // Updated time - Deleted: false, + FormType: "test_form", + FormVersion: "1.0", + Data: json.RawMessage(`{"field1":"updated_value1"}`), + CreatedAt: "2025-06-25T12:00:00Z", + UpdatedAt: "2025-06-25T12:05:00Z", // Updated time + Deleted: false, }, }, } @@ -192,7 +192,7 @@ func TestPushThenPull(t *testing.T) { // The new version should be greater than our previous version t.Logf("4. Pulling changes since version %d...", currentVersion) if pullResp.CurrentVersion <= currentVersion { - t.Errorf("expected new version (%d) to be greater than previous version (%d)", + t.Errorf("expected new version (%d) to be greater than previous version (%d)", pullResp.CurrentVersion, currentVersion) } else { t.Logf("Success! New version: %d > previous version: %d", pullResp.CurrentVersion, currentVersion) diff --git a/synkronus/internal/models/user.go b/synkronus/internal/models/user.go index dec0811e6..d8215f967 100644 --- a/synkronus/internal/models/user.go +++ b/synkronus/internal/models/user.go @@ -2,7 +2,7 @@ package models import ( "time" - + "github.com/google/uuid" ) diff --git a/synkronus/pkg/appbundle/test_helpers.go b/synkronus/pkg/appbundle/test_helpers.go index 2fd4ecafb..c2cf3e3f9 100644 --- a/synkronus/pkg/appbundle/test_helpers.go +++ b/synkronus/pkg/appbundle/test_helpers.go @@ -72,6 +72,11 @@ func createTestBundleFromDir(t *testing.T, srcDir string) (string, error) { return err } + // Skip data.json files (test data, not part of bundle structure) + if strings.HasSuffix(relPath, "data.json") { + return nil + } + // Determine the target path in the zip based on the source directory var zipPath string switch { @@ -81,6 +86,10 @@ func createTestBundleFromDir(t *testing.T, srcDir string) (string, error) { case strings.HasPrefix(relPath, "forms/"): // Files from testdata/forms/ go to forms/ in the zip zipPath = relPath + // Rename uischema.json to ui.json to match validation expectations + if strings.HasSuffix(zipPath, "uischema.json") { + zipPath = strings.TrimSuffix(zipPath, "uischema.json") + "ui.json" + } case strings.HasPrefix(relPath, "renderers/"): // Files from testdata/renderers/ go to renderers/ in the zip zipPath = relPath diff --git a/synkronus/pkg/attachment/manifest.go b/synkronus/pkg/attachment/manifest.go index ca593808c..534a73431 100644 --- a/synkronus/pkg/attachment/manifest.go +++ b/synkronus/pkg/attachment/manifest.go @@ -29,10 +29,10 @@ type AttachmentManifestRequest struct { // AttachmentManifestResponse represents the response containing attachment manifest type AttachmentManifestResponse struct { - CurrentVersion int64 `json:"current_version"` - Operations []AttachmentOperation `json:"operations"` - TotalDownloadSize int64 `json:"total_download_size"` - OperationCount OperationCount `json:"operation_count"` + CurrentVersion int64 `json:"current_version"` + Operations []AttachmentOperation `json:"operations"` + TotalDownloadSize int64 `json:"total_download_size"` + OperationCount OperationCount `json:"operation_count"` } // OperationCount represents the count of operations by type @@ -45,19 +45,19 @@ type OperationCount struct { type ManifestService interface { // GetManifest returns attachment operations since the specified version GetManifest(ctx context.Context, req AttachmentManifestRequest) (*AttachmentManifestResponse, error) - + // RecordOperation records an attachment operation for sync tracking RecordOperation(ctx context.Context, attachmentID, operation, clientID string, size *int, contentType *string) error - + // Initialize initializes the manifest service Initialize(ctx context.Context) error } // manifestService implements ManifestService type manifestService struct { - db *sql.DB - cfg *config.Config - log *logger.Logger + db *sql.DB + cfg *config.Config + log *logger.Logger baseURL string } @@ -65,7 +65,7 @@ type manifestService struct { func NewManifestService(db *sql.DB, cfg *config.Config, log *logger.Logger) ManifestService { // Construct base URL from config port baseURL := fmt.Sprintf("http://localhost:%s", cfg.Port) - + return &manifestService{ db: db, cfg: cfg, @@ -85,15 +85,15 @@ func (s *manifestService) Initialize(ctx context.Context) error { AND table_name = 'attachment_operations' ) `).Scan(&exists) - + if err != nil { return fmt.Errorf("failed to check attachment_operations table: %w", err) } - + if !exists { return fmt.Errorf("attachment_operations table does not exist - please run database migrations") } - + s.log.Info("Attachment manifest service initialized") return nil } @@ -173,7 +173,7 @@ func (s *manifestService) GetManifest(ctx context.Context, req AttachmentManifes op.Operation = "download" // Normalize to download for client downloadURL := s.generateDownloadURL(op.AttachmentID) op.DownloadURL = &downloadURL - + if op.Size != nil { totalDownloadSize += int64(*op.Size) } diff --git a/synkronus/pkg/attachment/service.go b/synkronus/pkg/attachment/service.go index f3e3613af..fba9534a7 100644 --- a/synkronus/pkg/attachment/service.go +++ b/synkronus/pkg/attachment/service.go @@ -12,10 +12,10 @@ import ( type Service interface { // Save stores the attachment with the given ID Save(ctx context.Context, attachmentID string, file io.Reader) error - + // Get retrieves the attachment with the given ID Get(ctx context.Context, attachmentID string) (io.ReadCloser, error) - + // Exists checks if an attachment with the given ID exists Exists(ctx context.Context, attachmentID string) (bool, error) } @@ -30,7 +30,7 @@ func NewService(cfg *config.Config) (Service, error) { if err := os.MkdirAll(storagePath, 0755); err != nil { return nil, err } - + return &service{ storagePath: storagePath, }, nil @@ -41,13 +41,13 @@ func (s *service) getAttachmentPath(attachmentID string) (string, error) { if filepath.IsAbs(attachmentID) || filepath.VolumeName(attachmentID) != "" { return "", os.ErrInvalid } - + // Clean the path to prevent directory traversal cleanPath := filepath.Clean(attachmentID) if cleanPath == "." || cleanPath == ".." { return "", os.ErrInvalid } - + return filepath.Join(s.storagePath, cleanPath), nil } @@ -56,26 +56,26 @@ func (s *service) Save(ctx context.Context, attachmentID string, file io.Reader) if err != nil { return err } - + // Check if file already exists if _, err := os.Stat(path); err == nil { return os.ErrExist } else if !os.IsNotExist(err) { return err } - + // Create all parent directories if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } - + // Create new file dst, err := os.Create(path) if err != nil { return err } defer dst.Close() - + // Copy file content _, err = io.Copy(dst, file) return err @@ -86,7 +86,7 @@ func (s *service) Get(ctx context.Context, attachmentID string) (io.ReadCloser, if err != nil { return nil, err } - + return os.Open(path) } @@ -95,7 +95,7 @@ func (s *service) Exists(ctx context.Context, attachmentID string) (bool, error) if err != nil { return false, err } - + _, err = os.Stat(path) if err == nil { return true, nil diff --git a/synkronus/pkg/dataexport/database.go b/synkronus/pkg/dataexport/database.go index 795757426..d27d0e653 100644 --- a/synkronus/pkg/dataexport/database.go +++ b/synkronus/pkg/dataexport/database.go @@ -36,10 +36,10 @@ type ObservationRow struct { type DatabaseInterface interface { // GetFormTypes returns all distinct form types in the observations table GetFormTypes(ctx context.Context) ([]string, error) - + // GetFormTypeSchema analyzes the JSON data structure for a form type and returns column definitions GetFormTypeSchema(ctx context.Context, formType string) (*FormTypeSchema, error) - + // GetObservationsForFormType returns all observations for a specific form type with flattened data GetObservationsForFormType(ctx context.Context, formType string, schema *FormTypeSchema) ([]ObservationRow, error) } diff --git a/synkronus/pkg/dataexport/postgres.go b/synkronus/pkg/dataexport/postgres.go index 120a554a6..dcf9a8dbe 100644 --- a/synkronus/pkg/dataexport/postgres.go +++ b/synkronus/pkg/dataexport/postgres.go @@ -26,13 +26,13 @@ func (p *postgresDB) GetFormTypes(ctx context.Context) ([]string, error) { WHERE deleted = false ORDER BY form_type ` - + rows, err := p.db.QueryContext(ctx, query) if err != nil { return nil, fmt.Errorf("failed to query form types: %w", err) } defer rows.Close() - + var formTypes []string for rows.Next() { var formType string @@ -41,11 +41,11 @@ func (p *postgresDB) GetFormTypes(ctx context.Context) ([]string, error) { } formTypes = append(formTypes, formType) } - + if err := rows.Err(); err != nil { return nil, fmt.Errorf("error iterating form types: %w", err) } - + return formTypes, nil } @@ -91,33 +91,33 @@ func (p *postgresDB) GetFormTypeSchema(ctx context.Context, formType string) (*F agg_types ORDER BY key ` - + rows, err := p.db.QueryContext(ctx, query, formType) if err != nil { return nil, fmt.Errorf("failed to analyze form type schema: %w", err) } defer rows.Close() - + var columns []FormTypeColumn for rows.Next() { var key, typesFound, sqlType string var typeCount int - + if err := rows.Scan(&key, &typesFound, &typeCount, &sqlType); err != nil { return nil, fmt.Errorf("failed to scan column info: %w", err) } - + columns = append(columns, FormTypeColumn{ Key: key, DataType: typesFound, SQLType: sqlType, }) } - + if err := rows.Err(); err != nil { return nil, fmt.Errorf("error iterating column info: %w", err) } - + return &FormTypeSchema{ FormType: formType, Columns: columns, @@ -138,12 +138,12 @@ func (p *postgresDB) GetObservationsForFormType(ctx context.Context, formType st selectParts = append(selectParts, fmt.Sprintf("(data ->> '%s')::text AS data_%s", col.Key, col.Key)) } } - + selectClause := "" if len(selectParts) > 0 { selectClause = ", " + strings.Join(selectParts, ", ") } - + query := fmt.Sprintf(` SELECT observation_id, @@ -160,18 +160,18 @@ func (p *postgresDB) GetObservationsForFormType(ctx context.Context, formType st WHERE form_type = $1 AND deleted = false ORDER BY created_at `, selectClause) - + rows, err := p.db.QueryContext(ctx, query, formType) if err != nil { return nil, fmt.Errorf("failed to query observations for form type %s: %w", formType, err) } defer rows.Close() - + var observations []ObservationRow for rows.Next() { var obs ObservationRow var geolocationBytes []byte - + // Create slice for scanning - base columns plus data fields scanArgs := make([]interface{}, 9+len(schema.Columns)) scanArgs[0] = &obs.ObservationID @@ -183,22 +183,22 @@ func (p *postgresDB) GetObservationsForFormType(ctx context.Context, formType st scanArgs[6] = &obs.Deleted scanArgs[7] = &obs.Version scanArgs[8] = &geolocationBytes - + // Add data field scan targets dataValues := make([]interface{}, len(schema.Columns)) for i := range schema.Columns { scanArgs[9+i] = &dataValues[i] } - + if err := rows.Scan(scanArgs...); err != nil { return nil, fmt.Errorf("failed to scan observation: %w", err) } - + // Handle geolocation if geolocationBytes != nil { obs.Geolocation = json.RawMessage(geolocationBytes) } - + // Build data fields map obs.DataFields = make(map[string]interface{}) for i, col := range schema.Columns { @@ -206,13 +206,13 @@ func (p *postgresDB) GetObservationsForFormType(ctx context.Context, formType st obs.DataFields["data_"+col.Key] = dataValues[i] } } - + observations = append(observations, obs) } - + if err := rows.Err(); err != nil { return nil, fmt.Errorf("error iterating observations: %w", err) } - + return observations, nil } diff --git a/synkronus/pkg/dataexport/postgres_test.go b/synkronus/pkg/dataexport/postgres_test.go index c6f1578f7..3eca6df49 100644 --- a/synkronus/pkg/dataexport/postgres_test.go +++ b/synkronus/pkg/dataexport/postgres_test.go @@ -170,17 +170,17 @@ func TestPostgresDB_GetObservationsForFormType(t *testing.T) { } tests := []struct { - name string - formType string - mockRows *sqlmock.Rows - expectedObsCount int - expectError bool + name string + formType string + mockRows *sqlmock.Rows + expectedObsCount int + expectError bool }{ { name: "successful observations query", formType: "survey", mockRows: sqlmock.NewRows([]string{ - "observation_id", "form_type", "form_version", "created_at", "updated_at", + "observation_id", "form_type", "form_version", "created_at", "updated_at", "synced_at", "deleted", "version", "geolocation", "data_question", "data_rating", }).AddRow( "obs1", "survey", "1.0", "2023-01-01T00:00:00Z", "2023-01-01T00:00:00Z", @@ -196,7 +196,7 @@ func TestPostgresDB_GetObservationsForFormType(t *testing.T) { name: "empty observations", formType: "survey", mockRows: sqlmock.NewRows([]string{ - "observation_id", "form_type", "form_version", "created_at", "updated_at", + "observation_id", "form_type", "form_version", "created_at", "updated_at", "synced_at", "deleted", "version", "geolocation", "data_question", "data_rating", }), expectedObsCount: 0, diff --git a/synkronus/pkg/dataexport/service_test.go b/synkronus/pkg/dataexport/service_test.go index 8f2648c7f..7a1e7bc89 100644 --- a/synkronus/pkg/dataexport/service_test.go +++ b/synkronus/pkg/dataexport/service_test.go @@ -12,11 +12,11 @@ import ( // MockDatabaseInterface is a mock implementation of DatabaseInterface for testing type MockDatabaseInterface struct { - FormTypes []string - FormTypeSchemas map[string]*FormTypeSchema - ObservationsData map[string][]ObservationRow - GetFormTypesError error - GetSchemaError error + FormTypes []string + FormTypeSchemas map[string]*FormTypeSchema + ObservationsData map[string][]ObservationRow + GetFormTypesError error + GetSchemaError error GetObservationsError error } @@ -51,11 +51,11 @@ func (m *MockDatabaseInterface) GetObservationsForFormType(ctx context.Context, func TestService_ExportParquetZip(t *testing.T) { tests := []struct { - name string - mockDB *MockDatabaseInterface - expectedFiles []string - expectError bool - errorContains string + name string + mockDB *MockDatabaseInterface + expectedFiles []string + expectError bool + errorContains string }{ { name: "successful export with multiple form types", @@ -148,7 +148,7 @@ func TestService_ExportParquetZip(t *testing.T) { service := NewService(tt.mockDB, cfg) zipReader, err := service.ExportParquetZip(context.Background()) - + if tt.expectError { if err == nil { t.Errorf("Expected error but got none") @@ -253,10 +253,10 @@ func TestService_buildArrowSchema(t *testing.T) { // Check base fields baseFields := []string{ - "observation_id", "form_type", "form_version", "created_at", + "observation_id", "form_type", "form_version", "created_at", "updated_at", "synced_at", "deleted", "version", "geolocation", } - + for i, expectedName := range baseFields { if arrowSchema.Field(i).Name != expectedName { t.Errorf("Expected field %d to be %s, got %s", i, expectedName, arrowSchema.Field(i).Name) diff --git a/synkronus/pkg/migrations/migrations.go b/synkronus/pkg/migrations/migrations.go index 31ee6a4d7..7c63cdfab 100644 --- a/synkronus/pkg/migrations/migrations.go +++ b/synkronus/pkg/migrations/migrations.go @@ -1,18 +1,18 @@ -package migrations - -import ( - "embed" - "io/fs" -) - -//go:embed sql/*.sql -var migrationFS embed.FS - -// GetFS returns the embedded filesystem containing migration files -func GetFS() fs.FS { - subFS, err := fs.Sub(migrationFS, "sql") - if err != nil { - panic(err) - } - return subFS -} +package migrations + +import ( + "embed" + "io/fs" +) + +//go:embed sql/*.sql +var migrationFS embed.FS + +// GetFS returns the embedded filesystem containing migration files +func GetFS() fs.FS { + subFS, err := fs.Sub(migrationFS, "sql") + if err != nil { + panic(err) + } + return subFS +} diff --git a/synkronus/pkg/sync/geolocation_test.go b/synkronus/pkg/sync/geolocation_test.go index b7d9cbb96..95b31ea69 100644 --- a/synkronus/pkg/sync/geolocation_test.go +++ b/synkronus/pkg/sync/geolocation_test.go @@ -124,11 +124,11 @@ func TestObservationWithGeolocation(t *testing.T) { t.Fatal("Geolocation should not be nil after unmarshaling") } if unmarshaled.Geolocation.Latitude != obs.Geolocation.Latitude { - t.Errorf("Geolocation latitude mismatch: expected %f, got %f", + t.Errorf("Geolocation latitude mismatch: expected %f, got %f", obs.Geolocation.Latitude, unmarshaled.Geolocation.Latitude) } if unmarshaled.Geolocation.Longitude != obs.Geolocation.Longitude { - t.Errorf("Geolocation longitude mismatch: expected %f, got %f", + t.Errorf("Geolocation longitude mismatch: expected %f, got %f", obs.Geolocation.Longitude, unmarshaled.Geolocation.Longitude) } } diff --git a/synkronus/pkg/sync/interface.go b/synkronus/pkg/sync/interface.go index 1226cc14a..74e8fe143 100644 --- a/synkronus/pkg/sync/interface.go +++ b/synkronus/pkg/sync/interface.go @@ -37,9 +37,9 @@ type Observation struct { CreatedAt string `json:"created_at" db:"created_at"` UpdatedAt string `json:"updated_at" db:"updated_at"` SyncedAt *string `json:"synced_at,omitempty" db:"synced_at"` - Deleted bool `json:"deleted" db:"deleted"` - Version int64 `json:"version" db:"version"` - Geolocation *Geolocation `json:"geolocation,omitempty" db:"geolocation,json"` + Deleted bool `json:"deleted" db:"deleted"` + Version int64 `json:"version" db:"version"` + Geolocation *Geolocation `json:"geolocation,omitempty" db:"geolocation,json"` } // SyncPullCursor represents pagination cursor for sync pull operations diff --git a/synkronus/pkg/sync/service.go b/synkronus/pkg/sync/service.go index 88e949047..5c1484189 100644 --- a/synkronus/pkg/sync/service.go +++ b/synkronus/pkg/sync/service.go @@ -206,7 +206,7 @@ func (s *Service) ProcessPushedRecords(ctx context.Context, records []Observatio s.log.Error("Failed to begin transaction", "error", err) return nil, fmt.Errorf("failed to begin transaction: %w", err) } - + committed := false defer func() { if !committed { diff --git a/synkronus/pkg/version/service.go b/synkronus/pkg/version/service.go index 194c5f79b..eace1e035 100644 --- a/synkronus/pkg/version/service.go +++ b/synkronus/pkg/version/service.go @@ -23,10 +23,10 @@ func NewService(db *sql.DB) Service { // SystemVersionInfo holds version and system information type SystemVersionInfo struct { - Server ServerInfo `json:"server"` - Database DatabaseInfo `json:"database,omitempty"` - System SystemInfo `json:"system"` - Build BuildInfo `json:"build"` + Server ServerInfo `json:"server"` + Database DatabaseInfo `json:"database,omitempty"` + System SystemInfo `json:"system"` + Build BuildInfo `json:"build"` } type ServerInfo struct { @@ -42,7 +42,7 @@ type DatabaseInfo struct { type SystemInfo struct { OS string `json:"os"` Architecture string `json:"architecture"` - CPUs int `json:"cpus"` + CPUs int `json:"cpus"` } type BuildInfo struct { @@ -90,7 +90,7 @@ func (s *service) GetVersion(ctx context.Context) (*SystemVersionInfo, error) { System: SystemInfo{ OS: runtime.GOOS, Architecture: runtime.GOARCH, - CPUs: runtime.NumCPU(), + CPUs: runtime.NumCPU(), }, Build: BuildInfo{ Commit: commit,