mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
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:
parent
3925019344
commit
82a4cb4c59
14 changed files with 2955 additions and 334 deletions
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
255
apps/citycorners/apps/web/src/routes/(app)/add-city/+page.svelte
Normal file
255
apps/citycorners/apps/web/src/routes/(app)/add-city/+page.svelte
Normal 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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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')} →</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: '© <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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue