Merge branch 'dev' into till-dev

This commit is contained in:
Wuesteon 2025-12-16 19:17:07 +01:00
commit 285e142970
251 changed files with 9752 additions and 3942 deletions

View file

@ -1 +0,0 @@
{}

View file

@ -1,87 +0,0 @@
{
"startTime": 1764952181915,
"sessionId": "session-1764952181915",
"lastActivity": 1764952181915,
"sessionDuration": 0,
"totalTasks": 1,
"successfulTasks": 1,
"failedTasks": 0,
"totalAgents": 0,
"activeAgents": 0,
"neuralEvents": 0,
"memoryMode": {
"reasoningbankOperations": 0,
"basicOperations": 0,
"autoModeSelections": 0,
"modeOverrides": 0,
"currentMode": "auto"
},
"operations": {
"store": {
"count": 0,
"totalDuration": 0,
"errors": 0
},
"retrieve": {
"count": 0,
"totalDuration": 0,
"errors": 0
},
"query": {
"count": 0,
"totalDuration": 0,
"errors": 0
},
"list": {
"count": 0,
"totalDuration": 0,
"errors": 0
},
"delete": {
"count": 0,
"totalDuration": 0,
"errors": 0
},
"search": {
"count": 0,
"totalDuration": 0,
"errors": 0
},
"init": {
"count": 0,
"totalDuration": 0,
"errors": 0
}
},
"performance": {
"avgOperationDuration": 0,
"minOperationDuration": null,
"maxOperationDuration": null,
"slowOperations": 0,
"fastOperations": 0,
"totalOperationTime": 0
},
"storage": {
"totalEntries": 0,
"reasoningbankEntries": 0,
"basicEntries": 0,
"databaseSize": 0,
"lastBackup": null,
"growthRate": 0
},
"errors": {
"total": 0,
"byType": {},
"byOperation": {},
"recent": []
},
"reasoningbank": {
"semanticSearches": 0,
"sqlFallbacks": 0,
"embeddingGenerated": 0,
"consolidations": 0,
"avgQueryTime": 0,
"cacheHits": 0,
"cacheMisses": 0
}
}

View file

@ -1,10 +0,0 @@
[
{
"id": "cmd-swarm-1764952182017",
"type": "swarm",
"success": true,
"duration": 4.868416999999994,
"timestamp": 1764952182022,
"metadata": {}
}
]

View file

@ -36,6 +36,9 @@ S3_REGION=us-east-1
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
# Unified Storage (single bucket for all apps)
MANACORE_STORAGE_PUBLIC_URL=http://localhost:9000/manacore-storage
# ============================================
# MANA-CORE-AUTH SERVICE
# ============================================
@ -57,6 +60,11 @@ STRIPE_SECRET_KEY=sk_test_YOUR_KEY
STRIPE_PUBLISHABLE_KEY=pk_test_YOUR_KEY
STRIPE_WEBHOOK_SECRET=whsec_YOUR_SECRET
# Brevo Email Service (get key from https://app.brevo.com/settings/keys/api)
BREVO_API_KEY=
EMAIL_SENDER_ADDRESS=noreply@manacore.ai
EMAIL_SENDER_NAME=ManaCore
# ============================================
# CHAT PROJECT
# ============================================
@ -127,9 +135,7 @@ PICTURE_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/picture
# Replicate API Token for AI image generation
PICTURE_REPLICATE_API_TOKEN=r8_QlvkstNhIc6NBX1ktpQ6ibvzOE2d2UQ1Emamd
# Storage Configuration (uses MinIO locally, Hetzner in production)
# Uses shared S3_* variables from above - no project-specific override needed for local dev
PICTURE_STORAGE_PUBLIC_URL=http://localhost:9000/picture-storage
# Storage: Uses unified manacore-storage bucket (see MANACORE_STORAGE_PUBLIC_URL above)
# Credit System (staging only - freemium: 3 free images, then credits)
PICTURE_APP_ID=picture-app
@ -148,8 +154,7 @@ NUTRIPHI_DATABASE_URL=postgresql://nutriphi:nutriphi_dev_password@localhost:5435
NUTRIPHI_APP_ID=nutriphi
NUTRIPHI_GEMINI_API_KEY=your-gemini-api-key-here
# S3 Storage (uses MinIO locally via shared S3_* variables, Hetzner in production)
NUTRIPHI_S3_PUBLIC_URL=http://localhost:9000/nutriphi-storage
# Storage: Uses unified manacore-storage bucket
# ============================================
# ZITARE PROJECT
@ -180,9 +185,7 @@ VOXEL_LAVA_API_URL=http://localhost:3010
CONTACTS_BACKEND_PORT=3015
CONTACTS_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/contacts
# S3 Storage for contact photos
CONTACTS_S3_BUCKET=contacts-photos
CONTACTS_S3_PUBLIC_URL=http://localhost:9000/contacts-photos
# Storage: Uses unified manacore-storage bucket
# Google OAuth for contacts import
# Get credentials from https://console.cloud.google.com/apis/credentials
@ -204,7 +207,6 @@ CALENDAR_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/calendar
STORAGE_BACKEND_PORT=3016
STORAGE_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/storage
STORAGE_S3_PUBLIC_URL=http://localhost:9000/storage-storage
STORAGE_MAX_FILE_SIZE=104857600
STORAGE_MAX_FILES_PER_UPLOAD=10
@ -265,7 +267,6 @@ FINANCE_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/finance
INVENTORY_BACKEND_PORT=3020
INVENTORY_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/inventory
INVENTORY_S3_PUBLIC_URL=http://localhost:9000/inventory-storage
# ============================================
# TECHBASE PROJECT

View file

