From 82a4cb4c5918620ad411b5f3c65731fbc4c2183a Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 29 Mar 2026 14:09:29 +0200 Subject: [PATCH] feat(citycorners): transform into multi-city platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuild CityCorners from a Konstanz-only guide into a user-generated platform for any city/village. Users can now create cities and add locations within them, growing the platform organically. - Add cities collection (name, slug, country, state, coordinates) - Add cityId FK to locations, scope locations to cities - New URL structure: /cities/[slug], /cities/[slug]/map, etc. - Home page becomes city discovery with search - Add city creation page with geocoding + slug generation - Context-aware navigation (global vs city mode) - Remove all Konstanz-specific hardcoding from i18n and map - Guest seed with 3 example cities (Konstanz, Zürich, Berlin) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/data/guest-seed.ts | 64 +- .../apps/web/src/lib/data/local-store.ts | 25 +- .../apps/web/src/lib/data/queries.ts | 44 + .../apps/web/src/lib/i18n/locales/de.json | 51 +- .../apps/web/src/lib/i18n/locales/en.json | 51 +- .../apps/web/src/routes/(app)/+layout.svelte | 59 +- .../apps/web/src/routes/(app)/+page.svelte | 362 ++----- .../src/routes/(app)/add-city/+page.svelte | 255 +++++ .../routes/(app)/cities/[slug]/+layout.svelte | 37 + .../routes/(app)/cities/[slug]/+page.svelte | 329 ++++++ .../(app)/cities/[slug]/add/+page.svelte | 450 +++++++++ .../cities/[slug]/locations/[id]/+page.svelte | 954 ++++++++++++++++++ .../[slug]/locations/[id]/edit/+page.svelte | 298 ++++++ .../(app)/cities/[slug]/map/+page.svelte | 310 ++++++ 14 files changed, 2955 insertions(+), 334 deletions(-) create mode 100644 apps/citycorners/apps/web/src/routes/(app)/add-city/+page.svelte create mode 100644 apps/citycorners/apps/web/src/routes/(app)/cities/[slug]/+layout.svelte create mode 100644 apps/citycorners/apps/web/src/routes/(app)/cities/[slug]/+page.svelte create mode 100644 apps/citycorners/apps/web/src/routes/(app)/cities/[slug]/add/+page.svelte create mode 100644 apps/citycorners/apps/web/src/routes/(app)/cities/[slug]/locations/[id]/+page.svelte create mode 100644 apps/citycorners/apps/web/src/routes/(app)/cities/[slug]/locations/[id]/edit/+page.svelte create mode 100644 apps/citycorners/apps/web/src/routes/(app)/cities/[slug]/map/+page.svelte diff --git a/apps/citycorners/apps/web/src/lib/data/guest-seed.ts b/apps/citycorners/apps/web/src/lib/data/guest-seed.ts index d062473d9..7d639e188 100644 --- a/apps/citycorners/apps/web/src/lib/data/guest-seed.ts +++ b/apps/citycorners/apps/web/src/lib/data/guest-seed.ts @@ -1,14 +1,50 @@ /** * Guest seed data for the CityCorners app. * - * Provides iconic Konstanz locations for the onboarding experience. + * Provides example cities and locations for the onboarding experience. */ -import type { LocalLocation } from './local-store'; +import type { LocalCity, LocalLocation } from './local-store'; + +export const guestCities: LocalCity[] = [ + { + id: 'city-konstanz', + name: 'Konstanz', + slug: 'konstanz', + country: 'Deutschland', + state: 'Baden-Württemberg', + description: + 'Universitätsstadt am Bodensee mit mittelalterlicher Altstadt, direkt an der Schweizer Grenze.', + latitude: 47.6603, + longitude: 9.1757, + }, + { + id: 'city-zuerich', + name: 'Zürich', + slug: 'zuerich', + country: 'Schweiz', + state: 'Zürich', + description: + 'Größte Stadt der Schweiz am Zürichsee, bekannt für Kultur, Finanzen und hohe Lebensqualität.', + latitude: 47.3769, + longitude: 8.5417, + }, + { + id: 'city-berlin', + name: 'Berlin', + slug: 'berlin', + country: 'Deutschland', + state: 'Berlin', + description: 'Hauptstadt Deutschlands mit vielfältiger Kultur, Geschichte und Nachtleben.', + latitude: 52.52, + longitude: 13.405, + }, +]; export const guestLocations: LocalLocation[] = [ { id: 'loc-muenster', + cityId: 'city-konstanz', name: 'Konstanzer Münster', category: 'sight', description: @@ -19,6 +55,7 @@ export const guestLocations: LocalLocation[] = [ }, { id: 'loc-imperia', + cityId: 'city-konstanz', name: 'Imperia', category: 'sight', description: @@ -29,6 +66,7 @@ export const guestLocations: LocalLocation[] = [ }, { id: 'loc-insel', + cityId: 'city-konstanz', name: 'Mainau – Blumeninsel', category: 'park', description: @@ -39,6 +77,7 @@ export const guestLocations: LocalLocation[] = [ }, { id: 'loc-strandbad', + cityId: 'city-konstanz', name: 'Strandbad Horn', category: 'beach', description: 'Beliebtes Freibad am Bodensee mit Sandstrand und Blick auf die Alpen.', @@ -46,4 +85,25 @@ export const guestLocations: LocalLocation[] = [ latitude: 47.6753, longitude: 9.2001, }, + { + id: 'loc-grossmuenster', + cityId: 'city-zuerich', + name: 'Grossmünster', + category: 'sight', + description: + 'Romanische Kirche aus dem 12. Jahrhundert, Wahrzeichen Zürichs mit Aussichtsturm über die Altstadt.', + address: 'Grossmünsterplatz, 8001 Zürich', + latitude: 47.3701, + longitude: 8.5441, + }, + { + id: 'loc-brandenburger-tor', + cityId: 'city-berlin', + name: 'Brandenburger Tor', + category: 'sight', + description: 'Das bekannteste Wahrzeichen Berlins und Symbol der deutschen Wiedervereinigung.', + address: 'Pariser Platz, 10117 Berlin', + latitude: 52.5163, + longitude: 13.3777, + }, ]; diff --git a/apps/citycorners/apps/web/src/lib/data/local-store.ts b/apps/citycorners/apps/web/src/lib/data/local-store.ts index 264159914..39aa7f482 100644 --- a/apps/citycorners/apps/web/src/lib/data/local-store.ts +++ b/apps/citycorners/apps/web/src/lib/data/local-store.ts @@ -1,16 +1,29 @@ /** * CityCorners — Local-First Data Layer * - * Locations and favorites stored locally for offline browsing. + * Cities, locations, and favorites stored locally for offline browsing. * Location lookup and web search remain server-side. */ import { createLocalStore, type BaseRecord } from '@manacore/local-store'; -import { guestLocations } from './guest-seed'; +import { guestCities, guestLocations } from './guest-seed'; // ─── Types ────────────────────────────────────────────────── +export interface LocalCity extends BaseRecord { + name: string; + slug: string; + country: string; + state?: string | null; + description?: string | null; + latitude: number; + longitude: number; + imageUrl?: string | null; + createdBy?: string | null; +} + export interface LocalLocation extends BaseRecord { + cityId: string; name: string; category: | 'sight' @@ -43,9 +56,14 @@ const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localh export const citycornersStore = createLocalStore({ appId: 'citycorners', collections: [ + { + name: 'cities', + indexes: ['slug', 'country', 'name'], + guestSeed: guestCities, + }, { name: 'locations', - indexes: ['category', 'name'], + indexes: ['cityId', 'category', 'name'], guestSeed: guestLocations, }, { @@ -59,5 +77,6 @@ export const citycornersStore = createLocalStore({ }); // Typed collection accessors +export const cityCollection = citycornersStore.collection('cities'); export const locationCollection = citycornersStore.collection('locations'); export const favoriteCollection = citycornersStore.collection('favorites'); diff --git a/apps/citycorners/apps/web/src/lib/data/queries.ts b/apps/citycorners/apps/web/src/lib/data/queries.ts index c704ffe7b..9d37d31e4 100644 --- a/apps/citycorners/apps/web/src/lib/data/queries.ts +++ b/apps/citycorners/apps/web/src/lib/data/queries.ts @@ -8,14 +8,26 @@ import { useLiveQueryWithDefault } from '@manacore/local-store/svelte'; import { + cityCollection, locationCollection, favoriteCollection, + type LocalCity, type LocalLocation, type LocalFavorite, } from './local-store'; // ─── Live Query Hooks (call during component init) ────────── +/** All cities, sorted by name. Auto-updates on any change. */ +export function useAllCities() { + return useLiveQueryWithDefault(async () => { + return cityCollection.getAll(undefined, { + sortBy: 'name', + sortDirection: 'asc', + }); + }, [] as LocalCity[]); +} + /** All locations, sorted by name. Auto-updates on any change. */ export function useAllLocations() { return useLiveQueryWithDefault(async () => { @@ -45,6 +57,11 @@ export function isFavorite(favorites: LocalFavorite[], locationId: string): bool return favorites.some((f) => f.locationId === locationId); } +/** Filter locations by city. */ +export function filterByCity(locations: LocalLocation[], cityId: string): LocalLocation[] { + return locations.filter((l) => l.cityId === cityId); +} + /** Filter locations by category. */ export function filterByCategory( locations: LocalLocation[], @@ -65,3 +82,30 @@ export function searchLocations(locations: LocalLocation[], query: string): Loca l.address?.toLowerCase().includes(search) ); } + +/** Filter cities by search query across name, country, state, description. */ +export function searchCities(cities: LocalCity[], query: string): LocalCity[] { + if (!query.trim()) return cities; + const search = query.toLowerCase().trim(); + return cities.filter( + (c) => + c.name.toLowerCase().includes(search) || + c.country.toLowerCase().includes(search) || + c.state?.toLowerCase().includes(search) || + c.description?.toLowerCase().includes(search) + ); +} + +/** Find a city by slug. */ +export function findCityBySlug(cities: LocalCity[], slug: string): LocalCity | undefined { + return cities.find((c) => c.slug === slug); +} + +/** Count locations per city. */ +export function getLocationCountByCity(locations: LocalLocation[]): Map { + const counts = new Map(); + for (const loc of locations) { + counts.set(loc.cityId, (counts.get(loc.cityId) || 0) + 1); + } + return counts; +} diff --git a/apps/citycorners/apps/web/src/lib/i18n/locales/de.json b/apps/citycorners/apps/web/src/lib/i18n/locales/de.json index b023b68f8..34e4a81df 100644 --- a/apps/citycorners/apps/web/src/lib/i18n/locales/de.json +++ b/apps/citycorners/apps/web/src/lib/i18n/locales/de.json @@ -1,7 +1,7 @@ { "app": { "name": "CityCorners", - "tagline": "Entdecke Konstanz" + "tagline": "Entdecke Städte weltweit" }, "nav": { "explore": "Entdecken", @@ -10,14 +10,45 @@ "favorites": "Favoriten", "settings": "Einstellungen", "showNav": "Navigation einblenden", - "hideNav": "Navigation ausblenden" + "hideNav": "Navigation ausblenden", + "cities": "Städte" + }, + "cities": { + "title": "Städte entdecken", + "subtitle": "Von der Community für die Community", + "search": "Stadt suchen...", + "add": "Stadt hinzufügen", + "empty": "Noch keine Städte. Sei der Erste!", + "locationsCount": "{count} Orte", + "noLocationsYet": "Noch keine Orte" + }, + "cityAdd": { + "title": "Neue Stadt anlegen", + "subtitle": "Füge eine Stadt, ein Dorf oder einen Ort hinzu", + "name": "Name", + "namePlaceholder": "z.B. Konstanz", + "country": "Land", + "countryPlaceholder": "z.B. Deutschland", + "state": "Bundesland / Region (optional)", + "statePlaceholder": "z.B. Baden-Württemberg", + "description": "Beschreibung (optional)", + "descriptionPlaceholder": "Was macht diesen Ort besonders?", + "imageUrl": "Bild-URL (optional)", + "imageUrlPlaceholder": "https://example.com/bild.jpg", + "submit": "Stadt anlegen", + "submitting": "Wird angelegt...", + "loginRequired": "Melde dich an, um Städte anzulegen.", + "error": "Fehler beim Anlegen. Bitte versuche es erneut.", + "geocoding": "Koordinaten werden ermittelt...", + "coordinatesFound": "Koordinaten gefunden", + "slugExists": "Eine Stadt mit diesem Namen existiert bereits." }, "home": { - "title": "Entdecke Konstanz", + "title": "Orte entdecken", "subtitle": "Sehenswürdigkeiten, Restaurants, Museen und mehr", "all": "Alle", "loading": "Laden...", - "noResults": "Keine Locations gefunden.", + "noResults": "Keine Orte gefunden.", "noResultsCategory": "Keine {category} gefunden.", "addFirst": "Ersten Ort hinzufügen", "loadMore": "Mehr laden" @@ -57,7 +88,7 @@ "linkCopied": "Link kopiert!", "showDetails": "Details", "back": "Zurück zur Übersicht", - "notFound": "Location nicht gefunden.", + "notFound": "Ort nicht gefunden.", "edit": "Bearbeiten", "delete": "Löschen", "deleteConfirm": "Bist du sicher, dass du diesen Ort löschen möchtest? Das kann nicht rückgängig gemacht werden.", @@ -114,7 +145,7 @@ }, "map": { "title": "Karte", - "subtitle": "Alle Orte in Konstanz", + "subtitle": "Alle Orte auf der Karte", "locateMe": "Mein Standort", "yourLocation": "Du bist hier", "geolocationNotSupported": "Standortbestimmung wird nicht unterstützt.", @@ -141,7 +172,7 @@ "login": "Anmelden", "register": "Registrieren", "about": "Über CityCorners", - "aboutText": "CityCorners ist ein Stadtführer für Konstanz am Bodensee. Entdecke Sehenswürdigkeiten, Restaurants, Museen und Läden." + "aboutText": "CityCorners ist eine offene Plattform für Stadtführer weltweit. Entdecke Orte, die von der Community geteilt werden — oder lege selbst eine Stadt an." }, "auth": { "loginTitle": "Login - CityCorners", @@ -149,7 +180,7 @@ }, "add": { "title": "Ort hinzufügen", - "subtitle": "Teile deinen Lieblingsort in Konstanz", + "subtitle": "Teile deinen Lieblingsort", "name": "Name", "namePlaceholder": "z.B. Café am See", "category": "Kategorie", @@ -157,10 +188,10 @@ "descriptionPlaceholder": "Was macht diesen Ort besonders?", "minChars": "Mindestens 10 Zeichen", "address": "Adresse (optional)", - "addressPlaceholder": "z.B. Seestraße 1, 78462 Konstanz", + "addressPlaceholder": "z.B. Seestraße 1", "searchTitle": "Ort im Web suchen", "searchSubtitle": "Wir suchen automatisch nach Infos und füllen das Formular vor.", - "searchPlaceholder": "z.B. Café Zeitlos Konstanz", + "searchPlaceholder": "z.B. Café Zeitlos", "searchButton": "Suchen", "skipSearch": "Überspringen und manuell eintragen", "foundSources": "Quellen gefunden:", diff --git a/apps/citycorners/apps/web/src/lib/i18n/locales/en.json b/apps/citycorners/apps/web/src/lib/i18n/locales/en.json index 24f5322d0..3f64c9046 100644 --- a/apps/citycorners/apps/web/src/lib/i18n/locales/en.json +++ b/apps/citycorners/apps/web/src/lib/i18n/locales/en.json @@ -1,7 +1,7 @@ { "app": { "name": "CityCorners", - "tagline": "Discover Konstanz" + "tagline": "Discover cities worldwide" }, "nav": { "explore": "Explore", @@ -10,14 +10,45 @@ "favorites": "Favorites", "settings": "Settings", "showNav": "Show navigation", - "hideNav": "Hide navigation" + "hideNav": "Hide navigation", + "cities": "Cities" + }, + "cities": { + "title": "Discover cities", + "subtitle": "By the community, for the community", + "search": "Search cities...", + "add": "Add a city", + "empty": "No cities yet. Be the first!", + "locationsCount": "{count} places", + "noLocationsYet": "No places yet" + }, + "cityAdd": { + "title": "Add a new city", + "subtitle": "Add a city, village, or town", + "name": "Name", + "namePlaceholder": "e.g. Berlin", + "country": "Country", + "countryPlaceholder": "e.g. Germany", + "state": "State / Region (optional)", + "statePlaceholder": "e.g. Bavaria", + "description": "Description (optional)", + "descriptionPlaceholder": "What makes this place special?", + "imageUrl": "Image URL (optional)", + "imageUrlPlaceholder": "https://example.com/image.jpg", + "submit": "Add city", + "submitting": "Creating...", + "loginRequired": "Sign in to add cities.", + "error": "Failed to create. Please try again.", + "geocoding": "Finding coordinates...", + "coordinatesFound": "Coordinates found", + "slugExists": "A city with this name already exists." }, "home": { - "title": "Discover Konstanz", + "title": "Discover places", "subtitle": "Sights, restaurants, museums and more", "all": "All", "loading": "Loading...", - "noResults": "No locations found.", + "noResults": "No places found.", "noResultsCategory": "No {category} found.", "addFirst": "Add the first place", "loadMore": "Load more" @@ -57,7 +88,7 @@ "linkCopied": "Link copied!", "showDetails": "Details", "back": "Back to overview", - "notFound": "Location not found.", + "notFound": "Place not found.", "edit": "Edit", "delete": "Delete", "deleteConfirm": "Are you sure you want to delete this place? This cannot be undone.", @@ -114,7 +145,7 @@ }, "map": { "title": "Map", - "subtitle": "All places in Konstanz", + "subtitle": "All places on the map", "locateMe": "My location", "yourLocation": "You are here", "geolocationNotSupported": "Geolocation is not supported.", @@ -141,7 +172,7 @@ "login": "Sign in", "register": "Sign up", "about": "About CityCorners", - "aboutText": "CityCorners is a city guide for Konstanz at Lake Constance. Discover sights, restaurants, museums and shops." + "aboutText": "CityCorners is an open platform for city guides worldwide. Discover places shared by the community — or add your own city." }, "auth": { "loginTitle": "Login - CityCorners", @@ -149,7 +180,7 @@ }, "add": { "title": "Add a place", - "subtitle": "Share your favorite spot in Konstanz", + "subtitle": "Share your favorite spot", "name": "Name", "namePlaceholder": "e.g. Lakeside Cafe", "category": "Category", @@ -157,10 +188,10 @@ "descriptionPlaceholder": "What makes this place special?", "minChars": "At least 10 characters", "address": "Address (optional)", - "addressPlaceholder": "e.g. Seestrasse 1, 78462 Konstanz", + "addressPlaceholder": "e.g. Main Street 1", "searchTitle": "Search for a place online", "searchSubtitle": "We'll automatically find info and pre-fill the form for you.", - "searchPlaceholder": "e.g. Cafe Zeitlos Konstanz", + "searchPlaceholder": "e.g. Cafe Zeitlos", "searchButton": "Search", "skipSearch": "Skip and enter manually", "foundSources": "Sources found:", diff --git a/apps/citycorners/apps/web/src/routes/(app)/+layout.svelte b/apps/citycorners/apps/web/src/routes/(app)/+layout.svelte index f6cdf089f..f4adc1941 100644 --- a/apps/citycorners/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/citycorners/apps/web/src/routes/(app)/+layout.svelte @@ -68,20 +68,37 @@ isTagStripVisible = !isTagStripVisible; } - let navItems = $derived([ - { href: '/', label: $_('nav.explore'), icon: 'compass' }, - { href: '/map', label: $_('nav.map'), icon: 'mappin' }, - { href: '/add', label: $_('nav.add'), icon: 'plus' }, - { href: '/favorites', label: $_('nav.favorites'), icon: 'heart' }, - { href: '/settings', label: $_('nav.settings'), icon: 'settings' }, - { - href: '/', - label: 'Tags', - icon: 'tag', - onClick: handleTagStripToggle, - active: isTagStripVisible, - }, - ]); + // Detect if we're inside a city context + let currentCitySlug = $derived.by(() => { + const path = $page.url.pathname; + const match = path.match(/^\/cities\/([^/]+)/); + return match ? match[1] : null; + }); + + let navItems = $derived.by(() => { + const slug = currentCitySlug; + if (slug) { + return [ + { href: `/cities/${slug}`, label: $_('nav.explore'), icon: 'compass' }, + { href: `/cities/${slug}/map`, label: $_('nav.map'), icon: 'mappin' }, + { href: `/cities/${slug}/add`, label: $_('nav.add'), icon: 'plus' }, + { href: '/favorites', label: $_('nav.favorites'), icon: 'heart' }, + { href: '/settings', label: $_('nav.settings'), icon: 'settings' }, + ]; + } + return [ + { href: '/', label: $_('nav.cities'), icon: 'compass' }, + { href: '/favorites', label: $_('nav.favorites'), icon: 'heart' }, + { href: '/settings', label: $_('nav.settings'), icon: 'settings' }, + { + href: '/', + label: 'Tags', + icon: 'tag', + onClick: handleTagStripToggle, + active: isTagStripVisible, + }, + ]; + }); function handleToggleTheme() { theme.toggleMode(); @@ -121,9 +138,13 @@ localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history.slice(0, MAX_HISTORY))); } + function getLocationHref(locId: string): string { + const slug = currentCitySlug; + return slug ? `/cities/${slug}/locations/${locId}` : `/locations/${locId}`; + } + async function handleSearch(query: string): Promise { if (!query.trim()) { - // Show search history when empty const history = getSearchHistory(); if (history.length === 0) return []; return history.map((h) => ({ @@ -131,13 +152,12 @@ title: h.name, subtitle: $_(`category.${h.category}`), icon: 'clock' as const, - href: `/locations/${h.id}`, + href: getLocationHref(h.id), isHistory: true, })); } try { - // Use suggestions endpoint for prefix matching (faster) const res = await fetch(api(`/locations/suggestions?q=${encodeURIComponent(query)}`)); if (!res.ok) return []; const data = await res.json(); @@ -147,11 +167,10 @@ title: s.name, subtitle: $_(`category.${s.category}`), icon: 'mappin' as const, - href: `/locations/${s.id}`, + href: getLocationHref(s.id), })); } - // Fallback to full search const fullRes = await fetch(api(`/locations/search?q=${encodeURIComponent(query)}`)); if (!fullRes.ok) return []; const fullData = await fullRes.json(); @@ -160,7 +179,7 @@ title: loc.name, subtitle: $_(`category.${loc.category}`), icon: 'mappin' as const, - href: `/locations/${loc.id}`, + href: getLocationHref(loc.id), })); } catch { return []; diff --git a/apps/citycorners/apps/web/src/routes/(app)/+page.svelte b/apps/citycorners/apps/web/src/routes/(app)/+page.svelte index b2b5dfb2b..7ecd355b3 100644 --- a/apps/citycorners/apps/web/src/routes/(app)/+page.svelte +++ b/apps/citycorners/apps/web/src/routes/(app)/+page.svelte @@ -1,122 +1,22 @@ @@ -125,206 +25,90 @@
-

{$_('home.title')}

-

{$_('home.subtitle')}

+

{$_('cities.title')}

+

{$_('cities.subtitle')}

- - - - - + {#if authStore.isAuthenticated} + + + + + + {/if}
-
- - {#each categoryKeys as cat} - - {/each} + +
+
-{#if loading} -
-
-
-{:else if filtered.length === 0} +{#if filtered.length === 0}
- {selectedCategory === 'restaurant' - ? '🍽️' - : selectedCategory === 'museum' - ? '🏛️' - : selectedCategory === 'shop' - ? '🛍️' - : selectedCategory === 'sight' - ? '🏰' - : selectedCategory === 'cafe' - ? '☕' - : selectedCategory === 'bar' - ? '🍸' - : selectedCategory === 'park' - ? '🌳' - : selectedCategory === 'beach' - ? '🏖️' - : selectedCategory === 'hotel' - ? '🏨' - : selectedCategory === 'event_venue' - ? '🎭' - : selectedCategory === 'viewpoint' - ? '🔭' - : '📍'} -

- {#if selectedCategory} - {$_('home.noResultsCategory', { - values: { category: $_(`categories.${selectedCategory}`) }, - })} - {:else} - {$_('home.noResults')} - {/if} -

- - {$_('home.addFirst')} - + 🏙️ +

{$_('cities.empty')}

+ {#if authStore.isAuthenticated} + + {$_('cities.add')} + + {/if}
{:else} -
- {#each filtered as location} + {/if} - - - {#if hasMore} -
- -
- {/if} {/if} diff --git a/apps/citycorners/apps/web/src/routes/(app)/add-city/+page.svelte b/apps/citycorners/apps/web/src/routes/(app)/add-city/+page.svelte new file mode 100644 index 000000000..5252d7a5a --- /dev/null +++ b/apps/citycorners/apps/web/src/routes/(app)/add-city/+page.svelte @@ -0,0 +1,255 @@ + + + + {$_('cityAdd.title')} - CityCorners + + +
+
+ + + + + +

{$_('cityAdd.title')}

+
+

{$_('cityAdd.subtitle')}

+
+ +{#if !authStore.isAuthenticated} +
+ 🏙️ +

{$_('cityAdd.loginRequired')}

+ + {$_('settings.login')} + +
+{:else} +
{ + e.preventDefault(); + handleSubmit(); + }} + class="space-y-5" + > + {#if error} +
{error}
+ {/if} + +
+ + + {#if slug && slugExists} +

{$_('cityAdd.slugExists')}

+ {:else if slug} +

/{slug}

+ {/if} +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + (imageError = false)} + placeholder={$_('cityAdd.imageUrlPlaceholder')} + class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" + /> + {#if imageUrl.trim() && !imageError} +
+ Preview (imageError = true)} + /> +
+ {/if} +
+ + {#if geocoding} +

{$_('cityAdd.geocoding')}

+ {:else if latitude !== undefined && longitude !== undefined} +

{$_('cityAdd.coordinatesFound')}

+ {/if} + +
+ + {$_('edit.cancel')} + + +
+
+{/if} diff --git a/apps/citycorners/apps/web/src/routes/(app)/cities/[slug]/+layout.svelte b/apps/citycorners/apps/web/src/routes/(app)/cities/[slug]/+layout.svelte new file mode 100644 index 000000000..2b09b0404 --- /dev/null +++ b/apps/citycorners/apps/web/src/routes/(app)/cities/[slug]/+layout.svelte @@ -0,0 +1,37 @@ + + +{#if currentCity} + {@render children()} +{:else if allCities.value.length > 0} +
+ 🔍 +

Stadt nicht gefunden.

+ + Zurück zu allen Städten + +
+{:else} + +
+
+
+{/if} diff --git a/apps/citycorners/apps/web/src/routes/(app)/cities/[slug]/+page.svelte b/apps/citycorners/apps/web/src/routes/(app)/cities/[slug]/+page.svelte new file mode 100644 index 000000000..151e19011 --- /dev/null +++ b/apps/citycorners/apps/web/src/routes/(app)/cities/[slug]/+page.svelte @@ -0,0 +1,329 @@ + + + + {city?.name || ''} - CityCorners + + +
+
+
+ + + + + +

{city?.name}

+
+

+ {#if city?.state} + {city.state}, {city.country} + {:else} + {city?.country} + {/if} +

+ {#if city?.description} +

{city.description}

+ {/if} +
+ + + + + +
+ + +
+ + {#each categoryKeys as cat} + {@const count = categoryCounts[cat] || 0} + {#if count > 0} + + {/if} + {/each} +
+ +{#if loading} +
+
+
+{:else if filtered.length === 0} +
+ 📍 +

+ {#if selectedCategory} + {$_('home.noResultsCategory', { + values: { category: $_(`categories.${selectedCategory}`) }, + })} + {:else} + {$_('home.noResults')} + {/if} +

+ + {$_('home.addFirst')} + +
+{:else} + + + {#if hasMore} +
+ +
+ {/if} +{/if} diff --git a/apps/citycorners/apps/web/src/routes/(app)/cities/[slug]/add/+page.svelte b/apps/citycorners/apps/web/src/routes/(app)/cities/[slug]/add/+page.svelte new file mode 100644 index 000000000..39b75fc7f --- /dev/null +++ b/apps/citycorners/apps/web/src/routes/(app)/cities/[slug]/add/+page.svelte @@ -0,0 +1,450 @@ + + + + {$_('add.title')} - {city?.name || 'CityCorners'} + + +
+
+ + + + + +

{$_('add.title')}

+
+

{$_('add.subtitle')} — {city?.name}

+
+ +{#if !authStore.isAuthenticated} +
+ 📍 +

{$_('add.loginRequired')}

+ + {$_('settings.login')} + +
+{:else if !lookupDone} +
+
+

{$_('add.searchTitle')}

+

{$_('add.searchSubtitle')}

+ +
+ e.key === 'Enter' && handleLookup()} + /> + +
+
+ + +
+{:else} + {#if sources.length > 0} +
+

{$_('add.foundSources')}

+
+ {#each sources.slice(0, 3) as source} + + {source.title} + + {/each} +
+
+ {/if} + +
{ + e.preventDefault(); + handleSubmit(); + }} + class="space-y-5" + > + {#if error} +
{error}
+ {/if} + +
+ + +
+ +
+ +
+ {#each categories as cat} + + {/each} +
+
+ +
+ + +

{$_('add.minChars')}

+
+ +
+ + + {#if geocoding} +

{$_('add.geocoding')}

+ {:else if latitude !== undefined && longitude !== undefined} +

+ {$_('add.coordinatesFound')} +

+ {/if} +
+ +
+ + (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} +
+ {$_('add.imagePreview')} (imageError = true)} + /> +
+ {:else if imageError} +
+

{$_('add.imageLoadError')}

+ +
+ {/if} +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+{/if} diff --git a/apps/citycorners/apps/web/src/routes/(app)/cities/[slug]/locations/[id]/+page.svelte b/apps/citycorners/apps/web/src/routes/(app)/cities/[slug]/locations/[id]/+page.svelte new file mode 100644 index 000000000..71f115895 --- /dev/null +++ b/apps/citycorners/apps/web/src/routes/(app)/cities/[slug]/locations/[id]/+page.svelte @@ -0,0 +1,954 @@ + + + + {location?.name || 'Location'} - {city?.name || 'CityCorners'} + + + +{#if loading} +
+
+
+{:else if !location} +
+ 🔍 +

{$_('detail.notFound')}

+ {$_('detail.back')} +
+{:else} + {@const images = allImages()} + + +
+ {#if images.length > 0} + {location.name} + {:else} +
+ 📍 +
+ {/if} + + + + + +
+ + + {#if authStore.isAuthenticated} + + {/if} +
+ + {#if images.length > 1} +
+ {selectedImageIndex + 1} / {images.length} +
+ {/if} + +
+ + {$_(`category.${location.category}`)} + + {#if isOpenNow(location.openingHours) === true} + + {$_('detail.openNow')} + + {:else if isOpenNow(location.openingHours) === false} + + {$_('detail.closedNow')} + + {/if} +
+
+ + + {#if images.length > 1} +
+ {#each images as img, i} + + {/each} + {#if authStore.isAuthenticated} + + {/if} +
+ {:else if authStore.isAuthenticated} +
+ +
+ {/if} + + + {#if showAddPhoto} +
+

{$_('gallery.addPhoto')}

+ {#if photoError} +
{photoError}
+ {/if} +
+ e.key === 'Enter' && handleAddPhoto()} + /> + +
+
+ {/if} + + +
+
+

{location.name}

+ {#if location.address} +

+ + + + + {location.address} +

+ {/if} +
+ +

{location.description}

+ + + {#if location.website || location.phone} +
+ {#if location.website} + + {/if} + {#if location.phone} +
+ {$_('detail.phone')}: + + {location.phone} + +
+ {/if} +
+ {/if} + + + {#if location.openingHours && Object.keys(location.openingHours).length > 0} +
+

{$_('detail.openingHours')}

+
+ + + {#each ['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'] as day} + {#if location.openingHours[day]} + + + + + {/if} + {/each} + +
{$_(`days.${day}`)} + {location.openingHours[day] === 'closed' + ? $_('detail.closed') + : location.openingHours[day]} +
+
+
+ {/if} + + +
+
+
+

{$_('reviews.title')}

+ {#if location.reviewStats && location.reviewStats.totalReviews > 0} +
+
+ {#each Array(5) as _, i} + + + + {/each} +
+ + {location.reviewStats.averageRating.toFixed(1)} + + + ({location.reviewStats.totalReviews}) + +
+ {/if} +
+ {#if authStore.isAuthenticated && !userHasReviewed} + + {/if} +
+ + {#if showReviewForm} +
+

{$_('reviews.yourRating')}

+ {#if reviewError} +
+ {reviewError} +
+ {/if} +
+ {#each [1, 2, 3, 4, 5] as star} + + {/each} +
+ + +
+ {/if} + + {#if reviews.length > 0} +
+ {#each reviews as review} +
+
+
+ {#each Array(5) as _, i} + + + + {/each} +
+
+ + {new Date(review.createdAt).toLocaleDateString()} + + {#if authStore.isAuthenticated && review.userId === authStore.user?.id} + + {/if} +
+
+ {#if review.comment} +

{review.comment}

+ {/if} +
+ {/each} +
+ {:else if !showReviewForm} +

{$_('reviews.noReviews')}

+ {/if} +
+ + + {#if isOwner} +
+ + + + + {$_('detail.edit')} + + +
+ {/if} + + + {#if showDeleteConfirm} +
+

{$_('detail.deleteConfirm')}

+
+ + +
+
+ {/if} + + + {#if location.latitude && location.longitude} + + {/if} + + + {#if location.timeline && location.timeline.length > 0} +
+

{$_('detail.history')}

+
+ {#each location.timeline as entry, i} +
+ {#if i < location.timeline!.length - 1} +
+ {/if} +
+
+
+
+ {entry.year} +

{entry.event}

+
+
+ {/each} +
+
+ {/if} + + + {#if nearbyLocations.length > 0} + + {/if} +
+{/if} + + diff --git a/apps/citycorners/apps/web/src/routes/(app)/cities/[slug]/locations/[id]/edit/+page.svelte b/apps/citycorners/apps/web/src/routes/(app)/cities/[slug]/locations/[id]/edit/+page.svelte new file mode 100644 index 000000000..83fe3e870 --- /dev/null +++ b/apps/citycorners/apps/web/src/routes/(app)/cities/[slug]/locations/[id]/edit/+page.svelte @@ -0,0 +1,298 @@ + + + + {$_('edit.title')} - {city?.name || 'CityCorners'} + + +
+
+ + + + + +

{$_('edit.title')}

+
+

{$_('edit.subtitle')}

+
+ +{#if loading} +
+
+
+{:else if forbidden} +
+ 🔒 +

{$_('edit.forbidden')}

+ + {$_('detail.back')} + +
+{:else} +
{ + e.preventDefault(); + handleSubmit(); + }} + class="space-y-5" + > + {#if error} +
{error}
+ {/if} + +
+ + +
+ +
+ +
+ {#each categories as cat} + + {/each} +
+
+ +
+ + +

{$_('add.minChars')}

+
+ +
+ + +
+ +
+ + (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} +
+ {$_('add.imagePreview')} (imageError = true)} + /> +
+ {:else if imageError} +
+

{$_('add.imageLoadError')}

+ +
+ {/if} +
+ +
+ + +
+ +
+ + +
+ +
+ + {$_('edit.cancel')} + + +
+
+{/if} diff --git a/apps/citycorners/apps/web/src/routes/(app)/cities/[slug]/map/+page.svelte b/apps/citycorners/apps/web/src/routes/(app)/cities/[slug]/map/+page.svelte new file mode 100644 index 000000000..15df6e75e --- /dev/null +++ b/apps/citycorners/apps/web/src/routes/(app)/cities/[slug]/map/+page.svelte @@ -0,0 +1,310 @@ + + + + {$_('map.title')} - {city?.name || 'CityCorners'} + + + + + +
+
+
+
+ + + + + +

{$_('map.title')}

+
+

{city?.name} - {$_('map.subtitle')}

+
+ +
+ + {#if locationError} +
{locationError}
+ {/if} + +
+ + {#each categoryKeys as cat} + + {/each} +
+ +
+
+ +