feat(citycorners): add city guide app for Konstanz with full monorepo integration

New project with three apps:
- Landing (Astro): static site with SVG illustrations, location data
- Backend (NestJS, port 3025): CRUD API for locations + favorites, Drizzle ORM, auth via mana-core-auth
- Web (SvelteKit, port 5196): Tailwind 4, PillNav, auth (login/register/SSO), Leaflet map, favorites with optimistic updates, theme/settings

Infrastructure: DB init SQL, setup-databases.sh, generate-env.mjs, root package.json scripts, Dockerfiles, docker-compose.macmini.yml (backend:3025, web:5022), Cloudflare wrangler.toml.

Branding: registered in shared-branding (AppId, APP_BRANDING, APP_ICONS, MANA_APPS, CitycornersLogo).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-23 10:56:26 +01:00
parent cf37d92633
commit 1c5c2446f6
83 changed files with 3663 additions and 3 deletions

View file

@ -0,0 +1,65 @@
# CityCorners
City guide for Konstanz (Bodensee) showcasing locations, restaurants, museums, and sights.
## Architecture
```
apps/citycorners/
├── apps/
│ ├── landing/ # Astro static site
│ ├── backend/ # NestJS API (port 3025)
│ └── web/ # SvelteKit web app (port 5196)
└── CLAUDE.md
```
## Development
```bash
# Full stack (auth + backend + web)
pnpm dev:citycorners:full
# Individual apps
pnpm dev:citycorners:landing
pnpm dev:citycorners:backend
pnpm dev:citycorners:web
# Database
pnpm citycorners:db:push # Push schema
pnpm citycorners:db:studio # Drizzle Studio
pnpm citycorners:db:seed # Seed sample data
# Deploy landing
pnpm deploy:landing:citycorners
```
## Database
PostgreSQL database `citycorners` with Drizzle ORM.
### Schema
- **locations** name, category (sight/restaurant/shop/museum), description, address, coordinates, imageUrl, timeline (JSONB)
- **favorites** userId, locationId (FK → locations, cascade delete), unique constraint on (userId, locationId)
## API Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/locations` | No | List all locations (optional `?category=` filter) |
| GET | `/locations/:id` | No | Get single location |
| POST | `/locations` | Yes | Create location |
| PUT | `/locations/:id` | Yes | Update location |
| DELETE | `/locations/:id` | Yes | Delete location |
| GET | `/favorites` | Yes | List user's favorites |
| POST | `/favorites/:locationId` | Yes | Add to favorites |
| DELETE | `/favorites/:locationId` | Yes | Remove from favorites |
## Categories
| DB Value | Label (DE) |
|----------|------------|
| `sight` | Sehenswürdigkeit |
| `restaurant` | Restaurant |
| `shop` | Laden |
| `museum` | Museum |

View file

@ -0,0 +1,87 @@
# syntax=docker/dockerfile:1
# Build stage
FROM node:20-alpine AS builder
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app
# Copy root workspace files
COPY pnpm-workspace.yaml ./
COPY package.json ./
COPY pnpm-lock.yaml ./
COPY patches ./patches
# Copy shared packages (required dependencies)
COPY packages/shared-drizzle-config ./packages/shared-drizzle-config
COPY packages/shared-errors ./packages/shared-errors
COPY packages/shared-nestjs-auth ./packages/shared-nestjs-auth
COPY packages/shared-nestjs-health ./packages/shared-nestjs-health
COPY packages/shared-nestjs-metrics ./packages/shared-nestjs-metrics
COPY packages/shared-nestjs-setup ./packages/shared-nestjs-setup
COPY packages/shared-tsconfig ./packages/shared-tsconfig
COPY packages/shared-error-tracking ./packages/shared-error-tracking
# Copy citycorners backend
COPY apps/citycorners/apps/backend ./apps/citycorners/apps/backend
# Install dependencies
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --ignore-scripts
# Build shared packages first (in dependency order)
WORKDIR /app/packages/shared-errors
RUN pnpm build
WORKDIR /app/packages/shared-nestjs-auth
RUN pnpm build
WORKDIR /app/packages/shared-nestjs-health
RUN pnpm build
WORKDIR /app/packages/shared-nestjs-metrics
RUN pnpm build
WORKDIR /app/packages/shared-nestjs-setup
RUN pnpm build
WORKDIR /app/packages/shared-error-tracking
RUN pnpm build
# Build the backend
WORKDIR /app/apps/citycorners/apps/backend
RUN pnpm build
# Production stage
FROM node:20-alpine AS production
# Install pnpm and postgresql-client for health checks
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate \
&& apk add --no-cache postgresql-client
WORKDIR /app
# Copy everything from builder (including node_modules)
COPY --from=builder /app/pnpm-workspace.yaml ./
COPY --from=builder /app/package.json ./
COPY --from=builder /app/pnpm-lock.yaml ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/packages ./packages
COPY --from=builder /app/apps/citycorners ./apps/citycorners
# Copy entrypoint script
COPY apps/citycorners/apps/backend/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
WORKDIR /app/apps/citycorners/apps/backend
# Expose port
EXPOSE 3025
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3025/health || exit 1
# Run entrypoint script
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "dist/main.js"]

View file

@ -0,0 +1,36 @@
#!/bin/sh
set -e
echo "=== CityCorners Backend Entrypoint ==="
# Wait for PostgreSQL to be ready
echo "Waiting for PostgreSQL..."
until pg_isready -h ${DB_HOST:-postgres} -p ${DB_PORT:-5432} -U ${DB_USER:-manacore} 2>/dev/null; do
echo "PostgreSQL is unavailable - sleeping"
sleep 2
done
echo "PostgreSQL is up!"
cd /app/apps/citycorners/apps/backend
# Run schema push (for development) or migrations (for production)
if [ "$NODE_ENV" = "production" ] && [ -d "src/db/migrations/meta" ]; then
echo "Running database migrations..."
npx tsx src/db/migrate.ts
echo "Migrations completed!"
else
echo "Pushing database schema (development mode)..."
npx drizzle-kit push --force
echo "Schema push completed!"
fi
# Run seed if seed file exists and SEED_ON_START is set
if [ "$SEED_ON_START" = "true" ] && [ -f "src/db/seed.ts" ]; then
echo "Running database seed..."
npx tsx src/db/seed.ts
echo "Seed completed!"
fi
# Execute the main command
echo "Starting application..."
exec "$@"

View file

@ -0,0 +1,6 @@
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
export default createDrizzleConfig({
dbName: 'citycorners',
additionalEnvVars: ['CITYCORNERS_DATABASE_URL'],
});

View file

@ -0,0 +1,50 @@
{
"name": "@citycorners/backend",
"version": "0.0.1",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"dev": "nest start --watch",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"type-check": "tsc --noEmit",
"migration:generate": "drizzle-kit generate",
"migration:run": "tsx src/db/migrate.ts",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts"
},
"dependencies": {
"@manacore/shared-error-tracking": "workspace:*",
"@manacore/shared-nestjs-auth": "workspace:*",
"@manacore/shared-nestjs-health": "workspace:*",
"@manacore/shared-nestjs-metrics": "workspace:*",
"@manacore/shared-nestjs-setup": "workspace:*",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.38.3",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/express": "^5.0.0",
"@types/node": "^22.10.2",
"source-map-support": "^0.5.21",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View file

@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './db/database.module';
import { LocationModule } from './location/location.module';
import { FavoriteModule } from './favorite/favorite.module';
import { HealthModule } from '@manacore/shared-nestjs-health';
import { MetricsModule } from '@manacore/shared-nestjs-metrics';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
LocationModule,
FavoriteModule,
HealthModule.forRoot({ serviceName: 'citycorners-backend' }),
MetricsModule.register({
prefix: 'citycorners_',
excludePaths: ['/health'],
}),
],
})
export class AppModule {}

View file

@ -0,0 +1,38 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import * as schema from './schema';
// Use require for postgres to avoid ESM/CommonJS interop issues
// eslint-disable-next-line @typescript-eslint/no-var-requires
const postgres = require('postgres');
let connection: ReturnType<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | null = null;
export function getConnection(databaseUrl: string) {
if (!connection) {
connection = postgres(databaseUrl, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
}
return connection;
}
export function getDb(databaseUrl: string) {
if (!db) {
const conn = getConnection(databaseUrl);
db = drizzle(conn, { schema });
}
return db;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = ReturnType<typeof getDb>;

View file

@ -0,0 +1,29 @@
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getDb, closeConnection } from './connection';
import type { Database } from './connection';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeConnection();
}
}

View file

@ -0,0 +1,25 @@
import 'dotenv/config';
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const postgres = require('postgres');
const databaseUrl =
process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/citycorners';
async function runMigrations() {
const connection = postgres(databaseUrl, { max: 1 });
const db = drizzle(connection);
console.log('Running migrations...');
await migrate(db, { migrationsFolder: './src/db/migrations' });
console.log('Migrations completed.');
await connection.end();
}
runMigrations().catch((err) => {
console.error('Migration failed:', err);
process.exit(1);
});

View file

@ -0,0 +1,20 @@
import { pgTable, uuid, text, timestamp, unique } from 'drizzle-orm/pg-core';
import { locations } from './locations.schema';
export const favorites = pgTable(
'favorites',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
locationId: uuid('location_id')
.notNull()
.references(() => locations.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
uniqueUserLocation: unique().on(table.userId, table.locationId),
})
);
export type Favorite = typeof favorites.$inferSelect;
export type NewFavorite = typeof favorites.$inferInsert;

View file

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

View file

@ -0,0 +1,36 @@
import {
pgTable,
uuid,
text,
timestamp,
doublePrecision,
jsonb,
pgEnum,
} from 'drizzle-orm/pg-core';
export const categoryEnum = pgEnum('location_category', ['sight', 'restaurant', 'shop', 'museum']);
export const locations = pgTable('locations', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
category: categoryEnum('category').notNull(),
description: text('description').notNull(),
address: text('address'),
latitude: doublePrecision('latitude'),
longitude: doublePrecision('longitude'),
imageUrl: text('image_url'),
timeline: jsonb('timeline').$type<TimelineEntry[]>().default([]),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
export interface TimelineEntry {
year: string;
event: string;
}
export type Location = typeof locations.$inferSelect;
export type NewLocation = typeof locations.$inferInsert;

View file

@ -0,0 +1,89 @@
import 'dotenv/config';
import { drizzle } from 'drizzle-orm/postgres-js';
import { locations } from './schema';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const postgres = require('postgres');
const databaseUrl =
process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/citycorners';
async function seed() {
const connection = postgres(databaseUrl);
const db = drizzle(connection);
console.log('Seeding citycorners database...');
await db.insert(locations).values([
{
name: 'Konstanzer Münster',
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.',
address: 'Münsterplatz 1, 78462 Konstanz',
latitude: 47.6603,
longitude: 9.1757,
imageUrl: '/images/muenster.svg',
timeline: [
{ year: '615', event: 'Grundsteinlegung' },
{ year: '1089', event: 'Romanischer Neubau' },
{ year: '1414-1418', event: 'Konzil von Konstanz' },
],
},
{
name: '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.',
address: 'Hafenstraße, 78462 Konstanz',
latitude: 47.6596,
longitude: 9.1784,
imageUrl: '/images/imperia.svg',
timeline: [{ year: '1993', event: 'Aufstellung im Hafen' }],
},
{
name: 'Restaurant Ophelia',
category: 'restaurant',
description:
'Fine-Dining-Restaurant im Riva-Gebäude am Konstanzer Hafen mit Blick auf den Bodensee.',
address: 'Seestraße 25, 78464 Konstanz',
latitude: 47.6589,
longitude: 9.1795,
imageUrl: '/images/ophelia.svg',
},
{
name: 'LAGO Shopping Center',
category: 'shop',
description: 'Großes Einkaufszentrum in der Konstanzer Innenstadt mit über 80 Geschäften.',
address: 'Bodanstraße 1, 78462 Konstanz',
latitude: 47.6615,
longitude: 9.1742,
imageUrl: '/images/lago.svg',
},
{
name: 'Rosgartenmuseum',
category: 'museum',
description:
'Das Rosgartenmuseum zeigt die Geschichte der Stadt Konstanz und der Bodenseeregion.',
address: 'Rosgartenstraße 3-5, 78462 Konstanz',
latitude: 47.6612,
longitude: 9.1753,
},
{
name: 'Archäologisches Landesmuseum',
category: 'museum',
description: 'Landesmuseum für Archäologie in Baden-Württemberg mit Funden aus der Region.',
address: 'Benediktinerplatz 5, 78467 Konstanz',
latitude: 47.6637,
longitude: 9.1801,
},
]);
console.log('Seeded 6 locations.');
await connection.end();
}
seed().catch((err) => {
console.error('Seed failed:', err);
process.exit(1);
});

View file

@ -0,0 +1,27 @@
import { Controller, Get, Post, Delete, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { FavoriteService } from './favorite.service';
@Controller('favorites')
@UseGuards(JwtAuthGuard)
export class FavoriteController {
constructor(private readonly favoriteService: FavoriteService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData) {
const favorites = await this.favoriteService.findByUserId(user.userId);
return { favorites };
}
@Post(':locationId')
async add(@CurrentUser() user: CurrentUserData, @Param('locationId') locationId: string) {
const favorite = await this.favoriteService.add(user.userId, locationId);
return { favorite };
}
@Delete(':locationId')
async remove(@CurrentUser() user: CurrentUserData, @Param('locationId') locationId: string) {
await this.favoriteService.remove(user.userId, locationId);
return { success: true };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { FavoriteController } from './favorite.controller';
import { FavoriteService } from './favorite.service';
@Module({
controllers: [FavoriteController],
providers: [FavoriteService],
exports: [FavoriteService],
})
export class FavoriteModule {}

View file

@ -0,0 +1,43 @@
import { Injectable, Inject, ConflictException } from '@nestjs/common';
import { eq, and } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { favorites } from '../db/schema';
import type { Favorite } from '../db/schema';
@Injectable()
export class FavoriteService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findByUserId(userId: string): Promise<Favorite[]> {
return this.db.select().from(favorites).where(eq(favorites.userId, userId));
}
async add(userId: string, locationId: string): Promise<Favorite> {
const existing = await this.db
.select()
.from(favorites)
.where(and(eq(favorites.userId, userId), eq(favorites.locationId, locationId)));
if (existing.length > 0) {
throw new ConflictException('Location already in favorites');
}
const [favorite] = await this.db.insert(favorites).values({ userId, locationId }).returning();
return favorite;
}
async remove(userId: string, locationId: string): Promise<void> {
await this.db
.delete(favorites)
.where(and(eq(favorites.userId, userId), eq(favorites.locationId, locationId)));
}
async isFavorite(userId: string, locationId: string): Promise<boolean> {
const result = await this.db
.select()
.from(favorites)
.where(and(eq(favorites.userId, userId), eq(favorites.locationId, locationId)));
return result.length > 0;
}
}

View file

@ -0,0 +1,8 @@
import { initErrorTracking } from '@manacore/shared-error-tracking';
initErrorTracking({
serviceName: 'citycorners-backend',
environment: process.env.NODE_ENV,
release: process.env.APP_VERSION,
debug: process.env.NODE_ENV === 'development',
});

View file

@ -0,0 +1,111 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { LocationService } from './location.service';
import { IsString, IsNotEmpty, IsOptional, IsNumber } from 'class-validator';
import { Type } from 'class-transformer';
class CreateLocationDto {
@IsString()
@IsNotEmpty()
name!: string;
@IsString()
@IsNotEmpty()
category!: 'sight' | 'restaurant' | 'shop' | 'museum';
@IsString()
@IsNotEmpty()
description!: string;
@IsString()
@IsOptional()
address?: string;
@IsNumber()
@IsOptional()
@Type(() => Number)
latitude?: number;
@IsNumber()
@IsOptional()
@Type(() => Number)
longitude?: number;
@IsString()
@IsOptional()
imageUrl?: string;
}
class UpdateLocationDto {
@IsString()
@IsOptional()
name?: string;
@IsString()
@IsOptional()
category?: 'sight' | 'restaurant' | 'shop' | 'museum';
@IsString()
@IsOptional()
description?: string;
@IsString()
@IsOptional()
address?: string;
@IsNumber()
@IsOptional()
@Type(() => Number)
latitude?: number;
@IsNumber()
@IsOptional()
@Type(() => Number)
longitude?: number;
@IsString()
@IsOptional()
imageUrl?: string;
}
@Controller('locations')
export class LocationController {
constructor(private readonly locationService: LocationService) {}
@Get()
async findAll(@Query('category') category?: string) {
const locations = await this.locationService.findAll(category);
return { locations };
}
@Get(':id')
async findById(@Param('id') id: string) {
const location = await this.locationService.findById(id);
return { location };
}
@Post()
@UseGuards(JwtAuthGuard)
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateLocationDto) {
const location = await this.locationService.create(dto);
return { location };
}
@Put(':id')
@UseGuards(JwtAuthGuard)
async update(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: UpdateLocationDto
) {
const location = await this.locationService.update(id, dto);
return { location };
}
@Delete(':id')
@UseGuards(JwtAuthGuard)
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.locationService.delete(id);
return { success: true };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { LocationController } from './location.controller';
import { LocationService } from './location.service';
@Module({
controllers: [LocationController],
providers: [LocationService],
exports: [LocationService],
})
export class LocationModule {}

View file

@ -0,0 +1,53 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { locations } from '../db/schema';
import type { Location, NewLocation } from '../db/schema';
@Injectable()
export class LocationService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findAll(category?: string): Promise<Location[]> {
if (category) {
return this.db
.select()
.from(locations)
.where(eq(locations.category, category as Location['category']));
}
return this.db.select().from(locations);
}
async findById(id: string): Promise<Location> {
const [location] = await this.db.select().from(locations).where(eq(locations.id, id));
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();
return location;
}
async update(id: string, data: Partial<NewLocation>): Promise<Location> {
const [location] = await this.db
.update(locations)
.set(data)
.where(eq(locations.id, id))
.returning();
if (!location) {
throw new NotFoundException(`Location with id ${id} not found`);
}
return location;
}
async delete(id: string): Promise<void> {
const [location] = await this.db.delete(locations).where(eq(locations.id, id)).returning();
if (!location) {
throw new NotFoundException(`Location with id ${id} not found`);
}
}
}

View file

@ -0,0 +1,9 @@
import './instrument';
import { bootstrapApp } from '@manacore/shared-nestjs-setup';
import { AppModule } from './app.module';
bootstrapApp(AppModule, {
defaultPort: 3025,
serviceName: 'CityCorners',
additionalCorsOrigins: ['http://localhost:5196'],
});

View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2021",
"module": "commonjs",
"moduleResolution": "node",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"outDir": "./dist",
"baseUrl": "./",
"rootDir": "./src",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,26 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
astro_dev.log
server.log
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/

View file

@ -0,0 +1,46 @@
# Astro Starter Kit: Basics
```sh
npm create astro@latest -- --template basics
```
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```text
/
├── public/
│ └── favicon.svg
├── src
│   ├── assets
│   │   └── astro.svg
│   ├── components
│   │   └── Welcome.astro
│   ├── layouts
│   │   └── Layout.astro
│   └── pages
│   └── index.astro
└── package.json
```
To learn more about the folder structure of an Astro project, refer to [our guide on project structure](https://docs.astro.build/en/basics/project-structure/).
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).

View file

@ -0,0 +1,5 @@
// @ts-check
import { defineConfig } from 'astro/config';
// https://astro.build/config
export default defineConfig({});

View file

@ -0,0 +1,19 @@
{
"name": "@citycorners/landing",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro",
"type-check": "astro check"
},
"dependencies": {
"@astrojs/check": "^0.9.0",
"astro": "^5.16.11",
"typescript": "^5.0.0"
}
}

View file

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

View file

@ -0,0 +1,23 @@
<svg width="800" height="500" viewBox="0 0 800 500" xmlns="http://www.w3.org/2000/svg">
<rect width="800" height="500" fill="#87CEEB"/>
<rect x="0" y="370" width="800" height="130" fill="#8B9467"/>
<!-- Modern museum building -->
<rect x="150" y="160" width="500" height="220" fill="#D4C5A0" rx="2"/>
<!-- Large entrance area -->
<rect x="300" y="200" width="200" height="180" fill="#B8A888"/>
<!-- Glass entrance -->
<rect x="340" y="250" width="120" height="130" fill="#A8D8EA" opacity="0.7" rx="2"/>
<!-- Windows row -->
<rect x="170" y="200" width="100" height="30" fill="#A8D8EA" opacity="0.5"/>
<rect x="530" y="200" width="100" height="30" fill="#A8D8EA" opacity="0.5"/>
<rect x="170" y="260" width="100" height="30" fill="#A8D8EA" opacity="0.5"/>
<rect x="530" y="260" width="100" height="30" fill="#A8D8EA" opacity="0.5"/>
<!-- Archaeological artifacts (decorative) -->
<circle cx="250" cy="340" r="25" fill="#CD853F" opacity="0.6"/>
<rect x="540" y="320" width="40" height="50" fill="#CD853F" opacity="0.6" rx="2"/>
<!-- Banner -->
<rect x="320" y="170" width="160" height="25" fill="#9333ea" rx="3"/>
<text x="400" y="189" text-anchor="middle" font-family="system-ui" font-size="12" fill="#fff" font-weight="600">ARCHÄOLOGIE</text>
<!-- Label -->
<text x="400" y="460" text-anchor="middle" font-family="system-ui" font-size="20" fill="#fff" font-weight="600">Archäologisches Landesmuseum</text>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,35 @@
<svg width="800" height="500" viewBox="0 0 800 500" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="water" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#5B9BD5"/>
<stop offset="100%" stop-color="#2E75B6"/>
</linearGradient>
</defs>
<!-- Sky -->
<rect width="800" height="280" fill="#FDE8D0"/>
<!-- Water -->
<rect x="0" y="280" width="800" height="220" fill="url(#water)"/>
<!-- Pier -->
<rect x="300" y="260" width="200" height="40" fill="#A08060" rx="2"/>
<rect x="340" y="300" width="20" height="60" fill="#8B7355"/>
<rect x="440" y="300" width="20" height="60" fill="#8B7355"/>
<!-- Statue base -->
<rect x="370" y="200" width="60" height="80" fill="#808080"/>
<!-- Statue figure -->
<ellipse cx="400" cy="160" rx="35" ry="50" fill="#C0C0C0"/>
<!-- Head -->
<circle cx="400" cy="105" r="20" fill="#D4C5A0"/>
<!-- Crown -->
<polygon points="385,90 390,75 395,88 400,72 405,88 410,75 415,90" fill="#DAA520"/>
<!-- Arms holding figures -->
<line x1="365" y1="145" x2="340" y2="130" stroke="#C0C0C0" stroke-width="8" stroke-linecap="round"/>
<line x1="435" y1="145" x2="460" y2="130" stroke="#C0C0C0" stroke-width="8" stroke-linecap="round"/>
<circle cx="335" cy="120" r="12" fill="#DAA520"/>
<circle cx="465" cy="120" r="12" fill="#8B0000"/>
<!-- Sun -->
<circle cx="680" cy="80" r="50" fill="#FFD700" opacity="0.6"/>
<!-- Waves -->
<path d="M0,320 Q50,310 100,320 Q150,330 200,320 Q250,310 300,320 Q350,330 400,320 Q450,310 500,320 Q550,330 600,320 Q650,310 700,320 Q750,330 800,320" fill="none" stroke="#fff" stroke-width="2" opacity="0.3"/>
<!-- Label -->
<text x="400" y="470" text-anchor="middle" font-family="system-ui" font-size="22" fill="#fff" font-weight="600">Imperia</text>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1,33 @@
<svg width="800" height="500" viewBox="0 0 800 500" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="glass" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#E8F4FD"/>
<stop offset="100%" stop-color="#B8D4E8"/>
</linearGradient>
</defs>
<rect width="800" height="500" fill="#87CEEB"/>
<!-- Ground -->
<rect x="0" y="400" width="800" height="100" fill="#888"/>
<!-- Building -->
<rect x="150" y="120" width="500" height="290" fill="#F5F5F5" rx="6"/>
<!-- Glass facade -->
<rect x="160" y="130" width="480" height="270" fill="url(#glass)" rx="4"/>
<!-- Grid lines (glass panels) -->
<line x1="280" y1="130" x2="280" y2="400" stroke="#ccc" stroke-width="1"/>
<line x1="400" y1="130" x2="400" y2="400" stroke="#ccc" stroke-width="1"/>
<line x1="520" y1="130" x2="520" y2="400" stroke="#ccc" stroke-width="1"/>
<line x1="160" y1="200" x2="640" y2="200" stroke="#ccc" stroke-width="1"/>
<line x1="160" y1="270" x2="640" y2="270" stroke="#ccc" stroke-width="1"/>
<line x1="160" y1="340" x2="640" y2="340" stroke="#ccc" stroke-width="1"/>
<!-- Entrance -->
<rect x="350" y="350" width="100" height="50" fill="#4A90D9" rx="2"/>
<!-- LAGO text on building -->
<text x="400" y="180" text-anchor="middle" font-family="system-ui" font-size="48" fill="#2563eb" font-weight="700" letter-spacing="8">LAGO</text>
<!-- People (simple) -->
<circle cx="320" cy="420" r="6" fill="#555"/>
<line x1="320" y1="426" x2="320" y2="445" stroke="#555" stroke-width="3"/>
<circle cx="480" cy="418" r="6" fill="#555"/>
<line x1="480" y1="424" x2="480" y2="443" stroke="#555" stroke-width="3"/>
<!-- Label -->
<text x="400" y="480" text-anchor="middle" font-family="system-ui" font-size="22" fill="#fff" font-weight="600">LAGO Shopping-Center</text>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1,31 @@
<svg width="800" height="500" viewBox="0 0 800 500" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#87CEEB"/>
<stop offset="100%" stop-color="#E0F0FF"/>
</linearGradient>
</defs>
<rect width="800" height="500" fill="url(#sky)"/>
<!-- Ground -->
<rect x="0" y="380" width="800" height="120" fill="#8B9467"/>
<rect x="0" y="370" width="800" height="20" fill="#A0A878"/>
<!-- Cathedral body -->
<rect x="250" y="150" width="300" height="230" fill="#D4C5A0" rx="4"/>
<!-- Tower -->
<rect x="370" y="50" width="60" height="330" fill="#C8B890"/>
<!-- Spire -->
<polygon points="400,10 370,80 430,80" fill="#6B7B5E"/>
<!-- Windows -->
<circle cx="400" cy="200" r="30" fill="#4A6FA5" opacity="0.6"/>
<rect x="310" y="280" width="30" height="50" fill="#4A6FA5" opacity="0.5" rx="15"/>
<rect x="360" y="280" width="30" height="50" fill="#4A6FA5" opacity="0.5" rx="15"/>
<rect x="410" y="280" width="30" height="50" fill="#4A6FA5" opacity="0.5" rx="15"/>
<rect x="460" y="280" width="30" height="50" fill="#4A6FA5" opacity="0.5" rx="15"/>
<!-- Door -->
<rect x="365" y="320" width="70" height="60" fill="#5C4A3A" rx="35"/>
<!-- Cross -->
<rect x="396" y="15" width="8" height="25" fill="#8B7355"/>
<rect x="389" y="22" width="22" height="8" fill="#8B7355"/>
<!-- Label -->
<text x="400" y="470" text-anchor="middle" font-family="system-ui" font-size="22" fill="#fff" font-weight="600">Konstanzer Münster</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,33 @@
<svg width="800" height="500" viewBox="0 0 800 500" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="evening" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#1a1a2e"/>
<stop offset="100%" stop-color="#16213e"/>
</linearGradient>
</defs>
<rect width="800" height="500" fill="url(#evening)"/>
<!-- Lake reflection -->
<rect x="0" y="350" width="800" height="150" fill="#0f3460" opacity="0.5"/>
<!-- Building -->
<rect x="200" y="180" width="400" height="190" fill="#2a2a4a" rx="4"/>
<!-- Windows with warm glow -->
<rect x="240" y="210" width="50" height="40" fill="#FFD700" opacity="0.7" rx="2"/>
<rect x="310" y="210" width="50" height="40" fill="#FFD700" opacity="0.5" rx="2"/>
<rect x="380" y="210" width="50" height="40" fill="#FFD700" opacity="0.8" rx="2"/>
<rect x="450" y="210" width="50" height="40" fill="#FFD700" opacity="0.6" rx="2"/>
<rect x="520" y="210" width="50" height="40" fill="#FFD700" opacity="0.4" rx="2"/>
<!-- Restaurant windows (ground floor, brighter) -->
<rect x="240" y="290" width="110" height="60" fill="#FFE4B5" opacity="0.9" rx="2"/>
<rect x="370" y="290" width="110" height="60" fill="#FFE4B5" opacity="0.9" rx="2"/>
<rect x="500" y="290" width="70" height="60" fill="#FFE4B5" opacity="0.7" rx="2"/>
<!-- Stars -->
<circle cx="100" cy="60" r="2" fill="#fff" opacity="0.8"/>
<circle cx="250" cy="40" r="1.5" fill="#fff" opacity="0.6"/>
<circle cx="500" cy="80" r="2" fill="#fff" opacity="0.7"/>
<circle cx="650" cy="50" r="1.5" fill="#fff" opacity="0.5"/>
<circle cx="720" cy="100" r="2" fill="#fff" opacity="0.8"/>
<!-- Michelin stars indicator -->
<text x="400" y="160" text-anchor="middle" font-size="28" fill="#FFD700">★★</text>
<!-- Label -->
<text x="400" y="460" text-anchor="middle" font-family="system-ui" font-size="22" fill="#fff" font-weight="600">Restaurant Ophelia</text>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1,25 @@
<svg width="800" height="500" viewBox="0 0 800 500" xmlns="http://www.w3.org/2000/svg">
<rect width="800" height="500" fill="#87CEEB"/>
<rect x="0" y="380" width="800" height="120" fill="#8B9467"/>
<!-- Historic building -->
<rect x="200" y="150" width="400" height="240" fill="#E8D5B0" rx="4"/>
<!-- Roof -->
<polygon points="180,150 400,50 620,150" fill="#8B4513"/>
<!-- Dormers -->
<polygon points="300,120 330,80 360,120" fill="#8B4513"/>
<rect x="315" y="95" width="30" height="25" fill="#4A6FA5" opacity="0.6"/>
<polygon points="440,120 470,80 500,120" fill="#8B4513"/>
<rect x="455" y="95" width="30" height="25" fill="#4A6FA5" opacity="0.6"/>
<!-- Windows (historic style) -->
<rect x="240" y="190" width="40" height="55" fill="#4A6FA5" opacity="0.5" rx="20"/>
<rect x="310" y="190" width="40" height="55" fill="#4A6FA5" opacity="0.5" rx="20"/>
<rect x="450" y="190" width="40" height="55" fill="#4A6FA5" opacity="0.5" rx="20"/>
<rect x="520" y="190" width="40" height="55" fill="#4A6FA5" opacity="0.5" rx="20"/>
<!-- Door -->
<rect x="370" y="300" width="60" height="90" fill="#5C4A3A" rx="30"/>
<!-- Museum sign -->
<rect x="300" y="270" width="200" height="25" fill="#8B4513" rx="3"/>
<text x="400" y="289" text-anchor="middle" font-family="serif" font-size="14" fill="#FFE4B5" font-weight="600">ROSGARTENMUSEUM</text>
<!-- Label -->
<text x="400" y="460" text-anchor="middle" font-family="system-ui" font-size="22" fill="#fff" font-weight="600">Rosgartenmuseum</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width="115" height="48"><path fill="#17191E" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="url(#a)" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="#17191E" d="M.02 30.31s4.02-1.95 8.05-1.95l3.04-9.4c.11-.45.44-.76.82-.76.37 0 .7.31.82.76l3.04 9.4c4.77 0 8.05 1.95 8.05 1.95L17 11.71c-.2-.56-.53-.91-.98-.91H7.83c-.44 0-.76.35-.97.9L.02 30.31Zm42.37-5.97c0 1.64-2.05 2.62-4.88 2.62-1.85 0-2.5-.45-2.5-1.41 0-1 .8-1.49 2.65-1.49 1.67 0 3.09.03 4.73.23v.05Zm.03-2.04a21.37 21.37 0 0 0-4.37-.36c-5.32 0-7.82 1.25-7.82 4.18 0 3.04 1.71 4.2 5.68 4.2 3.35 0 5.63-.84 6.46-2.92h.14c-.03.5-.05 1-.05 1.4 0 1.07.18 1.16 1.06 1.16h4.15a16.9 16.9 0 0 1-.36-4c0-1.67.06-2.93.06-4.62 0-3.45-2.07-5.64-8.56-5.64-2.8 0-5.9.48-8.26 1.19.22.93.54 2.83.7 4.06 2.04-.96 4.95-1.37 7.2-1.37 3.11 0 3.97.71 3.97 2.15v.57Zm11.37 3c-.56.07-1.33.07-2.12.07-.83 0-1.6-.03-2.12-.1l-.02.58c0 2.85 1.87 4.52 8.45 4.52 6.2 0 8.2-1.64 8.2-4.55 0-2.74-1.33-4.09-7.2-4.39-4.58-.2-4.99-.7-4.99-1.28 0-.66.59-1 3.65-1 3.18 0 4.03.43 4.03 1.35v.2a46.13 46.13 0 0 1 4.24.03l.02-.55c0-3.36-2.8-4.46-8.2-4.46-6.08 0-8.13 1.49-8.13 4.39 0 2.6 1.64 4.23 7.48 4.48 4.3.14 4.77.62 4.77 1.28 0 .7-.7 1.03-3.71 1.03-3.47 0-4.35-.48-4.35-1.47v-.13Zm19.82-12.05a17.5 17.5 0 0 1-6.24 3.48c.03.84.03 2.4.03 3.24l1.5.02c-.02 1.63-.04 3.6-.04 4.9 0 3.04 1.6 5.32 6.58 5.32 2.1 0 3.5-.23 5.23-.6a43.77 43.77 0 0 1-.46-4.13c-1.03.34-2.34.53-3.78.53-2 0-2.82-.55-2.82-2.13 0-1.37 0-2.65.03-3.84 2.57.02 5.13.07 6.64.11-.02-1.18.03-2.9.1-4.04-2.2.04-4.65.07-6.68.07l.07-2.93h-.16Zm13.46 6.04a767.33 767.33 0 0 1 .07-3.18H82.6c.07 1.96.07 3.98.07 6.92 0 2.95-.03 4.99-.07 6.93h5.18c-.09-1.37-.11-3.68-.11-5.65 0-3.1 1.26-4 4.12-4 1.33 0 2.28.16 3.1.46.03-1.16.26-3.43.4-4.43-.86-.25-1.81-.41-2.96-.41-2.46-.03-4.26.98-5.1 3.38l-.17-.02Zm22.55 3.65c0 2.5-1.8 3.66-4.64 3.66-2.81 0-4.61-1.1-4.61-3.66s1.82-3.52 4.61-3.52c2.82 0 4.64 1.03 4.64 3.52Zm4.71-.11c0-4.96-3.87-7.18-9.35-7.18-5.5 0-9.23 2.22-9.23 7.18 0 4.94 3.49 7.59 9.21 7.59 5.77 0 9.37-2.65 9.37-7.6Z"/><defs><linearGradient id="a" x1="6.33" x2="19.43" y1="40.8" y2="34.6" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="1024" fill="none"><path fill="url(#a)" fill-rule="evenodd" d="M-217.58 475.75c91.82-72.02 225.52-29.38 341.2-44.74C240 415.56 372.33 315.14 466.77 384.9c102.9 76.02 44.74 246.76 90.31 366.31 29.83 78.24 90.48 136.14 129.48 210.23 57.92 109.99 169.67 208.23 155.9 331.77-13.52 121.26-103.42 264.33-224.23 281.37-141.96 20.03-232.72-220.96-374.06-196.99-151.7 25.73-172.68 330.24-325.85 315.72-128.6-12.2-110.9-230.73-128.15-358.76-12.16-90.14 65.87-176.25 44.1-264.57-26.42-107.2-167.12-163.46-176.72-273.45-10.15-116.29 33.01-248.75 124.87-320.79Z" clip-rule="evenodd" style="opacity:.154"/><path fill="url(#b)" fill-rule="evenodd" d="M1103.43 115.43c146.42-19.45 275.33-155.84 413.5-103.59 188.09 71.13 409 212.64 407.06 413.88-1.94 201.25-259.28 278.6-414.96 405.96-130 106.35-240.24 294.39-405.6 265.3-163.7-28.8-161.93-274.12-284.34-386.66-134.95-124.06-436-101.46-445.82-284.6-9.68-180.38 247.41-246.3 413.54-316.9 101.01-42.93 207.83 21.06 316.62 6.61Z" clip-rule="evenodd" style="opacity:.154"/><defs><linearGradient id="b" x1="373" x2="1995.44" y1="1100" y2="118.03" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient><linearGradient id="a" x1="107.37" x2="1130.66" y1="1993.35" y2="1026.31" gradientUnits="userSpaceOnUse"><stop stop-color="#3245FF"/><stop offset="1" stop-color="#BC52EE"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,25 @@
---
// This is a placeholder for the filter component.
// The actual filtering logic will be implemented on the page.
const categories = ['Sehenswürdigkeit', 'Restaurant', 'Laden'];
---
<div class="filter-container">
<label for="category-filter">Filter by Category:</label>
<select id="category-filter">
<option value="">All</option>
{categories.map((category) => <option value={category.toLowerCase()}>{category}</option>)}
</select>
</div>
<style>
.filter-container {
margin: 20px;
text-align: center;
}
#category-filter {
padding: 10px;
border-radius: 5px;
border: 1px solid #ccc;
}
</style>

View file

@ -0,0 +1,25 @@
---
const { location } = Astro.props;
---
<div class="location-card" data-category="{location.category.toLowerCase()}">
<img src={location.image} alt={location.name} />
<h3>{location.name}</h3>
<p>{location.category}</p>
<a href={`/locations/${location.id}`}>Details</a>
</div>
<style>
.location-card {
border: 1px solid #ccc;
border-radius: 8px;
padding: 16px;
margin: 16px;
text-align: center;
}
.location-card img {
max-width: 100%;
height: auto;
border-radius: 4px;
}
</style>

View file

@ -0,0 +1,15 @@
<button id="theme-toggle" type="button" title="Toggle Theme"> Toggle Theme </button>
<style>
#theme-toggle {
position: fixed;
top: 1rem;
right: 1rem;
padding: 0.5rem 1rem;
border: 1px solid var(--color-text);
background-color: var(--color-background);
color: var(--color-text);
cursor: pointer;
border-radius: 4px;
}
</style>

View file

@ -0,0 +1,210 @@
---
import astroLogo from '../assets/astro.svg';
import background from '../assets/background.svg';
---
<div id="container">
<img id="background" src={background.src} alt="" fetchpriority="high" />
<main>
<section id="hero">
<a href="https://astro.build"
><img src={astroLogo.src} width="115" height="48" alt="Astro Homepage" /></a
>
<h1>
To get started, open the <code><pre>src/pages</pre></code> directory in your project.
</h1>
<section id="links">
<a class="button" href="https://docs.astro.build">Read our docs</a>
<a href="https://astro.build/chat"
>Join our Discord <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36"
><path
fill="currentColor"
d="M107.7 8.07A105.15 105.15 0 0 0 81.47 0a72.06 72.06 0 0 0-3.36 6.83 97.68 97.68 0 0 0-29.11 0A72.37 72.37 0 0 0 45.64 0a105.89 105.89 0 0 0-26.25 8.09C2.79 32.65-1.71 56.6.54 80.21a105.73 105.73 0 0 0 32.17 16.15 77.7 77.7 0 0 0 6.89-11.11 68.42 68.42 0 0 1-10.85-5.18c.91-.66 1.8-1.34 2.66-2a75.57 75.57 0 0 0 64.32 0c.87.71 1.76 1.39 2.66 2a68.68 68.68 0 0 1-10.87 5.19 77 77 0 0 0 6.89 11.1 105.25 105.25 0 0 0 32.19-16.14c2.64-27.38-4.51-51.11-18.9-72.15ZM42.45 65.69C36.18 65.69 31 60 31 53s5-12.74 11.43-12.74S54 46 53.89 53s-5.05 12.69-11.44 12.69Zm42.24 0C78.41 65.69 73.25 60 73.25 53s5-12.74 11.44-12.74S96.23 46 96.12 53s-5.04 12.69-11.43 12.69Z"
></path></svg
>
</a>
</section>
</section>
</main>
<a href="https://astro.build/blog/astro-5/" id="news" class="box">
<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"
><path
d="M24.667 12c1.333 1.414 2 3.192 2 5.334 0 4.62-4.934 5.7-7.334 12C18.444 28.567 18 27.456 18 26c0-4.642 6.667-7.053 6.667-14Zm-5.334-5.333c1.6 1.65 2.4 3.43 2.4 5.333 0 6.602-8.06 7.59-6.4 17.334C13.111 27.787 12 25.564 12 22.666c0-4.434 7.333-8 7.333-16Zm-6-5.333C15.111 3.555 16 5.556 16 7.333c0 8.333-11.333 10.962-5.333 22-3.488-.774-6-4-6-8 0-8.667 8.666-10 8.666-20Z"
fill="#111827"></path></svg
>
<h2>What's New in Astro 5.0?</h2>
<p>
From content layers to server islands, click to learn more about the new features and
improvements in Astro 5.0
</p>
</a>
</div>
<style>
#background {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
filter: blur(100px);
}
#container {
font-family: Inter, Roboto, 'Helvetica Neue', 'Arial Nova', 'Nimbus Sans', Arial, sans-serif;
height: 100%;
}
main {
height: 100%;
display: flex;
justify-content: center;
}
#hero {
display: flex;
align-items: start;
flex-direction: column;
justify-content: center;
padding: 16px;
}
h1 {
font-size: 22px;
margin-top: 0.25em;
}
#links {
display: flex;
gap: 16px;
}
#links a {
display: flex;
align-items: center;
padding: 10px 12px;
color: #111827;
text-decoration: none;
transition: color 0.2s;
}
#links a:hover {
color: rgb(78, 80, 86);
}
#links a svg {
height: 1em;
margin-left: 8px;
}
#links a.button {
color: white;
background: linear-gradient(83.21deg, #3245ff 0%, #bc52ee 100%);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.12),
inset 0 -2px 0 rgba(0, 0, 0, 0.24);
border-radius: 10px;
}
#links a.button:hover {
color: rgb(230, 230, 230);
box-shadow: none;
}
pre {
font-family:
ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono',
monospace;
font-weight: normal;
background: linear-gradient(14deg, #d83333 0%, #f041ff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0;
}
h2 {
margin: 0 0 1em;
font-weight: normal;
color: #111827;
font-size: 20px;
}
p {
color: #4b5563;
font-size: 16px;
line-height: 24px;
letter-spacing: -0.006em;
margin: 0;
}
code {
display: inline-block;
background:
linear-gradient(66.77deg, #f3cddd 0%, #f5cee7 100%) padding-box,
linear-gradient(155deg, #d83333 0%, #f041ff 18%, #f5cee7 45%) border-box;
border-radius: 8px;
border: 1px solid transparent;
padding: 6px 8px;
}
.box {
padding: 16px;
background: rgba(255, 255, 255, 1);
border-radius: 16px;
border: 1px solid white;
}
#news {
position: absolute;
bottom: 16px;
right: 16px;
max-width: 300px;
text-decoration: none;
transition: background 0.2s;
backdrop-filter: blur(50px);
}
#news:hover {
background: rgba(255, 255, 255, 0.55);
}
@media screen and (max-height: 368px) {
#news {
display: none;
}
}
@media screen and (max-width: 768px) {
#container {
display: flex;
flex-direction: column;
}
#hero {
display: block;
padding-top: 10%;
}
#links {
flex-wrap: wrap;
}
#links a.button {
padding: 14px 18px;
}
#news {
right: 16px;
left: 16px;
bottom: 2.5rem;
max-width: 100%;
}
h1 {
line-height: 1.5;
}
}
</style>

View file

@ -0,0 +1,93 @@
[
{
"id": 1,
"name": "Konstanzer Münster",
"category": "Sehenswürdigkeit",
"description": "Das Konstanzer Münster ist eine imposante Basilika, die über Jahrhunderte das Zentrum des Bistums Konstanz war. Besucher können den Turm besteigen und einen atemberaubenden Blick über die Stadt und den Bodensee genießen.",
"image": "/images/muenster.svg",
"address": "Münsterplatz 1, 78462 Konstanz",
"coordinates": {
"lat": 47.663,
"lng": 9.175
},
"timeline": [
{ "year": "ca. 600", "description": "Gründung einer ersten Bischofskirche." },
{ "year": "1054-1089", "description": "Neubau der Basilika nach ottonischem Vorbild." },
{
"year": "1414-1418",
"description": "Das Münster ist Schauplatz des Konzils von Konstanz."
},
{
"year": "1844-1853",
"description": "Neugotische Umgestaltung des Turms durch Heinrich Hübsch."
}
]
},
{
"id": 2,
"name": "Imperia",
"category": "Sehenswürdigkeit",
"description": "Die Imperia ist eine satirische Statue im Hafen von Konstanz, die an das Konzil von Konstanz erinnert. Sie dreht sich langsam um ihre Achse und ist ein beliebtes Fotomotiv.",
"image": "/images/imperia.svg",
"address": "Hafenstraße, 78462 Konstanz",
"coordinates": {
"lat": 47.66,
"lng": 9.18
}
},
{
"id": 3,
"name": "Restaurant Ophelia",
"category": "Restaurant",
"description": "Das mit zwei Michelin-Sternen ausgezeichnete Restaurant Ophelia bietet eine exquisite Küche in einem eleganten Ambiente. Es befindet sich im Hotel Riva am Ufer des Bodensees.",
"image": "/images/ophelia.svg",
"address": "Seestraße 25, 78464 Konstanz",
"coordinates": {
"lat": 47.67,
"lng": 9.19
}
},
{
"id": 4,
"name": "LAGO Shopping-Center",
"category": "Laden",
"description": "Das LAGO ist das größte Einkaufszentrum am Bodensee und bietet eine Vielzahl von Geschäften, Restaurants und Cafés. Es ist ein beliebter Treffpunkt für Einheimische und Touristen.",
"image": "/images/lago.svg",
"address": "Bodanstraße 1, 78462 Konstanz",
"coordinates": {
"lat": 47.658,
"lng": 9.176
}
},
{
"id": 5,
"name": "Rosgartenmuseum",
"category": "Museum",
"description": "Das Rosgartenmuseum ist das städtische Museum für Kunst, Kultur und Geschichte von Konstanz und der Bodenseeregion. Es wurde 1870 gegründet und befindet sich in einem ehemaligen Zunfthaus.",
"image": "/images/rosgartenmuseum.svg",
"address": "Rosgartenstraße 3-5, 78462 Konstanz",
"coordinates": {
"lat": 47.661,
"lng": 9.174
},
"timeline": [
{ "year": "1454", "description": "Das Gebäude wird als Zunfthaus der Metzger errichtet." },
{
"year": "1870",
"description": "Gründung des Museums durch den Apotheker und Stadtrat Ludwig Leiner."
}
]
},
{
"id": 6,
"name": "Archäologisches Landesmuseum Baden-Württemberg",
"category": "Museum",
"description": "Das Archäologische Landesmuseum (ALM) in Konstanz ist ein Zweigmuseum des ALM in Stuttgart und zeigt Funde aus der Archäologie, Geschichte und Kultur der Region.",
"image": "/images/alm.svg",
"address": "Benediktinerplatz 5, 78467 Konstanz",
"coordinates": {
"lat": 47.665,
"lng": 9.171
}
}
]

View file

@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>Astro Basics</title>
</head>
<body>
<slot />
</body>
</html>
<style>
html,
body {
margin: 0;
width: 100%;
height: 100%;
}
</style>

View file

@ -0,0 +1,46 @@
---
import Layout from '../layouts/Layout.astro';
import LocationCard from '../components/LocationCard.astro';
import Filter from '../components/Filter.astro';
import locations from '../data/locations.json';
---
<Layout>
<main>
<h1>CityCorners Konstanz</h1>
<Filter />
<div id="locations-container" class="locations-grid">
{locations.map((location) => <LocationCard location={location} />)}
</div>
</main>
</Layout>
<style>
.locations-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
const filter = document.getElementById('category-filter');
const locationsContainer = document.getElementById('locations-container');
const allLocations = Array.from(locationsContainer.children);
filter.addEventListener('change', (event) => {
const selectedCategory = event.target.value;
locationsContainer.innerHTML = ''; // Clear existing locations
const filteredLocations = allLocations.filter((locationElement) => {
if (!selectedCategory) return true; // Show all if no category is selected
return locationElement.dataset.category === selectedCategory;
});
filteredLocations.forEach((locationElement) => {
locationsContainer.appendChild(locationElement);
});
});
});
</script>

View file

@ -0,0 +1,113 @@
---
import Layout from '../../layouts/Layout.astro';
import locations from '../../data/locations.json';
export async function getStaticPaths() {
return locations.map((location) => ({
params: { id: location.id.toString() },
}));
}
const { id } = Astro.params;
const location = locations.find((loc) => loc.id.toString() === id);
if (!location) {
return Astro.redirect('/404');
}
---
<Layout>
<main class="location-detail">
<a href="/" class="back-link">&laquo; Zurück zur Übersicht</a>
<article>
<img src={location.image} alt={location.name} class="location-image" />
<h1>{location.name}</h1>
<p class="category">{location.category}</p>
<p class="description">{location.description}</p>
{
location.timeline && (
<div class="timeline">
<h2>Historische Zeitachse</h2>
<ul>
{location.timeline.map((event) => (
<li>
<strong>{event.year}:</strong> {event.description}
</li>
))}
</ul>
</div>
)
}
<p class="address"><strong>Adresse:</strong> {location.address}</p>
<!-- In a real application, you would use a map component here -->
<div class="map-placeholder">
Karte für {location.coordinates.lat}, {location.coordinates.lng}
</div>
</article>
</main>
</Layout>
<style>
.location-detail {
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.back-link {
display: inline-block;
margin-bottom: 20px;
text-decoration: none;
color: #333;
}
.location-image {
width: 100%;
height: auto;
border-radius: 8px;
margin-bottom: 20px;
}
.category {
font-style: italic;
color: #666;
}
.description {
line-height: 1.6;
}
.address {
margin-top: 20px;
}
.map-placeholder {
margin-top: 20px;
background-color: #f0f0f0;
padding: 50px;
text-align: center;
border-radius: 8px;
}
.timeline {
margin-top: 30px;
}
.timeline h2 {
margin-bottom: 15px;
}
.timeline ul {
list-style-type: none;
padding-left: 0;
border-left: 2px solid #ccc;
margin-left: 10px;
}
.timeline li {
padding: 10px 20px;
position: relative;
}
.timeline li::before {
content: '';
width: 10px;
height: 10px;
background-color: #ccc;
border-radius: 50%;
position: absolute;
left: -6px;
top: 18px;
}
</style>

View file

@ -0,0 +1,26 @@
import fs from 'fs/promises';
import path from 'path';
// This script is a placeholder for a data update agent.
// In a real application, this script would fetch data from an API,
// process it, and then write it to the locations.json file.
async function updateLocations() {
try {
const dataPath = path.join(process.cwd(), 'src', 'data', 'locations.json');
const data = await fs.readFile(dataPath, 'utf-8');
const locations = JSON.parse(data);
console.log('Successfully read location data.');
console.log(`Found ${locations.length} locations.`);
// Here you could add logic to fetch new data and compare it
// with the existing data to determine if an update is needed.
console.log('Location data is up to date.');
} catch (error) {
console.error('Error updating location data:', error);
}
}
updateLocations();

View file

@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}

View file

@ -0,0 +1,6 @@
# Cloudflare Pages configuration for CityCorners Landing
# Deployed via GitHub Actions (Direct Upload)
name = "citycorners-landing"
compatibility_date = "2024-12-01"
pages_build_output_dir = "dist"

View file

@ -0,0 +1,93 @@
# syntax=docker/dockerfile:1
# Build stage
FROM node:20-alpine AS builder
# Build arguments for SvelteKit static env vars
ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-auth:3001
ARG PUBLIC_CITYCORNERS_API_URL=http://citycorners-backend:3025
# Set as environment variables for build
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
ENV PUBLIC_CITYCORNERS_API_URL=$PUBLIC_CITYCORNERS_API_URL
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app
# Copy root workspace files
COPY pnpm-workspace.yaml ./
COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages needed by citycorners web
COPY packages/shared-auth ./packages/shared-auth
COPY packages/shared-auth-ui ./packages/shared-auth-ui
COPY packages/shared-branding ./packages/shared-branding
COPY packages/shared-config ./packages/shared-config
COPY packages/shared-error-tracking ./packages/shared-error-tracking
COPY packages/shared-icons ./packages/shared-icons
COPY packages/shared-stores ./packages/shared-stores
COPY packages/shared-tailwind ./packages/shared-tailwind
COPY packages/shared-theme ./packages/shared-theme
COPY packages/shared-theme-ui ./packages/shared-theme-ui
COPY packages/shared-types ./packages/shared-types
COPY packages/shared-ui ./packages/shared-ui
COPY packages/shared-utils ./packages/shared-utils
COPY packages/shared-vite-config ./packages/shared-vite-config
# Copy citycorners web
COPY apps/citycorners/apps/web ./apps/citycorners/apps/web
# Install dependencies
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile
# Build shared packages that need building
WORKDIR /app/packages/shared-vite-config
RUN pnpm build
WORKDIR /app/packages/shared-auth
RUN pnpm build || true
WORKDIR /app/packages/shared-error-tracking
RUN pnpm build
# Build the web app
WORKDIR /app/apps/citycorners/apps/web
RUN pnpm exec svelte-kit sync
RUN pnpm build
# Production stage
FROM node:20-alpine AS production
WORKDIR /app/apps/citycorners/apps/web
# Copy the pnpm store that symlinks point to
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
# Copy the app's node_modules
COPY --from=builder /app/apps/citycorners/apps/web/node_modules ./node_modules
# Copy built application
COPY --from=builder /app/apps/citycorners/apps/web/build ./build
COPY --from=builder /app/apps/citycorners/apps/web/package.json ./
# Copy entrypoint script
COPY apps/citycorners/apps/web/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# Expose port
EXPOSE 5018
# Set environment variables
ENV NODE_ENV=production
ENV PORT=5018
ENV HOST=0.0.0.0
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:5018/health || exit 1
# Run the app
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["node", "build"]

View file

@ -0,0 +1,9 @@
#!/bin/sh
set -e
echo "Starting CityCorners Web with runtime configuration..."
echo "PUBLIC_MANA_CORE_AUTH_URL_CLIENT: ${PUBLIC_MANA_CORE_AUTH_URL_CLIENT:-not set}"
echo "PUBLIC_CITYCORNERS_API_URL_CLIENT: ${PUBLIC_CITYCORNERS_API_URL_CLIENT:-not set}"
# Execute the main command
exec "$@"

View file

@ -0,0 +1,42 @@
{
"name": "@citycorners/web",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@manacore/shared-vite-config": "workspace:*",
"@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^2.47.1",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.1.7",
"@types/leaflet": "^1.9.8",
"@types/node": "^20.0.0",
"svelte": "^5.41.0",
"svelte-check": "^4.3.3",
"tailwindcss": "^4.1.7",
"tslib": "^2.4.1",
"typescript": "^5.9.3",
"vite": "^6.0.0"
},
"dependencies": {
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-error-tracking": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-stores": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"leaflet": "^1.9.4"
}
}

View file

@ -0,0 +1,8 @@
@import 'tailwindcss';
@import '@manacore/shared-tailwind/themes.css';
@source '../../../../packages/shared-ui/src';
@source '../../../../packages/shared-auth-ui/src';
@source '../../../../packages/shared-branding/src';
@source '../../../../packages/shared-theme-ui/src';
@source '../../../../packages/shared-stores/src';

View file

@ -0,0 +1,9 @@
/// <reference types="@sveltejs/kit" />
declare namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}

View file

@ -0,0 +1,19 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#2563eb" />
<meta name="description" content="CityCorners - Entdecke Konstanz" />
<title>CityCorners</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
<script>
window.__PUBLIC_MANA_CORE_AUTH_URL__ = '%env:PUBLIC_MANA_CORE_AUTH_URL_CLIENT%' || '%env:PUBLIC_MANA_CORE_AUTH_URL%';
window.__PUBLIC_BACKEND_URL__ = '%env:PUBLIC_BACKEND_URL_CLIENT%' || '%env:PUBLIC_BACKEND_URL%';
</script>
</body>
</html>

View file

@ -0,0 +1,5 @@
import type { HandleClientError } from '@sveltejs/kit';
export const handleError: HandleClientError = ({ error }) => {
console.error('Client error:', error);
};

View file

@ -0,0 +1,19 @@
import type { Handle } from '@sveltejs/kit';
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT = process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || '';
const PUBLIC_BACKEND_URL_CLIENT =
process.env.PUBLIC_CITYCORNERS_API_URL_CLIENT || process.env.PUBLIC_BACKEND_URL || '';
export const handle: Handle = async ({ event, resolve }) => {
const response = await resolve(event, {
transformPageChunk: ({ html }) => {
return html
.replace('%env:PUBLIC_MANA_CORE_AUTH_URL_CLIENT%', PUBLIC_MANA_CORE_AUTH_URL_CLIENT)
.replace('%env:PUBLIC_MANA_CORE_AUTH_URL%', process.env.PUBLIC_MANA_CORE_AUTH_URL || '')
.replace('%env:PUBLIC_BACKEND_URL_CLIENT%', PUBLIC_BACKEND_URL_CLIENT)
.replace('%env:PUBLIC_BACKEND_URL%', process.env.PUBLIC_BACKEND_URL || '');
},
});
return response;
};

View file

@ -0,0 +1,212 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Uses Mana Core Auth
*/
import { browser } from '$app/environment';
import { initializeWebAuth } from '@manacore/shared-auth';
import type { UserData } from '@manacore/shared-auth';
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
function getBackendUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
.__PUBLIC_BACKEND_URL__;
return injectedUrl || 'http://localhost:3025';
}
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3025';
}
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({
baseUrl: getAuthUrl(),
backendUrl: getBackendUrl(),
});
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}
return _authService;
}
function getTokenManager() {
if (!browser) return null;
getAuthService();
return _tokenManager;
}
let user = $state<UserData | null>(null);
let loading = $state(true);
let initialized = $state(false);
export const authStore = {
get user() {
return user;
},
get loading() {
return loading;
},
get isAuthenticated() {
return !!user;
},
get initialized() {
return initialized;
},
async initialize() {
if (initialized) return;
const authService = getAuthService();
if (!authService) {
initialized = true;
loading = false;
return;
}
loading = true;
try {
let authenticated = await authService.isAuthenticated();
if (!authenticated) {
const ssoResult = await authService.trySSO();
if (ssoResult.success) {
authenticated = true;
}
}
if (authenticated) {
const userData = await authService.getUserFromToken();
user = userData;
}
initialized = true;
} catch (error) {
console.error('Failed to initialize auth:', error);
user = null;
} finally {
loading = false;
}
},
async signIn(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.signIn(email, password);
if (!result.success) {
return { success: false, error: result.error || 'Login failed' };
}
const userData = await authService.getUserFromToken();
user = userData;
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
async signUp(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
try {
const sourceAppUrl = browser ? window.location.origin : undefined;
const result = await authService.signUp(email, password, sourceAppUrl);
if (!result.success) {
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
}
if (result.needsVerification) {
return { success: true, needsVerification: true };
}
const signInResult = await this.signIn(email, password);
return { ...signInResult, needsVerification: false };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage, needsVerification: false };
}
},
async signOut() {
const authService = getAuthService();
if (!authService) {
user = null;
return;
}
try {
await authService.signOut();
user = null;
} catch (error) {
console.error('Sign out error:', error);
user = null;
}
},
async resetPassword(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.forgotPassword(email);
if (!result.success) {
return { success: false, error: result.error || 'Password reset failed' };
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
async getAccessToken() {
const authService = getAuthService();
if (!authService) return null;
return await authService.getAppToken();
},
async getValidToken(): Promise<string | null> {
const tokenManager = getTokenManager();
if (!tokenManager) return null;
return await tokenManager.getValidToken();
},
async resendVerificationEmail(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const sourceAppUrl = browser ? window.location.origin : undefined;
const result = await authService.resendVerificationEmail(email, sourceAppUrl);
if (!result.success) {
return { success: false, error: result.error || 'Failed to send verification email' };
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
};

View file

@ -0,0 +1,109 @@
/**
* Favorites Store - Manages favorite locations using Svelte 5 runes
*/
import { browser } from '$app/environment';
import { authStore } from './auth.svelte';
interface Favorite {
id: string;
userId: string;
locationId: string;
createdAt: string;
}
function getBackendUrl(): string {
if (browser && typeof window !== 'undefined') {
return (window as any).__PUBLIC_BACKEND_URL__ || 'http://localhost:3025';
}
return 'http://localhost:3025';
}
let favoriteLocationIds = $state<Set<string>>(new Set());
let loading = $state(false);
export const favoritesStore = {
get favoriteIds() {
return favoriteLocationIds;
},
get loading() {
return loading;
},
isFavorite(locationId: string): boolean {
return favoriteLocationIds.has(locationId);
},
async load() {
if (!authStore.isAuthenticated) return;
loading = true;
try {
const token = await authStore.getValidToken();
if (!token) return;
const res = await fetch(`${getBackendUrl()}/favorites`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
favoriteLocationIds = new Set(data.favorites.map((f: Favorite) => f.locationId));
}
} catch (err) {
console.error('Failed to load favorites:', err);
} finally {
loading = false;
}
},
async toggle(locationId: string) {
if (!authStore.isAuthenticated) return;
const token = await authStore.getValidToken();
if (!token) return;
const isFav = favoriteLocationIds.has(locationId);
// Optimistic update
const newSet = new Set(favoriteLocationIds);
if (isFav) {
newSet.delete(locationId);
} else {
newSet.add(locationId);
}
favoriteLocationIds = newSet;
try {
const res = await fetch(`${getBackendUrl()}/favorites/${locationId}`, {
method: isFav ? 'DELETE' : 'POST',
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
// Revert on error
const revertSet = new Set(favoriteLocationIds);
if (isFav) {
revertSet.add(locationId);
} else {
revertSet.delete(locationId);
}
favoriteLocationIds = revertSet;
}
} catch (err) {
console.error('Failed to toggle favorite:', err);
// Revert
const revertSet = new Set(favoriteLocationIds);
if (isFav) {
revertSet.add(locationId);
} else {
revertSet.delete(locationId);
}
favoriteLocationIds = revertSet;
}
},
clear() {
favoriteLocationIds = new Set();
},
};

View file

@ -0,0 +1,89 @@
/**
* Theme Store - Manages theme state using Svelte 5 runes
*/
import { browser } from '$app/environment';
import type { ThemeMode, ThemeVariant } from '@manacore/shared-theme';
let mode = $state<ThemeMode>('system');
let variant = $state<ThemeVariant>('lume');
let initialized = $state(false);
let isDark = $derived(
mode === 'system'
? browser
? window.matchMedia('(prefers-color-scheme: dark)').matches
: true
: mode === 'dark'
);
function applyTheme() {
if (!browser) return;
const root = document.documentElement;
if (isDark) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
root.setAttribute('data-theme', variant);
}
export const theme = {
get mode() {
return mode;
},
get variant() {
return variant;
},
get isDark() {
return isDark;
},
get initialized() {
return initialized;
},
initialize() {
if (!browser || initialized) return;
const savedMode = localStorage.getItem('citycorners-theme-mode') as ThemeMode | null;
const savedVariant = localStorage.getItem('citycorners-theme-variant') as ThemeVariant | null;
if (savedMode) mode = savedMode;
if (savedVariant) variant = savedVariant;
applyTheme();
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', () => {
if (mode === 'system') {
applyTheme();
}
});
initialized = true;
},
setMode(newMode: ThemeMode) {
mode = newMode;
if (browser) {
localStorage.setItem('citycorners-theme-mode', newMode);
applyTheme();
}
},
setVariant(newVariant: ThemeVariant) {
variant = newVariant;
if (browser) {
localStorage.setItem('citycorners-theme-variant', newVariant);
applyTheme();
}
},
toggleMode() {
const newMode = isDark ? 'light' : 'dark';
this.setMode(newMode);
},
};

View file

@ -0,0 +1,207 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
import { THEME_DEFINITIONS, DEFAULT_THEME_VARIANTS } from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { getPillAppItems } from '@manacore/shared-branding';
const appItems = getPillAppItems('citycorners');
let { children } = $props();
let isDark = $derived(theme.isDark);
let showNav = $state(true);
let visibleThemes = $derived<ThemeVariant[]>([...DEFAULT_THEME_VARIANTS]);
let themeVariantItems = $derived<PillDropdownItem[]>(
visibleThemes.map((variant) => ({
id: variant,
label: THEME_DEFINITIONS[variant]?.label || variant,
icon: THEME_DEFINITIONS[variant]?.icon || '🎨',
onClick: () => theme.setVariant(variant),
active: (theme.variant || 'lume') === variant,
}))
);
let currentThemeVariantLabel = $derived(
THEME_DEFINITIONS[theme.variant]?.label || THEME_DEFINITIONS.lume?.label || 'Lume'
);
let userEmail = $derived(authStore.user?.email || 'Menü');
let navItems = $derived<PillNavItem[]>([
{ href: '/', label: 'Entdecken', icon: 'compass' },
{ href: '/map', label: 'Karte', icon: 'mappin' },
{ href: '/favorites', label: 'Favoriten', icon: 'heart' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
]);
function handleToggleTheme() {
theme.toggleMode();
}
function handleThemeModeChange(mode: 'light' | 'dark' | 'system') {
theme.setMode(mode);
}
async function handleLogout() {
await authStore.signOut();
favoritesStore.clear();
goto('/login');
}
function handleNavToggle() {
showNav = !showNav;
}
onMount(() => {
const savedNav = localStorage.getItem('citycorners-nav-visible');
if (savedNav !== null) showNav = savedNav !== 'false';
});
</script>
<div class="layout-container">
{#if showNav}
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="CityCorners"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLogout={authStore.isAuthenticated}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#2563eb"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
/>
{/if}
<button
class="pillnav-fab"
onclick={handleNavToggle}
title={showNav ? 'Navigation ausblenden' : 'Navigation einblenden'}
>
{#if !showNav}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="fab-icon">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
{:else}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="fab-icon">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{/if}
</button>
<main class="main-content bg-background">
<div class="content-wrapper">
{@render children()}
</div>
</main>
</div>
<style>
.layout-container {
display: flex;
flex-direction: column;
min-height: 100vh;
position: relative;
}
.main-content {
flex: 1;
transition: all 300ms ease;
padding-bottom: calc(150px + env(safe-area-inset-bottom));
}
.content-wrapper {
max-width: 1200px;
margin-left: auto;
margin-right: auto;
padding: 1rem;
}
@media (min-width: 640px) {
.content-wrapper {
padding: 1.5rem;
}
}
@media (min-width: 1024px) {
.content-wrapper {
padding: 2rem;
}
}
@media (max-width: 768px) {
.main-content {
padding-bottom: calc(160px + env(safe-area-inset-bottom));
}
}
.pillnav-fab {
position: fixed;
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
right: 1rem;
width: 54px;
height: 54px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
transition: all 0.2s ease;
}
.pillnav-fab:hover {
transform: scale(1.05);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
.pillnav-fab:active {
transform: scale(0.95);
}
:global(.dark) .pillnav-fab {
background: rgba(30, 30, 30, 0.9);
border-color: rgba(255, 255, 255, 0.1);
}
.fab-icon {
width: 24px;
height: 24px;
color: var(--color-foreground);
}
</style>

View file

@ -0,0 +1,158 @@
<script lang="ts">
import { onMount } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
interface Location {
id: string;
name: string;
category: string;
description: string;
address?: string;
latitude?: number;
longitude?: number;
imageUrl?: string;
}
let locations = $state<Location[]>([]);
let loading = $state(true);
let selectedCategory = $state<string | null>(null);
const backendUrl =
typeof window !== 'undefined'
? (window as any).__PUBLIC_BACKEND_URL__ || 'http://localhost:3025'
: 'http://localhost:3025';
const categories = [
{ value: 'sight', label: 'Sehenswürdigkeiten' },
{ value: 'restaurant', label: 'Restaurants' },
{ value: 'shop', label: 'Läden' },
{ value: 'museum', label: 'Museen' },
];
let filtered = $derived(
selectedCategory ? locations.filter((l) => l.category === selectedCategory) : locations
);
onMount(async () => {
try {
const res = await fetch(`${backendUrl}/locations`);
const data = await res.json();
locations = data.locations;
} catch (err) {
console.error('Failed to load locations:', err);
} finally {
loading = false;
}
if (authStore.isAuthenticated) {
favoritesStore.load();
}
});
function handleFavoriteToggle(e: MouseEvent, locationId: string) {
e.preventDefault();
e.stopPropagation();
favoritesStore.toggle(locationId);
}
</script>
<svelte:head>
<title>CityCorners - Entdecke Konstanz</title>
</svelte:head>
<header class="mb-6">
<h1 class="text-2xl font-bold text-foreground">Entdecke Konstanz</h1>
<p class="text-foreground-secondary">Sehenswürdigkeiten, Restaurants, Museen und mehr</p>
</header>
<div class="mb-6 flex flex-wrap gap-2">
<button
class="rounded-full px-4 py-2 text-sm transition-colors {selectedCategory === null
? 'bg-primary text-white'
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover'}"
onclick={() => (selectedCategory = null)}
>
Alle
</button>
{#each categories as cat}
<button
class="rounded-full px-4 py-2 text-sm transition-colors {selectedCategory === cat.value
? 'bg-primary text-white'
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover'}"
onclick={() => (selectedCategory = cat.value)}
>
{cat.label}
</button>
{/each}
</div>
{#if loading}
<p class="text-foreground-secondary">Laden...</p>
{:else if filtered.length === 0}
<p class="text-foreground-secondary">Keine Locations gefunden.</p>
{:else}
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{#each filtered as location}
<a
href="/locations/{location.id}"
class="group relative overflow-hidden rounded-xl border border-border bg-background-card transition-shadow hover:shadow-lg"
>
{#if location.imageUrl}
<img src={location.imageUrl} alt={location.name} class="h-48 w-full object-cover" />
{:else}
<div class="flex h-48 items-center justify-center bg-background-card-hover">
<span class="text-4xl">📍</span>
</div>
{/if}
<!-- Favorite button -->
{#if authStore.isAuthenticated}
<button
class="absolute right-3 top-3 flex h-9 w-9 items-center justify-center rounded-full bg-black/30 backdrop-blur-sm transition-all hover:bg-black/50"
onclick={(e) => handleFavoriteToggle(e, location.id)}
title={favoritesStore.isFavorite(location.id)
? 'Aus Favoriten entfernen'
: 'Zu Favoriten hinzufügen'}
>
{#if favoritesStore.isFavorite(location.id)}
<svg class="h-5 w-5 text-red-500" 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>
{:else}
<svg
class="h-5 w-5 text-white"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"
/>
</svg>
{/if}
</button>
{/if}
<div class="p-4">
<span
class="mb-1 inline-block rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary"
>
{categories.find((c) => c.value === location.category)?.label ?? location.category}
</span>
<h2 class="text-lg font-semibold text-foreground group-hover:text-primary">
{location.name}
</h2>
<p class="mt-1 line-clamp-2 text-sm text-foreground-secondary">
{location.description}
</p>
</div>
</a>
{/each}
</div>
{/if}

View file

@ -0,0 +1,124 @@
<script lang="ts">
import { onMount } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
interface Location {
id: string;
name: string;
category: string;
description: string;
imageUrl?: string;
}
let allLocations = $state<Location[]>([]);
let loading = $state(true);
const backendUrl =
typeof window !== 'undefined'
? (window as any).__PUBLIC_BACKEND_URL__ || 'http://localhost:3025'
: 'http://localhost:3025';
const categoryLabels: Record<string, string> = {
sight: 'Sehenswürdigkeit',
restaurant: 'Restaurant',
shop: 'Laden',
museum: 'Museum',
};
let favoriteLocations = $derived(allLocations.filter((l) => favoritesStore.isFavorite(l.id)));
onMount(async () => {
try {
const res = await fetch(`${backendUrl}/locations`);
const data = await res.json();
allLocations = data.locations;
} catch (err) {
console.error('Failed to load locations:', err);
} finally {
loading = false;
}
if (authStore.isAuthenticated) {
await favoritesStore.load();
}
});
function handleRemove(e: MouseEvent, locationId: string) {
e.preventDefault();
e.stopPropagation();
favoritesStore.toggle(locationId);
}
</script>
<svelte:head>
<title>Favoriten - CityCorners</title>
</svelte:head>
<header class="mb-6">
<h1 class="text-2xl font-bold text-foreground">Favoriten</h1>
<p class="text-foreground-secondary">Deine gespeicherten Orte</p>
</header>
{#if !authStore.isAuthenticated}
<div class="rounded-xl border border-border bg-background-card p-8 text-center">
<p class="mb-4 text-foreground-secondary">Melde dich an, um Favoriten zu speichern.</p>
<a
href="/login"
class="inline-block rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-primary/90"
>
Anmelden
</a>
</div>
{:else if loading}
<p class="text-foreground-secondary">Laden...</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">
Noch keine Favoriten. Tippe auf das Herz bei einer Location, um sie zu speichern.
</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"
>{categoryLabels[location.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="Aus Favoriten entfernen"
>
<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}

View file

@ -0,0 +1,278 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { authStore } from '$lib/stores/auth.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
interface TimelineEntry {
year: string;
event: string;
}
interface Location {
id: string;
name: string;
category: string;
description: string;
address?: string;
latitude?: number;
longitude?: number;
imageUrl?: string;
timeline?: TimelineEntry[];
}
let location = $state<Location | null>(null);
let loading = $state(true);
let mapContainer: HTMLDivElement;
const backendUrl =
typeof window !== 'undefined'
? (window as any).__PUBLIC_BACKEND_URL__ || 'http://localhost:3025'
: 'http://localhost:3025';
const categoryLabels: Record<string, string> = {
sight: 'Sehenswürdigkeit',
restaurant: 'Restaurant',
shop: 'Laden',
museum: 'Museum',
};
const categoryColors: Record<string, string> = {
sight: '#2563eb',
restaurant: '#dc2626',
shop: '#16a34a',
museum: '#9333ea',
};
onMount(async () => {
try {
const res = await fetch(`${backendUrl}/locations/${$page.params.id}`);
const data = await res.json();
location = data.location;
} catch (err) {
console.error('Failed to load location:', err);
} finally {
loading = false;
}
if (authStore.isAuthenticated) {
favoritesStore.load();
}
});
// Initialize mini map after location loads
$effect(() => {
if (!browser || !location || !location.latitude || !location.longitude || !mapContainer) return;
const initMap = async () => {
const L = await import('leaflet');
const map = L.map(mapContainer, { zoomControl: false, attributionControl: false }).setView(
[location!.latitude!, location!.longitude!],
16
);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
}).addTo(map);
const color = categoryColors[location!.category] || '#6b7280';
const icon = L.divIcon({
className: 'custom-marker',
html: `<div style="background:${color};width:32px;height:32px;border-radius:50%;border:3px solid white;box-shadow:0 2px 8px rgba(0,0,0,0.3);"></div>`,
iconSize: [32, 32],
iconAnchor: [16, 16],
});
L.marker([location!.latitude!, location!.longitude!], { icon }).addTo(map);
};
initMap();
});
</script>
<svelte:head>
<title>{location?.name || 'Location'} - CityCorners</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
</svelte:head>
{#if loading}
<div class="flex items-center justify-center py-20">
<div
class="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin"
></div>
</div>
{:else if !location}
<div class="py-20 text-center">
<span class="mb-4 block text-5xl">🔍</span>
<p class="text-foreground-secondary">Location nicht gefunden.</p>
<a href="/" class="mt-4 inline-block text-sm text-primary hover:underline"
>Zurück zur Übersicht</a
>
</div>
{:else}
<!-- Hero image with overlay -->
<div class="relative -mx-4 -mt-4 mb-6 sm:-mx-6 sm:-mt-6 lg:-mx-8 lg:-mt-8">
{#if location.imageUrl}
<img src={location.imageUrl} alt={location.name} class="h-72 w-full object-cover sm:h-80" />
{:else}
<div
class="flex h-72 items-center justify-center bg-gradient-to-br from-primary/20 to-primary/5 sm:h-80"
>
<span class="text-7xl">📍</span>
</div>
{/if}
<!-- Back button + Favorite button overlay -->
<div class="absolute left-4 top-4">
<a
href="/"
class="flex h-10 w-10 items-center justify-center rounded-full bg-black/30 text-white backdrop-blur-sm transition-colors hover:bg-black/50"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</a>
</div>
{#if authStore.isAuthenticated}
<div class="absolute right-4 top-4">
<button
class="flex h-10 w-10 items-center justify-center rounded-full bg-black/30 backdrop-blur-sm transition-all hover:bg-black/50"
onclick={() => favoritesStore.toggle(location!.id)}
title={favoritesStore.isFavorite(location.id)
? 'Aus Favoriten entfernen'
: 'Zu Favoriten hinzufügen'}
>
{#if favoritesStore.isFavorite(location.id)}
<svg class="h-5 w-5 text-red-500" 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>
{:else}
<svg
class="h-5 w-5 text-white"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"
/>
</svg>
{/if}
</button>
</div>
{/if}
<!-- Category badge on image -->
<div class="absolute bottom-4 left-4">
<span
class="rounded-full px-3 py-1 text-sm font-medium text-white backdrop-blur-sm"
style="background: {categoryColors[location.category] || '#6b7280'}cc"
>
{categoryLabels[location.category] || location.category}
</span>
</div>
</div>
<!-- Content -->
<div class="space-y-6">
<div>
<h1 class="text-3xl font-bold text-foreground">{location.name}</h1>
{#if location.address}
<p class="mt-2 flex items-center gap-1.5 text-foreground-secondary">
<svg
class="h-4 w-4 flex-shrink-0"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
/>
</svg>
{location.address}
</p>
{/if}
</div>
<p class="text-base leading-relaxed text-foreground">{location.description}</p>
<!-- Mini Map -->
{#if location.latitude && location.longitude}
<div class="overflow-hidden rounded-xl border border-border">
<div bind:this={mapContainer} class="h-52 w-full"></div>
<a
href="https://www.openstreetmap.org/?mlat={location.latitude}&mlon={location.longitude}#map=17/{location.latitude}/{location.longitude}"
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-center gap-2 border-t border-border bg-background-card px-4 py-2.5 text-sm text-foreground-secondary transition-colors hover:text-primary"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
/>
</svg>
In OpenStreetMap öffnen
</a>
</div>
{/if}
<!-- Timeline -->
{#if location.timeline && location.timeline.length > 0}
<div>
<h2 class="mb-4 text-xl font-semibold text-foreground">Geschichte</h2>
<div class="relative space-y-0">
{#each location.timeline as entry, i}
<div class="relative flex gap-4 pb-6 {i < location.timeline!.length - 1 ? '' : ''}">
<!-- Timeline line -->
{#if i < location.timeline!.length - 1}
<div class="absolute left-[11px] top-6 h-full w-0.5 bg-border"></div>
{/if}
<!-- Dot -->
<div
class="relative z-10 mt-1.5 h-6 w-6 flex-shrink-0 rounded-full border-2 border-primary bg-background flex items-center justify-center"
>
<div class="h-2 w-2 rounded-full bg-primary"></div>
</div>
<!-- Content -->
<div>
<span class="font-mono text-sm font-bold text-primary">{entry.year}</span>
<p class="mt-0.5 text-sm text-foreground-secondary">{entry.event}</p>
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<style>
:global(.custom-marker) {
background: transparent !important;
border: none !important;
}
</style>

View file

@ -0,0 +1,127 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
interface Location {
id: string;
name: string;
category: string;
description: string;
address?: string;
latitude?: number;
longitude?: number;
imageUrl?: string;
}
let locations = $state<Location[]>([]);
let mapContainer: HTMLDivElement;
let map: any = null;
const backendUrl =
typeof window !== 'undefined'
? (window as any).__PUBLIC_BACKEND_URL__ || 'http://localhost:3025'
: 'http://localhost:3025';
const categoryColors: Record<string, string> = {
sight: '#2563eb',
restaurant: '#dc2626',
shop: '#16a34a',
museum: '#9333ea',
};
const categoryLabels: Record<string, string> = {
sight: 'Sehenswürdigkeit',
restaurant: 'Restaurant',
shop: 'Laden',
museum: 'Museum',
};
onMount(async () => {
// Load locations
try {
const res = await fetch(`${backendUrl}/locations`);
const data = await res.json();
locations = data.locations;
} catch (err) {
console.error('Failed to load locations:', err);
}
if (!browser) return;
// Dynamically import Leaflet (client-side only)
const L = await import('leaflet');
await import('leaflet/dist/leaflet.css');
// Center on Konstanz
map = L.map(mapContainer).setView([47.6603, 9.1757], 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19,
}).addTo(map);
// Add markers for locations with coordinates
for (const loc of locations) {
if (loc.latitude && loc.longitude) {
const color = categoryColors[loc.category] || '#6b7280';
const icon = L.divIcon({
className: 'custom-marker',
html: `<div style="background:${color};width:28px;height:28px;border-radius:50%;border:3px solid white;box-shadow:0 2px 6px rgba(0,0,0,0.3);"></div>`,
iconSize: [28, 28],
iconAnchor: [14, 14],
});
const marker = L.marker([loc.latitude, loc.longitude], { icon }).addTo(map);
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">${categoryLabels[loc.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">Details &rarr;</a>
</div>
`);
}
}
});
</script>
<svelte:head>
<title>Karte - CityCorners</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
</svelte:head>
<div class="map-page">
<header class="mb-4">
<h1 class="text-2xl font-bold text-foreground">Karte</h1>
<p class="text-foreground-secondary">Alle Orte in Konstanz</p>
</header>
<div class="legend mb-4 flex flex-wrap gap-3">
{#each Object.entries(categoryColors) as [key, color]}
<div class="flex items-center gap-1.5 text-sm text-foreground-secondary">
<div class="w-3 h-3 rounded-full" style="background:{color}"></div>
{categoryLabels[key]}
</div>
{/each}
</div>
<div
bind:this={mapContainer}
class="map-container rounded-xl overflow-hidden border border-border"
></div>
</div>
<style>
.map-container {
width: 100%;
height: calc(100vh - 300px);
min-height: 400px;
}
:global(.custom-marker) {
background: transparent !important;
border: none !important;
}
</style>

View file

@ -0,0 +1,123 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
const themeVariants: ThemeVariant[] = [
'lume',
'nature',
'stone',
'ocean',
'sunset',
'midnight',
'rose',
'lavender',
];
async function handleLogout() {
await authStore.signOut();
goto('/login');
}
</script>
<svelte:head>
<title>Einstellungen - CityCorners</title>
</svelte:head>
<div class="space-y-8">
<h1 class="text-2xl font-bold text-foreground">Einstellungen</h1>
<!-- Theme Mode -->
<section class="rounded-xl border border-border bg-background-card p-5">
<h2 class="mb-4 text-lg font-semibold text-foreground">Erscheinungsbild</h2>
<div class="space-y-4">
<div>
<label class="mb-2 block text-sm font-medium text-foreground-secondary">Modus</label>
<div class="flex gap-2">
{#each [{ value: 'light', label: '☀️ Hell' }, { value: 'dark', label: '🌙 Dunkel' }, { value: 'system', label: '💻 System' }] as opt}
<button
class="rounded-lg px-4 py-2 text-sm transition-colors {theme.mode === opt.value
? 'bg-primary text-white'
: 'bg-background text-foreground-secondary hover:bg-background-card-hover'}"
onclick={() => theme.setMode(opt.value as 'light' | 'dark' | 'system')}
>
{opt.label}
</button>
{/each}
</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-foreground-secondary">Farbschema</label>
<div class="grid grid-cols-4 gap-2">
{#each themeVariants as v}
<button
class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors {theme.variant ===
v
? 'bg-primary text-white'
: 'bg-background text-foreground-secondary hover:bg-background-card-hover'}"
onclick={() => theme.setVariant(v)}
>
<span>{THEME_DEFINITIONS[v]?.icon || '🎨'}</span>
<span>{THEME_DEFINITIONS[v]?.label || v}</span>
</button>
{/each}
</div>
</div>
</div>
</section>
<!-- Account -->
<section class="rounded-xl border border-border bg-background-card p-5">
<h2 class="mb-4 text-lg font-semibold text-foreground">Account</h2>
{#if authStore.isAuthenticated}
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-foreground-secondary">E-Mail</span>
<span class="text-sm font-medium text-foreground">{authStore.user?.email}</span>
</div>
<hr class="border-border" />
<button
class="w-full rounded-lg bg-red-500/10 px-4 py-2 text-sm font-medium text-red-500 transition-colors hover:bg-red-500/20"
onclick={handleLogout}
>
Abmelden
</button>
</div>
{:else}
<div class="space-y-3">
<p class="text-sm text-foreground-secondary">
Melde dich an, um Favoriten zu speichern und alle Features zu nutzen.
</p>
<div class="flex gap-2">
<a
href="/login"
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary/90"
>
Anmelden
</a>
<a
href="/register"
class="rounded-lg bg-background px-4 py-2 text-sm font-medium text-foreground-secondary transition-colors hover:bg-background-card-hover"
>
Registrieren
</a>
</div>
</div>
{/if}
</section>
<!-- About -->
<section class="rounded-xl border border-border bg-background-card p-5">
<h2 class="mb-4 text-lg font-semibold text-foreground">Über CityCorners</h2>
<p class="text-sm text-foreground-secondary">
CityCorners ist ein Stadtführer für Konstanz am Bodensee. Entdecke Sehenswürdigkeiten,
Restaurants, Museen und Läden.
</p>
<p class="mt-2 text-xs text-foreground-secondary/60">Version 0.0.1</p>
</section>
</div>

View file

@ -0,0 +1,13 @@
<script lang="ts">
let { children } = $props();
</script>
<div class="min-h-screen flex items-center justify-center p-4 bg-background">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-foreground">CityCorners</h1>
<p class="text-foreground-secondary mt-2">Entdecke Konstanz</p>
</div>
{@render children()}
</div>
</div>

View file

@ -0,0 +1,56 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { LoginPage } from '@manacore/shared-auth-ui';
import { CitycornersLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
const redirectTo = $derived.by(() => {
const queryRedirect = $page.url.searchParams.get('redirectTo');
if (queryRedirect) return queryRedirect;
if (browser) {
const sessionRedirect = sessionStorage.getItem('auth-return-url');
if (sessionRedirect) {
sessionStorage.removeItem('auth-return-url');
return sessionRedirect;
}
}
return '/';
});
const verified = $derived($page.url.searchParams.get('verified') === 'true');
const initialEmail = $derived($page.url.searchParams.get('email') || '');
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
async function handleResendVerification(email: string) {
return authStore.resendVerificationEmail(email);
}
</script>
<svelte:head>
<title>Login - CityCorners</title>
</svelte:head>
<LoginPage
appName="CityCorners"
logo={CitycornersLogo}
primaryColor="#2563eb"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
{goto}
enableGoogle={false}
enableApple={false}
successRedirect={redirectTo}
registerPath="/register"
forgotPasswordPath="/forgot-password"
lightBackground="#eff6ff"
darkBackground="#1e1b4b"
{verified}
{initialEmail}
/>

View file

@ -0,0 +1,31 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { RegisterPage } from '@manacore/shared-auth-ui';
import { CitycornersLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
async function handleSignUp(email: string, password: string) {
return authStore.signUp(email, password);
}
async function handleResendVerification(email: string) {
return authStore.resendVerificationEmail(email);
}
</script>
<svelte:head>
<title>Registrieren - CityCorners</title>
</svelte:head>
<RegisterPage
appName="CityCorners"
logo={CitycornersLogo}
primaryColor="#2563eb"
onSignUp={handleSignUp}
onResendVerification={handleResendVerification}
{goto}
successRedirect="/"
loginPath="/login"
lightBackground="#eff6ff"
darkBackground="#1e1b4b"
/>

View file

@ -0,0 +1,42 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { ToastContainer, setupGlobalErrorHandler } from '@manacore/shared-ui';
let { children } = $props();
let loading = $state(true);
onMount(() => {
const cleanupErrorHandler = setupGlobalErrorHandler();
const init = async () => {
theme.initialize();
await authStore.initialize();
loading = false;
};
init();
return cleanupErrorHandler;
});
</script>
<ToastContainer />
{#if loading}
<div class="min-h-screen bg-background flex items-center justify-center">
<div class="text-center">
<div
class="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4"
></div>
<p class="text-foreground-secondary">CityCorners</p>
</div>
</div>
{:else}
<div class="min-h-screen bg-background text-foreground">
{@render children()}
</div>
{/if}

View file

@ -0,0 +1,10 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
return json({
status: 'ok',
service: 'citycorners-web',
timestamp: new Date().toISOString(),
});
};

View file

@ -0,0 +1,14 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
out: 'build',
}),
},
};
export default config;

View file

@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

View file

@ -0,0 +1,21 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite';
import { MANACORE_SHARED_PACKAGES, getBuildDefines } from '@manacore/shared-vite-config';
export default defineConfig({
plugins: [sveltekit(), tailwindcss()],
server: {
port: 5196,
strictPort: true,
},
ssr: {
noExternal: [...MANACORE_SHARED_PACKAGES],
},
optimizeDeps: {
exclude: [...MANACORE_SHARED_PACKAGES],
},
define: {
...getBuildDefines(),
},
});

View file

@ -717,6 +717,35 @@ services:
retries: 3
start_period: 40s
citycorners-backend:
build:
context: .
dockerfile: apps/citycorners/apps/backend/Dockerfile
image: citycorners-backend:local
container_name: mana-app-citycorners-backend
restart: always
depends_on:
mana-auth:
condition: service_healthy
environment:
NODE_ENV: production
PORT: 3025
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/citycorners
DB_HOST: postgres
DB_PORT: 5432
DB_USER: postgres
MANA_CORE_AUTH_URL: http://mana-auth:3001
CORS_ORIGINS: https://citycorners.mana.how,https://mana.how
ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
ports:
- "3025:3025"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3025/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# ============================================
# Tier 4: Matrix Stack (Ports 4000-4099)
# ============================================
@ -1611,6 +1640,35 @@ services:
retries: 3
start_period: 40s
citycorners-web:
build:
context: .
dockerfile: apps/citycorners/apps/web/Dockerfile
args:
PUBLIC_BACKEND_URL: http://citycorners-backend:3025
PUBLIC_MANA_CORE_AUTH_URL: http://mana-auth:3001
image: citycorners-web:local
container_name: mana-app-citycorners-web
restart: always
depends_on:
citycorners-backend:
condition: service_healthy
environment:
NODE_ENV: production
PORT: 5022
PUBLIC_BACKEND_URL: http://citycorners-backend:3025
PUBLIC_MANA_CORE_AUTH_URL: http://mana-auth:3001
PUBLIC_CITYCORNERS_API_URL_CLIENT: https://citycorners-api.mana.how
PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.mana.how
ports:
- "5022:5022"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:5022/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
picture-backend:
build:
context: .

View file

@ -19,6 +19,7 @@ CREATE DATABASE IF NOT EXISTS techbase;
CREATE DATABASE IF NOT EXISTS voxel_lava;
CREATE DATABASE IF NOT EXISTS figgos;
CREATE DATABASE IF NOT EXISTS context;
CREATE DATABASE IF NOT EXISTS citycorners;
-- Grant all privileges to the default user
GRANT ALL PRIVILEGES ON DATABASE chat TO manacore;
@ -37,5 +38,6 @@ GRANT ALL PRIVILEGES ON DATABASE techbase TO manacore;
GRANT ALL PRIVILEGES ON DATABASE voxel_lava TO manacore;
GRANT ALL PRIVILEGES ON DATABASE figgos TO manacore;
GRANT ALL PRIVILEGES ON DATABASE context TO manacore;
GRANT ALL PRIVILEGES ON DATABASE citycorners TO manacore;
GRANT ALL PRIVILEGES ON DATABASE glitchtip TO manacore;
GRANT ALL PRIVILEGES ON DATABASE manacore TO manacore;

View file

@ -165,6 +165,16 @@
"context:db:studio": "pnpm --filter @context/backend db:studio",
"context:db:seed": "pnpm --filter @context/backend db:seed",
"setup:db:context": "./scripts/setup-databases.sh context",
"citycorners:dev": "turbo run dev --filter=citycorners...",
"dev:citycorners:landing": "pnpm --filter @citycorners/landing dev",
"dev:citycorners:web": "pnpm --filter @citycorners/web dev",
"dev:citycorners:backend": "pnpm --filter @citycorners/backend dev",
"dev:citycorners:app": "turbo run dev --filter=@citycorners/web --filter=@citycorners/backend",
"dev:citycorners:full": "./scripts/setup-databases.sh citycorners && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:citycorners:backend\" \"pnpm dev:citycorners:web\"",
"citycorners:db:push": "pnpm --filter @citycorners/backend db:push",
"citycorners:db:studio": "pnpm --filter @citycorners/backend db:studio",
"citycorners:db:seed": "pnpm --filter @citycorners/backend db:seed",
"deploy:landing:citycorners": "pnpm --filter @citycorners/landing build && npx wrangler pages deploy apps/citycorners/apps/landing/dist --project-name=citycorners-landing",
"planta:dev": "turbo run dev --filter=planta...",
"dev:planta:web": "pnpm --filter @planta/web dev",
"dev:planta:backend": "pnpm --filter @planta/backend dev",

View file

@ -72,6 +72,9 @@ const matrixSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill=
// Playground icon (code/terminal with cyan gradient)
const playgroundSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#playgroundGrad)"/><path d="M380 340L260 512L380 684" stroke="white" stroke-width="48" stroke-linecap="round" stroke-linejoin="round"/><path d="M644 340L764 512L644 684" stroke="white" stroke-width="48" stroke-linecap="round" stroke-linejoin="round"/><path d="M560 280L464 744" stroke="white" stroke-width="40" stroke-linecap="round"/><defs><linearGradient id="playgroundGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#06b6d4"/><stop offset="1" stop-color="#0891b2"/></linearGradient></defs></svg>`;
// CityCorners icon (map pin with blue gradient)
const citycornersSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#ccGrad)"/><path d="M512 200C408.3 200 324 284.3 324 388C324 536 512 800 512 800C512 800 700 536 700 388C700 284.3 615.7 200 512 200ZM512 468C467.8 468 432 432.2 432 388C432 343.8 467.8 308 512 308C556.2 308 592 343.8 592 388C592 432.2 556.2 468 512 468Z" fill="white"/><circle cx="512" cy="388" r="60" fill="#2563eb" fill-opacity="0.4"/><defs><linearGradient id="ccGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#2563eb"/><stop offset="1" stop-color="#1d4ed8"/></linearGradient></defs></svg>`;
// Context icon (document/knowledge with sky blue gradient)
const contextSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#contextGrad)"/><rect x="300" y="240" width="424" height="544" rx="24" fill="white"/><path d="M400 400H624" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round"/><path d="M400 480H580" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round" stroke-opacity="0.6"/><path d="M400 560H540" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round" stroke-opacity="0.4"/><path d="M400 640H600" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round" stroke-opacity="0.3"/><path d="M620 240V380H760" stroke="white" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/><path d="M620 240L760 380" stroke="#0ea5e9" stroke-width="16" stroke-linecap="round" stroke-opacity="0.3"/><circle cx="680" cy="620" r="100" fill="#0ea5e9" fill-opacity="0.2" stroke="white" stroke-width="16"/><path d="M660 620L680 640L720 600" stroke="white" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/><defs><linearGradient id="contextGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#0ea5e9"/><stop offset="1" stop-color="#0284c7"/></linearGradient></defs></svg>`;
@ -102,6 +105,7 @@ export const APP_ICONS = {
matrix: svgToDataUrl(matrixSvg),
playground: svgToDataUrl(playgroundSvg),
context: svgToDataUrl(contextSvg),
citycorners: svgToDataUrl(citycornersSvg),
} as const;
export type AppIconId = keyof typeof APP_ICONS;

View file

@ -297,6 +297,19 @@ export const APP_BRANDING: Record<AppId, AppBranding> = {
logoStroke: true,
logoStrokeWidth: 1.5,
},
citycorners: {
id: 'citycorners',
name: 'CityCorners',
tagline: 'City Guide',
primaryColor: '#2563eb',
secondaryColor: '#3b82f6',
// Map pin / compass icon
logoPath:
'M15 10.5a3 3 0 11-6 0 3 3 0 016 0z M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z',
logoViewBox: '0 0 24 24',
logoStroke: true,
logoStrokeWidth: 1.5,
},
context: {
id: 'context',
name: 'Context',

View file

@ -38,6 +38,7 @@ export {
LightWriteLogo,
MukkeLogo,
ContextLogo,
CitycornersLogo,
} from './logos';
// Configuration

View file

@ -0,0 +1,13 @@
<script lang="ts">
import AppLogo from '../AppLogo.svelte';
interface Props {
size?: number;
color?: string;
class?: string;
}
let { size = 55, color, class: className = '' }: Props = $props();
</script>
<AppLogo app="citycorners" {size} {color} class={className} />

View file

@ -25,3 +25,4 @@ export { default as PlaygroundLogo } from './PlaygroundLogo.svelte';
export { default as LightWriteLogo } from './LightWriteLogo.svelte';
export { default as MukkeLogo } from './MukkeLogo.svelte';
export { default as ContextLogo } from './ContextLogo.svelte';
export { default as CitycornersLogo } from './CitycornersLogo.svelte';

View file

@ -340,6 +340,22 @@ export const MANA_APPS: ManaApp[] = [
comingSoon: false,
status: 'development',
},
{
id: 'citycorners',
name: 'CityCorners',
description: {
de: 'Stadtführer für Konstanz',
en: 'City Guide for Konstanz',
},
longDescription: {
de: 'Entdecke Sehenswürdigkeiten, Restaurants, Museen und Läden in Konstanz am Bodensee.',
en: 'Discover sights, restaurants, museums, and shops in Konstanz at Lake Constance.',
},
icon: APP_ICONS.citycorners,
color: '#2563eb',
comingSoon: false,
status: 'development',
},
];
/**
@ -430,6 +446,7 @@ export const APP_URLS: Record<AppIconId, { dev: string; prod: string }> = {
matrix: { dev: 'http://localhost:5180', prod: 'https://matrix.mana.how' },
playground: { dev: 'http://localhost:5190', prod: 'https://playground.mana.how' },
context: { dev: 'http://localhost:5192', prod: 'https://context.mana.how' },
citycorners: { dev: 'http://localhost:5196', prod: 'https://citycorners.mana.how' },
};
/**

View file

@ -25,7 +25,8 @@ export type AppId =
| 'playground'
| 'lightwrite'
| 'context'
| 'mukke';
| 'mukke'
| 'citycorners';
/**
* App branding configuration

View file

@ -666,6 +666,30 @@ const APP_CONFIGS = [
},
},
// CityCorners Backend (NestJS)
{
path: 'apps/citycorners/apps/backend/.env',
vars: {
NODE_ENV: () => 'development',
PORT: (env) => env.CITYCORNERS_BACKEND_PORT || '3025',
DATABASE_URL: (env) => env.CITYCORNERS_DATABASE_URL,
MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
DEV_BYPASS_AUTH: () => 'true',
DEV_USER_ID: (env) => env.DEV_USER_ID || '00000000-0000-0000-0000-000000000000',
CORS_ORIGINS: (env) => env.CORS_ORIGINS,
},
},
// CityCorners Web (SvelteKit)
{
path: 'apps/citycorners/apps/web/.env',
vars: {
PUBLIC_BACKEND_URL: (env) => `http://localhost:${env.CITYCORNERS_BACKEND_PORT || '3025'}`,
PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
PUBLIC_GLITCHTIP_DSN: (env) => env.PUBLIC_GLITCHTIP_DSN || '',
},
},
// TechBase Backend (NestJS)
{
path: 'apps/techbase/apps/backend/.env',

View file

@ -84,6 +84,7 @@ ALL_DATABASES=(
"mukke"
"traces"
"context"
"citycorners"
)
# Check if specific service requested
@ -205,9 +206,13 @@ setup_service() {
create_db_if_not_exists "context"
push_schema "@context/backend" "context"
;;
citycorners)
create_db_if_not_exists "citycorners"
push_schema "@citycorners/backend" "citycorners"
;;
*)
echo -e "${RED}Unknown service: $service${NC}"
echo "Available services: auth, chat, zitare, contacts, calendar, clock, todo, manadeck, mail, moodlit, picture, photos, finance, voxel-lava, figgos, planta, nutriphi, presi, storage, projectdoc, zitare_bot, todo_bot, nutriphi_bot, questions, skilltree, mukke, traces, context"
echo "Available services: auth, chat, zitare, contacts, calendar, clock, todo, manadeck, mail, moodlit, picture, photos, finance, voxel-lava, figgos, planta, nutriphi, presi, storage, projectdoc, zitare_bot, todo_bot, nutriphi_bot, questions, skilltree, mukke, traces, context, citycorners"
exit 1
;;
esac
@ -231,7 +236,7 @@ echo -e "\n${GREEN}Step 2: Pushing schemas${NC}"
echo "--------------------------------------"
# Push schemas for all known services
for service in auth chat zitare contacts calendar clock todo manadeck picture photos mail moodlit finance voxel-lava figgos planta nutriphi presi storage questions skilltree mukke traces context; do
for service in auth chat zitare contacts calendar clock todo manadeck picture photos mail moodlit finance voxel-lava figgos planta nutriphi presi storage questions skilltree mukke traces context citycorners; do
setup_service "$service" 2>/dev/null || true
done