mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
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:
parent
29f2c999b5
commit
9942a21b8e
10 changed files with 153 additions and 2549 deletions
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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')} →</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: '© <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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue