mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
39526918a3
commit
58fb3e8dff
11 changed files with 651 additions and 58 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue