mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
Merge branch 'dev' into till-dev
This commit is contained in:
commit
285e142970
251 changed files with 9752 additions and 3942 deletions
|
|
@ -1 +0,0 @@
|
|||
{}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
[
|
||||
{
|
||||
"id": "cmd-swarm-1764952182017",
|
||||
"type": "swarm",
|
||||
"success": true,
|
||||
"duration": 4.868416999999994,
|
||||
"timestamp": 1764952182022,
|
||||
"metadata": {}
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
32
.github/workflows/cd-staging.yml
vendored
32
.github/workflows/cd-staging.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
3
.husky/pre-push
Executable 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
724
CLAUDE.md
|
|
@ -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
678
CLAUDE.md.backup
Normal 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
181
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
37
apps/calendar/apps/web/docker-entrypoint.sh
Normal file
37
apps/calendar/apps/web/docker-entrypoint.sh
Normal 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 "$@"
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
145
apps/calendar/apps/web/src/lib/config/runtime.ts
Normal file
145
apps/calendar/apps/web/src/lib/config/runtime.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
|
||||
interface SearchItem {
|
||||
id: string;
|
||||
[key: string]: unknown;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
// State
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
10
apps/calendar/apps/web/src/routes/+layout.ts
Normal file
10
apps/calendar/apps/web/src/routes/+layout.ts
Normal 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;
|
||||
6
apps/calendar/apps/web/static/config.json
Normal file
6
apps/calendar/apps/web/static/config.json
Normal 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"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
31
apps/chat/apps/web/docker-entrypoint.sh
Normal file
31
apps/chat/apps/web/docker-entrypoint.sh
Normal 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 "$@"
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
123
apps/chat/apps/web/src/lib/config/runtime.ts
Normal file
123
apps/chat/apps/web/src/lib/config/runtime.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
10
apps/chat/apps/web/src/routes/+layout.ts
Normal file
10
apps/chat/apps/web/src/routes/+layout.ts
Normal 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;
|
||||
4
apps/chat/apps/web/static/config.json
Normal file
4
apps/chat/apps/web/static/config.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"BACKEND_URL": "http://localhost:3002",
|
||||
"AUTH_URL": "http://localhost:3001"
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
31
apps/clock/apps/web/docker-entrypoint.sh
Normal file
31
apps/clock/apps/web/docker-entrypoint.sh
Normal 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 "$@"
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
23
apps/clock/apps/web/src/lib/api/feedback.ts
Normal file
23
apps/clock/apps/web/src/lib/api/feedback.ts
Normal 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(),
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
123
apps/clock/apps/web/src/lib/config/runtime.ts
Normal file
123
apps/clock/apps/web/src/lib/config/runtime.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -245,6 +245,7 @@
|
|||
e.stopPropagation();
|
||||
handleDelete(timer.id, isLocal);
|
||||
}}
|
||||
aria-label="Delete timer"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
10
apps/clock/apps/web/src/routes/+layout.ts
Normal file
10
apps/clock/apps/web/src/routes/+layout.ts
Normal 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;
|
||||
4
apps/clock/apps/web/static/config.json
Normal file
4
apps/clock/apps/web/static/config.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"API_BASE_URL": "http://localhost:3017",
|
||||
"AUTH_URL": "http://localhost:3001"
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
98
apps/contacts/apps/web/Dockerfile
Normal file
98
apps/contacts/apps/web/Dockerfile
Normal 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"]
|
||||
31
apps/contacts/apps/web/docker-entrypoint.sh
Normal file
31
apps/contacts/apps/web/docker-entrypoint.sh
Normal 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 "$@"
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -445,12 +445,6 @@
|
|||
}
|
||||
|
||||
/* Loading & Empty */
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
previousNodeCount = currentNodeCount;
|
||||
});
|
||||
|
||||
// svelte-ignore non_reactive_update - Component reference doesn't need reactivity
|
||||
let graphComponent: NetworkGraph;
|
||||
let graphContainer: HTMLDivElement;
|
||||
|
||||
|
|
|
|||
123
apps/contacts/apps/web/src/lib/config/runtime.ts
Normal file
123
apps/contacts/apps/web/src/lib/config/runtime.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
10
apps/contacts/apps/web/src/routes/+layout.ts
Normal file
10
apps/contacts/apps/web/src/routes/+layout.ts
Normal 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;
|
||||
4
apps/contacts/apps/web/static/config.json
Normal file
4
apps/contacts/apps/web/static/config.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"BACKEND_URL": "http://localhost:3015",
|
||||
"AUTH_URL": "http://localhost:3001"
|
||||
}
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
45
apps/manacore/apps/web/docker-entrypoint.sh
Executable file
45
apps/manacore/apps/web/docker-entrypoint.sh
Executable 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 "$@"
|
||||
10
apps/manacore/apps/web/src/app.d.ts
vendored
10
apps/manacore/apps/web/src/app.d.ts
vendored
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue