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:
Till JS 2026-03-29 14:27:15 +02:00
parent 3686926a8e
commit e73d64c999
5 changed files with 110 additions and 364 deletions

View file

@ -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}

View file

@ -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 {

View file

@ -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`;

View file

@ -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 {

View file

@ -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();
}