merge: resolve docker-compose.coolify.yml conflict (accept remote)
This commit is contained in:
@@ -138,3 +138,119 @@ 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
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Build & Deploy auf Hetzner (nur main, kein PR)
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
deploy-hetzner:
|
||||||
|
runs-on: docker
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
needs:
|
||||||
|
- test-go-consent
|
||||||
|
container: docker:27-cli
|
||||||
|
steps:
|
||||||
|
- name: Deploy
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
DEPLOY_DIR="/opt/breakpilot-core"
|
||||||
|
COMPOSE_FILES="-f docker-compose.yml -f docker-compose.hetzner.yml"
|
||||||
|
COMMIT_SHA="${GITHUB_SHA:-unknown}"
|
||||||
|
SHORT_SHA="${COMMIT_SHA:0:8}"
|
||||||
|
REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||||
|
|
||||||
|
# Services die deployed werden
|
||||||
|
SERVICES="postgres valkey qdrant minio ollama mailpit embedding-service rag-service backend-core consent-service health-aggregator"
|
||||||
|
|
||||||
|
echo "=== BreakPilot Core Deploy ==="
|
||||||
|
echo "Commit: ${SHORT_SHA}"
|
||||||
|
echo "Deploy Dir: ${DEPLOY_DIR}"
|
||||||
|
echo "Services: ${SERVICES}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 1. Repo auf dem Host erstellen/aktualisieren via Helper-Container
|
||||||
|
echo "=== Updating code on host ==="
|
||||||
|
docker run --rm \
|
||||||
|
-v "${DEPLOY_DIR}:${DEPLOY_DIR}" \
|
||||||
|
--entrypoint sh \
|
||||||
|
alpine/git:latest \
|
||||||
|
-c "
|
||||||
|
if [ ! -d '${DEPLOY_DIR}/.git' ]; then
|
||||||
|
echo 'Erstmaliges Klonen nach ${DEPLOY_DIR}...'
|
||||||
|
git clone '${REPO_URL}' '${DEPLOY_DIR}'
|
||||||
|
else
|
||||||
|
cd '${DEPLOY_DIR}'
|
||||||
|
git fetch origin main
|
||||||
|
git reset --hard origin/main
|
||||||
|
fi
|
||||||
|
"
|
||||||
|
echo "Code aktualisiert auf ${SHORT_SHA}"
|
||||||
|
|
||||||
|
# 2. .env sicherstellen
|
||||||
|
docker run --rm -v "${DEPLOY_DIR}:${DEPLOY_DIR}" alpine \
|
||||||
|
sh -c "
|
||||||
|
if [ ! -f '${DEPLOY_DIR}/.env' ]; then
|
||||||
|
echo 'WARNUNG: ${DEPLOY_DIR}/.env fehlt!'
|
||||||
|
echo 'Erstelle .env aus .env.example mit Defaults...'
|
||||||
|
if [ -f '${DEPLOY_DIR}/.env.example' ]; then
|
||||||
|
cp '${DEPLOY_DIR}/.env.example' '${DEPLOY_DIR}/.env'
|
||||||
|
echo '.env aus .env.example erstellt'
|
||||||
|
else
|
||||||
|
echo 'Kein .env.example gefunden — Services starten mit Defaults'
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo '.env vorhanden'
|
||||||
|
fi
|
||||||
|
"
|
||||||
|
|
||||||
|
# 3. Shared Network erstellen (falls noch nicht vorhanden)
|
||||||
|
docker network create breakpilot-network 2>/dev/null || true
|
||||||
|
|
||||||
|
# 4. Build + Deploy via Helper-Container
|
||||||
|
echo ""
|
||||||
|
echo "=== Building + Deploying ==="
|
||||||
|
docker run --rm \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-v "${DEPLOY_DIR}:${DEPLOY_DIR}" \
|
||||||
|
-w "${DEPLOY_DIR}" \
|
||||||
|
docker:27-cli \
|
||||||
|
sh -c "
|
||||||
|
set -e
|
||||||
|
COMPOSE_FILES='-f docker-compose.yml -f docker-compose.hetzner.yml'
|
||||||
|
|
||||||
|
echo '=== Building Docker Images ==='
|
||||||
|
docker compose \${COMPOSE_FILES} build --parallel \
|
||||||
|
backend-core consent-service rag-service embedding-service health-aggregator
|
||||||
|
|
||||||
|
echo ''
|
||||||
|
echo '=== Starting infrastructure ==='
|
||||||
|
docker compose \${COMPOSE_FILES} up -d postgres valkey qdrant minio mailpit
|
||||||
|
|
||||||
|
echo 'Warte auf DB + Cache...'
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
echo ''
|
||||||
|
echo '=== Starting Ollama + pulling bge-m3 ==='
|
||||||
|
docker compose \${COMPOSE_FILES} up -d ollama
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# bge-m3 Modell pullen (nur beim ersten Mal ~670MB)
|
||||||
|
echo 'Pulling bge-m3 model (falls noch nicht vorhanden)...'
|
||||||
|
docker exec bp-core-ollama ollama pull bge-m3 2>&1 || echo 'WARNUNG: bge-m3 pull fehlgeschlagen (wird spaeter nachgeholt)'
|
||||||
|
|
||||||
|
echo ''
|
||||||
|
echo '=== Starting application services ==='
|
||||||
|
docker compose \${COMPOSE_FILES} up -d \
|
||||||
|
embedding-service rag-service backend-core consent-service health-aggregator
|
||||||
|
|
||||||
|
echo ''
|
||||||
|
echo '=== Health Checks ==='
|
||||||
|
sleep 15
|
||||||
|
for svc in bp-core-postgres bp-core-valkey bp-core-qdrant bp-core-ollama bp-core-embedding-service bp-core-rag-service bp-core-backend bp-core-consent-service bp-core-health; do
|
||||||
|
STATUS=\$(docker inspect --format='{{.State.Status}}' \"\${svc}\" 2>/dev/null || echo 'not found')
|
||||||
|
echo \"\${svc}: \${STATUS}\"
|
||||||
|
done
|
||||||
|
"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Deploy abgeschlossen: ${SHORT_SHA} ==="
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ networks:
|
|||||||
volumes:
|
volumes:
|
||||||
valkey_data:
|
valkey_data:
|
||||||
embedding_models:
|
embedding_models:
|
||||||
paddleocr_models:
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
@@ -142,35 +141,6 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- breakpilot-network
|
- breakpilot-network
|
||||||
|
|
||||||
# =========================================================
|
|
||||||
# OCR SERVICE (PaddleOCR PP-OCRv5 Latin)
|
|
||||||
# =========================================================
|
|
||||||
paddleocr-service:
|
|
||||||
build:
|
|
||||||
context: ./paddleocr-service
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: bp-core-paddleocr
|
|
||||||
ports:
|
|
||||||
- "8095:8095"
|
|
||||||
environment:
|
|
||||||
PADDLEOCR_API_KEY: ${PADDLEOCR_API_KEY:-}
|
|
||||||
FLAGS_use_mkldnn: "0"
|
|
||||||
volumes:
|
|
||||||
- paddleocr_models:/root/.paddleocr
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 4G
|
|
||||||
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
|
||||||
# =========================================================
|
# =========================================================
|
||||||
@@ -183,7 +153,7 @@ services:
|
|||||||
- "8099"
|
- "8099"
|
||||||
environment:
|
environment:
|
||||||
PORT: 8099
|
PORT: 8099
|
||||||
CHECK_SERVICES: "valkey:6379,consent-service:8081,rag-service:8097,embedding-service:8087,paddleocr-service:8095"
|
CHECK_SERVICES: "valkey:6379,consent-service:8081,rag-service:8097,embedding-service:8087"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:8099/health"]
|
test: ["CMD", "curl", "-f", "http://127.0.0.1:8099/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
175
docker-compose.hetzner.yml
Normal file
175
docker-compose.hetzner.yml
Normal 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:
|
||||||
@@ -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
|
||||||
|
|||||||
5
levis-holzbau/.dockerignore
Normal file
5
levis-holzbau/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
27
levis-holzbau/Dockerfile
Normal file
27
levis-holzbau/Dockerfile
Normal 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"]
|
||||||
25
levis-holzbau/app/globals.css
Normal file
25
levis-holzbau/app/globals.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
levis-holzbau/app/layout.tsx
Normal file
21
levis-holzbau/app/layout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
levis-holzbau/app/page.tsx
Normal file
71
levis-holzbau/app/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
120
levis-holzbau/app/projekte/[slug]/page.tsx
Normal file
120
levis-holzbau/app/projekte/[slug]/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
levis-holzbau/app/projekte/page.tsx
Normal file
59
levis-holzbau/app/projekte/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
101
levis-holzbau/app/sicherheit/page.tsx
Normal file
101
levis-holzbau/app/sicherheit/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
levis-holzbau/app/ueber/page.tsx
Normal file
83
levis-holzbau/app/ueber/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
7
levis-holzbau/components/AgeBadge.tsx
Normal file
7
levis-holzbau/components/AgeBadge.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
levis-holzbau/components/DifficultyBadge.tsx
Normal file
15
levis-holzbau/components/DifficultyBadge.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
levis-holzbau/components/Footer.tsx
Normal file
17
levis-holzbau/components/Footer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
95
levis-holzbau/components/HeroSection.tsx
Normal file
95
levis-holzbau/components/HeroSection.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
levis-holzbau/components/Logo.tsx
Normal file
35
levis-holzbau/components/Logo.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
levis-holzbau/components/Navbar.tsx
Normal file
44
levis-holzbau/components/Navbar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
levis-holzbau/components/ProjectCard.tsx
Normal file
42
levis-holzbau/components/ProjectCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
132
levis-holzbau/components/ProjectIllustration.tsx
Normal file
132
levis-holzbau/components/ProjectIllustration.tsx
Normal 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}</>
|
||||||
|
}
|
||||||
10
levis-holzbau/components/SafetyTip.tsx
Normal file
10
levis-holzbau/components/SafetyTip.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
levis-holzbau/components/StepCard.tsx
Normal file
15
levis-holzbau/components/StepCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
14
levis-holzbau/components/ToolIcon.tsx
Normal file
14
levis-holzbau/components/ToolIcon.tsx
Normal 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" />
|
||||||
|
}
|
||||||
15
levis-holzbau/lib/animations.ts
Normal file
15
levis-holzbau/lib/animations.ts
Normal 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 },
|
||||||
|
}
|
||||||
214
levis-holzbau/lib/projects.ts
Normal file
214
levis-holzbau/lib/projects.ts
Normal 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)
|
||||||
|
}
|
||||||
18
levis-holzbau/lib/types.ts
Normal file
18
levis-holzbau/lib/types.ts
Normal 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
6
levis-holzbau/next-env.d.ts
vendored
Normal 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.
|
||||||
6
levis-holzbau/next.config.js
Normal file
6
levis-holzbau/next.config.js
Normal 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
2017
levis-holzbau/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
levis-holzbau/package.json
Normal file
25
levis-holzbau/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
levis-holzbau/postcss.config.mjs
Normal file
8
levis-holzbau/postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
26
levis-holzbau/tailwind.config.ts
Normal file
26
levis-holzbau/tailwind.config.ts
Normal 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
|
||||||
40
levis-holzbau/tsconfig.json
Normal file
40
levis-holzbau/tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
@@ -107,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,
|
||||||
@@ -120,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]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user