feat: Add user-template-dictionary with SQLite persistence
- New template: user-template-dictionary with Flask backend - Features: Add/Edit/Delete words, SQLite database per user - Persistent storage: Docker Volumes mount to /data/ - Modern HTML/CSS/JS Frontend with error handling - REST API: GET/POST/PUT/DELETE endpoints - Health checks and comprehensive logging - Comprehensive documentation in docs/templates/DICTIONARY_TEMPLATE.md - Updated templates.json and .env.example Files: - user-template-dictionary/Dockerfile - user-template-dictionary/app.py - user-template-dictionary/requirements.txt - user-template-dictionary/templates/index.html - docs/templates/DICTIONARY_TEMPLATE.md - templates.json (updated) - .env.example (updated)
This commit is contained in:
parent
f791424e3c
commit
e811c4fe3d
|
|
@ -65,8 +65,9 @@ USER_TEMPLATE_IMAGE=user-template-01:latest
|
||||||
# Beispiele für Anpassungen:
|
# Beispiele für Anpassungen:
|
||||||
# - Nur Nginx: "user-template-01:latest"
|
# - Nur Nginx: "user-template-01:latest"
|
||||||
# - Mit Next.js: "user-template-01:latest;user-template-next:latest"
|
# - Mit Next.js: "user-template-01:latest;user-template-next:latest"
|
||||||
# - Alle: "user-template-01:latest;user-template-02:latest;user-template-next:latest"
|
# - Mit Wörterbuch: "user-template-01:latest;user-template-dictionary:latest"
|
||||||
USER_TEMPLATE_IMAGES="user-template-01:latest;user-template-02:latest;user-template-next:latest"
|
# - Alle: "user-template-01:latest;user-template-02:latest;user-template-next:latest;user-template-dictionary:latest"
|
||||||
|
USER_TEMPLATE_IMAGES="user-template-01:latest;user-template-02:latest;user-template-next:latest;user-template-dictionary:latest"
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# RESSOURCEN - Container-Limits
|
# RESSOURCEN - Container-Limits
|
||||||
|
|
|
||||||
589
docs/templates/DICTIONARY_TEMPLATE.md
vendored
Normal file
589
docs/templates/DICTIONARY_TEMPLATE.md
vendored
Normal file
|
|
@ -0,0 +1,589 @@
|
||||||
|
# 📚 Wörterbuch Template - Vollständige Dokumentation
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
Das **Wörterbuch Template** (`user-template-dictionary`) ist eine Flask-basierte Web-Anwendung, die es Benutzern ermöglicht, persönliche Wörterbuch-Einträge zu speichern und zu verwalten. Jeder Eintrag besteht aus einem **Wort** und seiner **Bedeutung**, die in einer **SQLite-Datenbank** persistiert werden.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- ✅ **Persönliche Wörterbuch-Datenbank** pro Benutzer
|
||||||
|
- ✅ **SQLite-Persistierung** - Daten bleiben erhalten nach Container-Neustart
|
||||||
|
- ✅ **REST API** für Verwaltung (GET, POST, PUT, DELETE)
|
||||||
|
- ✅ **Moderne HTML/CSS/JS Frontend** mit Fehlerbehandlung
|
||||||
|
- ✅ **Health Checks** für Monitoring
|
||||||
|
- ✅ **Vollständige Fehlerbehandlung** und Logging
|
||||||
|
- ✅ **Docker Volume Support** für Datenpersistierung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
### High-Level Diagramm
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser Request
|
||||||
|
↓
|
||||||
|
Flask Backend (Port 8080)
|
||||||
|
↓
|
||||||
|
SQLite Database (/data/app.db)
|
||||||
|
↓
|
||||||
|
Docker Volume (/volumes/{user-id})
|
||||||
|
↓
|
||||||
|
Persistente Speicherung
|
||||||
|
```
|
||||||
|
|
||||||
|
### Komponenten
|
||||||
|
|
||||||
|
**Frontend (HTML/CSS/JavaScript):**
|
||||||
|
- `templates/index.html` - Single Page Application mit React-ähnlichem State Management
|
||||||
|
- Responsive Design (Mobile-freundlich)
|
||||||
|
- Real-time UI Updates
|
||||||
|
- Benutzerfreundliche Fehlerbehandlung
|
||||||
|
|
||||||
|
**Backend (Flask + SQLite):**
|
||||||
|
- `app.py` - Python Flask Anwendung
|
||||||
|
- SQLite Datenbank in `/data/app.db`
|
||||||
|
- REST API Endpoints
|
||||||
|
- Logging und Health Checks
|
||||||
|
|
||||||
|
**Containerisierung:**
|
||||||
|
- `Dockerfile` - Python 3.11 slim Image
|
||||||
|
- `requirements.txt` - Python Dependencies (Flask, Werkzeug)
|
||||||
|
- Unprivileged User (Port 8080)
|
||||||
|
- Health Check Endpoint
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation & Setup
|
||||||
|
|
||||||
|
### Schritt 1: Template in `.env` registrieren
|
||||||
|
|
||||||
|
Bearbeite `.env` und füge das Dictionary Template hinzu:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env
|
||||||
|
USER_TEMPLATE_IMAGES="user-template-01:latest;user-template-02:latest;user-template-next:latest;user-template-dictionary:latest"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wichtig:** Nur hier definierte Templates werden von `bash install.sh` gebaut!
|
||||||
|
|
||||||
|
### Schritt 2: Metadaten in `templates.json` aktualisieren
|
||||||
|
|
||||||
|
Das Template ist bereits in `templates.json` registriert:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "dictionary",
|
||||||
|
"image": "user-template-dictionary:latest",
|
||||||
|
"display_name": "📚 Wörterbuch",
|
||||||
|
"description": "Persönliches Wörterbuch mit Datenbank - Speichern Sie Wörter und Bedeutungen"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritt 3: Build & Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Alle Templates bauen (inkl. dictionary)
|
||||||
|
bash install.sh
|
||||||
|
|
||||||
|
# Docker Compose neu starten
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## REST API Referenz
|
||||||
|
|
||||||
|
### Base URL
|
||||||
|
```
|
||||||
|
http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
#### 1. Frontend abrufen
|
||||||
|
```http
|
||||||
|
GET /
|
||||||
|
```
|
||||||
|
**Response:** HTML-Seite mit Interface
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 2. Alle Wörter abrufen
|
||||||
|
```http
|
||||||
|
GET /api/words
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"words": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"word": "Serendipität",
|
||||||
|
"meaning": "Das glückliche Finden von etwas Ungesucht",
|
||||||
|
"created_at": "2026-03-18T10:30:45"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"word": "Wanderlust",
|
||||||
|
"meaning": "Starkes Verlangen zu reisen und die Welt zu erkunden",
|
||||||
|
"created_at": "2026-03-18T11:15:20"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"count": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 3. Neues Wort hinzufügen
|
||||||
|
```http
|
||||||
|
POST /api/words
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"word": "Schadenfreude",
|
||||||
|
"meaning": "Freude über das Unglück anderer"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (201 Created):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"word": "Schadenfreude",
|
||||||
|
"meaning": "Freude über das Unglück anderer",
|
||||||
|
"created_at": "2026-03-18T12:00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Response (409 Conflict):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Das Wort \"Schadenfreude\" existiert bereits"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 4. Wort aktualisieren
|
||||||
|
```http
|
||||||
|
PUT /api/words/{id}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"word": "Schadenfreude",
|
||||||
|
"meaning": "Böse Freude über Missgeschick eines anderen"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"word": "Schadenfreude",
|
||||||
|
"meaning": "Böse Freude über Missgeschick eines anderen",
|
||||||
|
"created_at": "2026-03-18T12:00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5. Wort löschen
|
||||||
|
```http
|
||||||
|
DELETE /api/words/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (204 No Content):**
|
||||||
|
(Leerer Body, nur Status 204)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 6. Statistiken abrufen
|
||||||
|
```http
|
||||||
|
GET /api/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total_words": 2,
|
||||||
|
"last_added": "2026-03-18T11:15:20",
|
||||||
|
"database": "sqlite3",
|
||||||
|
"storage": "/data/app.db"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 7. Health Check
|
||||||
|
```http
|
||||||
|
GET /health
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"database": "connected"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Response (500):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "database connection failed"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datapersistierung
|
||||||
|
|
||||||
|
### Docker Volumes
|
||||||
|
|
||||||
|
Die Datenbank wird in einem **Docker Volume** gespeichert, damit Daten bei Container-Neustarts erhalten bleiben.
|
||||||
|
|
||||||
|
### Automatische Konfiguration (via `container_manager.py`)
|
||||||
|
|
||||||
|
Das Spawner Backend sollte automatisch Volumes mounten:
|
||||||
|
|
||||||
|
```python
|
||||||
|
volumes = {
|
||||||
|
f"/volumes/{user_id}": {
|
||||||
|
"bind": "/data",
|
||||||
|
"mode": "rw"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ergebnis:**
|
||||||
|
- Jeder User erhält ein eigenes Verzeichnis `/volumes/{user_id}`
|
||||||
|
- Die SQLite DB wird gespeichert in `/volumes/{user_id}/app.db`
|
||||||
|
- Der Container sieht dies als `/data/app.db`
|
||||||
|
- Beim Container-Restart bleiben Daten erhalten
|
||||||
|
|
||||||
|
### Manuelles Testen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Container starten
|
||||||
|
docker run -v /volumes/user-123:/data -p 8080:8080 user-template-dictionary:latest
|
||||||
|
|
||||||
|
# Datenbank inspizieren
|
||||||
|
sqlite3 /volumes/user-123/app.db "SELECT * FROM words;"
|
||||||
|
|
||||||
|
# Container stoppen und neustart
|
||||||
|
docker stop <container-id>
|
||||||
|
docker start <container-id>
|
||||||
|
|
||||||
|
# Daten sollten noch da sein!
|
||||||
|
sqlite3 /volumes/user-123/app.db "SELECT * FROM words;"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datenbankschema
|
||||||
|
|
||||||
|
### Tabelle: `words`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE words (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
word TEXT NOT NULL UNIQUE,
|
||||||
|
meaning TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Spalten:**
|
||||||
|
- `id` - Eindeutige ID (Auto-Increment)
|
||||||
|
- `word` - Das Wort (UNIQUE - keine Duplikate!)
|
||||||
|
- `meaning` - Bedeutung/Definition
|
||||||
|
- `created_at` - Erstellungsdatum
|
||||||
|
- `updated_at` - Letztes Update
|
||||||
|
|
||||||
|
**Constraints:**
|
||||||
|
- `word` ist UNIQUE - kann nicht doppelt vorkommen
|
||||||
|
- Maximale Länge: 255 Zeichen für Wort, 2000 für Bedeutung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
### Input Validation
|
||||||
|
|
||||||
|
- ✅ Wort und Bedeutung sind erforderlich
|
||||||
|
- ✅ Maximale Längen: 255 Zeichen (Wort), 2000 Zeichen (Bedeutung)
|
||||||
|
- ✅ HTML-Escaping in Frontend (XSS-Schutz)
|
||||||
|
- ✅ SQL-Injection-Schutz via Prepared Statements
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- ✅ Konsistente JSON-Error-Responses
|
||||||
|
- ✅ Aussagekräftige Error-Messages
|
||||||
|
- ✅ HTTP Status Codes korrekt gesetzt
|
||||||
|
- ✅ Logging aller Fehler
|
||||||
|
|
||||||
|
### Docker Security
|
||||||
|
|
||||||
|
- ✅ Unprivileged User (nicht Root)
|
||||||
|
- ✅ Port 8080 (nicht privilegierter Port)
|
||||||
|
- ✅ SQLite für Single-User (keine Multi-Client Issues)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monitoring & Debugging
|
||||||
|
|
||||||
|
### Logs anschauen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Live Logs des Containers
|
||||||
|
docker logs -f user-dictionary-abc123
|
||||||
|
|
||||||
|
# Beispiel Log Output:
|
||||||
|
# [DICTIONARY] Database path: /data/app.db
|
||||||
|
# [DICTIONARY] Table 'words' already exists
|
||||||
|
# [DICTIONARY] Retrieved 2 words
|
||||||
|
# [DICTIONARY] Word added: 'Serendipität'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In Chrome DevTools oder terminal:
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
|
||||||
|
# Antwort:
|
||||||
|
# {"status": "ok", "database": "connected"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Debugging
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Mit der Python Shell in den Container gehen
|
||||||
|
docker exec -it <container-id> python
|
||||||
|
|
||||||
|
# Dann:
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect('/data/app.db')
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('SELECT * FROM words')
|
||||||
|
print(cursor.fetchall())
|
||||||
|
```
|
||||||
|
|
||||||
|
### Statistiken
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/api/stats
|
||||||
|
|
||||||
|
# Antwort:
|
||||||
|
# {"total_words": 5, "last_added": "2026-03-18T...", "database": "sqlite3", "storage": "/data/app.db"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance & Limits
|
||||||
|
|
||||||
|
### Empfehlungen
|
||||||
|
|
||||||
|
- **SQLite Limit:** ~1 Million Zeilen problemlos
|
||||||
|
- **Typical Use:** Bis zu 10.000 Wörter problemlos
|
||||||
|
- **Query Time:** < 10ms für typische Abfragen
|
||||||
|
- **Database Size:** ~1KB pro Wort
|
||||||
|
|
||||||
|
### Resource Limits (via `.env`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In .env definieren:
|
||||||
|
DEFAULT_MEMORY_LIMIT=512m # RAM pro Container
|
||||||
|
DEFAULT_CPU_QUOTA=50000 # 0.5 CPU
|
||||||
|
```
|
||||||
|
|
||||||
|
### Skalierung
|
||||||
|
|
||||||
|
Falls die Anwendung wächst:
|
||||||
|
1. **PostgreSQL verwenden** statt SQLite (für Multi-User)
|
||||||
|
2. **Redis Caching** für häufige Abfragen
|
||||||
|
3. **Elasticsearch** für Full-Text Suche in Bedeutungen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Problem: "Datenbankfehler beim Abrufen"
|
||||||
|
|
||||||
|
```
|
||||||
|
Mögliche Ursachen:
|
||||||
|
1. Volume nicht gemountet
|
||||||
|
2. /data Verzeichnis nicht vorhanden
|
||||||
|
3. Datenbank-Datei korrupt
|
||||||
|
4. Permissionen falsch
|
||||||
|
|
||||||
|
Lösung:
|
||||||
|
docker exec container-id ls -la /data
|
||||||
|
docker exec container-id sqlite3 /data/app.db "SELECT 1"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Problem: "Wort existiert bereits" beim Hinzufügen
|
||||||
|
|
||||||
|
```
|
||||||
|
Das ist normal! Die Anwendung verhindert Duplikate.
|
||||||
|
Wörter sind eindeutig (UNIQUE Constraint).
|
||||||
|
|
||||||
|
Wenn das Problem ist: Bearbeiten statt Hinzufügen nutzen.
|
||||||
|
Oder: Wort löschen und neu hinzufügen.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Problem: Daten nach Restart weg
|
||||||
|
|
||||||
|
```
|
||||||
|
Ursache: Volume nicht korrekt gemountet.
|
||||||
|
|
||||||
|
Prüfen:
|
||||||
|
docker inspect <container-id> | grep -A10 Mounts
|
||||||
|
|
||||||
|
Sollte zeigen:
|
||||||
|
"Mounts": [
|
||||||
|
{
|
||||||
|
"Type": "bind",
|
||||||
|
"Source": "/volumes/user-123",
|
||||||
|
"Destination": "/data"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Problem: Container startet nicht
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Logs prüfen
|
||||||
|
docker logs <container-id> 2>&1 | tail -50
|
||||||
|
|
||||||
|
# Python Syntax prüfen
|
||||||
|
docker build -t test . --no-cache
|
||||||
|
|
||||||
|
# Dockerfile validieren
|
||||||
|
docker run --rm -it user-template-dictionary:latest python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wartung & Updates
|
||||||
|
|
||||||
|
### Backup der Datenbank
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Einzelnen User-Backup
|
||||||
|
tar -czf backup-user-123.tar.gz /volumes/user-123/
|
||||||
|
|
||||||
|
# Alle User-Datenbanken
|
||||||
|
tar -czf backup-all-users.tar.gz /volumes/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Datenbank Upgrade
|
||||||
|
|
||||||
|
Falls die Tabelle erweitert werden soll:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In app.py - init_db() Methode:
|
||||||
|
cursor.execute('''
|
||||||
|
ALTER TABLE words ADD COLUMN
|
||||||
|
category TEXT DEFAULT 'general'
|
||||||
|
''')
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs archivieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Logs rotieren
|
||||||
|
docker logs --timestamps user-dictionary-abc > logs.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration mit Spawner
|
||||||
|
|
||||||
|
### Automatischer Container-Spawn
|
||||||
|
|
||||||
|
Wenn ein Benutzer dieses Template wählt:
|
||||||
|
|
||||||
|
1. Spawner erstellt Container mit Template `user-template-dictionary:latest`
|
||||||
|
2. Mountet Volume: `/volumes/{user-id}:/data`
|
||||||
|
3. Traefik routet Request zu Container unter `https://coder.domain.com/{user-slug}`
|
||||||
|
4. Benutzer sieht Wörterbuch-Interface
|
||||||
|
5. Datenbank wird in `/volumes/{user-id}/app.db` erstellt
|
||||||
|
6. Bei nächstem Login: Gleicher Container + Gleiche Datenbank = Gleiche Wörter!
|
||||||
|
|
||||||
|
### Admin-Dashboard Integration
|
||||||
|
|
||||||
|
Im Admin-Dashboard können Admins:
|
||||||
|
- ✅ Container starten/stoppen
|
||||||
|
- ✅ Container löschen (löscht auch Datenbank!)
|
||||||
|
- ✅ Logs ansehen
|
||||||
|
- ✅ Container-Status prüfen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Weitere Verbesserungen (Optional)
|
||||||
|
|
||||||
|
### Mögliche Features für Zukunft
|
||||||
|
|
||||||
|
1. **Kategorien** - Wörter in Kategorien organisieren
|
||||||
|
2. **Export/Import** - CSV/JSON Download
|
||||||
|
3. **Suche** - Volltext-Suche in Wörtern/Bedeutungen
|
||||||
|
4. **Tags** - Flexible Kategorisierung
|
||||||
|
5. **Statistiken** - Graphen, Lernfortschritt
|
||||||
|
6. **Multi-Language** - Übersetzungen hinzufügen
|
||||||
|
7. **Phonetik** - Audio-Aussprache
|
||||||
|
8. **Spaced Repetition** - Lern-Algorithmus
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datenschutz & DSGVO
|
||||||
|
|
||||||
|
- ✅ Daten werden lokal in Containern gespeichert
|
||||||
|
- ✅ Keine Daten an Dritte übertragen
|
||||||
|
- ✅ Benutzer hat vollständige Kontrolle
|
||||||
|
- ✅ Einfaches Löschen möglich (Container löschen)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support & Issues
|
||||||
|
|
||||||
|
Bei Problemen:
|
||||||
|
|
||||||
|
1. Logs prüfen: `docker logs container-id`
|
||||||
|
2. Health Check testen: `curl http://localhost:8080/health`
|
||||||
|
3. Datenbank prüfen: `sqlite3 /data/app.db ".tables"`
|
||||||
|
4. API testen: `curl http://localhost:8080/api/words`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version & Changelog
|
||||||
|
|
||||||
|
**Version:** 1.0.0 (2026-03-18)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- ✅ Wort hinzufügen/löschen/bearbeiten
|
||||||
|
- ✅ SQLite Persistierung
|
||||||
|
- ✅ REST API
|
||||||
|
- ✅ Modern HTML/CSS/JS Frontend
|
||||||
|
- ✅ Health Checks
|
||||||
|
- ✅ Fehlerbehandlung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lizenz & Attribution
|
||||||
|
|
||||||
|
**Template:** Container Spawner
|
||||||
|
**Autor:** Rainer Wieland
|
||||||
|
**Lizenz:** MIT oder ähnlich
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Letzte Aktualisierung
|
||||||
|
|
||||||
|
- **Datum:** 2026-03-18
|
||||||
|
- **Version:** 1.0.0
|
||||||
|
- **Status:** Production Ready ✅
|
||||||
|
|
@ -17,6 +17,12 @@
|
||||||
"image": "user-template-next:latest",
|
"image": "user-template-next:latest",
|
||||||
"display_name": "Next.js Production",
|
"display_name": "Next.js Production",
|
||||||
"description": "React-Anwendung mit Shadcn/UI, TypeScript und modernem Build-Setup"
|
"description": "React-Anwendung mit Shadcn/UI, TypeScript und modernem Build-Setup"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "dictionary",
|
||||||
|
"image": "user-template-dictionary:latest",
|
||||||
|
"display_name": "📚 Wörterbuch",
|
||||||
|
"description": "Persönliches Wörterbuch mit Datenbank - Speichern Sie Wörter und Bedeutungen"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
user-template-dictionary/Dockerfile
Normal file
24
user-template-dictionary/Dockerfile
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Abhängigkeiten installieren
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# App-Code
|
||||||
|
COPY app.py .
|
||||||
|
COPY templates/ templates/
|
||||||
|
|
||||||
|
# Daten-Verzeichnis für SQLite Datenbank (wird als Volume gemountet)
|
||||||
|
RUN mkdir -p /data && chmod 755 /data
|
||||||
|
|
||||||
|
# Port 8080 exponieren (unprivileged)
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Health Check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD python -c "import requests; requests.get('http://localhost:8080/health')" || exit 1
|
||||||
|
|
||||||
|
# Starten
|
||||||
|
CMD ["python", "app.py"]
|
||||||
267
user-template-dictionary/app.py
Normal file
267
user-template-dictionary/app.py
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
"""
|
||||||
|
Persönliches Wörterbuch - Flask Backend mit SQLite
|
||||||
|
Speichert Wörter und Bedeutungen in einer persistenten Datenbank pro Benutzer
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Flask, render_template, request, jsonify
|
||||||
|
from datetime import datetime
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Logging konfigurieren
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Pfad zur persistenten Datenbank (wird als Docker Volume gemountet)
|
||||||
|
DB_PATH = "/data/app.db"
|
||||||
|
os.makedirs("/data", exist_ok=True)
|
||||||
|
|
||||||
|
logger.info(f"[DICTIONARY] Database path: {DB_PATH}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""Verbindung zur SQLite-Datenbank"""
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""Erstelle Tabelle beim Start (falls noch nicht vorhanden)"""
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Prüfe ob Tabelle bereits existiert
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT name FROM sqlite_master
|
||||||
|
WHERE type='table' AND name='words'
|
||||||
|
""")
|
||||||
|
|
||||||
|
if not cursor.fetchone():
|
||||||
|
logger.info("[DICTIONARY] Creating 'words' table...")
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE words (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
word TEXT NOT NULL UNIQUE,
|
||||||
|
meaning TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
conn.commit()
|
||||||
|
logger.info("[DICTIONARY] Table 'words' created successfully")
|
||||||
|
else:
|
||||||
|
logger.info("[DICTIONARY] Table 'words' already exists")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
"""Hauptseite mit HTML-Interface"""
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/health')
|
||||||
|
def health():
|
||||||
|
"""Health Check Endpoint für Docker"""
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT 1")
|
||||||
|
conn.close()
|
||||||
|
return {'status': 'ok', 'database': 'connected'}, 200
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DICTIONARY] Health check failed: {e}")
|
||||||
|
return {'status': 'error', 'message': str(e)}, 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/words', methods=['GET'])
|
||||||
|
def get_words():
|
||||||
|
"""Alle gespeicherten Wörter abrufen"""
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('SELECT id, word, meaning, created_at FROM words ORDER BY created_at DESC')
|
||||||
|
words = [dict(row) for row in cursor.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
logger.info(f"[DICTIONARY] Retrieved {len(words)} words")
|
||||||
|
return jsonify({
|
||||||
|
'words': words,
|
||||||
|
'count': len(words)
|
||||||
|
}), 200
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DICTIONARY] Error retrieving words: {e}")
|
||||||
|
return jsonify({'error': 'Fehler beim Abrufen der Wörter'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/words', methods=['POST'])
|
||||||
|
def add_word():
|
||||||
|
"""Neues Wort hinzufügen"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return jsonify({'error': 'Keine Daten empfangen'}), 400
|
||||||
|
|
||||||
|
word = data.get('word', '').strip()
|
||||||
|
meaning = data.get('meaning', '').strip()
|
||||||
|
|
||||||
|
# Validierung
|
||||||
|
if not word or not meaning:
|
||||||
|
return jsonify({'error': 'Wort und Bedeutung sind erforderlich'}), 400
|
||||||
|
|
||||||
|
if len(word) > 255:
|
||||||
|
return jsonify({'error': 'Wort ist zu lang (max. 255 Zeichen)'}), 400
|
||||||
|
|
||||||
|
if len(meaning) > 2000:
|
||||||
|
return jsonify({'error': 'Bedeutung ist zu lang (max. 2000 Zeichen)'}), 400
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute(
|
||||||
|
'INSERT INTO words (word, meaning) VALUES (?, ?)',
|
||||||
|
(word, meaning)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
word_id = cursor.lastrowid
|
||||||
|
|
||||||
|
# Neuen Eintrag zurück
|
||||||
|
cursor.execute('SELECT id, word, meaning, created_at FROM words WHERE id = ?', (word_id,))
|
||||||
|
new_word = dict(cursor.fetchone())
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
logger.info(f"[DICTIONARY] Word added: '{word}'")
|
||||||
|
return jsonify(new_word), 201
|
||||||
|
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
conn.close()
|
||||||
|
logger.warning(f"[DICTIONARY] Duplicate word: '{word}'")
|
||||||
|
return jsonify({'error': f'Das Wort "{word}" existiert bereits'}), 409
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DICTIONARY] Error adding word: {e}")
|
||||||
|
return jsonify({'error': 'Fehler beim Speichern des Wortes'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/words/<int:word_id>', methods=['PUT'])
|
||||||
|
def update_word(word_id):
|
||||||
|
"""Wort aktualisieren"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return jsonify({'error': 'Keine Daten empfangen'}), 400
|
||||||
|
|
||||||
|
word = data.get('word', '').strip()
|
||||||
|
meaning = data.get('meaning', '').strip()
|
||||||
|
|
||||||
|
# Validierung
|
||||||
|
if not word or not meaning:
|
||||||
|
return jsonify({'error': 'Wort und Bedeutung sind erforderlich'}), 400
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute(
|
||||||
|
'UPDATE words SET word = ?, meaning = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
(word, meaning, word_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'error': 'Wort nicht gefunden'}), 404
|
||||||
|
|
||||||
|
# Aktualisiertes Wort zurück
|
||||||
|
cursor.execute('SELECT id, word, meaning, created_at FROM words WHERE id = ?', (word_id,))
|
||||||
|
updated_word = dict(cursor.fetchone())
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
logger.info(f"[DICTIONARY] Word updated: ID {word_id}")
|
||||||
|
return jsonify(updated_word), 200
|
||||||
|
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
conn.close()
|
||||||
|
logger.warning(f"[DICTIONARY] Duplicate word on update: '{word}'")
|
||||||
|
return jsonify({'error': f'Das Wort "{word}" existiert bereits'}), 409
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DICTIONARY] Error updating word: {e}")
|
||||||
|
return jsonify({'error': 'Fehler beim Aktualisieren des Wortes'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/words/<int:word_id>', methods=['DELETE'])
|
||||||
|
def delete_word(word_id):
|
||||||
|
"""Wort löschen"""
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('DELETE FROM words WHERE id = ?', (word_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'error': 'Wort nicht gefunden'}), 404
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
logger.info(f"[DICTIONARY] Word deleted: ID {word_id}")
|
||||||
|
return '', 204
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DICTIONARY] Error deleting word: {e}")
|
||||||
|
return jsonify({'error': 'Fehler beim Löschen des Wortes'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/stats')
|
||||||
|
def get_stats():
|
||||||
|
"""Statistiken über die Wörterbuch-Datenbank"""
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute('SELECT COUNT(*) as total FROM words')
|
||||||
|
total = cursor.fetchone()['total']
|
||||||
|
|
||||||
|
cursor.execute('SELECT MAX(created_at) as last_added FROM words')
|
||||||
|
last_added = cursor.fetchone()['last_added']
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'total_words': total,
|
||||||
|
'last_added': last_added,
|
||||||
|
'database': 'sqlite3',
|
||||||
|
'storage': '/data/app.db'
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DICTIONARY] Error getting stats: {e}")
|
||||||
|
return jsonify({'error': 'Fehler beim Abrufen der Statistiken'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def not_found(error):
|
||||||
|
"""404 Handler"""
|
||||||
|
return jsonify({'error': 'Endpoint nicht gefunden'}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@app.errorhandler(500)
|
||||||
|
def server_error(error):
|
||||||
|
"""500 Handler"""
|
||||||
|
logger.error(f"[DICTIONARY] Server error: {error}")
|
||||||
|
return jsonify({'error': 'Interner Fehler'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
logger.info("[DICTIONARY] Starting Flask application...")
|
||||||
|
init_db()
|
||||||
|
logger.info("[DICTIONARY] Database initialized")
|
||||||
|
app.run(host='0.0.0.0', port=8080, debug=False)
|
||||||
2
user-template-dictionary/requirements.txt
Normal file
2
user-template-dictionary/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
Flask==3.0.0
|
||||||
|
Werkzeug==3.0.0
|
||||||
593
user-template-dictionary/templates/index.html
Normal file
593
user-template-dictionary/templates/index.html
Normal file
|
|
@ -0,0 +1,593 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>📚 Mein Wörterbuch</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 30px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
opacity: 0.95;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header .stats {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: 12px 15px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: none;
|
||||||
|
animation: slideDown 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert.error {
|
||||||
|
background: #ffe0e0;
|
||||||
|
color: #c92a2a;
|
||||||
|
border-left: 4px solid #c92a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert.success {
|
||||||
|
background: #d3f9d8;
|
||||||
|
color: #2f9e44;
|
||||||
|
border-left: 4px solid #2f9e44;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: #333;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 2px solid #dee2e6;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: all 0.3s;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 70px;
|
||||||
|
max-height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 11px 15px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 16px rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #e9ecef;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #ff6b6b;
|
||||||
|
color: white;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #ff5252;
|
||||||
|
}
|
||||||
|
|
||||||
|
.words-section {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 30px;
|
||||||
|
border-top: 2px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.words-section h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: #333;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.words-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-card {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 15px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
animation: slideUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-card:hover {
|
||||||
|
border-color: #667eea;
|
||||||
|
background: #f0f3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-content .word {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #667eea;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-content .meaning {
|
||||||
|
color: #555;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-content .date {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
padding: 50px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 30px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 3px solid #e9ecef;
|
||||||
|
border-top-color: #667eea;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
color: white;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-mode .edit-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-card {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>📚 Mein Wörterbuch</h1>
|
||||||
|
<p>Ihre persönliche Wörter-Sammlung</p>
|
||||||
|
<div class="stats">
|
||||||
|
<span id="wordCount">0</span> Wörter gespeichert
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="alert error" id="errorMsg"></div>
|
||||||
|
<div class="alert success" id="successMsg"></div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<h2>✍️ Neues Wort hinzufügen</h2>
|
||||||
|
<form id="wordForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="word">Wort</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="word"
|
||||||
|
placeholder="z.B. Serendipität"
|
||||||
|
maxlength="255"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="meaning">Bedeutung</label>
|
||||||
|
<textarea
|
||||||
|
id="meaning"
|
||||||
|
placeholder="z.B. Das glückliche Finden von etwas Ungesucht..."
|
||||||
|
maxlength="2000"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-group">
|
||||||
|
<button type="submit" class="btn-primary">Hinzufügen</button>
|
||||||
|
<button type="reset" class="btn-secondary">Zurücksetzen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="words-section">
|
||||||
|
<h2>📖 Ihre Wörter <span class="count-badge" id="countBadge">0</span></h2>
|
||||||
|
<div id="wordsList" class="words-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// State Management
|
||||||
|
let words = [];
|
||||||
|
let isLoading = false;
|
||||||
|
|
||||||
|
// DOM Elements
|
||||||
|
const wordForm = document.getElementById('wordForm');
|
||||||
|
const wordInput = document.getElementById('word');
|
||||||
|
const meaningInput = document.getElementById('meaning');
|
||||||
|
const wordsList = document.getElementById('wordsList');
|
||||||
|
const errorMsg = document.getElementById('errorMsg');
|
||||||
|
const successMsg = document.getElementById('successMsg');
|
||||||
|
const wordCount = document.getElementById('wordCount');
|
||||||
|
const countBadge = document.getElementById('countBadge');
|
||||||
|
|
||||||
|
// Event Listeners
|
||||||
|
wordForm.addEventListener('submit', handleAddWord);
|
||||||
|
|
||||||
|
// Funktionen
|
||||||
|
async function loadWords() {
|
||||||
|
isLoading = true;
|
||||||
|
wordsList.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/words');
|
||||||
|
if (!response.ok) throw new Error('Fehler beim Laden');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
words = data.words || [];
|
||||||
|
renderWords();
|
||||||
|
} catch (error) {
|
||||||
|
showError('Fehler beim Laden der Wörter: ' + error.message);
|
||||||
|
wordsList.innerHTML = '<div class="empty-state"><div class="empty-state-icon">⚠️</div><p>Fehler beim Laden</p></div>';
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWords() {
|
||||||
|
updateCount();
|
||||||
|
|
||||||
|
if (words.length === 0) {
|
||||||
|
wordsList.innerHTML = '<div class="empty-state"><div class="empty-state-icon">📚</div><p>Noch keine Wörter hinzugefügt.</p><p>Fügen Sie oben Ihr erstes Wort hinzu!</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wordsList.innerHTML = words.map(word => `
|
||||||
|
<div class="word-card">
|
||||||
|
<div class="word-content">
|
||||||
|
<div class="word">${escapeHtml(word.word)}</div>
|
||||||
|
<div class="meaning">${escapeHtml(word.meaning)}</div>
|
||||||
|
<div class="date">${formatDate(word.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="word-actions">
|
||||||
|
<button class="btn-danger" onclick="deleteWord(${word.id})">Löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddWord(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const word = wordInput.value.trim();
|
||||||
|
const meaning = meaningInput.value.trim();
|
||||||
|
|
||||||
|
if (!word || !meaning) {
|
||||||
|
showError('Bitte füllen Sie alle Felder aus');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = wordForm.querySelector('button[type="submit"]');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Wird gespeichert...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/words', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ word, meaning })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || 'Fehler beim Speichern');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newWord = await response.json();
|
||||||
|
words.unshift(newWord);
|
||||||
|
renderWords();
|
||||||
|
|
||||||
|
wordForm.reset();
|
||||||
|
wordInput.focus();
|
||||||
|
showSuccess('Wort erfolgreich hinzugefügt! 🎉');
|
||||||
|
} catch (error) {
|
||||||
|
showError('Fehler: ' + error.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Hinzufügen';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteWord(id) {
|
||||||
|
if (!confirm('Wort wirklich löschen?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/words/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || 'Fehler beim Löschen');
|
||||||
|
}
|
||||||
|
|
||||||
|
words = words.filter(w => w.id !== id);
|
||||||
|
renderWords();
|
||||||
|
showSuccess('Wort gelöscht!');
|
||||||
|
} catch (error) {
|
||||||
|
showError('Fehler: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCount() {
|
||||||
|
wordCount.textContent = words.length;
|
||||||
|
countBadge.textContent = words.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString) {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now - date;
|
||||||
|
|
||||||
|
// Wenn heute
|
||||||
|
if (diff < 24 * 60 * 60 * 1000) {
|
||||||
|
return date.toLocaleTimeString('de-DE', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleDateString('de-DE', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
errorMsg.textContent = msg;
|
||||||
|
errorMsg.classList.add('show');
|
||||||
|
setTimeout(() => errorMsg.classList.remove('show'), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSuccess(msg) {
|
||||||
|
successMsg.textContent = msg;
|
||||||
|
successMsg.classList.add('show');
|
||||||
|
setTimeout(() => successMsg.classList.remove('show'), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
loadWords();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue
Block a user