mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
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:
parent
06694eac19
commit
94d7e2bd02
28 changed files with 1736 additions and 558 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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_',
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
export * from './locations.schema';
|
||||
export * from './favorites.schema';
|
||||
export * from './collections.schema';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
68
apps/citycorners/apps/backend/src/guards/rate-limit.guard.ts
Normal file
68
apps/citycorners/apps/backend/src/guards/rate-limit.guard.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
interface Props {
|
||||
location: {
|
||||
id: number;
|
||||
id: number | string;
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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`}>
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
52
apps/citycorners/apps/web/src/lib/api.test.ts
Normal file
52
apps/citycorners/apps/web/src/lib/api.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">💙</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">📍</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"
|
||||
>
|
||||
← {$_('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">📍</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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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')} →</a>
|
||||
<a href="/locations/${loc.slug || loc.id}" style="color:${color};font-size:12px;font-weight:600">${$_('detail.showDetails')} →</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
919
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue