// Use global Vue and exifr (loaded via CDN in index.html) const { createApp, ref, computed, onMounted, onUnmounted, watch } = Vue; const formatLatLng = (num, isLat) => { if (typeof num !== 'number' || Number.isNaN(num)) return null; const dir = isLat ? (num >= 0 ? 'N' : 'S') : (num >= 0 ? 'E' : 'W'); const abs = Math.abs(num); const deg = Math.floor(abs); const minFloat = (abs - deg) * 60; const min = Math.floor(minFloat); const sec = (minFloat - min) * 60; return `${deg}° ${min}' ${sec.toFixed(2)}" ${dir}`; }; const TreeNode = { props: ['node', 'isExpanded', 'toggleExpand', 'isActiveId', 'selectById'], template: ` `, }; const app = { setup() { const items = ref([]); // [{ file, url, name, size, lastModified, meta? }] const current = ref(0); // index within orderedItems const loadingMeta = ref(false); const dragOver = ref(false); const imgRef = ref(null); const rootDirHandle = ref(null); // FS Access handle for the images folder (opened via Abrir carpeta) const projectRootHandle = ref(null); // FS Access handle for the project root (to write manifest.js) const sortKey = ref('name'); // name | size | modified | exifDate | altitude const sortDir = ref('asc'); // asc | desc const expanded = ref({ '': true }); // tree expansion state by dir path const selectedFincas = ref([]); // ['F1','F2','F3'] const selectedAlturas = ref([]); // ['60m','80m','100m'] const selectedArucos = ref([]); // ['G1','G2','G3','P1','P2','P3'] const lang = ref('es'); const toggleLang = () => { lang.value = lang.value === 'es' ? 'de' : 'es'; }; const hasImages = computed(() => items.value.length > 0); const filteredCount = computed(() => orderedItems.value.length); const hasVisible = computed(() => filteredCount.value > 0); const matchesFilters = (it) => { const p = it.parsed || {}; // Finca if (selectedFincas.value.length) { if (!p.finca || !selectedFincas.value.includes(p.finca)) return false; } // Altura if (selectedAlturas.value.length) { if (!p.alturaText || !selectedAlturas.value.includes(p.alturaText)) return false; } // Arucos: match if item has ANY of the selected if (selectedArucos.value.length) { const arr = p.arucos || []; if (!arr.some(a => selectedArucos.value.includes(a))) return false; } return true; }; const orderedItems = computed(() => { const arr = items.value.filter(matchesFilters).slice(); const dir = sortDir.value === 'desc' ? -1 : 1; const key = sortKey.value; const getVal = (it) => { switch (key) { case 'name': return (it.name || '').toLowerCase(); case 'size': return it.size || 0; case 'modified': return it.lastModified || 0; case 'exifDate': return it.meta?.date ? new Date(it.meta.date).getTime() : null; case 'altitude': return typeof it.meta?.altitude === 'number' ? it.meta.altitude : null; default: return (it.name || '').toLowerCase(); } }; arr.sort((a, b) => { const va = getVal(a); const vb = getVal(b); const aNull = (va == null); const bNull = (vb == null); if (aNull && bNull) return 0; if (aNull) return 1; // nulls last if (bNull) return -1; if (va < vb) return -1 * dir; if (va > vb) return 1 * dir; // tie-breaker by name const na = (a.name || '').toLowerCase(); const nb = (b.name || '').toLowerCase(); if (na < nb) return -1; if (na > nb) return 1; return 0; }); return arr; }); const currentItem = computed(() => hasVisible.value ? orderedItems.value[current.value] : null); const parseFromName = (filename) => { const base = filename.replace(/\.[^.]+$/, ''); const parts = base.split('-'); let finca = parts[0] || null; let alturaText = null; let arucosText = null; let toma = null; if (parts.length >= 2) { const alt = parts[1] || ''; // Accept '60m' or 'M60' forms; normalize to '60m' const m = alt.match(/(?:^M(\d+)$)|^(\d+)m$/i); const num = m ? (m[1] || m[2]) : null; alturaText = num ? (num + 'm') : alt; } if (parts.length >= 3) arucosText = parts[2] || null; if (parts.length >= 4) { const t = parts[3]; const tm = t.match(/(\d+)/); toma = tm ? parseInt(tm[1], 10) : null; } // Parse arucos like 'P1P2P3' or 'G1G2' into tokens const arucos = []; if (arucosText) { const re = /[GP][1-3]/g; let m; while ((m = re.exec(arucosText))) arucos.push(m[0]); // If commas present, also split and normalize tokens if (arucos.length === 0) { for (const tok of arucosText.split(/[,+\s]+/)) if (/^[GP][1-3]$/.test(tok)) arucos.push(tok); } } return { finca, alturaText, arucos, toma }; }; const decorateItem = (obj) => { const parsed = parseFromName(obj.name || ''); obj.parsed = parsed; return obj; }; const selectFiles = async (fileList) => { if (!fileList) return; // Revoke old URLs for (const it of items.value) { try { URL.revokeObjectURL(it.url); } catch {} } const files = Array.from(fileList).filter(f => f.type.startsWith('image/')); items.value = files.map((f, i) => decorateItem({ file: f, url: URL.createObjectURL(f), name: f.name, newName: f.name, size: f.size, lastModified: f.lastModified, path: f.webkitRelativePath || '', meta: null, metaError: null, renameStatus: null, // 'ok' | 'warn' | 'error' renameMessage: null, id: (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : (Date.now() + '-' + Math.random().toString(36).slice(2) + '-' + i), })); current.value = 0; // Preload metadata for the first image if (items.value.length) await ensureMetadataIndex(0); }; const ensureMetadataItem = async (it) => { if (!it || it.meta || it.metaError) return; loadingMeta.value = true; try { // exifr can parse URL or File; get minimal info + GPS const source = it.url; const meta = await exifr.parse(source, { tiff: true, ifd0: true, exif: true, gps: true }); let lat = null, lng = null; let altitudeVal = null; let altitudeRef = null; // 0 = above sea level, 1 = below try { const gps = await exifr.gps(source); if (gps && typeof gps.latitude === 'number' && typeof gps.longitude === 'number') { lat = gps.latitude; lng = gps.longitude; } // Try altitude from gps block const candAlt = gps?.altitude ?? gps?.Altitude ?? gps?.GPSAltitude; const candRef = gps?.altitudeRef ?? gps?.AltitudeRef ?? gps?.GPSAltitudeRef; if (typeof candAlt === 'number') altitudeVal = candAlt; if (candRef != null) altitudeRef = candRef; } catch {} if (altitudeVal == null) { const candAlt2 = meta?.GPSAltitude ?? meta?.Altitude ?? meta?.altitude; const candRef2 = meta?.GPSAltitudeRef ?? meta?.AltitudeRef ?? meta?.altitudeRef; if (typeof candAlt2 === 'number') altitudeVal = candAlt2; if (candRef2 != null) altitudeRef = candRef2; } // Normalize altitude: if ref==1 means below sea level -> negative if (typeof altitudeVal === 'number' && altitudeRef === 1) altitudeVal = -Math.abs(altitudeVal); it.meta = { lat, lng, latText: lat != null ? formatLatLng(lat, true) : null, lngText: lng != null ? formatLatLng(lng, false) : null, altitude: typeof altitudeVal === 'number' ? altitudeVal : null, altitudeText: typeof altitudeVal === 'number' ? `${altitudeVal.toFixed(2)} m` : null, make: meta?.Make || null, model: meta?.Model || null, date: meta?.DateTimeOriginal || meta?.CreateDate || null, orientation: meta?.Orientation || null, }; } catch (e) { it.metaError = e?.message || String(e); } finally { loadingMeta.value = false; } }; const ensureMetadataIndex = async (idx) => { const it = orderedItems.value[idx] ?? items.value[idx]; if (it) await ensureMetadataItem(it); }; const prefetchMetaIfNeeded = () => { if (sortKey.value === 'exifDate' || sortKey.value === 'altitude') { // Fire and forget for (const it of items.value) { ensureMetadataItem(it); } } }; const onFileInput = (e) => selectFiles(e.target.files); // Zoom and pan state const scale = ref(1); const tx = ref(0); const ty = ref(0); const isPanning = ref(false); const panStart = { x: 0, y: 0, tx: 0, ty: 0 }; const resetView = () => { scale.value = 1; tx.value = 0; ty.value = 0; }; const zoomBy = (delta) => { scale.value = Math.min(12, Math.max(1, scale.value + delta)); if (scale.value === 1) { tx.value = 0; ty.value = 0; } }; const onWheel = (e) => { e.preventDefault(); const delta = -Math.sign(e.deltaY) * 0.1; zoomBy(delta); }; const onPointerDown = (e) => { if (scale.value <= 1) return; isPanning.value = true; panStart.x = e.clientX; panStart.y = e.clientY; panStart.tx = tx.value; panStart.ty = ty.value; }; const onPointerMove = (e) => { if (!isPanning.value) return; tx.value = panStart.tx + (e.clientX - panStart.x); ty.value = panStart.ty + (e.clientY - panStart.y); }; const onPointerUp = () => { isPanning.value = false; }; const verifyPermission = async (handle, mode = 'read') => { try { const opts = { mode }; if ((await handle.queryPermission?.(opts)) === 'granted') return true; return (await handle.requestPermission?.(opts)) === 'granted'; } catch { return false; } }; const enumerateImages = async (dirHandle, basePath = '') => { const out = []; // Prefer values() for broader compatibility const iterator = dirHandle.values ? dirHandle.values() : dirHandle.entries(); for await (const entry of iterator) { const handle = entry[1] || entry; // entries(): [name, handle] or values(): handle try { if (handle.kind === 'file') { const file = await handle.getFile(); const name = handle.name || file.name; if (!file.type || file.type.startsWith('image/')) { out.push({ file, handle, dirHandle, path: basePath ? basePath + '/' + name : name }); } } else if (handle.kind === 'directory') { const name = handle.name; const sub = await enumerateImages(handle, basePath ? basePath + '/' + name : name); out.push(...sub); } } catch {} } return out; }; const onOpenFolder = async () => { if (!('showDirectoryPicker' in window)) { alert('Tu navegador no soporta acceso directo a carpeta. Usa "Cargar imágenes".'); return; } try { const dir = await window.showDirectoryPicker({ mode: 'readwrite' }); const ok = await verifyPermission(dir, 'read'); if (!ok) { alert('No se concedió permiso de lectura a la carpeta.'); return; } rootDirHandle.value = dir; const entries = await enumerateImages(dir, ''); // Revoke old URLs for (const it of items.value) { try { URL.revokeObjectURL(it.url); } catch {} } items.value = entries.map((en, i) => decorateItem({ file: en.file, fileHandle: en.handle, dirHandle: en.dirHandle, url: URL.createObjectURL(en.file), name: en.file.name, newName: en.file.name, size: en.file.size, lastModified: en.file.lastModified, path: en.path, meta: null, metaError: null, renameStatus: null, renameMessage: null, id: (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : (Date.now() + '-' + Math.random().toString(36).slice(2) + '-' + i), })); current.value = 0; if (items.value.length) await ensureMetadataIndex(0); // Try to write manifest if project root already selected await writeManifestIfPossible(); } catch (e) { // cancelled or denied } }; const onRefreshFolder = async () => { if (!rootDirHandle.value) { alert('Primero abrí una carpeta.'); return; } try { const ok = await verifyPermission(rootDirHandle.value, 'read'); if (!ok) { alert('Sin permiso de lectura para refrescar.'); return; } const entries = await enumerateImages(rootDirHandle.value, ''); for (const it of items.value) { try { URL.revokeObjectURL(it.url); } catch {} } items.value = entries.map((en, i) => decorateItem({ file: en.file, fileHandle: en.handle, dirHandle: en.dirHandle, url: URL.createObjectURL(en.file), name: en.file.name, newName: en.file.name, size: en.file.size, lastModified: en.file.lastModified, path: en.path, meta: null, metaError: null, renameStatus: null, renameMessage: null, id: (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : (Date.now() + '-' + Math.random().toString(36).slice(2) + '-' + i), })); current.value = 0; if (items.value.length) await ensureMetadataIndex(0); await writeManifestIfPossible(); } catch (e) { console.warn('Error al refrescar carpeta:', e); } }; const onSelectProjectRoot = async () => { if (!('showDirectoryPicker' in window)) { alert('Tu navegador no soporta seleccionar raíz del proyecto.'); return; } try { const dir = await window.showDirectoryPicker({ mode: 'readwrite' }); const ok = await verifyPermission(dir, 'readwrite'); if (!ok) { alert('No se concedió permiso de lectura/escritura a la raíz.'); return; } projectRootHandle.value = dir; await writeManifestIfPossible(); alert('Manifest actualizado en la raíz del proyecto.'); } catch (e) { // cancelled or denied } }; const buildManifestContent = (names) => { const lines = names.map(n => ` "${n}"`).join(',\n'); return `// Auto-generated list of images in ./imagenes at the same level as index.html\nwindow.IMG_MANIFEST = [\n${lines}\n];\n`; }; const getCurrentManifestNames = () => { // Prefer items inside the opened folder (rootDirHandle); fall back to items with url under imagenes const imgs = items.value.filter(it => it.name && (!it.name.includes(':'))); // Keep stable by name ascending const names = imgs.map(it => it.name).sort((a, b) => a.localeCompare(b)); return names; }; const writeManifestIfPossible = async () => { if (!projectRootHandle.value) return false; try { const fileHandle = await projectRootHandle.value.getFileHandle('manifest.js', { create: true }); const writable = await fileHandle.createWritable(); const names = getCurrentManifestNames(); await writable.write(buildManifestContent(names)); await writable.close(); return true; } catch (e) { console.warn('No se pudo escribir manifest.js:', e); return false; } }; const onDrop = (e) => { e.preventDefault(); dragOver.value = false; const dt = e.dataTransfer; if (!dt) return; let files = []; if (dt.items && dt.items.length) { for (const item of dt.items) { if (item.kind === 'file') files.push(item.getAsFile()); } } else if (dt.files && dt.files.length) { files = Array.from(dt.files); } selectFiles(files); }; const onDragOver = (e) => { e.preventDefault(); dragOver.value = true; }; const onDragLeave = (e) => { e.preventDefault(); dragOver.value = false; }; // Cargar fotos desde el servidor const loadPhotosFromServer = async () => { try { const response = await fetch('/api/photos'); if (!response.ok) throw new Error('Error al cargar fotos'); const photos = await response.json(); // Convertir URLs en items items.value = photos.map((photo, i) => decorateItem({ file: null, url: photo.url, name: photo.name, newName: photo.name, size: 0, lastModified: Date.now(), path: photo.name, meta: null, metaError: null, renameStatus: null, renameMessage: null, id: (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : (Date.now() + '-' + Math.random().toString(36).slice(2) + '-' + i), })); current.value = 0; if (items.value.length) await ensureMetadataIndex(0); } catch (error) { console.error('Error al cargar fotos:', error); } }; const prev = () => { const n = orderedItems.value.length; if (!n) return; current.value = (current.value - 1 + n) % n; }; const next = () => { const n = orderedItems.value.length; if (!n) return; current.value = (current.value + 1) % n; }; const onKey = (e) => { if (e.key === 'ArrowLeft') { e.preventDefault(); prev(); } else if (e.key === 'ArrowRight') { e.preventDefault(); next(); } }; watch(current, (idx) => { ensureMetadataIndex(idx); resetView(); }); watch(orderedItems, (arr) => { if (current.value >= arr.length) current.value = 0; }); watch(sortKey, prefetchMetaIfNeeded); watch(sortDir, () => {}); const buildTree = () => { const root = { type: 'dir', name: '', path: '', children: [] }; const dirMap = { '': root }; const getDirNode = (dirPath) => { if (dirMap[dirPath]) return dirMap[dirPath]; const parts = dirPath.split('/').filter(Boolean); let curPath = ''; let parent = root; for (const p of parts) { curPath = curPath ? curPath + '/' + p : p; if (!dirMap[curPath]) { const node = { type: 'dir', name: p, path: curPath, children: [] }; dirMap[curPath] = node; parent.children.push(node); } parent = dirMap[curPath]; } return dirMap[dirPath]; }; orderedItems.value.forEach((it) => { const dirPath = (it.path && it.path.includes('/')) ? it.path.split('/').slice(0, -1).join('/') : ''; const dirNode = getDirNode(dirPath); dirNode.children.push({ type: 'file', name: it.name, id: it.id, item: it }); }); return root; }; const treeRoot = computed(buildTree); const isActiveId = (id) => currentItem.value && currentItem.value.id === id; const selectById = (id) => { const idx = orderedItems.value.findIndex(it => it.id === id); if (idx >= 0) current.value = idx; }; const toggleExpand = (path) => { expanded.value[path] = !expanded.value[path]; }; const isExpanded = (path) => expanded.value[path] ?? true; const ensureExtension = (base, originalName) => { const origExt = (originalName.split('.').pop() || '').toLowerCase(); if (!base.includes('.')) return `${base}.${origExt}`; return base; }; const pickRootIfNeeded = async () => { if (!('showDirectoryPicker' in window)) return null; if (!rootDirHandle.value) { try { rootDirHandle.value = await window.showDirectoryPicker({ mode: 'readwrite' }); } catch (e) { return null; } } return rootDirHandle.value; }; const getDirHandleForPath = async (root, relPath) => { // relPath like "folder/sub/file.jpg"; we need the directory handle for that path const parts = relPath.split('/').filter(Boolean); if (parts.length <= 1) return root; // file at root const dirParts = parts.slice(0, -1); let dir = root; for (const p of dirParts) { dir = await dir.getDirectoryHandle(p, { create: false }); } return dir; }; const renameInPlace = async (it, newName) => { // Use existing directory handle if present, else prompt once let dir = it.dirHandle; if (!dir) { if (!('showDirectoryPicker' in window)) throw new Error('El navegador no permite renombrar archivos directamente.'); if (!it.path) throw new Error('Para renombrar automáticamente, abre la carpeta con "Abrir carpeta".'); const root = await pickRootIfNeeded(); if (!root) throw new Error('Permiso denegado o carpeta no seleccionada.'); dir = await getDirHandleForPath(root, it.path); } const canWrite = await verifyPermission(dir, 'readwrite'); if (!canWrite) throw new Error('Se necesita permiso de escritura para renombrar.'); const oldName = it.name; const finalName = ensureExtension(newName, it.name); if (finalName === oldName) return { status: 'ok', message: 'Sin cambios.' }; // Create new file and write content const newHandle = await dir.getFileHandle(finalName, { create: true }); const ws = await newHandle.createWritable(); const blob = it.file || (await (await dir.getFileHandle(oldName, { create: false })).getFile()); await ws.write(blob); await ws.close(); await dir.removeEntry(oldName); const newFile = await newHandle.getFile(); // Update item to point to new file try { URL.revokeObjectURL(it.url); } catch {} it.file = newFile; it.url = URL.createObjectURL(newFile); it.name = finalName; it.newName = finalName; it.parsed = parseFromName(finalName); // Update path to new name if (it.path) { const parts = it.path.split('/'); parts[parts.length - 1] = finalName; it.path = parts.join('/'); } it.fileHandle = newHandle; it.dirHandle = dir; // Attempt to refresh manifest await writeManifestIfPossible(); return { status: 'ok', message: 'Renombrado en carpeta seleccionada.' }; }; const saveAsCopy = async (it, newName) => { if (!('showSaveFilePicker' in window)) throw new Error('El navegador no soporta "Guardar como".'); const finalName = ensureExtension(newName, it.name); const handle = await window.showSaveFilePicker({ suggestedName: finalName, types: [{ description: 'Imagen', accept: { [it.file.type || 'image/*']: ['.' + (finalName.split('.').pop() || 'jpg')] } }] }); const ws = await handle.createWritable(); await ws.write(it.file); await ws.close(); return { status: 'warn', message: 'Copia guardada con el nuevo nombre.' }; }; const onCommitName = async () => { const it = currentItem.value; if (!it) return; const newBase = (it.newName || '').trim(); if (!newBase || newBase === it.name) return; try { let res; if ('showSaveFilePicker' in window) { res = await saveAsCopy(it, newBase); } else { throw new Error('Renombrado no disponible sin soporte de Guardar como.'); } it.renameStatus = res.status; it.renameMessage = res.message; } catch (e) { it.renameStatus = 'error'; it.renameMessage = e?.message || String(e); } }; onMounted(() => { window.addEventListener('keydown', onKey); // Cargar fotos automáticamente al iniciar loadPhotosFromServer(); }); onUnmounted(() => { window.removeEventListener('keydown', onKey); for (const it of items.value) { try { URL.revokeObjectURL(it.url); } catch {} } }); const hasAnyFilter = computed(() => selectedFincas.value.length || selectedAlturas.value.length || selectedArucos.value.length); const clearFilters = () => { selectedFincas.value = []; selectedAlturas.value = []; selectedArucos.value = []; current.value = 0; }; const downloadAllAsZip = async () => { try { const response = await fetch('/api/photos/zip'); if (!response.ok) throw new Error('Error al descargar zip'); const blob = await response.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'fotos.zip'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (error) { console.error('Error al descargar zip:', error); alert(lang.value === 'de' ? 'Fehler beim Herunterladen des ZIP' : 'Error al descargar el ZIP'); } }; return { items, current, currentItem, hasImages, hasVisible, onFileInput, onDrop, onDragOver, onDragLeave, dragOver, prev, next, loadingMeta, imgRef, onCommitName, sortKey, sortDir, orderedItems, treeRoot, toggleExpand, isExpanded, selectById, isActiveId, selectedFincas, selectedAlturas, selectedArucos, filteredCount, hasAnyFilter, clearFilters, scale, tx, ty, isPanning, resetView, zoomBy, onWheel, onPointerDown, onPointerMove, onPointerUp, lang, toggleLang, downloadAllAsZip }; }, template: `
{{ lang==='de' ? 'Foto‑Betrachter' : 'Visualizador de Fotos' }}
{{ lang==='de' ? 'Finca' : 'Finca' }}
{{ lang==='de' ? 'Aruco' : 'Aruco' }}
{{ lang==='de' ? 'Höhe' : 'Altura' }}
{{ lang==='de' ? 'Tipp: Dateien hierher ziehen' : 'Tip: arrastra y suelta archivos aquí' }}
{{ lang==='de' ? 'Bilder hier ablegen oder „Bilder laden“ benutzen' : 'Suelta imágenes aquí o usa "Cargar imágenes"' }}
{{ lang==='de' ? 'Keine Ergebnisse für den aktuellen Filter.' : 'No hay resultados para el filtro actual.' }}
{{ lang==='de' ? 'Finca' : 'Finca' }}: {{ currentItem.parsed.finca || '-' }}. {{ lang==='de' ? 'Höhe' : 'Altura' }}: {{ currentItem.parsed.alturaText || '-' }}. {{ lang==='de' ? 'Aruco(s)' : 'arucos' }}: {{ (currentItem.parsed.arucos||[]).join(',') || '-' }}. {{ lang==='de' ? 'Aufnahme' : 'Toma' }}: {{ currentItem.parsed.toma ?? '-' }}
{{ lang==='de' ? 'Herunterladen' : 'Descargar' }}
{{ current + 1 }} / {{ orderedItems.length }}
`, }; createApp(app).component('TreeNode', TreeNode).mount('#app');