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>
65
apps/citycorners/CLAUDE.md
Normal 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 |
|
||||
87
apps/citycorners/apps/backend/Dockerfile
Normal 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"]
|
||||
36
apps/citycorners/apps/backend/docker-entrypoint.sh
Executable 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 "$@"
|
||||
6
apps/citycorners/apps/backend/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
|
||||
|
||||
export default createDrizzleConfig({
|
||||
dbName: 'citycorners',
|
||||
additionalEnvVars: ['CITYCORNERS_DATABASE_URL'],
|
||||
});
|
||||
50
apps/citycorners/apps/backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
25
apps/citycorners/apps/backend/src/app.module.ts
Normal 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 {}
|
||||
38
apps/citycorners/apps/backend/src/db/connection.ts
Normal 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>;
|
||||
29
apps/citycorners/apps/backend/src/db/database.module.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
25
apps/citycorners/apps/backend/src/db/migrate.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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;
|
||||
2
apps/citycorners/apps/backend/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './locations.schema';
|
||||
export * from './favorites.schema';
|
||||
|
|
@ -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;
|
||||
89
apps/citycorners/apps/backend/src/db/seed.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
8
apps/citycorners/apps/backend/src/instrument.ts
Normal 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',
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
apps/citycorners/apps/backend/src/main.ts
Normal 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'],
|
||||
});
|
||||
27
apps/citycorners/apps/backend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
26
apps/citycorners/apps/landing/.gitignore
vendored
Normal 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/
|
||||
46
apps/citycorners/apps/landing/README.md
Normal 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).
|
||||
5
apps/citycorners/apps/landing/astro.config.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({});
|
||||
19
apps/citycorners/apps/landing/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
9
apps/citycorners/apps/landing/public/favicon.svg
Normal 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 |
23
apps/citycorners/apps/landing/public/images/alm.svg
Normal 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 |
35
apps/citycorners/apps/landing/public/images/imperia.svg
Normal 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 |
33
apps/citycorners/apps/landing/public/images/lago.svg
Normal 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 |
31
apps/citycorners/apps/landing/public/images/muenster.svg
Normal 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 |
33
apps/citycorners/apps/landing/public/images/ophelia.svg
Normal 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 |
|
|
@ -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 |
1
apps/citycorners/apps/landing/src/assets/astro.svg
Normal 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 |
1
apps/citycorners/apps/landing/src/assets/background.svg
Normal 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 |
25
apps/citycorners/apps/landing/src/components/Filter.astro
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
210
apps/citycorners/apps/landing/src/components/Welcome.astro
Normal 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>
|
||||
93
apps/citycorners/apps/landing/src/data/locations.json
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
22
apps/citycorners/apps/landing/src/layouts/Layout.astro
Normal 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>
|
||||
46
apps/citycorners/apps/landing/src/pages/index.astro
Normal 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>
|
||||
113
apps/citycorners/apps/landing/src/pages/locations/[id].astro
Normal 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">« 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>
|
||||
|
|
@ -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();
|
||||
5
apps/citycorners/apps/landing/tsconfig.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
6
apps/citycorners/apps/landing/wrangler.toml
Normal 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"
|
||||
93
apps/citycorners/apps/web/Dockerfile
Normal 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"]
|
||||
9
apps/citycorners/apps/web/docker-entrypoint.sh
Executable 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 "$@"
|
||||
42
apps/citycorners/apps/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
8
apps/citycorners/apps/web/src/app.css
Normal 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';
|
||||
9
apps/citycorners/apps/web/src/app.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/// <reference types="@sveltejs/kit" />
|
||||
|
||||
declare namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
19
apps/citycorners/apps/web/src/app.html
Normal 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>
|
||||
5
apps/citycorners/apps/web/src/hooks.client.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import type { HandleClientError } from '@sveltejs/kit';
|
||||
|
||||
export const handleError: HandleClientError = ({ error }) => {
|
||||
console.error('Client error:', error);
|
||||
};
|
||||
19
apps/citycorners/apps/web/src/hooks.server.ts
Normal 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;
|
||||
};
|
||||
212
apps/citycorners/apps/web/src/lib/stores/auth.svelte.ts
Normal 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 };
|
||||
}
|
||||
},
|
||||
};
|
||||
109
apps/citycorners/apps/web/src/lib/stores/favorites.svelte.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
89
apps/citycorners/apps/web/src/lib/stores/theme.svelte.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
207
apps/citycorners/apps/web/src/routes/(app)/+layout.svelte
Normal 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>
|
||||
158
apps/citycorners/apps/web/src/routes/(app)/+page.svelte
Normal 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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
127
apps/citycorners/apps/web/src/routes/(app)/map/+page.svelte
Normal 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: '© <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 →</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>
|
||||
123
apps/citycorners/apps/web/src/routes/(app)/settings/+page.svelte
Normal 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>
|
||||
13
apps/citycorners/apps/web/src/routes/(auth)/+layout.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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"
|
||||
/>
|
||||
42
apps/citycorners/apps/web/src/routes/+layout.svelte
Normal 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}
|
||||
10
apps/citycorners/apps/web/src/routes/health/+server.ts
Normal 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(),
|
||||
});
|
||||
};
|
||||
14
apps/citycorners/apps/web/svelte.config.js
Normal 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;
|
||||
14
apps/citycorners/apps/web/tsconfig.json
Normal 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"
|
||||
}
|
||||
}
|
||||
21
apps/citycorners/apps/web/vite.config.ts
Normal 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(),
|
||||
},
|
||||
});
|
||||
|
|
@ -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: .
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
10
package.json
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export {
|
|||
LightWriteLogo,
|
||||
MukkeLogo,
|
||||
ContextLogo,
|
||||
CitycornersLogo,
|
||||
} from './logos';
|
||||
|
||||
// Configuration
|
||||
|
|
|
|||
13
packages/shared-branding/src/logos/CitycornersLogo.svelte
Normal 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} />
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ export type AppId =
|
|||
| 'playground'
|
||||
| 'lightwrite'
|
||||
| 'context'
|
||||
| 'mukke';
|
||||
| 'mukke'
|
||||
| 'citycorners';
|
||||
|
||||
/**
|
||||
* App branding configuration
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||