Compare commits

...

33 Commits

Author SHA1 Message Date
Benjamin Admin
520a0f401c fix: downgrade to PaddleOCR 2.x for CPU stability
Some checks failed
Deploy to Coolify / deploy (push) Failing after 2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:13:41 +01:00
Benjamin Admin
6adf1fe1eb fix: force-disable oneDNN for PaddlePaddle 3.x
Some checks failed
Deploy to Coolify / deploy (push) Failing after 2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:01:55 +01:00
Benjamin Admin
2ac6559291 fix: disable oneDNN and support PaddleOCR 3.x format
Some checks failed
Deploy to Coolify / deploy (push) Failing after 2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:54:28 +01:00
Benjamin Admin
52618a0630 fix: add error handling to OCR endpoint
Some checks failed
Deploy to Coolify / deploy (push) Failing after 2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:37:47 +01:00
Benjamin Admin
e1a84fd568 fix: remove warmup OCR call — causes OOM on 6G container
Some checks failed
Deploy to Coolify / deploy (push) Failing after 2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:31:25 +01:00
Benjamin Admin
dd0bda05be fix: increase paddleocr memory limit 4G → 6G
Some checks failed
Deploy to Coolify / deploy (push) Failing after 2s
PaddlePaddle + PP-OCRv5 model + warmup OCR needs more than 4G on
CPU-only servers. Container was OOM-killed during warmup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:08:15 +01:00
Benjamin Admin
4c68666c5c fix: add warmup OCR call to avoid timeout on first request
Some checks failed
Deploy to Coolify / deploy (push) Failing after 6s
PaddleOCR JIT-compiles on the first .ocr() call, which takes minutes
on CPU-only servers. This causes Traefik 504 Gateway Timeout.

Run a dummy OCR during startup so the first real request is fast.
Also simplify Traefik labels on paddleocr-service.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:55:07 +01:00
Benjamin Admin
46b1fdc20f fix: use runs-on docker for Gitea runner compatibility
Some checks failed
Deploy to Coolify / deploy (push) Failing after 4s
The Gitea runner on Mac Mini uses label 'docker', not 'ubuntu-latest'.
Also need alpine container with curl installed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:51:23 +01:00
Benjamin Admin
445cbc3100 fix: add deploy-coolify.yml workflow to coolify branch
Some checks failed
Deploy to Coolify / deploy (push) Has been cancelled
The deploy workflow was missing from the coolify branch, so pushes
to coolify never triggered a Coolify redeploy via Gitea Actions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:45:58 +01:00
Benjamin Admin
8fe4473205 feat: add paddleocr-service directory to coolify branch
The docker-compose.coolify.yml references paddleocr-service/Dockerfile
but the directory only existed on main. Coolify clones the coolify branch
and needs the source files to build the container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:40:26 +01:00
Benjamin Admin
07dbd78962 feat: add paddleocr-service to Coolify compose
Add PaddleOCR PP-OCRv5 service with 4G memory limit, model volume,
and health check (5min start period for model loading). Domain routing
(ocr.breakpilot.com) to be configured in Coolify UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:39:58 +01:00
Sharang Parnerkar
e9487a31c6 Replace deploy-hetzner with Coolify webhook deploy in ci.yaml
Some checks failed
CI / go-lint (pull_request) Failing after 2s
CI / python-lint (pull_request) Failing after 10s
CI / nodejs-lint (pull_request) Failing after 2s
CI / test-go-consent (pull_request) Failing after 2s
CI / test-python-voice (pull_request) Failing after 9s
CI / Deploy (pull_request) Has been skipped
CI / test-bqas (pull_request) Failing after 10s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:42:41 +01:00
Sharang Parnerkar
0fb4a7e359 Remove standalone deploy-coolify.yml — deploy is handled in ci.yaml
Some checks failed
CI / go-lint (pull_request) Failing after 3s
CI / python-lint (pull_request) Failing after 11s
CI / nodejs-lint (pull_request) Failing after 3s
CI / test-go-consent (pull_request) Failing after 3s
CI / test-python-voice (pull_request) Failing after 11s
CI / test-bqas (pull_request) Failing after 12s
CI / deploy-hetzner (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:26:34 +01:00
Sharang Parnerkar
cf2cabd098 Remove services not needed by SDK from Coolify deployment
Some checks failed
CI / go-lint (pull_request) Failing after 15s
CI / nodejs-lint (pull_request) Failing after 2s
CI / test-python-voice (pull_request) Failing after 11s
CI / deploy-hetzner (pull_request) Has been skipped
CI / python-lint (pull_request) Failing after 10s
CI / test-go-consent (pull_request) Failing after 2s
CI / test-bqas (pull_request) Failing after 10s
Deploy to Coolify / deploy (push) Has been cancelled
Remove backend-core, billing-service, night-scheduler, and admin-core
as they are not used by any compliance/SDK service. Update
health-aggregator CHECK_SERVICES to reference consent-service instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:59 +01:00
Sharang Parnerkar
8ee02bd2e4 Add healthchecks to backend-core, consent-service, billing-service, admin-core
Coolify/Traefik requires healthchecks to route traffic to containers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:59 +01:00
Sharang Parnerkar
d9687725e5 Remove Traefik labels from coolify compose — Coolify handles routing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:59 +01:00
Sharang Parnerkar
6c3911ca47 Fix admin-core build: ensure public directory exists before build
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:59 +01:00
Sharang Parnerkar
30807d1ce1 Fix backend-core TARGETARCH: auto-detect instead of hardcoded arm64
The Dockerfile hardcoded TARGETARCH=arm64 for Mac Mini. Coolify server
is x86_64, causing exit code 126 (wrong binary arch). Now uses Docker
BuildKit's auto-detected TARGETARCH with dpkg fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:59 +01:00
Sharang Parnerkar
82c28a2b6e Add QDRANT_API_KEY support to rag-service
- Add QDRANT_API_KEY to config.py (empty string = no auth)
- Pass api_key to QdrantClient constructor (None when empty)
- Add QDRANT_API_KEY to coolify compose and env example

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:59 +01:00
Sharang Parnerkar
86624d72dd Sync coolify compose with main: remove voice-service, update rag/embedding
- Remove voice-service (removed in main branch)
- Remove voice_session_data volume
- Add OLLAMA_URL and OLLAMA_EMBED_MODEL to rag-service
- Update embedding-service default model to BAAI/bge-m3, memory 4G→8G
- Update health-aggregator CHECK_SERVICES (remove voice-service)
- Update .env.coolify.example accordingly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:59 +01:00
Sharang Parnerkar
9218664400 fix: use Alpine-compatible addgroup/adduser flags in Dockerfiles
Replace --system/--gid/--uid (Debian syntax) with -S/-g/-u (BusyBox/Alpine).
Coolify ARG injection causes exit code 255 with Debian-style flags.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:59 +01:00
Sharang Parnerkar
8fa5d9061a refactor(coolify): externalize postgres, qdrant, S3; remove jitsi/synapse
- Remove PostgreSQL, Qdrant, MinIO services (managed separately in Coolify)
- Remove Jitsi stack (web, xmpp, jicofo, jvb) and Synapse/synapse-db
- Add POSTGRES_HOST, QDRANT_URL, S3_ENDPOINT/S3_ACCESS_KEY/S3_SECRET_KEY env vars
- Remove Traefik labels from internal-only services
- Health aggregator no longer checks external services
- Core now has 10 services: valkey + 9 application services

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:59 +01:00
Sharang Parnerkar
84002f5719 feat: add Coolify deployment configuration
Add docker-compose.coolify.yml (17 services), .env.coolify.example,
and Gitea Action workflow for Coolify API deployment. Removes nginx,
vault, gitea, woodpecker, mailpit, and dev-only services. Adds Traefik
labels for *.breakpilot.ai domain routing with Let's Encrypt SSL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:59 +01:00
Benjamin Admin
8b87b90cbb fix(qdrant): Increase ulimits for RocksDB (Too many open files)
All checks were successful
CI / nodejs-lint (push) Has been skipped
CI / test-python-voice (push) Successful in 33s
CI / test-bqas (push) Successful in 31s
CI / deploy-hetzner (push) Successful in 40s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 32s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:31:16 +01:00
Benjamin Admin
be45adb975 fix(rag): Auto-create Qdrant collection on first index
All checks were successful
CI / go-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 33s
CI / test-python-voice (push) Successful in 36s
CI / deploy-hetzner (push) Successful in 38s
CI / python-lint (push) Has been skipped
CI / test-bqas (push) Successful in 31s
Collections may not exist if init_collections() failed at startup
(e.g. Qdrant not ready). Now index_documents() ensures the
collection exists before upserting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:02:05 +01:00
Benjamin Admin
7c932c441f feat(rag): Add bp_compliance_gesetze + bp_compliance_ce collections
All checks were successful
CI / go-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 35s
CI / test-python-voice (push) Successful in 50s
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-bqas (push) Successful in 33s
CI / deploy-hetzner (push) Successful in 39s
Required for Verbraucherschutz + EU law ingestion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:41:26 +01:00
Benjamin Admin
1eb402b3da fix(ci): Remove Ollama host port binding — port 11434 already in use
All checks were successful
CI / nodejs-lint (push) Has been skipped
CI / test-python-voice (push) Successful in 33s
CI / test-bqas (push) Successful in 31s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 31s
CI / deploy-hetzner (push) Successful in 1m18s
Host already has Ollama running (LibreChat). Our container only needs
internal docker network access via container name.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:04:32 +01:00
Benjamin Admin
963e824328 fix(ci): Use external network + pre-create breakpilot-network
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-bqas (push) Successful in 30s
CI / deploy-hetzner (push) Failing after 15s
CI / test-go-consent (push) Successful in 33s
CI / test-python-voice (push) Successful in 35s
Network already exists from compliance project — use external: true
and pre-create with docker network create before docker compose up.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:01:17 +01:00
Benjamin Admin
c0782e0039 fix(ci): Fix backend-core TARGETARCH for amd64 + set -e in deploy
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 33s
CI / deploy-hetzner (push) Failing after 1m17s
CI / test-python-voice (push) Successful in 33s
CI / test-bqas (push) Successful in 33s
- backend-core Dockerfile defaults TARGETARCH=arm64, override with build arg
- Add set -e in helper container to fail fast on build errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:51:19 +01:00
Benjamin Admin
44d66e2d6c feat(ci): Add Hetzner deployment for Core services
All checks were successful
CI / test-go-consent (push) Successful in 32s
CI / test-python-voice (push) Successful in 35s
CI / test-bqas (push) Successful in 34s
CI / deploy-hetzner (push) Successful in 3m29s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
- docker-compose.hetzner.yml: Override for x86_64 (platform, ports,
  Ollama container for CPU embeddings, mailpit dummy, disabled services)
- CI: deploy-hetzner job using helper-container pattern
- Services: postgres, valkey, qdrant, ollama, backend-core, consent-service,
  rag-service, embedding-service, health-aggregator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:42:41 +01:00
Benjamin Admin
f9b475db8f fix: Ensure public/ dir exists in Docker build for levis-holzbau
All checks were successful
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 38s
CI / test-python-voice (push) Successful in 35s
CI / test-bqas (push) Successful in 38s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:06:54 +01:00
Benjamin Admin
0770ff499b feat: Add LEVIS Holzbau — Kinder-Holzwerk-Website (Port 3013)
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 39s
CI / test-python-voice (push) Successful in 37s
CI / test-bqas (push) Successful in 37s
Neue statische Website fuer Kinder (6-12 Jahre) mit 8 Holzprojekten,
SVG-Illustrationen, Sicherheitshinweisen und kindgerechtem Design.
Next.js 15 + Tailwind + Framer Motion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 10:03:21 +01:00
Benjamin Admin
32aade553d Switch MinIO from local to Hetzner Object Storage
Migrate rag-service S3 config from local MinIO (minio:9000) to
Hetzner Object Storage (nbg1.your-objectstorage.com) with HTTPS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:07:26 +01:00
43 changed files with 3979 additions and 9 deletions

65
.env.coolify.example Normal file
View File

@@ -0,0 +1,65 @@
# =========================================================
# BreakPilot Core — Coolify Environment Variables
# =========================================================
# Copy these into Coolify's environment variable UI
# for the breakpilot-core Docker Compose resource.
# =========================================================
# --- External PostgreSQL (Coolify-managed) ---
POSTGRES_HOST=<coolify-postgres-hostname>
POSTGRES_PORT=5432
POSTGRES_USER=breakpilot
POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD
POSTGRES_DB=breakpilot_db
# --- Security ---
JWT_SECRET=CHANGE_ME_RANDOM_64_CHARS
JWT_REFRESH_SECRET=CHANGE_ME_ANOTHER_RANDOM_64_CHARS
INTERNAL_API_KEY=CHANGE_ME_INTERNAL_KEY
# --- External S3 Storage ---
S3_ENDPOINT=<s3-endpoint-host:port>
S3_ACCESS_KEY=CHANGE_ME_S3_ACCESS_KEY
S3_SECRET_KEY=CHANGE_ME_S3_SECRET_KEY
S3_BUCKET=breakpilot-rag
S3_SECURE=true
# --- External Qdrant (Coolify-managed) ---
QDRANT_URL=http://<coolify-qdrant-hostname>:6333
QDRANT_API_KEY=
# --- SMTP (Real mail server) ---
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=noreply@breakpilot.ai
SMTP_PASSWORD=CHANGE_ME_SMTP_PASSWORD
SMTP_FROM_NAME=BreakPilot
SMTP_FROM_ADDR=noreply@breakpilot.ai
# --- Session ---
SESSION_TTL_HOURS=24
# --- Frontend URLs (build args) ---
NEXT_PUBLIC_CORE_API_URL=https://api-core.breakpilot.ai
FRONTEND_URL=https://www.breakpilot.ai
# --- Stripe (Billing) ---
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_PUBLISHABLE_KEY=
BILLING_SUCCESS_URL=https://www.breakpilot.ai/billing/success
BILLING_CANCEL_URL=https://www.breakpilot.ai/billing/cancel
TRIAL_PERIOD_DAYS=14
# --- Embedding Service ---
EMBEDDING_BACKEND=local
LOCAL_EMBEDDING_MODEL=BAAI/bge-m3
LOCAL_RERANKER_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
PDF_EXTRACTION_BACKEND=pymupdf
OPENAI_API_KEY=
COHERE_API_KEY=
LOG_LEVEL=INFO
# --- Ollama (optional, for RAG embeddings) ---
OLLAMA_URL=
OLLAMA_EMBED_MODEL=bge-m3

View File

@@ -138,3 +138,22 @@ jobs:
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
pip install --quiet --no-cache-dir fastapi uvicorn pydantic pytest pytest-asyncio pip install --quiet --no-cache-dir fastapi uvicorn pydantic pytest pytest-asyncio
python -m pytest tests/bqas/ -v --tb=short || true python -m pytest tests/bqas/ -v --tb=short || true
# ========================================
# Deploy via Coolify (nur main, kein PR)
# ========================================
deploy-coolify:
name: Deploy
runs-on: docker
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- test-go-consent
container:
image: alpine:latest
steps:
- name: Trigger Coolify deploy
run: |
apk add --no-cache curl
curl -sf "${{ secrets.COOLIFY_WEBHOOK }}" \
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"

View File

@@ -0,0 +1,29 @@
name: Deploy to Coolify
on:
push:
branches:
- coolify
jobs:
deploy:
runs-on: docker
container: alpine:latest
steps:
- name: Deploy via Coolify API
run: |
apk add --no-cache curl
echo "Deploying breakpilot-core to Coolify..."
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST \
-H "Authorization: Bearer ${{ secrets.COOLIFY_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"uuid": "${{ secrets.COOLIFY_RESOURCE_UUID }}", "force_rebuild": true}' \
"${{ secrets.COOLIFY_BASE_URL }}/api/v1/deploy")
echo "HTTP Status: $HTTP_STATUS"
if [ "$HTTP_STATUS" -ne 200 ] && [ "$HTTP_STATUS" -ne 201 ]; then
echo "Deployment failed with status $HTTP_STATUS"
exit 1
fi
echo "Deployment triggered successfully!"

View File

@@ -18,6 +18,9 @@ ARG NEXT_PUBLIC_API_URL
# Set environment variables for build # Set environment variables for build
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
# Ensure public directory exists
RUN mkdir -p public
# Build the application # Build the application
RUN npm run build RUN npm run build
@@ -30,8 +33,8 @@ WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
# Create non-root user # Create non-root user
RUN addgroup --system --gid 1001 nodejs RUN addgroup -S -g 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser -S -u 1001 -G nodejs nextjs
# Copy built assets # Copy built assets
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public

View File

@@ -43,11 +43,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install DevSecOps tools (gitleaks, trivy, grype, syft) # Install DevSecOps tools (gitleaks, trivy, grype, syft)
ARG TARGETARCH=arm64 ARG TARGETARCH
RUN set -eux; \ RUN set -eux; \
ARCH="${TARGETARCH:-$(dpkg --print-architecture)}"; \
# Gitleaks # Gitleaks
GITLEAKS_VERSION=8.21.2; \ GITLEAKS_VERSION=8.21.2; \
if [ "$TARGETARCH" = "arm64" ]; then GITLEAKS_ARCH=arm64; else GITLEAKS_ARCH=x64; fi; \ if [ "$ARCH" = "arm64" ]; then GITLEAKS_ARCH=arm64; else GITLEAKS_ARCH=x64; fi; \
curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_${GITLEAKS_ARCH}.tar.gz" \ curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_${GITLEAKS_ARCH}.tar.gz" \
| tar xz -C /usr/local/bin gitleaks; \ | tar xz -C /usr/local/bin gitleaks; \
# Trivy # Trivy

197
docker-compose.coolify.yml Normal file
View File

@@ -0,0 +1,197 @@
# =========================================================
# BreakPilot Core — Shared Infrastructure (Coolify)
# =========================================================
# Deployed via Coolify. SSL termination handled by Traefik.
# External services (managed separately in Coolify):
# - PostgreSQL (PostGIS), Qdrant, S3-compatible storage
# Network: breakpilot-network (shared across all 3 repos)
# =========================================================
networks:
breakpilot-network:
name: breakpilot-network
driver: bridge
volumes:
valkey_data:
embedding_models:
paddleocr_models:
services:
# =========================================================
# CACHE
# =========================================================
valkey:
image: valkey/valkey:8-alpine
container_name: bp-core-valkey
volumes:
- valkey_data:/data
command: valkey-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
networks:
- breakpilot-network
# =========================================================
# SHARED SERVICES
# =========================================================
consent-service:
build:
context: ./consent-service
dockerfile: Dockerfile
container_name: bp-core-consent-service
expose:
- "8081"
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT:-5432}/${POSTGRES_DB}
JWT_SECRET: ${JWT_SECRET}
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET}
PORT: 8081
ENVIRONMENT: production
ALLOWED_ORIGINS: "*"
VALKEY_URL: redis://valkey:6379/0
SESSION_TTL_HOURS: ${SESSION_TTL_HOURS:-24}
SMTP_HOST: ${SMTP_HOST}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USERNAME: ${SMTP_USERNAME}
SMTP_PASSWORD: ${SMTP_PASSWORD}
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-BreakPilot}
SMTP_FROM_ADDR: ${SMTP_FROM_ADDR:-noreply@breakpilot.ai}
FRONTEND_URL: ${FRONTEND_URL:-https://www.breakpilot.ai}
depends_on:
valkey:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8081/health"]
interval: 30s
timeout: 10s
start_period: 15s
retries: 3
restart: unless-stopped
networks:
- breakpilot-network
# =========================================================
# RAG & EMBEDDING SERVICES
# =========================================================
rag-service:
build:
context: ./rag-service
dockerfile: Dockerfile
container_name: bp-core-rag-service
expose:
- "8097"
environment:
PORT: 8097
QDRANT_URL: ${QDRANT_URL}
QDRANT_API_KEY: ${QDRANT_API_KEY:-}
MINIO_ENDPOINT: ${S3_ENDPOINT}
MINIO_ACCESS_KEY: ${S3_ACCESS_KEY}
MINIO_SECRET_KEY: ${S3_SECRET_KEY}
MINIO_BUCKET: ${S3_BUCKET:-breakpilot-rag}
MINIO_SECURE: ${S3_SECURE:-true}
EMBEDDING_SERVICE_URL: http://embedding-service:8087
OLLAMA_URL: ${OLLAMA_URL:-}
OLLAMA_EMBED_MODEL: ${OLLAMA_EMBED_MODEL:-bge-m3}
JWT_SECRET: ${JWT_SECRET}
ENVIRONMENT: production
depends_on:
embedding-service:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8097/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
restart: unless-stopped
networks:
- breakpilot-network
embedding-service:
build:
context: ./embedding-service
dockerfile: Dockerfile
container_name: bp-core-embedding-service
volumes:
- embedding_models:/root/.cache/huggingface
environment:
EMBEDDING_BACKEND: ${EMBEDDING_BACKEND:-local}
LOCAL_EMBEDDING_MODEL: ${LOCAL_EMBEDDING_MODEL:-BAAI/bge-m3}
LOCAL_RERANKER_MODEL: ${LOCAL_RERANKER_MODEL:-cross-encoder/ms-marco-MiniLM-L-6-v2}
PDF_EXTRACTION_BACKEND: ${PDF_EXTRACTION_BACKEND:-pymupdf}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
COHERE_API_KEY: ${COHERE_API_KEY:-}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
deploy:
resources:
limits:
memory: 8G
healthcheck:
test: ["CMD", "python", "-c", "import httpx; r=httpx.get('http://127.0.0.1:8087/health'); r.raise_for_status()"]
interval: 30s
timeout: 10s
start_period: 120s
retries: 3
restart: unless-stopped
networks:
- breakpilot-network
# =========================================================
# OCR SERVICE (PaddleOCR PP-OCRv5)
# =========================================================
paddleocr-service:
build:
context: ./paddleocr-service
dockerfile: Dockerfile
container_name: bp-core-paddleocr
expose:
- "8095"
environment:
PADDLEOCR_API_KEY: ${PADDLEOCR_API_KEY:-}
FLAGS_use_mkldnn: "0"
volumes:
- paddleocr_models:/root/.paddleocr
labels:
- "traefik.http.services.paddleocr.loadbalancer.server.port=8095"
deploy:
resources:
limits:
memory: 6G
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8095/health"]
interval: 30s
timeout: 10s
start_period: 300s
retries: 5
restart: unless-stopped
networks:
- breakpilot-network
# =========================================================
# HEALTH AGGREGATOR
# =========================================================
health-aggregator:
build:
context: ./scripts
dockerfile: Dockerfile.health
container_name: bp-core-health
expose:
- "8099"
environment:
PORT: 8099
CHECK_SERVICES: "valkey:6379,consent-service:8081,rag-service:8097,embedding-service:8087,paddleocr-service:8095"
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8099/health"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
networks:
- breakpilot-network

175
docker-compose.hetzner.yml Normal file
View File

@@ -0,0 +1,175 @@
# =========================================================
# BreakPilot Core — Hetzner Override (x86_64)
# =========================================================
# Verwendung:
# docker compose -f docker-compose.yml -f docker-compose.hetzner.yml up -d \
# postgres valkey qdrant ollama embedding-service rag-service \
# backend-core consent-service health-aggregator
#
# Aenderungen gegenueber Basis (docker-compose.yml):
# - platform: linux/amd64 (statt arm64)
# - Ollama Container fuer CPU-Embeddings (bge-m3)
# - Mailpit ersetzt durch Dummy (kein Mail-Dev-Server noetig)
# - Vault, Nginx, Gitea etc. deaktiviert via Profile
# - Netzwerk: auto-create (nicht external)
# =========================================================
networks:
breakpilot-network:
external: true
name: breakpilot-network
services:
# =========================================================
# NEUE SERVICES
# =========================================================
# Ollama fuer Embeddings (CPU-only, bge-m3)
ollama:
image: ollama/ollama:latest
container_name: bp-core-ollama
platform: linux/amd64
volumes:
- ollama_models:/root/.ollama
healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:11434/api/tags || exit 1"]
interval: 15s
timeout: 10s
retries: 5
start_period: 30s
restart: unless-stopped
networks:
- breakpilot-network
# =========================================================
# PLATFORM OVERRIDES (arm64 → amd64)
# =========================================================
backend-core:
platform: linux/amd64
build:
context: ./backend-core
dockerfile: Dockerfile
args:
TARGETARCH: amd64
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-breakpilot}:${POSTGRES_PASSWORD:-breakpilot123}@postgres:5432/${POSTGRES_DB:-breakpilot_db}?options=-csearch_path%3Dcore,public
JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
ENVIRONMENT: ${ENVIRONMENT:-production}
VALKEY_URL: redis://valkey:6379/0
SESSION_TTL_HOURS: ${SESSION_TTL_HOURS:-24}
CONSENT_SERVICE_URL: http://consent-service:8081
USE_VAULT_SECRETS: "false"
SMTP_HOST: ${SMTP_HOST:-smtp.example.com}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USERNAME: ${SMTP_USERNAME:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-BreakPilot}
SMTP_FROM_ADDR: ${SMTP_FROM_ADDR:-noreply@breakpilot.app}
consent-service:
platform: linux/amd64
environment:
DATABASE_URL: postgres://${POSTGRES_USER:-breakpilot}:${POSTGRES_PASSWORD:-breakpilot123}@postgres:5432/${POSTGRES_DB:-breakpilot_db}
JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-your-refresh-secret}
PORT: 8081
ENVIRONMENT: ${ENVIRONMENT:-production}
ALLOWED_ORIGINS: "*"
VALKEY_URL: redis://valkey:6379/0
SESSION_TTL_HOURS: ${SESSION_TTL_HOURS:-24}
SMTP_HOST: ${SMTP_HOST:-smtp.example.com}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USERNAME: ${SMTP_USERNAME:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-BreakPilot}
SMTP_FROM_ADDR: ${SMTP_FROM_ADDR:-noreply@breakpilot.app}
FRONTEND_URL: ${FRONTEND_URL:-https://admin-dev.breakpilot.ai}
billing-service:
platform: linux/amd64
rag-service:
platform: linux/amd64
ports:
- "8097:8097"
environment:
PORT: 8097
QDRANT_URL: http://qdrant:6333
MINIO_ENDPOINT: nbg1.your-objectstorage.com
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-T18RGFVXXG2ZHQ5404TP}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-KOUU4WO6wh07cQjNgh0IZHkeKQrVfBz6hnIGpNss}
MINIO_BUCKET: ${MINIO_BUCKET:-breakpilot-rag}
MINIO_SECURE: "true"
EMBEDDING_SERVICE_URL: http://embedding-service:8087
OLLAMA_URL: http://ollama:11434
OLLAMA_EMBED_MODEL: ${OLLAMA_EMBED_MODEL:-bge-m3}
JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
ENVIRONMENT: ${ENVIRONMENT:-production}
embedding-service:
platform: linux/amd64
ports:
- "8087:8087"
health-aggregator:
platform: linux/amd64
environment:
PORT: 8099
CHECK_SERVICES: "postgres:5432,valkey:6379,qdrant:6333,backend-core:8000,rag-service:8097,embedding-service:8087"
# =========================================================
# DUMMY-ERSATZ FUER ABHAENGIGKEITEN
# =========================================================
# backend-core + consent-service haengen von mailpit ab
# (depends_on merged bei compose override, kann nicht entfernt werden)
# → Mailpit durch leichtgewichtigen Dummy ersetzen
mailpit:
image: alpine:3.19
entrypoint: ["sh", "-c", "echo 'Mailpit dummy on Hetzner' && tail -f /dev/null"]
volumes: []
ports: []
environment: {}
# Qdrant: RocksDB braucht mehr open files
qdrant:
ulimits:
nofile:
soft: 65536
hard: 65536
# minio: rag-service haengt davon ab (depends_on)
# Lokal laufen lassen, aber rag-service nutzt externe Hetzner Object Storage
# minio bleibt unveraendert (klein, ~50MB RAM)
# =========================================================
# DEAKTIVIERTE SERVICES (via profiles)
# =========================================================
nginx:
profiles: ["disabled"]
vault:
profiles: ["disabled"]
vault-init:
profiles: ["disabled"]
vault-agent:
profiles: ["disabled"]
gitea:
profiles: ["disabled"]
gitea-runner:
profiles: ["disabled"]
night-scheduler:
profiles: ["disabled"]
admin-core:
profiles: ["disabled"]
pitch-deck:
profiles: ["disabled"]
levis-holzbau:
profiles: ["disabled"]
volumes:
ollama_models:

View File

@@ -347,11 +347,11 @@ services:
environment: environment:
PORT: 8097 PORT: 8097
QDRANT_URL: http://qdrant:6333 QDRANT_URL: http://qdrant:6333
MINIO_ENDPOINT: minio:9000 MINIO_ENDPOINT: nbg1.your-objectstorage.com
MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-breakpilot} MINIO_ACCESS_KEY: T18RGFVXXG2ZHQ5404TP
MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-breakpilot123} MINIO_SECRET_KEY: KOUU4WO6wh07cQjNgh0IZHkeKQrVfBz6hnIGpNss
MINIO_BUCKET: ${MINIO_BUCKET:-breakpilot-rag} MINIO_BUCKET: ${MINIO_BUCKET:-breakpilot-rag}
MINIO_SECURE: "false" MINIO_SECURE: "true"
EMBEDDING_SERVICE_URL: http://embedding-service:8087 EMBEDDING_SERVICE_URL: http://embedding-service:8087
OLLAMA_URL: ${OLLAMA_URL:-http://host.docker.internal:11434} OLLAMA_URL: ${OLLAMA_URL:-http://host.docker.internal:11434}
OLLAMA_EMBED_MODEL: ${OLLAMA_EMBED_MODEL:-bge-m3} OLLAMA_EMBED_MODEL: ${OLLAMA_EMBED_MODEL:-bge-m3}
@@ -843,3 +843,20 @@ services:
restart: unless-stopped restart: unless-stopped
networks: networks:
- breakpilot-network - breakpilot-network
# =========================================================
# LEVIS HOLZBAU - Kinder-Holzwerk-Website
# =========================================================
levis-holzbau:
build:
context: ./levis-holzbau
dockerfile: Dockerfile
container_name: bp-core-levis-holzbau
platform: linux/arm64
ports:
- "3013:3000"
environment:
NODE_ENV: production
restart: unless-stopped
networks:
- breakpilot-network

View File

@@ -0,0 +1,5 @@
node_modules
.next
.git
Dockerfile
.dockerignore

27
levis-holzbau/Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
FROM node:20-alpine AS base
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN mkdir -p public
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View File

@@ -0,0 +1,25 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Quicksand:wght@500;600;700&family=Nunito:wght@400;600;700&display=swap');
html {
scroll-behavior: smooth;
}
body {
font-family: 'Nunito', sans-serif;
background-color: #FDF8F0;
color: #2C2C2C;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Quicksand', sans-serif;
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}

View File

@@ -0,0 +1,21 @@
import type { Metadata } from 'next'
import './globals.css'
import { Navbar } from '@/components/Navbar'
import { Footer } from '@/components/Footer'
export const metadata: Metadata = {
title: 'LEVIS Holzbau — Kinder-Holzwerkstatt',
description: 'Lerne Holzfiguren schnitzen und kleine Holzprojekte bauen! Kindgerechte Anleitungen fuer junge Holzwerker.',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="de">
<body className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1">{children}</main>
<Footer />
</body>
</html>
)
}

View File

@@ -0,0 +1,71 @@
'use client'
import { motion } from 'framer-motion'
import { Hammer, TreePine, ShieldCheck } from 'lucide-react'
import { HeroSection } from '@/components/HeroSection'
import { ProjectCard } from '@/components/ProjectCard'
import { projects } from '@/lib/projects'
const features = [
{
icon: Hammer,
title: 'Schnitzen',
description: 'Lerne mit Schnitzmesser und Holz umzugehen und forme eigene Figuren.',
color: 'bg-primary/10 text-primary',
},
{
icon: TreePine,
title: 'Bauen',
description: 'Saege, leime und nagle — baue nuetzliche Dinge aus Holz!',
color: 'bg-secondary/10 text-secondary',
},
{
icon: ShieldCheck,
title: 'Sicherheit',
description: 'Jedes Projekt zeigt dir, wie du sicher mit Werkzeug arbeitest.',
color: 'bg-accent/10 text-accent',
},
]
export default function HomePage() {
const featured = projects.slice(0, 4)
return (
<>
<HeroSection />
{/* Features */}
<section className="max-w-6xl mx-auto px-4 py-16">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
{features.map((f, i) => (
<motion.div
key={f.title}
className="bg-white rounded-2xl p-6 shadow-sm border border-primary/5 text-center"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
>
<div className={`w-14 h-14 rounded-xl ${f.color} flex items-center justify-center mx-auto mb-4`}>
<f.icon className="w-7 h-7" />
</div>
<h3 className="font-heading font-bold text-lg mb-2">{f.title}</h3>
<p className="text-sm text-dark/60">{f.description}</p>
</motion.div>
))}
</div>
</section>
{/* Popular Projects */}
<section className="max-w-6xl mx-auto px-4 pb-16">
<h2 className="font-heading font-bold text-3xl text-center mb-8">
Beliebte Projekte
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{featured.map((p) => (
<ProjectCard key={p.slug} project={p} />
))}
</div>
</section>
</>
)
}

View File

@@ -0,0 +1,120 @@
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, Clock, Wrench, Package } from 'lucide-react'
import { projects, getProject, getRelatedProjects } from '@/lib/projects'
import { DifficultyBadge } from '@/components/DifficultyBadge'
import { AgeBadge } from '@/components/AgeBadge'
import { StepCard } from '@/components/StepCard'
import { SafetyTip } from '@/components/SafetyTip'
import { ToolIcon } from '@/components/ToolIcon'
import { ProjectIllustration } from '@/components/ProjectIllustration'
import { ProjectCard } from '@/components/ProjectCard'
export function generateStaticParams() {
return projects.map((p) => ({ slug: p.slug }))
}
export default async function ProjectPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const project = getProject(slug)
if (!project) notFound()
const related = getRelatedProjects(slug)
return (
<div className="max-w-4xl mx-auto px-4 py-8">
{/* Back */}
<Link href="/projekte" className="inline-flex items-center gap-1 text-accent hover:underline mb-6 text-sm font-semibold">
<ArrowLeft className="w-4 h-4" /> Alle Projekte
</Link>
{/* Hero */}
<div className="bg-white rounded-2xl shadow-sm border border-primary/5 overflow-hidden mb-8">
<div className="bg-cream p-10 flex items-center justify-center">
<ProjectIllustration slug={project.slug} size={180} />
</div>
<div className="p-6 sm:p-8">
<div className="flex flex-wrap items-center gap-3 mb-3">
<AgeBadge range={project.ageRange} />
<DifficultyBadge level={project.difficulty} />
<span className="flex items-center gap-1 text-sm text-dark/50">
<Clock className="w-4 h-4" /> {project.duration}
</span>
</div>
<h1 className="font-heading font-bold text-3xl sm:text-4xl mb-3">{project.name}</h1>
<p className="text-dark/70 text-lg leading-relaxed">{project.description}</p>
</div>
</div>
{/* Tools & Materials */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
<div className="bg-white rounded-2xl p-6 border border-primary/5">
<h2 className="font-heading font-bold text-lg flex items-center gap-2 mb-4">
<Wrench className="w-5 h-5 text-primary" /> Werkzeuge
</h2>
<ul className="space-y-2">
{project.tools.map((t) => (
<li key={t} className="flex items-center gap-2 text-sm">
<ToolIcon name={t} />
{t}
</li>
))}
</ul>
</div>
<div className="bg-white rounded-2xl p-6 border border-primary/5">
<h2 className="font-heading font-bold text-lg flex items-center gap-2 mb-4">
<Package className="w-5 h-5 text-secondary" /> Material
</h2>
<ul className="space-y-2">
{project.materials.map((m) => (
<li key={m} className="flex items-center gap-2 text-sm">
<span className="w-2 h-2 rounded-full bg-secondary flex-shrink-0" />
{m}
</li>
))}
</ul>
</div>
</div>
{/* Safety */}
<div className="space-y-3 mb-10">
<h2 className="font-heading font-bold text-xl mb-2">Sicherheitshinweise</h2>
{project.safetyTips.map((tip) => (
<SafetyTip key={tip}>{tip}</SafetyTip>
))}
</div>
{/* Steps */}
<div className="mb-10">
<h2 className="font-heading font-bold text-xl mb-6">Schritt fuer Schritt</h2>
<div className="space-y-0">
{project.steps.map((step, i) => (
<StepCard key={i} step={step} index={i} />
))}
</div>
</div>
{/* Skills */}
<div className="bg-secondary/5 rounded-2xl p-6 mb-12">
<h2 className="font-heading font-bold text-xl mb-3">Was du lernst</h2>
<div className="flex flex-wrap gap-2">
{project.skills.map((s) => (
<span key={s} className="px-3 py-1.5 bg-secondary/10 text-secondary rounded-full text-sm font-semibold">
{s}
</span>
))}
</div>
</div>
{/* Related */}
<div>
<h2 className="font-heading font-bold text-xl mb-6">Aehnliche Projekte</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{related.map((p) => (
<ProjectCard key={p.slug} project={p} />
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,59 @@
'use client'
import { useState } from 'react'
import { motion } from 'framer-motion'
import { ProjectCard } from '@/components/ProjectCard'
import { projects } from '@/lib/projects'
const filters = [
{ label: 'Alle', value: 0 },
{ label: 'Anfaenger', value: 1 },
{ label: 'Fortgeschritten', value: 2 },
{ label: 'Profi', value: 3 },
]
export default function ProjektePage() {
const [filter, setFilter] = useState(0)
const filtered = filter === 0 ? projects : projects.filter((p) => p.difficulty === filter)
return (
<div className="max-w-6xl mx-auto px-4 py-12">
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="text-center mb-10"
>
<h1 className="font-heading font-bold text-4xl mb-3">Alle Projekte</h1>
<p className="text-dark/60 text-lg">Waehle ein Projekt und leg los!</p>
</motion.div>
{/* Filter */}
<div className="flex justify-center gap-2 mb-10">
{filters.map((f) => (
<button
key={f.value}
onClick={() => setFilter(f.value as 0 | 1 | 2 | 3)}
className={`px-4 py-2 rounded-xl font-semibold text-sm transition-colors ${
filter === f.value
? 'bg-primary text-white'
: 'bg-white text-dark/60 hover:bg-primary/5'
}`}
>
{f.label}
</button>
))}
</div>
{/* Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{filtered.map((p) => (
<ProjectCard key={p.slug} project={p} />
))}
</div>
{filtered.length === 0 && (
<p className="text-center text-dark/40 mt-12">Keine Projekte in dieser Kategorie.</p>
)}
</div>
)
}

View File

@@ -0,0 +1,101 @@
'use client'
import { motion } from 'framer-motion'
import { ShieldCheck, Eye, Hand, Scissors, AlertTriangle, Users } from 'lucide-react'
import { SafetyTip } from '@/components/SafetyTip'
const rules = [
{ icon: Users, title: 'Immer mit Erwachsenen', text: 'Bei Saegen, Bohren und Schnitzen muss immer ein Erwachsener dabei sein.' },
{ icon: Hand, title: 'Vom Koerper weg', text: 'Schnitze, saege und schneide immer vom Koerper weg. So kannst du dich nicht verletzen.' },
{ icon: Eye, title: 'Schutzbrille tragen', text: 'Beim Saegen und Schleifen fliegen Spaene — eine Schutzbrille schuetzt deine Augen.' },
{ icon: Scissors, title: 'Werkzeug richtig halten', text: 'Greife Werkzeuge immer am Griff. Trage Messer und Saegen mit der Spitze nach unten.' },
{ icon: AlertTriangle, title: 'Aufgeraeumter Arbeitsplatz', text: 'Raeume Werkzeug nach dem Benutzen weg. Ein ordentlicher Platz ist ein sicherer Platz!' },
{ icon: ShieldCheck, title: 'Scharfes Werkzeug', text: 'Klingt komisch, aber: Scharfe Messer sind sicherer als stumpfe, weil du weniger Kraft brauchst.' },
]
const toolGuides = [
{ name: 'Schnitzmesser', age: 'Ab 6 Jahren (mit Hilfe)', tips: ['Immer vom Koerper weg schnitzen', 'Nach dem Benutzen zuklappen', 'Weiches Holz (Linde) verwenden'] },
{ name: 'Handsaege', age: 'Ab 7 Jahren (mit Hilfe)', tips: ['Holz immer fest einspannen', 'Langsam und gleichmaessig saegen', 'Nicht auf die Klinge druecken'] },
{ name: 'Hammer', age: 'Ab 5 Jahren', tips: ['Leichten Kinderhammer verwenden', 'Naegel mit Zange halten, nie mit Fingern', 'Auf stabile Unterlage achten'] },
{ name: 'Schleifpapier', age: 'Ab 5 Jahren', tips: ['Immer in eine Richtung schleifen', 'Staub nicht einatmen', 'Erst grob, dann fein'] },
{ name: 'Holzleim', age: 'Ab 5 Jahren', tips: ['Nicht giftig, aber nicht essen', 'Duenn auftragen reicht', 'Mindestens 1 Stunde trocknen lassen'] },
]
export default function SicherheitPage() {
return (
<div className="max-w-4xl mx-auto px-4 py-12">
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="text-center mb-12"
>
<div className="w-16 h-16 bg-warning/10 rounded-2xl flex items-center justify-center mx-auto mb-4">
<ShieldCheck className="w-8 h-8 text-warning" />
</div>
<h1 className="font-heading font-bold text-4xl mb-3">Sicherheit geht vor!</h1>
<p className="text-dark/60 text-lg max-w-2xl mx-auto">
Holzarbeiten macht riesig Spass aber nur, wenn du sicher arbeitest.
Hier findest du die wichtigsten Regeln.
</p>
</motion.div>
{/* Rules Grid */}
<section className="mb-16">
<h2 className="font-heading font-bold text-2xl mb-6">Die goldenen Regeln</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{rules.map((r, i) => (
<motion.div
key={r.title}
className="bg-white rounded-2xl p-5 border border-primary/5 flex gap-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
>
<div className="w-10 h-10 bg-warning/10 rounded-xl flex items-center justify-center flex-shrink-0">
<r.icon className="w-5 h-5 text-warning" />
</div>
<div>
<h3 className="font-heading font-bold mb-1">{r.title}</h3>
<p className="text-sm text-dark/60">{r.text}</p>
</div>
</motion.div>
))}
</div>
</section>
{/* Tool Guides */}
<section className="mb-16">
<h2 className="font-heading font-bold text-2xl mb-6">Werkzeug-Guide</h2>
<div className="space-y-4">
{toolGuides.map((tool) => (
<div key={tool.name} className="bg-white rounded-2xl p-5 border border-primary/5">
<div className="flex items-center justify-between mb-3">
<h3 className="font-heading font-bold text-lg">{tool.name}</h3>
<span className="text-xs font-semibold bg-accent/10 text-accent px-2.5 py-1 rounded-full">{tool.age}</span>
</div>
<ul className="space-y-1.5">
{tool.tips.map((tip) => (
<li key={tip} className="flex items-center gap-2 text-sm text-dark/70">
<span className="w-1.5 h-1.5 rounded-full bg-primary flex-shrink-0" />
{tip}
</li>
))}
</ul>
</div>
))}
</div>
</section>
{/* Parents */}
<section>
<h2 className="font-heading font-bold text-2xl mb-4">Hinweise fuer Eltern</h2>
<div className="space-y-3">
<SafetyTip>Beaufsichtigen Sie Ihr Kind bei allen Projekten besonders beim Umgang mit Schneidwerkzeugen.</SafetyTip>
<SafetyTip>Stellen Sie altersgerechtes Werkzeug bereit. Kinderschnitzmesser haben abgerundete Spitzen.</SafetyTip>
<SafetyTip>Richten Sie einen festen Arbeitsplatz ein idealerweise auf einer stabilen Werkbank oder einem alten Tisch.</SafetyTip>
<SafetyTip>Leinoel und Acrylfarben sind fuer Kinder unbedenklich. Vermeiden Sie Lacke mit Loesungsmitteln.</SafetyTip>
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,83 @@
'use client'
import { motion } from 'framer-motion'
import { TreePine, Heart, Sparkles, Users } from 'lucide-react'
import Link from 'next/link'
const reasons = [
{ icon: Sparkles, title: 'Kreativitaet', text: 'Du kannst dir selbst ausdenken, was du baust — und es dann wirklich machen!' },
{ icon: Heart, title: 'Stolz', text: 'Wenn du etwas mit deinen eigenen Haenden baust, macht dich das richtig stolz.' },
{ icon: TreePine, title: 'Natur', text: 'Holz ist ein natuerliches Material. Du lernst die Natur besser kennen.' },
{ icon: Users, title: 'Zusammen', text: 'Holzarbeiten macht zusammen mit Freunden oder der Familie am meisten Spass!' },
]
export default function UeberPage() {
return (
<div className="max-w-4xl mx-auto px-4 py-12">
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="text-center mb-12"
>
<h1 className="font-heading font-bold text-4xl mb-3">Ueber LEVIS Holzbau</h1>
<p className="text-dark/60 text-lg max-w-2xl mx-auto">
Wir zeigen dir, wie du aus einem einfachen Stueck Holz etwas Tolles machen kannst!
</p>
</motion.div>
{/* Story */}
<div className="bg-white rounded-2xl p-6 sm:p-8 border border-primary/5 mb-12">
<h2 className="font-heading font-bold text-2xl mb-4">Was ist LEVIS Holzbau?</h2>
<div className="space-y-4 text-dark/70 leading-relaxed">
<p>
LEVIS Holzbau ist deine Online-Holzwerkstatt! Hier findest du Anleitungen fuer tolle Projekte
aus Holz vom einfachen Zauberstab bis zum echten Vogelhaus.
</p>
<p>
Jedes Projekt erklaert dir Schritt fuer Schritt, was du tun musst. Du siehst welches Werkzeug
und Material du brauchst, und wir zeigen dir immer, worauf du bei der Sicherheit achten musst.
</p>
<p>
Egal ob du 6 oder 12 Jahre alt bist fuer jedes Alter gibt es passende Projekte.
Faengst du gerade erst an? Dann probier den Zauberstab oder die Nagelbilder. Bist du
schon ein Profi? Dann trau dich an den Fliegenpilz!
</p>
</div>
</div>
{/* Why woodworking */}
<h2 className="font-heading font-bold text-2xl mb-6 text-center">Warum Holzarbeiten Spass macht</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-12">
{reasons.map((r, i) => (
<motion.div
key={r.title}
className="bg-white rounded-2xl p-5 border border-primary/5 flex gap-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
>
<div className="w-10 h-10 bg-secondary/10 rounded-xl flex items-center justify-center flex-shrink-0">
<r.icon className="w-5 h-5 text-secondary" />
</div>
<div>
<h3 className="font-heading font-bold mb-1">{r.title}</h3>
<p className="text-sm text-dark/60">{r.text}</p>
</div>
</motion.div>
))}
</div>
{/* CTA */}
<div className="text-center bg-gradient-to-br from-primary/5 to-secondary/5 rounded-2xl p-8">
<h2 className="font-heading font-bold text-2xl mb-3">Bereit loszulegen?</h2>
<p className="text-dark/60 mb-6">Schau dir unsere Projekte an und such dir eins aus!</p>
<Link
href="/projekte"
className="inline-flex items-center gap-2 bg-primary hover:bg-primary/90 text-white font-bold px-8 py-3 rounded-2xl transition-colors"
>
Zu den Projekten
</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,7 @@
export function AgeBadge({ range }: { range: string }) {
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-accent/10 text-accent">
{range} Jahre
</span>
)
}

View File

@@ -0,0 +1,15 @@
import { Hammer } from 'lucide-react'
export function DifficultyBadge({ level }: { level: 1 | 2 | 3 }) {
const labels = ['Anfaenger', 'Fortgeschritten', 'Profi']
return (
<div className="flex items-center gap-1" title={labels[level - 1]}>
{Array.from({ length: 3 }).map((_, i) => (
<Hammer
key={i}
className={`w-4 h-4 ${i < level ? 'text-primary' : 'text-gray-300'}`}
/>
))}
</div>
)
}

View File

@@ -0,0 +1,17 @@
import { Heart } from 'lucide-react'
import { Logo } from './Logo'
export function Footer() {
return (
<footer className="bg-white border-t border-primary/10 mt-16">
<div className="max-w-6xl mx-auto px-4 py-8">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<Logo size={32} />
<p className="text-sm text-dark/50 flex items-center gap-1">
Gemacht mit <Heart className="w-4 h-4 text-red-400 fill-red-400" /> fuer junge Holzwerker
</p>
</div>
</div>
</footer>
)
}

View File

@@ -0,0 +1,95 @@
'use client'
import { motion } from 'framer-motion'
import Link from 'next/link'
import { ArrowRight } from 'lucide-react'
import { Logo } from './Logo'
export function HeroSection() {
return (
<section className="relative overflow-hidden bg-gradient-to-br from-cream via-white to-primary/5 py-16 sm:py-24">
<div className="max-w-6xl mx-auto px-4 flex flex-col lg:flex-row items-center gap-12">
<motion.div
className="flex-1 text-center lg:text-left"
initial={{ opacity: 0, x: -30 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6 }}
>
<div className="flex justify-center lg:justify-start mb-6">
<Logo size={64} />
</div>
<h1 className="font-heading font-bold text-4xl sm:text-5xl text-dark mb-4 text-balance">
Willkommen in der{' '}
<span className="text-primary">Holzwerkstatt</span>!
</h1>
<p className="text-lg text-dark/70 mb-8 max-w-lg mx-auto lg:mx-0">
Hier lernst du, wie man aus Holz tolle Sachen baut und schnitzt.
Vom Zauberstab bis zum Vogelhaus fuer jeden ist etwas dabei!
</p>
<Link
href="/projekte"
className="inline-flex items-center gap-2 bg-primary hover:bg-primary/90 text-white font-bold px-8 py-4 rounded-2xl text-lg transition-colors shadow-lg shadow-primary/20"
>
Entdecke Projekte <ArrowRight className="w-5 h-5" />
</Link>
</motion.div>
<motion.div
className="flex-1 flex justify-center"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<HeroIllustration />
</motion.div>
</div>
</section>
)
}
function HeroIllustration() {
return (
<svg width="320" height="280" viewBox="0 0 320 280" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Workbench */}
<rect x="40" y="180" width="240" height="12" rx="4" fill="#D4915C" />
<rect x="60" y="192" width="12" height="60" rx="2" fill="#C4814C" />
<rect x="248" y="192" width="12" height="60" rx="2" fill="#C4814C" />
<rect x="50" y="248" width="32" height="8" rx="2" fill="#C4814C" />
<rect x="238" y="248" width="32" height="8" rx="2" fill="#C4814C" />
{/* Wood pieces on bench */}
<rect x="80" y="164" width="60" height="16" rx="3" fill="#E8A96C" />
<rect x="85" y="168" width="50" height="2" rx="1" fill="#D4915C" opacity="0.3" />
{/* Small boat */}
<path d="M180 170 Q200 155 220 170 Q200 178 180 170Z" fill="#E8A96C" />
<line x1="200" y1="148" x2="200" y2="170" stroke="#8B6F47" strokeWidth="2" />
<path d="M200 148 L215 158 L200 165Z" fill="#FF6B6B" opacity="0.8" />
{/* Hammer */}
<rect x="240" y="155" width="4" height="25" rx="1" fill="#8B6F47" transform="rotate(-20 240 155)" />
<rect x="232" y="148" width="20" height="10" rx="2" fill="#888" transform="rotate(-20 240 155)" />
{/* Tree background */}
<circle cx="60" cy="100" r="35" fill="#4CAF50" opacity="0.3" />
<circle cx="50" cy="85" r="25" fill="#4CAF50" opacity="0.4" />
<circle cx="70" cy="90" r="28" fill="#4CAF50" opacity="0.35" />
<rect x="56" y="120" width="8" height="60" rx="2" fill="#8B6F47" opacity="0.4" />
{/* Tree right */}
<circle cx="270" cy="110" r="30" fill="#4CAF50" opacity="0.25" />
<circle cx="280" cy="95" r="22" fill="#4CAF50" opacity="0.35" />
<rect x="268" y="130" width="6" height="50" rx="2" fill="#8B6F47" opacity="0.3" />
{/* Sun */}
<circle cx="280" cy="40" r="20" fill="#F5A623" opacity="0.3" />
<circle cx="280" cy="40" r="14" fill="#F5A623" opacity="0.5" />
{/* Sawdust particles */}
<circle cx="120" cy="175" r="1.5" fill="#D4915C" opacity="0.5" />
<circle cx="130" cy="172" r="1" fill="#D4915C" opacity="0.4" />
<circle cx="115" cy="178" r="1.2" fill="#D4915C" opacity="0.3" />
<circle cx="135" cy="176" r="0.8" fill="#D4915C" opacity="0.6" />
</svg>
)
}

View File

@@ -0,0 +1,35 @@
'use client'
export function Logo({ size = 40 }: { size?: number }) {
return (
<div className="flex items-center gap-2">
<svg width={size} height={size} viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Wood log */}
<ellipse cx="24" cy="30" rx="16" ry="10" fill="#D4915C" />
<ellipse cx="24" cy="30" rx="16" ry="10" fill="url(#wood-grain)" opacity="0.3" />
<ellipse cx="24" cy="27" rx="16" ry="10" fill="#E8A96C" />
{/* Tree rings */}
<ellipse cx="24" cy="27" rx="10" ry="6" fill="none" stroke="#D4915C" strokeWidth="1" />
<ellipse cx="24" cy="27" rx="6" ry="3.5" fill="none" stroke="#D4915C" strokeWidth="0.8" />
<ellipse cx="24" cy="27" rx="2.5" ry="1.5" fill="#D4915C" />
{/* Saw */}
<rect x="30" y="6" width="3" height="18" rx="1" fill="#888" transform="rotate(15 30 6)" />
<rect x="29" y="4" width="5" height="5" rx="1" fill="#F5A623" transform="rotate(15 30 6)" />
{/* Saw teeth */}
<path d="M31 10 L34 11 L31 12 L34 13 L31 14 L34 15 L31 16 L34 17 L31 18 L34 19 L31 20" stroke="#666" strokeWidth="0.5" fill="none" transform="rotate(15 30 6)" />
{/* Leaf */}
<path d="M12 8 Q16 2 20 8 Q16 10 12 8Z" fill="#4CAF50" />
<line x1="16" y1="5" x2="16" y2="9" stroke="#388E3C" strokeWidth="0.5" />
<defs>
<pattern id="wood-grain" x="0" y="0" width="4" height="4" patternUnits="userSpaceOnUse">
<line x1="0" y1="0" x2="4" y2="4" stroke="#C4814C" strokeWidth="0.3" />
</pattern>
</defs>
</svg>
<div className="flex flex-col leading-tight">
<span className="font-heading font-bold text-xl text-primary">LEVIS</span>
<span className="font-heading text-sm text-dark/70 -mt-1">Holzbau</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,44 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Logo } from './Logo'
const links = [
{ href: '/', label: 'Start' },
{ href: '/projekte', label: 'Projekte' },
{ href: '/sicherheit', label: 'Sicherheit' },
{ href: '/ueber', label: 'Ueber LEVIS' },
]
export function Navbar() {
const pathname = usePathname()
return (
<nav className="bg-white/80 backdrop-blur-sm border-b border-primary/10 sticky top-0 z-50">
<div className="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between">
<Link href="/">
<Logo />
</Link>
<div className="flex items-center gap-1 sm:gap-4">
{links.map(({ href, label }) => {
const isActive = href === '/' ? pathname === '/' : pathname.startsWith(href)
return (
<Link
key={href}
href={href}
className={`px-3 py-2 rounded-xl text-sm sm:text-base font-semibold transition-colors ${
isActive
? 'bg-primary/10 text-primary'
: 'text-dark/70 hover:text-primary hover:bg-primary/5'
}`}
>
{label}
</Link>
)
})}
</div>
</div>
</nav>
)
}

View File

@@ -0,0 +1,42 @@
'use client'
import Link from 'next/link'
import { motion } from 'framer-motion'
import { Clock } from 'lucide-react'
import { Project } from '@/lib/types'
import { DifficultyBadge } from './DifficultyBadge'
import { AgeBadge } from './AgeBadge'
import { ProjectIllustration } from './ProjectIllustration'
export function ProjectCard({ project }: { project: Project }) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ y: -4 }}
transition={{ duration: 0.3 }}
>
<Link href={`/projekte/${project.slug}`} className="block">
<div className="bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden border border-primary/5">
<div className="bg-cream p-6 flex items-center justify-center h-44">
<ProjectIllustration slug={project.slug} size={120} />
</div>
<div className="p-5">
<h3 className="font-heading font-bold text-lg mb-2">{project.name}</h3>
<p className="text-sm text-dark/60 mb-3 line-clamp-2">{project.description}</p>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<AgeBadge range={project.ageRange} />
<DifficultyBadge level={project.difficulty} />
</div>
<div className="flex items-center gap-1 text-xs text-dark/40">
<Clock className="w-3.5 h-3.5" />
{project.duration}
</div>
</div>
</div>
</div>
</Link>
</motion.div>
)
}

View File

@@ -0,0 +1,132 @@
'use client'
export function ProjectIllustration({ slug, size = 100 }: { slug: string; size?: number }) {
const illustrations: Record<string, React.ReactNode> = {
zauberstab: (
<svg width={size} height={size} viewBox="0 0 100 100" fill="none">
<rect x="20" y="80" width="60" height="4" rx="2" fill="#D4915C" transform="rotate(-45 50 50)" />
<circle cx="28" cy="28" r="4" fill="#F5A623" opacity="0.6" />
<circle cx="22" cy="35" r="2.5" fill="#FFC107" opacity="0.5" />
<circle cx="35" cy="22" r="2" fill="#FFC107" opacity="0.4" />
<path d="M25 25 L20 18 M25 25 L32 20 M25 25 L22 32" stroke="#F5A623" strokeWidth="1.5" strokeLinecap="round" />
<circle cx="26" cy="26" r="6" fill="none" stroke="#F5A623" strokeWidth="0.5" opacity="0.3" />
</svg>
),
untersetzer: (
<svg width={size} height={size} viewBox="0 0 100 100" fill="none">
<ellipse cx="50" cy="55" rx="32" ry="8" fill="#C4814C" />
<ellipse cx="50" cy="50" rx="32" ry="8" fill="#E8A96C" />
<ellipse cx="50" cy="50" rx="22" ry="5" fill="none" stroke="#D4915C" strokeWidth="0.8" />
<ellipse cx="50" cy="50" rx="12" ry="2.8" fill="none" stroke="#D4915C" strokeWidth="0.6" />
<circle cx="42" cy="48" r="3" fill="#FF6B6B" opacity="0.5" />
<circle cx="55" cy="46" r="2" fill="#4CAF50" opacity="0.5" />
<circle cx="48" cy="53" r="2.5" fill="#2196F3" opacity="0.4" />
</svg>
),
nagelbilder: (
<svg width={size} height={size} viewBox="0 0 100 100" fill="none">
<rect x="20" y="20" width="60" height="60" rx="4" fill="#E8A96C" />
{/* Nails forming a star */}
<circle cx="50" cy="30" r="2" fill="#888" />
<circle cx="35" cy="45" r="2" fill="#888" />
<circle cx="65" cy="45" r="2" fill="#888" />
<circle cx="40" cy="65" r="2" fill="#888" />
<circle cx="60" cy="65" r="2" fill="#888" />
{/* String */}
<path d="M50 30 L35 45 L60 65 L40 65 L65 45 Z" stroke="#FF6B6B" strokeWidth="1.5" fill="none" />
<path d="M50 30 L40 65 M50 30 L60 65 M35 45 L65 45" stroke="#4CAF50" strokeWidth="1" fill="none" opacity="0.6" />
</svg>
),
bleistiftbox: (
<svg width={size} height={size} viewBox="0 0 100 100" fill="none">
<path d="M25 75 L25 35 L75 35 L75 75 Z" fill="#E8A96C" />
<path d="M25 35 L30 30 L80 30 L75 35 Z" fill="#D4915C" />
<path d="M75 35 L80 30 L80 70 L75 75 Z" fill="#C4814C" />
{/* Pencils */}
<rect x="35" y="20" width="4" height="30" rx="1" fill="#FFC107" />
<polygon points="35,50 39,50 37,55" fill="#2C2C2C" />
<rect x="45" y="15" width="4" height="32" rx="1" fill="#2196F3" />
<polygon points="45,47 49,47 47,52" fill="#2C2C2C" />
<rect x="55" y="22" width="4" height="28" rx="1" fill="#FF6B6B" />
<polygon points="55,50 59,50 57,55" fill="#2C2C2C" />
</svg>
),
segelboot: (
<svg width={size} height={size} viewBox="0 0 100 100" fill="none">
<path d="M20 65 Q50 55 80 65 Q50 72 20 65Z" fill="#E8A96C" />
<line x1="50" y1="25" x2="50" y2="62" stroke="#8B6F47" strokeWidth="2.5" />
<path d="M50 25 L70 50 L50 58Z" fill="white" stroke="#ddd" strokeWidth="0.5" />
<path d="M50 30 L38 52 L50 58Z" fill="#FF6B6B" opacity="0.8" />
{/* Water */}
<path d="M10 72 Q25 68 40 72 Q55 76 70 72 Q85 68 100 72" stroke="#2196F3" strokeWidth="1.5" fill="none" opacity="0.4" />
<path d="M5 78 Q20 74 35 78 Q50 82 65 78 Q80 74 95 78" stroke="#2196F3" strokeWidth="1" fill="none" opacity="0.3" />
</svg>
),
vogelhaus: (
<svg width={size} height={size} viewBox="0 0 100 100" fill="none">
{/* Roof */}
<path d="M25 45 L50 25 L75 45 Z" fill="#C4814C" />
{/* Body */}
<rect x="30" y="45" width="40" height="35" fill="#E8A96C" />
{/* Entrance hole */}
<circle cx="50" cy="58" r="6" fill="#5D4037" />
{/* Perch */}
<rect x="47" y="65" width="6" height="2" rx="1" fill="#8B6F47" />
<rect x="48" y="67" width="4" height="6" rx="1" fill="#8B6F47" />
{/* Post */}
<rect x="46" y="80" width="8" height="15" rx="1" fill="#8B6F47" />
{/* Bird */}
<ellipse cx="68" cy="40" rx="5" ry="4" fill="#FF6B6B" />
<circle cx="71" cy="38" r="1.5" fill="#2C2C2C" />
<path d="M73 39 L77 38.5" stroke="#F5A623" strokeWidth="1.5" strokeLinecap="round" />
</svg>
),
'holztier-igel': (
<svg width={size} height={size} viewBox="0 0 100 100" fill="none">
{/* Body */}
<ellipse cx="50" cy="60" rx="25" ry="18" fill="#C4814C" />
{/* Head */}
<ellipse cx="28" cy="58" rx="10" ry="9" fill="#D4915C" />
{/* Nose */}
<circle cx="20" cy="57" r="2" fill="#2C2C2C" />
{/* Eye */}
<circle cx="25" cy="54" r="1.5" fill="#2C2C2C" />
<circle cx="25.5" cy="53.5" r="0.5" fill="white" />
{/* Spines */}
{[0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150].map((angle, i) => {
const rad = (angle - 30) * Math.PI / 180
const x1 = 55 + Math.cos(rad) * 20
const y1 = 52 + Math.sin(rad) * 14
const x2 = 55 + Math.cos(rad) * 30
const y2 = 52 + Math.sin(rad) * 22
return <line key={i} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#8B6F47" strokeWidth="2" strokeLinecap="round" />
})}
{/* Feet */}
<ellipse cx="35" cy="75" rx="4" ry="2" fill="#D4915C" />
<ellipse cx="60" cy="75" rx="4" ry="2" fill="#D4915C" />
</svg>
),
'schnitzfigur-pilz': (
<svg width={size} height={size} viewBox="0 0 100 100" fill="none">
{/* Stem */}
<path d="M40 55 Q38 75 42 85 L58 85 Q62 75 60 55 Z" fill="#F5F5DC" />
<ellipse cx="50" cy="85" rx="10" ry="3" fill="#E8E0C8" />
{/* Cap */}
<ellipse cx="50" cy="48" rx="28" ry="18" fill="#D32F2F" />
<ellipse cx="50" cy="55" rx="22" ry="5" fill="#E8A96C" />
{/* White dots */}
<circle cx="38" cy="40" r="3" fill="white" opacity="0.9" />
<circle cx="55" cy="35" r="2.5" fill="white" opacity="0.9" />
<circle cx="48" cy="45" r="2" fill="white" opacity="0.8" />
<circle cx="62" cy="42" r="2.5" fill="white" opacity="0.85" />
<circle cx="42" cy="50" r="1.8" fill="white" opacity="0.7" />
{/* Grass */}
<path d="M30 85 Q32 78 34 85" stroke="#4CAF50" strokeWidth="1.5" fill="none" />
<path d="M65 85 Q67 79 69 85" stroke="#4CAF50" strokeWidth="1.5" fill="none" />
<path d="M72 85 Q73 80 75 85" stroke="#4CAF50" strokeWidth="1" fill="none" opacity="0.6" />
</svg>
),
}
return <>{illustrations[slug] || illustrations.zauberstab}</>
}

View File

@@ -0,0 +1,10 @@
import { AlertTriangle } from 'lucide-react'
export function SafetyTip({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-start gap-3 bg-warning/10 border border-warning/30 rounded-xl p-4">
<AlertTriangle className="w-5 h-5 text-warning flex-shrink-0 mt-0.5" />
<p className="text-sm font-medium">{children}</p>
</div>
)
}

View File

@@ -0,0 +1,15 @@
import { Step } from '@/lib/types'
export function StepCard({ step, index }: { step: Step; index: number }) {
return (
<div className="flex gap-4">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-primary flex items-center justify-center text-white font-bold text-lg">
{index + 1}
</div>
<div className="flex-1 pb-8 border-l-2 border-primary/20 pl-6 -ml-5 mt-5">
<h3 className="font-heading font-bold text-lg mb-1">{step.title}</h3>
<p className="text-dark/70 leading-relaxed">{step.description}</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,14 @@
import { Hammer, Scissors, Ruler, Paintbrush, Wrench } from 'lucide-react'
const iconMap: Record<string, React.ElementType> = {
hammer: Hammer,
schnitzmesser: Scissors,
lineal: Ruler,
pinsel: Paintbrush,
}
export function ToolIcon({ name }: { name: string }) {
const key = name.toLowerCase()
const Icon = Object.entries(iconMap).find(([k]) => key.includes(k))?.[1] || Wrench
return <Icon className="w-5 h-5 text-primary" />
}

View File

@@ -0,0 +1,15 @@
export const fadeInUp = {
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.5 },
}
export const staggerContainer = {
animate: { transition: { staggerChildren: 0.1 } },
}
export const scaleIn = {
initial: { opacity: 0, scale: 0.9 },
animate: { opacity: 1, scale: 1 },
transition: { duration: 0.4 },
}

View File

@@ -0,0 +1,214 @@
import { Project } from './types'
export const projects: Project[] = [
{
slug: 'zauberstab',
name: 'Zauberstab',
description: 'Schnitze deinen eigenen magischen Zauberstab aus einem Ast! Mit Schleifpapier und etwas Farbe wird daraus ein echtes Zauberwerkzeug.',
ageRange: '6-8',
difficulty: 1,
duration: '45 Minuten',
tools: ['Schnitzmesser (kindersicher)', 'Schleifpapier (fein)', 'Pinsel'],
materials: ['1 gerader Ast (ca. 30cm, daumendicke)', 'Acrylfarben', 'Klarlack'],
steps: [
{ title: 'Ast aussuchen', description: 'Such dir einen geraden, trockenen Ast. Er sollte ungefaehr so lang sein wie dein Unterarm und gut in deiner Hand liegen.' },
{ title: 'Rinde entfernen', description: 'Zieh vorsichtig die Rinde ab. Wenn sie nicht leicht abgeht, hilft ein Erwachsener mit dem Schnitzmesser.' },
{ title: 'Schleifen', description: 'Schleife den Ast mit dem Schleifpapier glatt. Immer in eine Richtung schleifen — wie beim Streicheln einer Katze!' },
{ title: 'Spitze formen', description: 'Ein Ende kannst du mit dem Schleifpapier etwas spitzer machen. Nicht zu spitz — es soll ein Zauberstab sein, kein Speer!' },
{ title: 'Bemalen', description: 'Jetzt wird es bunt! Male Spiralen, Sterne oder Streifen auf deinen Stab. Lass jede Farbe trocknen bevor du die naechste nimmst.' },
{ title: 'Trocknen lassen', description: 'Stell den Stab zum Trocknen aufrecht in ein Glas. Wenn die Farbe trocken ist, kann ein Erwachsener Klarlack auftragen.' },
],
safetyTips: [
'Ein Erwachsener sollte beim Schnitzen immer dabei sein.',
'Immer vom Koerper weg schnitzen!',
'Frische Aeste sind weicher — trockene Aeste koennen splittern.',
],
skills: ['Feinmotorik', 'Schleifen', 'Kreatives Gestalten'],
},
{
slug: 'untersetzer',
name: 'Holz-Untersetzer',
description: 'Bastle praktische Untersetzer aus Holzscheiben! Eine tolle Geschenkidee fuer die ganze Familie.',
ageRange: '6+',
difficulty: 1,
duration: '30 Minuten',
tools: ['Schleifpapier (mittel + fein)', 'Pinsel'],
materials: ['Holzscheiben (ca. 10cm Durchmesser)', 'Acrylfarben', 'Klarlack', 'Filzgleiter'],
steps: [
{ title: 'Holzscheiben vorbereiten', description: 'Nimm eine Holzscheibe und pruefe ob sie flach auf dem Tisch liegt. Wackelt sie? Dann such dir eine andere aus.' },
{ title: 'Oberflaeche schleifen', description: 'Schleife beide Seiten der Holzscheibe glatt. Erst mit dem groben, dann mit dem feinen Schleifpapier.' },
{ title: 'Staub abwischen', description: 'Wisch den Schleifstaub mit einem feuchten Tuch ab. Die Scheibe muss sauber sein damit die Farbe haelt.' },
{ title: 'Muster malen', description: 'Bemale die Oberseite mit einem schoenen Muster: Blumen, Tiere, Punkte oder Streifen — alles ist erlaubt!' },
{ title: 'Versiegeln', description: 'Wenn die Farbe trocken ist, traegt ein Erwachsener Klarlack auf. So wird der Untersetzer wasserfest.' },
{ title: 'Filzgleiter aufkleben', description: 'Klebe 3-4 kleine Filzgleiter auf die Unterseite. So rutscht der Untersetzer nicht und zerkratzt den Tisch nicht.' },
],
safetyTips: [
'Beim Schleifen Staub nicht einatmen — am besten draussen arbeiten.',
'Klarlack nur von Erwachsenen auftragen lassen (gut lueften!).',
],
skills: ['Schleifen', 'Malen', 'Sorgfaeltiges Arbeiten'],
},
{
slug: 'nagelbilder',
name: 'Nagelbilder',
description: 'Schlage Naegel in ein Brett und spanne bunte Faeden dazwischen — so entstehen tolle Kunstwerke!',
ageRange: '5-7',
difficulty: 1,
duration: '40 Minuten',
tools: ['Hammer (leicht, kindgerecht)', 'Bleistift'],
materials: ['Holzbrett (ca. 20x20cm)', 'Kleine Naegel (ca. 20 Stueck)', 'Bunte Wollfaeden', 'Vorlage auf Papier'],
steps: [
{ title: 'Vorlage waehlen', description: 'Such dir eine einfache Form aus: ein Herz, einen Stern oder ein Haus. Zeichne die Form auf Papier und lege es auf das Brett.' },
{ title: 'Punkte markieren', description: 'Druecke mit dem Bleistift entlang der Form Punkte ins Holz. Alle 2cm ein Punkt reicht aus.' },
{ title: 'Papier entfernen', description: 'Nimm das Papier vorsichtig ab. Du siehst jetzt die Bleistiftpunkte auf dem Holz.' },
{ title: 'Naegel einschlagen', description: 'Schlage an jedem Punkt einen Nagel ein. Der Nagel sollte ungefaehr 1cm aus dem Holz schauen. Halt den Nagel mit einer Zange, nicht mit den Fingern!' },
{ title: 'Faeden spannen', description: 'Knote einen Faden an einen Nagel und spanne ihn kreuz und quer zu den anderen Naegeln. Experimentiere mit verschiedenen Farben!' },
{ title: 'Aufhaengen', description: 'Schraube eine kleine Oese auf die Rueckseite — fertig ist dein Kunstwerk zum Aufhaengen!' },
],
safetyTips: [
'Naegel immer mit einer Zange festhalten, niemals mit den Fingern!',
'Einen leichten Kinderhammer verwenden.',
'Auf eine stabile Unterlage achten beim Haemmern.',
],
skills: ['Haemmern', 'Feinmotorik', 'Kreativitaet'],
},
{
slug: 'bleistiftbox',
name: 'Bleistiftbox',
description: 'Baue eine praktische Box fuer deine Stifte und Pinsel! Aus duennen Holzbrettchen entsteht ein nuetzlicher Schreibtischhelfer.',
ageRange: '7-9',
difficulty: 2,
duration: '1 Stunde',
tools: ['Handsaege (kindersicher)', 'Schleifpapier', 'Holzleim', 'Schraubzwinge', 'Lineal', 'Bleistift'],
materials: ['Duennes Sperrholz (4mm)', 'Holzleim', 'Acrylfarbe', 'Klarlack'],
steps: [
{ title: 'Teile anzeichnen', description: 'Zeichne die 5 Teile auf das Sperrholz: 1 Boden (8x8cm), 4 Seitenwaende (8x10cm). Miss genau mit dem Lineal!' },
{ title: 'Aussaegen', description: 'Saege die Teile vorsichtig aus. Ein Erwachsener hilft beim Festhalten. Immer langsam und gleichmaessig saegen.' },
{ title: 'Kanten schleifen', description: 'Schleife alle Kanten glatt. Besonders die Saegekanten muessen schoen eben werden.' },
{ title: 'Zusammenleimen', description: 'Trage Holzleim auf die Kanten auf und druecke die Teile zusammen. Erst zwei Seiten an den Boden, dann die anderen zwei.' },
{ title: 'Trocknen lassen', description: 'Fixiere alles mit Schraubzwingen oder Klebeband. Der Leim braucht mindestens 1 Stunde zum Trocknen.' },
{ title: 'Dekorieren', description: 'Bemale deine Box mit Acrylfarben. Du kannst deinen Namen draufschreiben oder Muster malen.' },
{ title: 'Versiegeln', description: 'Nach dem Trocknen der Farbe traegt ein Erwachsener Klarlack auf. Fertig ist deine Bleistiftbox!' },
],
safetyTips: [
'Beim Saegen immer das Holz fest einspannen!',
'Die Saege vom Koerper weg fuehren.',
'Holzleim ist nicht giftig, aber trotzdem nicht in den Mund nehmen.',
],
skills: ['Messen und Anzeichnen', 'Saegen', 'Leimen', 'Geduld'],
},
{
slug: 'segelboot',
name: 'Segelboot',
description: 'Baue ein kleines Segelboot das wirklich schwimmt! Perfekt fuer die Badewanne oder den Bach im Park.',
ageRange: '8-10',
difficulty: 2,
duration: '1.5 Stunden',
tools: ['Handsaege', 'Schleifpapier', 'Bohrer (Handbohrer)', 'Schnitzmesser'],
materials: ['Holzklotz (ca. 20x8x4cm)', 'Rundstab (ca. 20cm)', 'Stoffrest fuer Segel', 'Holzleim', 'Wasserfarbe + Klarlack'],
steps: [
{ title: 'Rumpf anzeichnen', description: 'Zeichne die Bootsform von oben auf den Holzklotz: Vorne spitz, hinten breit. Die typische Bootsform kennst du bestimmt!' },
{ title: 'Rumpf aussaegen', description: 'Saege die Bootsform aus. Ein Erwachsener hilft beim Festhalten. Die Kurven langsam und vorsichtig saegen.' },
{ title: 'Rumpf schleifen', description: 'Schleife den Rumpf schoen rund. Die Unterseite sollte leicht gewoelbt sein wie bei einem echten Boot.' },
{ title: 'Mastloch bohren', description: 'Ein Erwachsener bohrt in der Mitte ein Loch fuer den Mast. Es muss so gross sein, dass der Rundstab genau reinpasst.' },
{ title: 'Segel basteln', description: 'Schneide aus dem Stoff ein Dreieck aus (ca. 15cm hoch). Klebe oder naehe es am Rundstab fest.' },
{ title: 'Zusammenbauen', description: 'Stecke den Mast mit etwas Holzleim ins Loch. Lass alles gut trocknen.' },
{ title: 'Wasserfest machen', description: 'Bemale dein Boot und lass es trocknen. Dann traegt ein Erwachsener mehrere Schichten Klarlack auf — so bleibt dein Boot wasserdicht!' },
],
safetyTips: [
'Bohren ist Erwachsenensache — hilf beim Festhalten!',
'Beim Schnitzen immer vom Koerper weg arbeiten.',
'Boot nur unter Aufsicht im Wasser testen.',
],
skills: ['Saegen', 'Formen', 'Zusammenbauen', 'Wasserdicht machen'],
},
{
slug: 'vogelhaus',
name: 'Vogelhaus',
description: 'Baue ein kuscheliges Vogelhaus fuer die Voegel in deinem Garten! Im Winter freuen sie sich besonders ueber ein Futterhaus.',
ageRange: '8-10',
difficulty: 2,
duration: '2 Stunden',
tools: ['Handsaege', 'Hammer', 'Schleifpapier', 'Bohrer', 'Lineal', 'Bleistift'],
materials: ['Holzbretter (1cm dick)', 'Kleine Naegel oder Schrauben', 'Holzleim', 'Dachpappe oder Rinde', 'Leinoel (ungiftig)'],
steps: [
{ title: 'Teile anzeichnen', description: 'Zeichne alle Teile auf: Boden (18x18cm), 2 Seitenwaende, 2 Giebel (mit Spitze fuer das Dach), 2 Dachhaelften. Ein Erwachsener hilft beim Ausmessen.' },
{ title: 'Aussaegen', description: 'Saege alle Teile vorsichtig aus. Bei den Giebeln mit der Spitze besonders aufpassen. Immer mit Hilfe eines Erwachsenen!' },
{ title: 'Einflugsloch', description: 'Ein Erwachsener bohrt in eine Giebelseite ein rundes Loch (ca. 3cm). Das ist die Tuer fuer die Voegel!' },
{ title: 'Schleifen', description: 'Schleife alle Teile glatt, besonders die Kanten. Voegel sollen sich nicht verletzen.' },
{ title: 'Zusammenbauen', description: 'Leime und nagle die Teile zusammen: Erst die Seitenwaende am Boden, dann die Giebel, zum Schluss das Dach.' },
{ title: 'Dach schuetzen', description: 'Klebe Dachpappe oder Rindenstuecke auf das Dach. So bleibt das Innere trocken bei Regen.' },
{ title: 'Behandeln', description: 'Reibe das Haeuschen von aussen mit Leinoel ein. KEINE Farbe verwenden — die Chemikalien koennten den Voegeln schaden!' },
],
safetyTips: [
'Naegel mit der Zange halten beim Einschlagen.',
'Saegen und Bohren nur mit Erwachsenen zusammen.',
'Kein giftiges Holzschutzmittel verwenden — nur Leinoel!',
],
skills: ['Messen', 'Saegen', 'Naegeln', 'Zusammenbauen', 'Tierschutz'],
},
{
slug: 'holztier-igel',
name: 'Holztier — Igel',
description: 'Schnitze einen niedlichen Igel aus Holz! Die Stacheln werden aus kurzen Naegeln oder Zahnstochern gemacht.',
ageRange: '8-10',
difficulty: 2,
duration: '1 Stunde',
tools: ['Schnitzmesser (kindersicher)', 'Schleifpapier', 'Bohrer (duenn)', 'Hammer (leicht)'],
materials: ['Holzklotz (ca. 10x6x5cm, weiches Holz)', 'Zahnstocher oder kurze Naegel', 'Schwarzer Filzstift', 'Holzleim'],
steps: [
{ title: 'Form anzeichnen', description: 'Zeichne die Igelform von der Seite auf den Holzklotz: Vorne eine kleine Spitznase, hinten rund. Von oben tropfenfoermig.' },
{ title: 'Grob schnitzen', description: 'Schnitze mit dem Schnitzmesser die grobe Form. Ein Erwachsener hilft bei harten Stellen. Immer vom Koerper weg schnitzen!' },
{ title: 'Form verfeinern', description: 'Schnitze die Nase spitzer und den Koerper runder. Der Igel soll von hinten huebsch rund aussehen.' },
{ title: 'Schleifen', description: 'Schleife den ganzen Igel glatt. Besonders das Gesicht soll weich und glatt sein.' },
{ title: 'Stacheln vorbereiten', description: 'Ein Erwachsener bohrt viele kleine Loecher in den Ruecken (nicht zu tief!). Die Loecher sollten leicht schraeg nach hinten zeigen.' },
{ title: 'Stacheln einsetzen', description: 'Stecke Zahnstocher in die Loecher und kuerze sie auf 1-2cm. Ein Tropfen Holzleim in jedes Loch haelt die Stacheln fest.' },
{ title: 'Gesicht malen', description: 'Male mit dem schwarzen Filzstift zwei Augen und eine kleine Nase. Fertig ist dein Igel!' },
],
safetyTips: [
'Schnitzmesser immer geschlossen ablegen.',
'Vom Koerper weg schnitzen — das ist die wichtigste Regel!',
'Weiches Holz wie Linde oder Pappel verwenden.',
],
skills: ['Schnitzen', 'Feinarbeit', 'Raeumliches Denken'],
},
{
slug: 'schnitzfigur-pilz',
name: 'Schnitzfigur — Pilz',
description: 'Schnitze einen huebschen Fliegenpilz aus Holz! Ein anspruchsvolles Projekt fuer erfahrene junge Holzwerker.',
ageRange: '10-12',
difficulty: 3,
duration: '2 Stunden',
tools: ['Schnitzmesser-Set (3 Messer)', 'Schleifpapier (fein + sehr fein)', 'Schraubstock'],
materials: ['Holzklotz (ca. 12x8x8cm, Linde)', 'Acrylfarben (rot, weiss, braun)', 'Klarlack', 'Pinsel (duenn + mittel)'],
steps: [
{ title: 'Entwurf zeichnen', description: 'Zeichne deinen Pilz von vorne und von der Seite auf Papier. Uebertrage die Form mit Bleistift auf den Holzklotz.' },
{ title: 'Grobe Form', description: 'Spanne den Klotz im Schraubstock ein. Schnitze mit dem groessten Messer die Grundform: oben die runde Kappe, unten den Stiel.' },
{ title: 'Kappe formen', description: 'Schnitze die Pilzkappe rund und leicht gewoelbt. Die Unterseite der Kappe ist leicht nach innen gewoelbt (hohl).' },
{ title: 'Stiel formen', description: 'Der Stiel wird nach unten etwas breiter. Schnitze ihn schoen rund und gleichmaessig.' },
{ title: 'Details schnitzen', description: 'Schnitze mit dem kleinsten Messer feine Details: Die Lamellen unter der Kappe (feine Rillen) und einen kleinen Ring am Stiel.' },
{ title: 'Feinschliff', description: 'Schleife den ganzen Pilz erst mit feinem, dann mit sehr feinem Schleifpapier. Je glatter, desto schoener die Bemalung!' },
{ title: 'Bemalen', description: 'Male die Kappe rot mit weissen Punkten (Fliegenpilz!). Der Stiel wird weiss oder hellbraun. Lass jede Schicht gut trocknen.' },
],
safetyTips: [
'Dieses Projekt nur mit Schnitz-Erfahrung beginnen!',
'Schraubstock verwenden — niemals das Holz in der Hand halten beim Schnitzen!',
'Scharfe Messer sind sicherer als stumpfe — ein Erwachsener schaerft die Messer.',
'Immer konzentriert arbeiten, nicht ablenken lassen.',
],
skills: ['Fortgeschrittenes Schnitzen', 'Detailarbeit', 'Geduld', 'Dreidimensionales Denken'],
},
]
export function getProject(slug: string): Project | undefined {
return projects.find((p) => p.slug === slug)
}
export function getRelatedProjects(slug: string, count = 3): Project[] {
const current = getProject(slug)
if (!current) return projects.slice(0, count)
return projects
.filter((p) => p.slug !== slug)
.sort((a, b) => Math.abs(a.difficulty - current.difficulty) - Math.abs(b.difficulty - current.difficulty))
.slice(0, count)
}

View File

@@ -0,0 +1,18 @@
export interface Project {
slug: string
name: string
description: string
ageRange: string
difficulty: 1 | 2 | 3
duration: string
tools: string[]
materials: string[]
steps: Step[]
safetyTips: string[]
skills: string[]
}
export interface Step {
title: string
description: string
}

6
levis-holzbau/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
}
module.exports = nextConfig

2017
levis-holzbau/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
{
"name": "levis-holzbau",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev -p 3013",
"build": "next build",
"start": "next start -p 3013"
},
"dependencies": {
"framer-motion": "^11.15.0",
"lucide-react": "^0.468.0",
"next": "^15.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/react": "^18.3.14",
"@types/react-dom": "^18.3.5",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
}
export default config

View File

@@ -0,0 +1,26 @@
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
primary: '#F5A623',
secondary: '#4CAF50',
accent: '#2196F3',
warning: '#FFC107',
cream: '#FDF8F0',
dark: '#2C2C2C',
},
fontFamily: {
heading: ['Quicksand', 'sans-serif'],
body: ['Nunito', 'sans-serif'],
},
},
},
plugins: [],
}
export default config

