feat(citycorners): add slugs, contacts, collections, clustering, rate limiting, soft deletes

1. SEO Slugs: Auto-generated from name (ä→ae, ö→oe, etc.), unique with
   -2/-3 suffix. Routes accept both UUID and slug. Seed data includes slugs.

2. Opening Hours + Contact: website, phone, openingHours fields in schema.
   Displayed on detail page, editable in add/edit forms.

3. Landing Page with API: Fetches locations from backend at build time,
   falls back to hardcoded JSON if API unreachable.

4. Frontend Tests: Vitest setup with api.test.ts (50 backend + web tests).

5. Marker Clustering: leaflet.markercluster for 10+ locations on map,
   direct markers for fewer.

6. Favorite Collections: New collections table with CRUD endpoints.
   Favorites page has tabs for favorites vs collections. Create, view,
   delete collections with location management.

7. Rate Limiting: In-memory guard (10 req/min) on write endpoints.
   Returns 429 with retryAfter.

8. Soft Deletes: deletedAt field, all reads filter deleted records.
   POST /locations/:id/restore endpoint for owners.

50 backend tests passing, 0 type errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-24 12:27:29 +01:00
parent 06694eac19
commit 94d7e2bd02
28 changed files with 1736 additions and 558 deletions

View file

@ -1,5 +1,6 @@
import type { Location } from '../db/schema/locations.schema';
import type { Favorite } from '../db/schema/favorites.schema';
import type { Collection } from '../db/schema/collections.schema';
export const TEST_USER_ID = 'test-user-123';
export const TEST_USER_EMAIL = 'test@example.com';
@ -8,6 +9,7 @@ export function createMockLocation(overrides: Partial<Location> = {}): Location
return {
id: 'loc-1',
name: 'Konstanzer Münster',
slug: 'konstanzer-muenster',
category: 'sight',
description: 'Historic cathedral in Konstanz.',
address: 'Münsterplatz 1, 78462 Konstanz',
@ -16,9 +18,13 @@ export function createMockLocation(overrides: Partial<Location> = {}): Location
imageUrl: '/images/muenster.svg',
images: [],
timeline: [{ year: '615', event: 'Founded' }],
website: null,
phone: null,
openingHours: null,
createdBy: null,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
deletedAt: null,
...overrides,
};
}
@ -33,6 +39,19 @@ export function createMockFavorite(overrides: Partial<Favorite> = {}): Favorite
};
}
export function createMockCollection(overrides: Partial<Collection> = {}): Collection {
return {
id: 'col-1',
userId: TEST_USER_ID,
name: 'My Favorites',
description: null,
locationIds: [],
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
...overrides,
};
}
export function createMockDb() {
return {
select: jest.fn().mockReturnThis(),

View file

@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './db/database.module';
import { LocationModule } from './location/location.module';
import { FavoriteModule } from './favorite/favorite.module';
import { CollectionModule } from './collection/collection.module';
import { HealthModule } from '@manacore/shared-nestjs-health';
import { MetricsModule } from '@manacore/shared-nestjs-metrics';
@ -15,6 +16,7 @@ import { MetricsModule } from '@manacore/shared-nestjs-metrics';
DatabaseModule,
LocationModule,
FavoriteModule,
CollectionModule,
HealthModule.forRoot({ serviceName: 'citycorners-backend' }),
MetricsModule.register({
prefix: 'citycorners_',

View file

@ -0,0 +1,82 @@
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { CollectionService } from './collection.service';
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
class CreateCollectionDto {
@IsString()
@IsNotEmpty()
name!: string;
@IsString()
@IsOptional()
description?: string;
}
class UpdateCollectionDto {
@IsString()
@IsOptional()
name?: string;
@IsString()
@IsOptional()
description?: string;
}
@Controller('collections')
@UseGuards(JwtAuthGuard)
export class CollectionController {
constructor(private readonly collectionService: CollectionService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData) {
const collections = await this.collectionService.findByUserId(user.userId);
return { collections };
}
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateCollectionDto) {
const collection = await this.collectionService.create({
name: dto.name,
description: dto.description,
userId: user.userId,
});
return { collection };
}
@Put(':id')
async update(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: UpdateCollectionDto
) {
const collection = await this.collectionService.update(id, dto, user.userId);
return { collection };
}
@Delete(':id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.collectionService.delete(id, user.userId);
return { success: true };
}
@Post(':id/locations/:locationId')
async addLocation(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Param('locationId') locationId: string
) {
const collection = await this.collectionService.addLocation(id, locationId, user.userId);
return { collection };
}
@Delete(':id/locations/:locationId')
async removeLocation(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Param('locationId') locationId: string
) {
const collection = await this.collectionService.removeLocation(id, locationId, user.userId);
return { collection };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CollectionController } from './collection.controller';
import { CollectionService } from './collection.service';
@Module({
controllers: [CollectionController],
providers: [CollectionService],
exports: [CollectionService],
})
export class CollectionModule {}

View file

@ -0,0 +1,87 @@
import { Injectable, Inject, NotFoundException, ForbiddenException } from '@nestjs/common';
import { eq, and } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { collections } from '../db/schema';
import type { Collection, NewCollection } from '../db/schema';
@Injectable()
export class CollectionService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findByUserId(userId: string): Promise<Collection[]> {
return this.db.select().from(collections).where(eq(collections.userId, userId));
}
async findById(id: string, userId: string): Promise<Collection> {
const [collection] = await this.db
.select()
.from(collections)
.where(and(eq(collections.id, id), eq(collections.userId, userId)));
if (!collection) {
throw new NotFoundException(`Collection with id ${id} not found`);
}
return collection;
}
async create(data: { name: string; description?: string; userId: string }): Promise<Collection> {
const [collection] = await this.db
.insert(collections)
.values({
name: data.name,
description: data.description,
userId: data.userId,
locationIds: [],
})
.returning();
return collection;
}
async update(
id: string,
data: { name?: string; description?: string },
userId: string
): Promise<Collection> {
const existing = await this.findById(id, userId);
const [updated] = await this.db
.update(collections)
.set(data)
.where(eq(collections.id, id))
.returning();
return updated;
}
async delete(id: string, userId: string): Promise<void> {
await this.findById(id, userId);
await this.db.delete(collections).where(eq(collections.id, id));
}
async addLocation(id: string, locationId: string, userId: string): Promise<Collection> {
const collection = await this.findById(id, userId);
const currentIds: string[] = (collection.locationIds as string[]) || [];
if (currentIds.includes(locationId)) {
return collection;
}
const [updated] = await this.db
.update(collections)
.set({ locationIds: [...currentIds, locationId] })
.where(eq(collections.id, id))
.returning();
return updated;
}
async removeLocation(id: string, locationId: string, userId: string): Promise<Collection> {
const collection = await this.findById(id, userId);
const currentIds: string[] = (collection.locationIds as string[]) || [];
const [updated] = await this.db
.update(collections)
.set({ locationIds: currentIds.filter((lid) => lid !== locationId) })
.where(eq(collections.id, id))
.returning();
return updated;
}
}

View file

@ -0,0 +1,17 @@
import { pgTable, uuid, text, timestamp, jsonb } from 'drizzle-orm/pg-core';
export const collections = pgTable('collections', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
name: text('name').notNull(),
description: text('description'),
locationIds: jsonb('location_ids').$type<string[]>().default([]),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
export type Collection = typeof collections.$inferSelect;
export type NewCollection = typeof collections.$inferInsert;

View file

@ -1,2 +1,3 @@
export * from './locations.schema';
export * from './favorites.schema';
export * from './collections.schema';

View file

@ -10,9 +10,12 @@ import {
export const categoryEnum = pgEnum('location_category', ['sight', 'restaurant', 'shop', 'museum']);
export type OpeningHours = Record<string, string>;
export const locations = pgTable('locations', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
slug: text('slug').unique(),
category: categoryEnum('category').notNull(),
description: text('description').notNull(),
address: text('address'),
@ -21,12 +24,16 @@ export const locations = pgTable('locations', {
imageUrl: text('image_url'),
images: jsonb('images').$type<LocationImage[]>().default([]),
timeline: jsonb('timeline').$type<TimelineEntry[]>().default([]),
website: text('website'),
phone: text('phone'),
openingHours: jsonb('opening_hours').$type<OpeningHours>(),
createdBy: text('created_by'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
});
export interface LocationImage {

View file

@ -17,6 +17,7 @@ async function seed() {
await db.insert(locations).values([
{
name: 'Konstanzer Münster',
slug: 'konstanzer-muenster',
category: 'sight',
description:
'Das Konstanzer Münster ist eine römisch-katholische Basilika in der Altstadt von Konstanz. Der Bau begann im Jahr 615 und wurde im Laufe der Jahrhunderte mehrmals erweitert.',
@ -32,6 +33,7 @@ async function seed() {
},
{
name: 'Imperia',
slug: 'imperia',
category: 'sight',
description:
'Die Imperia ist eine satirische Skulptur des Bildhauers Peter Lenk im Hafen von Konstanz. Sie dreht sich langsam um die eigene Achse.',
@ -43,6 +45,7 @@ async function seed() {
},
{
name: 'Restaurant Ophelia',
slug: 'restaurant-ophelia',
category: 'restaurant',
description:
'Fine-Dining-Restaurant im Riva-Gebäude am Konstanzer Hafen mit Blick auf den Bodensee.',
@ -53,6 +56,7 @@ async function seed() {
},
{
name: 'LAGO Shopping Center',
slug: 'lago-shopping-center',
category: 'shop',
description: 'Großes Einkaufszentrum in der Konstanzer Innenstadt mit über 80 Geschäften.',
address: 'Bodanstraße 1, 78462 Konstanz',
@ -62,6 +66,7 @@ async function seed() {
},
{
name: 'Rosgartenmuseum',
slug: 'rosgartenmuseum',
category: 'museum',
description:
'Das Rosgartenmuseum zeigt die Geschichte der Stadt Konstanz und der Bodenseeregion.',
@ -71,6 +76,7 @@ async function seed() {
},
{
name: 'Archäologisches Landesmuseum',
slug: 'archaeologisches-landesmuseum',
category: 'museum',
description: 'Landesmuseum für Archäologie in Baden-Württemberg mit Funden aus der Region.',
address: 'Benediktinerplatz 5, 78467 Konstanz',

View file

@ -0,0 +1,68 @@
import {
Injectable,
CanActivate,
ExecutionContext,
HttpException,
HttpStatus,
} from '@nestjs/common';
interface RequestRecord {
count: number;
resetAt: number;
}
@Injectable()
export class RateLimitGuard implements CanActivate {
private readonly requests = new Map<string, RequestRecord>();
private readonly maxRequests = 10;
private readonly windowMs = 60_000; // 1 minute
private cleanupInterval: ReturnType<typeof setInterval>;
constructor() {
// Clean up old entries every 5 minutes
this.cleanupInterval = setInterval(() => this.cleanup(), 5 * 60_000);
this.cleanupInterval.unref();
}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const ip =
request.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
request.ip ||
request.connection?.remoteAddress ||
'unknown';
const now = Date.now();
const record = this.requests.get(ip);
if (!record || now > record.resetAt) {
this.requests.set(ip, { count: 1, resetAt: now + this.windowMs });
return true;
}
record.count++;
if (record.count > this.maxRequests) {
const retryAfter = Math.ceil((record.resetAt - now) / 1000);
throw new HttpException(
{
statusCode: HttpStatus.TOO_MANY_REQUESTS,
message: 'Too many requests. Please try again later.',
retryAfter,
},
HttpStatus.TOO_MANY_REQUESTS
);
}
return true;
}
private cleanup() {
const now = Date.now();
for (const [ip, record] of this.requests) {
if (now > record.resetAt) {
this.requests.delete(ip);
}
}
}
}

View file

@ -12,10 +12,13 @@ describe('LocationController', () => {
locationService = {
findAll: jest.fn(),
findById: jest.fn(),
findBySlug: jest.fn(),
findByIdOrSlug: jest.fn(),
search: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
restore: jest.fn(),
};
lookupService = {
lookup: jest.fn(),
@ -57,6 +60,18 @@ describe('LocationController', () => {
});
});
describe('findById', () => {
it('should use findByIdOrSlug', async () => {
const location = createMockLocation();
locationService.findByIdOrSlug.mockResolvedValue(location);
const result = await controller.findById('konstanzer-muenster');
expect(locationService.findByIdOrSlug).toHaveBeenCalledWith('konstanzer-muenster');
expect(result).toEqual({ location });
});
});
describe('search', () => {
it('should search locations', async () => {
const locations = [createMockLocation()];
@ -142,4 +157,16 @@ describe('LocationController', () => {
expect(locationService.delete).toHaveBeenCalledWith('loc-1', TEST_USER_ID);
});
});
describe('restore', () => {
it('should restore a soft-deleted location', async () => {
const location = createMockLocation();
locationService.restore.mockResolvedValue(location);
const result = await controller.restore(mockUser, 'loc-1');
expect(result).toEqual({ location });
expect(locationService.restore).toHaveBeenCalledWith('loc-1', TEST_USER_ID);
});
});
});

View file

@ -2,8 +2,10 @@ import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } fro
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { LocationService } from './location.service';
import { LocationLookupService } from './location-lookup.service';
import { IsString, IsNotEmpty, IsOptional, IsNumber } from 'class-validator';
import { RateLimitGuard } from '../guards/rate-limit.guard';
import { IsString, IsNotEmpty, IsOptional, IsNumber, IsObject } from 'class-validator';
import { Type } from 'class-transformer';
import type { OpeningHours } from '../db/schema/locations.schema';
class CreateLocationDto {
@IsString()
@ -35,6 +37,18 @@ class CreateLocationDto {
@IsString()
@IsOptional()
imageUrl?: string;
@IsString()
@IsOptional()
website?: string;
@IsString()
@IsOptional()
phone?: string;
@IsObject()
@IsOptional()
openingHours?: OpeningHours;
}
class UpdateLocationDto {
@ -67,6 +81,18 @@ class UpdateLocationDto {
@IsString()
@IsOptional()
imageUrl?: string;
@IsString()
@IsOptional()
website?: string;
@IsString()
@IsOptional()
phone?: string;
@IsObject()
@IsOptional()
openingHours?: OpeningHours;
}
@Controller('locations')
@ -126,7 +152,7 @@ export class LocationController {
@Get(':id')
async findById(@Param('id') id: string) {
const location = await this.locationService.findById(id);
const location = await this.locationService.findByIdOrSlug(id);
return { location };
}
@ -149,7 +175,7 @@ export class LocationController {
}
@Post()
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, RateLimitGuard)
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateLocationDto) {
const location = await this.locationService.create({
...dto,
@ -159,7 +185,7 @@ export class LocationController {
}
@Put(':id')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, RateLimitGuard)
async update(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@ -170,9 +196,16 @@ export class LocationController {
}
@Delete(':id')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, RateLimitGuard)
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.locationService.delete(id, user.userId);
return { success: true };
}
@Post(':id/restore')
@UseGuards(JwtAuthGuard)
async restore(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
const location = await this.locationService.restore(id, user.userId);
return { location };
}
}

View file

@ -1,6 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException, ForbiddenException } from '@nestjs/common';
import { LocationService } from './location.service';
import { LocationService, generateSlug } from './location.service';
import { DATABASE_CONNECTION } from '../db/database.module';
import { createMockDb, createMockLocation } from '../__tests__/mock-factories';
@ -20,16 +20,36 @@ describe('LocationService', () => {
afterEach(() => jest.clearAllMocks());
describe('generateSlug', () => {
it('should convert name to slug', () => {
expect(generateSlug('Konstanzer Münster')).toBe('konstanzer-muenster');
});
it('should replace umlauts', () => {
expect(generateSlug('Über den Flüssen')).toBe('ueber-den-fluessen');
});
it('should replace ß with ss', () => {
expect(generateSlug('Große Straße')).toBe('grosse-strasse');
});
it('should deduplicate hyphens', () => {
expect(generateSlug('Name -- with --- hyphens')).toBe('name-with-hyphens');
});
it('should trim leading/trailing hyphens', () => {
expect(generateSlug(' Hello World ')).toBe('hello-world');
});
});
describe('findAll', () => {
it('should return paginated locations', async () => {
const locations = [
createMockLocation(),
createMockLocation({ id: 'loc-2', name: 'Imperia' }),
];
// Without category: count calls from() which resolves, data calls offset()
mockDb.from
.mockResolvedValueOnce([{ count: 2 }]) // count query
.mockReturnThis(); // data query chain continues
// Without category: count query calls where() (for notDeleted filter), data calls offset()
mockDb.where.mockResolvedValueOnce([{ count: 2 }]); // count query
mockDb.offset.mockResolvedValue(locations);
const result = await service.findAll();
@ -56,7 +76,7 @@ describe('LocationService', () => {
});
it('should respect page and limit', async () => {
mockDb.from.mockResolvedValueOnce([{ count: 50 }]).mockReturnThis();
mockDb.where.mockResolvedValueOnce([{ count: 50 }]);
mockDb.offset.mockResolvedValue([]);
const result = await service.findAll(undefined, 3, 10);
@ -84,6 +104,43 @@ describe('LocationService', () => {
});
});
describe('findBySlug', () => {
it('should return a location by slug', async () => {
const location = createMockLocation();
mockDb.where.mockResolvedValue([location]);
const result = await service.findBySlug('konstanzer-muenster');
expect(result).toEqual(location);
});
it('should throw NotFoundException if slug not found', async () => {
mockDb.where.mockResolvedValue([]);
await expect(service.findBySlug('nonexistent-slug')).rejects.toThrow(NotFoundException);
});
});
describe('findByIdOrSlug', () => {
it('should call findById for UUID', async () => {
const location = createMockLocation();
mockDb.where.mockResolvedValue([location]);
const result = await service.findByIdOrSlug('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
expect(result).toEqual(location);
});
it('should call findBySlug for non-UUID', async () => {
const location = createMockLocation();
mockDb.where.mockResolvedValue([location]);
const result = await service.findByIdOrSlug('konstanzer-muenster');
expect(result).toEqual(location);
});
});
describe('search', () => {
it('should search locations by query', async () => {
const locations = [createMockLocation()];
@ -104,8 +161,10 @@ describe('LocationService', () => {
});
describe('create', () => {
it('should create a new location', async () => {
it('should create a new location with auto-generated slug', async () => {
const newLocation = createMockLocation({ id: 'loc-new' });
// generateUniqueSlug: check existing slug
mockDb.where.mockResolvedValueOnce([]); // no existing slug
mockDb.returning.mockResolvedValue([newLocation]);
const result = await service.create({
@ -161,11 +220,15 @@ describe('LocationService', () => {
});
describe('delete', () => {
it('should delete a location owned by user', async () => {
it('should soft delete a location owned by user', async () => {
const existing = createMockLocation({ createdBy: 'user-1' });
mockDb.where.mockResolvedValueOnce([existing]); // findById
mockDb.where
.mockResolvedValueOnce([existing]) // findById
.mockReturnThis(); // update where
await expect(service.delete('loc-1', 'user-1')).resolves.not.toThrow();
expect(mockDb.update).toHaveBeenCalled();
expect(mockDb.set).toHaveBeenCalled();
});
it('should throw ForbiddenException if not owner', async () => {
@ -181,4 +244,36 @@ describe('LocationService', () => {
await expect(service.delete('nonexistent')).rejects.toThrow(NotFoundException);
});
});
describe('restore', () => {
it('should restore a soft-deleted location', async () => {
const deleted = createMockLocation({
createdBy: 'user-1',
deletedAt: new Date(),
});
mockDb.where.mockResolvedValueOnce([deleted]); // find
const restored = createMockLocation({ createdBy: 'user-1', deletedAt: null });
mockDb.returning.mockResolvedValue([restored]);
const result = await service.restore('loc-1', 'user-1');
expect(result.deletedAt).toBeNull();
});
it('should throw ForbiddenException if not owner', async () => {
const deleted = createMockLocation({
createdBy: 'other-user',
deletedAt: new Date(),
});
mockDb.where.mockResolvedValueOnce([deleted]);
await expect(service.restore('loc-1', 'attacker-user')).rejects.toThrow(ForbiddenException);
});
it('should throw NotFoundException if not found', async () => {
mockDb.where.mockResolvedValue([]);
await expect(service.restore('nonexistent')).rejects.toThrow(NotFoundException);
});
});
});

View file

@ -1,5 +1,5 @@
import { Injectable, Inject, NotFoundException, ForbiddenException } from '@nestjs/common';
import { eq, or, ilike, sql, desc, ne, and, isNotNull } from 'drizzle-orm';
import { eq, or, ilike, sql, desc, ne, and, isNotNull, isNull } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { locations } from '../db/schema';
@ -13,10 +13,26 @@ export interface PaginatedResult<T> {
totalPages: number;
}
export function generateSlug(name: string): string {
return name
.toLowerCase()
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@Injectable()
export class LocationService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
private notDeleted = isNull(locations.deletedAt);
async findAll(category?: string, page = 1, limit = 20): Promise<PaginatedResult<Location>> {
const offset = (page - 1) * limit;
@ -27,25 +43,27 @@ export class LocationService {
const countResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(locations)
.where(eq(locations.category, category as Location['category']));
.where(and(eq(locations.category, category as Location['category']), this.notDeleted));
total = countResult[0]?.count ?? 0;
items = await this.db
.select()
.from(locations)
.where(eq(locations.category, category as Location['category']))
.where(and(eq(locations.category, category as Location['category']), this.notDeleted))
.orderBy(desc(locations.createdAt))
.limit(limit)
.offset(offset);
} else {
const countResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(locations);
.from(locations)
.where(this.notDeleted);
total = countResult[0]?.count ?? 0;
items = await this.db
.select()
.from(locations)
.where(this.notDeleted)
.orderBy(desc(locations.createdAt))
.limit(limit)
.offset(offset);
@ -66,27 +84,73 @@ export class LocationService {
.select()
.from(locations)
.where(
or(
ilike(locations.name, pattern),
ilike(locations.description, pattern),
ilike(locations.address, pattern)
and(
or(
ilike(locations.name, pattern),
ilike(locations.description, pattern),
ilike(locations.address, pattern)
),
this.notDeleted
)
);
}
async findById(id: string): Promise<Location> {
const [location] = await this.db.select().from(locations).where(eq(locations.id, id));
const [location] = await this.db
.select()
.from(locations)
.where(and(eq(locations.id, id), this.notDeleted));
if (!location) {
throw new NotFoundException(`Location with id ${id} not found`);
}
return location;
}
async create(data: NewLocation): Promise<Location> {
const [location] = await this.db.insert(locations).values(data).returning();
async findBySlug(slug: string): Promise<Location> {
const [location] = await this.db
.select()
.from(locations)
.where(and(eq(locations.slug, slug), this.notDeleted));
if (!location) {
throw new NotFoundException(`Location with slug ${slug} not found`);
}
return location;
}
async findByIdOrSlug(idOrSlug: string): Promise<Location> {
if (UUID_PATTERN.test(idOrSlug)) {
return this.findById(idOrSlug);
}
return this.findBySlug(idOrSlug);
}
async create(data: NewLocation): Promise<Location> {
const slug = await this.generateUniqueSlug(data.name);
const [location] = await this.db
.insert(locations)
.values({ ...data, slug })
.returning();
return location;
}
private async generateUniqueSlug(name: string): Promise<string> {
const baseSlug = generateSlug(name);
let slug = baseSlug;
let counter = 1;
while (true) {
const [existing] = await this.db
.select({ id: locations.id })
.from(locations)
.where(eq(locations.slug, slug));
if (!existing) break;
counter++;
slug = `${baseSlug}-${counter}`;
}
return slug;
}
async update(id: string, data: Partial<NewLocation>, userId?: string): Promise<Location> {
const existing = await this.findById(id);
@ -126,7 +190,12 @@ export class LocationService {
})
.from(locations)
.where(
and(ne(locations.id, id), isNotNull(locations.latitude), isNotNull(locations.longitude))
and(
ne(locations.id, id),
isNotNull(locations.latitude),
isNotNull(locations.longitude),
this.notDeleted
)
)
.orderBy(haversine)
.limit(limit);
@ -166,7 +235,7 @@ export class LocationService {
const results = await this.db
.select({ id: locations.id, name: locations.name, category: locations.category })
.from(locations)
.where(ilike(locations.name, pattern))
.where(and(ilike(locations.name, pattern), this.notDeleted))
.limit(limit);
return results;
}
@ -179,6 +248,26 @@ export class LocationService {
throw new ForbiddenException('You can only delete your own locations');
}
await this.db.delete(locations).where(eq(locations.id, id));
// Soft delete
await this.db.update(locations).set({ deletedAt: new Date() }).where(eq(locations.id, id));
}
async restore(id: string, userId?: string): Promise<Location> {
// Find including soft-deleted
const [existing] = await this.db.select().from(locations).where(eq(locations.id, id));
if (!existing) {
throw new NotFoundException(`Location with id ${id} not found`);
}
if (existing.createdBy && userId && existing.createdBy !== userId) {
throw new ForbiddenException('You can only restore your own locations');
}
const [restored] = await this.db
.update(locations)
.set({ deletedAt: null })
.where(eq(locations.id, id))
.returning();
return restored;
}
}

View file

@ -1,7 +1,7 @@
---
interface Props {
location: {
id: number;
id: number | string;
name: string;
category: string;
description: string;

View file

@ -2,7 +2,39 @@
import Layout from '../layouts/Layout.astro';
import LocationCard from '../components/LocationCard.astro';
import Filter from '../components/Filter.astro';
import locations from '../data/locations.json';
import fallbackLocations from '../data/locations.json';
const BACKEND_URL = import.meta.env.BACKEND_URL || 'http://localhost:3025';
let locations = fallbackLocations;
try {
const res = await fetch(`${BACKEND_URL}/api/v1/locations?limit=100`);
if (res.ok) {
const data = await res.json();
// Map API response to the shape expected by LocationCard
const categoryMap: Record<string, string> = {
sight: 'Sehenswürdigkeit',
restaurant: 'Restaurant',
shop: 'Laden',
museum: 'Museum',
};
locations = data.locations.map((loc: any, index: number) => ({
id: loc.slug || loc.id,
name: loc.name,
category: categoryMap[loc.category] || loc.category,
description: loc.description,
image: loc.imageUrl || '/images/placeholder.jpg',
address: loc.address,
coordinates:
loc.latitude && loc.longitude ? { lat: loc.latitude, lng: loc.longitude } : undefined,
timeline: loc.timeline?.map((t: any) => ({ year: t.year, description: t.event })),
}));
}
} catch (e) {
// Fallback to hardcoded JSON if API is unreachable
console.warn('Could not fetch from API, using fallback data:', e);
}
---
<Layout>

View file

@ -1,19 +1,15 @@
---
import Layout from '../../layouts/Layout.astro';
import locations from '../../data/locations.json';
import fallbackLocations from '../../data/locations.json';
export async function getStaticPaths() {
return locations.map((location) => ({
params: { id: location.id.toString() },
}));
}
const BACKEND_URL = import.meta.env.BACKEND_URL || 'http://localhost:3025';
const { id } = Astro.params;
const location = locations.find((loc) => loc.id.toString() === id);
if (!location) {
return Astro.redirect('/');
}
const categoryMap: Record<string, string> = {
sight: 'Sehenswürdigkeit',
restaurant: 'Restaurant',
shop: 'Laden',
museum: 'Museum',
};
const categoryColors: Record<string, string> = {
Sehenswürdigkeit: 'bg-blue-100 text-blue-700',
@ -21,6 +17,68 @@ const categoryColors: Record<string, string> = {
Laden: 'bg-green-100 text-green-700',
Museum: 'bg-purple-100 text-purple-700',
};
function mapApiLocation(loc: any) {
return {
id: loc.slug || loc.id,
name: loc.name,
category: categoryMap[loc.category] || loc.category,
description: loc.description,
image: loc.imageUrl || '/images/placeholder.jpg',
address: loc.address,
coordinates:
loc.latitude && loc.longitude ? { lat: loc.latitude, lng: loc.longitude } : undefined,
timeline: loc.timeline?.map((t: any) => ({ year: t.year, description: t.event })),
};
}
let allLocations: any[] = [];
try {
const res = await fetch(`${BACKEND_URL}/api/v1/locations?limit=100`);
if (res.ok) {
const data = await res.json();
allLocations = data.locations.map(mapApiLocation);
} else {
allLocations = fallbackLocations;
}
} catch (e) {
console.warn('Could not fetch from API, using fallback data:', e);
allLocations = fallbackLocations;
}
export async function getStaticPaths() {
let locations: any[] = [];
try {
const backendUrl = import.meta.env.BACKEND_URL || 'http://localhost:3025';
const res = await fetch(`${backendUrl}/api/v1/locations?limit=100`);
if (res.ok) {
const data = await res.json();
locations = data.locations.map((loc: any) => ({
id: loc.slug || loc.id,
}));
}
} catch (e) {
// Fallback
}
if (locations.length === 0) {
const fallback = await import('../../data/locations.json');
locations = fallback.default.map((loc) => ({ id: loc.id.toString() }));
}
return locations.map((loc) => ({
params: { id: loc.id.toString() },
}));
}
const { id } = Astro.params;
const location = allLocations.find((loc) => loc.id.toString() === id);
if (!location) {
return Astro.redirect('/');
}
---
<Layout title={`${location.name} CityCorners`}>

View file

@ -27,7 +27,8 @@
"tailwindcss": "^4.1.7",
"tslib": "^2.4.1",
"typescript": "^5.9.3",
"vite": "^6.0.0"
"vite": "^6.0.0",
"vitest": "^4.1.1"
},
"dependencies": {
"@manacore/shared-auth": "workspace:*",

View file

@ -0,0 +1,52 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock $app/environment
vi.mock('$app/environment', () => ({
browser: true,
}));
describe('api utilities', () => {
beforeEach(() => {
vi.resetModules();
// Clear any window overrides
delete (window as any).__PUBLIC_BACKEND_URL__;
});
describe('getBackendUrl', () => {
it('should use injected URL if available', async () => {
(window as any).__PUBLIC_BACKEND_URL__ = 'https://api.example.com';
const { getBackendUrl } = await import('./api');
expect(getBackendUrl()).toBe('https://api.example.com');
});
it('should default to localhost:3025 in dev mode', async () => {
const { getBackendUrl } = await import('./api');
// When no injected URL and import.meta.env.DEV is true
const url = getBackendUrl();
// Either localhost or empty string depending on env
expect(typeof url).toBe('string');
});
});
describe('api', () => {
it('should prepend backend URL and /api/v1 prefix', async () => {
(window as any).__PUBLIC_BACKEND_URL__ = 'https://api.example.com';
const { api } = await import('./api');
expect(api('/locations')).toBe('https://api.example.com/api/v1/locations');
});
it('should handle paths with query params', async () => {
(window as any).__PUBLIC_BACKEND_URL__ = 'https://api.example.com';
const { api } = await import('./api');
expect(api('/locations?category=sight')).toBe(
'https://api.example.com/api/v1/locations?category=sight'
);
});
it('should handle path with id parameter', async () => {
(window as any).__PUBLIC_BACKEND_URL__ = 'https://api.example.com';
const { api } = await import('./api');
expect(api('/locations/abc-123')).toBe('https://api.example.com/api/v1/locations/abc-123');
});
});
});

View file

@ -50,7 +50,20 @@
"confirmDelete": "Endgültig löschen",
"deleting": "Wird gelöscht...",
"cancel": "Abbrechen",
"nearby": "In der Nähe"
"nearby": "In der Nähe",
"website": "Webseite",
"phone": "Telefon",
"openingHours": "Öffnungszeiten",
"closed": "Geschlossen"
},
"days": {
"mo": "Montag",
"tu": "Dienstag",
"we": "Mittwoch",
"th": "Donnerstag",
"fr": "Freitag",
"sa": "Samstag",
"su": "Sonntag"
},
"gallery": {
"addPhoto": "Foto hinzufügen",
@ -63,7 +76,25 @@
"empty": "Noch keine Favoriten. Tippe auf das Herz bei einer Location, um sie zu speichern.",
"loginRequired": "Melde dich an, um Favoriten zu speichern.",
"add": "Zu Favoriten hinzufügen",
"remove": "Aus Favoriten entfernen"
"remove": "Aus Favoriten entfernen",
"tabFavorites": "Favoriten",
"tabCollections": "Sammlungen"
},
"collections": {
"title": "Sammlungen",
"empty": "Noch keine Sammlungen erstellt.",
"create": "Sammlung erstellen",
"name": "Name",
"namePlaceholder": "z.B. Meine Lieblingsrestaurants",
"description": "Beschreibung (optional)",
"descriptionPlaceholder": "Worum geht es in dieser Sammlung?",
"save": "Speichern",
"cancel": "Abbrechen",
"locations": "{count} Orte",
"noLocations": "Keine Orte in dieser Sammlung.",
"delete": "Sammlung löschen",
"deleteConfirm": "Bist du sicher, dass du diese Sammlung löschen möchtest?",
"back": "Zurück zu Sammlungen"
},
"map": {
"title": "Karte",
@ -127,7 +158,11 @@
"imageLoadError": "Bild konnte nicht geladen werden.",
"imageRetry": "Erneut versuchen",
"geocoding": "Koordinaten werden ermittelt...",
"coordinatesFound": "Koordinaten gefunden"
"coordinatesFound": "Koordinaten gefunden",
"website": "Webseite (optional)",
"websitePlaceholder": "https://example.com",
"phone": "Telefon (optional)",
"phonePlaceholder": "+49 7531 12345"
},
"edit": {
"title": "Ort bearbeiten",

View file

@ -50,7 +50,20 @@
"confirmDelete": "Delete permanently",
"deleting": "Deleting...",
"cancel": "Cancel",
"nearby": "Nearby"
"nearby": "Nearby",
"website": "Website",
"phone": "Phone",
"openingHours": "Opening hours",
"closed": "Closed"
},
"days": {
"mo": "Monday",
"tu": "Tuesday",
"we": "Wednesday",
"th": "Thursday",
"fr": "Friday",
"sa": "Saturday",
"su": "Sunday"
},
"gallery": {
"addPhoto": "Add photo",
@ -63,7 +76,25 @@
"empty": "No favorites yet. Tap the heart on a location to save it.",
"loginRequired": "Sign in to save favorites.",
"add": "Add to favorites",
"remove": "Remove from favorites"
"remove": "Remove from favorites",
"tabFavorites": "Favorites",
"tabCollections": "Collections"
},
"collections": {
"title": "Collections",
"empty": "No collections created yet.",
"create": "Create collection",
"name": "Name",
"namePlaceholder": "e.g. My favorite restaurants",
"description": "Description (optional)",
"descriptionPlaceholder": "What is this collection about?",
"save": "Save",
"cancel": "Cancel",
"locations": "{count} places",
"noLocations": "No places in this collection.",
"delete": "Delete collection",
"deleteConfirm": "Are you sure you want to delete this collection?",
"back": "Back to collections"
},
"map": {
"title": "Map",
@ -103,16 +134,16 @@
"title": "Add a place",
"subtitle": "Share your favorite spot in Konstanz",
"name": "Name",
"namePlaceholder": "e.g. Lakeside Café",
"namePlaceholder": "e.g. Lakeside Cafe",
"category": "Category",
"description": "Description",
"descriptionPlaceholder": "What makes this place special?",
"minChars": "At least 10 characters",
"address": "Address (optional)",
"addressPlaceholder": "e.g. Seestraße 1, 78462 Konstanz",
"addressPlaceholder": "e.g. Seestrasse 1, 78462 Konstanz",
"searchTitle": "Search for a place online",
"searchSubtitle": "We'll automatically find info and pre-fill the form for you.",
"searchPlaceholder": "e.g. Café Zeitlos Konstanz",
"searchPlaceholder": "e.g. Cafe Zeitlos Konstanz",
"searchButton": "Search",
"skipSearch": "Skip and enter manually",
"foundSources": "Sources found:",
@ -127,7 +158,11 @@
"imageLoadError": "Image could not be loaded.",
"imageRetry": "Retry",
"geocoding": "Finding coordinates...",
"coordinatesFound": "Coordinates found"
"coordinatesFound": "Coordinates found",
"website": "Website (optional)",
"websitePlaceholder": "https://example.com",
"phone": "Phone (optional)",
"phonePlaceholder": "+49 7531 12345"
},
"edit": {
"title": "Edit place",

View file

@ -7,6 +7,7 @@
interface Location {
id: string;
slug?: string;
name: string;
category: string;
description: string;
@ -179,7 +180,7 @@
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{#each filtered as location}
<a
href="/locations/{location.id}"
href="/locations/{location.slug || location.id}"
class="group relative overflow-hidden rounded-xl border border-border bg-background-card transition-shadow hover:shadow-lg"
>
{#if location.imageUrl}

View file

@ -18,6 +18,8 @@
let imageUrl = $state('');
let latitude = $state<number | undefined>(undefined);
let longitude = $state<number | undefined>(undefined);
let website = $state('');
let phone = $state('');
let submitting = $state(false);
let error = $state('');
let geocoding = $state(false);
@ -89,6 +91,8 @@
description = '';
address = '';
imageUrl = '';
website = '';
phone = '';
category = 'sight';
latitude = undefined;
longitude = undefined;
@ -150,6 +154,8 @@
};
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;
@ -371,6 +377,34 @@
{/if}
</div>
<!-- Website -->
<div>
<label for="website" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.website')}</label
>
<input
id="website"
type="url"
bind:value={website}
placeholder={$_('add.websitePlaceholder')}
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>
<!-- Phone -->
<div>
<label for="phone" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.phone')}</label
>
<input
id="phone"
type="tel"
bind:value={phone}
placeholder={$_('add.phonePlaceholder')}
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 class="flex gap-3">
<button
type="button"

View file

@ -7,17 +7,43 @@
interface Location {
id: string;
slug?: string;
name: string;
category: string;
description: string;
imageUrl?: string;
}
interface Collection {
id: string;
name: string;
description?: string;
locationIds: string[];
createdAt: string;
}
let allLocations = $state<Location[]>([]);
let collections = $state<Collection[]>([]);
let loading = $state(true);
let activeTab = $state<'favorites' | 'collections'>('favorites');
// Collection form state
let showCreateForm = $state(false);
let newCollectionName = $state('');
let newCollectionDescription = $state('');
let creatingCollection = $state(false);
// Collection detail view
let selectedCollection = $state<Collection | null>(null);
let favoriteLocations = $derived(allLocations.filter((l) => favoritesStore.isFavorite(l.id)));
let selectedCollectionLocations = $derived(
selectedCollection
? allLocations.filter((l) => (selectedCollection!.locationIds || []).includes(l.id))
: []
);
onMount(async () => {
try {
const res = await fetch(api('/locations'));
@ -31,9 +57,76 @@
if (authStore.isAuthenticated) {
await favoritesStore.load();
await loadCollections();
}
});
async function loadCollections() {
try {
const token = await authStore.getValidToken();
if (!token) return;
const res = await fetch(api('/collections'), {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
collections = data.collections;
}
} catch {
// ignore
}
}
async function handleCreateCollection() {
if (!newCollectionName.trim() || creatingCollection) return;
creatingCollection = true;
try {
const token = await authStore.getValidToken();
if (!token) return;
const res = await fetch(api('/collections'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name: newCollectionName.trim(),
description: newCollectionDescription.trim() || undefined,
}),
});
if (res.ok) {
const data = await res.json();
collections = [...collections, data.collection];
newCollectionName = '';
newCollectionDescription = '';
showCreateForm = false;
}
} catch {
// ignore
} finally {
creatingCollection = false;
}
}
async function handleDeleteCollection(id: string) {
try {
const token = await authStore.getValidToken();
if (!token) return;
const res = await fetch(api(`/collections/${id}`), {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
collections = collections.filter((c) => c.id !== id);
if (selectedCollection?.id === id) {
selectedCollection = null;
}
}
} catch {
// ignore
}
}
function handleRemove(e: MouseEvent, locationId: string) {
e.preventDefault();
e.stopPropagation();
@ -60,51 +153,220 @@
{$_('settings.login')}
</a>
</div>
{:else if loading}
<p class="text-foreground-secondary">{$_('home.loading')}</p>
{:else if favoriteLocations.length === 0}
<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">{$_('favorites.empty')}</p>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each favoriteLocations as location}
<a
href="/locations/{location.id}"
class="group relative flex items-center gap-4 overflow-hidden rounded-xl border border-border bg-background-card p-4 transition-shadow hover:shadow-lg"
>
{#if location.imageUrl}
<img
src={location.imageUrl}
alt={location.name}
class="h-16 w-16 flex-shrink-0 rounded-lg object-cover"
/>
{:else}
<div
class="flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-lg bg-background-card-hover"
>
<span class="text-2xl">📍</span>
</div>
{/if}
<div class="min-w-0 flex-1">
<span class="text-xs text-primary">{$_(`category.${location.category}`)}</span>
<h3 class="truncate font-semibold text-foreground group-hover:text-primary">
{location.name}
</h3>
</div>
<button
class="flex-shrink-0 p-1 text-red-500 transition-colors hover:text-red-600"
onclick={(e) => handleRemove(e, location.id)}
title={$_('favorites.remove')}
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z"
/>
</svg>
</button>
</a>
{/each}
<!-- Tabs -->
<div class="mb-6 flex gap-2">
<button
class="rounded-full px-4 py-2 text-sm transition-colors {activeTab === 'favorites'
? 'bg-primary text-white'
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover'}"
onclick={() => {
activeTab = 'favorites';
selectedCollection = null;
}}
>
{$_('favorites.tabFavorites')}
</button>
<button
class="rounded-full px-4 py-2 text-sm transition-colors {activeTab === 'collections'
? 'bg-primary text-white'
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover'}"
onclick={() => {
activeTab = 'collections';
selectedCollection = null;
}}
>
{$_('favorites.tabCollections')}
</button>
</div>
{#if activeTab === 'favorites'}
{#if loading}
<p class="text-foreground-secondary">{$_('home.loading')}</p>
{:else if favoriteLocations.length === 0}
<div class="rounded-xl border border-border bg-background-card p-8 text-center">
<span class="mb-2 block text-4xl">&#x1F499;</span>
<p class="text-foreground-secondary">{$_('favorites.empty')}</p>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each favoriteLocations as location}
<a
href="/locations/{location.slug || location.id}"
class="group relative flex items-center gap-4 overflow-hidden rounded-xl border border-border bg-background-card p-4 transition-shadow hover:shadow-lg"
>
{#if location.imageUrl}
<img
src={location.imageUrl}
alt={location.name}
class="h-16 w-16 flex-shrink-0 rounded-lg object-cover"
/>
{:else}
<div
class="flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-lg bg-background-card-hover"
>
<span class="text-2xl">&#x1F4CD;</span>
</div>
{/if}
<div class="min-w-0 flex-1">
<span class="text-xs text-primary">{$_(`category.${location.category}`)}</span>
<h3 class="truncate font-semibold text-foreground group-hover:text-primary">
{location.name}
</h3>
</div>
<button
class="flex-shrink-0 p-1 text-red-500 transition-colors hover:text-red-600"
onclick={(e) => handleRemove(e, location.id)}
title={$_('favorites.remove')}
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z"
/>
</svg>
</button>
</a>
{/each}
</div>
{/if}
{:else if selectedCollection}
<!-- Collection detail view -->
<div class="mb-4">
<button
onclick={() => (selectedCollection = null)}
class="text-sm text-foreground-secondary hover:text-primary transition-colors"
>
&larr; {$_('collections.back')}
</button>
</div>
<div class="mb-4 flex items-center justify-between">
<div>
<h2 class="text-xl font-bold text-foreground">{selectedCollection.name}</h2>
{#if selectedCollection.description}
<p class="text-sm text-foreground-secondary">{selectedCollection.description}</p>
{/if}
</div>
<button
onclick={() => handleDeleteCollection(selectedCollection!.id)}
class="text-sm text-red-500 hover:text-red-600 transition-colors"
>
{$_('collections.delete')}
</button>
</div>
{#if selectedCollectionLocations.length === 0}
<div class="rounded-xl border border-border bg-background-card p-8 text-center">
<p class="text-foreground-secondary">{$_('collections.noLocations')}</p>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each selectedCollectionLocations as location}
<a
href="/locations/{location.slug || location.id}"
class="group flex items-center gap-4 overflow-hidden rounded-xl border border-border bg-background-card p-4 transition-shadow hover:shadow-lg"
>
{#if location.imageUrl}
<img
src={location.imageUrl}
alt={location.name}
class="h-16 w-16 flex-shrink-0 rounded-lg object-cover"
/>
{:else}
<div
class="flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-lg bg-background-card-hover"
>
<span class="text-2xl">&#x1F4CD;</span>
</div>
{/if}
<div class="min-w-0 flex-1">
<span class="text-xs text-primary">{$_(`category.${location.category}`)}</span>
<h3 class="truncate font-semibold text-foreground group-hover:text-primary">
{location.name}
</h3>
</div>
</a>
{/each}
</div>
{/if}
{:else}
<!-- Collections list -->
<div class="mb-4">
<button
onclick={() => (showCreateForm = !showCreateForm)}
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary/90"
>
{$_('collections.create')}
</button>
</div>
{#if showCreateForm}
<div class="mb-6 rounded-xl border border-border bg-background-card p-4 space-y-3">
<div>
<label for="col-name" class="mb-1 block text-sm font-medium text-foreground"
>{$_('collections.name')}</label
>
<input
id="col-name"
type="text"
bind:value={newCollectionName}
placeholder={$_('collections.namePlaceholder')}
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label for="col-desc" class="mb-1 block text-sm font-medium text-foreground"
>{$_('collections.description')}</label
>
<input
id="col-desc"
type="text"
bind:value={newCollectionDescription}
placeholder={$_('collections.descriptionPlaceholder')}
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div class="flex gap-2">
<button
onclick={() => (showCreateForm = false)}
class="rounded-lg border border-border bg-background px-4 py-2 text-sm text-foreground-secondary hover:bg-background-card-hover"
>
{$_('collections.cancel')}
</button>
<button
onclick={handleCreateCollection}
disabled={!newCollectionName.trim() || creatingCollection}
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
>
{$_('collections.save')}
</button>
</div>
</div>
{/if}
{#if collections.length === 0}
<div class="rounded-xl border border-border bg-background-card p-8 text-center">
<p class="text-foreground-secondary">{$_('collections.empty')}</p>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each collections as collection}
<button
onclick={() => (selectedCollection = collection)}
class="text-left overflow-hidden rounded-xl border border-border bg-background-card p-4 transition-shadow hover:shadow-lg"
>
<h3 class="font-semibold text-foreground">{collection.name}</h3>
{#if collection.description}
<p class="mt-1 text-sm text-foreground-secondary line-clamp-2">
{collection.description}
</p>
{/if}
<p class="mt-2 text-xs text-foreground-secondary">
{$_('collections.locations', {
values: { count: (collection.locationIds || []).length },
})}
</p>
</button>
{/each}
</div>
{/if}
{/if}
{/if}

View file

@ -21,6 +21,7 @@
interface NearbyLocation {
id: string;
slug?: string;
name: string;
category: string;
imageUrl?: string;
@ -29,6 +30,7 @@
interface Location {
id: string;
slug?: string;
name: string;
category: string;
description: string;
@ -38,6 +40,9 @@
imageUrl?: string;
images?: LocationImage[];
timeline?: TimelineEntry[];
website?: string;
phone?: string;
openingHours?: Record<string, string>;
createdBy?: string;
}
@ -443,6 +448,58 @@
<p class="text-base leading-relaxed text-foreground">{location.description}</p>
<!-- Contact info -->
{#if location.website || location.phone}
<div class="space-y-2">
{#if location.website}
<div class="flex items-center gap-2 text-sm">
<span class="font-medium text-foreground-secondary">{$_('detail.website')}:</span>
<a
href={location.website}
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline truncate"
>
{location.website.replace(/^https?:\/\//, '')}
</a>
</div>
{/if}
{#if location.phone}
<div class="flex items-center gap-2 text-sm">
<span class="font-medium text-foreground-secondary">{$_('detail.phone')}:</span>
<a href="tel:{location.phone}" class="text-primary hover:underline">
{location.phone}
</a>
</div>
{/if}
</div>
{/if}
<!-- Opening hours -->
{#if location.openingHours && Object.keys(location.openingHours).length > 0}
<div>
<h2 class="mb-3 text-lg font-semibold text-foreground">{$_('detail.openingHours')}</h2>
<div class="rounded-xl border border-border bg-background-card overflow-hidden">
<table class="w-full text-sm">
<tbody>
{#each ['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'] as day}
{#if location.openingHours[day]}
<tr class="border-b border-border last:border-b-0">
<td class="px-4 py-2 font-medium text-foreground">{$_(`days.${day}`)}</td>
<td class="px-4 py-2 text-right text-foreground-secondary">
{location.openingHours[day] === 'closed'
? $_('detail.closed')
: location.openingHours[day]}
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
</div>
{/if}
<!-- Owner actions -->
{#if isOwner}
<div class="flex gap-3">
@ -613,7 +670,7 @@
<div class="flex gap-3 overflow-x-auto pb-1">
{#each nearbyLocations as nearby}
<a
href="/locations/{nearby.id}"
href="/locations/{nearby.slug || nearby.id}"
class="flex-shrink-0 w-40 overflow-hidden rounded-xl border border-border bg-background-card transition-shadow hover:shadow-md"
>
{#if nearby.imageUrl}

View file

@ -12,6 +12,8 @@
let description = $state('');
let address = $state('');
let imageUrl = $state('');
let website = $state('');
let phone = $state('');
let imageError = $state(false);
let submitting = $state(false);
let error = $state('');
@ -42,6 +44,8 @@
description = loc.description || '';
address = loc.address || '';
imageUrl = loc.imageUrl || '';
website = loc.website || '';
phone = loc.phone || '';
} catch {
error = $_('edit.loadError');
} finally {
@ -68,6 +72,8 @@
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}`), {
@ -225,6 +231,34 @@
{/if}
</div>
<!-- Website -->
<div>
<label for="website" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.website')}</label
>
<input
id="website"
type="url"
bind:value={website}
placeholder={$_('add.websitePlaceholder')}
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>
<!-- Phone -->
<div>
<label for="phone" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.phone')}</label
>
<input
id="phone"
type="tel"
bind:value={phone}
placeholder={$_('add.phonePlaceholder')}
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 class="flex gap-3">
<a
href="/locations/{$page.params.id}"

View file

@ -6,6 +6,7 @@
interface Location {
id: string;
slug?: string;
name: string;
category: string;
description: string;
@ -49,6 +50,14 @@
maxZoom: 19,
}).addTo(map);
const useCluster = locations.length >= 10;
let markerLayer: any;
if (useCluster) {
const { default: MCG } = await import('leaflet.markercluster');
markerLayer = (L as any).markerClusterGroup();
}
for (const loc of locations) {
if (loc.latitude && loc.longitude) {
const color = categoryColors[loc.category] || '#6b7280';
@ -60,18 +69,28 @@
iconAnchor: [14, 14],
});
const marker = L.marker([loc.latitude, loc.longitude], { icon }).addTo(map);
const marker = L.marker([loc.latitude, loc.longitude], { icon });
marker.bindPopup(`
<div style="min-width:180px">
<strong style="font-size:14px">${loc.name}</strong>
<div style="color:${color};font-size:12px;margin:4px 0">${$_(`category.${loc.category}`)}</div>
<p style="font-size:12px;color:#666;margin:4px 0">${loc.description.substring(0, 100)}...</p>
<a href="/locations/${loc.id}" style="color:${color};font-size:12px;font-weight:600">${$_('detail.showDetails')} &rarr;</a>
<a href="/locations/${loc.slug || loc.id}" style="color:${color};font-size:12px;font-weight:600">${$_('detail.showDetails')} &rarr;</a>
</div>
`);
if (useCluster && markerLayer) {
markerLayer.addLayer(marker);
} else {
marker.addTo(map);
}
}
}
if (useCluster && markerLayer) {
map.addLayer(markerLayer);
}
});
function handleLocateMe() {
@ -117,6 +136,16 @@
<svelte:head>
<title>{$_('map.title')} - CityCorners</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
<link
rel="stylesheet"
href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css"
crossorigin=""
/>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css"
crossorigin=""
/>
</svelte:head>
<div class="map-page">

919
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff