feat(citycorners): add owner tracking, edit/delete UI, and pagination

1. Owner tracking (createdBy):
   - Add createdBy field to locations schema
   - Set createdBy to userId on location creation
   - Only owners can edit/delete their own locations
   - Seed/unowned locations remain editable by anyone

2. Edit/Delete UI:
   - Edit button + full edit form at /locations/:id/edit
   - Delete button with confirmation dialog on detail page
   - Both only visible to the location owner
   - ForbiddenException (403) if non-owner tries to modify

3. Pagination:
   - Backend returns paginated results (page, limit, total, totalPages)
   - Frontend "Load more" button for infinite scroll
   - Category filter reloads from API with server-side filtering
   - Default 20 items per page, max 100

Tests updated: 36 tests passing (5 new for ownership + pagination).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-24 11:19:15 +01:00
parent 39526918a3
commit 58fb3e8dff
11 changed files with 651 additions and 58 deletions

View file

@ -15,6 +15,7 @@ export function createMockLocation(overrides: Partial<Location> = {}): Location
longitude: 9.1757,
imageUrl: '/images/muenster.svg',
timeline: [{ year: '615', event: 'Founded' }],
createdBy: null,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
...overrides,
@ -38,6 +39,7 @@ export function createMockDb() {
where: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
returning: jest.fn(),

View file

@ -20,6 +20,7 @@ export const locations = pgTable('locations', {
longitude: doublePrecision('longitude'),
imageUrl: text('image_url'),
timeline: jsonb('timeline').$type<TimelineEntry[]>().default([]),
createdBy: text('created_by'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()

View file

@ -26,23 +26,34 @@ describe('LocationController', () => {
afterEach(() => jest.clearAllMocks());
describe('findAll', () => {
it('should return all locations', async () => {
it('should return paginated locations', async () => {
const locations = [createMockLocation(), createMockLocation({ id: 'loc-2' })];
locationService.findAll.mockResolvedValue(locations);
locationService.findAll.mockResolvedValue({
items: locations,
total: 2,
page: 1,
limit: 20,
totalPages: 1,
});
const result = await controller.findAll();
expect(result).toEqual({ locations });
expect(result.locations).toEqual(locations);
expect(result.pagination).toEqual({ total: 2, page: 1, limit: 20, totalPages: 1 });
});
it('should filter by category', async () => {
const museums = [createMockLocation({ category: 'museum' })];
locationService.findAll.mockResolvedValue(museums);
it('should pass category and pagination params', async () => {
locationService.findAll.mockResolvedValue({
items: [],
total: 0,
page: 2,
limit: 10,
totalPages: 0,
});
const result = await controller.findAll('museum');
await controller.findAll('museum', '2', '10');
expect(result).toEqual({ locations: museums });
expect(locationService.findAll).toHaveBeenCalledWith('museum');
expect(locationService.findAll).toHaveBeenCalledWith('museum', 2, 10);
});
});
@ -86,8 +97,8 @@ describe('LocationController', () => {
});
describe('create', () => {
it('should create a location', async () => {
const location = createMockLocation({ id: 'new-loc' });
it('should create a location with createdBy', async () => {
const location = createMockLocation({ id: 'new-loc', createdBy: TEST_USER_ID });
locationService.create.mockResolvedValue(location);
const result = await controller.create(mockUser, {
@ -97,16 +108,38 @@ describe('LocationController', () => {
});
expect(result).toEqual({ location });
expect(locationService.create).toHaveBeenCalledWith({
name: 'Test',
category: 'sight',
description: 'A test location',
createdBy: TEST_USER_ID,
});
});
});
describe('update', () => {
it('should pass userId to service', async () => {
const location = createMockLocation({ name: 'Updated' });
locationService.update.mockResolvedValue(location);
await controller.update(mockUser, 'loc-1', { name: 'Updated' });
expect(locationService.update).toHaveBeenCalledWith(
'loc-1',
{ name: 'Updated' },
TEST_USER_ID
);
});
});
describe('delete', () => {
it('should delete a location', async () => {
it('should pass userId to service', async () => {
locationService.delete.mockResolvedValue(undefined);
const result = await controller.delete(mockUser, 'loc-1');
expect(result).toEqual({ success: true });
expect(locationService.delete).toHaveBeenCalledWith('loc-1', TEST_USER_ID);
});
});
});

View file

@ -77,9 +77,24 @@ export class LocationController {
) {}
@Get()
async findAll(@Query('category') category?: string) {
const locations = await this.locationService.findAll(category);
return { locations };
async findAll(
@Query('category') category?: string,
@Query('page') page?: string,
@Query('limit') limit?: string
) {
const pageNum = page ? Math.max(1, parseInt(page, 10)) : 1;
const limitNum = limit ? Math.min(100, Math.max(1, parseInt(limit, 10))) : 20;
const result = await this.locationService.findAll(category, pageNum, limitNum);
return {
locations: result.items,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
};
}
@Get('lookup')
@ -109,7 +124,10 @@ export class LocationController {
@Post()
@UseGuards(JwtAuthGuard)
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateLocationDto) {
const location = await this.locationService.create(dto);
const location = await this.locationService.create({
...dto,
createdBy: user.userId,
});
return { location };
}
@ -120,14 +138,14 @@ export class LocationController {
@Param('id') id: string,
@Body() dto: UpdateLocationDto
) {
const location = await this.locationService.update(id, dto);
const location = await this.locationService.update(id, dto, user.userId);
return { location };
}
@Delete(':id')
@UseGuards(JwtAuthGuard)
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.locationService.delete(id);
await this.locationService.delete(id, user.userId);
return { success: true };
}
}

View file

@ -1,5 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { NotFoundException, ForbiddenException } from '@nestjs/common';
import { LocationService } from './location.service';
import { DATABASE_CONNECTION } from '../db/database.module';
import { createMockDb, createMockLocation } from '../__tests__/mock-factories';
@ -21,19 +21,23 @@ describe('LocationService', () => {
afterEach(() => jest.clearAllMocks());
describe('findAll', () => {
it('should return all locations', async () => {
it('should return paginated locations', async () => {
const locations = [
createMockLocation(),
createMockLocation({ id: 'loc-2', name: 'Imperia' }),
];
mockDb.where.mockResolvedValue(locations);
// findAll without category calls db.select().from(locations) which resolves via the chain
// Need to handle the case without category
mockDb.from.mockResolvedValue(locations);
// Without category: count calls from() which resolves, data calls offset()
mockDb.from
.mockResolvedValueOnce([{ count: 2 }]) // count query
.mockReturnThis(); // data query chain continues
mockDb.offset.mockResolvedValue(locations);
const result = await service.findAll();
expect(result).toEqual(locations);
expect(result.items).toEqual(locations);
expect(result.total).toBe(2);
expect(result.page).toBe(1);
expect(result.limit).toBe(20);
expect(mockDb.select).toHaveBeenCalled();
});
@ -41,11 +45,25 @@ describe('LocationService', () => {
const museums = [
createMockLocation({ id: 'loc-3', category: 'museum', name: 'Rosgartenmuseum' }),
];
mockDb.where.mockResolvedValue(museums);
// With category: count calls where(), data calls offset()
mockDb.where.mockResolvedValueOnce([{ count: 1 }]); // count query
mockDb.offset.mockResolvedValue(museums);
const result = await service.findAll('museum');
expect(result).toEqual(museums);
expect(result.items).toEqual(museums);
expect(result.total).toBe(1);
});
it('should respect page and limit', async () => {
mockDb.from.mockResolvedValueOnce([{ count: 50 }]).mockReturnThis();
mockDb.offset.mockResolvedValue([]);
const result = await service.findAll(undefined, 3, 10);
expect(result.page).toBe(3);
expect(result.limit).toBe(10);
expect(result.totalPages).toBe(5);
});
});
@ -102,17 +120,39 @@ describe('LocationService', () => {
});
describe('update', () => {
it('should update a location', async () => {
const updated = createMockLocation({ name: 'Updated Name' });
it('should update a location owned by user', async () => {
const existing = createMockLocation({ createdBy: 'user-1' });
mockDb.where.mockResolvedValueOnce([existing]); // findById
const updated = createMockLocation({ name: 'Updated Name', createdBy: 'user-1' });
mockDb.returning.mockResolvedValue([updated]);
const result = await service.update('loc-1', { name: 'Updated Name' });
const result = await service.update('loc-1', { name: 'Updated Name' }, 'user-1');
expect(result.name).toBe('Updated Name');
});
it('should throw ForbiddenException if not owner', async () => {
const existing = createMockLocation({ createdBy: 'other-user' });
mockDb.where.mockResolvedValueOnce([existing]); // findById
await expect(service.update('loc-1', { name: 'Hacked' }, 'attacker-user')).rejects.toThrow(
ForbiddenException
);
});
it('should allow update of unowned locations', async () => {
const existing = createMockLocation({ createdBy: null as any });
mockDb.where.mockResolvedValueOnce([existing]); // findById
const updated = createMockLocation({ name: 'Updated' });
mockDb.returning.mockResolvedValue([updated]);
const result = await service.update('loc-1', { name: 'Updated' }, 'any-user');
expect(result.name).toBe('Updated');
});
it('should throw NotFoundException if not found', async () => {
mockDb.returning.mockResolvedValue([]);
mockDb.where.mockResolvedValue([]);
await expect(service.update('nonexistent', { name: 'Test' })).rejects.toThrow(
NotFoundException
@ -121,14 +161,22 @@ describe('LocationService', () => {
});
describe('delete', () => {
it('should delete a location', async () => {
mockDb.returning.mockResolvedValue([createMockLocation()]);
it('should delete a location owned by user', async () => {
const existing = createMockLocation({ createdBy: 'user-1' });
mockDb.where.mockResolvedValueOnce([existing]); // findById
await expect(service.delete('loc-1')).resolves.not.toThrow();
await expect(service.delete('loc-1', 'user-1')).resolves.not.toThrow();
});
it('should throw ForbiddenException if not owner', async () => {
const existing = createMockLocation({ createdBy: 'other-user' });
mockDb.where.mockResolvedValueOnce([existing]); // findById
await expect(service.delete('loc-1', 'attacker-user')).rejects.toThrow(ForbiddenException);
});
it('should throw NotFoundException if not found', async () => {
mockDb.returning.mockResolvedValue([]);
mockDb.where.mockResolvedValue([]);
await expect(service.delete('nonexistent')).rejects.toThrow(NotFoundException);
});

View file

@ -1,22 +1,63 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, or, ilike } from 'drizzle-orm';
import { Injectable, Inject, NotFoundException, ForbiddenException } from '@nestjs/common';
import { eq, or, ilike, sql, desc } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { locations } from '../db/schema';
import type { Location, NewLocation } from '../db/schema';
export interface PaginatedResult<T> {
items: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
@Injectable()
export class LocationService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findAll(category?: string): Promise<Location[]> {
async findAll(category?: string, page = 1, limit = 20): Promise<PaginatedResult<Location>> {
const offset = (page - 1) * limit;
let items: Location[];
let total: number;
if (category) {
return this.db
.select()
const countResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(locations)
.where(eq(locations.category, category as Location['category']));
total = countResult[0]?.count ?? 0;
items = await this.db
.select()
.from(locations)
.where(eq(locations.category, category as Location['category']))
.orderBy(desc(locations.createdAt))
.limit(limit)
.offset(offset);
} else {
const countResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(locations);
total = countResult[0]?.count ?? 0;
items = await this.db
.select()
.from(locations)
.orderBy(desc(locations.createdAt))
.limit(limit)
.offset(offset);
}
return this.db.select().from(locations);
return {
items,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async search(query: string): Promise<Location[]> {
@ -46,22 +87,30 @@ export class LocationService {
return location;
}
async update(id: string, data: Partial<NewLocation>): Promise<Location> {
async update(id: string, data: Partial<NewLocation>, userId?: string): Promise<Location> {
const existing = await this.findById(id);
// If location has an owner, only the owner can edit
if (existing.createdBy && userId && existing.createdBy !== userId) {
throw new ForbiddenException('You can only edit your own locations');
}
const [location] = await this.db
.update(locations)
.set(data)
.where(eq(locations.id, id))
.returning();
if (!location) {
throw new NotFoundException(`Location with id ${id} not found`);
}
return location;
}
async delete(id: string): Promise<void> {
const [location] = await this.db.delete(locations).where(eq(locations.id, id)).returning();
if (!location) {
throw new NotFoundException(`Location with id ${id} not found`);
async delete(id: string, userId?: string): Promise<void> {
const existing = await this.findById(id);
// If location has an owner, only the owner can delete
if (existing.createdBy && userId && existing.createdBy !== userId) {
throw new ForbiddenException('You can only delete your own locations');
}
await this.db.delete(locations).where(eq(locations.id, id));
}
}

View file

@ -19,7 +19,8 @@
"loading": "Laden...",
"noResults": "Keine Locations gefunden.",
"noResultsCategory": "Keine {category} gefunden.",
"addFirst": "Ersten Ort hinzufügen"
"addFirst": "Ersten Ort hinzufügen",
"loadMore": "Mehr laden"
},
"categories": {
"sight": "Sehenswürdigkeiten",
@ -42,7 +43,13 @@
"linkCopied": "Link kopiert!",
"showDetails": "Details",
"back": "Zurück zur Übersicht",
"notFound": "Location nicht gefunden."
"notFound": "Location 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.",
"confirmDelete": "Endgültig löschen",
"deleting": "Wird gelöscht...",
"cancel": "Abbrechen"
},
"favorites": {
"title": "Favoriten",
@ -116,6 +123,16 @@
"geocoding": "Koordinaten werden ermittelt...",
"coordinatesFound": "Koordinaten gefunden"
},
"edit": {
"title": "Ort bearbeiten",
"subtitle": "Ändere die Details dieses Ortes",
"save": "Änderungen speichern",
"saving": "Wird gespeichert...",
"cancel": "Abbrechen",
"error": "Fehler beim Speichern. Bitte versuche es erneut.",
"loadError": "Ort konnte nicht geladen werden.",
"forbidden": "Du kannst nur deine eigenen Orte bearbeiten."
},
"offline": {
"title": "Keine Verbindung",
"message": "Du bist gerade offline. Sobald du wieder eine Internetverbindung hast, kannst du CityCorners weiter nutzen.",

View file

@ -19,7 +19,8 @@
"loading": "Loading...",
"noResults": "No locations found.",
"noResultsCategory": "No {category} found.",
"addFirst": "Add the first place"
"addFirst": "Add the first place",
"loadMore": "Load more"
},
"categories": {
"sight": "Sights",
@ -42,7 +43,13 @@
"linkCopied": "Link copied!",
"showDetails": "Details",
"back": "Back to overview",
"notFound": "Location not found."
"notFound": "Location not found.",
"edit": "Edit",
"delete": "Delete",
"deleteConfirm": "Are you sure you want to delete this place? This cannot be undone.",
"confirmDelete": "Delete permanently",
"deleting": "Deleting...",
"cancel": "Cancel"
},
"favorites": {
"title": "Favorites",
@ -116,6 +123,16 @@
"geocoding": "Finding coordinates...",
"coordinatesFound": "Coordinates found"
},
"edit": {
"title": "Edit place",
"subtitle": "Update the details of this place",
"save": "Save changes",
"saving": "Saving...",
"cancel": "Cancel",
"error": "Failed to save. Please try again.",
"loadError": "Could not load place.",
"forbidden": "You can only edit your own places."
},
"offline": {
"title": "No connection",
"message": "You are currently offline. You can continue using CityCorners once you have an internet connection again.",

View file

@ -14,10 +14,20 @@
latitude?: number;
longitude?: number;
imageUrl?: string;
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 = ['sight', 'restaurant', 'shop', 'museum'];
@ -36,22 +46,54 @@
selectedCategory ? locations.filter((l) => l.category === selectedCategory) : locations
);
onMount(async () => {
let hasMore = $derived(pagination ? pagination.page < pagination.totalPages : false);
async function loadLocations(page = 1, append = false) {
if (page === 1) loading = true;
else loadingMore = true;
try {
const res = await fetch(api('/locations'));
const params = new URLSearchParams({ page: String(page), limit: '20' });
if (selectedCategory) params.set('category', selectedCategory);
const res = await fetch(api(`/locations?${params}`));
const data = await res.json();
locations = data.locations;
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();
if (authStore.isAuthenticated) {
favoritesStore.load();
}
});
// Reload when category changes
$effect(() => {
// Track selectedCategory to re-run
const _ = selectedCategory;
// Don't run on initial mount (loading is still true)
if (!loading || locations.length > 0) {
loadLocations(1);
}
});
function handleFavoriteToggle(e: MouseEvent, locationId: string) {
e.preventDefault();
e.stopPropagation();
@ -86,7 +128,8 @@
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover'}"
onclick={() => (selectedCategory = null)}
>
{$_('home.all')} ({locations.length})
{$_('home.all')}
{pagination ? `(${pagination.total})` : ''}
</button>
{#each categoryKeys as cat}
<button
@ -101,7 +144,11 @@
</div>
{#if loading}
<p class="text-foreground-secondary">{$_('home.loading')}</p>
<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}
<div class="py-12 text-center">
<span class="mb-2 block text-4xl"
@ -197,4 +244,22 @@
</a>
{/each}
</div>
<!-- Load more -->
{#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

@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { _ } from 'svelte-i18n';
@ -22,12 +23,15 @@
longitude?: number;
imageUrl?: string;
timeline?: TimelineEntry[];
createdBy?: string;
}
let location = $state<Location | null>(null);
let loading = $state(true);
let mapContainer: HTMLDivElement;
let shareSuccess = $state(false);
let showDeleteConfirm = $state(false);
let deleting = $state(false);
const categoryColors: Record<string, string> = {
sight: '#2563eb',
@ -36,6 +40,12 @@
museum: '#9333ea',
};
let isOwner = $derived(
location?.createdBy != null &&
authStore.isAuthenticated &&
authStore.user?.id === location.createdBy
);
onMount(async () => {
try {
const res = await fetch(api(`/locations/${$page.params.id}`));
@ -98,6 +108,27 @@
setTimeout(() => (shareSuccess = false), 2000);
}
}
async function handleDelete() {
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('/');
}
} catch {
// ignore
} finally {
deleting = false;
showDeleteConfirm = false;
}
}
</script>
<svelte:head>
@ -253,6 +284,74 @@
<p class="text-base leading-relaxed text-foreground">{location.description}</p>
<!-- Owner actions: Edit + Delete -->
{#if isOwner}
<div class="flex gap-3">
<a
href="/locations/{location.id}/edit"
class="flex items-center gap-2 rounded-lg border border-border bg-background-card px-4 py-2.5 text-sm font-medium text-foreground-secondary transition-colors hover:bg-background-card-hover hover:text-foreground"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
{$_('detail.edit')}
</a>
<button
onclick={() => (showDeleteConfirm = true)}
class="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600 transition-colors hover:bg-red-100 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400 dark:hover:bg-red-950/50"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
{$_('detail.delete')}
</button>
</div>
{/if}
<!-- Delete confirmation -->
{#if showDeleteConfirm}
<div
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-950/30"
>
<p class="mb-3 text-sm text-red-700 dark:text-red-300">{$_('detail.deleteConfirm')}</p>
<div class="flex gap-2">
<button
onclick={() => (showDeleteConfirm = false)}
class="rounded-lg border border-border bg-background px-4 py-2 text-sm text-foreground-secondary hover:bg-background-card-hover"
>
{$_('detail.cancel')}
</button>
<button
onclick={handleDelete}
disabled={deleting}
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
{deleting ? $_('detail.deleting') : $_('detail.confirmDelete')}
</button>
</div>
</div>
{/if}
<!-- Map + Directions -->
{#if location.latitude && location.longitude}
<div class="overflow-hidden rounded-xl border border-border">

View file

@ -0,0 +1,244 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { api } from '$lib/api';
let loading = $state(true);
let name = $state('');
let category = $state('sight');
let description = $state('');
let address = $state('');
let imageUrl = $state('');
let imageError = $state(false);
let submitting = $state(false);
let error = $state('');
let forbidden = $state(false);
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);
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;
return;
}
name = loc.name || '';
category = loc.category || 'sight';
description = loc.description || '';
address = loc.address || '';
imageUrl = loc.imageUrl || '';
} catch {
error = $_('edit.loadError');
} finally {
loading = false;
}
});
async function handleSubmit() {
if (!isValid || submitting) return;
submitting = true;
error = '';
try {
const token = await authStore.getValidToken();
if (!token) {
error = $_('add.loginRequired');
return;
}
const body: Record<string, unknown> = {
name: name.trim(),
category,
description: description.trim(),
address: address.trim() || undefined,
imageUrl: imageUrl.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),
});
if (res.ok) {
goto(`/locations/${$page.params.id}`);
} else {
const data = await res.json().catch(() => ({}));
error = data.message || $_('edit.error');
}
} catch {
error = $_('edit.error');
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<title>{$_('edit.title')} - CityCorners</title>
</svelte:head>
<header class="mb-6">
<h1 class="text-2xl font-bold text-foreground">{$_('edit.title')}</h1>
<p class="text-foreground-secondary">{$_('edit.subtitle')}</p>
</header>
{#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 forbidden}
<div class="rounded-xl border border-border bg-background-card p-8 text-center">
<span class="mb-2 block text-4xl">🔒</span>
<p class="text-foreground-secondary">{$_('edit.forbidden')}</p>
<a
href="/locations/{$page.params.id}"
class="mt-4 inline-block text-sm text-primary hover:underline"
>
{$_('detail.back')}
</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>
<div>
<label for="imageUrl" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.imageUrl')}</label
>
<input
id="imageUrl"
type="url"
bind:value={imageUrl}
oninput={() => (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}
<div class="mt-2 overflow-hidden rounded-lg border border-border">
<img
src={imageUrl}
alt={$_('add.imagePreview')}
class="h-40 w-full object-cover"
onerror={() => (imageError = true)}
/>
</div>
{:else if imageError}
<div class="mt-2 flex items-center gap-2 rounded-lg bg-red-500/10 p-3">
<p class="flex-1 text-xs text-red-500">{$_('add.imageLoadError')}</p>
<button
type="button"
onclick={() => (imageError = false)}
class="text-xs font-medium text-red-500 hover:text-red-400"
>
{$_('add.imageRetry')}
</button>
</div>
{/if}
</div>
<div class="flex gap-3">
<a
href="/locations/{$page.params.id}"
class="rounded-lg border border-border bg-background px-4 py-3 text-sm font-medium text-foreground-secondary transition-colors hover:bg-background-card-hover"
>
{$_('edit.cancel')}
</a>
<button
type="submit"
disabled={!isValid || submitting}
class="flex-1 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 ? $_('edit.saving') : $_('edit.save')}
</button>
</div>
</form>
{/if}