mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 17:49:39 +02:00
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:
parent
241cb3332a
commit
a4f52df138
10 changed files with 226 additions and 40 deletions
14
apps/citycorners/apps/web/src/lib/api.ts
Normal file
14
apps/citycorners/apps/web/src/lib/api.ts
Normal 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}`;
|
||||
}
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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}` },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
164
apps/citycorners/apps/web/src/routes/(app)/add/+page.svelte
Normal file
164
apps/citycorners/apps/web/src/routes/(app)/add/+page.svelte
Normal 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}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue