fix(citycorners): add /api/v1/ prefix to all API calls and add location submission form

API paths: Created centralized api() helper in $lib/api.ts. All fetch calls now use /api/v1/ prefix matching the production NestJS route structure.

New feature: /add page where authenticated users can submit new locations with name, category, description, and optional address. Added "Hinzufügen" nav item with plus icon.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-23 12:12:56 +01:00
parent 241cb3332a
commit a4f52df138
10 changed files with 226 additions and 40 deletions

View file

@ -0,0 +1,14 @@
import { browser } from '$app/environment';
export function getBackendUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as any).__PUBLIC_BACKEND_URL__;
if (injectedUrl) return injectedUrl;
return import.meta.env.DEV ? 'http://localhost:3025' : '';
}
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3025';
}
export function api(path: string): string {
return `${getBackendUrl()}/api/v1${path}`;
}

View file

@ -6,6 +6,7 @@
"nav": {
"explore": "Entdecken",
"map": "Karte",
"add": "Hinzufügen",
"favorites": "Favoriten",
"settings": "Einstellungen",
"showNav": "Navigation einblenden",
@ -74,6 +75,22 @@
"loginTitle": "Login - CityCorners",
"registerTitle": "Registrieren - CityCorners"
},
"add": {
"title": "Ort hinzufügen",
"subtitle": "Teile deinen Lieblingsort in Konstanz",
"name": "Name",
"namePlaceholder": "z.B. Café am See",
"category": "Kategorie",
"description": "Beschreibung",
"descriptionPlaceholder": "Was macht diesen Ort besonders?",
"minChars": "Mindestens 10 Zeichen",
"address": "Adresse (optional)",
"addressPlaceholder": "z.B. Seestraße 1, 78462 Konstanz",
"submit": "Ort einreichen",
"submitting": "Wird eingereicht...",
"loginRequired": "Melde dich an, um Orte hinzuzufügen.",
"error": "Fehler beim Einreichen. Bitte versuche es erneut."
},
"offline": {
"title": "Keine Verbindung",
"message": "Du bist gerade offline. Sobald du wieder eine Internetverbindung hast, kannst du CityCorners weiter nutzen.",

View file

@ -6,6 +6,7 @@
"nav": {
"explore": "Explore",
"map": "Map",
"add": "Add",
"favorites": "Favorites",
"settings": "Settings",
"showNav": "Show navigation",
@ -74,6 +75,22 @@
"loginTitle": "Login - CityCorners",
"registerTitle": "Sign up - CityCorners"
},
"add": {
"title": "Add a place",
"subtitle": "Share your favorite spot in Konstanz",
"name": "Name",
"namePlaceholder": "e.g. Lakeside Café",
"category": "Category",
"description": "Description",
"descriptionPlaceholder": "What makes this place special?",
"minChars": "At least 10 characters",
"address": "Address (optional)",
"addressPlaceholder": "e.g. Seestraße 1, 78462 Konstanz",
"submit": "Submit place",
"submitting": "Submitting...",
"loginRequired": "Sign in to add places.",
"error": "Failed to submit. Please try again."
},
"offline": {
"title": "No connection",
"message": "You are currently offline. You can continue using CityCorners once you have an internet connection again.",

View file

@ -2,8 +2,8 @@
* Favorites Store - Manages favorite locations using Svelte 5 runes
*/
import { browser } from '$app/environment';
import { authStore } from './auth.svelte';
import { api } from '$lib/api';
interface Favorite {
id: string;
@ -12,13 +12,6 @@ interface Favorite {
createdAt: string;
}
function getBackendUrl(): string {
if (browser && typeof window !== 'undefined') {
return (window as any).__PUBLIC_BACKEND_URL__ || 'http://localhost:3025';
}
return 'http://localhost:3025';
}
let favoriteLocationIds = $state<Set<string>>(new Set());
let loading = $state(false);
@ -42,7 +35,7 @@ export const favoritesStore = {
const token = await authStore.getValidToken();
if (!token) return;
const res = await fetch(`${getBackendUrl()}/favorites`, {
const res = await fetch(`${api('/favorites')}`, {
headers: { Authorization: `Bearer ${token}` },
});
@ -75,7 +68,7 @@ export const favoritesStore = {
favoriteLocationIds = newSet;
try {
const res = await fetch(`${getBackendUrl()}/favorites/${locationId}`, {
const res = await fetch(`${api(`/favorites/${locationId}`)}`, {
method: isFav ? 'DELETE' : 'POST',
headers: { Authorization: `Bearer ${token}` },
});

View file

@ -13,6 +13,7 @@
import { getPillAppItems } from '@manacore/shared-branding';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { setLocale, supportedLocales } from '$lib/i18n';
import { api } from '$lib/api';
const appItems = getPillAppItems('citycorners');
@ -52,6 +53,7 @@
let navItems = $derived<PillNavItem[]>([
{ href: '/', label: $_('nav.explore'), icon: 'compass' },
{ href: '/map', label: $_('nav.map'), icon: 'mappin' },
{ href: '/add', label: $_('nav.add'), icon: 'plus' },
{ href: '/favorites', label: $_('nav.favorites'), icon: 'heart' },
{ href: '/settings', label: $_('nav.settings'), icon: 'settings' },
]);
@ -70,11 +72,6 @@
goto('/login');
}
const backendUrl =
typeof window !== 'undefined'
? (window as any).__PUBLIC_BACKEND_URL__ || 'http://localhost:3025'
: 'http://localhost:3025';
let inputBarBottomOffset = $derived(showNav ? '70px' : '16px');
interface SearchItem extends QuickInputItem {
@ -85,7 +82,7 @@
if (!query.trim()) return [];
try {
const res = await fetch(`${backendUrl}/locations/search?q=${encodeURIComponent(query)}`);
const res = await fetch(api(`/locations/search?q=${encodeURIComponent(query)}`));
if (!res.ok) return [];
const data = await res.json();
return data.locations.slice(0, 8).map((loc: any) => ({

View file

@ -3,6 +3,7 @@
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
import { api } from '$lib/api';
interface Location {
id: string;
@ -19,11 +20,6 @@
let loading = $state(true);
let selectedCategory = $state<string | null>(null);
const backendUrl =
typeof window !== 'undefined'
? (window as any).__PUBLIC_BACKEND_URL__ || 'http://localhost:3025'
: 'http://localhost:3025';
const categoryKeys = ['sight', 'restaurant', 'shop', 'museum'];
let filtered = $derived(
@ -32,7 +28,7 @@
onMount(async () => {
try {
const res = await fetch(`${backendUrl}/locations`);
const res = await fetch(api('/locations'));
const data = await res.json();
locations = data.locations;
} catch (err) {

View file

@ -0,0 +1,164 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { api } from '$lib/api';
let name = $state('');
let category = $state<string>('sight');
let description = $state('');
let address = $state('');
let submitting = $state(false);
let error = $state('');
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 handleSubmit() {
if (!isValid || submitting) return;
submitting = true;
error = '';
try {
const token = await authStore.getValidToken();
if (!token) {
error = $_('add.loginRequired');
return;
}
const res = await fetch(api('/locations'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name: name.trim(),
category,
description: description.trim(),
address: address.trim() || undefined,
}),
});
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;
}
}
</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}
<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>
<button
type="submit"
disabled={!isValid || submitting}
class="w-full 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>
</form>
{/if}

View file

@ -3,6 +3,7 @@
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
import { api } from '$lib/api';
interface Location {
id: string;
@ -15,16 +16,11 @@
let allLocations = $state<Location[]>([]);
let loading = $state(true);
const backendUrl =
typeof window !== 'undefined'
? (window as any).__PUBLIC_BACKEND_URL__ || 'http://localhost:3025'
: 'http://localhost:3025';
let favoriteLocations = $derived(allLocations.filter((l) => favoritesStore.isFavorite(l.id)));
onMount(async () => {
try {
const res = await fetch(`${backendUrl}/locations`);
const res = await fetch(api('/locations'));
const data = await res.json();
allLocations = data.locations;
} catch (err) {

View file

@ -5,6 +5,7 @@
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
import { api } from '$lib/api';
interface TimelineEntry {
year: string;
@ -27,11 +28,6 @@
let loading = $state(true);
let mapContainer: HTMLDivElement;
const backendUrl =
typeof window !== 'undefined'
? (window as any).__PUBLIC_BACKEND_URL__ || 'http://localhost:3025'
: 'http://localhost:3025';
const categoryLabels: Record<string, string> = {
sight: 'Sehenswürdigkeit',
restaurant: 'Restaurant',
@ -48,7 +44,7 @@
onMount(async () => {
try {
const res = await fetch(`${backendUrl}/locations/${$page.params.id}`);
const res = await fetch(api(`/locations/${$page.params.id}`));
const data = await res.json();
location = data.location;
} catch (err) {

View file

@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { _ } from 'svelte-i18n';
import { api } from '$lib/api';
interface Location {
id: string;
@ -18,11 +19,6 @@
let mapContainer: HTMLDivElement;
let map: any = null;
const backendUrl =
typeof window !== 'undefined'
? (window as any).__PUBLIC_BACKEND_URL__ || 'http://localhost:3025'
: 'http://localhost:3025';
const categoryColors: Record<string, string> = {
sight: '#2563eb',
restaurant: '#dc2626',
@ -40,7 +36,7 @@
onMount(async () => {
// Load locations
try {
const res = await fetch(`${backendUrl}/locations`);
const res = await fetch(api('/locations'));
const data = await res.json();
locations = data.locations;
} catch (err) {