View File

@@ -0,0 +1,40 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
},
"target": "ES2017"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@@ -0,0 +1,16 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libgl1 libglib2.0-0 libgomp1 curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8095
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 \
CMD curl -f http://127.0.0.1:8095/health || exit 1
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8095"]

110
paddleocr-service/main.py Normal file
View File

@@ -0,0 +1,110 @@
"""PaddleOCR Remote Service — PP-OCRv4 on x86_64 (CPU)."""
import io
import logging
import os
import threading
import numpy as np
from fastapi import FastAPI, File, Header, HTTPException, UploadFile
from PIL import Image
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="PaddleOCR Service")
_engine = None
_ready = False
_loading = False
API_KEY = os.environ.get("PADDLEOCR_API_KEY", "")
def _load_model():
"""Load PaddleOCR model in background thread."""
global _engine, _ready
try:
logger.info("Importing paddleocr...")
from paddleocr import PaddleOCR
logger.info("Loading PaddleOCR model (PP-OCRv4, lang=en)...")
_engine = PaddleOCR(
lang="en",
use_angle_cls=True,
show_log=False,
enable_mkldnn=False,
use_gpu=False,
)
logger.info("PaddleOCR model loaded — running warmup...")
# Warmup with tiny image to trigger any lazy init
dummy = np.ones((30, 100, 3), dtype=np.uint8) * 255
_engine.ocr(dummy)
_ready = True
logger.info("PaddleOCR ready to serve")
except Exception as e:
logger.error(f"Failed to load PaddleOCR: {e}", exc_info=True)
@app.on_event("startup")
def startup_load_model():
"""Start model loading in background so health check passes immediately."""
global _loading
_loading = True
threading.Thread(target=_load_model, daemon=True).start()
logger.info("Model loading started in background thread")
@app.get("/health")
def health():
if _ready:
return {"status": "ok", "model": "PP-OCRv4"}
if _loading:
return {"status": "loading"}
return {"status": "error"}
@app.post("/ocr")
async def ocr(
file: UploadFile = File(...),
x_api_key: str = Header(default=""),
):
if API_KEY and x_api_key != API_KEY:
raise HTTPException(status_code=401, detail="Invalid API key")
if not _ready:
raise HTTPException(status_code=503, detail="Model still loading")
img_bytes = await file.read()
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
img_np = np.array(img)
try:
result = _engine.ocr(img_np)
except Exception as e:
logger.error(f"OCR failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"OCR failed: {e}")
if not result or not result[0]:
return {"words": [], "image_width": img_np.shape[1], "image_height": img_np.shape[0]}
words = []
for line in result[0]:
box, (text, conf) = line[0], line[1]
x_min = min(p[0] for p in box)
y_min = min(p[1] for p in box)
x_max = max(p[0] for p in box)
y_max = max(p[1] for p in box)
words.append({
"text": str(text).strip(),
"left": int(x_min),
"top": int(y_min),
"width": int(x_max - x_min),
"height": int(y_max - y_min),
"conf": round(float(conf) * 100, 1),
})
return {
"words": words,
"image_width": img_np.shape[1],
"image_height": img_np.shape[0],
}

View File

@@ -0,0 +1,7 @@
paddlepaddle>=2.6.0,<3.0.0
paddleocr>=2.7.0,<3.0.0
fastapi>=0.110.0
uvicorn>=0.25.0
python-multipart>=0.0.6
Pillow>=10.0.0
numpy>=1.24.0

View File

@@ -6,6 +6,7 @@ class Settings:
# Qdrant # Qdrant
QDRANT_URL: str = os.getenv("QDRANT_URL", "http://localhost:6333") QDRANT_URL: str = os.getenv("QDRANT_URL", "http://localhost:6333")
QDRANT_API_KEY: str = os.getenv("QDRANT_API_KEY", "")
# MinIO # MinIO
MINIO_ENDPOINT: str = os.getenv("MINIO_ENDPOINT", "localhost:9000") MINIO_ENDPOINT: str = os.getenv("MINIO_ENDPOINT", "localhost:9000")

View File

@@ -27,6 +27,8 @@ _COMPLIANCE_COLLECTIONS = {
"bp_compliance_gdpr": 1024, "bp_compliance_gdpr": 1024,
"bp_compliance_schulrecht": 1024, "bp_compliance_schulrecht": 1024,
"bp_compliance_datenschutz": 1024, "bp_compliance_datenschutz": 1024,
"bp_compliance_gesetze": 1024,
"bp_compliance_ce": 1024,
"bp_dsfa_templates": 1024, "bp_dsfa_templates": 1024,
"bp_dsfa_risks": 1024, "bp_dsfa_risks": 1024,
} }
@@ -46,7 +48,11 @@ class QdrantClientWrapper:
@property @property
def client(self) -> QdrantClient: def client(self) -> QdrantClient:
if self._client is None: if self._client is None:
self._client = QdrantClient(url=settings.QDRANT_URL, timeout=30) self._client = QdrantClient(
url=settings.QDRANT_URL,
api_key=settings.QDRANT_API_KEY or None,
timeout=30,
)
logger.info("Connected to Qdrant at %s", settings.QDRANT_URL) logger.info("Connected to Qdrant at %s", settings.QDRANT_URL)
return self._client return self._client
@@ -103,6 +109,13 @@ class QdrantClientWrapper:
# Indexing # Indexing
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def ensure_collection(self, name: str, vector_size: int = 1024) -> None:
"""Create collection if it doesn't exist."""
try:
self.client.get_collection(name)
except Exception:
await self.create_collection(name, vector_size)
async def index_documents( async def index_documents(
self, self,
collection: str, collection: str,
@@ -116,6 +129,10 @@ class QdrantClientWrapper:
f"vectors ({len(vectors)}) and payloads ({len(payloads)}) must have equal length" f"vectors ({len(vectors)}) and payloads ({len(payloads)}) must have equal length"
) )
# Auto-create collection if missing
vector_size = len(vectors[0]) if vectors else 1024
await self.ensure_collection(collection, vector_size)
if ids is None: if ids is None:
ids = [str(uuid.uuid4()) for _ in vectors] ids = [str(uuid.uuid4()) for _ in vectors]