Initial commit: Vue 3 photo viewer, filters, zoom, docs

This commit is contained in:
arucosCIAT Bot
2025-09-25 22:54:44 -06:00
commit f5f48a9d1e
7 changed files with 1028 additions and 0 deletions

748
main.js Normal file
View File

@@ -0,0 +1,748 @@
// 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 File directly; get minimal info + GPS
const source = it.file || 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; };
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);
});
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; };
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 };
},
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="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 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');