@ -98,29 +98,41 @@ jobs:
POSTGRES_PORT=5432
POSTGRES_DB=manacore
POSTGRES_USER=postgres
POSTGRES_PASSWORD=${{ secrets.STAGING_POSTGRES_PASSWORD }}
POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}
# Redis - Configuration
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=${{ secrets.STAGING_REDIS_PASSWORD }}
REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }}
# Mana Core Auth - Configuration
MANA_SERVICE_URL=http://mana-core-auth:3001
JWT_SECRET=${{ secrets.STAGING_JWT_SECRET }}
JWT_PUBLIC_KEY=${{ secrets.STAGING_JWT_PUBLIC_KEY }}
JWT_PRIVATE_KEY=${{ secrets.STAGING_JWT_PRIVATE_KEY }}
JWT_SECRET=${{ secrets.JWT_SECRET }}
JWT_PUBLIC_KEY=${{ secrets.JWT_PUBLIC_KEY }}
JWT_PRIVATE_KEY=${{ secrets.JWT_PRIVATE_KEY }}
# Brevo Email Service
BREVO_API_KEY=${{ secrets.BREVO_API_KEY }}
EMAIL_SENDER_ADDRESS=noreply@manacore.ai
EMAIL_SENDER_NAME=ManaCore
# Supabase
SUPABASE_URL=${{ secrets.STAGING_SUPABASE_URL }}
SUPABASE_ANON_KEY=${{ secrets.STAGING_SUPABASE_ANON_KEY }}
SUPABASE_SERVICE_ROLE_KEY=${{ secrets.STAGING_SUPABASE_SERVICE_ROLE_KEY }}
SUPABASE_URL=${{ secrets.SUPABASE_URL }}
SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}
SUPABASE_SERVICE_ROLE_KEY=${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
# Azure OpenAI
AZURE_OPENAI_ENDPOINT=${{ secrets.STAGING_AZURE_OPENAI_ENDPOINT }}
AZURE_OPENAI_API_KEY=${{ secrets.STAGING_AZURE_OPENAI_API_KEY }}
AZURE_OPENAI_ENDPOINT=${{ secrets.AZURE_OPENAI_ENDPOINT }}
AZURE_OPENAI_API_KEY=${{ secrets.AZURE_OPENAI_API_KEY }}
AZURE_OPENAI_API_VERSION=2024-12-01-preview
# Hetzner Object Storage (S3-compatible)
S3_ENDPOINT=${{ secrets.S3_ENDPOINT }}
S3_REGION=${{ secrets.S3_REGION }}
S3_ACCESS_KEY=${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY=${{ secrets.S3_SECRET_KEY }}
MANACORE_STORAGE_PUBLIC_URL=${{ secrets.MANACORE_STORAGE_PUBLIC_URL }}
# Environment
NODE_ENV=staging
EOF

View file

@ -1,2 +1,5 @@
pnpm exec lint-staged
pnpm run type-check
# Run svelte-check on staged web apps (catches a11y, imports, Svelte 5 issues)
./scripts/svelte-check-staged.sh

3
.husky/pre-push Executable file
View file

@ -0,0 +1,3 @@
# Run production build check before push
# This catches npm package incompatibilities and Docker issues before CI/CD
./scripts/build-changed-apps.sh

724
CLAUDE.md
View file

@ -1,678 +1,176 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This file provides guidance to Claude Code when working with this repository.
## Monorepo Overview
## Purpose & Constraints
This is a pnpm workspace monorepo containing multiple product applications with shared packages. All projects use Supabase for database/auth and follow similar architectural patterns.
Multi-app SaaS monorepo with shared packages and centralized authentication.
**Package Manager:** pnpm 9.15.0 (use `pnpm` for all commands)
**Build System:** Turborepo
**Node Version:** 20+
- **Package Manager:** pnpm 9.15+ (use `pnpm` for all commands)
- **Build System:** Turborepo
- **Node Version:** 20+
- **NEVER create files unless necessary** - prefer editing existing files
- **NEVER create documentation** unless explicitly requested
- **Prefer editing over creating** - check if functionality exists first
## Detailed Guidelines
For comprehensive guidelines on code patterns and conventions, see the `.claude/` directory:
| Document | Purpose |
|----------|---------|
| [`.claude/GUIDELINES.md`](.claude/GUIDELINES.md) | Main reference overview |
| [`.claude/guidelines/code-style.md`](.claude/guidelines/code-style.md) | Formatting, naming, linting |
| [`.claude/guidelines/database.md`](.claude/guidelines/database.md) | Drizzle ORM, schema patterns |
| [`.claude/guidelines/testing.md`](.claude/guidelines/testing.md) | Jest/Vitest, mock factories |
| [`.claude/guidelines/nestjs-backend.md`](.claude/guidelines/nestjs-backend.md) | Controllers, services, DTOs |
| [`.claude/guidelines/error-handling.md`](.claude/guidelines/error-handling.md) | Go-style Result types, error codes |
| [`.claude/guidelines/sveltekit-web.md`](.claude/guidelines/sveltekit-web.md) | Svelte 5 runes, stores |
| [`.claude/guidelines/expo-mobile.md`](.claude/guidelines/expo-mobile.md) | React Native, NativeWind |
| [`.claude/guidelines/authentication.md`](.claude/guidelines/authentication.md) | Mana Core Auth integration |
**Always consult these guidelines before making changes.**
## Projects
| Project | Description | Apps |
| ------------ | ---------------------------- | --------------------------------------------------------- |
| **manacore** | Multi-app ecosystem platform | Expo mobile, SvelteKit web |
| **manadeck** | Card/deck management | NestJS backend, Expo mobile, SvelteKit web |
| **picture** | AI image generation | Expo mobile, SvelteKit web, Astro landing |
| **chat** | AI chat application | NestJS backend, Expo mobile, SvelteKit web, Astro landing |
| **zitare** | Daily inspiration quotes | NestJS backend, Expo mobile, SvelteKit web, Astro landing |
| **contacts** | Contact management | NestJS backend, SvelteKit web |
### Archived Projects (`apps-archived/`)
These projects are temporarily archived and excluded from the workspace. To re-activate, move back to `apps/`.
| Project | Description |
| ------------------ | -------------------------------- |
| **bauntown** | Community website for developers |
| **maerchenzauber** | AI story generation |
| **memoro** | Voice memo & AI analysis |
| **news** | News aggregation |
| **nutriphi** | Nutrition tracking |
| **reader** | Reading app |
| **uload** | URL shortener |
| **wisekeep** | AI wisdom extraction from video |
| **techbase** | Software comparison platform |
| **inventory** | Inventory management |
| **presi** | Presentation tool |
| **storage** | Cloud storage |
## Development Commands
For detailed local development setup, see **[docs/LOCAL_DEVELOPMENT.md](docs/LOCAL_DEVELOPMENT.md)**.
### Quick Start (Recommended)
Use `dev:*:full` commands to start any app with automatic database setup:
```bash
pnpm docker:up # Start PostgreSQL, Redis, MinIO
pnpm dev:chat:full # Start chat with auth + auto DB setup
pnpm dev:zitare:full # Start zitare with auth + auto DB setup
pnpm dev:contacts:full # Start contacts with auth + auto DB setup
pnpm dev:calendar:full # Start calendar with auth + auto DB setup
pnpm dev:clock:full # Start clock with auth + auto DB setup
pnpm dev:todo:full # Start todo with auth + auto DB setup
pnpm dev:picture:full # Start picture with auth + auto DB setup
```
These commands automatically:
1. Create the database if missing
2. Push the latest schema
3. Start auth, backend, and web with colored output
### Database Setup
```bash
pnpm setup:db # Setup ALL databases and schemas
pnpm setup:db:chat # Setup just chat
pnpm setup:db:auth # Setup just auth
```
### Individual App Commands
```bash
# Start specific project (runs all apps in project)
pnpm run manacore:dev
pnpm run manadeck:dev
pnpm run picture:dev
pnpm run chat:dev
pnpm run zitare:dev
pnpm run contacts:dev
# Start specific app within project
pnpm run dev:chat:mobile # Just mobile app
pnpm run dev:chat:backend # Just NestJS backend
pnpm run dev:chat:app # Web + backend together
# Build & quality
pnpm run build
pnpm run type-check
pnpm run format
```
Each project has its own `CLAUDE.md` with detailed project-specific commands.
## Architecture Patterns
### Monorepo Structure
## Monorepo Structure
```
manacore-monorepo/
├── apps/ # Active SaaS product applications
│ ├── chat/
│ │ ├── apps/
│ │ │ ├── backend/ # NestJS API
│ │ │ ├── mobile/ # Expo React Native app
│ │ │ ├── web/ # SvelteKit web app
│ │ │ └── landing/ # Astro marketing page
│ │ └── packages/ # Project-specific shared code
│ ├── manadeck/
│ ├── picture/
├── apps/ # Active SaaS applications
│ ├── chat/ # AI chat (backend, mobile, web, landing)
│ ├── picture/ # AI image generation
│ ├── zitare/ # Daily quotes
│ ├── contacts/ # Contact management
│ └── ...
├── apps-archived/ # Archived apps (excluded from workspace)
│ ├── bauntown/
│ ├── maerchenzauber/
│ ├── memoro/
│ ├── news/
│ ├── nutriphi/
│ ├── reader/
│ ├── uload/
│ └── wisekeep/
├── games/ # Game projects
│ └── {game-name}/ # Individual games
├── services/ # Standalone microservices
│ └── mana-core-auth/ # Central authentication service
├── packages/ # Monorepo-wide shared packages
└── docker/ # Docker configuration files
├── services/
│ └── mana-core-auth/ # Central auth service (port 3001)
├── packages/ # Shared packages (@manacore/*)
├── .claude/ # Code guidelines (detailed patterns)
└── docs/ # Technical documentation
```
### Standard Project Structure (inside apps/)
## Quick Start
```
apps/{project}/
├── apps/
│ ├── backend/ # NestJS API (when present)
│ ├── mobile/ # Expo React Native app
│ ├── web/ # SvelteKit web app
│ └── landing/ # Astro marketing page
├── packages/ # Project-specific shared code
└── package.json
```bash
# Start infrastructure (PostgreSQL, Redis, MinIO)
pnpm docker:up
# Start any app with automatic DB setup
pnpm dev:chat:full # Chat with auth + backend + web
pnpm dev:zitare:full # Zitare with auth + backend + web
pnpm dev:picture:full # Picture with auth + backend + web
pnpm dev:contacts:full # Contacts with auth + backend + web
```
### Turborepo Configuration
## Technology Stack
**CRITICAL: Avoid Recursive Turbo Calls**
| App Type | Stack |
|----------|-------|
| **Backend** | NestJS 10-11 + Drizzle ORM + PostgreSQL |
| **Web** | SvelteKit 2 + Svelte 5 (runes mode) |
| **Mobile** | Expo SDK 52-54 + React Native + NativeWind |
| **Landing** | Astro 5 + Tailwind CSS |
| **Auth** | mana-core-auth (EdDSA JWT, port 3001) |
Parent workspace packages (e.g., `apps/chat/package.json`, `apps/zitare/package.json`) must **NEVER** have scripts that call `turbo run <task>` for tasks that turbo orchestrates from the root.
## Critical Gotchas
### 1. Turborepo Infinite Loops
**NEVER** put `turbo run <task>` in child package.json for tasks orchestrated from root.
```jsonc
// WRONG - Creates infinite recursion!
// ❌ WRONG - Creates infinite recursion
// apps/chat/package.json
{
"scripts": {
"type-check": "turbo run type-check", // DON'T DO THIS
"build": "turbo run build", // DON'T DO THIS
"lint": "turbo run lint" // DON'T DO THIS
"build": "turbo run build" // DON'T DO THIS
}
}
// CORRECT - Let root turbo handle orchestration
// apps/chat/package.json
// ✅ CORRECT - Let root turbo handle it
{
"scripts": {
"dev": "turbo run dev" // OK for dev (persistent task, scoped)
// No type-check, build, lint scripts - handled by root turbo
"dev": "turbo run dev" // OK (persistent task)
// No type-check, build, lint - handled by root
}
}
```
**Why this matters:** When root turbo runs `type-check`, it finds packages with `type-check` scripts and runs them. If that script is `turbo run type-check`, it spawns another turbo process that does the same thing → infinite loop. This causes tasks to run for 10+ minutes with thousands of duplicate task entries.
**The `dev` script exception:** Using `turbo run dev` in parent packages is acceptable because:
1. It's typically run directly on that package (scoped)
2. Dev tasks are persistent and turbo handles them differently
**Current turbo.json settings:**
- `concurrency: "5"` - Parallel task limit (adjust based on machine)
- `type-check` has `dependsOn: ["^type-check"]` - Dependencies are checked first
### Technology Stack by App Type
**Mobile Apps (Expo):**
- React Native 0.76-0.81 + Expo SDK 52-54
- Expo Router (file-based routing)
- NativeWind (Tailwind for React Native)
- Zustand (state management)
**Web Apps (SvelteKit):**
- SvelteKit 2.x + Svelte 5
- Tailwind CSS
- Supabase SSR auth
**Landing Pages (Astro):**
- Astro 5.x
- Tailwind CSS
- Static site generation
**Backends (NestJS):**
- NestJS 10-11
- TypeScript
- Supabase integration
### Authentication Architecture
All projects use **mana-core-auth** as the central authentication service:
```
┌─────────────┐ ┌─────────────┐ ┌────────────────┐
│ Client │────>│ Backend │────>│ mana-core-auth │
│ (Web/Mobile)│ │ (NestJS) │ │ (port 3001) │
└─────────────┘ └─────────────┘ └────────────────┘
│ │ │
│ Bearer token │ POST /validate │
│ │ {token} │
│ │<────────────────────│
│ │ {valid, payload} │
<──────────────────│ │
│ Response │ │
```
#### Key Components
| Component | Purpose |
| ------------------------------- | -------------------------------------------------- |
| `services/mana-core-auth` | Central auth service (Better Auth + EdDSA JWT) |
| `@manacore/shared-nestjs-auth` | Shared NestJS guards/decorators for JWT validation |
| `@mana-core/nestjs-integration` | Extended NestJS module with auth + credits |
| `@manacore/shared-auth` | Client-side auth for web/mobile apps |
#### NestJS Backend Integration
**Option 1: Simple auth only** - Use `@manacore/shared-nestjs-auth`:
### 2. Svelte 5 Runes ONLY
Always use Svelte 5 runes syntax, never old Svelte syntax.
```typescript
// In your controller
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
@Controller('api')
@UseGuards(JwtAuthGuard)
export class MyController {
@Get('profile')
getProfile(@CurrentUser() user: CurrentUserData) {
return { userId: user.userId, email: user.email };
}
}
```
**Option 2: Auth + Credits** - Use `@mana-core/nestjs-integration`:
```typescript
// app.module.ts
import { ManaCoreModule } from '@mana-core/nestjs-integration';
@Module({
imports: [
ManaCoreModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
appId: config.get('APP_ID'),
serviceKey: config.get('MANA_CORE_SERVICE_KEY'),
debug: config.get('NODE_ENV') === 'development',
}),
inject: [ConfigService],
}),
],
})
export class AppModule {}
// In controller
import { AuthGuard } from '@mana-core/nestjs-integration/guards';
import { CurrentUser } from '@mana-core/nestjs-integration/decorators';
import { CreditClientService } from '@mana-core/nestjs-integration';
@Controller('api')
@UseGuards(AuthGuard)
export class ApiController {
constructor(private creditClient: CreditClientService) {}
@Post('generate')
async generate(@CurrentUser() user: any) {
await this.creditClient.consumeCredits(user.sub, 'generation', 10, 'AI generation');
// ... do work
}
}
```
#### Required Environment Variables
```env
# All backends need this
MANA_CORE_AUTH_URL=http://localhost:3001
# For development bypass (optional)
NODE_ENV=development
DEV_BYPASS_AUTH=true
DEV_USER_ID=your-test-user-id
# For credit operations (optional)
MANA_CORE_SERVICE_KEY=your-service-key
APP_ID=your-app-id
```
#### JWT Token Structure (EdDSA)
```json
{
"sub": "user-id",
"email": "user@example.com",
"role": "user",
"sid": "session-id",
"exp": 1764606251,
"iss": "manacore",
"aud": "manacore"
}
```
#### Testing Auth Integration
```bash
# 1. Start mana-core-auth
pnpm dev:auth
# 2. Start a backend (e.g., Zitare)
pnpm dev:zitare:backend
# 3. Get a token
TOKEN=$(curl -s -X POST http://localhost:3001/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com", "password": "password"}' | jq -r '.accessToken')
# 4. Call protected endpoint
curl http://localhost:3007/api/favorites \
-H "Authorization: Bearer $TOKEN"
```
#### Integrated Backends
| Backend | Package | Port |
| -------- | ------------------------------- | ---- |
| Chat | `@mana-core/nestjs-integration` | 3002 |
| Picture | `@manacore/shared-nestjs-auth` | 3006 |
| Zitare | `@manacore/shared-nestjs-auth` | 3007 |
| Presi | Custom (same pattern) | 3008 |
| ManaDeck | `@mana-core/nestjs-integration` | 3009 |
### Svelte 5 Runes Mode (Web Apps)
All SvelteKit apps use Svelte 5 runes:
```typescript
// CORRECT - Svelte 5
// ✅ CORRECT - Svelte 5 runes
let count = $state(0);
let doubled = $derived(count * 2);
$effect(() => {
console.log(count);
});
$effect(() => console.log(count));
// WRONG - Old Svelte syntax
// ❌ WRONG - Old Svelte syntax
let count = 0;
$: doubled = count * 2;
```
## Shared Packages (`packages/`)
| Package | Purpose |
| ------------------------------- | ----------------------------------------------- |
| `@manacore/shared-nestjs-auth` | NestJS JWT validation guards via mana-core-auth |
| `@mana-core/nestjs-integration` | NestJS module with auth guards + credit client |
| `@manacore/shared-auth` | Client-side auth service for web/mobile apps |
| `@manacore/shared-storage` | S3-compatible storage (MinIO local, Hetzner prod) |
| `@manacore/shared-supabase` | Unified Supabase client |
| `@manacore/shared-types` | Common TypeScript types |
| `@manacore/shared-utils` | Utility functions |
| `@manacore/shared-ui` | React Native UI components |
| `@manacore/shared-theme` | Theme configuration |
| `@manacore/shared-i18n` | Internationalization |
Import shared packages:
### 3. Authentication Integration
All backends need `MANA_CORE_AUTH_URL=http://localhost:3001` env var.
```typescript
import { createAuthService } from '@manacore/shared-auth';
import { formatDate, truncate } from '@manacore/shared-utils';
// Use @manacore/shared-nestjs-auth for JWT validation
import { JwtAuthGuard, CurrentUser } from '@manacore/shared-nestjs-auth';
@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile(@CurrentUser() user: CurrentUserData) {
return { userId: user.userId };
}
```
## Database (Supabase)
- All projects use Supabase for PostgreSQL database, auth, and storage
- Row Level Security (RLS) policies enforce access control via JWT claims
- Each project has its own Supabase project/schema
- Types typically generated via `supabase gen types`
## Object Storage (MinIO / Hetzner)
S3-compatible object storage for file uploads, generated images, etc.
### Architecture
| Environment | Service | Purpose |
|-------------|---------|---------|
| **Local** | MinIO (Docker) | S3-compatible local storage |
| **Production** | Hetzner Object Storage | Cost-effective S3-compatible cloud storage |
### Local Development
```bash
# Start infrastructure (includes MinIO)
pnpm docker:up
# MinIO Web Console: http://localhost:9001
# Username: minioadmin
# Password: minioadmin
# S3 API endpoint: http://localhost:9000
```
### Pre-configured Buckets
| Bucket | Project | Purpose |
|--------|---------|---------|
| `picture-storage` | Picture | AI-generated images |
| `chat-storage` | Chat | User file uploads |
| `manadeck-storage` | ManaDeck | Card/deck assets |
| `nutriphi-storage` | NutriPhi | Meal photos |
| `presi-storage` | Presi | Presentation slides |
| `calendar-storage` | Calendar | Calendar attachments |
| `contacts-storage` | Contacts | Contact avatars/files |
| `storage-storage` | Storage | Cloud drive files |
### Usage in Backend
### 4. Go-Style Error Handling
Use Result<T> types, never throw exceptions in application code.
```typescript
import { createPictureStorage, generateUserFileKey, getContentType } from '@manacore/shared-storage';
// ✅ CORRECT
import { Result, ok, err } from '@manacore/shared-errors';
const storage = createPictureStorage();
async function getUser(id: string): Promise<Result<User>> {
if (!id) return err('INVALID_USER_ID', 'User ID required');
return ok(user);
}
// Upload
const key = generateUserFileKey(userId, 'image.png');
const result = await storage.upload(key, buffer, {
contentType: getContentType('image.png'),
public: true,
});
// Download
const data = await storage.download(key);
// Presigned URLs
const uploadUrl = await storage.getUploadUrl(key, { expiresIn: 3600 });
// ❌ WRONG
async function getUser(id: string): Promise<User> {
if (!id) throw new Error('User ID required');
return user;
}
```
### Environment Variables
### 5. Environment Variables
Generated from `.env.development` via `pnpm setup:env` (auto-runs after install).
```env
# Local (in .env.development)
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
- **Mobile (Expo):** `EXPO_PUBLIC_*` prefix
- **Web (SvelteKit):** `PUBLIC_*` prefix
- **Backend (NestJS):** No prefix
# Production (Hetzner)
S3_ENDPOINT=https://fsn1.your-objectstorage.com
S3_REGION=fsn1
S3_ACCESS_KEY=your-access-key
S3_SECRET_KEY=your-secret-key
```
## Landing Pages (Cloudflare Pages)
All landing pages are deployed to Cloudflare Pages using Direct Upload via Wrangler CLI.
### Landing Pages
| Project | Package | Cloudflare Project | URL |
|---------|---------|-------------------|-----|
| Chat | `@chat/landing` | `chat-landing` | https://chat-landing.pages.dev |
| Picture | `@picture/landing` | `picture-landing` | https://picture-landing.pages.dev |
| ManaCore | `@manacore/landing` | `manacore-landing` | https://manacore-landing.pages.dev |
| ManaDeck | `@manadeck/landing` | `manadeck-landing` | https://manadeck-landing.pages.dev |
| Zitare | `@zitare/landing` | `zitare-landing` | https://zitare-landing.pages.dev |
### Local Deployment
## Common Commands
```bash
# First time: Login to Cloudflare
pnpm cf:login
# Create projects (one-time setup)
pnpm cf:projects:create
# Deploy individual landing page
pnpm deploy:landing:chat
pnpm deploy:landing:picture
pnpm deploy:landing:manacore
pnpm deploy:landing:manadeck
pnpm deploy:landing:zitare
# Deploy all landing pages
pnpm deploy:landing:all
# List all projects
pnpm cf:projects:list
pnpm install # Install dependencies
pnpm dev:{app}:full # Start app with DB setup
pnpm type-check # Type check all packages
pnpm format # Format code
pnpm build # Build all packages
pnpm docker:up # Start local infrastructure
pnpm setup:env # Regenerate .env files
```
### Adding New Landing Pages
## Documentation
1. Create the landing page in `apps/{project}/apps/landing/`
2. Add `wrangler.toml`:
```toml
name = "{project}-landing"
compatibility_date = "2024-12-01"
pages_build_output_dir = "dist"
```
3. Add deploy script to root `package.json`:
```json
"deploy:landing:{project}": "pnpm --filter @{project}/landing build && npx wrangler pages deploy apps/{project}/apps/landing/dist --project-name={project}-landing"
```
4. Create Cloudflare project: `npx wrangler pages project create {project}-landing --production-branch=main`
- **Code Patterns:** [.claude/GUIDELINES.md](.claude/GUIDELINES.md) - Detailed technical guidelines
- **Local Setup:** [docs/LOCAL_DEVELOPMENT.md](docs/LOCAL_DEVELOPMENT.md) - Complete dev environment setup
- **Database:** [docs/DATABASE_MIGRATIONS.md](docs/DATABASE_MIGRATIONS.md) - Migration best practices
- **Deployment:** [docs/DEPLOYMENT_ARCHITECTURE.md](docs/DEPLOYMENT_ARCHITECTURE.md) - Full deployment guide
- **All Docs:** [docs/README.md](docs/README.md) - Complete documentation index
- **Project-Specific:** Navigate to `apps/{project}/CLAUDE.md` for project details
### Custom Domains
## Detailed Guidelines
```bash
# Add custom domain to a project
npx wrangler pages project add-domain chat-landing chat.manacore.app
```
For comprehensive code patterns and conventions:
## Server Access
| Guideline | Purpose |
|-----------|---------|
| [code-style.md](.claude/guidelines/code-style.md) | Formatting, naming, linting |
| [database.md](.claude/guidelines/database.md) | Drizzle ORM, schema patterns |
| [error-handling.md](.claude/guidelines/error-handling.md) | Result types, error codes |
| [authentication.md](.claude/guidelines/authentication.md) | Mana Core Auth integration |
| [nestjs-backend.md](.claude/guidelines/nestjs-backend.md) | Controllers, services, DTOs |
| [sveltekit-web.md](.claude/guidelines/sveltekit-web.md) | Svelte 5 runes, stores |
| [expo-mobile.md](.claude/guidelines/expo-mobile.md) | React Native, NativeWind |
| [testing.md](.claude/guidelines/testing.md) | Jest/Vitest, mock factories |
### Hetzner Staging Server
**Always consult these guidelines before making changes.**
SSH access for deployment troubleshooting, log inspection, and service management:
## Verification
```bash
ssh -i ~/.ssh/hetzner_deploy_key deploy@46.224.108.214
```
When completing tasks, always end responses with the project signature to verify you've read this file.
**User:** `deploy`
**Key:** `~/.ssh/hetzner_deploy_key`
## Adding Dependencies
```bash
# Add to workspace root (dev tools only)
pnpm add -D <package> -w
# Add to specific project
pnpm add <package> --filter memoro
# Add to specific app within project
pnpm add <package> --filter @memoro/mobile
# Add to shared package
pnpm add <package> --filter @manacore/shared-utils
```
## Environment Variables
### Centralized Development Environment
All development environment variables are managed from a single file: `.env.development`
```bash
# First-time setup: generates all app-specific .env files
pnpm setup:env
# This also runs automatically after `pnpm install`
```
The script reads `.env.development` and generates platform-specific `.env` files for each app with the correct prefixes:
- **Expo mobile**: `EXPO_PUBLIC_*` prefix
- **SvelteKit web**: `PUBLIC_*` prefix
- **NestJS backend**: No prefix
### Key Files
- `.env.development` - Central source of truth (committed to git)
- `scripts/generate-env.mjs` - Generation script
- `apps/**/apps/**/.env` - Generated files (gitignored)
### Adding New Variables
1. Add the variable to `.env.development`
2. Update `scripts/generate-env.mjs` to map it to the appropriate apps
3. Run `pnpm setup:env` to regenerate
### Platform Prefix Patterns
**Mobile (Expo):**
```
EXPO_PUBLIC_SUPABASE_URL=...
EXPO_PUBLIC_SUPABASE_ANON_KEY=...
EXPO_PUBLIC_MIDDLEWARE_API_URL=...
```
**Web (SvelteKit):**
```
PUBLIC_SUPABASE_URL=...
PUBLIC_SUPABASE_ANON_KEY=...
```
**Backend (NestJS):**
```
SUPABASE_URL=...
SUPABASE_SERVICE_ROLE_KEY=...
PORT=...
```
## Project-Specific Documentation
- **[docs/LOCAL_DEVELOPMENT.md](docs/LOCAL_DEVELOPMENT.md)** - Database setup and `dev:*:full` commands
- **[docs/ENVIRONMENT_VARIABLES.md](docs/ENVIRONMENT_VARIABLES.md)** - Complete environment setup guide
- **[docs/DATABASE_MIGRATIONS.md](docs/DATABASE_MIGRATIONS.md)** - Migration best practices, CI/CD, rollback procedures
Each project has its own `CLAUDE.md` with detailed information:
- `apps/manacore/CLAUDE.md` - Multi-app ecosystem, auth details
- `apps/manadeck/CLAUDE.md` - Card/deck management
- `apps/chat/CLAUDE.md` - Chat API endpoints, AI models
- `apps/picture/CLAUDE.md` - AI image generation
- `services/mana-core-auth/` - Central authentication service
Navigate to the specific project directory to work on it.
## Code Quality Infrastructure (TODO)
A detailed plan for code quality tooling is available at `.claude/plans/proud-dancing-moon.md`. When ready to implement:
### Planned Setup
- **Pre-commit hooks**: Husky + lint-staged (format + lint on commit)
- **Commit messages**: Commitlint with Conventional Commits (`feat:`, `fix:`, `docs:`, etc.)
- **CI Pipeline**: GitHub Actions PR checks (lint, format, type-check, tests)
- **Formatting**: Tabs, single quotes, 100 char width (unified across all projects)
- **Test coverage**: 80% minimum for new code (once testing infrastructure is in place)
### Key Files to Create
```
.husky/pre-commit # Run lint-staged
.husky/commit-msg # Run commitlint
commitlint.config.js # Conventional commit rules
.github/workflows/pr-check.yml # CI pipeline
packages/eslint-config/ # Shared ESLint configuration
```
### Current State
- Testing: ~25 test files total (sparse coverage)
- Linting: Fragmented configs across projects
- CI: Only 2 backend deployment workflows exist
- Pre-commit: Only maerchenzauber has Husky (SSH URL fixer only)
**Project Signature:** 🏗️ ManaCore Monorepo

678
CLAUDE.md.backup Normal file
View file

@ -0,0 +1,678 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Monorepo Overview
This is a pnpm workspace monorepo containing multiple product applications with shared packages. All projects use Supabase for database/auth and follow similar architectural patterns.
**Package Manager:** pnpm 9.15.0 (use `pnpm` for all commands)
**Build System:** Turborepo
**Node Version:** 20+
## Detailed Guidelines
For comprehensive guidelines on code patterns and conventions, see the `.claude/` directory:
| Document | Purpose |
|----------|---------|
| [`.claude/GUIDELINES.md`](.claude/GUIDELINES.md) | Main reference overview |
| [`.claude/guidelines/code-style.md`](.claude/guidelines/code-style.md) | Formatting, naming, linting |
| [`.claude/guidelines/database.md`](.claude/guidelines/database.md) | Drizzle ORM, schema patterns |
| [`.claude/guidelines/testing.md`](.claude/guidelines/testing.md) | Jest/Vitest, mock factories |
| [`.claude/guidelines/nestjs-backend.md`](.claude/guidelines/nestjs-backend.md) | Controllers, services, DTOs |
| [`.claude/guidelines/error-handling.md`](.claude/guidelines/error-handling.md) | Go-style Result types, error codes |
| [`.claude/guidelines/sveltekit-web.md`](.claude/guidelines/sveltekit-web.md) | Svelte 5 runes, stores |
| [`.claude/guidelines/expo-mobile.md`](.claude/guidelines/expo-mobile.md) | React Native, NativeWind |
| [`.claude/guidelines/authentication.md`](.claude/guidelines/authentication.md) | Mana Core Auth integration |
**Always consult these guidelines before making changes.**
## Projects
| Project | Description | Apps |
| ------------ | ---------------------------- | --------------------------------------------------------- |
| **manacore** | Multi-app ecosystem platform | Expo mobile, SvelteKit web |
| **manadeck** | Card/deck management | NestJS backend, Expo mobile, SvelteKit web |
| **picture** | AI image generation | Expo mobile, SvelteKit web, Astro landing |
| **chat** | AI chat application | NestJS backend, Expo mobile, SvelteKit web, Astro landing |
| **zitare** | Daily inspiration quotes | NestJS backend, Expo mobile, SvelteKit web, Astro landing |
| **contacts** | Contact management | NestJS backend, SvelteKit web |
### Archived Projects (`apps-archived/`)
These projects are temporarily archived and excluded from the workspace. To re-activate, move back to `apps/`.
| Project | Description |
| ------------------ | -------------------------------- |
| **bauntown** | Community website for developers |
| **maerchenzauber** | AI story generation |
| **memoro** | Voice memo & AI analysis |
| **news** | News aggregation |
| **nutriphi** | Nutrition tracking |
| **reader** | Reading app |
| **uload** | URL shortener |
| **wisekeep** | AI wisdom extraction from video |
| **techbase** | Software comparison platform |
| **inventory** | Inventory management |
| **presi** | Presentation tool |
| **storage** | Cloud storage |
## Development Commands
For detailed local development setup, see **[docs/LOCAL_DEVELOPMENT.md](docs/LOCAL_DEVELOPMENT.md)**.
### Quick Start (Recommended)
Use `dev:*:full` commands to start any app with automatic database setup:
```bash
pnpm docker:up # Start PostgreSQL, Redis, MinIO
pnpm dev:chat:full # Start chat with auth + auto DB setup
pnpm dev:zitare:full # Start zitare with auth + auto DB setup
pnpm dev:contacts:full # Start contacts with auth + auto DB setup
pnpm dev:calendar:full # Start calendar with auth + auto DB setup
pnpm dev:clock:full # Start clock with auth + auto DB setup
pnpm dev:todo:full # Start todo with auth + auto DB setup
pnpm dev:picture:full # Start picture with auth + auto DB setup
```
These commands automatically:
1. Create the database if missing
2. Push the latest schema
3. Start auth, backend, and web with colored output
### Database Setup
```bash
pnpm setup:db # Setup ALL databases and schemas
pnpm setup:db:chat # Setup just chat
pnpm setup:db:auth # Setup just auth
```
### Individual App Commands
```bash
# Start specific project (runs all apps in project)
pnpm run manacore:dev
pnpm run manadeck:dev
pnpm run picture:dev
pnpm run chat:dev
pnpm run zitare:dev
pnpm run contacts:dev
# Start specific app within project
pnpm run dev:chat:mobile # Just mobile app
pnpm run dev:chat:backend # Just NestJS backend
pnpm run dev:chat:app # Web + backend together
# Build & quality
pnpm run build
pnpm run type-check
pnpm run format
```
Each project has its own `CLAUDE.md` with detailed project-specific commands.
## Architecture Patterns
### Monorepo Structure
```
manacore-monorepo/
├── apps/ # Active SaaS product applications
│ ├── chat/
│ │ ├── apps/
│ │ │ ├── backend/ # NestJS API
│ │ │ ├── mobile/ # Expo React Native app
│ │ │ ├── web/ # SvelteKit web app
│ │ │ └── landing/ # Astro marketing page
│ │ └── packages/ # Project-specific shared code
│ ├── manadeck/
│ ├── picture/
│ └── ...
├── apps-archived/ # Archived apps (excluded from workspace)
│ ├── bauntown/
│ ├── maerchenzauber/
│ ├── memoro/
│ ├── news/
│ ├── nutriphi/
│ ├── reader/
│ ├── uload/
│ └── wisekeep/
├── games/ # Game projects
│ └── {game-name}/ # Individual games
├── services/ # Standalone microservices
│ └── mana-core-auth/ # Central authentication service
├── packages/ # Monorepo-wide shared packages
└── docker/ # Docker configuration files
```
### Standard Project Structure (inside apps/)
```
apps/{project}/
├── apps/
│ ├── backend/ # NestJS API (when present)
│ ├── mobile/ # Expo React Native app
│ ├── web/ # SvelteKit web app
│ └── landing/ # Astro marketing page
├── packages/ # Project-specific shared code
└── package.json
```
### Turborepo Configuration
**CRITICAL: Avoid Recursive Turbo Calls**
Parent workspace packages (e.g., `apps/chat/package.json`, `apps/zitare/package.json`) must **NEVER** have scripts that call `turbo run <task>` for tasks that turbo orchestrates from the root.
```jsonc
// WRONG - Creates infinite recursion!
// apps/chat/package.json
{
"scripts": {
"type-check": "turbo run type-check", // DON'T DO THIS
"build": "turbo run build", // DON'T DO THIS
"lint": "turbo run lint" // DON'T DO THIS
}
}
// CORRECT - Let root turbo handle orchestration
// apps/chat/package.json
{
"scripts": {
"dev": "turbo run dev" // OK for dev (persistent task, scoped)
// No type-check, build, lint scripts - handled by root turbo
}
}
```
**Why this matters:** When root turbo runs `type-check`, it finds packages with `type-check` scripts and runs them. If that script is `turbo run type-check`, it spawns another turbo process that does the same thing → infinite loop. This causes tasks to run for 10+ minutes with thousands of duplicate task entries.
**The `dev` script exception:** Using `turbo run dev` in parent packages is acceptable because:
1. It's typically run directly on that package (scoped)
2. Dev tasks are persistent and turbo handles them differently
**Current turbo.json settings:**
- `concurrency: "5"` - Parallel task limit (adjust based on machine)
- `type-check` has `dependsOn: ["^type-check"]` - Dependencies are checked first
### Technology Stack by App Type
**Mobile Apps (Expo):**
- React Native 0.76-0.81 + Expo SDK 52-54
- Expo Router (file-based routing)
- NativeWind (Tailwind for React Native)
- Zustand (state management)
**Web Apps (SvelteKit):**
- SvelteKit 2.x + Svelte 5
- Tailwind CSS
- Supabase SSR auth
**Landing Pages (Astro):**
- Astro 5.x
- Tailwind CSS
- Static site generation
**Backends (NestJS):**
- NestJS 10-11
- TypeScript
- Supabase integration
### Authentication Architecture
All projects use **mana-core-auth** as the central authentication service:
```
┌─────────────┐ ┌─────────────┐ ┌────────────────┐
│ Client │────>│ Backend │────>│ mana-core-auth │
│ (Web/Mobile)│ │ (NestJS) │ │ (port 3001) │
└─────────────┘ └─────────────┘ └────────────────┘
│ │ │
│ Bearer token │ POST /validate │
│ │ {token} │
│ │<────────────────────│
│ │ {valid, payload} │
│<──────────────────│ │
│ Response │ │
```
#### Key Components
| Component | Purpose |
| ------------------------------- | -------------------------------------------------- |
| `services/mana-core-auth` | Central auth service (Better Auth + EdDSA JWT) |
| `@manacore/shared-nestjs-auth` | Shared NestJS guards/decorators for JWT validation |
| `@mana-core/nestjs-integration` | Extended NestJS module with auth + credits |
| `@manacore/shared-auth` | Client-side auth for web/mobile apps |
#### NestJS Backend Integration
**Option 1: Simple auth only** - Use `@manacore/shared-nestjs-auth`:
```typescript
// In your controller
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
@Controller('api')
@UseGuards(JwtAuthGuard)
export class MyController {
@Get('profile')
getProfile(@CurrentUser() user: CurrentUserData) {
return { userId: user.userId, email: user.email };
}
}
```
**Option 2: Auth + Credits** - Use `@mana-core/nestjs-integration`:
```typescript
// app.module.ts
import { ManaCoreModule } from '@mana-core/nestjs-integration';
@Module({
imports: [
ManaCoreModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
appId: config.get('APP_ID'),
serviceKey: config.get('MANA_CORE_SERVICE_KEY'),
debug: config.get('NODE_ENV') === 'development',
}),
inject: [ConfigService],
}),
],
})
export class AppModule {}
// In controller
import { AuthGuard } from '@mana-core/nestjs-integration/guards';
import { CurrentUser } from '@mana-core/nestjs-integration/decorators';
import { CreditClientService } from '@mana-core/nestjs-integration';
@Controller('api')
@UseGuards(AuthGuard)
export class ApiController {
constructor(private creditClient: CreditClientService) {}
@Post('generate')
async generate(@CurrentUser() user: any) {
await this.creditClient.consumeCredits(user.sub, 'generation', 10, 'AI generation');
// ... do work
}
}
```
#### Required Environment Variables
```env
# All backends need this
MANA_CORE_AUTH_URL=http://localhost:3001
# For development bypass (optional)
NODE_ENV=development
DEV_BYPASS_AUTH=true
DEV_USER_ID=your-test-user-id
# For credit operations (optional)
MANA_CORE_SERVICE_KEY=your-service-key
APP_ID=your-app-id
```
#### JWT Token Structure (EdDSA)
```json
{
"sub": "user-id",
"email": "user@example.com",
"role": "user",
"sid": "session-id",
"exp": 1764606251,
"iss": "manacore",
"aud": "manacore"
}
```
#### Testing Auth Integration
```bash
# 1. Start mana-core-auth
pnpm dev:auth
# 2. Start a backend (e.g., Zitare)
pnpm dev:zitare:backend
# 3. Get a token
TOKEN=$(curl -s -X POST http://localhost:3001/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com", "password": "password"}' | jq -r '.accessToken')
# 4. Call protected endpoint
curl http://localhost:3007/api/favorites \
-H "Authorization: Bearer $TOKEN"
```
#### Integrated Backends
| Backend | Package | Port |
| -------- | ------------------------------- | ---- |
| Chat | `@mana-core/nestjs-integration` | 3002 |
| Picture | `@manacore/shared-nestjs-auth` | 3006 |
| Zitare | `@manacore/shared-nestjs-auth` | 3007 |
| Presi | Custom (same pattern) | 3008 |
| ManaDeck | `@mana-core/nestjs-integration` | 3009 |
### Svelte 5 Runes Mode (Web Apps)
All SvelteKit apps use Svelte 5 runes:
```typescript
// CORRECT - Svelte 5
let count = $state(0);
let doubled = $derived(count * 2);
$effect(() => {
console.log(count);
});
// WRONG - Old Svelte syntax
let count = 0;
$: doubled = count * 2;
```
## Shared Packages (`packages/`)
| Package | Purpose |
| ------------------------------- | ----------------------------------------------- |
| `@manacore/shared-nestjs-auth` | NestJS JWT validation guards via mana-core-auth |
| `@mana-core/nestjs-integration` | NestJS module with auth guards + credit client |
| `@manacore/shared-auth` | Client-side auth service for web/mobile apps |
| `@manacore/shared-storage` | S3-compatible storage (MinIO local, Hetzner prod) |
| `@manacore/shared-supabase` | Unified Supabase client |
| `@manacore/shared-types` | Common TypeScript types |
| `@manacore/shared-utils` | Utility functions |
| `@manacore/shared-ui` | React Native UI components |
| `@manacore/shared-theme` | Theme configuration |
| `@manacore/shared-i18n` | Internationalization |
Import shared packages:
```typescript
import { createAuthService } from '@manacore/shared-auth';
import { formatDate, truncate } from '@manacore/shared-utils';
```
## Database (Supabase)
- All projects use Supabase for PostgreSQL database, auth, and storage
- Row Level Security (RLS) policies enforce access control via JWT claims
- Each project has its own Supabase project/schema
- Types typically generated via `supabase gen types`
## Object Storage (MinIO / Hetzner)
S3-compatible object storage for file uploads, generated images, etc.
### Architecture
| Environment | Service | Purpose |
|-------------|---------|---------|
| **Local** | MinIO (Docker) | S3-compatible local storage |
| **Production** | Hetzner Object Storage | Cost-effective S3-compatible cloud storage |
### Local Development
```bash
# Start infrastructure (includes MinIO)
pnpm docker:up
# MinIO Web Console: http://localhost:9001
# Username: minioadmin
# Password: minioadmin
# S3 API endpoint: http://localhost:9000
```
### Pre-configured Buckets
| Bucket | Project | Purpose |
|--------|---------|---------|
| `picture-storage` | Picture | AI-generated images |
| `chat-storage` | Chat | User file uploads |
| `manadeck-storage` | ManaDeck | Card/deck assets |
| `nutriphi-storage` | NutriPhi | Meal photos |
| `presi-storage` | Presi | Presentation slides |
| `calendar-storage` | Calendar | Calendar attachments |
| `contacts-storage` | Contacts | Contact avatars/files |
| `storage-storage` | Storage | Cloud drive files |
### Usage in Backend
```typescript
import { createPictureStorage, generateUserFileKey, getContentType } from '@manacore/shared-storage';
const storage = createPictureStorage();
// Upload
const key = generateUserFileKey(userId, 'image.png');
const result = await storage.upload(key, buffer, {
contentType: getContentType('image.png'),
public: true,
});
// Download
const data = await storage.download(key);
// Presigned URLs
const uploadUrl = await storage.getUploadUrl(key, { expiresIn: 3600 });
```
### Environment Variables
```env
# Local (in .env.development)
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
# Production (Hetzner)
S3_ENDPOINT=https://fsn1.your-objectstorage.com
S3_REGION=fsn1
S3_ACCESS_KEY=your-access-key
S3_SECRET_KEY=your-secret-key
```
## Landing Pages (Cloudflare Pages)
All landing pages are deployed to Cloudflare Pages using Direct Upload via Wrangler CLI.
### Landing Pages
| Project | Package | Cloudflare Project | URL |
|---------|---------|-------------------|-----|
| Chat | `@chat/landing` | `chat-landing` | https://chat-landing.pages.dev |
| Picture | `@picture/landing` | `picture-landing` | https://picture-landing.pages.dev |
| ManaCore | `@manacore/landing` | `manacore-landing` | https://manacore-landing.pages.dev |
| ManaDeck | `@manadeck/landing` | `manadeck-landing` | https://manadeck-landing.pages.dev |
| Zitare | `@zitare/landing` | `zitare-landing` | https://zitare-landing.pages.dev |
### Local Deployment
```bash
# First time: Login to Cloudflare
pnpm cf:login
# Create projects (one-time setup)
pnpm cf:projects:create
# Deploy individual landing page
pnpm deploy:landing:chat
pnpm deploy:landing:picture
pnpm deploy:landing:manacore
pnpm deploy:landing:manadeck
pnpm deploy:landing:zitare
# Deploy all landing pages
pnpm deploy:landing:all
# List all projects
pnpm cf:projects:list
```
### Adding New Landing Pages
1. Create the landing page in `apps/{project}/apps/landing/`
2. Add `wrangler.toml`:
```toml
name = "{project}-landing"
compatibility_date = "2024-12-01"
pages_build_output_dir = "dist"
```
3. Add deploy script to root `package.json`:
```json
"deploy:landing:{project}": "pnpm --filter @{project}/landing build && npx wrangler pages deploy apps/{project}/apps/landing/dist --project-name={project}-landing"
```
4. Create Cloudflare project: `npx wrangler pages project create {project}-landing --production-branch=main`
### Custom Domains
```bash
# Add custom domain to a project
npx wrangler pages project add-domain chat-landing chat.manacore.app
```
## Server Access
### Hetzner Staging Server
SSH access for deployment troubleshooting, log inspection, and service management:
```bash
ssh -i ~/.ssh/hetzner_deploy_key deploy@46.224.108.214
```
**User:** `deploy`
**Key:** `~/.ssh/hetzner_deploy_key`
## Adding Dependencies
```bash
# Add to workspace root (dev tools only)
pnpm add -D <package> -w
# Add to specific project
pnpm add <package> --filter memoro
# Add to specific app within project
pnpm add <package> --filter @memoro/mobile
# Add to shared package
pnpm add <package> --filter @manacore/shared-utils
```
## Environment Variables
### Centralized Development Environment
All development environment variables are managed from a single file: `.env.development`
```bash
# First-time setup: generates all app-specific .env files
pnpm setup:env
# This also runs automatically after `pnpm install`
```
The script reads `.env.development` and generates platform-specific `.env` files for each app with the correct prefixes:
- **Expo mobile**: `EXPO_PUBLIC_*` prefix
- **SvelteKit web**: `PUBLIC_*` prefix
- **NestJS backend**: No prefix
### Key Files
- `.env.development` - Central source of truth (committed to git)
- `scripts/generate-env.mjs` - Generation script
- `apps/**/apps/**/.env` - Generated files (gitignored)
### Adding New Variables
1. Add the variable to `.env.development`
2. Update `scripts/generate-env.mjs` to map it to the appropriate apps
3. Run `pnpm setup:env` to regenerate
### Platform Prefix Patterns
**Mobile (Expo):**
```
EXPO_PUBLIC_SUPABASE_URL=...
EXPO_PUBLIC_SUPABASE_ANON_KEY=...
EXPO_PUBLIC_MIDDLEWARE_API_URL=...
```
**Web (SvelteKit):**
```
PUBLIC_SUPABASE_URL=...
PUBLIC_SUPABASE_ANON_KEY=...
```
**Backend (NestJS):**
```
SUPABASE_URL=...
SUPABASE_SERVICE_ROLE_KEY=...
PORT=...
```
## Project-Specific Documentation
- **[docs/LOCAL_DEVELOPMENT.md](docs/LOCAL_DEVELOPMENT.md)** - Database setup and `dev:*:full` commands
- **[docs/ENVIRONMENT_VARIABLES.md](docs/ENVIRONMENT_VARIABLES.md)** - Complete environment setup guide
- **[docs/DATABASE_MIGRATIONS.md](docs/DATABASE_MIGRATIONS.md)** - Migration best practices, CI/CD, rollback procedures
Each project has its own `CLAUDE.md` with detailed information:
- `apps/manacore/CLAUDE.md` - Multi-app ecosystem, auth details
- `apps/manadeck/CLAUDE.md` - Card/deck management
- `apps/chat/CLAUDE.md` - Chat API endpoints, AI models
- `apps/picture/CLAUDE.md` - AI image generation
- `services/mana-core-auth/` - Central authentication service
Navigate to the specific project directory to work on it.
## Code Quality Infrastructure (TODO)
A detailed plan for code quality tooling is available at `.claude/plans/proud-dancing-moon.md`. When ready to implement:
### Planned Setup
- **Pre-commit hooks**: Husky + lint-staged (format + lint on commit)
- **Commit messages**: Commitlint with Conventional Commits (`feat:`, `fix:`, `docs:`, etc.)
- **CI Pipeline**: GitHub Actions PR checks (lint, format, type-check, tests)
- **Formatting**: Tabs, single quotes, 100 char width (unified across all projects)
- **Test coverage**: 80% minimum for new code (once testing infrastructure is in place)
### Key Files to Create
```
.husky/pre-commit # Run lint-staged
.husky/commit-msg # Run commitlint
commitlint.config.js # Conventional commit rules
.github/workflows/pr-check.yml # CI pipeline
packages/eslint-config/ # Shared ESLint configuration
```
### Current State
- Testing: ~25 test files total (sparse coverage)
- Linting: Fragmented configs across projects
- CI: Only 2 backend deployment workflows exist
- Pre-commit: Only maerchenzauber has Husky (SSH URL fixer only)

181
README.md
View file

@ -2,14 +2,54 @@
Monorepo containing all Manacore projects with shared packages and unified tooling.
## Staging URLs
All services are deployed to staging at `*.staging.manacore.ai`.
### Web Applications
| App | Staging URL | Description |
|-----|-------------|-------------|
| **ManaCore** | https://staging.manacore.ai | Central dashboard for all Mana apps |
| **Chat** | https://chat.staging.manacore.ai | AI chat application |
| **Calendar** | https://calendar.staging.manacore.ai | Calendar and scheduling |
| **Clock** | https://clock.staging.manacore.ai | World clock, timers, alarms |
| **Todo** | https://todo.staging.manacore.ai | Task management |
### Backend APIs
| Service | Staging URL | Port |
|---------|-------------|------|
| **Auth** | https://auth.staging.manacore.ai | 3001 |
| **Chat API** | https://chat-api.staging.manacore.ai | 3002 |
| **Calendar API** | https://calendar-api.staging.manacore.ai | 3016 |
| **Clock API** | https://clock-api.staging.manacore.ai | 3017 |
| **Todo API** | https://todo-api.staging.manacore.ai | 3018 |
### Landing Pages (Cloudflare Pages)
| Project | URL |
|---------|-----|
| **Chat** | https://chat-landing-90m.pages.dev |
| **Picture** | https://picture-landing.pages.dev |
| **ManaCore** | https://manacore-landing.pages.dev |
| **ManaDeck** | https://manadeck-landing.pages.dev |
| **Zitare** | https://zitare-landing.pages.dev |
| **Presi** | https://presi-landing.pages.dev |
## Projects
| Project | Description | Tech Stack |
| ------------------ | ------------------------------- | ------------------------------ |
| **maerchenzauber** | AI-powered story generation app | NestJS, Expo, SvelteKit, Astro |
| **manacore** | Multi-app ecosystem platform | Expo, SvelteKit, Astro |
| **manadeck** | Card/deck management app | NestJS, Expo, SvelteKit |
| **memoro** | Voice memo & AI analysis app | Expo, SvelteKit, Astro |
| Project | Description | Tech Stack |
|---------|-------------|------------|
| **manacore** | Multi-app ecosystem platform | Expo, SvelteKit |
| **chat** | AI chat application | NestJS, Expo, SvelteKit |
| **calendar** | Calendar & scheduling | NestJS, SvelteKit |
| **clock** | World clock, timers, alarms | NestJS, SvelteKit |
| **todo** | Task management | NestJS, SvelteKit |
| **contacts** | Contact management | NestJS, SvelteKit |
| **manadeck** | Card/deck management | NestJS, Expo, SvelteKit |
| **picture** | AI image generation | NestJS, Expo, SvelteKit |
| **zitare** | Daily inspiration quotes | NestJS, Expo, SvelteKit |
## Getting Started
@ -17,6 +57,7 @@ Monorepo containing all Manacore projects with shared packages and unified tooli
- Node.js 20+
- pnpm 9.15.0+
- Docker (for local development)
### Installation
@ -24,71 +65,77 @@ Monorepo containing all Manacore projects with shared packages and unified tooli
# Install pnpm globally (if not installed)
npm install -g pnpm
# Install all dependencies
# Install all dependencies (also generates .env files)
pnpm install
# Start Docker infrastructure
pnpm docker:up
```
### Development
### Quick Start
Use `dev:*:full` commands to start any app with automatic database setup:
```bash
# Start all projects in dev mode
pnpm run dev
pnpm docker:up # Start PostgreSQL, Redis, MinIO
pnpm dev:chat:full # Start chat with auth + auto DB setup
pnpm dev:calendar:full # Start calendar with auth + auto DB setup
pnpm dev:clock:full # Start clock with auth + auto DB setup
pnpm dev:todo:full # Start todo with auth + auto DB setup
pnpm dev:manacore:full # Start manacore with all backends
```
# Start a specific project
pnpm run maerchenzauber:dev
pnpm run manacore:dev
pnpm run manadeck:dev
pnpm run memoro:dev
### Development Commands
```bash
# Build all projects
pnpm run build
# Run tests
pnpm run test
pnpm build
# Type check
pnpm run type-check
pnpm type-check
# Lint
pnpm lint
# Format code
pnpm run format
pnpm format
```
## Shared Packages
Located in `packages/`:
| Package | Description |
| --------------------------- | --------------------------------------- |
| `@manacore/shared-types` | Common TypeScript types |
| `@manacore/shared-supabase` | Unified Supabase client |
| `@manacore/shared-utils` | Utility functions (date, string, async) |
| `@manacore/shared-ui` | React Native UI components |
### Using Shared Packages
```typescript
// In any project
import { User, ApiResponse } from '@manacore/shared-types';
import { createSupabaseClient } from '@manacore/shared-supabase';
import { formatDate, truncate, retry } from '@manacore/shared-utils';
```
| Package | Description |
|---------|-------------|
| `@manacore/shared-auth` | Client-side auth for web/mobile |
| `@manacore/shared-nestjs-auth` | NestJS JWT validation guards |
| `@manacore/shared-ui` | Shared Svelte UI components |
| `@manacore/shared-storage` | S3-compatible storage (MinIO/Hetzner) |
| `@manacore/shared-types` | Common TypeScript types |
| `@manacore/shared-utils` | Utility functions |
| `@manacore/shared-theme` | Theme configuration |
## Repository Structure
```
manacore-monorepo/
├── packages/ # Shared packages
│ ├── shared-types/ # TypeScript types
│ ├── shared-supabase/ # Supabase utilities
│ ├── shared-utils/ # Common utilities
│ └── shared-ui/ # React Native components
├── maerchenzauber/ # Storyteller project
├── manacore/ # Manacore apps project
├── manadeck/ # ManaDeck project
├── memoro/ # Memoro project
├── turbo.json # Turborepo configuration
├── pnpm-workspace.yaml # Workspace configuration
└── package.json # Root package
├── apps/ # Active product applications
│ ├── manacore/ # Central dashboard
│ ├── chat/ # AI chat app
│ ├── calendar/ # Calendar app
│ ├── clock/ # Clock/timer app
│ ├── todo/ # Task management
│ ├── contacts/ # Contact management
│ ├── manadeck/ # Card/deck app
│ ├── picture/ # AI image generation
│ └── zitare/ # Daily quotes
├── apps-archived/ # Archived projects
├── games/ # Game projects
├── services/
│ └── mana-core-auth/ # Central auth service
├── packages/ # Shared packages
├── docker/ # Docker configuration
└── .github/workflows/ # CI/CD pipelines
```
## Tooling
@ -96,28 +143,48 @@ manacore-monorepo/
- **Package Manager:** pnpm 9.15.0
- **Build System:** Turborepo
- **Formatting:** Prettier
- **Node Version:** 20 (see .nvmrc)
- **Linting:** ESLint
- **Git Hooks:** Husky (pre-commit, pre-push)
- **Node Version:** 20+
## Adding Dependencies
```bash
# Add to root (dev tools)
# Add to workspace root (dev tools only)
pnpm add -D <package> -w
# Add to specific project
pnpm add <package> --filter maerchenzauber
pnpm add <package> --filter @chat/web
# Add to shared package
pnpm add <package> --filter @manacore/shared-utils
```
## Contributing
## Deployment
1. Create a feature branch
2. Make changes
3. Run `pnpm run format` and `pnpm run type-check`
4. Commit with conventional commit messages
5. Create pull request
### Deploy Landing Pages
```bash
pnpm deploy:landing:chat
pnpm deploy:landing:picture
pnpm deploy:landing:manacore
pnpm deploy:landing:all # Deploy all landing pages
```
### Deploy to Staging
```bash
# Tag-based deployment (triggers CI/CD)
git tag chat-staging-v1.0.0
git push origin chat-staging-v1.0.0
```
## Documentation
- [CLAUDE.md](CLAUDE.md) - Detailed development guidelines
- [docs/LOCAL_DEVELOPMENT.md](docs/LOCAL_DEVELOPMENT.md) - Local setup guide
- [COMMANDS.md](COMMANDS.md) - All available commands
- [cicd/DEPLOYMENT.md](cicd/DEPLOYMENT.md) - Deployment documentation
## License

View file

@ -12,6 +12,7 @@ COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages
COPY packages/better-auth-types ./packages/better-auth-types
COPY packages/shared-errors ./packages/shared-errors
COPY packages/shared-nestjs-auth ./packages/shared-nestjs-auth
@ -23,6 +24,9 @@ COPY apps/calendar/apps/backend ./apps/calendar/apps/backend
RUN pnpm install --frozen-lockfile
# Build shared packages first
WORKDIR /app/packages/better-auth-types
RUN pnpm build
WORKDIR /app/packages/shared-errors
RUN pnpm build

View file

@ -20,6 +20,7 @@ COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages needed by calendar web
COPY packages/better-auth-types ./packages/better-auth-types
COPY packages/shared-auth ./packages/shared-auth
COPY packages/shared-auth-ui ./packages/shared-auth-ui
COPY packages/shared-branding ./packages/shared-branding
@ -47,6 +48,9 @@ COPY apps/calendar/apps/web ./apps/calendar/apps/web
RUN pnpm install --frozen-lockfile
# Build shared packages that need building
WORKDIR /app/packages/better-auth-types
RUN pnpm build || true
WORKDIR /app/packages/shared-auth
RUN pnpm build || true
@ -70,6 +74,10 @@ COPY --from=builder /app/apps/calendar/apps/web/node_modules ./node_modules
COPY --from=builder /app/apps/calendar/apps/web/build ./build
COPY --from=builder /app/apps/calendar/apps/web/package.json ./
# Copy entrypoint script for runtime config generation
COPY apps/calendar/apps/web/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Expose port
EXPOSE 5186
@ -82,5 +90,8 @@ ENV HOST=0.0.0.0
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:5186/health || exit 1
# Use entrypoint to generate runtime config
ENTRYPOINT ["docker-entrypoint.sh"]
# Run the app
CMD ["node", "build"]

View file

@ -0,0 +1,37 @@
#!/bin/sh
set -e
echo "🔧 Generating runtime configuration..."
# Environment variables with development defaults
BACKEND_URL=${BACKEND_URL:-"http://localhost:3016"}
AUTH_URL=${AUTH_URL:-"http://localhost:3001"}
TODO_API_URL=${TODO_API_URL:-"http://localhost:3018"}
CONTACTS_API_URL=${CONTACTS_API_URL:-"http://localhost:3015"}
echo "📝 Config values:"
echo " BACKEND_URL: $BACKEND_URL"
echo " AUTH_URL: $AUTH_URL"
echo " TODO_API_URL: $TODO_API_URL"
echo " CONTACTS_API_URL: $CONTACTS_API_URL"
# Generate config.json from environment variables
cat > /app/apps/calendar/apps/web/build/client/config.json <<EOF
{
"BACKEND_URL": "${BACKEND_URL}",
"AUTH_URL": "${AUTH_URL}",
"TODO_API_URL": "${TODO_API_URL}",
"CONTACTS_API_URL": "${CONTACTS_API_URL}"
}
EOF
echo "✅ Configuration generated at /app/apps/calendar/apps/web/build/client/config.json"
cat /app/apps/calendar/apps/web/build/client/config.json
# Remove pre-compressed versions (SvelteKit serves these instead of the raw file)
rm -f /app/apps/calendar/apps/web/build/client/config.json.br
rm -f /app/apps/calendar/apps/web/build/client/config.json.gz
echo "🗑️ Removed stale pre-compressed config files"
echo "🚀 Starting Calendar web app..."
exec "$@"

View file

@ -54,7 +54,8 @@
"lucide-svelte": "^0.559.0",
"suncalc": "^1.9.0",
"svelte-dnd-action": "^0.9.68",
"svelte-i18n": "^4.0.1"
"svelte-i18n": "^4.0.1",
"zod": "^3.25.76"
},
"type": "module"
}

View file

@ -3,15 +3,22 @@
* Allows Calendar app to fetch contact birthdays for display
*/
import { env } from '$env/dynamic/public';
import { createApiClient } from './base-client';
import { getContactsApiUrl } from '$lib/config/runtime';
const CONTACTS_API_BASE = env.PUBLIC_CONTACTS_API_URL || 'http://localhost:3015';
// Lazy-initialized client (runtime config is async)
let contactsClient: ReturnType<typeof createApiClient> | null = null;
const contactsClient = createApiClient({
baseUrl: CONTACTS_API_BASE,
apiPrefix: '/api/v1',
});
async function getContactsClient() {
if (!contactsClient) {
const contactsApiUrl = await getContactsApiUrl();
contactsClient = createApiClient({
baseUrl: contactsApiUrl,
apiPrefix: '/api/v1',
});
}
return contactsClient;
}
// ============================================
// Types for Birthday Integration
@ -61,7 +68,13 @@ interface BirthdaysResponse {
// API Functions
// ============================================
const fetchContactsApi = contactsClient.fetchApi;
async function fetchContactsApi<T>(
endpoint: string,
options?: Parameters<ReturnType<typeof createApiClient>['fetchApi']>[1]
) {
const client = await getContactsClient();
return client.fetchApi<T>(endpoint, options);
}
/**
* Fetch all contacts with birthdays from Contacts service

View file

@ -1,25 +1,33 @@
/**
* API Client for Calendar Backend
*
* Uses runtime configuration (12-factor pattern) instead of build-time env vars.
* Token handling: Uses authStore.getValidToken() which automatically
* refreshes expired tokens before making requests.
*/
import { env } from '$env/dynamic/public';
import { getBackendUrl } from '$lib/config/runtime';
import { createApiClient, type FetchOptions, type ApiResult } from './base-client';
const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3014';
let calendarClient: ReturnType<typeof createApiClient> | null = null;
const calendarClient = createApiClient({
baseUrl: API_BASE,
apiPrefix: '/api/v1',
});
async function getClient() {
if (!calendarClient) {
const backendUrl = await getBackendUrl();
calendarClient = createApiClient({
baseUrl: backendUrl,
apiPrefix: '/api/v1',
});
}
return calendarClient;
}
export async function fetchApi<T>(
endpoint: string,
options: FetchOptions = {}
): Promise<ApiResult<T>> {
return calendarClient.fetchApi<T>(endpoint, options);
const client = await getClient();
return client.fetchApi<T>(endpoint, options);
}
// Re-export types for backwards compatibility

View file

@ -3,15 +3,22 @@
* Allows Calendar app to fetch/manage todos from the Todo service
*/
import { env } from '$env/dynamic/public';
import { createApiClient, buildQueryString } from './base-client';
import { getTodoApiUrl } from '$lib/config/runtime';
const TODO_API_BASE = env.PUBLIC_TODO_BACKEND_URL || 'http://localhost:3018';
// Lazy-initialized client (runtime config is async)
let todoClient: ReturnType<typeof createApiClient> | null = null;
const todoClient = createApiClient({
baseUrl: TODO_API_BASE,
apiPrefix: '/api/v1',
});
async function getTodoClient() {
if (!todoClient) {
const todoApiUrl = await getTodoApiUrl();
todoClient = createApiClient({
baseUrl: todoApiUrl,
apiPrefix: '/api/v1',
});
}
return todoClient;
}
// ============================================
// Types (mirrored from @todo/shared for cross-app use)
@ -173,7 +180,13 @@ interface LabelsResponse {
// API Client (using shared base client)
// ============================================
const fetchTodoApi = todoClient.fetchApi;
async function fetchTodoApi<T>(
endpoint: string,
options?: Parameters<ReturnType<typeof createApiClient>['fetchApi']>[1]
) {
const client = await getTodoClient();
return client.fetchApi<T>(endpoint, options);
}
// ============================================
// Task API Functions

View file

@ -94,11 +94,11 @@
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<header
class="calendar-header"
class:compact={settingsStore.headerCompact}
oncontextmenu={handleContextMenu}
role="banner"
>
<h1 class="header-title">{title}</h1>
</header>

View file

@ -21,13 +21,19 @@
// View type labels
const viewLabels: Record<CalendarViewType, string> = {
day: 'Tag',
'3day': '3 Tage',
'5day': '5 Tage',
week: 'Woche',
'10day': '10 Tage',
'14day': '14 Tage',
'30day': '30 Tage',
'60day': '60 Tage',
'90day': '90 Tage',
'365day': '365 Tage',
month: 'Monat',
year: 'Jahr',
agenda: 'Agenda',
custom: 'Benutzerdefiniert',
};
// Views to show in selector

View file

@ -126,13 +126,13 @@
</h3>
<div class="mini-trend">
{#each miniTrend as day}
<div class="trend-bar-container" title="{day.label}: {day.count} Events">
<div class="trend-bar-container" title="{day.label || ''}: {day.count} Events">
<div
class="trend-bar"
style="height: {(day.count / maxTrendValue) * 100}%"
class:has-events={day.count > 0}
></div>
<span class="trend-label">{day.label.charAt(0)}</span>
<span class="trend-label">{day.label?.charAt(0) || ''}</span>
</div>
{/each}
</div>

View file

@ -421,6 +421,7 @@
<div class="edit-form">
<div class="form-row">
<div class="color-preview" style="background-color: {newTagColor}"></div>
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
bind:value={newTagName}
@ -431,8 +432,8 @@
/>
</div>
<div class="form-row">
<label class="form-label">Gruppe</label>
<select bind:value={newTagGroupId} class="group-select">
<label for="new-tag-group" class="form-label">Gruppe</label>
<select id="new-tag-group" bind:value={newTagGroupId} class="group-select">
<option value={null}>Keine Gruppe</option>
{#each eventTagGroupsStore.groups as group (group.id)}
<option value={group.id}>{group.name}</option>
@ -471,6 +472,7 @@
<div class="edit-form">
<div class="form-row">
<div class="color-preview" style="background-color: {editTagColor}"></div>
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
bind:value={editTagName}
@ -481,8 +483,8 @@
/>
</div>
<div class="form-row">
<label class="form-label">Gruppe</label>
<select bind:value={editTagGroupId} class="group-select">
<label for="edit-tag-group" class="form-label">Gruppe</label>
<select id="edit-tag-group" bind:value={editTagGroupId} class="group-select">
<option value={null}>Keine Gruppe</option>
{#each eventTagGroupsStore.groups as group (group.id)}
<option value={group.id}>{group.name}</option>
@ -524,6 +526,7 @@
<div class="edit-form">
<div class="form-row">
<div class="color-preview" style="background-color: {editGroupColor}"></div>
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
bind:value={editGroupName}
@ -713,6 +716,7 @@
<div class="new-group-form">
<div class="form-row">
<div class="color-preview" style="background-color: {newGroupColor}"></div>
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
bind:value={newGroupName}

View file

@ -26,25 +26,37 @@
// View labels (short versions for pill)
const viewLabels: Record<CalendarViewType, string> = {
day: '1',
'3day': '3',
'5day': '5',
week: '7',
'10day': '10',
'14day': '14',
'30day': '30',
'60day': '60',
'90day': '90',
'365day': '365',
month: 'M',
year: 'Y',
agenda: 'A',
custom: '',
};
// View titles for tooltip
const viewTitles: Record<CalendarViewType, string> = {
day: 'Tagesansicht',
'3day': '3-Tage-Ansicht',
'5day': '5-Tage-Ansicht',
week: 'Wochenansicht',
'10day': '10-Tage-Ansicht',
'14day': '14-Tage-Ansicht',
'30day': '30-Tage-Ansicht',
'60day': '60-Tage-Ansicht',
'90day': '90-Tage-Ansicht',
'365day': '365-Tage-Ansicht',
month: 'Monatsansicht',
year: 'Jahresansicht',
agenda: 'Agenda',
custom: 'Benutzerdefiniert',
};
// Get enabled views from settings

View file

@ -183,9 +183,10 @@
{#if visible}
<!-- Backdrop to block clicks on elements behind -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events a11y_no_noninteractive_element_interactions -->
<div
class="context-menu-backdrop"
role="presentation"
onpointerdown={(e) => {
e.preventDefault();
e.stopPropagation();
@ -384,6 +385,7 @@
}
.custom-input[type='number'] {
-moz-appearance: textfield;
appearance: textfield;
}
.custom-unit {

View file

@ -1230,12 +1230,6 @@
flex-shrink: 0;
}
.calendar-dot {
width: 14px;
height: 14px;
border-radius: 50%;
}
/* Calendar pills */
.calendar-pills-container {
padding: 0.5rem 0;
@ -1290,9 +1284,6 @@
flex-shrink: 0;
}
.calendar-pill-name {
}
.row-content {
flex: 1;
min-width: 0;

View file

@ -77,8 +77,8 @@
{#if groupTags.length > 0}
<div class="group-section">
<!-- Group Header -->
<button type="button" onclick={() => toggleGroup(group.id)} class="group-header">
<div class="flex items-center gap-2">
<div class="group-header">
<button type="button" onclick={() => toggleGroup(group.id)} class="group-toggle">
{#if isExpanded(group.id)}
<CaretDown size={16} weight="bold" class="text-muted-foreground" />
{:else}
@ -90,21 +90,18 @@
></div>
<span class="font-medium">{group.name}</span>
<span class="text-xs text-muted-foreground">({groupTags.length})</span>
</div>
</button>
{#if onEditGroup}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
onEditGroup(group);
}}
onclick={() => onEditGroup(group)}
class="edit-group-btn"
aria-label="Gruppe bearbeiten"
>
<Pencil size={14} />
</button>
{/if}
</button>
</div>
<!-- Tags in this group -->
{#if isExpanded(group.id)}

View file

@ -0,0 +1,145 @@
/**
* Runtime Configuration for Calendar App
*
* 12-Factor Pattern: Configuration is loaded from /config.json at runtime,
* generated by Docker entrypoint from environment variables.
* This allows the same Docker image to run in different environments
* without rebuilding.
*/
import { browser, dev } from '$app/environment';
import { z } from 'zod';
export interface RuntimeConfig {
BACKEND_URL: string;
AUTH_URL: string;
TODO_API_URL: string;
CONTACTS_API_URL: string;
}
/**
* Schema validation for config.json
* Ensures all required configuration is present and valid
*/
const ConfigSchema = z.object({
BACKEND_URL: z.string().url().min(1, 'BACKEND_URL must be a valid URL'),
AUTH_URL: z.string().url().min(1, 'AUTH_URL must be a valid URL'),
TODO_API_URL: z.string().url().min(1, 'TODO_API_URL must be a valid URL'),
CONTACTS_API_URL: z.string().url().min(1, 'CONTACTS_API_URL must be a valid URL'),
});
/**
* Development defaults - only used when:
* 1. dev === true (from $app/environment)
* 2. /config.json fetch fails
*
* In production, missing config.json is a deployment error.
*/
const DEV_CONFIG: RuntimeConfig = {
BACKEND_URL: 'http://localhost:3016',
AUTH_URL: 'http://localhost:3001',
TODO_API_URL: 'http://localhost:3018',
CONTACTS_API_URL: 'http://localhost:3015',
};
let cachedConfig: RuntimeConfig | null = null;
let configPromise: Promise<RuntimeConfig> | null = null;
/**
* Load configuration from /config.json
* Fail-hard in production if config is missing or invalid
*/
async function loadConfig(): Promise<RuntimeConfig> {
// Guard: SSR should never happen (we disabled it in +layout.ts)
if (!browser) {
if (dev) {
console.warn('[Calendar] Config accessed during SSR in dev mode, using fallback');
return DEV_CONFIG;
}
throw new Error('[Calendar] Runtime config called on server - SSR should be disabled');
}
// Return cached config if available
if (cachedConfig) return cachedConfig;
// Return existing promise if already loading
if (configPromise) return configPromise;
configPromise = fetch('/config.json')
.then((res) => {
if (!res.ok) {
if (dev) {
console.warn(
`[Calendar] Failed to load /config.json (HTTP ${res.status}), using dev defaults`
);
return DEV_CONFIG;
}
throw new Error(
`[Calendar] Failed to load /config.json (HTTP ${res.status}) - check Docker entrypoint script`
);
}
return res.json();
})
.then((config) => {
// Validate schema in production (fail hard on misconfiguration)
if (!dev) {
const result = ConfigSchema.safeParse(config);
if (!result.success) {
throw new Error(
`[Calendar] Invalid config.json schema: ${result.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')}`
);
}
}
cachedConfig = config as RuntimeConfig;
return cachedConfig;
});
return configPromise;
}
/**
* Get auth service URL
*/
export async function getAuthUrl(): Promise<string> {
const config = await loadConfig();
return config.AUTH_URL;
}
/**
* Get backend API URL
*/
export async function getBackendUrl(): Promise<string> {
const config = await loadConfig();
return config.BACKEND_URL;
}
/**
* Get todo service URL
*/
export async function getTodoApiUrl(): Promise<string> {
const config = await loadConfig();
return config.TODO_API_URL;
}
/**
* Get contacts service URL
*/
export async function getContactsApiUrl(): Promise<string> {
const config = await loadConfig();
return config.CONTACTS_API_URL;
}
/**
* Get full runtime config
*/
export async function getConfig(): Promise<RuntimeConfig> {
return loadConfig();
}
/**
* Initialize configuration (call early in app lifecycle)
* This triggers the config load and caches it for subsequent calls
*/
export async function initializeConfig(): Promise<void> {
await loadConfig();
}

