refactor(citycorners): redirect old routes + update landing page

- Replace old /map, /add, /locations/[id] routes with redirects to /
  (locations are now at /cities/[slug]/...)
- Rewrite landing page for multi-city platform concept
- Remove old Konstanz-specific landing components and data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-29 14:46:03 +02:00
parent 29f2c999b5
commit 9942a21b8e
10 changed files with 153 additions and 2549 deletions

View file

@ -1,73 +0,0 @@
---
const categories = [
{ value: '', label: 'Alle' },
{ value: 'sehenswürdigkeit', label: 'Sehenswürdigkeiten' },
{ value: 'restaurant', label: 'Restaurants' },
{ value: 'laden', label: 'Läden' },
{ value: 'museum', label: 'Museen' },
{ value: 'café', label: 'Cafés' },
{ value: 'bar', label: 'Bars' },
{ value: 'park', label: 'Parks' },
{ value: 'strandbad', label: 'Strandbäder' },
{ value: 'hotel', label: 'Hotels' },
{ value: 'veranstaltungsort', label: 'Veranstaltungsorte' },
{ value: 'aussichtspunkt', label: 'Aussichtspunkte' },
];
---
<div class="flex flex-wrap gap-2" id="category-filter">
{
categories.map((cat) => (
<button
class="filter-btn rounded-full px-4 py-2 text-sm font-medium transition-colors"
data-category={cat.value}
>
{cat.label}
</button>
))
}
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const buttons = document.querySelectorAll('.filter-btn');
const container = document.getElementById('locations-container');
if (!container) return;
const allCards = Array.from(container.children) as HTMLElement[];
let activeCategory = '';
function updateButtons() {
buttons.forEach((btn) => {
const cat = (btn as HTMLElement).dataset.category || '';
if (cat === activeCategory) {
btn.className =
'filter-btn rounded-full px-4 py-2 text-sm font-medium transition-colors bg-primary text-white';
} else {
btn.className =
'filter-btn rounded-full px-4 py-2 text-sm font-medium transition-colors bg-white text-gray-600 hover:bg-gray-100 border border-gray-200';
}
});
}
function filterCards() {
allCards.forEach((card) => {
const cardCategory = card.dataset.category || '';
if (!activeCategory || cardCategory === activeCategory) {
card.style.display = '';
} else {
card.style.display = 'none';
}
});
}
buttons.forEach((btn) => {
btn.addEventListener('click', () => {
activeCategory = (btn as HTMLElement).dataset.category || '';
updateButtons();
filterCards();
});
});
updateButtons();
});
</script>

View file

@ -1,48 +0,0 @@
---
interface Props {
location: {
id: number | string;
name: string;
category: string;
description: string;
image: string;
};
}
const { location } = Astro.props;
const categoryColors: Record<string, string> = {
Sehenswürdigkeit: 'bg-blue-100 text-blue-700',
Restaurant: 'bg-red-100 text-red-700',
Laden: 'bg-green-100 text-green-700',
Museum: 'bg-purple-100 text-purple-700',
Café: 'bg-amber-100 text-amber-700',
Bar: 'bg-orange-100 text-orange-700',
Park: 'bg-emerald-100 text-emerald-700',
Strandbad: 'bg-cyan-100 text-cyan-700',
Hotel: 'bg-indigo-100 text-indigo-700',
Veranstaltungsort: 'bg-pink-100 text-pink-700',
Aussichtspunkt: 'bg-sky-100 text-sky-700',
};
---
<a
href={`/locations/${location.id}`}
class="group block overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition-all hover:shadow-lg hover:-translate-y-0.5"
data-category={location.category.toLowerCase()}
>
<img src={location.image} alt={location.name} class="h-48 w-full object-cover" loading="lazy" />
<div class="p-4">
<span
class={`inline-block rounded-full px-2.5 py-0.5 text-xs font-medium ${categoryColors[location.category] || 'bg-gray-100 text-gray-700'}`}
>
{location.category}
</span>
<h3 class="mt-2 text-lg font-semibold text-gray-900 group-hover:text-primary">
{location.name}
</h3>
<p class="mt-1 line-clamp-2 text-sm text-gray-600">
{location.description}
</p>
</div>
</a>

View file

@ -1,261 +0,0 @@
[
{
"id": 1,
"name": "Konstanzer Münster",
"category": "Sehenswürdigkeit",
"description": "Das Konstanzer Münster ist eine imposante Basilika, die über Jahrhunderte das Zentrum des Bistums Konstanz war. Besucher können den Turm besteigen und einen atemberaubenden Blick über die Stadt und den Bodensee genießen.",
"image": "/images/muenster.jpg",
"address": "Münsterplatz 1, 78462 Konstanz",
"coordinates": {
"lat": 47.663,
"lng": 9.175
},
"timeline": [
{ "year": "ca. 600", "description": "Gründung einer ersten Bischofskirche." },
{ "year": "1054-1089", "description": "Neubau der Basilika nach ottonischem Vorbild." },
{
"year": "1414-1418",
"description": "Das Münster ist Schauplatz des Konzils von Konstanz."
},
{
"year": "1844-1853",
"description": "Neugotische Umgestaltung des Turms durch Heinrich Hübsch."
}
]
},
{
"id": 2,
"name": "Imperia",
"category": "Sehenswürdigkeit",
"description": "Die Imperia ist eine satirische Statue im Hafen von Konstanz, die an das Konzil von Konstanz erinnert. Sie dreht sich langsam um ihre Achse und ist ein beliebtes Fotomotiv.",
"image": "/images/imperia.jpg",
"address": "Hafenstraße, 78462 Konstanz",
"coordinates": {
"lat": 47.66,
"lng": 9.18
}
},
{
"id": 3,
"name": "Restaurant Ophelia",
"category": "Restaurant",
"description": "Das mit zwei Michelin-Sternen ausgezeichnete Restaurant Ophelia bietet eine exquisite Küche in einem eleganten Ambiente. Es befindet sich im Hotel Riva am Ufer des Bodensees.",
"image": "/images/ophelia.jpg",
"address": "Seestraße 25, 78464 Konstanz",
"coordinates": {
"lat": 47.67,
"lng": 9.19
}
},
{
"id": 4,
"name": "LAGO Shopping-Center",
"category": "Laden",
"description": "Das LAGO ist das größte Einkaufszentrum am Bodensee und bietet eine Vielzahl von Geschäften, Restaurants und Cafés. Es ist ein beliebter Treffpunkt für Einheimische und Touristen.",
"image": "/images/lago.jpg",
"address": "Bodanstraße 1, 78462 Konstanz",
"coordinates": {
"lat": 47.658,
"lng": 9.176
}
},
{
"id": 5,
"name": "Rosgartenmuseum",
"category": "Museum",
"description": "Das Rosgartenmuseum ist das städtische Museum für Kunst, Kultur und Geschichte von Konstanz und der Bodenseeregion. Es wurde 1870 gegründet und befindet sich in einem ehemaligen Zunfthaus.",
"image": "/images/rosgartenmuseum.jpg",
"address": "Rosgartenstraße 3-5, 78462 Konstanz",
"coordinates": {
"lat": 47.661,
"lng": 9.174
},
"timeline": [
{ "year": "1454", "description": "Das Gebäude wird als Zunfthaus der Metzger errichtet." },
{
"year": "1870",
"description": "Gründung des Museums durch den Apotheker und Stadtrat Ludwig Leiner."
}
]
},
{
"id": 6,
"name": "Archäologisches Landesmuseum Baden-Württemberg",
"category": "Museum",
"description": "Das Archäologische Landesmuseum (ALM) in Konstanz ist ein Zweigmuseum des ALM in Stuttgart und zeigt Funde aus der Archäologie, Geschichte und Kultur der Region.",
"image": "/images/alm.jpg",
"address": "Benediktinerplatz 5, 78467 Konstanz",
"coordinates": {
"lat": 47.665,
"lng": 9.171
}
},
{
"id": 7,
"name": "Café Zeitlos",
"category": "Café",
"description": "Gemütliches Café in der Konstanzer Altstadt mit hausgemachten Kuchen, Frühstück und einer großen Auswahl an Kaffeespezialitäten.",
"image": "/images/placeholder.jpg",
"address": "Hussenstraße 13, 78462 Konstanz",
"coordinates": {
"lat": 47.6609,
"lng": 9.1749
}
},
{
"id": 8,
"name": "Voglhaus Café",
"category": "Café",
"description": "Beliebtes Bio-Café mit vegetarischer und veganer Küche. Kreative Frühstücksgerichte und selbstgemachte Limonaden.",
"image": "/images/placeholder.jpg",
"address": "Wessenbergstraße 8, 78462 Konstanz",
"coordinates": {
"lat": 47.6619,
"lng": 9.1744
}
},
{
"id": 9,
"name": "Seekuh",
"category": "Bar",
"description": "Legendäre Konstanzer Bar und Kulturkneipe am Seerhein. Craft Beer, Cocktails und regelmäßig Konzerte auf kleiner Bühne.",
"image": "/images/placeholder.jpg",
"address": "Konradigasse 1, 78462 Konstanz",
"coordinates": {
"lat": 47.6632,
"lng": 9.1773
}
},
{
"id": 10,
"name": "Brauhaus Johann Albrecht",
"category": "Bar",
"description": "Brauhaus-Restaurant mit hauseigenem Bier direkt am Seerhein. Deftige Küche und frisch gebrautes Bier in historischem Ambiente.",
"image": "/images/placeholder.jpg",
"address": "Konradigasse 2, 78462 Konstanz",
"coordinates": {
"lat": 47.663,
"lng": 9.177
}
},
{
"id": 11,
"name": "Stadtgarten Konstanz",
"category": "Park",
"description": "Großer Park direkt am Bodenseeufer mit altem Baumbestand, Spielplätzen, Minigolf und Biergarten. Der beliebteste Erholungsort der Stadt.",
"image": "/images/placeholder.jpg",
"address": "Seestraße, 78464 Konstanz",
"coordinates": {
"lat": 47.6582,
"lng": 9.1812
}
},
{
"id": 12,
"name": "Lorettowald",
"category": "Park",
"description": "Bewaldeter Hügel im Süden von Konstanz mit Wanderwegen und Aussichtspunkten über den Bodensee. Beliebt bei Joggern und Spaziergängern.",
"image": "/images/placeholder.jpg",
"address": "Lorettostraße, 78464 Konstanz",
"coordinates": {
"lat": 47.6524,
"lng": 9.1768
}
},
{
"id": 13,
"name": "Strandbad Horn",
"category": "Strandbad",
"description": "Eines der größten Freibäder am Bodensee mit großer Liegewiese, Sandstrand, Sprungturm und Beachvolleyball. Traumhafter Seeblick.",
"image": "/images/placeholder.jpg",
"address": "Eichhornstraße 100, 78464 Konstanz",
"coordinates": {
"lat": 47.6527,
"lng": 9.201
}
},
{
"id": 14,
"name": "Schmugglerbucht",
"category": "Strandbad",
"description": "Kleine, versteckte Badestelle unterhalb der Seestraße. Bei Einheimischen beliebt als Geheimtipp zum Schwimmen im Bodensee.",
"image": "/images/placeholder.jpg",
"address": "Seestraße, 78464 Konstanz",
"coordinates": {
"lat": 47.6561,
"lng": 9.186
}
},
{
"id": 15,
"name": "Steigenberger Inselhotel",
"category": "Hotel",
"description": "Luxushotel in einem ehemaligen Dominikanerkloster auf einer Insel im Bodensee. Eines der historischsten Hotels Deutschlands.",
"image": "/images/placeholder.jpg",
"address": "Auf der Insel 1, 78462 Konstanz",
"coordinates": {
"lat": 47.6598,
"lng": 9.181
}
},
{
"id": 16,
"name": "Hotel Barbarossa",
"category": "Hotel",
"description": "Historisches Boutique-Hotel am Obermarkt mitten in der Altstadt. Individuell gestaltete Zimmer in einem Gebäude aus dem 15. Jahrhundert.",
"image": "/images/placeholder.jpg",
"address": "Obermarkt 8-12, 78462 Konstanz",
"coordinates": {
"lat": 47.6621,
"lng": 9.1746
}
},
{
"id": 17,
"name": "Konzil Konstanz",
"category": "Veranstaltungsort",
"description": "Historisches Konzilgebäude am Hafen, in dem 1417 das Konklave zur Papstwahl stattfand. Heute Veranstaltungshalle und Restaurant.",
"image": "/images/placeholder.jpg",
"address": "Hafenstraße 2, 78462 Konstanz",
"coordinates": {
"lat": 47.6596,
"lng": 9.178
}
},
{
"id": 18,
"name": "Stadttheater Konstanz",
"category": "Veranstaltungsort",
"description": "Das Theater Konstanz ist eines der ältesten aktiven Theater Deutschlands. Schauspiel, Musiktheater und Junges Theater auf mehreren Bühnen.",
"image": "/images/placeholder.jpg",
"address": "Konzilstraße 11, 78462 Konstanz",
"coordinates": {
"lat": 47.6593,
"lng": 9.177
}
},
{
"id": 19,
"name": "Münsterturm-Aussichtsplattform",
"category": "Aussichtspunkt",
"description": "Nach 193 Stufen erreicht man die Aussichtsplattform des Münsterturms mit 360°-Panorama über Konstanz, den Bodensee und bei klarer Sicht bis zu den Alpen.",
"image": "/images/placeholder.jpg",
"address": "Münsterplatz 1, 78462 Konstanz",
"coordinates": {
"lat": 47.6603,
"lng": 9.1757
}
},
{
"id": 20,
"name": "Hörnle-Spitze",
"category": "Aussichtspunkt",
"description": "Äußerste Spitze der Halbinsel Horn mit unverbautem 180°-Panorama über den Bodensee. Besonders beeindruckend bei Sonnenuntergang.",
"image": "/images/placeholder.jpg",
"address": "Hörnleweg, 78464 Konstanz",
"coordinates": {
"lat": 47.648,
"lng": 9.2085
}
}
]

View file

@ -3,7 +3,7 @@ interface Props {
title?: string;
}
const { title = 'CityCorners Entdecke Konstanz' } = Astro.props;
const { title = 'CityCorners Entdecke Städte weltweit' } = Astro.props;
---
<!doctype html>
@ -14,7 +14,7 @@ const { title = 'CityCorners Entdecke Konstanz' } = Astro.props;
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta
name="description"
content="Entdecke Sehenswürdigkeiten, Restaurants, Museen und Läden in Konstanz am Bodensee."
content="CityCorners Die offene Plattform für Stadtführer. Entdecke Orte weltweit, geteilt von der Community."
/>
<meta name="generator" content={Astro.generator} />
<title>{title}</title>

View file

@ -1,69 +1,156 @@
---
import Layout from '../layouts/Layout.astro';
import LocationCard from '../components/LocationCard.astro';
import Filter from '../components/Filter.astro';
import fallbackLocations from '../data/locations.json';
const BACKEND_URL = import.meta.env.BACKEND_URL || 'http://localhost:3025';
const APP_URL = 'https://citycorners.mana.how';
let locations = fallbackLocations;
try {
const res = await fetch(`${BACKEND_URL}/api/v1/locations?limit=100`);
if (res.ok) {
const data = await res.json();
// Map API response to the shape expected by LocationCard
const categoryMap: Record<string, string> = {
sight: 'Sehenswürdigkeit',
restaurant: 'Restaurant',
shop: 'Laden',
museum: 'Museum',
};
locations = data.locations.map((loc: any, index: number) => ({
id: loc.slug || loc.id,
name: loc.name,
category: categoryMap[loc.category] || loc.category,
description: loc.description,
image: loc.imageUrl || '/images/placeholder.jpg',
address: loc.address,
coordinates:
loc.latitude && loc.longitude ? { lat: loc.latitude, lng: loc.longitude } : undefined,
timeline: loc.timeline?.map((t: any) => ({ year: t.year, description: t.event })),
}));
}
} catch (e) {
// Fallback to hardcoded JSON if API is unreachable
console.warn('Could not fetch from API, using fallback data:', e);
}
const exampleCities = [
{
name: 'Konstanz',
country: 'Deutschland',
description: 'Universitätsstadt am Bodensee mit mittelalterlicher Altstadt',
slug: 'konstanz',
},
{
name: 'Zürich',
country: 'Schweiz',
description: 'Größte Stadt der Schweiz am Zürichsee',
slug: 'zuerich',
},
{
name: 'Berlin',
country: 'Deutschland',
description: 'Hauptstadt mit vielfältiger Kultur und Geschichte',
slug: 'berlin',
},
];
---
<Layout>
<!-- Hero -->
<header class="bg-primary py-16 text-white">
<div class="mx-auto max-w-6xl px-6 text-center">
<h1 class="text-4xl font-bold sm:text-5xl">CityCorners</h1>
<p class="mt-3 text-lg text-blue-100">Entdecke Konstanz am Bodensee</p>
<p class="mt-1 text-sm text-blue-200">Sehenswürdigkeiten · Restaurants · Museen · Läden</p>
<header class="bg-gradient-to-br from-blue-600 to-blue-800 py-20 text-white">
<div class="mx-auto max-w-4xl px-6 text-center">
<h1 class="text-4xl font-bold sm:text-5xl lg:text-6xl">CityCorners</h1>
<p class="mt-4 text-xl text-blue-100">Entdecke Städte weltweit</p>
<p class="mt-2 text-blue-200">
Von der Community für die Community — teile deine Lieblingsorte
</p>
<div class="mt-8 flex flex-col items-center gap-3 sm:flex-row sm:justify-center">
<a
href={APP_URL}
class="rounded-lg bg-white px-8 py-3 text-lg font-semibold text-blue-700 shadow-lg transition-all hover:bg-blue-50 hover:shadow-xl"
>
App öffnen
</a>
<a
href={`${APP_URL}/add-city`}
class="rounded-lg border-2 border-white/30 px-8 py-3 text-lg font-semibold text-white transition-all hover:border-white/60 hover:bg-white/10"
>
Stadt hinzufügen
</a>
</div>
</div>
</header>
<!-- Main content -->
<main class="mx-auto max-w-6xl px-6 py-10">
<div class="mb-8">
<Filter />
<!-- How it works -->
<section class="mx-auto max-w-5xl px-6 py-16">
<h2 class="mb-10 text-center text-3xl font-bold text-gray-900">So funktioniert's</h2>
<div class="grid gap-8 sm:grid-cols-3">
<div class="text-center">
<div
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-blue-100 text-3xl"
>
🏙️
</div>
<h3 class="text-lg font-semibold">Stadt anlegen</h3>
<p class="mt-2 text-gray-600">
Lege deine Stadt, dein Dorf oder deinen Lieblingsort an — egal wo auf der Welt.
</p>
</div>
<div class="text-center">
<div
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-green-100 text-3xl"
>
📍
</div>
<h3 class="text-lg font-semibold">Orte hinzufügen</h3>
<p class="mt-2 text-gray-600">
Teile Restaurants, Sehenswürdigkeiten, Cafés, Parks und mehr mit der Community.
</p>
</div>
<div class="text-center">
<div
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-100 text-3xl"
>
🗺️
</div>
<h3 class="text-lg font-semibold">Entdecken</h3>
<p class="mt-2 text-gray-600">
Erkunde Städte auf der Karte, filtere nach Kategorien und speichere Favoriten.
</p>
</div>
</div>
</section>
<div id="locations-container" class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{locations.map((location) => <LocationCard location={location} />)}
<!-- Example cities -->
<section class="bg-white py-16">
<div class="mx-auto max-w-5xl px-6">
<h2 class="mb-8 text-center text-3xl font-bold text-gray-900">Beispielstädte</h2>
<div class="grid gap-6 sm:grid-cols-3">
{
exampleCities.map((city) => (
<a
href={`${APP_URL}/cities/${city.slug}`}
class="group rounded-xl border border-gray-200 bg-gray-50 p-6 transition-all hover:border-blue-300 hover:shadow-md"
>
<h3 class="text-xl font-semibold text-gray-900 group-hover:text-blue-600">
{city.name}
</h3>
<p class="mt-1 text-sm text-gray-500">{city.country}</p>
<p class="mt-2 text-gray-600">{city.description}</p>
</a>
))
}
</div>
</div>
</main>
</section>
<!-- Features -->
<section class="mx-auto max-w-5xl px-6 py-16">
<h2 class="mb-8 text-center text-3xl font-bold text-gray-900">Features</h2>
<div class="grid gap-6 sm:grid-cols-2">
<div class="rounded-xl border border-gray-200 p-5">
<h3 class="font-semibold text-gray-900">Offline verfügbar</h3>
<p class="mt-1 text-sm text-gray-600">
Funktioniert auch ohne Internet — Daten werden lokal gespeichert und automatisch
synchronisiert.
</p>
</div>
<div class="rounded-xl border border-gray-200 p-5">
<h3 class="font-semibold text-gray-900">11 Kategorien</h3>
<p class="mt-1 text-sm text-gray-600">
Restaurants, Cafés, Museen, Parks, Hotels, Bars, Sehenswürdigkeiten und mehr.
</p>
</div>
<div class="rounded-xl border border-gray-200 p-5">
<h3 class="font-semibold text-gray-900">Interaktive Karte</h3>
<p class="mt-1 text-sm text-gray-600">
Farbcodierte Marker auf OpenStreetMap mit Standortbestimmung.
</p>
</div>
<div class="rounded-xl border border-gray-200 p-5">
<h3 class="font-semibold text-gray-900">Mehrsprachig</h3>
<p class="mt-1 text-sm text-gray-600">Deutsch und Englisch mit einfachem Sprachwechsel.</p>
</div>
</div>
</section>
<!-- Footer -->
<footer class="border-t border-gray-200 bg-white py-8">
<div class="mx-auto max-w-6xl px-6 text-center text-sm text-gray-500">
<p>CityCorners Ein Stadtführer für Konstanz</p>
<p>CityCorners Die offene Plattform für Stadtführer</p>
<p class="mt-1">
Teil des <a href="https://mana.how" class="text-primary hover:underline">Mana</a> Ökosystems
Teil des <a href="https://mana.how" class="text-blue-600 hover:underline">Mana</a>{' '}
Ökosystems
</p>
</div>
</footer>

