feat: complete memorial website features
- Add user contribution system (memories, timeline entries) - Add AI content moderation with Ollama (bad word detection + qwen3:4b) - Add family photo/video upload with admin approval - Add candle lighting feature - Add timeline and recipe sections - Add QR code page and OG image - Add site authentication (password-protected access) - Add proxy middleware for auth routing - Add admin dashboard for content management - Remove email fields, make name optional (default: Anonym) - Add CI/CD pipeline for Gitea Actions - Add Docker deployment configuration - Optimize Ollama RAM usage (42GB → 2.9GB) - Fix API routes accessibility through proxy middleware Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -3,3 +3,4 @@ ADMIN_PASSWORD=change-me-please
|
||||
|
||||
# Datenverzeichnis (Uploads & Datenbank)
|
||||
DATA_DIR=/data
|
||||
NEXT_PUBLIC_URL=https://maria-malejka.de
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build -t oma-memorial:latest .
|
||||
|
||||
- name: Stop and remove old container
|
||||
run: |
|
||||
docker stop oma-memorial || true
|
||||
docker rm oma-memorial || true
|
||||
|
||||
- name: Run container in proxy network
|
||||
run: |
|
||||
docker run -d \
|
||||
--name oma-memorial \
|
||||
--network proxy \
|
||||
--restart unless-stopped \
|
||||
-e NODE_ENV=production \
|
||||
-v $(pwd)/data:/app/data \
|
||||
oma-memorial:latest
|
||||
|
||||
- name: Health check
|
||||
run: |
|
||||
sleep 10
|
||||
docker exec oma-memorial curl -f http://localhost:3000 || exit 1
|
||||
|
||||
- name: Show container logs
|
||||
if: always()
|
||||
run: docker logs oma-memorial --tail 50
|
||||
@@ -26,3 +26,9 @@ next-env.d.ts
|
||||
|
||||
# Data (uploads & database)
|
||||
/data/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Production env
|
||||
.env.production
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
# OMA Memorial - Deployment Guide
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
### Gitea Actions Workflow
|
||||
Located at `.gitea/workflows/deploy.yml`
|
||||
|
||||
**Triggers:** Push to `main` branch
|
||||
|
||||
**Steps:**
|
||||
1. Checkout code
|
||||
2. Build Docker image
|
||||
3. Stop old container
|
||||
4. Run new container in `proxy` network
|
||||
5. Health check
|
||||
6. Show logs
|
||||
|
||||
### Docker Setup
|
||||
|
||||
**Image:** Multi-stage build with Node 20 Alpine
|
||||
**Container name:** `oma-memorial`
|
||||
**Network:** `proxy` (no ports exposed externally)
|
||||
**Port:** 3000 (internal only)
|
||||
|
||||
### Requirements
|
||||
|
||||
1. **Docker Network:**
|
||||
```bash
|
||||
docker network create proxy
|
||||
```
|
||||
|
||||
2. **Data Persistence:**
|
||||
- Volume mount: `./data:/app/data`
|
||||
- SQLite database persists across deployments
|
||||
|
||||
3. **Ollama (optional):**
|
||||
- Must be running on host or accessible
|
||||
- URL: `http://localhost:11434` or `http://host.docker.internal:11434`
|
||||
|
||||
### Manual Deployment
|
||||
|
||||
```bash
|
||||
# Build
|
||||
docker build -t oma-memorial:latest .
|
||||
|
||||
# Run
|
||||
docker run -d \
|
||||
--name oma-memorial \
|
||||
--network proxy \
|
||||
--restart unless-stopped \
|
||||
-e NODE_ENV=production \
|
||||
-v $(pwd)/data:/app/data \
|
||||
oma-memorial:latest
|
||||
|
||||
# Check logs
|
||||
docker logs -f oma-memorial
|
||||
|
||||
# Health check
|
||||
docker exec oma-memorial curl -f http://localhost:3000
|
||||
```
|
||||
|
||||
### Proxy Integration
|
||||
|
||||
The container runs in the `proxy` network and does **not** expose ports directly. Use a reverse proxy (nginx, Traefik, Caddy) to route traffic:
|
||||
|
||||
**Example nginx config:**
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name oma.example.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://oma-memorial:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example Traefik labels:**
|
||||
```yaml
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.oma.rule=Host(`oma.example.com`)"
|
||||
- "traefik.http.services.oma.loadbalancer.server.port=3000"
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `NODE_ENV=production` (required)
|
||||
- `PORT=3000` (default)
|
||||
- `ADMIN_PASSWORD` (optional, defaults to hash of "Oma2024!")
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
**Container won't start:**
|
||||
```bash
|
||||
docker logs oma-memorial
|
||||
```
|
||||
|
||||
**Database issues:**
|
||||
```bash
|
||||
# Check data volume
|
||||
docker exec oma-memorial ls -la /app/data
|
||||
```
|
||||
|
||||
**Network not found:**
|
||||
```bash
|
||||
docker network create proxy
|
||||
```
|
||||
|
||||
**Build fails:**
|
||||
```bash
|
||||
# Clean build
|
||||
docker system prune -af
|
||||
docker build --no-cache -t oma-memorial:latest .
|
||||
```
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
# 🤖 Ollama Content-Moderation Setup
|
||||
|
||||
## Automatische KI-Prüfung von Beiträgen
|
||||
|
||||
Die Website kann Beiträge automatisch mit einer lokalen KI (Ollama) prüfen und verdächtige Inhalte flaggen.
|
||||
|
||||
---
|
||||
|
||||
## 📥 Installation
|
||||
|
||||
### 1. Ollama installieren (einmalig)
|
||||
|
||||
**Mac:**
|
||||
```bash
|
||||
brew install ollama
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
curl -fsSL https://ollama.com/install.sh | sh
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
Download von https://ollama.com/download
|
||||
|
||||
---
|
||||
|
||||
### 2. Modell herunterladen (einmalig)
|
||||
|
||||
```bash
|
||||
ollama pull llama3.2:1b
|
||||
```
|
||||
|
||||
**Info:** Das Modell ist ~700MB groß und läuft sehr schnell auf deinem Mac.
|
||||
|
||||
---
|
||||
|
||||
### 3. Ollama starten
|
||||
|
||||
**Option A: Automatisch (macOS Service)**
|
||||
```bash
|
||||
brew services start ollama
|
||||
```
|
||||
|
||||
**Option B: Manuell (im Terminal lassen)**
|
||||
```bash
|
||||
ollama serve
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Wie es funktioniert
|
||||
|
||||
### Automatische Prüfung:
|
||||
- Wenn jemand einen Beitrag einreicht (Erinnerung, Zeitstrahl, etc.)
|
||||
- Wird automatisch die KI gefragt: "Ist das angemessen?"
|
||||
- **Falls unangemessen:** Status wird auf `flagged` 🚩 gesetzt
|
||||
- **Falls angemessen:** Status bleibt auf `pending` ✅
|
||||
|
||||
### Im Admin-Panel:
|
||||
- **Geflaggte Beiträge** erscheinen mit **rotem Hintergrund** 🔴
|
||||
- **KI-Warnung** wird angezeigt (z.B. "Spam erkannt")
|
||||
- Du kannst dann entscheiden:
|
||||
- ✅ Freigeben (trotzdem ok)
|
||||
- ❌ Ablehnen (KI hatte recht)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Was wird erkannt?
|
||||
|
||||
Die KI prüft auf:
|
||||
- ✅ **Spam & Werbung**
|
||||
- ✅ **Beleidigungen**
|
||||
- ✅ **Off-Topic** (völlig irrelevant)
|
||||
- ✅ **Unseriöse Inhalte**
|
||||
- ❌ **Grammatik/Rechtschreibung** wird NICHT bemängelt!
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### "Ollama not available"
|
||||
- Prüfe ob Ollama läuft: `ollama list`
|
||||
- Falls nicht: `ollama serve` im Terminal
|
||||
|
||||
### Moderation funktioniert nicht
|
||||
- Prüfe: `curl http://localhost:11434/api/version`
|
||||
- Sollte JSON zurückgeben
|
||||
|
||||
### KI flaggt zu viel / zu wenig
|
||||
- Prompt anpassen in: `src/app/api/moderate/route.ts`
|
||||
- Zeilen 20-40: Die KI-Instruktionen
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Deaktivieren
|
||||
|
||||
Falls du die Auto-Moderation nicht willst:
|
||||
|
||||
**Option 1:** Ollama einfach nicht starten
|
||||
- Dann werden alle Beiträge als `pending` markiert
|
||||
- Keine KI-Prüfung, aber auch keine Fehler
|
||||
|
||||
**Option 2:** Code entfernen
|
||||
- Lösche die Auto-Moderation in `src/app/api/contributions/route.ts` (Zeilen 35-48)
|
||||
|
||||
---
|
||||
|
||||
## 📊 Performance
|
||||
|
||||
- **Ollama läuft lokal** → keine API-Kosten!
|
||||
- **llama3.2:1b** → sehr schnell (~1-3 Sekunden)
|
||||
- **RAM-Nutzung:** ~1GB wenn aktiv
|
||||
- **CPU:** Minimal (außer während Moderation)
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Anpassungen
|
||||
|
||||
### Andere Modelle verwenden:
|
||||
```bash
|
||||
# Kleineres Modell (schneller, weniger genau)
|
||||
ollama pull llama3.2:1b
|
||||
|
||||
# Größeres Modell (langsamer, genauer)
|
||||
ollama pull llama3.2:3b
|
||||
```
|
||||
|
||||
Dann in `src/app/api/moderate/route.ts` ändern:
|
||||
```typescript
|
||||
model: 'llama3.2:3b' // statt :1b
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✨ Fazit
|
||||
|
||||
- ✅ **Kostenlos** & **privat**
|
||||
- ✅ **Automatisch** & **optional**
|
||||
- ✅ **Du behältst Kontrolle** (finale Entscheidung)
|
||||
- ✅ **Kein Setup nötig** in Production (läuft nur lokal)
|
||||
@@ -0,0 +1,69 @@
|
||||
# Oma Memorial - Performance Optimizations
|
||||
|
||||
## Für 20-40 gleichzeitige Benutzer optimiert
|
||||
|
||||
### Implementierte Optimierungen:
|
||||
|
||||
1. **ISR (Incremental Static Regeneration)**
|
||||
- Homepage wird alle 60 Sekunden neu generiert
|
||||
- Reduziert DB-Abfragen drastisch
|
||||
- Schnellere Ladezeiten für Besucher
|
||||
|
||||
2. **Caching**
|
||||
- Static Assets: 1 Jahr Cache
|
||||
- API Files: Immutable Cache
|
||||
- DB-Verbindungen: Connection Pooling
|
||||
|
||||
3. **Timeline-Fotos in Galerie**
|
||||
- Fotos aus Timeline werden automatisch zur Hauptgalerie hinzugefügt
|
||||
- Keine Duplikate in der DB
|
||||
- Virtuelle MediaItems mit hohen IDs
|
||||
|
||||
4. **Formular-Optimierung**
|
||||
- 3 klare, getrennte Upload-Bereiche:
|
||||
* Foto-Upload (nur Bilder)
|
||||
* Erinnerungen (Name + Titel + Text Pflicht)
|
||||
* Zeitstrahl (Generell/Persönlich, Jahr Pflicht)
|
||||
- Kompakte Darstellung
|
||||
- Thumbnail-Previews
|
||||
|
||||
5. **SQLite Optimierungen**
|
||||
- WAL Mode für bessere Concurrency
|
||||
- Increased Cache Size
|
||||
- Temp Store in Memory
|
||||
|
||||
6. **Next.js Config**
|
||||
- Gzip/Brotli Compression
|
||||
- Optimierte Image Formats (AVIF, WebP)
|
||||
- Security Headers
|
||||
|
||||
### Deployment-Empfehlungen:
|
||||
|
||||
**Für 20-40 User:**
|
||||
- ✅ Aktuelles Setup reicht aus
|
||||
- SQLite mit WAL Mode ist performant genug
|
||||
- ISR reduziert Last erheblich
|
||||
|
||||
**Optional (bei mehr Traffic):**
|
||||
- Redis für Session-Caching
|
||||
- CDN für Static Assets
|
||||
- PostgreSQL statt SQLite (ab 100+ User)
|
||||
|
||||
### Monitoring:
|
||||
|
||||
```bash
|
||||
# Memory usage prüfen
|
||||
npm run build && npm start
|
||||
# Öffne http://localhost:3000
|
||||
# Watch memory: htop oder Activity Monitor
|
||||
```
|
||||
|
||||
### Load Testing:
|
||||
|
||||
```bash
|
||||
# Install k6 or artillery
|
||||
npm install -g artillery
|
||||
|
||||
# Test with 40 concurrent users
|
||||
artillery quick --count 40 --num 10 http://localhost:3000
|
||||
```
|
||||
@@ -0,0 +1,48 @@
|
||||
This patch needs to be inserted after line 1122 (end of timeline.map loop) in src/app/admin/page.tsx:
|
||||
|
||||
|
||||
{/* Timeline Contributions (from visitors) */}
|
||||
<div className="mt-8 pt-6 border-t border-warm-border">
|
||||
<h3 className="font-cormorant italic text-2xl text-warm-brown mb-4">
|
||||
Beiträge von Besuchern
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{timelineContributions.filter(c => c.type === 'timeline').length === 0 ? (
|
||||
<p className="text-warm-brown-light text-sm italic font-lora">Keine Besucherbeiträge.</p>
|
||||
) : (
|
||||
timelineContributions
|
||||
.filter(c => c.type === 'timeline')
|
||||
.sort((a, b) => {
|
||||
if (a.status === 'flagged' && b.status !== 'flagged') return -1
|
||||
if (a.status !== 'flagged' && b.status === 'flagged') return 1
|
||||
if (a.status === 'pending' && b.status !== 'pending') return -1
|
||||
if (a.status !== 'pending' && b.status === 'pending') return 1
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
})
|
||||
.map((contribution) => {
|
||||
const photos = contribution.media_filenames ? contribution.media_filenames.split(',') : []
|
||||
|
||||
return (
|
||||
<div
|
||||
key={contribution.id}
|
||||
className={`rounded-xl p-4 border ${
|
||||
contribution.status === 'flagged'
|
||||
? 'bg-red-50 border-red-300'
|
||||
: contribution.status === 'pending'
|
||||
? 'bg-amber-50 border-amber-200'
|
||||
: 'bg-white/60 border-warm-border'
|
||||
}`}
|
||||
>
|
||||
{editingContribution?.id === contribution.id ? (
|
||||
// Edit mode with full inline editing + photo upload/delete
|
||||
// ... (full edit JSX from previous attempt)
|
||||
) : (
|
||||
// View mode with status badges, photos, approve/reject buttons
|
||||
// ... (full view JSX from previous attempt)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
+12
-12
@@ -1,24 +1,24 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
oma:
|
||||
oma-memorial:
|
||||
build: .
|
||||
container_name: oma-memorial
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- oma_data:/data
|
||||
environment:
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-change-me}
|
||||
- DATA_DIR=/data
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
networks:
|
||||
- proxy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/auth"]
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
oma_data:
|
||||
driver: local
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
compress: true,
|
||||
poweredByHeader: false,
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/api/files/:path*',
|
||||
headers: [
|
||||
{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' },
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
turbopack: {
|
||||
root: process.cwd(),
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
|
||||
Generated
+316
-2
@@ -8,9 +8,11 @@
|
||||
"name": "oma-memorial",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"framer-motion": "^11.2.0",
|
||||
"lucide-react": "^0.400.0",
|
||||
"next": "^16.1.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
@@ -737,7 +739,6 @@
|
||||
"version": "22.19.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
|
||||
"integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
@@ -750,6 +751,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.28",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
||||
@@ -771,6 +781,30 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/any-promise": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
@@ -905,6 +939,15 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase-css": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
@@ -979,6 +1022,35 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
@@ -1009,6 +1081,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -1026,6 +1107,12 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dlv": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
@@ -1040,6 +1127,12 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -1103,6 +1196,19 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
@@ -1169,6 +1275,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@@ -1234,6 +1349,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
@@ -1293,6 +1417,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
@@ -1501,6 +1637,51 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
@@ -1547,6 +1728,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
@@ -1710,6 +1900,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -1779,6 +1986,21 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -1857,6 +2079,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||
@@ -1911,6 +2139,32 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/styled-jsx": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||
@@ -2123,7 +2377,6 @@
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
@@ -2163,6 +2416,67 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"framer-motion": "^11.2.0",
|
||||
"lucide-react": "^0.400.0",
|
||||
"next": "^16.1.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# OG-Image erstellen
|
||||
|
||||
Um ein Open Graph Bild für Social Media Shares zu erstellen:
|
||||
|
||||
1. **Erstelle ein Bild mit folgenden Maßen:**
|
||||
- 1200 x 630 Pixel (optimale Größe für Facebook, Twitter, etc.)
|
||||
- Format: JPG oder PNG
|
||||
|
||||
2. **Inhalt:**
|
||||
- Foto von Maria Malejka
|
||||
- Text: "In Erinnerung an Maria Malejka"
|
||||
- Lebensdaten: "29. November 1944 – 10. Februar 2026"
|
||||
- Optional: Dezente Kerze oder florales Element
|
||||
|
||||
3. **Stil:**
|
||||
- Warme, elegante Farben (Creme, Gold, Braun)
|
||||
- Cormorant Garamond für Überschriften (italic)
|
||||
- Lora für Körpertext
|
||||
- Minimalistisch und respektvoll
|
||||
|
||||
4. **Speichern:**
|
||||
- Datei: `og-image.jpg`
|
||||
- Ort: `/public/og-image.jpg`
|
||||
- Das Bild ist bereits in `layout.tsx` konfiguriert
|
||||
|
||||
## Tools zur Erstellung:
|
||||
- Canva (einfach, Templates verfügbar)
|
||||
- Figma (professionell)
|
||||
- Photoshop (fortgeschritten)
|
||||
- Online-Generatoren wie og-image.vercel.app
|
||||
|
||||
## Aktueller Status:
|
||||
Die Konfiguration ist fertig in `src/app/layout.tsx`.
|
||||
Sobald du `public/og-image.jpg` hinzufügst, wird es automatisch genutzt.
|
||||
+1085
-14
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
// PUT: Update a candle
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const { name, message } = await req.json()
|
||||
const db = getDb()
|
||||
|
||||
db.prepare('UPDATE candles SET name = ?, message = ? WHERE id = ?').run(name, message || null, id)
|
||||
|
||||
const candle = db.prepare('SELECT * FROM candles WHERE id = ?').get(id)
|
||||
return NextResponse.json(candle)
|
||||
}
|
||||
|
||||
// DELETE: Remove a candle
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const db = getDb()
|
||||
|
||||
db.prepare('DELETE FROM candles WHERE id = ?').run(id)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
export async function GET() {
|
||||
const db = getDb()
|
||||
const candles = db
|
||||
.prepare('SELECT id, name, created_at FROM candles ORDER BY created_at DESC')
|
||||
.all()
|
||||
return NextResponse.json(candles)
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { name, message } = await req.json()
|
||||
if (!name?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Name ist erforderlich' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const result = db
|
||||
.prepare('INSERT INTO candles (name, message) VALUES (?, ?)')
|
||||
.run(name.trim(), message?.trim() || null)
|
||||
const candle = db
|
||||
.prepare('SELECT id, name, created_at FROM candles WHERE id = ?')
|
||||
.get(result.lastInsertRowid)
|
||||
return NextResponse.json(candle, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
|
||||
const db = getDb()
|
||||
|
||||
// Check if only updating status
|
||||
if (Object.keys(body).length === 1 && 'status' in body) {
|
||||
db.prepare('UPDATE contributions SET status = ? WHERE id = ?').run(body.status, id)
|
||||
} else {
|
||||
// Full update
|
||||
const { name, email, type, year, month, day, title, content, location, media_filenames, status } = body
|
||||
|
||||
db.prepare(`
|
||||
UPDATE contributions
|
||||
SET name = ?, email = ?, type = ?, year = ?, month = ?, day = ?, title = ?, content = ?, location = ?, media_filenames = ?, status = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
name,
|
||||
email || null,
|
||||
type,
|
||||
year || null,
|
||||
month || null,
|
||||
day || null,
|
||||
title || null,
|
||||
content || null,
|
||||
location || null,
|
||||
media_filenames || null,
|
||||
status || 'pending',
|
||||
id
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error updating contribution:', error)
|
||||
return NextResponse.json({ error: 'Failed to update contribution' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const db = getDb()
|
||||
|
||||
db.prepare('DELETE FROM contributions WHERE id = ?').run(id)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error deleting contribution:', error)
|
||||
return NextResponse.json({ error: 'Failed to delete contribution' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
// Simple bad word check
|
||||
function hasBadWords(text: string): { flag: boolean; reason?: string } {
|
||||
const lower = text.toLowerCase()
|
||||
const badWords = [
|
||||
{ word: 'hurensohn', reason: 'Beleidigung' },
|
||||
{ word: 'arschloch', reason: 'Beleidigung' },
|
||||
{ word: 'wichser', reason: 'Beleidigung' },
|
||||
{ word: 'fotze', reason: 'Beleidigung' },
|
||||
{ word: 'spam', reason: 'Spam-Verdacht' },
|
||||
{ word: 'werbung', reason: 'Werbung' },
|
||||
{ word: 'casino', reason: 'Werbung' },
|
||||
{ word: 'viagra', reason: 'Werbung' }
|
||||
]
|
||||
|
||||
for (const { word, reason } of badWords) {
|
||||
if (lower.includes(word)) {
|
||||
return { flag: true, reason }
|
||||
}
|
||||
}
|
||||
|
||||
return { flag: false }
|
||||
}
|
||||
|
||||
// Background AI moderation with Ollama
|
||||
async function moderateWithAI(contributionId: number, content: string) {
|
||||
console.log(`[AI-Mod] Starting for ${contributionId}`)
|
||||
|
||||
// Step 1: Instant bad word check
|
||||
const lowerCheck = content.toLowerCase()
|
||||
const badWords = ['hurensohn', 'arschloch', 'wichser', 'fotze']
|
||||
const foundBadWord = badWords.find(word => lowerCheck.includes(word))
|
||||
|
||||
if (foundBadWord) {
|
||||
console.log(`[AI-Mod] ⚠️ INSTANT FLAG: "${foundBadWord}" detected!`)
|
||||
const db = getDb()
|
||||
const flagResult = db.prepare(`
|
||||
UPDATE contributions
|
||||
SET status = 'flagged', moderation_reason = ?
|
||||
WHERE id = ?
|
||||
`).run(`Unangemessene Sprache: "${foundBadWord}"`, contributionId)
|
||||
console.log(`[AI-Mod] ✅ FLAGGED ${contributionId} instantly:`, flagResult.changes, 'rows')
|
||||
return // Done, no AI needed
|
||||
}
|
||||
|
||||
// Step 2: AI check for subtle issues
|
||||
console.log(`[AI-Mod] No bad words, asking AI...`)
|
||||
|
||||
try {
|
||||
const prompt = `Ist dieser Text unangemessen für eine Gedenkseite?
|
||||
|
||||
"${content}"
|
||||
|
||||
ERLAUBT: Liebe, Vermissen, Trauer
|
||||
VERBOTEN: Beleidigungen, Spam, Hassrede
|
||||
|
||||
Antworte NUR mit JSON (keine Erklärung):
|
||||
{"appropriate": true} oder {"appropriate": false, "reason": "..."}
|
||||
|
||||
JSON:`
|
||||
|
||||
const controller = new AbortController()
|
||||
setTimeout(() => controller.abort(), 10000) // 10sec timeout
|
||||
|
||||
const res = await fetch('http://localhost:11434/api/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model: 'qwen3:4b',
|
||||
prompt,
|
||||
stream: false,
|
||||
options: {
|
||||
temperature: 0.1,
|
||||
num_predict: 50,
|
||||
num_ctx: 256
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn(`[AI-Mod] Ollama error: ${res.status}`)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
const responseText = (data.response || '').trim()
|
||||
console.log(`[AI-Mod] Response: "${responseText}"`)
|
||||
|
||||
// Parse JSON
|
||||
let result: any = null
|
||||
try {
|
||||
const jsonMatch = responseText.match(/\{[^}]+\}/)
|
||||
if (jsonMatch) {
|
||||
result = JSON.parse(jsonMatch[0])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[AI-Mod] Parse error:`, e)
|
||||
}
|
||||
|
||||
// Update DB if inappropriate
|
||||
if (result && result.appropriate === false) {
|
||||
const db = getDb()
|
||||
const updateResult = db.prepare(`
|
||||
UPDATE contributions
|
||||
SET status = 'flagged', moderation_reason = ?
|
||||
WHERE id = ?
|
||||
`).run(result.reason || 'KI-Warnung', contributionId)
|
||||
|
||||
console.log(`[AI-Mod] ✅ FLAGGED ${contributionId}:`, updateResult)
|
||||
} else {
|
||||
console.log(`[AI-Mod] ✅ Passed ${contributionId}`)
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`[AI-Mod] Error for ${contributionId}:`, error.message)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { name, type, title, content, photoUrl, date, category } = body
|
||||
|
||||
if (!content || !type) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Content and type are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
// 1. Check bad words instantly
|
||||
const badWordCheck = hasBadWords(content + ' ' + (title || ''))
|
||||
const initialStatus = badWordCheck.flag ? 'flagged' : 'pending'
|
||||
const moderationReason = badWordCheck.flag ? badWordCheck.reason : null
|
||||
|
||||
// 2. Insert contribution
|
||||
const result = db.prepare(`
|
||||
INSERT INTO contributions (name, type, title, content, status, moderation_reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
name || 'Anonym',
|
||||
type,
|
||||
title || null,
|
||||
content,
|
||||
initialStatus,
|
||||
moderationReason || null
|
||||
)
|
||||
|
||||
const contributionId = Number(result.lastInsertRowid)
|
||||
console.log(`[API] Created contribution ${contributionId}, status: ${initialStatus}`)
|
||||
|
||||
// 3. If not already flagged, run AI check in background
|
||||
if (!badWordCheck.flag) {
|
||||
// Fire and forget - don't await
|
||||
moderateWithAI(contributionId, content).catch(e =>
|
||||
console.error('[AI-Mod] Background error:', e)
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
id: contributionId,
|
||||
message: 'Beitrag wurde gespeichert und wird geprüft'
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('[API] Error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create contribution' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const status = searchParams.get('status')
|
||||
const db = getDb()
|
||||
|
||||
let query: string
|
||||
let params: string[] = []
|
||||
|
||||
if (status) {
|
||||
query = 'SELECT * FROM contributions WHERE status = ? ORDER BY created_at DESC'
|
||||
params = [status]
|
||||
} else {
|
||||
// Return ALL contributions (admin needs all, public will filter client-side)
|
||||
query = 'SELECT * FROM contributions ORDER BY created_at DESC'
|
||||
}
|
||||
|
||||
const contributions = db.prepare(query).all(...params)
|
||||
return NextResponse.json(contributions)
|
||||
} catch (error) {
|
||||
console.error('[API] Error fetching:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch contributions' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
import { unlink } from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const DATA_DIR = path.resolve(process.cwd(), process.env.DATA_DIR || 'data')
|
||||
|
||||
// PUT: Update upload status (approve/reject)
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const { status } = await req.json()
|
||||
|
||||
if (!['approved', 'pending'].includes(status)) {
|
||||
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
db.prepare('UPDATE media SET status = ? WHERE id = ?').run(status, id)
|
||||
|
||||
const updated = db.prepare('SELECT * FROM media WHERE id = ?').get(id)
|
||||
return NextResponse.json(updated)
|
||||
}
|
||||
|
||||
// DELETE: Remove upload (and file)
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const db = getDb()
|
||||
|
||||
const media = db.prepare('SELECT * FROM media WHERE id = ?').get(id) as any
|
||||
if (!media) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Delete file
|
||||
try {
|
||||
const filePath = path.join(DATA_DIR, 'uploads', media.filename)
|
||||
await unlink(filePath)
|
||||
} catch {
|
||||
// File might not exist, continue
|
||||
}
|
||||
|
||||
// Delete from DB
|
||||
db.prepare('DELETE FROM media WHERE id = ?').run(id)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { writeFile, mkdir } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const maxDuration = 60
|
||||
|
||||
const DATA_DIR = path.resolve(process.cwd(), process.env.DATA_DIR || 'data')
|
||||
|
||||
const PHOTO_MIMES: Record<string, boolean> = {
|
||||
'image/jpeg': true,
|
||||
'image/jpg': true,
|
||||
'image/png': true,
|
||||
'image/webp': true,
|
||||
'image/gif': true,
|
||||
'image/heic': true,
|
||||
'image/heif': true,
|
||||
}
|
||||
|
||||
const VIDEO_MIMES: Record<string, boolean> = {
|
||||
'video/mp4': true,
|
||||
'video/quicktime': true,
|
||||
'video/x-msvideo': true,
|
||||
'video/webm': true,
|
||||
}
|
||||
|
||||
// GET: List all pending/approved family uploads
|
||||
export async function GET() {
|
||||
const db = getDb()
|
||||
const uploads = db
|
||||
.prepare("SELECT * FROM media WHERE status IN ('pending', 'approved') ORDER BY created_at DESC")
|
||||
.all()
|
||||
return NextResponse.json(uploads)
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const formData = await req.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
const name = formData.get('name') as string | null
|
||||
const email = formData.get('email') as string | null
|
||||
const relation = formData.get('relation') as string | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'Datei erforderlich' }, { status: 400 })
|
||||
}
|
||||
|
||||
let mimeType = file.type?.toLowerCase() || ''
|
||||
const ext = path.extname(file.name).toLowerCase()
|
||||
|
||||
if (!mimeType && (ext === '.heic' || ext === '.heif')) {
|
||||
mimeType = 'image/heic'
|
||||
}
|
||||
|
||||
let type: 'photo' | 'video'
|
||||
let uploadDir: string
|
||||
|
||||
if (PHOTO_MIMES[mimeType]) {
|
||||
type = 'photo'
|
||||
uploadDir = 'photos'
|
||||
} else if (VIDEO_MIMES[mimeType]) {
|
||||
type = 'video'
|
||||
uploadDir = 'videos'
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: 'Nur Fotos und Videos erlaubt' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const filename = `${uploadDir}/${randomUUID()}${ext || '.bin'}`
|
||||
const filePath = path.join(DATA_DIR, 'uploads', filename)
|
||||
|
||||
await mkdir(path.dirname(filePath), { recursive: true })
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
await writeFile(filePath, buffer)
|
||||
|
||||
// Build caption with uploader info
|
||||
let caption = `Von ${(name || 'Anonym').trim()}`
|
||||
if (relation?.trim()) caption += ` (${relation.trim()})`
|
||||
|
||||
const db = getDb()
|
||||
const result = db
|
||||
.prepare(
|
||||
"INSERT INTO media (filename, original_name, type, caption, status) VALUES (?, ?, ?, ?, 'pending')"
|
||||
)
|
||||
.run(filename, file.name, type, caption)
|
||||
|
||||
const media = db
|
||||
.prepare('SELECT * FROM media WHERE id = ?')
|
||||
.get(result.lastInsertRowid)
|
||||
|
||||
return NextResponse.json(media, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { createHash } from 'crypto'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
async function isAdmin() {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get('admin_auth')?.value
|
||||
const expected = createHash('sha256')
|
||||
.update(process.env.ADMIN_PASSWORD || 'change-me')
|
||||
.digest('hex')
|
||||
return token === expected
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
if (!await isAdmin()) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const { status } = await req.json()
|
||||
|
||||
if (status !== 'approved' && status !== 'rejected') {
|
||||
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
if (status === 'rejected') {
|
||||
// Delete the file and DB record
|
||||
const media = db.prepare('SELECT * FROM media WHERE id = ?').get(id) as { filename: string } | undefined
|
||||
if (media) {
|
||||
const path = await import('path')
|
||||
const fs = await import('fs/promises')
|
||||
const DATA_DIR = path.default.resolve(process.cwd(), process.env.DATA_DIR || 'data')
|
||||
try {
|
||||
await fs.unlink(path.default.join(DATA_DIR, 'uploads', media.filename))
|
||||
} catch {
|
||||
// File might not exist
|
||||
}
|
||||
db.prepare('DELETE FROM media WHERE id = ?').run(id)
|
||||
}
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
db.prepare("UPDATE media SET status = 'approved' WHERE id = ?").run(id)
|
||||
const updated = db.prepare('SELECT * FROM media WHERE id = ?').get(id)
|
||||
return NextResponse.json(updated)
|
||||
}
|
||||
@@ -5,15 +5,24 @@ export const runtime = 'nodejs'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const type = req.nextUrl.searchParams.get('type')
|
||||
const status = req.nextUrl.searchParams.get('status')
|
||||
const db = getDb()
|
||||
|
||||
const query = type
|
||||
? 'SELECT * FROM media WHERE type = ? ORDER BY sort_order, created_at'
|
||||
: 'SELECT * FROM media ORDER BY sort_order, created_at'
|
||||
let query = 'SELECT * FROM media WHERE 1=1'
|
||||
const queryParams: string[] = []
|
||||
|
||||
const media = type
|
||||
? db.prepare(query).all(type)
|
||||
: db.prepare(query).all()
|
||||
if (type) {
|
||||
query += ' AND type = ?'
|
||||
queryParams.push(type)
|
||||
}
|
||||
|
||||
if (status) {
|
||||
query += ' AND status = ?'
|
||||
queryParams.push(status)
|
||||
}
|
||||
|
||||
query += ' ORDER BY sort_order, created_at'
|
||||
|
||||
const media = db.prepare(query).all(...queryParams)
|
||||
return NextResponse.json(media)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/**
|
||||
* Ollama Content Moderation API
|
||||
* Prüft Beiträge mit llama3.2:1b lokal
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { text, name, title } = await req.json()
|
||||
|
||||
if (!text || text.trim().length === 0) {
|
||||
return NextResponse.json({ appropriate: true })
|
||||
}
|
||||
|
||||
// Ollama API aufrufen
|
||||
const ollamaResponse = await fetch('http://localhost:11434/api/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'llama3.2:1b',
|
||||
prompt: `Du bist ein Moderator für eine Gedenkseite. Prüfe, ob dieser Beitrag angemessen ist.
|
||||
|
||||
Name: ${name || 'Anonym'}
|
||||
Titel: ${title || 'Kein Titel'}
|
||||
Text: "${text}"
|
||||
|
||||
Unangemessen sind:
|
||||
- Spam, Werbung, Links zu Produkten
|
||||
- Beleidigungen, Hassrede
|
||||
- Völlig irrelevanter Inhalt
|
||||
- Unseriöse oder respektlose Inhalte
|
||||
|
||||
Angemessen sind:
|
||||
- Erinnerungen, Anekdoten
|
||||
- Kondolenzen, Beileidsbekundungen
|
||||
- Persönliche Geschichten
|
||||
- Emotionale oder traurige Texte
|
||||
|
||||
Antworte NUR mit einem JSON-Objekt:
|
||||
{
|
||||
"appropriate": true/false,
|
||||
"reason": "Kurze Begründung wenn unangemessen"
|
||||
}`,
|
||||
stream: false,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!ollamaResponse.ok) {
|
||||
console.warn('Ollama not available, skipping moderation')
|
||||
return NextResponse.json({ appropriate: true, ollama_unavailable: true })
|
||||
}
|
||||
|
||||
const ollamaData = await ollamaResponse.json()
|
||||
const responseText = ollamaData.response.trim()
|
||||
|
||||
// Parse JSON response
|
||||
try {
|
||||
const jsonMatch = responseText.match(/\{[\s\S]*\}/)
|
||||
if (!jsonMatch) throw new Error('No JSON found')
|
||||
|
||||
const result = JSON.parse(jsonMatch[0])
|
||||
|
||||
return NextResponse.json({
|
||||
appropriate: result.appropriate !== false,
|
||||
reason: result.reason || null,
|
||||
})
|
||||
} catch (parseError) {
|
||||
// Fallback: Text-based
|
||||
const inappropriate = responseText.toLowerCase().includes('unangemessen')
|
||||
|
||||
return NextResponse.json({
|
||||
appropriate: !inappropriate,
|
||||
reason: inappropriate ? 'KI hat Bedenken' : null,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Moderation error:', error)
|
||||
return NextResponse.json({
|
||||
appropriate: true,
|
||||
ollama_unavailable: true
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { createHash } from 'crypto'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
async function isAdmin() {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get('admin_auth')?.value
|
||||
const expected = createHash('sha256')
|
||||
.update(process.env.ADMIN_PASSWORD || 'change-me')
|
||||
.digest('hex')
|
||||
return token === expected
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
if (!await isAdmin()) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const { title, description, ingredients, instructions, sort_order } = await req.json()
|
||||
|
||||
const db = getDb()
|
||||
db.prepare(
|
||||
'UPDATE recipes SET title = ?, description = ?, ingredients = ?, instructions = ?, sort_order = ? WHERE id = ?'
|
||||
).run(title, description || null, ingredients || null, instructions || null, sort_order ?? 0, id)
|
||||
|
||||
const recipe = db.prepare('SELECT * FROM recipes WHERE id = ?').get(id)
|
||||
return NextResponse.json(recipe)
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
if (!await isAdmin()) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const db = getDb()
|
||||
db.prepare('DELETE FROM recipes WHERE id = ?').run(id)
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { createHash } from 'crypto'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
async function isAdmin() {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get('admin_auth')?.value
|
||||
const expected = createHash('sha256')
|
||||
.update(process.env.ADMIN_PASSWORD || 'change-me')
|
||||
.digest('hex')
|
||||
return token === expected
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const db = getDb()
|
||||
const recipes = db
|
||||
.prepare('SELECT * FROM recipes ORDER BY sort_order, created_at')
|
||||
.all()
|
||||
return NextResponse.json(recipes)
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!await isAdmin()) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { title, description, ingredients, instructions, sort_order } = await req.json()
|
||||
if (!title?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Titel ist erforderlich' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const result = db
|
||||
.prepare(
|
||||
'INSERT INTO recipes (title, description, ingredients, instructions, sort_order) VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run(
|
||||
title.trim(),
|
||||
description?.trim() || null,
|
||||
ingredients?.trim() || null,
|
||||
instructions?.trim() || null,
|
||||
sort_order ?? 0
|
||||
)
|
||||
const recipe = db
|
||||
.prepare('SELECT * FROM recipes WHERE id = ?')
|
||||
.get(result.lastInsertRowid)
|
||||
return NextResponse.json(recipe, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createHash } from 'crypto'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
function getExpectedToken() {
|
||||
return createHash('sha256')
|
||||
.update(process.env.SITE_PASSWORD || 'familie')
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { password } = await req.json()
|
||||
|
||||
if (password !== (process.env.SITE_PASSWORD || 'familie')) {
|
||||
return NextResponse.json({ error: 'Falsches Passwort' }, { status: 401 })
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ success: true })
|
||||
response.cookies.set('site_auth', getExpectedToken(), {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
path: '/',
|
||||
})
|
||||
return response
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
// PUT: Update contribution status or edit content
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const body = await req.json()
|
||||
|
||||
const db = getDb()
|
||||
|
||||
// If only status update
|
||||
if (body.status && Object.keys(body).length === 1) {
|
||||
if (!['approved', 'rejected', 'pending'].includes(body.status)) {
|
||||
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
|
||||
}
|
||||
db.prepare('UPDATE timeline_contributions SET status = ? WHERE id = ?').run(body.status, id)
|
||||
} else {
|
||||
// Full edit
|
||||
const { name, email, year, month, day, title, story } = body
|
||||
db.prepare(`
|
||||
UPDATE timeline_contributions
|
||||
SET name = ?, email = ?, year = ?, month = ?, day = ?, title = ?, story = ?
|
||||
WHERE id = ?
|
||||
`).run(name, email || null, year || null, month || null, day || null, title, story, id)
|
||||
}
|
||||
|
||||
const updated = db.prepare('SELECT * FROM timeline_contributions WHERE id = ?').get(id)
|
||||
return NextResponse.json(updated)
|
||||
}
|
||||
|
||||
// DELETE: Remove contribution
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const db = getDb()
|
||||
|
||||
db.prepare('DELETE FROM timeline_contributions WHERE id = ?').run(id)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
// GET: All contributions (approved ones for public, all for admin)
|
||||
export async function GET(req: NextRequest) {
|
||||
const db = getDb()
|
||||
const { searchParams } = new URL(req.url)
|
||||
const includeAll = searchParams.get('all') === 'true'
|
||||
|
||||
const query = includeAll
|
||||
? "SELECT * FROM timeline_contributions ORDER BY year DESC, month DESC, day DESC, created_at DESC"
|
||||
: "SELECT * FROM timeline_contributions WHERE status = 'approved' ORDER BY year DESC, month DESC, day DESC"
|
||||
|
||||
const contributions = db.prepare(query).all()
|
||||
return NextResponse.json(contributions)
|
||||
}
|
||||
|
||||
// POST: Submit new contribution
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json()
|
||||
const { name, email, year, month, day, title, story } = body
|
||||
|
||||
if (!name?.trim() || !year?.trim() || !title?.trim() || !story?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Name, Jahr, Titel und Geschichte sind erforderlich' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const result = db
|
||||
.prepare(
|
||||
`INSERT INTO timeline_contributions (name, email, year, month, day, title, story, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending')`
|
||||
)
|
||||
.run(
|
||||
name.trim(),
|
||||
email?.trim() || null,
|
||||
year.trim(),
|
||||
month?.trim() || null,
|
||||
day?.trim() || null,
|
||||
title.trim(),
|
||||
story.trim()
|
||||
)
|
||||
|
||||
const contribution = db
|
||||
.prepare('SELECT * FROM timeline_contributions WHERE id = ?')
|
||||
.get(result.lastInsertRowid)
|
||||
|
||||
return NextResponse.json(contribution, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { createHash } from 'crypto'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
async function isAdmin() {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get('admin_auth')?.value
|
||||
const expected = createHash('sha256')
|
||||
.update(process.env.ADMIN_PASSWORD || 'change-me')
|
||||
.digest('hex')
|
||||
return token === expected
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
if (!await isAdmin()) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const { year, month, day, title, description, location, sort_order, media_filenames } = await req.json()
|
||||
|
||||
const db = getDb()
|
||||
db.prepare(
|
||||
'UPDATE timeline SET year = ?, month = ?, day = ?, title = ?, description = ?, location = ?, media_filenames = ?, sort_order = ? WHERE id = ?'
|
||||
).run(
|
||||
year,
|
||||
month || null,
|
||||
day || null,
|
||||
title,
|
||||
description || null,
|
||||
location || null,
|
||||
media_filenames || null,
|
||||
sort_order ?? 0,
|
||||
id
|
||||
)
|
||||
|
||||
const entry = db.prepare('SELECT * FROM timeline WHERE id = ?').get(id)
|
||||
return NextResponse.json(entry)
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
if (!await isAdmin()) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const db = getDb()
|
||||
db.prepare('DELETE FROM timeline WHERE id = ?').run(id)
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { createHash } from 'crypto'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
async function isAdmin() {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get('admin_auth')?.value
|
||||
const expected = createHash('sha256')
|
||||
.update(process.env.ADMIN_PASSWORD || 'change-me')
|
||||
.digest('hex')
|
||||
return token === expected
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const db = getDb()
|
||||
const entries = db
|
||||
.prepare('SELECT * FROM timeline ORDER BY sort_order, year')
|
||||
.all()
|
||||
return NextResponse.json(entries)
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!await isAdmin()) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { year, month, day, title, description, location, sort_order, media_filenames } = await req.json()
|
||||
if (!year?.trim() || !title?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Jahr und Titel sind erforderlich' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const result = db
|
||||
.prepare('INSERT INTO timeline (year, month, day, title, description, location, media_filenames, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(
|
||||
year.trim(),
|
||||
month?.trim() || null,
|
||||
day?.trim() || null,
|
||||
title.trim(),
|
||||
description?.trim() || null,
|
||||
location?.trim() || null,
|
||||
media_filenames || null,
|
||||
sort_order ?? 0
|
||||
)
|
||||
const entry = db
|
||||
.prepare('SELECT * FROM timeline WHERE id = ?')
|
||||
.get(result.lastInsertRowid)
|
||||
return NextResponse.json(entry, { status: 201 })
|
||||
}
|
||||
+22
-22
@@ -47,18 +47,20 @@ const FOLDER_TO_TYPE: Record<string, 'photo' | 'video' | 'music'> = {
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!await isAdmin()) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const formData = await req.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
const caption = formData.get('caption') as string | null
|
||||
const files = formData.getAll('files') as File[]
|
||||
const singleFile = formData.get('file') as File | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'Keine Datei' }, { status: 400 })
|
||||
// Support both 'file' (single) and 'files' (multiple)
|
||||
const filesToProcess = files.length > 0 ? files : (singleFile ? [singleFile] : [])
|
||||
|
||||
if (filesToProcess.length === 0) {
|
||||
return NextResponse.json({ error: 'Keine Dateien' }, { status: 400 })
|
||||
}
|
||||
|
||||
const uploadedFiles = []
|
||||
|
||||
for (const file of filesToProcess) {
|
||||
let mimeType = file.type?.toLowerCase() || ''
|
||||
const ext = path.extname(file.name).toLowerCase()
|
||||
|
||||
@@ -68,10 +70,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const folder = MIME_TO_FOLDER[mimeType]
|
||||
if (!folder) {
|
||||
return NextResponse.json(
|
||||
{ error: `Dateityp "${mimeType}" nicht unterstützt` },
|
||||
{ status: 400 }
|
||||
)
|
||||
continue // Skip unsupported files
|
||||
}
|
||||
|
||||
const filename = `${folder}/${randomUUID()}${ext || '.bin'}`
|
||||
@@ -81,15 +80,16 @@ export async function POST(req: NextRequest) {
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
await writeFile(filePath, buffer)
|
||||
|
||||
const db = getDb()
|
||||
const result = db
|
||||
.prepare(
|
||||
'INSERT INTO media (filename, original_name, type, caption) VALUES (?, ?, ?, ?)'
|
||||
)
|
||||
.run(filename, file.name, FOLDER_TO_TYPE[folder], caption || null)
|
||||
uploadedFiles.push(filename)
|
||||
}
|
||||
|
||||
const media = db
|
||||
.prepare('SELECT * FROM media WHERE id = ?')
|
||||
.get(result.lastInsertRowid)
|
||||
return NextResponse.json(media, { status: 201 })
|
||||
if (uploadedFiles.length === 0) {
|
||||
return NextResponse.json({ error: 'Keine Dateien konnten verarbeitet werden' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Return array of filenames for multi-upload
|
||||
return NextResponse.json({
|
||||
filenames: uploadedFiles,
|
||||
count: uploadedFiles.length
|
||||
}, { status: 201 })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const revalidate = 60 // Revalidate every 60 seconds
|
||||
export const dynamic = 'force-static'
|
||||
export const fetchCache = 'force-cache'
|
||||
@@ -55,3 +55,81 @@
|
||||
background: #C4A04A;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
@page {
|
||||
margin: 1.5cm;
|
||||
size: A4 portrait;
|
||||
}
|
||||
|
||||
body {
|
||||
background: white !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
/* Hide interactive elements */
|
||||
nav,
|
||||
button,
|
||||
.no-print,
|
||||
[class*="hover:"],
|
||||
[href="#"],
|
||||
footer a:not([href^="mailto"]),
|
||||
[data-noprint] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Ensure proper page breaks */
|
||||
section {
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
page-break-after: avoid;
|
||||
break-after: avoid;
|
||||
}
|
||||
|
||||
img, figure {
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
/* Simplify backgrounds */
|
||||
* {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Make text readable */
|
||||
body, p, span, div {
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
/* Show links */
|
||||
a[href^="http"]:after {
|
||||
content: " (" attr(href) ")";
|
||||
font-size: 0.8em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Optimize images */
|
||||
img {
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
/* Hide decorative elements */
|
||||
.grain-overlay,
|
||||
video,
|
||||
iframe,
|
||||
canvas {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#f5f0e8"/>
|
||||
<text x="16" y="24" text-anchor="middle" font-size="22" fill="#b8860b">✦</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 207 B |
@@ -22,6 +22,7 @@ const lora = Lora({
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_URL || 'http://localhost:3000'),
|
||||
title: 'In Erinnerung an Maria Malejka',
|
||||
description:
|
||||
'Eine liebevolle Gedenkseite für Maria Malejka · 29. November 1944 – 10. Februar 2026',
|
||||
@@ -29,6 +30,20 @@ export const metadata: Metadata = {
|
||||
title: 'In Erinnerung an Maria Malejka',
|
||||
description: '29. November 1944 – 10. Februar 2026',
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: '/og-image.jpg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'In Erinnerung an Maria Malejka',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'In Erinnerung an Maria Malejka',
|
||||
description: '29. November 1944 – 10. Februar 2026',
|
||||
images: ['/og-image.jpg'],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ImageResponse } from 'next/og'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
export const alt = 'In Erinnerung an Maria Malejka'
|
||||
export const size = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
export const contentType = 'image/png'
|
||||
|
||||
export default async function Image() {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #FAF7F0 0%, #E8DDD0 100%)',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: 'serif',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Ornament top */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 60,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 20,
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 80, height: 1, background: '#C4A04A', opacity: 0.4 }} />
|
||||
<div style={{ fontSize: 24, color: '#C4A04A', opacity: 0.6 }}>✦</div>
|
||||
<div style={{ width: 80, height: 1, background: '#C4A04A', opacity: 0.4 }} />
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: 72,
|
||||
fontWeight: 300,
|
||||
color: '#3D2B1F',
|
||||
margin: 0,
|
||||
marginBottom: 20,
|
||||
fontStyle: 'italic',
|
||||
letterSpacing: -1,
|
||||
}}
|
||||
>
|
||||
Maria Malejka
|
||||
</h1>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: 28,
|
||||
color: '#7C6352',
|
||||
opacity: 0.8,
|
||||
letterSpacing: 4,
|
||||
marginBottom: 40,
|
||||
}}
|
||||
>
|
||||
29. November 1944 – 10. Februar 2026
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: '#7C6352',
|
||||
opacity: 0.7,
|
||||
fontStyle: 'italic',
|
||||
maxWidth: 700,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
„Wer im Herzen der Menschen weiterlebt,
|
||||
<br />
|
||||
der ist nicht wirklich fort."
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ornament bottom */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 60,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 20,
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 80, height: 1, background: '#C4A04A', opacity: 0.4 }} />
|
||||
<div style={{ fontSize: 24, color: '#C4A04A', opacity: 0.6 }}>✦</div>
|
||||
<div style={{ width: 80, height: 1, background: '#C4A04A', opacity: 0.4 }} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
...size,
|
||||
}
|
||||
)
|
||||
}
|
||||
+159
-13
@@ -1,12 +1,21 @@
|
||||
import { getDb } from '@/lib/db'
|
||||
import type { Memory, MediaItem } from '@/lib/types'
|
||||
import type { Memory, MediaItem, TimelineEntry, Recipe, TimelineContribution } from '@/lib/types'
|
||||
import HeroSection from '@/components/HeroSection'
|
||||
import PhotoSlideshow from '@/components/PhotoSlideshow'
|
||||
import PhotoGallery from '@/components/PhotoGallery'
|
||||
import MemorySection from '@/components/MemorySection'
|
||||
import WriteSection from '@/components/WriteSection'
|
||||
import VideoGallery from '@/components/VideoGallery'
|
||||
import TributeSection from '@/components/TributeSection'
|
||||
import CandleSection from '@/components/CandleSection'
|
||||
import TimelineSection from '@/components/TimelineSection'
|
||||
import TimelineUploadSection from '@/components/TimelineUploadSection'
|
||||
import MemoryUploadSection from '@/components/MemoryUploadSection'
|
||||
import PhotoUploadSection from '@/components/PhotoUploadSection'
|
||||
import FamilyUploadSection from '@/components/FamilyUploadSection'
|
||||
import RecipeSection from '@/components/RecipeSection'
|
||||
import RecipeUploadSection from '@/components/RecipeUploadSection'
|
||||
|
||||
export const revalidate = 10 // Revalidate every 10 seconds
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -19,15 +28,118 @@ export default async function HomePage() {
|
||||
const db = getDb()
|
||||
|
||||
const photos = plain<MediaItem>(
|
||||
db.prepare("SELECT * FROM media WHERE type = 'photo' ORDER BY sort_order, created_at").all()
|
||||
db.prepare("SELECT * FROM media WHERE type = 'photo' AND status = 'approved' ORDER BY sort_order, created_at DESC").all()
|
||||
)
|
||||
const videos = plain<MediaItem>(
|
||||
db.prepare("SELECT * FROM media WHERE type = 'video' ORDER BY sort_order, created_at").all()
|
||||
db.prepare("SELECT * FROM media WHERE type = 'video' AND status = 'approved' ORDER BY sort_order, created_at DESC").all()
|
||||
)
|
||||
const memories = plain<Memory>(
|
||||
const memories = plain<Memory>(
|
||||
db.prepare('SELECT * FROM memories ORDER BY created_at DESC').all()
|
||||
)
|
||||
|
||||
// Fetch approved user contributions (memories)
|
||||
let userMemories: any[] = []
|
||||
try {
|
||||
userMemories = plain(
|
||||
db.prepare(`
|
||||
SELECT id, name, title, content, created_at
|
||||
FROM contributions
|
||||
WHERE status = 'approved' AND type = 'memory'
|
||||
ORDER BY created_at DESC
|
||||
`).all()
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Error fetching user memories:', err)
|
||||
}
|
||||
|
||||
// Combine admin memories + approved user contributions
|
||||
const combinedMemories = [
|
||||
...memories,
|
||||
...userMemories.map((m: any) => ({
|
||||
id: m.id,
|
||||
title: m.title || 'Erinnerung',
|
||||
content: m.content,
|
||||
created_at: m.created_at,
|
||||
updated_at: m.created_at,
|
||||
}))
|
||||
]
|
||||
|
||||
const timeline = plain<TimelineEntry>(
|
||||
db.prepare('SELECT * FROM timeline ORDER BY sort_order, year').all()
|
||||
)
|
||||
|
||||
// Fetch approved timeline contributions
|
||||
let contributions: any[] = []
|
||||
try {
|
||||
contributions = plain(
|
||||
db.prepare("SELECT * FROM contributions WHERE status = 'approved' AND type = 'timeline' ORDER BY year, month, day").all()
|
||||
)
|
||||
} catch {
|
||||
// Fallback to old table
|
||||
try {
|
||||
contributions = plain(
|
||||
db.prepare("SELECT * FROM timeline_contributions WHERE status = 'approved' ORDER BY year, month, day").all()
|
||||
)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Collect all timeline photo filenames for the main gallery
|
||||
const timelinePhotoFilenames = new Set<string>()
|
||||
|
||||
// Combine official timeline + community contributions
|
||||
const combinedTimeline = [
|
||||
...timeline.map(t => {
|
||||
// Add timeline photos to set
|
||||
if (t.media_filenames) {
|
||||
t.media_filenames.split(',').forEach(f => timelinePhotoFilenames.add(f.trim()))
|
||||
}
|
||||
return { ...t, source: 'official' as const }
|
||||
}),
|
||||
...contributions.map((c: any) => {
|
||||
// Add contribution photos to set
|
||||
if (c.media_filenames) {
|
||||
c.media_filenames.split(',').forEach((f: string) => timelinePhotoFilenames.add(f.trim()))
|
||||
}
|
||||
return {
|
||||
id: c.id,
|
||||
year: c.year,
|
||||
month: c.month,
|
||||
day: c.day,
|
||||
title: c.title,
|
||||
description: c.content || c.story || null,
|
||||
location: c.location || null,
|
||||
media_filenames: c.media_filenames || null,
|
||||
sort_order: 0,
|
||||
created_at: c.created_at,
|
||||
source: 'community' as const,
|
||||
contributorName: c.name,
|
||||
}
|
||||
})
|
||||
].sort((a, b) => {
|
||||
const dateA = parseInt(a.year) * 10000 + parseInt(a.month || '0') * 100 + parseInt(a.day || '0')
|
||||
const dateB = parseInt(b.year) * 10000 + parseInt(b.month || '0') * 100 + parseInt(b.day || '0')
|
||||
return dateA - dateB
|
||||
})
|
||||
|
||||
// Create virtual MediaItem entries for timeline photos
|
||||
const timelinePhotos: MediaItem[] = Array.from(timelinePhotoFilenames).map((filename, i) => ({
|
||||
id: 999000 + i, // High ID to avoid conflicts
|
||||
filename,
|
||||
original_name: null,
|
||||
type: 'photo' as const,
|
||||
caption: 'Aus dem Zeitstrahl',
|
||||
sort_order: 9999,
|
||||
status: 'approved' as const,
|
||||
created_at: new Date().toISOString(),
|
||||
}))
|
||||
|
||||
// Merge with existing photos
|
||||
const allPhotos = [...photos, ...timelinePhotos]
|
||||
|
||||
const recipes = plain<Recipe>(
|
||||
db.prepare('SELECT * FROM recipes ORDER BY sort_order, title').all()
|
||||
)
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-cream">
|
||||
{/* Hero */}
|
||||
@@ -35,10 +147,18 @@ const memories = plain<Memory>(
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="sticky top-0 z-20 bg-cream/80 backdrop-blur-sm border-b border-warm-border">
|
||||
<div className="max-w-4xl mx-auto px-4 py-3 flex items-center justify-center gap-6 sm:gap-10">
|
||||
<div className="max-w-5xl mx-auto px-4 py-3 flex items-center justify-center gap-4 sm:gap-6 flex-wrap text-center">
|
||||
<a href="#ueber-oma" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
|
||||
Über Oma
|
||||
</a>
|
||||
<a href="#kerzen" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
|
||||
Kerzen
|
||||
</a>
|
||||
{timeline.length > 0 && (
|
||||
<a href="#zeitstrahl" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
|
||||
Zeitstrahl
|
||||
</a>
|
||||
)}
|
||||
{photos.length > 0 && (
|
||||
<a href="#bilder" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
|
||||
Bilder
|
||||
@@ -52,14 +172,31 @@ const memories = plain<Memory>(
|
||||
Videos
|
||||
</a>
|
||||
)}
|
||||
{recipes.length > 0 && (
|
||||
<a href="#rezepte" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
|
||||
Rezepte
|
||||
</a>
|
||||
)}
|
||||
<a href="#teilen" className="text-warm-brown-light hover:text-warm-gold text-sm font-cormorant italic transition-colors">
|
||||
Teilen
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Personal tribute */}
|
||||
<TributeSection />
|
||||
|
||||
{/* Candles */}
|
||||
<CandleSection />
|
||||
|
||||
{/* Timeline */}
|
||||
<TimelineSection entries={combinedTimeline} />
|
||||
|
||||
{/* Timeline Upload */}
|
||||
<TimelineUploadSection />
|
||||
|
||||
{/* Photos */}
|
||||
{photos.length > 0 && (
|
||||
{allPhotos.length > 0 && (
|
||||
<section id="bilder" className="py-16 sm:py-20">
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
<div className="text-center mb-12">
|
||||
@@ -72,24 +209,33 @@ const memories = plain<Memory>(
|
||||
<div className="h-px w-16 bg-warm-gold/40" />
|
||||
</div>
|
||||
</div>
|
||||
{photos.length > 1 && <PhotoSlideshow photos={photos} />}
|
||||
<PhotoGallery photos={photos} />
|
||||
{allPhotos.length > 1 && <PhotoSlideshow photos={allPhotos} />}
|
||||
<PhotoGallery photos={allPhotos} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Write */}
|
||||
<WriteSection />
|
||||
{/* Photo Upload */}
|
||||
<PhotoUploadSection />
|
||||
|
||||
{/* Memories */}
|
||||
<section id="erinnerungen">
|
||||
<MemorySection memories={memories} />
|
||||
<MemorySection memories={combinedMemories} />
|
||||
</section>
|
||||
|
||||
{/* Memory Upload */}
|
||||
<MemoryUploadSection />
|
||||
|
||||
{/* Videos */}
|
||||
<VideoGallery videos={videos} />
|
||||
|
||||
{/* Footer */}
|
||||
{/* Recipe section */}
|
||||
{recipes.length > 0 && <RecipeSection recipes={recipes} />}
|
||||
|
||||
{/* Recipe Upload */}
|
||||
<RecipeUploadSection />
|
||||
|
||||
{/* Footer placeholder */}
|
||||
<footer className="py-12 text-center border-t border-warm-border bg-amber-50/30">
|
||||
<div className="max-w-lg mx-auto px-4">
|
||||
<p className="font-cormorant italic text-warm-brown-light/60 text-lg">
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
export default function QRPage() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (canvasRef.current) {
|
||||
const url = typeof window !== 'undefined' ? window.location.origin : 'https://maria-malejka.de'
|
||||
QRCode.toCanvas(canvasRef.current, url, {
|
||||
width: 300,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#7C3A0E',
|
||||
light: '#FFFBF0',
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-cream flex items-center justify-center p-8">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<h1 className="font-cormorant italic text-4xl sm:text-5xl text-warm-brown mb-4">
|
||||
Maria Malejka
|
||||
</h1>
|
||||
<p className="font-lora text-warm-brown-light/60 text-sm mb-8">
|
||||
29. November 1944 — 10. Februar 2026
|
||||
</p>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8 mb-6">
|
||||
<canvas ref={canvasRef} className="mx-auto" />
|
||||
</div>
|
||||
|
||||
<p className="font-lora text-warm-brown-light text-sm leading-relaxed">
|
||||
Scanne diesen Code, um die Gedenkseite zu besuchen
|
||||
</p>
|
||||
|
||||
<div className="mt-8 text-xs text-warm-brown-light/40 font-lora">
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="px-4 py-2 border border-warm-border rounded-lg hover:bg-amber-50 transition-colors"
|
||||
>
|
||||
Drucken
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx global>{`
|
||||
@media print {
|
||||
body {
|
||||
background: white !important;
|
||||
}
|
||||
main {
|
||||
background: white !important;
|
||||
}
|
||||
button {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function ZugangPage() {
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/site-auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
window.location.href = '/'
|
||||
} else {
|
||||
setError('Falsches Passwort')
|
||||
setPassword('')
|
||||
}
|
||||
} catch {
|
||||
setError('Ein Fehler ist aufgetreten')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-cream flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm text-center">
|
||||
{/* Decorative element */}
|
||||
<div className="flex items-center justify-center gap-4 mb-8">
|
||||
<div className="h-px w-12 bg-warm-gold/40" />
|
||||
<span className="text-warm-gold text-xl">✦</span>
|
||||
<div className="h-px w-12 bg-warm-gold/40" />
|
||||
</div>
|
||||
|
||||
<h1 className="font-cormorant italic text-3xl sm:text-4xl text-warm-brown mb-2">
|
||||
In Erinnerung an
|
||||
</h1>
|
||||
<p className="font-cormorant text-2xl sm:text-3xl text-warm-brown-light mb-1">
|
||||
Maria Malejka
|
||||
</p>
|
||||
<p className="font-lora text-sm text-warm-brown-light/60 mb-10">
|
||||
Familiärer Zugang
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Passwort eingeben"
|
||||
autoFocus
|
||||
className="w-full px-4 py-3 rounded-lg border border-warm-border bg-white/80
|
||||
text-warm-brown placeholder:text-warm-brown-light/40
|
||||
font-lora text-center text-base
|
||||
focus:outline-none focus:ring-2 focus:ring-warm-gold/30 focus:border-warm-gold/50
|
||||
transition-colors"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="text-red-600/80 text-sm font-lora">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !password}
|
||||
className="w-full py-3 rounded-lg bg-warm-brown text-cream font-cormorant italic text-lg
|
||||
hover:bg-warm-brown/90 disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
>
|
||||
{loading ? 'Prüfe…' : 'Eintreten'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="flex items-center justify-center gap-3 mt-12">
|
||||
<div className="h-px w-8 bg-warm-gold/20" />
|
||||
<span className="text-warm-gold/30 text-xs">✦</span>
|
||||
<div className="h-px w-8 bg-warm-gold/20" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Flame } from 'lucide-react'
|
||||
|
||||
const candleData = [
|
||||
{ delay: 0.0, bodyH: 88, bodyW: 9 },
|
||||
{ delay: 0.4, bodyH: 112, bodyW: 11 },
|
||||
{ delay: 0.2, bodyH: 76, bodyW: 8 },
|
||||
{ delay: 0.6, bodyH: 100, bodyW: 10 },
|
||||
{ delay: 0.1, bodyH: 92, bodyW: 9 },
|
||||
{ delay: 0.5, bodyH: 120, bodyW: 12 },
|
||||
{ delay: 0.3, bodyH: 82, bodyW: 9 },
|
||||
]
|
||||
type CandleData = {
|
||||
id: number
|
||||
name: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
function Candle({ bodyH, bodyW, delay }: { bodyH: number; bodyW: number; delay: number }) {
|
||||
const flameW = bodyW * 1.8
|
||||
const flameH = bodyW * 2.6
|
||||
function relativeTime(created_at: string): string {
|
||||
const now = Date.now()
|
||||
const created = new Date(created_at + 'Z').getTime()
|
||||
const diffMs = now - created
|
||||
const minutes = Math.floor(diffMs / 60000)
|
||||
const hours = Math.floor(diffMs / 3600000)
|
||||
const days = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (minutes < 1) return 'gerade eben angezündet'
|
||||
if (minutes < 60) return `brennt seit ${minutes} ${minutes === 1 ? 'Minute' : 'Minuten'}`
|
||||
if (hours < 24) return `brennt seit ${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}`
|
||||
return `brennt seit ${days} ${days === 1 ? 'Tag' : 'Tagen'}`
|
||||
}
|
||||
|
||||
function CandleFlame({ size = 1, delay = 0 }: { size?: number; delay?: number }) {
|
||||
const flameW = 16 * size
|
||||
const flameH = 24 * size
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center" style={{ gap: 0 }}>
|
||||
{/* Flame */}
|
||||
<motion.div
|
||||
style={{ width: flameW, height: flameH, position: 'relative' }}
|
||||
animate={{
|
||||
@@ -33,7 +43,6 @@ function Candle({ bodyH, bodyW, delay }: { bodyH: number; bodyW: number; delay:
|
||||
delay,
|
||||
}}
|
||||
>
|
||||
{/* Outer glow */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -48,7 +57,6 @@ function Candle({ bodyH, bodyW, delay }: { bodyH: number; bodyW: number; delay:
|
||||
filter: 'blur(6px)',
|
||||
}}
|
||||
/>
|
||||
{/* Main flame */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -60,10 +68,9 @@ function Candle({ bodyH, bodyW, delay }: { bodyH: number; bodyW: number; delay:
|
||||
background:
|
||||
'radial-gradient(ellipse at 50% 90%, rgba(255,200,60,0.95) 0%, rgba(255,110,10,0.80) 45%, rgba(180,50,0,0.40) 75%, transparent 100%)',
|
||||
borderRadius: '50% 50% 35% 35% / 55% 55% 45% 45%',
|
||||
filter: `blur(${bodyW * 0.09}px)`,
|
||||
filter: 'blur(0.8px)',
|
||||
}}
|
||||
/>
|
||||
{/* Inner core */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -78,63 +85,408 @@ function Candle({ bodyH, bodyW, delay }: { bodyH: number; bodyW: number; delay:
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function SingleCandle({ candle, index }: { candle: CandleData; index: number }) {
|
||||
// Generate consistent but varied properties based on candle ID
|
||||
const seed = candle.id * 7919 // Prime number for good distribution
|
||||
const sizeVariant = ((seed % 5) / 4) * 0.6 + 0.7 // 0.7 to 1.3
|
||||
const heightVariant = ((seed % 7) / 6) * 0.5 + 0.75 // 0.75 to 1.25
|
||||
const hueShift = (seed % 20) - 10 // -10 to +10 hue shift
|
||||
const brightnessShift = ((seed % 15) - 7) / 100 // -0.07 to +0.08 brightness
|
||||
const rotation = ((seed % 11) - 5) / 2 // -2.5 to +2.5 degrees
|
||||
|
||||
// Calculate burn-down based on age
|
||||
const createdTime = new Date(candle.created_at + 'Z').getTime()
|
||||
const now = Date.now()
|
||||
const ageInHours = (now - createdTime) / (1000 * 60 * 60)
|
||||
const burnProgress = Math.min(ageInHours / 24, 0.4) // Burns down max 40% over 24 hours
|
||||
|
||||
const candleHeight = 60 * heightVariant * (1 - burnProgress)
|
||||
const candleWidth = 28 * sizeVariant
|
||||
const flameSize = 0.8 * sizeVariant
|
||||
const delay = (index % 7) * 0.15
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.8 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.6, delay: Math.min(index * 0.08, 1.2) }}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
filter: `brightness(${1 + brightnessShift})`,
|
||||
marginLeft: index > 0 ? '-8px' : '0', // Slight overlap for natural clustering
|
||||
}}
|
||||
className="group relative"
|
||||
>
|
||||
{/* Glow */}
|
||||
<div
|
||||
className="absolute -inset-3 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-500"
|
||||
style={{
|
||||
background: `radial-gradient(circle, rgba(255,180,40,${0.12 + brightnessShift}) 0%, transparent 70%)`,
|
||||
filter: 'blur(12px)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Flame */}
|
||||
<div className="relative z-10 mb-1">
|
||||
<CandleFlame size={flameSize} delay={delay} />
|
||||
</div>
|
||||
|
||||
{/* Wick */}
|
||||
<div
|
||||
style={{
|
||||
width: 1.5,
|
||||
height: 5,
|
||||
backgroundColor: 'rgba(60,30,10,0.9)',
|
||||
marginBottom: -1,
|
||||
zIndex: 1,
|
||||
width: 2,
|
||||
height: 6 * sizeVariant,
|
||||
background: 'linear-gradient(to bottom, #2b1a0a, #1a0f06)',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Candle body */}
|
||||
{/* Candle Body */}
|
||||
<div
|
||||
style={{
|
||||
width: bodyW,
|
||||
height: bodyH,
|
||||
background:
|
||||
'linear-gradient(to right, rgba(240,230,210,0.10) 0%, rgba(255,248,235,0.07) 40%, rgba(220,200,170,0.04) 100%)',
|
||||
borderRadius: '1px 1px 0 0',
|
||||
border: '1px solid rgba(255,255,255,0.05)',
|
||||
borderBottom: 'none',
|
||||
width: candleWidth,
|
||||
height: candleHeight,
|
||||
background: `linear-gradient(to bottom,
|
||||
hsl(${35 + hueShift}, ${65 + hueShift}%, ${88 + brightnessShift * 10}%) 0%,
|
||||
hsl(${32 + hueShift}, ${60 + hueShift}%, ${82 + brightnessShift * 10}%) 100%)`,
|
||||
borderRadius: `${2 * sizeVariant}px ${2 * sizeVariant}px ${4 * sizeVariant}px ${4 * sizeVariant}px`,
|
||||
boxShadow: `
|
||||
inset 2px 0 4px rgba(255,255,255,${0.4 + brightnessShift}),
|
||||
inset -2px 0 6px rgba(0,0,0,${0.2 - brightnessShift}),
|
||||
0 ${4 * heightVariant}px ${12 * heightVariant}px rgba(0,0,0,0.3)
|
||||
`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Wax drip highlight */}
|
||||
{/* Wax drips - more visible */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: bodyW * 0.2,
|
||||
width: bodyW * 0.15,
|
||||
height: bodyH * 0.4,
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
borderRadius: '0 0 50% 50%',
|
||||
left: `${15 + (seed % 30)}%`,
|
||||
width: `${4 * sizeVariant}px`,
|
||||
height: `${16 * heightVariant}px`,
|
||||
background: `linear-gradient(to bottom,
|
||||
hsl(${33 + hueShift}, ${60 + hueShift}%, ${82 + brightnessShift * 10}%) 0%,
|
||||
hsl(${33 + hueShift}, ${55 + hueShift}%, ${85 + brightnessShift * 10}%) 50%,
|
||||
transparent 100%)`,
|
||||
borderRadius: `0 0 ${2 * sizeVariant}px ${2 * sizeVariant}px`,
|
||||
opacity: 0.85,
|
||||
boxShadow: `inset 1px 0 2px rgba(255,255,255,0.3)`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${8 * (1 - burnProgress)}px`,
|
||||
right: `${10 + ((seed * 3) % 25)}%`,
|
||||
width: `${3.5 * sizeVariant}px`,
|
||||
height: `${20 * heightVariant}px`,
|
||||
background: `linear-gradient(to bottom,
|
||||
hsl(${34 + hueShift}, ${62 + hueShift}%, ${80 + brightnessShift * 10}%) 0%,
|
||||
hsl(${34 + hueShift}, ${58 + hueShift}%, ${83 + brightnessShift * 10}%) 60%,
|
||||
transparent 100%)`,
|
||||
borderRadius: `0 0 ${2 * sizeVariant}px ${2 * sizeVariant}px`,
|
||||
opacity: 0.75,
|
||||
boxShadow: `inset -1px 0 2px rgba(255,255,255,0.2)`,
|
||||
}}
|
||||
/>
|
||||
{/* Additional smaller drip for realism */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${4 * (1 - burnProgress)}px`,
|
||||
left: `${45 + (seed % 20)}%`,
|
||||
width: `${2 * sizeVariant}px`,
|
||||
height: `${10 * heightVariant}px`,
|
||||
background: `linear-gradient(to bottom,
|
||||
hsl(${35 + hueShift}, ${58 + hueShift}%, ${84 + brightnessShift * 10}%),
|
||||
transparent)`,
|
||||
borderRadius: `0 0 ${1 * sizeVariant}px ${1 * sizeVariant}px`,
|
||||
opacity: 0.6,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Base plate */}
|
||||
<div
|
||||
{/* Name Label */}
|
||||
<p
|
||||
className="text-amber-200/50 font-cormorant italic mt-2 text-center leading-tight"
|
||||
style={{
|
||||
width: bodyW + 6,
|
||||
height: 3,
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
borderRadius: '0 0 2px 2px',
|
||||
fontSize: `${10 * sizeVariant}px`,
|
||||
textShadow: '0 0 8px rgba(196,160,74,0.15)',
|
||||
maxWidth: `${80 * sizeVariant}px`,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{candle.name}
|
||||
</p>
|
||||
<p
|
||||
className="text-amber-200/35 font-lora text-center leading-tight"
|
||||
style={{
|
||||
fontSize: `${7 * sizeVariant}px`,
|
||||
marginTop: `${2 * sizeVariant}px`,
|
||||
}}
|
||||
>
|
||||
{relativeTime(candle.created_at)}
|
||||
</p>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function BurningNote({ message, onComplete }: { message: string; onComplete: () => void }) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onComplete, 4000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [onComplete])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 1 }}
|
||||
className="flex flex-col items-center gap-6"
|
||||
>
|
||||
{/* Paper with realistic texture and burning */}
|
||||
<div className="relative" style={{ width: 280, height: 360 }}>
|
||||
<motion.div
|
||||
className="relative w-full h-full overflow-visible"
|
||||
style={{
|
||||
background: `
|
||||
linear-gradient(135deg, #f9f3e6 0%, #f5ead8 25%, #f0e4ca 50%, #ebe0c4 75%, #e6d9b8 100%)
|
||||
`,
|
||||
boxShadow: '0 8px 30px rgba(0,0,0,0.4), inset 2px 2px 6px rgba(0,0,0,0.05)',
|
||||
borderRadius: '2px',
|
||||
position: 'relative',
|
||||
}}
|
||||
animate={{
|
||||
opacity: [1, 1, 0.7, 0],
|
||||
scale: [1, 1, 0.95, 0.85],
|
||||
}}
|
||||
transition={{ duration: 4, times: [0, 0.6, 0.85, 1] }}
|
||||
>
|
||||
{/* Paper texture lines */}
|
||||
<div className="absolute inset-0 opacity-10" style={{
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 25px,
|
||||
rgba(139, 69, 19, 0.1) 25px,
|
||||
rgba(139, 69, 19, 0.1) 26px
|
||||
)`
|
||||
}} />
|
||||
|
||||
{/* Fire spreading from bottom */}
|
||||
<motion.div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: `
|
||||
radial-gradient(ellipse at 50% 100%,
|
||||
rgba(255, 100, 0, 0.9) 0%,
|
||||
rgba(255, 69, 0, 0.8) 15%,
|
||||
rgba(220, 20, 0, 0.6) 30%,
|
||||
rgba(139, 0, 0, 0.4) 50%,
|
||||
rgba(70, 0, 0, 0.2) 70%,
|
||||
transparent 85%
|
||||
)
|
||||
`,
|
||||
mixBlendMode: 'multiply',
|
||||
}}
|
||||
initial={{ clipPath: 'inset(100% 0 0 0)' }}
|
||||
animate={{ clipPath: 'inset(0% 0 0 0)' }}
|
||||
transition={{ duration: 3.5, ease: 'easeIn' }}
|
||||
/>
|
||||
|
||||
{/* Burning edges effect */}
|
||||
<motion.div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: 'radial-gradient(circle at 50% 100%, rgba(50,20,0,0.8) 0%, transparent 60%)',
|
||||
}}
|
||||
initial={{ opacity: 0, y: '100%' }}
|
||||
animate={{ opacity: [0, 1, 1, 0], y: ['100%', '0%', '-20%', '-40%'] }}
|
||||
transition={{ duration: 3.5, times: [0, 0.3, 0.7, 1] }}
|
||||
/>
|
||||
|
||||
{/* Orange glow */}
|
||||
<motion.div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse at 50% 100%, rgba(255,140,0,0.6) 0%, transparent 60%)',
|
||||
filter: 'blur(15px)',
|
||||
}}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: [0, 0.8, 0.9, 0] }}
|
||||
transition={{ duration: 3.5 }}
|
||||
/>
|
||||
|
||||
{/* Message text */}
|
||||
<div className="relative z-10 p-8 h-full flex flex-col">
|
||||
<motion.div
|
||||
className="font-cormorant text-warm-brown/90 text-base leading-relaxed whitespace-pre-wrap"
|
||||
animate={{ opacity: [1, 1, 0.3, 0] }}
|
||||
transition={{ duration: 3.5, times: [0, 0.5, 0.8, 1] }}
|
||||
>
|
||||
{message}
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Ash particles rising */}
|
||||
{Array.from({ length: 25 }).map((_, i) => {
|
||||
const delay = 0.8 + Math.random() * 2
|
||||
const xOffset = (Math.random() - 0.5) * 100
|
||||
const rotation = Math.random() * 360
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="absolute"
|
||||
style={{
|
||||
width: Math.random() * 6 + 3,
|
||||
height: Math.random() * 6 + 3,
|
||||
borderRadius: Math.random() > 0.5 ? '50%' : '2px',
|
||||
background: i % 4 === 0 ? '#2a1810' : i % 4 === 1 ? '#3d2619' : i % 4 === 2 ? '#FFB347' : '#FF6B00',
|
||||
bottom: Math.random() * 40,
|
||||
left: `${30 + Math.random() * 40}%`,
|
||||
filter: 'blur(1px)',
|
||||
}}
|
||||
animate={{
|
||||
y: [-10, -180 - Math.random() * 120],
|
||||
x: [0, xOffset],
|
||||
opacity: [0, 0.8, 0.6, 0],
|
||||
scale: [1, 0.8, 0.4, 0],
|
||||
rotate: [0, rotation],
|
||||
}}
|
||||
transition={{
|
||||
duration: 2.5 + Math.random() * 1.5,
|
||||
delay,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Ember particles */}
|
||||
{Array.from({ length: 15 }).map((_, i) => (
|
||||
<motion.div
|
||||
key={`ember-${i}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
width: 2 + Math.random() * 3,
|
||||
height: 2 + Math.random() * 3,
|
||||
borderRadius: '50%',
|
||||
background: `rgba(255, ${100 + Math.random() * 100}, 0, 0.9)`,
|
||||
bottom: 0,
|
||||
left: `${20 + Math.random() * 60}%`,
|
||||
boxShadow: `0 0 ${4 + Math.random() * 6}px rgba(255,140,0,0.8)`,
|
||||
}}
|
||||
animate={{
|
||||
y: [0, -100 - Math.random() * 100],
|
||||
x: [0, (Math.random() - 0.5) * 80],
|
||||
opacity: [0, 1, 0.8, 0],
|
||||
scale: [0.8, 1.2, 0.6, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.2 + Math.random() * 0.8,
|
||||
delay: 0.5 + Math.random() * 2.5,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 2.8 }}
|
||||
className="text-amber-200/60 text-sm font-cormorant italic"
|
||||
>
|
||||
Deine Nachricht verbrennt für Oma...
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CandleSection() {
|
||||
const [candles, setCandles] = useState<CandleData[]>([])
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [burning, setBurning] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const loadCandles = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/candles')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setCandles(Array.isArray(data) ? data : [])
|
||||
}
|
||||
} catch {
|
||||
// Ignore network errors
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadCandles()
|
||||
const interval = setInterval(loadCandles, 60000)
|
||||
return () => clearInterval(interval)
|
||||
}, [loadCandles])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) return
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/candles', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name.trim(), message: message.trim() || null }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
// Only show burning animation if there's a message
|
||||
if (message.trim()) {
|
||||
setBurning(true)
|
||||
} else {
|
||||
// Skip burning, go straight to done
|
||||
setDone(true)
|
||||
setName('')
|
||||
setMessage('')
|
||||
loadCandles()
|
||||
setTimeout(() => {
|
||||
setDone(false)
|
||||
setShowModal(false)
|
||||
}, 2500)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBurnComplete = useCallback(() => {
|
||||
setBurning(false)
|
||||
setDone(true)
|
||||
setName('')
|
||||
setMessage('')
|
||||
loadCandles()
|
||||
setTimeout(() => {
|
||||
setDone(false)
|
||||
setShowModal(false)
|
||||
}, 2500)
|
||||
}, [loadCandles])
|
||||
|
||||
return (
|
||||
<section
|
||||
className="py-24 overflow-hidden"
|
||||
id="kerzen"
|
||||
className="py-20 sm:py-24 overflow-hidden"
|
||||
style={{ background: 'linear-gradient(to bottom, #060304 0%, #0d0807 50%, #060304 100%)' }}
|
||||
>
|
||||
<motion.div
|
||||
@@ -142,33 +494,147 @@ export default function CandleSection() {
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 1.2 }}
|
||||
className="text-center"
|
||||
className="text-center max-w-5xl mx-auto px-4"
|
||||
>
|
||||
{/* Candles */}
|
||||
<div className="flex items-end justify-center gap-3 sm:gap-5 mb-14">
|
||||
{candleData.map((c, i) => (
|
||||
<Candle key={i} {...c} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
{/* Header */}
|
||||
<p
|
||||
className="font-cormorant italic text-amber-200/40 text-2xl sm:text-3xl tracking-widest"
|
||||
className="font-cormorant italic text-amber-200/40 text-2xl sm:text-3xl tracking-widest mb-3"
|
||||
style={{ textShadow: '0 0 40px rgba(196,160,74,0.12)' }}
|
||||
>
|
||||
Ruhe in Frieden
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 mt-5">
|
||||
<div className="flex items-center justify-center gap-4 mb-12">
|
||||
<div className="h-px w-20 bg-amber-400/10" />
|
||||
<span className="text-amber-400/15 text-xs">✦</span>
|
||||
<div className="h-px w-20 bg-amber-400/10" />
|
||||
</div>
|
||||
|
||||
<p className="font-lora text-amber-100/20 text-xs tracking-[0.4em] uppercase mt-4">
|
||||
29. November 1944 — 10. Februar 2026
|
||||
{/* Candle Grid - with better spacing for many candles */}
|
||||
{candles.length > 0 && (
|
||||
<div className="flex flex-wrap items-end justify-center gap-2 sm:gap-3 mb-12 max-w-4xl mx-auto">
|
||||
{candles.map((candle, i) => (
|
||||
<SingleCandle key={candle.id} candle={candle} index={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Light a candle button */}
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.03 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
onClick={() => setShowModal(true)}
|
||||
className="inline-flex items-center gap-2.5 px-7 py-3.5 rounded-full border border-amber-400/20 bg-amber-900/20 hover:bg-amber-900/40 text-amber-200/70 hover:text-amber-200 transition-all duration-300 font-cormorant italic text-lg"
|
||||
>
|
||||
<Flame size={18} className="text-amber-400/60" />
|
||||
Zünde eine Kerze für Oma an
|
||||
</motion.button>
|
||||
|
||||
{candles.length > 0 && (
|
||||
<p className="text-amber-200/20 text-xs font-lora mt-4">
|
||||
{candles.length} {candles.length === 1 ? 'Kerze brennt' : 'Kerzen brennen'} für Oma
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Modal */}
|
||||
<AnimatePresence>
|
||||
{showModal && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center px-4"
|
||||
style={{ backgroundColor: 'rgba(6,3,4,0.92)' }}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget && !burning) {
|
||||
setShowModal(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="w-full max-w-md"
|
||||
>
|
||||
{burning ? (
|
||||
<BurningNote message={message} onComplete={handleBurnComplete} />
|
||||
) : done ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center"
|
||||
>
|
||||
<div className="flex justify-center mb-4">
|
||||
<CandleFlame size={1.5} />
|
||||
</div>
|
||||
<p className="text-amber-200/80 font-cormorant italic text-2xl">
|
||||
Deine Kerze brennt jetzt für Oma
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="bg-amber-950/60 backdrop-blur-sm rounded-2xl p-6 sm:p-8 border border-amber-800/20">
|
||||
<div className="flex justify-center mb-6">
|
||||
<CandleFlame size={1.2} />
|
||||
</div>
|
||||
|
||||
<h3 className="text-amber-200/80 font-cormorant italic text-2xl text-center mb-6">
|
||||
Eine Kerze für Oma
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-amber-200/40 text-xs font-lora mb-1.5 uppercase tracking-wider">
|
||||
Dein Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="z.B. Maria"
|
||||
className="w-full px-4 py-3 rounded-xl bg-amber-900/30 border border-amber-700/20 text-amber-100 placeholder-amber-200/20 focus:outline-none focus:ring-2 focus:ring-amber-400/30 font-lora text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-amber-200/40 text-xs font-lora mb-1.5 uppercase tracking-wider">
|
||||
Deine Nachricht an Oma
|
||||
<span className="normal-case tracking-normal text-amber-200/20 ml-1">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Was möchtest du Oma sagen..."
|
||||
rows={4}
|
||||
className="w-full px-4 py-3 rounded-xl bg-amber-900/30 border border-amber-700/20 text-amber-100 placeholder-amber-200/20 focus:outline-none focus:ring-2 focus:ring-amber-400/30 font-lora text-sm resize-none leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="flex-1 py-3 rounded-xl border border-amber-700/20 text-amber-200/40 hover:text-amber-200/60 transition-colors font-lora text-sm"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!name.trim() || submitting}
|
||||
className="flex-1 py-3 rounded-xl bg-amber-700/40 hover:bg-amber-700/60 disabled:opacity-40 disabled:cursor-not-allowed text-amber-100 transition-colors font-lora text-sm flex items-center justify-center gap-2"
|
||||
>
|
||||
<Flame size={14} />
|
||||
{message.trim() ? 'Zettel verbrennen' : 'Kerze anzünden'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Upload, CheckCircle } from 'lucide-react'
|
||||
|
||||
export default function FamilyUploadSection() {
|
||||
const [name, setName] = useState('')
|
||||
const [relation, setRelation] = useState('')
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('name', name.trim() || 'Anonym')
|
||||
formData.append('relation', relation.trim())
|
||||
if (file) {
|
||||
formData.append('file', file)
|
||||
}
|
||||
|
||||
const res = await fetch('/api/family-upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setSuccess(true)
|
||||
setName('')
|
||||
setRelation('')
|
||||
setFile(null)
|
||||
setTimeout(() => setSuccess(false), 5000)
|
||||
} else {
|
||||
const data = await res.json()
|
||||
setError(data.error || 'Upload fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler. Bitte versuche es erneut.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="teilen" className="py-16 sm:py-20 bg-amber-50/30">
|
||||
<div className="max-w-2xl mx-auto px-4">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="text-center mb-10"
|
||||
>
|
||||
<h2 className="font-cormorant italic text-4xl sm:text-5xl text-warm-brown mb-3">
|
||||
Teile deine Erinnerungen
|
||||
</h2>
|
||||
<div className="flex items-center justify-center gap-4 mb-4">
|
||||
<div className="h-px w-16 bg-warm-gold/40" />
|
||||
<span className="text-warm-gold text-xl">✦</span>
|
||||
<div className="h-px w-16 bg-warm-gold/40" />
|
||||
</div>
|
||||
<p className="font-lora text-warm-brown-light/60 text-sm leading-relaxed max-w-lg mx-auto">
|
||||
Hast du Fotos oder Videos von Oma? Teile sie mit der Familie.
|
||||
Alle Beiträge werden von Dennis geprüft, bevor sie veröffentlicht werden.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Success Message */}
|
||||
{success && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-6 bg-green-50 border border-green-200 rounded-xl p-4 flex items-start gap-3"
|
||||
>
|
||||
<CheckCircle className="text-green-600 flex-shrink-0 mt-0.5" size={20} />
|
||||
<div>
|
||||
<p className="font-lora text-green-800 font-semibold text-sm">
|
||||
Erfolgreich hochgeladen!
|
||||
</p>
|
||||
<p className="font-lora text-green-700 text-xs mt-0.5">
|
||||
Dein Beitrag wird geprüft und bald veröffentlicht.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-6 bg-red-50 border border-red-200 rounded-xl p-4"
|
||||
>
|
||||
<p className="font-lora text-red-800 text-sm">{error}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<motion.form
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-cream border border-warm-border rounded-2xl p-6 sm:p-8 shadow-sm"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block font-lora text-warm-brown text-sm mb-2">
|
||||
Dein Name <span className="text-warm-brown-light/40 text-xs">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="z.B. Maria Schmidt"
|
||||
className="w-full px-4 py-3 rounded-xl border border-warm-border bg-white text-warm-brown placeholder-warm-brown-light/30 focus:outline-none focus:ring-2 focus:ring-warm-gold/30 font-lora text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Relation */}
|
||||
<div>
|
||||
<label className="block font-lora text-warm-brown text-sm mb-2">
|
||||
Deine Beziehung zu Oma <span className="text-warm-brown-light/40 text-xs">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={relation}
|
||||
onChange={(e) => setRelation(e.target.value)}
|
||||
placeholder="z.B. Enkelin, Nichte, Freundin"
|
||||
className="w-full px-4 py-3 rounded-xl border border-warm-border bg-white text-warm-brown placeholder-warm-brown-light/30 focus:outline-none focus:ring-2 focus:ring-warm-gold/30 font-lora text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File Upload */}
|
||||
<div>
|
||||
<label className="block font-lora text-warm-brown text-sm mb-2">
|
||||
Foto oder Video <span className="text-warm-brown-light/40 text-xs">(optional)</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
onChange={(e) => {
|
||||
const selectedFile = e.target.files?.[0]
|
||||
if (selectedFile) {
|
||||
setFile(selectedFile)
|
||||
setError('')
|
||||
}
|
||||
}}
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="flex items-center justify-center gap-3 w-full px-4 py-8 rounded-xl border-2 border-dashed border-warm-border bg-amber-50/50 hover:bg-amber-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<Upload className="text-warm-gold" size={24} />
|
||||
<div className="text-center">
|
||||
{file ? (
|
||||
<>
|
||||
<p className="font-lora text-warm-brown text-sm font-semibold">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="font-lora text-warm-brown-light/50 text-xs mt-1">
|
||||
Klicke, um eine andere Datei auszuwählen
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-lora text-warm-brown text-sm font-semibold">
|
||||
Datei auswählen
|
||||
</p>
|
||||
<p className="font-lora text-warm-brown-light/50 text-xs mt-1">
|
||||
Foto oder Video hochladen
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<motion.button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
whileHover={{ scale: submitting ? 1 : 1.02 }}
|
||||
whileTap={{ scale: submitting ? 1 : 0.98 }}
|
||||
className="w-full py-4 rounded-xl bg-warm-gold hover:bg-warm-gold/90 disabled:bg-warm-brown-light/20 disabled:cursor-not-allowed text-white font-cormorant italic text-lg transition-colors shadow-sm disabled:shadow-none"
|
||||
>
|
||||
{submitting ? 'Wird hochgeladen...' : 'Hochladen'}
|
||||
</motion.button>
|
||||
|
||||
<p className="text-center font-lora text-warm-brown-light/40 text-xs leading-relaxed">
|
||||
Dein Beitrag wird von Dennis geprüft, bevor er auf der Seite erscheint.
|
||||
</p>
|
||||
</div>
|
||||
</motion.form>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -120,7 +120,7 @@ export default function HeroSection({ heroPhoto }: HeroSectionProps) {
|
||||
<p className="font-lora text-amber-100/70 text-base sm:text-lg tracking-[0.25em]">
|
||||
29. November 1944
|
||||
</p>
|
||||
<p className="font-lora text-amber-100/40 text-sm tracking-[0.2em] mt-2">
|
||||
<p className="font-lora text-amber-100/40 text-base sm:text-lg tracking-[0.25em] mt-2">
|
||||
— 10. Februar 2026 —
|
||||
</p>
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Heart, CheckCircle2, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function MemoryUploadSection() {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
title: '',
|
||||
content: '',
|
||||
})
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [submitSuccess, setSubmitSuccess] = useState(false)
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
setFiles(Array.from(e.target.files))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
let uploadedFilenames: string[] = []
|
||||
if (files.length > 0) {
|
||||
const uploadFormData = new FormData()
|
||||
files.forEach(file => uploadFormData.append('files', file))
|
||||
|
||||
const uploadRes = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: uploadFormData,
|
||||
})
|
||||
|
||||
if (!uploadRes.ok) throw new Error('Upload failed')
|
||||
const uploadData = await uploadRes.json()
|
||||
uploadedFilenames = uploadData.filenames || []
|
||||
}
|
||||
|
||||
await fetch('/api/contributions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: formData.name || 'Anonym',
|
||||
type: 'memory',
|
||||
title: formData.title,
|
||||
content: formData.content,
|
||||
media_filenames: uploadedFilenames.length > 0 ? uploadedFilenames.join(',') : null,
|
||||
}),
|
||||
})
|
||||
|
||||
setSubmitSuccess(true)
|
||||
setFormData({ name: '', title: '', content: '' })
|
||||
setFiles([])
|
||||
setTimeout(() => setSubmitSuccess(false), 3000)
|
||||
} catch (error) {
|
||||
console.error('Error:', error)
|
||||
alert('Fehler beim Absenden.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-12 px-4 bg-amber-50/30">
|
||||
<div className="max-w-xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-6"
|
||||
>
|
||||
<h3 className="font-cormorant italic text-2xl text-warm-brown mb-2 flex items-center justify-center gap-2">
|
||||
<Heart size={24} className="text-warm-gold" />
|
||||
Erinnerung teilen
|
||||
</h3>
|
||||
<p className="font-lora text-warm-brown-light/60 text-xs">
|
||||
Teile deine persönliche Erinnerung
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{submitSuccess ? (
|
||||
<motion.div
|
||||
key="success"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="bg-green-50 border border-green-200 rounded-xl p-6 text-center"
|
||||
>
|
||||
<CheckCircle2 className="w-10 h-10 text-green-600 mx-auto mb-2" />
|
||||
<p className="font-lora text-green-700/80 text-sm">
|
||||
Erinnerung eingereicht!
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.form
|
||||
key="form"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white/60 backdrop-blur-sm rounded-xl shadow-md border border-warm-border p-5 space-y-4"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Dein Name (optional)"
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="Titel *"
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30"
|
||||
/>
|
||||
<textarea
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
placeholder="Deine Erinnerung *"
|
||||
rows={4}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30 resize-none"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,video/*"
|
||||
onChange={handleFileChange}
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-xs text-warm-brown file:mr-3 file:py-1 file:px-3 file:rounded-full file:border-0 file:text-xs file:bg-warm-gold/10 file:text-warm-gold hover:file:bg-warm-gold/20 file:cursor-pointer focus:outline-none focus:ring-2 focus:ring-warm-gold/30"
|
||||
/>
|
||||
{files.length > 0 && (
|
||||
<p className="font-lora text-xs text-warm-brown-light/60">
|
||||
{files.length} Datei(en) ausgewählt
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-warm-gold hover:bg-warm-gold-light text-white font-lora text-sm py-2.5 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Sendet...
|
||||
</>
|
||||
) : (
|
||||
'Erinnerung teilen'
|
||||
)}
|
||||
</button>
|
||||
<p className="font-lora text-xs text-warm-brown-light/40 text-center">
|
||||
* Pflichtfelder
|
||||
</p>
|
||||
</motion.form>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
||||
const playingRef = useRef(false)
|
||||
|
||||
const [userMuted, setUserMuted] = useState(false)
|
||||
const [hasStarted, setHasStarted] = useState(false)
|
||||
|
||||
const getActive = useCallback(
|
||||
() => (activeRef.current === 'A' ? audioA.current : audioB.current),
|
||||
@@ -99,35 +100,37 @@ export default function MusicPlayer({ tracks }: { tracks: MediaItem[] }) {
|
||||
if (playingRef.current) return
|
||||
const a = audioA.current
|
||||
if (!a) return
|
||||
a.volume = VOLUME
|
||||
a.volume = userMuted ? 0 : VOLUME
|
||||
a.play().then(() => {
|
||||
playingRef.current = true
|
||||
setHasStarted(true)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
}, [userMuted])
|
||||
|
||||
// Try autoplay on mount (silent, then make audible on interaction)
|
||||
// Try autoplay on mount
|
||||
useEffect(() => {
|
||||
if (!src) return
|
||||
const a = audioA.current
|
||||
if (!a) return
|
||||
|
||||
// Try to autoplay
|
||||
a.volume = VOLUME
|
||||
// Try to autoplay immediately (unmuted)
|
||||
a.volume = userMuted ? 0 : VOLUME
|
||||
a.play().then(() => {
|
||||
playingRef.current = true
|
||||
setHasStarted(true)
|
||||
}).catch(() => {
|
||||
// Blocked — will start on first interaction via ensurePlaying
|
||||
// Blocked by browser — will start on first interaction
|
||||
})
|
||||
|
||||
// Safety net: on any interaction, make sure audio is playing
|
||||
// On any interaction, make sure audio is playing
|
||||
const handler = () => ensurePlaying()
|
||||
const events = ['click', 'touchstart', 'scroll', 'keydown'] as const
|
||||
events.forEach((e) => window.addEventListener(e, handler, { passive: true }))
|
||||
events.forEach((e) => window.addEventListener(e, handler, { once: true, passive: true }))
|
||||
|
||||
return () => {
|
||||
events.forEach((e) => window.removeEventListener(e, handler))
|
||||
}
|
||||
}, [src, ensurePlaying])
|
||||
}, [src, userMuted, ensurePlaying])
|
||||
|
||||
if (!track || !src) return null
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function PhotoSlideshow({ photos }: { photos: MediaItem[] }) {
|
||||
return (
|
||||
<div
|
||||
className="relative w-full overflow-hidden rounded-2xl shadow-2xl mb-10"
|
||||
style={{ aspectRatio: '16/7' }}
|
||||
style={{ aspectRatio: '3/2' }}
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Image as ImageIcon, CheckCircle2, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function PhotoUploadSection() {
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [submitSuccess, setSubmitSuccess] = useState(false)
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
setFiles(Array.from(e.target.files))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (files.length === 0) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const uploadFormData = new FormData()
|
||||
files.forEach(file => uploadFormData.append('files', file))
|
||||
|
||||
const uploadRes = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: uploadFormData,
|
||||
})
|
||||
|
||||
if (!uploadRes.ok) throw new Error('Upload failed')
|
||||
const uploadData = await uploadRes.json()
|
||||
const uploadedFilenames = uploadData.filenames || []
|
||||
|
||||
// Create media contribution
|
||||
await fetch('/api/contributions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Anonym',
|
||||
type: 'media',
|
||||
media_filenames: uploadedFilenames.join(','),
|
||||
}),
|
||||
})
|
||||
|
||||
setSubmitSuccess(true)
|
||||
setFiles([])
|
||||
setTimeout(() => setSubmitSuccess(false), 3000)
|
||||
} catch (error) {
|
||||
console.error('Error:', error)
|
||||
alert('Fehler beim Hochladen.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-12 px-4 bg-cream">
|
||||
<div className="max-w-xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-6"
|
||||
>
|
||||
<h3 className="font-cormorant italic text-2xl text-warm-brown mb-2 flex items-center justify-center gap-2">
|
||||
<ImageIcon size={24} className="text-warm-gold" />
|
||||
Fotos hochladen
|
||||
</h3>
|
||||
<p className="font-lora text-warm-brown-light/60 text-xs">
|
||||
Einfach Fotos hochladen - ohne Text oder Titel
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{submitSuccess ? (
|
||||
<motion.div
|
||||
key="success"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="bg-green-50 border border-green-200 rounded-xl p-6 text-center"
|
||||
>
|
||||
<CheckCircle2 className="w-10 h-10 text-green-600 mx-auto mb-2" />
|
||||
<p className="font-lora text-green-700/80 text-sm">
|
||||
Fotos hochgeladen!
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.form
|
||||
key="form"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white/60 backdrop-blur-sm rounded-xl shadow-md border border-warm-border p-5 space-y-4"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-xs text-warm-brown file:mr-3 file:py-1 file:px-3 file:rounded-full file:border-0 file:text-xs file:bg-warm-gold/10 file:text-warm-gold hover:file:bg-warm-gold/20 file:cursor-pointer focus:outline-none focus:ring-2 focus:ring-warm-gold/30"
|
||||
/>
|
||||
{files.length > 0 && (
|
||||
<p className="font-lora text-xs text-warm-brown-light/60">
|
||||
{files.length} Foto(s) ausgewählt
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || files.length === 0}
|
||||
className="w-full bg-warm-gold hover:bg-warm-gold-light text-white font-lora text-sm py-2.5 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Lädt hoch...
|
||||
</>
|
||||
) : (
|
||||
'Hochladen'
|
||||
)}
|
||||
</button>
|
||||
</motion.form>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { ChefHat, X } from 'lucide-react'
|
||||
|
||||
type Recipe = {
|
||||
id: number
|
||||
title: string
|
||||
description: string | null
|
||||
ingredients: string | null
|
||||
instructions: string | null
|
||||
}
|
||||
|
||||
function RecipeModal({ recipe, onClose }: { recipe: Recipe; onClose: () => void }) {
|
||||
const [activeTab, setActiveTab] = useState<'ingredients' | 'instructions'>('ingredients')
|
||||
|
||||
const ingredientsList = recipe.ingredients?.split('\n').filter(Boolean) || []
|
||||
const instructionsList = recipe.instructions?.split('\n').filter(Boolean) || []
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center px-4 py-8 overflow-y-auto"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.75)' }}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="relative bg-cream rounded-2xl shadow-2xl max-w-2xl w-full max-h-[85vh] overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-cream border-b border-warm-border z-10 px-6 sm:px-8 py-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 text-warm-brown-light/40 hover:text-warm-brown transition-colors"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
<div className="flex items-start gap-3">
|
||||
<ChefHat className="text-warm-gold mt-1 flex-shrink-0" size={28} />
|
||||
<div>
|
||||
<h3 className="font-cormorant italic text-3xl text-warm-brown">
|
||||
{recipe.title}
|
||||
</h3>
|
||||
{recipe.description && (
|
||||
<p className="font-lora text-warm-brown-light/60 text-sm mt-2 leading-relaxed">
|
||||
{recipe.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-warm-border bg-amber-50/50">
|
||||
<button
|
||||
onClick={() => setActiveTab('ingredients')}
|
||||
className={`flex-1 py-3 font-cormorant italic text-lg transition-colors ${
|
||||
activeTab === 'ingredients'
|
||||
? 'text-warm-brown border-b-2 border-warm-gold bg-cream'
|
||||
: 'text-warm-brown-light/50 hover:text-warm-brown-light'
|
||||
}`}
|
||||
>
|
||||
Zutaten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('instructions')}
|
||||
className={`flex-1 py-3 font-cormorant italic text-lg transition-colors ${
|
||||
activeTab === 'instructions'
|
||||
? 'text-warm-brown border-b-2 border-warm-gold bg-cream'
|
||||
: 'text-warm-brown-light/50 hover:text-warm-brown-light'
|
||||
}`}
|
||||
>
|
||||
Zubereitung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 sm:px-8 py-6 overflow-y-auto max-h-[calc(85vh-220px)]">
|
||||
<AnimatePresence mode="wait">
|
||||
{activeTab === 'ingredients' ? (
|
||||
<motion.div
|
||||
key="ingredients"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{ingredientsList.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{ingredientsList.map((ingredient, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-start gap-3 font-lora text-warm-brown-light"
|
||||
>
|
||||
<span className="text-warm-gold mt-1 flex-shrink-0">✦</span>
|
||||
<span>{ingredient}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-warm-brown-light/50 font-lora italic">
|
||||
Keine Zutaten angegeben
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="instructions"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{instructionsList.length > 0 ? (
|
||||
<ol className="space-y-4">
|
||||
{instructionsList.map((instruction, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-start gap-3 font-lora text-warm-brown-light leading-relaxed"
|
||||
>
|
||||
<span className="font-cormorant text-warm-gold text-xl font-semibold flex-shrink-0 mt-0.5">
|
||||
{i + 1}.
|
||||
</span>
|
||||
<span>{instruction}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="text-warm-brown-light/50 font-lora italic">
|
||||
Keine Anleitung angegeben
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RecipeSection({ recipes }: { recipes: Recipe[] }) {
|
||||
const [selectedRecipe, setSelectedRecipe] = useState<Recipe | null>(null)
|
||||
|
||||
if (recipes.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<section id="rezepte" className="py-16 sm:py-20 bg-cream">
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="font-cormorant italic text-4xl sm:text-5xl text-warm-brown mb-3">
|
||||
Omas Rezepte
|
||||
</h2>
|
||||
<div className="flex items-center justify-center gap-4 mb-3">
|
||||
<div className="h-px w-16 bg-warm-gold/40" />
|
||||
<span className="text-warm-gold text-xl">✦</span>
|
||||
<div className="h-px w-16 bg-warm-gold/40" />
|
||||
</div>
|
||||
<p className="font-lora text-warm-brown-light/60 text-sm">
|
||||
Die Geheimnisse aus ihrer Küche
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Recipe Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{recipes.map((recipe, index) => (
|
||||
<motion.div
|
||||
key={recipe.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
whileHover={{ y: -4 }}
|
||||
onClick={() => setSelectedRecipe(recipe)}
|
||||
className="bg-amber-50/50 border border-warm-border rounded-xl p-6 cursor-pointer transition-shadow hover:shadow-lg"
|
||||
>
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<ChefHat className="text-warm-gold flex-shrink-0 mt-0.5" size={24} />
|
||||
<h3 className="font-cormorant italic text-2xl text-warm-brown leading-tight">
|
||||
{recipe.title}
|
||||
</h3>
|
||||
</div>
|
||||
{recipe.description && (
|
||||
<p className="font-lora text-warm-brown-light/60 text-sm leading-relaxed line-clamp-3">
|
||||
{recipe.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 text-warm-gold text-sm font-lora italic">
|
||||
Rezept ansehen →
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Modal */}
|
||||
<AnimatePresence>
|
||||
{selectedRecipe && (
|
||||
<RecipeModal
|
||||
recipe={selectedRecipe}
|
||||
onClose={() => setSelectedRecipe(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { ChefHat, CheckCircle2, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function RecipeUploadSection() {
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
ingredients: '',
|
||||
instructions: '',
|
||||
})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [submitSuccess, setSubmitSuccess] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
await fetch('/api/contributions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Rezept',
|
||||
type: 'recipe',
|
||||
title: formData.title,
|
||||
content: JSON.stringify({
|
||||
description: formData.description,
|
||||
ingredients: formData.ingredients,
|
||||
instructions: formData.instructions,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
setSubmitSuccess(true)
|
||||
setFormData({
|
||||
title: '',
|
||||
description: '',
|
||||
ingredients: '',
|
||||
instructions: '',
|
||||
})
|
||||
setTimeout(() => setSubmitSuccess(false), 3000)
|
||||
} catch (error) {
|
||||
console.error('Error:', error)
|
||||
alert('Fehler beim Absenden.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-12 px-4 bg-amber-50/30">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-6"
|
||||
>
|
||||
<h3 className="font-cormorant italic text-2xl text-warm-brown mb-2 flex items-center justify-center gap-2">
|
||||
<ChefHat size={24} className="text-warm-gold" />
|
||||
Rezept teilen
|
||||
</h3>
|
||||
<p className="font-lora text-warm-brown-light/60 text-xs">
|
||||
Teile ein Rezept von Oma
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{submitSuccess ? (
|
||||
<motion.div
|
||||
key="success"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="bg-green-50 border border-green-200 rounded-xl p-6 text-center"
|
||||
>
|
||||
<CheckCircle2 className="w-10 h-10 text-green-600 mx-auto mb-2" />
|
||||
<p className="font-lora text-green-700/80 text-sm">
|
||||
Rezept eingereicht!
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.form
|
||||
key="form"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white/60 backdrop-blur-sm rounded-xl shadow-md border border-warm-border p-5 space-y-4"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="Rezept-Titel *"
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30"
|
||||
/>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Kurze Beschreibung (optional)"
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30 resize-none"
|
||||
/>
|
||||
<textarea
|
||||
value={formData.ingredients}
|
||||
onChange={(e) => setFormData({ ...formData, ingredients: e.target.value })}
|
||||
placeholder="Zutaten * (z.B. 500g Mehl, 2 Eier...)"
|
||||
rows={4}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30 resize-none"
|
||||
/>
|
||||
<textarea
|
||||
value={formData.instructions}
|
||||
onChange={(e) => setFormData({ ...formData, instructions: e.target.value })}
|
||||
placeholder="Zubereitung * (Schritt für Schritt...)"
|
||||
rows={5}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30 resize-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-warm-gold hover:bg-warm-gold-light text-white font-lora text-sm py-2.5 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Sendet...
|
||||
</>
|
||||
) : (
|
||||
'Rezept teilen'
|
||||
)}
|
||||
</button>
|
||||
<p className="font-lora text-xs text-warm-brown-light/40 text-center">
|
||||
* Pflichtfelder
|
||||
</p>
|
||||
</motion.form>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Calendar, CheckCircle } from 'lucide-react'
|
||||
|
||||
export default function TimelineContributionSection() {
|
||||
const [name, setName] = useState('')
|
||||
const [story, setStory] = useState('')
|
||||
const [addToTimeline, setAddToTimeline] = useState(false)
|
||||
const [year, setYear] = useState('')
|
||||
const [month, setMonth] = useState('')
|
||||
const [day, setDay] = useState('')
|
||||
const [title, setTitle] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
// No required fields - allow empty submissions
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/timeline-contributions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.trim() || 'Anonym',
|
||||
year: addToTimeline ? (year.trim() || null) : null,
|
||||
month: addToTimeline ? (month.trim() || null) : null,
|
||||
day: addToTimeline ? (day.trim() || null) : null,
|
||||
title: addToTimeline ? (title.trim() || 'Erinnerung') : 'Erinnerung',
|
||||
story: story.trim() || '',
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setSuccess(true)
|
||||
setName('')
|
||||
setYear('')
|
||||
setMonth('')
|
||||
setDay('')
|
||||
setTitle('')
|
||||
setStory('')
|
||||
setAddToTimeline(false)
|
||||
setTimeout(() => setSuccess(false), 5000)
|
||||
} else {
|
||||
const data = await res.json()
|
||||
setError(data.error || 'Fehler beim Senden')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler. Bitte versuche es erneut.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="zeitstrahl-beitragen" className="py-16 sm:py-20 bg-cream">
|
||||
<div className="max-w-2xl mx-auto px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="text-center mb-10"
|
||||
>
|
||||
<h2 className="font-cormorant italic text-4xl sm:text-5xl text-warm-brown mb-3">
|
||||
Teile deine Erinnerung
|
||||
</h2>
|
||||
<div className="flex items-center justify-center gap-4 mb-4">
|
||||
<div className="h-px w-16 bg-warm-gold/40" />
|
||||
<span className="text-warm-gold text-xl">✦</span>
|
||||
<div className="h-px w-16 bg-warm-gold/40" />
|
||||
</div>
|
||||
<p className="font-lora text-warm-brown-light/60 text-sm leading-relaxed max-w-lg mx-auto">
|
||||
Hast du eine besondere Erinnerung an Oma? Teile deine Geschichte mit uns.
|
||||
Alle Beiträge werden von Dennis geprüft.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{success && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-6 bg-green-50 border border-green-200 rounded-xl p-4 flex items-start gap-3"
|
||||
>
|
||||
<CheckCircle className="text-green-600 flex-shrink-0 mt-0.5" size={20} />
|
||||
<div>
|
||||
<p className="font-lora text-green-800 font-semibold text-sm">
|
||||
Vielen Dank für deine Erinnerung!
|
||||
</p>
|
||||
<p className="font-lora text-green-700 text-xs mt-0.5">
|
||||
Dein Beitrag wird geprüft und erscheint bald auf der Seite.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-6 bg-red-50 border border-red-200 rounded-xl p-4"
|
||||
>
|
||||
<p className="font-lora text-red-800 text-sm">{error}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.form
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-amber-50/50 border border-warm-border rounded-2xl p-6 sm:p-8 shadow-sm"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block font-lora text-warm-brown text-sm mb-2">
|
||||
Dein Name <span className="text-warm-brown-light/40 text-xs">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="z.B. Maria Schmidt"
|
||||
className="w-full px-4 py-3 rounded-xl border border-warm-border bg-white text-warm-brown placeholder-warm-brown-light/30 focus:outline-none focus:ring-2 focus:ring-warm-gold/30 font-lora text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Story */}
|
||||
<div>
|
||||
<label className="block font-lora text-warm-brown text-sm mb-2">
|
||||
Deine Erinnerung <span className="text-warm-brown-light/40 text-xs">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={story}
|
||||
onChange={(e) => setStory(e.target.value)}
|
||||
placeholder="Erzähle uns deine Erinnerung an Oma..."
|
||||
rows={6}
|
||||
className="w-full px-4 py-3 rounded-xl border border-warm-border bg-white text-warm-brown placeholder-warm-brown-light/30 focus:outline-none focus:ring-2 focus:ring-warm-gold/30 font-lora text-sm resize-none leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Add to timeline checkbox */}
|
||||
<div className="bg-white/60 rounded-xl p-4 border border-warm-border/50">
|
||||
<label className="flex items-start gap-3 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={addToTimeline}
|
||||
onChange={(e) => setAddToTimeline(e.target.checked)}
|
||||
className="mt-1 w-4 h-4 rounded border-warm-border text-warm-gold focus:ring-warm-gold/30"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="font-lora text-warm-brown text-sm font-medium group-hover:text-warm-gold transition-colors">
|
||||
Als Ereignis im Zeitstrahl anzeigen
|
||||
</span>
|
||||
<p className="font-lora text-warm-brown-light/50 text-xs mt-0.5">
|
||||
Nur ankreuzen, wenn es ein bestimmtes Ereignis war (z.B. "Omas 60. Geburtstag")
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Timeline fields - only shown if checkbox is checked */}
|
||||
{addToTimeline && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="space-y-4 bg-amber-50 rounded-xl p-4 border border-warm-gold/20"
|
||||
>
|
||||
<p className="font-lora text-warm-brown text-xs font-medium">
|
||||
Ergänze die Details für den Zeitstrahl:
|
||||
</p>
|
||||
|
||||
{/* Date */}
|
||||
<div>
|
||||
<label className="block font-lora text-warm-brown text-sm mb-2">
|
||||
Wann war das? <span className="text-warm-brown-light/40 text-xs">(optional)</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={year}
|
||||
onChange={(e) => setYear(e.target.value)}
|
||||
placeholder="Jahr"
|
||||
className="px-4 py-3 rounded-xl border border-warm-border bg-white text-warm-brown placeholder-warm-brown-light/30 focus:outline-none focus:ring-2 focus:ring-warm-gold/30 font-lora text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={month}
|
||||
onChange={(e) => setMonth(e.target.value)}
|
||||
placeholder="Monat"
|
||||
className="px-4 py-3 rounded-xl border border-warm-border bg-white text-warm-brown placeholder-warm-brown-light/30 focus:outline-none focus:ring-2 focus:ring-warm-gold/30 font-lora text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={day}
|
||||
onChange={(e) => setDay(e.target.value)}
|
||||
placeholder="Tag"
|
||||
className="px-4 py-3 rounded-xl border border-warm-border bg-white text-warm-brown placeholder-warm-brown-light/30 focus:outline-none focus:ring-2 focus:ring-warm-gold/30 font-lora text-sm"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-warm-brown-light/40 text-xs mt-1.5 font-lora">
|
||||
Alle Felder optional
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block font-lora text-warm-brown text-sm mb-2">
|
||||
Ereignis-Titel <span className="text-warm-brown-light/40 text-xs">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="z.B. Omas 60. Geburtstag"
|
||||
className="w-full px-4 py-3 rounded-xl border border-warm-border bg-white text-warm-brown placeholder-warm-brown-light/30 focus:outline-none focus:ring-2 focus:ring-warm-gold/30 font-lora text-sm"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<motion.button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
whileHover={{ scale: submitting ? 1 : 1.02 }}
|
||||
whileTap={{ scale: submitting ? 1 : 0.98 }}
|
||||
className="w-full py-4 rounded-xl bg-warm-gold hover:bg-warm-gold/90 disabled:bg-warm-brown-light/20 disabled:cursor-not-allowed text-white font-cormorant italic text-lg transition-colors shadow-sm disabled:shadow-none flex items-center justify-center gap-2"
|
||||
>
|
||||
<Calendar size={20} />
|
||||
{submitting ? 'Wird gesendet...' : 'Erinnerung teilen'}
|
||||
</motion.button>
|
||||
|
||||
<p className="text-center font-lora text-warm-brown-light/40 text-xs leading-relaxed">
|
||||
Dein Beitrag wird von Dennis geprüft.
|
||||
</p>
|
||||
</div>
|
||||
</motion.form>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { MapPin, X, Calendar, User } from 'lucide-react'
|
||||
|
||||
type TimelineEntry = {
|
||||
id: number
|
||||
year: string
|
||||
month: string | null
|
||||
day: string | null
|
||||
title: string
|
||||
description: string | null
|
||||
location: string | null
|
||||
media_filenames: string | null
|
||||
source: 'official' | 'community'
|
||||
contributorName?: string
|
||||
}
|
||||
|
||||
interface TimelineSectionProps {
|
||||
entries: TimelineEntry[]
|
||||
}
|
||||
|
||||
function formatDate(year: string, month?: string | null, day?: string | null): string {
|
||||
if (day && month) {
|
||||
const monthNames = ['Jan', 'Feb', 'März', 'Apr', 'Mai', 'Juni', 'Juli', 'Aug', 'Sept', 'Okt', 'Nov', 'Dez']
|
||||
const monthName = monthNames[parseInt(month) - 1] || month
|
||||
return `${day}. ${monthName} ${year}`
|
||||
} else if (month) {
|
||||
const monthNames = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
|
||||
const monthName = monthNames[parseInt(month) - 1] || `Monat ${month}`
|
||||
return `${monthName} ${year}`
|
||||
}
|
||||
return year
|
||||
}
|
||||
|
||||
export default function TimelineSection({ entries }: TimelineSectionProps) {
|
||||
const [selectedEntry, setSelectedEntry] = useState<TimelineEntry | null>(null)
|
||||
|
||||
// Find birth and death indices
|
||||
const birthIndex = entries.findIndex(e => e.title.toLowerCase().includes('geburt'))
|
||||
const deathIndex = entries.findIndex(e => e.title.toLowerCase().includes('tod') || e.title.toLowerCase().includes('verstorben'))
|
||||
|
||||
return (
|
||||
<section id="zeitstrahl" className="py-16 sm:py-24 px-4 bg-gradient-to-b from-amber-50/30 to-cream">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="font-cormorant italic text-4xl sm:text-5xl text-warm-brown mb-4">
|
||||
Lebensreise
|
||||
</h2>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<div className="h-px w-16 bg-warm-gold/40" />
|
||||
<span className="text-warm-gold text-xl">✦</span>
|
||||
<div className="h-px w-16 bg-warm-gold/40" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Timeline Path */}
|
||||
<div className="relative" style={{ minHeight: entries.length > 0 ? `${entries.length * 180}px` : '400px' }}>
|
||||
{/* SVG with line AND dots - Desktop */}
|
||||
{entries.length >= 2 && (
|
||||
<svg
|
||||
className="absolute left-0 top-0 w-full h-full pointer-events-none hidden sm:block"
|
||||
style={{ zIndex: 1 }}
|
||||
viewBox="-2 -2 104 104"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="timelineGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="rgb(196, 160, 74)" stopOpacity="0.8" />
|
||||
<stop offset="50%" stopColor="rgb(196, 160, 74)" stopOpacity="0.6" />
|
||||
<stop offset="100%" stopColor="rgb(196, 160, 74)" stopOpacity="0.8" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Draw the path */}
|
||||
<path
|
||||
d={(() => {
|
||||
const totalEntries = entries.length
|
||||
let pathString = ''
|
||||
|
||||
// Calculate actual spacing: py-4 (16px) + cards with sm:space-y-16 (64px)
|
||||
const cardSpacing = 64 // space-y-16
|
||||
const topPadding = 16 // py-4
|
||||
const dotOffset = 40 // Approximate middle of card (cards ~80-100px tall)
|
||||
|
||||
for (let i = 0; i < totalEntries; i++) {
|
||||
// Calculate Y based on actual layout
|
||||
const actualPixels = topPadding + (i * (cardSpacing + 80)) + dotOffset
|
||||
const containerHeight = topPadding + ((totalEntries - 1) * (cardSpacing + 80)) + dotOffset + 20
|
||||
const y = (actualPixels / containerHeight) * 100
|
||||
const x = 50
|
||||
|
||||
if (i === 0) {
|
||||
pathString = `M ${x} ${y}`
|
||||
} else {
|
||||
const prevActualPixels = topPadding + ((i - 1) * (cardSpacing + 80)) + dotOffset
|
||||
const prevY = (prevActualPixels / containerHeight) * 100
|
||||
const midY = (prevY + y) / 2
|
||||
|
||||
// Vary the curve intensity
|
||||
const curveIntensity = 8 + (i % 3) * 3 // Varies between 8, 11, 14
|
||||
const controlX1 = 50 + (i % 2 === 0 ? -curveIntensity : curveIntensity)
|
||||
const controlX2 = 50 + (i % 2 === 0 ? curveIntensity : -curveIntensity)
|
||||
|
||||
pathString += ` C ${controlX1} ${midY}, ${controlX2} ${midY}, ${x} ${y}`
|
||||
}
|
||||
}
|
||||
|
||||
return pathString
|
||||
})()}
|
||||
stroke="url(#timelineGradient)"
|
||||
strokeWidth="1"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
|
||||
{/* Draw dots at each point */}
|
||||
{entries.map((entry, i) => {
|
||||
const totalEntries = entries.length
|
||||
const cardSpacing = 64
|
||||
const topPadding = 16
|
||||
const dotOffset = 40
|
||||
|
||||
const actualPixels = topPadding + (i * (cardSpacing + 80)) + dotOffset
|
||||
const containerHeight = topPadding + ((totalEntries - 1) * (cardSpacing + 80)) + dotOffset + 20
|
||||
const y = (actualPixels / containerHeight) * 100
|
||||
const x = 50
|
||||
|
||||
const isBirth = i === birthIndex
|
||||
const isDeath = i === deathIndex
|
||||
const isSpecial = isBirth || isDeath
|
||||
|
||||
return (
|
||||
<circle
|
||||
key={i}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={isSpecial ? 1.4 : 0.9}
|
||||
fill="rgb(196, 160, 74)"
|
||||
stroke="white"
|
||||
strokeWidth="0.4"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* Mobile straight line */}
|
||||
<div className="absolute left-5 top-0 bottom-0 w-0.5 bg-gradient-to-b from-warm-gold/40 via-warm-gold/25 to-warm-gold/40 sm:hidden" />
|
||||
|
||||
{/* Entries */}
|
||||
<div className="space-y-12 sm:space-y-16 py-4 relative" style={{ zIndex: 10 }}>
|
||||
{entries.map((entry, index) => {
|
||||
const isLeft = index % 2 === 0
|
||||
const isBirth = index === birthIndex
|
||||
const isDeath = index === deathIndex
|
||||
const isSpecial = isBirth || isDeath
|
||||
const photos = entry.media_filenames ? entry.media_filenames.split(',') : []
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={`${entry.source}-${entry.id}`}
|
||||
initial={{ opacity: 0, x: isLeft ? -30 : 30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, margin: '-50px' }}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
className={`relative flex items-start ${
|
||||
isLeft
|
||||
? 'sm:flex-row flex-row sm:pr-[52%]'
|
||||
: 'sm:flex-row-reverse flex-row sm:pl-[52%]'
|
||||
}`}
|
||||
>
|
||||
{/* Content Card */}
|
||||
<motion.button
|
||||
onClick={() => setSelectedEntry(entry)}
|
||||
whileHover={{ scale: 1.02, y: -2 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className={`group cursor-pointer text-left w-full bg-white/80 backdrop-blur-sm rounded-xl shadow-md border border-warm-border p-4 sm:p-5 hover:shadow-lg transition-all ${
|
||||
isLeft ? 'sm:mr-auto' : 'sm:ml-auto'
|
||||
} ${isSpecial ? 'ring-2 ring-warm-gold/30' : ''}`}
|
||||
style={{ maxWidth: isSpecial ? '400px' : '360px' }}
|
||||
>
|
||||
{/* Photos */}
|
||||
{photos.length > 0 && (
|
||||
<div className={`grid gap-2 mb-3 ${photos.length === 1 ? 'grid-cols-1' : 'grid-cols-2'}`}>
|
||||
{photos.slice(0, 2).map((filename, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={`/api/files/${filename.trim()}`}
|
||||
alt=""
|
||||
className="w-full h-24 object-cover rounded-lg"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date */}
|
||||
<div className={`font-cormorant mb-1 ${isSpecial ? 'text-3xl text-warm-gold' : 'text-2xl text-warm-gold'}`}>
|
||||
{formatDate(entry.year, entry.month, entry.day)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className={`font-cormorant italic mb-1 group-hover:text-warm-gold transition-colors ${isSpecial ? 'text-xl text-warm-brown font-medium' : 'text-lg text-warm-brown'}`}>
|
||||
{entry.title}
|
||||
</h3>
|
||||
|
||||
{/* Location */}
|
||||
{entry.location && (
|
||||
<div className="flex items-center gap-1 text-warm-brown-light/50 mb-2 text-xs">
|
||||
<MapPin size={11} />
|
||||
<span className="font-lora">{entry.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description preview */}
|
||||
{entry.description && (
|
||||
<p className="font-lora text-warm-brown-light/60 text-xs leading-relaxed line-clamp-2">
|
||||
{entry.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Contributor */}
|
||||
{entry.source === 'community' && entry.contributorName && (
|
||||
<div className="flex items-center gap-1 text-amber-600/60 text-[10px] mt-2 italic font-lora">
|
||||
<User size={10} />
|
||||
<span>{entry.contributorName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-warm-gold/40 group-hover:text-warm-gold/70 text-[10px] font-lora mt-2 transition-colors">
|
||||
{photos.length > 2 && `+${photos.length - 2} weitere · `}Details →
|
||||
</p>
|
||||
</motion.button>
|
||||
|
||||
{/* Dot - Mobile only (Desktop dots are in SVG) */}
|
||||
<div className="sm:hidden absolute left-5 top-6 -translate-x-1/2">
|
||||
<div className={`rounded-full border-2 border-white ${
|
||||
isSpecial
|
||||
? 'w-5 h-5 bg-warm-gold ring-2 ring-warm-gold/20'
|
||||
: entry.source === 'community'
|
||||
? 'w-3 h-3 bg-amber-400'
|
||||
: 'w-3 h-3 bg-warm-gold'
|
||||
}`} />
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail Modal */}
|
||||
<AnimatePresence>
|
||||
{selectedEntry && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setSelectedEntry(null)}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, y: 20 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, y: 20 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-cream rounded-2xl shadow-2xl max-w-2xl w-full max-h-[85vh] overflow-y-auto"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-cream/95 backdrop-blur-sm border-b border-warm-border p-6 flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 text-warm-gold text-sm font-lora mb-2">
|
||||
<Calendar size={14} />
|
||||
{formatDate(selectedEntry.year, selectedEntry.month, selectedEntry.day)}
|
||||
</div>
|
||||
<h3 className="font-cormorant italic text-3xl text-warm-brown">
|
||||
{selectedEntry.title}
|
||||
</h3>
|
||||
{selectedEntry.location && (
|
||||
<div className="flex items-center gap-1 text-warm-brown-light/60 text-sm mt-1">
|
||||
<MapPin size={13} />
|
||||
<span className="font-lora">{selectedEntry.location}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedEntry(null)}
|
||||
className="text-warm-brown-light/40 hover:text-warm-brown transition-colors p-1"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Photos */}
|
||||
{selectedEntry.media_filenames && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{selectedEntry.media_filenames.split(',').map((filename, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={`/api/files/${filename.trim()}`}
|
||||
alt=""
|
||||
className="w-full h-48 object-cover rounded-lg"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{selectedEntry.description && (
|
||||
<p className="font-lora text-warm-brown-light/80 text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{selectedEntry.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Contributor info */}
|
||||
{selectedEntry.source === 'community' && selectedEntry.contributorName && (
|
||||
<div className="pt-4 border-t border-warm-border">
|
||||
<p className="font-lora text-xs text-amber-600/60 italic flex items-center gap-1.5">
|
||||
<User size={12} />
|
||||
Beitrag von {selectedEntry.contributorName}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Calendar, MapPin, CheckCircle2, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function TimelineUploadSection() {
|
||||
const [eventType, setEventType] = useState<'general' | 'personal'>('general')
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
year: '',
|
||||
month: '',
|
||||
day: '',
|
||||
title: '',
|
||||
description: '',
|
||||
location: '',
|
||||
})
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [submitSuccess, setSubmitSuccess] = useState(false)
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
setFiles(Array.from(e.target.files))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
let uploadedFilenames: string[] = []
|
||||
if (files.length > 0) {
|
||||
const uploadFormData = new FormData()
|
||||
files.forEach(file => uploadFormData.append('files', file))
|
||||
|
||||
const uploadRes = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: uploadFormData,
|
||||
})
|
||||
|
||||
if (!uploadRes.ok) throw new Error('Upload failed')
|
||||
const uploadData = await uploadRes.json()
|
||||
uploadedFilenames = uploadData.filenames || []
|
||||
}
|
||||
|
||||
await fetch('/api/contributions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: eventType === 'personal' && formData.name ? formData.name : (eventType === 'personal' ? 'Anonym' : null),
|
||||
type: 'timeline',
|
||||
year: formData.year,
|
||||
month: formData.month || null,
|
||||
day: formData.day || null,
|
||||
title: formData.title,
|
||||
content: formData.description || null,
|
||||
location: formData.location || null,
|
||||
media_filenames: uploadedFilenames.length > 0 ? uploadedFilenames.join(',') : null,
|
||||
}),
|
||||
})
|
||||
|
||||
setSubmitSuccess(true)
|
||||
setFormData({ name: '', year: '', month: '', day: '', title: '', description: '', location: '' })
|
||||
setFiles([])
|
||||
setTimeout(() => setSubmitSuccess(false), 3000)
|
||||
} catch (error) {
|
||||
console.error('Error:', error)
|
||||
alert('Fehler beim Absenden.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-12 px-4 bg-gradient-to-b from-cream to-amber-50/30">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-6"
|
||||
>
|
||||
<h3 className="font-cormorant italic text-2xl text-warm-brown mb-2 flex items-center justify-center gap-2">
|
||||
<Calendar size={24} className="text-warm-gold" />
|
||||
Zum Zeitstrahl beitragen
|
||||
</h3>
|
||||
<p className="font-lora text-warm-brown-light/60 text-xs">
|
||||
Teile ein Ereignis aus Omas Leben
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{submitSuccess ? (
|
||||
<motion.div
|
||||
key="success"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="bg-green-50 border border-green-200 rounded-xl p-6 text-center"
|
||||
>
|
||||
<CheckCircle2 className="w-10 h-10 text-green-600 mx-auto mb-2" />
|
||||
<p className="font-lora text-green-700/80 text-sm">
|
||||
Ereignis eingereicht!
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.form
|
||||
key="form"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white/60 backdrop-blur-sm rounded-xl shadow-md border border-warm-border p-5 space-y-4"
|
||||
>
|
||||
{/* Event Type Selection */}
|
||||
<div className="flex gap-3">
|
||||
<label className="flex-1 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
value="general"
|
||||
checked={eventType === 'general'}
|
||||
onChange={(e) => setEventType(e.target.value as 'general')}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="px-4 py-2 bg-white/80 border-2 border-warm-border rounded-lg text-center font-lora text-sm text-warm-brown peer-checked:border-warm-gold peer-checked:bg-warm-gold/10 transition-all">
|
||||
Generelles Ereignis
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex-1 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
value="personal"
|
||||
checked={eventType === 'personal'}
|
||||
onChange={(e) => setEventType(e.target.value as 'personal')}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="px-4 py-2 bg-white/80 border-2 border-warm-border rounded-lg text-center font-lora text-sm text-warm-brown peer-checked:border-warm-gold peer-checked:bg-warm-gold/10 transition-all">
|
||||
Persönliches Ereignis
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Conditional Name Field */}
|
||||
{eventType === 'personal' && (
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Dein Name (optional)"
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="Ereignis-Titel *"
|
||||
required
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30"
|
||||
/>
|
||||
|
||||
{/* Date */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.day}
|
||||
onChange={(e) => setFormData({ ...formData, day: e.target.value })}
|
||||
placeholder="Tag"
|
||||
className="px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.month}
|
||||
onChange={(e) => setFormData({ ...formData, month: e.target.value })}
|
||||
placeholder="Monat"
|
||||
className="px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.year}
|
||||
onChange={(e) => setFormData({ ...formData, year: e.target.value })}
|
||||
placeholder="Jahr *"
|
||||
required
|
||||
className="px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<input
|
||||
type="text"
|
||||
value={formData.location}
|
||||
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
|
||||
placeholder="Ort (optional)"
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30"
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Beschreibung (optional)"
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-sm text-warm-brown focus:outline-none focus:ring-2 focus:ring-warm-gold/30 resize-none"
|
||||
/>
|
||||
|
||||
{/* Photos */}
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="w-full px-3 py-2 bg-white/80 border border-warm-border rounded-lg font-lora text-xs text-warm-brown file:mr-3 file:py-1 file:px-3 file:rounded-full file:border-0 file:text-xs file:bg-warm-gold/10 file:text-warm-gold hover:file:bg-warm-gold/20 file:cursor-pointer focus:outline-none focus:ring-2 focus:ring-warm-gold/30"
|
||||
/>
|
||||
{files.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{files.map((file, i) => (
|
||||
<div key={i} className="aspect-square rounded-lg overflow-hidden bg-warm-brown/5">
|
||||
<img
|
||||
src={URL.createObjectURL(file)}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-warm-gold hover:bg-warm-gold-light text-white font-lora text-sm py-2.5 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Sendet...
|
||||
</>
|
||||
) : (
|
||||
'Ereignis einreichen'
|
||||
)}
|
||||
</button>
|
||||
<p className="font-lora text-xs text-warm-brown-light/40 text-center">
|
||||
* Pflichtfelder
|
||||
</p>
|
||||
</motion.form>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -124,9 +124,6 @@ export default function TributeSection() {
|
||||
<p className="font-cormorant italic text-warm-brown/50 text-lg">
|
||||
Maria Malejka
|
||||
</p>
|
||||
<p className="font-lora text-warm-brown-light/40 text-sm mt-1">
|
||||
29. November 1944 · 10. Februar 2026
|
||||
</p>
|
||||
<p className="font-lora text-warm-brown-light/30 text-xs mt-3 tracking-wide">
|
||||
Beerdigung am 20. Februar 2026
|
||||
</p>
|
||||
|
||||
+81
-1
@@ -2,7 +2,7 @@ import { DatabaseSync } from 'node:sqlite'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
export type { Memory, MediaItem } from './types'
|
||||
export type { Memory, MediaItem, Candle, TimelineEntry, Recipe, TimelineContribution } from './types'
|
||||
|
||||
const DATA_DIR = path.resolve(process.cwd(), process.env.DATA_DIR || 'data')
|
||||
|
||||
@@ -43,6 +43,52 @@ function initDb(db: DatabaseSync) {
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS candles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
message TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS timeline (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
year TEXT NOT NULL,
|
||||
month TEXT,
|
||||
day TEXT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
location TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contributions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT,
|
||||
email TEXT,
|
||||
type TEXT NOT NULL CHECK(type IN ('memory', 'timeline', 'media', 'recipe')),
|
||||
year TEXT,
|
||||
month TEXT,
|
||||
day TEXT,
|
||||
title TEXT,
|
||||
content TEXT,
|
||||
location TEXT,
|
||||
media_filenames TEXT,
|
||||
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'approved', 'rejected', 'flagged')),
|
||||
moderation_reason TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recipes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
ingredients TEXT,
|
||||
instructions TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
`)
|
||||
|
||||
// Migration: add author column if missing
|
||||
@@ -51,4 +97,38 @@ function initDb(db: DatabaseSync) {
|
||||
} catch {
|
||||
// Column already exists – ignore
|
||||
}
|
||||
|
||||
// Migration: add status column to media
|
||||
try {
|
||||
db.exec(`ALTER TABLE media ADD COLUMN status TEXT DEFAULT 'approved'`)
|
||||
} catch {
|
||||
// Column already exists – ignore
|
||||
}
|
||||
|
||||
// Migration: add media_filenames to timeline
|
||||
try {
|
||||
db.exec(`ALTER TABLE timeline ADD COLUMN media_filenames TEXT`)
|
||||
} catch {}
|
||||
|
||||
// Migration: add month, day, location to timeline
|
||||
try {
|
||||
db.exec(`ALTER TABLE timeline ADD COLUMN month TEXT`)
|
||||
} catch {}
|
||||
try {
|
||||
db.exec(`ALTER TABLE timeline ADD COLUMN day TEXT`)
|
||||
} catch {}
|
||||
try {
|
||||
db.exec(`ALTER TABLE timeline ADD COLUMN location TEXT`)
|
||||
} catch {}
|
||||
|
||||
// Add initial timeline entries if empty
|
||||
const count = db.prepare('SELECT COUNT(*) as count FROM timeline').get() as { count: number }
|
||||
if (count.count === 0) {
|
||||
db.prepare(`
|
||||
INSERT INTO timeline (year, month, day, title, description, location, sort_order)
|
||||
VALUES
|
||||
('1944', '11', '29', 'Geburt', 'Maria Malejka wurde geboren', 'Polen', 1),
|
||||
('2026', '2', '10', 'Tod', 'Maria Malejka ist friedlich verstorben', 'Deutschland', 999)
|
||||
`).run()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,5 +14,57 @@ export type MediaItem = {
|
||||
type: 'photo' | 'video' | 'music'
|
||||
caption: string | null
|
||||
sort_order: number
|
||||
status: 'approved' | 'pending'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type Candle = {
|
||||
id: number
|
||||
name: string
|
||||
message: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type TimelineEntry = {
|
||||
id: number
|
||||
year: string
|
||||
month: string | null
|
||||
day: string | null
|
||||
title: string
|
||||
description: string | null
|
||||
location: string | null
|
||||
media_filenames: string | null
|
||||
sort_order: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type Recipe = {
|
||||
id: number
|
||||
title: string
|
||||
description: string | null
|
||||
ingredients: string | null
|
||||
instructions: string | null
|
||||
sort_order: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type Contribution = {
|
||||
id: number
|
||||
name: string
|
||||
email: string | null
|
||||
type: 'memory' | 'timeline' | 'media' | 'recipe'
|
||||
year: string | null
|
||||
month: string | null
|
||||
day: string | null
|
||||
title: string | null
|
||||
content: string | null
|
||||
location: string | null
|
||||
media_filenames: string | null
|
||||
status: 'pending' | 'approved' | 'rejected' | 'flagged'
|
||||
moderation_reason?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Legacy alias for backwards compatibility
|
||||
export type TimelineContribution = Contribution
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
async function sha256(input: string): Promise<string> {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(input)
|
||||
const hash = await crypto.subtle.digest('SHA-256', data)
|
||||
return Array.from(new Uint8Array(hash))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
export default async function proxy(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl
|
||||
|
||||
// Allow: /zugang page, site-auth API, static assets, favicon
|
||||
if (
|
||||
pathname === '/zugang' ||
|
||||
pathname.startsWith('/api/site-auth') ||
|
||||
pathname.startsWith('/_next') ||
|
||||
pathname === '/favicon.ico' ||
|
||||
pathname === '/icon.svg'
|
||||
) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// Public API routes (no auth required)
|
||||
if (
|
||||
pathname.startsWith('/api/contributions') ||
|
||||
pathname.startsWith('/api/upload') ||
|
||||
pathname.startsWith('/api/candles') ||
|
||||
pathname.startsWith('/api/family-upload') ||
|
||||
pathname.startsWith('/api/timeline') ||
|
||||
pathname.startsWith('/api/recipes') ||
|
||||
pathname.startsWith('/api/memories') ||
|
||||
pathname.startsWith('/api/media') ||
|
||||
pathname.startsWith('/api/files') ||
|
||||
pathname.startsWith('/api/auth')
|
||||
) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
const sitePassword = process.env.SITE_PASSWORD || 'familie'
|
||||
const expectedToken = await sha256(sitePassword)
|
||||
const token = request.cookies.get('site_auth')?.value
|
||||
|
||||
if (token === expectedToken) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
const url = request.nextUrl.clone()
|
||||
url.pathname = '/zugang'
|
||||
return NextResponse.redirect(url)
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all paths except:
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization)
|
||||
* - favicon.ico
|
||||
*/
|
||||
'/((?!_next/static|_next/image|favicon.ico).*)',
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user