View file

@ -1,45 +1,25 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Uses Mana Core Auth
* Uses Mana Core Auth with runtime configuration (12-factor pattern)
*/
import { browser } from '$app/environment';
import { initializeWebAuth } from '@manacore/shared-auth';
import type { UserData } from '@manacore/shared-auth';
// Get auth URL dynamically at runtime - fallback for SSR and client
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
// Falls back to localhost for local development
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
// Server-side (SSR): use Docker internal URL for container-to-container communication
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
// Get backend URL dynamically at runtime
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:3014';
}
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3014';
}
import { getAuthUrl, getBackendUrl } from '$lib/config/runtime';
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
async function getAuthService() {
if (!browser) return null;
if (!_authService) {
const authUrl = await getAuthUrl();
const backendUrl = await getBackendUrl();
const auth = initializeWebAuth({
baseUrl: getAuthUrl(),
backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses
baseUrl: authUrl,
backendUrl: backendUrl, // Enables automatic token refresh on 401 responses
});
_authService = auth.authService;
_tokenManager = auth.tokenManager;
@ -47,10 +27,10 @@ function getAuthService() {
return _authService;
}
function getTokenManager() {
async function getTokenManager() {
if (!browser) return null;
// Ensure auth service is initialized first
getAuthService();
await getAuthService();
return _tokenManager;
}
@ -80,7 +60,7 @@ export const authStore = {
async initialize() {
if (initialized) return;
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
initialized = true;
loading = false;
@ -107,7 +87,7 @@ export const authStore = {
* Sign in with email and password
*/
async signIn(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -134,7 +114,7 @@ export const authStore = {
* Sign up with email and password
*/
async signUp(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
@ -164,7 +144,7 @@ export const authStore = {
* Sign out
*/
async signOut() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
user = null;
return;
@ -184,7 +164,7 @@ export const authStore = {
* Send password reset email
*/
async resetPassword(email: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -208,7 +188,7 @@ export const authStore = {
* @deprecated Use getValidToken() instead for automatic refresh
*/
async getAccessToken() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return null;
}
@ -220,7 +200,7 @@ export const authStore = {
* Automatically refreshes if the token is expired or about to expire
*/
async getValidToken(): Promise<string | null> {
const tokenManager = getTokenManager();
const tokenManager = await getTokenManager();
if (!tokenManager) {
return null;
}

View file

@ -20,6 +20,7 @@ const birthdayCalendar: Calendar = {
color: BIRTHDAY_CALENDAR.color,
isDefault: false,
isVisible: true, // Visibility controlled by settingsStore.showBirthdays
timezone: 'UTC',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};

View file

@ -4,7 +4,8 @@
interface SearchItem {
id: string;
[key: string]: unknown;
title?: string;
subtitle?: string;
}
// State

View file

@ -5,14 +5,17 @@
* - Global settings that apply to all apps
* - Per-app overrides for customization
* - localStorage caching for offline support
*
* Uses lazy initialization to wait for runtime config to load.
*/
import { browser } from '$app/environment';
import { createUserSettingsStore } from '@manacore/shared-theme';
import { authStore } from './auth.svelte';
import { getAuthUrl as getRuntimeAuthUrl } from '$lib/config/runtime';
// Get auth URL dynamically at runtime
function getAuthUrl(): string {
// Get auth URL with fallback for early access (before runtime config loads)
function getAuthUrlSync(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
@ -21,8 +24,128 @@ function getAuthUrl(): string {
return 'http://localhost:3001';
}
export const userSettings = createUserSettingsStore({
appId: 'calendar',
authUrl: getAuthUrl(),
getAccessToken: () => authStore.getAccessToken(),
});
// Lazy-initialized store (created after runtime config is loaded)
let _store: ReturnType<typeof createUserSettingsStore> | null = null;
function getOrCreateStore(authUrl?: string) {
if (!_store) {
_store = createUserSettingsStore({
appId: 'calendar',
authUrl: authUrl || getAuthUrlSync(),
getAccessToken: () => authStore.getAccessToken(),
});
}
return _store;
}
// Ensure store is initialized with correct URL from runtime config
async function ensureStore() {
if (!_store) {
const authUrl = await getRuntimeAuthUrl();
_store = createUserSettingsStore({
appId: 'calendar',
authUrl,
getAccessToken: () => authStore.getAccessToken(),
});
}
return _store;
}
// Export proxy that lazily initializes the store
export const userSettings = {
// Getters - use sync store (may have fallback URL initially)
get nav() {
return getOrCreateStore().nav;
},
get theme() {
return getOrCreateStore().theme;
},
get locale() {
return getOrCreateStore().locale;
},
get general() {
return getOrCreateStore().general;
},
get startPage() {
return getOrCreateStore().startPage;
},
get globalSettings() {
return getOrCreateStore().globalSettings;
},
get hasAppOverride() {
return getOrCreateStore().hasAppOverride;
},
get syncing() {
return getOrCreateStore().syncing;
},
get loaded() {
return getOrCreateStore().loaded;
},
get deviceId() {
return getOrCreateStore().deviceId;
},
get deviceSettings() {
return getOrCreateStore().deviceSettings;
},
get currentDeviceAppSettings() {
return getOrCreateStore().currentDeviceAppSettings;
},
// Methods that make API calls - ensure store has correct URL
async load() {
const store = await ensureStore();
return store.load();
},
async updateGlobal(
settings: Parameters<ReturnType<typeof createUserSettingsStore>['updateGlobal']>[0]
) {
const store = await ensureStore();
return store.updateGlobal(settings);
},
async updateAppOverride(
settings: Parameters<ReturnType<typeof createUserSettingsStore>['updateAppOverride']>[0]
) {
const store = await ensureStore();
return store.updateAppOverride(settings);
},
async removeAppOverride() {
const store = await ensureStore();
return store.removeAppOverride();
},
async setStartPage(appId: string, path: string) {
const store = await ensureStore();
return store.setStartPage(appId, path);
},
async updateGeneral(
settings: Parameters<ReturnType<typeof createUserSettingsStore>['updateGeneral']>[0]
) {
const store = await ensureStore();
return store.updateGeneral(settings);
},
getHiddenNavItemsForApp(appId: string) {
return getOrCreateStore().getHiddenNavItemsForApp(appId);
},
async toggleNavItemVisibility(appId: string, href: string) {
const store = await ensureStore();
return store.toggleNavItemVisibility(appId, href);
},
async setHiddenNavItems(appId: string, hiddenHrefs: string[]) {
const store = await ensureStore();
return store.setHiddenNavItems(appId, hiddenHrefs);
},
async updateDeviceAppSettings(settings: Record<string, unknown>) {
const store = await ensureStore();
return store.updateDeviceAppSettings(settings);
},
getDeviceAppSettings() {
return getOrCreateStore().getDeviceAppSettings();
},
async getDevices() {
const store = await ensureStore();
return store.getDevices();
},
async removeDevice(deviceId: string) {
const store = await ensureStore();
return store.removeDevice(deviceId);
},
};

View file

@ -128,13 +128,19 @@
// View labels
const viewLabels: Record<CalendarViewType, string> = {
day: 'Tag',
'3day': '3 Tage',
'5day': '5 Tage',
week: 'Woche',
'10day': '10 Tage',
'14day': '14 Tage',
'30day': '30 Tage',
'60day': '60 Tage',
'90day': '90 Tage',
'365day': '365 Tage',
month: 'Monat',
year: 'Jahr',
agenda: 'Agenda',
custom: 'Benutzerdefiniert',
};
// Duration options in minutes

View file

@ -15,6 +15,16 @@
let loading = $state(true);
onMount(async () => {
// Initialize runtime config first (12-factor pattern)
const { initializeConfig, getConfig } = await import('$lib/config/runtime');
await initializeConfig();
// Inject config into window for stores that need synchronous access
const config = await getConfig();
(
window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string }
).__PUBLIC_MANA_CORE_AUTH_URL__ = config.AUTH_URL;
// Wait for i18n locale to be loaded
await waitLocale();

View file

@ -0,0 +1,10 @@
/**
* Layout Configuration
*
* Disable SSR - this is a client-only SPA that:
* - Requires authentication (no SEO benefit)
* - Fetches all data client-side via authenticated APIs
* - Loads runtime config from /config.json (browser-only)
*/
export const ssr = false;

View file

@ -0,0 +1,6 @@
{
"BACKEND_URL": "http://localhost:3016",
"AUTH_URL": "http://localhost:3001",
"TODO_API_URL": "http://localhost:3018",
"CONTACTS_API_URL": "http://localhost:3015"
}

View file

@ -12,6 +12,7 @@ COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages
COPY packages/better-auth-types ./packages/better-auth-types
COPY packages/shared-errors ./packages/shared-errors
COPY packages/shared-nestjs-auth ./packages/shared-nestjs-auth
COPY packages/shared-storage ./packages/shared-storage
@ -23,6 +24,9 @@ COPY apps/chat/apps/backend ./apps/chat/apps/backend
RUN pnpm install --frozen-lockfile
# Build shared packages first
WORKDIR /app/packages/better-auth-types
RUN pnpm build
WORKDIR /app/packages/shared-errors
RUN pnpm build

View file

@ -20,6 +20,7 @@ COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages needed by chat web
COPY packages/better-auth-types ./packages/better-auth-types
COPY packages/shared-auth ./packages/shared-auth
COPY packages/shared-auth-ui ./packages/shared-auth-ui
COPY packages/shared-branding ./packages/shared-branding
@ -45,6 +46,9 @@ COPY apps/chat/apps/web ./apps/chat/apps/web
RUN pnpm install --frozen-lockfile
# Build shared packages that need building
WORKDIR /app/packages/better-auth-types
RUN pnpm build || true
WORKDIR /app/packages/shared-auth
RUN pnpm build || true
@ -68,6 +72,10 @@ COPY --from=builder /app/apps/chat/apps/web/node_modules ./node_modules
COPY --from=builder /app/apps/chat/apps/web/build ./build
COPY --from=builder /app/apps/chat/apps/web/package.json ./
# Copy entrypoint script for runtime config generation
COPY apps/chat/apps/web/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Expose port
EXPOSE 3000
@ -80,5 +88,8 @@ ENV HOST=0.0.0.0
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
# Use entrypoint to generate runtime config
ENTRYPOINT ["docker-entrypoint.sh"]
# Run the app
CMD ["node", "build"]

View file

@ -0,0 +1,31 @@
#!/bin/sh
set -e
echo "🔧 Generating runtime configuration..."
# Environment variables with development defaults
BACKEND_URL=${BACKEND_URL:-"http://localhost:3002"}
AUTH_URL=${AUTH_URL:-"http://localhost:3001"}
echo "📝 Config values:"
echo " BACKEND_URL: $BACKEND_URL"
echo " AUTH_URL: $AUTH_URL"
# Generate config.json from environment variables
cat > /app/apps/chat/apps/web/build/client/config.json <<EOF
{
"BACKEND_URL": "${BACKEND_URL}",
"AUTH_URL": "${AUTH_URL}"
}
EOF
echo "✅ Configuration generated at /app/apps/chat/apps/web/build/client/config.json"
cat /app/apps/chat/apps/web/build/client/config.json
# Remove pre-compressed versions (SvelteKit serves these instead of the raw file)
rm -f /app/apps/chat/apps/web/build/client/config.json.br
rm -f /app/apps/chat/apps/web/build/client/config.json.gz
echo "🗑️ Removed stale pre-compressed config files"
echo "🚀 Starting Chat web app..."
exec "$@"

View file

@ -45,6 +45,7 @@
"@manacore/shared-ui": "workspace:*",
"@manacore/shared-utils": "workspace:*",
"marked": "^17.0.0",
"svelte-i18n": "^4.0.1"
"svelte-i18n": "^4.0.1",
"zod": "^3.25.76"
}
}

View file

@ -107,6 +107,7 @@
{#if editingId === conv.id}
<!-- Edit Mode -->
<div class="flex items-center gap-1 px-3 py-2 mx-2">
<!-- svelte-ignore a11y_autofocus - Intentional for edit mode UX -->
<input
type="text"
bind:value={editTitle}

View file

@ -66,11 +66,11 @@
onSubmit({
id: template?.id,
name,
description: description.trim() || null,
description: description.trim() || undefined,
systemPrompt: systemPrompt,
initialQuestion: initialQuestion.trim() || null,
initialQuestion: initialQuestion.trim() || undefined,
color: selectedColor,
modelId: selectedModelId || null,
modelId: selectedModelId || undefined,
documentMode: documentMode,
});
}
@ -169,8 +169,8 @@
<!-- Color -->
<div>
<label class="block text-sm font-medium text-foreground mb-2"> Farbe </label>
<div class="flex flex-wrap gap-2">
<span class="block text-sm font-medium text-foreground mb-2" id="color-label">Farbe</span>
<div class="flex flex-wrap gap-2" role="group" aria-labelledby="color-label">
{#each TEMPLATE_COLORS as color}
<button
type="button"

View file

@ -0,0 +1,123 @@
/**
* Runtime Configuration Loader
*
* Implements 12-factor app "Config in Environment" principle.
* Configuration is loaded at runtime from /config.json generated by Docker entrypoint,
* allowing the same Docker image to work across all environments.
*
* Pattern: Client-only SPA (SSR disabled via +layout.ts)
* - Browser: Fetches /config.json (generated by docker-entrypoint.sh)
* - Validation: Enforces schema in production (fail hard on misconfiguration)
* - Dev fallback: Only when dev=true, never in staging/prod
*/
import { browser, dev } from '$app/environment';
import { z } from 'zod';
export interface RuntimeConfig {
BACKEND_URL: string;
AUTH_URL: string;
}
const ConfigSchema = z.object({
BACKEND_URL: z.string().url().min(1, 'BACKEND_URL must be a valid URL'),
AUTH_URL: z.string().url().min(1, 'AUTH_URL must be a valid URL'),
});
// Development fallback configuration (only used when dev=true)
const DEV_CONFIG: RuntimeConfig = {
BACKEND_URL: 'http://localhost:3002',
AUTH_URL: 'http://localhost:3001',
};
let cachedConfig: RuntimeConfig | null = null;
let configPromise: Promise<RuntimeConfig> | null = null;
/**
* Load runtime configuration from /config.json
* Uses caching to avoid multiple fetches
*/
async function loadConfig(): Promise<RuntimeConfig> {
// Guard: SSR should never happen (we disabled it in +layout.ts)
if (!browser) {
if (dev) {
console.warn('[Chat] Config accessed during SSR in dev mode, using fallback');
return DEV_CONFIG;
}
throw new Error('[Chat] Runtime config called on server - SSR should be disabled');
}
// Return cached config if available
if (cachedConfig) {
return cachedConfig;
}
// If already loading, return the existing promise
if (configPromise) {
return configPromise;
}
// Fetch config from /config.json (generated by docker-entrypoint.sh)
configPromise = fetch('/config.json')
.then((res) => {
if (!res.ok) {
if (dev) {
console.warn(
`[Chat] Failed to load /config.json (HTTP ${res.status}), using dev defaults`
);
return DEV_CONFIG;
}
throw new Error(
`[Chat] Failed to load /config.json (HTTP ${res.status}) - check Docker entrypoint script`
);
}
return res.json();
})
.then((config) => {
// Validate schema in production (fail hard on misconfiguration)
if (!dev) {
const result = ConfigSchema.safeParse(config);
if (!result.success) {
throw new Error(
`[Chat] Invalid config.json schema: ${result.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')}`
);
}
}
cachedConfig = config as RuntimeConfig;
return cachedConfig;
});
return configPromise;
}
/**
* Get the full runtime configuration
*/
export async function getConfig(): Promise<RuntimeConfig> {
return loadConfig();
}
/**
* Get the Auth service URL
*/
export async function getAuthUrl(): Promise<string> {
const config = await getConfig();
return config.AUTH_URL;
}
/**
* Get the Backend API URL
*/
export async function getBackendUrl(): Promise<string> {
const config = await getConfig();
return config.BACKEND_URL;
}
/**
* Initialize runtime configuration
* Call this early in app lifecycle (e.g., +layout.svelte onMount)
*/
export async function initializeConfig(): Promise<void> {
await loadConfig();
}

View file

@ -6,10 +6,12 @@
*
* Token handling: Uses authStore.getValidToken() which automatically
* refreshes expired tokens before making requests.
*
* Uses runtime configuration for 12-factor compliance.
*/
import { env } from '$env/dynamic/public';
import { authStore } from '$lib/stores/auth.svelte';
import { getBackendUrl } from '$lib/config/runtime';
import type {
Conversation,
Message,
@ -35,8 +37,6 @@ export type {
ChatCompletionResponse,
};
const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3002';
type FetchOptions = {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
body?: unknown;
@ -56,8 +56,11 @@ async function fetchApi<T>(
return { data: null, error: new Error('No authentication token') };
}
// Get backend URL from runtime config
const backendUrl = await getBackendUrl();
try {
const response = await fetch(`${API_BASE}/api/v1${endpoint}`, {
const response = await fetch(`${backendUrl}/api/v1${endpoint}`, {
method,
headers: {
'Content-Type': 'application/json',

View file

@ -1,45 +1,24 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Now using Mana Core Auth instead of Supabase Auth
* Uses Mana Core Auth with runtime configuration
*/
import { browser } from '$app/environment';
import { initializeWebAuth } from '@manacore/shared-auth';
import type { UserData } from '@manacore/shared-auth';
// Get auth URL dynamically at runtime - fallback for SSR and client
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
// Falls back to localhost for local development
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
// Server-side (SSR): use Docker internal URL for container-to-container communication
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
// Get backend URL dynamically at runtime
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:3002';
}
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3002';
}
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
import { getAuthUrl, getBackendUrl } from '$lib/config/runtime';
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
async function getAuthService() {
if (!browser) return null;
if (!_authService) {
const authUrl = await getAuthUrl();
const backendUrl = await getBackendUrl();
const auth = initializeWebAuth({
baseUrl: getAuthUrl(),
backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses
baseUrl: authUrl,
backendUrl: backendUrl, // Enables automatic token refresh on 401 responses
});
_authService = auth.authService;
_tokenManager = auth.tokenManager;
@ -47,10 +26,10 @@ function getAuthService() {
return _authService;
}
function getTokenManager() {
async function getTokenManager() {
if (!browser) return null;
// Ensure auth service is initialized first
getAuthService();
await getAuthService();
return _tokenManager;
}
@ -80,7 +59,7 @@ export const authStore = {
async initialize() {
if (initialized) return;
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
initialized = true;
loading = false;
@ -107,7 +86,7 @@ export const authStore = {
* Sign in with email and password
*/
async signIn(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -123,7 +102,7 @@ export const authStore = {
const userData = await authService.getUserFromToken();
user = userData;
return { success: true, error: null };
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
@ -134,7 +113,7 @@ export const authStore = {
* Sign up with email and password
*/
async signUp(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
@ -148,7 +127,7 @@ export const authStore = {
// Mana Core Auth requires separate login after signup
if (result.needsVerification) {
return { success: true, error: null, needsVerification: true };
return { success: true, needsVerification: true };
}
// Auto sign in after successful signup
@ -164,7 +143,7 @@ export const authStore = {
* Sign out
*/
async signOut() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
user = null;
return;
@ -184,7 +163,7 @@ export const authStore = {
* Send password reset email
*/
async resetPassword(email: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -196,7 +175,7 @@ export const authStore = {
return { success: false, error: result.error || 'Password reset failed' };
}
return { success: true, error: null };
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
@ -207,7 +186,7 @@ export const authStore = {
* Get user credit balance
*/
async getCredits() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return null;
}
@ -226,7 +205,7 @@ export const authStore = {
* @deprecated Use getValidToken() instead for automatic refresh
*/
async getAccessToken() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return null;
}
@ -238,7 +217,7 @@ export const authStore = {
* Automatically refreshes if the token is expired or about to expire
*/
async getValidToken(): Promise<string | null> {
const tokenManager = getTokenManager();
const tokenManager = await getTokenManager();
if (!tokenManager) {
return null;
}

View file

@ -166,9 +166,24 @@
</SettingsCard>
<div class="flex flex-wrap gap-4 text-sm mt-2">
<a href="#" class="text-[hsl(var(--primary))] hover:underline">Datenschutz</a>
<a href="#" class="text-[hsl(var(--primary))] hover:underline">Nutzungsbedingungen</a>
<a href="#" class="text-[hsl(var(--primary))] hover:underline">Hilfe & Support</a>
<button
onclick={() => alert('Datenschutz-Seite wird bald verfügbar sein.')}
class="text-[hsl(var(--primary))] hover:underline"
>
Datenschutz
</button>
<button
onclick={() => alert('Nutzungsbedingungen werden bald verfügbar sein.')}
class="text-[hsl(var(--primary))] hover:underline"
>
Nutzungsbedingungen
</button>
<button
onclick={() => alert('Hilfe & Support wird bald verfügbar sein.')}
class="text-[hsl(var(--primary))] hover:underline"
>
Hilfe & Support
</button>
</div>
</SettingsSection>
</SettingsPage>

View file

@ -81,11 +81,11 @@
await templatesStore.createTemplate({
userId: authStore.user.id,
name: data.name!,
description: data.description ?? null,
description: data.description,
systemPrompt: data.systemPrompt!,
initialQuestion: data.initialQuestion ?? null,
initialQuestion: data.initialQuestion,
color: data.color!,
modelId: data.modelId ?? null,
modelId: data.modelId,
isDefault: false,
documentMode: data.documentMode ?? false,
});

View file

@ -2,13 +2,17 @@
import '../app.css';
import { onMount } from 'svelte';
import { theme } from '$lib/stores/theme';
import { initializeConfig } from '$lib/config/runtime';
import Toast from '$lib/components/Toast.svelte';
let { children } = $props();
onMount(() => {
const cleanup = theme.initialize();
return cleanup;
// Initialize runtime config first (12-factor pattern)
initializeConfig();
// Initialize theme
return theme.initialize();
});
</script>

View file

@ -0,0 +1,10 @@
/**
* Layout Configuration
*
* Disable SSR - this is a client-only SPA that:
* - Requires authentication (no SEO benefit)
* - Fetches all data client-side via authenticated APIs
* - Loads runtime config from /config.json (browser-only)
*/
export const ssr = false;

View file

@ -0,0 +1,4 @@
{
"BACKEND_URL": "http://localhost:3002",
"AUTH_URL": "http://localhost:3001"
}

View file

@ -71,10 +71,10 @@ export interface Template {
id: string;
userId: string;
name: string;
description: string | null;
description?: string;
systemPrompt: string;
initialQuestion: string | null;
modelId: string | null;
initialQuestion?: string;
modelId?: string;
color: string;
isDefault: boolean;
documentMode: boolean;

View file

@ -12,6 +12,7 @@ COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages
COPY packages/better-auth-types ./packages/better-auth-types
COPY packages/shared-errors ./packages/shared-errors
COPY packages/shared-nestjs-auth ./packages/shared-nestjs-auth
@ -23,6 +24,9 @@ COPY apps/clock/apps/backend ./apps/clock/apps/backend
RUN pnpm install --frozen-lockfile
# Build shared packages first
WORKDIR /app/packages/better-auth-types
RUN pnpm build
WORKDIR /app/packages/shared-errors
RUN pnpm build

View file

@ -20,6 +20,7 @@ COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages needed by clock web
COPY packages/better-auth-types ./packages/better-auth-types
COPY packages/shared-auth ./packages/shared-auth
COPY packages/shared-auth-ui ./packages/shared-auth-ui
COPY packages/shared-branding ./packages/shared-branding
@ -45,6 +46,9 @@ COPY apps/clock/apps/web ./apps/clock/apps/web
RUN pnpm install --frozen-lockfile
# Build shared packages that need building
WORKDIR /app/packages/better-auth-types
RUN pnpm build || true
WORKDIR /app/packages/shared-auth
RUN pnpm build || true
@ -68,6 +72,10 @@ COPY --from=builder /app/apps/clock/apps/web/node_modules ./node_modules
COPY --from=builder /app/apps/clock/apps/web/build ./build
COPY --from=builder /app/apps/clock/apps/web/package.json ./
# Copy entrypoint script for runtime config generation
COPY apps/clock/apps/web/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Expose port
EXPOSE 5187
@ -80,5 +88,8 @@ ENV HOST=0.0.0.0
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:5187/health || exit 1
# Use entrypoint to generate runtime config
ENTRYPOINT ["docker-entrypoint.sh"]
# Run the app
CMD ["node", "build"]

View file

@ -0,0 +1,31 @@
#!/bin/sh
set -e
echo "🔧 Generating runtime configuration..."
# Environment variables with development defaults
API_BASE_URL=${API_BASE_URL:-"http://localhost:3017"}
AUTH_URL=${AUTH_URL:-"http://localhost:3001"}
echo "📝 Config values:"
echo " API_BASE_URL: $API_BASE_URL"
echo " AUTH_URL: $AUTH_URL"
# Generate config.json from environment variables
cat > /app/apps/clock/apps/web/build/client/config.json <<EOF
{
"API_BASE_URL": "${API_BASE_URL}",
"AUTH_URL": "${AUTH_URL}"
}
EOF
echo "✅ Configuration generated at /app/apps/clock/apps/web/build/client/config.json"
cat /app/apps/clock/apps/web/build/client/config.json
# Remove pre-compressed versions (SvelteKit serves these instead of the raw file)
rm -f /app/apps/clock/apps/web/build/client/config.json.br
rm -f /app/apps/clock/apps/web/build/client/config.json.gz
echo "🗑️ Removed stale pre-compressed config files"
echo "🚀 Starting Clock web app..."
exec "$@"

View file

@ -48,7 +48,8 @@
"d3": "^7.9.0",
"svelte-dnd-action": "^0.9.68",
"svelte-i18n": "^4.0.1",
"topojson-client": "^3.1.0"
"topojson-client": "^3.1.0",
"zod": "^3.25.76"
},
"type": "module"
}

View file

@ -1,10 +1,10 @@
/**
* API Client for Clock backend
* Uses runtime configuration for 12-factor compliance
*/
import { authStore } from '$lib/stores/auth.svelte';
const API_URL = 'http://localhost:3017/api/v1';
import { getApiBaseUrl } from '$lib/config/runtime';
export interface ApiResponse<T> {
data?: T;
@ -17,6 +17,7 @@ export async function fetchApi<T>(
): Promise<ApiResponse<T>> {
try {
const token = await authStore.getAccessToken();
const apiBaseUrl = await getApiBaseUrl();
const headers: HeadersInit = {
'Content-Type': 'application/json',
@ -27,7 +28,7 @@ export async function fetchApi<T>(
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_URL}${endpoint}`, {
const response = await fetch(`${apiBaseUrl}/api/v1${endpoint}`, {
...options,
headers,
});

View file

@ -0,0 +1,23 @@
/**
* Feedback Service Instance for Clock Web App
*/
import { createFeedbackService } from '@manacore/shared-feedback-service';
import { authStore } from '$lib/stores/auth.svelte';
import { browser } from '$app/environment';
// Get auth URL dynamically at runtime
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 'http://localhost:3001';
}
export const feedbackService = createFeedbackService({
apiUrl: getAuthUrl(),
appId: 'clock',
getAuthToken: async () => authStore.getAccessToken(),
});

View file

@ -20,7 +20,8 @@
let circumference = $derived(2 * Math.PI * radius);
let dashOffset = $derived(circumference - (percentage / 100) * circumference);
// Animation
// Animation - intentionally captures initial circumference for animation start
// svelte-ignore state_referenced_locally
let animatedOffset = $state(circumference);
let mounted = $state(false);

View file

@ -0,0 +1,123 @@
/**
* Runtime Configuration Loader
*
* Implements 12-factor app "Config in Environment" principle.
* Configuration is loaded at runtime from /config.json generated by Docker entrypoint,
* allowing the same Docker image to work across all environments.
*
* Pattern: Client-only SPA (SSR disabled via +layout.ts)
* - Browser: Fetches /config.json (generated by docker-entrypoint.sh)
* - Validation: Enforces schema in production (fail hard on misconfiguration)
* - Dev fallback: Only when dev=true, never in staging/prod
*/
import { browser, dev } from '$app/environment';
import { z } from 'zod';
export interface RuntimeConfig {
API_BASE_URL: string;
AUTH_URL: string;
}
const ConfigSchema = z.object({
API_BASE_URL: z.string().url().min(1, 'API_BASE_URL must be a valid URL'),
AUTH_URL: z.string().url().min(1, 'AUTH_URL must be a valid URL'),
});
// Development fallback configuration (only used when dev=true)
const DEV_CONFIG: RuntimeConfig = {
API_BASE_URL: 'http://localhost:3017',
AUTH_URL: 'http://localhost:3001',
};
let cachedConfig: RuntimeConfig | null = null;
let configPromise: Promise<RuntimeConfig> | null = null;
/**
* Load runtime configuration from /config.json
* Uses caching to avoid multiple fetches
*/
async function loadConfig(): Promise<RuntimeConfig> {
// Guard: SSR should never happen (we disabled it in +layout.ts)
if (!browser) {
if (dev) {
console.warn('[Clock] Config accessed during SSR in dev mode, using fallback');
return DEV_CONFIG;
}
throw new Error('[Clock] Runtime config called on server - SSR should be disabled');
}
// Return cached config if available
if (cachedConfig) {
return cachedConfig;
}
// If already loading, return the existing promise
if (configPromise) {
return configPromise;
}
// Fetch config from /config.json (generated by docker-entrypoint.sh)
configPromise = fetch('/config.json')
.then((res) => {
if (!res.ok) {
if (dev) {
console.warn(
`[Clock] Failed to load /config.json (HTTP ${res.status}), using dev defaults`
);
return DEV_CONFIG;
}
throw new Error(
`[Clock] Failed to load /config.json (HTTP ${res.status}) - check Docker entrypoint script`
);
}
return res.json();
})
.then((config) => {
// Validate schema in production (fail hard on misconfiguration)
if (!dev) {
const result = ConfigSchema.safeParse(config);
if (!result.success) {
throw new Error(
`[Clock] Invalid config.json schema: ${result.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')}`
);
}
}
cachedConfig = config as RuntimeConfig;
return cachedConfig;
});
return configPromise;
}
/**
* Get the full runtime configuration
*/
export async function getConfig(): Promise<RuntimeConfig> {
return loadConfig();
}
/**
* Get the Auth service URL
*/
export async function getAuthUrl(): Promise<string> {
const config = await getConfig();
return config.AUTH_URL;
}
/**
* Get the API base URL
*/
export async function getApiBaseUrl(): Promise<string> {
const config = await getConfig();
return config.API_BASE_URL;
}
/**
* Initialize runtime configuration
* Call this early in app lifecycle (e.g., +layout.svelte onMount)
*/
export async function initializeConfig(): Promise<void> {
await loadConfig();
}

View file

@ -1,44 +1,24 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Uses Mana Core Auth
* Uses Mana Core Auth with runtime configuration
*/
import { browser } from '$app/environment';
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
// Get auth URL dynamically at runtime - fallback for SSR and client
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
// Falls back to localhost for local development
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
// Server-side (SSR): use Docker internal URL for container-to-container communication
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
// Get backend URL dynamically at runtime
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:3017';
}
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3017';
}
import { getAuthUrl, getApiBaseUrl } from '$lib/config/runtime';
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
async function getAuthService() {
if (!browser) return null;
if (!_authService) {
const authUrl = await getAuthUrl();
const backendUrl = await getApiBaseUrl();
const auth = initializeWebAuth({
baseUrl: getAuthUrl(),
backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses
baseUrl: authUrl,
backendUrl: backendUrl, // Enables automatic token refresh on 401 responses
});
_authService = auth.authService;
_tokenManager = auth.tokenManager;
@ -46,10 +26,10 @@ function getAuthService() {
return _authService;
}
function getTokenManager() {
async function getTokenManager() {
if (!browser) return null;
// Ensure auth service is initialized first
getAuthService();
await getAuthService();
return _tokenManager;
}
@ -79,7 +59,7 @@ export const authStore = {
async initialize() {
if (initialized) return;
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
initialized = true;
loading = false;
@ -106,7 +86,7 @@ export const authStore = {
* Sign in with email and password
*/
async signIn(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -133,7 +113,7 @@ export const authStore = {
* Sign up with email and password
*/
async signUp(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
@ -163,7 +143,7 @@ export const authStore = {
* Sign out
*/
async signOut() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
user = null;
return;
@ -183,7 +163,7 @@ export const authStore = {
* Send password reset email
*/
async resetPassword(email: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -207,7 +187,7 @@ export const authStore = {
* @deprecated Use getValidToken() instead for automatic refresh
*/
async getAccessToken() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return null;
}
@ -219,7 +199,7 @@ export const authStore = {
* Automatically refreshes if the token is expired or about to expire
*/
async getValidToken(): Promise<string | null> {
const tokenManager = getTokenManager();
const tokenManager = await getTokenManager();
if (!tokenManager) {
return null;
}

View file

@ -69,7 +69,8 @@
try {
// Search alarms
const alarms = await alarmsApi.getAll();
const alarmsResponse = await alarmsApi.getAll();
const alarms = alarmsResponse.data || [];
const matchingAlarms = alarms
.filter((alarm) => alarm.label?.toLowerCase().includes(queryLower))
.slice(0, 5)
@ -81,7 +82,8 @@
results.push(...matchingAlarms);
// Search timers
const timers = await timersApi.getAll();
const timersResponse = await timersApi.getAll();
const timers = timersResponse.data || [];
const matchingTimers = timers
.filter((timer) => timer.label?.toLowerCase().includes(queryLower))
.slice(0, 5)

View file

@ -265,25 +265,25 @@
}}
>
<!-- Time -->
<div class="mb-4">
<label class="mb-1 block text-sm font-medium">{$_('alarm.time')}</label>
<label class="mb-4 block">
<span class="mb-1 block text-sm font-medium">{$_('alarm.time')}</span>
<input type="time" class="input time-input" bind:value={editTime} />
</div>
</label>
<!-- Label -->
<div class="mb-4">
<label class="mb-1 block text-sm font-medium">{$_('alarm.label')}</label>
<label class="mb-4 block">
<span class="mb-1 block text-sm font-medium">{$_('alarm.label')}</span>
<input
type="text"
class="input"
placeholder="Arbeit, Sport, etc."
bind:value={editLabel}
/>
</div>
</label>
<!-- Repeat Days -->
<div class="mb-4">
<label class="mb-2 block text-sm font-medium">{$_('alarm.repeat')}</label>
<div class="mb-2 text-sm font-medium">{$_('alarm.repeat')}</div>
<div class="day-selector">
{#each dayNames as day, i}
<button
@ -298,25 +298,25 @@
</div>
<!-- Sound -->
<div class="mb-4">
<label class="mb-1 block text-sm font-medium">{$_('alarm.sound')}</label>
<label class="mb-4 block">
<span class="mb-1 block text-sm font-medium">{$_('alarm.sound')}</span>
<select class="input" bind:value={editSound}>
{#each ALARM_SOUNDS as sound}
<option value={sound.id}>{sound.nameDE}</option>
{/each}
</select>
</div>
</label>
<!-- Snooze -->
<div class="mb-6">
<label class="mb-1 block text-sm font-medium">{$_('alarm.snooze')}</label>
<label class="mb-6 block">
<span class="mb-1 block text-sm font-medium">{$_('alarm.snooze')}</span>
<select class="input" bind:value={editSnoozeMinutes}>
<option value={5}>5 Minuten</option>
<option value={10}>10 Minuten</option>
<option value={15}>15 Minuten</option>
<option value={30}>30 Minuten</option>
</select>
</div>
</label>
<!-- Actions -->
<div class="flex gap-3">

View file

@ -1,32 +1,8 @@
<script lang="ts">
import { browser } from '$app/environment';
import { FeedbackPage } from '@manacore/shared-feedback-ui';
import { createFeedbackService } from '@manacore/shared-feedback-service';
import { feedbackService } from '$lib/api/feedback';
import { authStore } from '$lib/stores/auth.svelte';
import '$lib/i18n';
// Get auth URL dynamically at runtime
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 'http://localhost:3001';
}
const feedbackService = createFeedbackService({
appName: 'clock',
apiUrl: getAuthUrl(),
});
async function handleSubmit(data: { type: string; message: string; email?: string }) {
const token = await authStore.getAccessToken();
return feedbackService.submit({
...data,
token: token || undefined,
});
}
</script>
<FeedbackPage appName="Clock" onSubmit={handleSubmit} userEmail={authStore.user?.email} />
<FeedbackPage {feedbackService} appName="Clock" currentUserId={authStore.user?.id} />

View file

@ -1,6 +1,16 @@
<script lang="ts">
import { SubscriptionPage } from '@manacore/shared-subscription-ui';
import { authStore } from '$lib/stores/auth.svelte';
function handleSubscribe(planId: string) {
console.log('Subscribe to plan:', planId);
// TODO: Implement subscription logic
}
function handleBuyPackage(packageId: string) {
console.log('Buy package:', packageId);
// TODO: Implement package purchase logic
}
</script>
<SubscriptionPage user={authStore.user} appName="Clock" />
<SubscriptionPage appName="Clock" onSubscribe={handleSubscribe} onBuyPackage={handleBuyPackage} />

View file

@ -1,6 +1,26 @@
<script lang="ts">
import { ProfilePage } from '@manacore/shared-profile-ui';
import type { UserProfile, ProfileActions } from '@manacore/shared-profile-ui';
import { authStore } from '$lib/stores/auth.svelte';
import { goto } from '$app/navigation';
// Map auth store user to UserProfile
let userProfile = $derived<UserProfile>({
id: authStore.user?.id || '',
email: authStore.user?.email || '',
role: authStore.user?.role,
});
// Profile actions
const actions: ProfileActions = {
onLogout: async () => {
await authStore.signOut();
goto('/login');
},
onDeleteAccount: () => {
alert('Konto löschen ist noch nicht implementiert.');
},
};
</script>
<ProfilePage user={authStore.user} appName="Clock" />
<ProfilePage user={userProfile} appName="Clock" {actions} />

View file

@ -49,7 +49,7 @@
<h2 class="mb-4 text-lg font-semibold">{$_('settings.clockFormat')}</h2>
<div>
<label class="mb-2 block text-sm font-medium">Zeitformat</label>
<div class="mb-2 text-sm font-medium">Zeitformat</div>
<div class="flex gap-2">
<button
class="btn btn-sm"

View file

@ -121,6 +121,7 @@
style="background-color: {focused.color}"
></div>
{#if editingLabelId === focused.id}
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
class="bg-transparent border-b border-primary text-lg font-medium focus:outline-none"
@ -141,6 +142,7 @@
<button
class="text-muted-foreground hover:text-error transition-colors p-1"
onclick={() => stopwatchesStore.delete(focused.id)}
aria-label="Delete stopwatch"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -341,6 +343,7 @@
e.stopPropagation();
stopwatchesStore.delete(sw.id);
}}
aria-label="Delete stopwatch"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -397,6 +400,7 @@
e.stopPropagation();
stopwatchesStore.reset(sw.id);
}}
aria-label="Reset stopwatch"
>
<svg
xmlns="http://www.w3.org/2000/svg"

View file

@ -18,7 +18,7 @@
<span class="text-3xl">{def.icon}</span>
<div>
<h3 class="font-semibold">{def.label}</h3>
<p class="text-sm text-muted-foreground">{def.description}</p>
<p class="text-sm text-muted-foreground">{def.emoji}</p>
</div>
</div>
{#if theme.variant === variant}

View file

@ -245,6 +245,7 @@
e.stopPropagation();
handleDelete(timer.id, isLocal);
}}
aria-label="Delete timer"
>
<svg
xmlns="http://www.w3.org/2000/svg"

View file

@ -223,6 +223,7 @@
<button
class="absolute right-3 top-3 text-muted-foreground hover:text-error p-0.5"
onclick={() => removeCity(clock.id)}
aria-label="Remove city"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -269,7 +270,11 @@
<div class="card w-full max-w-md max-h-[80vh] flex flex-col">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold">{$_('worldClock.add')}</h2>
<button class="text-muted-foreground hover:text-foreground p-0.5" onclick={closeAddModal}>
<button
class="text-muted-foreground hover:text-foreground p-0.5"
onclick={closeAddModal}
aria-label="Close modal"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"

View file

@ -1,35 +1,28 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { locale } from 'svelte-i18n';
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
import { getForgotPasswordTranslations } from '@manacore/shared-i18n';
import { ClockLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import '$lib/i18n';
let error = $state('');
let success = $state(false);
let loading = $state(false);
// Get translations based on current locale
const translations = $derived(getForgotPasswordTranslations($locale || 'de'));
async function handleResetPassword(email: string) {
loading = true;
error = '';
success = false;
const result = await authStore.resetPassword(email);
if (result.success) {
success = true;
} else {
error = result.error || 'Passwort-Zurücksetzung fehlgeschlagen';
}
loading = false;
async function handleForgotPassword(email: string) {
return authStore.resetPassword(email);
}
</script>
<ForgotPasswordPage
appName="Clock"
appLogo=""
{loading}
{error}
{success}
onSubmit={handleResetPassword}
loginHref="/login"
logo={ClockLogo}
primaryColor="#f59e0b"
onForgotPassword={handleForgotPassword}
{goto}
loginPath="/login"
lightBackground="#fef3c7"
darkBackground="#1f1612"
{translations}
/>

View file

@ -1,38 +1,29 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { locale } from 'svelte-i18n';
import { RegisterPage } from '@manacore/shared-auth-ui';
import { getRegisterTranslations } from '@manacore/shared-i18n';
import { ClockLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import '$lib/i18n';
let error = $state('');
let loading = $state(false);
// Get translations based on current locale
const translations = $derived(getRegisterTranslations($locale || 'de'));
async function handleRegister(email: string, password: string) {
loading = true;
error = '';
const result = await authStore.signUp(email, password);
if (result.success) {
if (result.needsVerification) {
// Show verification message or redirect to verification page
goto('/login?registered=true');
} else {
goto('/');
}
} else {
error = result.error || 'Registrierung fehlgeschlagen';
}
loading = false;
async function handleSignUp(email: string, password: string) {
return authStore.signUp(email, password);
}
</script>
<RegisterPage
appName="Clock"
appLogo=""
{loading}
{error}
onSubmit={handleRegister}
loginHref="/login"
logo={ClockLogo}
primaryColor="#f59e0b"
onSignUp={handleSignUp}
{goto}
successRedirect="/"
loginPath="/login"
lightBackground="#fef3c7"
darkBackground="#1f1612"
{translations}
/>

View file

@ -5,6 +5,7 @@
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { waitLocale } from '$lib/i18n';
import { initializeConfig } from '$lib/config/runtime';
import ToastContainer from '$lib/components/ToastContainer.svelte';
import { AppLoadingSkeleton } from '$lib/components/skeletons';
@ -13,6 +14,9 @@
let loading = $state(true);
onMount(async () => {
// Initialize runtime config first (12-factor pattern)
await initializeConfig();
// Wait for locale to be loaded
await waitLocale();

View file

@ -0,0 +1,10 @@
/**
* Layout Configuration
*
* Disable SSR - this is a client-only SPA that:
* - Requires authentication (no SEO benefit)
* - Fetches all data client-side via authenticated APIs
* - Loads runtime config from /config.json (browser-only)
*/
export const ssr = false;

View file

@ -0,0 +1,4 @@
{
"API_BASE_URL": "http://localhost:3017",
"AUTH_URL": "http://localhost:3001"
}

View file

@ -4,19 +4,19 @@ import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { contacts } from '../db/schema';
import {
createContactsStorage,
generateUserFileKey,
createUnifiedStorage,
getContentType,
validateFileSize,
validateFileExtension,
IMAGE_EXTENSIONS,
APPS,
} from '@manacore/shared-storage';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
@Injectable()
export class PhotoService {
private storage = createContactsStorage();
private storage = createUnifiedStorage();
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
@ -66,19 +66,22 @@ export class PhotoService {
}
}
// Generate unique key for the new photo
// Generate unique key for the new photo: {userId}/contacts/{contactId}.{ext}
const filename = `${contactId}.${extension}`;
const key = generateUserFileKey(userId, filename);
const key = `${userId}/${APPS.CONTACTS}/${filename}`;
// Upload to S3
const contentType = getContentType(filename);
await this.storage.upload(key, file.buffer, {
const result = await this.storage.upload(key, file.buffer, {
contentType,
public: true,
});
// Generate the URL (for MinIO, construct it manually)
const photoUrl = `http://localhost:9000/contacts-storage/${key}`;
// Get URL from storage client or construct manually
const photoUrl =
result.url ||
this.storage.getPublicUrl(key) ||
`${process.env.MANACORE_STORAGE_PUBLIC_URL || 'http://localhost:9000/manacore-storage'}/${key}`;
// Update contact with photo URL
await this.db
@ -125,8 +128,12 @@ export class PhotoService {
}
private extractKeyFromUrl(url: string): string | null {
// Extract key from URLs like http://localhost:9000/contacts-storage/users/xxx/file.jpg
const match = url.match(/contacts-storage\/(.+)$/);
return match ? match[1] : null;
// Extract key from URLs like http://localhost:9000/manacore-storage/userId/contacts/file.jpg
// Also support old format: http://localhost:9000/contacts-storage/users/xxx/file.jpg
const unifiedMatch = url.match(/manacore-storage\/(.+)$/);
if (unifiedMatch) return unifiedMatch[1];
const legacyMatch = url.match(/contacts-storage\/(.+)$/);
return legacyMatch ? legacyMatch[1] : null;
}
}

View file

@ -0,0 +1,98 @@
# Build stage
FROM node:20-alpine AS builder
# Build arguments for SvelteKit static env vars
ARG PUBLIC_BACKEND_URL=http://contacts-backend:3015
ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-core-auth:3001
# Set as environment variables for build
ENV PUBLIC_BACKEND_URL=$PUBLIC_BACKEND_URL
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_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 contacts web
COPY packages/better-auth-types ./packages/better-auth-types
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-feedback-service ./packages/shared-feedback-service
COPY packages/shared-feedback-ui ./packages/shared-feedback-ui
COPY packages/shared-help-content ./packages/shared-help-content
COPY packages/shared-help-types ./packages/shared-help-types
COPY packages/shared-help-ui ./packages/shared-help-ui
COPY packages/shared-i18n ./packages/shared-i18n
COPY packages/shared-icons ./packages/shared-icons
COPY packages/shared-profile-ui ./packages/shared-profile-ui
COPY packages/shared-splitscreen ./packages/shared-splitscreen
COPY packages/shared-subscription-ui ./packages/shared-subscription-ui
COPY packages/shared-tags ./packages/shared-tags
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-ui ./packages/shared-ui
COPY packages/shared-utils ./packages/shared-utils
# Copy contacts packages
COPY apps/contacts/packages ./apps/contacts/packages
COPY apps/contacts/apps/web ./apps/contacts/apps/web
# Install dependencies
RUN pnpm install --frozen-lockfile
# Build shared packages that need building
WORKDIR /app/packages/better-auth-types
RUN pnpm build || true
WORKDIR /app/packages/shared-auth
RUN pnpm build || true
# Build the web app
WORKDIR /app/apps/contacts/apps/web
RUN pnpm build
# Production stage
FROM node:20-alpine AS production
# Keep same directory structure as builder so pnpm symlinks resolve correctly
WORKDIR /app/apps/contacts/apps/web
# Copy the pnpm store that symlinks point to (at /app/node_modules/.pnpm)
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
# Copy the app's node_modules (contains symlinks to the pnpm store)
COPY --from=builder /app/apps/contacts/apps/web/node_modules ./node_modules
# Copy built application
COPY --from=builder /app/apps/contacts/apps/web/build ./build
COPY --from=builder /app/apps/contacts/apps/web/package.json ./
# Copy entrypoint script for runtime config generation
COPY apps/contacts/apps/web/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Expose port
EXPOSE 3000
# Set environment variables
ENV NODE_ENV=production
ENV PORT=3000
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:3000/health || exit 1
# Use entrypoint to generate runtime config
ENTRYPOINT ["docker-entrypoint.sh"]
# Run the app
CMD ["node", "build"]

View file

@ -0,0 +1,31 @@
#!/bin/sh
set -e
echo "🔧 Generating runtime configuration..."
# Environment variables with development defaults
BACKEND_URL=${BACKEND_URL:-"http://localhost:3015"}
AUTH_URL=${AUTH_URL:-"http://localhost:3001"}
echo "📝 Config values:"
echo " BACKEND_URL: $BACKEND_URL"
echo " AUTH_URL: $AUTH_URL"
# Generate config.json from environment variables
cat > /app/apps/contacts/apps/web/build/client/config.json <<EOF
{
"BACKEND_URL": "${BACKEND_URL}",
"AUTH_URL": "${AUTH_URL}"
}
EOF
echo "✅ Configuration generated at /app/apps/contacts/apps/web/build/client/config.json"
cat /app/apps/contacts/apps/web/build/client/config.json
# Remove pre-compressed versions (SvelteKit serves these instead of the raw file)
rm -f /app/apps/contacts/apps/web/build/client/config.json.br
rm -f /app/apps/contacts/apps/web/build/client/config.json.gz
echo "🗑️ Removed stale pre-compressed config files"
echo "🚀 Starting Contacts web app..."
exec "$@"

View file

@ -54,7 +54,8 @@
"d3-zoom": "^3.0.0",
"date-fns": "^4.1.0",
"lucide-svelte": "^0.556.0",
"svelte-i18n": "^4.0.1"
"svelte-i18n": "^4.0.1",
"zod": "^3.25.76"
},
"type": "module"
}

View file

@ -1,9 +1,10 @@
/**
* Centralized API client with authentication
* Uses runtime configuration for 12-factor compliance
*/
import { authStore } from '$lib/stores/auth.svelte';
import { API_BASE } from './config';
import { getApiBase } from './config';
/**
* Make an authenticated API request
@ -16,6 +17,7 @@ export async function fetchWithAuth<T = unknown>(
options: RequestInit = {}
): Promise<T> {
const token = await authStore.getAccessToken();
const apiBase = await getApiBase();
const headers: HeadersInit = {
'Content-Type': 'application/json',
@ -26,7 +28,7 @@ export async function fetchWithAuth<T = unknown>(
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${url}`, {
const response = await fetch(`${apiBase}${url}`, {
...options,
headers,
});
@ -48,6 +50,7 @@ export async function fetchWithAuthFormData<T = unknown>(
options: RequestInit = {}
): Promise<T> {
const token = await authStore.getAccessToken();
const apiBase = await getApiBase();
const headers: HeadersInit = {
...(options.headers || {}),
@ -57,7 +60,7 @@ export async function fetchWithAuthFormData<T = unknown>(
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${url}`, {
const response = await fetch(`${apiBase}${url}`, {
...options,
headers,
});

View file

@ -1,13 +1,33 @@
import { PUBLIC_BACKEND_URL, PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
/**
* API Configuration
* Uses environment variables with fallbacks for development
* Uses runtime configuration for 12-factor compliance
*/
export const API_BASE = `${PUBLIC_BACKEND_URL || 'http://localhost:3015'}/api/v1`;
import { getBackendUrl, getAuthUrl } from '$lib/config/runtime';
/**
* Mana Core Auth URL
* Central authentication service URL
* Get API base URL with /api/v1 suffix
*/
export const MANA_AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
export async function getApiBase(): Promise<string> {
const backendUrl = await getBackendUrl();
return `${backendUrl}/api/v1`;
}
/**
* Get Mana Core Auth URL
*/
export async function getManaAuthUrl(): Promise<string> {
return await getAuthUrl();
}
/**
* @deprecated Use getApiBase() instead for runtime config
* This export is kept for backward compatibility
*/
export const API_BASE = 'http://localhost:3015/api/v1';
/**
* @deprecated Use getManaAuthUrl() instead for runtime config
* This export is kept for backward compatibility
*/
export const MANA_AUTH_URL = 'http://localhost:3001';

View file

@ -23,6 +23,7 @@
let saving = $state(false);
let deleting = $state(false);
let uploadingPhoto = $state(false);
// svelte-ignore non_reactive_update - Element reference doesn't need reactivity
let photoInput: HTMLInputElement;
// Edit form state
@ -1089,15 +1090,6 @@
}
/* Loading */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
gap: 1rem;
}
.spinner-lg {
width: 3rem;
height: 3rem;
@ -1105,11 +1097,6 @@
animation: spin 1s linear infinite;
}
.loading-text {
color: hsl(var(--color-muted-foreground));
font-size: 0.9375rem;
}
/* Error */
.error-container {
display: flex;

View file

@ -19,6 +19,7 @@
// Infinite scroll
let intersectionObserver: IntersectionObserver | null = null;
// svelte-ignore non_reactive_update - Element reference doesn't need reactivity
let loadMoreTrigger: HTMLDivElement;
// Batch selection state

View file

@ -445,12 +445,6 @@
}
/* Loading & Empty */
.loading {
display: flex;
justify-content: center;
padding: 1.5rem;
}
.spinner {
width: 1.25rem;
height: 1.25rem;

View file

@ -157,9 +157,10 @@
>
<!-- Tags Filter -->
<div class="filter-section">
<label class="filter-label">{$_('filters.tag')}</label>
<span class="filter-label" id="tag-filter-label">{$_('filters.tag')}</span>
<select
class="filter-select"
aria-labelledby="tag-filter-label"
value={selectedTagId || ''}
onchange={(e) => onTagChange(e.currentTarget.value || null)}
>
@ -172,9 +173,10 @@
<!-- Contact Info Filter -->
<div class="filter-section">
<label class="filter-label">{$_('filters.contactInfo')}</label>
<span class="filter-label" id="contact-filter-label">{$_('filters.contactInfo')}</span>
<select
class="filter-select"
aria-labelledby="contact-filter-label"
value={contactFilter}
onchange={(e) => onContactFilterChange(e.currentTarget.value as ContactFilter)}
>
@ -188,9 +190,10 @@
<!-- Birthday Filter -->
<div class="filter-section">
<label class="filter-label">{$_('filters.birthdayLabel')}</label>
<span class="filter-label" id="birthday-filter-label">{$_('filters.birthdayLabel')}</span>
<select
class="filter-select"
aria-labelledby="birthday-filter-label"
value={birthdayFilter}
onchange={(e) => onBirthdayFilterChange(e.currentTarget.value as BirthdayFilter)}
>
@ -204,9 +207,10 @@
<!-- Company Filter -->
{#if companies.length > 0}
<div class="filter-section">
<label class="filter-label">{$_('filters.company')}</label>
<span class="filter-label" id="company-filter-label">{$_('filters.company')}</span>
<select
class="filter-select"
aria-labelledby="company-filter-label"
value={selectedCompany || ''}
onchange={(e) => onCompanyChange(e.currentTarget.value || null)}
>
@ -320,9 +324,10 @@
<div class="filter-panel">
<!-- Tags Filter -->
<div class="filter-section">
<label class="filter-label">{$_('filters.tag')}</label>
<span class="filter-label" id="tag-filter-label">{$_('filters.tag')}</span>
<select
class="filter-select"
aria-labelledby="tag-filter-label"
value={selectedTagId || ''}
onchange={(e) => onTagChange(e.currentTarget.value || null)}
>
@ -335,9 +340,10 @@
<!-- Contact Info Filter -->
<div class="filter-section">
<label class="filter-label">{$_('filters.contactInfo')}</label>
<span class="filter-label" id="contact-filter-label">{$_('filters.contactInfo')}</span>
<select
class="filter-select"
aria-labelledby="contact-filter-label"
value={contactFilter}
onchange={(e) => onContactFilterChange(e.currentTarget.value as ContactFilter)}
>
@ -351,9 +357,10 @@
<!-- Birthday Filter -->
<div class="filter-section">
<label class="filter-label">{$_('filters.birthdayLabel')}</label>
<span class="filter-label" id="birthday-filter-label">{$_('filters.birthdayLabel')}</span>
<select
class="filter-select"
aria-labelledby="birthday-filter-label"
value={birthdayFilter}
onchange={(e) => onBirthdayFilterChange(e.currentTarget.value as BirthdayFilter)}
>
@ -367,9 +374,10 @@
<!-- Company Filter -->
{#if companies.length > 0}
<div class="filter-section">
<label class="filter-label">{$_('filters.company')}</label>
<span class="filter-label" id="company-filter-label">{$_('filters.company')}</span>
<select
class="filter-select"
aria-labelledby="company-filter-label"
value={selectedCompany || ''}
onchange={(e) => onCompanyChange(e.currentTarget.value || null)}
>

View file

@ -15,6 +15,7 @@
let loading = $state(false);
let selectedIndex = $state(0);
let searchTimeout: ReturnType<typeof setTimeout>;
// svelte-ignore non_reactive_update - Element reference doesn't need reactivity
let inputElement: HTMLInputElement;
// Reset state when modal opens
@ -109,12 +110,13 @@
</script>
{#if open}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_interactive_supports_focus -->
<div
class="search-backdrop"
role="dialog"
aria-modal="true"
aria-label="Kontakt suchen"
tabindex="-1"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
>

View file

@ -49,10 +49,14 @@
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events a11y_no_noninteractive_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
onclick={handleBackdropClick}
onkeydown={(e) => e.key === 'Escape' && onClose()}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<div class="bg-card rounded-xl shadow-xl w-full max-w-md p-6 space-y-6">
<!-- Header -->
@ -62,6 +66,7 @@
type="button"
onclick={onClose}
class="text-muted-foreground hover:text-foreground transition-colors"
aria-label={$_('common.close')}
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -92,8 +97,10 @@
<!-- Format Selection -->
<div class="space-y-3">
<label class="block text-sm font-medium text-foreground">{$_('export.format')}</label>
<div class="grid grid-cols-2 gap-3">
<span class="block text-sm font-medium text-foreground" id="format-label"
>{$_('export.format')}</span
>
<div class="grid grid-cols-2 gap-3" role="group" aria-labelledby="format-label">
<button
type="button"
onclick={() => (format = 'vcard')}

View file

@ -212,6 +212,7 @@
export { resetZoom, zoomIn, zoomOut };
</script>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
bind:this={containerElement}
class="network-graph-container"
@ -253,6 +254,7 @@
{@const isSelected = node.id === networkStore.selectedNodeId}
{@const isConnected = isConnectedToSelected(node.id, graphLinks)}
{@const isDimmed = networkStore.selectedNodeId && !isConnected}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<g
transform="translate({node.x ?? 0}, {node.y ?? 0})"
class="node"
@ -262,6 +264,7 @@
onmousedown={(e) => handleDragStart(e, node)}
onclick={() => handleNodeClick(node)}
ondblclick={() => handleNodeDoubleClick(node)}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && handleNodeClick(node)}
role="button"
tabindex="0"
aria-label={node.name}

View file

@ -37,6 +37,7 @@
previousNodeCount = currentNodeCount;
});
// svelte-ignore non_reactive_update - Component reference doesn't need reactivity
let graphComponent: NetworkGraph;
let graphContainer: HTMLDivElement;

View file

@ -0,0 +1,123 @@
/**
* Runtime Configuration Loader
*
* Implements 12-factor app "Config in Environment" principle.
* Configuration is loaded at runtime from /config.json generated by Docker entrypoint,
* allowing the same Docker image to work across all environments.
*
* Pattern: Client-only SPA (SSR disabled via +layout.ts)
* - Browser: Fetches /config.json (generated by docker-entrypoint.sh)
* - Validation: Enforces schema in production (fail hard on misconfiguration)
* - Dev fallback: Only when dev=true, never in staging/prod
*/
import { browser, dev } from '$app/environment';
import { z } from 'zod';
export interface RuntimeConfig {
BACKEND_URL: string;
AUTH_URL: string;
}
const ConfigSchema = z.object({
BACKEND_URL: z.string().url().min(1, 'BACKEND_URL must be a valid URL'),
AUTH_URL: z.string().url().min(1, 'AUTH_URL must be a valid URL'),
});
// Development fallback configuration (only used when dev=true)
const DEV_CONFIG: RuntimeConfig = {
BACKEND_URL: 'http://localhost:3015',
AUTH_URL: 'http://localhost:3001',
};
let cachedConfig: RuntimeConfig | null = null;
let configPromise: Promise<RuntimeConfig> | null = null;
/**
* Load runtime configuration from /config.json
* Uses caching to avoid multiple fetches
*/
async function loadConfig(): Promise<RuntimeConfig> {
// Guard: SSR should never happen (we disabled it in +layout.ts)
if (!browser) {
if (dev) {
console.warn('[Contacts] Config accessed during SSR in dev mode, using fallback');
return DEV_CONFIG;
}
throw new Error('[Contacts] Runtime config called on server - SSR should be disabled');
}
// Return cached config if available
if (cachedConfig) {
return cachedConfig;
}
// If already loading, return the existing promise
if (configPromise) {
return configPromise;
}
// Fetch config from /config.json (generated by docker-entrypoint.sh)
configPromise = fetch('/config.json')
.then((res) => {
if (!res.ok) {
if (dev) {
console.warn(
`[Contacts] Failed to load /config.json (HTTP ${res.status}), using dev defaults`
);
return DEV_CONFIG;
}
throw new Error(
`[Contacts] Failed to load /config.json (HTTP ${res.status}) - check Docker entrypoint script`
);
}
return res.json();
})
.then((config) => {
// Validate schema in production (fail hard on misconfiguration)
if (!dev) {
const result = ConfigSchema.safeParse(config);
if (!result.success) {
throw new Error(
`[Contacts] Invalid config.json schema: ${result.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')}`
);
}
}
cachedConfig = config as RuntimeConfig;
return cachedConfig;
});
return configPromise;
}
/**
* Get the full runtime configuration
*/
export async function getConfig(): Promise<RuntimeConfig> {
return loadConfig();
}
/**
* Get the Auth service URL
*/
export async function getAuthUrl(): Promise<string> {
const config = await getConfig();
return config.AUTH_URL;
}
/**
* Get the Backend API URL
*/
export async function getBackendUrl(): Promise<string> {
const config = await getConfig();
return config.BACKEND_URL;
}
/**
* Initialize runtime configuration
* Call this early in app lifecycle (e.g., +layout.svelte onMount)
*/
export async function initializeConfig(): Promise<void> {
await loadConfig();
}

View file

@ -1,45 +1,24 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Uses Mana Core Auth
* Uses Mana Core Auth with runtime configuration
*/
import { browser } from '$app/environment';
import { initializeWebAuth } from '@manacore/shared-auth';
import type { UserData } from '@manacore/shared-auth';
// Get auth URL dynamically at runtime - fallback for SSR and client
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
// Falls back to localhost for local development
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
// Server-side (SSR): use Docker internal URL for container-to-container communication
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
// Get backend URL dynamically at runtime
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:3015';
}
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3015';
}
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
import { getAuthUrl, getBackendUrl } from '$lib/config/runtime';
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
async function getAuthService() {
if (!browser) return null;
if (!_authService) {
const authUrl = await getAuthUrl();
const backendUrl = await getBackendUrl();
const auth = initializeWebAuth({
baseUrl: getAuthUrl(),
backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses
baseUrl: authUrl,
backendUrl: backendUrl, // Enables automatic token refresh on 401 responses
});
_authService = auth.authService;
_tokenManager = auth.tokenManager;
@ -47,10 +26,10 @@ function getAuthService() {
return _authService;
}
function getTokenManager() {
async function getTokenManager() {
if (!browser) return null;
// Ensure auth service is initialized first
getAuthService();
await getAuthService();
return _tokenManager;
}
@ -80,7 +59,7 @@ export const authStore = {
async initialize() {
if (initialized) return;
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
initialized = true;
loading = false;
@ -107,7 +86,7 @@ export const authStore = {
* Sign in with email and password
*/
async signIn(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -134,7 +113,7 @@ export const authStore = {
* Sign up with email and password
*/
async signUp(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
@ -164,7 +143,7 @@ export const authStore = {
* Sign out
*/
async signOut() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
user = null;
return;
@ -184,7 +163,7 @@ export const authStore = {
* Send password reset email
*/
async resetPassword(email: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -208,7 +187,7 @@ export const authStore = {
* @deprecated Use getValidToken() instead for automatic refresh
*/
async getAccessToken() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return null;
}
@ -220,7 +199,7 @@ export const authStore = {
* Automatically refreshes if the token is expired or about to expire
*/
async getValidToken(): Promise<string | null> {
const tokenManager = getTokenManager();
const tokenManager = await getTokenManager();
if (!tokenManager) {
return null;
}

View file

@ -404,28 +404,6 @@
opacity: 1;
}
/* Loading */
.loading-container {
display: flex;
justify-content: center;
padding: 4rem 0;
}
.spinner {
width: 2.5rem;
height: 2.5rem;
border: 3px solid hsl(var(--color-muted));
border-top-color: hsl(var(--color-primary));
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Empty State */
.empty-state {
display: flex;

View file

@ -74,6 +74,10 @@
}
onMount(async () => {
// Initialize runtime config first (12-factor pattern)
const { initializeConfig } = await import('$lib/config/runtime');
await initializeConfig();
// Setup global error handling
setupGlobalErrorHandling();

View file

@ -0,0 +1,10 @@
/**
* Layout Configuration
*
* Disable SSR - this is a client-only SPA that:
* - Requires authentication (no SEO benefit)
* - Fetches all data client-side via authenticated APIs
* - Loads runtime config from /config.json (browser-only)
*/
export const ssr = false;

View file

@ -0,0 +1,4 @@
{
"BACKEND_URL": "http://localhost:3015",
"AUTH_URL": "http://localhost:3001"
}

View file

@ -20,6 +20,7 @@ COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages needed by manacore web
COPY packages/better-auth-types ./packages/better-auth-types
COPY packages/shared-auth ./packages/shared-auth
COPY packages/shared-auth-ui ./packages/shared-auth-ui
COPY packages/shared-branding ./packages/shared-branding
@ -46,6 +47,9 @@ COPY apps/manacore/apps/web ./apps/manacore/apps/web
RUN pnpm install --frozen-lockfile
# Build shared packages that need building
WORKDIR /app/packages/better-auth-types
RUN pnpm build || true
WORKDIR /app/packages/shared-auth
RUN pnpm build || true
@ -69,6 +73,10 @@ COPY --from=builder /app/apps/manacore/apps/web/node_modules ./node_modules
COPY --from=builder /app/apps/manacore/apps/web/build ./build
COPY --from=builder /app/apps/manacore/apps/web/package.json ./
# Copy entrypoint script for runtime config generation
COPY apps/manacore/apps/web/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Expose port
EXPOSE 5173
@ -81,5 +89,8 @@ ENV HOST=0.0.0.0
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:5173/health || exit 1
# Use entrypoint to generate runtime config
ENTRYPOINT ["docker-entrypoint.sh"]
# Run the app
CMD ["node", "build"]

View file

@ -0,0 +1,45 @@
#!/bin/sh
set -e
# Docker Entrypoint for Manacore Web
# Generates runtime config from environment variables
# Implements "build once, configure at runtime" pattern
echo "🔧 Generating runtime configuration..."
# Default values for local development
API_BASE_URL=${API_BASE_URL:-"http://localhost:5173"}
AUTH_URL=${AUTH_URL:-"http://localhost:3001"}
TODO_API_URL=${TODO_API_URL:-"http://localhost:3018"}
CALENDAR_API_URL=${CALENDAR_API_URL:-"http://localhost:3016"}
CLOCK_API_URL=${CLOCK_API_URL:-"http://localhost:3017"}
CONTACTS_API_URL=${CONTACTS_API_URL:-"http://localhost:3015"}
# Ensure the directory exists (it should from the build, but be safe)
mkdir -p build/client
# Generate config.json from template
cat > build/client/config.json <<EOF
{
"API_BASE_URL": "${API_BASE_URL}",
"AUTH_URL": "${AUTH_URL}",
"TODO_API_URL": "${TODO_API_URL}",
"CALENDAR_API_URL": "${CALENDAR_API_URL}",
"CLOCK_API_URL": "${CLOCK_API_URL}",
"CONTACTS_API_URL": "${CONTACTS_API_URL}"
}
EOF
echo "✅ Runtime configuration generated:"
cat build/client/config.json
# Remove pre-compressed versions (SvelteKit serves these instead of the raw file)
rm -f build/client/config.json.br
rm -f build/client/config.json.gz
echo "🗑️ Removed stale pre-compressed config files"
echo ""
echo "🚀 Starting Node server..."
# Execute the CMD (node build)
exec "$@"

View file

@ -4,10 +4,16 @@
* Authentication is handled entirely by Mana Core Auth (@manacore/shared-auth).
* No Supabase is needed - all data comes from mana-core-auth APIs.
*/
import type { UserData } from '@manacore/shared-auth';
declare global {
namespace App {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface Locals {}
interface Locals {
session?: {
access_token: string;
user: UserData;
} | null;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface PageData {}
// interface Error {}

View file

@ -1,11 +1,12 @@
/**
* Credits Service for ManaCore Web App
* Handles credit balance, transactions, and packages
*
* Uses runtime configuration for 12-factor compliance
*/
import { authStore } from '$lib/stores/auth.svelte';
const MANA_AUTH_URL = 'http://localhost:3001'; // TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env
import { getAuthUrl } from '$lib/config/runtime';
// Types
export interface CreditBalance {
@ -52,8 +53,9 @@ export interface CreditPurchase {
// Helper function for authenticated requests
async function fetchWithAuth<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const token = await authStore.getAccessToken();
const authUrl = await getAuthUrl();
const response = await fetch(`${MANA_AUTH_URL}${endpoint}`, {
const response = await fetch(`${authUrl}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
@ -99,7 +101,8 @@ export const creditsService = {
* Get available credit packages (public endpoint)
*/
async getPackages(): Promise<CreditPackage[]> {
const response = await fetch(`${MANA_AUTH_URL}/api/v1/credits/packages`);
const authUrl = await getAuthUrl();
const response = await fetch(`${authUrl}/api/v1/credits/packages`);
if (!response.ok) {
throw new Error('Failed to fetch packages');
}

View file

@ -1,14 +1,27 @@
/**
* Feedback Service Instance for ManaCore Web App
*
* Uses runtime configuration for 12-factor compliance
*/
import { createFeedbackService } from '@manacore/shared-feedback-service';
import { authStore } from '$lib/stores/auth.svelte';
import { getAuthUrl } from '$lib/config/runtime';
const MANA_AUTH_URL = 'http://localhost:3001'; // TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env
// Lazy initialization to allow runtime config to load first
let _feedbackService: ReturnType<typeof createFeedbackService> | null = null;
export const feedbackService = createFeedbackService({
apiUrl: MANA_AUTH_URL,
appId: 'manacore',
getAuthToken: async () => authStore.getAccessToken(),
});
async function getFeedbackService() {
if (!_feedbackService) {
const authUrl = await getAuthUrl();
_feedbackService = createFeedbackService({
apiUrl: authUrl,
appId: 'manacore',
getAuthToken: async () => authStore.getAccessToken(),
});
}
return _feedbackService;
}
// Export the async getter for components
export { getFeedbackService as getService };

View file

@ -1,11 +1,12 @@
/**
* Referrals Service for ManaCore Web App
* Handles referral codes, stats, and referral tracking
*
* Uses runtime configuration for 12-factor compliance
*/
import { authStore } from '$lib/stores/auth.svelte';
const MANA_AUTH_URL = 'http://localhost:3001'; // TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env
import { getAuthUrl } from '$lib/config/runtime';
// Types
export interface ReferralStats {
@ -54,8 +55,9 @@ export interface ReferralValidation {
// Helper function for authenticated requests
async function fetchWithAuth<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const token = await authStore.getAccessToken();
const authUrl = await getAuthUrl();
const response = await fetch(`${MANA_AUTH_URL}${endpoint}`, {
const response = await fetch(`${authUrl}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
@ -109,7 +111,8 @@ export const referralsService = {
*/
async validateCode(code: string): Promise<ReferralValidation> {
try {
const response = await fetch(`${MANA_AUTH_URL}/api/v1/referrals/validate/${code}`);
const authUrl = await getAuthUrl();
const response = await fetch(`${authUrl}/api/v1/referrals/validate/${code}`);
if (!response.ok) {
return { valid: false, error: 'Invalid code' };
}

View file

@ -1,34 +1,23 @@
<script lang="ts">
/**
* Icon Component - Re-exports from @manacore/shared-icons
* This wrapper ensures backward compatibility with existing imports
* Icon Component - Wrapper for phosphor-svelte icons
* NOTE: This is a legacy wrapper. Use phosphor-svelte icons directly instead.
* Example: import { House, User } from '@manacore/shared-icons';
*/
import { iconPaths } from '@manacore/shared-icons';
interface Props {
name: keyof typeof iconPaths;
name: string;
size?: number;
class?: string;
color?: string;
}
let { name, size = 24, class: className = '', color }: Props = $props();
const path = $derived(iconPaths[name]);
</script>
{#if path}
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
fill={color || 'currentColor'}
viewBox="0 0 256 256"
class={className}
aria-hidden="true"
>
{@html path}
</svg>
{:else}
<span class="text-red-500" title="Icon '{name}' not found"></span>
{/if}
<span
class="text-orange-500"
title="Icon component is deprecated. Use direct imports from @manacore/shared-icons instead."
>
{name}
</span>

Some files were not shown because too many files have changed in this diff Show more