mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 04:01:09 +02:00
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:
parent
c4cc8529e3
commit
8e390395fd
8 changed files with 379 additions and 23 deletions
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue