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:
|
||||
# - Nur Nginx: "user-template-01: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"
|
||||
USER_TEMPLATE_IMAGES="user-template-01:latest;user-template-02:latest;user-template-next:latest"
|
||||
# - Mit Wörterbuch: "user-template-01:latest;user-template-dictionary: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
|
||||
|
|
|
|||
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",
|
||||
"display_name": "Next.js Production",
|
||||
"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