mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 02:41:09 +02:00
refactor(citycorners): switch city pages to local-first data layer
Remove API fetch calls from city-scoped pages since CityCorners has no backend server — all CRUD goes through IndexedDB via @manacore/local-store with mana-sync for server synchronization. - City home: use useAllLocations() + filterByCity() instead of API - Map: read locations from IndexedDB reactive queries - Detail: load from locationCollection.getById(), compute nearby locations locally with haversine distance - Edit: read/write via locationCollection - Add: insert via locationCollection instead of POST to API - Delete: use locationCollection.delete() instead of API call - Remove review/gallery API calls (no backend for these yet) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3686926a8e
commit
e73d64c999
5 changed files with 110 additions and 364 deletions
|
|
@ -1,47 +1,26 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { getContext } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { page } from '$app/stores';
|
||||
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 {
|
||||
useAllLocations,
|
||||
useAllFavorites,
|
||||
getFavoriteIds,
|
||||
filterByCity,
|
||||
filterByCategory,
|
||||
} from '$lib/data/queries';
|
||||
import { isOpenNow } from '$lib/opening-hours';
|
||||
import type { LocalCity } from '$lib/data/local-store';
|
||||
|
||||
const cityCtx = getContext<{ value: LocalCity | undefined }>('currentCity');
|
||||
let city = $derived(cityCtx.value);
|
||||
|
||||
const allLocations = useAllLocations();
|
||||
const allFavorites = useAllFavorites();
|
||||
let favoriteIds = $derived(getFavoriteIds(allFavorites.value));
|
||||
|
||||
interface Location {
|
||||
id: string;
|
||||
slug?: string;
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
address?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
imageUrl?: string;
|
||||
openingHours?: Record<string, string>;
|
||||
reviewStats?: { averageRating: number; totalReviews: number };
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
interface Pagination {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
let locations = $state<Location[]>([]);
|
||||
let pagination = $state<Pagination | null>(null);
|
||||
let loading = $state(true);
|
||||
let loadingMore = $state(false);
|
||||
let selectedCategory = $state<string | null>(null);
|
||||
|
||||
const categoryKeys = [
|
||||
|
|
@ -58,65 +37,23 @@
|
|||
'viewpoint',
|
||||
];
|
||||
|
||||
// Filter locations by city
|
||||
let cityLocations = $derived(city ? filterByCity(allLocations.value, city.id) : []);
|
||||
|
||||
let categoryCounts = $derived(
|
||||
categoryKeys.reduce(
|
||||
(acc, cat) => {
|
||||
acc[cat] = locations.filter((l) => l.category === cat).length;
|
||||
acc[cat] = cityLocations.filter((l) => l.category === cat).length;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
)
|
||||
);
|
||||
|
||||
let filtered = $derived(
|
||||
selectedCategory ? locations.filter((l) => l.category === selectedCategory) : locations
|
||||
);
|
||||
|
||||
let hasMore = $derived(pagination ? pagination.page < pagination.totalPages : false);
|
||||
let filtered = $derived(filterByCategory(cityLocations, selectedCategory));
|
||||
|
||||
let citySlug = $derived($page.params.slug);
|
||||
|
||||
async function loadLocations(page = 1, append = false) {
|
||||
if (page === 1) loading = true;
|
||||
else loadingMore = true;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), limit: '20' });
|
||||
if (selectedCategory) params.set('category', selectedCategory);
|
||||
if (city) params.set('cityId', city.id);
|
||||
const res = await fetch(api(`/locations?${params}`));
|
||||
const data = await res.json();
|
||||
if (append) {
|
||||
locations = [...locations, ...data.locations];
|
||||
} else {
|
||||
locations = data.locations;
|
||||
}
|
||||
pagination = data.pagination;
|
||||
} catch (err) {
|
||||
console.error('Failed to load locations:', err);
|
||||
} finally {
|
||||
loading = false;
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (pagination && hasMore && !loadingMore) {
|
||||
loadLocations(pagination.page + 1, true);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadLocations();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const _ = selectedCategory;
|
||||
if (!loading || locations.length > 0) {
|
||||
loadLocations(1);
|
||||
}
|
||||
});
|
||||
|
||||
function handleFavoriteToggle(e: MouseEvent, locationId: string) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
|
@ -168,8 +105,7 @@
|
|||
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover'}"
|
||||
onclick={() => (selectedCategory = null)}
|
||||
>
|
||||
{$_('home.all')}
|
||||
{pagination ? `(${pagination.total})` : ''}
|
||||
{$_('home.all')} ({cityLocations.length})
|
||||
</button>
|
||||
{#each categoryKeys as cat}
|
||||
{@const count = categoryCounts[cat] || 0}
|
||||
|
|
@ -186,13 +122,7 @@
|
|||
{/each}
|
||||
</div>
|
||||
|
||||
{#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 filtered.length === 0}
|
||||
{#if filtered.length === 0}
|
||||
<div class="py-12 text-center">
|
||||
<span class="mb-2 block text-4xl">📍</span>
|
||||
<p class="text-foreground-secondary">
|
||||
|
|
@ -212,7 +142,7 @@
|
|||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each filtered as location}
|
||||
<a
|
||||
href="/cities/{citySlug}/locations/{location.slug || location.id}"
|
||||
href="/cities/{citySlug}/locations/{location.id}"
|
||||
class="group relative overflow-hidden rounded-xl border border-border bg-background-card transition-shadow hover:shadow-lg"
|
||||
>
|
||||
{#if location.imageUrl}
|
||||
|
|
@ -263,67 +193,17 @@
|
|||
<span class="inline-block rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
|
||||
{$_(`categories.${location.category}`)}
|
||||
</span>
|
||||
{#if isOpenNow(location.openingHours) === true}
|
||||
<span
|
||||
class="inline-block rounded-full bg-green-500/10 px-2 py-0.5 text-xs text-green-600 dark:text-green-400"
|
||||
>
|
||||
{$_('detail.openNow')}
|
||||
</span>
|
||||
{:else if isOpenNow(location.openingHours) === false}
|
||||
<span
|
||||
class="inline-block rounded-full bg-red-500/10 px-2 py-0.5 text-xs text-red-500 dark:text-red-400"
|
||||
>
|
||||
{$_('detail.closedNow')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold text-foreground group-hover:text-primary">
|
||||
{location.name}
|
||||
</h2>
|
||||
<p class="mt-1 line-clamp-2 text-sm text-foreground-secondary">
|
||||
{location.description}
|
||||
</p>
|
||||
{#if location.reviewStats && location.reviewStats.totalReviews > 0}
|
||||
<div class="mt-2 flex items-center gap-1.5 text-xs text-foreground-secondary">
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each Array(5) as _, i}
|
||||
<svg
|
||||
class="h-3.5 w-3.5 {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>{location.reviewStats.averageRating.toFixed(1)}</span>
|
||||
<span class="text-foreground-secondary/60">({location.reviewStats.totalReviews})</span
|
||||
>
|
||||
</div>
|
||||
{#if location.description}
|
||||
<p class="mt-1 line-clamp-2 text-sm text-foreground-secondary">
|
||||
{location.description}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if hasMore}
|
||||
<div class="mt-8 text-center">
|
||||
<button
|
||||
onclick={loadMore}
|
||||
disabled={loadingMore}
|
||||
class="rounded-lg border border-border bg-background-card px-6 py-2.5 text-sm font-medium text-foreground-secondary transition-colors hover:bg-background-card-hover hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
{#if loadingMore}
|
||||
<div
|
||||
class="inline-block h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin mr-2 align-middle"
|
||||
></div>
|
||||
{/if}
|
||||
{$_('home.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -4,18 +4,14 @@
|
|||
import { _ } from 'svelte-i18n';
|
||||
import { page } from '$app/stores';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { api } from '$lib/api';
|
||||
import type { LocalCity } from '$lib/data/local-store';
|
||||
import { locationCollection, type LocalCity, type LocalLocation } from '$lib/data/local-store';
|
||||
|
||||
const cityCtx = getContext<{ value: LocalCity | undefined }>('currentCity');
|
||||
let city = $derived(cityCtx.value);
|
||||
let citySlug = $derived($page.params.slug);
|
||||
|
||||
// Lookup state
|
||||
let searchQuery = $state('');
|
||||
let searching = $state(false);
|
||||
let lookupDone = $state(false);
|
||||
let sources = $state<{ url: string; title: string }[]>([]);
|
||||
// Lookup state (skip lookup — no backend)
|
||||
let lookupDone = $state(true);
|
||||
|
||||
// Form state
|
||||
let name = $state('');
|
||||
|
|
@ -158,43 +154,23 @@
|
|||
error = '';
|
||||
|
||||
try {
|
||||
const token = await authStore.getValidToken();
|
||||
if (!token) {
|
||||
error = $_('add.loginRequired');
|
||||
return;
|
||||
}
|
||||
const locId = `loc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
name: name.trim(),
|
||||
category,
|
||||
description: description.trim(),
|
||||
const locData: Omit<LocalLocation, 'createdAt' | 'updatedAt' | 'deletedAt'> = {
|
||||
id: locId,
|
||||
cityId: city.id,
|
||||
name: name.trim(),
|
||||
category: category as LocalLocation['category'],
|
||||
description: description.trim(),
|
||||
address: address.trim() || null,
|
||||
imageUrl: imageUrl.trim() && !imageError ? imageUrl.trim() : null,
|
||||
latitude: latitude ?? null,
|
||||
longitude: longitude ?? null,
|
||||
timeline: null,
|
||||
};
|
||||
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(`/cities/${citySlug}/locations/${data.location.id}`);
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
error = data.message || $_('add.error');
|
||||
}
|
||||
await locationCollection.insert(locData);
|
||||
goto(`/cities/${citySlug}/locations/${locId}`);
|
||||
} catch {
|
||||
error = $_('add.error');
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -6,10 +6,14 @@
|
|||
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 {
|
||||
useAllFavorites,
|
||||
useAllLocations,
|
||||
getFavoriteIds,
|
||||
filterByCity,
|
||||
} from '$lib/data/queries';
|
||||
import { locationCollection, type LocalCity } from '$lib/data/local-store';
|
||||
import { isOpenNow } from '$lib/opening-hours';
|
||||
import type { LocalCity } from '$lib/data/local-store';
|
||||
|
||||
const cityCtx = getContext<{ value: LocalCity | undefined }>('currentCity');
|
||||
let city = $derived(cityCtx.value);
|
||||
|
|
@ -127,28 +131,64 @@
|
|||
return imgs;
|
||||
});
|
||||
|
||||
const allLocs = useAllLocations();
|
||||
|
||||
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;
|
||||
const locId = $page.params.id;
|
||||
const loc = await locationCollection.getById(locId);
|
||||
if (loc) {
|
||||
location = {
|
||||
id: loc.id,
|
||||
name: loc.name,
|
||||
category: loc.category,
|
||||
description: loc.description || '',
|
||||
address: loc.address || undefined,
|
||||
latitude: loc.latitude || undefined,
|
||||
longitude: loc.longitude || undefined,
|
||||
imageUrl: loc.imageUrl || undefined,
|
||||
timeline: loc.timeline?.map((t) => ({ year: String(t.year), event: t.event })),
|
||||
};
|
||||
|
||||
if (nearbyRes.ok) {
|
||||
const nearbyData = await nearbyRes.json();
|
||||
nearbyLocations = nearbyData.locations || [];
|
||||
// Find nearby locations from the same city
|
||||
if (city && loc.latitude && loc.longitude) {
|
||||
const cityLocs = filterByCity(allLocs.value, city.id).filter(
|
||||
(l) => l.id !== locId && l.latitude && l.longitude
|
||||
);
|
||||
nearbyLocations = cityLocs
|
||||
.map((l) => {
|
||||
const dist = Math.round(
|
||||
haversine(loc.latitude!, loc.longitude!, l.latitude!, l.longitude!)
|
||||
);
|
||||
return {
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
category: l.category,
|
||||
imageUrl: l.imageUrl || undefined,
|
||||
distance: dist,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.distance - b.distance)
|
||||
.slice(0, 5);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load location:', err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
||||
loadReviews();
|
||||
});
|
||||
|
||||
function haversine(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371000;
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!browser || !location || !location.latitude || !location.longitude || !mapContainer) return;
|
||||
|
||||
|
|
@ -199,12 +239,8 @@
|
|||
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(`/cities/${citySlug}`);
|
||||
await locationCollection.delete(location.id);
|
||||
goto(`/cities/${citySlug}`);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
|
|
@ -213,104 +249,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
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();
|
||||
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`;
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@
|
|||
import { page } from '$app/stores';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { api } from '$lib/api';
|
||||
import type { LocalCity } from '$lib/data/local-store';
|
||||
import { locationCollection, type LocalCity } from '$lib/data/local-store';
|
||||
|
||||
const cityCtx = getContext<{ value: LocalCity | undefined }>('currentCity');
|
||||
let city = $derived(cityCtx.value);
|
||||
|
|
@ -42,12 +41,9 @@
|
|||
|
||||
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;
|
||||
const loc = await locationCollection.getById($page.params.id);
|
||||
if (!loc) {
|
||||
error = $_('edit.loadError');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -56,8 +52,6 @@
|
|||
description = loc.description || '';
|
||||
address = loc.address || '';
|
||||
imageUrl = loc.imageUrl || '';
|
||||
website = loc.website || '';
|
||||
phone = loc.phone || '';
|
||||
} catch {
|
||||
error = $_('edit.loadError');
|
||||
} finally {
|
||||
|
|
@ -72,37 +66,14 @@
|
|||
error = '';
|
||||
|
||||
try {
|
||||
const token = await authStore.getValidToken();
|
||||
if (!token) {
|
||||
error = $_('add.loginRequired');
|
||||
return;
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
await locationCollection.update($page.params.id, {
|
||||
name: name.trim(),
|
||||
category,
|
||||
category: category as any,
|
||||
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),
|
||||
address: address.trim() || null,
|
||||
imageUrl: imageUrl.trim() || null,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
goto(`/cities/${citySlug}/locations/${$page.params.id}`);
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
error = data.message || $_('edit.error');
|
||||
}
|
||||
goto(`/cities/${citySlug}/locations/${$page.params.id}`);
|
||||
} catch {
|
||||
error = $_('edit.error');
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -3,26 +3,15 @@
|
|||
import { browser } from '$app/environment';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '$lib/api';
|
||||
import type { LocalCity } from '$lib/data/local-store';
|
||||
import { useAllLocations, filterByCity } from '$lib/data/queries';
|
||||
import type { LocalCity, LocalLocation } from '$lib/data/local-store';
|
||||
|
||||
const cityCtx = getContext<{ value: LocalCity | undefined }>('currentCity');
|
||||
let city = $derived(cityCtx.value);
|
||||
let citySlug = $derived($page.params.slug);
|
||||
|
||||
interface Location {
|
||||
id: string;
|
||||
slug?: string;
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
address?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
let locations = $state<Location[]>([]);
|
||||
const allLocations = useAllLocations();
|
||||
let cityLocations = $derived(city ? filterByCity(allLocations.value, city.id) : []);
|
||||
let mapContainer: HTMLDivElement;
|
||||
let map: any = null;
|
||||
let locating = $state(false);
|
||||
|
|
@ -74,8 +63,8 @@
|
|||
allMarkers = [];
|
||||
|
||||
const filtered = selectedCategory
|
||||
? locations.filter((l) => l.category === selectedCategory)
|
||||
: locations;
|
||||
? cityLocations.filter((l) => l.category === selectedCategory)
|
||||
: cityLocations;
|
||||
|
||||
const useCluster = filtered.length >= 10;
|
||||
|
||||
|
|
@ -122,16 +111,6 @@
|
|||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: '100' });
|
||||
if (city) params.set('cityId', city.id);
|
||||
const res = await fetch(api(`/locations?${params}`));
|
||||
const data = await res.json();
|
||||
locations = data.locations;
|
||||
} catch (err) {
|
||||
console.error('Failed to load locations:', err);
|
||||
}
|
||||
|
||||
if (!browser) return;
|
||||
|
||||
leafletLib = await import('leaflet');
|
||||
|
|
@ -158,7 +137,9 @@
|
|||
});
|
||||
|
||||
$effect(() => {
|
||||
const _ = selectedCategory;
|
||||
// Re-render markers when category filter or locations change
|
||||
const _cat = selectedCategory;
|
||||
const _locs = cityLocations;
|
||||
if (map && leafletLib) {
|
||||
updateMarkers();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue