feat(citycorners): UX quick wins for web app

- Lazy loading for location card images
- Category filter pills now show count (e.g. "Restaurants (3)")
- Better empty states with category-specific messages and emoji
- Share button on detail page (Web Share API with clipboard fallback)
- "On map" and "Directions" (Google Maps) buttons on detail page
- Geolocation "locate me" button on map page with user marker
- Image URL retry button on add form when image fails to load
- i18n keys for all new features (DE/EN)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-24 11:07:38 +01:00
parent 62c5dddab0
commit 5611f3824a
6 changed files with 279 additions and 63 deletions

View file

@ -17,7 +17,9 @@
"subtitle": "Sehenswürdigkeiten, Restaurants, Museen und mehr",
"all": "Alle",
"loading": "Laden...",
"noResults": "Keine Locations gefunden."
"noResults": "Keine Locations gefunden.",
"noResultsCategory": "Keine {category} gefunden.",
"addFirst": "Ersten Ort hinzufügen"
},
"categories": {
"sight": "Sehenswürdigkeiten",
@ -33,7 +35,12 @@
},
"detail": {
"history": "Geschichte",
"openInMaps": "In OpenStreetMap öffnen",
"openInMaps": "OSM",
"showOnMap": "Auf Karte",
"directions": "Route",
"share": "Teilen",
"linkCopied": "Link kopiert!",
"showDetails": "Details",
"back": "Zurück zur Übersicht",
"notFound": "Location nicht gefunden."
},
@ -47,7 +54,11 @@
},
"map": {
"title": "Karte",
"subtitle": "Alle Orte in Konstanz"
"subtitle": "Alle Orte in Konstanz",
"locateMe": "Mein Standort",
"yourLocation": "Du bist hier",
"geolocationNotSupported": "Standortbestimmung wird nicht unterstützt.",
"geolocationError": "Standort konnte nicht ermittelt werden."
},
"search": {
"placeholder": "Ort suchen...",
@ -101,6 +112,7 @@
"imageUrlPlaceholder": "https://example.com/bild.jpg",
"imagePreview": "Bildvorschau",
"imageLoadError": "Bild konnte nicht geladen werden.",
"imageRetry": "Erneut versuchen",
"geocoding": "Koordinaten werden ermittelt...",
"coordinatesFound": "Koordinaten gefunden"
},

View file

@ -17,7 +17,9 @@
"subtitle": "Sights, restaurants, museums and more",
"all": "All",
"loading": "Loading...",
"noResults": "No locations found."
"noResults": "No locations found.",
"noResultsCategory": "No {category} found.",
"addFirst": "Add the first place"
},
"categories": {
"sight": "Sights",
@ -33,7 +35,12 @@
},
"detail": {
"history": "History",
"openInMaps": "Open in OpenStreetMap",
"openInMaps": "OSM",
"showOnMap": "On map",
"directions": "Directions",
"share": "Share",
"linkCopied": "Link copied!",
"showDetails": "Details",
"back": "Back to overview",
"notFound": "Location not found."
},
@ -47,7 +54,11 @@
},
"map": {
"title": "Map",
"subtitle": "All places in Konstanz"
"subtitle": "All places in Konstanz",
"locateMe": "My location",
"yourLocation": "You are here",
"geolocationNotSupported": "Geolocation is not supported.",
"geolocationError": "Could not determine location."
},
"search": {
"placeholder": "Search places...",
@ -101,6 +112,7 @@
"imageUrlPlaceholder": "https://example.com/image.jpg",
"imagePreview": "Image preview",
"imageLoadError": "Image could not be loaded.",
"imageRetry": "Retry",
"geocoding": "Finding coordinates...",
"coordinatesFound": "Coordinates found"
},

View file

@ -22,6 +22,16 @@
const categoryKeys = ['sight', 'restaurant', 'shop', 'museum'];
let categoryCounts = $derived(
categoryKeys.reduce(
(acc, cat) => {
acc[cat] = locations.filter((l) => l.category === cat).length;
return acc;
},
{} as Record<string, number>
)
);
let filtered = $derived(
selectedCategory ? locations.filter((l) => l.category === selectedCategory) : locations
);
@ -76,7 +86,7 @@
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover'}"
onclick={() => (selectedCategory = null)}
>
{$_('home.all')}
{$_('home.all')} ({locations.length})
</button>
{#each categoryKeys as cat}
<button
@ -85,7 +95,7 @@
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover'}"
onclick={() => (selectedCategory = cat)}
>
{$_(`categories.${cat}`)}
{$_(`categories.${cat}`)} ({categoryCounts[cat] || 0})
</button>
{/each}
</div>
@ -93,7 +103,31 @@
{#if loading}
<p class="text-foreground-secondary">{$_('home.loading')}</p>
{:else if filtered.length === 0}
<p class="text-foreground-secondary">{$_('home.noResults')}</p>
<div class="py-12 text-center">
<span class="mb-2 block text-4xl"
>{selectedCategory === 'restaurant'
? '🍽️'
: selectedCategory === 'museum'
? '🏛️'
: selectedCategory === 'shop'
? '🛍️'
: selectedCategory === 'sight'
? '🏰'
: '📍'}</span
>
<p class="text-foreground-secondary">
{#if selectedCategory}
{$_('home.noResultsCategory', {
values: { category: $_(`categories.${selectedCategory}`) },
})}
{:else}
{$_('home.noResults')}
{/if}
</p>
<a href="/add" class="mt-3 inline-block text-sm text-primary hover:underline">
{$_('home.addFirst')}
</a>
</div>
{:else}
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{#each filtered as location}
@ -102,7 +136,12 @@
class="group relative overflow-hidden rounded-xl border border-border bg-background-card transition-shadow hover:shadow-lg"
>
{#if location.imageUrl}
<img src={location.imageUrl} alt={location.name} class="h-48 w-full object-cover" />
<img
src={location.imageUrl}
alt={location.name}
loading="lazy"
class="h-48 w-full object-cover"
/>
{:else}
<div class="flex h-48 items-center justify-center bg-background-card-hover">
<span class="text-4xl">📍</span>

View file

@ -356,7 +356,18 @@
/>
</div>
{:else if imageError}
<p class="mt-1 text-xs text-red-500">{$_('add.imageLoadError')}</p>
<div class="mt-2 flex items-center gap-2 rounded-lg bg-red-500/10 p-3">
<p class="flex-1 text-xs text-red-500">{$_('add.imageLoadError')}</p>
<button
type="button"
onclick={() => {
imageError = false;
}}
class="text-xs font-medium text-red-500 hover:text-red-400"
>
{$_('add.imageRetry')}
</button>
</div>
{/if}
</div>

View file

@ -27,13 +27,7 @@
let location = $state<Location | null>(null);
let loading = $state(true);
let mapContainer: HTMLDivElement;
const categoryLabels: Record<string, string> = {
sight: 'Sehenswürdigkeit',
restaurant: 'Restaurant',
shop: 'Laden',
museum: 'Museum',
};
let shareSuccess = $state(false);
const categoryColors: Record<string, string> = {
sight: '#2563eb',
@ -87,6 +81,23 @@
initMap();
});
async function handleShare() {
const url = window.location.href;
const title = location?.name || 'CityCorners';
if (navigator.share) {
try {
await navigator.share({ title, url });
} catch {
// User cancelled share
}
} else {
await navigator.clipboard.writeText(url);
shareSuccess = true;
setTimeout(() => (shareSuccess = false), 2000);
}
}
</script>
<svelte:head>
@ -121,7 +132,7 @@
</div>
{/if}
<!-- Back button + Favorite button overlay -->
<!-- Back button overlay -->
<div class="absolute left-4 top-4">
<a
href="/"
@ -133,8 +144,41 @@
</a>
</div>
{#if authStore.isAuthenticated}
<div class="absolute right-4 top-4">
<!-- Share + Favorite buttons overlay -->
<div class="absolute right-4 top-4 flex gap-2">
<button
class="flex h-10 w-10 items-center justify-center rounded-full bg-black/30 text-white backdrop-blur-sm transition-all hover:bg-black/50"
onclick={handleShare}
title={$_('detail.share')}
>
{#if shareSuccess}
<svg
class="h-5 w-5 text-green-400"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
{:else}
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z"
/>
</svg>
{/if}
</button>
{#if authStore.isAuthenticated}
<button
class="flex h-10 w-10 items-center justify-center rounded-full bg-black/30 backdrop-blur-sm transition-all hover:bg-black/50"
onclick={() => favoritesStore.toggle(location!.id)}
@ -164,8 +208,8 @@
</svg>
{/if}
</button>
</div>
{/if}
{/if}
</div>
<!-- Category badge on image -->
<div class="absolute bottom-4 left-4">
@ -173,7 +217,7 @@
class="rounded-full px-3 py-1 text-sm font-medium text-white backdrop-blur-sm"
style="background: {categoryColors[location.category] || '#6b7280'}cc"
>
{categoryLabels[location.category] || location.category}
{$_(`category.${location.category}`)}
</span>
</div>
</div>
@ -209,31 +253,73 @@
<p class="text-base leading-relaxed text-foreground">{location.description}</p>
<!-- Mini Map -->
<!-- Map + Directions -->
{#if location.latitude && location.longitude}
<div class="overflow-hidden rounded-xl border border-border">
<div bind:this={mapContainer} class="h-52 w-full"></div>
<a
href="https://www.openstreetmap.org/?mlat={location.latitude}&mlon={location.longitude}#map=17/{location.latitude}/{location.longitude}"
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-center gap-2 border-t border-border bg-background-card px-4 py-2.5 text-sm text-foreground-secondary transition-colors hover:text-primary"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
<div class="flex divide-x divide-border border-t border-border">
<a
href="/map"
class="flex flex-1 items-center justify-center gap-2 bg-background-card px-4 py-2.5 text-sm text-foreground-secondary transition-colors hover:text-primary"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
/>
</svg>
{$_('detail.openInMaps')}
</a>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 6.75V15m6-6v8.25m.503 3.498l4.875-2.437c.381-.19.622-.58.622-1.006V4.82c0-.836-.88-1.38-1.628-1.006l-3.869 1.934c-.317.159-.69.159-1.006 0L9.503 3.252a1.125 1.125 0 00-1.006 0L3.622 5.689C3.24 5.88 3 6.27 3 6.695V19.18c0 .836.88 1.38 1.628 1.006l3.869-1.934c.317-.159.69-.159 1.006 0l4.994 2.497c.317.158.69.158 1.006 0z"
/>
</svg>
{$_('detail.showOnMap')}
</a>
<a
href="https://www.google.com/maps/dir/?api=1&destination={location.latitude},{location.longitude}"
target="_blank"
rel="noopener noreferrer"
class="flex flex-1 items-center justify-center gap-2 bg-background-card px-4 py-2.5 text-sm text-foreground-secondary transition-colors hover:text-primary"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"
/>
</svg>
{$_('detail.directions')}
</a>
<a
href="https://www.openstreetmap.org/?mlat={location.latitude}&mlon={location.longitude}#map=17/{location.latitude}/{location.longitude}"
target="_blank"
rel="noopener noreferrer"
class="flex flex-1 items-center justify-center gap-2 bg-background-card px-4 py-2.5 text-sm text-foreground-secondary transition-colors hover:text-primary"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
/>
</svg>
{$_('detail.openInMaps')}
</a>
</div>
</div>
{/if}
@ -243,7 +329,7 @@
<h2 class="mb-4 text-xl font-semibold text-foreground">{$_('detail.history')}</h2>
<div class="relative space-y-0">
{#each location.timeline as entry, i}
<div class="relative flex gap-4 pb-6 {i < location.timeline!.length - 1 ? '' : ''}">
<div class="relative flex gap-4 pb-6">
<!-- Timeline line -->
{#if i < location.timeline!.length - 1}
<div class="absolute left-[11px] top-6 h-full w-0.5 bg-border"></div>

View file

@ -18,6 +18,8 @@
let locations = $state<Location[]>([]);
let mapContainer: HTMLDivElement;
let map: any = null;
let locating = $state(false);
let locationError = $state('');
const categoryColors: Record<string, string> = {
sight: '#2563eb',
@ -26,15 +28,7 @@
museum: '#9333ea',
};
const categoryLabels: Record<string, string> = {
sight: 'Sehenswürdigkeit',
restaurant: 'Restaurant',
shop: 'Laden',
museum: 'Museum',
};
onMount(async () => {
// Load locations
try {
const res = await fetch(api('/locations'));
const data = await res.json();
@ -45,11 +39,9 @@
if (!browser) return;
// Dynamically import Leaflet (client-side only)
const L = await import('leaflet');
await import('leaflet/dist/leaflet.css');
// Center on Konstanz
map = L.map(mapContainer).setView([47.6603, 9.1757], 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
@ -57,7 +49,6 @@
maxZoom: 19,
}).addTo(map);
// Add markers for locations with coordinates
for (const loc of locations) {
if (loc.latitude && loc.longitude) {
const color = categoryColors[loc.category] || '#6b7280';
@ -74,14 +65,53 @@
marker.bindPopup(`
<div style="min-width:180px">
<strong style="font-size:14px">${loc.name}</strong>
<div style="color:${color};font-size:12px;margin:4px 0">${categoryLabels[loc.category] || loc.category}</div>
<div style="color:${color};font-size:12px;margin:4px 0">${$_(`category.${loc.category}`)}</div>
<p style="font-size:12px;color:#666;margin:4px 0">${loc.description.substring(0, 100)}...</p>
<a href="/locations/${loc.id}" style="color:${color};font-size:12px;font-weight:600">Details &rarr;</a>
<a href="/locations/${loc.id}" style="color:${color};font-size:12px;font-weight:600">${$_('detail.showDetails')} &rarr;</a>
</div>
`);
}
}
});
function handleLocateMe() {
if (!browser || !map || !navigator.geolocation) {
locationError = $_('map.geolocationNotSupported');
return;
}
locating = true;
locationError = '';
navigator.geolocation.getCurrentPosition(
async (pos) => {
const { latitude, longitude } = pos.coords;
const L = await import('leaflet');
map.setView([latitude, longitude], 16);
// Add user marker
const userIcon = L.divIcon({
className: 'custom-marker',
html: `<div style="background:#3b82f6;width:16px;height:16px;border-radius:50%;border:3px solid white;box-shadow:0 0 0 4px rgba(59,130,246,0.3),0 2px 6px rgba(0,0,0,0.3);"></div>`,
iconSize: [16, 16],
iconAnchor: [8, 8],
});
L.marker([latitude, longitude], { icon: userIcon })
.addTo(map)
.bindPopup($_('map.yourLocation'))
.openPopup();
locating = false;
},
() => {
locationError = $_('map.geolocationError');
locating = false;
},
{ enableHighAccuracy: true, timeout: 10000 }
);
}
</script>
<svelte:head>
@ -90,16 +120,42 @@
</svelte:head>
<div class="map-page">
<header class="mb-4">
<h1 class="text-2xl font-bold text-foreground">{$_('map.title')}</h1>
<p class="text-foreground-secondary">{$_('map.subtitle')}</p>
<header class="mb-4 flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-foreground">{$_('map.title')}</h1>
<p class="text-foreground-secondary">{$_('map.subtitle')}</p>
</div>
<button
onclick={handleLocateMe}
disabled={locating}
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-background-card border border-border text-foreground-secondary shadow-sm transition-all hover:text-primary hover:border-primary disabled:opacity-50"
title={$_('map.locateMe')}
>
{#if locating}
<div
class="h-5 w-5 border-2 border-primary border-t-transparent rounded-full animate-spin"
></div>
{:else}
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"
/>
</svg>
{/if}
</button>
</header>
{#if locationError}
<div class="mb-3 rounded-lg bg-red-500/10 p-2 text-xs text-red-500">{locationError}</div>
{/if}
<div class="legend mb-4 flex flex-wrap gap-3">
{#each Object.entries(categoryColors) as [key, color]}
<div class="flex items-center gap-1.5 text-sm text-foreground-secondary">
<div class="w-3 h-3 rounded-full" style="background:{color}"></div>
{categoryLabels[key]}
{$_(`category.${key}`)}
</div>
{/each}
</div>