From 3b524e9c70bff57177f22d7daa68524f66a2d98d Mon Sep 17 00:00:00 2001
From: Alexander Bocken
Date: Tue, 19 May 2026 17:36:38 +0200
Subject: [PATCH] feat(route-builder): match dropped images to imported
hash-only waypoints
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
After a GPX import the image-anchor waypoints carry just their
content hash (no thumbnail). Drop the source JPEGs into the same
image dropzone and they're now hashed and matched against existing
waypoints first — on a hit, the thumbnail + full-resolution cache
are patched onto the matched row instead of creating a duplicate
waypoint marker on the map.
A blue 'Bildvorschau ergänzt' status row + contextual hint at the
top of the dropzone ('N Bild-Wegpunkte warten auf eine Vorschau —
Originale hier ablegen') makes the round-trip workflow discoverable.
Existing fresh-upload behaviour (new image → new chronologically-
inserted waypoint with EXIF GPS) is unchanged for any image whose
hash isn't already known to the builder.
---
package.json | 2 +-
.../hikes/route-builder/ImageDropzone.svelte | 75 +++++++++++++++++--
2 files changed, 70 insertions(+), 7 deletions(-)
diff --git a/package.json b/package.json
index 98fa59fc..889e165c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "homepage",
- "version": "1.77.0",
+ "version": "1.77.1",
"private": true,
"type": "module",
"scripts": {
diff --git a/src/lib/components/hikes/route-builder/ImageDropzone.svelte b/src/lib/components/hikes/route-builder/ImageDropzone.svelte
index ba470015..48a25feb 100644
--- a/src/lib/components/hikes/route-builder/ImageDropzone.svelte
+++ b/src/lib/components/hikes/route-builder/ImageDropzone.svelte
@@ -6,11 +6,15 @@
scheduleSave,
type Waypoint
} from './builderStore.svelte';
+ // `untrack` keeps the in-loop `builder.waypoints.find(...)` from
+ // registering as a dep on a non-reactive call site, avoiding effect
+ // loops when we patch the matched waypoint's `thumbnail`.
+ import { untrack } from 'svelte';
import { generateImageHashClient } from '$lib/imageHashClient';
import { readThumbnail } from './imageThumbnail';
import { setFullImage } from './fullImageCache.svelte';
- type Status = 'pending' | 'placed' | 'unplaced' | 'error';
+ type Status = 'pending' | 'placed' | 'unplaced' | 'matched' | 'error';
type Entry = {
id: string;
@@ -22,8 +26,17 @@
let entries = $state([]);
let isDragging = $state(false);
+ // Counts hash-only image waypoints (typically restored from a GPX
+ // import) that don't yet have a thumbnail — surfaces a contextual
+ // hint in the dropzone header so the user knows that dropping the
+ // source JPEGs here will attach previews to those rows in the table.
+ const orphanImageCount = $derived(
+ builder.waypoints.filter((w) => w.imageHash && !w.thumbnail).length
+ );
+
type Prepared =
- | { ok: true; wp: Waypoint; hasGps: boolean; id: string; file: File }
+ | { ok: true; kind: 'new'; wp: Waypoint; hasGps: boolean; id: string; file: File }
+ | { ok: true; kind: 'matched'; id: string; file: File }
| { ok: false };
async function handleFiles(files: File[]) {
@@ -49,6 +62,30 @@
thumbnail = await readThumbnail(file);
} catch { /* preview is optional */ }
const imageHash = await generateImageHashClient(file);
+
+ // Match path: if a previously-imported (or earlier-dropped)
+ // waypoint already carries this content hash, attach the
+ // thumbnail to it instead of creating a duplicate marker.
+ // Covers the GPX-roundtrip flow where the user loads an
+ // existing GPX (image hashes restored as bare waypoints)
+ // and then drops the source images to give them previews.
+ const existing = untrack(() =>
+ builder.waypoints.find((w) => w.imageHash === imageHash)
+ );
+ if (existing) {
+ if (thumbnail && !existing.thumbnail) existing.thumbnail = thumbnail;
+ // Trust the imported visibility if the existing waypoint
+ // already has one set — re-dropping shouldn't silently
+ // flip a private photo to public.
+ if (!existing.imageVisibility) existing.imageVisibility = 'public';
+ scheduleSave();
+ entries[entryIdx].status = 'matched';
+ entries[entryIdx].message = existing.unplaced
+ ? 'noch nicht auf der Karte platziert'
+ : undefined;
+ return { ok: true, kind: 'matched', id: existing.id, file };
+ }
+
const timestamp =
exif?.DateTimeOriginal instanceof Date ? exif.DateTimeOriginal.getTime() : null;
@@ -82,7 +119,7 @@
};
entries[entryIdx].status = hasGps ? 'placed' : 'unplaced';
- return { ok: true, wp, hasGps, id, file };
+ return { ok: true, kind: 'new', wp, hasGps, id, file };
} catch (err) {
entries[entryIdx].status = 'error';
entries[entryIdx].message = (err as Error).message;
@@ -100,10 +137,11 @@
// returning 0 even with workaround attempts.
for (const p of prepared) {
if (!p.ok) continue;
- insertWaypointChronologically(p.wp);
+ if (p.kind === 'new') insertWaypointChronologically(p.wp);
// Cache the original file so the waypoint table can show a
- // full-resolution preview this session. Persistence to
- // localStorage keeps only the small thumbnail.
+ // full-resolution preview this session (for both new + matched
+ // waypoints). Persistence to localStorage keeps only the small
+ // thumbnail.
setFullImage(p.id, p.file);
}
}
@@ -151,6 +189,14 @@
erscheinen in der Wegpunkt-Liste und können dort auf der Karte platziert
werden. Die Bilder verlassen dein Gerät nicht.
+ {#if orphanImageCount > 0}
+
+ {orphanImageCount}
+ {orphanImageCount === 1 ? 'Bild-Wegpunkt' : 'Bild-Wegpunkte'} aus der
+ geladenen GPX warten auf eine Vorschau — die Original-Bilder hier ablegen,
+ um sie über den Inhalts-Hash automatisch zuzuordnen.
+