Files
photo-server/public/main.js
josedario87 4c729866aa
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 27s
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
2025-10-27 12:00:05 -06:00

805 lines
37 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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' ? 'FotoBetrachter' : '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' ? 'EXIFDatum' : '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');