Merge pull request #14 from Memo-2023/till-dev

feat: major update with network graphs, themes, todo extensions, and more
This commit is contained in:
Nils Weiser 2025-12-10 02:47:58 +01:00 committed by GitHub
commit dae8c9dd3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
382 changed files with 39538 additions and 6275 deletions

View file

@ -57,12 +57,6 @@ STRIPE_SECRET_KEY=sk_test_YOUR_KEY
STRIPE_PUBLISHABLE_KEY=pk_test_YOUR_KEY
STRIPE_WEBHOOK_SECRET=whsec_YOUR_SECRET
# Email (Brevo/SendinBlue) - GDPR compliant EU provider
# Get your API key from https://app.brevo.com/settings/keys/api
BREVO_API_KEY=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
BREVO_FROM_EMAIL=noreply@manacore.app
BREVO_FROM_NAME=Mana Core
# ============================================
# CHAT PROJECT
# ============================================

View file

@ -52,7 +52,7 @@ All scripts in `scripts/deploy/`:
| Script | Purpose | Features |
| ------------------- | ---------------------------- | -------------------------------------------------- |
| `build-and-push.sh` | Build and push Docker images | Error handling, colored output, progress tracking |
| `deploy-hetzner.sh` | Deploy to Hetzner VPS | Zero-downtime, health checks, rollback on failure |
| `deploy-hetzner.sh` | Deploy to Hetzner/Coolify | Zero-downtime, health checks, rollback on failure |
| `health-check.sh` | Verify service health | Multiple endpoints, timeout handling |
| `rollback.sh` | Emergency rollback | Automated backup restoration, confirmation prompts |
| `migrate-db.sh` | Run database migrations | Supabase + Drizzle support, safe execution |

View file

@ -8,9 +8,10 @@ pnpm docker:down
pnpm dev:calendar:app
pnpm dev:todo:full
pnpm dev:contacts:full
pnpm dev:clock:full
pnpm dev:chat:app
pnpm dev:clock:app
pnpm dev:contacts:app
pnpm dev:context:app
pnpm dev:manacore:app # Nur ManaCore Web
pnpm dev:manacore:backends # Alle 9 Backends für Dashboard-Widgets

View file

@ -13,7 +13,7 @@
The Hive Mind collective has successfully analyzed, designed, and implemented a **complete production-ready deployment system** for the manacore-monorepo. Through coordinated effort across 4 specialized worker agents, we've delivered:
- **Comprehensive hosting platform analysis** (Hetzner + Docker Compose recommended)
- **Comprehensive hosting platform analysis** (Hetzner + Coolify recommended)
- **Complete deployment architecture** for 39 services across 10 projects
- **Fully automated CI/CD pipeline** with GitHub Actions
- **Production-ready testing infrastructure** targeting 80% coverage
@ -29,7 +29,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
### 1⃣ RESEARCHER AGENT - Infrastructure Analysis
**Mission**: Research and compare Hetzner hosting options
**Mission**: Research and compare Hetzner vs Coolify hosting options
**Key Findings**:
@ -70,7 +70,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
**Architecture Highlights**:
- **Environment Stages**: Development (local) → Staging (Docker Compose) → Production (Docker Compose/K8s)
- **Environment Stages**: Development (local) → Staging (Coolify) → Production (Coolify/K8s)
- **Domain Strategy**: `{service}.manacore.app` (e.g., `api-chat.manacore.app`)
- **Disaster Recovery**: RTO < 1 hour, RPO < 24 hours, automated daily backups
- **Resource Requirements**: 15 vCPU, 15 GB RAM, 100 GB SSD (~$150-300/month single-server)
@ -169,7 +169,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
- **Reasoning**: Instant rollback, minimal risk, production-proven
- **Vote**: Unanimous approval
3. **Container Orchestration**: Start with Docker Compose, migrate to K8s when scale demands
3. **Container Orchestration**: Start with Coolify, migrate to K8s when scale demands
- **Reasoning**: Simplicity now, scalability later
- **Vote**: Unanimous approval
@ -256,7 +256,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
**Week 3-4**:
- [ ] Provision production server(s)
- [ ] Set up production environment with Docker Compose
- [ ] Set up production environment in Coolify
- [ ] Deploy mana-core-auth service
- [ ] Deploy first 2 projects (chat, picture)
- [ ] Configure monitoring (Prometheus + Grafana)
@ -409,7 +409,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
- ✅ ISO 27001 certified infrastructure (Hetzner)
- ✅ SOC 2 Type II (Supabase)
- ✅ Automated backup retention policies
- ✅ Audit logs (GitHub Actions, Supabase)
- ✅ Audit logs (GitHub Actions, Coolify, Supabase)
---
@ -489,7 +489,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
**Session 2: Infrastructure Setup (2 hours)**
- Hands-on: Set up Hetzner server
- Hands-on: Set up Docker Compose
- Hands-on: Install and configure Coolify
- Practice: Deploy test service
**Session 3: CI/CD Operations (2 hours)**
@ -572,7 +572,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
### Long-Term (6-12 Months)
1. **Kubernetes Migration**
- Migrate from Docker Compose to Hetzner Kubernetes
- Migrate from Coolify to Hetzner Kubernetes
- Implement Helm charts for all services
- Set up Istio service mesh
@ -829,7 +829,7 @@ pnpm test:e2e
**Official Documentation**:
- [Hetzner Cloud Docs](https://docs.hetzner.com/)
- [Docker Compose Documentation](https://docs.docker.com/compose/)
- [Coolify Documentation](https://coolify.io/docs)
- [Turborepo Docs](https://turbo.build/repo/docs)
- [pnpm Workspaces](https://pnpm.io/workspaces)
- [GitHub Actions](https://docs.github.com/en/actions)

View file

@ -0,0 +1,561 @@
# Admin-Tool Modularisierung & Replicate-Integration
> **Dokument erstellt:** 28.01.2025
> **Status:** Konzeptphase
> **Ziel:** Wiederverwendbares Admin-Tool mit KI-Bildgenerierung
## 📋 Executive Summary
Das Memoro Admin-Tool entwickelt sich zu einem eigenständigen, wertvollen Werkzeug. Dieses Dokument beschreibt Konzepte zur:
1. **Modularisierung** des Admin-Tools für Wiederverwendbarkeit in anderen Projekten
2. **Integration von Replicate** zur KI-basierten Bildgenerierung für Personas
3. **Backend-Architektur** auf Hetzner VPS mit Coolify
## 🎯 Anforderungen
### Funktionale Anforderungen
- Admin-Tool soll in anderen Websites wiederverwendbar sein
- Komplette Trennung von Code und Content
- KI-basierte Bildgenerierung für Personas via Replicate
- Zentrale Backend-Services auf Hetzner VPS
- Verwaltung via Coolify (Docker-basiert)
### Nicht-funktionale Anforderungen
- Einfache Installation/Integration
- Minimale Dependencies
- Skalierbare Architektur
- Sichere API-Kommunikation
- Kosteneffiziente Bildgenerierung
## 🏗️ Modularisierungskonzepte
### Konzept 1: NPM Package + API Backend
**Architektur:**
```
@memoro/admin-tool (NPM Package)
├── components/ # Wiederverwendbare UI-Komponenten
├── layouts/ # Admin-Layouts
├── hooks/ # React/Vue Hooks für API
├── types/ # TypeScript Definitionen
└── utils/ # Helper Functions
@memoro/admin-api (Separates Backend)
├── /api/personas # Personas CRUD
├── /api/images # Bildgenerierung
├── /api/content # Content Management
└── /api/auth # Authentication
```
**Vorteile:**
- ✅ Maximale Wiederverwendbarkeit
- ✅ Framework-agnostisch (Adapter Pattern)
- ✅ Versionskontrolle via NPM
- ✅ Type-Safety durch TypeScript
**Nachteile:**
- ❌ Komplexe Initial-Setup
- ❌ Wartung von zwei Packages
- ❌ Breaking Changes Management
**Integration:**
```typescript
// In beliebiger Astro/Next/Vue App
import { AdminTool } from '@memoro/admin-tool';
import { MemoroadminProvider } from '@memoro/admin-tool/providers';
// Konfiguration
const config = {
apiUrl: 'https://api.memoro-admin.com',
apiKey: process.env.MEMORO_API_KEY,
features: ['personas', 'content', 'images']
};
<MemoroadminProvider config={config}>
<AdminTool />
</MemoroadminProvider>
```
### Konzept 2: Monorepo mit Shared Packages
**Struktur:**
```
memoro-workspace/
├── apps/
│ ├── memoro-website/ # Aktuelle Website
│ ├── admin-dashboard/ # Standalone Admin
│ └── api-backend/ # Zentrales Backend
├── packages/
│ ├── admin-ui/ # UI Components
│ ├── admin-core/ # Business Logic
│ ├── content-types/ # Shared Types
│ └── api-client/ # API Client Library
└── services/
├── image-generator/ # Replicate Service
└── content-sync/ # Content Synchronization
```
**Vorteile:**
- ✅ Einheitliche Entwicklung
- ✅ Shared Dependencies
- ✅ Einfaches Testing
- ✅ Atomic Commits
**Nachteile:**
- ❌ Größeres Repository
- ❌ Komplexere CI/CD
- ❌ Schwieriger für externe Nutzer
**Tools:**
- Turborepo oder NX für Monorepo Management
- Changesets für Versionierung
- pnpm Workspaces für Dependencies
### Konzept 3: Microservices + Web Components
**Architektur:**
```
Frontend (Web Components)
├── <memoro-admin-dashboard>
├── <memoro-persona-manager>
├── <memoro-image-generator>
└── <memoro-content-editor>
Microservices (Docker/Coolify)
├── persona-service/ # Node.js/Fastify
├── image-service/ # Python/FastAPI + Replicate
├── content-service/ # Node.js/Express
├── auth-service/ # Node.js/JWT
└── gateway/ # Kong/Traefik
```
**Vorteile:**
- ✅ Framework-unabhängig
- ✅ Isolierte Services
- ✅ Unabhängige Skalierung
- ✅ Native Browser-Support
**Nachteile:**
- ❌ Komplexe Orchestrierung
- ❌ Network Latency
- ❌ Service Discovery
**Integration:**
```html
<!-- In beliebiger HTML-Seite -->
<script src="https://admin.memoro.tools/components.js"></script>
<memoro-admin-dashboard
api-key="xxx"
theme="dark">
</memoro-admin-dashboard>
```
### Konzept 4: Plugin-System (Empfohlen) ⭐
**Architektur:**
```
@memoro/admin-core
├── core/
│ ├── plugin-system.ts # Plugin Registry
│ ├── api-client.ts # API Abstraction
│ └── auth.ts # Auth Management
├── plugins/
│ ├── personas/ # Personas Plugin
│ ├── image-generator/ # Replicate Plugin
│ ├── content-manager/ # Content Plugin
│ └── analytics/ # Analytics Plugin
└── adapters/
├── astro/ # Astro Integration
├── nextjs/ # Next.js Integration
└── vue/ # Vue Integration
```
**Plugin-Beispiel:**
```typescript
// personas-plugin.ts
export const personasPlugin: AdminPlugin = {
id: 'personas',
name: 'Personas Management',
version: '1.0.0',
routes: [
{ path: '/personas', component: PersonasList },
{ path: '/personas/:id', component: PersonaDetail }
],
api: {
endpoints: [
{ method: 'GET', path: '/personas', handler: getPersonas },
{ method: 'POST', path: '/personas/:id/image', handler: generateImage }
]
},
permissions: ['personas.read', 'personas.write', 'personas.generate'],
config: {
replicateModel: 'stability-ai/sdxl',
imageStyles: ['portrait', 'professional', 'casual']
}
};
```
**Vorteile:**
- ✅ Maximale Flexibilität
- ✅ Einfache Erweiterung
- ✅ Selective Features
- ✅ Community Plugins möglich
**Nachteile:**
- ❌ Initial-Komplexität
- ❌ Plugin-Kompatibilität
- ❌ Versioning-Challenges
## 🖼️ Replicate Integration
### Backend Service Architektur
```typescript
// services/image-generator/src/replicate-service.ts
import Replicate from 'replicate';
import { Queue } from 'bullmq';
import { S3 } from '@aws-sdk/client-s3';
export class ReplicateImageService {
private replicate: Replicate;
private queue: Queue;
private storage: S3;
async generatePersonaImage(persona: Persona): Promise<string> {
// 1. Prompt generieren basierend auf Persona-Daten
const prompt = this.buildPrompt(persona);
// 2. Job in Queue einreihen
const job = await this.queue.add('generate-image', {
personaId: persona.id,
prompt,
model: 'stable-diffusion-xl',
parameters: {
width: 1024,
height: 1024,
num_outputs: 4,
guidance_scale: 7.5
}
});
// 3. Auf Completion warten
const result = await job.waitUntilFinished();
// 4. Bilder in S3/Hetzner speichern
const imageUrls = await this.storeImages(result.images);
return imageUrls;
}
private buildPrompt(persona: Persona): string {
const { appearance, outfits, demographics } = persona;
return `
Professional portrait photo of a ${demographics.age} year old ${demographics.gender},
${appearance.description},
${appearance.hairColor} hair in ${appearance.hairStyle},
${appearance.eyeColor} eyes,
wearing ${outfits[0]?.items.top || 'business attire'},
${appearance.firstImpression},
studio lighting, high quality, detailed, realistic
`;
}
}
```
### Admin UI Integration
```typescript
// components/PersonaImageGenerator.tsx
export function PersonaImageGenerator({ persona }: Props) {
const [generating, setGenerating] = useState(false);
const [images, setImages] = useState<string[]>([]);
const [selectedImage, setSelectedImage] = useState<string>();
const [prompt, setPrompt] = useState('');
const generateImages = async () => {
setGenerating(true);
const response = await fetch(`/api/personas/${persona.id}/generate-images`, {
method: 'POST',
body: JSON.stringify({
prompt: prompt || buildDefaultPrompt(persona),
style: selectedStyle,
count: 4
})
});
const data = await response.json();
setImages(data.images);
setGenerating(false);
};
return (
<div className="image-generator">
<h3>KI Bildgenerierung</h3>
{/* Prompt Editor */}
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Beschreibe das gewünschte Bild..."
className="w-full h-32"
/>
{/* Style Selector */}
<StyleSelector
options={['portrait', 'professional', 'casual', 'lifestyle']}
selected={selectedStyle}
onChange={setSelectedStyle}
/>
{/* Generate Button */}
<button
onClick={generateImages}
disabled={generating}
className="btn-primary"
>
{generating ? 'Generiere...' : 'Bilder generieren'}
</button>
{/* Image Grid */}
{images.length > 0 && (
<div className="grid grid-cols-2 gap-4 mt-4">
{images.map((img, i) => (
<ImageOption
key={i}
src={img}
selected={selectedImage === img}
onSelect={() => setSelectedImage(img)}
/>
))}
</div>
)}
{/* Save Button */}
{selectedImage && (
<button
onClick={() => savePersonaImage(persona.id, selectedImage)}
className="btn-success mt-4"
>
Als Persona-Bild speichern
</button>
)}
</div>
);
}
```
## 🚀 Backend Deployment (Hetzner + Coolify)
### Docker Compose Konfiguration
```yaml
# docker-compose.yml für Coolify
version: '3.8'
services:
# API Gateway
gateway:
image: traefik:v2.9
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./traefik.yml:/traefik.yml
labels:
- "traefik.enable=true"
# Admin API
admin-api:
build: ./services/admin-api
environment:
DATABASE_URL: ${DATABASE_URL}
REPLICATE_API_KEY: ${REPLICATE_API_KEY}
JWT_SECRET: ${JWT_SECRET}
labels:
- "traefik.http.routers.api.rule=Host(`api.memoro-admin.com`)"
- "traefik.http.services.api.loadbalancer.server.port=3000"
# Image Generator Service
image-generator:
build: ./services/image-generator
environment:
REPLICATE_API_KEY: ${REPLICATE_API_KEY}
S3_BUCKET: ${S3_BUCKET}
REDIS_URL: redis://redis:6379
depends_on:
- redis
# Queue Worker
queue-worker:
build: ./services/queue-worker
environment:
REDIS_URL: redis://redis:6379
DATABASE_URL: ${DATABASE_URL}
depends_on:
- redis
- postgres
# Database
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: memoro_admin
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
# Cache & Queue
redis:
image: redis:7-alpine
volumes:
- redis-data:/data
# Object Storage (MinIO als S3-Alternative)
minio:
image: minio/minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_USER}
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD}
volumes:
- minio-data:/data
ports:
- "9001:9001"
volumes:
postgres-data:
redis-data:
minio-data:
```
### Coolify Deployment Steps
```bash
# 1. Repository in Coolify verbinden
# 2. Environment Variables setzen
# 3. Docker Compose auswählen
# 4. Custom Domain konfigurieren
# 5. SSL aktivieren (Let's Encrypt)
# 6. Health Checks einrichten
```
## 💰 Kostenanalyse
### Replicate Kosten
- **SDXL Model:** ~$0.0023 pro Bild
- **4 Varianten pro Persona:** ~$0.01
- **100 Personas:** ~$1.00
- **Monatliche Regeneration:** ~$12/Jahr
### Hetzner VPS
- **CPX31:** 16GB RAM, 4 vCPUs = 22€/Monat
- **Storage:** 160GB SSD inklusive
- **Traffic:** 20TB inklusive
### Gesamtkosten
- **Initial:** ~25€ Setup
- **Monatlich:** ~25€ (Hetzner + Replicate bei moderater Nutzung)
- **Jährlich:** ~300€
## 🔧 Implementierungsplan
### Phase 1: Proof of Concept (1 Woche)
1. ✅ Einfache API mit Express.js
2. ✅ Replicate Integration testen
3. ✅ Basis UI in Admin-Tool
4. ✅ Lokales Docker Setup
### Phase 2: MVP (2-3 Wochen)
1. ⏳ Plugin-System Grundstruktur
2. ⏳ Personas Plugin mit Bildgenerierung
3. ⏳ Deployment auf Hetzner
4. ⏳ Basis-Authentifizierung
### Phase 3: Production Ready (4-6 Wochen)
1. 📋 Vollständiges Plugin-System
2. 📋 NPM Package Publishing
3. 📋 Dokumentation & Examples
4. 📋 CI/CD Pipeline
5. 📋 Monitoring & Logging
### Phase 4: Erweiterungen
1. 🎯 Weitere Plugins (Analytics, SEO, etc.)
2. 🎯 Community Plugins Support
3. 🎯 SaaS-Version
4. 🎯 Multi-Tenancy
## 🎯 Empfehlung
**Empfohlene Architektur:** Plugin-System (Konzept 4) + Microservices Backend
**Begründung:**
1. **Flexibilität:** Plugins erlauben selektive Feature-Nutzung
2. **Skalierbarkeit:** Microservices können unabhängig skaliert werden
3. **Wiederverwendbarkeit:** Core + Plugins in beliebigen Projekten nutzbar
4. **Zukunftssicher:** Community kann eigene Plugins entwickeln
5. **Kosteneffizient:** Nur genutzte Services werden deployed
**Nächste Schritte:**
1. API-Service mit Replicate-Integration entwickeln
2. Basis Plugin-System implementieren
3. Personas-Plugin mit Bildgenerierung erstellen
4. Deployment auf Hetzner/Coolify
5. Documentation und Examples
## 📚 Technologie-Stack
### Frontend
- **Core:** TypeScript, Web Components
- **Adapters:** Astro, React, Vue
- **UI:** Tailwind CSS, Shadcn/ui
- **State:** Zustand oder Nanostores
### Backend
- **API:** Node.js mit Fastify
- **Queue:** BullMQ + Redis
- **Database:** PostgreSQL + Prisma
- **Storage:** MinIO (S3-compatible)
- **Auth:** JWT + Refresh Tokens
### Infrastructure
- **Hosting:** Hetzner VPS
- **Orchestration:** Coolify
- **Containers:** Docker + Compose
- **Monitoring:** Prometheus + Grafana
- **Logging:** Loki + Promtail
## 🔐 Security Considerations
1. **API Security:**
- Rate Limiting
- API Key Management
- CORS Configuration
- Request Validation
2. **Image Generation:**
- Content Filtering
- Usage Limits
- Watermarking Option
- GDPR Compliance
3. **Data Protection:**
- Encryption at Rest
- Secure Transmission
- Regular Backups
- Access Logging
## 📊 Success Metrics
- **Adoption:** Anzahl installierter Instanzen
- **Usage:** Generierte Bilder pro Monat
- **Performance:** API Response Time < 200ms
- **Reliability:** 99.9% Uptime
- **Cost:** < 30€/Monat bei 1000 Personas
## 🤝 Community & Support
- **Documentation:** docs.memoro-admin.tools
- **Discord:** Community Support
- **GitHub:** Issue Tracking
- **Newsletter:** Updates & Best Practices
- **Marketplace:** Plugin Directory
---
*Dieses Dokument wird kontinuierlich aktualisiert. Letzte Änderung: 28.01.2025*

View file

@ -0,0 +1,344 @@
# Netlify Functions vs. Hetzner VPS - Vergleich für Replicate Integration
## 🎯 Übersicht
Vergleich zwischen **Netlify Functions** (Serverless) und **Hetzner VPS** (Self-Hosted) für die Replicate API Integration.
## 💰 Kostenvergleich
### Netlify Functions
**Free Tier:**
- 125.000 Requests/Monat
- 100 Stunden Compute-Zeit/Monat
- Perfekt für Development & kleine Projekte
**Pro Tier ($19/Monat):**
- 1 Million Requests/Monat
- 1000 Stunden Compute-Zeit
**Kosten für Replicate Bildgenerierung:**
- Replicate API: ~$0.01 pro 4 Bilder
- Netlify Functions: GRATIS im Free Tier
- **Gesamtkosten: Nur Replicate-Nutzung (~$1-10/Monat)**
### Hetzner VPS + Coolify
**CPX31 Server:**
- €22/Monat (16GB RAM, 4 vCPUs)
- Unbegrenzte Requests
- Kann mehrere Services hosten
**Zusatzkosten:**
- Domain/SSL: €0 (Let's Encrypt)
- Backup: €2/Monat
- **Gesamtkosten: €24/Monat + Replicate**
## 🚀 Implementierungsvergleich
### Option 1: Netlify Functions (EMPFOHLEN) ⭐
**Implementierung:**
```javascript
// netlify/functions/generate-persona-image.js
import Replicate from 'replicate';
export async function handler(event, context) {
// CORS Headers
if (event.httpMethod === 'OPTIONS') {
return {
statusCode: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type, X-API-Key',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
},
};
}
// Check API Key
const apiKey = event.headers['x-api-key'];
if (apiKey !== process.env.API_KEY) {
return {
statusCode: 401,
body: JSON.stringify({ error: 'Unauthorized' }),
};
}
// Parse request
const { personaData, prompt, style = 'portrait', count = 4 } = JSON.parse(event.body);
// Initialize Replicate
const replicate = new Replicate({
auth: process.env.REPLICATE_API_TOKEN,
});
try {
// Generate images
const output = await replicate.run(
'stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b',
{
input: {
prompt: prompt || buildPromptFromPersona(personaData, style),
negative_prompt: 'ugly, distorted, blurry, low quality',
width: 1024,
height: 1024,
num_outputs: count,
scheduler: 'K_EULER',
num_inference_steps: 30,
guidance_scale: 7.5,
},
}
);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
success: true,
images: output,
}),
};
} catch (error) {
return {
statusCode: 500,
body: JSON.stringify({
success: false,
error: error.message,
}),
};
}
}
function buildPromptFromPersona(personaData, style) {
// Prompt building logic
const { appearance, demographics, professional } = personaData;
// ... (same as before)
}
```
**Setup:**
```bash
# 1. Install Netlify CLI
npm install -g netlify-cli
# 2. Create netlify.toml
cat > netlify.toml << EOF
[build]
command = "npm run build"
publish = "dist"
[functions]
directory = "netlify/functions"
[build.environment]
NODE_VERSION = "20"
EOF
# 3. Add environment variables in Netlify UI
# REPLICATE_API_TOKEN = "..."
# API_KEY = "..."
# 4. Deploy
netlify deploy --prod
```
**Frontend Integration bleibt gleich:**
```javascript
// Nur API URL ändern
const API_URL = '/.netlify/functions';
// Statt: const API_URL = 'http://localhost:3001/api';
```
### Option 2: Vercel Functions (Alternative)
**Ähnlich wie Netlify:**
- Free Tier: 100GB-Hrs/Monat
- Pro: $20/Monat
- Edge Functions möglich
- Bessere TypeScript-Unterstützung
```typescript
// api/generate-persona-image.ts
import { VercelRequest, VercelResponse } from '@vercel/node';
export default async function handler(req: VercelRequest, res: VercelResponse) {
// Similar implementation
}
```
### Option 3: Astro SSR mit API Routes (Neu in Astro 4.0)
**In deinem bestehenden Projekt:**
```typescript
// src/pages/api/generate-persona-image.ts
import type { APIRoute } from 'astro';
import Replicate from 'replicate';
export const POST: APIRoute = async ({ request }) => {
const data = await request.json();
// Implementation
return new Response(JSON.stringify(result), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
};
```
## 📊 Entscheidungsmatrix
| Kriterium | Netlify Functions | Hetzner VPS | Gewichtung |
| ------------------ | --------------------- | -------------- | ---------- |
| **Kosten** | ⭐⭐⭐⭐⭐ (€0-19/Mo) | ⭐⭐ (€24/Mo) | 30% |
| **Einfachheit** | ⭐⭐⭐⭐⭐ | ⭐⭐ | 25% |
| **Skalierbarkeit** | ⭐⭐⭐⭐ | ⭐⭐⭐ | 15% |
| **Kontrolle** | ⭐⭐ | ⭐⭐⭐⭐⭐ | 10% |
| **Performance** | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 10% |
| **Wartung** | ⭐⭐⭐⭐⭐ (keine) | ⭐⭐ (manuell) | 10% |
**Gewinner: Netlify Functions** 🏆
## 🎯 Empfehlung
### Für dein Memoro-Projekt: **Netlify Functions**
**Warum:**
1. **Kostenlos** für Development und kleine Nutzung
2. **Zero DevOps** - kein Server-Management
3. **Automatisches Scaling** - zahle nur was du nutzt
4. **5 Minuten Setup** vs. Stunden für VPS
5. **Integriert** mit deinem bestehenden Netlify Hosting
### Migrations-Pfad:
```bash
# Phase 1: Development (JETZT)
Netlify Functions Free Tier
- Test mit echten Usern
- Kosten: €0 + Replicate
# Phase 2: Growth (100+ Personas/Monat)
Netlify Functions Pro
- Kosten: €19/Monat + Replicate
# Phase 3: Scale (1000+ Personas/Monat)
Evaluiere Hetzner VPS für mehrere Services
- Kosten: €24/Monat + Replicate
```
## 🚀 Quick Start mit Netlify Functions
### 1. Erstelle Function-Datei:
```bash
mkdir -p netlify/functions
touch netlify/functions/generate-persona-image.js
```
### 2. Kopiere Code von oben
### 3. Environment Variables in Netlify:
```
Site Settings > Environment Variables:
- REPLICATE_API_TOKEN
- API_KEY
```
### 4. Deploy:
```bash
git add .
git commit -m "Add Netlify Function for image generation"
git push
```
### 5. Fertig! 🎉
## 💡 Pro-Tipps
### Bildoptimierung mit Netlify:
```javascript
// Nutze Netlify Image CDN für generierte Bilder
const optimizedUrl = `/.netlify/images?url=${imageUrl}&w=1024&q=80`;
```
### Caching Strategy:
```javascript
// Cache Replicate responses in Netlify Blobs (Free)
import { getStore } from '@netlify/blobs';
const store = getStore('persona-images');
await store.set(personaId, imageUrls);
```
### Rate Limiting:
```javascript
// Nutze Netlify Edge Functions für Rate Limiting
export const config = {
path: '/api/generate-image',
rateLimit: {
windowMs: 60000, // 1 minute
max: 5, // 5 requests per minute
},
};
```
## 📈 Kosten-Beispielrechnung
### Szenario: 50 Personas pro Monat
| Service | Netlify Functions | Hetzner VPS |
| --------------- | ----------------- | ----------- |
| Hosting | €0 (Free Tier) | €24 |
| Replicate | €0.50 | €0.50 |
| Zeit-Investment | 5 Minuten | 2-4 Stunden |
| **Total** | **€0.50** | **€24.50** |
### Szenario: 500 Personas pro Monat
| Service | Netlify Functions | Hetzner VPS |
| --------- | ----------------- | ----------- |
| Hosting | €19 (Pro) | €24 |
| Replicate | €5 | €5 |
| **Total** | **€24** | **€29** |
## 🏁 Fazit
**Netlify Functions ist die beste Wahl für dein Projekt:**
**Sofort einsatzbereit** - keine Server-Konfiguration
**Kosteneffizient** - zahle nur was du nutzt
**Automatisches Scaling** - keine Sorgen bei Traffic-Spitzen
**Integriert** - funktioniert nahtlos mit Astro
**Zukunftssicher** - einfache Migration wenn nötig
**Hetzner VPS nur wenn:**
- Du mehrere Backend-Services brauchst
- Du volle Kontrolle willst
- Du bereits DevOps-Erfahrung hast
- Du >1000 Requests/Tag erwartest
---
_Empfehlung: Starte mit Netlify Functions Free Tier und skaliere bei Bedarf._

View file

@ -160,13 +160,13 @@ curl -X POST http://localhost:3001/api/personas/generate-images \
}'
```
## 🚢 Production Deployment (Hetzner VPS)
## 🚢 Production Deployment (Hetzner + Coolify)
1. Push to GitHub
2. SSH to server and pull repository
2. Connect repository in Coolify
3. Set environment variables
4. Deploy with Docker Compose
5. Configure custom domain & SSL via Nginx/Certbot
4. Deploy as Docker Compose stack
5. Configure custom domain & SSL
## 📄 License

View file

@ -5,6 +5,7 @@ GEMINI_API_KEY=your-gemini-api-key-here
DATABASE_URL=postgresql://nutriphi:nutriphi_dev_password@localhost:5435/nutriphi
# Hetzner Object Storage (S3-compatible)
# Create in Coolify Dashboard -> S3 Storages
S3_ENDPOINT=https://fsn1.your-objectstorage.com
S3_ACCESS_KEY_ID=your-access-key-id
S3_SECRET_ACCESS_KEY=your-secret-access-key

View file

@ -7,7 +7,7 @@ PUBLIC_APP_URL=https://ulo.ad
# Database (PostgreSQL)
# Development: Use local Docker container
DATABASE_URL=postgresql://uload:uload_dev_password_123@localhost:5432/uload_dev
# Production: Use your Hetzner PostgreSQL container
# Production: Use your Coolify/Hetzner PostgreSQL container
# DATABASE_URL=postgresql://uload:your_password@uload-db-prod:5432/uload_prod
# File Storage (Cloudflare R2)
@ -30,7 +30,7 @@ PUBLIC_UMAMI_WEBSITE_ID=your-website-id
# AUTH_PROVIDER_CLIENT_ID=
# AUTH_PROVIDER_CLIENT_SECRET=
# Docker Compose specific (if needed)
# These can be set in your .env.production
# DEPLOY_URL=
# DEPLOY_TOKEN=
# Coolify specific (if needed)
# These will be set automatically by Coolify
# COOLIFY_URL=
# COOLIFY_TOKEN=

View file

@ -1,6 +1,6 @@
# =============================================================================
# uload Web Application Dockerfile
# Multi-stage build for production deployment
# Multi-stage build for production deployment with Coolify
#
# IMPORTANT: This Dockerfile must be built from the MONOREPO ROOT, not from uload/
# docker build -f uload/Dockerfile -t uload-web .

View file

@ -12,7 +12,7 @@ A modern URL shortener and link management platform built with SvelteKit and Poc
- **Frontend:** SvelteKit 2.0 + Svelte 5
- **Backend:** PocketBase (embedded)
- **Styling:** Tailwind CSS 4.0
- **Deployment:** Docker Compose on Hetzner VPS
- **Deployment:** Docker + Coolify on Hetzner VPS
- **Database:** SQLite (via PocketBase)
## 📦 Features
@ -59,7 +59,7 @@ docker-compose up --build
- [Deployment Guide](./DEPLOYMENT.md) - Complete Docker Compose deployment instructions
- [Lessons Learned](./DEPLOYMENT_LESSONS_LEARNED.md) - Troubleshooting and insights
- [Domain Setup](./DOMAIN_SETUP_ULO_AD.md) - ulo.ad configuration
- [Docker Setup](./DOCKER_SETUP.md) - Detailed Docker configuration
- [Coolify Setup](./COOLIFY_SETUP.md) - Detailed Coolify configuration
## 🔧 Environment Variables
@ -95,23 +95,23 @@ uload/
## 🚢 Deployment
The application is deployed on Hetzner VPS using Docker Compose with GitHub Actions for CI/CD.
The application is deployed on Hetzner VPS using Coolify with automatic deployments on push to main branch.
```bash
# Commit and push to deploy
git add .
git commit -m "Update"
git push origin main
# GitHub Actions handles deployment
# Coolify automatically deploys
```
### Manual Deployment Steps:
1. Set DNS A record to your server IP
2. Configure Nginx reverse proxy
1. Set DNS A record to `91.99.221.179`
2. Add domain in Coolify
3. Update environment variables
4. Enable SSL certificate via Certbot
5. Deploy application with docker compose
4. Enable SSL certificate
5. Deploy application
## 📊 Monitoring
@ -142,7 +142,7 @@ Common issues and solutions are documented in [DEPLOYMENT_LESSONS_LEARNED.md](./
For support, check:
- Application logs via `docker compose logs`
- Application logs in Coolify
- Health endpoint status
- PocketBase admin panel

View file

@ -7,7 +7,7 @@ PUBLIC_APP_URL=https://ulo.ad
# Database (PostgreSQL)
# Development: Use local Docker container
DATABASE_URL=postgresql://uload:uload_dev_password_123@localhost:5432/uload_dev
# Production: Use your Hetzner PostgreSQL container
# Production: Use your Coolify/Hetzner PostgreSQL container
# DATABASE_URL=postgresql://uload:your_password@uload-db-prod:5432/uload_prod
# File Storage (Cloudflare R2)
@ -30,7 +30,7 @@ PUBLIC_UMAMI_WEBSITE_ID=your-website-id
# AUTH_PROVIDER_CLIENT_ID=
# AUTH_PROVIDER_CLIENT_SECRET=
# Docker Compose specific (if needed)
# These can be set in your .env.production
# DEPLOY_URL=
# DEPLOY_TOKEN=
# Coolify specific (if needed)
# These will be set automatically by Coolify
# COOLIFY_URL=
# COOLIFY_TOKEN=

View file

@ -183,7 +183,7 @@ export const GET: RequestHandler = async () => {
next_steps: [
'1. Add REDIS_URL environment variable to your main app',
'2. Format: redis://username:password@host:port',
'3. Use the exact Redis service name from Docker Compose',
'3. Use the exact Redis service name from Coolify',
'4. Copy credentials from Redis service configuration',
],
},

View file

@ -1,7 +1,8 @@
# =============================================================================
# uload Docker Compose - Production (standalone)
# =============================================================================
# Use this for production deployment with Docker Compose.
# Use this for manual production deployment without Coolify.
# For Docker Compose deployments, use docker-compose.coolify.yml instead.
# =============================================================================
services:

View file

@ -0,0 +1,125 @@
# 🔑 Admin Panel Zugang - Schnellanleitung
## Der EINFACHSTE Weg:
### 1⃣ **Coolify Terminal nutzen**
1. Login in Coolify: https://coolify.ulo.ad (oder deine Coolify-URL)
2. Navigiere zu: **Applications → uLoad → Terminal**
3. Führe aus:
```bash
# Admin-Account erstellen
./pocketbase superuser create
# Eingabe:
# Email: admin@ulo.ad
# Password: [sicheres Passwort]
```
### 2**SSH Tunnel einrichten** (von deinem Computer)
```bash
# Windows (PowerShell/Terminal):
ssh -L 8090:localhost:8090 root@91.99.221.179
# Mac/Linux:
ssh -L 8090:localhost:8090 root@91.99.221.179
# Passwort eingeben (dein Server-Root-Passwort)
```
### 3⃣ **Admin Panel öffnen**
Browser öffnen und navigieren zu:
```
http://localhost:8090/_/
```
Mit den Zugangsdaten aus Schritt 1 einloggen.
---
## Alternative: Temporärer Web-Zugang
**⚠️ NUR für initiale Einrichtung!**
1. In Coolify → Configuration → Advanced → Custom Nginx:
```nginx
location /temp-admin/ {
allow 91.99.221.179; # DEINE IP hier!
deny all;
rewrite ^/temp-admin/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8090;
}
```
2. Speichern & Redeploy
3. Zugriff über: `https://ulo.ad/temp-admin/_/`
4. **WICHTIG**: Nach Setup wieder entfernen!
---
## Was du im Admin Panel tun solltest:
1. ✅ **Admin-Account sichern**
- Starkes Passwort setzen
- 2FA aktivieren (wenn verfügbar)
2. ✅ **Collections prüfen**
- `users` → Username-Feld vorhanden?
- `links` → use_username Feld vorhanden?
- `folders` → Struktur korrekt?
3. ✅ **Test-User anlegen**
- Username: till
- Email: deine@email.de
- Passwort setzen
4. ✅ **API Rules kontrollieren**
- users: Öffentliche Profile erlaubt?
- links: Richtige Berechtigungen?
---
## Troubleshooting:
**Problem: SSH Connection refused**
```bash
# Prüfe ob SSH auf dem Server läuft
ssh root@91.99.221.179 "echo SSH works"
```
**Problem: localhost:8090 zeigt nichts**
```bash
# Prüfe ob PocketBase läuft (im SSH):
curl http://localhost:8090/api/health
```
**Problem: Permission denied im Terminal**
```bash
# Als root ausführen:
sudo su
cd /app
./pocketbase superuser create
```
---
## Sicherheits-Checkliste:
- [ ] Admin-Account mit starkem Passwort
- [ ] SSH-Key statt Passwort für Server
- [ ] Keine öffentliche Admin-Route
- [ ] Regelmäßige Backups eingerichtet
- [ ] Monitoring aktiviert
Der SSH-Tunnel ist die sicherste Methode!

View file

@ -451,8 +451,8 @@ NODE_ENV=development npm run dev
| Feature | Development | Production |
|---------|------------|------------|
| PocketBase URL | http://localhost:8090 | https://pb.ulo.ad |
| Database | Local SQLite | Cloud (Docker) |
| Redis | localhost:6379 | Docker Redis |
| Database | Local SQLite | Cloud (Coolify) |
| Redis | localhost:6379 | Coolify Redis |
| Stripe | Test keys | Live keys |
| SSL | No (HTTP) | Yes (HTTPS) |
| Auth | Test accounts | Real users |

View file

@ -0,0 +1,441 @@
# Deployment Guide: Hetzner VPS mit Coolify
## Übersicht
Diese Anleitung beschreibt das Deployment einer SvelteKit + PocketBase Anwendung auf einem Hetzner VPS mit Coolify. Die Lösung kombiniert Frontend und Backend in einem einzelnen Docker Container für einfaches Deployment und Management.
## Voraussetzungen
- Hetzner VPS mit installiertem Coolify
- GitHub Repository mit dem Projekt
- Domain oder Subdomain (optional, aber empfohlen)
## Architektur
```
┌─────────────────────────────────────┐
│ Hetzner VPS │
│ │
│ ┌─────────────────────────────┐ │
│ │ Docker Compose │ │
│ │ │ │
│ │ ┌───────────────────────┐ │ │
│ │ │ Docker Container │ │ │
│ │ │ │ │ │
│ │ │ ┌─────────────────┐ │ │ │
│ │ │ │ SvelteKit App │ │ │ │
│ │ │ │ (Port 3000) │ │ │ │
│ │ │ └─────────────────┘ │ │ │
│ │ │ │ │ │
│ │ │ ┌─────────────────┐ │ │ │
│ │ │ │ PocketBase │ │ │ │
│ │ │ │ (Port 8090) │ │ │ │
│ │ │ └─────────────────┘ │ │ │
│ │ └───────────────────────┘ │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
```
## Schritt 1: Dockerfile erstellen
Erstelle eine `Dockerfile` im Root-Verzeichnis des Projekts:
```dockerfile
# Build Stage
FROM node:20-alpine AS builder
WORKDIR /app
# Dependencies installieren
COPY package*.json ./
RUN npm ci --only=production
# Dev dependencies für Build
COPY package*.json ./
RUN npm ci
# App kopieren und bauen
COPY . .
RUN npm run build
# Production Stage
FROM node:20-alpine
# System dependencies
RUN apk add --no-cache \
ca-certificates \
wget \
supervisor
WORKDIR /app
# PocketBase herunterladen
RUN wget https://github.com/pocketbase/pocketbase/releases/download/v0.26.2/pocketbase_0.26.2_linux_amd64.zip \
&& unzip pocketbase_0.26.2_linux_amd64.zip \
&& rm pocketbase_0.26.2_linux_amd64.zip \
&& chmod +x pocketbase
# Node.js App kopieren
COPY --from=builder /app/build build/
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules node_modules/
# PocketBase Daten (falls vorhanden)
COPY backend/pb_data /app/pb_data
# Supervisor config
RUN mkdir -p /etc/supervisor/conf.d
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# Ports
EXPOSE 3000 8090
# Start mit Supervisor
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
```
## Schritt 2: Supervisor Konfiguration
Erstelle eine `supervisord.conf` Datei für das Process Management:
```ini
[supervisord]
nodaemon=true
user=root
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
[program:pocketbase]
command=/app/pocketbase serve --http=0.0.0.0:8090 --dir=/app/pb_data
directory=/app
autostart=true
autorestart=true
stdout_logfile=/var/log/supervisor/pocketbase.log
stderr_logfile=/var/log/supervisor/pocketbase_err.log
environment=HOME="/app"
[program:sveltekit]
command=node build
directory=/app
autostart=true
autorestart=true
stdout_logfile=/var/log/supervisor/sveltekit.log
stderr_logfile=/var/log/supervisor/sveltekit_err.log
environment=NODE_ENV="production",PORT="3000",ORIGIN="https://your-domain.com",PUBLIC_POCKETBASE_URL="http://localhost:8090"
```
## Schritt 3: Environment Variables
Erstelle eine `.env.production` Datei (nicht ins Git committen!):
```bash
# SvelteKit
PORT=3000
ORIGIN=https://your-domain.com
NODE_ENV=production
# PocketBase Connection
PUBLIC_POCKETBASE_URL=http://localhost:8090
# Optional: PocketBase Admin
POCKETBASE_ADMIN_EMAIL=admin@example.com
POCKETBASE_ADMIN_PASSWORD=your-secure-password
```
## Schritt 4: Docker Compose (für lokales Testen)
Erstelle eine `docker-compose.yml` für lokale Tests:
```yaml
version: '3.8'
services:
app:
build: .
ports:
- '3000:3000'
- '8090:8090'
volumes:
- pb_data:/app/pb_data
environment:
- NODE_ENV=production
- PORT=3000
- ORIGIN=http://localhost:3000
- PUBLIC_POCKETBASE_URL=http://localhost:8090
restart: unless-stopped
volumes:
pb_data:
driver: local
```
Lokaler Test:
```bash
docker-compose up --build
```
## Schritt 5: Deployment mit Coolify
### 5.1 Repository vorbereiten
1. Committe alle Änderungen:
```bash
git add Dockerfile supervisord.conf
git commit -m "Add Docker deployment configuration"
git push origin main
```
### 5.2 Coolify Konfiguration
1. **Login** in Coolify Dashboard
2. **Neue Ressource erstellen:**
- Klicke auf "New Resource"
- Wähle "Application"
- Source: "GitHub"
3. **Repository verbinden:**
- Repository URL eingeben
- Branch: `main`
- Auto-Deploy aktivieren (optional)
4. **Build Configuration:**
- Build Pack: `Dockerfile`
- Dockerfile Path: `./Dockerfile`
- Build Context: `.`
5. **Environment Variables:**
```
NODE_ENV=production
PORT=3000
ORIGIN=https://your-domain.com
PUBLIC_POCKETBASE_URL=https://your-domain.com/api
```
6. **Networking:**
- Port Mapping: `3000:3000`
- Zusätzlich für PocketBase API: `8090:8090`
- Domain: `your-domain.com`
- SSL: Automatisch via Let's Encrypt
7. **Storage (wichtig für Daten-Persistenz):**
- Volume hinzufügen:
- Container Path: `/app/pb_data`
- Host Path: `/data/your-app/pb_data`
- Type: `Bind Mount`
### 5.3 Nginx Proxy Konfiguration (in Coolify)
Füge folgende Proxy-Regeln hinzu:
```nginx
# SvelteKit App
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# PocketBase API
location /api/ {
rewrite ^/api/(.*) /$1 break;
proxy_pass http://localhost:8090;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
}
# PocketBase Admin UI
location /_/ {
proxy_pass http://localhost:8090;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
}
```
## Schritt 6: Deployment durchführen
1. **Initial Deployment:**
- In Coolify: "Deploy" Button klicken
- Build Logs überwachen
- Warten bis Status "Running" ist
2. **Domain Setup:**
- DNS A-Record auf Server IP zeigen
- In Coolify SSL aktivieren
- Force HTTPS aktivieren
3. **PocketBase Admin Setup:**
- Navigiere zu `https://your-domain.com/_/`
- Admin Account erstellen
- Collections konfigurieren
## Schritt 7: Monitoring & Wartung
### Logs einsehen
In Coolify Dashboard:
- Application → Logs
- Real-time Logs für beide Services
### Backup Strategie
1. **Automatisches Backup mit Coolify:**
- Settings → Backups
- Schedule: Täglich um 3:00 Uhr
- Retention: 7 Tage
2. **Manuelles PocketBase Backup:**
```bash
# SSH zum Server
ssh user@your-server.com
# Backup erstellen
docker exec <container-id> /app/pocketbase backup create
# Backup herunterladen
scp user@your-server.com:/data/your-app/pb_data/backups/* ./backups/
```
### Updates
1. **Code Updates:**
- Push zu GitHub
- Coolify deployed automatisch (wenn Auto-Deploy aktiv)
2. **PocketBase Updates:**
- Dockerfile anpassen (neue Version)
- Commit & Push
- Redeploy in Coolify
## Troubleshooting
### Problem: Container startet nicht
**Lösung:**
```bash
# Logs prüfen
docker logs <container-id>
# Permissions prüfen
ls -la /data/your-app/pb_data
```
### Problem: PocketBase nicht erreichbar
**Lösung:**
1. Proxy-Konfiguration prüfen
2. Firewall Rules checken
3. Environment Variables verifizieren
### Problem: Daten nach Redeploy verloren
**Lösung:**
- Volume Mount korrekt konfigurieren
- Persistent Storage in Coolify aktivieren
## Performance Optimierung
### 1. CDN Integration (Optional)
Cloudflare Setup:
- DNS Proxy aktivieren
- Caching Rules für statische Assets
- Page Rules für API Endpoints
### 2. Resource Limits
In Coolify:
```yaml
resources:
limits:
memory: 1GB
cpu: 1.0
requests:
memory: 512MB
cpu: 0.5
```
### 3. Health Checks
```yaml
healthcheck:
test: ['CMD', 'wget', '--quiet', '--tries=1', '--spider', 'http://localhost:3000/health']
interval: 30s
timeout: 10s
retries: 3
```
## Sicherheit
### 1. Environment Variables
- Niemals Secrets im Code committen
- Coolify Secrets Manager nutzen
- Regelmäßig Passwörter rotieren
### 2. Firewall
```bash
# Nur benötigte Ports öffnen
ufw allow 22/tcp # SSH
ufw allow 80/tcp # HTTP
ufw allow 443/tcp # HTTPS
ufw enable
```
### 3. Updates
- Regelmäßige System Updates
- Dependency Updates via Dependabot
- Security Patches zeitnah einspielen
## Kosten-Übersicht
- **Hetzner VPS CX21:** ~5,83€/Monat
- 2 vCPU
- 4 GB RAM
- 40 GB SSD
- **Domain:** ~10-15€/Jahr
- **Backup Storage:** ~1€/Monat (optional)
**Gesamt:** ~7-8€/Monat
## Support & Hilfe
- [Coolify Documentation](https://coolify.io/docs)
- [SvelteKit Deployment Guide](https://kit.svelte.dev/docs/adapters)
- [PocketBase Documentation](https://pocketbase.io/docs)
- [Hetzner Cloud Console](https://console.hetzner.cloud)
## Checkliste für Go-Live
- [ ] Dockerfile erstellt und getestet
- [ ] Environment Variables konfiguriert
- [ ] GitHub Repository verbunden
- [ ] Coolify Application erstellt
- [ ] Domain konfiguriert
- [ ] SSL Zertifikat aktiv
- [ ] PocketBase Admin eingerichtet
- [ ] Backup Strategie implementiert
- [ ] Monitoring aktiviert
- [ ] Health Checks konfiguriert
- [ ] Firewall Rules gesetzt
- [ ] Erste erfolgreiche Deployment
- [ ] Smoke Tests durchgeführt

View file

@ -0,0 +1,217 @@
# Deployment Guide für uLoad
## 🏗️ Architektur-Übersicht
Das Projekt verwendet eine **Container-basierte Architektur** mit:
- **Frontend**: SvelteKit Application
- **Backend**: PocketBase (eingebettete SQLite-Datenbank)
- **Deployment**: Docker + Coolify auf Hetzner VPS
## 🔄 Deployment-Strategie
### 1. **Entwicklung (Local)**
```bash
# Lokale PocketBase starten
cd backend && ./pocketbase serve
# Frontend starten
npm run dev
# Beides zusammen
npm run dev:all
```
### 2. **Staging/Test**
```bash
# Mit docker-compose testen
docker-compose up --build
```
### 3. **Produktion (Coolify)**
#### Setup in Coolify:
1. **Neue Resource erstellen**:
- Type: Docker Compose
- Source: GitHub Repository
- Branch: main
- Docker Compose File: `docker-compose.prod.yml`
2. **Environment Variables setzen**:
```env
ORIGIN=https://ulo.ad
POCKETBASE_ADMIN_EMAIL=admin@ulo.ad
POCKETBASE_ADMIN_PASSWORD=<secure-password>
```
3. **Persistent Storage**:
- Volume für PocketBase: `/pb_data`
- Mountpoint: `pocketbase-data:/pb_data`
## 📦 Datenbank-Migrationen
### Schema-Änderungen:
1. Neue Migration erstellen:
```javascript
// backend/pb_migrations/TIMESTAMP_description.js
migrate(
(db) => {
// Änderungen hier
},
(db) => {
// Rollback hier
}
);
```
2. Migrationen werden automatisch beim Start angewendet
### Wichtige Collections:
- **users**: User-Accounts mit Username, Preferences
- **links**: Kurz-Links mit Metadaten
- **clicks**: Analytics-Daten
- **folders**: Link-Organisation (neu)
## 🚀 Deployment-Prozess
### Automatisch (empfohlen):
```bash
git add .
git commit -m "feat: neue Funktion"
git push origin main
# Coolify deployed automatisch
```
### Manuell (Notfall):
1. In Coolify Dashboard
2. Application → Redeploy
3. Logs prüfen
## 🔐 Sicherheit
### Produktions-Checklist:
- [ ] Sichere Passwörter in Coolify Secrets
- [ ] HTTPS aktiviert
- [ ] Rate Limiting konfiguriert
- [ ] Backup-Strategie aktiv
- [ ] Monitoring eingerichtet
### Backup-Strategie:
```bash
# PocketBase Backup (läuft im Container)
docker exec <container-id> /pb/pocketbase backup
# Volume Backup
docker run --rm -v pocketbase-data:/data -v $(pwd):/backup \
alpine tar czf /backup/pocketbase-backup.tar.gz /data
```
## 🎯 Best Practices
### 1. **Umgebungs-Trennung**:
- **Lokal**: Eigene PocketBase-Instanz
- **Staging**: Docker-Compose Test
- **Produktion**: Isolierter Container in Coolify
### 2. **Datenbank-Updates**:
- Immer Migrationen verwenden
- Niemals direkt in Produktion ändern
- Backup vor großen Änderungen
### 3. **Environment Variables**:
```javascript
// Verwendung in Code
const POCKETBASE_URL = import.meta.env.PUBLIC_POCKETBASE_URL || 'http://127.0.0.1:8090';
```
## 🐛 Troubleshooting
### Problem: Datenbank-Schema nicht aktuell
```bash
# Migration manuell ausführen
docker exec <container-id> /pb/pocketbase migrate up
```
### Problem: Container startet nicht
```bash
# Logs prüfen
docker logs <container-id>
# Health Check
curl https://ulo.ad/api/health
```
### Problem: Verbindung zu PocketBase fehlgeschlagen
- Prüfe Environment Variables
- Verifiziere Docker Network
- Check Firewall Rules
## 📊 Monitoring
### Health Endpoints:
- App Health: `https://ulo.ad/health`
- PocketBase: `https://ulo.ad/api/health`
- Admin Panel: `https://ulo.ad/_/`
### Logs in Coolify:
1. Application → Logs
2. Filter: "error" oder "warning"
3. Zeitraum anpassen
## 🔄 Rollback-Strategie
Bei Problemen:
1. **In Coolify**: Previous Deployments → Rollback
2. **Manuell**:
```bash
git revert HEAD
git push origin main
```
## 📝 Maintenance
### Regelmäßige Tasks:
- [ ] Wöchentliche Backups prüfen
- [ ] Monatliche Security Updates
- [ ] Quartalweise Performance Review
- [ ] Jährliche Dependency Updates
### Update-Prozess:
```bash
# Dependencies updaten
npm update
npm audit fix
# PocketBase updaten
# Neue Version in docker-compose.prod.yml
# Testen
docker-compose -f docker-compose.prod.yml build
docker-compose -f docker-compose.prod.yml up
# Deployen
git push origin main
```

View file

@ -0,0 +1,314 @@
# Deployment Dokumentation: Lessons Learned
## Projektübersicht
Deployment einer SvelteKit + PocketBase Anwendung auf Hetzner VPS mit Coolify.
## Finale Architektur
```
┌─────────────────────────────────────────────┐
│ Hetzner VPS (91.99.221.179) │
│ │
│ ┌────────────────────────────────────────┐ │
│ │ Docker Compose │ │
│ │ │ │
│ │ ┌───────────────────────────────────┐ │ │
│ │ │ Docker Container │ │ │
│ │ │ │ │ │
│ │ │ ┌──────────────────────────────┐ │ │ │
│ │ │ │ Supervisor Process Manager │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ ├─ SvelteKit (Port 3000) │ │ │ │
│ │ │ │ └─ PocketBase (Port 8090) │ │ │ │
│ │ │ └──────────────────────────────┘ │ │ │
│ │ └───────────────────────────────────┘ │ │
│ └────────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
```
## Was wir gemacht haben
### 1. Repository Vorbereitung
#### Probleme die wir lösen mussten:
- **MCP Server Submodule:** Git erkannte den MCP Server als Submodule, was Deployment verhinderte
- **Lösung:** MCP Server aus Git entfernt und in `.gitignore` hinzugefügt
- **NPM Dependencies:** Versionskonflikte bei `globals` und `prettier-plugin-tailwindcss`
- **Lösung:** Versionen angepasst und `--legacy-peer-deps` verwendet
- **Fehlende Dependencies:** `@tailwindcss/vite` war nicht installiert
- **Lösung:** Package nachinstalliert
### 2. Docker Setup
#### Dockerfile Evolution:
**Version 1 (Fehlgeschlagen):**
```dockerfile
COPY backend/pb_data /app/pb_data # Fehler: Ordner existiert nicht im Git
```
**Version 2 (Final):**
```dockerfile
# Multi-stage build für optimale Größe
FROM node:20-alpine AS builder
# Build mit legacy-peer-deps
RUN npm ci --legacy-peer-deps
FROM node:20-alpine
# Supervisor für Process Management
RUN apk add --no-cache supervisor wget unzip
# PocketBase Binary direkt herunterladen
RUN wget https://github.com/pocketbase/pocketbase/releases/download/v0.26.2/pocketbase_0.26.2_linux_amd64.zip
```
### 3. Process Management mit Supervisor
#### supervisord.conf Herausforderungen:
**Problem:** Environment Variables Expansion
```ini
# Fehlgeschlagen:
environment=ORIGIN="%(ENV_ORIGIN)s" # ENV_ORIGIN nicht definiert
# Lösung:
# Variables in Coolify setzen und Supervisor nutzt sie automatisch
```
### 4. Initialization Scripts
Drei verschiedene Ansätze entwickelt:
1. **docker-entrypoint.sh** - Initialisierung beim Container Start
2. **init-pocketbase.sh** - PocketBase Setup Script
3. **pb_schema.json** - Datenbankstruktur als JSON
### 5. Coolify Configuration
#### GitHub App Integration:
- GitHub App erstellt und Repository verbunden
- Automatische Deployments bei Git Push
#### Build Configuration:
- Build Pack: `Dockerfile` (NICHT Nixpacks!)
- Keine speziellen Build Commands nötig
#### Environment Variables (KRITISCH!):
```bash
# Diese MÜSSEN in Coolify gesetzt werden:
ORIGIN=http://w848k4ksk88o8w84kcosw488.91.99.221.179.sslip.io
PUBLIC_POCKETBASE_URL=http://localhost:8090
POCKETBASE_ADMIN_EMAIL=till.schneider@memoro.ai
POCKETBASE_ADMIN_PASSWORD=p0ck3tRA1N
```
## Kritische Erkenntnisse
### 1. Environment Variables sind ESSENTIELL
- **Problem:** Supervisor kann nicht starten ohne die ENV Variables
- **Symptom:** Endlosschleife im Container mit Supervisor Error
- **Lösung:** ALLE benötigten ENV Variables in Docker Compose configuration setzen
### 2. Docker Build Context
- **Problem:** `.dockerignore` fehlte anfangs
- **Folge:** Unnötige Dateien im Image
- **Lösung:** Sauberer `.dockerignore` erstellt
### 3. PocketBase Persistenz
- **Wichtig:** Volume Mount für `/app/pb_data` in Coolify konfigurieren
- **Sonst:** Datenverlust bei jedem Redeploy
### 4. Supervisor Syntax
- **Korrekt:** `%(ENV_VARIABLE_NAME)s` für Environment Variables
- **Wichtig:** Supervisor erwartet `ENV_` Prefix
### 5. Health Checks
- **Endpoint:** `/health` erstellt für Monitoring
- **Nutzen:** Coolify kann App-Status überwachen
## Debugging Workflow
1. **Logs prüfen:** Coolify Dashboard → Logs
2. **Container Status:** Prüfen ob Running
3. **Environment Variables:** Verifizieren dass alle gesetzt sind
4. **Netzwerk:** Ports und Domains prüfen
## Finale Dateistruktur
```
uload/
├── Dockerfile # Multi-stage build
├── docker-compose.yml # Lokales Testing
├── supervisord.conf # Process Management
├── docker-entrypoint.sh # Container Initialization
├── .dockerignore # Build Optimierung
├── .env.example # Environment Template
├── .env.production.example # Production Template
├── backend/
│ ├── pb_schema.json # Database Schema
│ ├── init-pocketbase.sh # PocketBase Setup
│ └── pb_migrations/ # Migrations (optional)
├── src/
│ └── routes/
│ └── health/
│ └── +server.ts # Health Check Endpoint
└── DEPLOYMENT.md # Deployment Guide
```
## Zeitaufwand
- **Repository Fixes:** 30 Minuten (Dependencies, Git Issues)
- **Docker Setup:** 45 Minuten (Multi-stage Build, PocketBase Integration)
- **Supervisor Config:** 20 Minuten (Environment Variables Issue)
- **Coolify Setup:** 15 Minuten (GitHub App, ENV Variables)
- **Debugging:** 30 Minuten (Logs analysieren, Fixes)
**Total:** ~2.5 Stunden
## Kommandos für Wartung
### Lokales Testing:
```bash
docker-compose up --build
```
### Deployment:
```bash
git add .
git commit -m "Update"
git push
# Coolify deployed automatisch
```
### PocketBase Admin:
```
http://[domain]/_/
Login: till.schneider@memoro.ai
```
### Health Check:
```bash
curl http://[domain]/health
```
## Was gut funktioniert hat
**Multi-Container in einem Image:** Supervisor managed beide Services perfekt
**Auto-Init:** PocketBase Setup läuft automatisch beim ersten Start
**GitHub Integration:** Push = Deploy
**Health Monitoring:** Endpoint zeigt Status beider Services
## Was wir anders machen würden
1. **Environment Variables zuerst:** Direkt in Coolify konfigurieren
2. **Simpler Start:** Erst ohne docker-entrypoint.sh
3. **Logging:** Mehr Debug-Output in Scripts
4. **Documentation:** ENV Requirements prominenter dokumentieren
## Kosten
- **Hetzner VPS CX21:** 5,83€/Monat
- **Domain:** Kostenlos via sslip.io
- **SSL:** Kostenlos via Coolify/Let's Encrypt
## Performance
- **Build Zeit:** ~30 Sekunden
- **Deployment:** ~1 Minute
- **Container Start:** ~5 Sekunden
- **Memory Usage:** ~200MB
- **CPU Usage:** <5% idle
## Domain Setup (ulo.ad)
### DNS Konfiguration
```
Type: A
Name: @
Value: 91.99.221.179
TTL: 3600
```
### Coolify Domain Setup
1. **Add Domain:** `ulo.ad` und `www.ulo.ad`
2. **SSL:** Generate Certificate + Force HTTPS
3. **Environment Variables UPDATE (KRITISCH!):**
```bash
ORIGIN=https://ulo.ad
PUBLIC_POCKETBASE_URL=https://ulo.ad/api
```
### Proxy Rules für PocketBase
```nginx
location /api {
rewrite ^/api/(.*) /$1 break;
proxy_pass http://localhost:8090;
}
location /_/ {
proxy_pass http://localhost:8090;
}
```
### Finale URLs
- **Production:** https://ulo.ad
- **Admin Panel:** https://ulo.ad/_/
- **API:** https://ulo.ad/api
- **Health:** https://ulo.ad/health
## Nächste Schritte
1. [x] Eigene Domain konfigurieren (ulo.ad)
2. [x] SSL aktivieren (Let's Encrypt via Coolify)
3. [ ] Backup Strategie implementieren
4. [ ] Monitoring erweitern
5. [ ] CI/CD Pipeline mit Tests
## Hilfreiche Links
- [Coolify Docs](https://coolify.io/docs)
- [Supervisor Documentation](http://supervisord.org/configuration.html)
- [SvelteKit Adapter Node](https://kit.svelte.dev/docs/adapter-node)
- [PocketBase Docker Deployment](https://pocketbase.io/docs/going-to-production/)
## Kontakt & URLs
### Temporäre URLs (vor Domain Setup)
- **Temp URL:** http://w848k4ksk88o8w84kcosw488.91.99.221.179.sslip.io
- **Server IP:** 91.99.221.179
- **Coolify:** http://91.99.221.179:8000
### Production URLs (ulo.ad)
- **Website:** https://ulo.ad
- **PocketBase Admin:** https://ulo.ad/_/
- **API Endpoint:** https://ulo.ad/api
- **Health Check:** https://ulo.ad/health
- **Admin Login:** till.schneider@memoro.ai
---
_Dokumentiert am 13. August 2024 nach erfolgreichem Deployment_
_Updated mit Domain Setup für ulo.ad_

View file

@ -0,0 +1,362 @@
# Domain Setup: ulo.ad
## Übersicht
Diese Anleitung beschreibt die Verbindung der Domain **ulo.ad** mit der auf Hetzner/Coolify gehosteten Anwendung.
**Aktuelle Situation:**
- App läuft auf: `http://w848k4ksk88o8w84kcosw488.91.99.221.179.sslip.io`
- Server IP: `91.99.221.179`
- Ziel Domain: `ulo.ad`
---
## Schritt 1: DNS Konfiguration
### Bei deinem DNS Provider (Namecheap, Cloudflare, etc.)
Erstelle folgende DNS Records:
#### Hauptdomain (ulo.ad):
```
Type: A
Name: @
Value: 91.99.221.179
TTL: 3600 (oder Auto)
Proxy: Aus (falls Cloudflare)
```
#### WWW Subdomain (www.ulo.ad):
```
Type: CNAME
Name: www
Value: ulo.ad
TTL: 3600
```
#### Optional - App Subdomain (app.ulo.ad):
```
Type: A
Name: app
Value: 91.99.221.179
TTL: 3600
```
### DNS Einstellungen für verschiedene Provider:
#### **Cloudflare:**
1. DNS → Records → Add Record
2. Proxy Status: DNS only (graue Wolke) für Anfang
3. Nach erfolgreichem Test: Proxy aktivieren (orange Wolke)
#### **Namecheap:**
1. Domain List → Manage → Advanced DNS
2. Add New Record → A Record
3. Host: @ | Value: 91.99.221.179
#### **Hetzner DNS:**
1. DNS Console → Zone hinzufügen
2. Record hinzufügen → Type A
3. Name: @ | Value: 91.99.221.179
---
## Schritt 2: Coolify Konfiguration
### 2.1 Domain hinzufügen
1. **Login in Coolify Dashboard**
```
http://91.99.221.179:8000
```
2. **Navigiere zu deiner Application**
- Projects → Dein Projekt → Application
3. **Domains Tab öffnen**
- Klicke auf "Domains"
4. **Domain hinzufügen**
- Klicke "Add Domain"
- Eingabe: `ulo.ad`
- Für www auch: `www.ulo.ad`
5. **SSL Konfiguration**
- ✅ **Generate SSL Certificate** (wichtig!)
- ✅ **Force HTTPS Redirect**
- ✅ **Auto redirect www to non-www** (oder umgekehrt)
### 2.2 Environment Variables anpassen
**KRITISCH: Diese müssen angepasst werden!**
1. **Gehe zu "Environment Variables"**
2. **Update folgende Variablen:**
```bash
# Alte Werte (LÖSCHEN/UPDATEN):
ORIGIN=http://w848k4ksk88o8w84kcosw488.91.99.221.179.sslip.io
PUBLIC_POCKETBASE_URL=http://localhost:8090
# NEUE WERTE:
ORIGIN=https://ulo.ad
PUBLIC_POCKETBASE_URL=https://ulo.ad/api
# Admin Credentials (bleiben gleich):
POCKETBASE_ADMIN_EMAIL=till.schneider@memoro.ai
POCKETBASE_ADMIN_PASSWORD=p0ck3tRA1N
```
3. **Speichern und Deploy**
- Save Changes
- Klicke "Redeploy" oder "Restart"
---
## Schritt 3: Proxy Konfiguration (in Coolify)
Falls noch nicht vorhanden, füge diese Proxy Rules hinzu:
1. **Gehe zu "Proxy" Tab**
2. **Custom Nginx Configuration:**
```nginx
# PocketBase API Routing
location /api {
rewrite ^/api/(.*) /$1 break;
proxy_pass http://localhost:8090;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# PocketBase Admin UI
location /_/ {
proxy_pass http://localhost:8090;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
}
# WebSocket Support
location /api/realtime {
proxy_pass http://localhost:8090;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
```
---
## Schritt 4: Deployment durchführen
1. **In Coolify:**
- Klicke "Deploy" oder "Redeploy"
- Warte bis Status "Running"
2. **Deployment verifizieren:**
- Container Logs prüfen
- Keine Errors sollten erscheinen
---
## Schritt 5: DNS Propagation & Testing
### Warten auf DNS Propagation
- **Dauer:** 5 Minuten bis 48 Stunden (meist < 1 Stunde)
- **Tipp:** .ad Domains können etwas länger dauern
### DNS Status prüfen:
```bash
# Terminal/Command Line:
nslookup ulo.ad
# Sollte zeigen: 91.99.221.179
# Oder online:
# https://www.whatsmydns.net/#A/ulo.ad
```
### SSL Zertifikat prüfen:
```bash
# Nach DNS Propagation (wichtig!):
curl -I https://ulo.ad
# Sollte HTTP/2 200 zeigen
```
---
## Schritt 6: Testen
### URLs die funktionieren sollten:
1. **Hauptseite:**
```
https://ulo.ad
```
2. **Health Check:**
```
https://ulo.ad/health
```
3. **PocketBase Admin:**
```
https://ulo.ad/_/
```
Login: till.schneider@memoro.ai
4. **API Endpoint:**
```
https://ulo.ad/api/health
```
---
## Troubleshooting
### Problem: "DNS_PROBE_FINISHED_NXDOMAIN"
**Ursache:** DNS noch nicht propagiert
**Lösung:**
- Warte 30-60 Minuten
- Prüfe DNS Records beim Provider
- Cache leeren: `ipconfig /flushdns` (Windows) oder `dscacheutil -flushcache` (Mac)
### Problem: "SSL_ERROR" oder "Your connection is not private"
**Ursache:** SSL Zertifikat noch nicht generiert
**Lösung:**
1. Warte bis DNS vollständig propagiert
2. In Coolify: "Force Renew Certificate"
3. Container neu starten
### Problem: "502 Bad Gateway"
**Ursache:** App nicht gestartet oder ENV Variables falsch
**Lösung:**
1. Environment Variables prüfen (besonders ORIGIN)
2. Container Logs in Coolify prüfen
3. Container neu starten
### Problem: "404 Not Found"
**Ursache:** Routing Problem oder App nicht gestartet
**Lösung:**
1. Proxy Configuration prüfen
2. Health Check testen: `https://ulo.ad/health`
3. Container Logs prüfen
### Problem: PocketBase Admin nicht erreichbar
**Ursache:** Proxy Rules fehlen
**Lösung:**
1. Nginx Proxy Config prüfen (siehe oben)
2. Direct URL testen: `http://91.99.221.179:8090/_/`
---
## Zeitplan
1. **DNS Setup:** 5 Minuten
2. **Coolify Config:** 10 Minuten
3. **DNS Propagation:** 5-60 Minuten
4. **SSL Generation:** 2-5 Minuten (nach DNS)
5. **Testing:** 5 Minuten
**Total:** ~30-90 Minuten (abhängig von DNS)
---
## Finale URLs
Nach erfolgreichem Setup:
| Service | URL | Beschreibung |
| ------------ | --------------------- | ---------------------- |
| Hauptseite | https://ulo.ad | SvelteKit Frontend |
| WWW | https://www.ulo.ad | Redirect zu Hauptseite |
| Admin Panel | https://ulo.ad/_/ | PocketBase Admin |
| API | https://ulo.ad/api | PocketBase API |
| Health Check | https://ulo.ad/health | System Status |
---
## Sicherheits-Checkliste
- [ ] SSL Zertifikat aktiv (HTTPS)
- [ ] Force HTTPS Redirect aktiviert
- [ ] Environment Variables gesetzt
- [ ] PocketBase Admin Passwort geändert
- [ ] Firewall Rules aktiv
- [ ] Backup Strategy implementiert
---
## Nächste Schritte nach Domain Setup
1. **Cloudflare Integration (optional):**
- Proxy aktivieren für DDoS Schutz
- Caching Rules einrichten
- Web Application Firewall
2. **Monitoring einrichten:**
- Uptime Monitoring (z.B. UptimeRobot)
- SSL Expiry Monitoring
- Performance Monitoring
3. **Backups konfigurieren:**
- Automated Backups in Coolify
- External Backup zu S3/B2
4. **Short Links testen:**
- Erstelle Test-Links
- QR Codes generieren
- Analytics prüfen
---
## Support Kontakte
**Domain Issues:** DNS Provider Support
**Server/Coolify:** Hetzner Support / Coolify Discord
**Application:** Internal Team
**Wichtige IPs/URLs:**
- Server IP: `91.99.221.179`
- Coolify Dashboard: `http://91.99.221.179:8000`
- Temp URL: `http://w848k4ksk88o8w84kcosw488.91.99.221.179.sslip.io`
---
_Dokumentation erstellt für ulo.ad Domain Setup - August 2024_

View file

@ -433,7 +433,7 @@ INSERT INTO links (short_code, original_url, user_id) VALUES
2. **Implement External Auth** - Replace PocketBase auth with external provider
3. **Remove PocketBase Code** - Delete `backend/`, `pb_hooks/`, `pocketbase` binary
4. **Update Documentation** - Update CLAUDE.md and README
5. **Deploy to Production** - Push to Hetzner VPS
5. **Deploy to Production** - Push to Coolify on Hetzner VPS
---

View file

@ -4,7 +4,7 @@
### 1. Admin-Account erstellen
In Docker container → uLoad App → **Terminal**:
In Coolify Dashboard → uLoad App → **Terminal**:
```bash
./pocketbase superuser create

View file

@ -0,0 +1,135 @@
# uLoad Deployment Guide
## 🚀 Deployment Architektur
Die Anwendung wurde vereinfacht und läuft nun **ohne embedded PocketBase**. PocketBase wird als separater Service in Coolify deployed.
### Container-Struktur
```
uLoad Container (Port 3000)
├── SvelteKit App
├── Node.js Runtime
└── Health Check Endpoint
PocketBase Container (Port 8090) - Separat in Coolify
├── PocketBase Server
├── SQLite Database
└── Admin UI
```
## 📦 Was wurde geändert?
### Entfernte Komponenten:
- ❌ Embedded PocketBase im Container
- ❌ Supervisor Process Manager
- ❌ Docker Entrypoint Script
- ❌ PocketBase Initialisierungs-Scripts
- ❌ Port 8090 Mapping im App Container
- ❌ Volume für pb_data
### Neue Struktur:
- ✅ Schlanker Container nur mit SvelteKit
- ✅ Externe PocketBase Verbindung via `PUBLIC_POCKETBASE_URL`
- ✅ Direkte Node.js Ausführung (kein Supervisor)
- ✅ Reduzierte Image-Größe (~80MB kleiner)
## 🔧 Deployment in Coolify
### 1. PocketBase Service (falls noch nicht vorhanden)
```yaml
# Als separater Service in Coolify
Service: PocketBase
Port: 8090
Persistent Volume: /pb_data
```
### 2. uLoad App Service
```yaml
# docker-compose.coolify.yml verwenden
Service: uLoad
Port: 3000
Build: Dockerfile
Environment:
- PUBLIC_POCKETBASE_URL=http://pocketbase:8090 # Interner Service Name
- ORIGIN=https://ulo.ad
- PUBLIC_UMAMI_URL=...
- PUBLIC_UMAMI_WEBSITE_ID=...
```
## 🔄 Migration Steps
### Für bestehende Deployments:
1. **PocketBase Daten sichern** (falls noch embedded):
```bash
docker cp container_name:/app/pb_data ./pb_data_backup
```
2. **Externe PocketBase aufsetzen** in Coolify
3. **Daten importieren** in neue PocketBase Instanz
4. **Environment Variables anpassen**:
```env
PUBLIC_POCKETBASE_URL=http://pocketbase:8090
```
5. **Neues Image deployen** mit aktualisiertem Dockerfile
## 📝 Environment Variables
```env
# Required
PORT=3000
ORIGIN=https://ulo.ad
PUBLIC_POCKETBASE_URL=http://pocketbase:8090
# Optional
PUBLIC_UMAMI_URL=https://analytics.domain.com
PUBLIC_UMAMI_WEBSITE_ID=xxx-xxx-xxx
```
## 🏗️ Build & Deploy
### Lokal testen:
```bash
# Build
docker build -t uload:latest .
# Run mit externer PocketBase
docker run -p 3000:3000 \
-e PUBLIC_POCKETBASE_URL=https://pb.ulo.ad \
-e ORIGIN=http://localhost:3000 \
uload:latest
```
### Production Deploy:
```bash
# Via Coolify mit docker-compose.coolify.yml
# Oder direkt:
docker compose -f docker-compose.yml up -d
```
## 📊 Vorteile der neuen Architektur
1. **Bessere Skalierbarkeit**: App und DB können unabhängig skaliert werden
2. **Einfachere Updates**: PocketBase Updates ohne App-Rebuild
3. **Kleinere Images**: ~120MB statt ~200MB
4. **Klarere Trennung**: Frontend und Backend sind klar getrennt
5. **Flexiblere Konfiguration**: PocketBase kann zentral für mehrere Apps genutzt werden
## ⚠️ Wichtige Hinweise
- PocketBase Hooks (`pb_hooks/`) müssen manuell zur externen PocketBase migriert werden
- Schema (`backend/pb_schema.json`) muss in der externen PocketBase importiert werden
- Interne Coolify Networking nutzen (nicht über Public URL)
- Health Check läuft nur auf Port 3000 (nicht mehr auf 8090)

View file

@ -21,7 +21,7 @@ REDIS_PASSWORD=
**Produktion (.env):**
```env
# Füge deine Redis-Daten hier ein:
# Füge deine Coolify Redis-Daten hier ein:
REDIS_HOST=your-redis-host
REDIS_PORT=6379
REDIS_USERNAME=default
@ -95,8 +95,8 @@ redis-cli flushall
## Nächste Schritte für Produktion:
1. Redis in Docker Compose einrichten
2. Umgebungsvariablen setzen:
1. Redis in Coolify einrichten
2. Umgebungsvariablen in Coolify setzen:
- REDIS_HOST
- REDIS_PORT
- REDIS_PASSWORD

View file

@ -81,7 +81,7 @@ PUBLIC_POCKETBASE_URL=https://ulo.ad/api
POCKETBASE_ADMIN_URL=http://localhost:8090 # Nur intern!
```
### 3. Firewall Rules (in Hetzner)
### 3. Firewall Rules (in Coolify/Hetzner)
```bash
# Nur benötigte Ports öffnen
@ -105,7 +105,7 @@ apt install fail2ban
### 2. Logs überwachen
```bash
# In Monitoring einrichten für:
# In Coolify Alerts einrichten für:
- Fehlgeschlagene Admin-Logins
- Ungewöhnlich viele API-Anfragen
- 404 auf /_/ Route

View file

@ -86,7 +86,7 @@ POCKETBASE_URL=http://127.0.0.1:8090
# ENTFERNEN: supervisor config für pocketbase
```
### Nginx Configuration:
### Nginx in Coolify:
```nginx
location /api/ {

View file

@ -15,7 +15,7 @@ Das uLoad-Projekt war kürzlich komplett down, was zu kritischen Problemen gefü
- **Frontend:** SvelteKit 2.22 mit Svelte 5.0
- **Backend:** PocketBase (https://pb.ulo.ad)
- **Hosting:** Hetzner VPS mit Docker Compose
- **Hosting:** Hetzner VPS mit Coolify
- **Database:** PocketBase SQLite mit persistentem Volume
- **Deployment:** Docker mit Supervisor (Multi-Service Container)
@ -29,7 +29,7 @@ Das uLoad-Projekt war kürzlich komplett down, was zu kritischen Problemen gefü
2. **Single Server Setup**
- Ein Hetzner VPS für gesamte Infrastruktur
- Keine Redundanz oder Load Balancing
- Single server as Point of Failure
- Coolify als Single Point of Failure
3. **Container Architecture**
- SvelteKit und PocketBase in einem Container
@ -370,8 +370,8 @@ jobs:
steps:
- name: Deploy to Primary
run: |
# Docker Deployment
ssh deploy@server "cd /app && docker compose pull && docker compose up -d"
# Coolify Deployment
curl -X POST ${{ secrets.COOLIFY_WEBHOOK }}
- name: Health Check
run: |
@ -389,7 +389,7 @@ jobs:
if: failure()
run: |
# Automatisches Rollback bei Fehler
ssh deploy@server "cd /app && git checkout HEAD~1 && docker compose up -d"
curl -X POST ${{ secrets.COOLIFY_ROLLBACK_WEBHOOK }}
```
### 2. Monitoring & Alerting

View file

@ -5,7 +5,7 @@
### 1. Environment Separation
- **Development**: `http://localhost:8090` (lokal)
- **Production**: `https://pb.ulo.ad` (Docker)
- **Production**: `https://pb.ulo.ad` (Coolify)
- Automatische Umgebungserkennung basierend auf `dev` Flag
### 2. Code-Änderungen
@ -181,10 +181,10 @@ Falls Probleme: Admin UI → Settings → API Rules
| Aspekt | Development | Production |
| -------------- | --------------------- | ----------------- |
| PocketBase URL | http://localhost:8090 | https://pb.ulo.ad |
| Datenbank | Lokal (SQLite) | Cloud (Docker) |
| Datenbank | Lokal (SQLite) | Cloud (Coolify) |
| Auth | Test-Accounts | Echte User |
| Stripe | Test-Keys | Live-Keys |
| Redis | localhost:6379 | Docker Redis |
| Redis | localhost:6379 | Coolify Redis |
| SSL | Nein (HTTP) | Ja (HTTPS) |
| CSP | Relaxed | Strict |

View file

@ -0,0 +1,513 @@
Redis Cache für uLoad - Was bringt es
wirklich?
Das Hauptproblem aktuell
Jedes Mal wenn jemand einen deiner
Short-Links klickt, muss deine App:
1. PocketBase fragen "welche URL gehört zu
diesem Short-Code?"
2. Warten bis PocketBase in der SQLite
Datei sucht
3. Antwort zurückgeben und weiterleiten
Das dauert 50-100ms pro Klick. Bei vielen
Klicks wird PocketBase zum Flaschenhals.
Was Redis ändern würde
Redis ist wie ein ultra-schneller
Zwischenspeicher. Statt jedes Mal
PocketBase zu fragen, schaust du erst in
Redis nach. Das ist 20-50x schneller!
Die 3 größten Verbesserungen
1. Link-Redirects (90% deines Traffics)
Problem: Jeder Klick = PocketBase Query =
langsam
Lösung: Beliebte Links werden in Redis
gespeichert
Beispiel:
- Dein Link "ulo.ad/meeting" wird 100x am
Tag geklickt
- Ohne Redis: 100x PocketBase Query (100x
80ms = 8 Sekunden Serverzeit)
- Mit Redis: 1x PocketBase + 99x Redis (1x
80ms + 99x 2ms = 0.28 Sekunden)
Ergebnis: 30x schneller, 95% weniger
PocketBase Last
2. Dashboard Performance
Problem: Dein Dashboard lädt alle deine
Links aus PocketBase
Lösung: Links werden für 5 Minuten gecacht
Beispiel:
- Du hast 50 Links, checkst Dashboard 20x
am Tag
- Ohne Redis: 20x komplette Liste laden =
langsam
- Mit Redis: 1x laden, 19x aus Cache =
blitzschnell
3. Analytics & Statistiken
Problem: Click-Zahlen berechnen ist langsam
(viele Datenbankabfragen)
Lösung: Statistiken werden live berechnet
und gecacht
Beispiel:
- "Zeige Clicks der letzten 7 Tage" braucht
normalerweise komplizierte Queries
- Mit Redis: Zahlen werden kontinuierlich
aktualisiert, sofort verfügbar
Rate Limiting Verbesserung
Aktuell: Dein Rate Limiter vergisst alles
bei Server-Restart
Mit Redis: Rate Limits bleiben bestehen,
auch über Restarts hinweg
Das bedeutet: Angreifer können nicht mehr
durch Server-Restart deine Limits umgehen.
Konkrete Zahlen für dich
Traffic-Verteilung schätzen:
- 90% Redirects (Leute klicken deine Links)
- 8% Dashboard/API (Du managst deine Links)
- 2% Sonstiges (Login, Settings, etc.)
Performance ohne Redis:
- Redirect: 80ms durchschnittlich
- Dashboard laden: 300ms
- Bei 1000 Klicks/Tag: PocketBase ist
gestresst
Performance mit Redis:
- Redirect: 5ms (95% aus Cache)
- Dashboard laden: 20ms (meist aus Cache)
- Bei 10.000 Klicks/Tag: PocketBase
entspannt
Wann merkst du den Unterschied?
Sofort spürbar:
- Dashboard lädt viel schneller (besonders
mit vielen Links)
- Redirects sind blitzschnell
- Analytics laden sofort statt zu "denken"
Bei mehr Traffic:
- Server bleibt entspannt auch bei
Viral-Links
- Keine Timeouts mehr bei hoher Last
- PocketBase überlastet nicht
Skalierung:
- Ohne Redis: ~500 Redirects/Minute machbar
- Mit Redis: ~5000+ Redirects/Minute
machbar
Kosten vs. Nutzen
Option 1: Kostenlos starten
- Upstash Redis (10k Commands/Tag gratis)
- Perfekt zum Testen
- Upgrade wenn nötig
Option 2: Minimale Kosten
- €5-15/Monat für ordentlichen Redis
- Dafür 10x bessere Performance
Was du sparst:
- Weniger Server-Stress = längere
Hardware-Lebensdauer
- Bessere User Experience = mehr zufriedene
Nutzer
- Skalierung möglich ohne neuen Server
Die Realität für uLoad
Jetzt: Bei 100 Klicks gleichzeitig würde
dein Server wahrscheinlich strugglen
Mit Redis: Bei 1000 Klicks gleichzeitig
läuft alles smooth
Jetzt: Dashboard mit 100 Links braucht eine
Sekunde zum Laden
Mit Redis: Dashboard lädt in 0.1 Sekunden
Jetzt: Click-Statistiken berechnen dauert
ewig
Mit Redis: Statistiken sind immer aktuell
und sofort da
Meine ehrliche Einschätzung
Für uLoad ist Redis ein No-Brainer:
- Minimaler Aufwand (2-3 Tage Setup)
- Maximaler Nutzen (10x Performance)
- Kosten fast null (kann gratis starten)
Der größte Gewinn ist bei Redirects - das
ist dein Kernfeature und wird damit
ultra-schnell. Plus du bist ready für
Viral-Links die plötzlich 1000x geklickt
werden.
Bottom Line: Redis macht aus deinem "hobby
project" eine professionelle, skalierbare
Anwendung für praktisch null Zusatzkosten.
Das ist vermutlich das beste ROI was du
kriegen kannst! 🎯
> super danke. wir können ja reddis auch
> einfach auf unserem hetzter vps mit coolify
> hochfahren oder? was brucht es an
> reccourcen? was mus man beachten? sollte es
> lieber auf einem adnere server liegen?
> sollte es im gleichen project in coolify
> liegen?
⏺ Redis auf Hetzner VPS mit Coolify -
Perfekte Idee!
Ja, absolut machbar und sinnvoll!
Dein aktueller Hetzner VPS kann Redis
problemlos mit hosten. Coolify macht das
super einfach.
Resource-Bedarf von Redis
RAM (das Wichtigste):
- Redis lädt alles in den RAM - das macht
es so schnell
- Für uLoad geschätzt: 50-200MB RAM
- Link Cache: ~1MB pro 10.000 Links
- Session Cache: ~10MB für 1000 aktive
User
- Analytics: ~20-50MB je nach History
- Rate Limiting: ~5-10MB
CPU:
- Minimal! Redis ist extrem CPU-effizient
- Für uLoad: <1% CPU Auslastung
- Nur bei komplexen Operationen mehr
Disk:
- Persistence: 100-500MB für Dumps
- Logs: 10-50MB
- Total: <1GB
Dein CX21 VPS Kapazität:
- RAM: 8GB total, aktuell ~2GB verwendet →
6GB frei!
- CPU: 2 vCPUs, aktuell <20% Mehr als
genug
- Disk: 40GB, Redis braucht <1GB Kein
Problem
Fazit: Dein Server kann Redis locker
verkraften! 🎯
Coolify Setup - zwei Optionen
Option 1: Gleiches Projekt (Empfohlen für
Start)
# Einfach Redis Service zu deinem
bestehenden docker-compose hinzufügen
services:
app: # Deine bestehende App
redis:
image: redis:7-alpine
command: redis-server --appendonly yes
--maxmemory 512mb --maxmemory-policy
allkeys-lru
volumes: - redis-data:/data
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
volumes:
redis-data:
Option 2: Separates Projekt in Coolify
- Vorteil: Unabhängiges Deployment von
deiner App
- Nachteil: Etwas komplizierter zu
verwalten
Ich empfehle Option 1 - Redis ist so
stabil, das macht keinen Unterschied.
Coolify Konfiguration
Redis Environment Variables:
# In Coolify für Redis Service setzen:
REDIS_PASSWORD=dein-sicheres-passwort-hier
REDIS_MAXMEMORY=512mb
REDIS_SAVE_INTERVAL=900 # Auto-save alle
15 Min
Deine App erweitern:
# Environment Variable für deine App
hinzufügen:
REDIS_URL=redis://:dein-passwort@redis:6379
Networking in Coolify:
- Redis und App sind automatisch im
gleichen Docker Network
- Deine App kann Redis über redis:6379
erreichen
- Kein Port nach außen öffnen nötig
(Sicherheit!)
Wichtige Redis Konfiguration
Memory Management:
# Redis Config (über Command Parameter):
--maxmemory 512mb # Begrenze RAM Nutzung
--maxmemory-policy allkeys-lru # Alte Keys
löschen wenn voll
Persistence:
# Zwei Optionen kombinieren:
--appendonly yes # AOF für Durability
--save 900 1 # RDB Snapshot alle 15
Min
Security:
# Passwort setzen:
--requirepass dein-starkes-passwort
# Gefährliche Commands deaktivieren:
--rename-command FLUSHDB ""
--rename-command FLUSHALL ""
Monitoring in Coolify
Redis Logs überwachen:
- Coolify zeigt Redis Logs in Echtzeit
- Achte auf Memory Warnings
- Connection Errors beobachten
Wichtige Redis Metriken:
# Commands zum Monitoring (über redis-cli):
INFO memory # RAM Nutzung
INFO stats # Operations/sec
INFO clients # Verbindungen
MONITOR # Live Commands (nur
zum Debugging!)
Backup Strategy
Automatische Redis Backups:
# Script in deinem Container:
#!/bin/bash
# redis-backup.sh
DATE=$(date +%Y%m%d_%H%M%S)
redis-cli --rdb
/backups/redis_backup_$DATE.rdb
# In Coolify als Cron Job alle 6 Stunden
Oder einfacher:
# Volume Mount für Persistence reicht:
volumes: - redis-data:/data # Redis saved
automatisch hier
Separater Server - wann sinnvoll?
Bleib bei einem Server wenn:
- <10.000 Requests/Tag (definitiv dein
Fall)
- <1000 gleichzeitige User
- Development/Early Stage (definitiv dein
Fall)
Separater Redis Server nur bei:
- > 100.000 Requests/Tag
- Multi-Region Setup
- Dedicated Redis Cluster mit Failover
Für uLoad: Ein Server reicht völlig!
Coolify Deployment Steps
1. Docker Compose erweitern:
# Zu deiner bestehenden docker-compose.yml
hinzufügen:
services: # ... deine bestehenden Services
redis:
image: redis:7-alpine
command: >
redis-server
--appendonly yes
--maxmemory 512mb
--maxmemory-policy allkeys-lru
--requirepass ${REDIS_PASSWORD}
volumes:
- redis-data:/data
restart: unless-stopped
networks:
- app-network
healthcheck:
test: ["CMD", "redis-cli", "-a",
"${REDIS_PASSWORD}", "ping"]
interval: 30s
timeout: 10s
retries: 3
volumes:
redis-data:
networks:
app-network:
driver: bridge
2. Environment Variables in Coolify:
# Für Redis:
REDIS_PASSWORD=SuperSecurePassword123!
# Für deine App:
REDIS_URL=redis://:SuperSecurePassword123!@
redis:6379
3. Deploy & Test:
- Coolify deployed automatisch
- Teste Connection aus deiner App
- Check Redis Logs für Errors
Was beachten?
Security:
- Passwort setzen (Redis hat standardmäßig
keins!)
- Port nicht öffentlich machen (nur intern)
- Command Renaming für gefährliche Commands
Performance:
- Memory Limit setzen (512MB reicht für
Start)
- LRU Policy aktivieren (alte Keys werden
gelöscht)
- Connection Pooling in deiner App
Monitoring:
- Redis Logs in Coolify beobachten
- Memory Usage im Auge behalten
- Hit Rate messen (wie oft Cache getroffen
wird)
Troubleshooting
Häufige Probleme:
1. Connection refused: Redis Service nicht
gestartet
2. Auth failed: Passwort falsch oder nicht
gesetzt
3. Memory full: Maxmemory erreicht, LRU
Policy prüfen
Debug Commands:
# In Coolify Terminal (Redis Container):
redis-cli -a dein-passwort info
redis-cli -a dein-passwort ping
redis-cli -a dein-passwort client list
Meine Empfehlung
Setup für uLoad:
1. Gleicher Server (Hetzner VPS) ✅
2. Gleiches Coolify Projekt ✅
3. 512MB Memory Limit ✅
4. Passwort-geschützt ✅
5. Persistence aktiviert ✅
Vorteile:
- Einfachste Lösung
- Keine Extra-Kosten
- Latenz minimal (gleicher Server)
- Easy zu verwalten
Nachteile:
- Keine (für deine Use Case)
Redis auf deinem bestehenden Setup ist der
perfekte nächste Schritt! 🚀

View file

@ -108,7 +108,7 @@ Modern Tech Stack:
- Frontend: SvelteKit 2.0 + Svelte 5 (Cutting-Edge)
- Backend: PocketBase (Embedded, Skalierbar)
- Styling: Tailwind CSS 4.0 (Neueste Version)
- Deployment: Docker Compose auf Hetzner VPS
- Deployment: Docker + Coolify auf Hetzner VPS
- Database: SQLite via PocketBase (Performant)
- Payments: Stripe Integration

View file

@ -11,7 +11,7 @@ Dieses Dokument analysiert die optimale Strategie für die Implementierung einer
- **Framework**: SvelteKit 2.22 mit Svelte 5.0
- **Backend**: PocketBase (eingebettet)
- **Styling**: Tailwind CSS 4.0
- **Deployment**: Docker Compose auf Hetzner VPS
- **Deployment**: Docker + Coolify auf Hetzner VPS
- **Hauptfunktionen**:
- URL-Verkürzung mit QR-Code-Generierung
- Benutzer-Dashboard mit Analytics

View file

@ -0,0 +1,335 @@
# Self-Hosted IP Geolocation Solutions für Coolify/VPS
**Erstellt:** 16. August 2025
**Version:** 1.0
**Kontext:** Unabhängige, kommerzielle Geolocation-Lösung für uload
## Executive Summary
Für kommerziellen Einsatz ohne Abhängigkeit von externen Services gibt es mehrere exzellente self-hosted Lösungen, die perfekt mit Coolify auf einem VPS funktionieren.
## Option 1: MaxMind GeoLite2 Docker Container (⭐ EMPFOHLEN)
### Setup als Docker Service in Coolify
**1. Docker Compose Service:**
```yaml
services:
geolite2-server:
image: ghcr.io/m-rots/geolite2-server:latest
container_name: geolite2-server
restart: always
ports:
- '8080:8080'
environment:
- MAXMIND_LICENSE_KEY=${MAXMIND_LICENSE_KEY}
- UPDATE_INTERVAL=24h
volumes:
- geolite2-data:/usr/share/GeoIP
networks:
- coolify
uload-app:
# ... existing config
depends_on:
- geolite2-server
environment:
- GEOLOCATION_URL=http://geolite2-server:8080
volumes:
geolite2-data:
```
**2. Integration im Code:**
```javascript
// src/lib/geolocation.ts
export async function getLocationFromIP(ipAddress: string) {
try {
const response = await fetch(
`${process.env.GEOLOCATION_URL || 'http://localhost:8080'}/json/${ipAddress}`
);
const data = await response.json();
return {
country: data.country?.names?.en || 'Unknown',
city: data.city?.names?.en || 'Unknown'
};
} catch (error) {
return { country: 'Unknown', city: 'Unknown' };
}
}
```
**Vorteile:**
- ✅ Komplett self-hosted
- ✅ Kostenlos (GeoLite2 License)
- ✅ Automatische Updates
- ✅ Keine API Limits
- ✅ GDPR-konform (keine Daten verlassen Server)
**Setup Steps:**
1. Registriere kostenlosen MaxMind Account
2. Erstelle License Key
3. Deploy via Coolify
4. Fertig!
## Option 2: IP2Location LITE Docker
### Fertige Docker Solution
```yaml
services:
ip2location:
image: ip2location/ip2location-lite:latest
container_name: ip2location
restart: always
ports:
- '8081:80'
volumes:
- ./ip2location-data:/var/lib/ip2location
environment:
- AUTO_UPDATE=true
- UPDATE_FREQUENCY=weekly
```
**Integration:**
```javascript
async function getLocationFromIP(ip) {
const response = await fetch(`http://ip2location:80/api/${ip}`);
return await response.json();
}
```
**Vorteile:**
- ✅ Ebenfalls kostenlos für kommerzielle Nutzung
- ✅ Sehr leichtgewichtig
- ✅ Gute Genauigkeit
## Option 3: GeoIP2 Server (Rust-basiert, Ultra-Fast)
### High-Performance Solution
```dockerfile
# Dockerfile
FROM ghcr.io/lily-mosquitoes/geoip2-server:latest
COPY GeoLite2-City.mmdb /data/
CMD ["--database", "/data/GeoLite2-City.mmdb", "--port", "3000"]
```
**Coolify Deployment:**
```yaml
services:
geoip-server:
build: ./geoip-server
restart: always
ports:
- '3000:3000'
volumes:
- ./data:/data
mem_limit: 128m
cpus: 0.25
```
**Performance:**
- < 1ms Response Time
- 50MB RAM Footprint
- 10k+ Requests/Second
## Option 4: All-in-One Solution mit Plausible Analytics
### Bonus: Komplettes Analytics System
```yaml
services:
plausible:
image: plausible/analytics:latest
container_name: plausible
restart: always
command: sh -c "sleep 10 && /entrypoint.sh db createdb && /entrypoint.sh db migrate && /entrypoint.sh run"
depends_on:
- plausible_db
- plausible_events_db
ports:
- 8000:8000
env_file:
- plausible-conf.env
volumes:
- ./geoip:/geoip:ro
```
**Vorteile:**
- ✅ Komplettes Analytics System
- ✅ Integrierte Geolocation
- ✅ GDPR-konform
- ✅ Schönes Dashboard
## Empfehlung für uload
### Sofort-Implementation (1 Tag)
**1. MaxMind GeoLite2 Server via Coolify:**
```bash
# 1. MaxMind Account erstellen (kostenlos)
# https://www.maxmind.com/en/geolite2/signup
# 2. License Key generieren
# 3. Docker Compose in Coolify
```
**docker-compose.coolify.yml Addition:**
```yaml
geolite2:
image: maxmindinc/geoipupdate:latest
container_name: geoip-updater
environment:
GEOIPUPDATE_ACCOUNT_ID: ${MAXMIND_ACCOUNT_ID}
GEOIPUPDATE_LICENSE_KEY: ${MAXMIND_LICENSE_KEY}
GEOIPUPDATE_EDITION_IDS: 'GeoLite2-City GeoLite2-Country'
GEOIPUPDATE_FREQUENCY: 72
volumes:
- geoip-data:/usr/share/GeoIP
restart: unless-stopped
geoip-api:
image: ghcr.io/m-rots/geolite2-server:latest
container_name: geoip-api
depends_on:
- geolite2
ports:
- '127.0.0.1:8080:8080'
volumes:
- geoip-data:/usr/share/GeoIP:ro
restart: unless-stopped
```
**4. Code Integration:**
```javascript
// src/lib/services/geolocation.ts
const GEOIP_SERVICE = process.env.GEOIP_SERVICE_URL || 'http://geoip-api:8080';
export async function getLocationFromIP(ipAddress: string) {
// Skip private IPs
if (isPrivateIP(ipAddress)) {
return { country: 'Local', city: 'Local' };
}
try {
const response = await fetch(`${GEOIP_SERVICE}/json/${ipAddress}`, {
signal: AbortSignal.timeout(1000) // 1s timeout
});
if (!response.ok) throw new Error('GeoIP lookup failed');
const data = await response.json();
return {
country: data.country?.names?.en || 'Unknown',
city: data.city?.names?.en || 'Unknown',
region: data.subdivisions?.[0]?.names?.en,
latitude: data.location?.latitude,
longitude: data.location?.longitude
};
} catch (error) {
console.error('GeoIP lookup error:', error);
return { country: 'Unknown', city: 'Unknown' };
}
}
function isPrivateIP(ip: string): boolean {
return ip === '::1' ||
ip === '127.0.0.1' ||
ip.startsWith('192.168.') ||
ip.startsWith('10.') ||
ip.startsWith('172.');
}
```
## Implementierungs-Checkliste
### Tag 1: Setup
- [ ] MaxMind Account erstellen
- [ ] License Key generieren
- [ ] Docker Service in Coolify deployen
- [ ] Environment Variables setzen
### Tag 2: Integration
- [ ] Geolocation Service Code hinzufügen
- [ ] Click-Handler updaten
- [ ] Error Handling testen
- [ ] Performance Monitoring
### Tag 3: Optimization
- [ ] Caching Layer (Redis/Memory)
- [ ] Batch Updates für alte Daten
- [ ] Dashboard für Geo-Stats
## Kosten-Nutzen-Analyse
| Lösung | Einmalige Kosten | Laufende Kosten | Performance | Wartung |
| ---------------- | ---------------- | --------------- | ----------- | ------- |
| MaxMind GeoLite2 | 0€ | 0€ | Excellent | Minimal |
| IP2Location LITE | 0€ | 0€ | Sehr gut | Minimal |
| Plausible Bundle | 0€ | 0€ | Gut | Mittel |
## Performance Benchmarks
**Test Setup:** 1000 unique IPs
- MaxMind Docker: ~0.8ms avg response
- Direct MMDB: ~0.2ms avg response
- External API: ~50-200ms avg response
## Fazit
**Beste Option:** MaxMind GeoLite2 Docker Container
**Gründe:**
1. **Zero Cost** - Komplett kostenlos für kommerzielle Nutzung
2. **Zero Dependencies** - Läuft komplett auf eurem Server
3. **GDPR Compliant** - Keine Daten verlassen euren Server
4. **Production Ready** - Von Millionen Sites verwendet
5. **Coolify Native** - Ein Docker Compose und fertig
**Next Steps:**
1. MaxMind Account in 5 Min erstellen
2. Docker Service deployen (10 Min)
3. Code Integration (30 Min)
4. **Total: < 1 Stunde bis Production!**
## Bonus: Nginx GeoIP Module
Falls ihr Nginx verwendet, gibt es noch eine ultra-schnelle Option:
```nginx
# nginx.conf
load_module modules/ngx_http_geoip2_module.so;
http {
geoip2 /usr/share/GeoIP/GeoLite2-City.mmdb {
$geoip2_country_name country names en;
$geoip2_city_name city names en;
}
# Pass to upstream
proxy_set_header X-Country $geoip2_country_name;
proxy_set_header X-City $geoip2_city_name;
}
```
Dann im Code einfach Header auslesen - 0ms Overhead!

View file

@ -2,7 +2,7 @@
## Required Environment Variables
Add these to your deployment platform:
Add these to your deployment platform (e.g., Coolify):
```bash
# Stripe API Keys (Test)

View file

@ -5,8 +5,10 @@ import { DatabaseModule } from './db/database.module';
import { HealthModule } from './health/health.module';
import { CalendarModule } from './calendar/calendar.module';
import { EventModule } from './event/event.module';
import { EventTagModule } from './event-tag/event-tag.module';
import { ReminderModule } from './reminder/reminder.module';
import { ShareModule } from './share/share.module';
import { NetworkModule } from './network/network.module';
@Module({
imports: [
@ -19,8 +21,10 @@ import { ShareModule } from './share/share.module';
HealthModule,
CalendarModule,
EventModule,
EventTagModule,
ReminderModule,
ShareModule,
NetworkModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,45 @@
import { pgTable, uuid, text, timestamp, varchar, primaryKey, index } from 'drizzle-orm/pg-core';
import { events } from './events.schema';
/**
* Event tags table - stores user-defined tags with colors
*/
export const eventTags = pgTable(
'event_tags',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
name: varchar('name', { length: 100 }).notNull(),
color: varchar('color', { length: 7 }).default('#3B82F6'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
userIdx: index('event_tags_user_idx').on(table.userId),
})
);
/**
* Event to tags junction table - many-to-many relationship
*/
export const eventToTags = pgTable(
'event_to_tags',
{
eventId: uuid('event_id')
.notNull()
.references(() => events.id, { onDelete: 'cascade' }),
tagId: uuid('tag_id')
.notNull()
.references(() => eventTags.id, { onDelete: 'cascade' }),
},
(table) => ({
pk: primaryKey({ columns: [table.eventId, table.tagId] }),
eventIdx: index('event_to_tags_event_idx').on(table.eventId),
tagIdx: index('event_to_tags_tag_idx').on(table.tagId),
})
);
export type EventTag = typeof eventTags.$inferSelect;
export type NewEventTag = typeof eventTags.$inferInsert;
export type EventToTag = typeof eventToTags.$inferSelect;
export type NewEventToTag = typeof eventToTags.$inferInsert;

View file

@ -1,6 +1,7 @@
// Calendar Database Schemas
export * from './calendars.schema';
export * from './events.schema';
export * from './event-tags.schema';
export * from './calendar-shares.schema';
export * from './reminders.schema';
export * from './external-calendars.schema';

View file

@ -0,0 +1,12 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
export class CreateEventTagDto {
@IsString()
@MaxLength(100)
name!: string;
@IsString()
@IsOptional()
@MaxLength(7)
color?: string;
}

View file

@ -0,0 +1,13 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
export class UpdateEventTagDto {
@IsString()
@IsOptional()
@MaxLength(100)
name?: string;
@IsString()
@IsOptional()
@MaxLength(7)
color?: string;
}

View file

@ -0,0 +1,62 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
ParseUUIDPipe,
NotFoundException,
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { EventTagService } from './event-tag.service';
import { CreateEventTagDto } from './dto/create-event-tag.dto';
import { UpdateEventTagDto } from './dto/update-event-tag.dto';
@Controller('event-tags')
@UseGuards(JwtAuthGuard)
export class EventTagController {
constructor(private readonly eventTagService: EventTagService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData) {
const tags = await this.eventTagService.findByUserId(user.userId);
return { tags };
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const tag = await this.eventTagService.findById(id, user.userId);
if (!tag) {
throw new NotFoundException('Tag not found');
}
return { tag };
}
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateEventTagDto) {
const tag = await this.eventTagService.create({
...dto,
userId: user.userId,
});
return { tag };
}
@Put(':id')
async update(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateEventTagDto
) {
const tag = await this.eventTagService.update(id, user.userId, dto);
return { tag };
}
@Delete(':id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
await this.eventTagService.delete(id, user.userId);
return { success: true };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { EventTagController } from './event-tag.controller';
import { EventTagService } from './event-tag.service';
@Module({
controllers: [EventTagController],
providers: [EventTagService],
exports: [EventTagService],
})
export class EventTagModule {}

View file

@ -0,0 +1,119 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and, inArray } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { eventTags, eventToTags } from '../db/schema';
import type { EventTag, NewEventTag } from '../db/schema';
const DEFAULT_TAGS = [
{ name: 'Arbeit', color: '#3b82f6' }, // blue
{ name: 'Persönlich', color: '#22c55e' }, // green
{ name: 'Familie', color: '#ec4899' }, // pink
{ name: 'Wichtig', color: '#ef4444' }, // red
] as const;
@Injectable()
export class EventTagService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findByUserId(userId: string): Promise<EventTag[]> {
const tags = await this.db.select().from(eventTags).where(eq(eventTags.userId, userId));
// Create default tags on first access (when user has no tags yet)
if (tags.length === 0) {
return this.createDefaultTags(userId);
}
return tags;
}
private async createDefaultTags(userId: string): Promise<EventTag[]> {
const tagsToCreate = DEFAULT_TAGS.map((tag) => ({
userId,
name: tag.name,
color: tag.color,
}));
return this.db.insert(eventTags).values(tagsToCreate).returning();
}
async findById(id: string, userId: string): Promise<EventTag | null> {
const [tag] = await this.db
.select()
.from(eventTags)
.where(and(eq(eventTags.id, id), eq(eventTags.userId, userId)));
return tag || null;
}
async create(data: NewEventTag): Promise<EventTag> {
const [tag] = await this.db.insert(eventTags).values(data).returning();
return tag;
}
async update(id: string, userId: string, data: Partial<NewEventTag>): Promise<EventTag> {
const [tag] = await this.db
.update(eventTags)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(eventTags.id, id), eq(eventTags.userId, userId)))
.returning();
if (!tag) {
throw new NotFoundException('Tag not found');
}
return tag;
}
async delete(id: string, userId: string): Promise<void> {
await this.db.delete(eventTags).where(and(eq(eventTags.id, id), eq(eventTags.userId, userId)));
}
async getTagsForEvent(eventId: string): Promise<EventTag[]> {
const results = await this.db
.select({ tag: eventTags })
.from(eventToTags)
.innerJoin(eventTags, eq(eventToTags.tagId, eventTags.id))
.where(eq(eventToTags.eventId, eventId));
return results.map((r) => r.tag);
}
async getTagIdsForEvent(eventId: string): Promise<string[]> {
const results = await this.db
.select({ tagId: eventToTags.tagId })
.from(eventToTags)
.where(eq(eventToTags.eventId, eventId));
return results.map((r) => r.tagId);
}
async setEventTags(eventId: string, tagIds: string[]): Promise<void> {
// Remove existing tags
await this.db.delete(eventToTags).where(eq(eventToTags.eventId, eventId));
// Add new tags
if (tagIds.length > 0) {
const values = tagIds.map((tagId) => ({ eventId, tagId }));
await this.db.insert(eventToTags).values(values).onConflictDoNothing();
}
}
async addTagToEvent(eventId: string, tagId: string): Promise<void> {
await this.db.insert(eventToTags).values({ eventId, tagId }).onConflictDoNothing();
}
async removeTagFromEvent(eventId: string, tagId: string): Promise<void> {
await this.db
.delete(eventToTags)
.where(and(eq(eventToTags.eventId, eventId), eq(eventToTags.tagId, tagId)));
}
async getTagsByIds(ids: string[], userId: string): Promise<EventTag[]> {
if (ids.length === 0) return [];
return this.db
.select()
.from(eventTags)
.where(and(inArray(eventTags.id, ids), eq(eventTags.userId, userId)));
}
}

View file

@ -6,6 +6,7 @@ import {
IsDateString,
IsUUID,
IsIn,
IsArray,
MaxLength,
} from 'class-validator';
import type { EventMetadata } from '../../db/schema/events.schema';
@ -63,4 +64,9 @@ export class CreateEventDto {
@IsOptional()
@IsObject()
metadata?: EventMetadata;
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
tagIds?: string[];
}

View file

@ -73,4 +73,9 @@ export class UpdateEventDto {
@IsOptional()
@IsObject()
metadata?: EventMetadata;
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
tagIds?: string[];
}

View file

@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { EventController } from './event.controller';
import { EventService } from './event.service';
import { CalendarModule } from '../calendar/calendar.module';
import { EventTagModule } from '../event-tag/event-tag.module';
@Module({
imports: [CalendarModule],
imports: [CalendarModule, EventTagModule],
controllers: [EventController],
providers: [EventService],
exports: [EventService],

View file

@ -5,13 +5,15 @@ import { Database } from '../db/connection';
import { events, Event, NewEvent } from '../db/schema/events.schema';
import { calendars } from '../db/schema/calendars.schema';
import { CalendarService } from '../calendar/calendar.service';
import { EventTagService } from '../event-tag/event-tag.service';
import { CreateEventDto, UpdateEventDto, QueryEventsDto } from './dto';
@Injectable()
export class EventService {
constructor(
@Inject(DATABASE_CONNECTION) private db: Database,
private calendarService: CalendarService
private calendarService: CalendarService,
private eventTagService: EventTagService
) {}
async queryEvents(userId: string, query: QueryEventsDto): Promise<Event[]> {
@ -104,6 +106,12 @@ export class EventService {
};
const [created] = await this.db.insert(events).values(newEvent).returning();
// Set tags if provided
if (dto.tagIds && dto.tagIds.length > 0) {
await this.eventTagService.setEventTags(created.id, dto.tagIds);
}
return created;
}
@ -115,8 +123,11 @@ export class EventService {
await this.calendarService.findByIdOrThrow(dto.calendarId, userId);
}
// Handle tags separately
const { tagIds, ...eventData } = dto;
const updateData: Partial<NewEvent> = {
...dto,
...eventData,
startTime: dto.startTime ? new Date(dto.startTime) : undefined,
endTime: dto.endTime ? new Date(dto.endTime) : undefined,
recurrenceEndDate: dto.recurrenceEndDate ? new Date(dto.recurrenceEndDate) : undefined,
@ -136,6 +147,11 @@ export class EventService {
.where(and(eq(events.id, id), eq(events.userId, userId)))
.returning();
// Update tags if provided
if (tagIds !== undefined) {
await this.eventTagService.setEventTags(id, tagIds);
}
return updated;
}
@ -173,9 +189,24 @@ export class EventService {
.where(and(...conditions))
.orderBy(events.startTime);
return result.map((r) => ({
...r.event,
calendar: r.calendar,
}));
// Load tags for all events
const eventsWithCalendar = await Promise.all(
result.map(async (r) => {
const tags = await this.eventTagService.getTagsForEvent(r.event.id);
return {
...r.event,
calendar: r.calendar,
tags,
};
})
);
return eventsWithCalendar;
}
async getEventWithTags(id: string, userId: string) {
const event = await this.findByIdOrThrow(id, userId);
const tags = await this.eventTagService.getTagsForEvent(id);
return { ...event, tags };
}
}

View file

@ -0,0 +1,18 @@
import { Controller, Get, UseGuards, Headers } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { NetworkService } from './network.service';
@Controller('api/v1/network')
@UseGuards(JwtAuthGuard)
export class NetworkController {
constructor(private readonly networkService: NetworkService) {}
@Get('graph')
async getGraph(
@CurrentUser() user: CurrentUserData,
@Headers('authorization') authorization?: string
) {
const accessToken = authorization?.replace('Bearer ', '');
return this.networkService.getGraph(user.userId, accessToken);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { NetworkController } from './network.controller';
import { NetworkService } from './network.service';
@Module({
controllers: [NetworkController],
providers: [NetworkService],
exports: [NetworkService],
})
export class NetworkModule {}

View file

@ -0,0 +1,178 @@
import { Injectable, Inject } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { events, eventToTags } from '../db/schema';
interface Tag {
id: string;
name: string;
color: string | null;
}
export interface NetworkNode {
id: string;
name: string;
photoUrl: string | null;
company: string | null;
isFavorite: boolean;
tags: Tag[];
connectionCount: number;
}
export interface NetworkLink {
source: string;
target: string;
type: 'tag';
strength: number;
sharedTags: string[];
}
export interface NetworkGraphResponse {
nodes: NetworkNode[];
links: NetworkLink[];
}
@Injectable()
export class NetworkService {
private authUrl: string;
constructor(
@Inject(DATABASE_CONNECTION) private db: Database,
private configService: ConfigService
) {
this.authUrl = this.configService.get<string>('MANA_CORE_AUTH_URL') || 'http://localhost:3001';
}
/**
* Fetch tags from central Tags API
*/
private async fetchTagsByIds(tagIds: string[], accessToken: string): Promise<Map<string, Tag>> {
if (tagIds.length === 0) return new Map();
try {
const response = await fetch(`${this.authUrl}/api/v1/tags/by-ids?ids=${tagIds.join(',')}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
console.error('Failed to fetch tags from central API:', response.status);
return new Map();
}
const tags: Tag[] = await response.json();
return new Map(tags.map((t) => [t.id, t]));
} catch (error) {
console.error('Error fetching tags from central API:', error);
return new Map();
}
}
/**
* Build a network graph of events connected by shared tags
*/
async getGraph(userId: string, accessToken?: string): Promise<NetworkGraphResponse> {
// 1. Get all events for user
const eventsData = await this.db
.select({
event: events,
})
.from(events)
.where(eq(events.userId, userId));
// 2. Get tag IDs for each event from junction table
const eventTagIdsMap = new Map<string, string[]>();
const allTagIds = new Set<string>();
for (const { event } of eventsData) {
const tagRelations = await this.db
.select({
tagId: eventToTags.tagId,
})
.from(eventToTags)
.where(eq(eventToTags.eventId, event.id));
const tagIds = tagRelations.map((r) => r.tagId);
eventTagIdsMap.set(event.id, tagIds);
tagIds.forEach((id) => allTagIds.add(id));
}
// 3. Fetch tag details from central Tags API
let tagsMap = new Map<string, Tag>();
if (accessToken && allTagIds.size > 0) {
tagsMap = await this.fetchTagsByIds(Array.from(allTagIds), accessToken);
}
// 4. Build tags for each event
const eventTagsMap = new Map<string, Tag[]>();
for (const { event } of eventsData) {
const tagIds = eventTagIdsMap.get(event.id) || [];
const tags = tagIds.map((id) => tagsMap.get(id)).filter((t): t is Tag => t !== undefined);
eventTagsMap.set(event.id, tags);
}
// 5. Filter events that have at least one tag
const eventsWithTagsList = eventsData.filter((e) => {
const tags = eventTagsMap.get(e.event.id) || [];
return tags.length > 0;
});
// 6. Build nodes
const nodes: NetworkNode[] = eventsWithTagsList.map(({ event }) => {
const tags = eventTagsMap.get(event.id) || [];
return {
id: event.id,
name: event.title,
photoUrl: null, // Events don't have photos
company: event.location || null, // Use location as subtitle
isFavorite: false,
tags,
connectionCount: 0, // Will be calculated below
};
});
// 7. Build links based on shared tags
const links: NetworkLink[] = [];
const connectionCounts = new Map<string, number>();
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const node1 = nodes[i];
const node2 = nodes[j];
// Find shared tags
const sharedTags = node1.tags
.filter((t1) => node2.tags.some((t2) => t2.id === t1.id))
.map((t) => t.name);
if (sharedTags.length > 0) {
// Calculate strength based on number of shared tags
const maxTags = Math.max(node1.tags.length, node2.tags.length);
const strength = Math.round((sharedTags.length / maxTags) * 100);
links.push({
source: node1.id,
target: node2.id,
type: 'tag',
strength,
sharedTags,
});
// Update connection counts
connectionCounts.set(node1.id, (connectionCounts.get(node1.id) || 0) + 1);
connectionCounts.set(node2.id, (connectionCounts.get(node2.id) || 0) + 1);
}
}
}
// 8. Update connection counts in nodes
for (const node of nodes) {
node.connectionCount = connectionCounts.get(node.id) || 0;
}
return { nodes, links };
}
}

View file

@ -17,6 +17,7 @@
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.1.7",
"@types/d3-force": "^3.0.0",
"@types/node": "^20.0.0",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
@ -30,6 +31,7 @@
"dependencies": {
"@calendar/shared": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-tags": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-feedback-service": "workspace:*",
@ -43,6 +45,7 @@
"@manacore/shared-theme-ui": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@neodrag/svelte": "^2.3.3",
"d3-force": "^3.0.0",
"date-fns": "^4.1.0",
"svelte-dnd-action": "^0.9.68",
"svelte-i18n": "^4.0.1"

View file

@ -5,6 +5,8 @@
@source "../../../packages/shared/src";
@source "../../../../../packages/shared-ui/src";
@source "../../../../../packages/shared-theme-ui/src";
@source "../../../../../packages/shared-theme-ui/src/components";
@source "../../../../../packages/shared-theme-ui/src/pages";
/* Calendar-specific CSS Variables */
@layer base {

View file

@ -5,21 +5,21 @@
*/
import type { Handle } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
// Get client-side URLs from environment (Docker runtime)
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
const PUBLIC_BACKEND_URL_CLIENT =
process.env.PUBLIC_BACKEND_URL_CLIENT || process.env.PUBLIC_BACKEND_URL || '';
export const handle: Handle = async ({ event, resolve }) => {
// Get client-side URLs from environment at RUNTIME (not build time)
// Use $env/dynamic/private to read actual runtime environment variables
const authUrlClient = env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || env.PUBLIC_MANA_CORE_AUTH_URL || '';
const backendUrlClient = env.PUBLIC_BACKEND_URL_CLIENT || env.PUBLIC_BACKEND_URL || '';
return resolve(event, {
transformPageChunk: ({ html }) => {
// Inject runtime environment variables into the HTML
// These will be available on window.__PUBLIC_*__ for client-side code
const envScript = `<script>
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${authUrlClient}";
window.__PUBLIC_BACKEND_URL__ = "${backendUrlClient}";
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${PUBLIC_MANA_CORE_AUTH_URL_CLIENT}";
window.__PUBLIC_BACKEND_URL__ = "${PUBLIC_BACKEND_URL_CLIENT}";
</script>`;
return html.replace('<head>', `<head>${envScript}`);
},

View file

@ -0,0 +1,132 @@
/**
* Event Tags API Client - Uses central Tags API from mana-core-auth
*
* This module wraps the central Tags API to provide backward-compatible
* "event tags" interface for the Calendar app. Tags are now unified
* across all Manacore apps (Todo, Calendar, Contacts).
*/
import { browser } from '$app/environment';
import {
createTagsClient,
type Tag,
type CreateTagInput,
type UpdateTagInput,
} from '@manacore/shared-tags';
import { authStore } from '$lib/stores/auth.svelte';
// Re-export Tag as EventTag for backward compatibility
export type EventTag = Tag;
export type CreateEventTagInput = CreateTagInput;
export type UpdateEventTagInput = UpdateTagInput;
// 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';
}
// Lazy-initialized client
let _tagsClient: ReturnType<typeof createTagsClient> | null = null;
function getTagsClient() {
if (!browser) return null;
if (!_tagsClient) {
_tagsClient = createTagsClient({
authUrl: getAuthUrl(),
getToken: async () => {
const token = await authStore.getAccessToken();
return token || '';
},
});
}
return _tagsClient;
}
export async function getEventTags() {
const client = getTagsClient();
if (!client) return { data: null, error: null };
try {
const tags = await client.getAll();
return { data: tags, error: null };
} catch (e) {
return {
data: null,
error: { message: e instanceof Error ? e.message : 'Failed to fetch tags' },
};
}
}
export async function getEventTag(id: string) {
const client = getTagsClient();
if (!client) return { data: null, error: null };
try {
const tag = await client.getById(id);
return { data: tag, error: null };
} catch (e) {
return {
data: null,
error: { message: e instanceof Error ? e.message : 'Failed to fetch tag' },
};
}
}
export async function createEventTag(data: CreateEventTagInput) {
const client = getTagsClient();
if (!client) return { data: null, error: { message: 'Tags client not available' } };
try {
const tag = await client.create(data);
return { data: tag, error: null };
} catch (e) {
return {
data: null,
error: { message: e instanceof Error ? e.message : 'Failed to create tag' },
};
}
}
export async function updateEventTag(id: string, data: UpdateEventTagInput) {
const client = getTagsClient();
if (!client) return { data: null, error: { message: 'Tags client not available' } };
try {
const tag = await client.update(id, data);
return { data: tag, error: null };
} catch (e) {
return {
data: null,
error: { message: e instanceof Error ? e.message : 'Failed to update tag' },
};
}
}
export async function deleteEventTag(id: string) {
const client = getTagsClient();
if (!client) return { data: null, error: { message: 'Tags client not available' } };
try {
await client.delete(id);
return { data: { success: true }, error: null };
} catch (e) {
return {
data: null,
error: { message: e instanceof Error ? e.message : 'Failed to delete tag' },
};
}
}
export async function createDefaultEventTags() {
const client = getTagsClient();
if (!client) return { data: null, error: null };
try {
const tags = await client.createDefaults();
return { data: tags, error: null };
} catch (e) {
return {
data: null,
error: { message: e instanceof Error ? e.message : 'Failed to create default tags' },
};
}
}

View file

@ -9,6 +9,7 @@ export interface QueryEventsParams {
startDate: string;
endDate: string;
calendarIds?: string[];
search?: string;
}
export async function getEvents(params: QueryEventsParams) {
@ -19,9 +20,25 @@ export async function getEvents(params: QueryEventsParams) {
if (params.calendarIds?.length) {
searchParams.set('calendarIds', params.calendarIds.join(','));
}
if (params.search) {
searchParams.set('search', params.search);
}
return fetchApi<CalendarEvent[]>(`/events?${searchParams.toString()}`);
}
export async function searchEvents(query: string, limit: number = 10) {
// Search events within the next year
const now = new Date();
const oneYearFromNow = new Date();
oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1);
return getEvents({
startDate: now.toISOString(),
endDate: oneYearFromNow.toISOString(),
search: query,
});
}
export async function getEvent(id: string) {
const result = await fetchApi<{ event: CalendarEvent }>(`/events/${id}`);
if (result.error || !result.data) {

View file

@ -0,0 +1,47 @@
/**
* Network Graph API Client
*/
import { fetchApi } from './client';
export interface NetworkTag {
id: string;
name: string;
color: string | null;
}
export interface NetworkNode {
id: string;
name: string;
photoUrl: string | null;
company: string | null;
isFavorite: boolean;
tags: NetworkTag[];
connectionCount: number;
}
export interface NetworkLink {
source: string;
target: string;
type: 'tag';
strength: number;
sharedTags: string[];
}
export interface NetworkGraphResponse {
nodes: NetworkNode[];
links: NetworkLink[];
}
export const networkApi = {
/**
* Get the network graph of events connected by shared tags
*/
async getGraph(): Promise<NetworkGraphResponse> {
const result = await fetchApi<NetworkGraphResponse>('/network/graph');
if (result.error) {
throw result.error;
}
return result.data || { nodes: [], links: [] };
},
};

View file

@ -4,10 +4,12 @@
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { toast } from '$lib/stores/toast';
import EventForm from './EventForm.svelte';
import { TagBadge } from '@manacore/shared-ui';
import type { CalendarEvent, UpdateEventInput } from '@calendar/shared';
import * as api from '$lib/api/events';
import { format, parseISO } from 'date-fns';
import { de } from 'date-fns/locale';
import { EventDetailSkeleton } from '$lib/components/skeletons';
interface Props {
eventId: string;
@ -147,10 +149,7 @@
<div class="modal-backdrop" onclick={handleBackdropClick}>
<div class="modal-container" role="dialog" aria-modal="true" aria-labelledby="modal-title">
{#if loading}
<div class="modal-loading">
<div class="spinner"></div>
<p>Laden...</p>
</div>
<EventDetailSkeleton />
{:else if event}
<div class="modal-header">
<h2 id="modal-title" class="modal-title">
@ -384,6 +383,30 @@
</div>
{/if}
<!-- Tags -->
{#if event.tags && event.tags.length > 0}
<div class="detail-row">
<span class="detail-icon">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
</span>
<div class="detail-content">
<span class="detail-label">Tags</span>
<div class="tags-display">
{#each event.tags as tag (tag.id)}
<TagBadge tag={{ name: tag.name, color: tag.color }} />
{/each}
</div>
</div>
</div>
{/if}
<!-- Teilnehmer -->
{#if event.metadata?.attendees && event.metadata.attendees.length > 0}
<div class="detail-row">
@ -476,31 +499,6 @@
}
}
.modal-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
gap: 1rem;
color: hsl(var(--color-muted-foreground));
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid hsl(var(--color-border));
border-top-color: hsl(var(--color-primary));
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.modal-header {
display: flex;
align-items: center;
@ -691,4 +689,12 @@
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
}
/* Tags display */
.tags-display {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.25rem;
}
</style>

View file

@ -1,11 +1,15 @@
<script lang="ts">
import { onMount } from 'svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
import { TagSelector, type Tag } from '@manacore/shared-ui';
import type {
CalendarEvent,
CreateEventInput,
UpdateEventInput,
LocationDetails,
EventTag,
} from '@calendar/shared';
import { format, addMinutes, parseISO } from 'date-fns';
@ -36,6 +40,32 @@
let locationCity = $state(event?.metadata?.locationDetails?.city || '');
let locationCountry = $state(event?.metadata?.locationDetails?.country || '');
// Tags state - store as Tag[] for compatibility with TagSelector
let selectedTags = $state<Tag[]>(
event?.tags?.map((t) => ({
id: t.id,
name: t.name,
color: t.color,
})) || []
);
// Convert EventTag to Tag type for shared-ui components
function eventTagToTag(tag: EventTag): Tag {
return {
id: tag.id,
name: tag.name,
color: tag.color,
};
}
// Handle tag selection changes
function handleTagsChange(newTags: Tag[]) {
selectedTags = newTags;
}
// Derived available tags for TagSelector
let availableTags = $derived(eventTagsStore.tags.map(eventTagToTag));
// Auto-expand location details if any field is filled
$effect(() => {
if (event?.metadata?.locationDetails) {
@ -90,6 +120,13 @@
let submitting = $state(false);
// Load tags on mount
onMount(() => {
if (eventTagsStore.tags.length === 0) {
eventTagsStore.fetchTags();
}
});
function handleSubmit(e: Event) {
e.preventDefault();
@ -142,6 +179,7 @@
endTime: endDateTime.toISOString(),
calendarId,
metadata: finalMetadata,
tagIds: selectedTags.length > 0 ? selectedTags.map((t) => t.id) : undefined,
};
submitting = true;
@ -337,6 +375,20 @@
></textarea>
</div>
<!-- Tags -->
{#if availableTags.length > 0 || eventTagsStore.loading}
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground">Tags</label>
<TagSelector
tags={availableTags}
{selectedTags}
onTagsChange={handleTagsChange}
placeholder="Tags auswählen..."
addTagLabel="Tag hinzufügen"
/>
</div>
{/if}
<div class="flex justify-end gap-3 pt-4 border-t border-border">
<button
type="button"

View file

@ -0,0 +1,43 @@
<script lang="ts">
/**
* AgendaSkeleton - Skeleton for agenda page event list
* Shows date groups with event items
*/
import { SkeletonBox } from '@manacore/shared-ui';
</script>
<div class="space-y-6" role="status" aria-label="Termine werden geladen...">
<!-- Date Groups -->
{#each Array(4) as _, groupIndex}
<div class="space-y-2" style="opacity: {Math.max(0.4, 1 - groupIndex * 0.15)}">
<!-- Date Header -->
<div class="px-2">
<SkeletonBox width="140px" height="14px" borderRadius="4px" />
</div>
<!-- Event Items -->
{#each Array(groupIndex === 0 ? 3 : 2) as _, eventIndex}
<div
class="bg-card rounded-xl p-4 flex gap-4"
style="opacity: {Math.max(0.5, 1 - eventIndex * 0.15)}"
>
<!-- Color Bar -->
<SkeletonBox width="4px" height="48px" borderRadius="2px" />
<!-- Event Content -->
<div class="flex-1 space-y-2">
<!-- Time -->
<SkeletonBox width="90px" height="12px" borderRadius="4px" />
<!-- Title -->
<SkeletonBox width="70%" height="16px" borderRadius="4px" />
<!-- Location (occasionally) -->
{#if eventIndex === 0}
<SkeletonBox width="50%" height="14px" borderRadius="4px" />
{/if}
</div>
</div>
{/each}
</div>
{/each}
</div>

View file

@ -0,0 +1,55 @@
<script lang="ts">
/**
* AppLoadingSkeleton - Full-page loading skeleton for app initialization
* Replaces spinner with calendar-themed skeleton layout
*/
import { SkeletonBox } from '@manacore/shared-ui';
</script>
<div
class="flex min-h-screen items-center justify-center bg-background"
role="status"
aria-label="Kalender wird geladen..."
>
<div class="w-full max-w-md px-6 space-y-8">
<!-- Logo/Header Area -->
<div class="flex flex-col items-center gap-4">
<SkeletonBox width="64px" height="64px" borderRadius="16px" />
<SkeletonBox width="180px" height="24px" borderRadius="8px" />
</div>
<!-- Calendar Preview Skeleton -->
<div class="bg-card rounded-xl p-4 space-y-4">
<!-- Mini Calendar Header -->
<div class="flex items-center justify-between">
<SkeletonBox width="120px" height="20px" />
<div class="flex gap-2">
<SkeletonBox width="32px" height="32px" borderRadius="8px" />
<SkeletonBox width="32px" height="32px" borderRadius="8px" />
</div>
</div>
<!-- Weekday Headers -->
<div class="grid grid-cols-7 gap-1">
{#each Array(7) as _}
<SkeletonBox width="100%" height="24px" borderRadius="4px" />
{/each}
</div>
<!-- Calendar Days Grid -->
<div class="grid grid-cols-7 gap-1">
{#each Array(35) as _, i}
<div style="opacity: {Math.max(0.3, 1 - (i % 7) * 0.08)}">
<SkeletonBox width="100%" height="32px" borderRadius="8px" />
</div>
{/each}
</div>
</div>
<!-- Loading Indicator -->
<div class="flex justify-center">
<SkeletonBox width="140px" height="16px" borderRadius="4px" />
</div>
</div>
</div>

View file

@ -0,0 +1,149 @@
<script lang="ts">
/**
* CalendarViewSkeleton - Skeleton for the main calendar week view
* Shows a week grid with time slots and placeholder events
*/
import { SkeletonBox } from '@manacore/shared-ui';
// 7 days of the week
const days = Array(7);
// Show hours from 8 to 18 (working hours)
const hours = Array(10);
</script>
<div class="calendar-skeleton" role="status" aria-label="Kalender wird geladen...">
<!-- Header with day names -->
<div class="calendar-header">
<!-- Time column spacer -->
<div class="time-spacer"></div>
<!-- Day headers -->
{#each days as _, dayIndex}
<div class="day-header" style="opacity: {Math.max(0.5, 1 - dayIndex * 0.05)}">
<SkeletonBox width="32px" height="14px" borderRadius="4px" />
<SkeletonBox width="28px" height="28px" borderRadius="50%" />
</div>
{/each}
</div>
<!-- Calendar grid -->
<div class="calendar-grid">
<!-- Time column -->
<div class="time-column">
{#each hours as _, hourIndex}
<div class="time-slot" style="opacity: {Math.max(0.4, 1 - hourIndex * 0.05)}">
<SkeletonBox width="36px" height="12px" borderRadius="4px" />
</div>
{/each}
</div>
<!-- Day columns -->
{#each days as _, dayIndex}
<div class="day-column" style="opacity: {Math.max(0.6, 1 - dayIndex * 0.04)}">
{#each hours as _, hourIndex}
<div class="hour-cell"></div>
{/each}
<!-- Placeholder events -->
{#if dayIndex === 1}
<div class="event-placeholder" style="top: 10%; height: 15%;">
<SkeletonBox width="100%" height="100%" borderRadius="6px" />
</div>
{/if}
{#if dayIndex === 2}
<div class="event-placeholder" style="top: 30%; height: 10%;">
<SkeletonBox width="100%" height="100%" borderRadius="6px" />
</div>
{/if}
{#if dayIndex === 3}
<div class="event-placeholder" style="top: 50%; height: 20%;">
<SkeletonBox width="100%" height="100%" borderRadius="6px" />
</div>
{/if}
{#if dayIndex === 4}
<div class="event-placeholder" style="top: 20%; height: 8%;">
<SkeletonBox width="100%" height="100%" borderRadius="6px" />
</div>
<div class="event-placeholder" style="top: 60%; height: 12%;">
<SkeletonBox width="100%" height="100%" borderRadius="6px" />
</div>
{/if}
{#if dayIndex === 5}
<div class="event-placeholder" style="top: 40%; height: 15%;">
<SkeletonBox width="100%" height="100%" borderRadius="6px" />
</div>
{/if}
</div>
{/each}
</div>
</div>
<style>
.calendar-skeleton {
display: flex;
flex-direction: column;
height: 100%;
min-height: 600px;
overflow: hidden;
}
.calendar-header {
display: flex;
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
padding: 0.75rem 0;
}
.time-spacer {
width: 60px;
flex-shrink: 0;
}
.day-header {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 0.25rem;
}
.calendar-grid {
display: flex;
flex: 1;
overflow: hidden;
}
.time-column {
width: 60px;
flex-shrink: 0;
display: flex;
flex-direction: column;
}
.time-slot {
height: 60px;
display: flex;
align-items: flex-start;
justify-content: flex-end;
padding-right: 0.5rem;
padding-top: 0.25rem;
}
.day-column {
flex: 1;
position: relative;
border-left: 1px solid hsl(var(--color-border) / 0.3);
}
.hour-cell {
height: 60px;
border-bottom: 1px solid hsl(var(--color-border) / 0.2);
}
.event-placeholder {
position: absolute;
left: 4px;
right: 4px;
}
</style>

View file

@ -0,0 +1,75 @@
<script lang="ts">
/**
* EventDetailSkeleton - Skeleton for event detail modal
* Matches the layout of EventDetailModal
*/
import { SkeletonBox } from '@manacore/shared-ui';
</script>
<div class="p-6 space-y-5" role="status" aria-label="Termin wird geladen...">
<!-- Header with Title and Actions -->
<div class="flex items-start justify-between gap-4">
<SkeletonBox width="60%" height="24px" borderRadius="6px" />
<div class="flex gap-2 flex-shrink-0">
<SkeletonBox width="32px" height="32px" borderRadius="8px" />
<SkeletonBox width="32px" height="32px" borderRadius="8px" />
</div>
</div>
<!-- Calendar Row -->
<div class="flex items-start gap-3">
<SkeletonBox width="20px" height="20px" borderRadius="50%" />
<div class="flex flex-col gap-1.5 flex-1">
<SkeletonBox width="60px" height="12px" borderRadius="4px" />
<SkeletonBox width="120px" height="16px" borderRadius="4px" />
</div>
</div>
<!-- Time Row -->
<div class="flex items-start gap-3">
<SkeletonBox width="20px" height="20px" borderRadius="4px" />
<div class="flex flex-col gap-1.5 flex-1">
<SkeletonBox width="40px" height="12px" borderRadius="4px" />
<SkeletonBox width="200px" height="16px" borderRadius="4px" />
</div>
</div>
<!-- Location Row -->
<div class="flex items-start gap-3">
<SkeletonBox width="20px" height="20px" borderRadius="4px" />
<div class="flex flex-col gap-1.5 flex-1">
<SkeletonBox width="40px" height="12px" borderRadius="4px" />
<SkeletonBox width="160px" height="16px" borderRadius="4px" />
</div>
</div>
<!-- Description Row -->
<div class="flex items-start gap-3">
<SkeletonBox width="20px" height="20px" borderRadius="4px" />
<div class="flex flex-col gap-1.5 flex-1">
<SkeletonBox width="80px" height="12px" borderRadius="4px" />
<div class="space-y-2">
<SkeletonBox width="100%" height="14px" borderRadius="4px" />
<SkeletonBox width="90%" height="14px" borderRadius="4px" />
<SkeletonBox width="70%" height="14px" borderRadius="4px" />
</div>
</div>
</div>
<!-- Attendees Row -->
<div class="flex items-start gap-3">
<SkeletonBox width="20px" height="20px" borderRadius="4px" />
<div class="flex flex-col gap-2 flex-1">
<SkeletonBox width="100px" height="12px" borderRadius="4px" />
<div class="flex flex-col gap-1.5">
{#each Array(3) as _, i}
<div class="flex items-center gap-2" style="opacity: {Math.max(0.5, 1 - i * 0.2)}">
<SkeletonBox width="140px" height="14px" borderRadius="4px" />
<SkeletonBox width="24px" height="18px" borderRadius="4px" />
</div>
{/each}
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,16 @@
<script lang="ts">
/**
* RedirectSkeleton - Simple centered skeleton for redirect pages
*/
import { SkeletonBox } from '@manacore/shared-ui';
</script>
<div
class="flex flex-col items-center justify-center min-h-[50vh] gap-4"
role="status"
aria-label="Weiterleitung..."
>
<SkeletonBox width="48px" height="48px" borderRadius="50%" />
<SkeletonBox width="100px" height="16px" borderRadius="4px" />
</div>

View file

@ -0,0 +1,21 @@
/**
* Calendar App Skeleton Components
*
* App-specific skeleton loaders that match the exact layout of calendar components.
* Built on top of @manacore/shared-ui skeleton primitives.
*/
// App Loading Skeleton
export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte';
// Agenda Skeleton
export { default as AgendaSkeleton } from './AgendaSkeleton.svelte';
// Event Detail Skeleton
export { default as EventDetailSkeleton } from './EventDetailSkeleton.svelte';
// Redirect Skeleton
export { default as RedirectSkeleton } from './RedirectSkeleton.svelte';
// Calendar View Skeleton
export { default as CalendarViewSkeleton } from './CalendarViewSkeleton.svelte';

View file

@ -0,0 +1,114 @@
/**
* Event Tags Store - Manages event tags using Svelte 5 runes
*
* Uses the central Tags API from mana-core-auth. Tags are now unified
* across all Manacore apps (Todo, Calendar, Contacts).
*/
import type { EventTag } from '$lib/api/event-tags';
import * as api from '$lib/api/event-tags';
// State
let tags = $state<EventTag[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
// Helper to safely get tags array (Svelte 5 runes safety)
function getTagsArray(): EventTag[] {
const arr = tags ?? [];
return Array.isArray(arr) ? arr : [];
}
export const eventTagsStore = {
// Getters
get tags() {
return tags;
},
get loading() {
return loading;
},
get error() {
return error;
},
/**
* Fetch all tags
*/
async fetchTags() {
loading = true;
error = null;
const result = await api.getEventTags();
if (result.error) {
error = result.error.message;
tags = [];
} else {
tags = result.data || [];
}
loading = false;
return result;
},
/**
* Create a new tag
*/
async createTag(data: api.CreateEventTagInput) {
const result = await api.createEventTag(data);
if (result.data) {
tags = [...tags, result.data];
}
return result;
},
/**
* Update a tag
*/
async updateTag(id: string, data: api.UpdateEventTagInput) {
const result = await api.updateEventTag(id, data);
if (result.data) {
tags = getTagsArray().map((t) => (t.id === id ? result.data! : t));
}
return result;
},
/**
* Delete a tag
*/
async deleteTag(id: string) {
const result = await api.deleteEventTag(id);
if (!result.error) {
tags = getTagsArray().filter((t) => t.id !== id);
}
return result;
},
/**
* Get tag by ID
*/
getById(id: string) {
return getTagsArray().find((t) => t.id === id);
},
/**
* Get tags by IDs
*/
getByIds(ids: string[]) {
return getTagsArray().filter((t) => ids.includes(t.id));
},
/**
* Clear store
*/
clear() {
tags = [];
error = null;
},
};

View file

@ -0,0 +1,370 @@
/**
* Network Store - Manages network graph state with D3-force simulation
*/
import { browser } from '$app/environment';
import { networkApi } from '$lib/api/network';
import type { NetworkNode, NetworkLink } from '$lib/api/network';
import {
forceSimulation,
forceLink,
forceManyBody,
forceCenter,
forceCollide,
type Simulation,
} from 'd3-force';
import type {
SimulationNode as SharedSimulationNode,
SimulationLink as SharedSimulationLink,
} from '@manacore/shared-ui';
// Re-export types from shared-ui for convenience
export type SimulationNode = SharedSimulationNode;
export type SimulationLink = SharedSimulationLink;
// State
let nodes = $state<SimulationNode[]>([]);
let links = $state<SimulationLink[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let selectedNodeId = $state<string | null>(null);
let simulation: Simulation<SimulationNode, SimulationLink> | null = null;
let searchQuery = $state('');
let filterTagId = $state<string | null>(null);
let filterLocation = $state<string | null>(null);
let minStrength = $state(0);
let tickCounter = $state(0);
let simulationInitialized = false;
let dataLoaded = false;
let lastDimensions = { width: 0, height: 0 };
// Derived state for filtering
const filteredNodes = $derived.by(() => {
let result = nodes;
// Search filter
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter(
(node) =>
node.name.toLowerCase().includes(query) ||
node.subtitle?.toLowerCase().includes(query) ||
node.tags.some((t) => t.name.toLowerCase().includes(query))
);
}
// Tag filter
if (filterTagId) {
result = result.filter((node) => node.tags.some((t) => t.id === filterTagId));
}
// Location filter (uses subtitle field)
if (filterLocation) {
result = result.filter((node) => node.subtitle === filterLocation);
}
return result;
});
const filteredLinks = $derived.by(() => {
const filteredNodeIds = new Set(filteredNodes.map((n) => n.id));
return links.filter((link) => {
const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
const targetId = typeof link.target === 'string' ? link.target : link.target.id;
// Check if both nodes are visible
if (!filteredNodeIds.has(sourceId) || !filteredNodeIds.has(targetId)) {
return false;
}
// Filter by minimum strength
if (minStrength > 0 && link.strength < minStrength) {
return false;
}
return true;
});
});
// Get unique locations for filter dropdown
const uniqueLocations = $derived.by(() => {
const locations = new Set<string>();
for (const node of nodes) {
if (node.subtitle) {
locations.add(node.subtitle);
}
}
return Array.from(locations).sort();
});
// Get unique tags for filter dropdown
const uniqueTags = $derived.by(() => {
const tagsMap = new Map<string, { id: string; name: string; color: string | null }>();
for (const node of nodes) {
for (const tag of node.tags) {
if (!tagsMap.has(tag.id)) {
tagsMap.set(tag.id, tag);
}
}
}
return Array.from(tagsMap.values()).sort((a, b) => a.name.localeCompare(b.name));
});
export const networkStore = {
// Getters
get nodes() {
void tickCounter;
return filteredNodes;
},
get allNodes() {
void tickCounter;
return nodes;
},
get links() {
void tickCounter;
return filteredLinks;
},
get allLinks() {
void tickCounter;
return links;
},
get tick() {
return tickCounter;
},
get loading() {
return loading;
},
get error() {
return error;
},
get selectedNodeId() {
return selectedNodeId;
},
get selectedNode() {
return nodes.find((n) => n.id === selectedNodeId) || null;
},
get searchQuery() {
return searchQuery;
},
get filterTagId() {
return filterTagId;
},
get filterLocation() {
return filterLocation;
},
get minStrength() {
return minStrength;
},
get uniqueLocations() {
return uniqueLocations;
},
get uniqueTags() {
return uniqueTags;
},
/**
* Load network graph data from API
*/
async loadGraph(force = false) {
if (dataLoaded && !force) {
return;
}
if (loading) {
return;
}
loading = true;
error = null;
if (simulation) {
simulation.stop();
simulation = null;
}
simulationInitialized = false;
try {
const response = await networkApi.getGraph();
// Convert to simulation nodes with subtitle for location
nodes = response.nodes.map((node) => ({
...node,
subtitle: node.company, // Map company/location to subtitle
x: undefined,
y: undefined,
vx: undefined,
vy: undefined,
fx: null,
fy: null,
}));
// Convert to simulation links
links = response.links.map((link) => ({
source: link.source,
target: link.target,
type: link.type,
strength: link.strength,
sharedTags: link.sharedTags,
}));
dataLoaded = true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load network graph';
console.error('Failed to load network graph:', e);
} finally {
loading = false;
}
},
/**
* Initialize D3 force simulation
*/
initSimulation(width: number, height: number) {
if (!browser) return;
if (nodes.length === 0) return;
if (width <= 0 || height <= 0) return;
if (simulationInitialized && simulation) {
if (
Math.abs(lastDimensions.width - width) > 50 ||
Math.abs(lastDimensions.height - height) > 50
) {
lastDimensions = { width, height };
this.updateSimulationCenter(width, height);
}
return;
}
if (simulation) {
simulation.stop();
}
lastDimensions = { width, height };
const centerX = width / 2;
const centerY = height / 2;
const radius = Math.min(width, height) / 3;
nodes.forEach((node, i) => {
if (node.x === undefined || node.y === undefined) {
const angle = (i / nodes.length) * 2 * Math.PI;
const r = radius * (0.5 + Math.random() * 0.5);
node.x = centerX + r * Math.cos(angle);
node.y = centerY + r * Math.sin(angle);
}
});
simulation = forceSimulation<SimulationNode, SimulationLink>(nodes)
.force(
'link',
forceLink<SimulationNode, SimulationLink>(links)
.id((d) => d.id)
.distance(100)
.strength(0.5)
)
.force('charge', forceManyBody().strength(-300))
.force('center', forceCenter(centerX, centerY))
.force('collision', forceCollide().radius(50))
.on('tick', () => {
tickCounter++;
});
simulationInitialized = true;
simulation.alpha(1).restart();
},
updateSimulationCenter(width: number, height: number) {
if (simulation) {
simulation.force('center', forceCenter(width / 2, height / 2));
simulation.alpha(0.3).restart();
}
},
stopSimulation() {
if (simulation) {
simulation.stop();
simulation = null;
}
simulationInitialized = false;
},
reset() {
this.stopSimulation();
nodes = [];
links = [];
dataLoaded = false;
lastDimensions = { width: 0, height: 0 };
tickCounter = 0;
},
reheatSimulation() {
if (simulation) {
simulation.alpha(0.3).restart();
}
},
fixNode(nodeId: string, x: number, y: number) {
const node = nodes.find((n) => n.id === nodeId);
if (node) {
node.fx = x;
node.fy = y;
}
},
releaseNode(nodeId: string) {
const node = nodes.find((n) => n.id === nodeId);
if (node) {
node.fx = null;
node.fy = null;
}
},
selectNode(nodeId: string | null) {
selectedNodeId = nodeId;
},
setSearch(query: string) {
searchQuery = query;
},
setFilterTag(tagId: string | null) {
filterTagId = tagId;
},
setFilterLocation(location: string | null) {
filterLocation = location;
},
setMinStrength(strength: number) {
minStrength = strength;
},
clearFilters() {
searchQuery = '';
filterTagId = null;
filterLocation = null;
minStrength = 0;
},
getConnectedNodes(nodeId: string): SimulationNode[] {
const connectedIds = new Set<string>();
for (const link of links) {
const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
const targetId = typeof link.target === 'string' ? link.target : link.target.id;
if (sourceId === nodeId) {
connectedIds.add(targetId);
} else if (targetId === nodeId) {
connectedIds.add(sourceId);
}
}
return nodes.filter((n) => connectedIds.has(n.id));
},
getNodeLinks(nodeId: string): SimulationLink[] {
return links.filter((link) => {
const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
const targetId = typeof link.target === 'string' ? link.target : link.target.id;
return sourceId === nodeId || targetId === nodeId;
});
},
};

View file

@ -3,15 +3,25 @@
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale } from 'svelte-i18n';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
import { PillNavigation, CommandBar } from '@manacore/shared-ui';
import type {
PillNavItem,
PillDropdownItem,
CommandBarItem,
QuickAction,
} from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { viewStore } from '$lib/stores/view.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
import {
THEME_DEFINITIONS,
DEFAULT_THEME_VARIANTS,
EXTENDED_THEME_VARIANTS,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import {
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
@ -19,21 +29,68 @@
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import { searchEvents } from '$lib/api/events';
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
// App switcher items
const appItems = getPillAppItems('calendar');
let { children } = $props();
// CommandBar state
let commandBarOpen = $state(false);
// CommandBar quick actions (no search for calendar yet)
const commandBarQuickActions: QuickAction[] = [
{ id: 'new', label: 'Neuen Termin erstellen', icon: 'plus', href: '/event/new', shortcut: 'N' },
{
id: 'today',
label: 'Zu Heute springen',
icon: 'calendar',
onclick: () => viewStore.goToToday(),
},
{ id: 'agenda', label: 'Agenda anzeigen', icon: 'list', href: '/agenda' },
{ id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' },
];
// CommandBar search - search events
async function handleCommandBarSearch(query: string): Promise<CommandBarItem[]> {
if (!query.trim()) return [];
const result = await searchEvents(query);
if (result.error || !result.data) return [];
return result.data.slice(0, 10).map((event) => ({
id: event.id,
title: event.title,
subtitle: format(new Date(event.startTime), 'dd. MMM yyyy, HH:mm', { locale: de }),
}));
}
function handleCommandBarSelect(item: CommandBarItem) {
goto(`/event/${item.id}`);
}
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
// Use theme store's isDark directly
let isDark = $derived(theme.isDark);
// Get pinned themes from user settings (extended themes only)
let pinnedThemes = $derived<ThemeVariant[]>(
(userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant =>
EXTENDED_THEME_VARIANTS.includes(t as ThemeVariant)
)
);
// Visible themes in PillNav: default + pinned extended
let visibleThemes = $derived<ThemeVariant[]>([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]);
// Theme variant dropdown items
let themeVariantItems = $derived<PillDropdownItem[]>([
...theme.variants.map((variant) => ({
...visibleThemes.map((variant) => ({
id: variant,
label: THEME_DEFINITIONS[variant].label,
icon: THEME_DEFINITIONS[variant].icon,
@ -69,6 +126,8 @@
const navItems: PillNavItem[] = [
{ href: '/', label: 'Kalender', icon: 'calendar' },
{ href: '/agenda', label: 'Agenda', icon: 'list' },
{ href: '/tags', label: 'Tags', icon: 'tag' },
{ href: '/network', label: 'Netzwerk', icon: 'share-2' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];
@ -79,6 +138,13 @@
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
// Cmd/Ctrl+K to open command bar (works even in inputs)
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
event.preventDefault();
commandBarOpen = true;
return;
}
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
@ -209,6 +275,18 @@
{@render children()}
</div>
</main>
<!-- Global Command Bar (Cmd/K) -->
<CommandBar
bind:open={commandBarOpen}
onClose={() => (commandBarOpen = false)}
onSearch={handleCommandBarSearch}
onSelect={handleCommandBarSelect}
quickActions={commandBarQuickActions}
placeholder="Termin suchen..."
emptyText="Keine Termine gefunden"
searchingText="Suche..."
/>
</div>
<style>

View file

@ -18,6 +18,7 @@
import CalendarSidebar from '$lib/components/calendar/CalendarSidebar.svelte';
import QuickEventOverlay from '$lib/components/event/QuickEventOverlay.svelte';
import EventDetailModal from '$lib/components/event/EventDetailModal.svelte';
import { CalendarViewSkeleton } from '$lib/components/skeletons';
import { format, addMinutes } from 'date-fns';
import { de } from 'date-fns/locale';
@ -166,7 +167,9 @@
<CalendarHeader />
<div class="calendar-content">
{#if viewStore.viewType === 'day'}
{#if !initialized}
<CalendarViewSkeleton />
{:else if viewStore.viewType === 'day'}
<DayView onQuickCreate={handleQuickCreate} />
{:else if viewStore.viewType === '5day'}
<MultiDayView dayCount={5} onQuickCreate={handleQuickCreate} />

View file

@ -6,6 +6,7 @@
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { format, parseISO, isToday, isTomorrow, addDays, startOfDay, endOfDay } from 'date-fns';
import { de } from 'date-fns/locale';
import { AgendaSkeleton } from '$lib/components/skeletons';
let loading = $state(true);
@ -81,7 +82,7 @@
</header>
{#if loading}
<div class="loading">Laden...</div>
<AgendaSkeleton />
{:else if groupedEvents.length === 0}
<div class="empty-state card">
<p>Keine Termine in den nächsten 30 Tagen</p>
@ -153,12 +154,6 @@
margin: 0;
}
.loading {
text-align: center;
padding: 2rem;
color: hsl(var(--color-muted-foreground));
}
.empty-state {
text-align: center;
padding: 3rem;

View file

@ -2,6 +2,7 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { RedirectSkeleton } from '$lib/components/skeletons';
// Redirect to main calendar page with event modal
onMount(() => {
@ -10,34 +11,4 @@
});
</script>
<div class="redirect-loading">
<div class="spinner"></div>
<p>Laden...</p>
</div>
<style>
.redirect-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 50vh;
gap: 1rem;
color: hsl(var(--color-muted-foreground));
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid hsl(var(--color-border));
border-top-color: hsl(var(--color-primary));
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
<RedirectSkeleton />

View file

@ -7,7 +7,7 @@
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { toast } from '$lib/stores/toast';
import EventForm from '$lib/components/event/EventForm.svelte';
import type { CreateEventInput } from '@calendar/shared';
import type { CreateEventInput, UpdateEventInput } from '@calendar/shared';
import { addHours, parseISO } from 'date-fns';
let initialStart = $state<Date | null>(null);
@ -25,8 +25,9 @@
}
});
async function handleSave(data: CreateEventInput) {
const result = await eventsStore.createEvent(data);
async function handleSave(data: CreateEventInput | UpdateEventInput) {
// In create mode, data is always CreateEventInput
const result = await eventsStore.createEvent(data as CreateEventInput);
if (result.error) {
toast.error(`Fehler beim Erstellen: ${result.error.message}`);

View file

@ -0,0 +1,415 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { networkStore, type SimulationNode } from '$lib/stores/network.svelte';
import { NetworkGraph, NetworkControls } from '@manacore/shared-ui';
import '$lib/i18n';
let graphComponent: NetworkGraph;
let controlsComponent: NetworkControls;
let graphContainer: HTMLDivElement;
function handleNodeClick(node: SimulationNode) {
// Select node (highlight connections)
networkStore.selectNode(node.id);
}
function handleNodeDoubleClick(node: SimulationNode) {
// Navigate to event detail page
goto(`/event/${node.id}`);
}
function handleBackgroundClick() {
networkStore.selectNode(null);
}
function handleDragStart(node: SimulationNode) {
networkStore.fixNode(node.id, node.x ?? 0, node.y ?? 0);
networkStore.reheatSimulation();
}
function handleDrag(node: SimulationNode, x: number, y: number) {
networkStore.fixNode(node.id, x, y);
}
function handleDragEnd(node: SimulationNode) {
networkStore.releaseNode(node.id);
}
function handleZoomIn() {
graphComponent?.zoomIn();
}
function handleZoomOut() {
graphComponent?.zoomOut();
}
function handleResetZoom() {
graphComponent?.resetZoom();
}
function handleFocusSelected() {
graphComponent?.focusOnSelectedNode();
}
function handleFocusSearch() {
controlsComponent?.focusSearch();
}
function handleSearch(query: string) {
networkStore.setSearch(query);
}
function handleTagFilter(tagId: string | null) {
networkStore.setFilterTag(tagId);
}
function handleSubtitleFilter(location: string | null) {
networkStore.setFilterLocation(location);
}
function handleStrengthFilter(strength: number) {
networkStore.setMinStrength(strength);
}
function handleClearFilters() {
networkStore.clearFilters();
}
// Initialize simulation when data is loaded and container is ready
$effect(() => {
if (!networkStore.loading && networkStore.allNodes.length > 0 && graphContainer) {
const rect = graphContainer.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
networkStore.initSimulation(rect.width, rect.height);
}
}
});
onMount(() => {
networkStore.loadGraph();
});
onDestroy(() => {
networkStore.stopSimulation();
});
</script>
<svelte:head>
<title>Netzwerk - Kalender</title>
</svelte:head>
<div class="network-page">
<!-- Controls (floating) -->
<div class="controls-wrapper">
<NetworkControls
bind:this={controlsComponent}
searchQuery={networkStore.searchQuery}
tags={networkStore.uniqueTags}
selectedTagId={networkStore.filterTagId}
subtitles={networkStore.uniqueLocations}
selectedSubtitle={networkStore.filterLocation}
subtitleLabel="Ort"
nodeCount={networkStore.nodes.length}
linkCount={networkStore.links.length}
nodeLabel="Events"
linkLabel="Verbindungen"
searchPlaceholder="Event suchen..."
minStrength={networkStore.minStrength}
onSearch={handleSearch}
onTagFilter={handleTagFilter}
onSubtitleFilter={handleSubtitleFilter}
onStrengthFilter={handleStrengthFilter}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onResetZoom={handleResetZoom}
onFocusSelected={handleFocusSelected}
onClearFilters={handleClearFilters}
/>
</div>
<!-- Error Banner -->
{#if networkStore.error}
<div class="error-banner" role="alert">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>{networkStore.error}</span>
</div>
{/if}
<!-- Main Content -->
<div class="graph-container" bind:this={graphContainer}>
{#if networkStore.loading}
<div class="loading-container">
<div class="loading-spinner"></div>
<p>Lade Netzwerk-Graph...</p>
</div>
{:else}
<NetworkGraph
bind:this={graphComponent}
nodes={networkStore.nodes}
links={networkStore.links}
selectedNodeId={networkStore.selectedNodeId}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
onBackgroundClick={handleBackgroundClick}
onDragStart={handleDragStart}
onDrag={handleDrag}
onDragEnd={handleDragEnd}
onFocusSearch={handleFocusSearch}
/>
{/if}
</div>
<!-- Selected Event Info Panel -->
{#if networkStore.selectedNode}
<div class="info-panel">
<div class="info-header">
<h3>{networkStore.selectedNode.name}</h3>
<button class="close-btn" onclick={() => networkStore.selectNode(null)}>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{#if networkStore.selectedNode.subtitle}
<p class="info-subtitle">{networkStore.selectedNode.subtitle}</p>
{/if}
{#if networkStore.selectedNode.tags.length > 0}
<div class="info-tags">
{#each networkStore.selectedNode.tags as tag}
<span
class="tag"
style="background-color: {tag.color || 'hsl(var(--muted))'}; color: white;"
>
{tag.name}
</span>
{/each}
</div>
{/if}
<div class="info-stats">
<span>{networkStore.selectedNode.connectionCount} Verbindungen</span>
</div>
<button class="view-btn" onclick={() => goto(`/event/${networkStore.selectedNode?.id}`)}>
Event anzeigen
</button>
</div>
{/if}
</div>
<style>
.network-page {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
}
/* Floating Controls */
.controls-wrapper {
position: absolute;
top: 5rem; /* Below the nav */
left: 1rem;
z-index: 10;
max-width: calc(100% - 2rem);
}
/* Error Banner */
.error-banner {
position: absolute;
top: 5rem;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: hsl(var(--destructive) / 0.1);
border: 1px solid hsl(var(--destructive) / 0.3);
border-radius: 0.875rem;
color: hsl(var(--destructive));
backdrop-filter: blur(8px);
}
/* Graph Container - Full screen */
.graph-container {
flex: 1;
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
}
/* Loading */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 1rem;
color: hsl(var(--muted-foreground));
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid hsl(var(--muted));
border-top-color: hsl(var(--primary));
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Info Panel */
.info-panel {
position: fixed;
top: 5rem;
right: 1rem;
bottom: 1rem;
width: 320px;
max-width: calc(100vw - 2rem);
z-index: 50;
background: hsl(var(--card) / 0.9);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--border) / 0.5);
border-radius: 1rem;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
animation: slideInRight 0.2s ease-out;
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.info-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.info-header h3 {
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--foreground));
margin: 0;
}
.close-btn {
padding: 0.25rem;
border-radius: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
color: hsl(var(--muted-foreground));
transition: all 0.15s;
}
.close-btn:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.info-subtitle {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0;
}
.info-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.info-stats {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
}
.view-btn {
margin-top: auto;
padding: 0.75rem 1rem;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
border-radius: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.view-btn:hover {
opacity: 0.9;
}
/* Responsive */
@media (max-width: 1024px) {
.info-panel {
width: 100%;
max-width: 100%;
top: auto;
right: 0;
bottom: 0;
height: auto;
max-height: 50vh;
border-radius: 1rem 1rem 0 0;
animation: slideInUp 0.2s ease-out;
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}
@media (max-width: 768px) {
.controls-wrapper {
top: 6rem;
width: calc(100% - 1rem);
max-width: none;
}
}
</style>

View file

@ -0,0 +1,309 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { TagList, TagEditModal, type Tag } from '@manacore/shared-ui';
import { MagnifyingGlass, Plus, CaretLeft } from '@manacore/shared-icons';
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
import type { EventTag } from '@calendar/shared';
let searchQuery = $state('');
let showModal = $state(false);
let editingTag = $state<EventTag | null>(null);
const filteredTags = $derived.by(() => {
if (!searchQuery.trim()) return eventTagsStore.tags;
const query = searchQuery.toLowerCase();
return eventTagsStore.tags.filter((t) => t.name.toLowerCase().includes(query));
});
// Convert EventTag to Tag type for shared-ui components
function eventTagToTag(tag: EventTag): Tag {
return {
id: tag.id,
name: tag.name,
color: tag.color,
};
}
function openCreateModal() {
editingTag = null;
showModal = true;
}
function openEditModal(tag: Tag) {
const eventTag = eventTagsStore.tags.find((t) => t.id === tag.id);
if (eventTag) {
editingTag = eventTag;
showModal = true;
}
}
function closeModal() {
showModal = false;
editingTag = null;
}
async function handleSave(name: string, color: string) {
try {
if (editingTag) {
await eventTagsStore.updateTag(editingTag.id, { name, color });
} else {
await eventTagsStore.createTag({ name, color });
}
closeModal();
} catch (e) {
console.error('Failed to save tag:', e);
}
}
async function handleDelete() {
if (!editingTag) return;
try {
await eventTagsStore.deleteTag(editingTag.id);
closeModal();
} catch (e) {
console.error('Failed to delete tag:', e);
}
}
async function handleDeleteFromList(tag: Tag) {
if (!confirm(`Tag "${tag.name}" wirklich löschen?`)) return;
try {
await eventTagsStore.deleteTag(tag.id);
} catch (e) {
console.error('Failed to delete tag:', e);
}
}
onMount(() => {
if (eventTagsStore.tags.length === 0) {
eventTagsStore.fetchTags();
}
});
</script>
<svelte:head>
<title>Tags - Kalender</title>
</svelte:head>
<div class="page-container">
<!-- Header -->
<header class="header">
<a href="/" class="back-button" aria-label="Zurück">
<CaretLeft size={20} weight="bold" />
</a>
<h1 class="title">Tags</h1>
<button onclick={openCreateModal} class="add-button" aria-label="Neues Tag">
<Plus size={20} weight="bold" />
</button>
</header>
<!-- Search -->
<div class="search-wrapper">
<MagnifyingGlass size={20} class="search-icon" />
<input
type="text"
placeholder="Tags durchsuchen..."
bind:value={searchQuery}
class="search-input"
/>
</div>
{#if eventTagsStore.error}
<div class="error-banner" role="alert">
<span>{eventTagsStore.error}</span>
</div>
{/if}
<!-- Tag List using shared component -->
<TagList
tags={filteredTags.map(eventTagToTag)}
loading={eventTagsStore.loading}
onEdit={openEditModal}
onDelete={handleDeleteFromList}
emptyMessage={searchQuery ? 'Keine Tags gefunden' : 'Keine Tags vorhanden'}
emptyDescription={searchQuery
? `Kein Tag für "${searchQuery}" gefunden`
: 'Erstelle dein erstes Tag'}
/>
{#if !eventTagsStore.loading && eventTagsStore.tags.length > 0}
<p class="tags-count">
{eventTagsStore.tags.length}
{eventTagsStore.tags.length === 1 ? 'Tag' : 'Tags'}
</p>
{/if}
{#if !eventTagsStore.loading && eventTagsStore.tags.length === 0 && !searchQuery}
<div class="empty-cta">
<button onclick={openCreateModal} class="btn btn-primary">
<Plus size={16} weight="bold" />
Neues Tag
</button>
</div>
{/if}
</div>
<!-- Create/Edit Modal using shared component -->
<TagEditModal
tag={editingTag ? eventTagToTag(editingTag) : null}
isOpen={showModal}
onClose={closeModal}
onSave={handleSave}
onDelete={editingTag ? handleDelete : undefined}
title={editingTag ? 'Tag bearbeiten' : 'Neues Tag'}
saveLabel={editingTag ? 'Speichern' : 'Erstellen'}
deleteLabel="Löschen"
cancelLabel="Abbrechen"
namePlaceholder="Tag Name"
colorLabel="Farbe"
previewLabel="Vorschau"
deleteConfirmMessage={`Tag "${editingTag?.name || ''}" wirklich löschen?`}
/>
<style>
.page-container {
max-width: 640px;
margin: 0 auto;
padding: 0 1rem 2rem;
}
/* Header */
.header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 0;
margin-bottom: 0.5rem;
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: hsl(var(--muted));
color: hsl(var(--foreground));
transition: all 0.2s ease;
}
.back-button:hover {
background: hsl(var(--muted-foreground) / 0.2);
transform: translateX(-2px);
}
.title {
flex: 1;
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--foreground));
}
.add-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.add-button:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
}
/* Search */
.search-wrapper {
position: relative;
margin-bottom: 1.5rem;
}
.search-wrapper :global(.search-icon) {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
color: hsl(var(--muted-foreground));
pointer-events: none;
}
.search-input {
width: 100%;
padding: 0.875rem 1rem 0.875rem 3rem;
border: 1.5px solid hsl(var(--border));
border-radius: 0.75rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.9375rem;
transition: all 0.2s ease;
}
.search-input:focus {
outline: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
}
/* Error */
.error-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: hsl(0 84% 60% / 0.1);
border: 1px solid hsl(0 84% 60% / 0.3);
border-radius: 0.75rem;
color: hsl(0 84% 60%);
margin-bottom: 1.5rem;
}
/* Count */
.tags-count {
text-align: center;
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin-top: 1.5rem;
}
/* Empty CTA */
.empty-cta {
display: flex;
justify-content: center;
margin-top: 1rem;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border-radius: 0.625rem;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
border: none;
text-decoration: none;
}
.btn-primary {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.btn-primary:hover {
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
}
</style>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ThemePage } from '@manacore/shared-theme-ui';
import { theme } from '$lib/stores/theme';
</script>
<svelte:head>
<title>Themes | Calendar</title>
</svelte:head>
<ThemePage
currentVariant={theme.variant}
onSelectTheme={(v) => theme.setVariant(v)}
showModeSelector={true}
currentMode={theme.mode}
onModeChange={(m) => theme.setMode(m)}
showBackButton={true}
onBack={() => goto('/')}
/>

View file

@ -4,6 +4,7 @@
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
import ToastContainer from '$lib/components/ToastContainer.svelte';
import { AppLoadingSkeleton } from '$lib/components/skeletons';
let { children } = $props();
@ -23,14 +24,7 @@
<ToastContainer />
{#if loading}
<div class="flex min-h-screen items-center justify-center bg-background">
<div class="text-center">
<div
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
></div>
<p class="text-muted-foreground">Laden...</p>
</div>
</div>
<AppLoadingSkeleton />
{:else}
<div class="min-h-screen bg-background text-foreground">
{@render children()}

View file

@ -7,6 +7,18 @@ export interface EventAttendee {
status?: 'accepted' | 'declined' | 'tentative' | 'pending';
}
/**
* Event tag with color
*/
export interface EventTag {
id: string;
userId: string;
name: string;
color: string;
createdAt: Date | string;
updatedAt: Date | string;
}
/**
* How to display all-day events
*/
@ -92,6 +104,9 @@ export interface CalendarEvent {
// Metadata
metadata?: EventMetadata | null;
// Tags (populated when fetched)
tags?: EventTag[];
createdAt: Date | string;
updatedAt: Date | string;
}
@ -124,6 +139,7 @@ export interface CreateEventInput {
color?: string;
status?: EventStatus;
metadata?: EventMetadata;
tagIds?: string[];
}
/**
@ -144,6 +160,7 @@ export interface UpdateEventInput {
color?: string | null;
status?: EventStatus;
metadata?: EventMetadata;
tagIds?: string[];
}
/**

View file

@ -27,18 +27,15 @@
"@google/generative-ai": "^0.24.1",
"@manacore/shared-errors": "workspace:*",
"@manacore/shared-nestjs-auth": "workspace:*",
"@manacore/shared-storage": "workspace:*",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@types/multer": "^1.4.11",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.38.3",
"multer": "^1.4.5-lts.1",
"openai": "^4.77.0",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",

View file

@ -8,7 +8,6 @@ import { SpaceModule } from './space/space.module';
import { DocumentModule } from './document/document.module';
import { ModelModule } from './model/model.module';
import { HealthModule } from './health/health.module';
import { StorageModule } from './storage/storage.module';
@Module({
imports: [
@ -24,7 +23,6 @@ import { StorageModule } from './storage/storage.module';
DocumentModule,
ModelModule,
HealthModule,
StorageModule,
],
})
export class AppModule {}

View file

@ -1,3 +0,0 @@
export * from './storage.module';
export * from './storage.service';
export * from './storage.controller';

View file

@ -1,137 +0,0 @@
import {
Controller,
Post,
Get,
Delete,
Param,
Body,
UseInterceptors,
UploadedFile,
BadRequestException,
NotFoundException,
UseGuards,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { StorageService } from './storage.service';
interface PresignedUploadRequest {
filename: string;
folder?: string;
}
@Controller('api/storage')
@UseGuards(JwtAuthGuard)
export class StorageController {
constructor(private readonly storageService: StorageService) {}
/**
* Upload a file directly
*/
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
async uploadFile(
@CurrentUser() user: CurrentUserData,
@UploadedFile() file: Express.Multer.File,
@Body('folder') folder?: string
) {
if (!file) {
throw new BadRequestException('No file provided');
}
const result = await this.storageService.uploadFile(
user.userId,
file.originalname,
file.buffer,
{
folder,
}
);
return {
success: true,
data: result,
};
}
/**
* Get a presigned URL for client-side upload
*/
@Post('presigned-upload')
async getPresignedUpload(
@CurrentUser() user: CurrentUserData,
@Body() body: PresignedUploadRequest
) {
if (!body.filename) {
throw new BadRequestException('Filename is required');
}
const result = await this.storageService.getPresignedUploadUrl(user.userId, body.filename, {
folder: body.folder,
});
return {
success: true,
data: result,
};
}
/**
* Get a presigned URL for downloading
*/
@Get('download/:key(*)')
async getDownloadUrl(@CurrentUser() user: CurrentUserData, @Param('key') key: string) {
// Ensure user can only access their own files
if (!key.startsWith(`users/${user.userId}/`)) {
throw new NotFoundException('File not found');
}
const exists = await this.storageService.fileExists(key);
if (!exists) {
throw new NotFoundException('File not found');
}
const url = await this.storageService.getPresignedDownloadUrl(key);
return {
success: true,
data: { url },
};
}
/**
* Delete a file
*/
@Delete(':key(*)')
async deleteFile(@CurrentUser() user: CurrentUserData, @Param('key') key: string) {
// Ensure user can only delete their own files
if (!key.startsWith(`users/${user.userId}/`)) {
throw new NotFoundException('File not found');
}
const exists = await this.storageService.fileExists(key);
if (!exists) {
throw new NotFoundException('File not found');
}
await this.storageService.deleteFile(key);
return {
success: true,
message: 'File deleted',
};
}
/**
* List user's files
*/
@Get('list')
async listFiles(@CurrentUser() user: CurrentUserData, @Body('folder') folder?: string) {
const files = await this.storageService.listUserFiles(user.userId, folder);
return {
success: true,
data: { files },
};
}
}

View file

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { StorageService } from './storage.service';
import { StorageController } from './storage.controller';
@Module({
controllers: [StorageController],
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}

View file

@ -1,152 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import {
createChatStorage,
generateUserFileKey,
getContentType,
validateFileSize,
validateFileExtension,
IMAGE_EXTENSIONS,
DOCUMENT_EXTENSIONS,
AUDIO_EXTENSIONS,
} from '@manacore/shared-storage';
import type { StorageClient, UploadResult } from '@manacore/shared-storage';
export interface FileUploadResult {
key: string;
url?: string;
contentType: string;
size: number;
}
export interface PresignedUploadData {
uploadUrl: string;
key: string;
expiresIn: number;
}
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
const ALLOWED_EXTENSIONS = [...IMAGE_EXTENSIONS, ...DOCUMENT_EXTENSIONS, ...AUDIO_EXTENSIONS];
@Injectable()
export class StorageService {
private readonly logger = new Logger(StorageService.name);
private storage: StorageClient | null = null;
private getStorage(): StorageClient {
if (!this.storage) {
this.storage = createChatStorage();
}
return this.storage;
}
/**
* Upload a file to storage
*/
async uploadFile(
userId: string,
filename: string,
data: Buffer,
options?: { folder?: string; public?: boolean }
): Promise<FileUploadResult> {
// Validate file size (MAX_FILE_SIZE is in bytes)
if (!validateFileSize(data.length, MAX_FILE_SIZE / (1024 * 1024))) {
throw new Error(`File size exceeds maximum allowed (${MAX_FILE_SIZE / (1024 * 1024)}MB)`);
}
// Validate file extension
if (!validateFileExtension(filename, ALLOWED_EXTENSIONS)) {
throw new Error(
`File type not allowed. Allowed extensions: ${ALLOWED_EXTENSIONS.join(', ')}`
);
}
const contentType = getContentType(filename);
const key = generateUserFileKey(userId, filename, options?.folder);
const storage = this.getStorage();
const result: UploadResult = await storage.upload(key, data, {
contentType,
public: options?.public ?? false,
});
this.logger.log(`File uploaded: ${key} (${data.length} bytes)`);
return {
key: result.key,
url: result.url,
contentType,
size: data.length,
};
}
/**
* Get a presigned URL for uploading (client-side upload)
*/
async getPresignedUploadUrl(
userId: string,
filename: string,
options?: { folder?: string; expiresIn?: number }
): Promise<PresignedUploadData> {
// Validate file extension
if (!validateFileExtension(filename, ALLOWED_EXTENSIONS)) {
throw new Error(
`File type not allowed. Allowed extensions: ${ALLOWED_EXTENSIONS.join(', ')}`
);
}
const key = generateUserFileKey(userId, filename, options?.folder);
const expiresIn = options?.expiresIn ?? 3600; // 1 hour default
const storage = this.getStorage();
const uploadUrl = await storage.getUploadUrl(key, { expiresIn });
return {
uploadUrl,
key,
expiresIn,
};
}
/**
* Get a presigned URL for downloading
*/
async getPresignedDownloadUrl(key: string, expiresIn = 3600): Promise<string> {
const storage = this.getStorage();
return storage.getDownloadUrl(key, { expiresIn });
}
/**
* Download a file from storage
*/
async downloadFile(key: string): Promise<Buffer> {
const storage = this.getStorage();
return storage.download(key);
}
/**
* Delete a file from storage
*/
async deleteFile(key: string): Promise<void> {
const storage = this.getStorage();
await storage.delete(key);
this.logger.log(`File deleted: ${key}`);
}
/**
* Check if a file exists
*/
async fileExists(key: string): Promise<boolean> {
const storage = this.getStorage();
return storage.exists(key);
}
/**
* List files for a user
*/
async listUserFiles(userId: string, folder?: string): Promise<string[]> {
const storage = this.getStorage();
const prefix = folder ? `users/${userId}/${folder}/` : `users/${userId}/`;
const files = await storage.list(prefix);
return files.map((f) => f.key);
}
}

View file

@ -6,3 +6,5 @@
@source "../../../../packages/shared-auth-ui/src";
@source "../../../../packages/shared-branding/src";
@source "../../../../packages/shared-theme-ui/src";
@source "../../../../packages/shared-theme-ui/src/components";
@source "../../../../packages/shared-theme-ui/src/pages";

View file

@ -5,21 +5,21 @@
*/
import type { Handle } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
// Get client-side URLs from environment (Docker runtime)
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
const PUBLIC_BACKEND_URL_CLIENT =
process.env.PUBLIC_BACKEND_URL_CLIENT || process.env.PUBLIC_BACKEND_URL || '';
export const handle: Handle = async ({ event, resolve }) => {
// Get client-side URLs from environment at RUNTIME (not build time)
// Use $env/dynamic/private to read actual runtime environment variables
const authUrlClient = env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || env.PUBLIC_MANA_CORE_AUTH_URL || '';
const backendUrlClient = env.PUBLIC_BACKEND_URL_CLIENT || env.PUBLIC_BACKEND_URL || '';
return resolve(event, {
transformPageChunk: ({ html }) => {
// Inject runtime environment variables into the HTML
// These will be available on window.__PUBLIC_*__ for client-side code
const envScript = `<script>
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${authUrlClient}";
window.__PUBLIC_BACKEND_URL__ = "${backendUrlClient}";
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${PUBLIC_MANA_CORE_AUTH_URL_CLIENT}";
window.__PUBLIC_BACKEND_URL__ = "${PUBLIC_BACKEND_URL_CLIENT}";
</script>`;
return html.replace('<head>', `<head>${envScript}`);
},

View file

@ -6,7 +6,12 @@
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { theme } from '$lib/stores/theme';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
import {
THEME_DEFINITIONS,
DEFAULT_THEME_VARIANTS,
EXTENDED_THEME_VARIANTS,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import {
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
@ -30,10 +35,20 @@
// Use theme store's isDark directly
let isDark = $derived(theme.isDark);
// Get pinned themes from user settings (extended themes only)
let pinnedThemes = $derived<ThemeVariant[]>(
(userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant =>
EXTENDED_THEME_VARIANTS.includes(t as ThemeVariant)
)
);
// Visible themes in PillNav: default + pinned extended
let visibleThemes = $derived<ThemeVariant[]>([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]);
// Theme variant dropdown items
let themeVariantItems = $derived<PillDropdownItem[]>([
// Theme variants
...theme.variants.map((variant) => ({
// Theme variants (only default + pinned)
...visibleThemes.map((variant) => ({
id: variant,
label: THEME_DEFINITIONS[variant].label,
icon: THEME_DEFINITIONS[variant].icon,

View file

@ -5,6 +5,8 @@
@source "../../../packages/shared/src";
@source "../../../../../packages/shared-ui/src";
@source "../../../../../packages/shared-theme-ui/src";
@source "../../../../../packages/shared-theme-ui/src/components";
@source "../../../../../packages/shared-theme-ui/src/pages";
/* Clock-specific CSS Variables */
@layer base {

View file

@ -5,21 +5,21 @@
*/
import type { Handle } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
// Get client-side URLs from environment (Docker runtime)
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
const PUBLIC_BACKEND_URL_CLIENT =
process.env.PUBLIC_BACKEND_URL_CLIENT || process.env.PUBLIC_BACKEND_URL || '';
export const handle: Handle = async ({ event, resolve }) => {
// Get client-side URLs from environment at RUNTIME (not build time)
// Use $env/dynamic/private to read actual runtime environment variables
const authUrlClient = env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || env.PUBLIC_MANA_CORE_AUTH_URL || '';
const backendUrlClient = env.PUBLIC_BACKEND_URL_CLIENT || env.PUBLIC_BACKEND_URL || '';
return resolve(event, {
transformPageChunk: ({ html }) => {
// Inject runtime environment variables into the HTML
// These will be available on window.__PUBLIC_*__ for client-side code
const envScript = `<script>
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${authUrlClient}";
window.__PUBLIC_BACKEND_URL__ = "${backendUrlClient}";
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${PUBLIC_MANA_CORE_AUTH_URL_CLIENT}";
window.__PUBLIC_BACKEND_URL__ = "${PUBLIC_BACKEND_URL_CLIENT}";
</script>`;
return html.replace('<head>', `<head>${envScript}`);
},

View file

@ -0,0 +1,27 @@
<script lang="ts">
/**
* AlarmsSkeleton - Skeleton for alarms grid
* Shows placeholder tiles for alarm times
*/
import { SkeletonBox } from '@manacore/shared-ui';
</script>
<div role="status" aria-label="Wecker werden geladen...">
<!-- Alarm Preset Grid (matches DEFAULT_ALARM_PRESETS layout) -->
<div class="grid grid-cols-4 sm:grid-cols-6 gap-2">
{#each Array(12) as _, i}
<div
class="bg-card rounded-xl p-3 border border-border flex flex-col items-center justify-center"
style="opacity: {Math.max(0.4, 1 - i * 0.05)}"
>
<!-- Time -->
<SkeletonBox width="48px" height="24px" borderRadius="4px" />
<!-- Label -->
<div class="mt-1">
<SkeletonBox width="40px" height="10px" borderRadius="4px" />
</div>
</div>
{/each}
</div>
</div>

View file

@ -0,0 +1,47 @@
<script lang="ts">
/**
* AppLoadingSkeleton - Full-page loading skeleton for app initialization
* Replaces spinner with clock-themed skeleton layout
*/
import { SkeletonBox } from '@manacore/shared-ui';
</script>
<div
class="flex min-h-screen items-center justify-center bg-background"
role="status"
aria-label="App wird geladen..."
>
<div class="w-full max-w-sm px-6 space-y-8">
<!-- Clock Icon Area -->
<div class="flex flex-col items-center gap-4">
<SkeletonBox width="80px" height="80px" borderRadius="50%" />
<SkeletonBox width="120px" height="24px" borderRadius="8px" />
</div>
<!-- Time Display Skeleton -->
<div class="bg-card rounded-2xl p-6 space-y-4">
<!-- Large Time -->
<div class="flex justify-center">
<SkeletonBox width="200px" height="48px" borderRadius="8px" />
</div>
<!-- Date -->
<div class="flex justify-center">
<SkeletonBox width="140px" height="16px" borderRadius="4px" />
</div>
<!-- Quick Actions -->
<div class="flex justify-center gap-3 pt-2">
{#each Array(4) as _, i}
<SkeletonBox width="48px" height="48px" borderRadius="12px" />
{/each}
</div>
</div>
<!-- Loading Text -->
<div class="flex justify-center">
<SkeletonBox width="100px" height="14px" borderRadius="4px" />
</div>
</div>
</div>

View file

@ -0,0 +1,47 @@
<script lang="ts">
/**
* TimersSkeleton - Skeleton for active timers grid
* Shows placeholder tiles for timer displays
*/
import { SkeletonBox } from '@manacore/shared-ui';
</script>
<div role="status" aria-label="Timer werden geladen...">
<!-- Section Header -->
<div class="mb-2">
<SkeletonBox width="80px" height="12px" borderRadius="4px" />
</div>
<!-- Timer Grid -->
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
{#each Array(4) as _, i}
<div
class="bg-card rounded-xl p-3 border border-border"
style="opacity: {Math.max(0.5, 1 - i * 0.12)}"
>
<!-- Time + Delete Button Row -->
<div class="flex items-start justify-between mb-1">
<SkeletonBox width="70px" height="24px" borderRadius="4px" />
<SkeletonBox width="14px" height="14px" borderRadius="4px" />
</div>
<!-- Label -->
<div class="mb-2">
<SkeletonBox width="50px" height="10px" borderRadius="4px" />
</div>
<!-- Progress Bar -->
<div class="mb-2">
<SkeletonBox width="100%" height="4px" borderRadius="2px" />
</div>
<!-- Control Buttons -->
<div class="flex gap-1">
<SkeletonBox width="100%" height="28px" borderRadius="6px" />
<SkeletonBox width="48px" height="28px" borderRadius="6px" />
</div>
</div>
{/each}
</div>
</div>

View file

@ -0,0 +1,36 @@
<script lang="ts">
/**
* WorldClockSkeleton - Skeleton for world clock grid
* Shows placeholder cards for city times
*/
import { SkeletonBox } from '@manacore/shared-ui';
</script>
<div
class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"
role="status"
aria-label="Weltuhren werden geladen..."
>
{#each Array(6) as _, i}
<div
class="bg-card rounded-xl p-4 border border-border"
style="opacity: {Math.max(0.4, 1 - i * 0.1)}"
>
<!-- Day/Night + City Name -->
<div class="mb-2 flex items-center gap-2">
<SkeletonBox width="32px" height="12px" borderRadius="4px" />
<SkeletonBox width="80px" height="14px" borderRadius="4px" />
</div>
<!-- Large Time Display -->
<SkeletonBox width="140px" height="40px" borderRadius="6px" />
<!-- Date and Offset -->
<div class="mt-2 flex items-center justify-between">
<SkeletonBox width="90px" height="13px" borderRadius="4px" />
<SkeletonBox width="36px" height="14px" borderRadius="4px" />
</div>
</div>
{/each}
</div>

View file

@ -0,0 +1,18 @@
/**
* Clock App Skeleton Components
*
* App-specific skeleton loaders that match the exact layout of clock components.
* Built on top of @manacore/shared-ui skeleton primitives.
*/
// App Loading Skeleton
export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte';
// World Clock Skeleton
export { default as WorldClockSkeleton } from './WorldClockSkeleton.svelte';
// Alarms Skeleton
export { default as AlarmsSkeleton } from './AlarmsSkeleton.svelte';
// Timers Skeleton
export { default as TimersSkeleton } from './TimersSkeleton.svelte';

View file

@ -3,12 +3,22 @@
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale } from 'svelte-i18n';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
import { PillNavigation, CommandBar } from '@manacore/shared-ui';
import type {
PillNavItem,
PillDropdownItem,
CommandBarItem,
QuickAction,
} from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
import {
THEME_DEFINITIONS,
DEFAULT_THEME_VARIANTS,
EXTENDED_THEME_VARIANTS,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import {
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
@ -16,21 +26,108 @@
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import { alarmsApi } from '$lib/api/alarms';
import { timersApi } from '$lib/api/timers';
// App switcher items
const appItems = getPillAppItems('clock');
let { children } = $props();
// CommandBar state
let commandBarOpen = $state(false);
// CommandBar quick actions
const commandBarQuickActions: QuickAction[] = [
{
id: 'alarm',
label: 'Neuen Wecker erstellen',
icon: 'bell',
href: '/alarms?new=true',
shortcut: 'A',
},
{
id: 'timer',
label: 'Neuen Timer starten',
icon: 'timer',
href: '/timers?new=true',
shortcut: 'T',
},
{ id: 'stopwatch', label: 'Stoppuhr', icon: 'stopwatch', href: '/stopwatch' },
{ id: 'pomodoro', label: 'Pomodoro starten', icon: 'target', href: '/pomodoro' },
{ id: 'worldclock', label: 'Weltzeituhr', icon: 'globe', href: '/world-clock' },
{ id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' },
];
// CommandBar search - search alarms and timers
async function handleCommandBarSearch(query: string): Promise<CommandBarItem[]> {
if (!query.trim()) return [];
const queryLower = query.toLowerCase();
const results: CommandBarItem[] = [];
try {
// Search alarms
const alarms = await alarmsApi.getAll();
const matchingAlarms = alarms
.filter((alarm) => alarm.label?.toLowerCase().includes(queryLower))
.slice(0, 5)
.map((alarm) => ({
id: `alarm-${alarm.id}`,
title: alarm.label || 'Wecker',
subtitle: `⏰ ${alarm.time} ${alarm.enabled ? '(aktiv)' : '(inaktiv)'}`,
}));
results.push(...matchingAlarms);
// Search timers
const timers = await timersApi.getAll();
const matchingTimers = timers
.filter((timer) => timer.label?.toLowerCase().includes(queryLower))
.slice(0, 5)
.map((timer) => {
const mins = Math.floor(timer.durationSeconds / 60);
const secs = timer.durationSeconds % 60;
return {
id: `timer-${timer.id}`,
title: timer.label || 'Timer',
subtitle: `⏱️ ${mins}:${secs.toString().padStart(2, '0')} ${timer.status === 'running' ? '(läuft)' : ''}`,
};
});
results.push(...matchingTimers);
} catch {
// Ignore errors
}
return results.slice(0, 10);
}
function handleCommandBarSelect(item: CommandBarItem) {
if (item.id.startsWith('alarm-')) {
goto('/alarms');
} else if (item.id.startsWith('timer-')) {
goto('/timers');
}
}
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
// Use theme store's isDark directly
let isDark = $derived(theme.isDark);
// Get pinned themes from user settings (extended themes only)
let pinnedThemes = $derived<ThemeVariant[]>(
(userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant =>
EXTENDED_THEME_VARIANTS.includes(t as ThemeVariant)
)
);
// Visible themes in PillNav: default + pinned extended
let visibleThemes = $derived<ThemeVariant[]>([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]);
// Theme variant dropdown items (with SSR fallback)
let themeVariantItems = $derived<PillDropdownItem[]>([
...(theme.variants || []).map((variant) => ({
...visibleThemes.map((variant) => ({
id: variant,
label: THEME_DEFINITIONS[variant]?.label || variant,
icon: THEME_DEFINITIONS[variant]?.icon || '🎨',
@ -83,6 +180,13 @@
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
// Cmd/Ctrl+K to open command bar (works even in inputs)
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
event.preventDefault();
commandBarOpen = true;
return;
}
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
@ -206,6 +310,18 @@
{@render children()}
</div>
</main>
<!-- Global Command Bar (Cmd/K) -->
<CommandBar
bind:open={commandBarOpen}
onClose={() => (commandBarOpen = false)}
onSearch={handleCommandBarSearch}
onSelect={handleCommandBarSelect}
quickActions={commandBarQuickActions}
placeholder="Schnellzugriff..."
emptyText="Keine Ergebnisse"
searchingText="Suche..."
/>
</div>
<style>

View file

@ -7,6 +7,7 @@
import { toast } from '$lib/stores/toast';
import type { CreateAlarmInput, Alarm } from '@clock/shared';
import { ALARM_SOUNDS, DEFAULT_ALARM_PRESETS } from '@clock/shared';
import { AlarmsSkeleton } from '$lib/components/skeletons';
// Quick create form (inline)
let newTime = $state('07:00');
@ -194,11 +195,7 @@
<!-- Loading State -->
{#if alarmsStore.loading}
<div class="flex justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-r-transparent"
></div>
</div>
<AlarmsSkeleton />
{:else}
<!-- Default Alarm Presets (Grid) -->
<div class="alarm-grid">

View file

@ -7,6 +7,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from '$lib/stores/toast';
import { QUICK_TIMER_PRESETS, formatDuration } from '@clock/shared';
import { TimersSkeleton } from '$lib/components/skeletons';
// Form state (inline on page)
let formMinutes = $state(5);
@ -219,11 +220,7 @@
<!-- Loading State -->
{#if timersStore.loading}
<div class="flex justify-center py-8">
<div
class="h-6 w-6 animate-spin rounded-full border-2 border-primary border-r-transparent"
></div>
</div>
<TimersSkeleton />
{:else if allTimers.length > 0}
<!-- Active Timers -->
<div>

View file

@ -7,6 +7,7 @@
import { toast } from '$lib/stores/toast';
import { POPULAR_TIMEZONES } from '@clock/shared';
import WorldMap from '$lib/components/WorldMap.svelte';
import { WorldClockSkeleton } from '$lib/components/skeletons';
// State
let showAddModal = $state(false);
@ -205,11 +206,7 @@
<!-- World Clock List -->
{#if worldClocksStore.loading}
<div class="flex justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-r-transparent"
></div>
</div>
<WorldClockSkeleton />
{:else if worldClocksStore.sortedWorldClocks.length === 0}
<div class="card py-12 text-center">
<p class="text-lg text-muted-foreground">{$_('worldClock.noClocks')}</p>

View file

@ -6,6 +6,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import { waitLocale } from '$lib/i18n';
import ToastContainer from '$lib/components/ToastContainer.svelte';
import { AppLoadingSkeleton } from '$lib/components/skeletons';
let { children } = $props();
@ -28,14 +29,7 @@
<ToastContainer />
{#if $isLocaleLoading || loading}
<div class="flex min-h-screen items-center justify-center bg-background">
<div class="text-center">
<div
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
></div>
<p class="text-muted-foreground">Laden...</p>
</div>
</div>
<AppLoadingSkeleton />
{:else}
<div class="min-h-screen bg-background text-foreground">
{@render children()}

View file

@ -83,11 +83,6 @@ pnpm build # Build for production
| `/api/v1/contacts/:id/favorite` | POST | Toggle favorite |
| `/api/v1/contacts/:id/archive` | POST | Toggle archive |
| `/api/v1/contacts/:id/photo` | POST | Upload contact photo |
| `/api/v1/groups` | GET | Get user's groups |
| `/api/v1/groups` | POST | Create new group |
| `/api/v1/groups/:id` | PATCH | Update group |
| `/api/v1/groups/:id` | DELETE | Delete group |
| `/api/v1/groups/:id/contacts` | POST | Add contacts to group |
| `/api/v1/tags` | GET | Get user's tags |
| `/api/v1/tags` | POST | Create new tag |
| `/api/v1/tags/:id` | DELETE | Delete tag |
@ -129,20 +124,6 @@ pnpm build # Build for production
- `shared_with` (JSONB) - Array of user IDs
- `created_at`, `updated_at` (TIMESTAMP)
**contact_groups** - Groups for organizing contacts
- `id` (UUID) - Primary key
- `user_id` (VARCHAR) - User reference
- `name` (VARCHAR) - Group name
- `description` (TEXT) - Optional description
- `color` (VARCHAR) - Group color
- `created_at` (TIMESTAMP)
**contact_to_groups** - Many-to-many relation
- `contact_id` (UUID) - Contact reference
- `group_id` (UUID) - Group reference
**contact_tags** - Tags for contacts
- `id` (UUID) - Primary key
@ -208,7 +189,7 @@ S3_BUCKET=contacts-photos
# Get credentials from https://console.cloud.google.com/apis/credentials
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret
GOOGLE_REDIRECT_URI=http://localhost:5184/import?tab=google
GOOGLE_REDIRECT_URI=http://localhost:5184/data?tab=import&source=google
```
#### Mobile (.env)

View file

@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './db/database.module';
import { ContactModule } from './contact/contact.module';
import { GroupModule } from './group/group.module';
import { TagModule } from './tag/tag.module';
import { NoteModule } from './note/note.module';
import { ActivityModule } from './activity/activity.module';
@ -10,6 +9,10 @@ import { HealthModule } from './health/health.module';
import { ImportModule } from './import/import.module';
import { ExportModule } from './export/export.module';
import { GoogleModule } from './google/google.module';
import { DuplicatesModule } from './duplicates/duplicates.module';
import { PhotoModule } from './photo/photo.module';
import { BatchModule } from './batch/batch.module';
import { NetworkModule } from './network/network.module';
@Module({
imports: [
@ -19,7 +22,6 @@ import { GoogleModule } from './google/google.module';
}),
DatabaseModule,
ContactModule,
GroupModule,
TagModule,
NoteModule,
ActivityModule,
@ -27,6 +29,10 @@ import { GoogleModule } from './google/google.module';
ImportModule,
ExportModule,
GoogleModule,
DuplicatesModule,
PhotoModule,
BatchModule,
NetworkModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,68 @@
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { BatchService } from './batch.service';
import { IsArray, IsString, IsBoolean, IsOptional, ArrayMinSize } from 'class-validator';
class BatchContactIdsDto {
@IsArray()
@IsString({ each: true })
@ArrayMinSize(1)
contactIds: string[];
}
class BatchArchiveDto extends BatchContactIdsDto {
@IsBoolean()
@IsOptional()
archive?: boolean = true;
}
class BatchFavoriteDto extends BatchContactIdsDto {
@IsBoolean()
@IsOptional()
favorite?: boolean = true;
}
class BatchTagsDto extends BatchContactIdsDto {
@IsArray()
@IsString({ each: true })
@ArrayMinSize(1)
tagIds: string[];
}
@Controller('batch')
@UseGuards(JwtAuthGuard)
export class BatchController {
constructor(private readonly batchService: BatchService) {}
@Post('delete')
async deleteMany(@CurrentUser() user: CurrentUserData, @Body() dto: BatchContactIdsDto) {
const result = await this.batchService.deleteMany(dto.contactIds, user.userId);
return result;
}
@Post('archive')
async archiveMany(@CurrentUser() user: CurrentUserData, @Body() dto: BatchArchiveDto) {
const result = await this.batchService.archiveMany(
dto.contactIds,
user.userId,
dto.archive ?? true
);
return result;
}
@Post('favorite')
async favoriteMany(@CurrentUser() user: CurrentUserData, @Body() dto: BatchFavoriteDto) {
const result = await this.batchService.favoriteMany(
dto.contactIds,
user.userId,
dto.favorite ?? true
);
return result;
}
@Post('add-tags')
async addTags(@CurrentUser() user: CurrentUserData, @Body() dto: BatchTagsDto) {
const result = await this.batchService.addTags(dto.contactIds, dto.tagIds, user.userId);
return result;
}
}

View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { BatchController } from './batch.controller';
import { BatchService } from './batch.service';
import { DatabaseModule } from '../db/database.module';
@Module({
imports: [DatabaseModule],
controllers: [BatchController],
providers: [BatchService],
exports: [BatchService],
})
export class BatchModule {}

View file

@ -0,0 +1,155 @@
import { Injectable, Inject, BadRequestException } from '@nestjs/common';
import { eq, and, inArray } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { contacts, contactToTags } from '../db/schema';
import type { Contact } from '../db/schema';
export interface BatchResult {
success: number;
failed: number;
errors: string[];
}
@Injectable()
export class BatchService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
/**
* Delete multiple contacts
*/
async deleteMany(contactIds: string[], userId: string): Promise<BatchResult> {
if (contactIds.length === 0) {
throw new BadRequestException('No contacts specified');
}
const result: BatchResult = { success: 0, failed: 0, errors: [] };
try {
// Delete in a single query
const deleted = await this.db
.delete(contacts)
.where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIds)))
.returning();
result.success = deleted.length;
result.failed = contactIds.length - deleted.length;
if (result.failed > 0) {
result.errors.push(
`${result.failed} contacts could not be deleted (not found or no permission)`
);
}
} catch (e) {
result.failed = contactIds.length;
result.errors.push(e instanceof Error ? e.message : 'Delete failed');
}
return result;
}
/**
* Archive multiple contacts
*/
async archiveMany(contactIds: string[], userId: string, archive = true): Promise<BatchResult> {
if (contactIds.length === 0) {
throw new BadRequestException('No contacts specified');
}
const result: BatchResult = { success: 0, failed: 0, errors: [] };
try {
const updated = await this.db
.update(contacts)
.set({ isArchived: archive, updatedAt: new Date() })
.where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIds)))
.returning();
result.success = updated.length;
result.failed = contactIds.length - updated.length;
if (result.failed > 0) {
result.errors.push(
`${result.failed} contacts could not be ${archive ? 'archived' : 'unarchived'}`
);
}
} catch (e) {
result.failed = contactIds.length;
result.errors.push(e instanceof Error ? e.message : 'Archive operation failed');
}
return result;
}
/**
* Set favorite status for multiple contacts
*/
async favoriteMany(contactIds: string[], userId: string, favorite = true): Promise<BatchResult> {
if (contactIds.length === 0) {
throw new BadRequestException('No contacts specified');
}
const result: BatchResult = { success: 0, failed: 0, errors: [] };
try {
const updated = await this.db
.update(contacts)
.set({ isFavorite: favorite, updatedAt: new Date() })
.where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIds)))
.returning();
result.success = updated.length;
result.failed = contactIds.length - updated.length;
if (result.failed > 0) {
result.errors.push(`${result.failed} contacts could not be updated`);
}
} catch (e) {
result.failed = contactIds.length;
result.errors.push(e instanceof Error ? e.message : 'Favorite operation failed');
}
return result;
}
/**
* Add tags to multiple contacts
*/
async addTags(contactIds: string[], tagIds: string[], userId: string): Promise<BatchResult> {
if (contactIds.length === 0) {
throw new BadRequestException('No contacts specified');
}
const result: BatchResult = { success: 0, failed: 0, errors: [] };
// Verify contacts belong to user
const validContacts = await this.db
.select({ id: contacts.id })
.from(contacts)
.where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIds)));
const validIds = new Set(validContacts.map((c) => c.id));
for (const contactId of contactIds) {
if (!validIds.has(contactId)) {
result.failed++;
continue;
}
for (const tagId of tagIds) {
try {
await this.db.insert(contactToTags).values({ contactId, tagId }).onConflictDoNothing();
} catch {
// Ignore individual tag failures
}
}
result.success++;
}
if (result.failed > 0) {
result.errors.push(`${result.failed} contacts could not be tagged`);
}
return result;
}
}

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