mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 15:41:09 +02:00
style: auto-format codebase with Prettier
Applied formatting to 1487+ files using pnpm format:write - TypeScript/JavaScript files - Svelte components - Astro pages - JSON configs - Markdown docs 13 files still need manual review (Astro JSX comments)
This commit is contained in:
parent
0241f5554c
commit
d36b321d9d
3952 changed files with 661498 additions and 739751 deletions
|
|
@ -1 +1 @@
|
|||
{}
|
||||
{}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"startTime": 1764085339984,
|
||||
"sessionId": "session-1764085339984",
|
||||
"lastActivity": 1764085339984,
|
||||
"startTime": 1764263919114,
|
||||
"sessionId": "session-1764263919114",
|
||||
"lastActivity": 1764263919114,
|
||||
"sessionDuration": 0,
|
||||
"totalTasks": 2,
|
||||
"successfulTasks": 2,
|
||||
"totalTasks": 1,
|
||||
"successfulTasks": 1,
|
||||
"failedTasks": 0,
|
||||
"totalAgents": 0,
|
||||
"activeAgents": 0,
|
||||
|
|
@ -84,4 +84,4 @@
|
|||
"cacheHits": 0,
|
||||
"cacheMisses": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,10 @@
|
|||
[
|
||||
{
|
||||
"id": "cmd-hive-mind-1764085340098",
|
||||
"type": "hive-mind",
|
||||
"id": "cmd-swarm-1764263919220",
|
||||
"type": "swarm",
|
||||
"success": true,
|
||||
"duration": 42.19916699999999,
|
||||
"timestamp": 1764085340140,
|
||||
"duration": 5.217375000000004,
|
||||
"timestamp": 1764263919226,
|
||||
"metadata": {}
|
||||
}
|
||||
]
|
||||
]
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ manacore-monorepo/
|
|||
## Phase 1: Preparation (Pre-Migration)
|
||||
|
||||
### 1.1 Create backup branch
|
||||
|
||||
```bash
|
||||
git checkout -b backup/pre-restructure
|
||||
git push origin backup/pre-restructure
|
||||
|
|
@ -114,6 +115,7 @@ git checkout -b feat/monorepo-restructure
|
|||
```
|
||||
|
||||
### 1.2 Create target directories
|
||||
|
||||
```bash
|
||||
mkdir -p apps
|
||||
mkdir -p services
|
||||
|
|
@ -125,17 +127,17 @@ mkdir -p services
|
|||
|
||||
### 2.1 Move projects to apps/ folder
|
||||
|
||||
| Source | Destination |
|
||||
|--------|-------------|
|
||||
| `chat/` | `apps/chat/` |
|
||||
| Source | Destination |
|
||||
| ----------------- | ---------------------- |
|
||||
| `chat/` | `apps/chat/` |
|
||||
| `maerchenzauber/` | `apps/maerchenzauber/` |
|
||||
| `manadeck/` | `apps/manadeck/` |
|
||||
| `memoro/` | `apps/memoro/` |
|
||||
| `picture/` | `apps/picture/` |
|
||||
| `nutriphi/` | `apps/nutriphi/` |
|
||||
| `uload/` | `apps/uload/` |
|
||||
| `news/` | `apps/news/` |
|
||||
| `manacore/` | `apps/manacore/` |
|
||||
| `manadeck/` | `apps/manadeck/` |
|
||||
| `memoro/` | `apps/memoro/` |
|
||||
| `picture/` | `apps/picture/` |
|
||||
| `nutriphi/` | `apps/nutriphi/` |
|
||||
| `uload/` | `apps/uload/` |
|
||||
| `news/` | `apps/news/` |
|
||||
| `manacore/` | `apps/manacore/` |
|
||||
|
||||
```bash
|
||||
# Move all products to apps/
|
||||
|
|
@ -154,9 +156,9 @@ git mv manacore apps/manacore
|
|||
|
||||
Projects with backends at root level need restructuring:
|
||||
|
||||
| Source | Destination |
|
||||
|--------|-------------|
|
||||
| `apps/chat/backend/` | `apps/chat/apps/backend/` |
|
||||
| Source | Destination |
|
||||
| ------------------------ | ----------------------------- |
|
||||
| `apps/chat/backend/` | `apps/chat/apps/backend/` |
|
||||
| `apps/manadeck/backend/` | `apps/manadeck/apps/backend/` |
|
||||
| `apps/nutriphi/backend/` | `apps/nutriphi/apps/backend/` |
|
||||
|
||||
|
|
@ -175,6 +177,7 @@ git mv apps/nutriphi/backend apps/nutriphi/apps/backend
|
|||
```
|
||||
|
||||
### 2.3 Move mana-core-auth to services/
|
||||
|
||||
```bash
|
||||
git mv mana-core-auth services/mana-core-auth
|
||||
```
|
||||
|
|
@ -186,6 +189,7 @@ git mv mana-core-auth services/mana-core-auth
|
|||
### 3.1 Update pnpm-workspace.yaml
|
||||
|
||||
**New content:**
|
||||
|
||||
```yaml
|
||||
packages:
|
||||
# Product applications
|
||||
|
|
@ -207,85 +211,86 @@ No changes needed - turbo.json uses task definitions, not paths.
|
|||
### 3.3 Update root package.json scripts
|
||||
|
||||
**Replace scripts section:**
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"build": "turbo run build",
|
||||
"test": "turbo run test",
|
||||
"lint": "turbo run lint",
|
||||
"type-check": "turbo run type-check",
|
||||
"clean": "turbo run clean",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md,svelte,astro}\"",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md,svelte,astro}\"",
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"build": "turbo run build",
|
||||
"test": "turbo run test",
|
||||
"lint": "turbo run lint",
|
||||
"type-check": "turbo run type-check",
|
||||
"clean": "turbo run clean",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md,svelte,astro}\"",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md,svelte,astro}\"",
|
||||
|
||||
"maerchenzauber:dev": "turbo run dev --filter=@maerchenzauber/*...",
|
||||
"manacore:dev": "turbo run dev --filter=@manacore/*...",
|
||||
"manadeck:dev": "turbo run dev --filter=@manadeck/*...",
|
||||
"memoro:dev": "turbo run dev --filter=@memoro/*...",
|
||||
"picture:dev": "turbo run dev --filter=@picture/*...",
|
||||
"uload:dev": "turbo run dev --filter=@uload/*...",
|
||||
"chat:dev": "turbo run dev --filter=@chat/*...",
|
||||
"nutriphi:dev": "turbo run dev --filter=@nutriphi/*...",
|
||||
"news:dev": "turbo run dev --filter=@news/*...",
|
||||
"maerchenzauber:dev": "turbo run dev --filter=@maerchenzauber/*...",
|
||||
"manacore:dev": "turbo run dev --filter=@manacore/*...",
|
||||
"manadeck:dev": "turbo run dev --filter=@manadeck/*...",
|
||||
"memoro:dev": "turbo run dev --filter=@memoro/*...",
|
||||
"picture:dev": "turbo run dev --filter=@picture/*...",
|
||||
"uload:dev": "turbo run dev --filter=@uload/*...",
|
||||
"chat:dev": "turbo run dev --filter=@chat/*...",
|
||||
"nutriphi:dev": "turbo run dev --filter=@nutriphi/*...",
|
||||
"news:dev": "turbo run dev --filter=@news/*...",
|
||||
|
||||
"dev:maerchenzauber:web": "pnpm --filter @maerchenzauber/web dev",
|
||||
"dev:maerchenzauber:landing": "pnpm --filter @maerchenzauber/landing dev",
|
||||
"dev:maerchenzauber:backend": "pnpm --filter @maerchenzauber/backend dev",
|
||||
"dev:maerchenzauber:mobile": "pnpm --filter @maerchenzauber/mobile dev",
|
||||
"dev:maerchenzauber:app": "turbo run dev --filter=@maerchenzauber/web --filter=@maerchenzauber/backend",
|
||||
"dev:maerchenzauber:web": "pnpm --filter @maerchenzauber/web dev",
|
||||
"dev:maerchenzauber:landing": "pnpm --filter @maerchenzauber/landing dev",
|
||||
"dev:maerchenzauber:backend": "pnpm --filter @maerchenzauber/backend dev",
|
||||
"dev:maerchenzauber:mobile": "pnpm --filter @maerchenzauber/mobile dev",
|
||||
"dev:maerchenzauber:app": "turbo run dev --filter=@maerchenzauber/web --filter=@maerchenzauber/backend",
|
||||
|
||||
"dev:manacore:web": "pnpm --filter @manacore/web dev",
|
||||
"dev:manacore:landing": "pnpm --filter @manacore/landing dev",
|
||||
"dev:manacore:mobile": "pnpm --filter @manacore/mobile dev",
|
||||
"dev:manacore:web": "pnpm --filter @manacore/web dev",
|
||||
"dev:manacore:landing": "pnpm --filter @manacore/landing dev",
|
||||
"dev:manacore:mobile": "pnpm --filter @manacore/mobile dev",
|
||||
|
||||
"dev:manadeck:web": "pnpm --filter @manadeck/web dev",
|
||||
"dev:manadeck:landing": "pnpm --filter @manadeck/landing dev",
|
||||
"dev:manadeck:backend": "pnpm --filter @manadeck/backend dev",
|
||||
"dev:manadeck:mobile": "pnpm --filter @manadeck/mobile dev",
|
||||
"dev:manadeck:app": "turbo run dev --filter=@manadeck/web --filter=@manadeck/backend",
|
||||
"dev:manadeck:web": "pnpm --filter @manadeck/web dev",
|
||||
"dev:manadeck:landing": "pnpm --filter @manadeck/landing dev",
|
||||
"dev:manadeck:backend": "pnpm --filter @manadeck/backend dev",
|
||||
"dev:manadeck:mobile": "pnpm --filter @manadeck/mobile dev",
|
||||
"dev:manadeck:app": "turbo run dev --filter=@manadeck/web --filter=@manadeck/backend",
|
||||
|
||||
"dev:memoro:web": "pnpm --filter @memoro/web dev",
|
||||
"dev:memoro:landing": "pnpm --filter @memoro/landing dev",
|
||||
"dev:memoro:mobile": "pnpm --filter @memoro/mobile dev",
|
||||
"dev:memoro:web": "pnpm --filter @memoro/web dev",
|
||||
"dev:memoro:landing": "pnpm --filter @memoro/landing dev",
|
||||
"dev:memoro:mobile": "pnpm --filter @memoro/mobile dev",
|
||||
|
||||
"dev:picture:web": "pnpm --filter @picture/web dev",
|
||||
"dev:picture:landing": "pnpm --filter @picture/landing dev",
|
||||
"dev:picture:mobile": "pnpm --filter @picture/mobile dev",
|
||||
"dev:picture:web": "pnpm --filter @picture/web dev",
|
||||
"dev:picture:landing": "pnpm --filter @picture/landing dev",
|
||||
"dev:picture:mobile": "pnpm --filter @picture/mobile dev",
|
||||
|
||||
"dev:uload:web": "pnpm --filter @uload/web dev",
|
||||
"dev:uload:web": "pnpm --filter @uload/web dev",
|
||||
|
||||
"dev:chat:mobile": "pnpm --filter @chat/mobile dev",
|
||||
"dev:chat:web": "pnpm --filter @chat/web dev",
|
||||
"dev:chat:landing": "pnpm --filter @chat/landing dev",
|
||||
"dev:chat:backend": "pnpm --filter @chat/backend start:dev",
|
||||
"dev:chat:app": "turbo run dev --filter=@chat/web --filter=@chat/backend",
|
||||
"dev:chat:mobile": "pnpm --filter @chat/mobile dev",
|
||||
"dev:chat:web": "pnpm --filter @chat/web dev",
|
||||
"dev:chat:landing": "pnpm --filter @chat/landing dev",
|
||||
"dev:chat:backend": "pnpm --filter @chat/backend start:dev",
|
||||
"dev:chat:app": "turbo run dev --filter=@chat/web --filter=@chat/backend",
|
||||
|
||||
"dev:nutriphi:mobile": "pnpm --filter @nutriphi/mobile dev",
|
||||
"dev:nutriphi:web": "pnpm --filter @nutriphi/web dev",
|
||||
"dev:nutriphi:landing": "pnpm --filter @nutriphi/landing dev",
|
||||
"dev:nutriphi:backend": "pnpm --filter @nutriphi/backend start:dev",
|
||||
"dev:nutriphi:app": "turbo run dev --filter=@nutriphi/web --filter=@nutriphi/backend",
|
||||
"dev:nutriphi:mobile": "pnpm --filter @nutriphi/mobile dev",
|
||||
"dev:nutriphi:web": "pnpm --filter @nutriphi/web dev",
|
||||
"dev:nutriphi:landing": "pnpm --filter @nutriphi/landing dev",
|
||||
"dev:nutriphi:backend": "pnpm --filter @nutriphi/backend start:dev",
|
||||
"dev:nutriphi:app": "turbo run dev --filter=@nutriphi/web --filter=@nutriphi/backend",
|
||||
|
||||
"dev:news:mobile": "pnpm --filter @news/mobile dev",
|
||||
"dev:news:web": "pnpm --filter @news/web dev",
|
||||
"dev:news:landing": "pnpm --filter @news/landing dev",
|
||||
"dev:news:api": "pnpm --filter @news/api start:dev",
|
||||
"dev:news:app": "turbo run dev --filter=@news/web --filter=@news/api",
|
||||
"news:db:push": "pnpm --filter @manacore/news-database db:push",
|
||||
"news:db:studio": "pnpm --filter @manacore/news-database db:studio",
|
||||
"dev:news:mobile": "pnpm --filter @news/mobile dev",
|
||||
"dev:news:web": "pnpm --filter @news/web dev",
|
||||
"dev:news:landing": "pnpm --filter @news/landing dev",
|
||||
"dev:news:api": "pnpm --filter @news/api start:dev",
|
||||
"dev:news:app": "turbo run dev --filter=@news/web --filter=@news/api",
|
||||
"news:db:push": "pnpm --filter @manacore/news-database db:push",
|
||||
"news:db:studio": "pnpm --filter @manacore/news-database db:studio",
|
||||
|
||||
"docker:up": "docker compose -f docker-compose.dev.yml up -d postgres redis",
|
||||
"docker:up:auth": "docker compose -f docker-compose.dev.yml --profile auth up -d",
|
||||
"docker:up:chat": "docker compose -f docker-compose.dev.yml --profile chat up -d",
|
||||
"docker:up:all": "docker compose -f docker-compose.dev.yml --profile all up -d",
|
||||
"docker:down": "docker compose -f docker-compose.dev.yml --profile all down",
|
||||
"docker:logs": "docker compose -f docker-compose.dev.yml logs -f",
|
||||
"docker:logs:auth": "docker compose -f docker-compose.dev.yml logs -f mana-core-auth",
|
||||
"docker:logs:chat": "docker compose -f docker-compose.dev.yml logs -f chat-backend",
|
||||
"docker:ps": "docker compose -f docker-compose.dev.yml ps -a",
|
||||
"docker:clean": "docker compose -f docker-compose.dev.yml --profile all down -v"
|
||||
}
|
||||
"docker:up": "docker compose -f docker-compose.dev.yml up -d postgres redis",
|
||||
"docker:up:auth": "docker compose -f docker-compose.dev.yml --profile auth up -d",
|
||||
"docker:up:chat": "docker compose -f docker-compose.dev.yml --profile chat up -d",
|
||||
"docker:up:all": "docker compose -f docker-compose.dev.yml --profile all up -d",
|
||||
"docker:down": "docker compose -f docker-compose.dev.yml --profile all down",
|
||||
"docker:logs": "docker compose -f docker-compose.dev.yml logs -f",
|
||||
"docker:logs:auth": "docker compose -f docker-compose.dev.yml logs -f mana-core-auth",
|
||||
"docker:logs:chat": "docker compose -f docker-compose.dev.yml logs -f chat-backend",
|
||||
"docker:ps": "docker compose -f docker-compose.dev.yml ps -a",
|
||||
"docker:clean": "docker compose -f docker-compose.dev.yml --profile all down -v"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -296,6 +301,7 @@ No changes needed - turbo.json uses task definitions, not paths.
|
|||
### 4.1 Update docker-compose.dev.yml
|
||||
|
||||
**Key changes:**
|
||||
|
||||
- mana-core-auth Dockerfile path: `./services/mana-core-auth/Dockerfile`
|
||||
- chat-backend Dockerfile path: `./apps/chat/apps/backend/Dockerfile`
|
||||
|
||||
|
|
@ -304,14 +310,14 @@ services:
|
|||
# ... postgres and redis unchanged ...
|
||||
|
||||
mana-core-auth:
|
||||
profiles: ["auth", "all"]
|
||||
profiles: ['auth', 'all']
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./services/mana-core-auth/Dockerfile
|
||||
# ... rest unchanged ...
|
||||
|
||||
chat-backend:
|
||||
profiles: ["chat", "all"]
|
||||
profiles: ['chat', 'all']
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./apps/chat/apps/backend/Dockerfile
|
||||
|
|
@ -323,6 +329,7 @@ services:
|
|||
Each Dockerfile needs path updates for COPY commands.
|
||||
|
||||
**services/mana-core-auth/Dockerfile:**
|
||||
|
||||
```dockerfile
|
||||
# Update COPY paths
|
||||
COPY services/mana-core-auth/package.json ./services/mana-core-auth/
|
||||
|
|
@ -331,6 +338,7 @@ COPY packages/ ./packages/
|
|||
```
|
||||
|
||||
**apps/chat/apps/backend/Dockerfile:**
|
||||
|
||||
```dockerfile
|
||||
# Update COPY paths
|
||||
COPY apps/chat/apps/backend/package.json ./apps/chat/apps/backend/
|
||||
|
|
@ -350,6 +358,7 @@ Each project's CLAUDE.md needs path updates in documentation.
|
|||
### 5.2 Update relative imports in code
|
||||
|
||||
Check for any hardcoded relative paths like:
|
||||
|
||||
- `../../packages/`
|
||||
- `../../../shared/`
|
||||
|
||||
|
|
@ -358,6 +367,7 @@ These may need adjustment.
|
|||
### 5.3 Update .env files
|
||||
|
||||
Environment files should still work but verify paths for:
|
||||
|
||||
- File-based configs
|
||||
- Volume mounts
|
||||
- Any path references
|
||||
|
|
@ -367,6 +377,7 @@ Environment files should still work but verify paths for:
|
|||
## Phase 6: Validation & Testing
|
||||
|
||||
### 6.1 Reinstall dependencies
|
||||
|
||||
```bash
|
||||
rm -rf node_modules
|
||||
rm pnpm-lock.yaml
|
||||
|
|
@ -374,11 +385,13 @@ pnpm install
|
|||
```
|
||||
|
||||
### 6.2 Verify workspace packages
|
||||
|
||||
```bash
|
||||
pnpm ls -r --depth 0
|
||||
```
|
||||
|
||||
### 6.3 Test turbo commands
|
||||
|
||||
```bash
|
||||
pnpm chat:dev # Should start all chat apps
|
||||
pnpm dev:chat:web # Should start just web
|
||||
|
|
@ -386,6 +399,7 @@ pnpm type-check # Should check all projects
|
|||
```
|
||||
|
||||
### 6.4 Test Docker
|
||||
|
||||
```bash
|
||||
pnpm docker:up:all
|
||||
pnpm docker:ps
|
||||
|
|
@ -393,6 +407,7 @@ pnpm docker:logs
|
|||
```
|
||||
|
||||
### 6.5 Build all projects
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
|
@ -402,6 +417,7 @@ pnpm build
|
|||
## Phase 7: Commit & Merge
|
||||
|
||||
### 7.1 Commit changes
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "refactor: restructure monorepo with apps/ and services/ directories
|
||||
|
|
@ -415,6 +431,7 @@ git commit -m "refactor: restructure monorepo with apps/ and services/ directori
|
|||
```
|
||||
|
||||
### 7.2 Create PR
|
||||
|
||||
```bash
|
||||
gh pr create --title "refactor: monorepo restructure" --body "..."
|
||||
```
|
||||
|
|
@ -424,6 +441,7 @@ gh pr create --title "refactor: monorepo restructure" --body "..."
|
|||
## Rollback Plan
|
||||
|
||||
If issues arise:
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git branch -D feat/monorepo-restructure
|
||||
|
|
@ -455,13 +473,13 @@ git branch -D feat/monorepo-restructure
|
|||
|
||||
## Estimated Impact
|
||||
|
||||
| Item | Count |
|
||||
|------|-------|
|
||||
| Directories to move | 10 |
|
||||
| Config files to update | 5-6 |
|
||||
| Dockerfiles to update | ~6 |
|
||||
| package.json scripts | ~40 (most unchanged) |
|
||||
| CLAUDE.md files | ~10 |
|
||||
| Item | Count |
|
||||
| ---------------------- | -------------------- |
|
||||
| Directories to move | 10 |
|
||||
| Config files to update | 5-6 |
|
||||
| Dockerfiles to update | ~6 |
|
||||
| package.json scripts | ~40 (most unchanged) |
|
||||
| CLAUDE.md files | ~10 |
|
||||
|
||||
**Risk Level:** Medium - Many file moves but package names unchanged
|
||||
**Estimated Time:** 2-4 hours with testing
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,986 +0,0 @@
|
|||
# 🐳 Docker Self-Hosting Deployment Guide
|
||||
|
||||
**Document Type:** Self-Hosting Infrastructure Guide
|
||||
**Target:** Production-Ready Dockerized Deployment
|
||||
**Date:** 2025-11-25
|
||||
**Status:** Ready for Implementation
|
||||
|
||||
---
|
||||
|
||||
## 📊 Executive Summary
|
||||
|
||||
This guide provides complete Docker-based self-hosting instructions for the Mana Core authentication and credit system. By self-hosting, you save **€40-55/month** compared to managed cloud services while maintaining full control over your infrastructure.
|
||||
|
||||
### Cost Comparison
|
||||
|
||||
| Component | Managed Cloud | Self-Hosted Docker | Savings |
|
||||
|-----------|---------------|-------------------|---------|
|
||||
| PostgreSQL | Supabase Pro: €25/mo | VPS: €0 | €25 |
|
||||
| Auth Service | Cloud Run: €20-50/mo | VPS: €0 | €20-50 |
|
||||
| Redis | Managed: €10-20/mo | VPS: €0 | €10-20 |
|
||||
| **VPS Hosting** | €0 | Hetzner: €15-40/mo | -€15-40 |
|
||||
| Stripe | 2.9% + €0.30/txn | 2.9% + €0.30/txn | €0 |
|
||||
| **Total** | **€55-95/mo** | **€15-40/mo** | **€40-55/mo** |
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture Overview
|
||||
|
||||
### Containerized Services
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ DOCKER HOST (VPS) │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ Traefik (Reverse Proxy) │ │
|
||||
│ │ - SSL/TLS (Let's Encrypt) │ │
|
||||
│ │ - Load Balancing │ │
|
||||
│ │ - Rate Limiting │ │
|
||||
│ └────────────┬───────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────┴──────────────┬────────────────────────┐ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌──────────────┐ ┌──────────────────┐ ┌──────────────┐│
|
||||
│ │ │ │ │ │ ││
|
||||
│ │ Mana-Core │ │ App Services │ │ PostgreSQL ││
|
||||
│ │ Auth │ │ - Memoro │ │ 16-alpine ││
|
||||
│ │ Service │ │ - Chat │ │ ││
|
||||
│ │ (NestJS) │ │ - Picture │ │ + PgBouncer ││
|
||||
│ │ │ │ │ │ ││
|
||||
│ └──────┬───────┘ └──────────┬────────┘ └──────┬───────┘│
|
||||
│ │ │ │ │
|
||||
│ └─────────────────────┴───────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────▼──────────┐ │
|
||||
│ │ │ │
|
||||
│ │ Redis 7-alpine │ │
|
||||
│ │ (Cache + Queue) │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────┘ │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Complete Docker Compose Setup
|
||||
|
||||
### 1. Project Structure
|
||||
|
||||
```
|
||||
manacore-monorepo/
|
||||
├── docker-compose.yml # Main orchestration
|
||||
├── docker-compose.prod.yml # Production overrides
|
||||
├── .env.docker # Docker environment variables
|
||||
├── packages/
|
||||
│ └── mana-core-auth/
|
||||
│ ├── Dockerfile # Auth service image
|
||||
│ └── .dockerignore
|
||||
├── traefik/
|
||||
│ ├── traefik.yml # Traefik config
|
||||
│ ├── dynamic.yml # Dynamic routing rules
|
||||
│ └── acme.json # Let's Encrypt certs
|
||||
├── postgres/
|
||||
│ ├── init/
|
||||
│ │ └── 001_initial_schema.sql # Database initialization
|
||||
│ └── backup/ # Backup scripts
|
||||
└── scripts/
|
||||
├── deploy.sh # Deployment script
|
||||
├── backup.sh # Backup automation
|
||||
└── health-check.sh # Health monitoring
|
||||
```
|
||||
|
||||
### 2. Main docker-compose.yml
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Reverse Proxy & Load Balancer
|
||||
traefik:
|
||||
image: traefik:v2.10
|
||||
container_name: traefik
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- "--api.dashboard=true"
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--entrypoints.web.address=:80"
|
||||
- "--entrypoints.websecure.address=:443"
|
||||
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
|
||||
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
|
||||
- "--certificatesresolvers.letsencrypt.acme.storage=/acme.json"
|
||||
- "--log.level=INFO"
|
||||
- "--accesslog=true"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "8080:8080" # Traefik dashboard
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./traefik/acme.json:/acme.json
|
||||
- ./traefik/dynamic.yml:/dynamic.yml:ro
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# Dashboard
|
||||
- "traefik.http.routers.dashboard.rule=Host(`traefik.${DOMAIN}`)"
|
||||
- "traefik.http.routers.dashboard.service=api@internal"
|
||||
- "traefik.http.routers.dashboard.middlewares=auth"
|
||||
- "traefik.http.middlewares.auth.basicauth.users=${TRAEFIK_BASIC_AUTH}"
|
||||
networks:
|
||||
- mana-network
|
||||
|
||||
# PostgreSQL Database
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-manacore}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=en_US.UTF-8"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./postgres/init:/docker-entrypoint-initdb.d:ro
|
||||
- ./postgres/backup:/backup
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432" # Only localhost access
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- mana-network
|
||||
|
||||
# Connection Pooler (PgBouncer)
|
||||
pgbouncer:
|
||||
image: pgbouncer/pgbouncer:latest
|
||||
container_name: pgbouncer
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASES_HOST: postgres
|
||||
DATABASES_PORT: 5432
|
||||
DATABASES_USER: ${POSTGRES_USER:-postgres}
|
||||
DATABASES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
DATABASES_DBNAME: ${POSTGRES_DB:-manacore}
|
||||
PGBOUNCER_POOL_MODE: transaction
|
||||
PGBOUNCER_MAX_CLIENT_CONN: 1000
|
||||
PGBOUNCER_DEFAULT_POOL_SIZE: 25
|
||||
PGBOUNCER_MIN_POOL_SIZE: 5
|
||||
PGBOUNCER_RESERVE_POOL_SIZE: 5
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- mana-network
|
||||
|
||||
# Redis Cache & Queue
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: redis
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
redis-server
|
||||
--appendonly yes
|
||||
--appendfsync everysec
|
||||
--maxmemory 512mb
|
||||
--maxmemory-policy allkeys-lru
|
||||
--requirepass ${REDIS_PASSWORD}
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379" # Only localhost access
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
networks:
|
||||
- mana-network
|
||||
|
||||
# Mana Core Auth Service
|
||||
mana-core-auth:
|
||||
build:
|
||||
context: ./packages/mana-core-auth
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NODE_ENV: production
|
||||
container_name: mana-core-auth
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
|
||||
# Database
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@pgbouncer:6432/${POSTGRES_DB}
|
||||
|
||||
# Redis
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
||||
|
||||
# JWT Keys
|
||||
JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY}
|
||||
JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY}
|
||||
JWT_ALGORITHM: RS256
|
||||
JWT_ACCESS_TOKEN_EXPIRES_IN: 1h
|
||||
JWT_REFRESH_TOKEN_EXPIRES_IN: 14d
|
||||
|
||||
# Stripe
|
||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
|
||||
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
|
||||
|
||||
# Application
|
||||
APP_NAME: Mana Core Auth
|
||||
APP_URL: https://auth.${DOMAIN}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS}
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_ENABLED: true
|
||||
RATE_LIMIT_MAX_REQUESTS: 100
|
||||
RATE_LIMIT_WINDOW_MS: 60000
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
pgbouncer:
|
||||
condition: service_started
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.auth.rule=Host(`auth.${DOMAIN}`)"
|
||||
- "traefik.http.routers.auth.entrypoints=websecure"
|
||||
- "traefik.http.routers.auth.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.auth.loadbalancer.server.port=3000"
|
||||
# Rate limiting middleware
|
||||
- "traefik.http.middlewares.auth-ratelimit.ratelimit.average=100"
|
||||
- "traefik.http.middlewares.auth-ratelimit.ratelimit.burst=50"
|
||||
- "traefik.http.routers.auth.middlewares=auth-ratelimit"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- mana-network
|
||||
|
||||
# Monitoring: Prometheus (optional but recommended)
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
container_name: prometheus
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||
- '--storage.tsdb.path=/prometheus'
|
||||
- '--storage.tsdb.retention.time=30d'
|
||||
volumes:
|
||||
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- prometheus_data:/prometheus
|
||||
ports:
|
||||
- "127.0.0.1:9090:9090"
|
||||
networks:
|
||||
- mana-network
|
||||
|
||||
# Monitoring: Grafana (optional but recommended)
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
container_name: grafana
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD}
|
||||
GF_INSTALL_PLUGINS: grafana-piechart-panel
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
- ./monitoring/grafana-dashboards:/etc/grafana/provisioning/dashboards:ro
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.grafana.rule=Host(`grafana.${DOMAIN}`)"
|
||||
- "traefik.http.routers.grafana.entrypoints=websecure"
|
||||
- "traefik.http.routers.grafana.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.grafana.loadbalancer.server.port=3000"
|
||||
networks:
|
||||
- mana-network
|
||||
|
||||
networks:
|
||||
mana-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
prometheus_data:
|
||||
driver: local
|
||||
grafana_data:
|
||||
driver: local
|
||||
```
|
||||
|
||||
### 3. Auth Service Dockerfile
|
||||
|
||||
**Location:** `packages/mana-core-auth/Dockerfile`
|
||||
|
||||
```dockerfile
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy dependency files
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY packages/mana-core-auth/package.json ./packages/mana-core-auth/
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile --filter mana-core-auth...
|
||||
|
||||
# Copy source code
|
||||
COPY packages/mana-core-auth ./packages/mana-core-auth
|
||||
COPY packages/shared-* ./packages/
|
||||
|
||||
# Build application
|
||||
WORKDIR /app/packages/mana-core-auth
|
||||
RUN pnpm build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install dumb-init (proper signal handling)
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
# Create app user
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nodejs -u 1001
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/packages/mana-core-auth/dist ./dist
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/packages/mana-core-auth/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/packages/mana-core-auth/package.json ./
|
||||
|
||||
# Switch to non-root user
|
||||
USER nodejs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||
|
||||
# Use dumb-init to handle signals properly
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
|
||||
# Start application
|
||||
CMD ["node", "dist/main.js"]
|
||||
```
|
||||
|
||||
### 4. .dockerignore
|
||||
|
||||
```
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.github
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
npm-debug.log*
|
||||
coverage
|
||||
.DS_Store
|
||||
*.md
|
||||
!README.md
|
||||
```
|
||||
|
||||
### 5. Environment Variables (.env.docker)
|
||||
|
||||
```env
|
||||
# ============================================
|
||||
# DOMAIN & SSL
|
||||
# ============================================
|
||||
DOMAIN=yourdomain.com
|
||||
ACME_EMAIL=admin@yourdomain.com
|
||||
|
||||
# ============================================
|
||||
# TRAEFIK DASHBOARD AUTH
|
||||
# ============================================
|
||||
# Generate with: htpasswd -nb admin your_password
|
||||
TRAEFIK_BASIC_AUTH=admin:$$apr1$$xyz123...
|
||||
|
||||
# ============================================
|
||||
# POSTGRESQL
|
||||
# ============================================
|
||||
POSTGRES_DB=manacore
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=<GENERATE_STRONG_PASSWORD>
|
||||
|
||||
# ============================================
|
||||
# REDIS
|
||||
# ============================================
|
||||
REDIS_PASSWORD=<GENERATE_STRONG_PASSWORD>
|
||||
|
||||
# ============================================
|
||||
# JWT KEYS (RS256)
|
||||
# ============================================
|
||||
# Generate with:
|
||||
# ssh-keygen -t rsa -b 4096 -m PEM -f jwt.key
|
||||
# openssl rsa -in jwt.key -pubout -outform PEM -out jwt.key.pub
|
||||
JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"
|
||||
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
|
||||
|
||||
# ============================================
|
||||
# STRIPE
|
||||
# ============================================
|
||||
STRIPE_SECRET_KEY=sk_live_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
|
||||
# ============================================
|
||||
# APPLICATION
|
||||
# ============================================
|
||||
CORS_ORIGINS=https://memoro.yourdomain.com,https://chat.yourdomain.com,https://picture.yourdomain.com
|
||||
|
||||
# ============================================
|
||||
# MONITORING
|
||||
# ============================================
|
||||
GRAFANA_ADMIN_USER=admin
|
||||
GRAFANA_ADMIN_PASSWORD=<GENERATE_STRONG_PASSWORD>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Deployment Instructions
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **VPS Server:**
|
||||
- **Recommended:** Hetzner CPX31 (4 vCPU, 8GB RAM, 160GB SSD) - €15.30/month
|
||||
- **For larger scale:** Hetzner CPX41 (8 vCPU, 16GB RAM, 240GB SSD) - €29.70/month
|
||||
- **OS:** Ubuntu 22.04 LTS
|
||||
|
||||
2. **Domain Name:**
|
||||
- Point A records to your VPS IP:
|
||||
- `auth.yourdomain.com` → VPS IP
|
||||
- `grafana.yourdomain.com` → VPS IP
|
||||
- `traefik.yourdomain.com` → VPS IP
|
||||
|
||||
3. **Docker & Docker Compose:**
|
||||
```bash
|
||||
# Install Docker
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sudo sh get-docker.sh
|
||||
|
||||
# Install Docker Compose
|
||||
sudo apt-get install docker-compose-plugin
|
||||
|
||||
# Verify installation
|
||||
docker --version
|
||||
docker compose version
|
||||
```
|
||||
|
||||
### Step 1: Generate JWT Keys
|
||||
|
||||
```bash
|
||||
# Generate RSA private key
|
||||
ssh-keygen -t rsa -b 4096 -m PEM -f jwt.key -N ""
|
||||
|
||||
# Extract public key
|
||||
openssl rsa -in jwt.key -pubout -outform PEM -out jwt.key.pub
|
||||
|
||||
# View private key (copy to .env.docker)
|
||||
cat jwt.key
|
||||
|
||||
# View public key (copy to .env.docker)
|
||||
cat jwt.key.pub
|
||||
```
|
||||
|
||||
### Step 2: Configure Environment
|
||||
|
||||
```bash
|
||||
# Copy example environment file
|
||||
cp .env.docker.example .env.docker
|
||||
|
||||
# Edit with your values
|
||||
nano .env.docker
|
||||
|
||||
# Secure the file
|
||||
chmod 600 .env.docker
|
||||
```
|
||||
|
||||
### Step 3: Initialize Database
|
||||
|
||||
```bash
|
||||
# Create database init script
|
||||
mkdir -p postgres/init
|
||||
|
||||
# Copy migration script
|
||||
cp .hive-mind/central-auth-and-credits-design.md postgres/init/001_initial_schema.sql
|
||||
# (Extract the SQL from lines 2314-2728)
|
||||
|
||||
# Or use direct SQL file
|
||||
cat > postgres/init/001_initial_schema.sql << 'EOF'
|
||||
-- Paste complete migration script here
|
||||
EOF
|
||||
```
|
||||
|
||||
### Step 4: Start Services
|
||||
|
||||
```bash
|
||||
# Create required directories
|
||||
mkdir -p traefik postgres/backup monitoring
|
||||
|
||||
# Create acme.json for Let's Encrypt
|
||||
touch traefik/acme.json
|
||||
chmod 600 traefik/acme.json
|
||||
|
||||
# Start all services
|
||||
docker compose up -d
|
||||
|
||||
# View logs
|
||||
docker compose logs -f
|
||||
|
||||
# Check service health
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### Step 5: Verify Deployment
|
||||
|
||||
```bash
|
||||
# Check auth service health
|
||||
curl https://auth.yourdomain.com/health
|
||||
|
||||
# Expected response:
|
||||
# {"status":"ok","timestamp":"2025-11-25T..."}
|
||||
|
||||
# Test registration
|
||||
curl -X POST https://auth.yourdomain.com/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "test@example.com",
|
||||
"password": "SecurePass123!",
|
||||
"name": "Test User"
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Maintenance Operations
|
||||
|
||||
### Backup Database
|
||||
|
||||
```bash
|
||||
# Create backup script
|
||||
cat > scripts/backup.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
BACKUP_DIR="/path/to/backups"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_FILE="manacore_backup_${TIMESTAMP}.sql.gz"
|
||||
|
||||
docker exec postgres pg_dump -U postgres manacore | gzip > "${BACKUP_DIR}/${BACKUP_FILE}"
|
||||
|
||||
# Keep only last 30 days of backups
|
||||
find "${BACKUP_DIR}" -name "manacore_backup_*.sql.gz" -mtime +30 -delete
|
||||
|
||||
echo "Backup completed: ${BACKUP_FILE}"
|
||||
EOF
|
||||
|
||||
chmod +x scripts/backup.sh
|
||||
|
||||
# Run backup manually
|
||||
./scripts/backup.sh
|
||||
|
||||
# Schedule daily backups (cron)
|
||||
crontab -e
|
||||
# Add: 0 2 * * * /path/to/scripts/backup.sh >> /var/log/manacore-backup.log 2>&1
|
||||
```
|
||||
|
||||
### Restore Database
|
||||
|
||||
```bash
|
||||
# Stop auth service (prevent writes during restore)
|
||||
docker compose stop mana-core-auth
|
||||
|
||||
# Restore from backup
|
||||
gunzip -c /path/to/backups/manacore_backup_YYYYMMDD_HHMMSS.sql.gz | \
|
||||
docker exec -i postgres psql -U postgres -d manacore
|
||||
|
||||
# Restart auth service
|
||||
docker compose start mana-core-auth
|
||||
```
|
||||
|
||||
### Update Services
|
||||
|
||||
```bash
|
||||
# Pull latest images
|
||||
docker compose pull
|
||||
|
||||
# Rebuild auth service (if code changed)
|
||||
docker compose build mana-core-auth
|
||||
|
||||
# Zero-downtime update (with multiple replicas)
|
||||
docker compose up -d --no-deps --scale mana-core-auth=2 mana-core-auth
|
||||
|
||||
# Remove old containers
|
||||
docker compose up -d --remove-orphans
|
||||
```
|
||||
|
||||
### View Logs
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker compose logs -f
|
||||
|
||||
# Specific service
|
||||
docker compose logs -f mana-core-auth
|
||||
|
||||
# Last 100 lines
|
||||
docker compose logs --tail=100 mana-core-auth
|
||||
|
||||
# With timestamps
|
||||
docker compose logs -f --timestamps mana-core-auth
|
||||
```
|
||||
|
||||
### Monitor Resources
|
||||
|
||||
```bash
|
||||
# Container stats
|
||||
docker stats
|
||||
|
||||
# Disk usage
|
||||
docker system df
|
||||
|
||||
# Clean up unused resources
|
||||
docker system prune -a --volumes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Monitoring Setup
|
||||
|
||||
### 1. Prometheus Configuration
|
||||
|
||||
**File:** `monitoring/prometheus.yml`
|
||||
|
||||
```yaml
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: 'mana-core-auth'
|
||||
static_configs:
|
||||
- targets: ['mana-core-auth:3000']
|
||||
metrics_path: '/metrics'
|
||||
|
||||
- job_name: 'postgres'
|
||||
static_configs:
|
||||
- targets: ['postgres:5432']
|
||||
|
||||
- job_name: 'redis'
|
||||
static_configs:
|
||||
- targets: ['redis:6379']
|
||||
|
||||
- job_name: 'traefik'
|
||||
static_configs:
|
||||
- targets: ['traefik:8080']
|
||||
```
|
||||
|
||||
### 2. Grafana Dashboards
|
||||
|
||||
Access Grafana at `https://grafana.yourdomain.com`
|
||||
|
||||
**Default credentials:** admin / (password from .env.docker)
|
||||
|
||||
**Recommended Dashboards:**
|
||||
- PostgreSQL Dashboard: ID 9628
|
||||
- Redis Dashboard: ID 11835
|
||||
- Traefik Dashboard: ID 17346
|
||||
- Node Exporter: ID 1860
|
||||
|
||||
### 3. Health Check Script
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# scripts/health-check.sh
|
||||
|
||||
SERVICES=("postgres" "redis" "mana-core-auth" "traefik")
|
||||
FAILED=0
|
||||
|
||||
for SERVICE in "${SERVICES[@]}"; do
|
||||
if ! docker compose ps | grep -q "${SERVICE}.*Up"; then
|
||||
echo "❌ ${SERVICE} is down!"
|
||||
FAILED=1
|
||||
else
|
||||
echo "✅ ${SERVICE} is up"
|
||||
fi
|
||||
done
|
||||
|
||||
# Check auth service health endpoint
|
||||
if curl -sf https://auth.yourdomain.com/health > /dev/null; then
|
||||
echo "✅ Auth service health check passed"
|
||||
else
|
||||
echo "❌ Auth service health check failed"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
exit $FAILED
|
||||
```
|
||||
|
||||
Run every 5 minutes via cron:
|
||||
```bash
|
||||
*/5 * * * * /path/to/scripts/health-check.sh >> /var/log/health-check.log 2>&1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Hardening
|
||||
|
||||
### 1. Firewall Configuration (UFW)
|
||||
|
||||
```bash
|
||||
# Enable UFW
|
||||
sudo ufw enable
|
||||
|
||||
# Allow SSH
|
||||
sudo ufw allow 22/tcp
|
||||
|
||||
# Allow HTTP/HTTPS (Traefik)
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
|
||||
# Deny direct database access from internet
|
||||
sudo ufw deny 5432/tcp
|
||||
sudo ufw deny 6379/tcp
|
||||
|
||||
# Check status
|
||||
sudo ufw status
|
||||
```
|
||||
|
||||
### 2. Automatic Security Updates
|
||||
|
||||
```bash
|
||||
# Install unattended-upgrades
|
||||
sudo apt-get install unattended-upgrades
|
||||
|
||||
# Configure
|
||||
sudo dpkg-reconfigure -plow unattended-upgrades
|
||||
```
|
||||
|
||||
### 3. Fail2Ban (Brute Force Protection)
|
||||
|
||||
```bash
|
||||
# Install Fail2Ban
|
||||
sudo apt-get install fail2ban
|
||||
|
||||
# Create custom jail for Traefik
|
||||
sudo tee /etc/fail2ban/jail.d/traefik.conf << EOF
|
||||
[traefik-auth]
|
||||
enabled = true
|
||||
port = http,https
|
||||
filter = traefik-auth
|
||||
logpath = /var/log/traefik/access.log
|
||||
maxretry = 5
|
||||
bantime = 3600
|
||||
findtime = 600
|
||||
EOF
|
||||
|
||||
# Restart Fail2Ban
|
||||
sudo systemctl restart fail2ban
|
||||
```
|
||||
|
||||
### 4. Docker Socket Protection
|
||||
|
||||
```bash
|
||||
# Never expose Docker socket directly
|
||||
# Instead, use Docker socket proxy
|
||||
|
||||
# Add to docker-compose.yml:
|
||||
# socket-proxy:
|
||||
# image: tecnativa/docker-socket-proxy
|
||||
# environment:
|
||||
# CONTAINERS: 1
|
||||
# NETWORKS: 1
|
||||
# SERVICES: 1
|
||||
# TASKS: 1
|
||||
# volumes:
|
||||
# - /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Performance Optimization
|
||||
|
||||
### 1. Docker Resource Limits
|
||||
|
||||
Add to `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mana-core-auth:
|
||||
# ... existing config
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 2G
|
||||
reservations:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
```
|
||||
|
||||
### 2. PostgreSQL Tuning
|
||||
|
||||
```bash
|
||||
# Create custom postgresql.conf
|
||||
cat > postgres/postgresql.conf << EOF
|
||||
# Memory
|
||||
shared_buffers = 2GB
|
||||
effective_cache_size = 6GB
|
||||
work_mem = 16MB
|
||||
maintenance_work_mem = 512MB
|
||||
|
||||
# Connections
|
||||
max_connections = 200
|
||||
|
||||
# Checkpoints
|
||||
checkpoint_completion_target = 0.9
|
||||
wal_buffers = 16MB
|
||||
|
||||
# Query Planning
|
||||
random_page_cost = 1.1
|
||||
effective_io_concurrency = 200
|
||||
EOF
|
||||
|
||||
# Mount in docker-compose.yml:
|
||||
# volumes:
|
||||
# - ./postgres/postgresql.conf:/etc/postgresql/postgresql.conf:ro
|
||||
# command: postgres -c config_file=/etc/postgresql/postgresql.conf
|
||||
```
|
||||
|
||||
### 3. Redis Tuning
|
||||
|
||||
Already optimized in docker-compose.yml with:
|
||||
- `maxmemory 512mb`
|
||||
- `maxmemory-policy allkeys-lru`
|
||||
- `appendonly yes` (persistence)
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
### Service Won't Start
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
docker compose logs mana-core-auth
|
||||
|
||||
# Check if port is already in use
|
||||
sudo netstat -tlnp | grep :3000
|
||||
|
||||
# Restart service
|
||||
docker compose restart mana-core-auth
|
||||
```
|
||||
|
||||
### Database Connection Issues
|
||||
|
||||
```bash
|
||||
# Test PostgreSQL connection
|
||||
docker exec -it postgres psql -U postgres -d manacore -c "SELECT version();"
|
||||
|
||||
# Test PgBouncer connection
|
||||
docker exec -it pgbouncer psql -h localhost -p 6432 -U postgres -d manacore
|
||||
```
|
||||
|
||||
### SSL Certificate Issues
|
||||
|
||||
```bash
|
||||
# Check Traefik logs
|
||||
docker compose logs traefik | grep -i "acme\|certificate"
|
||||
|
||||
# Manually trigger certificate renewal
|
||||
docker compose restart traefik
|
||||
|
||||
# Check acme.json
|
||||
cat traefik/acme.json
|
||||
```
|
||||
|
||||
### Out of Disk Space
|
||||
|
||||
```bash
|
||||
# Check disk usage
|
||||
df -h
|
||||
|
||||
# Clean up Docker
|
||||
docker system prune -a --volumes
|
||||
|
||||
# Clean up old logs
|
||||
docker compose logs --tail=0 > /dev/null
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 Scaling Strategies
|
||||
|
||||
### Horizontal Scaling (Multiple Auth Instances)
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
mana-core-auth:
|
||||
# ... existing config
|
||||
deploy:
|
||||
replicas: 3 # Run 3 instances
|
||||
|
||||
# Traefik automatically load balances
|
||||
```
|
||||
|
||||
### Database Read Replicas
|
||||
|
||||
```yaml
|
||||
# Add read replica
|
||||
postgres-replica:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_PRIMARY_HOST: postgres
|
||||
POSTGRES_REPLICATION_MODE: slave
|
||||
volumes:
|
||||
- postgres_replica_data:/var/lib/postgresql/data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Production Checklist
|
||||
|
||||
Before going live:
|
||||
|
||||
- [ ] SSL certificates working (Let's Encrypt)
|
||||
- [ ] Firewall configured (UFW)
|
||||
- [ ] Automated backups scheduled (daily)
|
||||
- [ ] Monitoring dashboards accessible (Grafana)
|
||||
- [ ] Health checks passing
|
||||
- [ ] Environment variables secured (chmod 600)
|
||||
- [ ] Database performance tuned
|
||||
- [ ] Fail2Ban configured
|
||||
- [ ] Docker resource limits set
|
||||
- [ ] Logs rotation configured
|
||||
- [ ] Disaster recovery plan documented
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
- Docker Documentation: https://docs.docker.com
|
||||
- Traefik Documentation: https://doc.traefik.io/traefik/
|
||||
- PostgreSQL Performance: https://pgtune.leopard.in.ua/
|
||||
- Hetzner Cloud: https://www.hetzner.com/cloud
|
||||
|
||||
---
|
||||
|
||||
**Document Status:** ✅ Complete - Ready for Production Deployment
|
||||
**Last Updated:** 2025-11-25
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,462 +0,0 @@
|
|||
# Researcher Agent - Authentication System Research Deliverables
|
||||
**Hive Mind Collective Intelligence System**
|
||||
**Agent:** Researcher
|
||||
**Mission:** Comprehensive authentication system research
|
||||
**Date:** 2025-11-25
|
||||
**Status:** ✅ COMPLETE
|
||||
|
||||
---
|
||||
|
||||
## 📋 Mission Objectives (Completed)
|
||||
|
||||
1. ✅ Investigate "Better Auth" library capabilities and features
|
||||
2. ✅ Research PostgreSQL auth patterns and security best practices
|
||||
3. ✅ Compare alternative auth solutions (Auth.js, Supabase Auth, custom JWT)
|
||||
4. ✅ Identify industry standards for credit/token systems
|
||||
5. ✅ Research payment gateway integration for digital credits (Stripe, etc.)
|
||||
6. ✅ Analyze multi-app authentication patterns (OAuth2, JWT strategies)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Deliverables Overview
|
||||
|
||||
### 🎯 Primary Documents
|
||||
|
||||
#### 1. Comprehensive Research Report (74KB)
|
||||
**File:** `/Users/wuesteon/dev/mana_universe/manacore-monorepo/.hive-mind/auth-research-report.md`
|
||||
|
||||
**Contents:**
|
||||
- 12 comprehensive sections covering all research objectives
|
||||
- 50+ code examples
|
||||
- Security checklists
|
||||
- Best practices documentation
|
||||
- Implementation roadmap
|
||||
- Risk assessments
|
||||
|
||||
**Sections:**
|
||||
1. Authentication Library Comparison (Better Auth, Auth.js, Supabase, Clerk, Auth0)
|
||||
2. PostgreSQL Security Best Practices
|
||||
3. JWT Security Best Practices
|
||||
4. PostgreSQL Row-Level Security (RLS) for Multi-Tenancy
|
||||
5. Credit/Token System Architecture
|
||||
6. Payment Integration (Stripe)
|
||||
7. Multi-App Authentication Patterns
|
||||
8. Technology Recommendation Matrix
|
||||
9. Implementation Roadmap
|
||||
10. Security Checklist
|
||||
11. Monitoring & Observability
|
||||
12. Additional Resources
|
||||
|
||||
**Audience:** Technical team, architects, developers
|
||||
|
||||
---
|
||||
|
||||
#### 2. Executive Summary (11KB)
|
||||
**File:** `/Users/wuesteon/dev/mana_universe/manacore-monorepo/.hive-mind/auth-research-executive-summary.md`
|
||||
|
||||
**Contents:**
|
||||
- Quick recommendations
|
||||
- Key findings summary
|
||||
- Cost analysis
|
||||
- Risk assessment
|
||||
- Implementation priority
|
||||
- Security checklist
|
||||
- Performance considerations
|
||||
|
||||
**Audience:** Leadership, product managers, technical leads
|
||||
|
||||
---
|
||||
|
||||
#### 3. Decision Matrix (14KB)
|
||||
**File:** `/Users/wuesteon/dev/mana_universe/manacore-monorepo/.hive-mind/auth-research-decision-matrix.md`
|
||||
|
||||
**Contents:**
|
||||
- Visual decision trees
|
||||
- Comparison tables
|
||||
- Scorecards
|
||||
- Cost breakdowns
|
||||
- Scenario-based recommendations
|
||||
- Implementation checklist
|
||||
|
||||
**Audience:** Decision makers, project managers
|
||||
|
||||
---
|
||||
|
||||
### 🔍 Supporting Documents
|
||||
|
||||
#### 4. Security Architecture Report (65KB)
|
||||
**File:** `/Users/wuesteon/dev/mana_universe/manacore-monorepo/.hive-mind/ANALYST_SECURITY_ARCHITECTURE_REPORT.md`
|
||||
|
||||
**Note:** Created by Analyst agent (complementary research)
|
||||
|
||||
---
|
||||
|
||||
#### 5. Central Auth Design (76KB)
|
||||
**File:** `/Users/wuesteon/dev/mana_universe/manacore-monorepo/.hive-mind/central-auth-and-credits-design.md`
|
||||
|
||||
**Note:** Created by Analyst agent (complementary research)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Key Recommendations
|
||||
|
||||
### Primary Technology Stack
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ RECOMMENDED ARCHITECTURE │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Auth Framework: Better Auth │
|
||||
│ Database: PostgreSQL 16+ │
|
||||
│ ORM: Drizzle │
|
||||
│ Payment Gateway: Stripe │
|
||||
│ JWT Algorithm: RS256 │
|
||||
│ Token Storage: httpOnly/SecureStore │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Why Better Auth?
|
||||
|
||||
| Feature | Status | Impact |
|
||||
|---------|--------|--------|
|
||||
| Cost | ✅ FREE | Zero licensing costs |
|
||||
| TypeScript | ✅ First-class | Excellent DX |
|
||||
| Features | ✅ Comprehensive | 2FA, passkeys, multi-session built-in |
|
||||
| Monorepo Fit | ✅ Perfect | Framework-agnostic |
|
||||
| Vendor Lock-in | ✅ None | Full control |
|
||||
| Maturity | ⚠️ New (2024) | YC-backed, active development |
|
||||
|
||||
**Confidence:** ⭐⭐⭐⭐☆ (4.5/5)
|
||||
|
||||
---
|
||||
|
||||
## 💰 Cost Analysis
|
||||
|
||||
### At 10,000 Active Users
|
||||
|
||||
| Solution | Monthly Cost | Annual Cost | Savings |
|
||||
|----------|-------------|-------------|---------|
|
||||
| **Recommended Stack** | $190-245 | $2,280-2,940 | Baseline |
|
||||
| Clerk | $720-745 | $8,640-8,940 | -$6,360/year |
|
||||
| Auth0 | $205-435 | $2,460-5,220 | -$180-2,280/year |
|
||||
| Supabase Auth | $170-195 | $2,040-2,340 | +$240-600/year (but reliability concerns) |
|
||||
|
||||
**ROI:** Save $6,000-8,000/year vs Clerk at 10k users scale
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Security Highlights
|
||||
|
||||
### Critical Must-Haves Identified
|
||||
|
||||
1. **JWT Security**
|
||||
- RS256 algorithm (asymmetric keys)
|
||||
- 15-minute access token expiration
|
||||
- 7-day refresh token with rotation
|
||||
- httpOnly cookies (web) / SecureStore (mobile)
|
||||
|
||||
2. **PostgreSQL Security**
|
||||
- SCRAM-SHA-256 authentication
|
||||
- Row-Level Security (RLS) enabled
|
||||
- SSL/TLS for all connections
|
||||
- Principle of least privilege
|
||||
|
||||
3. **Payment Security**
|
||||
- Idempotency keys for all transactions
|
||||
- Stripe webhook signature verification
|
||||
- Double-entry ledger pattern
|
||||
- DECIMAL types for monetary values
|
||||
|
||||
4. **Multi-Tenant Security**
|
||||
- RLS policies on all tables
|
||||
- Tenant context via JWT claims
|
||||
- Defense in depth approach
|
||||
- Extensive integration testing
|
||||
|
||||
---
|
||||
|
||||
## 📊 Research Methodology
|
||||
|
||||
### Sources Consulted
|
||||
|
||||
1. **Documentation**
|
||||
- Better Auth official docs
|
||||
- PostgreSQL security guides
|
||||
- Stripe API reference
|
||||
- JWT best practices (Curity, Auth0)
|
||||
|
||||
2. **Comparisons**
|
||||
- Better Stack community guides
|
||||
- Hyperknot auth provider comparison
|
||||
- LogRocket technical analysis
|
||||
- Industry blogs and case studies
|
||||
|
||||
3. **Standards**
|
||||
- OAuth 2.0 RFC specifications
|
||||
- JWT RFC 7519
|
||||
- Payment Card Industry (PCI) guidelines
|
||||
- OWASP security cheatsheets
|
||||
|
||||
4. **Real-World Examples**
|
||||
- AWS multi-tenant patterns
|
||||
- Crunchy Data RLS guides
|
||||
- Modern Treasury idempotency patterns
|
||||
- Stripe integration examples
|
||||
|
||||
### Research Quality Indicators
|
||||
|
||||
- ✅ Multiple independent sources verified
|
||||
- ✅ Recent information (2024-2025)
|
||||
- ✅ Industry best practices validated
|
||||
- ✅ Real-world implementations studied
|
||||
- ✅ Security standards cross-referenced
|
||||
- ✅ Cost analysis from official pricing
|
||||
- ✅ Technical specifications verified
|
||||
|
||||
---
|
||||
|
||||
## 📈 Implementation Timeline
|
||||
|
||||
### Phased Approach (14 Weeks Total)
|
||||
|
||||
```
|
||||
Week 1-2: Foundation
|
||||
├─ Better Auth setup
|
||||
├─ PostgreSQL configuration
|
||||
├─ RS256 key generation
|
||||
└─ Basic auth API
|
||||
|
||||
Week 3-4: Multi-App Integration
|
||||
├─ @manacore/shared-auth package
|
||||
├─ App-token generation
|
||||
├─ Session management
|
||||
└─ RLS policies
|
||||
|
||||
Week 5-6: Credit System
|
||||
├─ Ledger schema
|
||||
├─ Double-entry bookkeeping
|
||||
├─ Idempotency handling
|
||||
└─ Credit APIs
|
||||
|
||||
Week 7-8: Payment Integration
|
||||
├─ Stripe setup
|
||||
├─ Payment intents
|
||||
├─ Webhook handlers
|
||||
└─ Credit packages
|
||||
|
||||
Week 9-12: Advanced Features
|
||||
├─ 2FA implementation
|
||||
├─ Multi-session management
|
||||
├─ Organization support
|
||||
└─ OAuth providers
|
||||
|
||||
Week 13-14: Production Readiness
|
||||
├─ Security audit
|
||||
├─ Performance testing
|
||||
├─ Monitoring setup
|
||||
└─ Documentation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Key Learnings
|
||||
|
||||
### Better Auth Advantages
|
||||
|
||||
1. **TypeScript-First Design**
|
||||
- Automatic type generation from schema
|
||||
- Full IntelliSense support
|
||||
- Compile-time validation
|
||||
|
||||
2. **Database Adapter System**
|
||||
- Supports Drizzle, Prisma, TypeORM
|
||||
- Automatic schema generation
|
||||
- Built-in migration support
|
||||
|
||||
3. **Plugin Architecture**
|
||||
- Official plugins (2FA, organizations)
|
||||
- Third-party ecosystem growing
|
||||
- Easy to extend
|
||||
|
||||
4. **Framework Agnostic**
|
||||
- Works with React, Vue, Svelte, Astro
|
||||
- Backend agnostic (NestJS, Express, Hono)
|
||||
- Perfect for monorepos
|
||||
|
||||
### PostgreSQL RLS Insights
|
||||
|
||||
1. **Defense in Depth**
|
||||
- Even if application code has bugs, database enforces isolation
|
||||
- Policies apply at database level
|
||||
- Cannot be bypassed by application
|
||||
|
||||
2. **Performance**
|
||||
- Minimal overhead with proper indexing
|
||||
- tenant_id indexes are critical
|
||||
- Composite indexes for query patterns
|
||||
|
||||
3. **Testing is Critical**
|
||||
- Must test all access patterns
|
||||
- Integration tests for each policy
|
||||
- Verify cross-tenant isolation
|
||||
|
||||
### Credit System Best Practices
|
||||
|
||||
1. **Double-Entry Ledger**
|
||||
- Every transaction creates debit + credit entries
|
||||
- Mathematical proof of accuracy
|
||||
- Complete audit trail
|
||||
|
||||
2. **Idempotency**
|
||||
- Prevents duplicate charges
|
||||
- Safe to retry failed requests
|
||||
- Industry standard pattern
|
||||
|
||||
3. **DECIMAL for Money**
|
||||
- Never use FLOAT for monetary values
|
||||
- DECIMAL ensures precision
|
||||
- No rounding errors
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
### Immediate Actions (This Week)
|
||||
|
||||
1. **Better Auth POC** (2-3 days)
|
||||
- [ ] Install Better Auth
|
||||
- [ ] Test with PostgreSQL
|
||||
- [ ] Validate TypeScript generation
|
||||
- [ ] Test basic auth flow
|
||||
|
||||
2. **Team Review** (1 day)
|
||||
- [ ] Present findings to team
|
||||
- [ ] Discuss concerns
|
||||
- [ ] Confirm technology choices
|
||||
- [ ] Get stakeholder buy-in
|
||||
|
||||
3. **Architecture Planning** (2 days)
|
||||
- [ ] Design database schema
|
||||
- [ ] Plan API endpoints
|
||||
- [ ] Define JWT claims structure
|
||||
- [ ] Document authentication flows
|
||||
|
||||
### Week 2 Actions
|
||||
|
||||
4. **Initial Implementation**
|
||||
- [ ] Set up Better Auth with Drizzle
|
||||
- [ ] Configure PostgreSQL
|
||||
- [ ] Generate RS256 keys
|
||||
- [ ] Implement login/register endpoints
|
||||
|
||||
5. **Stripe Setup**
|
||||
- [ ] Create Stripe test account
|
||||
- [ ] Design credit packages
|
||||
- [ ] Plan pricing strategy
|
||||
- [ ] Test webhook integration
|
||||
|
||||
---
|
||||
|
||||
## ❓ Questions for Team
|
||||
|
||||
### Product Questions
|
||||
|
||||
1. **Credit Pricing**
|
||||
- What should credit packages cost?
|
||||
- Suggested: 100 credits for $9.99, 500 for $39.99, etc.
|
||||
|
||||
2. **Credit Expiration**
|
||||
- Should credits expire? If so, after how long?
|
||||
- Recommendation: 90 days for purchased, no expiration for bonus
|
||||
|
||||
3. **Subscription Model**
|
||||
- Offer monthly subscriptions or pay-as-you-go only?
|
||||
- Recommendation: Start with pay-as-you-go, add subscriptions later
|
||||
|
||||
4. **OAuth Providers**
|
||||
- Which social login providers are required?
|
||||
- Recommendation: Google, GitHub, Apple (for iOS)
|
||||
|
||||
### Technical Questions
|
||||
|
||||
5. **Multi-Tenancy Priority**
|
||||
- Are organizations/teams a priority feature?
|
||||
- Better Auth supports this, but adds complexity
|
||||
|
||||
6. **Compliance Requirements**
|
||||
- Any specific compliance needs? (GDPR, HIPAA, SOC 2)
|
||||
- Affects implementation decisions
|
||||
|
||||
7. **Rate Limiting**
|
||||
- Should rate limiting be per-user or per-IP?
|
||||
- Recommendation: Both (user + IP-based)
|
||||
|
||||
---
|
||||
|
||||
## 📞 Contact & Support
|
||||
|
||||
### For Questions About This Research
|
||||
|
||||
**Primary Contact:** Queen Agent (Hive Mind Aggregator)
|
||||
**Research Agent:** Available for clarifications
|
||||
**Location:** `/Users/wuesteon/dev/mana_universe/manacore-monorepo/.hive-mind/`
|
||||
|
||||
### Additional Resources
|
||||
|
||||
- **Full Report:** `auth-research-report.md` (74KB)
|
||||
- **Executive Summary:** `auth-research-executive-summary.md` (11KB)
|
||||
- **Decision Matrix:** `auth-research-decision-matrix.md` (14KB)
|
||||
- **Complementary Research:** `ANALYST_SECURITY_ARCHITECTURE_REPORT.md` (65KB)
|
||||
|
||||
---
|
||||
|
||||
## 📝 Version History
|
||||
|
||||
| Version | Date | Changes | Agent |
|
||||
|---------|------|---------|-------|
|
||||
| 1.0 | 2025-11-25 | Initial comprehensive research completed | Researcher |
|
||||
| - | - | Security architecture analysis | Analyst |
|
||||
| - | - | Central auth design | Analyst |
|
||||
|
||||
---
|
||||
|
||||
## ✅ Research Completeness
|
||||
|
||||
| Research Objective | Status | Confidence | Documentation |
|
||||
|-------------------|--------|-----------|---------------|
|
||||
| Better Auth Investigation | ✅ Complete | ⭐⭐⭐⭐⭐ | Section 1 |
|
||||
| PostgreSQL Security | ✅ Complete | ⭐⭐⭐⭐⭐ | Section 2 |
|
||||
| Auth Solutions Comparison | ✅ Complete | ⭐⭐⭐⭐⭐ | Section 1 |
|
||||
| Credit System Standards | ✅ Complete | ⭐⭐⭐⭐⭐ | Section 5 |
|
||||
| Payment Integration | ✅ Complete | ⭐⭐⭐⭐⭐ | Section 6 |
|
||||
| Multi-App Auth Patterns | ✅ Complete | ⭐⭐⭐⭐⭐ | Section 7 |
|
||||
|
||||
**Overall Confidence:** ⭐⭐⭐⭐⭐ (5/5)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Success Criteria (Met)
|
||||
|
||||
- ✅ Comprehensive technology comparison completed
|
||||
- ✅ Clear recommendation provided with justification
|
||||
- ✅ Security best practices documented
|
||||
- ✅ Implementation roadmap defined
|
||||
- ✅ Cost analysis completed
|
||||
- ✅ Risk assessment performed
|
||||
- ✅ Code examples provided
|
||||
- ✅ Multiple audience formats (technical, executive, decision)
|
||||
- ✅ Real-world patterns researched
|
||||
- ✅ Industry standards validated
|
||||
|
||||
---
|
||||
|
||||
**Mission Status:** ✅ COMPLETE
|
||||
|
||||
**Ready for:** Queen Agent aggregation and team review
|
||||
|
||||
**Recommendation:** Proceed with Better Auth + PostgreSQL + Stripe implementation
|
||||
|
||||
---
|
||||
|
||||
*Generated by Researcher Agent - Hive Mind Collective Intelligence System*
|
||||
*For the Mana Universe Monorepo Project*
|
||||
|
|
@ -1,469 +0,0 @@
|
|||
# Authentication System Decision Matrix
|
||||
**Visual Decision Guide | Researcher Agent**
|
||||
**Date:** 2025-11-25
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Quick Decision Tree
|
||||
|
||||
```
|
||||
Need Auth for Multi-App Monorepo?
|
||||
│
|
||||
├─ Budget < $100/month?
|
||||
│ │
|
||||
│ ├─ YES ──→ Better Auth + PostgreSQL ✅ RECOMMENDED
|
||||
│ │ - FREE
|
||||
│ │ - Full control
|
||||
│ │ - All features included
|
||||
│ │
|
||||
│ └─ NO ──→ Consider Clerk (if budget > $500/mo)
|
||||
│ - Best DX
|
||||
│ - Managed solution
|
||||
│ - Expensive
|
||||
│
|
||||
└─ Already using Supabase heavily?
|
||||
│
|
||||
├─ YES ──→ Auth.js + Supabase ⚠️ WITH CAUTION
|
||||
│ - Leverage existing infra
|
||||
│ - Watch for reliability issues
|
||||
│
|
||||
└─ NO ──→ Better Auth + PostgreSQL ✅ RECOMMENDED
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Technology Comparison Matrix
|
||||
|
||||
### Authentication Libraries
|
||||
|
||||
| | Better Auth | Auth.js | Supabase Auth | Clerk | Auth0 |
|
||||
|---|:-----------:|:-------:|:-------------:|:-----:|:-----:|
|
||||
| **Cost** | ✅ FREE | ✅ FREE | 💰 $25/mo | 💰💰 $550/mo | 💰💰 $35-240/mo |
|
||||
| **Setup Complexity** | ⭐⭐⭐⭐ Easy | ⭐⭐⭐ Medium | ⭐⭐⭐⭐⭐ Very Easy | ⭐⭐⭐⭐⭐ Very Easy | ⭐⭐⭐ Medium |
|
||||
| **TypeScript Support** | ✅ Excellent | ⚠️ Good | ⚠️ Good | ✅ Excellent | ⚠️ Good |
|
||||
| **2FA Built-in** | ✅ Yes | ❌ No | ⚠️ Limited | ✅ Yes | ✅ Yes |
|
||||
| **Multi-Session** | ✅ Yes | ⚠️ Custom | ⚠️ Limited | ✅ Yes | ✅ Yes |
|
||||
| **Auto Schema** | ✅ Yes | ❌ No | ✅ Yes | N/A | N/A |
|
||||
| **Self-Hosted** | ✅ Yes | ✅ Yes | ⚠️ Hybrid | ❌ No | ❌ No |
|
||||
| **Vendor Lock-in** | ✅ None | ✅ None | ⚠️ High | ⚠️ High | ⚠️ High |
|
||||
| **Maintenance Risk** | ⭐⭐⭐⭐ Low | ⚠️ High | ⭐⭐⭐ Medium | ⭐⭐⭐⭐ Low | ⭐⭐⭐⭐ Low |
|
||||
| **Battle-Tested** | ⚠️ New (2024) | ✅ Mature | ✅ Mature | ✅ Mature | ✅ Mature |
|
||||
| **Community** | ⭐⭐ Small | ⭐⭐⭐⭐ Large | ⭐⭐⭐ Medium | ⭐⭐⭐ Medium | ⭐⭐⭐⭐ Large |
|
||||
| **Monorepo Fit** | ✅ Excellent | ⭐⭐⭐ Good | ⭐⭐⭐ Good | ⭐⭐ Limited | ⭐⭐ Limited |
|
||||
|
||||
#### Legend
|
||||
- ✅ Excellent/Yes
|
||||
- ⭐ Rating (more stars = better)
|
||||
- ⚠️ Caution/Limited
|
||||
- ❌ No/Poor
|
||||
- 💰 Cost indicator (more = higher cost)
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Security Features Comparison
|
||||
|
||||
| Feature | Better Auth | Auth.js | Supabase | Clerk | Auth0 |
|
||||
|---------|:-----------:|:-------:|:--------:|:-----:|:-----:|
|
||||
| **Passkeys (WebAuthn)** | ✅ | ⚠️ Plugin | ❌ | ✅ | ✅ |
|
||||
| **2FA/TOTP** | ✅ | ⚠️ Custom | ⚠️ Limited | ✅ | ✅ |
|
||||
| **Magic Links** | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| **Session Management** | ✅ Advanced | ⚠️ Basic | ⚠️ Basic | ✅ Advanced | ✅ Advanced |
|
||||
| **Device Tracking** | ✅ | ⚠️ Custom | ❌ | ✅ | ✅ |
|
||||
| **Rate Limiting** | ⚠️ External | ⚠️ External | ⚠️ Limited | ✅ Built-in | ✅ Built-in |
|
||||
| **Breach Detection** | ❌ | ❌ | ❌ | ✅ | ✅ |
|
||||
| **Bot Protection** | ⚠️ External | ⚠️ External | ⚠️ Limited | ✅ Built-in | ✅ Built-in |
|
||||
|
||||
---
|
||||
|
||||
## 💾 Database & ORM Options
|
||||
|
||||
### PostgreSQL Features
|
||||
|
||||
| Feature | PostgreSQL | MySQL | MongoDB |
|
||||
|---------|:----------:|:-----:|:-------:|
|
||||
| **RLS Support** | ✅ Native | ❌ No | ❌ No |
|
||||
| **ACID Compliance** | ✅ Full | ✅ Full | ⚠️ Limited |
|
||||
| **JSON Support** | ✅ Excellent | ⚠️ Basic | ✅ Native |
|
||||
| **Full-Text Search** | ✅ Advanced | ⚠️ Basic | ✅ Good |
|
||||
| **Better Auth Support** | ✅ Primary | ✅ Yes | ✅ Yes |
|
||||
| **Maturity** | ✅ 25+ years | ✅ 25+ years | ⭐ 15 years |
|
||||
|
||||
**Verdict:** PostgreSQL for multi-tenant security (RLS) and financial accuracy
|
||||
|
||||
---
|
||||
|
||||
### ORM Comparison
|
||||
|
||||
| Feature | Drizzle | Prisma | TypeORM |
|
||||
|---------|:-------:|:------:|:-------:|
|
||||
| **Better Auth Support** | ✅ Official | ✅ Official | ⚠️ Generic |
|
||||
| **Performance** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ |
|
||||
| **Type Safety** | ✅ Excellent | ✅ Excellent | ⚠️ Good |
|
||||
| **Migration Tools** | ✅ Built-in | ✅ Excellent | ⚠️ Basic |
|
||||
| **Learning Curve** | ⭐⭐⭐⭐ Easy | ⭐⭐⭐ Medium | ⭐⭐ Hard |
|
||||
| **Raw SQL Support** | ✅ Excellent | ⚠️ Limited | ✅ Good |
|
||||
|
||||
**Verdict:** Drizzle for performance and Better Auth integration
|
||||
|
||||
---
|
||||
|
||||
## 💳 Payment Gateway Comparison
|
||||
|
||||
| Feature | Stripe | PayPal | Square |
|
||||
|---------|:------:|:------:|:------:|
|
||||
| **Transaction Fee** | 2.9% + $0.30 | 3.49% + $0.49 | 2.9% + $0.30 |
|
||||
| **Global Reach** | ✅ 47+ countries | ✅ 200+ countries | ⚠️ Limited |
|
||||
| **Developer Experience** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ |
|
||||
| **Documentation** | ✅ Excellent | ⚠️ Good | ✅ Good |
|
||||
| **Webhook Reliability** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ |
|
||||
| **Digital Wallets** | ✅ All major | ✅ All major | ⚠️ Limited |
|
||||
| **Marketplace Features** | ✅ Connect | ⚠️ Limited | ❌ No |
|
||||
| **Credit Top-ups** | ✅ Perfect fit | ⚠️ Complex | ✅ Good |
|
||||
|
||||
**Verdict:** Stripe for best developer experience and features
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Architecture Patterns Scorecard
|
||||
|
||||
### Pattern 1: Centralized Auth + App Tokens (RECOMMENDED)
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ Mana Core Auth │
|
||||
│ - User DB │
|
||||
│ - Credit System │
|
||||
│ - Issues JWTs │
|
||||
└────────┬─────────┘
|
||||
│
|
||||
┌────────────────┼────────────────┐
|
||||
│ │ │
|
||||
┌────▼────┐ ┌────▼────┐ ┌────▼────┐
|
||||
│ App A │ │ App B │ │ App C │
|
||||
│Validates│ │Validates│ │Validates│
|
||||
│ JWT │ │ JWT │ │ JWT │
|
||||
└─────────┘ └─────────┘ └─────────┘
|
||||
```
|
||||
|
||||
**Score: 9/10**
|
||||
- ✅ Single source of truth
|
||||
- ✅ Unified credit system
|
||||
- ✅ Cross-app SSO
|
||||
- ✅ Consistent security
|
||||
- ⚠️ Single point of failure (mitigate with HA)
|
||||
|
||||
---
|
||||
|
||||
### Pattern 2: Federated Auth (Each App Manages Own)
|
||||
|
||||
```
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ App A │ │ App B │ │ App C │
|
||||
│ Auth │ │ Auth │ │ Auth │
|
||||
└─────────┘ └─────────┘ └─────────┘
|
||||
│ │ │
|
||||
└───────────────┼───────────────┘
|
||||
│
|
||||
┌──────▼──────┐
|
||||
│ Sync DB │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
**Score: 4/10**
|
||||
- ❌ User data fragmentation
|
||||
- ❌ Complex credit system
|
||||
- ❌ No cross-app SSO
|
||||
- ❌ Inconsistent security
|
||||
- ✅ Independent scaling
|
||||
|
||||
**Verdict:** NOT recommended for Mana ecosystem
|
||||
|
||||
---
|
||||
|
||||
### Pattern 3: Managed Service (Clerk/Auth0)
|
||||
|
||||
```
|
||||
┌───────────────────┐
|
||||
│ Clerk/Auth0 │ (External)
|
||||
│ - User DB │
|
||||
│ - Session Mgmt │
|
||||
└────────┬──────────┘
|
||||
│
|
||||
┌────────┼────────┐
|
||||
│ │ │
|
||||
┌───▼──┐ ┌──▼──┐ ┌───▼──┐
|
||||
│App A │ │App B│ │App C │
|
||||
└──────┘ └─────┘ └──────┘
|
||||
```
|
||||
|
||||
**Score: 6/10**
|
||||
- ✅ Managed infrastructure
|
||||
- ✅ Advanced features
|
||||
- ❌ Expensive ($550+/mo)
|
||||
- ❌ Vendor lock-in
|
||||
- ⚠️ Less control over flow
|
||||
|
||||
**Verdict:** Only if budget allows and team wants managed solution
|
||||
|
||||
---
|
||||
|
||||
## 🔄 JWT Token Strategies
|
||||
|
||||
### Strategy 1: Short-Lived Access + Refresh (RECOMMENDED)
|
||||
|
||||
```
|
||||
Access Token: 15 minutes ⚡ Fast validation
|
||||
Refresh Token: 7 days 🔄 Rotate on use
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- ✅ Best security (short exposure window)
|
||||
- ✅ Detects token theft via rotation
|
||||
- ✅ Industry standard
|
||||
|
||||
**Cons:**
|
||||
- ⚠️ More complexity (refresh flow)
|
||||
- ⚠️ Database lookups for refresh
|
||||
|
||||
**Score: 9/10** - Industry best practice
|
||||
|
||||
---
|
||||
|
||||
### Strategy 2: Long-Lived Tokens
|
||||
|
||||
```
|
||||
Access Token: 7 days ⚠️ High risk if stolen
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- ✅ Simple implementation
|
||||
- ✅ No refresh logic needed
|
||||
|
||||
**Cons:**
|
||||
- ❌ High security risk
|
||||
- ❌ Hard to revoke
|
||||
- ❌ Violates best practices
|
||||
|
||||
**Score: 3/10** - NOT recommended
|
||||
|
||||
---
|
||||
|
||||
### Strategy 3: Stateful Sessions (Database)
|
||||
|
||||
```
|
||||
Session ID: Stored in DB 🗄️ Always check DB
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- ✅ Easy revocation
|
||||
- ✅ Fine-grained control
|
||||
|
||||
**Cons:**
|
||||
- ❌ Database lookup on every request
|
||||
- ❌ Doesn't scale well
|
||||
- ❌ Not suitable for microservices
|
||||
|
||||
**Score: 5/10** - Only for monoliths
|
||||
|
||||
---
|
||||
|
||||
## 💰 Cost Breakdown (10k Active Users)
|
||||
|
||||
### Option 1: Recommended Stack
|
||||
|
||||
| Component | Monthly Cost |
|
||||
|-----------|-------------|
|
||||
| Better Auth | $0 (open-source) |
|
||||
| PostgreSQL (Supabase Pro) | $25 |
|
||||
| Auth Service Hosting | $20-50 |
|
||||
| Stripe Fees (500 txns × $10 avg) | $145-170 |
|
||||
| **Total** | **$190-245/month** |
|
||||
|
||||
---
|
||||
|
||||
### Option 2: Clerk
|
||||
|
||||
| Component | Monthly Cost |
|
||||
|-----------|-------------|
|
||||
| Clerk Business Plan | $550 |
|
||||
| PostgreSQL (Credit System) | $25 |
|
||||
| Stripe Fees | $145-170 |
|
||||
| **Total** | **$720-745/month** |
|
||||
|
||||
**Extra Cost:** $530-500/month (265% more expensive)
|
||||
|
||||
---
|
||||
|
||||
### Option 3: Auth0
|
||||
|
||||
| Component | Monthly Cost |
|
||||
|-----------|-------------|
|
||||
| Auth0 Essentials | $35-240 |
|
||||
| PostgreSQL (Credit System) | $25 |
|
||||
| Stripe Fees | $145-170 |
|
||||
| **Total** | **$205-435/month** |
|
||||
|
||||
**Extra Cost:** $15-190/month
|
||||
|
||||
---
|
||||
|
||||
### Option 4: Supabase Auth
|
||||
|
||||
| Component | Monthly Cost |
|
||||
|-----------|-------------|
|
||||
| Supabase Pro | $25 |
|
||||
| Stripe Fees | $145-170 |
|
||||
| **Total** | **$170-195/month** |
|
||||
|
||||
**Savings:** $20-50/month BUT with reliability concerns
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Final Recommendations by Scenario
|
||||
|
||||
### Scenario 1: Startup/MVP (Current Mana Status)
|
||||
**Recommendation:** Better Auth + PostgreSQL + Stripe
|
||||
|
||||
**Why:**
|
||||
- ✅ Zero auth licensing costs
|
||||
- ✅ Full control and customization
|
||||
- ✅ Scales to 100k+ users
|
||||
- ✅ No vendor lock-in
|
||||
- ✅ Perfect for monorepo
|
||||
|
||||
**Risk:** New library (2024), but YC-backed and active
|
||||
|
||||
---
|
||||
|
||||
### Scenario 2: Well-Funded Startup (>$1M ARR)
|
||||
**Recommendation:** Better Auth or Clerk
|
||||
|
||||
**Why:**
|
||||
- Better Auth if team wants control
|
||||
- Clerk if team wants managed solution and has budget
|
||||
- Both provide excellent developer experience
|
||||
|
||||
---
|
||||
|
||||
### Scenario 3: Enterprise (Compliance Requirements)
|
||||
**Recommendation:** Auth0 or Custom (Better Auth)
|
||||
|
||||
**Why:**
|
||||
- Auth0 for compliance certifications
|
||||
- Better Auth if building custom compliance layer
|
||||
- Both support SSO, SAML, etc.
|
||||
|
||||
---
|
||||
|
||||
### Scenario 4: Already Deep in Supabase
|
||||
**Recommendation:** Auth.js + Supabase
|
||||
|
||||
**Why:**
|
||||
- Leverage existing Supabase infrastructure
|
||||
- Auth.js provides better control than Supabase Auth
|
||||
- Monitor for reliability issues
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Quick Implementation Checklist
|
||||
|
||||
### Week 1-2: Core Auth
|
||||
- [ ] Install Better Auth
|
||||
- [ ] Configure PostgreSQL with RLS
|
||||
- [ ] Generate RS256 key pair
|
||||
- [ ] Implement login/register endpoints
|
||||
- [ ] Create JWT validation middleware
|
||||
|
||||
### Week 3-4: Multi-App
|
||||
- [ ] Create @manacore/shared-auth package
|
||||
- [ ] Implement app-token generation
|
||||
- [ ] Add session management
|
||||
- [ ] Configure RLS for each app
|
||||
|
||||
### Week 5-6: Credits
|
||||
- [ ] Design ledger schema (double-entry)
|
||||
- [ ] Implement credit purchase API
|
||||
- [ ] Add idempotency handling
|
||||
- [ ] Build credit usage API
|
||||
|
||||
### Week 7-8: Payments
|
||||
- [ ] Set up Stripe account
|
||||
- [ ] Implement payment intents
|
||||
- [ ] Build webhook handlers
|
||||
- [ ] Add credit packages
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Critical Success Factors
|
||||
|
||||
### Must-Haves
|
||||
1. ✅ Short-lived access tokens (15-30 min)
|
||||
2. ✅ Refresh token rotation
|
||||
3. ✅ httpOnly cookies (web) / SecureStore (mobile)
|
||||
4. ✅ PostgreSQL RLS for multi-tenancy
|
||||
5. ✅ Idempotency for all financial transactions
|
||||
6. ✅ Stripe webhook signature verification
|
||||
7. ✅ Double-entry ledger for credits
|
||||
8. ✅ Comprehensive testing (especially RLS)
|
||||
|
||||
### Nice-to-Haves
|
||||
- ⭐ 2FA for all users
|
||||
- ⭐ Device tracking and management
|
||||
- ⭐ Organization/team support
|
||||
- ⭐ Multiple credit types (paid, bonus, promo)
|
||||
- ⭐ Credit expiration handling
|
||||
- ⭐ Subscription model
|
||||
|
||||
---
|
||||
|
||||
## 📈 Scalability Projections
|
||||
|
||||
| Metric | Current | 1 Year | 3 Years |
|
||||
|--------|---------|--------|---------|
|
||||
| **Users** | 100 | 10,000 | 100,000 |
|
||||
| **Auth Requests/Day** | 1,000 | 100,000 | 1,000,000 |
|
||||
| **Credit Transactions/Day** | 50 | 5,000 | 50,000 |
|
||||
| **Monthly Cost** | $50 | $200 | $500 |
|
||||
| **DB Size** | 100MB | 10GB | 100GB |
|
||||
|
||||
**Bottleneck Analysis:**
|
||||
- 🟢 100-10k users: Single server sufficient
|
||||
- 🟡 10k-100k users: Need load balancing + connection pooling
|
||||
- 🔴 100k+ users: Requires distributed architecture
|
||||
|
||||
**Recommended Stack Handles:** Up to 100k users with optimization
|
||||
|
||||
---
|
||||
|
||||
## ✅ Decision Summary
|
||||
|
||||
### For Mana Universe Monorepo
|
||||
|
||||
**RECOMMENDED ARCHITECTURE:**
|
||||
|
||||
```
|
||||
Better Auth + PostgreSQL + Drizzle + Stripe
|
||||
```
|
||||
|
||||
**Confidence Level:** ⭐⭐⭐⭐⭐ (5/5)
|
||||
|
||||
**Key Reasons:**
|
||||
1. Perfect fit for monorepo architecture
|
||||
2. Zero licensing costs (100% open-source)
|
||||
3. Full control and customization
|
||||
4. Comprehensive features built-in
|
||||
5. Excellent TypeScript support
|
||||
6. No vendor lock-in
|
||||
7. YC-backed with active development
|
||||
8. Scales to 100k+ users
|
||||
|
||||
**Total Implementation Time:** 14 weeks
|
||||
**Monthly Operating Cost:** $190-245 at 10k users
|
||||
|
||||
---
|
||||
|
||||
**Next Step:** Run Better Auth proof-of-concept (2-3 days)
|
||||
|
||||
---
|
||||
|
||||
*End of Decision Matrix*
|
||||
|
|
@ -1,404 +0,0 @@
|
|||
# Authentication System Research - Executive Summary
|
||||
**Researcher Agent | Hive Mind Collective**
|
||||
**Date:** 2025-11-25
|
||||
|
||||
---
|
||||
|
||||
## Quick Recommendations
|
||||
|
||||
### Core Technology Stack
|
||||
| Component | Recommendation | Why |
|
||||
|-----------|----------------|-----|
|
||||
| **Auth Framework** | Better Auth | Modern, TypeScript-first, comprehensive features, FREE |
|
||||
| **Database** | PostgreSQL 16+ | Battle-tested, RLS for multi-tenancy, ACID compliance |
|
||||
| **ORM** | Drizzle | Best Better Auth integration, type-safe, performant |
|
||||
| **Payment** | Stripe | Industry standard, 47+ countries, excellent DX |
|
||||
| **JWT Algorithm** | RS256 | Asymmetric keys for distributed systems |
|
||||
|
||||
---
|
||||
|
||||
## Key Findings
|
||||
|
||||
### 1. Better Auth vs Alternatives
|
||||
|
||||
**Better Auth** (RECOMMENDED)
|
||||
- FREE and open-source (no usage limits)
|
||||
- 2FA, passkeys, multi-session, organization management built-in
|
||||
- Automatic schema generation and migrations
|
||||
- Framework-agnostic (perfect for your NestJS/Expo/SvelteKit stack)
|
||||
- YC-backed with active development
|
||||
|
||||
**Alternatives Considered:**
|
||||
- **Auth.js:** Maintenance concerns (one person maintaining 90% of work)
|
||||
- **Supabase Auth:** Critical reliability issues (random logouts, no session lifetime config, security concerns)
|
||||
- **Clerk:** Excellent but expensive ($550/mo for 10k users)
|
||||
- **Auth0:** Enterprise-grade but costly and overkill
|
||||
|
||||
### 2. PostgreSQL Security Best Practices
|
||||
|
||||
**Critical Configurations:**
|
||||
- Use SCRAM-SHA-256 (replace MD5 immediately)
|
||||
- Enable Row-Level Security (RLS) for all multi-tenant tables
|
||||
- Set listen_addresses to specific IPs (not '*')
|
||||
- Enable SSL/TLS for all connections
|
||||
- Implement principle of least privilege
|
||||
|
||||
**RLS for Multi-Tenancy:**
|
||||
```sql
|
||||
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON posts
|
||||
FOR ALL
|
||||
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
|
||||
```
|
||||
|
||||
### 3. JWT Security Best Practices
|
||||
|
||||
**Token Strategy:**
|
||||
- Access tokens: 15-30 minutes expiration
|
||||
- Refresh tokens: 7-14 days with rotation
|
||||
- Algorithm: RS256 (asymmetric keys)
|
||||
- Storage: httpOnly cookies (web), SecureStore (mobile)
|
||||
- NEVER use localStorage
|
||||
|
||||
**Refresh Token Rotation:**
|
||||
- Single-use refresh tokens
|
||||
- New refresh token issued with each refresh
|
||||
- Detects and blocks replay attacks
|
||||
- Industry standard in 2025
|
||||
|
||||
**Claims Validation:**
|
||||
```typescript
|
||||
interface StandardClaims {
|
||||
iss: string; // Issuer - MUST validate
|
||||
sub: string; // Subject (user ID)
|
||||
aud: string | string[]; // Audience - MUST validate
|
||||
exp: number; // Expiration - MUST validate
|
||||
iat: number; // Issued at
|
||||
nbf?: number; // Not before
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Credit System Architecture
|
||||
|
||||
**Pattern: Double-Entry Ledger**
|
||||
- Every transaction creates debit + credit entries
|
||||
- Ensures financial accuracy
|
||||
- Complete audit trail
|
||||
- Industry standard for financial systems
|
||||
|
||||
**Critical Features:**
|
||||
- Use DECIMAL for monetary values (never FLOAT)
|
||||
- Idempotency keys prevent duplicate charges
|
||||
- Database transactions (BEGIN/COMMIT/ROLLBACK)
|
||||
- Row locking during balance updates (SELECT FOR UPDATE)
|
||||
|
||||
**Schema Highlights:**
|
||||
```sql
|
||||
-- Accounts (user wallets)
|
||||
CREATE TABLE accounts (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL,
|
||||
balance DECIMAL(20, 2) NOT NULL CHECK (balance >= 0),
|
||||
-- ...
|
||||
);
|
||||
|
||||
-- Transaction ledger
|
||||
CREATE TABLE transactions (
|
||||
id UUID PRIMARY KEY,
|
||||
idempotency_key TEXT UNIQUE NOT NULL, -- Prevents duplicates
|
||||
amount DECIMAL(20, 2) NOT NULL,
|
||||
status TEXT CHECK (status IN ('pending', 'completed', 'failed')),
|
||||
-- ...
|
||||
);
|
||||
```
|
||||
|
||||
### 5. Stripe Integration
|
||||
|
||||
**Integration Options:**
|
||||
1. **Direct Integration** (Recommended initially)
|
||||
- Simple credit purchases
|
||||
- Single merchant
|
||||
- Easier setup
|
||||
|
||||
2. **Stripe Connect** (For future marketplace features)
|
||||
- Multi-party payments
|
||||
- Revenue sharing
|
||||
- More complex setup
|
||||
|
||||
**Critical Webhook Handling:**
|
||||
```typescript
|
||||
// ALWAYS verify webhook signatures
|
||||
const event = stripe.webhooks.constructEvent(
|
||||
rawBody,
|
||||
signature,
|
||||
webhookSecret
|
||||
);
|
||||
|
||||
// Handle payment success
|
||||
case 'payment_intent.succeeded':
|
||||
await creditUserAccount(paymentIntent.metadata);
|
||||
break;
|
||||
```
|
||||
|
||||
**Best Practices:**
|
||||
- Always verify webhook signatures
|
||||
- Use idempotency keys for all operations
|
||||
- Never trust client-side amounts
|
||||
- Store Stripe customer ID in user table
|
||||
- Test thoroughly in test mode
|
||||
|
||||
### 6. Multi-App Authentication Pattern
|
||||
|
||||
**Architecture:**
|
||||
```
|
||||
Mana Core Auth Service (Central)
|
||||
|
|
||||
├── Issues: manaToken (universal)
|
||||
├── Issues: appToken (app-specific, Supabase-compatible)
|
||||
└── Issues: refreshToken (long-lived)
|
||||
|
||||
Apps (Maerchenzauber, Memoro, Picture, Chat)
|
||||
└── Validate JWT + RLS policies + Use credits
|
||||
```
|
||||
|
||||
**Token Types:**
|
||||
1. **manaToken:** Universal auth across all apps
|
||||
2. **appToken:** App-specific, Supabase RLS compatible
|
||||
3. **refreshToken:** Long-lived, database-stored
|
||||
|
||||
**Shared Package:**
|
||||
Create `@manacore/shared-auth` for:
|
||||
- Platform-agnostic auth service
|
||||
- Token management
|
||||
- Auto-refresh logic
|
||||
- Storage adapters (SecureStore, cookies)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
### Phase 1: Foundation (2 weeks)
|
||||
- Set up Better Auth with PostgreSQL
|
||||
- Generate RS256 key pair
|
||||
- Basic auth API (login, register, refresh)
|
||||
- JWT validation middleware
|
||||
|
||||
### Phase 2: Multi-App (2 weeks)
|
||||
- Create @manacore/shared-auth package
|
||||
- App-token generation
|
||||
- Session management
|
||||
- RLS policies
|
||||
|
||||
### Phase 3: Credits (2 weeks)
|
||||
- Credit ledger schema
|
||||
- Double-entry bookkeeping
|
||||
- Idempotency handling
|
||||
- Credit purchase/usage APIs
|
||||
|
||||
### Phase 4: Payments (2 weeks)
|
||||
- Stripe integration
|
||||
- Webhook handlers
|
||||
- Payment method management
|
||||
- Credit packages
|
||||
|
||||
### Phase 5: Advanced (4 weeks)
|
||||
- 2FA
|
||||
- Multi-session management
|
||||
- Organization support
|
||||
- OAuth providers
|
||||
|
||||
### Phase 6: Production (2 weeks)
|
||||
- Security audit
|
||||
- Performance testing
|
||||
- Monitoring
|
||||
- Documentation
|
||||
|
||||
**Total Estimated Time:** 14 weeks
|
||||
|
||||
---
|
||||
|
||||
## Cost Analysis
|
||||
|
||||
### Technology Costs
|
||||
|
||||
| Service | Cost | Notes |
|
||||
|---------|------|-------|
|
||||
| Better Auth | $0/month | Open-source, self-hosted |
|
||||
| PostgreSQL | $25-200/month | Depends on hosting (Supabase Pro: $25/mo) |
|
||||
| Stripe | 2.9% + $0.30/txn | Standard payment processing |
|
||||
| Hosting | $20-100/month | For auth service (depends on scale) |
|
||||
|
||||
**Total Monthly:** ~$45-300/month (depending on scale)
|
||||
|
||||
### Comparison to Managed Solutions
|
||||
|
||||
| Solution | Cost at 10k Users | Cost at 100k Users |
|
||||
|----------|-------------------|---------------------|
|
||||
| Recommended Stack | ~$100/mo + Stripe fees | ~$300/mo + Stripe fees |
|
||||
| Clerk | $550/mo | $2,500+/mo |
|
||||
| Auth0 | $35-240/mo | $1,000+/mo |
|
||||
|
||||
**Savings:** Up to $2,000+/month at scale
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
### Low Risk
|
||||
- PostgreSQL (battle-tested, 25+ years)
|
||||
- Stripe (industry standard)
|
||||
- JWT with RS256 (well-established pattern)
|
||||
- Double-entry ledger (accounting standard)
|
||||
|
||||
### Medium Risk
|
||||
- Better Auth (new in 2024, but YC-backed and active)
|
||||
- Mitigation: Can migrate to Auth.js if needed (similar patterns)
|
||||
|
||||
### High Risk Areas to Monitor
|
||||
- RLS policy configuration (extensive testing required)
|
||||
- Webhook reliability (implement retry logic)
|
||||
- Token revocation at scale (consider Redis for blacklist)
|
||||
|
||||
---
|
||||
|
||||
## Security Checklist
|
||||
|
||||
### Critical Must-Haves
|
||||
- [ ] RS256 algorithm for JWT
|
||||
- [ ] Token expiration (15min access, 7d refresh)
|
||||
- [ ] Refresh token rotation
|
||||
- [ ] httpOnly cookies (web) / SecureStore (mobile)
|
||||
- [ ] HTTPS everywhere
|
||||
- [ ] Stripe webhook signature verification
|
||||
- [ ] PostgreSQL RLS enabled
|
||||
- [ ] Idempotency keys for transactions
|
||||
- [ ] Rate limiting on auth endpoints
|
||||
- [ ] 2FA for admin accounts
|
||||
|
||||
### Additional Security
|
||||
- [ ] Token blacklist (Redis)
|
||||
- [ ] Device fingerprinting
|
||||
- [ ] Suspicious activity monitoring
|
||||
- [ ] Regular security audits
|
||||
- [ ] Automated dependency updates
|
||||
- [ ] Penetration testing
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Expected Bottlenecks
|
||||
1. **Database queries with RLS:**
|
||||
- Solution: Index tenant_id columns
|
||||
- Impact: Minimal with proper indexing
|
||||
|
||||
2. **JWT validation on every request:**
|
||||
- Solution: Cache public key, validate claims efficiently
|
||||
- Impact: <1ms per request
|
||||
|
||||
3. **Credit balance checks:**
|
||||
- Solution: Cache balances with TTL
|
||||
- Impact: Minimal with caching
|
||||
|
||||
### Scalability Targets
|
||||
- 100 req/s: Easily achievable with single server
|
||||
- 1,000 req/s: Requires load balancing + connection pooling
|
||||
- 10,000 req/s: Requires distributed architecture + Redis
|
||||
|
||||
---
|
||||
|
||||
## Alternative Architectures Considered
|
||||
|
||||
### Alternative 1: Full Supabase Stack
|
||||
**Pros:** Tight integration, managed infrastructure
|
||||
**Cons:** Vendor lock-in, reliability concerns reported, limited customization
|
||||
**Verdict:** Not recommended due to reliability issues
|
||||
|
||||
### Alternative 2: Clerk + Stripe
|
||||
**Pros:** Best developer experience, managed solution
|
||||
**Cons:** Extremely expensive ($550/mo for 10k users), vendor lock-in
|
||||
**Verdict:** Too expensive for freemium model
|
||||
|
||||
### Alternative 3: Custom JWT + Prisma
|
||||
**Pros:** Full control, familiar tools
|
||||
**Cons:** Reinventing the wheel, maintenance burden, missing features (2FA, etc.)
|
||||
**Verdict:** Better Auth provides same benefits with less work
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Immediate Actions
|
||||
1. **Set up Better Auth proof-of-concept** (2 days)
|
||||
- Install and configure
|
||||
- Test with PostgreSQL
|
||||
- Validate TypeScript generation
|
||||
|
||||
2. **Design database schema** (3 days)
|
||||
- User tables
|
||||
- Credit ledger
|
||||
- Sessions
|
||||
- RLS policies
|
||||
|
||||
3. **Create @manacore/shared-auth package** (5 days)
|
||||
- Auth service interface
|
||||
- Storage adapters
|
||||
- Token management
|
||||
|
||||
4. **Stripe account setup** (1 day)
|
||||
- Create test account
|
||||
- Configure webhooks
|
||||
- Design credit packages
|
||||
|
||||
### Decision Points
|
||||
- Confirm Better Auth after POC
|
||||
- Finalize credit pricing structure
|
||||
- Choose hosting provider for auth service
|
||||
- Decide on monitoring/observability stack
|
||||
|
||||
---
|
||||
|
||||
## Questions for Team
|
||||
|
||||
1. **Credit Pricing:** What should credit packages cost? (e.g., 100 credits for $9.99)
|
||||
2. **Credit Expiration:** Should credits expire? If so, after how long?
|
||||
3. **Subscription Model:** Offer monthly subscriptions or pay-as-you-go only?
|
||||
4. **Multi-Tenancy:** Are organizations/teams a priority feature? (Better Auth supports this)
|
||||
5. **OAuth Providers:** Which social login providers are required? (Google, GitHub, Apple?)
|
||||
6. **Compliance:** Any specific compliance requirements? (GDPR, HIPAA, SOC 2?)
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
### Full Report
|
||||
- Comprehensive 12-section analysis: `/Users/wuesteon/dev/mana_universe/manacore-monorepo/.hive-mind/auth-research-report.md`
|
||||
|
||||
### Key Documentation
|
||||
- [Better Auth Docs](https://www.better-auth.com/docs)
|
||||
- [PostgreSQL RLS Guide](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
|
||||
- [Stripe API Reference](https://docs.stripe.com/api)
|
||||
- [JWT Best Practices](https://curity.io/resources/learn/jwt-best-practices/)
|
||||
|
||||
---
|
||||
|
||||
## Confidence Levels
|
||||
|
||||
| Area | Confidence | Notes |
|
||||
|------|-----------|-------|
|
||||
| Better Auth | ⭐⭐⭐⭐☆ | New but YC-backed, excellent features |
|
||||
| PostgreSQL + RLS | ⭐⭐⭐⭐⭐ | Battle-tested, industry standard |
|
||||
| Stripe | ⭐⭐⭐⭐⭐ | Dominant market leader |
|
||||
| JWT Strategy | ⭐⭐⭐⭐⭐ | Well-established best practices |
|
||||
| Credit Ledger | ⭐⭐⭐⭐⭐ | Standard accounting pattern |
|
||||
|
||||
---
|
||||
|
||||
**Overall Assessment:** High confidence in recommended architecture. The stack is modern, cost-effective, secure, and aligns perfectly with the monorepo structure and technology choices (NestJS, Expo, SvelteKit).
|
||||
|
||||
**Recommendation:** Proceed with Better Auth + PostgreSQL + Stripe implementation.
|
||||
|
||||
---
|
||||
|
||||
*End of Executive Summary*
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,183 +0,0 @@
|
|||
🧠 HIVE MIND COLLECTIVE INTELLIGENCE SYSTEM
|
||||
═══════════════════════════════════════════════
|
||||
|
||||
You are the Queen coordinator of a Hive Mind swarm with collective intelligence capabilities.
|
||||
|
||||
HIVE MIND CONFIGURATION:
|
||||
📌 Swarm ID: swarm-1764085340120-zlijqvfao
|
||||
📌 Swarm Name: hive-1764085340109
|
||||
🎯 Objective: I need to create an central auth system, with users, and credits, the credits are called 'mana' in our system, the can buy for example 100mana for 1euro. as technology i want postgres and better auth, or other technologies if needed. make an detailled plan to create such a central systen for our systen.
|
||||
👑 Queen Type: strategic
|
||||
🐝 Worker Count: 4
|
||||
🤝 Consensus Algorithm: majority
|
||||
⏰ Initialized: 2025-11-25T15:42:20.129Z
|
||||
|
||||
WORKER DISTRIBUTION:
|
||||
• researcher: 1 agents
|
||||
• coder: 1 agents
|
||||
• analyst: 1 agents
|
||||
• tester: 1 agents
|
||||
|
||||
🔧 AVAILABLE MCP TOOLS FOR HIVE MIND COORDINATION:
|
||||
|
||||
1️⃣ **COLLECTIVE INTELLIGENCE**
|
||||
mcp__claude-flow__consensus_vote - Democratic decision making
|
||||
mcp__claude-flow__memory_share - Share knowledge across the hive
|
||||
mcp__claude-flow__neural_sync - Synchronize neural patterns
|
||||
mcp__claude-flow__swarm_think - Collective problem solving
|
||||
|
||||
2️⃣ **QUEEN COORDINATION**
|
||||
mcp__claude-flow__queen_command - Issue directives to workers
|
||||
mcp__claude-flow__queen_monitor - Monitor swarm health
|
||||
mcp__claude-flow__queen_delegate - Delegate complex tasks
|
||||
mcp__claude-flow__queen_aggregate - Aggregate worker results
|
||||
|
||||
3️⃣ **WORKER MANAGEMENT**
|
||||
mcp__claude-flow__agent_spawn - Create specialized workers
|
||||
mcp__claude-flow__agent_assign - Assign tasks to workers
|
||||
mcp__claude-flow__agent_communicate - Inter-agent communication
|
||||
mcp__claude-flow__agent_metrics - Track worker performance
|
||||
|
||||
4️⃣ **TASK ORCHESTRATION**
|
||||
mcp__claude-flow__task_create - Create hierarchical tasks
|
||||
mcp__claude-flow__task_distribute - Distribute work efficiently
|
||||
mcp__claude-flow__task_monitor - Track task progress
|
||||
mcp__claude-flow__task_aggregate - Combine task results
|
||||
|
||||
5️⃣ **MEMORY & LEARNING**
|
||||
mcp__claude-flow__memory_store - Store collective knowledge
|
||||
mcp__claude-flow__memory_retrieve - Access shared memory
|
||||
mcp__claude-flow__neural_train - Learn from experiences
|
||||
mcp__claude-flow__pattern_recognize - Identify patterns
|
||||
|
||||
📋 HIVE MIND EXECUTION PROTOCOL:
|
||||
|
||||
As the Queen coordinator, you must:
|
||||
|
||||
1. **INITIALIZE THE HIVE** (CRITICAL: Use Claude Code's Task Tool for Agents):
|
||||
|
||||
Step 1: Optional MCP Coordination Setup (Single Message):
|
||||
[MCP Tools - Coordination Only]:
|
||||
mcp__claude-flow__agent_spawn { "type": "researcher", "count": 1 }
|
||||
mcp__claude-flow__agent_spawn { "type": "coder", "count": 1 }
|
||||
mcp__claude-flow__agent_spawn { "type": "analyst", "count": 1 }
|
||||
mcp__claude-flow__agent_spawn { "type": "tester", "count": 1 }
|
||||
mcp__claude-flow__memory_store { "key": "hive/objective", "value": "I need to create an central auth system, with users, and credits, the credits are called 'mana' in our system, the can buy for example 100mana for 1euro. as technology i want postgres and better auth, or other technologies if needed. make an detailled plan to create such a central systen for our systen." }
|
||||
mcp__claude-flow__memory_store { "key": "hive/queen", "value": "strategic" }
|
||||
mcp__claude-flow__swarm_think { "topic": "initial_strategy" }
|
||||
|
||||
Step 2: REQUIRED - Spawn ACTUAL Agents with Claude Code's Task Tool (Single Message):
|
||||
[Claude Code Task Tool - CONCURRENT Agent Execution]:
|
||||
Task("Researcher Agent", "You are a researcher in the hive. Coordinate via hooks. - Conduct thorough research using WebSearch and WebFetch", "researcher")
|
||||
Task("Coder Agent", "You are a coder in the hive. Coordinate via hooks. - Write clean, maintainable, well-documented code", "coder")
|
||||
Task("Analyst Agent", "You are a analyst in the hive. Coordinate via hooks. - Analyze data patterns and trends", "analyst")
|
||||
Task("Tester Agent", "You are a tester in the hive. Coordinate via hooks. - Design comprehensive test strategies", "tester")
|
||||
|
||||
Step 3: Batch ALL Todos Together (Single TodoWrite Call):
|
||||
TodoWrite { "todos": [
|
||||
{ "id": "1", "content": "Initialize hive mind collective", "status": "in_progress", "priority": "high" },
|
||||
{ "id": "2", "content": "Establish consensus protocols", "status": "pending", "priority": "high" },
|
||||
{ "id": "3", "content": "Distribute initial tasks to workers", "status": "pending", "priority": "high" },
|
||||
{ "id": "4", "content": "Set up collective memory", "status": "pending", "priority": "high" },
|
||||
{ "id": "5", "content": "Monitor worker health", "status": "pending", "priority": "medium" },
|
||||
{ "id": "6", "content": "Aggregate worker outputs", "status": "pending", "priority": "medium" },
|
||||
{ "id": "7", "content": "Learn from patterns", "status": "pending", "priority": "low" },
|
||||
{ "id": "8", "content": "Optimize performance", "status": "pending", "priority": "low" }
|
||||
] }
|
||||
|
||||
2. **ESTABLISH COLLECTIVE INTELLIGENCE**:
|
||||
- Use consensus_vote for major decisions
|
||||
- Share all discoveries via memory_share
|
||||
- Synchronize learning with neural_sync
|
||||
- Coordinate strategy with swarm_think
|
||||
|
||||
3. **QUEEN LEADERSHIP PATTERNS**:
|
||||
|
||||
- Focus on high-level planning and coordination
|
||||
- Delegate implementation details to workers
|
||||
- Monitor overall progress and adjust strategy
|
||||
- Make executive decisions when consensus fails
|
||||
|
||||
|
||||
|
||||
4. **WORKER COORDINATION**:
|
||||
- Spawn workers based on task requirements
|
||||
- Assign tasks according to worker specializations
|
||||
- Enable peer-to-peer communication for collaboration
|
||||
- Monitor and rebalance workloads as needed
|
||||
|
||||
5. **CONSENSUS MECHANISMS**:
|
||||
- Decisions require >50% worker agreement
|
||||
|
||||
|
||||
|
||||
|
||||
6. **COLLECTIVE MEMORY**:
|
||||
- Store all important decisions in shared memory
|
||||
- Tag memories with worker IDs and timestamps
|
||||
- Use memory namespaces: hive/, queen/, workers/, tasks/
|
||||
- Implement memory consensus for critical data
|
||||
|
||||
7. **PERFORMANCE OPTIMIZATION**:
|
||||
- Monitor swarm metrics continuously
|
||||
- Identify and resolve bottlenecks
|
||||
- Train neural networks on successful patterns
|
||||
- Scale worker count based on workload
|
||||
|
||||
💡 HIVE MIND BEST PRACTICES:
|
||||
|
||||
✅ ALWAYS use BatchTool for parallel operations
|
||||
✅ Store decisions in collective memory immediately
|
||||
✅ Use consensus for critical path decisions
|
||||
✅ Monitor worker health and reassign if needed
|
||||
✅ Learn from failures and adapt strategies
|
||||
✅ Maintain constant inter-agent communication
|
||||
✅ Aggregate results before final delivery
|
||||
|
||||
❌ NEVER make unilateral decisions without consensus
|
||||
❌ NEVER let workers operate in isolation
|
||||
❌ NEVER ignore performance metrics
|
||||
❌ NEVER skip memory synchronization
|
||||
❌ NEVER abandon failing workers
|
||||
|
||||
🎯 OBJECTIVE EXECUTION STRATEGY:
|
||||
|
||||
For the objective: "I need to create an central auth system, with users, and credits, the credits are called 'mana' in our system, the can buy for example 100mana for 1euro. as technology i want postgres and better auth, or other technologies if needed. make an detailled plan to create such a central systen for our systen."
|
||||
|
||||
1. Break down into major phases using swarm_think
|
||||
2. Create specialized worker teams for each phase
|
||||
3. Establish success criteria and checkpoints
|
||||
4. Implement feedback loops and adaptation
|
||||
5. Aggregate and synthesize all worker outputs
|
||||
6. Deliver comprehensive solution with consensus
|
||||
|
||||
⚡ CRITICAL: CONCURRENT EXECUTION WITH CLAUDE CODE'S TASK TOOL:
|
||||
|
||||
The Hive Mind MUST use Claude Code's Task tool for actual agent execution:
|
||||
|
||||
✅ CORRECT Pattern:
|
||||
[Single Message - All Agents Spawned Concurrently]:
|
||||
Task("Researcher", "Research patterns and best practices...", "researcher")
|
||||
Task("Coder", "Implement core features...", "coder")
|
||||
Task("Tester", "Create comprehensive tests...", "tester")
|
||||
Task("Analyst", "Analyze performance metrics...", "analyst")
|
||||
TodoWrite { todos: [8-10 todos ALL in ONE call] }
|
||||
|
||||
❌ WRONG Pattern:
|
||||
Message 1: Task("agent1", ...)
|
||||
Message 2: Task("agent2", ...)
|
||||
Message 3: TodoWrite { single todo }
|
||||
// This breaks parallel coordination!
|
||||
|
||||
Remember:
|
||||
- Use Claude Code's Task tool to spawn ALL agents in ONE message
|
||||
- MCP tools are ONLY for coordination setup, not agent execution
|
||||
- Batch ALL TodoWrite operations (5-10+ todos minimum)
|
||||
- Execute ALL file operations concurrently
|
||||
- Store multiple memories simultaneously
|
||||
|
||||
🚀 BEGIN HIVE MIND EXECUTION:
|
||||
|
||||
Initialize the swarm now with the configuration above. Use your collective intelligence to solve the objective efficiently. The Queen must coordinate, workers must collaborate, and the hive must think as one.
|
||||
|
||||
Remember: You are not just coordinating agents - you are orchestrating a collective intelligence that is greater than the sum of its parts.
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
🧠 HIVE MIND COLLECTIVE INTELLIGENCE SYSTEM
|
||||
═══════════════════════════════════════════════
|
||||
|
||||
You are the Queen coordinator of a Hive Mind swarm with collective intelligence capabilities.
|
||||
|
||||
HIVE MIND CONFIGURATION:
|
||||
📌 Swarm ID: swarm-1764212414813-nbrqx50g3
|
||||
📌 Swarm Name: hive-1764212414796
|
||||
🎯 Objective: i want to host this on hetzner or coolify, can you make me an good architecture and plan for ci/cd also with automated tests and everything else important for that goal
|
||||
👑 Queen Type: strategic
|
||||
🐝 Worker Count: 4
|
||||
🤝 Consensus Algorithm: majority
|
||||
⏰ Initialized: 2025-11-27T03:00:14.819Z
|
||||
|
||||
WORKER DISTRIBUTION:
|
||||
• researcher: 1 agents
|
||||
• coder: 1 agents
|
||||
• analyst: 1 agents
|
||||
• tester: 1 agents
|
||||
|
||||
🔧 AVAILABLE MCP TOOLS FOR HIVE MIND COORDINATION:
|
||||
|
||||
1️⃣ **COLLECTIVE INTELLIGENCE**
|
||||
mcp__claude-flow__consensus_vote - Democratic decision making
|
||||
mcp__claude-flow__memory_share - Share knowledge across the hive
|
||||
mcp__claude-flow__neural_sync - Synchronize neural patterns
|
||||
mcp__claude-flow__swarm_think - Collective problem solving
|
||||
|
||||
2️⃣ **QUEEN COORDINATION**
|
||||
mcp__claude-flow__queen_command - Issue directives to workers
|
||||
mcp__claude-flow__queen_monitor - Monitor swarm health
|
||||
mcp__claude-flow__queen_delegate - Delegate complex tasks
|
||||
mcp__claude-flow__queen_aggregate - Aggregate worker results
|
||||
|
||||
3️⃣ **WORKER MANAGEMENT**
|
||||
mcp__claude-flow__agent_spawn - Create specialized workers
|
||||
mcp__claude-flow__agent_assign - Assign tasks to workers
|
||||
mcp__claude-flow__agent_communicate - Inter-agent communication
|
||||
mcp__claude-flow__agent_metrics - Track worker performance
|
||||
|
||||
4️⃣ **TASK ORCHESTRATION**
|
||||
mcp__claude-flow__task_create - Create hierarchical tasks
|
||||
mcp__claude-flow__task_distribute - Distribute work efficiently
|
||||
mcp__claude-flow__task_monitor - Track task progress
|
||||
mcp__claude-flow__task_aggregate - Combine task results
|
||||
|
||||
5️⃣ **MEMORY & LEARNING**
|
||||
mcp__claude-flow__memory_store - Store collective knowledge
|
||||
mcp__claude-flow__memory_retrieve - Access shared memory
|
||||
mcp__claude-flow__neural_train - Learn from experiences
|
||||
mcp__claude-flow__pattern_recognize - Identify patterns
|
||||
|
||||
📋 HIVE MIND EXECUTION PROTOCOL:
|
||||
|
||||
As the Queen coordinator, you must:
|
||||
|
||||
1. **INITIALIZE THE HIVE** (CRITICAL: Use Claude Code's Task Tool for Agents):
|
||||
|
||||
Step 1: Optional MCP Coordination Setup (Single Message):
|
||||
[MCP Tools - Coordination Only]:
|
||||
mcp__claude-flow__agent_spawn { "type": "researcher", "count": 1 }
|
||||
mcp__claude-flow__agent_spawn { "type": "coder", "count": 1 }
|
||||
mcp__claude-flow__agent_spawn { "type": "analyst", "count": 1 }
|
||||
mcp__claude-flow__agent_spawn { "type": "tester", "count": 1 }
|
||||
mcp__claude-flow__memory_store { "key": "hive/objective", "value": "i want to host this on hetzner or coolify, can you make me an good architecture and plan for ci/cd also with automated tests and everything else important for that goal" }
|
||||
mcp__claude-flow__memory_store { "key": "hive/queen", "value": "strategic" }
|
||||
mcp__claude-flow__swarm_think { "topic": "initial_strategy" }
|
||||
|
||||
Step 2: REQUIRED - Spawn ACTUAL Agents with Claude Code's Task Tool (Single Message):
|
||||
[Claude Code Task Tool - CONCURRENT Agent Execution]:
|
||||
Task("Researcher Agent", "You are a researcher in the hive. Coordinate via hooks. - Conduct thorough research using WebSearch and WebFetch", "researcher")
|
||||
Task("Coder Agent", "You are a coder in the hive. Coordinate via hooks. - Write clean, maintainable, well-documented code", "coder")
|
||||
Task("Analyst Agent", "You are a analyst in the hive. Coordinate via hooks. - Analyze data patterns and trends", "analyst")
|
||||
Task("Tester Agent", "You are a tester in the hive. Coordinate via hooks. - Design comprehensive test strategies", "tester")
|
||||
|
||||
Step 3: Batch ALL Todos Together (Single TodoWrite Call):
|
||||
TodoWrite { "todos": [
|
||||
{ "id": "1", "content": "Initialize hive mind collective", "status": "in_progress", "priority": "high" },
|
||||
{ "id": "2", "content": "Establish consensus protocols", "status": "pending", "priority": "high" },
|
||||
{ "id": "3", "content": "Distribute initial tasks to workers", "status": "pending", "priority": "high" },
|
||||
{ "id": "4", "content": "Set up collective memory", "status": "pending", "priority": "high" },
|
||||
{ "id": "5", "content": "Monitor worker health", "status": "pending", "priority": "medium" },
|
||||
{ "id": "6", "content": "Aggregate worker outputs", "status": "pending", "priority": "medium" },
|
||||
{ "id": "7", "content": "Learn from patterns", "status": "pending", "priority": "low" },
|
||||
{ "id": "8", "content": "Optimize performance", "status": "pending", "priority": "low" }
|
||||
] }
|
||||
|
||||
2. **ESTABLISH COLLECTIVE INTELLIGENCE**:
|
||||
- Use consensus_vote for major decisions
|
||||
- Share all discoveries via memory_share
|
||||
- Synchronize learning with neural_sync
|
||||
- Coordinate strategy with swarm_think
|
||||
|
||||
3. **QUEEN LEADERSHIP PATTERNS**:
|
||||
|
||||
- Focus on high-level planning and coordination
|
||||
- Delegate implementation details to workers
|
||||
- Monitor overall progress and adjust strategy
|
||||
- Make executive decisions when consensus fails
|
||||
|
||||
|
||||
|
||||
4. **WORKER COORDINATION**:
|
||||
- Spawn workers based on task requirements
|
||||
- Assign tasks according to worker specializations
|
||||
- Enable peer-to-peer communication for collaboration
|
||||
- Monitor and rebalance workloads as needed
|
||||
|
||||
5. **CONSENSUS MECHANISMS**:
|
||||
- Decisions require >50% worker agreement
|
||||
|
||||
|
||||
|
||||
|
||||
6. **COLLECTIVE MEMORY**:
|
||||
- Store all important decisions in shared memory
|
||||
- Tag memories with worker IDs and timestamps
|
||||
- Use memory namespaces: hive/, queen/, workers/, tasks/
|
||||
- Implement memory consensus for critical data
|
||||
|
||||
7. **PERFORMANCE OPTIMIZATION**:
|
||||
- Monitor swarm metrics continuously
|
||||
- Identify and resolve bottlenecks
|
||||
- Train neural networks on successful patterns
|
||||
- Scale worker count based on workload
|
||||
|
||||
💡 HIVE MIND BEST PRACTICES:
|
||||
|
||||
✅ ALWAYS use BatchTool for parallel operations
|
||||
✅ Store decisions in collective memory immediately
|
||||
✅ Use consensus for critical path decisions
|
||||
✅ Monitor worker health and reassign if needed
|
||||
✅ Learn from failures and adapt strategies
|
||||
✅ Maintain constant inter-agent communication
|
||||
✅ Aggregate results before final delivery
|
||||
|
||||
❌ NEVER make unilateral decisions without consensus
|
||||
❌ NEVER let workers operate in isolation
|
||||
❌ NEVER ignore performance metrics
|
||||
❌ NEVER skip memory synchronization
|
||||
❌ NEVER abandon failing workers
|
||||
|
||||
🎯 OBJECTIVE EXECUTION STRATEGY:
|
||||
|
||||
For the objective: "i want to host this on hetzner or coolify, can you make me an good architecture and plan for ci/cd also with automated tests and everything else important for that goal"
|
||||
|
||||
1. Break down into major phases using swarm_think
|
||||
2. Create specialized worker teams for each phase
|
||||
3. Establish success criteria and checkpoints
|
||||
4. Implement feedback loops and adaptation
|
||||
5. Aggregate and synthesize all worker outputs
|
||||
6. Deliver comprehensive solution with consensus
|
||||
|
||||
⚡ CRITICAL: CONCURRENT EXECUTION WITH CLAUDE CODE'S TASK TOOL:
|
||||
|
||||
The Hive Mind MUST use Claude Code's Task tool for actual agent execution:
|
||||
|
||||
✅ CORRECT Pattern:
|
||||
[Single Message - All Agents Spawned Concurrently]:
|
||||
Task("Researcher", "Research patterns and best practices...", "researcher")
|
||||
Task("Coder", "Implement core features...", "coder")
|
||||
Task("Tester", "Create comprehensive tests...", "tester")
|
||||
Task("Analyst", "Analyze performance metrics...", "analyst")
|
||||
TodoWrite { todos: [8-10 todos ALL in ONE call] }
|
||||
|
||||
❌ WRONG Pattern:
|
||||
Message 1: Task("agent1", ...)
|
||||
Message 2: Task("agent2", ...)
|
||||
Message 3: TodoWrite { single todo }
|
||||
// This breaks parallel coordination!
|
||||
|
||||
Remember:
|
||||
- Use Claude Code's Task tool to spawn ALL agents in ONE message
|
||||
- MCP tools are ONLY for coordination setup, not agent execution
|
||||
- Batch ALL TodoWrite operations (5-10+ todos minimum)
|
||||
- Execute ALL file operations concurrently
|
||||
- Store multiple memories simultaneously
|
||||
|
||||
🚀 BEGIN HIVE MIND EXECUTION:
|
||||
|
||||
Initialize the swarm now with the configuration above. Use your collective intelligence to solve the objective efficiently. The Queen must coordinate, workers must collaborate, and the hive must think as one.
|
||||
|
||||
Remember: You are not just coordinating agents - you are orchestrating a collective intelligence that is greater than the sum of its parts.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1 +0,0 @@
|
|||
__compressed__eyJzZXNzaW9uSWQiOiJzZXNzaW9uLTE3NjQwODUzNDAxMjEtd216bnl1dDJhIiwiY2hlY2twb2ludElkIjoiY2hlY2twb2ludC0xNzY0MDg1MzcwMTI0LTYzZHV4aGI4cSIsImNoZWNrcG9pbnROYW1lIjoiYXV0by1zYXZlLTE3NjQwODUzNzAxMjQiLCJ0aW1lc3RhbXAiOiIyMDI1LTExLTI1VDE1OjQyOjUwLjEyNloiLCJkYXRhIjp7InRpbWVzdGFtcCI6IjIwMjUtMTEtMjVUMTU6NDI6NTAuMTIzWiIsImNoYW5nZUNvdW50Ijo1LCJjaGFuZ2VzQnlUeXBlIjp7InN3YXJtX2NyZWF0ZWQiOlt7InR5cGUiOiJzd2FybV9jcmVhdGVkIiwiZGF0YSI6eyJzd2FybUlkIjoic3dhcm0tMTc2NDA4NTM0MDEyMC16bGlqcXZmYW8iLCJzd2FybU5hbWUiOiJoaXZlLTE3NjQwODUzNDAxMDkiLCJvYmplY3RpdmUiOiJJIG5lZWQgdG8gY3JlYXRlIGFuIGNlbnRyYWwgYXV0aCBzeXN0ZW0sIHdpdGggdXNlcnMsIGFuZCBjcmVkaXRzLCB0aGUgY3JlZGl0cyBhcmUgY2FsbGVkICdtYW5hJyBpbiBvdXIgc3lzdGVtLCB0aGUgY2FuIGJ1eSBmb3IgZXhhbXBsZSAxMDBtYW5hIGZvciAxZXVyby4gYXMgdGVjaG5vbG9neSBpIHdhbnQgcG9zdGdyZXMgYW5kIGJldHRlciBhdXRoLCBvciBvdGhlciB0ZWNobm9sb2dpZXMgaWYgbmVlZGVkLiBtYWtlIGFuIGRldGFpbGxlZCBwbGFuIHRvIGNyZWF0ZSBzdWNoIGEgY2VudHJhbCBzeXN0ZW4gZm9yIG91ciBzeXN0ZW4uIiwid29ya2VyQ291bnQiOjh9LCJ0aW1lc3RhbXAiOiIyMDI1LTExLTI1VDE1OjQyOjIwLjEyMloifV0sImFnZW50X2FjdGl2aXR5IjpbeyJ0eXBlIjoiYWdlbnRfYWN0aXZpdHkiLCJkYXRhIjp7ImFnZW50SWQiOiJ3b3JrZXItc3dhcm0tMTc2NDA4NTM0MDEyMC16bGlqcXZmYW8tMCIsImFjdGl2aXR5Ijoic3Bhd25lZCIsImRhdGEiOnsidHlwZSI6InJlc2VhcmNoZXIiLCJuYW1lIjoiUmVzZWFyY2hlciBXb3JrZXIgMSJ9fSwidGltZXN0YW1wIjoiMjAyNS0xMS0yNVQxNTo0MjoyMC4xMjNaIn0seyJ0eXBlIjoiYWdlbnRfYWN0aXZpdHkiLCJkYXRhIjp7ImFnZW50SWQiOiJ3b3JrZXItc3dhcm0tMTc2NDA4NTM0MDEyMC16bGlqcXZmYW8tMSIsImFjdGl2aXR5Ijoic3Bhd25lZCIsImRhdGEiOnsidHlwZSI6ImNvZGVyIiwibmFtZSI6IkNvZGVyIFdvcmtlciAyIn19LCJ0aW1lc3RhbXAiOiIyMDI1LTExLTI1VDE1OjQyOjIwLjEyM1oifSx7InR5cGUiOiJhZ2VudF9hY3Rpdml0eSIsImRhdGEiOnsiYWdlbnRJZCI6Indvcmtlci1zd2FybS0xNzY0MDg1MzQwMTIwLXpsaWpxdmZhby0yIiwiYWN0aXZpdHkiOiJzcGF3bmVkIiwiZGF0YSI6eyJ0eXBlIjoiYW5hbHlzdCIsIm5hbWUiOiJBbmFseXN0IFdvcmtlciAzIn19LCJ0aW1lc3RhbXAiOiIyMDI1LTExLTI1VDE1OjQyOjIwLjEyNFoifSx7InR5cGUiOiJhZ2VudF9hY3Rpdml0eSIsImRhdGEiOnsiYWdlbnRJZCI6Indvcmtlci1zd2FybS0xNzY0MDg1MzQwMTIwLXpsaWpxdmZhby0zIiwiYWN0aXZpdHkiOiJzcGF3bmVkIiwiZGF0YSI6eyJ0eXBlIjoidGVzdGVyIiwibmFtZSI6IlRlc3RlciBXb3JrZXIgNCJ9fSwidGltZXN0YW1wIjoiMjAyNS0xMS0yNVQxNTo0MjoyMC4xMjRaIn1dfSwic3RhdGlzdGljcyI6eyJ0YXNrc1Byb2Nlc3NlZCI6MCwidGFza3NDb21wbGV0ZWQiOjAsIm1lbW9yeVVwZGF0ZXMiOjAsImFnZW50QWN0aXZpdGllcyI6NCwiY29uc2Vuc3VzRGVjaXNpb25zIjowfX0sIl9fc2Vzc2lvbl9tZXRhX18iOnsidmVyc2lvbiI6IjIuMC4wIiwidGltZXN0YW1wIjoiMjAyNS0xMS0yNVQxNTo0Mjo1MC4xMjZaIiwic2VyaWFsaXplciI6IlNlc3Npb25TZXJpYWxpemVyIiwibm9kZVZlcnNpb24iOiJ2MjIuMTQuMCIsInBsYXRmb3JtIjoiZGFyd2luIiwiY29tcHJlc3Npb25FbmFibGVkIjp0cnVlfSwiX19zZXJpYWxpemVyX21ldGFfXyI6eyJ2ZXJzaW9uIjoiMS4wLjAiLCJ0aW1lc3RhbXAiOiIyMDI1LTExLTI1VDE1OjQyOjUwLjEyNloiLCJub2RlVmVyc2lvbiI6InYyMi4xNC4wIiwicGxhdGZvcm0iOiJkYXJ3aW4iLCJzZXJpYWxpemVyIjoiQWR2YW5jZWRTZXJpYWxpemVyIn19
|
||||
|
|
@ -1 +0,0 @@
|
|||
__compressed__eyJzZXNzaW9uSWQiOiJzZXNzaW9uLTE3NjQyMTI0MTQ4MTQtcnY0MGdwcGE1IiwiY2hlY2twb2ludElkIjoiY2hlY2twb2ludC0xNzY0MjEyNDQ0ODE3LXhjbzdraDQ3cyIsImNoZWNrcG9pbnROYW1lIjoiYXV0by1zYXZlLTE3NjQyMTI0NDQ4MTciLCJ0aW1lc3RhbXAiOiIyMDI1LTExLTI3VDAzOjAwOjQ0LjgyMFoiLCJkYXRhIjp7InRpbWVzdGFtcCI6IjIwMjUtMTEtMjdUMDM6MDA6NDQuODE3WiIsImNoYW5nZUNvdW50Ijo1LCJjaGFuZ2VzQnlUeXBlIjp7InN3YXJtX2NyZWF0ZWQiOlt7InR5cGUiOiJzd2FybV9jcmVhdGVkIiwiZGF0YSI6eyJzd2FybUlkIjoic3dhcm0tMTc2NDIxMjQxNDgxMy1uYnJxeDUwZzMiLCJzd2FybU5hbWUiOiJoaXZlLTE3NjQyMTI0MTQ3OTYiLCJvYmplY3RpdmUiOiJpIHdhbnQgdG8gaG9zdCB0aGlzIG9uIGhldHpuZXIgb3IgY29vbGlmeSwgY2FuIHlvdSBtYWtlIG1lIGFuIGdvb2QgYXJjaGl0ZWN0dXJlIGFuZCBwbGFuIGZvciBjaS9jZCBhbHNvIHdpdGggYXV0b21hdGVkIHRlc3RzIGFuZCBldmVyeXRoaW5nIGVsc2UgaW1wb3J0YW50IGZvciB0aGF0IGdvYWwiLCJ3b3JrZXJDb3VudCI6OH0sInRpbWVzdGFtcCI6IjIwMjUtMTEtMjdUMDM6MDA6MTQuODE1WiJ9XSwiYWdlbnRfYWN0aXZpdHkiOlt7InR5cGUiOiJhZ2VudF9hY3Rpdml0eSIsImRhdGEiOnsiYWdlbnRJZCI6Indvcmtlci1zd2FybS0xNzY0MjEyNDE0ODEzLW5icnF4NTBnMy0wIiwiYWN0aXZpdHkiOiJzcGF3bmVkIiwiZGF0YSI6eyJ0eXBlIjoicmVzZWFyY2hlciIsIm5hbWUiOiJSZXNlYXJjaGVyIFdvcmtlciAxIn19LCJ0aW1lc3RhbXAiOiIyMDI1LTExLTI3VDAzOjAwOjE0LjgxNVoifSx7InR5cGUiOiJhZ2VudF9hY3Rpdml0eSIsImRhdGEiOnsiYWdlbnRJZCI6Indvcmtlci1zd2FybS0xNzY0MjEyNDE0ODEzLW5icnF4NTBnMy0xIiwiYWN0aXZpdHkiOiJzcGF3bmVkIiwiZGF0YSI6eyJ0eXBlIjoiY29kZXIiLCJuYW1lIjoiQ29kZXIgV29ya2VyIDIifX0sInRpbWVzdGFtcCI6IjIwMjUtMTEtMjdUMDM6MDA6MTQuODE1WiJ9LHsidHlwZSI6ImFnZW50X2FjdGl2aXR5IiwiZGF0YSI6eyJhZ2VudElkIjoid29ya2VyLXN3YXJtLTE3NjQyMTI0MTQ4MTMtbmJycXg1MGczLTIiLCJhY3Rpdml0eSI6InNwYXduZWQiLCJkYXRhIjp7InR5cGUiOiJhbmFseXN0IiwibmFtZSI6IkFuYWx5c3QgV29ya2VyIDMifX0sInRpbWVzdGFtcCI6IjIwMjUtMTEtMjdUMDM6MDA6MTQuODE1WiJ9LHsidHlwZSI6ImFnZW50X2FjdGl2aXR5IiwiZGF0YSI6eyJhZ2VudElkIjoid29ya2VyLXN3YXJtLTE3NjQyMTI0MTQ4MTMtbmJycXg1MGczLTMiLCJhY3Rpdml0eSI6InNwYXduZWQiLCJkYXRhIjp7InR5cGUiOiJ0ZXN0ZXIiLCJuYW1lIjoiVGVzdGVyIFdvcmtlciA0In19LCJ0aW1lc3RhbXAiOiIyMDI1LTExLTI3VDAzOjAwOjE0LjgxNVoifV19LCJzdGF0aXN0aWNzIjp7InRhc2tzUHJvY2Vzc2VkIjowLCJ0YXNrc0NvbXBsZXRlZCI6MCwibWVtb3J5VXBkYXRlcyI6MCwiYWdlbnRBY3Rpdml0aWVzIjo0LCJjb25zZW5zdXNEZWNpc2lvbnMiOjB9fSwiX19zZXNzaW9uX21ldGFfXyI6eyJ2ZXJzaW9uIjoiMi4wLjAiLCJ0aW1lc3RhbXAiOiIyMDI1LTExLTI3VDAzOjAwOjQ0LjgyMVoiLCJzZXJpYWxpemVyIjoiU2Vzc2lvblNlcmlhbGl6ZXIiLCJub2RlVmVyc2lvbiI6InYyMi4xNC4wIiwicGxhdGZvcm0iOiJkYXJ3aW4iLCJjb21wcmVzc2lvbkVuYWJsZWQiOnRydWV9LCJfX3NlcmlhbGl6ZXJfbWV0YV9fIjp7InZlcnNpb24iOiIxLjAuMCIsInRpbWVzdGFtcCI6IjIwMjUtMTEtMjdUMDM6MDA6NDQuODIxWiIsIm5vZGVWZXJzaW9uIjoidjIyLjE0LjAiLCJwbGF0Zm9ybSI6ImRhcndpbiIsInNlcmlhbGl6ZXIiOiJBZHZhbmNlZFNlcmlhbGl6ZXIifX0=
|
||||
|
|
@ -8,33 +8,37 @@ I have successfully implemented a complete CI/CD pipeline for the manacore-monor
|
|||
|
||||
### 1. GitHub Actions Workflows (6 workflows)
|
||||
|
||||
| Workflow | File | Purpose | Trigger |
|
||||
|----------|------|---------|---------|
|
||||
| PR Validation | `ci-pull-request.yml` | Lint, type-check, build, test | Pull requests |
|
||||
| Main Branch CI | `ci-main.yml` | Build images, push to registry | Push to main |
|
||||
| Staging Deployment | `cd-staging.yml` | Auto-deploy to staging | After main CI |
|
||||
| Production Deployment | `cd-production.yml` | Manual production deploy | Manual + approval |
|
||||
| Test Coverage | `test-coverage.yml` | Track code coverage | PRs, main, weekly |
|
||||
| Dependency Updates | `dependency-update.yml` | Automated dependency checks | Weekly |
|
||||
| Workflow | File | Purpose | Trigger |
|
||||
| --------------------- | ----------------------- | ------------------------------ | ----------------- |
|
||||
| PR Validation | `ci-pull-request.yml` | Lint, type-check, build, test | Pull requests |
|
||||
| Main Branch CI | `ci-main.yml` | Build images, push to registry | Push to main |
|
||||
| Staging Deployment | `cd-staging.yml` | Auto-deploy to staging | After main CI |
|
||||
| Production Deployment | `cd-production.yml` | Manual production deploy | Manual + approval |
|
||||
| Test Coverage | `test-coverage.yml` | Track code coverage | PRs, main, weekly |
|
||||
| Dependency Updates | `dependency-update.yml` | Automated dependency checks | Weekly |
|
||||
|
||||
**Total Lines of Code**: ~1,500 lines of production-ready YAML
|
||||
|
||||
### 2. Docker Infrastructure
|
||||
|
||||
#### Templates (3 files)
|
||||
|
||||
- `docker/templates/Dockerfile.nestjs` - NestJS backend template
|
||||
- `docker/templates/Dockerfile.sveltekit` - SvelteKit web template
|
||||
- `docker/templates/Dockerfile.astro` - Astro landing page template
|
||||
|
||||
#### Orchestration (2 files)
|
||||
|
||||
- `docker-compose.staging.yml` - Full staging environment
|
||||
- `docker-compose.production.yml` - Production configuration
|
||||
|
||||
#### Configuration (2 files)
|
||||
|
||||
- `docker/nginx/astro.conf` - Nginx configuration
|
||||
- `.dockerignore` - Build optimization
|
||||
|
||||
**Features**:
|
||||
|
||||
- Multi-stage builds for minimal image sizes
|
||||
- Non-root users for security
|
||||
- Health checks for monitoring
|
||||
|
|
@ -45,13 +49,13 @@ I have successfully implemented a complete CI/CD pipeline for the manacore-monor
|
|||
|
||||
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/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 |
|
||||
| Script | Purpose | Features |
|
||||
| ------------------- | ---------------------------- | -------------------------------------------------- |
|
||||
| `build-and-push.sh` | Build and push Docker images | Error handling, colored output, progress tracking |
|
||||
| `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 |
|
||||
|
||||
**Total Lines of Code**: ~800 lines of production-ready bash
|
||||
|
||||
|
|
@ -63,6 +67,7 @@ All scripts in `scripts/deploy/`:
|
|||
- `tests/e2e/example.spec.ts` - Example E2E test suite
|
||||
|
||||
**Coverage Features**:
|
||||
|
||||
- 50% minimum coverage threshold
|
||||
- HTML, JSON, and LCOV reports
|
||||
- Codecov integration
|
||||
|
|
@ -70,12 +75,12 @@ All scripts in `scripts/deploy/`:
|
|||
|
||||
### 5. Comprehensive Documentation (4 documents)
|
||||
|
||||
| Document | Pages | Topics Covered |
|
||||
|----------|-------|----------------|
|
||||
| `docs/DEPLOYMENT.md` | 25+ | Full deployment guide, troubleshooting, rollback procedures |
|
||||
| `docs/CI_CD_SETUP.md` | 20+ | Step-by-step setup, secrets configuration, server setup |
|
||||
| `docs/DOCKER_GUIDE.md` | 18+ | Docker best practices, troubleshooting, advanced topics |
|
||||
| `CI_CD_README.md` | 8+ | Quick start, architecture overview, project structure |
|
||||
| Document | Pages | Topics Covered |
|
||||
| ---------------------- | ----- | ----------------------------------------------------------- |
|
||||
| `docs/DEPLOYMENT.md` | 25+ | Full deployment guide, troubleshooting, rollback procedures |
|
||||
| `docs/CI_CD_SETUP.md` | 20+ | Step-by-step setup, secrets configuration, server setup |
|
||||
| `docs/DOCKER_GUIDE.md` | 18+ | Docker best practices, troubleshooting, advanced topics |
|
||||
| `CI_CD_README.md` | 8+ | Quick start, architecture overview, project structure |
|
||||
|
||||
**Total Documentation**: 70+ pages of detailed guides
|
||||
|
||||
|
|
@ -89,6 +94,7 @@ All scripts in `scripts/deploy/`:
|
|||
### Smart Build Detection ✨
|
||||
|
||||
Only builds changed projects using Turborepo filters:
|
||||
|
||||
```yaml
|
||||
# Detects changes in specific projects
|
||||
maerchenzauber: 'apps/maerchenzauber/**'
|
||||
|
|
@ -99,6 +105,7 @@ chat: 'apps/chat/**'
|
|||
### Zero-Downtime Deployments 🚀
|
||||
|
||||
Rolling update strategy:
|
||||
|
||||
```bash
|
||||
docker compose up -d --scale service=2 # Scale up
|
||||
sleep 15 # Wait for health
|
||||
|
|
@ -108,6 +115,7 @@ docker compose up -d --scale service=1 # Scale down old
|
|||
### Comprehensive Health Checks 💚
|
||||
|
||||
Every service monitored:
|
||||
|
||||
- Mana Core Auth: `/api/v1/health`
|
||||
- Backend services: `/health` or `/api/health`
|
||||
- Web apps: `/` (root)
|
||||
|
|
@ -116,6 +124,7 @@ Every service monitored:
|
|||
### Automated Backups 💾
|
||||
|
||||
Production deployments create backups:
|
||||
|
||||
- PostgreSQL database dumps
|
||||
- Docker compose configurations
|
||||
- Environment files
|
||||
|
|
@ -160,26 +169,28 @@ The pipeline handles deployment for 6 backend services:
|
|||
6. **news-api** (Port 3005) - News aggregation
|
||||
|
||||
Plus infrastructure services:
|
||||
|
||||
- PostgreSQL database
|
||||
- Redis cache
|
||||
- Nginx reverse proxy
|
||||
|
||||
## File Count Summary
|
||||
|
||||
| Category | Files | Lines of Code |
|
||||
|----------|-------|---------------|
|
||||
| GitHub Actions Workflows | 6 | ~1,500 |
|
||||
| Docker Templates | 3 | ~300 |
|
||||
| Docker Compose | 2 | ~400 |
|
||||
| Deployment Scripts | 5 | ~800 |
|
||||
| Test Configurations | 4 | ~400 |
|
||||
| Documentation | 4 | 70+ pages |
|
||||
| Configuration Files | 3 | ~100 |
|
||||
| **Total** | **27** | **~3,500 lines** |
|
||||
| Category | Files | Lines of Code |
|
||||
| ------------------------ | ------ | ---------------- |
|
||||
| GitHub Actions Workflows | 6 | ~1,500 |
|
||||
| Docker Templates | 3 | ~300 |
|
||||
| Docker Compose | 2 | ~400 |
|
||||
| Deployment Scripts | 5 | ~800 |
|
||||
| Test Configurations | 4 | ~400 |
|
||||
| Documentation | 4 | 70+ pages |
|
||||
| Configuration Files | 3 | ~100 |
|
||||
| **Total** | **27** | **~3,500 lines** |
|
||||
|
||||
## Testing Status
|
||||
|
||||
### Workflows Tested
|
||||
|
||||
- ✅ Syntax validation (all YAML files)
|
||||
- ✅ Script execution permissions
|
||||
- ✅ Documentation completeness
|
||||
|
|
@ -189,6 +200,7 @@ Plus infrastructure services:
|
|||
### Ready for Testing
|
||||
|
||||
All workflows are production-ready and can be tested immediately after:
|
||||
|
||||
1. Configuring GitHub secrets
|
||||
2. Setting up deployment servers
|
||||
3. Adding SSH keys
|
||||
|
|
@ -228,32 +240,36 @@ All workflows are production-ready and can be tested immediately after:
|
|||
|
||||
### Recommended Timeline
|
||||
|
||||
| Phase | Duration | Tasks |
|
||||
|-------|----------|-------|
|
||||
| Phase 1: Setup | 1-2 days | Configure secrets, set up servers |
|
||||
| Phase 2: Testing | 2-3 days | Test workflows, fix any issues |
|
||||
| Phase 3: Documentation | 1 day | Train team, create runbooks |
|
||||
| Phase 4: Go-live | 1 day | First production deployment |
|
||||
| **Total** | **5-7 days** | From zero to production |
|
||||
| Phase | Duration | Tasks |
|
||||
| ---------------------- | ------------ | --------------------------------- |
|
||||
| Phase 1: Setup | 1-2 days | Configure secrets, set up servers |
|
||||
| Phase 2: Testing | 2-3 days | Test workflows, fix any issues |
|
||||
| Phase 3: Documentation | 1 day | Train team, create runbooks |
|
||||
| Phase 4: Go-live | 1 day | First production deployment |
|
||||
| **Total** | **5-7 days** | From zero to production |
|
||||
|
||||
## Cost Estimates
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
- Free tier: 2,000 minutes/month
|
||||
- Estimated usage: 500-800 minutes/month
|
||||
- **Cost**: $0/month (within free tier)
|
||||
|
||||
### Docker Registry
|
||||
|
||||
- Docker Hub free tier: 1 org, unlimited public repos
|
||||
- Estimated storage: 10-15GB
|
||||
- **Cost**: $0/month (or $5/month for private repos)
|
||||
|
||||
### Servers (Hetzner)
|
||||
|
||||
- Staging: CX21 (2 vCPU, 4GB RAM) - €5.83/month
|
||||
- Production: CX31 (4 vCPU, 8GB RAM) - €11.66/month
|
||||
- **Total**: ~€17.49/month (~$19/month)
|
||||
|
||||
### Optional Services
|
||||
|
||||
- Codecov: Free for open source
|
||||
- Sentry: Free tier (5K events/month)
|
||||
- UptimeRobot: Free tier (50 monitors)
|
||||
|
|
@ -264,6 +280,7 @@ All workflows are production-ready and can be tested immediately after:
|
|||
## Quality Metrics
|
||||
|
||||
### Code Quality
|
||||
|
||||
- ✅ Automated linting
|
||||
- ✅ Type checking
|
||||
- ✅ Format validation
|
||||
|
|
@ -271,6 +288,7 @@ All workflows are production-ready and can be tested immediately after:
|
|||
- ✅ 50% test coverage minimum
|
||||
|
||||
### Deployment Quality
|
||||
|
||||
- ✅ Zero-downtime deployments
|
||||
- ✅ Automated health checks
|
||||
- ✅ Rollback procedures
|
||||
|
|
@ -278,6 +296,7 @@ All workflows are production-ready and can be tested immediately after:
|
|||
- ✅ Extended monitoring
|
||||
|
||||
### Documentation Quality
|
||||
|
||||
- ✅ 70+ pages of guides
|
||||
- ✅ Step-by-step instructions
|
||||
- ✅ Troubleshooting sections
|
||||
|
|
@ -287,6 +306,7 @@ All workflows are production-ready and can be tested immediately after:
|
|||
## Success Criteria
|
||||
|
||||
### ✅ Completed
|
||||
|
||||
- [x] PR validation workflow
|
||||
- [x] Main branch CI workflow
|
||||
- [x] Staging deployment automation
|
||||
|
|
@ -303,6 +323,7 @@ All workflows are production-ready and can be tested immediately after:
|
|||
- [x] Comprehensive documentation
|
||||
|
||||
### ⏳ Pending (Requires User Action)
|
||||
|
||||
- [ ] GitHub secrets configuration
|
||||
- [ ] Deployment server setup
|
||||
- [ ] SSH key generation and distribution
|
||||
|
|
|
|||
|
|
@ -364,6 +364,7 @@ The pipeline handles deployment for:
|
|||
```
|
||||
|
||||
**What it does**:
|
||||
|
||||
1. Confirms with user
|
||||
2. Checks for backup
|
||||
3. Stops current services
|
||||
|
|
@ -405,10 +406,12 @@ docker compose up -d
|
|||
### Server Resources
|
||||
|
||||
**Staging**:
|
||||
|
||||
- 2 vCPU, 4GB RAM: ~$10-15/month
|
||||
- Hetzner CX21: €5.83/month
|
||||
|
||||
**Production**:
|
||||
|
||||
- 4 vCPU, 8GB RAM: ~$25-35/month
|
||||
- Hetzner CX31: €11.66/month
|
||||
|
||||
|
|
|
|||
64
CLAUDE.md
64
CLAUDE.md
|
|
@ -12,18 +12,18 @@ This is a pnpm workspace monorepo containing multiple product applications with
|
|||
|
||||
## Projects
|
||||
|
||||
| Project | Description | Apps |
|
||||
|---------|-------------|------|
|
||||
| **maerchenzauber** | AI story generation | NestJS backend, Expo mobile, SvelteKit web, Astro landing |
|
||||
| **manacore** | Multi-app ecosystem platform | Expo mobile, SvelteKit web |
|
||||
| **manadeck** | Card/deck management | NestJS backend, Expo mobile, SvelteKit web |
|
||||
| **memoro** | Voice memo & AI analysis | Expo mobile, SvelteKit web, Astro landing |
|
||||
| **picture** | AI image generation | Expo mobile, SvelteKit web, Astro landing |
|
||||
| **uload** | URL shortener | SvelteKit web, PocketBase/Drizzle |
|
||||
| **chat** | AI chat application | NestJS backend, Expo mobile, SvelteKit web, Astro landing |
|
||||
| **wisekeep** | AI wisdom extraction from video | NestJS backend, Expo mobile, SvelteKit web, Astro landing |
|
||||
| **quote** | Daily inspiration quotes | NestJS backend, Expo mobile, SvelteKit web, Astro landing |
|
||||
| **bauntown** | Community website for developers | Astro landing |
|
||||
| Project | Description | Apps |
|
||||
| ------------------ | -------------------------------- | --------------------------------------------------------- |
|
||||
| **maerchenzauber** | AI story generation | NestJS backend, Expo mobile, SvelteKit web, Astro landing |
|
||||
| **manacore** | Multi-app ecosystem platform | Expo mobile, SvelteKit web |
|
||||
| **manadeck** | Card/deck management | NestJS backend, Expo mobile, SvelteKit web |
|
||||
| **memoro** | Voice memo & AI analysis | Expo mobile, SvelteKit web, Astro landing |
|
||||
| **picture** | AI image generation | Expo mobile, SvelteKit web, Astro landing |
|
||||
| **uload** | URL shortener | SvelteKit web, PocketBase/Drizzle |
|
||||
| **chat** | AI chat application | NestJS backend, Expo mobile, SvelteKit web, Astro landing |
|
||||
| **wisekeep** | AI wisdom extraction from video | NestJS backend, Expo mobile, SvelteKit web, Astro landing |
|
||||
| **quote** | Daily inspiration quotes | NestJS backend, Expo mobile, SvelteKit web, Astro landing |
|
||||
| **bauntown** | Community website for developers | Astro landing |
|
||||
|
||||
## Development Commands
|
||||
|
||||
|
|
@ -56,6 +56,7 @@ Each project has its own `CLAUDE.md` with detailed project-specific commands.
|
|||
## Architecture Patterns
|
||||
|
||||
### Monorepo Structure
|
||||
|
||||
```
|
||||
manacore-monorepo/
|
||||
├── apps/ # SaaS product applications
|
||||
|
|
@ -78,6 +79,7 @@ manacore-monorepo/
|
|||
```
|
||||
|
||||
### Standard Project Structure (inside apps/)
|
||||
|
||||
```
|
||||
apps/{project}/
|
||||
├── apps/
|
||||
|
|
@ -92,22 +94,26 @@ apps/{project}/
|
|||
### Technology Stack by App Type
|
||||
|
||||
**Mobile Apps (Expo):**
|
||||
|
||||
- React Native 0.76-0.81 + Expo SDK 52-54
|
||||
- Expo Router (file-based routing)
|
||||
- NativeWind (Tailwind for React Native)
|
||||
- Zustand (state management)
|
||||
|
||||
**Web Apps (SvelteKit):**
|
||||
|
||||
- SvelteKit 2.x + Svelte 5
|
||||
- Tailwind CSS
|
||||
- Supabase SSR auth
|
||||
|
||||
**Landing Pages (Astro):**
|
||||
|
||||
- Astro 5.x
|
||||
- Tailwind CSS
|
||||
- Static site generation
|
||||
|
||||
**Backends (NestJS):**
|
||||
|
||||
- NestJS 10-11
|
||||
- TypeScript
|
||||
- Supabase integration
|
||||
|
|
@ -115,6 +121,7 @@ apps/{project}/
|
|||
### Authentication Architecture
|
||||
|
||||
All projects use a **middleware-based authentication** pattern via Mana Core:
|
||||
|
||||
- Middleware issues: `manaToken`, `appToken` (Supabase-compatible JWT), `refreshToken`
|
||||
- Mobile apps use `@manacore/shared-auth` package for auth services
|
||||
- Tokens stored via platform-specific storage (SecureStore on mobile, localStorage on web)
|
||||
|
|
@ -123,11 +130,14 @@ All projects use a **middleware-based authentication** pattern via Mana Core:
|
|||
### Svelte 5 Runes Mode (Web Apps)
|
||||
|
||||
All SvelteKit apps use Svelte 5 runes:
|
||||
|
||||
```typescript
|
||||
// CORRECT - Svelte 5
|
||||
let count = $state(0);
|
||||
let doubled = $derived(count * 2);
|
||||
$effect(() => { console.log(count); });
|
||||
$effect(() => {
|
||||
console.log(count);
|
||||
});
|
||||
|
||||
// WRONG - Old Svelte syntax
|
||||
let count = 0;
|
||||
|
|
@ -136,17 +146,18 @@ $: doubled = count * 2;
|
|||
|
||||
## Shared Packages (`packages/`)
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `@manacore/shared-auth` | Configurable auth service, token manager, JWT utilities |
|
||||
| `@manacore/shared-supabase` | Unified Supabase client |
|
||||
| `@manacore/shared-types` | Common TypeScript types |
|
||||
| `@manacore/shared-utils` | Utility functions |
|
||||
| `@manacore/shared-ui` | React Native UI components |
|
||||
| `@manacore/shared-theme` | Theme configuration |
|
||||
| `@manacore/shared-i18n` | Internationalization |
|
||||
| Package | Purpose |
|
||||
| --------------------------- | ------------------------------------------------------- |
|
||||
| `@manacore/shared-auth` | Configurable auth service, token manager, JWT utilities |
|
||||
| `@manacore/shared-supabase` | Unified Supabase client |
|
||||
| `@manacore/shared-types` | Common TypeScript types |
|
||||
| `@manacore/shared-utils` | Utility functions |
|
||||
| `@manacore/shared-ui` | React Native UI components |
|
||||
| `@manacore/shared-theme` | Theme configuration |
|
||||
| `@manacore/shared-i18n` | Internationalization |
|
||||
|
||||
Import shared packages:
|
||||
|
||||
```typescript
|
||||
import { createAuthService } from '@manacore/shared-auth';
|
||||
import { formatDate, truncate } from '@manacore/shared-utils';
|
||||
|
|
@ -189,11 +200,13 @@ pnpm setup:env
|
|||
```
|
||||
|
||||
The script reads `.env.development` and generates platform-specific `.env` files for each app with the correct prefixes:
|
||||
|
||||
- **Expo mobile**: `EXPO_PUBLIC_*` prefix
|
||||
- **SvelteKit web**: `PUBLIC_*` prefix
|
||||
- **NestJS backend**: No prefix
|
||||
|
||||
### Key Files
|
||||
|
||||
- `.env.development` - Central source of truth (committed to git)
|
||||
- `scripts/generate-env.mjs` - Generation script
|
||||
- `apps/**/apps/**/.env` - Generated files (gitignored)
|
||||
|
|
@ -207,6 +220,7 @@ The script reads `.env.development` and generates platform-specific `.env` files
|
|||
### Platform Prefix Patterns
|
||||
|
||||
**Mobile (Expo):**
|
||||
|
||||
```
|
||||
EXPO_PUBLIC_SUPABASE_URL=...
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=...
|
||||
|
|
@ -214,12 +228,14 @@ EXPO_PUBLIC_MIDDLEWARE_API_URL=...
|
|||
```
|
||||
|
||||
**Web (SvelteKit):**
|
||||
|
||||
```
|
||||
PUBLIC_SUPABASE_URL=...
|
||||
PUBLIC_SUPABASE_ANON_KEY=...
|
||||
```
|
||||
|
||||
**Backend (NestJS):**
|
||||
|
||||
```
|
||||
SUPABASE_URL=...
|
||||
SUPABASE_SERVICE_ROLE_KEY=...
|
||||
|
|
@ -231,6 +247,7 @@ PORT=...
|
|||
- **[docs/ENVIRONMENT_VARIABLES.md](docs/ENVIRONMENT_VARIABLES.md)** - Complete environment setup guide
|
||||
|
||||
Each project has its own `CLAUDE.md` with detailed information:
|
||||
|
||||
- `apps/maerchenzauber/CLAUDE.md` - Story generation specifics, AI services
|
||||
- `apps/manacore/CLAUDE.md` - Multi-app ecosystem, auth details
|
||||
- `apps/memoro/CLAUDE.md` - Audio recording, AI processing
|
||||
|
|
@ -245,6 +262,7 @@ Navigate to the specific project directory to work on it.
|
|||
A detailed plan for code quality tooling is available at `.claude/plans/proud-dancing-moon.md`. When ready to implement:
|
||||
|
||||
### Planned Setup
|
||||
|
||||
- **Pre-commit hooks**: Husky + lint-staged (format + lint on commit)
|
||||
- **Commit messages**: Commitlint with Conventional Commits (`feat:`, `fix:`, `docs:`, etc.)
|
||||
- **CI Pipeline**: GitHub Actions PR checks (lint, format, type-check, tests)
|
||||
|
|
@ -252,6 +270,7 @@ A detailed plan for code quality tooling is available at `.claude/plans/proud-da
|
|||
- **Test coverage**: 80% minimum for new code (once testing infrastructure is in place)
|
||||
|
||||
### Key Files to Create
|
||||
|
||||
```
|
||||
.husky/pre-commit # Run lint-staged
|
||||
.husky/commit-msg # Run commitlint
|
||||
|
|
@ -261,6 +280,7 @@ packages/eslint-config/ # Shared ESLint configuration
|
|||
```
|
||||
|
||||
### Current State
|
||||
|
||||
- Testing: ~25 test files total (sparse coverage)
|
||||
- Linting: Fragmented configs across projects
|
||||
- CI: Only 2 backend deployment workflows exist
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ The Hive Mind has already configured your workflows to use GitHub Container Regi
|
|||
### How It Works
|
||||
|
||||
When GitHub Actions runs:
|
||||
|
||||
1. Automatically logs in to ghcr.io using `GITHUB_TOKEN`
|
||||
2. Builds Docker images
|
||||
3. Pushes to: `ghcr.io/wuesteon/mana-core-auth`, `ghcr.io/wuesteon/chat-backend`, etc.
|
||||
|
|
@ -84,10 +85,10 @@ If you want to pull images on your Hetzner servers, add these secrets:
|
|||
|
||||
**GitHub** → **Your Repo** → **Settings** → **Secrets and variables** → **Actions** → **New repository secret**
|
||||
|
||||
| Secret Name | Value | Purpose |
|
||||
|-------------|-------|---------|
|
||||
| Secret Name | Value | Purpose |
|
||||
| --------------- | --------------------------------- | ----------------------------- |
|
||||
| `GHCR_USERNAME` | `wuesteon` (your GitHub username) | For pulling images on servers |
|
||||
| `GHCR_TOKEN` | Your PAT from above | For pulling images on servers |
|
||||
| `GHCR_TOKEN` | Your PAT from above | For pulling images on servers |
|
||||
|
||||
Then update `docker-compose.staging.yml` and `docker-compose.production.yml` to include login:
|
||||
|
||||
|
|
@ -116,6 +117,7 @@ ghcr.io/wuesteon/maerchenzauber-backend:latest
|
|||
```
|
||||
|
||||
**Tags**:
|
||||
|
||||
- `latest` - Most recent build from main branch
|
||||
- `main` - Same as latest (branch-based tag)
|
||||
- `main-a1b2c3d` - Specific commit SHA (for rollbacks)
|
||||
|
|
@ -168,6 +170,7 @@ Your colleague automatically has access because they have access to the reposito
|
|||
Update image references in `docker-compose.staging.yml` and `docker-compose.production.yml`:
|
||||
|
||||
**Before** (if using Docker Hub):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mana-core-auth:
|
||||
|
|
@ -175,6 +178,7 @@ services:
|
|||
```
|
||||
|
||||
**After** (using GitHub Container Registry):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mana-core-auth:
|
||||
|
|
@ -186,10 +190,12 @@ services:
|
|||
## Storage Limits
|
||||
|
||||
**GitHub Container Registry Free Tier**:
|
||||
|
||||
- **Storage**: 500 MB (across all packages)
|
||||
- **Data transfer**: 1 GB/month
|
||||
|
||||
**How long until you hit limits?**:
|
||||
|
||||
- Average Docker image size: 150 MB
|
||||
- You can store ~3 images before hitting 500 MB
|
||||
- **Recommendation**: Enable auto-delete for old images
|
||||
|
|
@ -203,7 +209,7 @@ name: Cleanup Old Container Images
|
|||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * 0' # Weekly on Sunday
|
||||
- cron: '0 0 * * 0' # Weekly on Sunday
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
|
@ -228,6 +234,7 @@ This keeps only the 3 most recent versions and deletes untagged images.
|
|||
### Issue: "Permission denied while trying to connect to the Docker daemon"
|
||||
|
||||
**Solution**: Add your user to docker group on deployment server:
|
||||
|
||||
```bash
|
||||
sudo usermod -aG docker $USER
|
||||
newgrp docker
|
||||
|
|
@ -236,6 +243,7 @@ newgrp docker
|
|||
### Issue: "unauthorized: unauthenticated"
|
||||
|
||||
**Solution**: Login again with your PAT:
|
||||
|
||||
```bash
|
||||
echo YOUR_PAT_TOKEN | docker login ghcr.io -u wuesteon --password-stdin
|
||||
```
|
||||
|
|
@ -247,6 +255,7 @@ echo YOUR_PAT_TOKEN | docker login ghcr.io -u wuesteon --password-stdin
|
|||
### Issue: Images not appearing in GitHub Packages
|
||||
|
||||
**Solution**:
|
||||
|
||||
1. Check GitHub Actions workflow completed successfully
|
||||
2. Check the workflow pushed images (look for "Pushed to ghcr.io" in logs)
|
||||
3. Images may take 1-2 minutes to appear in Packages tab
|
||||
|
|
@ -255,16 +264,16 @@ echo YOUR_PAT_TOKEN | docker login ghcr.io -u wuesteon --password-stdin
|
|||
|
||||
## Comparison: Docker Hub vs ghcr.io
|
||||
|
||||
| Feature | Docker Hub (Free) | GitHub Container Registry |
|
||||
|---------|-------------------|---------------------------|
|
||||
| **Cost** | Free (limited) | Free (generous) |
|
||||
| **Pull rate limits** | 100 pulls/6 hours | Unlimited |
|
||||
| **Storage** | 1 repo (free tier) | 500 MB (all packages) |
|
||||
| **Private repos** | 1 private repo | Unlimited private |
|
||||
| **Team access** | Manual invitation | Automatic via GitHub |
|
||||
| **Authentication** | Username + Token | GitHub account |
|
||||
| **Setup complexity** | Medium (create repos manually) | Low (automatic) |
|
||||
| **Integration** | Good | Excellent (native GitHub) |
|
||||
| Feature | Docker Hub (Free) | GitHub Container Registry |
|
||||
| -------------------- | ------------------------------ | ------------------------- |
|
||||
| **Cost** | Free (limited) | Free (generous) |
|
||||
| **Pull rate limits** | 100 pulls/6 hours | Unlimited |
|
||||
| **Storage** | 1 repo (free tier) | 500 MB (all packages) |
|
||||
| **Private repos** | 1 private repo | Unlimited private |
|
||||
| **Team access** | Manual invitation | Automatic via GitHub |
|
||||
| **Authentication** | Username + Token | GitHub account |
|
||||
| **Setup complexity** | Medium (create repos manually) | Low (automatic) |
|
||||
| **Integration** | Good | Excellent (native GitHub) |
|
||||
|
||||
**Winner for 2-person team**: GitHub Container Registry ✅
|
||||
|
||||
|
|
@ -283,6 +292,7 @@ echo YOUR_PAT_TOKEN | docker login ghcr.io -u wuesteon --password-stdin
|
|||
## Summary
|
||||
|
||||
**What you get with ghcr.io**:
|
||||
|
||||
- ✅ Zero setup (already configured by Hive Mind)
|
||||
- ✅ Automatic authentication in GitHub Actions
|
||||
- ✅ Your colleague has instant access
|
||||
|
|
@ -291,6 +301,7 @@ echo YOUR_PAT_TOKEN | docker login ghcr.io -u wuesteon --password-stdin
|
|||
- ✅ Native GitHub integration
|
||||
|
||||
**What you need to do**:
|
||||
|
||||
- ✅ Nothing! (for CI/CD pipeline)
|
||||
- 🔧 Create PAT for deployment servers (5 minutes)
|
||||
- 🧹 Optional: Set up auto-cleanup (5 minutes)
|
||||
|
|
|
|||
|
|
@ -130,19 +130,20 @@ manacore-monorepo/
|
|||
|
||||
## Lines of Code by Category
|
||||
|
||||
| Category | Files | Approx. Lines |
|
||||
|----------|-------|---------------|
|
||||
| GitHub Actions YAML | 6 | 1,500 |
|
||||
| Deployment Scripts (Bash) | 5 | 800 |
|
||||
| Docker Configurations | 5 | 400 |
|
||||
| Test Configurations | 4 | 400 |
|
||||
| Documentation (Markdown) | 5 | 70+ pages |
|
||||
| Configuration Files | 3 | 100 |
|
||||
| **Total** | **28** | **~3,500 lines** |
|
||||
| Category | Files | Approx. Lines |
|
||||
| ------------------------- | ------ | ---------------- |
|
||||
| GitHub Actions YAML | 6 | 1,500 |
|
||||
| Deployment Scripts (Bash) | 5 | 800 |
|
||||
| Docker Configurations | 5 | 400 |
|
||||
| Test Configurations | 4 | 400 |
|
||||
| Documentation (Markdown) | 5 | 70+ pages |
|
||||
| Configuration Files | 3 | 100 |
|
||||
| **Total** | **28** | **~3,500 lines** |
|
||||
|
||||
## Key Features Implemented
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
- Smart build detection (only affected projects)
|
||||
- Automated PR validation
|
||||
- Docker image building and pushing
|
||||
|
|
@ -152,6 +153,7 @@ manacore-monorepo/
|
|||
- Dependency scanning and updates
|
||||
|
||||
### Docker
|
||||
|
||||
- Multi-stage builds for optimization
|
||||
- Non-root users for security
|
||||
- Health checks for monitoring
|
||||
|
|
@ -159,6 +161,7 @@ manacore-monorepo/
|
|||
- Environment-specific configurations
|
||||
|
||||
### Deployment
|
||||
|
||||
- Zero-downtime rolling updates
|
||||
- Automated health checks
|
||||
- Pre-deployment backups
|
||||
|
|
@ -166,6 +169,7 @@ manacore-monorepo/
|
|||
- Emergency rollback procedures
|
||||
|
||||
### Testing
|
||||
|
||||
- Unit tests (Vitest/Jest)
|
||||
- E2E tests (Playwright)
|
||||
- Coverage reporting (Codecov)
|
||||
|
|
@ -173,6 +177,7 @@ manacore-monorepo/
|
|||
- 50% minimum coverage threshold
|
||||
|
||||
### Documentation
|
||||
|
||||
- Quick start guide (30 minutes)
|
||||
- Complete setup guide (step-by-step)
|
||||
- Deployment operations guide
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
**Mission**: Research and compare Hetzner vs Coolify hosting options
|
||||
|
||||
**Key Findings**:
|
||||
|
||||
- ✅ **Recommended Platform**: Coolify + Hetzner
|
||||
- ✅ **Cost Efficiency**: 92% cheaper than traditional PaaS ($50/month vs $300/month)
|
||||
- ✅ **Performance**: Hetzner beats DigitalOcean in CPU benchmarks (5-10% faster)
|
||||
|
|
@ -40,6 +41,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
**Decision Matrix Score**: 8.40/10 (highest among 4 options evaluated)
|
||||
|
||||
**Research Scope**:
|
||||
|
||||
- 24+ web searches across official docs, benchmarks, case studies
|
||||
- Detailed cost breakdowns for 6-project deployment
|
||||
- Security analysis (ISO 27001, GDPR compliance)
|
||||
|
|
@ -58,6 +60,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
**Mission**: Design complete deployment architecture for 39 services
|
||||
|
||||
**Key Deliverables**:
|
||||
|
||||
- ✅ **Service Inventory**: 10 NestJS backends, 9 SvelteKit web apps, 9 Astro landing pages, 8 Expo mobile apps
|
||||
- ✅ **Container Strategy**: Multi-stage Docker builds (Alpine Linux, 120-180 MB final images)
|
||||
- ✅ **Deployment Topology**: Blue-green deployment with zero-downtime updates
|
||||
|
|
@ -66,6 +69,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
- ✅ **Monitoring Stack**: Prometheus + Grafana + Loki + Sentry
|
||||
|
||||
**Architecture Highlights**:
|
||||
|
||||
- **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
|
||||
|
|
@ -85,6 +89,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
**Mission**: Implement complete CI/CD pipeline and Docker infrastructure
|
||||
|
||||
**Key Deliverables**:
|
||||
|
||||
- ✅ **6 GitHub Actions Workflows**: PR validation, main CI, staging/production deployment, coverage tracking, dependency updates
|
||||
- ✅ **3 Dockerfile Templates**: NestJS, SvelteKit, Astro (multi-stage, optimized for pnpm monorepo)
|
||||
- ✅ **5 Deployment Scripts**: build-and-push, deploy-hetzner, health-check, rollback, migrate-db
|
||||
|
|
@ -92,12 +97,14 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
- ✅ **Testing Infrastructure**: Vitest, Jest, Playwright configurations
|
||||
|
||||
**Pipeline Features**:
|
||||
|
||||
- **Smart Build Detection**: Only builds changed projects (Turborepo filters)
|
||||
- **Zero-Downtime Deployments**: Rolling updates with automated health checks
|
||||
- **Security**: Weekly audits, non-root Docker users, SSH key rotation
|
||||
- **Performance**: Layer caching reduces build time 12-15 min → 2-3 min
|
||||
|
||||
**Code Statistics**:
|
||||
|
||||
- **28 production-ready files created**
|
||||
- **~3,500 lines of code**
|
||||
- **70+ pages of documentation**
|
||||
|
|
@ -118,6 +125,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
**Mission**: Design and implement comprehensive automated testing strategy
|
||||
|
||||
**Key Deliverables**:
|
||||
|
||||
- ✅ **3 Major Documentation Files**: Master strategy, implementation guide, executive summary (50,000+ words)
|
||||
- ✅ **Shared Test Configuration Package**: Reusable configs for all app types (Jest, Vitest, Playwright)
|
||||
- ✅ **7 Production-Quality Test Examples**: Backend, mobile, web, shared (3,400+ lines)
|
||||
|
|
@ -133,6 +141,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
| Shared Packages | Vitest | 90% | N/A |
|
||||
|
||||
**Current State Analysis**:
|
||||
|
||||
- **Before**: 25 test files, ~5% coverage
|
||||
- **Target**: 80% coverage for new code, 100% for critical paths (auth, payments)
|
||||
- **Impact**: 80%+ bug reduction estimated
|
||||
|
|
@ -178,26 +187,26 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
|
||||
### Documentation Created (15+ Files)
|
||||
|
||||
| Category | Files | Pages | Word Count | Status |
|
||||
|----------|-------|-------|------------|--------|
|
||||
| **Infrastructure Research** | 1 | 40+ | 50,000+ | ✅ Complete |
|
||||
| **Architecture Design** | 3 | 45+ | 87,000+ | ✅ Complete |
|
||||
| **CI/CD Implementation** | 5 | 76+ | 80,000+ | ✅ Complete |
|
||||
| **Testing Strategy** | 3 | 50+ | 50,000+ | ✅ Complete |
|
||||
| **Test Examples** | 7 | 25+ | 3,400 lines | ✅ Complete |
|
||||
| **TOTAL** | **19** | **236+** | **~200,000** | ✅ Complete |
|
||||
| Category | Files | Pages | Word Count | Status |
|
||||
| --------------------------- | ------ | -------- | ------------ | ----------- |
|
||||
| **Infrastructure Research** | 1 | 40+ | 50,000+ | ✅ Complete |
|
||||
| **Architecture Design** | 3 | 45+ | 87,000+ | ✅ Complete |
|
||||
| **CI/CD Implementation** | 5 | 76+ | 80,000+ | ✅ Complete |
|
||||
| **Testing Strategy** | 3 | 50+ | 50,000+ | ✅ Complete |
|
||||
| **Test Examples** | 7 | 25+ | 3,400 lines | ✅ Complete |
|
||||
| **TOTAL** | **19** | **236+** | **~200,000** | ✅ Complete |
|
||||
|
||||
### Code & Configuration Files (40+ Files)
|
||||
|
||||
| Category | Files | Lines of Code | Status |
|
||||
|----------|-------|---------------|--------|
|
||||
| **GitHub Actions Workflows** | 7 | ~800 | ✅ Complete |
|
||||
| **Dockerfiles & Compose** | 5 | ~500 | ✅ Complete |
|
||||
| **Deployment Scripts** | 5 | ~1,200 | ✅ Complete |
|
||||
| **Test Configurations** | 6 | ~400 | ✅ Complete |
|
||||
| **Test Examples** | 7 | ~3,400 | ✅ Complete |
|
||||
| **Documentation Support** | 10+ | ~1,000 | ✅ Complete |
|
||||
| **TOTAL** | **40+** | **~7,300** | ✅ Complete |
|
||||
| Category | Files | Lines of Code | Status |
|
||||
| ---------------------------- | ------- | ------------- | ----------- |
|
||||
| **GitHub Actions Workflows** | 7 | ~800 | ✅ Complete |
|
||||
| **Dockerfiles & Compose** | 5 | ~500 | ✅ Complete |
|
||||
| **Deployment Scripts** | 5 | ~1,200 | ✅ Complete |
|
||||
| **Test Configurations** | 6 | ~400 | ✅ Complete |
|
||||
| **Test Examples** | 7 | ~3,400 | ✅ Complete |
|
||||
| **Documentation Support** | 10+ | ~1,000 | ✅ Complete |
|
||||
| **TOTAL** | **40+** | **~7,300** | ✅ Complete |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -221,6 +230,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
**Goal**: Complete infrastructure foundation
|
||||
|
||||
**Week 1 Tasks**:
|
||||
|
||||
- [ ] Create Hetzner account and provision staging server
|
||||
- [ ] Install Coolify on staging server
|
||||
- [ ] Configure all 22 GitHub secrets
|
||||
|
|
@ -228,6 +238,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
- [ ] Configure custom domains and DNS
|
||||
|
||||
**Week 2 Tasks**:
|
||||
|
||||
- [ ] Deploy first project (chat) to staging
|
||||
- [ ] Test complete CI/CD pipeline
|
||||
- [ ] Verify health checks and monitoring
|
||||
|
|
@ -243,6 +254,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
**Goal**: Deploy all projects to production
|
||||
|
||||
**Week 3-4**:
|
||||
|
||||
- [ ] Provision production server(s)
|
||||
- [ ] Set up production environment in Coolify
|
||||
- [ ] Deploy mana-core-auth service
|
||||
|
|
@ -250,6 +262,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
- [ ] Configure monitoring (Prometheus + Grafana)
|
||||
|
||||
**Week 5-6**:
|
||||
|
||||
- [ ] Deploy remaining 7 projects (maerchenzauber, manacore, manadeck, memoro, uload, nutriphi, others)
|
||||
- [ ] Set up Cloudflare CDN for static assets
|
||||
- [ ] Configure SSL/TLS for all domains
|
||||
|
|
@ -292,6 +305,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
### Infrastructure Costs (Monthly)
|
||||
|
||||
**Option A: Single-Server Setup (Recommended for Start)**
|
||||
|
||||
- **Hetzner CCX32**: 8 vCPU, 32 GB RAM, 240 GB SSD - **$50/month**
|
||||
- **Domains**: 6 domains @ $12/year each - **$6/month**
|
||||
- **Cloudflare**: Free tier (CDN, SSL, DNS) - **$0/month**
|
||||
|
|
@ -300,6 +314,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
- **Total**: **~$56/month**
|
||||
|
||||
**Option B: Multi-Server Setup (Scaling Phase)**
|
||||
|
||||
- **Hetzner CCX22** (staging): 4 vCPU, 16 GB RAM - **$25/month**
|
||||
- **Hetzner CCX42** (production): 16 vCPU, 64 GB RAM - **$100/month**
|
||||
- **Hetzner CX32** (monitoring): 4 vCPU, 8 GB RAM - **$15/month**
|
||||
|
|
@ -307,6 +322,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
- **Total**: **~$146/month**
|
||||
|
||||
**Option C: High-Availability Setup (Future)**
|
||||
|
||||
- **Hetzner Kubernetes Cluster**: 3 nodes (CCX32 each) - **$150/month**
|
||||
- **Load Balancer**: **$5/month**
|
||||
- **Object Storage (R2)**: 10 GB - **$0.15/month**
|
||||
|
|
@ -314,6 +330,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
- **Total**: **~$205/month**
|
||||
|
||||
**Comparison to Alternatives**:
|
||||
|
||||
- **AWS/Azure/GCP**: $500-1,000/month (3-18x more expensive)
|
||||
- **Heroku/Railway/Render**: $300-500/month (5-9x more expensive)
|
||||
- **DigitalOcean App Platform**: $150-300/month (2.5-5x more expensive)
|
||||
|
|
@ -327,24 +344,28 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
### Key Performance Indicators (KPIs)
|
||||
|
||||
**Deployment Metrics**:
|
||||
|
||||
- ✅ Deployment Time: < 10 minutes (current: manual, 2+ hours)
|
||||
- ✅ Deployment Frequency: Multiple times per day (current: weekly)
|
||||
- ✅ Rollback Time: < 5 minutes (current: hours)
|
||||
- ✅ Failed Deployments: < 5% (current: unknown)
|
||||
|
||||
**Quality Metrics**:
|
||||
|
||||
- ✅ Test Coverage: 80% minimum (current: ~5%)
|
||||
- ✅ Critical Path Coverage: 100% (current: ~0%)
|
||||
- ✅ Build Success Rate: > 95% (current: unknown)
|
||||
- ✅ Code Review Turnaround: < 24 hours
|
||||
|
||||
**Reliability Metrics**:
|
||||
|
||||
- ✅ Uptime: 99.9% (current: unknown)
|
||||
- ✅ Mean Time to Recovery (MTTR): < 1 hour
|
||||
- ✅ Mean Time Between Failures (MTBF): > 30 days
|
||||
- ✅ Backup Success Rate: 100%
|
||||
|
||||
**Cost Metrics**:
|
||||
|
||||
- ✅ Infrastructure Cost: < $100/month (target: $56/month)
|
||||
- ✅ Cost per Service: < $5/month
|
||||
- ✅ Cost Reduction: 92% vs traditional PaaS
|
||||
|
|
@ -356,6 +377,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
### Security Measures Implemented
|
||||
|
||||
**Infrastructure Security**:
|
||||
|
||||
- ✅ Non-root Docker containers
|
||||
- ✅ Read-only filesystems where possible
|
||||
- ✅ Network segmentation (frontend, backend, data layers)
|
||||
|
|
@ -364,6 +386,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
- ✅ Automatic security updates (Dependabot)
|
||||
|
||||
**Application Security**:
|
||||
|
||||
- ✅ Environment variable encryption (GitHub Secrets)
|
||||
- ✅ SSL/TLS for all services (Let's Encrypt)
|
||||
- ✅ JWT-based authentication (@manacore/shared-auth)
|
||||
|
|
@ -372,6 +395,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
- ✅ CORS policies enforced
|
||||
|
||||
**CI/CD Security**:
|
||||
|
||||
- ✅ Weekly dependency audits
|
||||
- ✅ Docker image scanning (Trivy)
|
||||
- ✅ No secrets in code (enforced by pre-commit hooks)
|
||||
|
|
@ -380,6 +404,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
- ✅ Signed commits (optional, recommended)
|
||||
|
||||
**Compliance**:
|
||||
|
||||
- ✅ GDPR compliance (Hetzner EU data centers)
|
||||
- ✅ ISO 27001 certified infrastructure (Hetzner)
|
||||
- ✅ SOC 2 Type II (Supabase)
|
||||
|
|
@ -393,27 +418,32 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
### Quick Navigation
|
||||
|
||||
**Getting Started**:
|
||||
|
||||
1. 🚀 [QUICK_START_CICD.md](./QUICK_START_CICD.md) - 30-minute deployment guide
|
||||
2. 📖 [CI_CD_README.md](./CI_CD_README.md) - Overview and quick reference
|
||||
3. 🏗️ [docs/CI_CD_SETUP.md](./docs/CI_CD_SETUP.md) - Complete setup instructions
|
||||
|
||||
**Architecture & Design**:
|
||||
|
||||
1. 🏛️ [docs/DEPLOYMENT_ARCHITECTURE.md](./docs/DEPLOYMENT_ARCHITECTURE.md) - Complete architecture spec
|
||||
2. 📊 [docs/DEPLOYMENT_DIAGRAMS.md](./docs/DEPLOYMENT_DIAGRAMS.md) - ASCII diagrams
|
||||
3. 📋 [docs/DEPLOYMENT_RUNBOOKS.md](./docs/DEPLOYMENT_RUNBOOKS.md) - Operational procedures
|
||||
|
||||
**CI/CD Implementation**:
|
||||
|
||||
1. 🔧 [docs/DEPLOYMENT.md](./docs/DEPLOYMENT.md) - Deployment operations guide
|
||||
2. 🐳 [docs/DOCKER_GUIDE.md](./docs/DOCKER_GUIDE.md) - Docker best practices
|
||||
3. ⚙️ [.github/workflows/](../.github/workflows/) - GitHub Actions workflows
|
||||
|
||||
**Testing Strategy**:
|
||||
|
||||
1. 🧪 [docs/TESTING.md](./docs/TESTING.md) - Master testing strategy (35,000+ words)
|
||||
2. 🚀 [docs/TESTING_IMPLEMENTATION_GUIDE.md](./docs/TESTING_IMPLEMENTATION_GUIDE.md) - Quick start
|
||||
3. 📊 [docs/TESTING_SUMMARY.md](./docs/TESTING_SUMMARY.md) - Executive summary
|
||||
4. 💡 [docs/test-examples/](./docs/test-examples/) - Production-quality examples
|
||||
|
||||
**Infrastructure Research**:
|
||||
|
||||
1. 🔍 [.hive-mind/sessions/research-report-hosting-infrastructure.md](./.hive-mind/sessions/research-report-hosting-infrastructure.md) - Complete research report (40+ pages)
|
||||
|
||||
---
|
||||
|
|
@ -423,21 +453,25 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
### Developer Onboarding (2-4 Hours)
|
||||
|
||||
**Session 1: CI/CD Basics (1 hour)**
|
||||
|
||||
- Read: QUICK_START_CICD.md
|
||||
- Hands-on: Create test PR and observe automated checks
|
||||
- Practice: Fix failing tests, see green checkmarks
|
||||
|
||||
**Session 2: Testing Fundamentals (1 hour)**
|
||||
|
||||
- Read: TESTING_IMPLEMENTATION_GUIDE.md
|
||||
- Hands-on: Write tests for one component using examples
|
||||
- Practice: Run tests locally, verify coverage
|
||||
|
||||
**Session 3: Docker & Deployment (1 hour)**
|
||||
|
||||
- Read: DOCKER_GUIDE.md sections 1-4
|
||||
- Hands-on: Build Docker image locally
|
||||
- Practice: Test container locally with docker-compose
|
||||
|
||||
**Session 4: Advanced Topics (1 hour, optional)**
|
||||
|
||||
- Read: DEPLOYMENT_ARCHITECTURE.md sections 1-5
|
||||
- Discuss: Blue-green deployment, rollback procedures
|
||||
- Review: Monitoring dashboards, alert thresholds
|
||||
|
|
@ -447,21 +481,25 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
### DevOps Onboarding (4-8 Hours)
|
||||
|
||||
**Session 1: Architecture Deep Dive (2 hours)**
|
||||
|
||||
- Read: DEPLOYMENT_ARCHITECTURE.md (complete)
|
||||
- Review: DEPLOYMENT_DIAGRAMS.md
|
||||
- Discuss: Design decisions and trade-offs
|
||||
|
||||
**Session 2: Infrastructure Setup (2 hours)**
|
||||
|
||||
- Hands-on: Set up Hetzner server
|
||||
- Hands-on: Install and configure Coolify
|
||||
- Practice: Deploy test service
|
||||
|
||||
**Session 3: CI/CD Operations (2 hours)**
|
||||
|
||||
- Read: CI_CD_SETUP.md (complete)
|
||||
- Hands-on: Configure GitHub secrets
|
||||
- Practice: Trigger manual deployment
|
||||
|
||||
**Session 4: Incident Response (2 hours)**
|
||||
|
||||
- Read: DEPLOYMENT_RUNBOOKS.md
|
||||
- Practice: Execute rollback procedure
|
||||
- Practice: Restore from backup
|
||||
|
|
@ -474,26 +512,31 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
### Common Issues & Solutions
|
||||
|
||||
**Issue 1: Docker Build Fails in CI**
|
||||
|
||||
- **Symptom**: GitHub Actions workflow fails at "Build Docker Image" step
|
||||
- **Solution**: Check .dockerignore, verify all dependencies in package.json
|
||||
- **Reference**: DOCKER_GUIDE.md section 6.1
|
||||
|
||||
**Issue 2: Tests Fail Locally but Pass in CI**
|
||||
|
||||
- **Symptom**: Local test failures but CI shows green
|
||||
- **Solution**: Clear node_modules and pnpm cache, check Node.js version
|
||||
- **Reference**: TESTING_IMPLEMENTATION_GUIDE.md section 5.1
|
||||
|
||||
**Issue 3: Deployment Succeeds but Service Unhealthy**
|
||||
|
||||
- **Symptom**: Deployment completes but health check fails
|
||||
- **Solution**: Check environment variables, verify Supabase connection
|
||||
- **Reference**: DEPLOYMENT.md section 4.3
|
||||
|
||||
**Issue 4: Coverage Below Threshold**
|
||||
|
||||
- **Symptom**: CI fails with "Coverage threshold not met"
|
||||
- **Solution**: Add missing tests or adjust thresholds temporarily
|
||||
- **Reference**: TESTING.md section 4
|
||||
|
||||
**Issue 5: Slow Build Times**
|
||||
|
||||
- **Symptom**: GitHub Actions taking 15+ minutes
|
||||
- **Solution**: Enable Turborepo remote cache, optimize Docker layers
|
||||
- **Reference**: CI_CD_SETUP.md section 7
|
||||
|
|
@ -616,6 +659,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
## 🙏 ACKNOWLEDGMENTS
|
||||
|
||||
**Hive Mind Worker Agents**:
|
||||
|
||||
- 🔍 **Researcher**: Comprehensive infrastructure analysis (24+ searches, 40+ pages)
|
||||
- 🏗️ **Analyst**: Complete architecture design (87,000+ characters)
|
||||
- 💻 **Coder**: Production-ready implementation (28 files, 3,500+ lines)
|
||||
|
|
@ -628,6 +672,7 @@ The Hive Mind collective has successfully analyzed, designed, and implemented a
|
|||
## 📜 LICENSE & USAGE
|
||||
|
||||
All code, configurations, and documentation produced by the Hive Mind are:
|
||||
|
||||
- ✅ Royalty-free for use in the manacore-monorepo
|
||||
- ✅ Modifiable without restriction
|
||||
- ✅ Distributable within your organization
|
||||
|
|
@ -642,7 +687,7 @@ All code, configurations, and documentation produced by the Hive Mind are:
|
|||
**Status**: ✅ ALL OBJECTIVES ACHIEVED
|
||||
**Recommendation**: PROCEED WITH IMPLEMENTATION
|
||||
|
||||
*"Alone we are smart. Together we are brilliant."* - Hive Mind Collective
|
||||
_"Alone we are smart. Together we are brilliant."_ - Hive Mind Collective
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -653,6 +698,7 @@ All code, configurations, and documentation produced by the Hive Mind are:
|
|||
**Root Directory**: `/Users/wuesteon/dev/mana_universe/manacore-monorepo/`
|
||||
|
||||
**Documentation**:
|
||||
|
||||
- `docs/DEPLOYMENT_ARCHITECTURE.md`
|
||||
- `docs/DEPLOYMENT_DIAGRAMS.md`
|
||||
- `docs/DEPLOYMENT_RUNBOOKS.md`
|
||||
|
|
@ -665,6 +711,7 @@ All code, configurations, and documentation produced by the Hive Mind are:
|
|||
- `docs/test-examples/` (directory with 7 files)
|
||||
|
||||
**CI/CD**:
|
||||
|
||||
- `.github/workflows/test.yml`
|
||||
- `.github/workflows/ci-pull-request.yml`
|
||||
- `.github/workflows/ci-main.yml`
|
||||
|
|
@ -674,6 +721,7 @@ All code, configurations, and documentation produced by the Hive Mind are:
|
|||
- `.github/workflows/dependency-update.yml`
|
||||
|
||||
**Docker**:
|
||||
|
||||
- `docker/templates/Dockerfile.nestjs`
|
||||
- `docker/templates/Dockerfile.sveltekit`
|
||||
- `docker/templates/Dockerfile.astro`
|
||||
|
|
@ -683,6 +731,7 @@ All code, configurations, and documentation produced by the Hive Mind are:
|
|||
- `.dockerignore`
|
||||
|
||||
**Scripts**:
|
||||
|
||||
- `scripts/deploy/build-and-push.sh`
|
||||
- `scripts/deploy/deploy-hetzner.sh`
|
||||
- `scripts/deploy/health-check.sh`
|
||||
|
|
@ -690,24 +739,28 @@ All code, configurations, and documentation produced by the Hive Mind are:
|
|||
- `scripts/deploy/migrate-db.sh`
|
||||
|
||||
**Test Configuration**:
|
||||
|
||||
- `packages/test-config/` (6 configuration files)
|
||||
- `vitest.config.ts`
|
||||
- `jest.config.js`
|
||||
- `playwright.config.ts`
|
||||
|
||||
**Quick Starts**:
|
||||
|
||||
- `CI_CD_README.md`
|
||||
- `QUICK_START_CICD.md`
|
||||
- `CI_CD_IMPLEMENTATION_SUMMARY.md`
|
||||
- `FILES_CREATED.md`
|
||||
|
||||
**Hive Mind**:
|
||||
|
||||
- `.hive-mind/sessions/research-report-hosting-infrastructure.md`
|
||||
- `HIVE_MIND_FINAL_REPORT.md` (this file)
|
||||
|
||||
### B. Command Reference
|
||||
|
||||
**Quick Start Commands**:
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
|
@ -732,6 +785,7 @@ pnpm run deploy:production
|
|||
```
|
||||
|
||||
**Development Commands**:
|
||||
|
||||
```bash
|
||||
# Start local development
|
||||
pnpm run dev
|
||||
|
|
@ -751,6 +805,7 @@ pnpm test:e2e
|
|||
```
|
||||
|
||||
**Deployment Commands** (via scripts):
|
||||
|
||||
```bash
|
||||
# Build and push all services
|
||||
./scripts/deploy/build-and-push.sh
|
||||
|
|
@ -772,6 +827,7 @@ pnpm test:e2e
|
|||
### C. Resource Links
|
||||
|
||||
**Official Documentation**:
|
||||
|
||||
- [Hetzner Cloud Docs](https://docs.hetzner.com/)
|
||||
- [Coolify Documentation](https://coolify.io/docs)
|
||||
- [Turborepo Docs](https://turbo.build/repo/docs)
|
||||
|
|
@ -779,17 +835,20 @@ pnpm test:e2e
|
|||
- [GitHub Actions](https://docs.github.com/en/actions)
|
||||
|
||||
**Testing Frameworks**:
|
||||
|
||||
- [Jest](https://jestjs.io/)
|
||||
- [Vitest](https://vitest.dev/)
|
||||
- [Playwright](https://playwright.dev/)
|
||||
- [Testing Library](https://testing-library.com/)
|
||||
|
||||
**Container Ecosystem**:
|
||||
|
||||
- [Docker Documentation](https://docs.docker.com/)
|
||||
- [Docker Compose](https://docs.docker.com/compose/)
|
||||
- [Multi-stage Builds](https://docs.docker.com/build/building/multi-stage/)
|
||||
|
||||
**Monitoring & Observability**:
|
||||
|
||||
- [Prometheus](https://prometheus.io/docs/)
|
||||
- [Grafana](https://grafana.com/docs/)
|
||||
- [Loki](https://grafana.com/docs/loki/)
|
||||
|
|
@ -798,17 +857,20 @@ pnpm test:e2e
|
|||
### D. Support & Contribution
|
||||
|
||||
**Questions or Issues?**
|
||||
|
||||
1. Check the troubleshooting sections in relevant docs
|
||||
2. Review the FAQ in TESTING.md and DEPLOYMENT.md
|
||||
3. Consult the Hive Mind collective wisdom in this report
|
||||
|
||||
**Found a Bug or Improvement?**
|
||||
|
||||
1. Document the issue with steps to reproduce
|
||||
2. Propose a solution based on the established patterns
|
||||
3. Test thoroughly before implementing
|
||||
4. Update relevant documentation
|
||||
|
||||
**Want to Extend the System?**
|
||||
|
||||
1. Review the "Future Enhancements" section
|
||||
2. Follow the established architectural patterns
|
||||
3. Maintain consistency with existing code style
|
||||
|
|
@ -818,8 +880,8 @@ pnpm test:e2e
|
|||
|
||||
**END OF HIVE MIND FINAL REPORT**
|
||||
|
||||
*Generated by Strategic Queen Coordinator with collective intelligence from 4 specialized worker agents.*
|
||||
_Generated by Strategic Queen Coordinator with collective intelligence from 4 specialized worker agents._
|
||||
|
||||
*Total coordination time: ~2 hours*
|
||||
*Total deliverables: 280+ pages of documentation + 40+ production-ready files*
|
||||
*Status: Mission Complete ✅*
|
||||
_Total coordination time: ~2 hours_
|
||||
_Total deliverables: 280+ pages of documentation + 40+ production-ready files_
|
||||
_Status: Mission Complete ✅_
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ git push origin test/ci-pipeline
|
|||
If you want to test quickly, here's the absolute minimum:
|
||||
|
||||
### For PR Testing Only (No Deployment)
|
||||
|
||||
```
|
||||
# Just these 3 secrets to test PR workflow:
|
||||
DOCKER_USERNAME=your-username
|
||||
|
|
@ -116,6 +117,7 @@ DOCKER_REGISTRY=your-username
|
|||
```
|
||||
|
||||
### For Staging Deployment
|
||||
|
||||
```
|
||||
# Add these 5 more secrets:
|
||||
STAGING_HOST=your-server-ip
|
||||
|
|
@ -175,6 +177,7 @@ ssh -i ~/.ssh/github-staging deploy@staging.example.com 'echo "Success"'
|
|||
## Troubleshooting
|
||||
|
||||
### "Permission denied (publickey)"
|
||||
|
||||
```bash
|
||||
# Check SSH key was added to server
|
||||
ssh deploy@staging.example.com 'cat ~/.ssh/authorized_keys'
|
||||
|
|
@ -184,6 +187,7 @@ ssh deploy@staging.example.com 'cat ~/.ssh/authorized_keys'
|
|||
```
|
||||
|
||||
### "Docker command not found"
|
||||
|
||||
```bash
|
||||
# Install Docker on server
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
|
|
@ -192,6 +196,7 @@ sudo usermod -aG docker deploy
|
|||
```
|
||||
|
||||
### "Health checks failing"
|
||||
|
||||
```bash
|
||||
# Check service logs
|
||||
ssh deploy@staging.example.com
|
||||
|
|
|
|||
22
README.md
22
README.md
|
|
@ -4,12 +4,12 @@ Monorepo containing all Manacore projects with shared packages and unified tooli
|
|||
|
||||
## Projects
|
||||
|
||||
| Project | Description | Tech Stack |
|
||||
|---------|-------------|------------|
|
||||
| Project | Description | Tech Stack |
|
||||
| ------------------ | ------------------------------- | ------------------------------ |
|
||||
| **maerchenzauber** | AI-powered story generation app | NestJS, Expo, SvelteKit, Astro |
|
||||
| **manacore** | Multi-app ecosystem platform | Expo, SvelteKit, Astro |
|
||||
| **manadeck** | Card/deck management app | NestJS, Expo, SvelteKit |
|
||||
| **memoro** | Voice memo & AI analysis app | Expo, SvelteKit, Astro |
|
||||
| **manacore** | Multi-app ecosystem platform | Expo, SvelteKit, Astro |
|
||||
| **manadeck** | Card/deck management app | NestJS, Expo, SvelteKit |
|
||||
| **memoro** | Voice memo & AI analysis app | Expo, SvelteKit, Astro |
|
||||
|
||||
## Getting Started
|
||||
|
||||
|
|
@ -57,12 +57,12 @@ pnpm run format
|
|||
|
||||
Located in `packages/`:
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `@manacore/shared-types` | Common TypeScript types |
|
||||
| `@manacore/shared-supabase` | Unified Supabase client |
|
||||
| `@manacore/shared-utils` | Utility functions (date, string, async) |
|
||||
| `@manacore/shared-ui` | React Native UI components |
|
||||
| Package | Description |
|
||||
| --------------------------- | --------------------------------------- |
|
||||
| `@manacore/shared-types` | Common TypeScript types |
|
||||
| `@manacore/shared-supabase` | Unified Supabase client |
|
||||
| `@manacore/shared-utils` | Utility functions (date, string, async) |
|
||||
| `@manacore/shared-ui` | React Native UI components |
|
||||
|
||||
### Using Shared Packages
|
||||
|
||||
|
|
|
|||
|
|
@ -17,12 +17,14 @@ This document summarizes the comprehensive testing strategy for the central auth
|
|||
## Critical Business Paths
|
||||
|
||||
### Priority 1: Authentication Flow
|
||||
|
||||
1. **User Registration** → Tokens Generated → Secure Storage → Access Granted
|
||||
2. **User Login** → Token Validation → Session Established → Multi-Device Support
|
||||
3. **Token Expiration** → Automatic Refresh → Seamless Continuation
|
||||
4. **User Logout** → Token Invalidation → Secure Cleanup
|
||||
|
||||
### Priority 2: Credit System Flow
|
||||
|
||||
1. **Credit Purchase** → Payment Validation → Balance Update → Transaction Logged
|
||||
2. **Pre-Operation Validation** → Operation Execution → Credit Deduction → Balance Update
|
||||
3. **Failed Operation** → No Charge Applied → User Notified
|
||||
|
|
@ -34,16 +36,17 @@ This document summarizes the comprehensive testing strategy for the central auth
|
|||
|
||||
### Authentication Testing (45 Test Cases)
|
||||
|
||||
| Category | Test Cases | Priority | Coverage |
|
||||
|----------|-----------|----------|----------|
|
||||
| Registration (Email/Social) | 8 | P0 | 100% |
|
||||
| Login/Logout | 10 | P0 | 100% |
|
||||
| Token Refresh | 6 | P0 | 100% |
|
||||
| Session Management | 6 | P1 | 100% |
|
||||
| Password Management | 5 | P1 | 90% |
|
||||
| Multi-Device/Multi-App | 10 | P0-P1 | 100% |
|
||||
| Category | Test Cases | Priority | Coverage |
|
||||
| --------------------------- | ---------- | -------- | -------- |
|
||||
| Registration (Email/Social) | 8 | P0 | 100% |
|
||||
| Login/Logout | 10 | P0 | 100% |
|
||||
| Token Refresh | 6 | P0 | 100% |
|
||||
| Session Management | 6 | P1 | 100% |
|
||||
| Password Management | 5 | P1 | 90% |
|
||||
| Multi-Device/Multi-App | 10 | P0-P1 | 100% |
|
||||
|
||||
**Key Security Tests:**
|
||||
|
||||
- SQL Injection Prevention ✓
|
||||
- JWT Token Manipulation ✓
|
||||
- Token Expiration Enforcement ✓
|
||||
|
|
@ -52,17 +55,18 @@ This document summarizes the comprehensive testing strategy for the central auth
|
|||
|
||||
### Credit System Testing (38 Test Cases)
|
||||
|
||||
| Category | Test Cases | Priority | Coverage |
|
||||
|----------|-----------|----------|----------|
|
||||
| Credit Purchase | 6 | P0 | 100% |
|
||||
| Balance Checking | 4 | P0 | 100% |
|
||||
| Credit Consumption | 8 | P0 | 100% |
|
||||
| Refund & Adjustments | 4 | P1 | 100% |
|
||||
| Transaction History | 4 | P2 | 90% |
|
||||
| Concurrent Transactions | 6 | P0 | 100% |
|
||||
| Cross-App Visibility | 6 | P0 | 100% |
|
||||
| Category | Test Cases | Priority | Coverage |
|
||||
| ----------------------- | ---------- | -------- | -------- |
|
||||
| Credit Purchase | 6 | P0 | 100% |
|
||||
| Balance Checking | 4 | P0 | 100% |
|
||||
| Credit Consumption | 8 | P0 | 100% |
|
||||
| Refund & Adjustments | 4 | P1 | 100% |
|
||||
| Transaction History | 4 | P2 | 90% |
|
||||
| Concurrent Transactions | 6 | P0 | 100% |
|
||||
| Cross-App Visibility | 6 | P0 | 100% |
|
||||
|
||||
**Key Security Tests:**
|
||||
|
||||
- Balance Tampering Prevention ✓
|
||||
- Unauthorized Deduction Prevention ✓
|
||||
- Replay Attack Prevention ✓
|
||||
|
|
@ -71,23 +75,24 @@ This document summarizes the comprehensive testing strategy for the central auth
|
|||
|
||||
### Integration Testing (15 Test Cases)
|
||||
|
||||
| Platform | Test Cases | Priority |
|
||||
|----------|-----------|----------|
|
||||
| iOS Mobile (Expo) | 3 | P0 |
|
||||
| Android Mobile (Expo) | 3 | P0 |
|
||||
| Web (SvelteKit) | 3 | P0 |
|
||||
| Backend (NestJS) | 3 | P0 |
|
||||
| Payment Gateway (RevenueCat) | 3 | P0 |
|
||||
| Platform | Test Cases | Priority |
|
||||
| ---------------------------- | ---------- | -------- |
|
||||
| iOS Mobile (Expo) | 3 | P0 |
|
||||
| Android Mobile (Expo) | 3 | P0 |
|
||||
| Web (SvelteKit) | 3 | P0 |
|
||||
| Backend (NestJS) | 3 | P0 |
|
||||
| Payment Gateway (RevenueCat) | 3 | P0 |
|
||||
|
||||
### Performance Testing (12 Test Cases)
|
||||
|
||||
| Test Type | Scenarios | Load Target |
|
||||
|-----------|-----------|-------------|
|
||||
| Load Testing | 3 | 1000 concurrent users |
|
||||
| Stress Testing | 2 | 5000 operations |
|
||||
| Scalability Testing | 2 | 1M transactions/day |
|
||||
| Test Type | Scenarios | Load Target |
|
||||
| ------------------- | --------- | --------------------- |
|
||||
| Load Testing | 3 | 1000 concurrent users |
|
||||
| Stress Testing | 2 | 5000 operations |
|
||||
| Scalability Testing | 2 | 1M transactions/day |
|
||||
|
||||
**Performance Targets:**
|
||||
|
||||
- Login Response Time: < 2 seconds (P95)
|
||||
- Token Refresh: < 2 seconds (P95)
|
||||
- Credit Balance Check: < 100ms (P95)
|
||||
|
|
@ -98,24 +103,28 @@ This document summarizes the comprehensive testing strategy for the central auth
|
|||
## Test Automation Breakdown
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- **Framework:** Jest
|
||||
- **Coverage Target:** 80%+
|
||||
- **Location:** `packages/shared-auth/`, `packages/shared-credit-service/`
|
||||
- **Run Frequency:** Every commit (pre-commit hook)
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- **Framework:** Jest + Supertest
|
||||
- **Coverage Target:** 100% critical paths
|
||||
- **Location:** `*/apps/backend/test/`, `*/apps/mobile/features/*/tests/`
|
||||
- **Run Frequency:** Every pull request
|
||||
|
||||
### E2E Tests
|
||||
|
||||
- **Framework:** Detox (mobile), Playwright (web)
|
||||
- **Coverage Target:** 100% user journeys
|
||||
- **Location:** `*/apps/*/e2e/`, `*/apps/*/tests/`
|
||||
- **Run Frequency:** Pre-staging deployment
|
||||
|
||||
### Performance Tests
|
||||
|
||||
- **Framework:** k6
|
||||
- **Target:** 1000 concurrent users without degradation
|
||||
- **Location:** `tests/performance/`
|
||||
|
|
@ -126,54 +135,64 @@ This document summarizes the comprehensive testing strategy for the central auth
|
|||
## Critical Test Scenarios
|
||||
|
||||
### 1. Concurrent Credit Deduction (Race Condition)
|
||||
|
||||
**Risk:** High - Could cause financial discrepancies
|
||||
**Test:** TC-CREDIT-CONSUME-003
|
||||
**Mitigation:** Database transactions with optimistic locking
|
||||
|
||||
**Scenario:**
|
||||
|
||||
- User has 100 credits
|
||||
- 3 operations triggered simultaneously (30 credits each)
|
||||
- Expected: All succeed, final balance = 10 credits
|
||||
- Test validates: No over-deduction or under-deduction
|
||||
|
||||
### 2. Token Refresh During High Load
|
||||
|
||||
**Risk:** Medium - User experience degradation
|
||||
**Test:** TC-PERF-LOAD-002
|
||||
**Mitigation:** Token manager queue + cooldown mechanism
|
||||
|
||||
**Scenario:**
|
||||
|
||||
- 500 users with expired tokens make API calls simultaneously
|
||||
- Expected: Single refresh per user, all requests succeed
|
||||
- Test validates: No duplicate refreshes, queue handles load
|
||||
|
||||
### 3. Payment Webhook Duplicate Detection
|
||||
|
||||
**Risk:** High - Could cause double-crediting
|
||||
**Test:** TC-CREDIT-PURCHASE-003
|
||||
**Mitigation:** Idempotency keys, transaction ID validation
|
||||
|
||||
**Scenario:**
|
||||
|
||||
- Webhook received successfully
|
||||
- Same webhook replayed (network retry)
|
||||
- Expected: Second webhook ignored, no double-crediting
|
||||
- Test validates: Idempotent processing
|
||||
|
||||
### 4. Cross-App Credit Synchronization
|
||||
|
||||
**Risk:** Medium - User confusion, trust issues
|
||||
**Test:** TC-INT-CROSS-002
|
||||
**Mitigation:** Central credit service, real-time updates
|
||||
|
||||
**Scenario:**
|
||||
|
||||
- Consume credits in Memoro
|
||||
- Immediately check balance in Maerchenzauber
|
||||
- Expected: Balance updated in < 1 second
|
||||
- Test validates: Consistent state across apps
|
||||
|
||||
### 5. Multi-Device Session Management
|
||||
|
||||
**Risk:** Low - Potential token conflicts
|
||||
**Test:** TC-AUTH-SESSION-001
|
||||
**Mitigation:** Independent refresh tokens per device
|
||||
|
||||
**Scenario:**
|
||||
|
||||
- User logs in on iOS, Android, and Web
|
||||
- All devices active simultaneously
|
||||
- Token refresh on one device
|
||||
|
|
@ -187,17 +206,20 @@ This document summarizes the comprehensive testing strategy for the central auth
|
|||
### Authentication Security
|
||||
|
||||
**SQL Injection Prevention (TC-SEC-AUTH-001)**
|
||||
|
||||
- Test payloads: `admin'--`, `' OR '1'='1`, `'; DROP TABLE users;--`
|
||||
- Expected: All rejected, no DB queries executed
|
||||
- Result: PASS ✓ (parameterized queries used)
|
||||
|
||||
**JWT Token Manipulation (TC-SEC-AUTH-002)**
|
||||
|
||||
- Modify token claims (user ID, role, credits)
|
||||
- Re-sign with wrong secret
|
||||
- Expected: Signature validation fails, 401 error
|
||||
- Result: PASS ✓ (RS256 verification)
|
||||
|
||||
**Brute Force Protection (TC-SEC-AUTH-004)**
|
||||
|
||||
- 5 failed login attempts
|
||||
- Expected: Account locked for 15 minutes
|
||||
- Result: PASS ✓ (rate limiting implemented)
|
||||
|
|
@ -205,12 +227,14 @@ This document summarizes the comprehensive testing strategy for the central auth
|
|||
### Credit System Security
|
||||
|
||||
**Balance Tampering Prevention (TC-SEC-CREDIT-001)**
|
||||
|
||||
- Attempt to modify balance via API manipulation
|
||||
- Client-side storage modification
|
||||
- Expected: Server-side validation rejects all attempts
|
||||
- Result: PASS ✓ (server-authoritative balance)
|
||||
|
||||
**Replay Attack Prevention (TC-SEC-CREDIT-003)**
|
||||
|
||||
- Capture and replay payment webhook
|
||||
- Expected: Duplicate detected by transaction ID
|
||||
- Result: PASS ✓ (idempotency keys)
|
||||
|
|
@ -220,6 +244,7 @@ This document summarizes the comprehensive testing strategy for the central auth
|
|||
## Acceptance Criteria Checklist
|
||||
|
||||
### Authentication System
|
||||
|
||||
- [x] User can register with email/password in < 3 seconds
|
||||
- [x] User can login with email/password in < 2 seconds
|
||||
- [x] Token refresh happens automatically without user interaction
|
||||
|
|
@ -229,6 +254,7 @@ This document summarizes the comprehensive testing strategy for the central auth
|
|||
- [x] 99.9% uptime for authentication services
|
||||
|
||||
### Credit System
|
||||
|
||||
- [x] Credit balance updates within 1 second of purchase
|
||||
- [x] Credit deduction happens only after operation succeeds
|
||||
- [x] Failed operations never charge credits
|
||||
|
|
@ -238,6 +264,7 @@ This document summarizes the comprehensive testing strategy for the central auth
|
|||
- [x] Refunds processed within 1 hour (automated)
|
||||
|
||||
### Integration
|
||||
|
||||
- [x] Mobile apps support iOS 14+ and Android 10+
|
||||
- [x] Web apps work on Chrome, Safari, Firefox, Edge (latest 2 versions)
|
||||
- [x] RevenueCat purchase flow completes in < 30 seconds
|
||||
|
|
@ -245,6 +272,7 @@ This document summarizes the comprehensive testing strategy for the central auth
|
|||
- [x] Cross-app authentication works seamlessly
|
||||
|
||||
### Security
|
||||
|
||||
- [x] No plaintext passwords stored anywhere
|
||||
- [x] JWT tokens secured with RS256 algorithm
|
||||
- [x] Rate limiting prevents brute force attacks
|
||||
|
|
@ -253,6 +281,7 @@ This document summarizes the comprehensive testing strategy for the central auth
|
|||
- [ ] Penetration test: No critical vulnerabilities (requires external audit)
|
||||
|
||||
### Performance
|
||||
|
||||
- [x] System handles 1000 concurrent users without degradation
|
||||
- [x] 99th percentile response time < 3 seconds
|
||||
- [x] Token refresh completes in < 2 seconds
|
||||
|
|
@ -264,24 +293,28 @@ This document summarizes the comprehensive testing strategy for the central auth
|
|||
## Test Execution Strategy
|
||||
|
||||
### Daily (Automated)
|
||||
|
||||
- Smoke tests (5 minutes)
|
||||
- Core auth flows
|
||||
- Credit balance checks
|
||||
- CI/CD pipeline integration
|
||||
|
||||
### Weekly (Automated + Manual)
|
||||
|
||||
- Full regression suite (1 hour)
|
||||
- Integration tests
|
||||
- Performance smoke tests
|
||||
- Security dependency scan
|
||||
|
||||
### Monthly (Scheduled)
|
||||
|
||||
- Full security audit
|
||||
- Load testing (1000+ concurrent users)
|
||||
- Penetration testing (external)
|
||||
- Compliance review
|
||||
|
||||
### Per Deployment (Automated)
|
||||
|
||||
- Pre-deployment: Full regression (30 minutes)
|
||||
- Post-deployment: Smoke tests (5 minutes)
|
||||
- Canary deployment monitoring (1 hour)
|
||||
|
|
@ -293,18 +326,21 @@ This document summarizes the comprehensive testing strategy for the central auth
|
|||
### Critical Risks (Requires Immediate Testing)
|
||||
|
||||
**1. Credit Double-Deduction**
|
||||
|
||||
- **Impact:** HIGH (Financial loss, legal liability)
|
||||
- **Probability:** MEDIUM (Concurrent operations common)
|
||||
- **Mitigation:** Database transactions, optimistic locking
|
||||
- **Test:** TC-CREDIT-CONSUME-003, TC-CREDIT-CONSUME-004
|
||||
|
||||
**2. Payment Webhook Failure**
|
||||
|
||||
- **Impact:** HIGH (Lost revenue, user frustration)
|
||||
- **Probability:** MEDIUM (Network issues, gateway downtime)
|
||||
- **Mitigation:** Idempotency, retry mechanism, manual reconciliation
|
||||
- **Test:** TC-CREDIT-PURCHASE-003, TC-CREDIT-PURCHASE-004
|
||||
|
||||
**3. Token Hijacking**
|
||||
|
||||
- **Impact:** HIGH (Account compromise, data breach)
|
||||
- **Probability:** LOW (HTTPS enforced, short token lifetime)
|
||||
- **Mitigation:** HTTPS only, token rotation, short expiry
|
||||
|
|
@ -313,12 +349,14 @@ This document summarizes the comprehensive testing strategy for the central auth
|
|||
### Medium Risks (Monitor Closely)
|
||||
|
||||
**4. Cross-App State Inconsistency**
|
||||
|
||||
- **Impact:** MEDIUM (User confusion, support burden)
|
||||
- **Probability:** MEDIUM (Caching issues, sync delays)
|
||||
- **Mitigation:** Central credit service, real-time updates
|
||||
- **Test:** TC-INT-CROSS-002
|
||||
|
||||
**5. Concurrent Login Session Conflicts**
|
||||
|
||||
- **Impact:** MEDIUM (User experience disruption)
|
||||
- **Probability:** LOW (Independent tokens per device)
|
||||
- **Mitigation:** Device-specific refresh tokens
|
||||
|
|
@ -328,28 +366,31 @@ This document summarizes the comprehensive testing strategy for the central auth
|
|||
|
||||
## Test Environment Summary
|
||||
|
||||
| Environment | Purpose | Payment | Database |
|
||||
|-------------|---------|---------|----------|
|
||||
| **Development** | Developer testing | Mock gateway | Supabase dev |
|
||||
| **Staging** | QA validation, pre-production | RevenueCat sandbox | Supabase staging |
|
||||
| **Production** | Live users | RevenueCat production | Supabase production |
|
||||
| Environment | Purpose | Payment | Database |
|
||||
| --------------- | ----------------------------- | --------------------- | ------------------- |
|
||||
| **Development** | Developer testing | Mock gateway | Supabase dev |
|
||||
| **Staging** | QA validation, pre-production | RevenueCat sandbox | Supabase staging |
|
||||
| **Production** | Live users | RevenueCat production | Supabase production |
|
||||
|
||||
---
|
||||
|
||||
## Tools & Infrastructure
|
||||
|
||||
### Testing Frameworks
|
||||
|
||||
- **Unit/Integration:** Jest, Supertest
|
||||
- **E2E:** Detox (mobile), Playwright (web)
|
||||
- **Performance:** k6, Lighthouse
|
||||
- **Security:** OWASP ZAP, Snyk, SonarQube
|
||||
|
||||
### CI/CD
|
||||
|
||||
- **Platform:** GitHub Actions
|
||||
- **Stages:** Lint → Unit Tests → Integration Tests → E2E Tests → Deploy
|
||||
- **Quality Gates:** 80% code coverage, 0 critical security issues, all tests passing
|
||||
|
||||
### Monitoring
|
||||
|
||||
- **Application:** New Relic, Sentry
|
||||
- **Infrastructure:** Cloud provider monitoring
|
||||
- **Alerts:** Slack integration for failures
|
||||
|
|
@ -359,6 +400,7 @@ This document summarizes the comprehensive testing strategy for the central auth
|
|||
## Gaps & Recommendations
|
||||
|
||||
### Current Gaps
|
||||
|
||||
1. **Load Testing:** Not yet executed at full 1000 user scale
|
||||
- **Recommendation:** Schedule weekly k6 load tests
|
||||
- **Owner:** DevOps team
|
||||
|
|
@ -376,6 +418,7 @@ This document summarizes the comprehensive testing strategy for the central auth
|
|||
- **Owner:** Backend team
|
||||
|
||||
### Future Enhancements
|
||||
|
||||
1. **Visual Regression Testing:** Add Chromatic or Percy for UI consistency
|
||||
2. **Accessibility Testing:** Ensure WCAG 2.1 AA compliance
|
||||
3. **Internationalization Testing:** Validate all 32 languages (Memoro)
|
||||
|
|
@ -386,17 +429,20 @@ This document summarizes the comprehensive testing strategy for the central auth
|
|||
## Success Metrics
|
||||
|
||||
### Test Coverage Goals
|
||||
|
||||
- Unit Test Coverage: **> 80%** ✓
|
||||
- Integration Test Coverage: **100% critical paths** ✓
|
||||
- E2E Test Coverage: **100% user journeys** (In Progress)
|
||||
- Security Test Coverage: **100% OWASP Top 10** ✓
|
||||
|
||||
### Quality Metrics
|
||||
|
||||
- Production Bugs: **< 5 critical bugs per quarter**
|
||||
- Mean Time to Detection (MTTD): **< 1 hour**
|
||||
- Mean Time to Resolution (MTTR): **< 4 hours for critical, < 24 hours for high**
|
||||
|
||||
### Performance Metrics
|
||||
|
||||
- API Response Time (P95): **< 500ms** ✓
|
||||
- Token Refresh Time (P95): **< 2s** ✓
|
||||
- Credit Balance Check (P95): **< 100ms** ✓
|
||||
|
|
@ -407,18 +453,21 @@ This document summarizes the comprehensive testing strategy for the central auth
|
|||
## Next Steps
|
||||
|
||||
### Week 1: Test Infrastructure Setup
|
||||
|
||||
- [ ] Configure k6 for load testing
|
||||
- [ ] Set up Detox for mobile E2E
|
||||
- [ ] Integrate security scanning into CI/CD
|
||||
- [ ] Create test data management scripts
|
||||
|
||||
### Week 2-3: Test Execution
|
||||
|
||||
- [ ] Execute all P0 test cases manually
|
||||
- [ ] Automate P0 test cases
|
||||
- [ ] Run first load test (100 concurrent users)
|
||||
- [ ] Security scan and vulnerability remediation
|
||||
|
||||
### Week 4: Validation & Reporting
|
||||
|
||||
- [ ] Full regression suite execution
|
||||
- [ ] Performance baseline established
|
||||
- [ ] Security audit report
|
||||
|
|
@ -436,12 +485,14 @@ This comprehensive testing strategy covers **110+ test cases** across authentica
|
|||
4. **Automation:** CI/CD integration for continuous quality assurance
|
||||
|
||||
**Estimated Effort:**
|
||||
|
||||
- Initial Test Development: 4 weeks (2 QA engineers)
|
||||
- Ongoing Regression Testing: 2 days/sprint
|
||||
- Load Testing: 1 day/week
|
||||
- Security Audits: 1 week/quarter (external)
|
||||
|
||||
**Key Success Factors:**
|
||||
|
||||
- Early involvement of QA in feature development
|
||||
- Automated regression suite in CI/CD pipeline
|
||||
- Regular security audits and penetration testing
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ This file provides guidance to Claude Code when working with the BaunTown projec
|
|||
## Project Overview
|
||||
|
||||
BaunTown is a community website for developers and creators with:
|
||||
|
||||
- Multilingual support (DE, EN, IT)
|
||||
- Payment integration (Stripe, PayPal)
|
||||
- Content collections for news, projects, tutorials, etc.
|
||||
|
|
@ -56,6 +57,7 @@ pnpm --filter @bauntown/landing preview
|
|||
## Environment Variables
|
||||
|
||||
Create `apps/bauntown/apps/landing/.env`:
|
||||
|
||||
```bash
|
||||
STRIPE_SECRET_KEY=sk_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
|
|
@ -65,31 +67,31 @@ PUBLIC_STRIPE_KEY=pk_...
|
|||
|
||||
## Technology Stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|------------|
|
||||
| Framework | Astro 5.x |
|
||||
| Styling | CSS/Tailwind |
|
||||
| i18n | astro-i18n-aut (DE, EN, IT) |
|
||||
| Payments | Stripe, PayPal |
|
||||
| Analytics | Plausible (via Partytown) |
|
||||
| APIs | Google Sheets/Docs |
|
||||
| Deployment | Netlify (SSR + Functions) |
|
||||
| Component | Technology |
|
||||
| ---------- | --------------------------- |
|
||||
| Framework | Astro 5.x |
|
||||
| Styling | CSS/Tailwind |
|
||||
| i18n | astro-i18n-aut (DE, EN, IT) |
|
||||
| Payments | Stripe, PayPal |
|
||||
| Analytics | Plausible (via Partytown) |
|
||||
| APIs | Google Sheets/Docs |
|
||||
| Deployment | Netlify (SSR + Functions) |
|
||||
|
||||
## Content Collections
|
||||
|
||||
BaunTown uses Astro Content Collections:
|
||||
|
||||
| Collection | Purpose |
|
||||
|------------|---------|
|
||||
| tools | Design, Development, Productivity tools |
|
||||
| news | AI, Web, Design, Community news |
|
||||
| models | AI models (Text, Image) |
|
||||
| projects | Web, Mobile, Desktop projects |
|
||||
| tutorials | Courses (UI/UX, Business, Marketing) |
|
||||
| missions | Community challenges |
|
||||
| vision | Long-term vision items |
|
||||
| join | Join page content |
|
||||
| members | Team members |
|
||||
| Collection | Purpose |
|
||||
| ---------- | --------------------------------------- |
|
||||
| tools | Design, Development, Productivity tools |
|
||||
| news | AI, Web, Design, Community news |
|
||||
| models | AI models (Text, Image) |
|
||||
| projects | Web, Mobile, Desktop projects |
|
||||
| tutorials | Courses (UI/UX, Business, Marketing) |
|
||||
| missions | Community challenges |
|
||||
| vision | Long-term vision items |
|
||||
| join | Join page content |
|
||||
| members | Team members |
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
|
|
@ -110,6 +112,7 @@ BaunTown uses Astro Content Collections:
|
|||
## Deployment
|
||||
|
||||
Deployed via Netlify with `@astrojs/netlify` adapter:
|
||||
|
||||
- Static pages pre-rendered
|
||||
- Dynamic routes use Netlify Functions
|
||||
- Configuration in `netlify.toml`
|
||||
|
|
|
|||
|
|
@ -67,4 +67,4 @@ Die Website unterstützt mehrere Sprachen mit einer URL-Struktur wie `example.co
|
|||
|
||||
- `de` - Deutsch (Standard)
|
||||
- `en` - Englisch
|
||||
- `it` - Italienisch
|
||||
- `it` - Italienisch
|
||||
|
|
|
|||
|
|
@ -67,12 +67,13 @@ npm install stripe @stripe/stripe-js @paypal/paypal-js
|
|||
### 3. Erstellung der Frontend-Komponenten
|
||||
|
||||
#### PaymentForm.astro
|
||||
|
||||
```astro
|
||||
---
|
||||
import { useTranslations } from '../utils/i18n';
|
||||
|
||||
interface Props {
|
||||
lang: string;
|
||||
lang: string;
|
||||
}
|
||||
|
||||
const { lang } = Astro.props;
|
||||
|
|
@ -80,69 +81,74 @@ const t = useTranslations(lang);
|
|||
---
|
||||
|
||||
<div class="payment-container">
|
||||
<!-- Zahlungstyp-Auswahl -->
|
||||
<div class="payment-type-selector">
|
||||
<button id="one-time" class="payment-type-btn active">{t('support.onetime')}</button>
|
||||
<button id="recurring" class="payment-type-btn">{t('support.recurring')}</button>
|
||||
</div>
|
||||
|
||||
<!-- Kaffee-Größen -->
|
||||
<div class="coffee-options">
|
||||
<!-- ... -->
|
||||
</div>
|
||||
|
||||
<!-- Zahlungsmethoden -->
|
||||
<div class="payment-buttons">
|
||||
<button id="stripe-button" class="payment-method-btn">
|
||||
<!-- ... -->
|
||||
<span>{t('support.payWithStripe')}</span>
|
||||
</button>
|
||||
<button id="paypal-button" class="payment-method-btn">
|
||||
<!-- ... -->
|
||||
<span>{t('support.payWithPayPal')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Zahlungstyp-Auswahl -->
|
||||
<div class="payment-type-selector">
|
||||
<button id="one-time" class="payment-type-btn active">{t('support.onetime')}</button>
|
||||
<button id="recurring" class="payment-type-btn">{t('support.recurring')}</button>
|
||||
</div>
|
||||
|
||||
<!-- Kaffee-Größen -->
|
||||
<div class="coffee-options">
|
||||
<!-- ... -->
|
||||
</div>
|
||||
|
||||
<!-- Zahlungsmethoden -->
|
||||
<div class="payment-buttons">
|
||||
<button id="stripe-button" class="payment-method-btn">
|
||||
<!-- ... -->
|
||||
<span>{t('support.payWithStripe')}</span>
|
||||
</button>
|
||||
<button id="paypal-button" class="payment-method-btn">
|
||||
<!-- ... -->
|
||||
<span>{t('support.payWithPayPal')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Stripe-Instanz initialisieren
|
||||
const stripePromise = loadStripe(import.meta.env.PUBLIC_STRIPE_PUBLISHABLE_KEY || 'pk_test_placeholder');
|
||||
|
||||
// Event-Listener für Stripe-Button
|
||||
stripeBtn?.addEventListener('click', async () => {
|
||||
try {
|
||||
// Checkout-Session erstellen
|
||||
const response = await fetch('/.netlify/functions/create-payment-intent', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount, isRecurring, priceId, coffeeSize
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Zur Checkout-Seite weiterleiten
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
return;
|
||||
}
|
||||
|
||||
// Alternativ: redirectToCheckout verwenden
|
||||
if (data.sessionId || data.id) {
|
||||
const stripe = await stripePromise;
|
||||
await stripe.redirectToCheckout({
|
||||
sessionId: data.sessionId || data.id,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Payment error:', error);
|
||||
}
|
||||
});
|
||||
});
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Stripe-Instanz initialisieren
|
||||
const stripePromise = loadStripe(
|
||||
import.meta.env.PUBLIC_STRIPE_PUBLISHABLE_KEY || 'pk_test_placeholder'
|
||||
);
|
||||
|
||||
// Event-Listener für Stripe-Button
|
||||
stripeBtn?.addEventListener('click', async () => {
|
||||
try {
|
||||
// Checkout-Session erstellen
|
||||
const response = await fetch('/.netlify/functions/create-payment-intent', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount,
|
||||
isRecurring,
|
||||
priceId,
|
||||
coffeeSize,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Zur Checkout-Seite weiterleiten
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
return;
|
||||
}
|
||||
|
||||
// Alternativ: redirectToCheckout verwenden
|
||||
if (data.sessionId || data.id) {
|
||||
const stripe = await stripePromise;
|
||||
await stripe.redirectToCheckout({
|
||||
sessionId: data.sessionId || data.id,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Payment error:', error);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
|
|
@ -154,50 +160,52 @@ const t = useTranslations(lang);
|
|||
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
||||
|
||||
exports.handler = async (event, context) => {
|
||||
const headers = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS"
|
||||
};
|
||||
|
||||
try {
|
||||
const { amount, isRecurring, coffeeSize } = JSON.parse(event.body || '{}');
|
||||
const amountInCents = Math.round(amount * 100);
|
||||
|
||||
// Stripe Checkout Session erstellen
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
payment_method_types: ['card'],
|
||||
line_items: [{
|
||||
price_data: {
|
||||
currency: 'eur',
|
||||
product_data: {
|
||||
name: `BaunTown Kaffee - ${coffeeSize || 'Mittlerer Kaffee'}`,
|
||||
description: isRecurring ? 'Monatliche Unterstützung' : 'Einmalige Unterstützung',
|
||||
},
|
||||
unit_amount: amountInCents,
|
||||
recurring: isRecurring ? { interval: 'month' } : undefined,
|
||||
},
|
||||
quantity: 1,
|
||||
}],
|
||||
mode: isRecurring ? 'subscription' : 'payment',
|
||||
success_url: `${process.env.URL || 'https://bauntown.com'}/support-success`,
|
||||
cancel_url: `${process.env.URL || 'https://bauntown.com'}/support-cancel`,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
url: session.url
|
||||
})
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
statusCode: 500,
|
||||
headers,
|
||||
body: JSON.stringify({ error: error.message })
|
||||
};
|
||||
}
|
||||
const headers = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
};
|
||||
|
||||
try {
|
||||
const { amount, isRecurring, coffeeSize } = JSON.parse(event.body || '{}');
|
||||
const amountInCents = Math.round(amount * 100);
|
||||
|
||||
// Stripe Checkout Session erstellen
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
payment_method_types: ['card'],
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: 'eur',
|
||||
product_data: {
|
||||
name: `BaunTown Kaffee - ${coffeeSize || 'Mittlerer Kaffee'}`,
|
||||
description: isRecurring ? 'Monatliche Unterstützung' : 'Einmalige Unterstützung',
|
||||
},
|
||||
unit_amount: amountInCents,
|
||||
recurring: isRecurring ? { interval: 'month' } : undefined,
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
mode: isRecurring ? 'subscription' : 'payment',
|
||||
success_url: `${process.env.URL || 'https://bauntown.com'}/support-success`,
|
||||
cancel_url: `${process.env.URL || 'https://bauntown.com'}/support-cancel`,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
url: session.url,
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
statusCode: 500,
|
||||
headers,
|
||||
body: JSON.stringify({ error: error.message }),
|
||||
};
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
|
|
@ -265,7 +273,7 @@ export function useTranslations(lang: keyof typeof ui) {
|
|||
- Navigieren Sie zu Ihrem Netlify-Dashboard
|
||||
- Gehen Sie zu "Site settings" > "Environment variables"
|
||||
- Fügen Sie die folgenden Umgebungsvariablen hinzu:
|
||||
- `PUBLIC_STRIPE_PUBLISHABLE_KEY` (muss mit PUBLIC_ beginnen für Frontend-Verwendung)
|
||||
- `PUBLIC_STRIPE_PUBLISHABLE_KEY` (muss mit PUBLIC\_ beginnen für Frontend-Verwendung)
|
||||
- `STRIPE_SECRET_KEY`
|
||||
- `STRIPE_WEBHOOK_SECRET` (optional für Webhook-Verarbeitung)
|
||||
|
||||
|
|
@ -315,9 +323,9 @@ Die Netlify-Function enthält CORS-Header für die Anfrageverarbeitung:
|
|||
|
||||
```javascript
|
||||
const headers = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS"
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
};
|
||||
```
|
||||
|
||||
|
|
@ -327,15 +335,15 @@ Die Implementation enthält einen Fallback-Mechanismus für Entwicklungs- und Te
|
|||
|
||||
```javascript
|
||||
if (!process.env.STRIPE_SECRET_KEY) {
|
||||
console.log("WARNUNG: STRIPE_SECRET_KEY fehlt - liefere Test-Antwort");
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
url: `${process.env.URL || 'https://bauntown.com'}/support-success?test=true`,
|
||||
message: "Test mode - no Stripe key available"
|
||||
})
|
||||
};
|
||||
console.log('WARNUNG: STRIPE_SECRET_KEY fehlt - liefere Test-Antwort');
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
url: `${process.env.URL || 'https://bauntown.com'}/support-success?test=true`,
|
||||
message: 'Test mode - no Stripe key available',
|
||||
}),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -346,9 +354,10 @@ if (!process.env.STRIPE_SECRET_KEY) {
|
|||
Die Stripe-Integration für das "Buy Me a Coffee"-Feature wurde erfolgreich implementiert und ermöglicht sowohl einmalige als auch wiederkehrende Spenden. Durch die Verwendung von Netlify Functions wird die Sicherheit erhöht, da sensible Daten wie der Stripe Secret Key nicht im Frontend verfügbar sind.
|
||||
|
||||
Die wichtigsten Punkte für eine fehlerfreie Implementierung:
|
||||
|
||||
1. Verwenden Sie `PUBLIC_`-Präfix für Frontend-Umgebungsvariablen in Astro
|
||||
2. Verwenden Sie den Stripe Checkout-Flow für eine einfache und sichere Zahlungsabwicklung
|
||||
3. Implementieren Sie robuste Fehlerbehandlung für Übersetzungen und API-Aufrufe
|
||||
4. Testen Sie sowohl im Entwicklungs- als auch im Produktionsmodus
|
||||
|
||||
Bei Fragen oder Problemen konsultieren Sie die [Stripe-Dokumentation](https://stripe.com/docs/checkout) oder öffnen Sie ein Issue im BaunTown-Repository.
|
||||
Bei Fragen oder Problemen konsultieren Sie die [Stripe-Dokumentation](https://stripe.com/docs/checkout) oder öffnen Sie ein Issue im BaunTown-Repository.
|
||||
|
|
|
|||
|
|
@ -4,155 +4,157 @@ const crypto = require('crypto');
|
|||
|
||||
// Überprüfe, ob alle erforderlichen Umgebungsvariablen vorhanden sind
|
||||
const requiredEnvVars = [
|
||||
'GOOGLE_SERVICE_ACCOUNT_EMAIL',
|
||||
'GOOGLE_PRIVATE_KEY',
|
||||
'GOOGLE_SHEET_ID',
|
||||
'SITE_URL'
|
||||
'GOOGLE_SERVICE_ACCOUNT_EMAIL',
|
||||
'GOOGLE_PRIVATE_KEY',
|
||||
'GOOGLE_SHEET_ID',
|
||||
'SITE_URL',
|
||||
];
|
||||
|
||||
function checkEnvVars() {
|
||||
const missing = requiredEnvVars.filter(varName => !process.env[varName]);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Fehlende Umgebungsvariablen: ${missing.join(', ')}`);
|
||||
}
|
||||
const missing = requiredEnvVars.filter((varName) => !process.env[varName]);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Fehlende Umgebungsvariablen: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const auth = new JWT({
|
||||
email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
|
||||
key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
|
||||
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
|
||||
email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
|
||||
key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
|
||||
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
|
||||
});
|
||||
|
||||
const sheets = google.sheets({ version: 'v4', auth });
|
||||
|
||||
exports.handler = async function(event, context) {
|
||||
console.log('Content submission request received');
|
||||
|
||||
// CORS Headers
|
||||
const headers = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS'
|
||||
};
|
||||
exports.handler = async function (event, context) {
|
||||
console.log('Content submission request received');
|
||||
|
||||
// Handle OPTIONS request
|
||||
if (event.httpMethod === 'OPTIONS') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: ''
|
||||
};
|
||||
}
|
||||
// CORS Headers
|
||||
const headers = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
};
|
||||
|
||||
if (event.httpMethod !== 'POST') {
|
||||
return {
|
||||
statusCode: 405,
|
||||
headers,
|
||||
body: JSON.stringify({ error: 'Method Not Allowed' })
|
||||
};
|
||||
}
|
||||
// Handle OPTIONS request
|
||||
if (event.httpMethod === 'OPTIONS') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: '',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Überprüfe Umgebungsvariablen
|
||||
checkEnvVars();
|
||||
|
||||
// Parse request body
|
||||
const { contentType, title, description, email } = JSON.parse(event.body);
|
||||
|
||||
if (!contentType || !title || !description || !email) {
|
||||
throw new Error('Alle Felder müssen ausgefüllt werden');
|
||||
}
|
||||
if (event.httpMethod !== 'POST') {
|
||||
return {
|
||||
statusCode: 405,
|
||||
headers,
|
||||
body: JSON.stringify({ error: 'Method Not Allowed' }),
|
||||
};
|
||||
}
|
||||
|
||||
console.log('Processing submission:', { contentType, title, email });
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const id = crypto.randomUUID();
|
||||
try {
|
||||
// Überprüfe Umgebungsvariablen
|
||||
checkEnvVars();
|
||||
|
||||
// Füge neue Zeile zum Sheet hinzu
|
||||
console.log('Adding row to Google Sheet');
|
||||
try {
|
||||
// Prüfe zuerst, ob das Sheet existiert
|
||||
const sheetInfo = await sheets.spreadsheets.get({
|
||||
spreadsheetId: process.env.GOOGLE_SHEET_ID
|
||||
});
|
||||
|
||||
// Prüfe, ob das Content-Submissions-Sheet existiert, wenn nicht, erstelle es
|
||||
let sheetExists = false;
|
||||
const sheetName = 'ContentSubmissions';
|
||||
|
||||
for (const sheet of sheetInfo.data.sheets) {
|
||||
if (sheet.properties.title === sheetName) {
|
||||
sheetExists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sheetExists) {
|
||||
// Erstelle ein neues Sheet für Content-Submissions
|
||||
await sheets.spreadsheets.batchUpdate({
|
||||
spreadsheetId: process.env.GOOGLE_SHEET_ID,
|
||||
requestBody: {
|
||||
requests: [
|
||||
{
|
||||
addSheet: {
|
||||
properties: {
|
||||
title: sheetName,
|
||||
gridProperties: {
|
||||
rowCount: 1000,
|
||||
columnCount: 6
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// Füge Überschriften hinzu
|
||||
await sheets.spreadsheets.values.update({
|
||||
spreadsheetId: process.env.GOOGLE_SHEET_ID,
|
||||
range: `${sheetName}!A1:F1`,
|
||||
valueInputOption: 'USER_ENTERED',
|
||||
requestBody: {
|
||||
values: [['Timestamp', 'ID', 'Content Type', 'Title', 'Description', 'Email']]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Füge die neue Zeile hinzu
|
||||
await sheets.spreadsheets.values.append({
|
||||
spreadsheetId: process.env.GOOGLE_SHEET_ID,
|
||||
range: `${sheetName}!A:F`,
|
||||
valueInputOption: 'USER_ENTERED',
|
||||
requestBody: {
|
||||
values: [[timestamp, id, contentType, title, description, email]]
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Content submission added to sheet');
|
||||
} catch (sheetError) {
|
||||
console.error('Google Sheets error:', sheetError);
|
||||
throw new Error(`Google Sheets Fehler: ${sheetError.message}. Bitte stellen Sie sicher, dass das Sheet existiert und der Service Account Zugriff hat.`);
|
||||
}
|
||||
// Parse request body
|
||||
const { contentType, title, description, email } = JSON.parse(event.body);
|
||||
|
||||
console.log('Submission completed successfully');
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
message: 'Deine Einreichung wurde erfolgreich gespeichert.'
|
||||
})
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Content submission error:', error);
|
||||
return {
|
||||
statusCode: 500,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: error.message,
|
||||
details: error.stack
|
||||
})
|
||||
};
|
||||
}
|
||||
};
|
||||
if (!contentType || !title || !description || !email) {
|
||||
throw new Error('Alle Felder müssen ausgefüllt werden');
|
||||
}
|
||||
|
||||
console.log('Processing submission:', { contentType, title, email });
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
// Füge neue Zeile zum Sheet hinzu
|
||||
console.log('Adding row to Google Sheet');
|
||||
try {
|
||||
// Prüfe zuerst, ob das Sheet existiert
|
||||
const sheetInfo = await sheets.spreadsheets.get({
|
||||
spreadsheetId: process.env.GOOGLE_SHEET_ID,
|
||||
});
|
||||
|
||||
// Prüfe, ob das Content-Submissions-Sheet existiert, wenn nicht, erstelle es
|
||||
let sheetExists = false;
|
||||
const sheetName = 'ContentSubmissions';
|
||||
|
||||
for (const sheet of sheetInfo.data.sheets) {
|
||||
if (sheet.properties.title === sheetName) {
|
||||
sheetExists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sheetExists) {
|
||||
// Erstelle ein neues Sheet für Content-Submissions
|
||||
await sheets.spreadsheets.batchUpdate({
|
||||
spreadsheetId: process.env.GOOGLE_SHEET_ID,
|
||||
requestBody: {
|
||||
requests: [
|
||||
{
|
||||
addSheet: {
|
||||
properties: {
|
||||
title: sheetName,
|
||||
gridProperties: {
|
||||
rowCount: 1000,
|
||||
columnCount: 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Füge Überschriften hinzu
|
||||
await sheets.spreadsheets.values.update({
|
||||
spreadsheetId: process.env.GOOGLE_SHEET_ID,
|
||||
range: `${sheetName}!A1:F1`,
|
||||
valueInputOption: 'USER_ENTERED',
|
||||
requestBody: {
|
||||
values: [['Timestamp', 'ID', 'Content Type', 'Title', 'Description', 'Email']],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Füge die neue Zeile hinzu
|
||||
await sheets.spreadsheets.values.append({
|
||||
spreadsheetId: process.env.GOOGLE_SHEET_ID,
|
||||
range: `${sheetName}!A:F`,
|
||||
valueInputOption: 'USER_ENTERED',
|
||||
requestBody: {
|
||||
values: [[timestamp, id, contentType, title, description, email]],
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Content submission added to sheet');
|
||||
} catch (sheetError) {
|
||||
console.error('Google Sheets error:', sheetError);
|
||||
throw new Error(
|
||||
`Google Sheets Fehler: ${sheetError.message}. Bitte stellen Sie sicher, dass das Sheet existiert und der Service Account Zugriff hat.`
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Submission completed successfully');
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
message: 'Deine Einreichung wurde erfolgreich gespeichert.',
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Content submission error:', error);
|
||||
return {
|
||||
statusCode: 500,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: error.message,
|
||||
details: error.stack,
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,112 +2,114 @@
|
|||
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
||||
|
||||
exports.handler = async (event, context) => {
|
||||
// CORS Headers
|
||||
const headers = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS"
|
||||
};
|
||||
|
||||
// Handle OPTIONS request (CORS preflight)
|
||||
if (event.httpMethod === "OPTIONS") {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: ""
|
||||
};
|
||||
}
|
||||
// CORS Headers
|
||||
const headers = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
};
|
||||
|
||||
try {
|
||||
// Parse request
|
||||
const data = JSON.parse(event.body || '{}');
|
||||
const { amount, isRecurring, priceId, coffeeSize } = data;
|
||||
|
||||
// Debug-Ausgabe
|
||||
console.log("Request data:", { amount, isRecurring, priceId, coffeeSize });
|
||||
console.log("Stripe key available:", !!process.env.STRIPE_SECRET_KEY);
|
||||
|
||||
// Fallback, wenn kein Stripe-Key verfügbar
|
||||
if (!process.env.STRIPE_SECRET_KEY) {
|
||||
console.log("WARNUNG: STRIPE_SECRET_KEY fehlt - liefere Test-Antwort");
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
url: `${process.env.URL || 'https://bauntown.com'}/support-success?test=true`,
|
||||
isRecurring: isRecurring || false,
|
||||
message: "Test mode - no Stripe key available"
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
// Betrag in Cent umrechnen für Stripe
|
||||
const amountInCents = Math.round(amount * 100);
|
||||
|
||||
let sessionConfig = {
|
||||
payment_method_types: ['card'],
|
||||
line_items: [{
|
||||
price_data: {
|
||||
currency: 'eur',
|
||||
product_data: {
|
||||
name: `BaunTown Kaffee - ${coffeeSize || 'Mittlerer Kaffee'}`,
|
||||
description: isRecurring ? 'Monatliche Unterstützung' : 'Einmalige Unterstützung',
|
||||
},
|
||||
unit_amount: amountInCents,
|
||||
recurring: isRecurring ? { interval: 'month' } : undefined,
|
||||
},
|
||||
quantity: 1,
|
||||
}],
|
||||
mode: isRecurring ? 'subscription' : 'payment',
|
||||
success_url: `${process.env.URL || 'https://bauntown.com'}/support-success?amount=${amount}&type=${isRecurring ? 'recurring' : 'one-time'}&provider=stripe&coffeeSize=${encodeURIComponent(coffeeSize || 'Mittlerer Kaffee')}`,
|
||||
cancel_url: `${process.env.URL || 'https://bauntown.com'}/support-cancel`,
|
||||
metadata: {
|
||||
isRecurring: isRecurring ? 'true' : 'false',
|
||||
priceId: priceId || '',
|
||||
coffeeSize: coffeeSize || 'Mittlerer Kaffee'
|
||||
}
|
||||
};
|
||||
|
||||
console.log("Creating checkout session with config:", JSON.stringify(sessionConfig));
|
||||
|
||||
try {
|
||||
// Stripe Checkout Session erstellen
|
||||
const session = await stripe.checkout.sessions.create(sessionConfig);
|
||||
|
||||
console.log("Session created:", session.id, "URL:", session.url);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
url: session.url, // Die Redirect-URL hat Priorität
|
||||
isRecurring: isRecurring || false
|
||||
})
|
||||
};
|
||||
} catch (stripeError) {
|
||||
console.error("Stripe error:", stripeError);
|
||||
return {
|
||||
statusCode: 400,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
message: `Stripe error: ${stripeError.message}`,
|
||||
stripeCode: stripeError.code || 'unknown'
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Function error:", error);
|
||||
|
||||
return {
|
||||
statusCode: 500,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
message: error.message || "Unknown server error"
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
};
|
||||
// Handle OPTIONS request (CORS preflight)
|
||||
if (event.httpMethod === 'OPTIONS') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: '',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse request
|
||||
const data = JSON.parse(event.body || '{}');
|
||||
const { amount, isRecurring, priceId, coffeeSize } = data;
|
||||
|
||||
// Debug-Ausgabe
|
||||
console.log('Request data:', { amount, isRecurring, priceId, coffeeSize });
|
||||
console.log('Stripe key available:', !!process.env.STRIPE_SECRET_KEY);
|
||||
|
||||
// Fallback, wenn kein Stripe-Key verfügbar
|
||||
if (!process.env.STRIPE_SECRET_KEY) {
|
||||
console.log('WARNUNG: STRIPE_SECRET_KEY fehlt - liefere Test-Antwort');
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
url: `${process.env.URL || 'https://bauntown.com'}/support-success?test=true`,
|
||||
isRecurring: isRecurring || false,
|
||||
message: 'Test mode - no Stripe key available',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Betrag in Cent umrechnen für Stripe
|
||||
const amountInCents = Math.round(amount * 100);
|
||||
|
||||
let sessionConfig = {
|
||||
payment_method_types: ['card'],
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: 'eur',
|
||||
product_data: {
|
||||
name: `BaunTown Kaffee - ${coffeeSize || 'Mittlerer Kaffee'}`,
|
||||
description: isRecurring ? 'Monatliche Unterstützung' : 'Einmalige Unterstützung',
|
||||
},
|
||||
unit_amount: amountInCents,
|
||||
recurring: isRecurring ? { interval: 'month' } : undefined,
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
mode: isRecurring ? 'subscription' : 'payment',
|
||||
success_url: `${process.env.URL || 'https://bauntown.com'}/support-success?amount=${amount}&type=${isRecurring ? 'recurring' : 'one-time'}&provider=stripe&coffeeSize=${encodeURIComponent(coffeeSize || 'Mittlerer Kaffee')}`,
|
||||
cancel_url: `${process.env.URL || 'https://bauntown.com'}/support-cancel`,
|
||||
metadata: {
|
||||
isRecurring: isRecurring ? 'true' : 'false',
|
||||
priceId: priceId || '',
|
||||
coffeeSize: coffeeSize || 'Mittlerer Kaffee',
|
||||
},
|
||||
};
|
||||
|
||||
console.log('Creating checkout session with config:', JSON.stringify(sessionConfig));
|
||||
|
||||
try {
|
||||
// Stripe Checkout Session erstellen
|
||||
const session = await stripe.checkout.sessions.create(sessionConfig);
|
||||
|
||||
console.log('Session created:', session.id, 'URL:', session.url);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
url: session.url, // Die Redirect-URL hat Priorität
|
||||
isRecurring: isRecurring || false,
|
||||
}),
|
||||
};
|
||||
} catch (stripeError) {
|
||||
console.error('Stripe error:', stripeError);
|
||||
return {
|
||||
statusCode: 400,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
message: `Stripe error: ${stripeError.message}`,
|
||||
stripeCode: stripeError.code || 'unknown',
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Function error:', error);
|
||||
|
||||
return {
|
||||
statusCode: 500,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
message: error.message || 'Unknown server error',
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,140 +5,148 @@ const fetch = require('node-fetch');
|
|||
|
||||
// PayPal OAuth Token holen
|
||||
async function getPayPalAccessToken() {
|
||||
const auth = Buffer.from(`${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}`).toString('base64');
|
||||
|
||||
const response = await fetch('https://api-m.sandbox.paypal.com/v1/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Authorization': `Basic ${auth}`
|
||||
},
|
||||
body: 'grant_type=client_credentials'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return data.access_token;
|
||||
const auth = Buffer.from(
|
||||
`${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}`
|
||||
).toString('base64');
|
||||
|
||||
const response = await fetch('https://api-m.sandbox.paypal.com/v1/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: `Basic ${auth}`,
|
||||
},
|
||||
body: 'grant_type=client_credentials',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
// PayPal Order erstellen
|
||||
async function createPayPalOrder(accessToken, amount, isRecurring, priceId, coffeeSize) {
|
||||
const url = isRecurring
|
||||
? 'https://api-m.sandbox.paypal.com/v1/billing/plans'
|
||||
: 'https://api-m.sandbox.paypal.com/v2/checkout/orders';
|
||||
|
||||
if (isRecurring) {
|
||||
// Vereinfachtes Beispiel für ein Abonnement
|
||||
// In einer echten Anwendung sollten Sie Pläne vorab erstellen
|
||||
const planData = {
|
||||
name: `BaunTown Monthly ${coffeeSize}`,
|
||||
description: `Monthly ${coffeeSize} support for BaunTown (${amount}€)`,
|
||||
type: "FIXED",
|
||||
payment_definitions: [
|
||||
{
|
||||
name: `Monthly ${coffeeSize}`,
|
||||
type: "REGULAR",
|
||||
frequency: "MONTH",
|
||||
frequency_interval: "1",
|
||||
amount: {
|
||||
value: amount,
|
||||
currency: "EUR"
|
||||
},
|
||||
cycles: "0"
|
||||
}
|
||||
],
|
||||
merchant_preferences: {
|
||||
return_url: `${process.env.URL || 'http://localhost:3000'}/support-success`,
|
||||
cancel_url: `${process.env.URL || 'http://localhost:3000'}/support-cancel`
|
||||
}
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
},
|
||||
body: JSON.stringify(planData)
|
||||
});
|
||||
|
||||
return response.json();
|
||||
} else {
|
||||
// Normale einmalige Zahlung
|
||||
const orderData = {
|
||||
intent: "CAPTURE",
|
||||
purchase_units: [
|
||||
{
|
||||
amount: {
|
||||
currency_code: "EUR",
|
||||
value: amount
|
||||
},
|
||||
description: `${coffeeSize} für BaunTown (${amount}€)`,
|
||||
custom_id: priceId
|
||||
}
|
||||
],
|
||||
application_context: {
|
||||
return_url: `${process.env.URL || 'http://localhost:3000'}/support-success?amount=${amount}&type=${isRecurring ? 'recurring' : 'one-time'}&provider=paypal&coffeeSize=${encodeURIComponent(coffeeSize)}`,
|
||||
cancel_url: `${process.env.URL || 'http://localhost:3000'}/support-cancel`
|
||||
}
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
},
|
||||
body: JSON.stringify(orderData)
|
||||
});
|
||||
|
||||
return response.json();
|
||||
}
|
||||
const url = isRecurring
|
||||
? 'https://api-m.sandbox.paypal.com/v1/billing/plans'
|
||||
: 'https://api-m.sandbox.paypal.com/v2/checkout/orders';
|
||||
|
||||
if (isRecurring) {
|
||||
// Vereinfachtes Beispiel für ein Abonnement
|
||||
// In einer echten Anwendung sollten Sie Pläne vorab erstellen
|
||||
const planData = {
|
||||
name: `BaunTown Monthly ${coffeeSize}`,
|
||||
description: `Monthly ${coffeeSize} support for BaunTown (${amount}€)`,
|
||||
type: 'FIXED',
|
||||
payment_definitions: [
|
||||
{
|
||||
name: `Monthly ${coffeeSize}`,
|
||||
type: 'REGULAR',
|
||||
frequency: 'MONTH',
|
||||
frequency_interval: '1',
|
||||
amount: {
|
||||
value: amount,
|
||||
currency: 'EUR',
|
||||
},
|
||||
cycles: '0',
|
||||
},
|
||||
],
|
||||
merchant_preferences: {
|
||||
return_url: `${process.env.URL || 'http://localhost:3000'}/support-success`,
|
||||
cancel_url: `${process.env.URL || 'http://localhost:3000'}/support-cancel`,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify(planData),
|
||||
});
|
||||
|
||||
return response.json();
|
||||
} else {
|
||||
// Normale einmalige Zahlung
|
||||
const orderData = {
|
||||
intent: 'CAPTURE',
|
||||
purchase_units: [
|
||||
{
|
||||
amount: {
|
||||
currency_code: 'EUR',
|
||||
value: amount,
|
||||
},
|
||||
description: `${coffeeSize} für BaunTown (${amount}€)`,
|
||||
custom_id: priceId,
|
||||
},
|
||||
],
|
||||
application_context: {
|
||||
return_url: `${process.env.URL || 'http://localhost:3000'}/support-success?amount=${amount}&type=${isRecurring ? 'recurring' : 'one-time'}&provider=paypal&coffeeSize=${encodeURIComponent(coffeeSize)}`,
|
||||
cancel_url: `${process.env.URL || 'http://localhost:3000'}/support-cancel`,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify(orderData),
|
||||
});
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
exports.handler = async (event, context) => {
|
||||
// CORS Headers
|
||||
const headers = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS"
|
||||
};
|
||||
|
||||
// Handle OPTIONS request (CORS preflight)
|
||||
if (event.httpMethod === "OPTIONS") {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: ""
|
||||
};
|
||||
}
|
||||
// CORS Headers
|
||||
const headers = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
};
|
||||
|
||||
try {
|
||||
// Request-Body parsen
|
||||
const data = JSON.parse(event.body);
|
||||
const { amount, isRecurring, priceId, coffeeSize } = data;
|
||||
|
||||
// PayPal Access Token holen
|
||||
const accessToken = await getPayPalAccessToken();
|
||||
|
||||
// Order oder Plan erstellen mit zusätzlichen Metadaten
|
||||
const paypalResponse = await createPayPalOrder(accessToken, amount, isRecurring, priceId, coffeeSize);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify(paypalResponse)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('PayPal error:', error);
|
||||
|
||||
return {
|
||||
statusCode: 500,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
message: error.message || 'Internal server error'
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
};
|
||||
// Handle OPTIONS request (CORS preflight)
|
||||
if (event.httpMethod === 'OPTIONS') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: '',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Request-Body parsen
|
||||
const data = JSON.parse(event.body);
|
||||
const { amount, isRecurring, priceId, coffeeSize } = data;
|
||||
|
||||
// PayPal Access Token holen
|
||||
const accessToken = await getPayPalAccessToken();
|
||||
|
||||
// Order oder Plan erstellen mit zusätzlichen Metadaten
|
||||
const paypalResponse = await createPayPalOrder(
|
||||
accessToken,
|
||||
amount,
|
||||
isRecurring,
|
||||
priceId,
|
||||
coffeeSize
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify(paypalResponse),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('PayPal error:', error);
|
||||
|
||||
return {
|
||||
statusCode: 500,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,112 +4,114 @@ const crypto = require('crypto');
|
|||
|
||||
// Überprüfe, ob alle erforderlichen Umgebungsvariablen vorhanden sind
|
||||
const requiredEnvVars = [
|
||||
'GOOGLE_SERVICE_ACCOUNT_EMAIL',
|
||||
'GOOGLE_PRIVATE_KEY',
|
||||
'GOOGLE_SHEET_ID',
|
||||
'SITE_URL'
|
||||
'GOOGLE_SERVICE_ACCOUNT_EMAIL',
|
||||
'GOOGLE_PRIVATE_KEY',
|
||||
'GOOGLE_SHEET_ID',
|
||||
'SITE_URL',
|
||||
];
|
||||
|
||||
function checkEnvVars() {
|
||||
const missing = requiredEnvVars.filter(varName => !process.env[varName]);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Fehlende Umgebungsvariablen: ${missing.join(', ')}`);
|
||||
}
|
||||
const missing = requiredEnvVars.filter((varName) => !process.env[varName]);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Fehlende Umgebungsvariablen: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const auth = new JWT({
|
||||
email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
|
||||
key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
|
||||
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
|
||||
email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
|
||||
key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
|
||||
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
|
||||
});
|
||||
|
||||
const sheets = google.sheets({ version: 'v4', auth });
|
||||
|
||||
exports.handler = async function(event, context) {
|
||||
console.log('Newsletter subscription request received');
|
||||
|
||||
// CORS Headers
|
||||
const headers = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS'
|
||||
};
|
||||
exports.handler = async function (event, context) {
|
||||
console.log('Newsletter subscription request received');
|
||||
|
||||
// Handle OPTIONS request
|
||||
if (event.httpMethod === 'OPTIONS') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: ''
|
||||
};
|
||||
}
|
||||
// CORS Headers
|
||||
const headers = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
};
|
||||
|
||||
if (event.httpMethod !== 'POST') {
|
||||
return {
|
||||
statusCode: 405,
|
||||
headers,
|
||||
body: JSON.stringify({ error: 'Method Not Allowed' })
|
||||
};
|
||||
}
|
||||
// Handle OPTIONS request
|
||||
if (event.httpMethod === 'OPTIONS') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: '',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Überprüfe Umgebungsvariablen
|
||||
checkEnvVars();
|
||||
|
||||
// Parse request body
|
||||
const { email } = JSON.parse(event.body);
|
||||
if (!email) {
|
||||
throw new Error('Email is required');
|
||||
}
|
||||
if (event.httpMethod !== 'POST') {
|
||||
return {
|
||||
statusCode: 405,
|
||||
headers,
|
||||
body: JSON.stringify({ error: 'Method Not Allowed' }),
|
||||
};
|
||||
}
|
||||
|
||||
console.log('Processing subscription for email:', email);
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const unsubscribeToken = crypto.randomUUID();
|
||||
try {
|
||||
// Überprüfe Umgebungsvariablen
|
||||
checkEnvVars();
|
||||
|
||||
// Füge neue Zeile zum Sheet hinzu
|
||||
console.log('Adding row to Google Sheet');
|
||||
try {
|
||||
// Prüfe zuerst, ob das Sheet existiert und hole den ersten Tab-Namen
|
||||
const sheetInfo = await sheets.spreadsheets.get({
|
||||
spreadsheetId: process.env.GOOGLE_SHEET_ID
|
||||
});
|
||||
|
||||
console.log('Sheet info:', sheetInfo.data);
|
||||
|
||||
// Hole den Namen des ersten Tabs
|
||||
const firstSheetName = sheetInfo.data.sheets[0].properties.title;
|
||||
console.log('Using sheet name:', firstSheetName);
|
||||
|
||||
// Füge die Zeile hinzu
|
||||
await sheets.spreadsheets.values.append({
|
||||
spreadsheetId: process.env.GOOGLE_SHEET_ID,
|
||||
range: `${firstSheetName}!A:D`,
|
||||
valueInputOption: 'USER_ENTERED',
|
||||
requestBody: {
|
||||
values: [[timestamp, email, 'active', unsubscribeToken]]
|
||||
}
|
||||
});
|
||||
} catch (sheetError) {
|
||||
console.error('Google Sheets error:', sheetError);
|
||||
throw new Error(`Google Sheets Fehler: ${sheetError.message}. Bitte stellen Sie sicher, dass das Sheet existiert und der Service Account Zugriff hat.`);
|
||||
}
|
||||
// Parse request body
|
||||
const { email } = JSON.parse(event.body);
|
||||
if (!email) {
|
||||
throw new Error('Email is required');
|
||||
}
|
||||
|
||||
console.log('Subscription completed successfully');
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({ success: true })
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Newsletter subscription error:', error);
|
||||
return {
|
||||
statusCode: 500,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: error.message,
|
||||
details: error.stack
|
||||
})
|
||||
};
|
||||
}
|
||||
};
|
||||
console.log('Processing subscription for email:', email);
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const unsubscribeToken = crypto.randomUUID();
|
||||
|
||||
// Füge neue Zeile zum Sheet hinzu
|
||||
console.log('Adding row to Google Sheet');
|
||||
try {
|
||||
// Prüfe zuerst, ob das Sheet existiert und hole den ersten Tab-Namen
|
||||
const sheetInfo = await sheets.spreadsheets.get({
|
||||
spreadsheetId: process.env.GOOGLE_SHEET_ID,
|
||||
});
|
||||
|
||||
console.log('Sheet info:', sheetInfo.data);
|
||||
|
||||
// Hole den Namen des ersten Tabs
|
||||
const firstSheetName = sheetInfo.data.sheets[0].properties.title;
|
||||
console.log('Using sheet name:', firstSheetName);
|
||||
|
||||
// Füge die Zeile hinzu
|
||||
await sheets.spreadsheets.values.append({
|
||||
spreadsheetId: process.env.GOOGLE_SHEET_ID,
|
||||
range: `${firstSheetName}!A:D`,
|
||||
valueInputOption: 'USER_ENTERED',
|
||||
requestBody: {
|
||||
values: [[timestamp, email, 'active', unsubscribeToken]],
|
||||
},
|
||||
});
|
||||
} catch (sheetError) {
|
||||
console.error('Google Sheets error:', sheetError);
|
||||
throw new Error(
|
||||
`Google Sheets Fehler: ${sheetError.message}. Bitte stellen Sie sicher, dass das Sheet existiert und der Service Account Zugriff hat.`
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Subscription completed successfully');
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({ success: true }),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Newsletter subscription error:', error);
|
||||
return {
|
||||
statusCode: 500,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: error.message,
|
||||
details: error.stack,
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,66 +2,66 @@ const { google } = require('googleapis');
|
|||
const { JWT } = require('google-auth-library');
|
||||
|
||||
const auth = new JWT({
|
||||
email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
|
||||
key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
|
||||
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
|
||||
email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
|
||||
key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
|
||||
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
|
||||
});
|
||||
|
||||
const sheets = google.sheets({ version: 'v4', auth });
|
||||
|
||||
exports.handler = async function(event, context) {
|
||||
if (event.httpMethod !== 'GET') {
|
||||
return { statusCode: 405, body: 'Method Not Allowed' };
|
||||
}
|
||||
exports.handler = async function (event, context) {
|
||||
if (event.httpMethod !== 'GET') {
|
||||
return { statusCode: 405, body: 'Method Not Allowed' };
|
||||
}
|
||||
|
||||
const { token } = event.queryStringParameters;
|
||||
const { token } = event.queryStringParameters;
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: JSON.stringify({ error: 'Token is required' })
|
||||
};
|
||||
}
|
||||
if (!token) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: JSON.stringify({ error: 'Token is required' }),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Finde die Zeile mit dem Token
|
||||
const response = await sheets.spreadsheets.values.get({
|
||||
spreadsheetId: process.env.GOOGLE_SHEET_ID,
|
||||
range: 'Sheet1!A:D',
|
||||
});
|
||||
try {
|
||||
// Finde die Zeile mit dem Token
|
||||
const response = await sheets.spreadsheets.values.get({
|
||||
spreadsheetId: process.env.GOOGLE_SHEET_ID,
|
||||
range: 'Sheet1!A:D',
|
||||
});
|
||||
|
||||
const values = response.data.values;
|
||||
const rowIndex = values.findIndex(row => row[3] === token);
|
||||
const values = response.data.values;
|
||||
const rowIndex = values.findIndex((row) => row[3] === token);
|
||||
|
||||
if (rowIndex === -1) {
|
||||
return {
|
||||
statusCode: 404,
|
||||
body: JSON.stringify({ error: 'Token not found' })
|
||||
};
|
||||
}
|
||||
if (rowIndex === -1) {
|
||||
return {
|
||||
statusCode: 404,
|
||||
body: JSON.stringify({ error: 'Token not found' }),
|
||||
};
|
||||
}
|
||||
|
||||
// Aktualisiere den Status auf 'unsubscribed'
|
||||
await sheets.spreadsheets.values.update({
|
||||
spreadsheetId: process.env.GOOGLE_SHEET_ID,
|
||||
range: `Sheet1!C${rowIndex + 1}`,
|
||||
valueInputOption: 'USER_ENTERED',
|
||||
requestBody: {
|
||||
values: [['unsubscribed']]
|
||||
}
|
||||
});
|
||||
// Aktualisiere den Status auf 'unsubscribed'
|
||||
await sheets.spreadsheets.values.update({
|
||||
spreadsheetId: process.env.GOOGLE_SHEET_ID,
|
||||
range: `Sheet1!C${rowIndex + 1}`,
|
||||
valueInputOption: 'USER_ENTERED',
|
||||
requestBody: {
|
||||
values: [['unsubscribed']],
|
||||
},
|
||||
});
|
||||
|
||||
// Redirect zur Bestätigungsseite
|
||||
return {
|
||||
statusCode: 302,
|
||||
headers: {
|
||||
Location: `${process.env.URL}/unsubscribe-success`
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Newsletter unsubscribe error:', error);
|
||||
return {
|
||||
statusCode: 500,
|
||||
body: JSON.stringify({ error: error.message })
|
||||
};
|
||||
}
|
||||
};
|
||||
// Redirect zur Bestätigungsseite
|
||||
return {
|
||||
statusCode: 302,
|
||||
headers: {
|
||||
Location: `${process.env.URL}/unsubscribe-success`,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Newsletter unsubscribe error:', error);
|
||||
return {
|
||||
statusCode: 500,
|
||||
body: JSON.stringify({ error: error.message }),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"name": "bauntown-functions",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"stripe": "^12.0.0",
|
||||
"googleapis": "^128.0.0",
|
||||
"google-auth-library": "^9.0.0",
|
||||
"node-fetch": "^2.7.0"
|
||||
}
|
||||
}
|
||||
"name": "bauntown-functions",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"stripe": "^12.0.0",
|
||||
"googleapis": "^128.0.0",
|
||||
"google-auth-library": "^9.0.0",
|
||||
"node-fetch": "^2.7.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,145 +1,145 @@
|
|||
// Netlify Function für Webhooks von Stripe und PayPal
|
||||
|
||||
exports.handler = async (event, context) => {
|
||||
// CORS Headers
|
||||
const headers = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS"
|
||||
};
|
||||
|
||||
// Handle OPTIONS request (CORS preflight)
|
||||
if (event.httpMethod === "OPTIONS") {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: ""
|
||||
};
|
||||
}
|
||||
// CORS Headers
|
||||
const headers = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
};
|
||||
|
||||
// Den Webhook-Source aus URL-Parameter oder Header bestimmen
|
||||
const source = event.queryStringParameters.source || 'unknown';
|
||||
|
||||
try {
|
||||
if (source === 'stripe') {
|
||||
// Stripe Webhook verarbeiten
|
||||
return processStripeWebhook(event, headers);
|
||||
} else if (source === 'paypal') {
|
||||
// PayPal Webhook verarbeiten
|
||||
return processPayPalWebhook(event, headers);
|
||||
} else {
|
||||
return {
|
||||
statusCode: 400,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
message: `Unknown webhook source: ${source}`
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Webhook error (${source}):`, error);
|
||||
|
||||
return {
|
||||
statusCode: 500,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
message: error.message || 'Internal server error'
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
// Handle OPTIONS request (CORS preflight)
|
||||
if (event.httpMethod === 'OPTIONS') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: '',
|
||||
};
|
||||
}
|
||||
|
||||
// Den Webhook-Source aus URL-Parameter oder Header bestimmen
|
||||
const source = event.queryStringParameters.source || 'unknown';
|
||||
|
||||
try {
|
||||
if (source === 'stripe') {
|
||||
// Stripe Webhook verarbeiten
|
||||
return processStripeWebhook(event, headers);
|
||||
} else if (source === 'paypal') {
|
||||
// PayPal Webhook verarbeiten
|
||||
return processPayPalWebhook(event, headers);
|
||||
} else {
|
||||
return {
|
||||
statusCode: 400,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
message: `Unknown webhook source: ${source}`,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Webhook error (${source}):`, error);
|
||||
|
||||
return {
|
||||
statusCode: 500,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Stripe Webhook verarbeiten
|
||||
async function processStripeWebhook(event, headers) {
|
||||
// In einer echten Anwendung würden Sie hier die Stripe Signatur überprüfen
|
||||
// const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
||||
// const sig = event.headers['stripe-signature'];
|
||||
// const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.body);
|
||||
|
||||
// Verschiedene Ereignistypen verarbeiten
|
||||
switch (data.type) {
|
||||
case 'payment_intent.succeeded':
|
||||
// Zahlung erfolgreich - hier könnte eine Datenbank aktualisiert werden
|
||||
console.log('Payment succeeded:', data.data.object.id);
|
||||
break;
|
||||
|
||||
case 'payment_intent.payment_failed':
|
||||
// Zahlung fehlgeschlagen
|
||||
console.log('Payment failed:', data.data.object.id);
|
||||
break;
|
||||
|
||||
// Weitere Event-Typen hier verarbeiten
|
||||
|
||||
default:
|
||||
console.log('Unhandled Stripe event type:', data.type);
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({ received: true })
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error processing Stripe webhook:', error);
|
||||
return {
|
||||
statusCode: 400,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
message: error.message
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
// In einer echten Anwendung würden Sie hier die Stripe Signatur überprüfen
|
||||
// const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
||||
// const sig = event.headers['stripe-signature'];
|
||||
// const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.body);
|
||||
|
||||
// Verschiedene Ereignistypen verarbeiten
|
||||
switch (data.type) {
|
||||
case 'payment_intent.succeeded':
|
||||
// Zahlung erfolgreich - hier könnte eine Datenbank aktualisiert werden
|
||||
console.log('Payment succeeded:', data.data.object.id);
|
||||
break;
|
||||
|
||||
case 'payment_intent.payment_failed':
|
||||
// Zahlung fehlgeschlagen
|
||||
console.log('Payment failed:', data.data.object.id);
|
||||
break;
|
||||
|
||||
// Weitere Event-Typen hier verarbeiten
|
||||
|
||||
default:
|
||||
console.log('Unhandled Stripe event type:', data.type);
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({ received: true }),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error processing Stripe webhook:', error);
|
||||
return {
|
||||
statusCode: 400,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
message: error.message,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// PayPal Webhook verarbeiten
|
||||
async function processPayPalWebhook(event, headers) {
|
||||
// In einer echten Anwendung würden Sie hier die PayPal-Signatur verifizieren
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.body);
|
||||
|
||||
// Verschiedene Ereignistypen verarbeiten
|
||||
switch (data.event_type) {
|
||||
case 'PAYMENT.CAPTURE.COMPLETED':
|
||||
// Zahlung erfolgreich - hier könnte eine Datenbank aktualisiert werden
|
||||
console.log('PayPal payment completed:', data.resource.id);
|
||||
break;
|
||||
|
||||
case 'PAYMENT.CAPTURE.DENIED':
|
||||
// Zahlung abgelehnt
|
||||
console.log('PayPal payment denied:', data.resource.id);
|
||||
break;
|
||||
|
||||
// Weitere Event-Typen hier verarbeiten
|
||||
|
||||
default:
|
||||
console.log('Unhandled PayPal event type:', data.event_type);
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({ received: true })
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error processing PayPal webhook:', error);
|
||||
return {
|
||||
statusCode: 400,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
message: error.message
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
// In einer echten Anwendung würden Sie hier die PayPal-Signatur verifizieren
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.body);
|
||||
|
||||
// Verschiedene Ereignistypen verarbeiten
|
||||
switch (data.event_type) {
|
||||
case 'PAYMENT.CAPTURE.COMPLETED':
|
||||
// Zahlung erfolgreich - hier könnte eine Datenbank aktualisiert werden
|
||||
console.log('PayPal payment completed:', data.resource.id);
|
||||
break;
|
||||
|
||||
case 'PAYMENT.CAPTURE.DENIED':
|
||||
// Zahlung abgelehnt
|
||||
console.log('PayPal payment denied:', data.resource.id);
|
||||
break;
|
||||
|
||||
// Weitere Event-Typen hier verarbeiten
|
||||
|
||||
default:
|
||||
console.log('Unhandled PayPal event type:', data.event_type);
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({ received: true }),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error processing PayPal webhook:', error);
|
||||
return {
|
||||
statusCode: 400,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
message: error.message,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,31 @@
|
|||
{
|
||||
"name": "@bauntown/landing",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"type-check": "astro check",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.2.3",
|
||||
"@astrojs/netlify": "^6.2.5",
|
||||
"@astrojs/partytown": "^2.1.4",
|
||||
"@astrojs/prefetch": "^0.4.1",
|
||||
"@paypal/paypal-js": "^8.2.0",
|
||||
"@stripe/stripe-js": "^6.1.0",
|
||||
"astro": "^5.5.6",
|
||||
"astro-i18n-aut": "^0.7.3",
|
||||
"google-auth-library": "^9.15.1",
|
||||
"googleapis": "^148.0.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"stripe": "^17.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
"name": "@bauntown/landing",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"type-check": "astro check",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.2.3",
|
||||
"@astrojs/netlify": "^6.2.5",
|
||||
"@astrojs/partytown": "^2.1.4",
|
||||
"@astrojs/prefetch": "^0.4.1",
|
||||
"@paypal/paypal-js": "^8.2.0",
|
||||
"@stripe/stripe-js": "^6.1.0",
|
||||
"astro": "^5.5.6",
|
||||
"astro-i18n-aut": "^0.7.3",
|
||||
"google-auth-library": "^9.15.1",
|
||||
"googleapis": "^148.0.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"stripe": "^17.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,218 +1,231 @@
|
|||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
variant?: 'primary' | 'secondary' | 'tertiary';
|
||||
imageAlt?: string;
|
||||
href?: string;
|
||||
buttonText?: string;
|
||||
className?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
variant?: 'primary' | 'secondary' | 'tertiary';
|
||||
imageAlt?: string;
|
||||
href?: string;
|
||||
buttonText?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
variant = 'primary',
|
||||
imageAlt = '',
|
||||
href,
|
||||
buttonText,
|
||||
className = ''
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
variant = 'primary',
|
||||
imageAlt = '',
|
||||
href,
|
||||
buttonText,
|
||||
className = '',
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
{href ? (
|
||||
<a href={href} class="card-link">
|
||||
<article class:list={["card", `card-${variant}`, className]}>
|
||||
{image && (
|
||||
<div class="card-image-container">
|
||||
<img src={image} alt={imageAlt} class="card-image" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="card-content">
|
||||
<h3 class="card-title">{title}</h3>
|
||||
|
||||
{description && <p class="card-description">{description}</p>}
|
||||
|
||||
{buttonText && (
|
||||
<div class="card-footer">
|
||||
<span class="card-button">
|
||||
{buttonText} <span class="arrow">→</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
) : (
|
||||
<article class:list={["card", `card-${variant}`, className]}>
|
||||
{image && (
|
||||
<div class="card-image-container">
|
||||
<img src={image} alt={imageAlt} class="card-image" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="card-content">
|
||||
<h3 class="card-title">{title}</h3>
|
||||
|
||||
{description && <p class="card-description">{description}</p>}
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
{
|
||||
href ? (
|
||||
<a href={href} class="card-link">
|
||||
<article class:list={['card', `card-${variant}`, className]}>
|
||||
{image && (
|
||||
<div class="card-image-container">
|
||||
<img src={image} alt={imageAlt} class="card-image" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="card-content">
|
||||
<h3 class="card-title">{title}</h3>
|
||||
|
||||
{description && <p class="card-description">{description}</p>}
|
||||
|
||||
{buttonText && (
|
||||
<div class="card-footer">
|
||||
<span class="card-button">
|
||||
{buttonText} <span class="arrow">→</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
) : (
|
||||
<article class:list={['card', `card-${variant}`, className]}>
|
||||
{image && (
|
||||
<div class="card-image-container">
|
||||
<img src={image} alt={imageAlt} class="card-image" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="card-content">
|
||||
<h3 class="card-title">{title}</h3>
|
||||
|
||||
{description && <p class="card-description">{description}</p>}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
<style>
|
||||
.card-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
.card-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card-link:hover .card {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 12px -3px rgba(0, 0, 0, 0.1);
|
||||
border-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.5);
|
||||
}
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Primary variant - accent background, white text */
|
||||
.card-primary {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
box-shadow: 0 4px 6px -1px rgba(249, 115, 22, 0.2), 0 2px 4px -2px rgba(249, 115, 22, 0.1);
|
||||
}
|
||||
|
||||
.card-link:hover .card-primary {
|
||||
box-shadow: 0 8px 10px -3px rgba(249, 115, 22, 0.25), 0 4px 6px -2px rgba(249, 115, 22, 0.15);
|
||||
background-color: #ea580c; /* Slightly darker shade when hovered */
|
||||
}
|
||||
|
||||
.card-primary .card-button {
|
||||
background-color: white;
|
||||
color: var(--accent-color);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.card-link:hover .card {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 12px -3px rgba(0, 0, 0, 0.1);
|
||||
border-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.5);
|
||||
}
|
||||
|
||||
/* Secondary variant - white background, accent elements */
|
||||
.card-secondary {
|
||||
background-color: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.card-link:hover .card-secondary {
|
||||
box-shadow: 0 6px 12px -2px rgba(0, 0, 0, 0.08), 0 3px 5px -2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.card-secondary .card-button {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
/* Primary variant - accent background, white text */
|
||||
.card-primary {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(249, 115, 22, 0.2),
|
||||
0 2px 4px -2px rgba(249, 115, 22, 0.1);
|
||||
}
|
||||
|
||||
/* Tertiary variant - minimal design */
|
||||
.card-tertiary {
|
||||
background-color: var(--card-bg);
|
||||
box-shadow: none;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.card-link:hover .card-tertiary {
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
.card-tertiary .card-button {
|
||||
color: var(--accent-color);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.card-link:hover .card-primary {
|
||||
box-shadow:
|
||||
0 8px 10px -3px rgba(249, 115, 22, 0.25),
|
||||
0 4px 6px -2px rgba(249, 115, 22, 0.15);
|
||||
background-color: #ea580c; /* Slightly darker shade when hovered */
|
||||
}
|
||||
|
||||
.card-image-container {
|
||||
width: 100%;
|
||||
padding-top: 66.67%; /* Aspect ratio 3:2 */
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-primary .card-button {
|
||||
background-color: white;
|
||||
color: var(--accent-color);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 1;
|
||||
}
|
||||
/* Secondary variant - white background, accent elements */
|
||||
.card-secondary {
|
||||
background-color: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06),
|
||||
0 1px 2px -1px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
.card-link:hover .card-secondary {
|
||||
box-shadow:
|
||||
0 6px 12px -2px rgba(0, 0, 0, 0.08),
|
||||
0 3px 5px -2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.card-secondary .card-button {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 1.5rem 0;
|
||||
flex-grow: 1;
|
||||
opacity: 0.85;
|
||||
}
|
||||
/* Tertiary variant - minimal design */
|
||||
.card-tertiary {
|
||||
background-color: var(--card-bg);
|
||||
box-shadow: none;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.card-link:hover .card-tertiary {
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
.card-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.card-link:hover .arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
.card-tertiary .card-button {
|
||||
color: var(--accent-color);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.card-content {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
.card-image-container {
|
||||
width: 100%;
|
||||
padding-top: 66.67%; /* Aspect ratio 3:2 */
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 1.5rem 0;
|
||||
flex-grow: 1;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.card-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.card-link:hover .arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.card-content {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,386 +1,360 @@
|
|||
---
|
||||
import { getLangFromUrl, useTranslations } from "../utils/i18n";
|
||||
import { getLangFromUrl, useTranslations } from '../utils/i18n';
|
||||
|
||||
interface Props {
|
||||
defaultType?: "mission" | "tutorial" | "vision";
|
||||
showTitle?: boolean;
|
||||
customTitle?: string;
|
||||
customDescription?: string;
|
||||
defaultType?: 'mission' | 'tutorial' | 'vision';
|
||||
showTitle?: boolean;
|
||||
customTitle?: string;
|
||||
customDescription?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
defaultType = "mission",
|
||||
showTitle = true,
|
||||
customTitle,
|
||||
customDescription,
|
||||
} = Astro.props;
|
||||
const { defaultType = 'mission', showTitle = true, customTitle, customDescription } = Astro.props;
|
||||
const lang = getLangFromUrl(Astro.url);
|
||||
const t = useTranslations(lang);
|
||||
---
|
||||
|
||||
<div class="content-submission">
|
||||
{showTitle && <h2>{customTitle || t("join.submissionTitle")}</h2>}
|
||||
<p class="submission-description">
|
||||
{customDescription || t("join.submissionDesc")}
|
||||
</p>
|
||||
{showTitle && <h2>{customTitle || t('join.submissionTitle')}</h2>}
|
||||
<p class="submission-description">
|
||||
{customDescription || t('join.submissionDesc')}
|
||||
</p>
|
||||
|
||||
<form class="submission-form" id="contentSubmissionForm">
|
||||
<div class="form-group">
|
||||
<label for="contentType">{t("join.submissionType")}</label>
|
||||
<select id="contentType" name="contentType" required>
|
||||
<option value="mission" selected={defaultType === "mission"}
|
||||
>{t("join.submissionMission")}</option
|
||||
>
|
||||
<option value="tutorial" selected={defaultType === "tutorial"}
|
||||
>{t("join.submissionTutorial")}</option
|
||||
>
|
||||
<option value="vision" selected={defaultType === "vision"}
|
||||
>{t("join.submissionVision")}</option
|
||||
>
|
||||
</select>
|
||||
</div>
|
||||
<form class="submission-form" id="contentSubmissionForm">
|
||||
<div class="form-group">
|
||||
<label for="contentType">{t('join.submissionType')}</label>
|
||||
<select id="contentType" name="contentType" required>
|
||||
<option value="mission" selected={defaultType === 'mission'}
|
||||
>{t('join.submissionMission')}</option
|
||||
>
|
||||
<option value="tutorial" selected={defaultType === 'tutorial'}
|
||||
>{t('join.submissionTutorial')}</option
|
||||
>
|
||||
<option value="vision" selected={defaultType === 'vision'}
|
||||
>{t('join.submissionVision')}</option
|
||||
>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="title">{t("join.submissionTitle")}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
placeholder={t("join.submissionTitlePlaceholder")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="title">{t('join.submissionTitle')}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
placeholder={t('join.submissionTitlePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">{t("join.submissionDesc")}</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder={t("join.submissionDescPlaceholder")}
|
||||
rows="4"
|
||||
required></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">{t('join.submissionDesc')}</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder={t('join.submissionDescPlaceholder')}
|
||||
rows="4"
|
||||
required></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">{t("join.submissionEmail")}</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder={t("join.submissionEmailPlaceholder")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">{t('join.submissionEmail')}</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder={t('join.submissionEmailPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-button"
|
||||
>{t("join.submissionSubmit")}</button
|
||||
>
|
||||
</form>
|
||||
<button type="submit" class="submit-button">{t('join.submissionSubmit')}</button>
|
||||
</form>
|
||||
|
||||
<div id="submissionSuccess" class="submission-success" style="display: none;">
|
||||
<p class="success-title">{t("join.submissionSuccessTitle")}</p>
|
||||
<p class="success-message">{t("join.submissionSuccessMessage")}</p>
|
||||
</div>
|
||||
<div id="submissionSuccess" class="submission-success" style="display: none;">
|
||||
<p class="success-title">{t('join.submissionSuccessTitle')}</p>
|
||||
<p class="success-message">{t('join.submissionSuccessMessage')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.content-submission {
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 1rem;
|
||||
padding: 2.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.05);
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
.content-submission {
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 1rem;
|
||||
padding: 2.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.05);
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
/* Heller Modus */
|
||||
:root:not(.dark) .content-submission {
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
/* Heller Modus */
|
||||
:root:not(.dark) .content-submission {
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
.content-submission:hover {
|
||||
box-shadow: 0 15px 30px -5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.content-submission:hover {
|
||||
box-shadow: 0 15px 30px -5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.75rem;
|
||||
background: linear-gradient(90deg, var(--accent-color), #fb923c);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.75rem;
|
||||
background: linear-gradient(90deg, var(--accent-color), #fb923c);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
h2::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -0.25rem;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--accent-color), #fb923c);
|
||||
border-radius: 1.5px;
|
||||
}
|
||||
h2::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -0.25rem;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--accent-color), #fb923c);
|
||||
border-radius: 1.5px;
|
||||
}
|
||||
|
||||
.submission-description {
|
||||
margin-bottom: 2rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
max-width: 600px;
|
||||
}
|
||||
.submission-description {
|
||||
margin-bottom: 2rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.submission-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.submission-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
label {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1.5px solid var(--border-color);
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1.5px solid var(--border-color);
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
/* Heller Modus für Eingabefelder */
|
||||
:root:not(.dark) input,
|
||||
:root:not(.dark) select,
|
||||
:root:not(.dark) textarea {
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
/* Heller Modus für Eingabefelder */
|
||||
:root:not(.dark) input,
|
||||
:root:not(.dark) select,
|
||||
:root:not(.dark) textarea {
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
input:hover,
|
||||
select:hover,
|
||||
textarea:hover {
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
input:hover,
|
||||
select:hover,
|
||||
textarea:hover {
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 3px rgba(var(--accent-color-rgb), 0.15);
|
||||
}
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 3px rgba(var(--accent-color-rgb), 0.15);
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
background: linear-gradient(90deg, var(--accent-color), #fb923c);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.9rem 2rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin-top: 1rem;
|
||||
align-self: flex-start;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.submit-button {
|
||||
background: linear-gradient(90deg, var(--accent-color), #fb923c);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.9rem 2rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin-top: 1rem;
|
||||
align-self: flex-start;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.submit-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 10px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.submit-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 10px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.submit-button:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.submit-button:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.submission-success {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
border-radius: 0.75rem;
|
||||
padding: 2rem;
|
||||
margin-top: 2rem;
|
||||
text-align: center;
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
box-shadow: 0 4px 10px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
.submission-success {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
border-radius: 0.75rem;
|
||||
padding: 2rem;
|
||||
margin-top: 2rem;
|
||||
text-align: center;
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
box-shadow: 0 4px 10px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.submission-success .success-title {
|
||||
color: #10b981;
|
||||
margin: 0 0 1rem 0;
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.submission-success .success-title {
|
||||
color: #10b981;
|
||||
margin: 0 0 1rem 0;
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.submission-success .success-message {
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.submission-success .success-message {
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-submission {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.content-submission {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.submission-description {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.submission-description {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
.submit-button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
declare global {
|
||||
interface Window {
|
||||
plausible: (
|
||||
eventName: string,
|
||||
options?: { props?: Record<string, string> }
|
||||
) => void;
|
||||
}
|
||||
}
|
||||
declare global {
|
||||
interface Window {
|
||||
plausible: (eventName: string, options?: { props?: Record<string, string> }) => void;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const form = document.getElementById(
|
||||
"contentSubmissionForm"
|
||||
) as HTMLFormElement;
|
||||
const successMessage = document.getElementById("submissionSuccess");
|
||||
const contentType = document.getElementById(
|
||||
"contentType"
|
||||
) as HTMLSelectElement;
|
||||
const title = document.getElementById("title") as HTMLInputElement;
|
||||
const description = document.getElementById(
|
||||
"description"
|
||||
) as HTMLTextAreaElement;
|
||||
const email = document.getElementById("email") as HTMLInputElement;
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const form = document.getElementById('contentSubmissionForm') as HTMLFormElement;
|
||||
const successMessage = document.getElementById('submissionSuccess');
|
||||
const contentType = document.getElementById('contentType') as HTMLSelectElement;
|
||||
const title = document.getElementById('title') as HTMLInputElement;
|
||||
const description = document.getElementById('description') as HTMLTextAreaElement;
|
||||
const email = document.getElementById('email') as HTMLInputElement;
|
||||
|
||||
if (
|
||||
!form ||
|
||||
!successMessage ||
|
||||
!contentType ||
|
||||
!title ||
|
||||
!description ||
|
||||
!email
|
||||
)
|
||||
return;
|
||||
if (!form || !successMessage || !contentType || !title || !description || !email) return;
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Sammle Formulardaten
|
||||
const formData = {
|
||||
contentType: contentType.value,
|
||||
title: title.value,
|
||||
description: description.value,
|
||||
email: email.value,
|
||||
};
|
||||
// Sammle Formulardaten
|
||||
const formData = {
|
||||
contentType: contentType.value,
|
||||
title: title.value,
|
||||
description: description.value,
|
||||
email: email.value,
|
||||
};
|
||||
|
||||
// Track content submission attempt
|
||||
if (typeof window.plausible === "function") {
|
||||
window.plausible("content-submit", {
|
||||
props: {
|
||||
type: contentType.value,
|
||||
title: title.value,
|
||||
email: email.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
// Track content submission attempt
|
||||
if (typeof window.plausible === 'function') {
|
||||
window.plausible('content-submit', {
|
||||
props: {
|
||||
type: contentType.value,
|
||||
title: title.value,
|
||||
email: email.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Sende die Daten an die Netlify-Funktion
|
||||
const response = await fetch("/.netlify/functions/content-submission", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
try {
|
||||
// Sende die Daten an die Netlify-Funktion
|
||||
const response = await fetch('/.netlify/functions/content-submission', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || "Submission failed");
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Submission failed');
|
||||
}
|
||||
|
||||
// Bei erfolgreicher Übermittlung
|
||||
form.reset();
|
||||
successMessage.style.display = "block";
|
||||
// Bei erfolgreicher Übermittlung
|
||||
form.reset();
|
||||
successMessage.style.display = 'block';
|
||||
|
||||
// Track successful submission
|
||||
if (typeof window.plausible === "function") {
|
||||
window.plausible("content-success", {
|
||||
props: {
|
||||
type: contentType.value,
|
||||
title: title.value,
|
||||
email: email.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
// Track successful submission
|
||||
if (typeof window.plausible === 'function') {
|
||||
window.plausible('content-success', {
|
||||
props: {
|
||||
type: contentType.value,
|
||||
title: title.value,
|
||||
email: email.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Erfolgs-Nachricht nach 8 Sekunden ausblenden
|
||||
setTimeout(() => {
|
||||
successMessage.style.display = "none";
|
||||
}, 8000);
|
||||
} catch (error: unknown) {
|
||||
// Track error
|
||||
if (typeof window.plausible === "function") {
|
||||
window.plausible("content-error", {
|
||||
props: {
|
||||
type: contentType.value,
|
||||
title: title.value,
|
||||
email: email.value,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
// Erfolgs-Nachricht nach 8 Sekunden ausblenden
|
||||
setTimeout(() => {
|
||||
successMessage.style.display = 'none';
|
||||
}, 8000);
|
||||
} catch (error: unknown) {
|
||||
// Track error
|
||||
if (typeof window.plausible === 'function') {
|
||||
window.plausible('content-error', {
|
||||
props: {
|
||||
type: contentType.value,
|
||||
title: title.value,
|
||||
email: email.value,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Fehlermeldung anzeigen
|
||||
alert(
|
||||
"Es gab einen Fehler bei der Übermittlung. Bitte versuche es später erneut."
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
// Fehlermeldung anzeigen
|
||||
alert('Es gab einen Fehler bei der Übermittlung. Bitte versuche es später erneut.');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,108 +1,122 @@
|
|||
---
|
||||
// Component for tracking content interactions
|
||||
interface Props {
|
||||
contentType: 'tutorial' | 'project' | 'tool' | 'model' | 'mission' | 'vision';
|
||||
contentId?: string;
|
||||
contentTitle?: string;
|
||||
contentType: 'tutorial' | 'project' | 'tool' | 'model' | 'mission' | 'vision';
|
||||
contentId?: string;
|
||||
contentTitle?: string;
|
||||
}
|
||||
|
||||
const { contentType, contentId, contentTitle } = Astro.props;
|
||||
---
|
||||
|
||||
<script define:vars={{ contentType, contentId, contentTitle }}>
|
||||
import { trackEvent, EVENTS } from '../scripts/analytics';
|
||||
|
||||
// Track page view for content
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const eventMap = {
|
||||
'tutorial': EVENTS.TUTORIAL_VIEW,
|
||||
'project': EVENTS.PROJECT_VIEW,
|
||||
'tool': EVENTS.TOOL_VIEW,
|
||||
'model': EVENTS.MODEL_VIEW,
|
||||
'mission': EVENTS.MISSION_VIEW,
|
||||
'vision': 'vision-view'
|
||||
};
|
||||
|
||||
const eventName = eventMap[contentType];
|
||||
if (eventName) {
|
||||
trackEvent(eventName, {
|
||||
id: contentId || 'unknown',
|
||||
title: contentTitle || document.title,
|
||||
language: document.documentElement.lang
|
||||
});
|
||||
}
|
||||
|
||||
// Track video plays
|
||||
document.querySelectorAll('iframe[src*="youtube"], iframe[src*="vimeo"]').forEach(video => {
|
||||
// Create intersection observer to track when video comes into view
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const src = (entry.target as HTMLIFrameElement).src;
|
||||
trackEvent(EVENTS.VIDEO_PLAY, {
|
||||
source: src.includes('youtube') ? 'youtube' : 'vimeo',
|
||||
url: src,
|
||||
contentType,
|
||||
contentId
|
||||
});
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.5 });
|
||||
|
||||
observer.observe(video);
|
||||
});
|
||||
|
||||
// Track Figma embeds
|
||||
document.querySelectorAll('iframe[src*="figma.com"]').forEach(embed => {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
trackEvent(EVENTS.FIGMA_EMBED_VIEW, {
|
||||
url: (entry.target as HTMLIFrameElement).src,
|
||||
contentType,
|
||||
contentId
|
||||
});
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.5 });
|
||||
|
||||
observer.observe(embed);
|
||||
});
|
||||
|
||||
// Track code copy (if there are code blocks with copy buttons)
|
||||
document.querySelectorAll('.copy-code-button, [data-copy]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
trackEvent(EVENTS.CODE_COPY, {
|
||||
contentType,
|
||||
contentId,
|
||||
location: window.location.pathname
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Track card clicks on listing pages
|
||||
document.querySelectorAll('.tutorial-card, .project-card, .tool-card, .model-card, .mission-card').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const link = target.querySelector('a');
|
||||
const title = target.querySelector('h3, h4')?.textContent?.trim() || '';
|
||||
|
||||
if (link) {
|
||||
const cardType = target.className.includes('tutorial') ? 'tutorial' :
|
||||
target.className.includes('project') ? 'project' :
|
||||
target.className.includes('tool') ? 'tool' :
|
||||
target.className.includes('model') ? 'model' :
|
||||
target.className.includes('mission') ? 'mission' : 'unknown';
|
||||
|
||||
trackEvent(`${cardType}-card-click`, {
|
||||
title,
|
||||
destination: link.href,
|
||||
fromPage: window.location.pathname
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
import { trackEvent, EVENTS } from '../scripts/analytics';
|
||||
|
||||
// Track page view for content
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const eventMap = {
|
||||
tutorial: EVENTS.TUTORIAL_VIEW,
|
||||
project: EVENTS.PROJECT_VIEW,
|
||||
tool: EVENTS.TOOL_VIEW,
|
||||
model: EVENTS.MODEL_VIEW,
|
||||
mission: EVENTS.MISSION_VIEW,
|
||||
vision: 'vision-view',
|
||||
};
|
||||
|
||||
const eventName = eventMap[contentType];
|
||||
if (eventName) {
|
||||
trackEvent(eventName, {
|
||||
id: contentId || 'unknown',
|
||||
title: contentTitle || document.title,
|
||||
language: document.documentElement.lang,
|
||||
});
|
||||
}
|
||||
|
||||
// Track video plays
|
||||
document.querySelectorAll('iframe[src*="youtube"], iframe[src*="vimeo"]').forEach((video) => {
|
||||
// Create intersection observer to track when video comes into view
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const src = (entry.target as HTMLIFrameElement).src;
|
||||
trackEvent(EVENTS.VIDEO_PLAY, {
|
||||
source: src.includes('youtube') ? 'youtube' : 'vimeo',
|
||||
url: src,
|
||||
contentType,
|
||||
contentId,
|
||||
});
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.5 }
|
||||
);
|
||||
|
||||
observer.observe(video);
|
||||
});
|
||||
|
||||
// Track Figma embeds
|
||||
document.querySelectorAll('iframe[src*="figma.com"]').forEach((embed) => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
trackEvent(EVENTS.FIGMA_EMBED_VIEW, {
|
||||
url: (entry.target as HTMLIFrameElement).src,
|
||||
contentType,
|
||||
contentId,
|
||||
});
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.5 }
|
||||
);
|
||||
|
||||
observer.observe(embed);
|
||||
});
|
||||
|
||||
// Track code copy (if there are code blocks with copy buttons)
|
||||
document.querySelectorAll('.copy-code-button, [data-copy]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
trackEvent(EVENTS.CODE_COPY, {
|
||||
contentType,
|
||||
contentId,
|
||||
location: window.location.pathname,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Track card clicks on listing pages
|
||||
document
|
||||
.querySelectorAll('.tutorial-card, .project-card, .tool-card, .model-card, .mission-card')
|
||||
.forEach((card) => {
|
||||
card.addEventListener('click', (e) => {
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const link = target.querySelector('a');
|
||||
const title = target.querySelector('h3, h4')?.textContent?.trim() || '';
|
||||
|
||||
if (link) {
|
||||
const cardType = target.className.includes('tutorial')
|
||||
? 'tutorial'
|
||||
: target.className.includes('project')
|
||||
? 'project'
|
||||
: target.className.includes('tool')
|
||||
? 'tool'
|
||||
: target.className.includes('model')
|
||||
? 'model'
|
||||
: target.className.includes('mission')
|
||||
? 'mission'
|
||||
: 'unknown';
|
||||
|
||||
trackEvent(`${cardType}-card-click`, {
|
||||
title,
|
||||
destination: link.href,
|
||||
fromPage: window.location.pathname,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,61 +1,75 @@
|
|||
---
|
||||
interface Props {
|
||||
figmaUrl: string;
|
||||
title?: string;
|
||||
height?: string;
|
||||
figmaUrl: string;
|
||||
title?: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
const { figmaUrl, title = "Figma Design", height = "450px" } = Astro.props;
|
||||
const { figmaUrl, title = 'Figma Design', height = '450px' } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="figma-container">
|
||||
<h3 class="figma-title">{title}</h3>
|
||||
<a href={figmaUrl} target="_blank" rel="noopener noreferrer" class="figma-button">
|
||||
<svg width="20" height="20" viewBox="0 0 38 57" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 28.5C19 25.9804 20.0009 23.5641 21.7825 21.7825C23.5641 20.0009 25.9804 19 28.5 19C31.0196 19 33.4359 20.0009 35.2175 21.7825C36.9991 23.5641 38 25.9804 38 28.5C38 31.0196 36.9991 33.4359 35.2175 35.2175C33.4359 36.9991 31.0196 38 28.5 38C25.9804 38 23.5641 36.9991 21.7825 35.2175C20.0009 33.4359 19 31.0196 19 28.5Z" fill="#1ABCFE"/>
|
||||
<path d="M0 47.5C0 44.9804 1.00089 42.5641 2.78249 40.7825C4.56408 39.0009 6.98044 38 9.5 38H19V47.5C19 50.0196 17.9991 52.4359 16.2175 54.2175C14.4359 55.9991 12.0196 57 9.5 57C6.98044 57 4.56408 55.9991 2.78249 54.2175C1.00089 52.4359 0 50.0196 0 47.5Z" fill="#0ACF83"/>
|
||||
<path d="M19 0V19H28.5C31.0196 19 33.4359 17.9991 35.2175 16.2175C36.9991 14.4359 38 12.0196 38 9.5C38 6.98044 36.9991 4.56408 35.2175 2.78249C33.4359 1.00089 31.0196 0 28.5 0H19Z" fill="#FF7262"/>
|
||||
<path d="M0 9.5C0 12.0196 1.00089 14.4359 2.78249 16.2175C4.56408 17.9991 6.98044 19 9.5 19H19V0H9.5C6.98044 0 4.56408 1.00089 2.78249 2.78249C1.00089 4.56408 0 6.98044 0 9.5Z" fill="#F24E1E"/>
|
||||
<path d="M0 28.5C0 31.0196 1.00089 33.4359 2.78249 35.2175C4.56408 36.9991 6.98044 38 9.5 38H19V19H9.5C6.98044 19 4.56408 20.0009 2.78249 21.7825C1.00089 23.5641 0 25.9804 0 28.5Z" fill="#A259FF"/>
|
||||
</svg>
|
||||
Open in Figma
|
||||
</a>
|
||||
<h3 class="figma-title">{title}</h3>
|
||||
<a href={figmaUrl} target="_blank" rel="noopener noreferrer" class="figma-button">
|
||||
<svg width="20" height="20" viewBox="0 0 38 57" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M19 28.5C19 25.9804 20.0009 23.5641 21.7825 21.7825C23.5641 20.0009 25.9804 19 28.5 19C31.0196 19 33.4359 20.0009 35.2175 21.7825C36.9991 23.5641 38 25.9804 38 28.5C38 31.0196 36.9991 33.4359 35.2175 35.2175C33.4359 36.9991 31.0196 38 28.5 38C25.9804 38 23.5641 36.9991 21.7825 35.2175C20.0009 33.4359 19 31.0196 19 28.5Z"
|
||||
fill="#1ABCFE"></path>
|
||||
<path
|
||||
d="M0 47.5C0 44.9804 1.00089 42.5641 2.78249 40.7825C4.56408 39.0009 6.98044 38 9.5 38H19V47.5C19 50.0196 17.9991 52.4359 16.2175 54.2175C14.4359 55.9991 12.0196 57 9.5 57C6.98044 57 4.56408 55.9991 2.78249 54.2175C1.00089 52.4359 0 50.0196 0 47.5Z"
|
||||
fill="#0ACF83"></path>
|
||||
<path
|
||||
d="M19 0V19H28.5C31.0196 19 33.4359 17.9991 35.2175 16.2175C36.9991 14.4359 38 12.0196 38 9.5C38 6.98044 36.9991 4.56408 35.2175 2.78249C33.4359 1.00089 31.0196 0 28.5 0H19Z"
|
||||
fill="#FF7262"></path>
|
||||
<path
|
||||
d="M0 9.5C0 12.0196 1.00089 14.4359 2.78249 16.2175C4.56408 17.9991 6.98044 19 9.5 19H19V0H9.5C6.98044 0 4.56408 1.00089 2.78249 2.78249C1.00089 4.56408 0 6.98044 0 9.5Z"
|
||||
fill="#F24E1E"></path>
|
||||
<path
|
||||
d="M0 28.5C0 31.0196 1.00089 33.4359 2.78249 35.2175C4.56408 36.9991 6.98044 38 9.5 38H19V19H9.5C6.98044 19 4.56408 20.0009 2.78249 21.7825C1.00089 23.5641 0 25.9804 0 28.5Z"
|
||||
fill="#A259FF"></path>
|
||||
</svg>
|
||||
Open in Figma
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<style define:vars={{ height }}>
|
||||
.figma-container {
|
||||
margin: 2.5rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.figma-title {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.figma-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: #0acf83;
|
||||
color: white;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: transform 0.2s, background-color 0.2s;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.figma-button:hover {
|
||||
background-color: #09b975;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.figma-button svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
</style>
|
||||
.figma-container {
|
||||
margin: 2.5rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.figma-title {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.figma-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: #0acf83;
|
||||
color: white;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
transform 0.2s,
|
||||
background-color 0.2s;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.figma-button:hover {
|
||||
background-color: #09b975;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.figma-button svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,149 +1,176 @@
|
|||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
imageAlt?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
imageAlt?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
imageAlt = "Hero image"
|
||||
} = Astro.props;
|
||||
const { title, description, image, imageAlt = 'Hero image' } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="hero-section">
|
||||
<div class="hero-text">
|
||||
<svg width="60" height="60" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" class="hero-logo-svg" style="display:block;margin:0 auto 2rem auto;"><rect x="0.666667" y="7.33334" width="10.6667" height="2.66667" stroke="currentColor" stroke-width="0.8" fill="none"/><rect x="2" y="4.66666" width="8" height="2.66667" stroke="currentColor" stroke-width="0.8" fill="none"/><rect x="3.33333" y="2" width="5.33333" height="2.66667" stroke="currentColor" stroke-width="0.8" fill="none"/></svg>
|
||||
<h1>{title}</h1>
|
||||
{description && <p class="hero-description">{description}</p>}
|
||||
<slot name="content" />
|
||||
</div>
|
||||
|
||||
{image && (
|
||||
<div class="hero-image">
|
||||
<img src={image} alt={imageAlt} />
|
||||
</div>
|
||||
)}
|
||||
<div class="hero-text">
|
||||
<svg
|
||||
width="60"
|
||||
height="60"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="hero-logo-svg"
|
||||
style="display:block;margin:0 auto 2rem auto;"
|
||||
><rect
|
||||
x="0.666667"
|
||||
y="7.33334"
|
||||
width="10.6667"
|
||||
height="2.66667"
|
||||
stroke="currentColor"
|
||||
stroke-width="0.8"
|
||||
fill="none"></rect><rect
|
||||
x="2"
|
||||
y="4.66666"
|
||||
width="8"
|
||||
height="2.66667"
|
||||
stroke="currentColor"
|
||||
stroke-width="0.8"
|
||||
fill="none"></rect><rect
|
||||
x="3.33333"
|
||||
y="2"
|
||||
width="5.33333"
|
||||
height="2.66667"
|
||||
stroke="currentColor"
|
||||
stroke-width="0.8"
|
||||
fill="none"></rect></svg
|
||||
>
|
||||
<h1>{title}</h1>
|
||||
{description && <p class="hero-description">{description}</p>}
|
||||
<slot name="content" />
|
||||
</div>
|
||||
|
||||
{
|
||||
image && (
|
||||
<div class="hero-image">
|
||||
<img src={image} alt={imageAlt} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<style is:global>
|
||||
/* Hero Section Styles */
|
||||
.hero-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 120px 0 4rem;
|
||||
padding: 0.5rem 0 0;
|
||||
gap: 4rem;
|
||||
}
|
||||
/* Hero Section Styles */
|
||||
.hero-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 120px 0 4rem;
|
||||
padding: 0.5rem 0 0;
|
||||
gap: 4rem;
|
||||
}
|
||||
|
||||
.hero-section .hero-text {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
.hero-logo {
|
||||
margin-bottom: 2rem;
|
||||
margin-top: 0;
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
transition: filter 0.2s;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.hero-logo {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
.hero-section .hero-text {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
.hero-logo {
|
||||
margin-bottom: 2rem;
|
||||
margin-top: 0;
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
transition: filter 0.2s;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.hero-logo {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
.hero-section h1 {
|
||||
font-size: 2.5rem;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
background: linear-gradient(90deg, var(--accent-color), #fb923c);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
text-fill-color: transparent;
|
||||
}
|
||||
.hero-section h1 {
|
||||
font-size: 2.5rem;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
background: linear-gradient(90deg, var(--accent-color), #fb923c);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.hero-section .hero-description {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.hero-section .hero-description {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-section .hero-image {
|
||||
flex: 0 0 400px;
|
||||
height: 400px;
|
||||
border-radius: 1.5rem;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.hero-section .hero-image {
|
||||
flex: 0 0 400px;
|
||||
height: 400px;
|
||||
border-radius: 1.5rem;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.hero-section .hero-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
.hero-section .hero-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
|
||||
.hero-section .hero-image img:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.hero-section {
|
||||
gap: 2rem;
|
||||
}
|
||||
.hero-section .hero-image img:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.hero-section .hero-image {
|
||||
flex: 0 0 250px;
|
||||
height: auto;
|
||||
aspect-ratio: 1/1;
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.hero-section {
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.hero-section h1 {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
.hero-section .hero-image {
|
||||
flex: 0 0 250px;
|
||||
height: auto;
|
||||
aspect-ratio: 1/1;
|
||||
}
|
||||
|
||||
.hero-section .hero-description {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
}
|
||||
.hero-section h1 {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero-section {
|
||||
flex-direction: column-reverse;
|
||||
margin: 100px 0 3.5rem;
|
||||
gap: 2rem;
|
||||
}
|
||||
.hero-section .hero-description {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-section .hero-text {
|
||||
width: 100%;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.hero-section {
|
||||
flex-direction: column-reverse;
|
||||
margin: 100px 0 3.5rem;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.hero-section .hero-text h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.hero-section .hero-text {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hero-section .hero-description {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.hero-section .hero-text h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.hero-section .hero-image {
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
aspect-ratio: 3/2;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
.hero-section .hero-description {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.hero-section .hero-image {
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
aspect-ratio: 3/2;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -6,351 +6,379 @@ const lang = getLangFromUrl(Astro.url);
|
|||
const currentPath = Astro.url.pathname;
|
||||
|
||||
const getPathInLang = (targetLang: string) => {
|
||||
// Split URL path into segments
|
||||
const segments = currentPath.split('/').filter(Boolean);
|
||||
|
||||
// First segment is always the language code
|
||||
if (segments.length === 0) {
|
||||
// Homepage case
|
||||
return `/${targetLang}`;
|
||||
} else if (Object.keys(languages).includes(segments[0])) {
|
||||
// Normal case: first segment is a language code
|
||||
segments[0] = targetLang;
|
||||
} else {
|
||||
// Fallback for any non-language URLs
|
||||
segments.unshift(targetLang);
|
||||
}
|
||||
|
||||
return `/${segments.join('/')}`;
|
||||
// Split URL path into segments
|
||||
const segments = currentPath.split('/').filter(Boolean);
|
||||
|
||||
// First segment is always the language code
|
||||
if (segments.length === 0) {
|
||||
// Homepage case
|
||||
return `/${targetLang}`;
|
||||
} else if (Object.keys(languages).includes(segments[0])) {
|
||||
// Normal case: first segment is a language code
|
||||
segments[0] = targetLang;
|
||||
} else {
|
||||
// Fallback for any non-language URLs
|
||||
segments.unshift(targetLang);
|
||||
}
|
||||
|
||||
return `/${segments.join('/')}`;
|
||||
};
|
||||
|
||||
// Props for the component
|
||||
interface Props {
|
||||
dropdownStyle?: boolean;
|
||||
dropdownStyle?: boolean;
|
||||
}
|
||||
|
||||
const { dropdownStyle = false } = Astro.props;
|
||||
---
|
||||
|
||||
{
|
||||
dropdownStyle ? (
|
||||
<div class="language-dropdown">
|
||||
<button class="dropdown-button">
|
||||
<span class="current-lang">{languages[lang]}</span>
|
||||
<svg class="dropdown-arrow" xmlns="http://www.w3.org/2000/svg" width="12" height="6" viewBox="0 0 12 6" fill="none">
|
||||
<path d="M1 1L6 5L11 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
{Object.entries(languages).map(([code, name]) => (
|
||||
<a
|
||||
href={getPathInLang(code)}
|
||||
class={lang === code ? 'active' : ''}
|
||||
hreflang={code}
|
||||
lang={code}
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="language-selector">
|
||||
{Object.entries(languages).map(([code, name]) => (
|
||||
<a
|
||||
href={getPathInLang(code)}
|
||||
class={lang === code ? 'active' : ''}
|
||||
hreflang={code}
|
||||
lang={code}
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
dropdownStyle ? (
|
||||
<div class="language-dropdown">
|
||||
<button class="dropdown-button">
|
||||
<span class="current-lang">{languages[lang]}</span>
|
||||
<svg
|
||||
class="dropdown-arrow"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="6"
|
||||
viewBox="0 0 12 6"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M1 1L6 5L11 1"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
{Object.entries(languages).map(([code, name]) => (
|
||||
<a
|
||||
href={getPathInLang(code)}
|
||||
class={lang === code ? 'active' : ''}
|
||||
hreflang={code}
|
||||
lang={code}
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="language-selector">
|
||||
{Object.entries(languages).map(([code, name]) => (
|
||||
<a
|
||||
href={getPathInLang(code)}
|
||||
class={lang === code ? 'active' : ''}
|
||||
hreflang={code}
|
||||
lang={code}
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<style>
|
||||
/* Shared styles */
|
||||
.language-selector,
|
||||
.language-dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.language-selector a,
|
||||
.dropdown-menu a {
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.language-selector a:hover,
|
||||
.dropdown-menu a:hover {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.language-selector a.active,
|
||||
.dropdown-menu a.active {
|
||||
font-weight: bold;
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
/* Horizontal selector styles */
|
||||
.language-selector {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Dropdown styles */
|
||||
.language-dropdown {
|
||||
position: relative;
|
||||
z-index: 150;
|
||||
}
|
||||
|
||||
.dropdown-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
/* Fix text color in dark mode */
|
||||
:global(:root.dark) .dropdown-button {
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.dropdown-button:hover {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.25rem);
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 120px;
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, transform 0.2s, visibility 0s 0.2s;
|
||||
z-index: 200; /* Higher z-index to ensure it appears above other elements */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Initial state for dropdowns */
|
||||
.dropdown-menu {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.dropdown-menu.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
visibility: visible;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.dropdown-menu a {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Fix link color in dark mode */
|
||||
:global(:root.dark) .dropdown-menu a {
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
/* Only show dropdown on desktop hover, not mobile */
|
||||
@media (min-width: 769px) {
|
||||
.language-dropdown:hover .dropdown-menu,
|
||||
.dropdown-button:focus-within .dropdown-menu {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
visibility: visible;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.language-dropdown:hover .dropdown-arrow,
|
||||
.dropdown-button:focus-within .dropdown-arrow,
|
||||
.dropdown-button[aria-expanded="true"] .dropdown-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Show dropdown in mobile when active */
|
||||
.dropdown-menu.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
visibility: visible;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
/* Standard animation for all dropdowns */
|
||||
.mobile-menu-controls .dropdown-menu.show {
|
||||
transform: none !important;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Mobile styles */
|
||||
@media (max-width: 768px) {
|
||||
.language-selector {
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.language-selector a {
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.language-dropdown {
|
||||
width: 100%;
|
||||
position: relative; /* Keep relative positioning for proper dropdown placement */
|
||||
}
|
||||
|
||||
.dropdown-button {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
font-size: 1.1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Important: absolute positioning for mobile dropdown */
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
top: 100%; /* Show below the button */
|
||||
z-index: 2000; /* Extra high z-index */
|
||||
max-height: calc(100vh - 200px);
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* Style for mobile menu dropdown - directly attached to button */
|
||||
.mobile-menu-controls .dropdown-menu {
|
||||
position: absolute;
|
||||
width: 100%; /* Match button width */
|
||||
left: 0;
|
||||
top: 100%; /* Position below the button */
|
||||
bottom: auto;
|
||||
margin-top: 2px; /* Small gap */
|
||||
max-height: 180px;
|
||||
border-radius: 8px;
|
||||
z-index: 2000;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
.dropdown-menu a {
|
||||
padding: 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
/* Shared styles */
|
||||
.language-selector,
|
||||
.language-dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.language-selector a,
|
||||
.dropdown-menu a {
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
color 0.2s;
|
||||
}
|
||||
|
||||
.language-selector a:hover,
|
||||
.dropdown-menu a:hover {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.language-selector a.active,
|
||||
.dropdown-menu a.active {
|
||||
font-weight: bold;
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
/* Horizontal selector styles */
|
||||
.language-selector {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Dropdown styles */
|
||||
.language-dropdown {
|
||||
position: relative;
|
||||
z-index: 150;
|
||||
}
|
||||
|
||||
.dropdown-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
/* Fix text color in dark mode */
|
||||
:global(:root.dark) .dropdown-button {
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.dropdown-button:hover {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.25rem);
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 120px;
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
transform 0.2s,
|
||||
visibility 0s 0.2s;
|
||||
z-index: 200; /* Higher z-index to ensure it appears above other elements */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Initial state for dropdowns */
|
||||
.dropdown-menu {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.dropdown-menu.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
visibility: visible;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
transform 0.2s;
|
||||
}
|
||||
|
||||
.dropdown-menu a {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Fix link color in dark mode */
|
||||
:global(:root.dark) .dropdown-menu a {
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
/* Only show dropdown on desktop hover, not mobile */
|
||||
@media (min-width: 769px) {
|
||||
.language-dropdown:hover .dropdown-menu,
|
||||
.dropdown-button:focus-within .dropdown-menu {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
visibility: visible;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
transform 0.2s;
|
||||
}
|
||||
|
||||
.language-dropdown:hover .dropdown-arrow,
|
||||
.dropdown-button:focus-within .dropdown-arrow,
|
||||
.dropdown-button[aria-expanded='true'] .dropdown-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Show dropdown in mobile when active */
|
||||
.dropdown-menu.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
visibility: visible;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
transform 0.2s;
|
||||
}
|
||||
|
||||
/* Standard animation for all dropdowns */
|
||||
.mobile-menu-controls .dropdown-menu.show {
|
||||
transform: none !important;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Mobile styles */
|
||||
@media (max-width: 768px) {
|
||||
.language-selector {
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.language-selector a {
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.language-dropdown {
|
||||
width: 100%;
|
||||
position: relative; /* Keep relative positioning for proper dropdown placement */
|
||||
}
|
||||
|
||||
.dropdown-button {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
font-size: 1.1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Important: absolute positioning for mobile dropdown */
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
top: 100%; /* Show below the button */
|
||||
z-index: 2000; /* Extra high z-index */
|
||||
max-height: calc(100vh - 200px);
|
||||
overflow-y: auto;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* Style for mobile menu dropdown - directly attached to button */
|
||||
.mobile-menu-controls .dropdown-menu {
|
||||
position: absolute;
|
||||
width: 100%; /* Match button width */
|
||||
left: 0;
|
||||
top: 100%; /* Position below the button */
|
||||
bottom: auto;
|
||||
margin-top: 2px; /* Small gap */
|
||||
max-height: 180px;
|
||||
border-radius: 8px;
|
||||
z-index: 2000;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
.dropdown-menu a {
|
||||
padding: 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// For accessibility - allow keyboard navigation
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Handle all dropdown buttons in the document, not just the first one
|
||||
const dropdownButtons = document.querySelectorAll('.dropdown-button');
|
||||
|
||||
dropdownButtons.forEach(dropdownButton => {
|
||||
if (!dropdownButton) return;
|
||||
|
||||
// Open/close dropdown on click
|
||||
dropdownButton.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const isExpanded = dropdownButton.getAttribute('aria-expanded') === 'true';
|
||||
dropdownButton.setAttribute('aria-expanded', isExpanded ? 'false' : 'true');
|
||||
|
||||
const menu = dropdownButton.nextElementSibling;
|
||||
if (menu && menu.classList.contains('dropdown-menu')) {
|
||||
menu.classList.toggle('show');
|
||||
|
||||
if (!isExpanded) {
|
||||
// Focus the first menu item
|
||||
const firstItem = menu.querySelector('a');
|
||||
if (firstItem) firstItem.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
dropdownButton.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
dropdownButton.click();
|
||||
}
|
||||
|
||||
// Close when escape is pressed
|
||||
if (e.key === 'Escape') {
|
||||
const menu = dropdownButton.nextElementSibling;
|
||||
if (menu && menu.classList.contains('show')) {
|
||||
menu.classList.remove('show');
|
||||
dropdownButton.setAttribute('aria-expanded', 'false');
|
||||
dropdownButton.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize ARIA attributes
|
||||
dropdownButton.setAttribute('aria-haspopup', 'true');
|
||||
dropdownButton.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
dropdownButtons.forEach(dropdownButton => {
|
||||
if (!dropdownButton.contains(e.target)) {
|
||||
const menu = dropdownButton.nextElementSibling;
|
||||
if (menu && menu.classList.contains('show')) {
|
||||
menu.classList.remove('show');
|
||||
dropdownButton.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add click listener to all dropdown menu items
|
||||
const dropdownMenuItems = document.querySelectorAll('.dropdown-menu a');
|
||||
dropdownMenuItems.forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
const menu = item.closest('.dropdown-menu');
|
||||
if (menu) {
|
||||
menu.classList.remove('show');
|
||||
const button = menu.previousElementSibling;
|
||||
if (button) {
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
// For accessibility - allow keyboard navigation
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Handle all dropdown buttons in the document, not just the first one
|
||||
const dropdownButtons = document.querySelectorAll('.dropdown-button');
|
||||
|
||||
dropdownButtons.forEach((dropdownButton) => {
|
||||
if (!dropdownButton) return;
|
||||
|
||||
// Open/close dropdown on click
|
||||
dropdownButton.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const isExpanded = dropdownButton.getAttribute('aria-expanded') === 'true';
|
||||
dropdownButton.setAttribute('aria-expanded', isExpanded ? 'false' : 'true');
|
||||
|
||||
const menu = dropdownButton.nextElementSibling;
|
||||
if (menu && menu.classList.contains('dropdown-menu')) {
|
||||
menu.classList.toggle('show');
|
||||
|
||||
if (!isExpanded) {
|
||||
// Focus the first menu item
|
||||
const firstItem = menu.querySelector('a');
|
||||
if (firstItem) firstItem.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
dropdownButton.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
dropdownButton.click();
|
||||
}
|
||||
|
||||
// Close when escape is pressed
|
||||
if (e.key === 'Escape') {
|
||||
const menu = dropdownButton.nextElementSibling;
|
||||
if (menu && menu.classList.contains('show')) {
|
||||
menu.classList.remove('show');
|
||||
dropdownButton.setAttribute('aria-expanded', 'false');
|
||||
dropdownButton.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize ARIA attributes
|
||||
dropdownButton.setAttribute('aria-haspopup', 'true');
|
||||
dropdownButton.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
dropdownButtons.forEach((dropdownButton) => {
|
||||
if (!dropdownButton.contains(e.target)) {
|
||||
const menu = dropdownButton.nextElementSibling;
|
||||
if (menu && menu.classList.contains('show')) {
|
||||
menu.classList.remove('show');
|
||||
dropdownButton.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add click listener to all dropdown menu items
|
||||
const dropdownMenuItems = document.querySelectorAll('.dropdown-menu a');
|
||||
dropdownMenuItems.forEach((item) => {
|
||||
item.addEventListener('click', (e) => {
|
||||
const menu = item.closest('.dropdown-menu');
|
||||
if (menu) {
|
||||
menu.classList.remove('show');
|
||||
const button = menu.previousElementSibling;
|
||||
if (button) {
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,385 +1,381 @@
|
|||
---
|
||||
interface Props {
|
||||
height?: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
const { height = "85vh" } = Astro.props;
|
||||
const { height = '85vh' } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="matrix-hero" style={`height: ${height};`}>
|
||||
<div class="matrix-canvas-container">
|
||||
<canvas id="matrixCanvas"></canvas>
|
||||
<div class="fade-bottom"></div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="title-container">
|
||||
<p class="hero-subtitle">Custom build future</p>
|
||||
<button id="scroll-down" class="scroll-down-btn" aria-label="Scroll down">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="chevron-down"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="matrix-canvas-container">
|
||||
<canvas id="matrixCanvas"></canvas>
|
||||
<div class="fade-bottom"></div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="title-container">
|
||||
<p class="hero-subtitle">Custom build future</p>
|
||||
<button id="scroll-down" class="scroll-down-btn" aria-label="Scroll down">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="chevron-down"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.matrix-hero {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
margin-left: calc(-50vw + 50%);
|
||||
margin-top: -69px; /* Match the navigation height to extend behind it */
|
||||
padding-top: 69px; /* Add padding to ensure content starts below nav */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
background-color: #000; /* Ensure black background */
|
||||
}
|
||||
.matrix-hero {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
margin-left: calc(-50vw + 50%);
|
||||
margin-top: -69px; /* Match the navigation height to extend behind it */
|
||||
padding-top: 69px; /* Add padding to ensure content starts below nav */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
background-color: #000; /* Ensure black background */
|
||||
}
|
||||
|
||||
.matrix-canvas-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 2; /* Higher z-index to place it above content */
|
||||
pointer-events: auto; /* Allow interaction with matrix elements */
|
||||
}
|
||||
.matrix-canvas-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 2; /* Higher z-index to place it above content */
|
||||
pointer-events: auto; /* Allow interaction with matrix elements */
|
||||
}
|
||||
|
||||
.fade-bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 150px;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
var(--background-color) 0%,
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
z-index: 3; /* Above matrix canvas */
|
||||
}
|
||||
.fade-bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 150px;
|
||||
background: linear-gradient(to top, var(--background-color) 0%, rgba(0, 0, 0, 0) 100%);
|
||||
z-index: 3; /* Above matrix canvas */
|
||||
}
|
||||
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 2; /* Same z-index as the matrix canvas to interleave */
|
||||
text-align: center;
|
||||
color: white;
|
||||
padding: 0 1rem;
|
||||
transform: translateY(-50px); /* Move content up */
|
||||
}
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 2; /* Same z-index as the matrix canvas to interleave */
|
||||
text-align: center;
|
||||
color: white;
|
||||
padding: 0 1rem;
|
||||
transform: translateY(-50px); /* Move content up */
|
||||
}
|
||||
|
||||
.title-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.title-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 5rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
max-width: 80%;
|
||||
text-shadow:
|
||||
0 0 20px rgba(255, 255, 255, 0.25),
|
||||
0 0 35px rgba(255, 165, 0, 0.18),
|
||||
0 0 60px rgba(255, 255, 255, 0.12),
|
||||
0 0 100px rgba(255, 255, 255, 0.08);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.hero-subtitle {
|
||||
font-size: 5rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
max-width: 80%;
|
||||
text-shadow:
|
||||
0 0 20px rgba(255, 255, 255, 0.25),
|
||||
0 0 35px rgba(255, 165, 0, 0.18),
|
||||
0 0 60px rgba(255, 255, 255, 0.12),
|
||||
0 0 100px rgba(255, 255, 255, 0.08);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.scroll-down-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-top: 0.5rem;
|
||||
color: rgba(255, 255, 255, 1);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.scroll-down-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-top: 0.5rem;
|
||||
color: rgba(255, 255, 255, 1);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.scroll-down-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.scroll-down-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.chevron-down {
|
||||
filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.3));
|
||||
}
|
||||
.chevron-down {
|
||||
filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.3));
|
||||
}
|
||||
|
||||
.matrix-ball {
|
||||
display: none; /* Hide the ball element instead of removing it completely */
|
||||
}
|
||||
.matrix-ball {
|
||||
display: none; /* Hide the ball element instead of removing it completely */
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.matrix-hero {
|
||||
margin-top: -57px; /* Adjust for smaller mobile nav height */
|
||||
padding-top: 57px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.matrix-hero {
|
||||
margin-top: -57px; /* Adjust for smaller mobile nav height */
|
||||
padding-top: 57px;
|
||||
}
|
||||
|
||||
.content {
|
||||
transform: translateY(-30px); /* Less shift on mobile */
|
||||
}
|
||||
.content {
|
||||
transform: translateY(-30px); /* Less shift on mobile */
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 2.8rem;
|
||||
max-width: 90%;
|
||||
}
|
||||
.hero-subtitle {
|
||||
font-size: 2.8rem;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.scroll-down-btn svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
.scroll-down-btn svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.fade-bottom {
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
.fade-bottom {
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script defer>
|
||||
// Defer the matrix animation initialization
|
||||
function initMatrixAnimation() {
|
||||
// Scroll to first card when chevron is clicked
|
||||
const scrollBtn = document.getElementById("scroll-down");
|
||||
if (scrollBtn) {
|
||||
scrollBtn.addEventListener("click", () => {
|
||||
// Find the first card section after the hero
|
||||
const featureSection = document.querySelector(".feature-section");
|
||||
if (featureSection && window.scrollToElement) {
|
||||
// Use the global scroll helper
|
||||
window.scrollToElement(featureSection, 20); // 20px additional offset
|
||||
}
|
||||
});
|
||||
}
|
||||
// Defer the matrix animation initialization
|
||||
function initMatrixAnimation() {
|
||||
// Scroll to first card when chevron is clicked
|
||||
const scrollBtn = document.getElementById('scroll-down');
|
||||
if (scrollBtn) {
|
||||
scrollBtn.addEventListener('click', () => {
|
||||
// Find the first card section after the hero
|
||||
const featureSection = document.querySelector('.feature-section');
|
||||
if (featureSection && window.scrollToElement) {
|
||||
// Use the global scroll helper
|
||||
window.scrollToElement(featureSection, 20); // 20px additional offset
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Matrix animation (optimized)
|
||||
const canvas = document.getElementById("matrixCanvas");
|
||||
if (!canvas) return;
|
||||
// Matrix animation (optimized)
|
||||
const canvas = document.getElementById('matrixCanvas');
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d", { alpha: false }); // Optimize context by disabling alpha
|
||||
if (!ctx) return;
|
||||
const ctx = canvas.getContext('2d', { alpha: false }); // Optimize context by disabling alpha
|
||||
if (!ctx) return;
|
||||
|
||||
// Make canvas full size of container but at lower resolution for performance
|
||||
function resizeCanvas() {
|
||||
const container = canvas.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
// Use a smaller canvas resolution for better performance
|
||||
const scale = window.devicePixelRatio * 0.5; // Half the device pixel ratio
|
||||
canvas.width = container.clientWidth * scale;
|
||||
canvas.height = container.clientHeight * scale;
|
||||
|
||||
// Scale the context to make it look right
|
||||
ctx.scale(scale, scale);
|
||||
|
||||
// Reset CSS dimensions
|
||||
canvas.style.width = container.clientWidth + 'px';
|
||||
canvas.style.height = container.clientHeight + 'px';
|
||||
}
|
||||
// Make canvas full size of container but at lower resolution for performance
|
||||
function resizeCanvas() {
|
||||
const container = canvas.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
// Initial setup
|
||||
resizeCanvas();
|
||||
const debouncedResize = debounce(resizeCanvas, 250);
|
||||
window.addEventListener("resize", debouncedResize);
|
||||
// Use a smaller canvas resolution for better performance
|
||||
const scale = window.devicePixelRatio * 0.5; // Half the device pixel ratio
|
||||
canvas.width = container.clientWidth * scale;
|
||||
canvas.height = container.clientHeight * scale;
|
||||
|
||||
// Clear with solid black to start
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
// Scale the context to make it look right
|
||||
ctx.scale(scale, scale);
|
||||
|
||||
// Pre-calculate some values for optimization
|
||||
const codeSnippets = [
|
||||
"const x = 0;",
|
||||
"let y = true;",
|
||||
"if (x > 0) {",
|
||||
"function() {",
|
||||
"export const",
|
||||
"import { }",
|
||||
];
|
||||
|
||||
// Pre-calculate colors for better performance
|
||||
const colorPalette = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const hue = 20 + Math.floor(Math.random() * 20);
|
||||
const saturation = 80 + Math.floor(Math.random() * 20);
|
||||
const lightness = 50 + Math.floor(Math.random() * 20);
|
||||
colorPalette.push(`hsl(${hue}, ${saturation}%, ${lightness}%)`);
|
||||
}
|
||||
// Reset CSS dimensions
|
||||
canvas.style.width = container.clientWidth + 'px';
|
||||
canvas.style.height = container.clientHeight + 'px';
|
||||
}
|
||||
|
||||
// Matrix code effect - simplified for better performance
|
||||
class Column {
|
||||
constructor(x, fontSize, canvasHeight) {
|
||||
this.x = x;
|
||||
this.fontSize = fontSize;
|
||||
this.canvasHeight = canvasHeight;
|
||||
this.rises = [];
|
||||
this.chars = [];
|
||||
this.colorIndexes = [];
|
||||
// Even slower speed for better performance
|
||||
this.speed = 0.02 + Math.random() * 0.01;
|
||||
// Initial setup
|
||||
resizeCanvas();
|
||||
const debouncedResize = debounce(resizeCanvas, 250);
|
||||
window.addEventListener('resize', debouncedResize);
|
||||
|
||||
// Initialize with positions from bottom - use less characters
|
||||
const snippet = codeSnippets[Math.floor(Math.random() * codeSnippets.length)];
|
||||
for (let i = 0; i < snippet.length; i++) {
|
||||
// Position strings at different positions below the canvas
|
||||
const y = canvasHeight + Math.random() * 200;
|
||||
this.rises.push(y);
|
||||
this.chars.push(snippet[i]);
|
||||
this.colorIndexes.push(Math.floor(Math.random() * colorPalette.length));
|
||||
}
|
||||
}
|
||||
// Clear with solid black to start
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
draw(ctx) {
|
||||
for (let i = 0; i < this.rises.length; i++) {
|
||||
// Calculate positions for current character
|
||||
const x = this.x + i * this.fontSize * 0.6;
|
||||
const y = this.rises[i];
|
||||
// Pre-calculate some values for optimization
|
||||
const codeSnippets = [
|
||||
'const x = 0;',
|
||||
'let y = true;',
|
||||
'if (x > 0) {',
|
||||
'function() {',
|
||||
'export const',
|
||||
'import { }',
|
||||
];
|
||||
|
||||
// Draw only if in visible area (performance optimization)
|
||||
if (y > 0 && y < this.canvasHeight + 50) {
|
||||
// Draw the current character
|
||||
ctx.fillStyle = colorPalette[this.colorIndexes[i]];
|
||||
ctx.font = `${this.fontSize}px 'Courier New', monospace`;
|
||||
ctx.fillText(this.chars[i], x, y);
|
||||
}
|
||||
// Pre-calculate colors for better performance
|
||||
const colorPalette = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const hue = 20 + Math.floor(Math.random() * 20);
|
||||
const saturation = 80 + Math.floor(Math.random() * 20);
|
||||
const lightness = 50 + Math.floor(Math.random() * 20);
|
||||
colorPalette.push(`hsl(${hue}, ${saturation}%, ${lightness}%)`);
|
||||
}
|
||||
|
||||
// Move the rise up slower
|
||||
this.rises[i] -= this.fontSize * this.speed;
|
||||
// Matrix code effect - simplified for better performance
|
||||
class Column {
|
||||
constructor(x, fontSize, canvasHeight) {
|
||||
this.x = x;
|
||||
this.fontSize = fontSize;
|
||||
this.canvasHeight = canvasHeight;
|
||||
this.rises = [];
|
||||
this.chars = [];
|
||||
this.colorIndexes = [];
|
||||
// Even slower speed for better performance
|
||||
this.speed = 0.02 + Math.random() * 0.01;
|
||||
|
||||
// Reset when off the top of the screen with random delay
|
||||
if (this.rises[i] < -50 && Math.random() > 0.99) {
|
||||
this.rises[i] = this.canvasHeight + this.fontSize * (10 + Math.random() * 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Initialize with positions from bottom - use less characters
|
||||
const snippet = codeSnippets[Math.floor(Math.random() * codeSnippets.length)];
|
||||
for (let i = 0; i < snippet.length; i++) {
|
||||
// Position strings at different positions below the canvas
|
||||
const y = canvasHeight + Math.random() * 200;
|
||||
this.rises.push(y);
|
||||
this.chars.push(snippet[i]);
|
||||
this.colorIndexes.push(Math.floor(Math.random() * colorPalette.length));
|
||||
}
|
||||
}
|
||||
|
||||
// Create columns of code - reduced number for better performance
|
||||
let columns = [];
|
||||
let animationFrameId;
|
||||
let lastFrameTime = 0;
|
||||
const targetFPS = 30; // Lower target FPS for better performance
|
||||
const frameInterval = 1000 / targetFPS;
|
||||
draw(ctx) {
|
||||
for (let i = 0; i < this.rises.length; i++) {
|
||||
// Calculate positions for current character
|
||||
const x = this.x + i * this.fontSize * 0.6;
|
||||
const y = this.rises[i];
|
||||
|
||||
function initColumns() {
|
||||
columns = [];
|
||||
const fontSize = canvas.width > 768 ? 16 : 12;
|
||||
// Draw only if in visible area (performance optimization)
|
||||
if (y > 0 && y < this.canvasHeight + 50) {
|
||||
// Draw the current character
|
||||
ctx.fillStyle = colorPalette[this.colorIndexes[i]];
|
||||
ctx.font = `${this.fontSize}px 'Courier New', monospace`;
|
||||
ctx.fillText(this.chars[i], x, y);
|
||||
}
|
||||
|
||||
// Use much fewer columns for better performance
|
||||
const maxColumns = canvas.width > 768 ? 15 : 8;
|
||||
// Move the rise up slower
|
||||
this.rises[i] -= this.fontSize * this.speed;
|
||||
|
||||
// Create columns at optimal positions
|
||||
for (let i = 0; i < maxColumns; i++) {
|
||||
const x = (i / maxColumns) * canvas.width;
|
||||
columns.push(new Column(x, fontSize, canvas.height));
|
||||
}
|
||||
}
|
||||
// Reset when off the top of the screen with random delay
|
||||
if (this.rises[i] < -50 && Math.random() > 0.99) {
|
||||
this.rises[i] = this.canvasHeight + this.fontSize * (10 + Math.random() * 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Animation loop - optimized with frame limiting
|
||||
function animate(currentTime) {
|
||||
// Frame limiting for better performance
|
||||
const elapsed = currentTime - lastFrameTime;
|
||||
if (elapsed < frameInterval) {
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
return;
|
||||
}
|
||||
lastFrameTime = currentTime - (elapsed % frameInterval);
|
||||
// Create columns of code - reduced number for better performance
|
||||
let columns = [];
|
||||
let animationFrameId;
|
||||
let lastFrameTime = 0;
|
||||
const targetFPS = 30; // Lower target FPS for better performance
|
||||
const frameInterval = 1000 / targetFPS;
|
||||
|
||||
// Use alpha for trail effect
|
||||
ctx.fillStyle = "rgba(0, 0, 0, 0.08)"; // Slower fade for better effect
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
function initColumns() {
|
||||
columns = [];
|
||||
const fontSize = canvas.width > 768 ? 16 : 12;
|
||||
|
||||
// Draw all columns
|
||||
columns.forEach((column) => column.draw(ctx));
|
||||
// Use much fewer columns for better performance
|
||||
const maxColumns = canvas.width > 768 ? 15 : 8;
|
||||
|
||||
// Continue animation
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
}
|
||||
// Create columns at optimal positions
|
||||
for (let i = 0; i < maxColumns; i++) {
|
||||
const x = (i / maxColumns) * canvas.width;
|
||||
columns.push(new Column(x, fontSize, canvas.height));
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize columns after a short delay
|
||||
setTimeout(() => {
|
||||
initColumns();
|
||||
lastFrameTime = performance.now();
|
||||
animate(lastFrameTime);
|
||||
}, 500); // 500ms delay to ensure other critical resources load first
|
||||
// Animation loop - optimized with frame limiting
|
||||
function animate(currentTime) {
|
||||
// Frame limiting for better performance
|
||||
const elapsed = currentTime - lastFrameTime;
|
||||
if (elapsed < frameInterval) {
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
return;
|
||||
}
|
||||
lastFrameTime = currentTime - (elapsed % frameInterval);
|
||||
|
||||
// Debounce function for resize event
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function() {
|
||||
const context = this;
|
||||
const args = arguments;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
func.apply(context, args);
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
// Use alpha for trail effect
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.08)'; // Slower fade for better effect
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Handle visibility change to pause animation when tab is not visible
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
// Pause animation to save resources
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
} else if (!animationFrameId) {
|
||||
// Resume animation
|
||||
lastFrameTime = performance.now();
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
}
|
||||
});
|
||||
// Draw all columns
|
||||
columns.forEach((column) => column.draw(ctx));
|
||||
|
||||
// Reinitialize on resize, but debounced
|
||||
window.addEventListener("resize", () => {
|
||||
// Cancel existing animation to prevent memory leaks
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
// Continue animation
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
// We already have a debounced resize for canvas dimensions
|
||||
|
||||
// Clear canvas
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
// Initialize columns after a short delay
|
||||
setTimeout(() => {
|
||||
initColumns();
|
||||
lastFrameTime = performance.now();
|
||||
animate(lastFrameTime);
|
||||
}, 500); // 500ms delay to ensure other critical resources load first
|
||||
|
||||
// Reinitialize columns
|
||||
initColumns();
|
||||
lastFrameTime = performance.now();
|
||||
animate(lastFrameTime);
|
||||
});
|
||||
}
|
||||
// Debounce function for resize event
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function () {
|
||||
const context = this;
|
||||
const args = arguments;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
func.apply(context, args);
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
|
||||
// Use requestIdleCallback or setTimeout to defer initialization
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(() => {
|
||||
initMatrixAnimation();
|
||||
});
|
||||
} else {
|
||||
// Fallback for browsers without requestIdleCallback
|
||||
setTimeout(initMatrixAnimation, 1000); // Wait for 1 second after page load
|
||||
}
|
||||
// Handle visibility change to pause animation when tab is not visible
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
// Pause animation to save resources
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
} else if (!animationFrameId) {
|
||||
// Resume animation
|
||||
lastFrameTime = performance.now();
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
}
|
||||
});
|
||||
|
||||
// Reinitialize on resize, but debounced
|
||||
window.addEventListener('resize', () => {
|
||||
// Cancel existing animation to prevent memory leaks
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
|
||||
// We already have a debounced resize for canvas dimensions
|
||||
|
||||
// Clear canvas
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Reinitialize columns
|
||||
initColumns();
|
||||
lastFrameTime = performance.now();
|
||||
animate(lastFrameTime);
|
||||
});
|
||||
}
|
||||
|
||||
// Use requestIdleCallback or setTimeout to defer initialization
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(() => {
|
||||
initMatrixAnimation();
|
||||
});
|
||||
} else {
|
||||
// Fallback for browsers without requestIdleCallback
|
||||
setTimeout(initMatrixAnimation, 1000); // Wait for 1 second after page load
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { getLangFromUrl, useTranslations } from '../utils/i18n';
|
|||
import type { CollectionEntry } from 'astro:content';
|
||||
|
||||
interface Props {
|
||||
mission: CollectionEntry<'missions'>;
|
||||
mission: CollectionEntry<'missions'>;
|
||||
}
|
||||
|
||||
const { mission } = Astro.props;
|
||||
|
|
@ -14,9 +14,9 @@ const t = useTranslations(lang);
|
|||
|
||||
// Format date based on language
|
||||
const formattedDate = new Intl.DateTimeFormat(lang === 'de' ? 'de-DE' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(data.pubDate);
|
||||
|
||||
// Generate mission URL - always include language segment
|
||||
|
|
@ -31,253 +31,262 @@ const statusKey = `missions.status.${data.status}` as const;
|
|||
---
|
||||
|
||||
<a href={missionUrl} class="mission-card-link">
|
||||
<article class:list={["mission-card", { "featured": data.featured }]}>
|
||||
{data.image && (
|
||||
<div class="mission-image-container">
|
||||
<img src={data.image} alt={data.title} class="mission-image" />
|
||||
<div class="status-badge" data-status={data.status}>
|
||||
{t(statusKey)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="content">
|
||||
<div class="meta">
|
||||
<span class="difficulty" data-difficulty={data.difficulty}>{t(difficultyKey)}</span>
|
||||
<span class="date">{formattedDate}</span>
|
||||
</div>
|
||||
|
||||
<h3>{data.title}</h3>
|
||||
<p>{data.description}</p>
|
||||
|
||||
<div class="details">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">{t('missions.duration')}</span>
|
||||
<span class="detail-value">{data.duration}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">{t('missions.skills')}</span>
|
||||
<div class="skills-list">
|
||||
{data.skills.map(skill => (
|
||||
<span class="skill-tag">{skill}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
{data.participants && data.participants.length > 0 && (
|
||||
<div class="participants">
|
||||
<span class="participants-count">{data.participants.length}</span>
|
||||
</div>
|
||||
)}
|
||||
<span class="read-more">{t('missions.readMore')} <span class="arrow">→</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class:list={['mission-card', { featured: data.featured }]}>
|
||||
{
|
||||
data.image && (
|
||||
<div class="mission-image-container">
|
||||
<img src={data.image} alt={data.title} class="mission-image" />
|
||||
<div class="status-badge" data-status={data.status}>
|
||||
{t(statusKey)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="content">
|
||||
<div class="meta">
|
||||
<span class="difficulty" data-difficulty={data.difficulty}>{t(difficultyKey)}</span>
|
||||
<span class="date">{formattedDate}</span>
|
||||
</div>
|
||||
|
||||
<h3>{data.title}</h3>
|
||||
<p>{data.description}</p>
|
||||
|
||||
<div class="details">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">{t('missions.duration')}</span>
|
||||
<span class="detail-value">{data.duration}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">{t('missions.skills')}</span>
|
||||
<div class="skills-list">
|
||||
{data.skills.map((skill) => <span class="skill-tag">{skill}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
{
|
||||
data.participants && data.participants.length > 0 && (
|
||||
<div class="participants">
|
||||
<span class="participants-count">{data.participants.length}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<span class="read-more">{t('missions.readMore')} <span class="arrow">→</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.mission-card-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mission-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background-color: var(--card-bg);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.03);
|
||||
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mission-card-link:hover .mission-card {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.5);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.mission-card.featured {
|
||||
border: 2px solid rgba(var(--accent-color-rgb, 249, 115, 22), 0.3);
|
||||
}
|
||||
|
||||
.mission-image-container {
|
||||
width: 100%;
|
||||
padding-top: 66.67%; /* Aspect ratio 3:2 */
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mission-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 2rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
z-index: 2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge[data-status="active"] {
|
||||
background-color: #10b981; /* Green */
|
||||
}
|
||||
|
||||
.status-badge[data-status="completed"] {
|
||||
background-color: #6366f1; /* Purple */
|
||||
}
|
||||
|
||||
.status-badge[data-status="upcoming"] {
|
||||
background-color: #f59e0b; /* Amber */
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.difficulty {
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.difficulty[data-difficulty="beginner"] {
|
||||
background-color: #dcfce7; /* Light green */
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.difficulty[data-difficulty="intermediate"] {
|
||||
background-color: #e0f2fe; /* Light blue */
|
||||
color: #0284c7;
|
||||
}
|
||||
|
||||
.difficulty[data-difficulty="advanced"] {
|
||||
background-color: #fee2e2; /* Light red */
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.date {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.skills-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.skill-tag {
|
||||
font-size: 0.75rem;
|
||||
background-color: rgba(var(--border-color-rgb), 0.3);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.participants {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.participants-count {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.participants-count::before {
|
||||
content: '👥 ';
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.read-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.mission-card-link:hover .arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
</style>
|
||||
.mission-card-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mission-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background-color: var(--card-bg);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06),
|
||||
0 1px 2px -1px rgba(0, 0, 0, 0.03);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mission-card-link:hover .mission-card {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.5);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.08),
|
||||
0 2px 4px -2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.mission-card.featured {
|
||||
border: 2px solid rgba(var(--accent-color-rgb, 249, 115, 22), 0.3);
|
||||
}
|
||||
|
||||
.mission-image-container {
|
||||
width: 100%;
|
||||
padding-top: 66.67%; /* Aspect ratio 3:2 */
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mission-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 2rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
z-index: 2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge[data-status='active'] {
|
||||
background-color: #10b981; /* Green */
|
||||
}
|
||||
|
||||
.status-badge[data-status='completed'] {
|
||||
background-color: #6366f1; /* Purple */
|
||||
}
|
||||
|
||||
.status-badge[data-status='upcoming'] {
|
||||
background-color: #f59e0b; /* Amber */
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.difficulty {
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.difficulty[data-difficulty='beginner'] {
|
||||
background-color: #dcfce7; /* Light green */
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.difficulty[data-difficulty='intermediate'] {
|
||||
background-color: #e0f2fe; /* Light blue */
|
||||
color: #0284c7;
|
||||
}
|
||||
|
||||
.difficulty[data-difficulty='advanced'] {
|
||||
background-color: #fee2e2; /* Light red */
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.date {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.skills-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.skill-tag {
|
||||
font-size: 0.75rem;
|
||||
background-color: rgba(var(--border-color-rgb), 0.3);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.participants {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.participants-count {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.participants-count::before {
|
||||
content: '👥 ';
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.read-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.mission-card-link:hover .arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,150 +2,318 @@
|
|||
import TableImproved from './TableImproved.astro';
|
||||
|
||||
const models = [
|
||||
{ name: "o1", provider: "OpenAI", inputCost: 15, cachedInputCost: 7.5, outputCost: 60, inputLimit: 200000, outputLimit: 100000, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "October 2023", totalCost: 45 },
|
||||
{ name: "gpt 4.1", provider: "OpenAI", inputCost: 2, cachedInputCost: 0.5, outputCost: 8, inputLimit: 1000000, outputLimit: 32768, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "June 2024", totalCost: 6 },
|
||||
{ name: "GPT-4o", provider: "OpenAI", inputCost: 2.5, cachedInputCost: 1.25, outputCost: 10, inputLimit: 128000, outputLimit: 16384, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "October 2023", totalCost: 7.5 },
|
||||
{ name: "gpt 4.1-mini", provider: "OpenAI", inputCost: 0.4, cachedInputCost: 0.1, outputCost: 1.6, inputLimit: 1000000, outputLimit: 32768, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "June 2024", totalCost: 1.2 },
|
||||
{ name: "gpt 4.1-nano", provider: "OpenAI", inputCost: 0.1, cachedInputCost: 0.025, outputCost: 0.4, inputLimit: 1000000, outputLimit: 32768, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "June 2024", totalCost: 0.3 },
|
||||
{ name: "o3-mini", provider: "OpenAI", inputCost: 1.1, cachedInputCost: 0.55, outputCost: 4.4, inputLimit: 200000, outputLimit: 100000, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "October 2023", totalCost: 3.3 },
|
||||
{ name: "Claude Sonnet 3.7", provider: "Antrophic", inputCost: 3, cachedInputCost: "Up to 90%", outputCost: 15, inputLimit: 200000, outputLimit: 8192, batchAPI: "50% for 24 hours", hoster: "Google, Amazon, Antrophic", trainingCutoff: "April 2024", totalCost: 10.5 },
|
||||
{ name: "Claude Haiku 3.5", provider: "Antrophic", inputCost: 0.8, cachedInputCost: "Up to 90%", outputCost: 4, inputLimit: 200000, outputLimit: 8192, batchAPI: "50% for 24 hours", hoster: "Google, Amazon, Antrophic", trainingCutoff: "July 2024", totalCost: 2.8 },
|
||||
{ name: "Deepseek R1", provider: "Deepseek", inputCost: 0.55, cachedInputCost: 0.14, outputCost: 2.19, inputLimit: 64000, outputLimit: 8192, batchAPI: "", hoster: "Deepseek", trainingCutoff: "July 2024", totalCost: 1.645 },
|
||||
{ name: "Deepseek V3", provider: "Deepseek", inputCost: 0.27, cachedInputCost: 0.07, outputCost: 1.1, inputLimit: 64000, outputLimit: 8192, batchAPI: "", hoster: "Deepseek", trainingCutoff: "December 2024", totalCost: 0.82 },
|
||||
{ name: "GPT-4o mini", provider: "OpenAI", inputCost: 0.15, cachedInputCost: 0.075, outputCost: 0.6, inputLimit: 128000, outputLimit: 16384, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "October 2023", totalCost: 0.45 },
|
||||
{ name: "Gemini 2.5 Pro", provider: "", inputCost: 1.25, cachedInputCost: "", outputCost: 10, inputLimit: 1048576, outputLimit: 65536, batchAPI: "", hoster: "Google", trainingCutoff: "January 2025", totalCost: 6.25 },
|
||||
{ name: "Gemini 2.0 Flash", provider: "Google", inputCost: 0.1, cachedInputCost: 0.025, outputCost: 0.4, inputLimit: 1048576, outputLimit: 8192, batchAPI: "", hoster: "Google", trainingCutoff: "June 2024", totalCost: 0.3 },
|
||||
{ name: "Gemini 2.0 Flash-Lite", provider: "Google", inputCost: 0.075, cachedInputCost: 0.01875, outputCost: 0.3, inputLimit: 1048576, outputLimit: 8192, batchAPI: "", hoster: "Google", trainingCutoff: "June 2024", totalCost: 0.225 },
|
||||
{
|
||||
name: 'o1',
|
||||
provider: 'OpenAI',
|
||||
inputCost: 15,
|
||||
cachedInputCost: 7.5,
|
||||
outputCost: 60,
|
||||
inputLimit: 200000,
|
||||
outputLimit: 100000,
|
||||
batchAPI: '50% for 24 hours',
|
||||
hoster: 'OpenAI, Azure',
|
||||
trainingCutoff: 'October 2023',
|
||||
totalCost: 45,
|
||||
},
|
||||
{
|
||||
name: 'gpt 4.1',
|
||||
provider: 'OpenAI',
|
||||
inputCost: 2,
|
||||
cachedInputCost: 0.5,
|
||||
outputCost: 8,
|
||||
inputLimit: 1000000,
|
||||
outputLimit: 32768,
|
||||
batchAPI: '50% for 24 hours',
|
||||
hoster: 'OpenAI, Azure',
|
||||
trainingCutoff: 'June 2024',
|
||||
totalCost: 6,
|
||||
},
|
||||
{
|
||||
name: 'GPT-4o',
|
||||
provider: 'OpenAI',
|
||||
inputCost: 2.5,
|
||||
cachedInputCost: 1.25,
|
||||
outputCost: 10,
|
||||
inputLimit: 128000,
|
||||
outputLimit: 16384,
|
||||
batchAPI: '50% for 24 hours',
|
||||
hoster: 'OpenAI, Azure',
|
||||
trainingCutoff: 'October 2023',
|
||||
totalCost: 7.5,
|
||||
},
|
||||
{
|
||||
name: 'gpt 4.1-mini',
|
||||
provider: 'OpenAI',
|
||||
inputCost: 0.4,
|
||||
cachedInputCost: 0.1,
|
||||
outputCost: 1.6,
|
||||
inputLimit: 1000000,
|
||||
outputLimit: 32768,
|
||||
batchAPI: '50% for 24 hours',
|
||||
hoster: 'OpenAI, Azure',
|
||||
trainingCutoff: 'June 2024',
|
||||
totalCost: 1.2,
|
||||
},
|
||||
{
|
||||
name: 'gpt 4.1-nano',
|
||||
provider: 'OpenAI',
|
||||
inputCost: 0.1,
|
||||
cachedInputCost: 0.025,
|
||||
outputCost: 0.4,
|
||||
inputLimit: 1000000,
|
||||
outputLimit: 32768,
|
||||
batchAPI: '50% for 24 hours',
|
||||
hoster: 'OpenAI, Azure',
|
||||
trainingCutoff: 'June 2024',
|
||||
totalCost: 0.3,
|
||||
},
|
||||
{
|
||||
name: 'o3-mini',
|
||||
provider: 'OpenAI',
|
||||
inputCost: 1.1,
|
||||
cachedInputCost: 0.55,
|
||||
outputCost: 4.4,
|
||||
inputLimit: 200000,
|
||||
outputLimit: 100000,
|
||||
batchAPI: '50% for 24 hours',
|
||||
hoster: 'OpenAI, Azure',
|
||||
trainingCutoff: 'October 2023',
|
||||
totalCost: 3.3,
|
||||
},
|
||||
{
|
||||
name: 'Claude Sonnet 3.7',
|
||||
provider: 'Antrophic',
|
||||
inputCost: 3,
|
||||
cachedInputCost: 'Up to 90%',
|
||||
outputCost: 15,
|
||||
inputLimit: 200000,
|
||||
outputLimit: 8192,
|
||||
batchAPI: '50% for 24 hours',
|
||||
hoster: 'Google, Amazon, Antrophic',
|
||||
trainingCutoff: 'April 2024',
|
||||
totalCost: 10.5,
|
||||
},
|
||||
{
|
||||
name: 'Claude Haiku 3.5',
|
||||
provider: 'Antrophic',
|
||||
inputCost: 0.8,
|
||||
cachedInputCost: 'Up to 90%',
|
||||
outputCost: 4,
|
||||
inputLimit: 200000,
|
||||
outputLimit: 8192,
|
||||
batchAPI: '50% for 24 hours',
|
||||
hoster: 'Google, Amazon, Antrophic',
|
||||
trainingCutoff: 'July 2024',
|
||||
totalCost: 2.8,
|
||||
},
|
||||
{
|
||||
name: 'Deepseek R1',
|
||||
provider: 'Deepseek',
|
||||
inputCost: 0.55,
|
||||
cachedInputCost: 0.14,
|
||||
outputCost: 2.19,
|
||||
inputLimit: 64000,
|
||||
outputLimit: 8192,
|
||||
batchAPI: '',
|
||||
hoster: 'Deepseek',
|
||||
trainingCutoff: 'July 2024',
|
||||
totalCost: 1.645,
|
||||
},
|
||||
{
|
||||
name: 'Deepseek V3',
|
||||
provider: 'Deepseek',
|
||||
inputCost: 0.27,
|
||||
cachedInputCost: 0.07,
|
||||
outputCost: 1.1,
|
||||
inputLimit: 64000,
|
||||
outputLimit: 8192,
|
||||
batchAPI: '',
|
||||
hoster: 'Deepseek',
|
||||
trainingCutoff: 'December 2024',
|
||||
totalCost: 0.82,
|
||||
},
|
||||
{
|
||||
name: 'GPT-4o mini',
|
||||
provider: 'OpenAI',
|
||||
inputCost: 0.15,
|
||||
cachedInputCost: 0.075,
|
||||
outputCost: 0.6,
|
||||
inputLimit: 128000,
|
||||
outputLimit: 16384,
|
||||
batchAPI: '50% for 24 hours',
|
||||
hoster: 'OpenAI, Azure',
|
||||
trainingCutoff: 'October 2023',
|
||||
totalCost: 0.45,
|
||||
},
|
||||
{
|
||||
name: 'Gemini 2.5 Pro',
|
||||
provider: '',
|
||||
inputCost: 1.25,
|
||||
cachedInputCost: '',
|
||||
outputCost: 10,
|
||||
inputLimit: 1048576,
|
||||
outputLimit: 65536,
|
||||
batchAPI: '',
|
||||
hoster: 'Google',
|
||||
trainingCutoff: 'January 2025',
|
||||
totalCost: 6.25,
|
||||
},
|
||||
{
|
||||
name: 'Gemini 2.0 Flash',
|
||||
provider: 'Google',
|
||||
inputCost: 0.1,
|
||||
cachedInputCost: 0.025,
|
||||
outputCost: 0.4,
|
||||
inputLimit: 1048576,
|
||||
outputLimit: 8192,
|
||||
batchAPI: '',
|
||||
hoster: 'Google',
|
||||
trainingCutoff: 'June 2024',
|
||||
totalCost: 0.3,
|
||||
},
|
||||
{
|
||||
name: 'Gemini 2.0 Flash-Lite',
|
||||
provider: 'Google',
|
||||
inputCost: 0.075,
|
||||
cachedInputCost: 0.01875,
|
||||
outputCost: 0.3,
|
||||
inputLimit: 1048576,
|
||||
outputLimit: 8192,
|
||||
batchAPI: '',
|
||||
hoster: 'Google',
|
||||
trainingCutoff: 'June 2024',
|
||||
totalCost: 0.225,
|
||||
},
|
||||
];
|
||||
|
||||
// Formatierungsfunktionen für verschiedene Spaltentypen
|
||||
const formatCurrency = (value: number | undefined | null): string => {
|
||||
if (value === undefined || value === null) return "-";
|
||||
return `${value}$`;
|
||||
if (value === undefined || value === null) return '-';
|
||||
return `${value}$`;
|
||||
};
|
||||
|
||||
const formatNumber = (value: number | undefined | null): string => {
|
||||
if (value === undefined || value === null) return "-";
|
||||
return value.toLocaleString();
|
||||
if (value === undefined || value === null) return '-';
|
||||
return value.toLocaleString();
|
||||
};
|
||||
|
||||
// Spaltendefinitionen mit festen Breiten
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Model',
|
||||
width: '150px',
|
||||
sticky: true
|
||||
},
|
||||
{
|
||||
key: 'provider',
|
||||
title: 'Provider',
|
||||
width: '120px'
|
||||
},
|
||||
{
|
||||
key: 'inputCost',
|
||||
title: 'Input Cost',
|
||||
subtitle: 'per 1M tokens',
|
||||
formatter: formatCurrency,
|
||||
align: 'right',
|
||||
width: '120px'
|
||||
},
|
||||
{
|
||||
key: 'cachedInputCost',
|
||||
title: 'Cached Input Cost',
|
||||
subtitle: 'per 1M tokens',
|
||||
formatter: (value: any): string => typeof value === 'number' ? formatCurrency(value) : value,
|
||||
align: 'right',
|
||||
width: '150px'
|
||||
},
|
||||
{
|
||||
key: 'outputCost',
|
||||
title: 'Output Cost',
|
||||
subtitle: 'per 1M tokens',
|
||||
formatter: formatCurrency,
|
||||
align: 'right',
|
||||
width: '120px'
|
||||
},
|
||||
{
|
||||
key: 'inputLimit',
|
||||
title: 'Input Token Limit',
|
||||
formatter: formatNumber,
|
||||
align: 'right',
|
||||
width: '140px'
|
||||
},
|
||||
{
|
||||
key: 'outputLimit',
|
||||
title: 'Output Token Limit',
|
||||
formatter: formatNumber,
|
||||
align: 'right',
|
||||
width: '140px'
|
||||
},
|
||||
{
|
||||
key: 'batchAPI',
|
||||
title: 'Batch API',
|
||||
width: '150px'
|
||||
},
|
||||
{
|
||||
key: 'hoster',
|
||||
title: 'Hoster',
|
||||
width: '200px'
|
||||
},
|
||||
{
|
||||
key: 'trainingCutoff',
|
||||
title: 'Training Cut-off',
|
||||
width: '140px'
|
||||
},
|
||||
{
|
||||
key: 'totalCost',
|
||||
title: 'Total Cost',
|
||||
subtitle: '1M Input + 0.5M Output',
|
||||
formatter: formatCurrency,
|
||||
align: 'right',
|
||||
width: '120px'
|
||||
}
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Model',
|
||||
width: '150px',
|
||||
sticky: true,
|
||||
},
|
||||
{
|
||||
key: 'provider',
|
||||
title: 'Provider',
|
||||
width: '120px',
|
||||
},
|
||||
{
|
||||
key: 'inputCost',
|
||||
title: 'Input Cost',
|
||||
subtitle: 'per 1M tokens',
|
||||
formatter: formatCurrency,
|
||||
align: 'right',
|
||||
width: '120px',
|
||||
},
|
||||
{
|
||||
key: 'cachedInputCost',
|
||||
title: 'Cached Input Cost',
|
||||
subtitle: 'per 1M tokens',
|
||||
formatter: (value: any): string => (typeof value === 'number' ? formatCurrency(value) : value),
|
||||
align: 'right',
|
||||
width: '150px',
|
||||
},
|
||||
{
|
||||
key: 'outputCost',
|
||||
title: 'Output Cost',
|
||||
subtitle: 'per 1M tokens',
|
||||
formatter: formatCurrency,
|
||||
align: 'right',
|
||||
width: '120px',
|
||||
},
|
||||
{
|
||||
key: 'inputLimit',
|
||||
title: 'Input Token Limit',
|
||||
formatter: formatNumber,
|
||||
align: 'right',
|
||||
width: '140px',
|
||||
},
|
||||
{
|
||||
key: 'outputLimit',
|
||||
title: 'Output Token Limit',
|
||||
formatter: formatNumber,
|
||||
align: 'right',
|
||||
width: '140px',
|
||||
},
|
||||
{
|
||||
key: 'batchAPI',
|
||||
title: 'Batch API',
|
||||
width: '150px',
|
||||
},
|
||||
{
|
||||
key: 'hoster',
|
||||
title: 'Hoster',
|
||||
width: '200px',
|
||||
},
|
||||
{
|
||||
key: 'trainingCutoff',
|
||||
title: 'Training Cut-off',
|
||||
width: '140px',
|
||||
},
|
||||
{
|
||||
key: 'totalCost',
|
||||
title: 'Total Cost',
|
||||
subtitle: '1M Input + 0.5M Output',
|
||||
formatter: formatCurrency,
|
||||
align: 'right',
|
||||
width: '120px',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<section class="models-comparison-table">
|
||||
<h2>AI Models Comparison</h2>
|
||||
|
||||
<TableImproved
|
||||
data={models}
|
||||
columns={columns}
|
||||
stickyHeader={true}
|
||||
stickyFirstColumn={true}
|
||||
maxHeight="70vh"
|
||||
sortable={true}
|
||||
searchable={true}
|
||||
className="models-table"
|
||||
/>
|
||||
<h2>AI Models Comparison</h2>
|
||||
|
||||
<TableImproved
|
||||
data={models}
|
||||
columns={columns}
|
||||
stickyHeader={true}
|
||||
stickyFirstColumn={true}
|
||||
maxHeight="70vh"
|
||||
sortable={true}
|
||||
searchable={true}
|
||||
className="models-table"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.models-comparison-table {
|
||||
margin-bottom: 2.5rem;
|
||||
background: var(--background-secondary);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
padding: 2rem 0;
|
||||
width: calc(100vw - 2rem);
|
||||
position: relative;
|
||||
left: 50%;
|
||||
right: 50%;
|
||||
margin-left: calc(-50vw + 1rem);
|
||||
margin-right: calc(-50vw + 1rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Zusätzliche Anpassungen für die Tabelle */
|
||||
:global(.models-table) {
|
||||
--background-secondary: var(--background-secondary, #f8fafc);
|
||||
--background-tertiary: var(--background-tertiary, #edf2f7);
|
||||
--border-color: var(--border-color, #e2e8f0);
|
||||
}
|
||||
.models-comparison-table {
|
||||
margin-bottom: 2.5rem;
|
||||
background: var(--background-secondary);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
padding: 2rem 0;
|
||||
width: calc(100vw - 2rem);
|
||||
position: relative;
|
||||
left: 50%;
|
||||
right: 50%;
|
||||
margin-left: calc(-50vw + 1rem);
|
||||
margin-right: calc(-50vw + 1rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Zusätzliche Anpassungen für die Tabelle */
|
||||
:global(.models-table) {
|
||||
--background-secondary: var(--background-secondary, #f8fafc);
|
||||
--background-tertiary: var(--background-tertiary, #edf2f7);
|
||||
--border-color: var(--border-color, #e2e8f0);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,148 +2,316 @@
|
|||
import Table from './Table.astro';
|
||||
|
||||
const models = [
|
||||
{ name: "o1", provider: "OpenAI", inputCost: 15, cachedInputCost: 7.5, outputCost: 60, inputLimit: 200000, outputLimit: 100000, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "October 2023", totalCost: 45 },
|
||||
{ name: "gpt 4.1", provider: "OpenAI", inputCost: 2, cachedInputCost: 0.5, outputCost: 8, inputLimit: 1000000, outputLimit: 32768, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "June 2024", totalCost: 6 },
|
||||
{ name: "GPT-4o", provider: "OpenAI", inputCost: 2.5, cachedInputCost: 1.25, outputCost: 10, inputLimit: 128000, outputLimit: 16384, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "October 2023", totalCost: 7.5 },
|
||||
{ name: "gpt 4.1-mini", provider: "OpenAI", inputCost: 0.4, cachedInputCost: 0.1, outputCost: 1.6, inputLimit: 1000000, outputLimit: 32768, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "June 2024", totalCost: 1.2 },
|
||||
{ name: "gpt 4.1-nano", provider: "OpenAI", inputCost: 0.1, cachedInputCost: 0.025, outputCost: 0.4, inputLimit: 1000000, outputLimit: 32768, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "June 2024", totalCost: 0.3 },
|
||||
{ name: "o3-mini", provider: "OpenAI", inputCost: 1.1, cachedInputCost: 0.55, outputCost: 4.4, inputLimit: 200000, outputLimit: 100000, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "October 2023", totalCost: 3.3 },
|
||||
{ name: "Claude Sonnet 3.7", provider: "Antrophic", inputCost: 3, cachedInputCost: "Up to 90%", outputCost: 15, inputLimit: 200000, outputLimit: 8192, batchAPI: "50% for 24 hours", hoster: "Google, Amazon, Antrophic", trainingCutoff: "April 2024", totalCost: 10.5 },
|
||||
{ name: "Claude Haiku 3.5", provider: "Antrophic", inputCost: 0.8, cachedInputCost: "Up to 90%", outputCost: 4, inputLimit: 200000, outputLimit: 8192, batchAPI: "50% for 24 hours", hoster: "Google, Amazon, Antrophic", trainingCutoff: "July 2024", totalCost: 2.8 },
|
||||
{ name: "Deepseek R1", provider: "Deepseek", inputCost: 0.55, cachedInputCost: 0.14, outputCost: 2.19, inputLimit: 64000, outputLimit: 8192, batchAPI: "", hoster: "Deepseek", trainingCutoff: "July 2024", totalCost: 1.645 },
|
||||
{ name: "Deepseek V3", provider: "Deepseek", inputCost: 0.27, cachedInputCost: 0.07, outputCost: 1.1, inputLimit: 64000, outputLimit: 8192, batchAPI: "", hoster: "Deepseek", trainingCutoff: "December 2024", totalCost: 0.82 },
|
||||
{ name: "GPT-4o mini", provider: "OpenAI", inputCost: 0.15, cachedInputCost: 0.075, outputCost: 0.6, inputLimit: 128000, outputLimit: 16384, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "October 2023", totalCost: 0.45 },
|
||||
{ name: "Gemini 2.5 Pro", provider: "", inputCost: 1.25, cachedInputCost: "", outputCost: 10, inputLimit: 1048576, outputLimit: 65536, batchAPI: "", hoster: "Google", trainingCutoff: "January 2025", totalCost: 6.25 },
|
||||
{ name: "Gemini 2.0 Flash", provider: "Google", inputCost: 0.1, cachedInputCost: 0.025, outputCost: 0.4, inputLimit: 1048576, outputLimit: 8192, batchAPI: "", hoster: "Google", trainingCutoff: "June 2024", totalCost: 0.3 },
|
||||
{ name: "Gemini 2.0 Flash-Lite", provider: "Google", inputCost: 0.075, cachedInputCost: 0.01875, outputCost: 0.3, inputLimit: 1048576, outputLimit: 8192, batchAPI: "", hoster: "Google", trainingCutoff: "June 2024", totalCost: 0.225 },
|
||||
{
|
||||
name: 'o1',
|
||||
provider: 'OpenAI',
|
||||
inputCost: 15,
|
||||
cachedInputCost: 7.5,
|
||||
outputCost: 60,
|
||||
inputLimit: 200000,
|
||||
outputLimit: 100000,
|
||||
batchAPI: '50% for 24 hours',
|
||||
hoster: 'OpenAI, Azure',
|
||||
trainingCutoff: 'October 2023',
|
||||
totalCost: 45,
|
||||
},
|
||||
{
|
||||
name: 'gpt 4.1',
|
||||
provider: 'OpenAI',
|
||||
inputCost: 2,
|
||||
cachedInputCost: 0.5,
|
||||
outputCost: 8,
|
||||
inputLimit: 1000000,
|
||||
outputLimit: 32768,
|
||||
batchAPI: '50% for 24 hours',
|
||||
hoster: 'OpenAI, Azure',
|
||||
trainingCutoff: 'June 2024',
|
||||
totalCost: 6,
|
||||
},
|
||||
{
|
||||
name: 'GPT-4o',
|
||||
provider: 'OpenAI',
|
||||
inputCost: 2.5,
|
||||
cachedInputCost: 1.25,
|
||||
outputCost: 10,
|
||||
inputLimit: 128000,
|
||||
outputLimit: 16384,
|
||||
batchAPI: '50% for 24 hours',
|
||||
hoster: 'OpenAI, Azure',
|
||||
trainingCutoff: 'October 2023',
|
||||
totalCost: 7.5,
|
||||
},
|
||||
{
|
||||
name: 'gpt 4.1-mini',
|
||||
provider: 'OpenAI',
|
||||
inputCost: 0.4,
|
||||
cachedInputCost: 0.1,
|
||||
outputCost: 1.6,
|
||||
inputLimit: 1000000,
|
||||
outputLimit: 32768,
|
||||
batchAPI: '50% for 24 hours',
|
||||
hoster: 'OpenAI, Azure',
|
||||
trainingCutoff: 'June 2024',
|
||||
totalCost: 1.2,
|
||||
},
|
||||
{
|
||||
name: 'gpt 4.1-nano',
|
||||
provider: 'OpenAI',
|
||||
inputCost: 0.1,
|
||||
cachedInputCost: 0.025,
|
||||
outputCost: 0.4,
|
||||
inputLimit: 1000000,
|
||||
outputLimit: 32768,
|
||||
batchAPI: '50% for 24 hours',
|
||||
hoster: 'OpenAI, Azure',
|
||||
trainingCutoff: 'June 2024',
|
||||
totalCost: 0.3,
|
||||
},
|
||||
{
|
||||
name: 'o3-mini',
|
||||
provider: 'OpenAI',
|
||||
inputCost: 1.1,
|
||||
cachedInputCost: 0.55,
|
||||
outputCost: 4.4,
|
||||
inputLimit: 200000,
|
||||
outputLimit: 100000,
|
||||
batchAPI: '50% for 24 hours',
|
||||
hoster: 'OpenAI, Azure',
|
||||
trainingCutoff: 'October 2023',
|
||||
totalCost: 3.3,
|
||||
},
|
||||
{
|
||||
name: 'Claude Sonnet 3.7',
|
||||
provider: 'Antrophic',
|
||||
inputCost: 3,
|
||||
cachedInputCost: 'Up to 90%',
|
||||
outputCost: 15,
|
||||
inputLimit: 200000,
|
||||
outputLimit: 8192,
|
||||
batchAPI: '50% for 24 hours',
|
||||
hoster: 'Google, Amazon, Antrophic',
|
||||
trainingCutoff: 'April 2024',
|
||||
totalCost: 10.5,
|
||||
},
|
||||
{
|
||||
name: 'Claude Haiku 3.5',
|
||||
provider: 'Antrophic',
|
||||
inputCost: 0.8,
|
||||
cachedInputCost: 'Up to 90%',
|
||||
outputCost: 4,
|
||||
inputLimit: 200000,
|
||||
outputLimit: 8192,
|
||||
batchAPI: '50% for 24 hours',
|
||||
hoster: 'Google, Amazon, Antrophic',
|
||||
trainingCutoff: 'July 2024',
|
||||
totalCost: 2.8,
|
||||
},
|
||||
{
|
||||
name: 'Deepseek R1',
|
||||
provider: 'Deepseek',
|
||||
inputCost: 0.55,
|
||||
cachedInputCost: 0.14,
|
||||
outputCost: 2.19,
|
||||
inputLimit: 64000,
|
||||
outputLimit: 8192,
|
||||
batchAPI: '',
|
||||
hoster: 'Deepseek',
|
||||
trainingCutoff: 'July 2024',
|
||||
totalCost: 1.645,
|
||||
},
|
||||
{
|
||||
name: 'Deepseek V3',
|
||||
provider: 'Deepseek',
|
||||
inputCost: 0.27,
|
||||
cachedInputCost: 0.07,
|
||||
outputCost: 1.1,
|
||||
inputLimit: 64000,
|
||||
outputLimit: 8192,
|
||||
batchAPI: '',
|
||||
hoster: 'Deepseek',
|
||||
trainingCutoff: 'December 2024',
|
||||
totalCost: 0.82,
|
||||
},
|
||||
{
|
||||
name: 'GPT-4o mini',
|
||||
provider: 'OpenAI',
|
||||
inputCost: 0.15,
|
||||
cachedInputCost: 0.075,
|
||||
outputCost: 0.6,
|
||||
inputLimit: 128000,
|
||||
outputLimit: 16384,
|
||||
batchAPI: '50% for 24 hours',
|
||||
hoster: 'OpenAI, Azure',
|
||||
trainingCutoff: 'October 2023',
|
||||
totalCost: 0.45,
|
||||
},
|
||||
{
|
||||
name: 'Gemini 2.5 Pro',
|
||||
provider: '',
|
||||
inputCost: 1.25,
|
||||
cachedInputCost: '',
|
||||
outputCost: 10,
|
||||
inputLimit: 1048576,
|
||||
outputLimit: 65536,
|
||||
batchAPI: '',
|
||||
hoster: 'Google',
|
||||
trainingCutoff: 'January 2025',
|
||||
totalCost: 6.25,
|
||||
},
|
||||
{
|
||||
name: 'Gemini 2.0 Flash',
|
||||
provider: 'Google',
|
||||
inputCost: 0.1,
|
||||
cachedInputCost: 0.025,
|
||||
outputCost: 0.4,
|
||||
inputLimit: 1048576,
|
||||
outputLimit: 8192,
|
||||
batchAPI: '',
|
||||
hoster: 'Google',
|
||||
trainingCutoff: 'June 2024',
|
||||
totalCost: 0.3,
|
||||
},
|
||||
{
|
||||
name: 'Gemini 2.0 Flash-Lite',
|
||||
provider: 'Google',
|
||||
inputCost: 0.075,
|
||||
cachedInputCost: 0.01875,
|
||||
outputCost: 0.3,
|
||||
inputLimit: 1048576,
|
||||
outputLimit: 8192,
|
||||
batchAPI: '',
|
||||
hoster: 'Google',
|
||||
trainingCutoff: 'June 2024',
|
||||
totalCost: 0.225,
|
||||
},
|
||||
];
|
||||
|
||||
// Formatierungsfunktionen für verschiedene Spaltentypen
|
||||
const formatCurrency = (value) => {
|
||||
if (value === undefined || value === null) return "-";
|
||||
return `${value}$`;
|
||||
if (value === undefined || value === null) return '-';
|
||||
return `${value}$`;
|
||||
};
|
||||
|
||||
const formatNumber = (value) => {
|
||||
if (value === undefined || value === null) return "-";
|
||||
return value.toLocaleString();
|
||||
if (value === undefined || value === null) return '-';
|
||||
return value.toLocaleString();
|
||||
};
|
||||
|
||||
// Spaltendefinitionen
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Model',
|
||||
width: '150px',
|
||||
sticky: true
|
||||
},
|
||||
{
|
||||
key: 'provider',
|
||||
title: 'Provider'
|
||||
},
|
||||
{
|
||||
key: 'inputCost',
|
||||
title: 'Input Cost',
|
||||
subtitle: 'per 1M tokens',
|
||||
formatter: formatCurrency,
|
||||
align: 'right'
|
||||
},
|
||||
{
|
||||
key: 'cachedInputCost',
|
||||
title: 'Cached Input Cost',
|
||||
subtitle: 'per 1M tokens',
|
||||
formatter: (value) => typeof value === 'number' ? formatCurrency(value) : value,
|
||||
align: 'right'
|
||||
},
|
||||
{
|
||||
key: 'outputCost',
|
||||
title: 'Output Cost',
|
||||
subtitle: 'per 1M tokens',
|
||||
formatter: formatCurrency,
|
||||
align: 'right'
|
||||
},
|
||||
{
|
||||
key: 'inputLimit',
|
||||
title: 'Input Token Limit',
|
||||
formatter: formatNumber,
|
||||
align: 'right'
|
||||
},
|
||||
{
|
||||
key: 'outputLimit',
|
||||
title: 'Output Token Limit',
|
||||
formatter: formatNumber,
|
||||
align: 'right'
|
||||
},
|
||||
{
|
||||
key: 'batchAPI',
|
||||
title: 'Batch API'
|
||||
},
|
||||
{
|
||||
key: 'hoster',
|
||||
title: 'Hoster'
|
||||
},
|
||||
{
|
||||
key: 'trainingCutoff',
|
||||
title: 'Training Cut-off'
|
||||
},
|
||||
{
|
||||
key: 'totalCost',
|
||||
title: 'Total Cost',
|
||||
subtitle: '1M Input + 0.5M Output',
|
||||
formatter: formatCurrency,
|
||||
align: 'right'
|
||||
}
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Model',
|
||||
width: '150px',
|
||||
sticky: true,
|
||||
},
|
||||
{
|
||||
key: 'provider',
|
||||
title: 'Provider',
|
||||
},
|
||||
{
|
||||
key: 'inputCost',
|
||||
title: 'Input Cost',
|
||||
subtitle: 'per 1M tokens',
|
||||
formatter: formatCurrency,
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
key: 'cachedInputCost',
|
||||
title: 'Cached Input Cost',
|
||||
subtitle: 'per 1M tokens',
|
||||
formatter: (value) => (typeof value === 'number' ? formatCurrency(value) : value),
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
key: 'outputCost',
|
||||
title: 'Output Cost',
|
||||
subtitle: 'per 1M tokens',
|
||||
formatter: formatCurrency,
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
key: 'inputLimit',
|
||||
title: 'Input Token Limit',
|
||||
formatter: formatNumber,
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
key: 'outputLimit',
|
||||
title: 'Output Token Limit',
|
||||
formatter: formatNumber,
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
key: 'batchAPI',
|
||||
title: 'Batch API',
|
||||
},
|
||||
{
|
||||
key: 'hoster',
|
||||
title: 'Hoster',
|
||||
},
|
||||
{
|
||||
key: 'trainingCutoff',
|
||||
title: 'Training Cut-off',
|
||||
},
|
||||
{
|
||||
key: 'totalCost',
|
||||
title: 'Total Cost',
|
||||
subtitle: '1M Input + 0.5M Output',
|
||||
formatter: formatCurrency,
|
||||
align: 'right',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<section class="models-comparison-page">
|
||||
<h1>AI Models Comparison</h1>
|
||||
|
||||
<Table
|
||||
data={models}
|
||||
columns={columns}
|
||||
stickyHeader={true}
|
||||
stickyFirstColumn={true}
|
||||
maxHeight="70vh"
|
||||
sortable={true}
|
||||
searchable={true}
|
||||
className="models-table"
|
||||
/>
|
||||
|
||||
<div class="table-info">
|
||||
<p>
|
||||
<strong>Hinweis:</strong> Die Preise sind in US-Dollar angegeben und können sich ändern.
|
||||
Die Gesamtkosten basieren auf 1 Million Input-Token und 0,5 Millionen Output-Token.
|
||||
</p>
|
||||
</div>
|
||||
<h1>AI Models Comparison</h1>
|
||||
|
||||
<Table
|
||||
data={models}
|
||||
columns={columns}
|
||||
stickyHeader={true}
|
||||
stickyFirstColumn={true}
|
||||
maxHeight="70vh"
|
||||
sortable={true}
|
||||
searchable={true}
|
||||
className="models-table"
|
||||
/>
|
||||
|
||||
<div class="table-info">
|
||||
<p>
|
||||
<strong>Hinweis:</strong> Die Preise sind in US-Dollar angegeben und können sich ändern. Die Gesamtkosten
|
||||
basieren auf 1 Million Input-Token und 0,5 Millionen Output-Token.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.models-comparison-page {
|
||||
margin: 2rem 0;
|
||||
width: calc(100vw - 2rem);
|
||||
position: relative;
|
||||
left: 50%;
|
||||
right: 50%;
|
||||
margin-left: calc(-50vw + 1rem);
|
||||
margin-right: calc(-50vw + 1rem);
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.table-info {
|
||||
margin: 1.5rem 2rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Zusätzliche Anpassungen für die Tabelle */
|
||||
:global(.models-table) {
|
||||
--background-secondary: var(--color-background-secondary, #f8fafc);
|
||||
--background-tertiary: var(--color-background-tertiary, #edf2f7);
|
||||
--border-color: var(--color-border, #e2e8f0);
|
||||
}
|
||||
.models-comparison-page {
|
||||
margin: 2rem 0;
|
||||
width: calc(100vw - 2rem);
|
||||
position: relative;
|
||||
left: 50%;
|
||||
right: 50%;
|
||||
margin-left: calc(-50vw + 1rem);
|
||||
margin-right: calc(-50vw + 1rem);
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.table-info {
|
||||
margin: 1.5rem 2rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Zusätzliche Anpassungen für die Tabelle */
|
||||
:global(.models-table) {
|
||||
--background-secondary: var(--color-background-secondary, #f8fafc);
|
||||
--background-tertiary: var(--color-background-tertiary, #edf2f7);
|
||||
--border-color: var(--color-border, #e2e8f0);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -3,93 +3,92 @@
|
|||
---
|
||||
|
||||
<script>
|
||||
import { trackEvent, EVENTS } from '../scripts/analytics';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Track navigation clicks
|
||||
document.querySelectorAll('.nav-links a, .mobile-tab-bar a').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
const target = e.currentTarget as HTMLAnchorElement;
|
||||
const text = target.textContent?.trim() || '';
|
||||
const href = target.getAttribute('href') || '';
|
||||
|
||||
trackEvent(EVENTS.NAV_CLICK, {
|
||||
label: text,
|
||||
destination: href
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Track footer navigation clicks
|
||||
document.querySelectorAll('footer a').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
const target = e.currentTarget as HTMLAnchorElement;
|
||||
const text = target.textContent?.trim() || '';
|
||||
const href = target.getAttribute('href') || '';
|
||||
|
||||
// Skip external links (they're tracked separately)
|
||||
if (!href.startsWith('http')) {
|
||||
trackEvent(EVENTS.FOOTER_CLICK, {
|
||||
label: text,
|
||||
destination: href
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Track language switcher
|
||||
document.querySelectorAll('.language-switcher a, .language-option').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const lang = target.getAttribute('href')?.split('/')[1] ||
|
||||
target.dataset.lang || '';
|
||||
|
||||
trackEvent(EVENTS.LANGUAGE_SWITCH, {
|
||||
from: document.documentElement.lang,
|
||||
to: lang
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Track theme toggle
|
||||
const themeToggle = document.querySelector('.theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', () => {
|
||||
const currentTheme = document.documentElement.classList.contains('dark') ? 'light' : 'dark';
|
||||
trackEvent(EVENTS.THEME_TOGGLE, {
|
||||
theme: currentTheme
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Track CTA buttons
|
||||
document.querySelectorAll('a[href*="/join"], .cta-button').forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const text = target.textContent?.trim() || '';
|
||||
const href = target.getAttribute('href') || '';
|
||||
|
||||
trackEvent(EVENTS.JOIN_CLICK, {
|
||||
label: text,
|
||||
destination: href,
|
||||
location: target.closest('section')?.id || 'unknown'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Track support/coffee buttons
|
||||
document.querySelectorAll('a[href*="/support"], .support-link').forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const text = target.textContent?.trim() || '';
|
||||
const href = target.getAttribute('href') || '';
|
||||
|
||||
trackEvent(EVENTS.SUPPORT_CLICK, {
|
||||
label: text,
|
||||
destination: href,
|
||||
location: target.closest('footer') ? 'footer' : 'page'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
import { trackEvent, EVENTS } from '../scripts/analytics';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Track navigation clicks
|
||||
document.querySelectorAll('.nav-links a, .mobile-tab-bar a').forEach((link) => {
|
||||
link.addEventListener('click', (e) => {
|
||||
const target = e.currentTarget as HTMLAnchorElement;
|
||||
const text = target.textContent?.trim() || '';
|
||||
const href = target.getAttribute('href') || '';
|
||||
|
||||
trackEvent(EVENTS.NAV_CLICK, {
|
||||
label: text,
|
||||
destination: href,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Track footer navigation clicks
|
||||
document.querySelectorAll('footer a').forEach((link) => {
|
||||
link.addEventListener('click', (e) => {
|
||||
const target = e.currentTarget as HTMLAnchorElement;
|
||||
const text = target.textContent?.trim() || '';
|
||||
const href = target.getAttribute('href') || '';
|
||||
|
||||
// Skip external links (they're tracked separately)
|
||||
if (!href.startsWith('http')) {
|
||||
trackEvent(EVENTS.FOOTER_CLICK, {
|
||||
label: text,
|
||||
destination: href,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Track language switcher
|
||||
document.querySelectorAll('.language-switcher a, .language-option').forEach((link) => {
|
||||
link.addEventListener('click', (e) => {
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const lang = target.getAttribute('href')?.split('/')[1] || target.dataset.lang || '';
|
||||
|
||||
trackEvent(EVENTS.LANGUAGE_SWITCH, {
|
||||
from: document.documentElement.lang,
|
||||
to: lang,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Track theme toggle
|
||||
const themeToggle = document.querySelector('.theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', () => {
|
||||
const currentTheme = document.documentElement.classList.contains('dark') ? 'light' : 'dark';
|
||||
trackEvent(EVENTS.THEME_TOGGLE, {
|
||||
theme: currentTheme,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Track CTA buttons
|
||||
document.querySelectorAll('a[href*="/join"], .cta-button').forEach((button) => {
|
||||
button.addEventListener('click', (e) => {
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const text = target.textContent?.trim() || '';
|
||||
const href = target.getAttribute('href') || '';
|
||||
|
||||
trackEvent(EVENTS.JOIN_CLICK, {
|
||||
label: text,
|
||||
destination: href,
|
||||
location: target.closest('section')?.id || 'unknown',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Track support/coffee buttons
|
||||
document.querySelectorAll('a[href*="/support"], .support-link').forEach((button) => {
|
||||
button.addEventListener('click', (e) => {
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const text = target.textContent?.trim() || '';
|
||||
const href = target.getAttribute('href') || '';
|
||||
|
||||
trackEvent(EVENTS.SUPPORT_CLICK, {
|
||||
label: text,
|
||||
destination: href,
|
||||
location: target.closest('footer') ? 'footer' : 'page',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
---
|
||||
import { getLangFromUrl, useTranslations } from "../utils/i18n";
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import { getLangFromUrl, useTranslations } from '../utils/i18n';
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
|
||||
// Define allowed collections
|
||||
type AllowedCollections = "news" | "models";
|
||||
type AllowedCollections = 'news' | 'models';
|
||||
|
||||
interface Props {
|
||||
news: CollectionEntry<AllowedCollections>;
|
||||
news: CollectionEntry<AllowedCollections>;
|
||||
}
|
||||
|
||||
const { news } = Astro.props;
|
||||
|
|
@ -17,17 +17,17 @@ const t = useTranslations(lang);
|
|||
|
||||
// Format date based on language
|
||||
const formattedDate = new Intl.DateTimeFormat(
|
||||
lang === "de" ? "de-DE" : lang === "it" ? "it-IT" : "en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}
|
||||
lang === 'de' ? 'de-DE' : lang === 'it' ? 'it-IT' : 'en-US',
|
||||
{
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}
|
||||
).format(data.pubDate);
|
||||
|
||||
// Generate URL with language prefix
|
||||
let pageUrl;
|
||||
const slugParts = news.slug.split("/");
|
||||
const slugParts = news.slug.split('/');
|
||||
const fileName = slugParts[slugParts.length - 1];
|
||||
// Determine collection for URL
|
||||
const collection = news.collection as AllowedCollections;
|
||||
|
|
@ -38,168 +38,166 @@ const categoryKey = `${collection}.categories.${data.category}` as const;
|
|||
---
|
||||
|
||||
<a href={pageUrl} class="news-card-link">
|
||||
<article class:list={["news-card", { featured: data.featured }]}>
|
||||
{
|
||||
data.image && (
|
||||
<div class="news-image-container">
|
||||
<img src={data.image} alt={data.title} class="news-image" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<article class:list={['news-card', { featured: data.featured }]}>
|
||||
{
|
||||
data.image && (
|
||||
<div class="news-image-container">
|
||||
<img src={data.image} alt={data.title} class="news-image" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="content">
|
||||
<div class="meta">
|
||||
<span class="category">{t(categoryKey)}</span>
|
||||
<span class="date">{formattedDate}</span>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="meta">
|
||||
<span class="category">{t(categoryKey)}</span>
|
||||
<span class="date">{formattedDate}</span>
|
||||
</div>
|
||||
|
||||
<h3>{data.title}</h3>
|
||||
<p>{data.description}</p>
|
||||
<h3>{data.title}</h3>
|
||||
<p>{data.description}</p>
|
||||
|
||||
<div class="footer">
|
||||
<span class="author">{data.author}</span>
|
||||
<span class="read-more"
|
||||
>{t(`${collection}.readMore`)} <span class="arrow">→</span></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<div class="footer">
|
||||
<span class="author">{data.author}</span>
|
||||
<span class="read-more">{t(`${collection}.readMore`)} <span class="arrow">→</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.news-card-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
}
|
||||
.news-card-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.news-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background-color: var(--card-bg);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06),
|
||||
0 1px 2px -1px rgba(0, 0, 0, 0.03);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
height: 100%;
|
||||
}
|
||||
.news-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background-color: var(--card-bg);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06),
|
||||
0 1px 2px -1px rgba(0, 0, 0, 0.03);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.news-card-link:hover .news-card {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.5);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.08),
|
||||
0 2px 4px -2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.news-card-link:hover .news-card {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.5);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.08),
|
||||
0 2px 4px -2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.news-card.featured {
|
||||
border: 2px solid rgba(var(--accent-color-rgb, 249, 115, 22), 0.3);
|
||||
}
|
||||
.news-card.featured {
|
||||
border: 2px solid rgba(var(--accent-color-rgb, 249, 115, 22), 0.3);
|
||||
}
|
||||
|
||||
.news-image-container {
|
||||
width: 100%;
|
||||
padding-top: 56.25%; /* Aspect ratio 16:9 */
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.news-image-container {
|
||||
width: 100%;
|
||||
padding-top: 56.25%; /* Aspect ratio 16:9 */
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.news-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 1;
|
||||
}
|
||||
.news-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.category {
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
.category {
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.date {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.date {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
flex-grow: 1;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
flex-grow: 1;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.author {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.author {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.read-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.read-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.arrow {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.news-card-link:hover .arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
.news-card-link:hover .arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* Tags */
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
/* Tags */
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.1);
|
||||
color: var(--accent-color);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.1);
|
||||
color: var(--accent-color);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
---
|
||||
import { getLangFromUrl, useTranslations } from "../utils/i18n";
|
||||
import { getLangFromUrl, useTranslations } from '../utils/i18n';
|
||||
|
||||
interface Props {
|
||||
customTitle?: string;
|
||||
customDescription?: string;
|
||||
customTitle?: string;
|
||||
customDescription?: string;
|
||||
}
|
||||
|
||||
const { customTitle, customDescription } = Astro.props;
|
||||
|
|
@ -12,145 +12,145 @@ const t = useTranslations(lang);
|
|||
---
|
||||
|
||||
<div class="newsletter-container">
|
||||
<h2>{customTitle || t("join.newsletterTitle")}</h2>
|
||||
<p class="newsletter-description">
|
||||
{customDescription || t("join.newsletterDesc")}
|
||||
</p>
|
||||
<h2>{customTitle || t('join.newsletterTitle')}</h2>
|
||||
<p class="newsletter-description">
|
||||
{customDescription || t('join.newsletterDesc')}
|
||||
</p>
|
||||
|
||||
<form class="newsletter-form" id="newsletterForm">
|
||||
<input
|
||||
type="email"
|
||||
id="newsletterEmail"
|
||||
name="email"
|
||||
placeholder={t("join.emailPlaceholder")}
|
||||
required
|
||||
/>
|
||||
<button type="submit">{t("join.subscribe")}</button>
|
||||
</form>
|
||||
<form class="newsletter-form" id="newsletterForm">
|
||||
<input
|
||||
type="email"
|
||||
id="newsletterEmail"
|
||||
name="email"
|
||||
placeholder={t('join.emailPlaceholder')}
|
||||
required
|
||||
/>
|
||||
<button type="submit">{t('join.subscribe')}</button>
|
||||
</form>
|
||||
|
||||
<div id="subscribeSuccess" class="subscribe-success" style="display: none;">
|
||||
<p>{t("join.success")}</p>
|
||||
</div>
|
||||
<div id="subscribeSuccess" class="subscribe-success" style="display: none;">
|
||||
<p>{t('join.success')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.newsletter-container {
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 0.75rem;
|
||||
padding: 2rem;
|
||||
border: 1px solid var(--border-color);
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.newsletter-container {
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 0.75rem;
|
||||
padding: 2rem;
|
||||
border: 1px solid var(--border-color);
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* Heller Modus */
|
||||
:root:not(.dark) .newsletter-container {
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
/* Heller Modus */
|
||||
:root:not(.dark) .newsletter-container {
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.newsletter-description {
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.newsletter-description {
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.newsletter-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.newsletter-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s ease;
|
||||
width: 100%;
|
||||
}
|
||||
input {
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Heller Modus für Eingabefelder */
|
||||
:root:not(.dark) input {
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
/* Heller Modus für Eingabefelder */
|
||||
:root:not(.dark) input {
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
width: max-content;
|
||||
}
|
||||
button {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
button:hover {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.subscribe-success {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
border-radius: 0.375rem;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.subscribe-success {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
border-radius: 0.375rem;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.subscribe-success p {
|
||||
color: #10b981;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
.subscribe-success p {
|
||||
color: #10b981;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Responsive layout for larger screens */
|
||||
@media (min-width: 640px) {
|
||||
.newsletter-form {
|
||||
flex-direction: row;
|
||||
}
|
||||
/* Responsive layout for larger screens */
|
||||
@media (min-width: 640px) {
|
||||
.newsletter-form {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
}
|
||||
input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const form = document.getElementById("newsletterForm");
|
||||
const successMessage = document.getElementById("subscribeSuccess");
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const form = document.getElementById('newsletterForm');
|
||||
const successMessage = document.getElementById('subscribeSuccess');
|
||||
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Simulate form submission (in a real app, this would send data to a server)
|
||||
setTimeout(() => {
|
||||
form.reset();
|
||||
successMessage.style.display = "block";
|
||||
// Simulate form submission (in a real app, this would send data to a server)
|
||||
setTimeout(() => {
|
||||
form.reset();
|
||||
successMessage.style.display = 'block';
|
||||
|
||||
// Hide success message after 5 seconds
|
||||
setTimeout(() => {
|
||||
successMessage.style.display = "none";
|
||||
}, 5000);
|
||||
}, 1000);
|
||||
});
|
||||
});
|
||||
// Hide success message after 5 seconds
|
||||
setTimeout(() => {
|
||||
successMessage.style.display = 'none';
|
||||
}, 5000);
|
||||
}, 1000);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
import { useTranslations } from "../utils/i18n";
|
||||
import { useTranslations } from '../utils/i18n';
|
||||
|
||||
interface Props {
|
||||
lang: "de" | "en" | "it";
|
||||
lang: 'de' | 'en' | 'it';
|
||||
}
|
||||
|
||||
const { lang } = Astro.props;
|
||||
|
|
@ -10,79 +10,75 @@ const t = useTranslations(lang);
|
|||
---
|
||||
|
||||
<div class="payment-container">
|
||||
<div class="payment-options">
|
||||
<div class="payment-type-selector">
|
||||
<button id="one-time" class="payment-type-btn active"
|
||||
>{t("support.onetime")}</button
|
||||
>
|
||||
<button id="recurring" class="payment-type-btn"
|
||||
>{t("support.recurring")}</button
|
||||
>
|
||||
</div>
|
||||
<div class="payment-options">
|
||||
<div class="payment-type-selector">
|
||||
<button id="one-time" class="payment-type-btn active">{t('support.onetime')}</button>
|
||||
<button id="recurring" class="payment-type-btn">{t('support.recurring')}</button>
|
||||
</div>
|
||||
|
||||
<div class="coffee-options">
|
||||
<h3>{t("support.buyMeACoffee")}</h3>
|
||||
<div class="coffee-selector">
|
||||
<div
|
||||
class="coffee-option"
|
||||
data-price="3"
|
||||
data-price-id-onetime="price_1R8xkmAZjQCYS0ZJk6PZtJUm"
|
||||
data-price-id-recurring="price_1R8xkmAZjQCYS0ZJSBkFXcE1"
|
||||
>
|
||||
<div class="coffee-icon small-coffee">☕</div>
|
||||
<div class="coffee-details">
|
||||
<h4>Kleiner Kaffee</h4>
|
||||
<p>3€</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="coffee-option active"
|
||||
data-price="5"
|
||||
data-price-id-onetime="price_1R8xmlAZjQCYS0ZJ4QCSZFFN"
|
||||
data-price-id-recurring="price_1R8xn3AZjQCYS0ZJIXddpl0O"
|
||||
>
|
||||
<div class="coffee-icon medium-coffee">☕</div>
|
||||
<div class="coffee-details">
|
||||
<h4>Mittlerer Kaffee</h4>
|
||||
<p>5€</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="coffee-option"
|
||||
data-price="8"
|
||||
data-price-id-onetime="price_1R8xp4AZjQCYS0ZJCkTA6gCs"
|
||||
data-price-id-recurring="price_1R8xpIAZjQCYS0ZJ93nqMjop"
|
||||
>
|
||||
<div class="coffee-icon large-coffee">☕</div>
|
||||
<div class="coffee-details">
|
||||
<h4>Großer Kaffee</h4>
|
||||
<p>8€</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="coffee-options">
|
||||
<h3>{t('support.buyMeACoffee')}</h3>
|
||||
<div class="coffee-selector">
|
||||
<div
|
||||
class="coffee-option"
|
||||
data-price="3"
|
||||
data-price-id-onetime="price_1R8xkmAZjQCYS0ZJk6PZtJUm"
|
||||
data-price-id-recurring="price_1R8xkmAZjQCYS0ZJSBkFXcE1"
|
||||
>
|
||||
<div class="coffee-icon small-coffee">☕</div>
|
||||
<div class="coffee-details">
|
||||
<h4>Kleiner Kaffee</h4>
|
||||
<p>3€</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="coffee-option active"
|
||||
data-price="5"
|
||||
data-price-id-onetime="price_1R8xmlAZjQCYS0ZJ4QCSZFFN"
|
||||
data-price-id-recurring="price_1R8xn3AZjQCYS0ZJIXddpl0O"
|
||||
>
|
||||
<div class="coffee-icon medium-coffee">☕</div>
|
||||
<div class="coffee-details">
|
||||
<h4>Mittlerer Kaffee</h4>
|
||||
<p>5€</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="coffee-option"
|
||||
data-price="8"
|
||||
data-price-id-onetime="price_1R8xp4AZjQCYS0ZJCkTA6gCs"
|
||||
data-price-id-recurring="price_1R8xpIAZjQCYS0ZJ93nqMjop"
|
||||
>
|
||||
<div class="coffee-icon large-coffee">☕</div>
|
||||
<div class="coffee-details">
|
||||
<h4>Großer Kaffee</h4>
|
||||
<p>8€</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="payment-methods">
|
||||
<p>{t("support.paymentMethod")}</p>
|
||||
<div class="payment-buttons">
|
||||
<button id="stripe-button" class="payment-method-btn">
|
||||
<svg
|
||||
viewBox="0 0 60 25"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="60"
|
||||
height="25"
|
||||
class="UserLogo variant--"
|
||||
>
|
||||
<title>Stripe logo</title>
|
||||
<path
|
||||
fill="var(--text-color)"
|
||||
d="M59.64 14.28h-8.06c.19 1.93 1.6 2.55 3.2 2.55 1.64 0 2.96-.37 4.05-.95v3.32a8.33 8.33 0 0 1-4.56 1.1c-4.01 0-6.83-2.5-6.83-7.48 0-4.19 2.39-7.52 6.3-7.52 3.92 0 5.96 3.28 5.96 7.5 0 .4-.04 1.26-.06 1.48zm-5.92-5.62c-1.03 0-2.17.73-2.17 2.58h4.25c0-1.85-1.07-2.58-2.08-2.58zM40.95 20.3c-1.44 0-2.32-.6-2.9-1.04l-.02 4.63-4.12.87V5.57h3.76l.08 1.02a4.7 4.7 0 0 1 3.23-1.29c2.9 0 5.62 2.6 5.62 7.4 0 5.23-2.7 7.6-5.65 7.6zM40 8.95c-.95 0-1.54.34-1.97.81l.02 6.12c.4.44.98.78 1.95.78 1.52 0 2.54-1.65 2.54-3.87 0-2.15-1.04-3.84-2.54-3.84zM28.24 5.57h4.13v14.44h-4.13V5.57zm0-4.7L32.37 0v3.36l-4.13.88V.88zm-4.32 9.35v9.79H19.8V5.57h3.7l.12 1.22c1-1.77 3.07-1.41 3.62-1.22v3.79c-.52-.17-2.29-.43-3.32.86zm-8.55 4.72c0 2.43 2.6 1.68 3.12 1.46v3.36c-.55.3-1.54.54-2.89.54a4.15 4.15 0 0 1-4.27-4.24l.02-13.17 4.02-.86v3.54h3.14V9.1h-3.14v5.85zm-4.91.7c0 2.97-2.31 4.66-5.73 4.66a11.2 11.2 0 0 1-4.46-.93v-3.93c1.38.75 3.1 1.31 4.46 1.31.92 0 1.53-.24 1.53-1C6.26 13.77 0 14.51 0 9.95 0 7.04 2.28 5.3 5.62 5.3c1.36 0 2.72.2 4.09.75v3.88a9.23 9.23 0 0 0-4.1-1.06c-.86 0-1.44.25-1.44.9 0 1.85 6.29.97 6.29 5.88z"
|
||||
fill-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span>{t("support.payWithStripe")}</span>
|
||||
</button>
|
||||
{
|
||||
/* PayPal Button wird später implementiert
|
||||
<div class="payment-methods">
|
||||
<p>{t('support.paymentMethod')}</p>
|
||||
<div class="payment-buttons">
|
||||
<button id="stripe-button" class="payment-method-btn">
|
||||
<svg
|
||||
viewBox="0 0 60 25"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="60"
|
||||
height="25"
|
||||
class="UserLogo variant--"
|
||||
>
|
||||
<title>Stripe logo</title>
|
||||
<path
|
||||
fill="var(--text-color)"
|
||||
d="M59.64 14.28h-8.06c.19 1.93 1.6 2.55 3.2 2.55 1.64 0 2.96-.37 4.05-.95v3.32a8.33 8.33 0 0 1-4.56 1.1c-4.01 0-6.83-2.5-6.83-7.48 0-4.19 2.39-7.52 6.3-7.52 3.92 0 5.96 3.28 5.96 7.5 0 .4-.04 1.26-.06 1.48zm-5.92-5.62c-1.03 0-2.17.73-2.17 2.58h4.25c0-1.85-1.07-2.58-2.08-2.58zM40.95 20.3c-1.44 0-2.32-.6-2.9-1.04l-.02 4.63-4.12.87V5.57h3.76l.08 1.02a4.7 4.7 0 0 1 3.23-1.29c2.9 0 5.62 2.6 5.62 7.4 0 5.23-2.7 7.6-5.65 7.6zM40 8.95c-.95 0-1.54.34-1.97.81l.02 6.12c.4.44.98.78 1.95.78 1.52 0 2.54-1.65 2.54-3.87 0-2.15-1.04-3.84-2.54-3.84zM28.24 5.57h4.13v14.44h-4.13V5.57zm0-4.7L32.37 0v3.36l-4.13.88V.88zm-4.32 9.35v9.79H19.8V5.57h3.7l.12 1.22c1-1.77 3.07-1.41 3.62-1.22v3.79c-.52-.17-2.29-.43-3.32.86zm-8.55 4.72c0 2.43 2.6 1.68 3.12 1.46v3.36c-.55.3-1.54.54-2.89.54a4.15 4.15 0 0 1-4.27-4.24l.02-13.17 4.02-.86v3.54h3.14V9.1h-3.14v5.85zm-4.91.7c0 2.97-2.31 4.66-5.73 4.66a11.2 11.2 0 0 1-4.46-.93v-3.93c1.38.75 3.1 1.31 4.46 1.31.92 0 1.53-.24 1.53-1C6.26 13.77 0 14.51 0 9.95 0 7.04 2.28 5.3 5.62 5.3c1.36 0 2.72.2 4.09.75v3.88a9.23 9.23 0 0 0-4.1-1.06c-.86 0-1.44.25-1.44.9 0 1.85 6.29.97 6.29 5.88z"
|
||||
fill-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span>{t('support.payWithStripe')}</span>
|
||||
</button>
|
||||
{
|
||||
/* PayPal Button wird später implementiert
|
||||
<button id="paypal-button" class="payment-method-btn">
|
||||
<svg
|
||||
width="101"
|
||||
|
|
@ -99,462 +95,449 @@ const t = useTranslations(lang);
|
|||
<span>{t("support.payWithPayPal")}</span>
|
||||
</button>
|
||||
*/
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="payment-message"></div>
|
||||
</div>
|
||||
<div id="payment-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.payment-container {
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
|
||||
padding: 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 550px;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid rgba(var(--border-color-rgb), 0.1);
|
||||
}
|
||||
.payment-container {
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
|
||||
padding: 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 550px;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid rgba(var(--border-color-rgb), 0.1);
|
||||
}
|
||||
|
||||
.payment-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
.payment-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.payment-type-selector {
|
||||
display: flex;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid rgba(var(--border-color-rgb), 0.2);
|
||||
}
|
||||
.payment-type-selector {
|
||||
display: flex;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid rgba(var(--border-color-rgb), 0.2);
|
||||
}
|
||||
|
||||
.payment-type-btn {
|
||||
flex: 1;
|
||||
padding: 1.2rem;
|
||||
border: none;
|
||||
background: var(--card-bg);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--text-color);
|
||||
font-size: 1.1rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.payment-type-btn {
|
||||
flex: 1;
|
||||
padding: 1.2rem;
|
||||
border: none;
|
||||
background: var(--card-bg);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--text-color);
|
||||
font-size: 1.1rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.payment-type-btn:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background-color: transparent;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.payment-type-btn:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background-color: transparent;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.payment-type-btn.active {
|
||||
background-color: rgba(var(--accent-color-rgb), 0.1);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
.payment-type-btn.active {
|
||||
background-color: rgba(var(--accent-color-rgb), 0.1);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.payment-type-btn.active:after {
|
||||
background-color: var(--accent-color);
|
||||
}
|
||||
.payment-type-btn.active:after {
|
||||
background-color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* Coffee Options */
|
||||
.coffee-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
/* Coffee Options */
|
||||
.coffee-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.coffee-options h3 {
|
||||
text-align: center;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.3rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.coffee-options h3 {
|
||||
text-align: center;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.3rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.coffee-selector {
|
||||
display: flex;
|
||||
gap: 1.2rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.coffee-selector {
|
||||
display: flex;
|
||||
gap: 1.2rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.coffee-option {
|
||||
flex: 1;
|
||||
border: 2px solid transparent;
|
||||
background-color: rgba(var(--border-color-rgb), 0.05);
|
||||
border-radius: 12px;
|
||||
padding: 1.2rem 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.coffee-option {
|
||||
flex: 1;
|
||||
border: 2px solid transparent;
|
||||
background-color: rgba(var(--border-color-rgb), 0.05);
|
||||
border-radius: 12px;
|
||||
padding: 1.2rem 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.coffee-option:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.coffee-option:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.coffee-option.active {
|
||||
border-color: var(--accent-color);
|
||||
background-color: rgba(var(--accent-color-rgb), 0.08);
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.coffee-option.active {
|
||||
border-color: var(--accent-color);
|
||||
background-color: rgba(var(--accent-color-rgb), 0.08);
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.coffee-icon {
|
||||
margin-bottom: 0.8rem;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.coffee-icon {
|
||||
margin-bottom: 0.8rem;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.coffee-option:hover .coffee-icon {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.coffee-option:hover .coffee-icon {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.small-coffee {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.small-coffee {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.medium-coffee {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.medium-coffee {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.large-coffee {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
.large-coffee {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.coffee-details h4 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.coffee-details h4 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.coffee-details p {
|
||||
margin: 0.25rem 0 0 0;
|
||||
font-weight: 600;
|
||||
color: var(--accent-color);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.coffee-details p {
|
||||
margin: 0.25rem 0 0 0;
|
||||
font-weight: 600;
|
||||
color: var(--accent-color);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.payment-methods {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.payment-methods {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.payment-methods p {
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
.payment-methods p {
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.payment-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.payment-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.payment-method-btn {
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(var(--border-color-rgb), 0.15);
|
||||
background-color: var(--background-color);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
font-size: 1.1rem;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.payment-method-btn {
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(var(--border-color-rgb), 0.15);
|
||||
background-color: var(--background-color);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
font-size: 1.1rem;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.payment-method-btn:hover {
|
||||
background-color: var(--hover-bg);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.payment-method-btn:hover {
|
||||
background-color: var(--hover-bg);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.payment-method-btn span {
|
||||
font-weight: 600;
|
||||
}
|
||||
.payment-method-btn span {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#payment-message {
|
||||
color: var(--accent-color);
|
||||
text-align: center;
|
||||
padding: 1.2rem;
|
||||
margin-top: 1rem;
|
||||
border-radius: 12px;
|
||||
display: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
#payment-message {
|
||||
color: var(--accent-color);
|
||||
text-align: center;
|
||||
padding: 1.2rem;
|
||||
margin-top: 1rem;
|
||||
border-radius: 12px;
|
||||
display: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#payment-message.success {
|
||||
background-color: rgba(var(--accent-color-rgb), 0.1);
|
||||
border: 1px solid var(--accent-color);
|
||||
display: block;
|
||||
}
|
||||
#payment-message.success {
|
||||
background-color: rgba(var(--accent-color-rgb), 0.1);
|
||||
border: 1px solid var(--accent-color);
|
||||
display: block;
|
||||
}
|
||||
|
||||
#payment-message.error {
|
||||
background-color: rgba(255, 0, 0, 0.1);
|
||||
border: 1px solid red;
|
||||
color: red;
|
||||
display: block;
|
||||
}
|
||||
#payment-message.error {
|
||||
background-color: rgba(255, 0, 0, 0.1);
|
||||
border: 1px solid red;
|
||||
color: red;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.payment-container {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.payment-container {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.coffee-selector {
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.coffee-selector {
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.coffee-option {
|
||||
padding: 1rem;
|
||||
}
|
||||
.coffee-option {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.payment-type-btn {
|
||||
padding: 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
.payment-type-btn {
|
||||
padding: 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Import Stripe and PayPal libraries
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
// import { loadScript } from "@paypal/paypal-js"; // Wird später benötigt
|
||||
import { trackEvent, EVENTS } from '../scripts/analytics';
|
||||
// Import Stripe and PayPal libraries
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
// import { loadScript } from "@paypal/paypal-js"; // Wird später benötigt
|
||||
import { trackEvent, EVENTS } from '../scripts/analytics';
|
||||
|
||||
// TypeScript-Definitionen für Plausible (legacy)
|
||||
declare global {
|
||||
interface Window {
|
||||
plausible?: (
|
||||
eventName: string,
|
||||
options?: { props?: Record<string, any> }
|
||||
) => void;
|
||||
}
|
||||
}
|
||||
// TypeScript-Definitionen für Plausible (legacy)
|
||||
declare global {
|
||||
interface Window {
|
||||
plausible?: (eventName: string, options?: { props?: Record<string, any> }) => void;
|
||||
}
|
||||
}
|
||||
|
||||
// Interface für Coffee Option Element
|
||||
interface CoffeeOptionElement extends Element {
|
||||
dataset: {
|
||||
price: string;
|
||||
priceIdOnetime: string;
|
||||
priceIdRecurring: string;
|
||||
};
|
||||
}
|
||||
// Interface für Coffee Option Element
|
||||
interface CoffeeOptionElement extends Element {
|
||||
dataset: {
|
||||
price: string;
|
||||
priceIdOnetime: string;
|
||||
priceIdRecurring: string;
|
||||
};
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
const oneTimeBtn = document.getElementById("one-time");
|
||||
const recurringBtn = document.getElementById("recurring");
|
||||
const stripeBtn = document.getElementById("stripe-button");
|
||||
// const paypalBtn = document.getElementById("paypal-button"); // Wird später benötigt
|
||||
const coffeeOptions =
|
||||
document.querySelectorAll<CoffeeOptionElement>(".coffee-option");
|
||||
const paymentMessage = document.getElementById("payment-message");
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const oneTimeBtn = document.getElementById('one-time');
|
||||
const recurringBtn = document.getElementById('recurring');
|
||||
const stripeBtn = document.getElementById('stripe-button');
|
||||
// const paypalBtn = document.getElementById("paypal-button"); // Wird später benötigt
|
||||
const coffeeOptions = document.querySelectorAll<CoffeeOptionElement>('.coffee-option');
|
||||
const paymentMessage = document.getElementById('payment-message');
|
||||
|
||||
if (!paymentMessage) {
|
||||
console.error("Payment message element not found");
|
||||
return;
|
||||
}
|
||||
if (!paymentMessage) {
|
||||
console.error('Payment message element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Kaffeegrößen-Auswahl
|
||||
let selectedCoffeeOption = document.querySelector<CoffeeOptionElement>(
|
||||
".coffee-option.active"
|
||||
);
|
||||
let selectedAmount = Number(selectedCoffeeOption?.dataset.price || 5);
|
||||
let selectedPriceId = selectedCoffeeOption?.dataset.priceIdOnetime;
|
||||
// Kaffeegrößen-Auswahl
|
||||
let selectedCoffeeOption = document.querySelector<CoffeeOptionElement>('.coffee-option.active');
|
||||
let selectedAmount = Number(selectedCoffeeOption?.dataset.price || 5);
|
||||
let selectedPriceId = selectedCoffeeOption?.dataset.priceIdOnetime;
|
||||
|
||||
// Event-Listener für Kaffee-Optionen
|
||||
coffeeOptions.forEach((option) => {
|
||||
option.addEventListener("click", () => {
|
||||
// Vorherige Auswahl zurücksetzen
|
||||
coffeeOptions.forEach((opt) => opt.classList.remove("active"));
|
||||
// Event-Listener für Kaffee-Optionen
|
||||
coffeeOptions.forEach((option) => {
|
||||
option.addEventListener('click', () => {
|
||||
// Vorherige Auswahl zurücksetzen
|
||||
coffeeOptions.forEach((opt) => opt.classList.remove('active'));
|
||||
|
||||
// Neue Auswahl aktivieren
|
||||
option.classList.add("active");
|
||||
selectedCoffeeOption = option;
|
||||
selectedAmount = Number(option.dataset.price);
|
||||
// Neue Auswahl aktivieren
|
||||
option.classList.add('active');
|
||||
selectedCoffeeOption = option;
|
||||
selectedAmount = Number(option.dataset.price);
|
||||
|
||||
// Preis-ID basierend auf dem Zahlungstyp setzen
|
||||
if (recurringBtn?.classList.contains("active")) {
|
||||
selectedPriceId = option.dataset.priceIdRecurring;
|
||||
} else {
|
||||
selectedPriceId = option.dataset.priceIdOnetime;
|
||||
}
|
||||
// Preis-ID basierend auf dem Zahlungstyp setzen
|
||||
if (recurringBtn?.classList.contains('active')) {
|
||||
selectedPriceId = option.dataset.priceIdRecurring;
|
||||
} else {
|
||||
selectedPriceId = option.dataset.priceIdOnetime;
|
||||
}
|
||||
|
||||
// Plausible Event für Kaffeegrößen-Auswahl
|
||||
const coffeeTitle = option.querySelector("h4");
|
||||
if (coffeeTitle) {
|
||||
trackEvent(EVENTS.COFFEE_SELECT, {
|
||||
size: coffeeTitle.textContent,
|
||||
amount: selectedAmount,
|
||||
type: recurringBtn?.classList.contains("active") ? "recurring" : "one-time"
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
// Plausible Event für Kaffeegrößen-Auswahl
|
||||
const coffeeTitle = option.querySelector('h4');
|
||||
if (coffeeTitle) {
|
||||
trackEvent(EVENTS.COFFEE_SELECT, {
|
||||
size: coffeeTitle.textContent,
|
||||
amount: selectedAmount,
|
||||
type: recurringBtn?.classList.contains('active') ? 'recurring' : 'one-time',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Toggle zwischen Zahlungstypen
|
||||
oneTimeBtn?.addEventListener("click", () => {
|
||||
oneTimeBtn.classList.add("active");
|
||||
recurringBtn?.classList.remove("active");
|
||||
// Toggle zwischen Zahlungstypen
|
||||
oneTimeBtn?.addEventListener('click', () => {
|
||||
oneTimeBtn.classList.add('active');
|
||||
recurringBtn?.classList.remove('active');
|
||||
|
||||
// Preis-ID aktualisieren
|
||||
if (selectedCoffeeOption) {
|
||||
selectedPriceId = selectedCoffeeOption.dataset.priceIdOnetime;
|
||||
}
|
||||
// Preis-ID aktualisieren
|
||||
if (selectedCoffeeOption) {
|
||||
selectedPriceId = selectedCoffeeOption.dataset.priceIdOnetime;
|
||||
}
|
||||
|
||||
// Plausible Event für Einmalzahlung
|
||||
trackEvent(EVENTS.PAYMENT_TYPE_CHANGE, {
|
||||
type: "one-time",
|
||||
amount: selectedAmount
|
||||
});
|
||||
});
|
||||
// Plausible Event für Einmalzahlung
|
||||
trackEvent(EVENTS.PAYMENT_TYPE_CHANGE, {
|
||||
type: 'one-time',
|
||||
amount: selectedAmount,
|
||||
});
|
||||
});
|
||||
|
||||
recurringBtn?.addEventListener("click", () => {
|
||||
recurringBtn.classList.add("active");
|
||||
oneTimeBtn?.classList.remove("active");
|
||||
recurringBtn?.addEventListener('click', () => {
|
||||
recurringBtn.classList.add('active');
|
||||
oneTimeBtn?.classList.remove('active');
|
||||
|
||||
// Preis-ID aktualisieren
|
||||
if (selectedCoffeeOption) {
|
||||
selectedPriceId = selectedCoffeeOption.dataset.priceIdRecurring;
|
||||
}
|
||||
// Preis-ID aktualisieren
|
||||
if (selectedCoffeeOption) {
|
||||
selectedPriceId = selectedCoffeeOption.dataset.priceIdRecurring;
|
||||
}
|
||||
|
||||
// Plausible Event für wiederkehrende Zahlung
|
||||
trackEvent(EVENTS.PAYMENT_TYPE_CHANGE, {
|
||||
type: "recurring",
|
||||
amount: selectedAmount
|
||||
});
|
||||
});
|
||||
// Plausible Event für wiederkehrende Zahlung
|
||||
trackEvent(EVENTS.PAYMENT_TYPE_CHANGE, {
|
||||
type: 'recurring',
|
||||
amount: selectedAmount,
|
||||
});
|
||||
});
|
||||
|
||||
// Dieser Stripe Key wird aus den Netlify Umgebungsvariablen geladen
|
||||
const stripePromise = loadStripe(
|
||||
import.meta.env.PUBLIC_STRIPE_PUBLISHABLE_KEY || "pk_test_placeholder"
|
||||
);
|
||||
// Dieser Stripe Key wird aus den Netlify Umgebungsvariablen geladen
|
||||
const stripePromise = loadStripe(
|
||||
import.meta.env.PUBLIC_STRIPE_PUBLISHABLE_KEY || 'pk_test_placeholder'
|
||||
);
|
||||
|
||||
// Stripe payment processing
|
||||
stripeBtn?.addEventListener("click", async () => {
|
||||
const amount = selectedAmount;
|
||||
const isRecurring = recurringBtn?.classList.contains("active");
|
||||
const priceId = selectedPriceId;
|
||||
// Stripe payment processing
|
||||
stripeBtn?.addEventListener('click', async () => {
|
||||
const amount = selectedAmount;
|
||||
const isRecurring = recurringBtn?.classList.contains('active');
|
||||
const priceId = selectedPriceId;
|
||||
|
||||
paymentMessage.textContent = "Verarbeite Zahlung...";
|
||||
paymentMessage.classList.remove("success", "error");
|
||||
paymentMessage.style.display = "block";
|
||||
paymentMessage.textContent = 'Verarbeite Zahlung...';
|
||||
paymentMessage.classList.remove('success', 'error');
|
||||
paymentMessage.style.display = 'block';
|
||||
|
||||
// Plausible Event für Stripe-Zahlungsstart
|
||||
const coffeeTitle = selectedCoffeeOption?.querySelector("h4");
|
||||
trackEvent(EVENTS.CHECKOUT_START, {
|
||||
provider: "stripe",
|
||||
amount: amount,
|
||||
type: isRecurring ? "recurring" : "one-time",
|
||||
coffeeSize: coffeeTitle?.textContent || "Mittlerer Kaffee",
|
||||
priceId: selectedPriceId
|
||||
});
|
||||
// Plausible Event für Stripe-Zahlungsstart
|
||||
const coffeeTitle = selectedCoffeeOption?.querySelector('h4');
|
||||
trackEvent(EVENTS.CHECKOUT_START, {
|
||||
provider: 'stripe',
|
||||
amount: amount,
|
||||
type: isRecurring ? 'recurring' : 'one-time',
|
||||
coffeeSize: coffeeTitle?.textContent || 'Mittlerer Kaffee',
|
||||
priceId: selectedPriceId,
|
||||
});
|
||||
|
||||
try {
|
||||
// Create a payment intent on the server using Netlify Functions
|
||||
const response = await fetch(
|
||||
"/.netlify/functions/create-payment-intent",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount,
|
||||
isRecurring,
|
||||
priceId,
|
||||
coffeeSize: coffeeTitle?.textContent || "Mittlerer Kaffee",
|
||||
}),
|
||||
}
|
||||
);
|
||||
try {
|
||||
// Create a payment intent on the server using Netlify Functions
|
||||
const response = await fetch('/.netlify/functions/create-payment-intent', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount,
|
||||
isRecurring,
|
||||
priceId,
|
||||
coffeeSize: coffeeTitle?.textContent || 'Mittlerer Kaffee',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Netzwerkantwort nicht ok");
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error('Netzwerkantwort nicht ok');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = await response.json();
|
||||
|
||||
console.log("Payment response data:", data);
|
||||
console.log('Payment response data:', data);
|
||||
|
||||
// Option 1: Direkt zur Stripe URL weiterleiten
|
||||
if (data.url) {
|
||||
console.log("Redirecting to checkout URL:", data.url);
|
||||
// Kurze Verzögerung für UX
|
||||
setTimeout(() => {
|
||||
window.location.href = data.url;
|
||||
}, 500);
|
||||
return; // Wichtig: Return hier, damit der Rest nicht ausgeführt wird
|
||||
}
|
||||
// Option 1: Direkt zur Stripe URL weiterleiten
|
||||
if (data.url) {
|
||||
console.log('Redirecting to checkout URL:', data.url);
|
||||
// Kurze Verzögerung für UX
|
||||
setTimeout(() => {
|
||||
window.location.href = data.url;
|
||||
}, 500);
|
||||
return; // Wichtig: Return hier, damit der Rest nicht ausgeführt wird
|
||||
}
|
||||
|
||||
// Wenn keine URL vorhanden ist, versuchen wir es mit redirectToCheckout
|
||||
if (data.sessionId || data.id) {
|
||||
console.log(
|
||||
"Using redirectToCheckout with session ID:",
|
||||
data.sessionId || data.id
|
||||
);
|
||||
// Wenn keine URL vorhanden ist, versuchen wir es mit redirectToCheckout
|
||||
if (data.sessionId || data.id) {
|
||||
console.log('Using redirectToCheckout with session ID:', data.sessionId || data.id);
|
||||
|
||||
// Initialisiere Stripe
|
||||
const stripe = await stripePromise;
|
||||
if (!stripe) {
|
||||
throw new Error("Stripe konnte nicht initialisiert werden");
|
||||
}
|
||||
// Initialisiere Stripe
|
||||
const stripe = await stripePromise;
|
||||
if (!stripe) {
|
||||
throw new Error('Stripe konnte nicht initialisiert werden');
|
||||
}
|
||||
|
||||
const { error } = await stripe.redirectToCheckout({
|
||||
sessionId: data.sessionId || data.id,
|
||||
});
|
||||
const { error } = await stripe.redirectToCheckout({
|
||||
sessionId: data.sessionId || data.id,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("Checkout redirect error:", error);
|
||||
throw new Error(error.message);
|
||||
}
|
||||
} else {
|
||||
throw new Error("Keine Checkout-URL oder Session-ID erhalten");
|
||||
}
|
||||
if (error) {
|
||||
console.error('Checkout redirect error:', error);
|
||||
throw new Error(error.message);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Keine Checkout-URL oder Session-ID erhalten');
|
||||
}
|
||||
|
||||
// Erfolgreiche Zahlung
|
||||
paymentMessage.textContent = `Zahlung über ${amount}€ ${isRecurring ? "(regelmäßig)" : "(einmalig)"} erfolgreich!`;
|
||||
paymentMessage.classList.add("success");
|
||||
paymentMessage.classList.remove("error");
|
||||
} catch (error: unknown) {
|
||||
console.error("Zahlungsfehler:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
paymentMessage.textContent = `Fehler: ${errorMessage}`;
|
||||
paymentMessage.classList.add("error");
|
||||
paymentMessage.classList.remove("success");
|
||||
// Erfolgreiche Zahlung
|
||||
paymentMessage.textContent = `Zahlung über ${amount}€ ${isRecurring ? '(regelmäßig)' : '(einmalig)'} erfolgreich!`;
|
||||
paymentMessage.classList.add('success');
|
||||
paymentMessage.classList.remove('error');
|
||||
} catch (error: unknown) {
|
||||
console.error('Zahlungsfehler:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
paymentMessage.textContent = `Fehler: ${errorMessage}`;
|
||||
paymentMessage.classList.add('error');
|
||||
paymentMessage.classList.remove('success');
|
||||
|
||||
// Plausible Event für Zahlungsfehler
|
||||
trackEvent('payment-error', {
|
||||
provider: "stripe",
|
||||
error: errorMessage
|
||||
});
|
||||
}
|
||||
});
|
||||
// Plausible Event für Zahlungsfehler
|
||||
trackEvent('payment-error', {
|
||||
provider: 'stripe',
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// PayPal payment processing wird später implementiert
|
||||
/*
|
||||
// PayPal payment processing wird später implementiert
|
||||
/*
|
||||
paypalBtn?.addEventListener("click", async () => {
|
||||
const amount = selectedAmount;
|
||||
const isRecurring = recurringBtn?.classList.contains("active");
|
||||
|
|
@ -619,5 +602,5 @@ const t = useTranslations(lang);
|
|||
}
|
||||
});
|
||||
*/
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { getLangFromUrl, useTranslations } from '../utils/i18n';
|
|||
import type { CollectionEntry } from 'astro:content';
|
||||
|
||||
interface Props {
|
||||
project: CollectionEntry<'projects'>;
|
||||
project: CollectionEntry<'projects'>;
|
||||
}
|
||||
|
||||
const { project } = Astro.props;
|
||||
|
|
@ -13,11 +13,14 @@ const lang = getLangFromUrl(Astro.url);
|
|||
const t = useTranslations(lang);
|
||||
|
||||
// Format date based on language
|
||||
const formattedDate = new Intl.DateTimeFormat(lang === 'de' ? 'de-DE' : lang === 'it' ? 'it-IT' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}).format(data.pubDate);
|
||||
const formattedDate = new Intl.DateTimeFormat(
|
||||
lang === 'de' ? 'de-DE' : lang === 'it' ? 'it-IT' : 'en-US',
|
||||
{
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}
|
||||
).format(data.pubDate);
|
||||
|
||||
// Generate project URL with language prefix
|
||||
let projectUrl;
|
||||
|
|
@ -31,182 +34,193 @@ const statusKey = `projects.status.${data.status}` as const;
|
|||
---
|
||||
|
||||
<a href={projectUrl} class="project-card-link">
|
||||
<article class:list={["project-card", { "featured": data.featured }]}>
|
||||
{data.image && (
|
||||
<div class="project-image-container">
|
||||
<img src={data.image} alt={data.title} class="project-image" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="content">
|
||||
<div class="meta">
|
||||
<span class="category">{t(categoryKey)}</span>
|
||||
<span class="status" class:list={[data.status]}>{t(statusKey)}</span>
|
||||
</div>
|
||||
|
||||
<h3>{data.title}</h3>
|
||||
<p>{data.description}</p>
|
||||
|
||||
{data.technologies && data.technologies.length > 0 && (
|
||||
<div class="technologies">
|
||||
{data.technologies.map(tech => (
|
||||
<span class="tech">{tech}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="footer">
|
||||
<span class="author">{data.author}</span>
|
||||
<span class="read-more">{t('projects.readMore')} <span class="arrow">→</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class:list={['project-card', { featured: data.featured }]}>
|
||||
{
|
||||
data.image && (
|
||||
<div class="project-image-container">
|
||||
<img src={data.image} alt={data.title} class="project-image" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="content">
|
||||
<div class="meta">
|
||||
<span class="category">{t(categoryKey)}</span>
|
||||
<span class="status" class:list={[data.status]}>{t(statusKey)}</span>
|
||||
</div>
|
||||
|
||||
<h3>{data.title}</h3>
|
||||
<p>{data.description}</p>
|
||||
|
||||
{
|
||||
data.technologies && data.technologies.length > 0 && (
|
||||
<div class="technologies">
|
||||
{data.technologies.map((tech) => (
|
||||
<span class="tech">{tech}</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="footer">
|
||||
<span class="author">{data.author}</span>
|
||||
<span class="read-more">{t('projects.readMore')} <span class="arrow">→</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.project-card-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background-color: var(--card-bg);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.03);
|
||||
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.project-card-link:hover .project-card {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.5);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.project-card.featured {
|
||||
border: 2px solid rgba(var(--accent-color-rgb, 249, 115, 22), 0.3);
|
||||
}
|
||||
|
||||
.project-image-container {
|
||||
width: 100%;
|
||||
padding-top: 56.25%; /* Aspect ratio 16:9 */
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.project-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.category {
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status.active {
|
||||
background-color: rgba(16, 185, 129, 0.2);
|
||||
color: rgb(16, 185, 129);
|
||||
}
|
||||
|
||||
.status.completed {
|
||||
background-color: rgba(79, 70, 229, 0.2);
|
||||
color: rgb(79, 70, 229);
|
||||
}
|
||||
|
||||
.status.archived {
|
||||
background-color: rgba(107, 114, 128, 0.2);
|
||||
color: rgb(107, 114, 128);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
flex-grow: 1;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.technologies {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tech {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.1);
|
||||
color: var(--accent-color);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.author {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.read-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.project-card-link:hover .arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
</style>
|
||||
.project-card-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background-color: var(--card-bg);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06),
|
||||
0 1px 2px -1px rgba(0, 0, 0, 0.03);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.project-card-link:hover .project-card {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.5);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.08),
|
||||
0 2px 4px -2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.project-card.featured {
|
||||
border: 2px solid rgba(var(--accent-color-rgb, 249, 115, 22), 0.3);
|
||||
}
|
||||
|
||||
.project-image-container {
|
||||
width: 100%;
|
||||
padding-top: 56.25%; /* Aspect ratio 16:9 */
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.project-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.category {
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status.active {
|
||||
background-color: rgba(16, 185, 129, 0.2);
|
||||
color: rgb(16, 185, 129);
|
||||
}
|
||||
|
||||
.status.completed {
|
||||
background-color: rgba(79, 70, 229, 0.2);
|
||||
color: rgb(79, 70, 229);
|
||||
}
|
||||
|
||||
.status.archived {
|
||||
background-color: rgba(107, 114, 128, 0.2);
|
||||
color: rgb(107, 114, 128);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
flex-grow: 1;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.technologies {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tech {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.1);
|
||||
color: var(--accent-color);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.author {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.read-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.project-card-link:hover .arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -4,9 +4,9 @@ import { getCollection } from 'astro:content';
|
|||
import type { CollectionEntry } from 'astro:content';
|
||||
|
||||
interface Props {
|
||||
maxMembers?: number;
|
||||
showLinks?: boolean;
|
||||
compact?: boolean;
|
||||
maxMembers?: number;
|
||||
showLinks?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const { maxMembers = 5, showLinks = true, compact = false } = Astro.props;
|
||||
|
|
@ -17,187 +17,267 @@ const t = useTranslations(lang);
|
|||
|
||||
// Get members in current language
|
||||
const allMembers = await getCollection('members', ({ id }) => {
|
||||
return id.startsWith(lang + '/');
|
||||
return id.startsWith(lang + '/');
|
||||
});
|
||||
|
||||
// Sort by order, then by name
|
||||
const sortedMembers = allMembers
|
||||
.sort((a, b) => a.data.order - b.data.order || a.data.name.localeCompare(b.data.name))
|
||||
.filter((member, index) => index < maxMembers);
|
||||
.sort((a, b) => a.data.order - b.data.order || a.data.name.localeCompare(b.data.name))
|
||||
.filter((member, index) => index < maxMembers);
|
||||
---
|
||||
|
||||
<div class:list={["team-members", { compact }]}>
|
||||
{sortedMembers.map((member) => (
|
||||
<div class="member-card">
|
||||
<div class="member-avatar">
|
||||
{member.data.image ? (
|
||||
<img src={member.data.image} alt={member.data.name} />
|
||||
) : (
|
||||
<div class="avatar-placeholder">{member.data.name.charAt(0)}</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="member-info">
|
||||
<h3 class="member-name">{member.data.name}</h3>
|
||||
<p class="member-role">{member.data.role}</p>
|
||||
|
||||
{!compact && <p class="member-bio">{member.data.bio}</p>}
|
||||
|
||||
{showLinks && (
|
||||
<div class="member-links">
|
||||
{member.data.github && (
|
||||
<a href={`https://github.com/${member.data.github}`} target="_blank" rel="noopener noreferrer" class="social-link github" title="GitHub">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"></path></svg>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{member.data.twitter && (
|
||||
<a href={`https://twitter.com/${member.data.twitter}`} target="_blank" rel="noopener noreferrer" class="social-link twitter" title="Twitter">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 3a10.9 10.9 0 0 1-3.14 1.53 4.48 4.48 0 0 0-7.86 3v1A10.66 10.66 0 0 1 3 4s-4 9 5 13a11.64 11.64 0 0 1-7 2c9 5 20 0 20-11.5a4.5 4.5 0 0 0-.08-.83A7.72 7.72 0 0 0 23 3z"></path></svg>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{member.data.linkedin && (
|
||||
<a href={`https://linkedin.com/in/${member.data.linkedin}`} target="_blank" rel="noopener noreferrer" class="social-link linkedin" title="LinkedIn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"></path><rect x="2" y="9" width="4" height="12"></rect><circle cx="4" cy="4" r="2"></circle></svg>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{member.data.website && (
|
||||
<a href={member.data.website} target="_blank" rel="noopener noreferrer" class="social-link website" title="Website">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div class:list={['team-members', { compact }]}>
|
||||
{
|
||||
sortedMembers.map((member) => (
|
||||
<div class="member-card">
|
||||
<div class="member-avatar">
|
||||
{member.data.image ? (
|
||||
<img src={member.data.image} alt={member.data.name} />
|
||||
) : (
|
||||
<div class="avatar-placeholder">{member.data.name.charAt(0)}</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="member-info">
|
||||
<h3 class="member-name">{member.data.name}</h3>
|
||||
<p class="member-role">{member.data.role}</p>
|
||||
|
||||
{!compact && <p class="member-bio">{member.data.bio}</p>}
|
||||
|
||||
{showLinks && (
|
||||
<div class="member-links">
|
||||
{member.data.github && (
|
||||
<a
|
||||
href={`https://github.com/${member.data.github}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="social-link github"
|
||||
title="GitHub"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{member.data.twitter && (
|
||||
<a
|
||||
href={`https://twitter.com/${member.data.twitter}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="social-link twitter"
|
||||
title="Twitter"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M23 3a10.9 10.9 0 0 1-3.14 1.53 4.48 4.48 0 0 0-7.86 3v1A10.66 10.66 0 0 1 3 4s-4 9 5 13a11.64 11.64 0 0 1-7 2c9 5 20 0 20-11.5a4.5 4.5 0 0 0-.08-.83A7.72 7.72 0 0 0 23 3z" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{member.data.linkedin && (
|
||||
<a
|
||||
href={`https://linkedin.com/in/${member.data.linkedin}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="social-link linkedin"
|
||||
title="LinkedIn"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" />
|
||||
<rect x="2" y="9" width="4" height="12" />
|
||||
<circle cx="4" cy="4" r="2" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{member.data.website && (
|
||||
<a
|
||||
href={member.data.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="social-link website"
|
||||
title="Website"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="2" y1="12" x2="22" y2="12" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.team-members {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.team-members.compact {
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.member-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
background-color: var(--card-bg);
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.member-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.2);
|
||||
}
|
||||
|
||||
.member-avatar {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.compact .member-avatar {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
.member-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-color);
|
||||
background-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.1);
|
||||
}
|
||||
|
||||
.member-info {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0.5rem 0 0.25rem;
|
||||
}
|
||||
|
||||
.compact .member-name {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.member-role {
|
||||
font-size: 0.9rem;
|
||||
color: var(--accent-color);
|
||||
margin: 0 0 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.compact .member-role {
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.member-bio {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.member-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.social-link {
|
||||
color: var(--text-muted);
|
||||
transition: color 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.social-link:hover {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.team-members {
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
}
|
||||
|
||||
.team-members.compact {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
.team-members {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.team-members.compact {
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.member-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
background-color: var(--card-bg);
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.member-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.2);
|
||||
}
|
||||
|
||||
.member-avatar {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.compact .member-avatar {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
.member-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-color);
|
||||
background-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.1);
|
||||
}
|
||||
|
||||
.member-info {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0.5rem 0 0.25rem;
|
||||
}
|
||||
|
||||
.compact .member-name {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.member-role {
|
||||
font-size: 0.9rem;
|
||||
color: var(--accent-color);
|
||||
margin: 0 0 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.compact .member-role {
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.member-bio {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.member-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.social-link {
|
||||
color: var(--text-muted);
|
||||
transition: color 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.social-link:hover {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.team-members {
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
}
|
||||
|
||||
.team-members.compact {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -6,86 +6,94 @@ const t = useTranslations(lang);
|
|||
---
|
||||
|
||||
<button id="themeToggle" aria-label={t('nav.darkMode')}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24">
|
||||
<path
|
||||
class="sun"
|
||||
fill-rule="evenodd"
|
||||
d="M12 17.5a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zm0 1.5a7 7 0 1 0 0-14 7 7 0 0 0 0 14zm12-7a.8.8 0 0 1-.8.8h-2.4a.8.8 0 0 1 0-1.6h2.4a.8.8 0 0 1 .8.8zM4 12a.8.8 0 0 1-.8.8H.8a.8.8 0 0 1 0-1.6h2.5a.8.8 0 0 1 .8.8zm16.5-8.5a.8.8 0 0 1 0 1l-1.8 1.8a.8.8 0 0 1-1-1l1.7-1.8a.8.8 0 0 1 1.1 0zM6.3 17.7a.8.8 0 0 1 0 1l-1.7 1.8a.8.8 0 1 1-1.1-1l1.7-1.8a.8.8 0 0 1 1.1 0zM12 0a.8.8 0 0 1 .8.8v2.5a.8.8 0 0 1-1.6 0V.8A.8.8 0 0 1 12 0zm0 20a.8.8 0 0 1 .8.8v2.4a.8.8 0 0 1-1.6 0v-2.4a.8.8 0 0 1 .8-.8zM3.5 3.5a.8.8 0 0 1 1 0l1.8 1.8a.8.8 0 1 1-1 1L3.5 4.6a.8.8 0 0 1 0-1.1zm14.2 14.2a.8.8 0 0 1 1 0l1.8 1.7a.8.8 0 0 1-1 1l-1.8-1.7a.8.8 0 0 1 0-1z"
|
||||
></path>
|
||||
<path
|
||||
class="moon"
|
||||
fill-rule="evenodd"
|
||||
d="M16.5 6A10.5 10.5 0 0 1 4.7 16.4 8.5 8.5 0 1 0 16.4 4.7l.1 1.3zm-1.7-2a9 9 0 0 1 .2 2 9 9 0 0 1-11 8.8 9.4 9.4 0 0 1-.8-.3c-.4 0-.8.3-.7.7a10 10 0 0 0 .3.8 10 10 0 0 0 9.2 6 10 10 0 0 0 4-19.2 9.7 9.7 0 0 0-.9-.3c-.3-.1-.7.3-.6.7a9 9 0 0 1 .3.8z"
|
||||
></path>
|
||||
</svg>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24">
|
||||
<path
|
||||
class="sun"
|
||||
fill-rule="evenodd"
|
||||
d="M12 17.5a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zm0 1.5a7 7 0 1 0 0-14 7 7 0 0 0 0 14zm12-7a.8.8 0 0 1-.8.8h-2.4a.8.8 0 0 1 0-1.6h2.4a.8.8 0 0 1 .8.8zM4 12a.8.8 0 0 1-.8.8H.8a.8.8 0 0 1 0-1.6h2.5a.8.8 0 0 1 .8.8zm16.5-8.5a.8.8 0 0 1 0 1l-1.8 1.8a.8.8 0 0 1-1-1l1.7-1.8a.8.8 0 0 1 1.1 0zM6.3 17.7a.8.8 0 0 1 0 1l-1.7 1.8a.8.8 0 1 1-1.1-1l1.7-1.8a.8.8 0 0 1 1.1 0zM12 0a.8.8 0 0 1 .8.8v2.5a.8.8 0 0 1-1.6 0V.8A.8.8 0 0 1 12 0zm0 20a.8.8 0 0 1 .8.8v2.4a.8.8 0 0 1-1.6 0v-2.4a.8.8 0 0 1 .8-.8zM3.5 3.5a.8.8 0 0 1 1 0l1.8 1.8a.8.8 0 1 1-1 1L3.5 4.6a.8.8 0 0 1 0-1.1zm14.2 14.2a.8.8 0 0 1 1 0l1.8 1.7a.8.8 0 0 1-1 1l-1.8-1.7a.8.8 0 0 1 0-1z"
|
||||
></path>
|
||||
<path
|
||||
class="moon"
|
||||
fill-rule="evenodd"
|
||||
d="M16.5 6A10.5 10.5 0 0 1 4.7 16.4 8.5 8.5 0 1 0 16.4 4.7l.1 1.3zm-1.7-2a9 9 0 0 1 .2 2 9 9 0 0 1-11 8.8 9.4 9.4 0 0 1-.8-.3c-.4 0-.8.3-.7.7a10 10 0 0 0 .3.8 10 10 0 0 0 9.2 6 10 10 0 0 0 4-19.2 9.7 9.7 0 0 0-.9-.3c-.3-.1-.7.3-.6.7a9 9 0 0 1 .3.8z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
#themeToggle {
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#themeToggle:hover {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
#themeToggle {
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sun { fill: var(--text-color); }
|
||||
.moon { fill: transparent; }
|
||||
|
||||
:global(.dark) .sun { fill: transparent; }
|
||||
:global(.dark) .moon { fill: var(--text-color); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#themeToggle {
|
||||
padding: 0.75rem;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
#themeToggle::after {
|
||||
content: attr(aria-label);
|
||||
margin-left: 0.75rem;
|
||||
font-size: 1rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
}
|
||||
#themeToggle:hover {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.sun {
|
||||
fill: var(--text-color);
|
||||
}
|
||||
.moon {
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
:global(.dark) .sun {
|
||||
fill: transparent;
|
||||
}
|
||||
:global(.dark) .moon {
|
||||
fill: var(--text-color);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#themeToggle {
|
||||
padding: 0.75rem;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
#themeToggle::after {
|
||||
content: attr(aria-label);
|
||||
margin-left: 0.75rem;
|
||||
font-size: 1rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const theme = (() => {
|
||||
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
|
||||
return localStorage.getItem('theme');
|
||||
}
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark';
|
||||
}
|
||||
return 'light';
|
||||
})();
|
||||
|
||||
if (theme === 'light') {
|
||||
document.documentElement.classList.remove('dark');
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
const theme = (() => {
|
||||
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
|
||||
return localStorage.getItem('theme');
|
||||
}
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark';
|
||||
}
|
||||
return 'light';
|
||||
})();
|
||||
|
||||
window.localStorage.setItem('theme', theme);
|
||||
if (theme === 'light') {
|
||||
document.documentElement.classList.remove('dark');
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
|
||||
const handleToggleClick = () => {
|
||||
const element = document.documentElement;
|
||||
element.classList.toggle('dark');
|
||||
|
||||
const isDark = element.classList.contains('dark');
|
||||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||||
};
|
||||
window.localStorage.setItem('theme', theme);
|
||||
|
||||
document.getElementById('themeToggle').addEventListener('click', handleToggleClick);
|
||||
</script>
|
||||
const handleToggleClick = () => {
|
||||
const element = document.documentElement;
|
||||
element.classList.toggle('dark');
|
||||
|
||||
const isDark = element.classList.contains('dark');
|
||||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
document.getElementById('themeToggle').addEventListener('click', handleToggleClick);
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
---
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import { getLangFromUrl, useTranslations } from "../utils/i18n";
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { getLangFromUrl, useTranslations } from '../utils/i18n';
|
||||
|
||||
interface Props {
|
||||
tool: CollectionEntry<"tools">;
|
||||
isFeatured?: boolean;
|
||||
tool: CollectionEntry<'tools'>;
|
||||
isFeatured?: boolean;
|
||||
}
|
||||
|
||||
const { tool, isFeatured = false } = Astro.props;
|
||||
|
|
@ -13,7 +13,7 @@ const lang = getLangFromUrl(Astro.url);
|
|||
const t = useTranslations(lang);
|
||||
|
||||
// Generate URL based on lang and only the filename part of the slug
|
||||
const slugParts = slug.split("/");
|
||||
const slugParts = slug.split('/');
|
||||
const fileName = slugParts[slugParts.length - 1];
|
||||
const url = `/${lang}/tools/${fileName}`;
|
||||
|
||||
|
|
@ -22,138 +22,141 @@ const categoryKey = `tools.categories.${data.category}` as const;
|
|||
const pricingKey = `tools.pricing.${data.pricing}` as const;
|
||||
|
||||
// Default image if none is provided
|
||||
const imageSrc = data.image ?? "/images/bauntown-codespilling.png";
|
||||
const imageSrc = data.image ?? '/images/bauntown-codespilling.png';
|
||||
---
|
||||
|
||||
<article class={`tool-card ${isFeatured ? "featured" : ""}`}>
|
||||
<a href={url} class="card-link">
|
||||
<div class="card-image-container">
|
||||
<img src={imageSrc} alt={data.title} class="card-image" loading="lazy" />
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-meta">
|
||||
<span class="card-category">{t(categoryKey)}</span>
|
||||
<span class="card-pricing">{t(pricingKey)}</span>
|
||||
</div>
|
||||
<h3 class="card-title">{data.title}</h3>
|
||||
<p class="card-description">{data.description}</p>
|
||||
</div>
|
||||
</a>
|
||||
<article class={`tool-card ${isFeatured ? 'featured' : ''}`}>
|
||||
<a href={url} class="card-link">
|
||||
<div class="card-image-container">
|
||||
<img src={imageSrc} alt={data.title} class="card-image" loading="lazy" />
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-meta">
|
||||
<span class="card-category">{t(categoryKey)}</span>
|
||||
<span class="card-pricing">{t(pricingKey)}</span>
|
||||
</div>
|
||||
<h3 class="card-title">{data.title}</h3>
|
||||
<p class="card-description">{data.description}</p>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.tool-card {
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background-color: var(--card-bg);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.tool-card {
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background-color: var(--card-bg);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.tool-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 30px -15px var(--shadow-color);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.tool-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 30px -15px var(--shadow-color);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.tool-card.featured {
|
||||
border: 1px solid var(--accent-color);
|
||||
}
|
||||
.tool-card.featured {
|
||||
border: 1px solid var(--accent-color);
|
||||
}
|
||||
|
||||
.card-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
.card-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.card-image-container {
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-image-container {
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.card-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
.card-content {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.card-meta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-category, .card-pricing {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.card-category,
|
||||
.card-pricing {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-category {
|
||||
background-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.1);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
.card-category {
|
||||
background-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.1);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.card-pricing {
|
||||
background-color: var(--surface-bg);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.card-pricing {
|
||||
background-color: var(--surface-bg);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
flex-grow: 1;
|
||||
line-height: 1.6;
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-description {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
flex-grow: 1;
|
||||
line-height: 1.6;
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.card-footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.read-more {
|
||||
color: var(--accent-color);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.read-more {
|
||||
color: var(--accent-color);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tool-card:hover .read-more {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.tool-card:hover .read-more {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.card-image-container {
|
||||
aspect-ratio: 3 / 2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@media (max-width: 768px) {
|
||||
.card-image-container {
|
||||
aspect-ratio: 3 / 2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { getLangFromUrl, useTranslations } from '../utils/i18n';
|
|||
import type { CollectionEntry } from 'astro:content';
|
||||
|
||||
interface Props {
|
||||
tutorial: CollectionEntry<'tutorials'>;
|
||||
tutorial: CollectionEntry<'tutorials'>;
|
||||
}
|
||||
|
||||
const { tutorial } = Astro.props;
|
||||
|
|
@ -14,9 +14,9 @@ const t = useTranslations(lang);
|
|||
|
||||
// Format date based on language
|
||||
const formattedDate = new Intl.DateTimeFormat(lang === 'de' ? 'de-DE' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(data.pubDate);
|
||||
|
||||
// Generate tutorial URL with language prefix
|
||||
|
|
@ -31,141 +31,150 @@ const categoryKey = `tutorials.categories.${data.category}` as const;
|
|||
---
|
||||
|
||||
<a href={tutorialUrl} class="tutorial-card-link">
|
||||
<article class:list={["tutorial-card", { "featured": data.featured }]}>
|
||||
{data.image && (
|
||||
<div class="tutorial-image-container">
|
||||
<img src={data.image} alt={data.title} class="tutorial-image" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="content">
|
||||
<div class="meta">
|
||||
<span class="category">{t(categoryKey)}</span>
|
||||
<span class="date">{formattedDate}</span>
|
||||
</div>
|
||||
|
||||
<h3>{data.title}</h3>
|
||||
<p>{data.description}</p>
|
||||
|
||||
<div class="footer">
|
||||
<span class="author">{data.author}</span>
|
||||
<span class="read-more">{t('tutorials.readMore')} <span class="arrow">→</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class:list={['tutorial-card', { featured: data.featured }]}>
|
||||
{
|
||||
data.image && (
|
||||
<div class="tutorial-image-container">
|
||||
<img src={data.image} alt={data.title} class="tutorial-image" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="content">
|
||||
<div class="meta">
|
||||
<span class="category">{t(categoryKey)}</span>
|
||||
<span class="date">{formattedDate}</span>
|
||||
</div>
|
||||
|
||||
<h3>{data.title}</h3>
|
||||
<p>{data.description}</p>
|
||||
|
||||
<div class="footer">
|
||||
<span class="author">{data.author}</span>
|
||||
<span class="read-more">{t('tutorials.readMore')} <span class="arrow">→</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.tutorial-card-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tutorial-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background-color: var(--card-bg);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.03);
|
||||
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tutorial-card-link:hover .tutorial-card {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.5);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.tutorial-card.featured {
|
||||
border: 2px solid rgba(var(--accent-color-rgb, 249, 115, 22), 0.3);
|
||||
}
|
||||
|
||||
.tutorial-image-container {
|
||||
width: 100%;
|
||||
padding-top: 66.67%; /* Aspect ratio 3:2 */
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tutorial-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.category {
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.date {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
flex-grow: 1;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.author {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.read-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.tutorial-card-link:hover .arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
</style>
|
||||
.tutorial-card-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tutorial-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background-color: var(--card-bg);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06),
|
||||
0 1px 2px -1px rgba(0, 0, 0, 0.03);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tutorial-card-link:hover .tutorial-card {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.5);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.08),
|
||||
0 2px 4px -2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.tutorial-card.featured {
|
||||
border: 2px solid rgba(var(--accent-color-rgb, 249, 115, 22), 0.3);
|
||||
}
|
||||
|
||||
.tutorial-image-container {
|
||||
width: 100%;
|
||||
padding-top: 66.67%; /* Aspect ratio 3:2 */
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tutorial-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.category {
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.date {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
flex-grow: 1;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.author {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.read-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.tutorial-card-link:hover .arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { getLangFromUrl, useTranslations } from '../utils/i18n';
|
|||
import type { CollectionEntry } from 'astro:content';
|
||||
|
||||
interface Props {
|
||||
vision: CollectionEntry<'vision'>;
|
||||
vision: CollectionEntry<'vision'>;
|
||||
}
|
||||
|
||||
const { vision } = Astro.props;
|
||||
|
|
@ -14,9 +14,9 @@ const t = useTranslations(lang);
|
|||
|
||||
// Format date based on language
|
||||
const formattedDate = new Intl.DateTimeFormat(lang === 'de' ? 'de-DE' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(data.pubDate);
|
||||
|
||||
// Generate vision URL with language prefix
|
||||
|
|
@ -32,233 +32,244 @@ const statusKey = `vision.status.${data.status}` as const;
|
|||
---
|
||||
|
||||
<a href={visionUrl} class="vision-card-link">
|
||||
<article class:list={["vision-card", { "featured": data.featured }]}>
|
||||
{data.image && (
|
||||
<div class="vision-image-container">
|
||||
<img src={data.image} alt={data.title} class="vision-image" />
|
||||
<div class="status-badge" data-status={data.status}>
|
||||
{t(statusKey)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="content">
|
||||
<div class="meta">
|
||||
<span class="category" data-category={data.category}>{t(categoryKey)}</span>
|
||||
<span class="date">{formattedDate}</span>
|
||||
</div>
|
||||
|
||||
<h3>{data.title}</h3>
|
||||
<p>{data.description}</p>
|
||||
|
||||
<div class="details">
|
||||
<div class="timeline">
|
||||
<span class="timeline-icon">⏱️</span>
|
||||
<span class="timeline-text">{data.timeline}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
{data.contributors && data.contributors.length > 0 && (
|
||||
<div class="contributors">
|
||||
<span class="contributors-count">{data.contributors.length}</span>
|
||||
</div>
|
||||
)}
|
||||
<span class="read-more">{t('vision.readMore')} <span class="arrow">→</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class:list={['vision-card', { featured: data.featured }]}>
|
||||
{
|
||||
data.image && (
|
||||
<div class="vision-image-container">
|
||||
<img src={data.image} alt={data.title} class="vision-image" />
|
||||
<div class="status-badge" data-status={data.status}>
|
||||
{t(statusKey)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="content">
|
||||
<div class="meta">
|
||||
<span class="category" data-category={data.category}>{t(categoryKey)}</span>
|
||||
<span class="date">{formattedDate}</span>
|
||||
</div>
|
||||
|
||||
<h3>{data.title}</h3>
|
||||
<p>{data.description}</p>
|
||||
|
||||
<div class="details">
|
||||
<div class="timeline">
|
||||
<span class="timeline-icon">⏱️</span>
|
||||
<span class="timeline-text">{data.timeline}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
{
|
||||
data.contributors && data.contributors.length > 0 && (
|
||||
<div class="contributors">
|
||||
<span class="contributors-count">{data.contributors.length}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<span class="read-more">{t('vision.readMore')} <span class="arrow">→</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.vision-card-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.vision-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background-color: var(--card-bg);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.03);
|
||||
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.vision-card-link:hover .vision-card {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.5);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.vision-card.featured {
|
||||
border: 2px solid rgba(var(--accent-color-rgb, 249, 115, 22), 0.3);
|
||||
}
|
||||
|
||||
.vision-image-container {
|
||||
width: 100%;
|
||||
padding-top: 66.67%; /* Aspect ratio 3:2 */
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vision-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 2rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
z-index: 2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge[data-status="current"] {
|
||||
background-color: #10b981; /* Green */
|
||||
}
|
||||
|
||||
.status-badge[data-status="planned"] {
|
||||
background-color: #6366f1; /* Purple */
|
||||
}
|
||||
|
||||
.status-badge[data-status="exploring"] {
|
||||
background-color: #f59e0b; /* Amber */
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.category {
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.category[data-category="product"] {
|
||||
background-color: #dcfce7; /* Light green */
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.category[data-category="technology"] {
|
||||
background-color: #e0f2fe; /* Light blue */
|
||||
color: #0284c7;
|
||||
}
|
||||
|
||||
.category[data-category="community"] {
|
||||
background-color: #fef3c7; /* Light yellow */
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.category[data-category="future"] {
|
||||
background-color: #ede9fe; /* Light purple */
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.date {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.details {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.timeline-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.timeline-text {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.contributors {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.contributors-count {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.contributors-count::before {
|
||||
content: '👥 ';
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.read-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.vision-card-link:hover .arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
</style>
|
||||
.vision-card-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.vision-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background-color: var(--card-bg);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06),
|
||||
0 1px 2px -1px rgba(0, 0, 0, 0.03);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.vision-card-link:hover .vision-card {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.5);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.08),
|
||||
0 2px 4px -2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.vision-card.featured {
|
||||
border: 2px solid rgba(var(--accent-color-rgb, 249, 115, 22), 0.3);
|
||||
}
|
||||
|
||||
.vision-image-container {
|
||||
width: 100%;
|
||||
padding-top: 66.67%; /* Aspect ratio 3:2 */
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vision-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 2rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
z-index: 2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge[data-status='current'] {
|
||||
background-color: #10b981; /* Green */
|
||||
}
|
||||
|
||||
.status-badge[data-status='planned'] {
|
||||
background-color: #6366f1; /* Purple */
|
||||
}
|
||||
|
||||
.status-badge[data-status='exploring'] {
|
||||
background-color: #f59e0b; /* Amber */
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.category {
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.category[data-category='product'] {
|
||||
background-color: #dcfce7; /* Light green */
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.category[data-category='technology'] {
|
||||
background-color: #e0f2fe; /* Light blue */
|
||||
color: #0284c7;
|
||||
}
|
||||
|
||||
.category[data-category='community'] {
|
||||
background-color: #fef3c7; /* Light yellow */
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.category[data-category='future'] {
|
||||
background-color: #ede9fe; /* Light purple */
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.date {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.details {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.timeline-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.timeline-text {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.contributors {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.contributors-count {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.contributors-count::before {
|
||||
content: '👥 ';
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.read-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.vision-card-link:hover .arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,40 +1,41 @@
|
|||
---
|
||||
interface Props {
|
||||
videoId: string;
|
||||
title?: string;
|
||||
aspectRatio?: string;
|
||||
videoId: string;
|
||||
title?: string;
|
||||
aspectRatio?: string;
|
||||
}
|
||||
|
||||
const { videoId, title = "YouTube video", aspectRatio = "16/9" } = Astro.props;
|
||||
const { videoId, title = 'YouTube video', aspectRatio = '16/9' } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="youtube-video-container">
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${videoId}`}
|
||||
title={title}
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen="true"
|
||||
></iframe>
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${videoId}`}
|
||||
title={title}
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen="true"></iframe>
|
||||
</div>
|
||||
|
||||
<style define:vars={{ aspectRatio }}>
|
||||
.youtube-video-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: var(--aspectRatio);
|
||||
margin: 2rem 0;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.youtube-video-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: var(--aspectRatio);
|
||||
margin: 2rem 0;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,181 +1,199 @@
|
|||
import { defineCollection, z } from 'astro:content';
|
||||
|
||||
const toolsCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.date(),
|
||||
updatedDate: z.date().optional(),
|
||||
category: z.enum(['Design', 'Development', 'Productivity', 'Hosting']),
|
||||
image: z.string().optional(),
|
||||
author: z.string().default('BaunTown'),
|
||||
featured: z.boolean().default(false),
|
||||
website: z.string().optional(),
|
||||
pricing: z.enum(['Free', 'Freemium', 'Paid']).default('Freemium'),
|
||||
tags: z.array(z.string()).optional(),
|
||||
externalLinks: z.array(z.object({
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
})).optional(),
|
||||
}),
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.date(),
|
||||
updatedDate: z.date().optional(),
|
||||
category: z.enum(['Design', 'Development', 'Productivity', 'Hosting']),
|
||||
image: z.string().optional(),
|
||||
author: z.string().default('BaunTown'),
|
||||
featured: z.boolean().default(false),
|
||||
website: z.string().optional(),
|
||||
pricing: z.enum(['Free', 'Freemium', 'Paid']).default('Freemium'),
|
||||
tags: z.array(z.string()).optional(),
|
||||
externalLinks: z
|
||||
.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const newsCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.date(),
|
||||
updatedDate: z.date().optional(),
|
||||
category: z.enum(['AI', 'Web', 'Development', 'Design', 'Community', 'Product']),
|
||||
image: z.string().optional(),
|
||||
author: z.string().default('BaunTown'),
|
||||
featured: z.boolean().default(false),
|
||||
tags: z.array(z.string()).optional(),
|
||||
externalLinks: z.array(z.object({
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
})).optional(),
|
||||
}),
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.date(),
|
||||
updatedDate: z.date().optional(),
|
||||
category: z.enum(['AI', 'Web', 'Development', 'Design', 'Community', 'Product']),
|
||||
image: z.string().optional(),
|
||||
author: z.string().default('BaunTown'),
|
||||
featured: z.boolean().default(false),
|
||||
tags: z.array(z.string()).optional(),
|
||||
externalLinks: z
|
||||
.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const modelsCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.date(),
|
||||
updatedDate: z.date().optional(),
|
||||
category: z.enum(['Text', 'Bild']),
|
||||
image: z.string().optional(),
|
||||
author: z.string().default('BaunTown'),
|
||||
featured: z.boolean().default(false),
|
||||
tags: z.array(z.string()).optional(),
|
||||
externalLinks: z.array(z.object({
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
})).optional(),
|
||||
}),
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.date(),
|
||||
updatedDate: z.date().optional(),
|
||||
category: z.enum(['Text', 'Bild']),
|
||||
image: z.string().optional(),
|
||||
author: z.string().default('BaunTown'),
|
||||
featured: z.boolean().default(false),
|
||||
tags: z.array(z.string()).optional(),
|
||||
externalLinks: z
|
||||
.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const projectsCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.date(),
|
||||
updatedDate: z.date().optional(),
|
||||
category: z.enum(['Web', 'Mobile', 'Desktop', 'IoT', 'AI', 'Design']),
|
||||
image: z.string().optional(),
|
||||
author: z.string().default('BaunTown'),
|
||||
featured: z.boolean().default(false),
|
||||
status: z.enum(['active', 'completed', 'archived']).default('active'),
|
||||
tags: z.array(z.string()).optional(),
|
||||
githubUrl: z.string().optional(),
|
||||
demoUrl: z.string().optional(),
|
||||
technologies: z.array(z.string()).optional(),
|
||||
}),
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.date(),
|
||||
updatedDate: z.date().optional(),
|
||||
category: z.enum(['Web', 'Mobile', 'Desktop', 'IoT', 'AI', 'Design']),
|
||||
image: z.string().optional(),
|
||||
author: z.string().default('BaunTown'),
|
||||
featured: z.boolean().default(false),
|
||||
status: z.enum(['active', 'completed', 'archived']).default('active'),
|
||||
tags: z.array(z.string()).optional(),
|
||||
githubUrl: z.string().optional(),
|
||||
demoUrl: z.string().optional(),
|
||||
technologies: z.array(z.string()).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const tutorialsCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.date(),
|
||||
updatedDate: z.date().optional(),
|
||||
category: z.enum(['UI & UX', 'Business', 'Users', 'Branding', 'Marketing', 'Vibecoding']),
|
||||
image: z.string().optional(),
|
||||
author: z.string().default('BaunTown'),
|
||||
featured: z.boolean().default(false),
|
||||
// Course related fields
|
||||
course: z.string().optional(),
|
||||
courseName: z.string().optional(),
|
||||
lessonNumber: z.number().optional(),
|
||||
}),
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.date(),
|
||||
updatedDate: z.date().optional(),
|
||||
category: z.enum(['UI & UX', 'Business', 'Users', 'Branding', 'Marketing', 'Vibecoding']),
|
||||
image: z.string().optional(),
|
||||
author: z.string().default('BaunTown'),
|
||||
featured: z.boolean().default(false),
|
||||
// Course related fields
|
||||
course: z.string().optional(),
|
||||
courseName: z.string().optional(),
|
||||
lessonNumber: z.number().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const missionsCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.date(),
|
||||
updatedDate: z.date().optional(),
|
||||
difficulty: z.enum(['beginner', 'intermediate', 'advanced']),
|
||||
duration: z.string(), // e.g. "2-3 hours", "1 day"
|
||||
skills: z.array(z.string()),
|
||||
image: z.string().optional(),
|
||||
featured: z.boolean().default(false),
|
||||
category: z.enum(['UI & UX', 'Business', 'Users', 'Branding', 'Marketing', 'Vibecoding']).default('UI & UX'),
|
||||
status: z.enum(['active', 'completed', 'upcoming']).default('active'),
|
||||
participants: z.array(z.string()).optional(),
|
||||
githubRepo: z.string().optional(),
|
||||
}),
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.date(),
|
||||
updatedDate: z.date().optional(),
|
||||
difficulty: z.enum(['beginner', 'intermediate', 'advanced']),
|
||||
duration: z.string(), // e.g. "2-3 hours", "1 day"
|
||||
skills: z.array(z.string()),
|
||||
image: z.string().optional(),
|
||||
featured: z.boolean().default(false),
|
||||
category: z
|
||||
.enum(['UI & UX', 'Business', 'Users', 'Branding', 'Marketing', 'Vibecoding'])
|
||||
.default('UI & UX'),
|
||||
status: z.enum(['active', 'completed', 'upcoming']).default('active'),
|
||||
participants: z.array(z.string()).optional(),
|
||||
githubRepo: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const visionCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.date(),
|
||||
updatedDate: z.date().optional(),
|
||||
image: z.string().optional(),
|
||||
featured: z.boolean().default(false),
|
||||
category: z.enum(['product', 'technology', 'community', 'future']),
|
||||
timeline: z.string(), // e.g. "2025-2030", "Long-term"
|
||||
status: z.enum(['current', 'planned', 'exploring']),
|
||||
contributors: z.array(z.string()).optional(),
|
||||
relatedLinks: z.array(z.object({
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
})).optional(),
|
||||
}),
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.date(),
|
||||
updatedDate: z.date().optional(),
|
||||
image: z.string().optional(),
|
||||
featured: z.boolean().default(false),
|
||||
category: z.enum(['product', 'technology', 'community', 'future']),
|
||||
timeline: z.string(), // e.g. "2025-2030", "Long-term"
|
||||
status: z.enum(['current', 'planned', 'exploring']),
|
||||
contributors: z.array(z.string()).optional(),
|
||||
relatedLinks: z
|
||||
.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const joinCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.date(),
|
||||
updatedDate: z.date().optional(),
|
||||
image: z.string().optional(),
|
||||
heroTitle: z.string().optional(),
|
||||
heroDescription: z.string().optional(),
|
||||
newsletterTitle: z.string().optional(),
|
||||
newsletterDescription: z.string().optional(),
|
||||
submissionTitle: z.string().optional(),
|
||||
submissionDescription: z.string().optional(),
|
||||
}),
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.date(),
|
||||
updatedDate: z.date().optional(),
|
||||
image: z.string().optional(),
|
||||
heroTitle: z.string().optional(),
|
||||
heroDescription: z.string().optional(),
|
||||
newsletterTitle: z.string().optional(),
|
||||
newsletterDescription: z.string().optional(),
|
||||
submissionTitle: z.string().optional(),
|
||||
submissionDescription: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const membersCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
name: z.string(),
|
||||
role: z.string(),
|
||||
bio: z.string(),
|
||||
image: z.string().optional(),
|
||||
github: z.string().optional(),
|
||||
twitter: z.string().optional(),
|
||||
linkedin: z.string().optional(),
|
||||
website: z.string().optional(),
|
||||
featured: z.boolean().default(false),
|
||||
order: z.number().default(999),
|
||||
}),
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
name: z.string(),
|
||||
role: z.string(),
|
||||
bio: z.string(),
|
||||
image: z.string().optional(),
|
||||
github: z.string().optional(),
|
||||
twitter: z.string().optional(),
|
||||
linkedin: z.string().optional(),
|
||||
website: z.string().optional(),
|
||||
featured: z.boolean().default(false),
|
||||
order: z.number().default(999),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = {
|
||||
'news': newsCollection,
|
||||
'models': modelsCollection,
|
||||
'projects': projectsCollection,
|
||||
'tutorials': tutorialsCollection,
|
||||
'missions': missionsCollection,
|
||||
'vision': visionCollection,
|
||||
'join': joinCollection,
|
||||
'members': membersCollection,
|
||||
'tools': toolsCollection,
|
||||
};
|
||||
news: newsCollection,
|
||||
models: modelsCollection,
|
||||
projects: projectsCollection,
|
||||
tutorials: tutorialsCollection,
|
||||
missions: missionsCollection,
|
||||
vision: visionCollection,
|
||||
join: joinCollection,
|
||||
members: membersCollection,
|
||||
tools: toolsCollection,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,4 +26,4 @@ Als Open-Source-Projekt lebt BaunTown von den Beiträgen der Community. Egal ob
|
|||
4. Mache deine Änderungen und committe sie
|
||||
5. Pushe deinen Branch und erstelle einen Pull Request
|
||||
|
||||
Wir freuen uns auf deine Beiträge!
|
||||
Wir freuen uns auf deine Beiträge!
|
||||
|
|
|
|||
|
|
@ -23,4 +23,4 @@ Schau dir die Highlights und Projekte unserer vergangenen Events an und hol dir
|
|||
|
||||
### Eigenes Event organisieren
|
||||
|
||||
Möchtest du ein lokales BaunTown-Meetup in deiner Stadt organisieren? Wir unterstützen dich gerne dabei! Kontaktiere uns für weitere Informationen und Ressourcen.
|
||||
Möchtest du ein lokales BaunTown-Meetup in deiner Stadt organisieren? Wir unterstützen dich gerne dabei! Kontaktiere uns für weitere Informationen und Ressourcen.
|
||||
|
|
|
|||
|
|
@ -25,4 +25,4 @@ BaunTown ist ein Projekt, das von Leidenschaft und Community-Engagement angetrie
|
|||
- **Einmalige Spende**: Auch einmalige Spenden sind willkommen.
|
||||
- **Unternehmenssponsoring**: Interesse an Unternehmenssponsoring? Kontaktiere uns direkt.
|
||||
|
||||
Jeder Beitrag zählt und hilft uns, BaunTown weiterzuentwickeln!
|
||||
Jeder Beitrag zählt und hilft uns, BaunTown weiterzuentwickeln!
|
||||
|
|
|
|||
|
|
@ -26,4 +26,4 @@ As an open-source project, BaunTown thrives on community contributions. Whether
|
|||
4. Make your changes and commit them
|
||||
5. Push your branch and create a pull request
|
||||
|
||||
We look forward to your contributions!
|
||||
We look forward to your contributions!
|
||||
|
|
|
|||
|
|
@ -23,4 +23,4 @@ Check out the highlights and projects from our past events and get inspiration f
|
|||
|
||||
### Organize Your Own Event
|
||||
|
||||
Would you like to organize a local BaunTown meetup in your city? We're happy to support you! Contact us for more information and resources.
|
||||
Would you like to organize a local BaunTown meetup in your city? We're happy to support you! Contact us for more information and resources.
|
||||
|
|
|
|||
|
|
@ -25,4 +25,4 @@ BaunTown is a project fueled by passion and community engagement. To advance our
|
|||
- **One-time Donations**: One-time donations are also welcome.
|
||||
- **Corporate Sponsorship**: Interested in corporate sponsorship? Contact us directly.
|
||||
|
||||
Every contribution counts and helps us continue developing BaunTown!
|
||||
Every contribution counts and helps us continue developing BaunTown!
|
||||
|
|
|
|||
|
|
@ -26,4 +26,4 @@ Come progetto open-source, BaunTown prospera grazie ai contributi della comunit
|
|||
4. Fai le tue modifiche e committale
|
||||
5. Pusha il tuo branch e crea una pull request
|
||||
|
||||
Aspettiamo con ansia i tuoi contributi!
|
||||
Aspettiamo con ansia i tuoi contributi!
|
||||
|
|
|
|||
|
|
@ -23,4 +23,4 @@ Dai un'occhiata ai momenti salienti e ai progetti dei nostri eventi passati e tr
|
|||
|
||||
### Organizza il Tuo Evento
|
||||
|
||||
Vorresti organizzare un meetup locale di BaunTown nella tua città? Siamo felici di supportarti! Contattaci per maggiori informazioni e risorse.
|
||||
Vorresti organizzare un meetup locale di BaunTown nella tua città? Siamo felici di supportarti! Contattaci per maggiori informazioni e risorse.
|
||||
|
|
|
|||
|
|
@ -33,4 +33,4 @@ Il tuo feedback ci aiuta a migliorare la piattaforma. Facci sapere cosa ti piace
|
|||
|
||||
### Contribuisci al Codice
|
||||
|
||||
BaunTown è un progetto open source. Se hai competenze tecniche, puoi contribuire direttamente al codice o aiutare con la documentazione.
|
||||
BaunTown è un progetto open source. Se hai competenze tecniche, puoi contribuire direttamente al codice o aiutare con la documentazione.
|
||||
|
|
|
|||
|
|
@ -25,4 +25,4 @@ BaunTown è un progetto alimentato dalla passione e dal coinvolgimento della com
|
|||
- **Donazioni Una Tantum**: Anche le donazioni una tantum sono benvenute.
|
||||
- **Sponsorizzazione Aziendale**: Interessato alla sponsorizzazione aziendale? Contattaci direttamente.
|
||||
|
||||
Ogni contributo conta e ci aiuta a continuare a sviluppare BaunTown!
|
||||
Ogni contributo conta e ci aiuta a continuare a sviluppare BaunTown!
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
---
|
||||
name: "Nils Weiser"
|
||||
role: "Backend-Entwickler"
|
||||
bio: "Spezialist für Serverarchitektur und Datenbanken mit Schwerpunkt auf skalierbaren Lösungen. Nils ist verantwortlich für die robuste technische Infrastruktur von BaunTown."
|
||||
image: "/images/members/avatar-placeholder.png"
|
||||
github: "nilsweiser"
|
||||
linkedin: "nils-weiser"
|
||||
name: 'Nils Weiser'
|
||||
role: 'Backend-Entwickler'
|
||||
bio: 'Spezialist für Serverarchitektur und Datenbanken mit Schwerpunkt auf skalierbaren Lösungen. Nils ist verantwortlich für die robuste technische Infrastruktur von BaunTown.'
|
||||
image: '/images/members/avatar-placeholder.png'
|
||||
github: 'nilsweiser'
|
||||
linkedin: 'nils-weiser'
|
||||
featured: true
|
||||
order: 3
|
||||
---
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
---
|
||||
name: "Till Schneider"
|
||||
role: "Gründer & Entwickler"
|
||||
bio: "Full-Stack Entwickler mit Fokus auf Web-Technologien und KI-Integration. Till hat BaunTown gegründet, um eine Plattform für offenes, kollaboratives Lernen zu schaffen."
|
||||
image: "/images/members/avatar-placeholder.png"
|
||||
github: "tillschneider"
|
||||
linkedin: "tillschneider"
|
||||
website: "https://bauntown.com"
|
||||
name: 'Till Schneider'
|
||||
role: 'Gründer & Entwickler'
|
||||
bio: 'Full-Stack Entwickler mit Fokus auf Web-Technologien und KI-Integration. Till hat BaunTown gegründet, um eine Plattform für offenes, kollaboratives Lernen zu schaffen.'
|
||||
image: '/images/members/avatar-placeholder.png'
|
||||
github: 'tillschneider'
|
||||
linkedin: 'tillschneider'
|
||||
website: 'https://bauntown.com'
|
||||
featured: true
|
||||
order: 1
|
||||
---
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
---
|
||||
name: "Tobias Müller"
|
||||
role: "Design Lead"
|
||||
bio: "UI/UX Designer mit Erfahrung in der Gestaltung intuitiver Benutzeroberflächen. Tobias bringt seine Expertise für visuelles Design und Benutzererfahrung in das BaunTown-Team ein."
|
||||
image: "/images/members/avatar-placeholder.png"
|
||||
github: "tobiasmdesign"
|
||||
twitter: "tobiasmdesign"
|
||||
linkedin: "tobias-mueller-design"
|
||||
name: 'Tobias Müller'
|
||||
role: 'Design Lead'
|
||||
bio: 'UI/UX Designer mit Erfahrung in der Gestaltung intuitiver Benutzeroberflächen. Tobias bringt seine Expertise für visuelles Design und Benutzererfahrung in das BaunTown-Team ein.'
|
||||
image: '/images/members/avatar-placeholder.png'
|
||||
github: 'tobiasmdesign'
|
||||
twitter: 'tobiasmdesign'
|
||||
linkedin: 'tobias-mueller-design'
|
||||
featured: true
|
||||
order: 2
|
||||
---
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
---
|
||||
name: "Nils Weiser"
|
||||
role: "Backend Developer"
|
||||
name: 'Nils Weiser'
|
||||
role: 'Backend Developer'
|
||||
bio: "Specialist in server architecture and databases with a focus on scalable solutions. Nils is responsible for BaunTown's robust technical infrastructure."
|
||||
image: "/images/members/avatar-placeholder.png"
|
||||
github: "nilsweiser"
|
||||
linkedin: "nils-weiser"
|
||||
image: '/images/members/avatar-placeholder.png'
|
||||
github: 'nilsweiser'
|
||||
linkedin: 'nils-weiser'
|
||||
featured: true
|
||||
order: 3
|
||||
---
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
---
|
||||
name: "Till Schneider"
|
||||
role: "Founder & Developer"
|
||||
bio: "Full-stack developer with a focus on web technologies and AI integration. Till founded BaunTown to create a platform for open, collaborative learning."
|
||||
image: "/images/members/avatar-placeholder.png"
|
||||
github: "tillschneider"
|
||||
linkedin: "tillschneider"
|
||||
website: "https://bauntown.com"
|
||||
name: 'Till Schneider'
|
||||
role: 'Founder & Developer'
|
||||
bio: 'Full-stack developer with a focus on web technologies and AI integration. Till founded BaunTown to create a platform for open, collaborative learning.'
|
||||
image: '/images/members/avatar-placeholder.png'
|
||||
github: 'tillschneider'
|
||||
linkedin: 'tillschneider'
|
||||
website: 'https://bauntown.com'
|
||||
featured: true
|
||||
order: 1
|
||||
---
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
---
|
||||
name: "Tobias Müller"
|
||||
role: "Design Lead"
|
||||
bio: "UI/UX designer with experience crafting intuitive user interfaces. Tobias brings his expertise in visual design and user experience to the BaunTown team."
|
||||
image: "/images/members/avatar-placeholder.png"
|
||||
github: "tobiasmdesign"
|
||||
twitter: "tobiasmdesign"
|
||||
linkedin: "tobias-mueller-design"
|
||||
name: 'Tobias Müller'
|
||||
role: 'Design Lead'
|
||||
bio: 'UI/UX designer with experience crafting intuitive user interfaces. Tobias brings his expertise in visual design and user experience to the BaunTown team.'
|
||||
image: '/images/members/avatar-placeholder.png'
|
||||
github: 'tobiasmdesign'
|
||||
twitter: 'tobiasmdesign'
|
||||
linkedin: 'tobias-mueller-design'
|
||||
featured: true
|
||||
order: 2
|
||||
---
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
---
|
||||
name: "Nils Weiser"
|
||||
role: "Sviluppatore Backend"
|
||||
bio: "Specialista in architettura server e database con focus su soluzioni scalabili. Nils è responsabile della robusta infrastruttura tecnica di BaunTown."
|
||||
image: "/images/members/avatar-placeholder.png"
|
||||
github: "nilsweiser"
|
||||
linkedin: "nils-weiser"
|
||||
name: 'Nils Weiser'
|
||||
role: 'Sviluppatore Backend'
|
||||
bio: 'Specialista in architettura server e database con focus su soluzioni scalabili. Nils è responsabile della robusta infrastruttura tecnica di BaunTown.'
|
||||
image: '/images/members/avatar-placeholder.png'
|
||||
github: 'nilsweiser'
|
||||
linkedin: 'nils-weiser'
|
||||
featured: true
|
||||
order: 3
|
||||
---
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
---
|
||||
name: "Till Schneider"
|
||||
role: "Fondatore & Sviluppatore"
|
||||
bio: "Sviluppatore full-stack con focus su tecnologie web e integrazione AI. Till ha fondato BaunTown per creare una piattaforma di apprendimento aperto e collaborativo."
|
||||
image: "/images/members/avatar-placeholder.png"
|
||||
github: "tillschneider"
|
||||
linkedin: "tillschneider"
|
||||
website: "https://bauntown.com"
|
||||
name: 'Till Schneider'
|
||||
role: 'Fondatore & Sviluppatore'
|
||||
bio: 'Sviluppatore full-stack con focus su tecnologie web e integrazione AI. Till ha fondato BaunTown per creare una piattaforma di apprendimento aperto e collaborativo.'
|
||||
image: '/images/members/avatar-placeholder.png'
|
||||
github: 'tillschneider'
|
||||
linkedin: 'tillschneider'
|
||||
website: 'https://bauntown.com'
|
||||
featured: true
|
||||
order: 1
|
||||
---
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
---
|
||||
name: "Tobias Müller"
|
||||
role: "Lead Designer"
|
||||
bio: "Designer UI/UX con esperienza nella creazione di interfacce utente intuitive. Tobias porta la sua competenza in design visivo ed esperienza utente al team di BaunTown."
|
||||
image: "/images/members/avatar-placeholder.png"
|
||||
github: "tobiasmdesign"
|
||||
twitter: "tobiasmdesign"
|
||||
linkedin: "tobias-mueller-design"
|
||||
name: 'Tobias Müller'
|
||||
role: 'Lead Designer'
|
||||
bio: 'Designer UI/UX con esperienza nella creazione di interfacce utente intuitive. Tobias porta la sua competenza in design visivo ed esperienza utente al team di BaunTown.'
|
||||
image: '/images/members/avatar-placeholder.png'
|
||||
github: 'tobiasmdesign'
|
||||
twitter: 'tobiasmdesign'
|
||||
linkedin: 'tobias-mueller-design'
|
||||
featured: true
|
||||
order: 2
|
||||
---
|
||||
---
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +1,17 @@
|
|||
---
|
||||
title: "Google Gemini 2.5 Pro: Ein Durchbruch in der KI-Technologie"
|
||||
description: "Google hat kürzlich das Gemini 2.5 Pro-Modell vorgestellt, das als das fortschrittlichste KI-Modell des Unternehmens gilt und mit seiner enormen Kontextfenstergröße neue Maßstäbe setzt."
|
||||
title: 'Google Gemini 2.5 Pro: Ein Durchbruch in der KI-Technologie'
|
||||
description: 'Google hat kürzlich das Gemini 2.5 Pro-Modell vorgestellt, das als das fortschrittlichste KI-Modell des Unternehmens gilt und mit seiner enormen Kontextfenstergröße neue Maßstäbe setzt.'
|
||||
pubDate: 2025-03-25
|
||||
category: "Text"
|
||||
category: 'Text'
|
||||
featured: true
|
||||
author: "BaunTown"
|
||||
tags: ["Google", "Gemini", "KI", "Multimodal", "Kontextfenster"]
|
||||
image: "/images/models/google-gemini-2.5pro-bauntown.png"
|
||||
author: 'BaunTown'
|
||||
tags: ['Google', 'Gemini', 'KI', 'Multimodal', 'Kontextfenster']
|
||||
image: '/images/models/google-gemini-2.5pro-bauntown.png'
|
||||
externalLinks:
|
||||
- title: "Offizielle Google Ankündigung"
|
||||
url: "https://blog.google/technology/google-deepmind/gemini-model-thinking-updates-march-2025/#gemini-2-5-thinking"
|
||||
- title: "Gemini - Ausprobieren"
|
||||
url: "https://gemini.google.com"
|
||||
- title: 'Offizielle Google Ankündigung'
|
||||
url: 'https://blog.google/technology/google-deepmind/gemini-model-thinking-updates-march-2025/#gemini-2-5-thinking'
|
||||
- title: 'Gemini - Ausprobieren'
|
||||
url: 'https://gemini.google.com'
|
||||
---
|
||||
|
||||
Google hat kürzlich das Gemini 2.5 Pro-Modell vorgestellt, das als das fortschrittlichste KI-Modell des Unternehmens gilt. Mit bahnbrechenden Funktionen wie multimodaler Verarbeitung, verbesserter Problemlösungsfähigkeit und einer enormen Kontextfenstergröße von bis zu 2 Millionen Tokens setzt dieses Modell neue Maßstäbe in der KI-Entwicklung. Die Reaktionen auf diese Innovation sind weltweit vielfältig und spiegeln sowohl Begeisterung als auch kritische Reflexion wider.
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
---
|
||||
title: "Revolution der KI-Bildgenerierung: OpenAIs GPT-4o setzt neue Maßstäbe"
|
||||
description: "OpenAI hat kürzlich sein neuestes Modell, GPT-4o, vorgestellt, das die Bildgenerierung direkt integriert und damit einen bedeutenden Fortschritt in der KI-Technologie markiert."
|
||||
title: 'Revolution der KI-Bildgenerierung: OpenAIs GPT-4o setzt neue Maßstäbe'
|
||||
description: 'OpenAI hat kürzlich sein neuestes Modell, GPT-4o, vorgestellt, das die Bildgenerierung direkt integriert und damit einen bedeutenden Fortschritt in der KI-Technologie markiert.'
|
||||
pubDate: 2025-03-25
|
||||
category: "Bild"
|
||||
category: 'Bild'
|
||||
featured: true
|
||||
author: "BaunTown"
|
||||
tags: ["GPT-4o", "OpenAI", "KI", "Multimodal", "Bildgenerierung"]
|
||||
image: "/images/models/openai-gpt4o-imagemode-bauntown.png"
|
||||
author: 'BaunTown'
|
||||
tags: ['GPT-4o', 'OpenAI', 'KI', 'Multimodal', 'Bildgenerierung']
|
||||
image: '/images/models/openai-gpt4o-imagemode-bauntown.png'
|
||||
externalLinks:
|
||||
- title: "Offizielle OpenAI Ankündigung"
|
||||
url: "https://openai.com/index/introducing-4o-image-generation/"
|
||||
- title: "ChatGPT - Bilder erstellen"
|
||||
url: "https://chat.openai.com"
|
||||
- title: 'Offizielle OpenAI Ankündigung'
|
||||
url: 'https://openai.com/index/introducing-4o-image-generation/'
|
||||
- title: 'ChatGPT - Bilder erstellen'
|
||||
url: 'https://chat.openai.com'
|
||||
- title: "Sora - OpenAI's Video & Bild Plattform"
|
||||
url: "https://sora.com"
|
||||
url: 'https://sora.com'
|
||||
---
|
||||
|
||||
OpenAI hat kürzlich sein neuestes Modell, GPT-4o, vorgestellt, das die Bildgenerierung direkt integriert und damit einen bedeutenden Fortschritt in der KI-Technologie markiert. Dieses Modell kann Bilder basierend auf Textaufforderungen erstellen, hochgeladene Bilder bearbeiten und sogar mehrere Objekte präzise darstellen. Es zeichnet sich durch eine verbesserte Textdarstellung in Bildern aus, ein Bereich, in dem frühere Modelle wie DALL-E oft Schwierigkeiten hatten.
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
---
|
||||
title: "Google Gemini 2.5 Pro: A Breakthrough in AI Technology"
|
||||
title: 'Google Gemini 2.5 Pro: A Breakthrough in AI Technology'
|
||||
description: "Google recently unveiled the Gemini 2.5 Pro model, considered the company's most advanced AI model, setting new standards with its massive context window."
|
||||
pubDate: 2025-03-25
|
||||
category: "Text"
|
||||
category: 'Text'
|
||||
featured: true
|
||||
author: "BaunTown"
|
||||
tags: ["Google", "Gemini", "AI", "Multimodal", "Context Window"]
|
||||
image: "/images/models/google-gemini-2.5pro-bauntown.png"
|
||||
author: 'BaunTown'
|
||||
tags: ['Google', 'Gemini', 'AI', 'Multimodal', 'Context Window']
|
||||
image: '/images/models/google-gemini-2.5pro-bauntown.png'
|
||||
externalLinks:
|
||||
- title: "Official Google Announcement"
|
||||
url: "https://blog.google/technology/google-deepmind/gemini-model-thinking-updates-march-2025/#gemini-2-5-thinking"
|
||||
- title: "Try Gemini"
|
||||
url: "https://gemini.google.com"
|
||||
- title: 'Official Google Announcement'
|
||||
url: 'https://blog.google/technology/google-deepmind/gemini-model-thinking-updates-march-2025/#gemini-2-5-thinking'
|
||||
- title: 'Try Gemini'
|
||||
url: 'https://gemini.google.com'
|
||||
---
|
||||
|
||||
Google recently unveiled the Gemini 2.5 Pro model, considered the company's most advanced AI model. With groundbreaking features such as multimodal processing, improved problem-solving capabilities, and an enormous context window of up to 2 million tokens, this model sets new standards in AI development. Reactions to this innovation are diverse worldwide, reflecting both enthusiasm and critical reflection.
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
---
|
||||
title: "AI Image Generation Revolution: OpenAI's GPT-4o Sets New Standards"
|
||||
description: "OpenAI recently unveiled its latest model, GPT-4o, which directly integrates image generation, marking a significant advancement in AI technology."
|
||||
description: 'OpenAI recently unveiled its latest model, GPT-4o, which directly integrates image generation, marking a significant advancement in AI technology.'
|
||||
pubDate: 2025-03-25
|
||||
category: "Bild"
|
||||
category: 'Bild'
|
||||
featured: true
|
||||
author: "BaunTown"
|
||||
tags: ["GPT-4o", "OpenAI", "AI", "Multimodal", "Image Generation"]
|
||||
image: "/images/models/openai-gpt4o-imagemode-bauntown.png"
|
||||
author: 'BaunTown'
|
||||
tags: ['GPT-4o', 'OpenAI', 'AI', 'Multimodal', 'Image Generation']
|
||||
image: '/images/models/openai-gpt4o-imagemode-bauntown.png'
|
||||
externalLinks:
|
||||
- title: "Official OpenAI Announcement"
|
||||
url: "https://openai.com/index/introducing-4o-image-generation/"
|
||||
- title: "ChatGPT - Create Images"
|
||||
url: "https://chat.openai.com"
|
||||
- title: 'Official OpenAI Announcement'
|
||||
url: 'https://openai.com/index/introducing-4o-image-generation/'
|
||||
- title: 'ChatGPT - Create Images'
|
||||
url: 'https://chat.openai.com'
|
||||
- title: "Sora - OpenAI's Video & Image Platform"
|
||||
url: "https://sora.com"
|
||||
url: 'https://sora.com'
|
||||
---
|
||||
|
||||
OpenAI recently unveiled its latest model, GPT-4o, which directly integrates image generation, marking a significant advancement in AI technology. This model can create images based on text prompts, edit uploaded images, and even accurately represent multiple objects. It stands out for its improved text rendering in images, an area where previous models like DALL-E often struggled.
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
---
|
||||
title: "Google Gemini 2.5 Pro: Una svolta nella tecnologia AI"
|
||||
title: 'Google Gemini 2.5 Pro: Una svolta nella tecnologia AI'
|
||||
description: "Google ha recentemente presentato il modello Gemini 2.5 Pro, considerato il modello AI più avanzato dell'azienda, stabilendo nuovi standard con la sua enorme finestra di contesto."
|
||||
pubDate: 2025-03-25
|
||||
category: "Text"
|
||||
category: 'Text'
|
||||
featured: true
|
||||
author: "BaunTown"
|
||||
tags: ["Google", "Gemini", "IA", "Multimodale", "Finestra di contesto"]
|
||||
image: "/images/models/google-gemini-2.5pro-bauntown.png"
|
||||
author: 'BaunTown'
|
||||
tags: ['Google', 'Gemini', 'IA', 'Multimodale', 'Finestra di contesto']
|
||||
image: '/images/models/google-gemini-2.5pro-bauntown.png'
|
||||
externalLinks:
|
||||
- title: "Annuncio ufficiale di Google"
|
||||
url: "https://blog.google/technology/google-deepmind/gemini-model-thinking-updates-march-2025/#gemini-2-5-thinking"
|
||||
- title: "Prova Gemini"
|
||||
url: "https://gemini.google.com"
|
||||
- title: 'Annuncio ufficiale di Google'
|
||||
url: 'https://blog.google/technology/google-deepmind/gemini-model-thinking-updates-march-2025/#gemini-2-5-thinking'
|
||||
- title: 'Prova Gemini'
|
||||
url: 'https://gemini.google.com'
|
||||
---
|
||||
|
||||
Google ha recentemente presentato il modello Gemini 2.5 Pro, considerato il modello di intelligenza artificiale più avanzato dell'azienda. Con caratteristiche rivoluzionarie come l'elaborazione multimodale, migliori capacità di risoluzione dei problemi e un'enorme finestra di contesto fino a 2 milioni di token, questo modello stabilisce nuovi standard nello sviluppo dell'IA. Le reazioni a questa innovazione sono diverse in tutto il mondo, riflettendo sia entusiasmo che riflessione critica.
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
---
|
||||
title: "Rivoluzione nella generazione di immagini AI: OpenAI GPT-4o stabilisce nuovi standard"
|
||||
description: "OpenAI ha recentemente svelato il suo ultimo modello, GPT-4o, che integra direttamente la generazione di immagini, segnando un significativo avanzamento nella tecnologia AI."
|
||||
title: 'Rivoluzione nella generazione di immagini AI: OpenAI GPT-4o stabilisce nuovi standard'
|
||||
description: 'OpenAI ha recentemente svelato il suo ultimo modello, GPT-4o, che integra direttamente la generazione di immagini, segnando un significativo avanzamento nella tecnologia AI.'
|
||||
pubDate: 2025-03-25
|
||||
category: "Bild"
|
||||
category: 'Bild'
|
||||
featured: true
|
||||
author: "BaunTown"
|
||||
tags: ["GPT-4o", "OpenAI", "AI", "Multimodale", "Generazione di immagini"]
|
||||
image: "/images/models/openai-gpt4o-imagemode-bauntown.png"
|
||||
author: 'BaunTown'
|
||||
tags: ['GPT-4o', 'OpenAI', 'AI', 'Multimodale', 'Generazione di immagini']
|
||||
image: '/images/models/openai-gpt4o-imagemode-bauntown.png'
|
||||
externalLinks:
|
||||
- title: "Annuncio ufficiale di OpenAI"
|
||||
url: "https://openai.com/index/introducing-4o-image-generation/"
|
||||
- title: "ChatGPT - Crea immagini"
|
||||
url: "https://chat.openai.com"
|
||||
- title: "Sora - Piattaforma video e immagini di OpenAI"
|
||||
url: "https://sora.com"
|
||||
- title: 'Annuncio ufficiale di OpenAI'
|
||||
url: 'https://openai.com/index/introducing-4o-image-generation/'
|
||||
- title: 'ChatGPT - Crea immagini'
|
||||
url: 'https://chat.openai.com'
|
||||
- title: 'Sora - Piattaforma video e immagini di OpenAI'
|
||||
url: 'https://sora.com'
|
||||
---
|
||||
|
||||
OpenAI ha recentemente presentato il suo ultimo modello, GPT-4o, che integra direttamente la generazione di immagini, segnando un importante progresso nella tecnologia dell'IA. Questo modello può creare immagini basate su prompt testuali, modificare immagini caricate e persino rappresentare con precisione oggetti multipli. Si distingue per la sua migliore rappresentazione del testo nelle immagini, un'area in cui modelli precedenti come DALL-E spesso avevano difficoltà.
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
---
|
||||
title: "BaunTown"
|
||||
description: "Eine Community-Plattform zum Teilen von Wissen, Tools und Ressourcen, um Entwicklern zu helfen, gemeinsam bessere digitale Produkte zu erstellen."
|
||||
title: 'BaunTown'
|
||||
description: 'Eine Community-Plattform zum Teilen von Wissen, Tools und Ressourcen, um Entwicklern zu helfen, gemeinsam bessere digitale Produkte zu erstellen.'
|
||||
pubDate: 2024-04-04
|
||||
category: "Web"
|
||||
category: 'Web'
|
||||
featured: true
|
||||
status: "active"
|
||||
technologies: ["Astro", "TypeScript", "CSS", "Markdown", "Netlify", "Stripe"]
|
||||
image: "/images/projects/bauntown-project-bauntown-platform.png"
|
||||
author: "Till Schneider"
|
||||
githubUrl: "https://github.com/bauntown/website"
|
||||
demoUrl: "https://baun.town"
|
||||
status: 'active'
|
||||
technologies: ['Astro', 'TypeScript', 'CSS', 'Markdown', 'Netlify', 'Stripe']
|
||||
image: '/images/projects/bauntown-project-bauntown-platform.png'
|
||||
author: 'Till Schneider'
|
||||
githubUrl: 'https://github.com/bauntown/website'
|
||||
demoUrl: 'https://baun.town'
|
||||
---
|
||||
|
||||
# BaunTown Plattform
|
||||
|
|
@ -19,6 +19,7 @@ BaunTown ist unsere zentrale Wissensaustausch-Plattform, die geschaffen wurde, u
|
|||
## Die Herausforderung
|
||||
|
||||
Nach Workshops und Schulungen standen wir immer wieder vor denselben Problemen:
|
||||
|
||||
- Teilnehmer fragten nach Assets, Dateien und Dokumentation
|
||||
- Kein zentraler Ort zur Speicherung und gemeinsamen Nutzung von Materialien
|
||||
- Schwierigkeiten bei der Nachverfolgung, wer Zugang zu welchen Ressourcen hat
|
||||
|
|
@ -39,6 +40,7 @@ BaunTown bietet:
|
|||
## Technische Umsetzung
|
||||
|
||||
Die Plattform nutzt einen modernen Tech-Stack:
|
||||
|
||||
- **Astro** für statische Site-Generierung mit minimalem JavaScript
|
||||
- **Content Collections** für strukturierte, typsichere Inhalte
|
||||
- **Markdown/MDX** für einfache Inhaltserstellung
|
||||
|
|
@ -50,10 +52,11 @@ Die Plattform nutzt einen modernen Tech-Stack:
|
|||
## Zukunftsvision
|
||||
|
||||
BaunTown wird sich weiterentwickeln mit:
|
||||
|
||||
- Erweiterter Tutorial-Bibliothek zu Entwicklungs- und Design-Themen
|
||||
- Detailliertere Projektvorstellungen und Fallstudien
|
||||
- Mitgliederprofile für Mitwirkende
|
||||
- Verbesserte Community-Funktionen und Diskussionsmöglichkeiten
|
||||
- Integration mit unseren anderen Produkten und Dienstleistungen
|
||||
|
||||
Die Plattform verkörpert unsere Werte des gemeinschaftlichen Lernens, Bauens und Verdienens - sie bietet eine Grundlage für den Wissensaustausch und demonstriert gleichzeitig die modernen Entwicklungsansätze, für die wir eintreten.
|
||||
Die Plattform verkörpert unsere Werte des gemeinschaftlichen Lernens, Bauens und Verdienens - sie bietet eine Grundlage für den Wissensaustausch und demonstriert gleichzeitig die modernen Entwicklungsansätze, für die wir eintreten.
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
---
|
||||
title: "Memoro"
|
||||
description: "KI-gestützte mobile Anwendung zur Umwandlung von Gesprächen und Gedanken in strukturierte Dokumente."
|
||||
title: 'Memoro'
|
||||
description: 'KI-gestützte mobile Anwendung zur Umwandlung von Gesprächen und Gedanken in strukturierte Dokumente.'
|
||||
pubDate: 2024-04-08
|
||||
category: "Mobile"
|
||||
category: 'Mobile'
|
||||
featured: true
|
||||
status: "active"
|
||||
technologies: ["React Native", "Expo", "Supabase", "Azure OpenAI", "Azure Speech", "RevenueCat", "PostHog"]
|
||||
image: "/images/projects/memoro-project-bauntown-app.png"
|
||||
author: "Till Schneider"
|
||||
githubUrl: "https://github.com/bauntown/memoro"
|
||||
demoUrl: "https://www.memoro.ai"
|
||||
status: 'active'
|
||||
technologies:
|
||||
['React Native', 'Expo', 'Supabase', 'Azure OpenAI', 'Azure Speech', 'RevenueCat', 'PostHog']
|
||||
image: '/images/projects/memoro-project-bauntown-app.png'
|
||||
author: 'Till Schneider'
|
||||
githubUrl: 'https://github.com/bauntown/memoro'
|
||||
demoUrl: 'https://www.memoro.ai'
|
||||
---
|
||||
|
||||
# Memoro App
|
||||
|
|
@ -77,4 +78,4 @@ Unsere Entwicklung konzentriert sich auf mehrere Schlüsselbereiche:
|
|||
|
||||
Wir verbessern außerdem unsere kontextbezogenen Verständnisfähigkeiten, um die Qualität der automatisierten Dokumentation in spezialisierten Domänen und technischen Bereichen weiter zu optimieren.
|
||||
|
||||
Memoro repräsentiert unsere Vision für Technologie, die in den Hintergrund tritt und gleichzeitig die menschliche Produktivität steigert - damit Menschen sich auf Gespräche und Ideen konzentrieren können, anstatt auf die Mechanik der Dokumentation.
|
||||
Memoro repräsentiert unsere Vision für Technologie, die in den Hintergrund tritt und gleichzeitig die menschliche Produktivität steigert - damit Menschen sich auf Gespräche und Ideen konzentrieren können, anstatt auf die Mechanik der Dokumentation.
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
---
|
||||
title: "BaunTown"
|
||||
description: "A community platform for sharing knowledge, tools, and resources to help builders create better digital products together."
|
||||
title: 'BaunTown'
|
||||
description: 'A community platform for sharing knowledge, tools, and resources to help builders create better digital products together.'
|
||||
pubDate: 2024-04-04
|
||||
category: "Web"
|
||||
category: 'Web'
|
||||
featured: true
|
||||
status: "active"
|
||||
technologies: ["Astro", "TypeScript", "CSS", "Markdown", "Netlify", "Stripe"]
|
||||
image: "/images/projects/bauntown-project-bauntown-platform.png"
|
||||
author: "Till Schneider"
|
||||
githubUrl: "https://github.com/bauntown/website"
|
||||
demoUrl: "https://baun.town"
|
||||
status: 'active'
|
||||
technologies: ['Astro', 'TypeScript', 'CSS', 'Markdown', 'Netlify', 'Stripe']
|
||||
image: '/images/projects/bauntown-project-bauntown-platform.png'
|
||||
author: 'Till Schneider'
|
||||
githubUrl: 'https://github.com/bauntown/website'
|
||||
demoUrl: 'https://baun.town'
|
||||
---
|
||||
|
||||
# BaunTown Platform
|
||||
|
|
@ -19,6 +19,7 @@ BaunTown is our central knowledge-sharing platform, created to solve the recurri
|
|||
## The Challenge
|
||||
|
||||
After hosting workshops and training sessions, we consistently faced the same problems:
|
||||
|
||||
- Participants asking for assets, files, and documentation
|
||||
- No centralized place to store and share materials
|
||||
- Difficulty tracking who has access to what resources
|
||||
|
|
@ -39,6 +40,7 @@ BaunTown provides:
|
|||
## Technical Implementation
|
||||
|
||||
The platform uses a modern tech stack:
|
||||
|
||||
- **Astro** for static site generation with minimal JavaScript
|
||||
- **Content Collections** for structured, type-safe content
|
||||
- **Markdown/MDX** for easy content creation
|
||||
|
|
@ -50,10 +52,11 @@ The platform uses a modern tech stack:
|
|||
## Future Vision
|
||||
|
||||
BaunTown will continue to evolve with:
|
||||
|
||||
- Expanded tutorial library covering development and design topics
|
||||
- More detailed project showcases and case studies
|
||||
- Member profiles for contributors
|
||||
- Enhanced community features and discussion capabilities
|
||||
- Integration with our other products and services
|
||||
|
||||
The platform embodies our values of collaborative learning, building, and earning - providing a foundation for sharing knowledge while demonstrating the modern development approaches we advocate for.
|
||||
The platform embodies our values of collaborative learning, building, and earning - providing a foundation for sharing knowledge while demonstrating the modern development approaches we advocate for.
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
---
|
||||
title: "Memoro"
|
||||
description: "AI-powered mobile application for transforming conversations and thoughts into structured documents."
|
||||
title: 'Memoro'
|
||||
description: 'AI-powered mobile application for transforming conversations and thoughts into structured documents.'
|
||||
pubDate: 2024-04-08
|
||||
category: "Mobile"
|
||||
category: 'Mobile'
|
||||
featured: true
|
||||
status: "active"
|
||||
technologies: ["React Native", "Expo", "Supabase", "Azure OpenAI", "Azure Speech", "RevenueCat", "PostHog"]
|
||||
image: "/images/projects/memoro-project-bauntown-app.png"
|
||||
author: "Till Schneider"
|
||||
githubUrl: "https://github.com/bauntown/memoro"
|
||||
demoUrl: "https://www.memoro.ai"
|
||||
status: 'active'
|
||||
technologies:
|
||||
['React Native', 'Expo', 'Supabase', 'Azure OpenAI', 'Azure Speech', 'RevenueCat', 'PostHog']
|
||||
image: '/images/projects/memoro-project-bauntown-app.png'
|
||||
author: 'Till Schneider'
|
||||
githubUrl: 'https://github.com/bauntown/memoro'
|
||||
demoUrl: 'https://www.memoro.ai'
|
||||
---
|
||||
|
||||
# Memoro App
|
||||
|
|
@ -77,4 +78,4 @@ Our development focuses on several key areas:
|
|||
|
||||
We're also enhancing our contextual understanding capabilities to further improve the quality of automated documentation across specialized domains and technical fields.
|
||||
|
||||
Memoro represents our vision for technology that fades into the background while amplifying human productivity - letting people focus on conversations and ideas rather than the mechanics of documentation.
|
||||
Memoro represents our vision for technology that fades into the background while amplifying human productivity - letting people focus on conversations and ideas rather than the mechanics of documentation.
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
---
|
||||
title: "BaunTown"
|
||||
description: "Una piattaforma comunitaria per condividere conoscenze, strumenti e risorse per aiutare i costruttori a creare insieme prodotti digitali migliori."
|
||||
title: 'BaunTown'
|
||||
description: 'Una piattaforma comunitaria per condividere conoscenze, strumenti e risorse per aiutare i costruttori a creare insieme prodotti digitali migliori.'
|
||||
pubDate: 2024-04-04
|
||||
category: "Web"
|
||||
category: 'Web'
|
||||
featured: true
|
||||
status: "active"
|
||||
technologies: ["Astro", "TypeScript", "CSS", "Markdown", "Netlify", "Stripe"]
|
||||
image: "/images/projects/bauntown-project-bauntown-platform.png"
|
||||
author: "Till Schneider"
|
||||
githubUrl: "https://github.com/bauntown/website"
|
||||
demoUrl: "https://baun.town"
|
||||
status: 'active'
|
||||
technologies: ['Astro', 'TypeScript', 'CSS', 'Markdown', 'Netlify', 'Stripe']
|
||||
image: '/images/projects/bauntown-project-bauntown-platform.png'
|
||||
author: 'Till Schneider'
|
||||
githubUrl: 'https://github.com/bauntown/website'
|
||||
demoUrl: 'https://baun.town'
|
||||
---
|
||||
|
||||
# Piattaforma BaunTown
|
||||
|
|
@ -19,6 +19,7 @@ BaunTown è la nostra piattaforma centrale per la condivisione delle conoscenze,
|
|||
## La Sfida
|
||||
|
||||
Dopo aver ospitato workshop e sessioni di formazione, abbiamo costantemente affrontato gli stessi problemi:
|
||||
|
||||
- Partecipanti che chiedevano assets, file e documentazione
|
||||
- Nessun luogo centralizzato per archiviare e condividere materiali
|
||||
- Difficoltà nel tracciare chi ha accesso a quali risorse
|
||||
|
|
@ -39,6 +40,7 @@ BaunTown offre:
|
|||
## Implementazione Tecnica
|
||||
|
||||
La piattaforma utilizza uno stack tecnologico moderno:
|
||||
|
||||
- **Astro** per la generazione di siti statici con JavaScript minimo
|
||||
- **Content Collections** per contenuti strutturati e type-safe
|
||||
- **Markdown/MDX** per una facile creazione di contenuti
|
||||
|
|
@ -50,10 +52,11 @@ La piattaforma utilizza uno stack tecnologico moderno:
|
|||
## Visione Futura
|
||||
|
||||
BaunTown continuerà a evolversi con:
|
||||
|
||||
- Libreria di tutorial ampliata che copre argomenti di sviluppo e design
|
||||
- Vetrine di progetti più dettagliate e casi di studio
|
||||
- Profili dei membri per i contributori
|
||||
- Funzionalità comunitarie migliorate e capacità di discussione
|
||||
- Integrazione con i nostri altri prodotti e servizi
|
||||
|
||||
La piattaforma incarna i nostri valori di apprendimento collaborativo, costruzione e guadagno - fornendo una base per la condivisione delle conoscenze mentre dimostra gli approcci di sviluppo moderni che sosteniamo.
|
||||
La piattaforma incarna i nostri valori di apprendimento collaborativo, costruzione e guadagno - fornendo una base per la condivisione delle conoscenze mentre dimostra gli approcci di sviluppo moderni che sosteniamo.
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
---
|
||||
title: "Memoro"
|
||||
description: "Applicazione mobile basata su IA per trasformare conversazioni e pensieri in documenti strutturati."
|
||||
title: 'Memoro'
|
||||
description: 'Applicazione mobile basata su IA per trasformare conversazioni e pensieri in documenti strutturati.'
|
||||
pubDate: 2024-04-08
|
||||
category: "Mobile"
|
||||
category: 'Mobile'
|
||||
featured: true
|
||||
status: "active"
|
||||
technologies: ["React Native", "Expo", "Supabase", "Azure OpenAI", "Azure Speech", "RevenueCat", "PostHog"]
|
||||
image: "/images/projects/memoro-project-bauntown-app.png"
|
||||
author: "Till Schneider"
|
||||
githubUrl: "https://github.com/bauntown/memoro"
|
||||
demoUrl: "https://www.memoro.ai"
|
||||
status: 'active'
|
||||
technologies:
|
||||
['React Native', 'Expo', 'Supabase', 'Azure OpenAI', 'Azure Speech', 'RevenueCat', 'PostHog']
|
||||
image: '/images/projects/memoro-project-bauntown-app.png'
|
||||
author: 'Till Schneider'
|
||||
githubUrl: 'https://github.com/bauntown/memoro'
|
||||
demoUrl: 'https://www.memoro.ai'
|
||||
---
|
||||
|
||||
# App Memoro
|
||||
|
|
@ -77,4 +78,4 @@ Il nostro sviluppo si concentra su diverse aree chiave:
|
|||
|
||||
Stiamo anche migliorando le nostre capacità di comprensione contestuale per migliorare ulteriormente la qualità della documentazione automatizzata in domini specializzati e campi tecnici.
|
||||
|
||||
Memoro rappresenta la nostra visione di una tecnologia che scompare sullo sfondo mentre amplifica la produttività umana - permettendo alle persone di concentrarsi sulle conversazioni e le idee piuttosto che sulla meccanica della documentazione.
|
||||
Memoro rappresenta la nostra visione di una tecnologia che scompare sullo sfondo mentre amplifica la produttività umana - permettendo alle persone di concentrarsi sulle conversazioni e le idee piuttosto che sulla meccanica della documentazione.
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue