feat(citycorners): add photo gallery, nearby locations, and search history

1. Photo Gallery:
   - New `images` JSONB array field in locations schema
   - POST /locations/:id/images endpoint to add photos (auth required)
   - Gallery with thumbnail strip and image counter on detail page
   - Any authenticated user can add photos to any location
   - "Add photo" button inline with thumbnails

2. Nearby Locations:
   - GET /locations/:id/nearby endpoint with Haversine distance query
   - Configurable radius (default 2km, max 10km)
   - Returns up to 5 nearby locations sorted by distance
   - Horizontal scroll card strip on detail page showing distance

3. Search Suggestions + History:
   - GET /locations/suggestions endpoint (prefix matching, fast)
   - Search history stored in localStorage (max 8 entries)
   - Empty search shows recent history with clock icon
   - Selected locations automatically saved to history
   - Falls back to full-text search if no prefix matches

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-24 11:25:17 +01:00
parent c4cc8529e3
commit 8e390395fd
8 changed files with 379 additions and 23 deletions

View file

@ -14,6 +14,7 @@ export function createMockLocation(overrides: Partial<Location> = {}): Location
latitude: 47.6603,
longitude: 9.1757,
imageUrl: '/images/muenster.svg',
images: [],
timeline: [{ year: '615', event: 'Founded' }],
createdBy: null,
createdAt: new Date('2026-01-01'),

View file

