commit 4c729866aa553d1cefaa126bb6aaee1d4db37b81 Author: josedario87 Date: Mon Oct 27 12:00:05 2025 -0600 Configurar despliegue con Docker, Traefik y Authentik - Agregar Dockerfile para build multi-stage con Node 20 - Configurar docker-compose.yml con Traefik y Authentik exteriorlvl2 - Crear workflow de Gitea Actions para CI/CD automático - Configurar routers público (assets) y protegido (app + APIs) - Documentar arquitectura y proceso de despliegue diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3da34ec --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +npm-debug.log +.git +.gitignore +.gitea +README.md +.claude +*.log +# No ignorar photos durante el build - se monta como volumen diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..038e96d --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,40 @@ +name: build-and-deploy + +on: + push: + branches: [ main ] + +jobs: +#───────────────── build, push & deploy (unified) ───────────────── + build-and-deploy: + runs-on: docker + env: + REG: gitea.nucleoriofrio.com/nucleo000 + steps: + - uses: actions/checkout@v3 + - uses: docker/setup-buildx-action@v2 + - uses: docker/login-action@v2 + with: + registry: gitea.nucleoriofrio.com + username: nucleo000 + password: 7bc7b2fcd283bd6a251bef3ede368b7f897c919d + + - name: Build+push photo-server + run: | + docker build -t $REG/photo-server:${{ github.sha }} -t $REG/photo-server:latest . + docker push $REG/photo-server:${{ github.sha }} + docker push $REG/photo-server:latest + + - name: Info about photos directory + run: | + echo "ℹ️ Photos directory expected at: /srv/photo-server/photos" + echo " Make sure it's mounted and accessible on the deployment host" + + - name: Pull fresh images used in compose + run: docker compose pull + + - name: Clean up stack + run: docker compose --project-name photo-server down + + - name: Update stack + run: docker compose --project-name photo-server up -d --remove-orphans --wait diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ef95e6d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +photos/* +!photos/.gitkeep +*.log +.env +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0fda42e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci --omit=dev + +# Production stage +FROM node:20-alpine AS production + +WORKDIR /app + +# Copy dependencies from builder +COPY --from=builder /app/node_modules ./node_modules + +# Copy application files +COPY server.js ./ +COPY package.json ./ +COPY public ./public + +# Create photos directory for volume mounting +RUN mkdir -p /app/photos + +# Expose port +EXPOSE 3001 + +# Start the application +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..0955a74 --- /dev/null +++ b/README.md @@ -0,0 +1,148 @@ +# Photo Server + +Servidor Express para visualización de fotos con autenticación Authentik y despliegue automático. + +## Características + +- 📸 Visualización de fotos con zoom y pan +- 🔐 Autenticación con Authentik (RBAC) +- 📦 Descarga de todas las fotos como ZIP +- 🐳 Despliegue automático con Gitea Actions +- 🚀 Proxy inverso con Traefik + Let's Encrypt +- 🏷️ Filtros por Finca, Altura y Arucos +- 📊 Información EXIF (GPS, altitud, fecha, cámara) +- 🌍 Interfaz en español y alemán +- ⌨️ Navegación con teclado + +## Arquitectura de Producción + +- **Backend**: Express.js (Node 20) +- **Frontend**: Vue 3 + Vanilla JS +- **Autenticación**: Authentik (forward-auth) +- **Proxy**: Traefik con Let's Encrypt +- **CI/CD**: Gitea Actions +- **Registry**: Gitea Container Registry + +## Requisitos de Despliegue + +### 1. Directorio de fotos en el servidor + +El servidor de producción debe tener las fotos en: +```bash +/srv/photo-server/photos/ +``` + +Este directorio se monta como volumen read-only en el contenedor. + +### 2. Traefik configurado + +- Red Docker `principal` +- Entrypoint `websecure` (puerto 443) +- Let's Encrypt configurado como `letsencrypt` +- Middleware `authentik-forward-auth@file` + +### 3. Authentik configurado + +- Outpost para forward-auth +- Aplicación para `photos.nucleoriofrio.com` +- RBAC configurado + +## Despliegue Automático + +El despliegue es **automático** al hacer push a `main`: + +1. Gitea Actions construye la imagen Docker +2. Sube la imagen al registry: `gitea.nucleoriofrio.com/nucleo000/photo-server:latest` +3. Actualiza el stack en producción con `docker compose` + +### Despliegue Manual + +```bash +# Build +docker build -t gitea.nucleoriofrio.com/nucleo000/photo-server:latest . + +# Push +docker push gitea.nucleoriofrio.com/nucleo000/photo-server:latest + +# Deploy +docker compose pull +docker compose --project-name photo-server down +docker compose --project-name photo-server up -d +``` + +## Desarrollo Local + +### Instalación + +```bash +npm install +``` + +### Agregar fotos + +```bash +cp /ruta/a/tus/fotos/*.jpg photos/ +``` + +### Iniciar servidor local + +```bash +npm start +``` + +Servidor disponible en: http://localhost:3001 + +### Detener servidor + +```bash +# Presiona Ctrl+C en la terminal donde está corriendo +# O busca y termina el proceso +pkill -f "node server.js" +``` + +## URLs + +- **Producción**: https://photos.nucleoriofrio.com (con auth) +- **Local**: http://localhost:3001 (sin auth) + +## Estructura del proyecto + +``` +photo-server/ +├── .gitea/ +│ └── workflows/ +│ └── build.yml # CI/CD workflow +├── public/ # Archivos estáticos +│ ├── index.html # Frontend Vue +│ ├── main.js # Lógica de la aplicación +│ └── styles.css # Estilos +├── photos/ # Fotos (ignorado en git) +├── server.js # Servidor Express +├── Dockerfile # Build del contenedor +├── docker-compose.yml # Despliegue en producción +├── .dockerignore # Archivos excluidos del build +└── package.json # Dependencias +``` + +## Configuración de Traefik + +El `docker-compose.yml` incluye labels de Traefik para: + +- **Router público** (sin auth): Assets estáticos (CSS, JS, favicon) +- **Router protegido** (con Authentik): + - `/` → Página principal + - `/api/photos` → Lista de fotos + - `/api/photos/zip` → Descarga ZIP + - `/photos/:name` → Fotos individuales + +## Variables de Entorno + +- `NODE_ENV=production` → Modo de producción +- `PORT=3001` → Puerto interno del servidor + +## Seguridad + +- Todas las APIs y fotos protegidas con Authentik +- Solo assets estáticos (CSS/JS) son públicos +- Headers de seguridad (X-Frame-Options, X-Content-Type-Options) +- Límite de body de 500MB para descargas ZIP grandes diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3d955e7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,83 @@ +services: + photo-server: + image: gitea.nucleoriofrio.com/nucleo000/photo-server:latest + container_name: photo-server + restart: unless-stopped + volumes: + # Mount photos directory from server + - /srv/photo-server/photos:/app/photos:ro + environment: + - NODE_ENV=production + - PORT=3001 + networks: + - principal + labels: + # Habilitar Traefik + - "traefik.enable=true" + + # ========================================== + # Router público para recursos estáticos (sin autenticación) + # Assets: CSS, JS, favicon, etc. + # ========================================== + - "traefik.http.routers.photos-nucleoriofrio-public.rule=Host(`photos.nucleoriofrio.com`) && (PathPrefix(`/styles.css`) || PathPrefix(`/main.js`) || Path(`/favicon.ico`)) && !PathPrefix(`/api/`)" + - "traefik.http.routers.photos-nucleoriofrio-public.entrypoints=websecure" + - "traefik.http.routers.photos-nucleoriofrio-public.tls.certresolver=letsencrypt" + - "traefik.http.routers.photos-nucleoriofrio-public.priority=100" + - "traefik.http.routers.photos-nucleoriofrio-public.middlewares=photos-static-headers" + - "traefik.http.routers.photos-nucleoriofrio-public.service=photos-nucleoriofrio-service" + + # ========================================== + # Router protegido para el resto de la app + # ========================================== + # IMPORTANTE: Todas las APIs y contenido principal requieren autenticación + # - /api/photos → Lista de fotos (requiere auth) + # - /api/photos/zip → Descarga ZIP (requiere auth) + # - /photos/:name → Ver foto individual (requiere auth) + # - Página principal (/) → Requiere auth + - "traefik.http.routers.photos-nucleoriofrio.rule=Host(`photos.nucleoriofrio.com`)" + - "traefik.http.routers.photos-nucleoriofrio.entrypoints=websecure" + - "traefik.http.routers.photos-nucleoriofrio.tls.certresolver=letsencrypt" + - "traefik.http.routers.photos-nucleoriofrio.priority=50" + # Middlewares (orden: auth -> headers -> body-size) + - "traefik.http.routers.photos-nucleoriofrio.middlewares=photos-authentik,photos-headers,photos-body-size" + - "traefik.http.routers.photos-nucleoriofrio.service=photos-nucleoriofrio-service" + + # ========================================== + # Middleware: Authentik ForwardAuth (mismo que rioCata) + # ========================================== + - "traefik.http.middlewares.photos-authentik.forwardauth.address=http://ak-outpost-exterior-lvl2:9000/outpost.goauthentik.io/auth/traefik" + - "traefik.http.middlewares.photos-authentik.forwardauth.trustForwardHeader=true" + - "traefik.http.middlewares.photos-authentik.forwardauth.authResponseHeaders=X-Authentik-Username,X-Authentik-Email,X-Authentik-Name,X-Authentik-Uid,X-Authentik-Groups,X-Authentik-Entitlements" + + # ========================================== + # Middleware: Headers para assets estáticos + # ========================================== + - "traefik.http.middlewares.photos-static-headers.headers.customrequestheaders.X-Forwarded-Proto=https" + - "traefik.http.middlewares.photos-static-headers.headers.customrequestheaders.X-Forwarded-Scheme=https" + - "traefik.http.middlewares.photos-static-headers.headers.customresponseheaders.X-Content-Type-Options=nosniff" + - "traefik.http.middlewares.photos-static-headers.headers.customresponseheaders.X-XSS-Protection=1; mode=block" + - "traefik.http.middlewares.photos-static-headers.headers.customresponseheaders.Cache-Control=public, max-age=86400" + + # ========================================== + # Middleware: Headers personalizados para app protegida + # ========================================== + - "traefik.http.middlewares.photos-headers.headers.customrequestheaders.X-Forwarded-Proto=https" + - "traefik.http.middlewares.photos-headers.headers.customrequestheaders.X-Forwarded-Scheme=https" + - "traefik.http.middlewares.photos-headers.headers.customresponseheaders.X-Frame-Options=SAMEORIGIN" + - "traefik.http.middlewares.photos-headers.headers.customresponseheaders.X-Content-Type-Options=nosniff" + - "traefik.http.middlewares.photos-headers.headers.customresponseheaders.X-XSS-Protection=1; mode=block" + + # ========================================== + # Middleware: Tamaño máximo de body (500MB para fotos grandes y ZIP) + # ========================================== + - "traefik.http.middlewares.photos-body-size.buffering.maxrequestbodybytes=524288000" + + # ========================================== + # Service + # ========================================== + - "traefik.http.services.photos-nucleoriofrio-service.loadbalancer.server.port=3001" + - "traefik.http.services.photos-nucleoriofrio-service.loadbalancer.passhostheader=true" + +networks: + principal: + external: true diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..edad24c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1748 @@ +{ + "name": "photo-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "photo-server", + "version": "1.0.0", + "dependencies": { + "archiver": "^7.0.1", + "cors": "^2.8.5", + "express": "^4.18.2" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "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==", + "license": "MIT" + }, + "node_modules/bare-events": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.7.0.tgz", + "integrity": "sha512-b3N5eTW1g7vXkw+0CXh/HazGTcO5KYuu/RCNaJbDMPI6LHDi+7qe8EmxKUVe1sUbY2KZOVZFyj62x0OEz9qyAA==", + "license": "Apache-2.0" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "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==", + "license": "MIT" + }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "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==", + "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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/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==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3d53eba --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "photo-server", + "version": "1.0.0", + "description": "Servidor Express para visualizador de fotos", + "type": "module", + "main": "server.js", + "scripts": { + "dev": "PORT=3001 node server.js", + "start": "PORT=3001 node server.js" + }, + "dependencies": { + "archiver": "^7.0.1", + "cors": "^2.8.5", + "express": "^4.18.2" + } +} diff --git a/photos/.gitkeep b/photos/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..4d859ee --- /dev/null +++ b/public/index.html @@ -0,0 +1,17 @@ + + + + + + Visualizador de Fotos + + + +
+ + + + + + + diff --git a/public/main.js b/public/main.js new file mode 100644 index 0000000..25092ab --- /dev/null +++ b/public/main.js @@ -0,0 +1,804 @@ +// Use global Vue and exifr (loaded via CDN in index.html) +const { createApp, ref, computed, onMounted, onUnmounted, watch } = Vue; + +const formatLatLng = (num, isLat) => { + if (typeof num !== 'number' || Number.isNaN(num)) return null; + const dir = isLat ? (num >= 0 ? 'N' : 'S') : (num >= 0 ? 'E' : 'W'); + const abs = Math.abs(num); + const deg = Math.floor(abs); + const minFloat = (abs - deg) * 60; + const min = Math.floor(minFloat); + const sec = (minFloat - min) * 60; + return `${deg}° ${min}' ${sec.toFixed(2)}" ${dir}`; +}; + +const TreeNode = { + props: ['node', 'isExpanded', 'toggleExpand', 'isActiveId', 'selectById'], + template: ` + + + `, +}; + +const app = { + setup() { + const items = ref([]); // [{ file, url, name, size, lastModified, meta? }] + const current = ref(0); // index within orderedItems + const loadingMeta = ref(false); + const dragOver = ref(false); + const imgRef = ref(null); + const rootDirHandle = ref(null); // FS Access handle for the images folder (opened via Abrir carpeta) + const projectRootHandle = ref(null); // FS Access handle for the project root (to write manifest.js) + const sortKey = ref('name'); // name | size | modified | exifDate | altitude + const sortDir = ref('asc'); // asc | desc + const expanded = ref({ '': true }); // tree expansion state by dir path + const selectedFincas = ref([]); // ['F1','F2','F3'] + const selectedAlturas = ref([]); // ['60m','80m','100m'] + const selectedArucos = ref([]); // ['G1','G2','G3','P1','P2','P3'] + const lang = ref('es'); + const toggleLang = () => { lang.value = lang.value === 'es' ? 'de' : 'es'; }; + + const hasImages = computed(() => items.value.length > 0); + const filteredCount = computed(() => orderedItems.value.length); + const hasVisible = computed(() => filteredCount.value > 0); + const matchesFilters = (it) => { + const p = it.parsed || {}; + // Finca + if (selectedFincas.value.length) { + if (!p.finca || !selectedFincas.value.includes(p.finca)) return false; + } + // Altura + if (selectedAlturas.value.length) { + if (!p.alturaText || !selectedAlturas.value.includes(p.alturaText)) return false; + } + // Arucos: match if item has ANY of the selected + if (selectedArucos.value.length) { + const arr = p.arucos || []; + if (!arr.some(a => selectedArucos.value.includes(a))) return false; + } + return true; + }; + const orderedItems = computed(() => { + const arr = items.value.filter(matchesFilters).slice(); + const dir = sortDir.value === 'desc' ? -1 : 1; + const key = sortKey.value; + const getVal = (it) => { + switch (key) { + case 'name': return (it.name || '').toLowerCase(); + case 'size': return it.size || 0; + case 'modified': return it.lastModified || 0; + case 'exifDate': return it.meta?.date ? new Date(it.meta.date).getTime() : null; + case 'altitude': return typeof it.meta?.altitude === 'number' ? it.meta.altitude : null; + default: return (it.name || '').toLowerCase(); + } + }; + arr.sort((a, b) => { + const va = getVal(a); const vb = getVal(b); + const aNull = (va == null); const bNull = (vb == null); + if (aNull && bNull) return 0; + if (aNull) return 1; // nulls last + if (bNull) return -1; + if (va < vb) return -1 * dir; + if (va > vb) return 1 * dir; + // tie-breaker by name + const na = (a.name || '').toLowerCase(); + const nb = (b.name || '').toLowerCase(); + if (na < nb) return -1; + if (na > nb) return 1; + return 0; + }); + return arr; + }); + const currentItem = computed(() => hasVisible.value ? orderedItems.value[current.value] : null); + + const parseFromName = (filename) => { + const base = filename.replace(/\.[^.]+$/, ''); + const parts = base.split('-'); + let finca = parts[0] || null; + let alturaText = null; + let arucosText = null; + let toma = null; + if (parts.length >= 2) { + const alt = parts[1] || ''; + // Accept '60m' or 'M60' forms; normalize to '60m' + const m = alt.match(/(?:^M(\d+)$)|^(\d+)m$/i); + const num = m ? (m[1] || m[2]) : null; + alturaText = num ? (num + 'm') : alt; + } + if (parts.length >= 3) arucosText = parts[2] || null; + if (parts.length >= 4) { + const t = parts[3]; + const tm = t.match(/(\d+)/); + toma = tm ? parseInt(tm[1], 10) : null; + } + // Parse arucos like 'P1P2P3' or 'G1G2' into tokens + const arucos = []; + if (arucosText) { + const re = /[GP][1-3]/g; + let m; + while ((m = re.exec(arucosText))) arucos.push(m[0]); + // If commas present, also split and normalize tokens + if (arucos.length === 0) { + for (const tok of arucosText.split(/[,+\s]+/)) if (/^[GP][1-3]$/.test(tok)) arucos.push(tok); + } + } + return { finca, alturaText, arucos, toma }; + }; + + const decorateItem = (obj) => { + const parsed = parseFromName(obj.name || ''); + obj.parsed = parsed; + return obj; + }; + + const selectFiles = async (fileList) => { + if (!fileList) return; + // Revoke old URLs + for (const it of items.value) { + try { URL.revokeObjectURL(it.url); } catch {} + } + const files = Array.from(fileList).filter(f => f.type.startsWith('image/')); + items.value = files.map((f, i) => decorateItem({ + file: f, + url: URL.createObjectURL(f), + name: f.name, + newName: f.name, + size: f.size, + lastModified: f.lastModified, + path: f.webkitRelativePath || '', + meta: null, + metaError: null, + renameStatus: null, // 'ok' | 'warn' | 'error' + renameMessage: null, + id: (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : (Date.now() + '-' + Math.random().toString(36).slice(2) + '-' + i), + })); + current.value = 0; + // Preload metadata for the first image + if (items.value.length) await ensureMetadataIndex(0); + }; + + const ensureMetadataItem = async (it) => { + if (!it || it.meta || it.metaError) return; + loadingMeta.value = true; + try { + // exifr can parse URL or File; get minimal info + GPS + const source = it.url; + const meta = await exifr.parse(source, { tiff: true, ifd0: true, exif: true, gps: true }); + let lat = null, lng = null; + let altitudeVal = null; + let altitudeRef = null; // 0 = above sea level, 1 = below + try { + const gps = await exifr.gps(source); + if (gps && typeof gps.latitude === 'number' && typeof gps.longitude === 'number') { + lat = gps.latitude; lng = gps.longitude; + } + // Try altitude from gps block + const candAlt = gps?.altitude ?? gps?.Altitude ?? gps?.GPSAltitude; + const candRef = gps?.altitudeRef ?? gps?.AltitudeRef ?? gps?.GPSAltitudeRef; + if (typeof candAlt === 'number') altitudeVal = candAlt; + if (candRef != null) altitudeRef = candRef; + } catch {} + if (altitudeVal == null) { + const candAlt2 = meta?.GPSAltitude ?? meta?.Altitude ?? meta?.altitude; + const candRef2 = meta?.GPSAltitudeRef ?? meta?.AltitudeRef ?? meta?.altitudeRef; + if (typeof candAlt2 === 'number') altitudeVal = candAlt2; + if (candRef2 != null) altitudeRef = candRef2; + } + // Normalize altitude: if ref==1 means below sea level -> negative + if (typeof altitudeVal === 'number' && altitudeRef === 1) altitudeVal = -Math.abs(altitudeVal); + it.meta = { + lat, + lng, + latText: lat != null ? formatLatLng(lat, true) : null, + lngText: lng != null ? formatLatLng(lng, false) : null, + altitude: typeof altitudeVal === 'number' ? altitudeVal : null, + altitudeText: typeof altitudeVal === 'number' ? `${altitudeVal.toFixed(2)} m` : null, + make: meta?.Make || null, + model: meta?.Model || null, + date: meta?.DateTimeOriginal || meta?.CreateDate || null, + orientation: meta?.Orientation || null, + }; + } catch (e) { + it.metaError = e?.message || String(e); + } finally { + loadingMeta.value = false; + } + }; + const ensureMetadataIndex = async (idx) => { + const it = orderedItems.value[idx] ?? items.value[idx]; + if (it) await ensureMetadataItem(it); + }; + + const prefetchMetaIfNeeded = () => { + if (sortKey.value === 'exifDate' || sortKey.value === 'altitude') { + // Fire and forget + for (const it of items.value) { ensureMetadataItem(it); } + } + }; + + const onFileInput = (e) => selectFiles(e.target.files); + + // Zoom and pan state + const scale = ref(1); + const tx = ref(0); + const ty = ref(0); + const isPanning = ref(false); + const panStart = { x: 0, y: 0, tx: 0, ty: 0 }; + + const resetView = () => { scale.value = 1; tx.value = 0; ty.value = 0; }; + const zoomBy = (delta) => { scale.value = Math.min(12, Math.max(1, scale.value + delta)); if (scale.value === 1) { tx.value = 0; ty.value = 0; } }; + const onWheel = (e) => { e.preventDefault(); const delta = -Math.sign(e.deltaY) * 0.1; zoomBy(delta); }; + const onPointerDown = (e) => { if (scale.value <= 1) return; isPanning.value = true; panStart.x = e.clientX; panStart.y = e.clientY; panStart.tx = tx.value; panStart.ty = ty.value; }; + const onPointerMove = (e) => { if (!isPanning.value) return; tx.value = panStart.tx + (e.clientX - panStart.x); ty.value = panStart.ty + (e.clientY - panStart.y); }; + const onPointerUp = () => { isPanning.value = false; }; + + const verifyPermission = async (handle, mode = 'read') => { + try { + const opts = { mode }; + if ((await handle.queryPermission?.(opts)) === 'granted') return true; + return (await handle.requestPermission?.(opts)) === 'granted'; + } catch { return false; } + }; + + const enumerateImages = async (dirHandle, basePath = '') => { + const out = []; + // Prefer values() for broader compatibility + const iterator = dirHandle.values ? dirHandle.values() : dirHandle.entries(); + for await (const entry of iterator) { + const handle = entry[1] || entry; // entries(): [name, handle] or values(): handle + try { + if (handle.kind === 'file') { + const file = await handle.getFile(); + const name = handle.name || file.name; + if (!file.type || file.type.startsWith('image/')) { + out.push({ file, handle, dirHandle, path: basePath ? basePath + '/' + name : name }); + } + } else if (handle.kind === 'directory') { + const name = handle.name; + const sub = await enumerateImages(handle, basePath ? basePath + '/' + name : name); + out.push(...sub); + } + } catch {} + } + return out; + }; + + const onOpenFolder = async () => { + if (!('showDirectoryPicker' in window)) { + alert('Tu navegador no soporta acceso directo a carpeta. Usa "Cargar imágenes".'); + return; + } + try { + const dir = await window.showDirectoryPicker({ mode: 'readwrite' }); + const ok = await verifyPermission(dir, 'read'); + if (!ok) { alert('No se concedió permiso de lectura a la carpeta.'); return; } + rootDirHandle.value = dir; + const entries = await enumerateImages(dir, ''); + // Revoke old URLs + for (const it of items.value) { try { URL.revokeObjectURL(it.url); } catch {} } + items.value = entries.map((en, i) => decorateItem({ + file: en.file, + fileHandle: en.handle, + dirHandle: en.dirHandle, + url: URL.createObjectURL(en.file), + name: en.file.name, + newName: en.file.name, + size: en.file.size, + lastModified: en.file.lastModified, + path: en.path, + meta: null, + metaError: null, + renameStatus: null, + renameMessage: null, + id: (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : (Date.now() + '-' + Math.random().toString(36).slice(2) + '-' + i), + })); + current.value = 0; + if (items.value.length) await ensureMetadataIndex(0); + // Try to write manifest if project root already selected + await writeManifestIfPossible(); + } catch (e) { + // cancelled or denied + } + }; + + const onRefreshFolder = async () => { + if (!rootDirHandle.value) { alert('Primero abrí una carpeta.'); return; } + try { + const ok = await verifyPermission(rootDirHandle.value, 'read'); + if (!ok) { alert('Sin permiso de lectura para refrescar.'); return; } + const entries = await enumerateImages(rootDirHandle.value, ''); + for (const it of items.value) { try { URL.revokeObjectURL(it.url); } catch {} } + items.value = entries.map((en, i) => decorateItem({ + file: en.file, + fileHandle: en.handle, + dirHandle: en.dirHandle, + url: URL.createObjectURL(en.file), + name: en.file.name, + newName: en.file.name, + size: en.file.size, + lastModified: en.file.lastModified, + path: en.path, + meta: null, + metaError: null, + renameStatus: null, + renameMessage: null, + id: (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : (Date.now() + '-' + Math.random().toString(36).slice(2) + '-' + i), + })); + current.value = 0; + if (items.value.length) await ensureMetadataIndex(0); + await writeManifestIfPossible(); + } catch (e) { + console.warn('Error al refrescar carpeta:', e); + } + }; + + const onSelectProjectRoot = async () => { + if (!('showDirectoryPicker' in window)) { + alert('Tu navegador no soporta seleccionar raíz del proyecto.'); + return; + } + try { + const dir = await window.showDirectoryPicker({ mode: 'readwrite' }); + const ok = await verifyPermission(dir, 'readwrite'); + if (!ok) { alert('No se concedió permiso de lectura/escritura a la raíz.'); return; } + projectRootHandle.value = dir; + await writeManifestIfPossible(); + alert('Manifest actualizado en la raíz del proyecto.'); + } catch (e) { + // cancelled or denied + } + }; + + const buildManifestContent = (names) => { + const lines = names.map(n => ` "${n}"`).join(',\n'); + return `// Auto-generated list of images in ./imagenes at the same level as index.html\nwindow.IMG_MANIFEST = [\n${lines}\n];\n`; + }; + + const getCurrentManifestNames = () => { + // Prefer items inside the opened folder (rootDirHandle); fall back to items with url under imagenes + const imgs = items.value.filter(it => it.name && (!it.name.includes(':'))); + // Keep stable by name ascending + const names = imgs.map(it => it.name).sort((a, b) => a.localeCompare(b)); + return names; + }; + + const writeManifestIfPossible = async () => { + if (!projectRootHandle.value) return false; + try { + const fileHandle = await projectRootHandle.value.getFileHandle('manifest.js', { create: true }); + const writable = await fileHandle.createWritable(); + const names = getCurrentManifestNames(); + await writable.write(buildManifestContent(names)); + await writable.close(); + return true; + } catch (e) { + console.warn('No se pudo escribir manifest.js:', e); + return false; + } + }; + + const onDrop = (e) => { + e.preventDefault(); + dragOver.value = false; + const dt = e.dataTransfer; + if (!dt) return; + let files = []; + if (dt.items && dt.items.length) { + for (const item of dt.items) { + if (item.kind === 'file') files.push(item.getAsFile()); + } + } else if (dt.files && dt.files.length) { + files = Array.from(dt.files); + } + selectFiles(files); + }; + + const onDragOver = (e) => { e.preventDefault(); dragOver.value = true; }; + const onDragLeave = (e) => { e.preventDefault(); dragOver.value = false; }; + + // Cargar fotos desde el servidor + const loadPhotosFromServer = async () => { + try { + const response = await fetch('/api/photos'); + if (!response.ok) throw new Error('Error al cargar fotos'); + const photos = await response.json(); + + // Convertir URLs en items + items.value = photos.map((photo, i) => decorateItem({ + file: null, + url: photo.url, + name: photo.name, + newName: photo.name, + size: 0, + lastModified: Date.now(), + path: photo.name, + meta: null, + metaError: null, + renameStatus: null, + renameMessage: null, + id: (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : (Date.now() + '-' + Math.random().toString(36).slice(2) + '-' + i), + })); + current.value = 0; + if (items.value.length) await ensureMetadataIndex(0); + } catch (error) { + console.error('Error al cargar fotos:', error); + } + }; + + const prev = () => { + const n = orderedItems.value.length; + if (!n) return; + current.value = (current.value - 1 + n) % n; + }; + const next = () => { + const n = orderedItems.value.length; + if (!n) return; + current.value = (current.value + 1) % n; + }; + + const onKey = (e) => { + if (e.key === 'ArrowLeft') { e.preventDefault(); prev(); } + else if (e.key === 'ArrowRight') { e.preventDefault(); next(); } + }; + + watch(current, (idx) => { ensureMetadataIndex(idx); resetView(); }); + watch(orderedItems, (arr) => { if (current.value >= arr.length) current.value = 0; }); + watch(sortKey, prefetchMetaIfNeeded); + watch(sortDir, () => {}); + + const buildTree = () => { + const root = { type: 'dir', name: '', path: '', children: [] }; + const dirMap = { '': root }; + const getDirNode = (dirPath) => { + if (dirMap[dirPath]) return dirMap[dirPath]; + const parts = dirPath.split('/').filter(Boolean); + let curPath = ''; + let parent = root; + for (const p of parts) { + curPath = curPath ? curPath + '/' + p : p; + if (!dirMap[curPath]) { + const node = { type: 'dir', name: p, path: curPath, children: [] }; + dirMap[curPath] = node; + parent.children.push(node); + } + parent = dirMap[curPath]; + } + return dirMap[dirPath]; + }; + orderedItems.value.forEach((it) => { + const dirPath = (it.path && it.path.includes('/')) ? it.path.split('/').slice(0, -1).join('/') : ''; + const dirNode = getDirNode(dirPath); + dirNode.children.push({ type: 'file', name: it.name, id: it.id, item: it }); + }); + return root; + }; + const treeRoot = computed(buildTree); + + const isActiveId = (id) => currentItem.value && currentItem.value.id === id; + const selectById = (id) => { + const idx = orderedItems.value.findIndex(it => it.id === id); + if (idx >= 0) current.value = idx; + }; + const toggleExpand = (path) => { expanded.value[path] = !expanded.value[path]; }; + const isExpanded = (path) => expanded.value[path] ?? true; + + const ensureExtension = (base, originalName) => { + const origExt = (originalName.split('.').pop() || '').toLowerCase(); + if (!base.includes('.')) return `${base}.${origExt}`; + return base; + }; + + const pickRootIfNeeded = async () => { + if (!('showDirectoryPicker' in window)) return null; + if (!rootDirHandle.value) { + try { + rootDirHandle.value = await window.showDirectoryPicker({ mode: 'readwrite' }); + } catch (e) { + return null; + } + } + return rootDirHandle.value; + }; + + const getDirHandleForPath = async (root, relPath) => { + // relPath like "folder/sub/file.jpg"; we need the directory handle for that path + const parts = relPath.split('/').filter(Boolean); + if (parts.length <= 1) return root; // file at root + const dirParts = parts.slice(0, -1); + let dir = root; + for (const p of dirParts) { + dir = await dir.getDirectoryHandle(p, { create: false }); + } + return dir; + }; + + const renameInPlace = async (it, newName) => { + // Use existing directory handle if present, else prompt once + let dir = it.dirHandle; + if (!dir) { + if (!('showDirectoryPicker' in window)) throw new Error('El navegador no permite renombrar archivos directamente.'); + if (!it.path) throw new Error('Para renombrar automáticamente, abre la carpeta con "Abrir carpeta".'); + const root = await pickRootIfNeeded(); + if (!root) throw new Error('Permiso denegado o carpeta no seleccionada.'); + dir = await getDirHandleForPath(root, it.path); + } + const canWrite = await verifyPermission(dir, 'readwrite'); + if (!canWrite) throw new Error('Se necesita permiso de escritura para renombrar.'); + const oldName = it.name; + const finalName = ensureExtension(newName, it.name); + if (finalName === oldName) return { status: 'ok', message: 'Sin cambios.' }; + // Create new file and write content + const newHandle = await dir.getFileHandle(finalName, { create: true }); + const ws = await newHandle.createWritable(); + const blob = it.file || (await (await dir.getFileHandle(oldName, { create: false })).getFile()); + await ws.write(blob); + await ws.close(); + await dir.removeEntry(oldName); + const newFile = await newHandle.getFile(); + // Update item to point to new file + try { URL.revokeObjectURL(it.url); } catch {} + it.file = newFile; + it.url = URL.createObjectURL(newFile); + it.name = finalName; + it.newName = finalName; + it.parsed = parseFromName(finalName); + // Update path to new name + if (it.path) { + const parts = it.path.split('/'); + parts[parts.length - 1] = finalName; + it.path = parts.join('/'); + } + it.fileHandle = newHandle; + it.dirHandle = dir; + // Attempt to refresh manifest + await writeManifestIfPossible(); + return { status: 'ok', message: 'Renombrado en carpeta seleccionada.' }; + }; + + const saveAsCopy = async (it, newName) => { + if (!('showSaveFilePicker' in window)) throw new Error('El navegador no soporta "Guardar como".'); + const finalName = ensureExtension(newName, it.name); + const handle = await window.showSaveFilePicker({ + suggestedName: finalName, + types: [{ description: 'Imagen', accept: { [it.file.type || 'image/*']: ['.' + (finalName.split('.').pop() || 'jpg')] } }] + }); + const ws = await handle.createWritable(); + await ws.write(it.file); + await ws.close(); + return { status: 'warn', message: 'Copia guardada con el nuevo nombre.' }; + }; + + const onCommitName = async () => { + const it = currentItem.value; + if (!it) return; + const newBase = (it.newName || '').trim(); + if (!newBase || newBase === it.name) return; + try { + let res; + if ('showSaveFilePicker' in window) { + res = await saveAsCopy(it, newBase); + } else { + throw new Error('Renombrado no disponible sin soporte de Guardar como.'); + } + it.renameStatus = res.status; + it.renameMessage = res.message; + } catch (e) { + it.renameStatus = 'error'; + it.renameMessage = e?.message || String(e); + } + }; + + onMounted(() => { + window.addEventListener('keydown', onKey); + // Cargar fotos automáticamente al iniciar + loadPhotosFromServer(); + }); + onUnmounted(() => { + window.removeEventListener('keydown', onKey); + for (const it of items.value) { + try { URL.revokeObjectURL(it.url); } catch {} + } + }); + + const hasAnyFilter = computed(() => selectedFincas.value.length || selectedAlturas.value.length || selectedArucos.value.length); + const clearFilters = () => { selectedFincas.value = []; selectedAlturas.value = []; selectedArucos.value = []; current.value = 0; }; + + const downloadAllAsZip = async () => { + try { + const response = await fetch('/api/photos/zip'); + if (!response.ok) throw new Error('Error al descargar zip'); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'fotos.zip'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (error) { + console.error('Error al descargar zip:', error); + alert(lang.value === 'de' ? 'Fehler beim Herunterladen des ZIP' : 'Error al descargar el ZIP'); + } + }; + + return { items, current, currentItem, hasImages, hasVisible, onFileInput, onDrop, onDragOver, onDragLeave, dragOver, prev, next, loadingMeta, imgRef, onCommitName, sortKey, sortDir, orderedItems, treeRoot, toggleExpand, isExpanded, selectById, isActiveId, selectedFincas, selectedAlturas, selectedArucos, filteredCount, hasAnyFilter, clearFilters, scale, tx, ty, isPanning, resetView, zoomBy, onWheel, onPointerDown, onPointerMove, onPointerUp, lang, toggleLang, downloadAllAsZip }; + }, + template: ` +
+
+
{{ lang==='de' ? 'Foto‑Betrachter' : 'Visualizador de Fotos' }}
+
+
+ + + +
+
+
+ {{ lang==='de' ? 'Finca' : 'Finca' }} + + + +
+
+ {{ lang==='de' ? 'Aruco' : 'Aruco' }} + + + + + + +
+
+ {{ lang==='de' ? 'Höhe' : 'Altura' }} + + + +
+
+ +
+
+ {{ lang==='de' ? 'Tipp: Dateien hierher ziehen' : 'Tip: arrastra y suelta archivos aquí' }} +
+
+ +
+
+
+
{{ lang==='de' ? 'Bilder hier ablegen oder „Bilder laden“ benutzen' : 'Suelta imágenes aquí o usa "Cargar imágenes"' }}
+
+
+
{{ lang==='de' ? 'Keine Ergebnisse für den aktuellen Filter.' : 'No hay resultados para el filtro actual.' }}
+
+
+
+
+ {{ lang==='de' ? 'Finca' : 'Finca' }}: {{ currentItem.parsed.finca || '-' }}. + {{ lang==='de' ? 'Höhe' : 'Altura' }}: {{ currentItem.parsed.alturaText || '-' }}. + {{ lang==='de' ? 'Aruco(s)' : 'arucos' }}: {{ (currentItem.parsed.arucos||[]).join(',') || '-' }}. + {{ lang==='de' ? 'Aufnahme' : 'Toma' }}: {{ currentItem.parsed.toma ?? '-' }} +
+
+ +
+
+ + + + {{ lang==='de' ? 'Herunterladen' : 'Descargar' }} +
+ +
{{ current + 1 }} / {{ orderedItems.length }}
+
+
+ + +
+
+ `, +}; + +createApp(app).component('TreeNode', TreeNode).mount('#app'); diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..d1bcd61 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,90 @@ +:root { + --bg: #0f1115; + --panel: #181b22; + --text: #e8eaf0; + --muted: #9aa3b2; + --accent: #56b6c2; + --border: #2a2f3a; +} + +* { box-sizing: border-box; } +html, body, #app { height: 100%; margin: 0; background: var(--bg); color: var(--text); font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; } +.app { display: flex; flex-direction: column; height: 100%; } + +.topbar { + min-height: 56px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + border-bottom: 1px solid var(--border); + background: #12151b; +} +.brand { font-weight: 600; letter-spacing: 0.2px; } +.actions { display: flex; gap: 12px; align-items: center; } +.btn { display: inline-flex; align-items: center; gap: 8px; background: var(--panel); border: 1px solid var(--border); padding: 8px 12px; border-radius: 8px; cursor: pointer; color: var(--text); } +.btn input[type=file] { display: none; } +.hint { color: var(--muted); font-size: 12px; } + +.content { display: grid; grid-template-columns: 1fr 320px; flex: 1 1 auto; min-height: 0; } + +.viewer { position: relative; height: 100%; border-right: 1px solid var(--border); display: flex; align-items: center; justify-content: center; background: #0d0f14; overflow: hidden; } +.viewer.over { outline: 2px dashed var(--accent); outline-offset: -8px; } +.placeholder { color: var(--muted); text-align: center; } +.dropmsg { opacity: 0.8; } +.stage { width: 100%; height: 100%; position: relative; display: grid; place-items: center; } +.img-wrap { user-select: none; cursor: grab; } +.img-wrap.grabbing { cursor: grabbing; } +.photo { max-width: 95vw; max-height: 95vh; width: auto; height: auto; object-fit: contain; pointer-events: none; } + +.nav { position: absolute; inset: 0; display: flex; justify-content: space-between; align-items: center; pointer-events: none; } +.nav-btn { pointer-events: auto; margin: 0 8px; background: rgba(0,0,0,0.35); border: 1px solid var(--border); color: var(--text); width: 44px; height: 44px; border-radius: 50%; font-size: 18px; cursor: pointer; } +.nav-btn:hover { background: rgba(0,0,0,0.5); } +.nav .left { } +.nav .right { } +.counter { position: absolute; bottom: 10px; right: 12px; background: rgba(0,0,0,0.5); padding: 4px 8px; border-radius: 6px; font-size: 12px; } +.info-header { position: absolute; top: 8px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.5); padding: 6px 10px; border-radius: 8px; font-size: 13px; white-space: nowrap; z-index: 3; } +.tools { position: absolute; top: 8px; right: 8px; display: flex; gap: 6px; } +.tools { z-index: 3; } +.tool-btn { background: rgba(0,0,0,0.5); border: 1px solid var(--border); color: var(--text); padding: 6px 8px; border-radius: 6px; font-size: 12px; cursor: pointer; } +.tool-btn:hover { background: rgba(0,0,0,0.65); } + +.side { background: var(--panel); height: 100%; overflow: auto; } +.panel { padding: 16px; } +.panel h3 { margin: 0 0 12px; font-size: 16px; font-weight: 600; } +.row { display: flex; justify-content: space-between; gap: 8px; padding: 6px 0; border-bottom: 1px dashed rgba(255,255,255,0.04); } +.row:last-child { border-bottom: none; } +.key { color: var(--muted); min-width: 84px; } +.name-input { width: 100%; padding: 6px 8px; border-radius: 6px; border: 1px solid var(--border); background: #11151c; color: var(--text); } +.small-muted { color: var(--muted); font-size: 12px; } +.ok { color: #8bd17c; } +.warn { color: #f0c674; } +.val a { color: var(--accent); text-decoration: none; } +.val a:hover { text-decoration: underline; } +.sep { height: 12px; } +.muted { color: var(--muted); } +.error .val { color: #ff6b6b; } + +/* Tree */ +.tree { max-height: 35vh; overflow: auto; border: 1px solid var(--border); border-radius: 8px; padding: 8px; background: #12161d; } +.node { padding: 4px 4px; cursor: default; user-select: none; } +.node.file { cursor: pointer; display: flex; align-items: center; gap: 8px; border-radius: 6px; padding: 4px 6px; } +.node.file:hover { background: rgba(255,255,255,0.04); } +.node.file.active { background: rgba(86, 182, 194, 0.15); } +.dir-row { display: flex; align-items: center; gap: 6px; cursor: pointer; border-radius: 6px; padding: 4px 6px; } +.dir-row:hover { background: rgba(255,255,255,0.04); } +.children { padding-left: 16px; } +.caret { width: 14px; display: inline-block; color: var(--muted); } +.dot { color: var(--muted); width: 10px; display: inline-block; text-align: center; } +.label { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } + +@media (max-width: 900px) { + .content { grid-template-columns: 1fr; } +.side { border-top: 1px solid var(--border); } +} + +/* Filters */ +.filters { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; } +.filter-group { display: flex; gap: 8px; align-items: center; background: #12161b; border: 1px solid var(--border); padding: 6px 8px; border-radius: 8px; } +.filter-group label { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; cursor: pointer; } +.filter-group input[type=checkbox] { margin: 0; } diff --git a/server.js b/server.js new file mode 100644 index 0000000..f6510a5 --- /dev/null +++ b/server.js @@ -0,0 +1,98 @@ +import express from 'express'; +import cors from 'cors'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { readdir } from 'fs/promises'; +import archiver from 'archiver'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const app = express(); +const PORT = process.env.PORT || 3001; + +// Middleware +app.use(cors()); +app.use(express.json()); + +// Servir archivos estáticos (HTML, CSS, JS) +app.use(express.static(join(__dirname, 'public'))); + +// Servir fotos +app.use('/photos', express.static(join(__dirname, 'photos'))); + +// API para listar fotos +app.get('/api/photos', async (req, res) => { + try { + const photosDir = join(__dirname, 'photos'); + const files = await readdir(photosDir, { withFileTypes: true }); + + const photos = files + .filter(file => { + if (!file.isFile()) return false; + const ext = file.name.toLowerCase().split('.').pop(); + return ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext); + }) + .map(file => ({ + name: file.name, + url: `/photos/${file.name}` + })); + + res.json(photos); + } catch (error) { + console.error('Error al listar fotos:', error); + res.status(500).json({ error: 'Error al listar fotos' }); + } +}); + +// API para descargar todas las fotos como ZIP +app.get('/api/photos/zip', async (req, res) => { + try { + const photosDir = join(__dirname, 'photos'); + const files = await readdir(photosDir, { withFileTypes: true }); + + const photoFiles = files.filter(file => { + if (!file.isFile()) return false; + const ext = file.name.toLowerCase().split('.').pop(); + return ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext); + }); + + if (photoFiles.length === 0) { + return res.status(404).json({ error: 'No hay fotos para descargar' }); + } + + // Configurar headers para la descarga + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', 'attachment; filename=fotos.zip'); + + // Crear archivo ZIP + const archive = archiver('zip', { + zlib: { level: 9 } // Nivel de compresión + }); + + // Manejar errores del archiver + archive.on('error', (err) => { + console.error('Error al crear ZIP:', err); + res.status(500).json({ error: 'Error al crear ZIP' }); + }); + + // Pipe del archivo al response + archive.pipe(res); + + // Agregar archivos al ZIP + for (const file of photoFiles) { + archive.file(join(photosDir, file.name), { name: file.name }); + } + + // Finalizar el archivo + await archive.finalize(); + } catch (error) { + console.error('Error al generar ZIP:', error); + res.status(500).json({ error: 'Error al generar ZIP' }); + } +}); + +app.listen(PORT, () => { + console.log(`🚀 Servidor de fotos corriendo en http://localhost:${PORT}`); + console.log(`📸 API de fotos disponible en http://localhost:${PORT}/api/photos`); +});