feat(citycorners): transform into multi-city platform

Rebuild CityCorners from a Konstanz-only guide into a user-generated
platform for any city/village. Users can now create cities and add
locations within them, growing the platform organically.

- Add cities collection (name, slug, country, state, coordinates)
- Add cityId FK to locations, scope locations to cities
- New URL structure: /cities/[slug], /cities/[slug]/map, etc.
- Home page becomes city discovery with search
- Add city creation page with geocoding + slug generation
- Context-aware navigation (global vs city mode)
- Remove all Konstanz-specific hardcoding from i18n and map
- Guest seed with 3 example cities (Konstanz, Zürich, Berlin)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-29 14:09:29 +02:00
parent 3925019344
commit 82a4cb4c59
14 changed files with 2955 additions and 334 deletions

View file

@ -1,14 +1,50 @@
/**
* Guest seed data for the CityCorners app.
*
* Provides iconic Konstanz locations for the onboarding experience.
* Provides example cities and locations for the onboarding experience.
*/
import type { LocalLocation } from './local-store';
import type { LocalCity, LocalLocation } from './local-store';
export const guestCities: LocalCity[] = [
{
id: 'city-konstanz',
name: 'Konstanz',
slug: 'konstanz',
country: 'Deutschland',
state: 'Baden-Württemberg',
description:
'Universitätsstadt am Bodensee mit mittelalterlicher Altstadt, direkt an der Schweizer Grenze.',
latitude: 47.6603,
longitude: 9.1757,
},
{
id: 'city-zuerich',
name: 'Zürich',
slug: 'zuerich',
country: 'Schweiz',
state: 'Zürich',
description:
'Größte Stadt der Schweiz am Zürichsee, bekannt für Kultur, Finanzen und hohe Lebensqualität.',
latitude: 47.3769,
longitude: 8.5417,
},
{
id: 'city-berlin',
name: 'Berlin',
slug: 'berlin',
country: 'Deutschland',
state: 'Berlin',
description: 'Hauptstadt Deutschlands mit vielfältiger Kultur, Geschichte und Nachtleben.',
latitude: 52.52,
longitude: 13.405,
},
];
export const guestLocations: LocalLocation[] = [
{
id: 'loc-muenster',
cityId: 'city-konstanz',
name: 'Konstanzer Münster',
category: 'sight',
description:
@ -19,6 +55,7 @@ export const guestLocations: LocalLocation[] = [
},
{
id: 'loc-imperia',
cityId: 'city-konstanz',
name: 'Imperia',
category: 'sight',
description:
@ -29,6 +66,7 @@ export const guestLocations: LocalLocation[] = [
},
{
id: 'loc-insel',
cityId: 'city-konstanz',
name: 'Mainau Blumeninsel',
category: 'park',
description:
@ -39,6 +77,7 @@ export const guestLocations: LocalLocation[] = [
},
{
id: 'loc-strandbad',
cityId: 'city-konstanz',
name: 'Strandbad Horn',
category: 'beach',
description: 'Beliebtes Freibad am Bodensee mit Sandstrand und Blick auf die Alpen.',
@ -46,4 +85,25 @@ export const guestLocations: LocalLocation[] = [
latitude: 47.6753,
longitude: 9.2001,
},
{
id: 'loc-grossmuenster',
cityId: 'city-zuerich',
name: 'Grossmünster',
category: 'sight',
description:
'Romanische Kirche aus dem 12. Jahrhundert, Wahrzeichen Zürichs mit Aussichtsturm über die Altstadt.',
address: 'Grossmünsterplatz, 8001 Zürich',
latitude: 47.3701,
longitude: 8.5441,
},
{
id: 'loc-brandenburger-tor',
cityId: 'city-berlin',
name: 'Brandenburger Tor',
category: 'sight',
description: 'Das bekannteste Wahrzeichen Berlins und Symbol der deutschen Wiedervereinigung.',
address: 'Pariser Platz, 10117 Berlin',
latitude: 52.5163,
longitude: 13.3777,
},
];

View file

@ -1,16 +1,29 @@
/**
* CityCorners Local-First Data Layer
*
* Locations and favorites stored locally for offline browsing.
* Cities, locations, and favorites stored locally for offline browsing.
* Location lookup and web search remain server-side.
*/
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
import { guestLocations } from './guest-seed';
import { guestCities, guestLocations } from './guest-seed';
// ─── Types ──────────────────────────────────────────────────
export interface LocalCity extends BaseRecord {
name: string;
slug: string;
country: string;
state?: string | null;
description?: string | null;
latitude: number;
longitude: number;
imageUrl?: string | null;
createdBy?: string | null;
}
export interface LocalLocation extends BaseRecord {
cityId: string;
name: string;
category:
| 'sight'
@ -43,9 +56,14 @@ const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localh
export const citycornersStore = createLocalStore({
appId: 'citycorners',
collections: [
{
name: 'cities',
indexes: ['slug', 'country', 'name'],
guestSeed: guestCities,
},
{
name: 'locations',
indexes: ['category', 'name'],
indexes: ['cityId', 'category', 'name'],
guestSeed: guestLocations,
},
{
@ -59,5 +77,6 @@ export const citycornersStore = createLocalStore({
});
// Typed collection accessors
export const cityCollection = citycornersStore.collection<LocalCity>('cities');
export const locationCollection = citycornersStore.collection<LocalLocation>('locations');
export const favoriteCollection = citycornersStore.collection<LocalFavorite>('favorites');

View file

@ -8,14 +8,26 @@
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import {
cityCollection,
locationCollection,
favoriteCollection,
type LocalCity,
type LocalLocation,
type LocalFavorite,
} from './local-store';
// ─── Live Query Hooks (call during component init) ──────────
/** All cities, sorted by name. Auto-updates on any change. */
export function useAllCities() {
return useLiveQueryWithDefault(async () => {
return cityCollection.getAll(undefined, {
sortBy: 'name',
sortDirection: 'asc',
});
}, [] as LocalCity[]);
}
/** All locations, sorted by name. Auto-updates on any change. */
export function useAllLocations() {
return useLiveQueryWithDefault(async () => {
@ -45,6 +57,11 @@ export function isFavorite(favorites: LocalFavorite[], locationId: string): bool
return favorites.some((f) => f.locationId === locationId);
}
/** Filter locations by city. */
export function filterByCity(locations: LocalLocation[], cityId: string): LocalLocation[] {
return locations.filter((l) => l.cityId === cityId);
}
/** Filter locations by category. */
export function filterByCategory(
locations: LocalLocation[],
@ -65,3 +82,30 @@ export function searchLocations(locations: LocalLocation[], query: string): Loca
l.address?.toLowerCase().includes(search)
);
}
/** Filter cities by search query across name, country, state, description. */
export function searchCities(cities: LocalCity[], query: string): LocalCity[] {
if (!query.trim()) return cities;
const search = query.toLowerCase().trim();
return cities.filter(
(c) =>
c.name.toLowerCase().includes(search) ||
c.country.toLowerCase().includes(search) ||
c.state?.toLowerCase().includes(search) ||
c.description?.toLowerCase().includes(search)
);
}
/** Find a city by slug. */
export function findCityBySlug(cities: LocalCity[], slug: string): LocalCity | undefined {
return cities.find((c) => c.slug === slug);
}
/** Count locations per city. */
export function getLocationCountByCity(locations: LocalLocation[]): Map<string, number> {
const counts = new Map<string, number>();
for (const loc of locations) {
counts.set(loc.cityId, (counts.get(loc.cityId) || 0) + 1);
}
return counts;
}

View file

@ -1,7 +1,7 @@
{
"app": {
"name": "CityCorners",
"tagline": "Entdecke Konstanz"
"tagline": "Entdecke Städte weltweit"
},
"nav": {
"explore": "Entdecken",
@ -10,14 +10,45 @@
"favorites": "Favoriten",
"settings": "Einstellungen",
"showNav": "Navigation einblenden",
"hideNav": "Navigation ausblenden"
"hideNav": "Navigation ausblenden",
"cities": "Städte"
},
"cities": {
"title": "Städte entdecken",
"subtitle": "Von der Community für die Community",
"search": "Stadt suchen...",
"add": "Stadt hinzufügen",
"empty": "Noch keine Städte. Sei der Erste!",
"locationsCount": "{count} Orte",
"noLocationsYet": "Noch keine Orte"
},
"cityAdd": {
"title": "Neue Stadt anlegen",
"subtitle": "Füge eine Stadt, ein Dorf oder einen Ort hinzu",
"name": "Name",
"namePlaceholder": "z.B. Konstanz",
"country": "Land",
"countryPlaceholder": "z.B. Deutschland",
"state": "Bundesland / Region (optional)",
"statePlaceholder": "z.B. Baden-Württemberg",
"description": "Beschreibung (optional)",
"descriptionPlaceholder": "Was macht diesen Ort besonders?",
"imageUrl": "Bild-URL (optional)",
"imageUrlPlaceholder": "https://example.com/bild.jpg",
"submit": "Stadt anlegen",
"submitting": "Wird angelegt...",
"loginRequired": "Melde dich an, um Städte anzulegen.",
"error": "Fehler beim Anlegen. Bitte versuche es erneut.",
"geocoding": "Koordinaten werden ermittelt...",
"coordinatesFound": "Koordinaten gefunden",
"slugExists": "Eine Stadt mit diesem Namen existiert bereits."
},
"home": {
"title": "Entdecke Konstanz",
"title": "Orte entdecken",
"subtitle": "Sehenswürdigkeiten, Restaurants, Museen und mehr",
"all": "Alle",
"loading": "Laden...",
"noResults": "Keine Locations gefunden.",
"noResults": "Keine Orte gefunden.",
"noResultsCategory": "Keine {category} gefunden.",
"addFirst": "Ersten Ort hinzufügen",
"loadMore": "Mehr laden"
@ -57,7 +88,7 @@
"linkCopied": "Link kopiert!",
"showDetails": "Details",
"back": "Zurück zur Übersicht",
"notFound": "Location nicht gefunden.",
"notFound": "Ort nicht gefunden.",
"edit": "Bearbeiten",
"delete": "Löschen",
"deleteConfirm": "Bist du sicher, dass du diesen Ort löschen möchtest? Das kann nicht rückgängig gemacht werden.",
@ -114,7 +145,7 @@
},
"map": {
"title": "Karte",
"subtitle": "Alle Orte in Konstanz",
"subtitle": "Alle Orte auf der Karte",
"locateMe": "Mein Standort",
"yourLocation": "Du bist hier",
"geolocationNotSupported": "Standortbestimmung wird nicht unterstützt.",
@ -141,7 +172,7 @@
"login": "Anmelden",
"register": "Registrieren",
"about": "Über CityCorners",
"aboutText": "CityCorners ist ein Stadtführer für Konstanz am Bodensee. Entdecke Sehenswürdigkeiten, Restaurants, Museen und Läden."
"aboutText": "CityCorners ist eine offene Plattform für Stadtführer weltweit. Entdecke Orte, die von der Community geteilt werden — oder lege selbst eine Stadt an."
},
"auth": {
"loginTitle": "Login - CityCorners",
@ -149,7 +180,7 @@
},
"add": {
"title": "Ort hinzufügen",
"subtitle": "Teile deinen Lieblingsort in Konstanz",
"subtitle": "Teile deinen Lieblingsort",
"name": "Name",
"namePlaceholder": "z.B. Café am See",
"category": "Kategorie",
@ -157,10 +188,10 @@
"descriptionPlaceholder": "Was macht diesen Ort besonders?",
"minChars": "Mindestens 10 Zeichen",
"address": "Adresse (optional)",
"addressPlaceholder": "z.B. Seestraße 1, 78462 Konstanz",
"addressPlaceholder": "z.B. Seestraße 1",
"searchTitle": "Ort im Web suchen",
"searchSubtitle": "Wir suchen automatisch nach Infos und füllen das Formular vor.",
"searchPlaceholder": "z.B. Café Zeitlos Konstanz",
"searchPlaceholder": "z.B. Café Zeitlos",
"searchButton": "Suchen",
"skipSearch": "Überspringen und manuell eintragen",
"foundSources": "Quellen gefunden:",

View file

@ -1,7 +1,7 @@
{
"app": {
"name": "CityCorners",
"tagline": "Discover Konstanz"
"tagline": "Discover cities worldwide"
},
"nav": {
"explore": "Explore",
@ -10,14 +10,45 @@
"favorites": "Favorites",
"settings": "Settings",
"showNav": "Show navigation",
"hideNav": "Hide navigation"
"hideNav": "Hide navigation",
"cities": "Cities"
},
"cities": {
"title": "Discover cities",
"subtitle": "By the community, for the community",
"search": "Search cities...",
"add": "Add a city",
"empty": "No cities yet. Be the first!",
"locationsCount": "{count} places",
"noLocationsYet": "No places yet"
},
"cityAdd": {
"title": "Add a new city",
"subtitle": "Add a city, village, or town",
"name": "Name",
"namePlaceholder": "e.g. Berlin",
"country": "Country",
"countryPlaceholder": "e.g. Germany",
"state": "State / Region (optional)",
"statePlaceholder": "e.g. Bavaria",
"description": "Description (optional)",
"descriptionPlaceholder": "What makes this place special?",
"imageUrl": "Image URL (optional)",
"imageUrlPlaceholder": "https://example.com/image.jpg",
"submit": "Add city",
"submitting": "Creating...",
"loginRequired": "Sign in to add cities.",
"error": "Failed to create. Please try again.",
"geocoding": "Finding coordinates...",
"coordinatesFound": "Coordinates found",
"slugExists": "A city with this name already exists."
},
"home": {
"title": "Discover Konstanz",
"title": "Discover places",
"subtitle": "Sights, restaurants, museums and more",
"all": "All",
"loading": "Loading...",
"noResults": "No locations found.",
"noResults": "No places found.",
"noResultsCategory": "No {category} found.",
"addFirst": "Add the first place",
"loadMore": "Load more"
@ -57,7 +88,7 @@
"linkCopied": "Link copied!",
"showDetails": "Details",
"back": "Back to overview",
"notFound": "Location not found.",
"notFound": "Place not found.",
"edit": "Edit",
"delete": "Delete",
"deleteConfirm": "Are you sure you want to delete this place? This cannot be undone.",
@ -114,7 +145,7 @@
},
"map": {
"title": "Map",
"subtitle": "All places in Konstanz",
"subtitle": "All places on the map",
"locateMe": "My location",
"yourLocation": "You are here",
"geolocationNotSupported": "Geolocation is not supported.",
@ -141,7 +172,7 @@
"login": "Sign in",
"register": "Sign up",
"about": "About CityCorners",
"aboutText": "CityCorners is a city guide for Konstanz at Lake Constance. Discover sights, restaurants, museums and shops."
"aboutText": "CityCorners is an open platform for city guides worldwide. Discover places shared by the community — or add your own city."
},
"auth": {
"loginTitle": "Login - CityCorners",
@ -149,7 +180,7 @@
},
"add": {
"title": "Add a place",
"subtitle": "Share your favorite spot in Konstanz",
"subtitle": "Share your favorite spot",
"name": "Name",
"namePlaceholder": "e.g. Lakeside Cafe",
"category": "Category",
@ -157,10 +188,10 @@
"descriptionPlaceholder": "What makes this place special?",
"minChars": "At least 10 characters",
"address": "Address (optional)",
"addressPlaceholder": "e.g. Seestrasse 1, 78462 Konstanz",
"addressPlaceholder": "e.g. Main Street 1",
"searchTitle": "Search for a place online",
"searchSubtitle": "We'll automatically find info and pre-fill the form for you.",
"searchPlaceholder": "e.g. Cafe Zeitlos Konstanz",
"searchPlaceholder": "e.g. Cafe Zeitlos",
"searchButton": "Search",
"skipSearch": "Skip and enter manually",
"foundSources": "Sources found:",

View file

@ -68,20 +68,37 @@
isTagStripVisible = !isTagStripVisible;
}
let navItems = $derived<PillNavItem[]>([
{ href: '/', label: $_('nav.explore'), icon: 'compass' },
{ href: '/map', label: $_('nav.map'), icon: 'mappin' },
{ href: '/add', label: $_('nav.add'), icon: 'plus' },
{ href: '/favorites', label: $_('nav.favorites'), icon: 'heart' },
{ href: '/settings', label: $_('nav.settings'), icon: 'settings' },
{
href: '/',
label: 'Tags',
icon: 'tag',
onClick: handleTagStripToggle,
active: isTagStripVisible,
},
]);
// Detect if we're inside a city context
let currentCitySlug = $derived.by(() => {
const path = $page.url.pathname;
const match = path.match(/^\/cities\/([^/]+)/);
return match ? match[1] : null;
});
let navItems = $derived.by<PillNavItem[]>(() => {
const slug = currentCitySlug;
if (slug) {
return [
{ href: `/cities/${slug}`, label: $_('nav.explore'), icon: 'compass' },
{ href: `/cities/${slug}/map`, label: $_('nav.map'), icon: 'mappin' },
{ href: `/cities/${slug}/add`, label: $_('nav.add'), icon: 'plus' },
{ href: '/favorites', label: $_('nav.favorites'), icon: 'heart' },
{ href: '/settings', label: $_('nav.settings'), icon: 'settings' },
];
}
return [
{ href: '/', label: $_('nav.cities'), icon: 'compass' },
{ href: '/favorites', label: $_('nav.favorites'), icon: 'heart' },
{ href: '/settings', label: $_('nav.settings'), icon: 'settings' },
{
href: '/',
label: 'Tags',
icon: 'tag',
onClick: handleTagStripToggle,
active: isTagStripVisible,
},
];
});
function handleToggleTheme() {
theme.toggleMode();
@ -121,9 +138,13 @@
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history.slice(0, MAX_HISTORY)));
}
function getLocationHref(locId: string): string {
const slug = currentCitySlug;
return slug ? `/cities/${slug}/locations/${locId}` : `/locations/${locId}`;
}
async function handleSearch(query: string): Promise<SearchItem[]> {
if (!query.trim()) {
// Show search history when empty
const history = getSearchHistory();
if (history.length === 0) return [];
return history.map((h) => ({
@ -131,13 +152,12 @@
title: h.name,
subtitle: $_(`category.${h.category}`),
icon: 'clock' as const,
href: `/locations/${h.id}`,
href: getLocationHref(h.id),
isHistory: true,
}));
}
try {
// 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();
@ -147,11 +167,10 @@
title: s.name,
subtitle: $_(`category.${s.category}`),
icon: 'mappin' as const,
href: `/locations/${s.id}`,
href: getLocationHref(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();
@ -160,7 +179,7 @@
title: loc.name,
subtitle: $_(`category.${loc.category}`),
icon: 'mappin' as const,
href: `/locations/${loc.id}`,
href: getLocationHref(loc.id),
}));
} catch {
return [];

View file

@ -1,122 +1,22 @@
<script lang="ts">
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
import { useAllFavorites, getFavoriteIds } from '$lib/data/queries';
import { api } from '$lib/api';
import { isOpenNow } from '$lib/opening-hours';
import {
useAllCities,
useAllLocations,
searchCities,
getLocationCountByCity,
} from '$lib/data/queries';
// Live query for favorites — auto-updates on IndexedDB changes
const allFavorites = useAllFavorites();
let favoriteIds = $derived(getFavoriteIds(allFavorites.value));
const allCities = useAllCities();
const allLocations = useAllLocations();
interface Location {
id: string;
slug?: string;
name: string;
category: string;
description: string;
address?: string;
latitude?: number;
longitude?: number;
imageUrl?: string;
openingHours?: Record<string, string>;
reviewStats?: { averageRating: number; totalReviews: number };
createdBy?: string;
}
let searchQuery = $state('');
interface Pagination {
total: number;
page: number;
limit: number;
totalPages: number;
}
let locationCounts = $derived(getLocationCountByCity(allLocations.value));
let locations = $state<Location[]>([]);
let pagination = $state<Pagination | null>(null);
let loading = $state(true);
let loadingMore = $state(false);
let selectedCategory = $state<string | null>(null);
const categoryKeys = [
'sight',
'restaurant',
'shop',
'museum',
'cafe',
'bar',
'park',
'beach',
'hotel',
'event_venue',
'viewpoint',
];
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
);
let hasMore = $derived(pagination ? pagination.page < pagination.totalPages : false);
async function loadLocations(page = 1, append = false) {
if (page === 1) loading = true;
else loadingMore = true;
try {
const params = new URLSearchParams({ page: String(page), limit: '20' });
if (selectedCategory) params.set('category', selectedCategory);
const res = await fetch(api(`/locations?${params}`));
const data = await res.json();
if (append) {
locations = [...locations, ...data.locations];
} else {
locations = data.locations;
}
pagination = data.pagination;
} catch (err) {
console.error('Failed to load locations:', err);
} finally {
loading = false;
loadingMore = false;
}
}
function loadMore() {
if (pagination && hasMore && !loadingMore) {
loadLocations(pagination.page + 1, true);
}
}
onMount(() => {
loadLocations();
});
// Reload when category changes
$effect(() => {
// Track selectedCategory to re-run
const _ = selectedCategory;
// Don't run on initial mount (loading is still true)
if (!loading || locations.length > 0) {
loadLocations(1);
}
});
function handleFavoriteToggle(e: MouseEvent, locationId: string) {
e.preventDefault();
e.stopPropagation();
favoritesStore.toggle(locationId);
}
let filtered = $derived(searchCities(allCities.value, searchQuery));
</script>
<svelte:head>
@ -125,206 +25,90 @@
<header class="mb-6 flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-foreground">{$_('home.title')}</h1>
<p class="text-foreground-secondary">{$_('home.subtitle')}</p>
<h1 class="text-2xl font-bold text-foreground">{$_('cities.title')}</h1>
<p class="text-foreground-secondary">{$_('cities.subtitle')}</p>
</div>
<a
href="/add"
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary text-white shadow-md transition-all hover:bg-primary/90 hover:shadow-lg"
title={$_('add.title')}
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</a>
{#if authStore.isAuthenticated}
<a
href="/add-city"
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary text-white shadow-md transition-all hover:bg-primary/90 hover:shadow-lg"
title={$_('cities.add')}
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</a>
{/if}
</header>
<div class="mb-6 flex flex-wrap gap-2">
<button
class="rounded-full px-4 py-2 text-sm transition-colors {selectedCategory === null
? 'bg-primary text-white'
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover'}"
onclick={() => (selectedCategory = null)}
>
{$_('home.all')}
{pagination ? `(${pagination.total})` : ''}
</button>
{#each categoryKeys as cat}
<button
class="rounded-full px-4 py-2 text-sm transition-colors {selectedCategory === cat
? 'bg-primary text-white'
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover'}"
onclick={() => (selectedCategory = cat)}
>
{$_(`categories.${cat}`)} ({categoryCounts[cat] || 0})
</button>
{/each}
<!-- Search -->
<div class="mb-6">
<input
type="text"
bind:value={searchQuery}
placeholder={$_('cities.search')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
{#if loading}
<div class="flex items-center justify-center py-12">
<div
class="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"
></div>
</div>
{:else if filtered.length === 0}
{#if filtered.length === 0}
<div class="py-12 text-center">
<span class="mb-2 block text-4xl"
>{selectedCategory === 'restaurant'
? '🍽️'
: selectedCategory === 'museum'
? '🏛️'
: selectedCategory === 'shop'
? '🛍️'
: selectedCategory === 'sight'
? '🏰'
: selectedCategory === 'cafe'
? '☕'
: selectedCategory === 'bar'
? '🍸'
: selectedCategory === 'park'
? '🌳'
: selectedCategory === 'beach'
? '🏖️'
: selectedCategory === 'hotel'
? '🏨'
: selectedCategory === 'event_venue'
? '🎭'
: selectedCategory === 'viewpoint'
? '🔭'
: '📍'}</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>
<span class="mb-2 block text-4xl">🏙️</span>
<p class="text-foreground-secondary">{$_('cities.empty')}</p>
{#if authStore.isAuthenticated}
<a href="/add-city" class="mt-3 inline-block text-sm text-primary hover:underline">
{$_('cities.add')}
</a>
{/if}
</div>
{:else}
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{#each filtered as location}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each filtered as city}
<a
href="/locations/{location.slug || location.id}"
href="/cities/{city.slug}"
class="group relative overflow-hidden rounded-xl border border-border bg-background-card transition-shadow hover:shadow-lg"
>
{#if location.imageUrl}
{#if city.imageUrl}
<img
src={location.imageUrl}
alt={location.name}
src={city.imageUrl}
alt={city.name}
loading="lazy"
class="h-48 w-full object-cover"
class="h-40 w-full object-cover"
/>
{:else}
<div class="flex h-48 items-center justify-center bg-background-card-hover">
<span class="text-4xl">📍</span>
</div>
{/if}
<!-- Favorite button -->
{#if authStore.isAuthenticated}
<button
class="absolute right-3 top-3 flex h-9 w-9 items-center justify-center rounded-full bg-black/30 backdrop-blur-sm transition-all hover:bg-black/50"
onclick={(e) => handleFavoriteToggle(e, location.id)}
title={favoriteIds.has(location.id) ? $_('favorites.remove') : $_('favorites.add')}
<div
class="flex h-40 items-center justify-center bg-gradient-to-br from-primary/10 to-primary/5"
>
{#if favoriteIds.has(location.id)}
<svg class="h-5 w-5 text-red-500" fill="currentColor" viewBox="0 0 24 24">
<path
d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z"
/>
</svg>
{:else}
<svg
class="h-5 w-5 text-white"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"
/>
</svg>
{/if}
</button>
<span class="text-5xl">🏙️</span>
</div>
{/if}
<div class="p-4">
<div class="mb-1 flex flex-wrap items-center gap-1.5">
<span class="inline-block rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
{$_(`categories.${location.category}`)}
</span>
{#if isOpenNow(location.openingHours) === true}
<span
class="inline-block rounded-full bg-green-500/10 px-2 py-0.5 text-xs text-green-600 dark:text-green-400"
>
{$_('detail.openNow')}
</span>
{:else if isOpenNow(location.openingHours) === false}
<span
class="inline-block rounded-full bg-red-500/10 px-2 py-0.5 text-xs text-red-500 dark:text-red-400"
>
{$_('detail.closedNow')}
</span>
<h2 class="text-lg font-semibold text-foreground group-hover:text-primary">
{city.name}
</h2>
<p class="text-sm text-foreground-secondary">
{#if city.state}
{city.state}, {city.country}
{:else}
{city.country}
{/if}
</p>
{#if city.description}
<p class="mt-1 line-clamp-2 text-sm text-foreground-secondary/80">
{city.description}
</p>
{/if}
<div class="mt-2 text-xs text-foreground-secondary/60">
{@const count = locationCounts.get(city.id) || 0}
{#if count > 0}
{$_('cities.locationsCount', { values: { count } })}
{:else}
{$_('cities.noLocationsYet')}
{/if}
</div>
<h2 class="text-lg font-semibold text-foreground group-hover:text-primary">
{location.name}
</h2>
<p class="mt-1 line-clamp-2 text-sm text-foreground-secondary">
{location.description}
</p>
{#if location.reviewStats && location.reviewStats.totalReviews > 0}
<div class="mt-2 flex items-center gap-1.5 text-xs text-foreground-secondary">
<div class="flex items-center gap-0.5">
{#each Array(5) as _, i}
<svg
class="h-3.5 w-3.5 {i < Math.round(location.reviewStats.averageRating)
? 'text-amber-400'
: 'text-gray-300 dark:text-gray-600'}"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
/>
</svg>
{/each}
</div>
<span>{location.reviewStats.averageRating.toFixed(1)}</span>
<span class="text-foreground-secondary/60">({location.reviewStats.totalReviews})</span
>
</div>
{/if}
</div>
</a>
{/each}
</div>
<!-- Load more -->
{#if hasMore}
<div class="mt-8 text-center">
<button
onclick={loadMore}
disabled={loadingMore}
class="rounded-lg border border-border bg-background-card px-6 py-2.5 text-sm font-medium text-foreground-secondary transition-colors hover:bg-background-card-hover hover:text-foreground disabled:opacity-50"
>
{#if loadingMore}
<div
class="inline-block h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin mr-2 align-middle"
></div>
{/if}
{$_('home.loadMore')}
</button>
</div>
{/if}
{/if}

View file

@ -0,0 +1,255 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { cityCollection, type LocalCity } from '$lib/data/local-store';
import { useAllCities } from '$lib/data/queries';
const allCities = useAllCities();
let name = $state('');
let country = $state('');
let stateRegion = $state('');
let description = $state('');
let imageUrl = $state('');
let latitude = $state<number | undefined>(undefined);
let longitude = $state<number | undefined>(undefined);
let submitting = $state(false);
let error = $state('');
let geocoding = $state(false);
let imageError = $state(false);
let slug = $derived(
name
.trim()
.toLowerCase()
.replace(/[äÄ]/g, 'ae')
.replace(/[öÖ]/g, 'oe')
.replace(/[üÜ]/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
);
let slugExists = $derived(allCities.value.some((c) => c.slug === slug));
let isValid = $derived(name.trim().length > 0 && country.trim().length > 0 && !slugExists);
async function geocodeCityName() {
const q = name.trim();
if (!q) return;
geocoding = true;
try {
const searchQ = country.trim() ? `${q}, ${country.trim()}` : q;
const res = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchQ)}&limit=1`,
{ headers: { 'User-Agent': 'CityCorners/1.0' } }
);
const results = await res.json();
if (results.length > 0) {
latitude = parseFloat(results[0].lat);
longitude = parseFloat(results[0].lon);
}
} catch {
// best-effort
} finally {
geocoding = false;
}
}
let geocodeTimeout: ReturnType<typeof setTimeout> | undefined;
function handleNameInput() {
clearTimeout(geocodeTimeout);
geocodeTimeout = setTimeout(() => {
if (name.trim().length > 2) {
geocodeCityName();
}
}, 1000);
}
async function handleSubmit() {
if (!isValid || submitting) return;
submitting = true;
error = '';
try {
// Geocode if we don't have coordinates yet
if (latitude === undefined || longitude === undefined) {
await geocodeCityName();
}
if (latitude === undefined || longitude === undefined) {
// Default to 0,0 — user can update later
latitude = 0;
longitude = 0;
}
const cityData: Omit<LocalCity, 'createdAt' | 'updatedAt' | 'deletedAt'> = {
id: `city-${slug}-${Date.now()}`,
name: name.trim(),
slug,
country: country.trim(),
state: stateRegion.trim() || null,
description: description.trim() || null,
latitude,
longitude,
imageUrl: imageUrl.trim() && !imageError ? imageUrl.trim() : null,
createdBy: authStore.user?.id || null,
};
await cityCollection.insert(cityData);
goto(`/cities/${slug}`);
} catch {
error = $_('cityAdd.error');
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<title>{$_('cityAdd.title')} - CityCorners</title>
</svelte:head>
<header class="mb-6">
<div class="flex items-center gap-2 mb-1">
<a href="/" class="text-foreground-secondary hover:text-primary transition-colors">
<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="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</a>
<h1 class="text-2xl font-bold text-foreground">{$_('cityAdd.title')}</h1>
</div>
<p class="text-foreground-secondary">{$_('cityAdd.subtitle')}</p>
</header>
{#if !authStore.isAuthenticated}
<div class="rounded-xl border border-border bg-background-card p-8 text-center">
<span class="mb-2 block text-4xl">🏙️</span>
<p class="mb-4 text-foreground-secondary">{$_('cityAdd.loginRequired')}</p>
<a
href="/login?redirectTo=/add-city"
class="inline-block rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-primary/90"
>
{$_('settings.login')}
</a>
</div>
{:else}
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-5"
>
{#if error}
<div class="rounded-lg bg-red-500/10 p-3 text-sm text-red-500">{error}</div>
{/if}
<div>
<label for="name" class="mb-1 block text-sm font-medium text-foreground"
>{$_('cityAdd.name')}</label
>
<input
id="name"
type="text"
bind:value={name}
oninput={handleNameInput}
placeholder={$_('cityAdd.namePlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
{#if slug && slugExists}
<p class="mt-1 text-xs text-red-500">{$_('cityAdd.slugExists')}</p>
{:else if slug}
<p class="mt-1 text-xs text-foreground-secondary/60">/{slug}</p>
{/if}
</div>
<div>
<label for="country" class="mb-1 block text-sm font-medium text-foreground"
>{$_('cityAdd.country')}</label
>
<input
id="country"
type="text"
bind:value={country}
placeholder={$_('cityAdd.countryPlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label for="state" class="mb-1 block text-sm font-medium text-foreground"
>{$_('cityAdd.state')}</label
>
<input
id="state"
type="text"
bind:value={stateRegion}
placeholder={$_('cityAdd.statePlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label for="description" class="mb-1 block text-sm font-medium text-foreground"
>{$_('cityAdd.description')}</label
>
<textarea
id="description"
bind:value={description}
placeholder={$_('cityAdd.descriptionPlaceholder')}
rows="3"
class="w-full resize-none rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
></textarea>
</div>
<div>
<label for="imageUrl" class="mb-1 block text-sm font-medium text-foreground"
>{$_('cityAdd.imageUrl')}</label
>
<input
id="imageUrl"
type="url"
bind:value={imageUrl}
oninput={() => (imageError = false)}
placeholder={$_('cityAdd.imageUrlPlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
{#if imageUrl.trim() && !imageError}
<div class="mt-2 overflow-hidden rounded-lg border border-border">
<img
src={imageUrl}
alt="Preview"
class="h-40 w-full object-cover"
onerror={() => (imageError = true)}
/>
</div>
{/if}
</div>
{#if geocoding}
<p class="text-xs text-foreground-secondary/60">{$_('cityAdd.geocoding')}</p>
{:else if latitude !== undefined && longitude !== undefined}
<p class="text-xs text-green-600 dark:text-green-400">{$_('cityAdd.coordinatesFound')}</p>
{/if}
<div class="flex gap-3">
<a
href="/"
class="rounded-lg border border-border bg-background px-4 py-3 text-sm font-medium text-foreground-secondary transition-colors hover:bg-background-card-hover"
>
{$_('edit.cancel')}
</a>
<button
type="submit"
disabled={!isValid || submitting}
class="flex-1 rounded-lg bg-primary px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? $_('cityAdd.submitting') : $_('cityAdd.submit')}
</button>
</div>
</form>
{/if}

View file

@ -0,0 +1,37 @@
<script lang="ts">
import { page } from '$app/stores';
import { setContext } from 'svelte';
import { useAllCities, findCityBySlug } from '$lib/data/queries';
import type { LocalCity } from '$lib/data/local-store';
let { children } = $props();
const allCities = useAllCities();
let currentCity = $derived(findCityBySlug(allCities.value, $page.params.slug ?? ''));
setContext('currentCity', {
get value() {
return currentCity;
},
});
</script>
{#if currentCity}
{@render children()}
{:else if allCities.value.length > 0}
<div class="py-12 text-center">
<span class="mb-2 block text-4xl">🔍</span>
<p class="text-foreground-secondary">Stadt nicht gefunden.</p>
<a href="/" class="mt-3 inline-block text-sm text-primary hover:underline">
Zurück zu allen Städten
</a>
</div>
{:else}
<!-- Still loading -->
<div class="flex items-center justify-center py-12">
<div
class="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"
></div>
</div>
{/if}

View file

@ -0,0 +1,329 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { page } from '$app/stores';
import { authStore } from '$lib/stores/auth.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
import { useAllFavorites, getFavoriteIds } from '$lib/data/queries';
import { api } from '$lib/api';
import { isOpenNow } from '$lib/opening-hours';
import type { LocalCity } from '$lib/data/local-store';
const cityCtx = getContext<{ value: LocalCity | undefined }>('currentCity');
let city = $derived(cityCtx.value);
const allFavorites = useAllFavorites();
let favoriteIds = $derived(getFavoriteIds(allFavorites.value));
interface Location {
id: string;
slug?: string;
name: string;
category: string;
description: string;
address?: string;
latitude?: number;
longitude?: number;
imageUrl?: string;
openingHours?: Record<string, string>;
reviewStats?: { averageRating: number; totalReviews: number };
createdBy?: string;
}
interface Pagination {
total: number;
page: number;
limit: number;
totalPages: number;
}
let locations = $state<Location[]>([]);
let pagination = $state<Pagination | null>(null);
let loading = $state(true);
let loadingMore = $state(false);
let selectedCategory = $state<string | null>(null);
const categoryKeys = [
'sight',
'restaurant',
'shop',
'museum',
'cafe',
'bar',
'park',
'beach',
'hotel',
'event_venue',
'viewpoint',
];
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
);
let hasMore = $derived(pagination ? pagination.page < pagination.totalPages : false);
let citySlug = $derived($page.params.slug);
async function loadLocations(page = 1, append = false) {
if (page === 1) loading = true;
else loadingMore = true;
try {
const params = new URLSearchParams({ page: String(page), limit: '20' });
if (selectedCategory) params.set('category', selectedCategory);
if (city) params.set('cityId', city.id);
const res = await fetch(api(`/locations?${params}`));
const data = await res.json();
if (append) {
locations = [...locations, ...data.locations];
} else {
locations = data.locations;
}
pagination = data.pagination;
} catch (err) {
console.error('Failed to load locations:', err);
} finally {
loading = false;
loadingMore = false;
}
}
function loadMore() {
if (pagination && hasMore && !loadingMore) {
loadLocations(pagination.page + 1, true);
}
}
onMount(() => {
loadLocations();
});
$effect(() => {
const _ = selectedCategory;
if (!loading || locations.length > 0) {
loadLocations(1);
}
});
function handleFavoriteToggle(e: MouseEvent, locationId: string) {
e.preventDefault();
e.stopPropagation();
favoritesStore.toggle(locationId);
}
</script>
<svelte:head>
<title>{city?.name || ''} - CityCorners</title>
</svelte:head>
<header class="mb-6 flex items-start justify-between">
<div>
<div class="mb-1 flex items-center gap-2">
<a href="/" class="text-foreground-secondary hover:text-primary transition-colors">
<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="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</a>
<h1 class="text-2xl font-bold text-foreground">{city?.name}</h1>
</div>
<p class="text-foreground-secondary">
{#if city?.state}
{city.state}, {city.country}
{:else}
{city?.country}
{/if}
</p>
{#if city?.description}
<p class="mt-1 text-sm text-foreground-secondary/80">{city.description}</p>
{/if}
</div>
<a
href="/cities/{citySlug}/add"
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary text-white shadow-md transition-all hover:bg-primary/90 hover:shadow-lg"
title={$_('add.title')}
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</a>
</header>
<!-- Category filter pills -->
<div class="mb-6 flex flex-wrap gap-2">
<button
class="rounded-full px-4 py-2 text-sm transition-colors {selectedCategory === null
? 'bg-primary text-white'
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover'}"
onclick={() => (selectedCategory = null)}
>
{$_('home.all')}
{pagination ? `(${pagination.total})` : ''}
</button>
{#each categoryKeys as cat}
{@const count = categoryCounts[cat] || 0}
{#if count > 0}
<button
class="rounded-full px-4 py-2 text-sm transition-colors {selectedCategory === cat
? 'bg-primary text-white'
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover'}"
onclick={() => (selectedCategory = cat)}
>
{$_(`categories.${cat}`)} ({count})
</button>
{/if}
{/each}
</div>
{#if loading}
<div class="flex items-center justify-center py-12">
<div
class="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"
></div>
</div>
{:else if filtered.length === 0}
<div class="py-12 text-center">
<span class="mb-2 block text-4xl">📍</span>
<p class="text-foreground-secondary">
{#if selectedCategory}
{$_('home.noResultsCategory', {
values: { category: $_(`categories.${selectedCategory}`) },
})}
{:else}
{$_('home.noResults')}
{/if}
</p>
<a href="/cities/{citySlug}/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}
<a
href="/cities/{citySlug}/locations/{location.slug || location.id}"
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}
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>
</div>
{/if}
{#if authStore.isAuthenticated}
<button
class="absolute right-3 top-3 flex h-9 w-9 items-center justify-center rounded-full bg-black/30 backdrop-blur-sm transition-all hover:bg-black/50"
onclick={(e) => handleFavoriteToggle(e, location.id)}
title={favoriteIds.has(location.id) ? $_('favorites.remove') : $_('favorites.add')}
>
{#if favoriteIds.has(location.id)}
<svg class="h-5 w-5 text-red-500" fill="currentColor" viewBox="0 0 24 24">
<path
d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z"
/>
</svg>
{:else}
<svg
class="h-5 w-5 text-white"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"
/>
</svg>
{/if}
</button>
{/if}
<div class="p-4">
<div class="mb-1 flex flex-wrap items-center gap-1.5">
<span class="inline-block rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
{$_(`categories.${location.category}`)}
</span>
{#if isOpenNow(location.openingHours) === true}
<span
class="inline-block rounded-full bg-green-500/10 px-2 py-0.5 text-xs text-green-600 dark:text-green-400"
>
{$_('detail.openNow')}
</span>
{:else if isOpenNow(location.openingHours) === false}
<span
class="inline-block rounded-full bg-red-500/10 px-2 py-0.5 text-xs text-red-500 dark:text-red-400"
>
{$_('detail.closedNow')}
</span>
{/if}
</div>
<h2 class="text-lg font-semibold text-foreground group-hover:text-primary">
{location.name}
</h2>
<p class="mt-1 line-clamp-2 text-sm text-foreground-secondary">
{location.description}
</p>
{#if location.reviewStats && location.reviewStats.totalReviews > 0}
<div class="mt-2 flex items-center gap-1.5 text-xs text-foreground-secondary">
<div class="flex items-center gap-0.5">
{#each Array(5) as _, i}
<svg
class="h-3.5 w-3.5 {i < Math.round(location.reviewStats.averageRating)
? 'text-amber-400'
: 'text-gray-300 dark:text-gray-600'}"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
/>
</svg>
{/each}
</div>
<span>{location.reviewStats.averageRating.toFixed(1)}</span>
<span class="text-foreground-secondary/60">({location.reviewStats.totalReviews})</span
>
</div>
{/if}
</div>
</a>
{/each}
</div>
{#if hasMore}
<div class="mt-8 text-center">
<button
onclick={loadMore}
disabled={loadingMore}
class="rounded-lg border border-border bg-background-card px-6 py-2.5 text-sm font-medium text-foreground-secondary transition-colors hover:bg-background-card-hover hover:text-foreground disabled:opacity-50"
>
{#if loadingMore}
<div
class="inline-block h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin mr-2 align-middle"
></div>
{/if}
{$_('home.loadMore')}
</button>
</div>
{/if}
{/if}

View file

@ -0,0 +1,450 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { page } from '$app/stores';
import { authStore } from '$lib/stores/auth.svelte';
import { api } from '$lib/api';
import type { LocalCity } from '$lib/data/local-store';
const cityCtx = getContext<{ value: LocalCity | undefined }>('currentCity');
let city = $derived(cityCtx.value);
let citySlug = $derived($page.params.slug);
// Lookup state
let searchQuery = $state('');
let searching = $state(false);
let lookupDone = $state(false);
let sources = $state<{ url: string; title: string }[]>([]);
// Form state
let name = $state('');
let category = $state<string>('sight');
let description = $state('');
let address = $state('');
let imageUrl = $state('');
let latitude = $state<number | undefined>(undefined);
let longitude = $state<number | undefined>(undefined);
let website = $state('');
let phone = $state('');
let submitting = $state(false);
let error = $state('');
let geocoding = $state(false);
let imageError = $state(false);
const categories = [
{ value: 'sight', labelKey: 'category.sight' },
{ value: 'restaurant', labelKey: 'category.restaurant' },
{ value: 'shop', labelKey: 'category.shop' },
{ value: 'museum', labelKey: 'category.museum' },
{ value: 'cafe', labelKey: 'category.cafe' },
{ value: 'bar', labelKey: 'category.bar' },
{ value: 'park', labelKey: 'category.park' },
{ value: 'beach', labelKey: 'category.beach' },
{ value: 'hotel', labelKey: 'category.hotel' },
{ value: 'event_venue', labelKey: 'category.event_venue' },
{ value: 'viewpoint', labelKey: 'category.viewpoint' },
];
let isValid = $derived(name.trim().length > 0 && description.trim().length > 10);
async function handleLookup() {
if (!searchQuery.trim() || searching) return;
searching = true;
error = '';
sources = [];
try {
const res = await fetch(api(`/locations/lookup?q=${encodeURIComponent(searchQuery.trim())}`));
if (!res.ok) throw new Error('Lookup failed');
const data = await res.json();
if (data.result) {
name = data.result.name || searchQuery.trim();
description = data.result.description || '';
address = data.result.address || '';
category = data.result.category || 'sight';
sources = data.result.sources || [];
if (data.result.imageUrl) {
imageUrl = data.result.imageUrl;
imageError = false;
}
if (data.result.latitude && data.result.longitude) {
latitude = data.result.latitude;
longitude = data.result.longitude;
}
} else {
name = searchQuery.trim();
}
lookupDone = true;
if (address && !latitude) {
geocodeAddress();
}
} catch {
name = searchQuery.trim();
lookupDone = true;
} finally {
searching = false;
}
}
function handleSkipLookup() {
name = searchQuery.trim();
lookupDone = true;
}
function handleReset() {
lookupDone = false;
searchQuery = '';
name = '';
description = '';
address = '';
imageUrl = '';
website = '';
phone = '';
category = 'sight';
latitude = undefined;
longitude = undefined;
sources = [];
error = '';
imageError = false;
}
async function geocodeAddress() {
const addr = address.trim();
if (!addr) return;
geocoding = true;
try {
const cityName = city?.name || '';
const q =
cityName && !addr.toLowerCase().includes(cityName.toLowerCase())
? `${addr}, ${cityName}`
: addr;
const res = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(q)}&limit=1`,
{ headers: { 'User-Agent': 'CityCorners/1.0' } }
);
const results = await res.json();
if (results.length > 0) {
latitude = parseFloat(results[0].lat);
longitude = parseFloat(results[0].lon);
}
} catch {
// Geocoding is best-effort
} finally {
geocoding = false;
}
}
let geocodeTimeout: ReturnType<typeof setTimeout> | undefined;
function handleAddressInput() {
clearTimeout(geocodeTimeout);
geocodeTimeout = setTimeout(() => {
if (address.trim().length > 5) {
geocodeAddress();
}
}, 1000);
}
async function handleSubmit() {
if (!isValid || submitting || !city) return;
submitting = true;
error = '';
try {
const token = await authStore.getValidToken();
if (!token) {
error = $_('add.loginRequired');
return;
}
const body: Record<string, unknown> = {
name: name.trim(),
category,
description: description.trim(),
cityId: city.id,
};
if (address.trim()) body.address = address.trim();
if (imageUrl.trim() && !imageError) body.imageUrl = imageUrl.trim();
if (website.trim()) body.website = website.trim();
if (phone.trim()) body.phone = phone.trim();
if (latitude !== undefined && longitude !== undefined) {
body.latitude = latitude;
body.longitude = longitude;
}
const res = await fetch(api('/locations'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
if (res.ok) {
const data = await res.json();
goto(`/cities/${citySlug}/locations/${data.location.id}`);
} else {
const data = await res.json().catch(() => ({}));
error = data.message || $_('add.error');
}
} catch {
error = $_('add.error');
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<title>{$_('add.title')} - {city?.name || 'CityCorners'}</title>
</svelte:head>
<header class="mb-6">
<div class="flex items-center gap-2 mb-1">
<a
href="/cities/{citySlug}"
class="text-foreground-secondary hover:text-primary transition-colors"
>
<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="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</a>
<h1 class="text-2xl font-bold text-foreground">{$_('add.title')}</h1>
</div>
<p class="text-foreground-secondary">{$_('add.subtitle')}{city?.name}</p>
</header>
{#if !authStore.isAuthenticated}
<div class="rounded-xl border border-border bg-background-card p-8 text-center">
<span class="mb-2 block text-4xl">📍</span>
<p class="mb-4 text-foreground-secondary">{$_('add.loginRequired')}</p>
<a
href="/login?redirectTo=/cities/{citySlug}/add"
class="inline-block rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-primary/90"
>
{$_('settings.login')}
</a>
</div>
{:else if !lookupDone}
<div class="space-y-4">
<div class="rounded-xl border border-border bg-background-card p-5">
<h2 class="mb-1 text-lg font-semibold text-foreground">{$_('add.searchTitle')}</h2>
<p class="mb-4 text-sm text-foreground-secondary">{$_('add.searchSubtitle')}</p>
<div class="flex gap-2">
<input
type="text"
bind:value={searchQuery}
placeholder={$_('add.searchPlaceholder')}
class="flex-1 rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
onkeydown={(e) => e.key === 'Enter' && handleLookup()}
/>
<button
onclick={handleLookup}
disabled={!searchQuery.trim() || searching}
class="rounded-lg bg-primary px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if searching}
<div
class="h-5 w-5 border-2 border-white border-t-transparent rounded-full animate-spin"
></div>
{:else}
{$_('add.searchButton')}
{/if}
</button>
</div>
</div>
<button
onclick={handleSkipLookup}
class="w-full text-center text-sm text-foreground-secondary hover:text-primary transition-colors"
>
{$_('add.skipSearch')}
</button>
</div>
{:else}
{#if sources.length > 0}
<div class="mb-5 rounded-lg bg-primary/5 border border-primary/20 p-3">
<p class="mb-2 text-xs font-medium text-primary">{$_('add.foundSources')}</p>
<div class="space-y-1">
{#each sources.slice(0, 3) as source}
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
class="block truncate text-xs text-foreground-secondary hover:text-primary"
>
{source.title}
</a>
{/each}
</div>
</div>
{/if}
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-5"
>
{#if error}
<div class="rounded-lg bg-red-500/10 p-3 text-sm text-red-500">{error}</div>
{/if}
<div>
<label for="name" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.name')}</label
>
<input
id="name"
type="text"
bind:value={name}
placeholder={$_('add.namePlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label for="category" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.category')}</label
>
<div class="flex flex-wrap gap-2">
{#each categories as cat}
<button
type="button"
class="rounded-full px-4 py-2 text-sm transition-colors {category === cat.value
? 'bg-primary text-white'
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover border border-border'}"
onclick={() => (category = cat.value)}
>
{$_(cat.labelKey)}
</button>
{/each}
</div>
</div>
<div>
<label for="description" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.description')}</label
>
<textarea
id="description"
bind:value={description}
placeholder={$_('add.descriptionPlaceholder')}
rows="4"
class="w-full resize-none rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
></textarea>
<p class="mt-1 text-xs text-foreground-secondary/60">{$_('add.minChars')}</p>
</div>
<div>
<label for="address" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.address')}</label
>
<input
id="address"
type="text"
bind:value={address}
oninput={handleAddressInput}
placeholder={$_('add.addressPlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
{#if geocoding}
<p class="mt-1 text-xs text-foreground-secondary/60">{$_('add.geocoding')}</p>
{:else if latitude !== undefined && longitude !== undefined}
<p class="mt-1 text-xs text-green-600 dark:text-green-400">
{$_('add.coordinatesFound')}
</p>
{/if}
</div>
<div>
<label for="imageUrl" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.imageUrl')}</label
>
<input
id="imageUrl"
type="url"
bind:value={imageUrl}
oninput={() => (imageError = false)}
placeholder={$_('add.imageUrlPlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
{#if imageUrl.trim() && !imageError}
<div class="mt-2 overflow-hidden rounded-lg border border-border">
<img
src={imageUrl}
alt={$_('add.imagePreview')}
class="h-40 w-full object-cover"
onerror={() => (imageError = true)}
/>
</div>
{:else if imageError}
<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>
<div>
<label for="website" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.website')}</label
>
<input
id="website"
type="url"
bind:value={website}
placeholder={$_('add.websitePlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label for="phone" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.phone')}</label
>
<input
id="phone"
type="tel"
bind:value={phone}
placeholder={$_('add.phonePlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div class="flex gap-3">
<button
type="button"
onclick={handleReset}
class="rounded-lg border border-border bg-background px-4 py-3 text-sm font-medium text-foreground-secondary transition-colors hover:bg-background-card-hover"
>
{$_('add.reset')}
</button>
<button
type="submit"
disabled={!isValid || submitting}
class="flex-1 rounded-lg bg-primary px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? $_('add.submitting') : $_('add.submit')}
</button>
</div>
</form>
{/if}

View file

@ -0,0 +1,954 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
import { useAllFavorites, getFavoriteIds } from '$lib/data/queries';
import { api } from '$lib/api';
import { isOpenNow } from '$lib/opening-hours';
import type { LocalCity } from '$lib/data/local-store';
const cityCtx = getContext<{ value: LocalCity | undefined }>('currentCity');
let city = $derived(cityCtx.value);
let citySlug = $derived($page.params.slug);
const allFavorites = useAllFavorites();
let favoriteIds = $derived(getFavoriteIds(allFavorites.value));
interface TimelineEntry {
year: string;
event: string;
}
interface LocationImage {
url: string;
addedBy?: string;
addedAt?: string;
}
interface NearbyLocation {
id: string;
slug?: string;
name: string;
category: string;
imageUrl?: string;
distance: number;
}
interface ReviewStats {
averageRating: number;
totalReviews: number;
}
interface Review {
id: string;
userId: string;
rating: number;
comment?: string;
createdAt: string;
}
interface Location {
id: string;
slug?: string;
name: string;
category: string;
description: string;
address?: string;
latitude?: number;
longitude?: number;
imageUrl?: string;
images?: LocationImage[];
timeline?: TimelineEntry[];
website?: string;
phone?: string;
openingHours?: Record<string, string>;
reviewStats?: ReviewStats;
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);
let reviews = $state<Review[]>([]);
let reviewRating = $state(0);
let reviewComment = $state('');
let submittingReview = $state(false);
let reviewError = $state('');
let showReviewForm = $state(false);
let userHasReviewed = $derived(
authStore.isAuthenticated && reviews.some((r) => r.userId === authStore.user?.id)
);
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',
shop: '#16a34a',
museum: '#9333ea',
cafe: '#b45309',
bar: '#ea580c',
park: '#15803d',
beach: '#0891b2',
hotel: '#4f46e5',
event_venue: '#db2777',
viewpoint: '#0ea5e9',
};
let isOwner = $derived(
location?.createdBy != null &&
authStore.isAuthenticated &&
authStore.user?.id === location.createdBy
);
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 [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 {
loading = false;
}
loadReviews();
});
$effect(() => {
if (!browser || !location || !location.latitude || !location.longitude || !mapContainer) return;
const initMap = async () => {
const L = await import('leaflet');
const map = L.map(mapContainer, {
zoomControl: false,
attributionControl: false,
}).setView([location!.latitude!, location!.longitude!], 16);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
}).addTo(map);
const color = categoryColors[location!.category] || '#6b7280';
const icon = L.divIcon({
className: 'custom-marker',
html: `<div style="background:${color};width:32px;height:32px;border-radius:50%;border:3px solid white;box-shadow:0 2px 8px rgba(0,0,0,0.3);"></div>`,
iconSize: [32, 32],
iconAnchor: [16, 16],
});
L.marker([location!.latitude!, location!.longitude!], { icon }).addTo(map);
};
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
}
} else {
await navigator.clipboard.writeText(url);
shareSuccess = true;
setTimeout(() => (shareSuccess = false), 2000);
}
}
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(`/cities/${citySlug}`);
} catch {
// ignore
} finally {
deleting = false;
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;
}
}
async function loadReviews() {
if (!location) return;
try {
const res = await fetch(api(`/reviews/${location.id}`));
if (res.ok) {
const data = await res.json();
reviews = data.reviews || [];
}
} catch {
// ignore
}
}
async function handleSubmitReview() {
if (!location || submittingReview || reviewRating === 0) return;
submittingReview = true;
reviewError = '';
try {
const token = await authStore.getValidToken();
const res = await fetch(api(`/reviews/${location.id}`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
rating: reviewRating,
comment: reviewComment.trim() || undefined,
}),
});
if (res.ok) {
reviewRating = 0;
reviewComment = '';
showReviewForm = false;
await loadReviews();
const statsRes = await fetch(api(`/reviews/${location.id}/stats`));
if (statsRes.ok) {
const statsData = await statsRes.json();
location = { ...location, reviewStats: statsData.stats };
}
} else {
reviewError = $_('reviews.error');
}
} catch {
reviewError = $_('reviews.error');
} finally {
submittingReview = false;
}
}
async function handleDeleteReview() {
if (!location) return;
try {
const token = await authStore.getValidToken();
await fetch(api(`/reviews/${location.id}`), {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
await loadReviews();
const statsRes = await fetch(api(`/reviews/${location.id}/stats`));
if (statsRes.ok) {
const statsData = await statsRes.json();
location = { ...location, reviewStats: statsData.stats };
}
} catch {
// ignore
}
}
function formatDistance(meters: number): string {
if (meters < 1000) return `${meters} m`;
return `${(meters / 1000).toFixed(1)} km`;
}
</script>
<svelte:head>
<title>{location?.name || 'Location'} - {city?.name || 'CityCorners'}</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
</svelte:head>
{#if loading}
<div class="flex items-center justify-center py-20">
<div
class="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin"
></div>
</div>
{:else if !location}
<div class="py-20 text-center">
<span class="mb-4 block text-5xl">🔍</span>
<p class="text-foreground-secondary">{$_('detail.notFound')}</p>
<a href="/cities/{citySlug}" class="mt-4 inline-block text-sm text-primary hover:underline"
>{$_('detail.back')}</a
>
</div>
{:else}
{@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 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"
>
<span class="text-7xl">📍</span>
</div>
{/if}
<!-- Back button overlay -->
<div class="absolute left-4 top-4">
<a
href="/cities/{citySlug}"
class="flex h-10 w-10 items-center justify-center rounded-full bg-black/30 text-white backdrop-blur-sm transition-colors hover:bg-black/50"
>
<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="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</a>
</div>
<!-- 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)}
title={favoriteIds.has(location.id) ? $_('favorites.remove') : $_('favorites.add')}
>
{#if favoriteIds.has(location.id)}
<svg class="h-5 w-5 text-red-500" fill="currentColor" viewBox="0 0 24 24">
<path
d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z"
/>
</svg>
{:else}
<svg
class="h-5 w-5 text-white"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"
/>
</svg>
{/if}
</button>
{/if}
</div>
{#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}
<div class="absolute bottom-4 left-4 flex items-center gap-2">
<span
class="rounded-full px-3 py-1 text-sm font-medium text-white backdrop-blur-sm"
style="background: {categoryColors[location.category] || '#6b7280'}cc"
>
{$_(`category.${location.category}`)}
</span>
{#if isOpenNow(location.openingHours) === true}
<span
class="rounded-full bg-green-500/90 px-3 py-1 text-sm font-medium text-white backdrop-blur-sm"
>
{$_('detail.openNow')}
</span>
{:else if isOpenNow(location.openingHours) === false}
<span
class="rounded-full bg-red-500/80 px-3 py-1 text-sm font-medium text-white backdrop-blur-sm"
>
{$_('detail.closedNow')}
</span>
{/if}
</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>
<h1 class="text-3xl font-bold text-foreground">{location.name}</h1>
{#if location.address}
<p class="mt-2 flex items-center gap-1.5 text-foreground-secondary">
<svg
class="h-4 w-4 flex-shrink-0"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
/>
</svg>
{location.address}
</p>
{/if}
</div>
<p class="text-base leading-relaxed text-foreground">{location.description}</p>
<!-- Contact info -->
{#if location.website || location.phone}
<div class="space-y-2">
{#if location.website}
<div class="flex items-center gap-2 text-sm">
<span class="font-medium text-foreground-secondary">{$_('detail.website')}:</span>
<a
href={location.website}
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline truncate"
>
{location.website.replace(/^https?:\/\//, '')}
</a>
</div>
{/if}
{#if location.phone}
<div class="flex items-center gap-2 text-sm">
<span class="font-medium text-foreground-secondary">{$_('detail.phone')}:</span>
<a href="tel:{location.phone}" class="text-primary hover:underline">
{location.phone}
</a>
</div>
{/if}
</div>
{/if}
<!-- Opening hours -->
{#if location.openingHours && Object.keys(location.openingHours).length > 0}
<div>
<h2 class="mb-3 text-lg font-semibold text-foreground">{$_('detail.openingHours')}</h2>
<div class="rounded-xl border border-border bg-background-card overflow-hidden">
<table class="w-full text-sm">
<tbody>
{#each ['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'] as day}
{#if location.openingHours[day]}
<tr class="border-b border-border last:border-b-0">
<td class="px-4 py-2 font-medium text-foreground">{$_(`days.${day}`)}</td>
<td class="px-4 py-2 text-right text-foreground-secondary">
{location.openingHours[day] === 'closed'
? $_('detail.closed')
: location.openingHours[day]}
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
</div>
{/if}
<!-- Reviews -->
<div>
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<h2 class="text-xl font-semibold text-foreground">{$_('reviews.title')}</h2>
{#if location.reviewStats && location.reviewStats.totalReviews > 0}
<div class="flex items-center gap-1.5">
<div class="flex items-center gap-0.5">
{#each Array(5) as _, i}
<svg
class="h-4 w-4 {i < Math.round(location.reviewStats.averageRating)
? 'text-amber-400'
: 'text-gray-300 dark:text-gray-600'}"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
/>
</svg>
{/each}
</div>
<span class="text-sm font-medium text-foreground">
{location.reviewStats.averageRating.toFixed(1)}
</span>
<span class="text-sm text-foreground-secondary">
({location.reviewStats.totalReviews})
</span>
</div>
{/if}
</div>
{#if authStore.isAuthenticated && !userHasReviewed}
<button
onclick={() => (showReviewForm = !showReviewForm)}
class="text-sm font-medium text-primary hover:underline"
>
{$_('reviews.write')}
</button>
{/if}
</div>
{#if showReviewForm}
<div class="mb-4 rounded-xl border border-border bg-background-card p-4">
<p class="mb-3 text-sm font-medium text-foreground">{$_('reviews.yourRating')}</p>
{#if reviewError}
<div class="mb-3 rounded-lg bg-red-500/10 p-2 text-xs text-red-500">
{reviewError}
</div>
{/if}
<div class="mb-3 flex gap-1">
{#each [1, 2, 3, 4, 5] as star}
<button
onclick={() => (reviewRating = star)}
class="transition-transform hover:scale-110"
>
<svg
class="h-8 w-8 {star <= reviewRating
? 'text-amber-400'
: 'text-gray-300 dark:text-gray-600'}"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
/>
</svg>
</button>
{/each}
</div>
<textarea
bind:value={reviewComment}
placeholder={$_('reviews.commentPlaceholder')}
rows="2"
class="mb-3 w-full 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 resize-none"
></textarea>
<button
onclick={handleSubmitReview}
disabled={reviewRating === 0 || submittingReview}
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
>
{submittingReview ? $_('reviews.submitting') : $_('reviews.submit')}
</button>
</div>
{/if}
{#if reviews.length > 0}
<div class="space-y-3">
{#each reviews as review}
<div class="rounded-xl border border-border bg-background-card p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
{#each Array(5) as _, i}
<svg
class="h-4 w-4 {i < review.rating
? 'text-amber-400'
: 'text-gray-300 dark:text-gray-600'}"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
/>
</svg>
{/each}
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-foreground-secondary">
{new Date(review.createdAt).toLocaleDateString()}
</span>
{#if authStore.isAuthenticated && review.userId === authStore.user?.id}
<button
onclick={handleDeleteReview}
class="text-xs text-red-500 hover:underline"
>
{$_('reviews.delete')}
</button>
{/if}
</div>
</div>
{#if review.comment}
<p class="mt-2 text-sm text-foreground-secondary">{review.comment}</p>
{/if}
</div>
{/each}
</div>
{:else if !showReviewForm}
<p class="text-sm text-foreground-secondary">{$_('reviews.noReviews')}</p>
{/if}
</div>
<!-- Owner actions -->
{#if isOwner}
<div class="flex gap-3">
<a
href="/cities/{citySlug}/locations/{location.id}/edit"
class="flex items-center gap-2 rounded-lg border border-border bg-background-card px-4 py-2.5 text-sm font-medium text-foreground-secondary transition-colors hover:bg-background-card-hover hover:text-foreground"
>
<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="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
{$_('detail.edit')}
</a>
<button
onclick={() => (showDeleteConfirm = true)}
class="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600 transition-colors hover:bg-red-100 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400 dark:hover:bg-red-950/50"
>
<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="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
{$_('detail.delete')}
</button>
</div>
{/if}
<!-- Delete confirmation -->
{#if showDeleteConfirm}
<div
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-950/30"
>
<p class="mb-3 text-sm text-red-700 dark:text-red-300">{$_('detail.deleteConfirm')}</p>
<div class="flex gap-2">
<button
onclick={() => (showDeleteConfirm = false)}
class="rounded-lg border border-border bg-background px-4 py-2 text-sm text-foreground-secondary hover:bg-background-card-hover"
>
{$_('detail.cancel')}
</button>
<button
onclick={handleDelete}
disabled={deleting}
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
{deleting ? $_('detail.deleting') : $_('detail.confirmDelete')}
</button>
</div>
</div>
{/if}
<!-- 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>
<div class="flex divide-x divide-border border-t border-border">
<a
href="/cities/{citySlug}/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"
>
<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}
<!-- Timeline -->
{#if location.timeline && location.timeline.length > 0}
<div>
<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">
{#if i < location.timeline!.length - 1}
<div class="absolute left-[11px] top-6 h-full w-0.5 bg-border"></div>
{/if}
<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>
<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>
</div>
</div>
{/each}
</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="/cities/{citySlug}/locations/{nearby.slug || 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}
<style>
:global(.custom-marker) {
background: transparent !important;
border: none !important;
}
</style>

View file

@ -0,0 +1,298 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { api } from '$lib/api';
import type { LocalCity } from '$lib/data/local-store';
const cityCtx = getContext<{ value: LocalCity | undefined }>('currentCity');
let city = $derived(cityCtx.value);
let citySlug = $derived($page.params.slug);
let loading = $state(true);
let name = $state('');
let category = $state('sight');
let description = $state('');
let address = $state('');
let imageUrl = $state('');
let website = $state('');
let phone = $state('');
let imageError = $state(false);
let submitting = $state(false);
let error = $state('');
let forbidden = $state(false);
const categories = [
{ value: 'sight', labelKey: 'category.sight' },
{ value: 'restaurant', labelKey: 'category.restaurant' },
{ value: 'shop', labelKey: 'category.shop' },
{ value: 'museum', labelKey: 'category.museum' },
{ value: 'cafe', labelKey: 'category.cafe' },
{ value: 'bar', labelKey: 'category.bar' },
{ value: 'park', labelKey: 'category.park' },
{ value: 'beach', labelKey: 'category.beach' },
{ value: 'hotel', labelKey: 'category.hotel' },
{ value: 'event_venue', labelKey: 'category.event_venue' },
{ value: 'viewpoint', labelKey: 'category.viewpoint' },
];
let isValid = $derived(name.trim().length > 0 && description.trim().length > 10);
onMount(async () => {
try {
const res = await fetch(api(`/locations/${$page.params.id}`));
const data = await res.json();
const loc = data.location;
if (loc.createdBy && loc.createdBy !== authStore.user?.id) {
forbidden = true;
return;
}
name = loc.name || '';
category = loc.category || 'sight';
description = loc.description || '';
address = loc.address || '';
imageUrl = loc.imageUrl || '';
website = loc.website || '';
phone = loc.phone || '';
} catch {
error = $_('edit.loadError');
} finally {
loading = false;
}
});
async function handleSubmit() {
if (!isValid || submitting) return;
submitting = true;
error = '';
try {
const token = await authStore.getValidToken();
if (!token) {
error = $_('add.loginRequired');
return;
}
const body: Record<string, unknown> = {
name: name.trim(),
category,
description: description.trim(),
address: address.trim() || undefined,
imageUrl: imageUrl.trim() || undefined,
website: website.trim() || undefined,
phone: phone.trim() || undefined,
};
const res = await fetch(api(`/locations/${$page.params.id}`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
if (res.ok) {
goto(`/cities/${citySlug}/locations/${$page.params.id}`);
} else {
const data = await res.json().catch(() => ({}));
error = data.message || $_('edit.error');
}
} catch {
error = $_('edit.error');
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<title>{$_('edit.title')} - {city?.name || 'CityCorners'}</title>
</svelte:head>
<header class="mb-6">
<div class="flex items-center gap-2 mb-1">
<a
href="/cities/{citySlug}/locations/{$page.params.id}"
class="text-foreground-secondary hover:text-primary transition-colors"
>
<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="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</a>
<h1 class="text-2xl font-bold text-foreground">{$_('edit.title')}</h1>
</div>
<p class="text-foreground-secondary">{$_('edit.subtitle')}</p>
</header>
{#if loading}
<div class="flex items-center justify-center py-12">
<div
class="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"
></div>
</div>
{:else if forbidden}
<div class="rounded-xl border border-border bg-background-card p-8 text-center">
<span class="mb-2 block text-4xl">🔒</span>
<p class="text-foreground-secondary">{$_('edit.forbidden')}</p>
<a
href="/cities/{citySlug}/locations/{$page.params.id}"
class="mt-4 inline-block text-sm text-primary hover:underline"
>
{$_('detail.back')}
</a>
</div>
{:else}
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-5"
>
{#if error}
<div class="rounded-lg bg-red-500/10 p-3 text-sm text-red-500">{error}</div>
{/if}
<div>
<label for="name" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.name')}</label
>
<input
id="name"
type="text"
bind:value={name}
placeholder={$_('add.namePlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label for="category" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.category')}</label
>
<div class="flex flex-wrap gap-2">
{#each categories as cat}
<button
type="button"
class="rounded-full px-4 py-2 text-sm transition-colors {category === cat.value
? 'bg-primary text-white'
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover border border-border'}"
onclick={() => (category = cat.value)}
>
{$_(cat.labelKey)}
</button>
{/each}
</div>
</div>
<div>
<label for="description" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.description')}</label
>
<textarea
id="description"
bind:value={description}
placeholder={$_('add.descriptionPlaceholder')}
rows="4"
class="w-full resize-none rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
></textarea>
<p class="mt-1 text-xs text-foreground-secondary/60">{$_('add.minChars')}</p>
</div>
<div>
<label for="address" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.address')}</label
>
<input
id="address"
type="text"
bind:value={address}
placeholder={$_('add.addressPlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label for="imageUrl" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.imageUrl')}</label
>
<input
id="imageUrl"
type="url"
bind:value={imageUrl}
oninput={() => (imageError = false)}
placeholder={$_('add.imageUrlPlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
{#if imageUrl.trim() && !imageError}
<div class="mt-2 overflow-hidden rounded-lg border border-border">
<img
src={imageUrl}
alt={$_('add.imagePreview')}
class="h-40 w-full object-cover"
onerror={() => (imageError = true)}
/>
</div>
{:else if imageError}
<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>
<div>
<label for="website" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.website')}</label
>
<input
id="website"
type="url"
bind:value={website}
placeholder={$_('add.websitePlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label for="phone" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.phone')}</label
>
<input
id="phone"
type="tel"
bind:value={phone}
placeholder={$_('add.phonePlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div class="flex gap-3">
<a
href="/cities/{citySlug}/locations/{$page.params.id}"
class="rounded-lg border border-border bg-background px-4 py-3 text-sm font-medium text-foreground-secondary transition-colors hover:bg-background-card-hover"
>
{$_('edit.cancel')}
</a>
<button
type="submit"
disabled={!isValid || submitting}
class="flex-1 rounded-lg bg-primary px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? $_('edit.saving') : $_('edit.save')}
</button>
</div>
</form>
{/if}

View file

@ -0,0 +1,310 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import { browser } from '$app/environment';
import { _ } from 'svelte-i18n';
import { page } from '$app/stores';
import { api } from '$lib/api';
import type { LocalCity } from '$lib/data/local-store';
const cityCtx = getContext<{ value: LocalCity | undefined }>('currentCity');
let city = $derived(cityCtx.value);
let citySlug = $derived($page.params.slug);
interface Location {
id: string;
slug?: string;
name: string;
category: string;
description: string;
address?: string;
latitude?: number;
longitude?: number;
imageUrl?: string;
}
let locations = $state<Location[]>([]);
let mapContainer: HTMLDivElement;
let map: any = null;
let locating = $state(false);
let locationError = $state('');
let selectedCategory = $state<string | null>(null);
const categoryKeys = [
'sight',
'restaurant',
'shop',
'museum',
'cafe',
'bar',
'park',
'beach',
'hotel',
'event_venue',
'viewpoint',
];
const categoryColors: Record<string, string> = {
sight: '#2563eb',
restaurant: '#dc2626',
shop: '#16a34a',
museum: '#9333ea',
cafe: '#b45309',
bar: '#ea580c',
park: '#15803d',
beach: '#0891b2',
hotel: '#4f46e5',
event_venue: '#db2777',
viewpoint: '#0ea5e9',
};
let allMarkers: any[] = [];
let markerLayer: any = null;
let leafletLib: any = null;
function updateMarkers() {
if (!map || !leafletLib) return;
const L = leafletLib;
if (markerLayer) {
map.removeLayer(markerLayer);
}
for (const m of allMarkers) {
map.removeLayer(m);
}
allMarkers = [];
const filtered = selectedCategory
? locations.filter((l) => l.category === selectedCategory)
: locations;
const useCluster = filtered.length >= 10;
if (useCluster && (L as any).markerClusterGroup) {
markerLayer = (L as any).markerClusterGroup();
} else {
markerLayer = null;
}
for (const loc of filtered) {
if (loc.latitude && loc.longitude) {
const color = categoryColors[loc.category] || '#6b7280';
const icon = L.divIcon({
className: 'custom-marker',
html: `<div style="background:${color};width:28px;height:28px;border-radius:50%;border:3px solid white;box-shadow:0 2px 6px rgba(0,0,0,0.3);"></div>`,
iconSize: [28, 28],
iconAnchor: [14, 14],
});
const marker = L.marker([loc.latitude, loc.longitude], { icon });
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">${$_(`category.${loc.category}`)}</div>
<p style="font-size:12px;color:#666;margin:4px 0">${loc.description.substring(0, 100)}...</p>
<a href="/cities/${citySlug}/locations/${loc.slug || loc.id}" style="color:${color};font-size:12px;font-weight:600">${$_('detail.showDetails')} &rarr;</a>
</div>
`);
if (useCluster && markerLayer) {
markerLayer.addLayer(marker);
} else {
marker.addTo(map);
allMarkers.push(marker);
}
}
}
if (useCluster && markerLayer) {
map.addLayer(markerLayer);
}
}
onMount(async () => {
try {
const params = new URLSearchParams({ limit: '100' });
if (city) params.set('cityId', city.id);
const res = await fetch(api(`/locations?${params}`));
const data = await res.json();
locations = data.locations;
} catch (err) {
console.error('Failed to load locations:', err);
}
if (!browser) return;
leafletLib = await import('leaflet');
const L = leafletLib;
await import('leaflet/dist/leaflet.css');
const lat = city?.latitude ?? 47.6603;
const lng = city?.longitude ?? 9.1757;
map = L.map(mapContainer).setView([lat, lng], 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19,
}).addTo(map);
try {
await import('leaflet.markercluster');
} catch {
// clustering not available
}
updateMarkers();
});
$effect(() => {
const _ = selectedCategory;
if (map && leafletLib) {
updateMarkers();
}
});
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);
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>
<title>{$_('map.title')} - {city?.name || 'CityCorners'}</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
<link
rel="stylesheet"
href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css"
crossorigin=""
/>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css"
crossorigin=""
/>
</svelte:head>
<div class="map-page">
<header class="mb-4 flex items-start justify-between">
<div>
<div class="flex items-center gap-2">
<a
href="/cities/{citySlug}"
class="text-foreground-secondary hover:text-primary transition-colors"
>
<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="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</a>
<h1 class="text-2xl font-bold text-foreground">{$_('map.title')}</h1>
</div>
<p class="text-foreground-secondary">{city?.name} - {$_('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="mb-4 flex flex-wrap gap-2">
<button
class="flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-colors {selectedCategory ===
null
? 'bg-primary text-white'
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover border border-border'}"
onclick={() => (selectedCategory = null)}
>
{$_('map.filterAll')}
</button>
{#each categoryKeys as cat}
<button
class="flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-colors {selectedCategory ===
cat
? 'bg-primary text-white'
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover border border-border'}"
onclick={() => (selectedCategory = selectedCategory === cat ? null : cat)}
>
<div class="w-2.5 h-2.5 rounded-full" style="background:{categoryColors[cat]}"></div>
{$_(`category.${cat}`)}
</button>
{/each}
</div>
<div
bind:this={mapContainer}
class="map-container rounded-xl overflow-hidden border border-border"
></div>
</div>
<style>
.map-container {
width: 100%;
height: calc(100vh - 300px);
min-height: 400px;
}
:global(.custom-marker) {
background: transparent !important;
border: none !important;
}
</style>