View file

@ -1,190 +0,0 @@
---
import Layout from '../../layouts/Layout.astro';
import fallbackLocations from '../../data/locations.json';
const BACKEND_URL = import.meta.env.BACKEND_URL || 'http://localhost:3025';
const categoryMap: Record<string, string> = {
sight: 'Sehenswürdigkeit',
restaurant: 'Restaurant',
shop: 'Laden',
museum: 'Museum',
};
const categoryColors: Record<string, string> = {
Sehenswürdigkeit: 'bg-blue-100 text-blue-700',
Restaurant: 'bg-red-100 text-red-700',
Laden: 'bg-green-100 text-green-700',
Museum: 'bg-purple-100 text-purple-700',
};
function mapApiLocation(loc: any) {
return {
id: loc.slug || loc.id,
name: loc.name,
category: categoryMap[loc.category] || loc.category,
description: loc.description,
image: loc.imageUrl || '/images/placeholder.jpg',
address: loc.address,
coordinates:
loc.latitude && loc.longitude ? { lat: loc.latitude, lng: loc.longitude } : undefined,
timeline: loc.timeline?.map((t: any) => ({ year: t.year, description: t.event })),
};
}
let allLocations: any[] = [];
try {
const res = await fetch(`${BACKEND_URL}/api/v1/locations?limit=100`);
if (res.ok) {
const data = await res.json();
allLocations = data.locations.map(mapApiLocation);
} else {
allLocations = fallbackLocations;
}
} catch (e) {
console.warn('Could not fetch from API, using fallback data:', e);
allLocations = fallbackLocations;
}
export async function getStaticPaths() {
let locations: any[] = [];
try {
const backendUrl = import.meta.env.BACKEND_URL || 'http://localhost:3025';
const res = await fetch(`${backendUrl}/api/v1/locations?limit=100`);
if (res.ok) {
const data = await res.json();
locations = data.locations.map((loc: any) => ({
id: loc.slug || loc.id,
}));
}
} catch (e) {
// Fallback
}
if (locations.length === 0) {
const fallback = await import('../../data/locations.json');
locations = fallback.default.map((loc) => ({ id: loc.id.toString() }));
}
return locations.map((loc) => ({
params: { id: loc.id.toString() },
}));
}
const { id } = Astro.params;
const location = allLocations.find((loc) => loc.id.toString() === id);
if (!location) {
return Astro.redirect('/');
}
---
<Layout title={`${location.name} CityCorners`}>
<main class="mx-auto max-w-3xl px-6 py-8">
<a
href="/"
class="mb-6 inline-flex items-center gap-1 text-sm text-gray-500 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="M15.75 19.5L8.25 12l7.5-7.5"></path>
</svg>
Zurück zur Übersicht
</a>
<img
src={location.image}
alt={location.name}
class="mb-6 h-64 w-full rounded-xl object-cover sm:h-80"
/>
<span
class={`inline-block rounded-full px-3 py-1 text-sm font-medium ${categoryColors[location.category] || 'bg-gray-100 text-gray-700'}`}
>
{location.category}
</span>
<h1 class="mt-3 text-3xl font-bold text-gray-900">{location.name}</h1>
{
location.address && (
<p class="mt-2 flex items-center gap-1.5 text-gray-500">
<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>
)
}
<p class="mt-4 text-base leading-relaxed text-gray-700">{location.description}</p>
{
location.timeline && location.timeline.length > 0 && (
<div class="mt-8">
<h2 class="mb-4 text-xl font-semibold text-gray-900">Geschichte</h2>
<div class="space-y-0">
{location.timeline.map((event: { year: string; description: string }, i: number) => (
<div class="relative flex gap-4 pb-6">
{i < location.timeline!.length - 1 && (
<div class="absolute left-[11px] top-6 h-full w-0.5 bg-gray-200" />
)}
<div class="relative z-10 mt-1.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full border-2 border-primary bg-white">
<div class="h-2 w-2 rounded-full bg-primary" />
</div>
<div>
<span class="font-mono text-sm font-bold text-primary">{event.year}</span>
<p class="mt-0.5 text-sm text-gray-600">{event.description}</p>
</div>
</div>
))}
</div>
</div>
)
}
{
location.coordinates && (
<div class="mt-8 overflow-hidden rounded-xl border border-gray-200">
<a
href={`https://www.openstreetmap.org/?mlat=${location.coordinates.lat}&mlon=${location.coordinates.lng}#map=17/${location.coordinates.lat}/${location.coordinates.lng}`}
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-center gap-2 bg-gray-50 px-4 py-4 text-sm text-gray-500 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>
In OpenStreetMap öffnen
</a>
</div>
)
}
</main>
</Layout>

