From 8e390395fda8d68e007365633b693d6f155aa9d2 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 24 Mar 2026 11:25:17 +0100 Subject: [PATCH] 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) --- .../backend/src/__tests__/mock-factories.ts | 1 + .../backend/src/db/schema/locations.schema.ts | 7 + .../src/location/location.controller.ts | 27 +++ .../backend/src/location/location.service.ts | 72 +++++- .../apps/web/src/lib/i18n/locales/de.json | 8 +- .../apps/web/src/lib/i18n/locales/en.json | 8 +- .../apps/web/src/routes/(app)/+layout.svelte | 57 ++++- .../routes/(app)/locations/[id]/+page.svelte | 222 ++++++++++++++++-- 8 files changed, 379 insertions(+), 23 deletions(-) diff --git a/apps/citycorners/apps/backend/src/__tests__/mock-factories.ts b/apps/citycorners/apps/backend/src/__tests__/mock-factories.ts index 52d5b6e2d..84a58ef58 100644 --- a/apps/citycorners/apps/backend/src/__tests__/mock-factories.ts +++ b/apps/citycorners/apps/backend/src/__tests__/mock-factories.ts @@ -14,6 +14,7 @@ export function createMockLocation(overrides: Partial = {}): 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'), diff --git a/apps/citycorners/apps/backend/src/db/schema/locations.schema.ts b/apps/citycorners/apps/backend/src/db/schema/locations.schema.ts index 9c0ecdde1..6c1bed490 100644 --- a/apps/citycorners/apps/backend/src/db/schema/locations.schema.ts +++ b/apps/citycorners/apps/backend/src/db/schema/locations.schema.ts @@ -19,6 +19,7 @@ export const locations = pgTable('locations', { latitude: doublePrecision('latitude'), longitude: doublePrecision('longitude'), imageUrl: text('image_url'), + images: jsonb('images').$type().default([]), timeline: jsonb('timeline').$type().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; diff --git a/apps/citycorners/apps/backend/src/location/location.controller.ts b/apps/citycorners/apps/backend/src/location/location.controller.ts index e373c4a1f..e2e4fa24d 100644 --- a/apps/citycorners/apps/backend/src/location/location.controller.ts +++ b/apps/citycorners/apps/backend/src/location/location.controller.ts @@ -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) { diff --git a/apps/citycorners/apps/backend/src/location/location.service.ts b/apps/citycorners/apps/backend/src/location/location.service.ts index dbdf8d5c7..e38800c66 100644 --- a/apps/citycorners/apps/backend/src/location/location.service.ts +++ b/apps/citycorners/apps/backend/src/location/location.service.ts @@ -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 { 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` + 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 { + 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 { const existing = await this.findById(id); diff --git a/apps/citycorners/apps/web/src/lib/i18n/locales/de.json b/apps/citycorners/apps/web/src/lib/i18n/locales/de.json index cfc93e18a..680312f33 100644 --- a/apps/citycorners/apps/web/src/lib/i18n/locales/de.json +++ b/apps/citycorners/apps/web/src/lib/i18n/locales/de.json @@ -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", diff --git a/apps/citycorners/apps/web/src/lib/i18n/locales/en.json b/apps/citycorners/apps/web/src/lib/i18n/locales/en.json index 2e6a47bb3..0dd72e3c9 100644 --- a/apps/citycorners/apps/web/src/lib/i18n/locales/en.json +++ b/apps/citycorners/apps/web/src/lib/i18n/locales/en.json @@ -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", diff --git a/apps/citycorners/apps/web/src/routes/(app)/+layout.svelte b/apps/citycorners/apps/web/src/routes/(app)/+layout.svelte index cb32b1a52..61dd6bb88 100644 --- a/apps/citycorners/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/citycorners/apps/web/src/routes/(app)/+layout.svelte @@ -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 { - 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); } } diff --git a/apps/citycorners/apps/web/src/routes/(app)/locations/[id]/+page.svelte b/apps/citycorners/apps/web/src/routes/(app)/locations/[id]/+page.svelte index 273ebfb20..7332e07f3 100644 --- a/apps/citycorners/apps/web/src/routes/(app)/locations/[id]/+page.svelte +++ b/apps/citycorners/apps/web/src/routes/(app)/locations/[id]/+page.svelte @@ -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(null); + let nearbyLocations = $state([]); 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 = { 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`; + } @@ -151,10 +226,16 @@ > {:else} - + {@const images = allImages()} + +
- {#if location.imageUrl} - {location.name} + {#if images.length > 0} + {location.name} {:else}
- + + {#if images.length > 1} +
+ {selectedImageIndex + 1} / {images.length} +
+ {/if} + +
+ + {#if images.length > 1} +
+ {#each images as img, i} + + {/each} + {#if authStore.isAuthenticated} + + {/if} +
+ {:else if authStore.isAuthenticated} +
+ +
+ {/if} + + + {#if showAddPhoto} +
+

{$_('gallery.addPhoto')}

+ {#if photoError} +
{photoError}
+ {/if} +
+ e.key === 'Enter' && handleAddPhoto()} + /> + +
+
+ {/if} +