@ -19,6 +19,7 @@ export const locations = pgTable('locations', {
latitude: doublePrecision('latitude'),
longitude: doublePrecision('longitude'),
imageUrl: text('image_url'),
images: jsonb('images').$type<LocationImage[]>().default([]),
timeline: jsonb('timeline').$type<TimelineEntry[]>().default([]),
createdBy: text('created_by'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
@ -28,6 +29,12 @@ export const locations = pgTable('locations', {
.notNull(),
});
export interface LocationImage {
url: string;
addedBy?: string;
addedAt?: string;
}
export interface TimelineEntry {
year: string;
event: string;

View file

@ -115,12 +115,39 @@ export class LocationController {
return { locations };
}
@Get('suggestions')
async suggestions(@Query('q') query: string) {
if (!query || query.trim().length === 0) {
return { suggestions: [] };
}
const suggestions = await this.locationService.searchSuggestions(query.trim());
return { suggestions };
}
@Get(':id')
async findById(@Param('id') id: string) {
const location = await this.locationService.findById(id);
return { location };
}
@Get(':id/nearby')
async findNearby(@Param('id') id: string, @Query('radius') radius?: string) {
const radiusKm = radius ? Math.min(10, Math.max(0.5, parseFloat(radius))) : 2;
const nearby = await this.locationService.findNearby(id, radiusKm);
return { locations: nearby };
}
@Post(':id/images')
@UseGuards(JwtAuthGuard)
async addImage(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() body: { imageUrl: string }
) {
const location = await this.locationService.addImage(id, body.imageUrl, user.userId);
return { location };
}
@Post()
@UseGuards(JwtAuthGuard)
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateLocationDto) {

View file

@ -1,9 +1,9 @@
import { Injectable, Inject, NotFoundException, ForbiddenException } from '@nestjs/common';
import { eq, or, ilike, sql, desc } from 'drizzle-orm';
import { eq, or, ilike, sql, desc, ne, and, isNotNull } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { locations } from '../db/schema';
import type { Location, NewLocation } from '../db/schema';
import type { Location, NewLocation, LocationImage } from '../db/schema';
export interface PaginatedResult<T> {
items: T[];
@ -103,6 +103,74 @@ export class LocationService {
return location;
}
async findNearby(
id: string,
radiusKm = 2,
limit = 5
): Promise<(Location & { distance: number })[]> {
const location = await this.findById(id);
if (!location.latitude || !location.longitude) return [];
const haversine = sql<number>`
6371 * acos(
LEAST(1.0, cos(radians(${location.latitude})) * cos(radians(${locations.latitude}))
* cos(radians(${locations.longitude}) - radians(${location.longitude}))
+ sin(radians(${location.latitude})) * sin(radians(${locations.latitude})))
)
`;
const results = await this.db
.select({
location: locations,
distance: haversine,
})
.from(locations)
.where(
and(ne(locations.id, id), isNotNull(locations.latitude), isNotNull(locations.longitude))
)
.orderBy(haversine)
.limit(limit);
return results
.filter((r) => r.distance <= radiusKm)
.map((r) => ({
...r.location,
distance: Math.round(r.distance * 1000), // meters
}));
}
async addImage(id: string, imageUrl: string, userId: string): Promise<Location> {
const location = await this.findById(id);
const currentImages: LocationImage[] = (location.images as LocationImage[]) || [];
const newImage: LocationImage = {
url: imageUrl,
addedBy: userId,
addedAt: new Date().toISOString(),
};
const [updated] = await this.db
.update(locations)
.set({ images: [...currentImages, newImage] })
.where(eq(locations.id, id))
.returning();
return updated;
}
async searchSuggestions(
query: string,
limit = 5
): Promise<{ id: string; name: string; category: string }[]> {
if (!query.trim()) return [];
const pattern = `${query}%`;
const results = await this.db
.select({ id: locations.id, name: locations.name, category: locations.category })
.from(locations)
.where(ilike(locations.name, pattern))
.limit(limit);
return results;
}
async delete(id: string, userId?: string): Promise<void> {
const existing = await this.findById(id);

View file

@ -49,7 +49,13 @@
"deleteConfirm": "Bist du sicher, dass du diesen Ort löschen möchtest? Das kann nicht rückgängig gemacht werden.",
"confirmDelete": "Endgültig löschen",
"deleting": "Wird gelöscht...",
"cancel": "Abbrechen"
"cancel": "Abbrechen",
"nearby": "In der Nähe"
},
"gallery": {
"addPhoto": "Foto hinzufügen",
"add": "Hinzufügen",
"addError": "Foto konnte nicht hinzugefügt werden."
},
"favorites": {
"title": "Favoriten",

View file

@ -49,7 +49,13 @@
"deleteConfirm": "Are you sure you want to delete this place? This cannot be undone.",
"confirmDelete": "Delete permanently",
"deleting": "Deleting...",
"cancel": "Cancel"
"cancel": "Cancel",
"nearby": "Nearby"
},
"gallery": {
"addPhoto": "Add photo",
"add": "Add",
"addError": "Could not add photo."
},
"favorites": {
"title": "Favorites",

View file

@ -76,16 +76,61 @@
interface SearchItem extends QuickInputItem {
href?: string;
isHistory?: boolean;
}
const SEARCH_HISTORY_KEY = 'citycorners-search-history';
const MAX_HISTORY = 8;
function getSearchHistory(): { query: string; name: string; category: string; id: string }[] {
try {
return JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || '[]');
} catch {
return [];
}
}
function saveToHistory(loc: { id: string; name: string; category: string }) {
const history = getSearchHistory().filter((h) => h.id !== loc.id);
history.unshift({ query: loc.name, name: loc.name, category: loc.category, id: loc.id });
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history.slice(0, MAX_HISTORY)));
}
async function handleSearch(query: string): Promise<SearchItem[]> {
if (!query.trim()) return [];
if (!query.trim()) {
// Show search history when empty
const history = getSearchHistory();
if (history.length === 0) return [];
return history.map((h) => ({
id: h.id,
title: h.name,
subtitle: $_(`category.${h.category}`),
icon: 'clock' as const,
href: `/locations/${h.id}`,
isHistory: true,
}));
}
try {
const res = await fetch(api(`/locations/search?q=${encodeURIComponent(query)}`));
// Use suggestions endpoint for prefix matching (faster)
const res = await fetch(api(`/locations/suggestions?q=${encodeURIComponent(query)}`));
if (!res.ok) return [];
const data = await res.json();
return data.locations.slice(0, 8).map((loc: any) => ({
if (data.suggestions?.length > 0) {
return data.suggestions.map((s: any) => ({
id: s.id,
title: s.name,
subtitle: $_(`category.${s.category}`),
icon: 'mappin' as const,
href: `/locations/${s.id}`,
}));
}
// Fallback to full search
const fullRes = await fetch(api(`/locations/search?q=${encodeURIComponent(query)}`));
if (!fullRes.ok) return [];
const fullData = await fullRes.json();
return fullData.locations.slice(0, 8).map((loc: any) => ({
id: loc.id,
title: loc.name,
subtitle: $_(`category.${loc.category}`),
@ -99,6 +144,12 @@
function handleSelect(item: SearchItem) {
if (item.href) {
// Save to search history
saveToHistory({
id: item.id as string,
name: item.title,
category: item.subtitle || '',
});
goto(item.href);
}
}

View file

@ -13,6 +13,20 @@
event: string;
}
interface LocationImage {
url: string;
addedBy?: string;
addedAt?: string;
}
interface NearbyLocation {
id: string;
name: string;
category: string;
imageUrl?: string;
distance: number;
}
interface Location {
id: string;
name: string;
@ -22,17 +36,26 @@
latitude?: number;
longitude?: number;
imageUrl?: string;
images?: LocationImage[];
timeline?: TimelineEntry[];
createdBy?: string;
}
let location = $state<Location | null>(null);
let nearbyLocations = $state<NearbyLocation[]>([]);
let loading = $state(true);
let mapContainer: HTMLDivElement;
let shareSuccess = $state(false);
let showDeleteConfirm = $state(false);
let deleting = $state(false);
// Gallery state
let selectedImageIndex = $state(0);
let showAddPhoto = $state(false);
let newPhotoUrl = $state('');
let addingPhoto = $state(false);
let photoError = $state('');
const categoryColors: Record<string, string> = {
sight: '#2563eb',
restaurant: '#dc2626',
@ -46,11 +69,32 @@
authStore.user?.id === location.createdBy
);
// All images: primary imageUrl + gallery images
let allImages = $derived(() => {
if (!location) return [];
const imgs: string[] = [];
if (location.imageUrl) imgs.push(location.imageUrl);
if (location.images) {
for (const img of location.images) {
if (img.url && !imgs.includes(img.url)) imgs.push(img.url);
}
}
return imgs;
});
onMount(async () => {
try {
const res = await fetch(api(`/locations/${$page.params.id}`));
const data = await res.json();
location = data.location;
const [locRes, nearbyRes] = await Promise.all([
fetch(api(`/locations/${$page.params.id}`)),
fetch(api(`/locations/${$page.params.id}/nearby`)),
]);
const locData = await locRes.json();
location = locData.location;
if (nearbyRes.ok) {
const nearbyData = await nearbyRes.json();
nearbyLocations = nearbyData.locations || [];
}
} catch (err) {
console.error('Failed to load location:', err);
} finally {
@ -100,7 +144,7 @@
try {
await navigator.share({ title, url });
} catch {
// User cancelled share
// User cancelled
}
} else {
await navigator.clipboard.writeText(url);
@ -112,16 +156,13 @@
async function handleDelete() {
if (!location || deleting) return;
deleting = true;
try {
const token = await authStore.getValidToken();
const res = await fetch(api(`/locations/${location.id}`), {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
goto('/');
}
if (res.ok) goto('/');
} catch {
// ignore
} finally {
@ -129,6 +170,40 @@
showDeleteConfirm = false;
}
}
async function handleAddPhoto() {
if (!newPhotoUrl.trim() || !location || addingPhoto) return;
addingPhoto = true;
photoError = '';
try {
const token = await authStore.getValidToken();
const res = await fetch(api(`/locations/${location.id}/images`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ imageUrl: newPhotoUrl.trim() }),
});
if (res.ok) {
const data = await res.json();
location = data.location;
newPhotoUrl = '';
showAddPhoto = false;
} else {
photoError = $_('gallery.addError');
}
} catch {
photoError = $_('gallery.addError');
} finally {
addingPhoto = false;
}
}
function formatDistance(meters: number): string {
if (meters < 1000) return `${meters} m`;
return `${(meters / 1000).toFixed(1)} km`;
}
</script>
<svelte:head>
@ -151,10 +226,16 @@
>
</div>
{:else}
<!-- Hero image with overlay -->
{@const images = allImages()}
<!-- Hero image / Gallery -->
<div class="relative -mx-4 -mt-4 mb-6 sm:-mx-6 sm:-mt-6 lg:-mx-8 lg:-mt-8">
{#if location.imageUrl}
<img src={location.imageUrl} alt={location.name} class="h-72 w-full object-cover sm:h-80" />
{#if images.length > 0}
<img
src={images[selectedImageIndex]}
alt={location.name}
class="h-72 w-full object-cover sm:h-80"
/>
{:else}
<div
class="flex h-72 items-center justify-center bg-gradient-to-br from-primary/20 to-primary/5 sm:h-80"
@ -242,7 +323,16 @@
{/if}
</div>
<!-- Category badge on image -->
<!-- Image counter badge -->
{#if images.length > 1}
<div
class="absolute bottom-4 right-4 rounded-full bg-black/50 px-2.5 py-1 text-xs text-white backdrop-blur-sm"
>
{selectedImageIndex + 1} / {images.length}
</div>
{/if}
<!-- Category badge -->
<div class="absolute bottom-4 left-4">
<span
class="rounded-full px-3 py-1 text-sm font-medium text-white backdrop-blur-sm"
@ -253,6 +343,75 @@
</div>
</div>
<!-- Gallery thumbnails -->
{#if images.length > 1}
<div class="mb-6 flex gap-2 overflow-x-auto pb-1">
{#each images as img, i}
<button
onclick={() => (selectedImageIndex = i)}
class="h-16 w-20 flex-shrink-0 overflow-hidden rounded-lg border-2 transition-all {selectedImageIndex ===
i
? 'border-primary shadow-md'
: 'border-transparent opacity-60 hover:opacity-100'}"
>
<img src={img} alt="" class="h-full w-full object-cover" loading="lazy" />
</button>
{/each}
{#if authStore.isAuthenticated}
<button
onclick={() => (showAddPhoto = !showAddPhoto)}
class="flex h-16 w-20 flex-shrink-0 items-center justify-center rounded-lg border-2 border-dashed border-border text-foreground-secondary transition-colors hover:border-primary hover:text-primary"
title={$_('gallery.addPhoto')}
>
<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 4.5v15m7.5-7.5h-15" />
</svg>
</button>
{/if}
</div>
{:else if authStore.isAuthenticated}
<div class="mb-4">
<button
onclick={() => (showAddPhoto = !showAddPhoto)}
class="text-sm text-foreground-secondary hover:text-primary transition-colors"
>
+ {$_('gallery.addPhoto')}
</button>
</div>
{/if}
<!-- Add photo form -->
{#if showAddPhoto}
<div class="mb-6 rounded-xl border border-border bg-background-card p-4">
<p class="mb-3 text-sm font-medium text-foreground">{$_('gallery.addPhoto')}</p>
{#if photoError}
<div class="mb-3 rounded-lg bg-red-500/10 p-2 text-xs text-red-500">{photoError}</div>
{/if}
<div class="flex gap-2">
<input
type="url"
bind:value={newPhotoUrl}
placeholder={$_('add.imageUrlPlaceholder')}
class="flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
onkeydown={(e) => e.key === 'Enter' && handleAddPhoto()}
/>
<button
onclick={handleAddPhoto}
disabled={!newPhotoUrl.trim() || addingPhoto}
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
>
{addingPhoto ? '...' : $_('gallery.add')}
</button>
</div>
</div>
{/if}
<!-- Content -->
<div class="space-y-6">
<div>
@ -284,7 +443,7 @@
<p class="text-base leading-relaxed text-foreground">{location.description}</p>
<!-- Owner actions: Edit + Delete -->
<!-- Owner actions -->
{#if isOwner}
<div class="flex gap-3">
<a
@ -429,17 +588,14 @@
<div class="relative space-y-0">
{#each location.timeline as entry, i}
<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>
{/if}
<!-- Dot -->
<div
class="relative z-10 mt-1.5 h-6 w-6 flex-shrink-0 rounded-full border-2 border-primary bg-background flex items-center justify-center"
>
<div class="h-2 w-2 rounded-full bg-primary"></div>
</div>
<!-- Content -->
<div>
<span class="font-mono text-sm font-bold text-primary">{entry.year}</span>
<p class="mt-0.5 text-sm text-foreground-secondary">{entry.event}</p>
@ -449,6 +605,40 @@
</div>
</div>
{/if}
<!-- Nearby locations -->
{#if nearbyLocations.length > 0}
<div>
<h2 class="mb-4 text-xl font-semibold text-foreground">{$_('detail.nearby')}</h2>
<div class="flex gap-3 overflow-x-auto pb-1">
{#each nearbyLocations as nearby}
<a
href="/locations/{nearby.id}"
class="flex-shrink-0 w-40 overflow-hidden rounded-xl border border-border bg-background-card transition-shadow hover:shadow-md"
>
{#if nearby.imageUrl}
<img
src={nearby.imageUrl}
alt={nearby.name}
loading="lazy"
class="h-24 w-full object-cover"
/>
{:else}
<div class="flex h-24 items-center justify-center bg-background-card-hover">
<span class="text-2xl">📍</span>
</div>
{/if}
<div class="p-2.5">
<p class="text-sm font-medium text-foreground line-clamp-1">{nearby.name}</p>
<p class="mt-0.5 text-xs text-foreground-secondary">
{$_(`category.${nearby.category}`)} · {formatDistance(nearby.distance)}
</p>
</div>
</a>
{/each}
</div>
</div>
{/if}
</div>
{/if}