From f5f48a9d1e448a6860f8ca8db5aa7abb79dd6b8c Mon Sep 17 00:00:00 2001 From: arucosCIAT Bot Date: Thu, 25 Sep 2025 22:54:44 -0600 Subject: [PATCH] Initial commit: Vue 3 photo viewer, filters, zoom, docs --- .gitignore | 11 + README.md | 66 ++++ README.txt | 95 ++++++ imagenes/.gitkeep | 1 + index.html | 17 ++ main.js | 748 ++++++++++++++++++++++++++++++++++++++++++++++ styles.css | 90 ++++++ 7 files changed, 1028 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 README.txt create mode 100644 imagenes/.gitkeep create mode 100644 index.html create mode 100644 main.js create mode 100644 styles.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0061399 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Ignore image contents but keep the folder +imagenes/* +!imagenes/.gitkeep + +# OS/editor junk +.DS_Store +Thumbs.db +.idea/ +.vscode/ +node_modules/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f536a9 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# Visualizador de Fotos / Foto‑Betrachter + +Aplicación web sin build (CDN) para visualizar y navegar imágenes localmente, leer EXIF (GPS/altitud), filtrar por metadatos embebidos en el nombre del archivo, ordenar, hacer zoom/pan y descargar la imagen actual. No se requiere servidor ni instalación. + +Build‑freie Web‑App (CDN) zum lokalen Betrachten von Bildern, Lesen von EXIF (GPS/Höhe), Filtern anhand im Dateinamen kodierter Metadaten, Sortieren, Zoomen/Pannen und Herunterladen des aktuellen Bildes. Kein Server, keine Installation nötig. + +## Español + +- Abrí `index.html` en tu navegador (funciona por `file://` o `http(s)` local). +- Cargar imágenes: botón “Cargar imágenes” o arrastrar y soltar sobre el visor. Podés seleccionar varias o una carpeta (según navegador). +- Navegación: flechas del teclado (izquierda/derecha) o botones ◀ ▶. +- Filtros (barra superior): + - Finca: F1/F2/F3 + - Aruco: G1/G2/G3/P1/P2/P3 (coincide si contiene cualquiera de los seleccionados) + - Altura: 60m/80m/100m (se interpreta desde nombres como `Finca-Altura-arucos-toma`, aceptando `M60` o `60m`) + - Si el filtro devuelve 0 resultados aparece “Limpiar filtros”. + +Formato de nombres (requerido para filtros) +- Estructura: `Finca-Altura-arucos-toma` +- Ejemplos válidos: + - `F1-M60-P1P2-5.JPG` → Finca F1 · Altura 60m · Arucos P1,P2 · Toma 5 + - `F2-80m-G1-12.jpg` → Finca F2 · Altura 80m · Aruco G1 · Toma 12 +- Altura puede escribirse como `M60` o `60m` (se normaliza a 60m/80m/100m). +- Los arucos pueden ir concatenados (P1P2, G1G2G3) o separados por comas. +- Panel lateral: + - Ordenar por: Nombre, Tamaño, Modificada, Fecha EXIF, Altitud (asc/desc). + - Árbol de archivos: refleja la jerarquía y el orden actual. + - Detalles: Nombre editable (con “Guardar como…”), tamaño, fechas y GPS (si EXIF disponible). Enlace a Google Maps cuando hay coordenadas. +- Zoom y pan: rueda del mouse o botones +/−, “Reset” para volver al encuadre. Arrastrá para panear cuando el zoom > 1. +- Descargar: botón “Descargar” para guardar la imagen actual. +- Idioma: botón “Deutsch/Español” en la barra superior cambia todos los textos de la interfaz. + +Limitaciones +- El renombrado “en sitio” del archivo no se realiza (solo “Guardar como…”). Para manipular archivos en disco se necesitaría la File System Access API en contexto seguro (Chrome/Edge + localhost/https). +- La lectura de EXIF desde URL locales puede variar por navegador. +- Todo ocurre localmente en tu navegador; no se suben imágenes a ningún servidor. + +## Deutsch + +- `index.html` im Browser öffnen (funktioniert via `file://` oder lokalem `http(s)`). +- Bilder laden: „Bilder laden“ oder per Drag‑and‑Drop in den Viewer. Mehrfachauswahl möglich (je nach Browser auch Ordner). +- Navigation: Pfeiltasten (links/rechts) oder Buttons ◀ ▶. +- Filter (obere Leiste): + - Finca: F1/F2/F3 + - Aruco: G1/G2/G3/P1/P2/P3 (erfüllt, wenn eines der ausgewählten vorkommt) + - Höhe: 60m/80m/100m (aus Dateinamen wie `Finca-Altura-arucos-toma` erkannt; sowohl `M60` als auch `60m`) + - Wenn 0 Treffer: „Filter löschen“ erscheint. +- Seitenleiste: + - Sortieren nach: Name, Größe, Geändert, EXIF‑Datum, Höhe (auf/absteigend). + - Dateibaum: zeigt Hierarchie und aktuelle Reihenfolge. + - Details: Bearbeitbarer Name (mit „Speichern unter…“), Größe, Datum und GPS (falls EXIF vorhanden). Link zu Google Maps bei Koordinaten. +- Zoom & Pan: Mausrad oder +/−, „Zurücksetzen“ für Standardansicht. Bei Zoom > 1 ziehen zum Pannen. +- Herunterladen: „Herunterladen“ speichert das aktuelle Bild. +- Sprache: „Deutsch/Español“‑Button in der oberen Leiste wechselt alle UI‑Texte. + +Dateiname‑Format (für Filter erforderlich) +- Struktur: `Finca-Altura-arucos-toma` +- Gültige Beispiele: + - `F1-M60-P1P2-5.JPG` → Finca F1 · Höhe 60m · Aruco(s) P1,P2 · Aufnahme 5 + - `F2-80m-G1-12.jpg` → Finca F2 · Höhe 80m · Aruco G1 · Aufnahme 12 +- Höhe kann als `M60` oder `60m` geschrieben werden (normalisiert zu 60m/80m/100m). +- Arucos können zusammengehängt (P1P2, G1G2G3) oder kommasepariert sein. +Einschränkungen +- Direktes Umbenennen im Dateisystem erfolgt nicht (nur „Speichern unter…“). Für Schreibzugriff wäre die File System Access API im sicheren Kontext (Chrome/Edge + localhost/https) nötig. +- EXIF‑Lesen kann je nach Browser unterschiedlich funktionieren. +- Alles läuft lokal im Browser; es werden keine Bilder hochgeladen. diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..cc8bf32 --- /dev/null +++ b/README.txt @@ -0,0 +1,95 @@ +================================================================================ + VISUALIZADOR DE FOTOS · FOTO‑BETRACHTER +================================================================================ + +Un visor de imágenes ligero (sin build) que corre 100% en tu navegador. +Ein leichter Bildbetrachter (ohne Build), 100% im Browser. + +-------------------------------------------------------------------------------- + ▶ ARRANQUE RÁPIDO · SCHNELLSTART +-------------------------------------------------------------------------------- +1) Abre/Öffne: index.html (funciona via file:// o http(s)/lokal). +2) Carga/Lade Bilder: botón “Cargar imágenes” / „Bilder laden“ o arrastra/ziehen. +3) Navega/Navigiere: ◀ ▶ (flechas/Pfeile) o los botones en pantalla. +4) Filtra/Filter: Finca, Aruco, Altura (puedes combinar Mehrfachauswahl). +5) Ordena/Sortieren: Nombre/Größe/Geändert/EXIF‑Datum/Höhe (asc/desc). +6) Zoom & Pan: rueda/Mausrad o +/−, “Reset/Zurücksetzen” para encuadre normal. +7) Descargar/Herunterladen: guarda la imagen actual. +8) Idioma/Sprache: “Deutsch/Español” cambia todos los textos de la UI. + +-------------------------------------------------------------------------------- + ✦ ¿QUÉ LEE DEL NOMBRE? · WAS STEHT IM DATEINAMEN? +-------------------------------------------------------------------------------- +Formato/Form: Finca-Altura-arucos-toma +Ejemplo/Beispiel: F1-M60-P1P2-5 → Finca=F1 · Altura=60m · Aruco(s)=P1,P2 · Toma=5 +• Altura/Höhe: acepta „M60“ o „60m“ (normaliza a 60m/80m/100m) +• Aruco: G1,G2,G3,P1,P2,P3 (también concatenados: P1P2, G1G2G3) +• Toma/Aufnahme: número entero al final del nombre +• Extensión/Erweiterung: .JPG/.JPEG/.PNG/.WEBP/.GIF/.HEIC + +-------------------------------------------------------------------------------- + ☰ FILTROS (ES) · FILTER (DE) +-------------------------------------------------------------------------------- +ES +• Finca: F1, F2, F3 +• Aruco: G1, G2, G3, P1, P2, P3 (coincide si contiene cualquiera) +• Altura: 60m, 80m, 100m +• Si 0 resultados: aparece “Limpiar filtros” + +DE +• Finca: F1, F2, F3 +• Aruco: G1, G2, G3, P1, P2, P3 (erfüllt, wenn eines vorkommt) +• Höhe: 60m, 80m, 100m +• Bei 0 Treffern: „Filter löschen“ + +-------------------------------------------------------------------------------- + ⇥ LATERAL (ES) · SEITENLEISTE (DE) +-------------------------------------------------------------------------------- +ES +• Ordenar: Nombre, Tamaño, Modificada, Fecha EXIF, Altitud (asc/desc) +• Árbol: jerarquía y orden actuales +• Detalles: nombre editable (Guardar como…), tamaño, fechas +• GPS/EXIF: latitud, longitud, altitud (si disponible) + enlace a Google Maps + +DE +• Sortieren: Name, Größe, Geändert, EXIF‑Datum, Höhe (auf/ab) +• Dateibaum: aktuelle Hierarchie/Reihenfolge +• Details: bearbeitbarer Name („Speichern unter…“), Größe, Datum +• GPS/EXIF: Breite, Länge, Höhe (falls vorhanden) + Link zu Google Maps + +-------------------------------------------------------------------------------- + 🔍 ZOOM / PAN +-------------------------------------------------------------------------------- +• Zoom: rueda del mouse / Mausrad o botones + / − (hasta/bis 12×) +• Pan: arrastra/ziehen la imagen cuando hay zoom > 1 +• Reset/Zurücksetzen: vuelve a encuadre normal + +-------------------------------------------------------------------------------- + ⬇ DESCARGA · HERUNTERLADEN +-------------------------------------------------------------------------------- +• Guarda la imagen actual tal como se ve (sin modificar el archivo original) +• Speichert das aktuelle Bild (Originaldatei bleibt unverändert) + +-------------------------------------------------------------------------------- + 🌐 IDIOMA · SPRACHE +-------------------------------------------------------------------------------- +• Botón “Deutsch/Español” en la barra superior +• Schalter „Deutsch/Español“ oben in der Leiste + +-------------------------------------------------------------------------------- + ⚠ NOTAS / LIMITACIONES · HINWEISE / EINSCHRÄNKUNGEN +-------------------------------------------------------------------------------- +• Renombrado: solo “Guardar como…”; no se escribe en el archivo original +• Umbenennen: nur „Speichern unter…“, keine Schreibzugriffe auf Originaldateien +• EXIF: lectura/Lesen depende del navegador +• Todo/local: no se suben archivos · Es werden keine Dateien hochgeladen + +-------------------------------------------------------------------------------- + 💡 CONSEJOS · TIPPS +-------------------------------------------------------------------------------- +• Para un servidor local simple: python3 -m http.server 5173 (opcional) +• Großes Set? Carga por carpeta (según soporte del navegador) + +================================================================================ + HECHO CON VUE 3 (CDN) · GEMACHT MIT VUE 3 (CDN) +================================================================================ diff --git a/imagenes/.gitkeep b/imagenes/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/imagenes/.gitkeep @@ -0,0 +1 @@ + diff --git a/index.html b/index.html new file mode 100644 index 0000000..d65f1eb --- /dev/null +++ b/index.html @@ -0,0 +1,17 @@ + + + + + + Visualizador de Fotos + + + +
+ + + + + + + diff --git a/main.js b/main.js new file mode 100644 index 0000000..373e727 --- /dev/null +++ b/main.js @@ -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: ` + + + `, +}; + +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: ` +
+
+
{{ lang==='de' ? 'Foto‑Betrachter' : 'Visualizador de Fotos' }}
+
+
+ + +
+
+
+ {{ lang==='de' ? 'Finca' : 'Finca' }} + + + +
+
+ {{ lang==='de' ? 'Aruco' : 'Aruco' }} + + + + + + +
+
+ {{ lang==='de' ? 'Höhe' : 'Altura' }} + + + +
+
+ +
+
+ {{ lang==='de' ? 'Tipp: Dateien hierher ziehen' : 'Tip: arrastra y suelta archivos aquí' }} +
+
+ +
+
+
+
{{ lang==='de' ? 'Bilder hier ablegen oder „Bilder laden“ benutzen' : 'Suelta imágenes aquí o usa "Cargar imágenes"' }}
+
+
+
{{ lang==='de' ? 'Keine Ergebnisse für den aktuellen Filter.' : 'No hay resultados para el filtro actual.' }}
+
+
+
+
+ {{ lang==='de' ? 'Finca' : 'Finca' }}: {{ currentItem.parsed.finca || '-' }}. + {{ lang==='de' ? 'Höhe' : 'Altura' }}: {{ currentItem.parsed.alturaText || '-' }}. + {{ lang==='de' ? 'Aruco(s)' : 'arucos' }}: {{ (currentItem.parsed.arucos||[]).join(',') || '-' }}. + {{ lang==='de' ? 'Aufnahme' : 'Toma' }}: {{ currentItem.parsed.toma ?? '-' }} +
+
+ +
+
+ + + + {{ lang==='de' ? 'Herunterladen' : 'Descargar' }} +
+ +
{{ current + 1 }} / {{ orderedItems.length }}
+
+
+ + +
+
+ `, +}; + +createApp(app).component('TreeNode', TreeNode).mount('#app'); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..d1bcd61 --- /dev/null +++ b/styles.css @@ -0,0 +1,90 @@ +:root { + --bg: #0f1115; + --panel: #181b22; + --text: #e8eaf0; + --muted: #9aa3b2; + --accent: #56b6c2; + --border: #2a2f3a; +} + +* { box-sizing: border-box; } +html, body, #app { height: 100%; margin: 0; background: var(--bg); color: var(--text); font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; } +.app { display: flex; flex-direction: column; height: 100%; } + +.topbar { + min-height: 56px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + border-bottom: 1px solid var(--border); + background: #12151b; +} +.brand { font-weight: 600; letter-spacing: 0.2px; } +.actions { display: flex; gap: 12px; align-items: center; } +.btn { display: inline-flex; align-items: center; gap: 8px; background: var(--panel); border: 1px solid var(--border); padding: 8px 12px; border-radius: 8px; cursor: pointer; color: var(--text); } +.btn input[type=file] { display: none; } +.hint { color: var(--muted); font-size: 12px; } + +.content { display: grid; grid-template-columns: 1fr 320px; flex: 1 1 auto; min-height: 0; } + +.viewer { position: relative; height: 100%; border-right: 1px solid var(--border); display: flex; align-items: center; justify-content: center; background: #0d0f14; overflow: hidden; } +.viewer.over { outline: 2px dashed var(--accent); outline-offset: -8px; } +.placeholder { color: var(--muted); text-align: center; } +.dropmsg { opacity: 0.8; } +.stage { width: 100%; height: 100%; position: relative; display: grid; place-items: center; } +.img-wrap { user-select: none; cursor: grab; } +.img-wrap.grabbing { cursor: grabbing; } +.photo { max-width: 95vw; max-height: 95vh; width: auto; height: auto; object-fit: contain; pointer-events: none; } + +.nav { position: absolute; inset: 0; display: flex; justify-content: space-between; align-items: center; pointer-events: none; } +.nav-btn { pointer-events: auto; margin: 0 8px; background: rgba(0,0,0,0.35); border: 1px solid var(--border); color: var(--text); width: 44px; height: 44px; border-radius: 50%; font-size: 18px; cursor: pointer; } +.nav-btn:hover { background: rgba(0,0,0,0.5); } +.nav .left { } +.nav .right { } +.counter { position: absolute; bottom: 10px; right: 12px; background: rgba(0,0,0,0.5); padding: 4px 8px; border-radius: 6px; font-size: 12px; } +.info-header { position: absolute; top: 8px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.5); padding: 6px 10px; border-radius: 8px; font-size: 13px; white-space: nowrap; z-index: 3; } +.tools { position: absolute; top: 8px; right: 8px; display: flex; gap: 6px; } +.tools { z-index: 3; } +.tool-btn { background: rgba(0,0,0,0.5); border: 1px solid var(--border); color: var(--text); padding: 6px 8px; border-radius: 6px; font-size: 12px; cursor: pointer; } +.tool-btn:hover { background: rgba(0,0,0,0.65); } + +.side { background: var(--panel); height: 100%; overflow: auto; } +.panel { padding: 16px; } +.panel h3 { margin: 0 0 12px; font-size: 16px; font-weight: 600; } +.row { display: flex; justify-content: space-between; gap: 8px; padding: 6px 0; border-bottom: 1px dashed rgba(255,255,255,0.04); } +.row:last-child { border-bottom: none; } +.key { color: var(--muted); min-width: 84px; } +.name-input { width: 100%; padding: 6px 8px; border-radius: 6px; border: 1px solid var(--border); background: #11151c; color: var(--text); } +.small-muted { color: var(--muted); font-size: 12px; } +.ok { color: #8bd17c; } +.warn { color: #f0c674; } +.val a { color: var(--accent); text-decoration: none; } +.val a:hover { text-decoration: underline; } +.sep { height: 12px; } +.muted { color: var(--muted); } +.error .val { color: #ff6b6b; } + +/* Tree */ +.tree { max-height: 35vh; overflow: auto; border: 1px solid var(--border); border-radius: 8px; padding: 8px; background: #12161d; } +.node { padding: 4px 4px; cursor: default; user-select: none; } +.node.file { cursor: pointer; display: flex; align-items: center; gap: 8px; border-radius: 6px; padding: 4px 6px; } +.node.file:hover { background: rgba(255,255,255,0.04); } +.node.file.active { background: rgba(86, 182, 194, 0.15); } +.dir-row { display: flex; align-items: center; gap: 6px; cursor: pointer; border-radius: 6px; padding: 4px 6px; } +.dir-row:hover { background: rgba(255,255,255,0.04); } +.children { padding-left: 16px; } +.caret { width: 14px; display: inline-block; color: var(--muted); } +.dot { color: var(--muted); width: 10px; display: inline-block; text-align: center; } +.label { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } + +@media (max-width: 900px) { + .content { grid-template-columns: 1fr; } +.side { border-top: 1px solid var(--border); } +} + +/* Filters */ +.filters { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; } +.filter-group { display: flex; gap: 8px; align-items: center; background: #12161b; border: 1px solid var(--border); padding: 6px 8px; border-radius: 8px; } +.filter-group label { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; cursor: pointer; } +.filter-group input[type=checkbox] { margin: 0; }