Configurar despliegue con Docker, Traefik y Authentik
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 27s
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 27s
- 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
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -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
|
||||
40
.gitea/workflows/build.yml
Normal file
40
.gitea/workflows/build.yml
Normal file
@@ -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
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
photos/*
|
||||
!photos/.gitkeep
|
||||
*.log
|
||||
.env
|
||||
.DS_Store
|
||||
32
Dockerfile
Normal file
32
Dockerfile
Normal file
@@ -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"]
|
||||
148
README.md
Normal file
148
README.md
Normal file
@@ -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
|
||||
83
docker-compose.yml
Normal file
83
docker-compose.yml
Normal file
@@ -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
|
||||
1748
package-lock.json
generated
Normal file
1748
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
package.json
Normal file
16
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
0
photos/.gitkeep
Normal file
0
photos/.gitkeep
Normal file
17
public/index.html
Normal file
17
public/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Visualizador de Fotos</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<!-- Global CDN builds to work from file:// without CORS issues -->
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/exifr/dist/full.umd.js"></script>
|
||||
<script src="./main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
804
public/main.js
Normal file
804
public/main.js
Normal file
@@ -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: `
|
||||
<template v-if="node.type==='dir'">
|
||||
<div class="node dir">
|
||||
<div class="dir-row" @click="toggleExpand(node.path)">
|
||||
<span class="caret">{{ isExpanded(node.path) ? '▼' : '▶' }}</span>
|
||||
<span class="label" :title="node.path">{{ node.name || 'Raíz' }}</span>
|
||||
</div>
|
||||
<div v-if="isExpanded(node.path)" class="children">
|
||||
<TreeNode v-for="child in node.children" :key="child.path || child.id"
|
||||
:node="child"
|
||||
:isExpanded="isExpanded"
|
||||
:toggleExpand="toggleExpand"
|
||||
:isActiveId="isActiveId"
|
||||
:selectById="selectById"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="node file" :class="{ active: isActiveId(node.id) }" @click="selectById(node.id)">
|
||||
<span class="dot">•</span>
|
||||
<span class="label" :title="node.name">{{ node.name }}</span>
|
||||
</div>
|
||||
</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: `
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<div class="brand">{{ lang==='de' ? 'Foto‑Betrachter' : 'Visualizador de Fotos' }}</div>
|
||||
<div class="actions" style="gap:16px; align-items:flex-start;">
|
||||
<div style="display:flex; gap:8px; align-items:center;">
|
||||
<label class="btn">
|
||||
{{ lang==='de' ? 'Bilder laden' : 'Cargar imágenes' }}
|
||||
<input type="file" accept="image/*" multiple webkitdirectory directory @change="onFileInput" />
|
||||
</label>
|
||||
<button class="btn" @click="downloadAllAsZip" v-if="hasImages">{{ lang==='de' ? 'Alle als ZIP herunterladen' : 'Descargar todas en ZIP' }}</button>
|
||||
<button class="btn" @click="toggleLang">{{ lang==='de' ? 'Español' : 'Deutsch' }}</button>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<div class="filter-group">
|
||||
<span class="key">{{ lang==='de' ? 'Finca' : 'Finca' }}</span>
|
||||
<label><input type="checkbox" value="F1" v-model="selectedFincas" /> F1</label>
|
||||
<label><input type="checkbox" value="F2" v-model="selectedFincas" /> F2</label>
|
||||
<label><input type="checkbox" value="F3" v-model="selectedFincas" /> F3</label>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="key">{{ lang==='de' ? 'Aruco' : 'Aruco' }}</span>
|
||||
<label><input type="checkbox" value="G1" v-model="selectedArucos" /> G1</label>
|
||||
<label><input type="checkbox" value="G2" v-model="selectedArucos" /> G2</label>
|
||||
<label><input type="checkbox" value="G3" v-model="selectedArucos" /> G3</label>
|
||||
<label><input type="checkbox" value="P1" v-model="selectedArucos" /> P1</label>
|
||||
<label><input type="checkbox" value="P2" v-model="selectedArucos" /> P2</label>
|
||||
<label><input type="checkbox" value="P3" v-model="selectedArucos" /> P3</label>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="key">{{ lang==='de' ? 'Höhe' : 'Altura' }}</span>
|
||||
<label><input type="checkbox" value="60m" v-model="selectedAlturas" /> 60m</label>
|
||||
<label><input type="checkbox" value="80m" v-model="selectedAlturas" /> 80m</label>
|
||||
<label><input type="checkbox" value="100m" v-model="selectedAlturas" /> 100m</label>
|
||||
</div>
|
||||
<div class="filter-group" v-if="filteredCount === 0 && hasAnyFilter">
|
||||
<button class="btn" @click="clearFilters">{{ lang==='de' ? 'Filter löschen' : 'Limpiar filtros' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="hint">{{ lang==='de' ? 'Tipp: Dateien hierher ziehen' : 'Tip: arrastra y suelta archivos aquí' }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content" @drop="onDrop" @dragover="onDragOver" @dragleave="onDragLeave">
|
||||
<div class="viewer" :class="{ over: dragOver }">
|
||||
<div v-if="!hasImages" class="placeholder">
|
||||
<div class="dropmsg">{{ lang==='de' ? 'Bilder hier ablegen oder „Bilder laden“ benutzen' : 'Suelta imágenes aquí o usa "Cargar imágenes"' }}</div>
|
||||
</div>
|
||||
<div v-else-if="!hasVisible" class="placeholder">
|
||||
<div class="dropmsg">{{ lang==='de' ? 'Keine Ergebnisse für den aktuellen Filter.' : 'No hay resultados para el filtro actual.' }}</div>
|
||||
<div style="margin-top:8px" v-if="hasAnyFilter"><button class="btn" @click="clearFilters">{{ lang==='de' ? 'Filter löschen' : 'Limpiar filtros' }}</button></div>
|
||||
</div>
|
||||
<div v-else class="stage" @wheel.prevent="onWheel">
|
||||
<div v-if="currentItem?.parsed" class="info-header">
|
||||
{{ 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 ?? '-' }}
|
||||
</div>
|
||||
<div class="img-wrap" :class="{grabbing: isPanning}" :style="{ transform: 'translate(' + tx + 'px,' + ty + 'px) scale(' + scale + ')' }" @mousedown="onPointerDown" @mousemove="onPointerMove" @mouseup="onPointerUp" @mouseleave="onPointerUp">
|
||||
<img ref="imgRef" class="photo" :src="currentItem.url" :alt="currentItem.name" />
|
||||
</div>
|
||||
<div class="tools">
|
||||
<button class="tool-btn" @click="zoomBy(0.2)">+</button>
|
||||
<button class="tool-btn" @click="zoomBy(-0.2)">-</button>
|
||||
<button class="tool-btn" @click="resetView">{{ lang==='de' ? 'Zurücksetzen' : 'Reset' }}</button>
|
||||
<a class="tool-btn" :href="currentItem.url" :download="currentItem.name">{{ lang==='de' ? 'Herunterladen' : 'Descargar' }}</a>
|
||||
</div>
|
||||
<div class="nav">
|
||||
<button class="nav-btn left" @click="prev" aria-label="Anterior">◀</button>
|
||||
<button class="nav-btn right" @click="next" aria-label="Siguiente">▶</button>
|
||||
</div>
|
||||
<div class="counter">{{ current + 1 }} / {{ orderedItems.length }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="side">
|
||||
<div class="panel">
|
||||
<h3>{{ lang==='de' ? 'Sortieren' : 'Ordenar' }}</h3>
|
||||
<div class="row">
|
||||
<span class="key">{{ lang==='de' ? 'Feld' : 'Campo' }}</span>
|
||||
<span class="val" style="flex:1">
|
||||
<select v-model="sortKey" class="name-input">
|
||||
<option value="name">{{ lang==='de' ? 'Name' : 'Nombre' }}</option>
|
||||
<option value="size">{{ lang==='de' ? 'Größe' : 'Tamaño' }}</option>
|
||||
<option value="modified">{{ lang==='de' ? 'Geändert' : 'Modificada' }}</option>
|
||||
<option value="exifDate">{{ lang==='de' ? 'EXIF‑Datum' : 'Fecha EXIF' }}</option>
|
||||
<option value="altitude">{{ lang==='de' ? 'Höhe' : 'Altitud' }}</option>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="key">{{ lang==='de' ? 'Richtung' : 'Dirección' }}</span>
|
||||
<span class="val" style="flex:1">
|
||||
<select v-model="sortDir" class="name-input">
|
||||
<option value="asc">{{ lang==='de' ? 'Aufsteigend' : 'Ascendente' }}</option>
|
||||
<option value="desc">{{ lang==='de' ? 'Absteigend' : 'Descendente' }}</option>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
<div class="sep"></div>
|
||||
<h3>{{ lang==='de' ? 'Dateien' : 'Archivos' }}</h3>
|
||||
<div v-if="!hasImages" class="muted">{{ lang==='de' ? 'Keine Bilder' : 'No hay imágenes' }}</div>
|
||||
<div v-else class="tree">
|
||||
<TreeNode v-for="node in treeRoot.children" :key="node.path || node.id"
|
||||
:node="node"
|
||||
:isExpanded="isExpanded"
|
||||
:toggleExpand="toggleExpand"
|
||||
:isActiveId="isActiveId"
|
||||
:selectById="selectById"
|
||||
/>
|
||||
</div>
|
||||
<div class="sep"></div>
|
||||
<h3>{{ lang==='de' ? 'Details' : 'Detalles' }}</h3>
|
||||
<div v-if="!hasImages" class="muted">{{ lang==='de' ? 'Keine Bilder geladen' : 'No hay imágenes cargadas' }}</div>
|
||||
<div v-else-if="!hasVisible" class="muted">{{ lang==='de' ? 'Keine Ergebnisse für den aktuellen Filter.' : 'No hay resultados del filtro' }}</div>
|
||||
<template v-else>
|
||||
<div style="margin-bottom:12px;">
|
||||
<a :href="currentItem.url" :download="currentItem.name" class="btn" style="display:block; text-align:center; text-decoration:none;">
|
||||
{{ lang==='de' ? 'Bild herunterladen' : 'Descargar imagen' }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="key">{{ lang==='de' ? 'Name' : 'Nombre' }}</span>
|
||||
<span class="val" style="flex:1">
|
||||
<input class="name-input" v-model="currentItem.newName" @keydown.enter.prevent="onCommitName" @blur="onCommitName" />
|
||||
<div class="small-muted" v-if="currentItem.renameMessage">
|
||||
<span :class="{ ok: currentItem.renameStatus==='ok', warn: currentItem.renameStatus==='warn' }">{{ currentItem.renameMessage }}</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div class="row"><span class="key">{{ lang==='de' ? 'Größe' : 'Tamaño' }}</span><span class="val">{{ (currentItem.size/1024/1024).toFixed(2) }} MB</span></div>
|
||||
<div class="row"><span class="key">{{ lang==='de' ? 'Geändert' : 'Modificada' }}</span><span class="val">{{ new Date(currentItem.lastModified).toLocaleString() }}</span></div>
|
||||
<div class="sep"></div>
|
||||
<div class="row" v-if="loadingMeta"><span class="key">GPS</span><span class="val">{{ lang==='de' ? 'EXIF wird gelesen…' : 'Leyendo EXIF…' }}</span></div>
|
||||
<template v-else>
|
||||
<div class="row" v-if="currentItem.meta?.latText"><span class="key">{{ lang==='de' ? 'Breitengrad' : 'Latitud' }}</span><span class="val">{{ currentItem.meta.latText }}</span></div>
|
||||
<div class="row" v-if="currentItem.meta?.lngText"><span class="key">{{ lang==='de' ? 'Längengrad' : 'Longitud' }}</span><span class="val">{{ currentItem.meta.lngText }}</span></div>
|
||||
<div class="row" v-if="currentItem.meta?.altitudeText"><span class="key">{{ lang==='de' ? 'Höhe' : 'Altitud' }}</span><span class="val">{{ currentItem.meta.altitudeText }}</span></div>
|
||||
<div class="row" v-if="currentItem.meta?.lat != null && currentItem.meta?.lng != null">
|
||||
<span class="key">GPS</span>
|
||||
<span class="val"><a :href="'https://www.google.com/maps?q=' + currentItem.meta.lat + ',' + currentItem.meta.lng" target="_blank" rel="noopener">{{ lang==='de' ? 'In Google Maps öffnen' : 'Abrir en Google Maps' }}</a></span>
|
||||
</div>
|
||||
<div class="row" v-if="!currentItem.metaError && (currentItem.meta?.lat == null || currentItem.meta?.lng == null)"><span class="key">GPS</span><span class="val muted">{{ lang==='de' ? 'Nicht verfügbar' : 'No disponible' }}</span></div>
|
||||
<div class="row" v-if="currentItem.meta?.make"><span class="key">{{ lang==='de' ? 'Kamera' : 'Cámara' }}</span><span class="val">{{ currentItem.meta.make }} {{ currentItem.meta.model || '' }}</span></div>
|
||||
<div class="row" v-if="currentItem.meta?.date"><span class="key">{{ lang==='de' ? 'Datum' : 'Fecha' }}</span><span class="val">{{ new Date(currentItem.meta.date).toLocaleString() }}</span></div>
|
||||
<div class="row error" v-if="currentItem.metaError"><span class="key">EXIF</span><span class="val">{{ currentItem.metaError }}</span></div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
createApp(app).component('TreeNode', TreeNode).mount('#app');
|
||||
90
public/styles.css
Normal file
90
public/styles.css
Normal file
@@ -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; }
|
||||
98
server.js
Normal file
98
server.js
Normal file
@@ -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`);
|
||||
});
|
||||
Reference in New Issue
Block a user