View file

@ -1,425 +1,9 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { api } from '$lib/api';
import { onMount } from 'svelte';
// 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' },
];
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;
// Auto-geocode if we got an address but no coordinates
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 q = addr.includes('Konstanz') ? addr : `${addr}, Konstanz`;
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) 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(),
};
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(`/locations/${data.location.id}`);
} else {
const data = await res.json().catch(() => ({}));
error = data.message || $_('add.error');
}
} catch {
error = $_('add.error');
} finally {
submitting = false;
}
}
// Old route — redirect to city discovery
onMount(() => {
goto('/', { replaceState: true });
});
</script>
<svelte:head>
<title>{$_('add.title')} - CityCorners</title>
</svelte:head>
<header class="mb-6">
<h1 class="text-2xl font-bold text-foreground">{$_('add.title')}</h1>
<p class="text-foreground-secondary">{$_('add.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">{$_('add.loginRequired')}</p>
<a
href="/login?redirectTo=/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}
<!-- Step 1: Search for the location online -->
<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}
<!-- Step 2: Edit and submit -->
{#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>
<!-- Image URL -->
<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>
<!-- Website -->
<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>
<!-- Phone -->
<div>
<label for="phone" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.phone')}</label
>
<input
id="phone"
type="tel"
bind:value={phone}
placeholder={$_('add.phonePlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div class="flex gap-3">
<button
type="button"
onclick={handleReset}
class="rounded-lg border border-border bg-background px-4 py-3 text-sm font-medium text-foreground-secondary transition-colors hover:bg-background-card-hover"
>
{$_('add.reset')}
</button>
<button
type="submit"
disabled={!isValid || submitting}
class="flex-1 rounded-lg bg-primary px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? $_('add.submitting') : $_('add.submit')}
</button>
</div>
</form>
{/if}

View file

@ -1,958 +1,10 @@
<script lang="ts">
import { onMount } 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 { onMount } from 'svelte';
// Live query for favorites — auto-updates on IndexedDB changes
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);
// Review state
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)
);
// Gallery state
let selectedImageIndex = $state(0);
let showAddPhoto = $state(false);
let newPhotoUrl = $state('');
let addingPhoto = $state(false);
let photoError = $state('');
const categoryColors: Record<string, string> = {
sight: '#2563eb',
restaurant: '#dc2626',
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
);
// All images: primary imageUrl + gallery images
let allImages = $derived(() => {
if (!location) return [];
const imgs: string[] = [];
if (location.imageUrl) imgs.push(location.imageUrl);
if (location.images) {
for (const img of location.images) {
if (img.url && !imgs.includes(img.url)) imgs.push(img.url);
}
}
return imgs;
// Old route — redirect to city discovery
// Locations are now at /cities/[slug]/locations/[id]
onMount(() => {
goto('/', { replaceState: true });
});
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;
}
// Load reviews
loadReviews();
});
// Initialize mini map after location loads
$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('/');
} 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();
// Update review stats on the location
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'} - 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="/" 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="/"
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>
<!-- Image counter badge -->
{#if images.length > 1}
<div
class="absolute bottom-4 right-4 rounded-full bg-black/50 px-2.5 py-1 text-xs text-white backdrop-blur-sm"
>
{selectedImageIndex + 1} / {images.length}
</div>
{/if}
<!-- Category badge + open status -->
<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>
<!-- Review form -->
{#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}
<!-- Review list -->
{#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="/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="/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="/locations/{nearby.slug || nearby.id}"
class="flex-shrink-0 w-40 overflow-hidden rounded-xl border border-border bg-background-card transition-shadow hover:shadow-md"
>
{#if nearby.imageUrl}
<img
src={nearby.imageUrl}
alt={nearby.name}
loading="lazy"
class="h-24 w-full object-cover"
/>
{:else}
<div class="flex h-24 items-center justify-center bg-background-card-hover">
<span class="text-2xl">📍</span>
</div>
{/if}
<div class="p-2.5">
<p class="text-sm font-medium text-foreground line-clamp-1">{nearby.name}</p>
<p class="mt-0.5 text-xs text-foreground-secondary">
{$_(`category.${nearby.category}`)} · {formatDistance(nearby.distance)}
</p>
</div>
</a>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<style>
:global(.custom-marker) {
background: transparent !important;
border: none !important;
}
</style>

View file

@ -1,278 +1,8 @@
<script lang="ts">
import { onMount } 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 { onMount } from 'svelte';
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' },
];
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;
}
onMount(() => {
goto('/', { replaceState: true });
});
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(`/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')} - CityCorners</title>
</svelte:head>
<header class="mb-6">
<h1 class="text-2xl font-bold text-foreground">{$_('edit.title')}</h1>
<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="/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>
<!-- Website -->
<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>
<!-- Phone -->
<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="/locations/{$page.params.id}"
class="rounded-lg border border-border bg-background px-4 py-3 text-sm font-medium text-foreground-secondary transition-colors hover:bg-background-card-hover"
>
{$_('edit.cancel')}
</a>
<button
type="submit"
disabled={!isValid || submitting}
class="flex-1 rounded-lg bg-primary px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? $_('edit.saving') : $_('edit.save')}
</button>
</div>
</form>
{/if}

View file

@ -1,286 +1,9 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { _ } from 'svelte-i18n';
import { api } from '$lib/api';
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;
// Remove existing markers
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="/locations/${loc.slug || loc.id}" style="color:${color};font-size:12px;font-weight:600">${$_('detail.showDetails')} &rarr;</a>
</div>
`);
if (useCluster && markerLayer) {
markerLayer.addLayer(marker);
} else {
marker.addTo(map);
allMarkers.push(marker);
}
}
}
if (useCluster && markerLayer) {
map.addLayer(markerLayer);
}
}
onMount(async () => {
try {
const res = await fetch(api('/locations?limit=100'));
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');
map = L.map(mapContainer).setView([47.6603, 9.1757], 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19,
}).addTo(map);
try {
await import('leaflet.markercluster');
} catch {
// clustering not available
}
updateMarkers();
// Old route — redirect to city discovery
onMount(() => {
goto('/', { replaceState: true });
});
// Re-render markers when category filter changes
$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);
// Add user marker
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')} - 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>
<h1 class="text-2xl font-bold text-foreground">{$_('map.title')}</h1>
<p class="text-foreground-secondary">{$_('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>