Compare commits

..

26 Commits

Author SHA1 Message Date
61d01e1e11 Resize screenshot 2025-08-21 09:43:43 +02:00
8c13e470ad add mobile screenshot to readme 2025-08-21 09:41:20 +02:00
526f030661 Update Screenshot 2025-08-21 09:40:20 +02:00
223e2aa007 docs: README – Desktop+Mobile Screenshots nebeneinander anzeigen 2025-08-21 09:36:23 +02:00
c3290c071a SEO: og:image/twitter:image, site_name, locale, hreflang und JSON-LD (WebApplication); Route fuer screenshot.png 2025-08-20 10:19:04 +02:00
80cb551ecc Remove sentence 2025-08-20 10:15:23 +02:00
06b8910b02 Update screenshot 2025-08-20 10:14:24 +02:00
36cace78a6 Change text 2025-08-20 10:12:00 +02:00
63a970cb43 Change text in hints 2025-08-20 10:11:39 +02:00
12bf7e6276 Update footer 2025-08-20 10:05:57 +02:00
fc54056572 Server: Fallback – aktiviere OpenThesaurus, wenn weder OT noch WF ausgewählt ist 2025-08-20 10:04:42 +02:00
a52a7ac2cf UI: Ein-Zeichen-Inputs mit +, Klick-Entfernen, alphabetische Sortierung und Drag&Drop auf pos1–pos5; Button/Reset-Styles vereinheitlicht; Abstände/Alignment angepasst; README aktualisiert 2025-08-20 09:45:02 +02:00
d730e6b266 Admin: Passwortgeschütztes Statistik-Dashboard implementiert
- Neue Routen: /login, /stats, /logout
- Session-basierte Authentifizierung
- Umfassende Statistiken: Seitenaufrufe, Suchvorgänge, Quellen
- Environment-Variablen: ADMIN_PASSWORD, FLASK_SECRET_KEY
- Docker-Integration mit docker-compose.yml
- Responsive UI mit Charts und Aktivitätsprotokoll
2025-08-20 09:11:53 +02:00
fcbcb07e76 Logging: Automatische Log-Bereinigung nach 7 Tagen implementiert 2025-08-20 08:56:07 +02:00
bcc6869d13 Logging: Logs werden jetzt im logs/ Unterverzeichnis gespeichert 2025-08-20 08:40:32 +02:00
634806ec44 Reset-Button: Löscht jetzt auch Suchergebnisse 2025-08-20 08:28:56 +02:00
6560acd1d9 Set DN 2025-08-20 08:20:11 +02:00
4d4ecf70b8 SEO: robots.txt und sitemap.xml für bessere Auffindbarkeit hinzugefügt 2025-08-20 08:08:19 +02:00
e4890bf2f2 Verbessere Eingabefelder: Größere Schrift, Monospace-Font und automatische Großbuchstaben-Transformation 2025-08-19 21:42:09 +02:00
f5deb5a839 Quellen-Filter über Trefferliste, dynamisches Filtering; Umlaut-Filter hinzugefügt/angepasst; Rahmen um Trefferliste 2025-08-19 15:06:07 +02:00
177e4d01ce Update screen shot 2025-08-19 13:11:22 +02:00
1cb32a2268 Embed screen shot 2025-08-19 13:08:53 +02:00
44f14bcffd Add screen shot 2025-08-19 13:06:34 +02:00
c93a813c96 PWA-Unterstützung hinzugefügt: manifest.webmanifest, Service Worker, Installierbarkeit auf Homescreen/Desktop 2025-08-19 12:58:47 +02:00
b2cd91b970 Close code block 2025-08-19 12:50:47 +02:00
ced79efe0a Remove last line 2025-08-19 12:50:24 +02:00
14 changed files with 1234 additions and 128 deletions

2
.gitignore vendored
View File

@@ -1,6 +1,8 @@
.venv/ .venv/
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
# Logs
logs/
*.log *.log
.env .env
.DS_Store .DS_Store

86
LOGGING.md Normal file
View File

@@ -0,0 +1,86 @@
# Logging-System
Das Wordle-Helper Logging-System protokolliert Seitenaufrufe und Suchanfragen ohne personenbezogene Daten wie IP-Adressen.
## Log-Datei
- **Verzeichnis:** `logs/`
- **Datei:** `logs/app.log`
- **Format:** UTF-8
- **Rotation:** Automatische Rotation nach 7 Tagen
- **Backup:** Komprimierte Backup-Dateien (30 Tage aufbewahrt)
- **Verzeichnis:** Wird automatisch erstellt, falls es nicht existiert
## Protokollierte Ereignisse
### 1. Seitenaufrufe (PAGE_VIEW)
```
2024-01-01 12:00:00 - INFO - PAGE_VIEW: index | User-Agent: Mozilla/5.0...
2024-01-01 12:00:01 - INFO - PAGE_VIEW: manifest | User-Agent: Mozilla/5.0...
2024-01-01 12:00:02 - INFO - PAGE_VIEW: service_worker | User-Agent: Mozilla/5.0...
```
### 2. Suchanfragen (SEARCH)
```
2024-01-01 12:00:05 - INFO - SEARCH: pos='hallo' includes='' excludes='' sources=['OT'] | User-Agent: Mozilla/5.0...
2024-01-01 12:00:10 - INFO - SEARCH: pos='' includes='aei' excludes='rst' sources=['OT', 'WF'] | User-Agent: Mozilla/5.0...
```
## Log-Format
- **Zeitstempel:** ISO-Format (YYYY-MM-DD HH:MM:SS)
- **Ereignistyp:** PAGE_VIEW oder SEARCH
- **Details:** Spezifische Informationen je nach Ereignistyp
- **User-Agent:** Gekürzt auf 100 Zeichen (ohne IP-Adressen)
## Datenschutz
- **Keine IP-Adressen** werden protokolliert
- **Keine persönlichen Daten** werden gespeichert
- **User-Agent** wird gekürzt und anonymisiert
- **Logs** werden nicht ins Git-Repository übertragen (`.gitignore`)
## Konfiguration
Das Logging ist in `app.py` konfiguriert:
```python
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(logs_dir / 'app.log', encoding='utf-8'),
logging.StreamHandler()
]
)
```
## Automatische Log-Bereinigung
Das System bereinigt automatisch alte Log-Dateien:
- **Rotation:** Nach 7 Tagen wird die aktuelle Log-Datei komprimiert
- **Backup:** Komprimierte Dateien werden 30 Tage aufbewahrt
- **Format:** Backup-Dateien: `app_YYYYMMDD_HHMMSS.log.gz`
- **Trigger:** Bereinigung wird bei jedem Seitenaufruf geprüft
- **Fehlerbehandlung:** Fehler bei der Bereinigung werden geloggt
## Log-Analyse
Die Log-Datei kann mit Standard-Tools analysiert werden:
```bash
# Alle Suchanfragen anzeigen
grep "SEARCH:" logs/app.log
# Seitenaufrufe zählen
grep "PAGE_VIEW:" logs/app.log | wc -l
# Häufigste Suchparameter
grep "SEARCH:" logs/app.log | cut -d'|' -f1
# Logs-Verzeichnis anzeigen
ls -la logs/
```

View File

@@ -5,12 +5,23 @@ HilfsWebApp für deutsche WordleRätsel. Nutzer geben bekannte Buchstab
## Features ## Features
- Filter nach Positionen (15), enthaltenen und ausgeschlossenen Buchstaben - Filter nach Positionen (15), enthaltenen und ausgeschlossenen Buchstaben
- Enthaltene/Ausgeschlossene Buchstaben per EinZeichenEingabe und „+“-Button hinzufügen
- Ausgewählte Buchstaben werden als Badges angezeigt, Klick entfernt den Buchstaben wieder
- Alphabetische Sortierung der ausgewählten Buchstaben (deutsche Locale)
- DragandDrop: Buchstaben aus „Enthalten“ direkt auf die Felder `pos1``pos5` ziehen
- Deutsche Wortliste (nur 5 Buchstaben), aus OpenThesaurus und wordfreq gemerged - Deutsche Wortliste (nur 5 Buchstaben), aus OpenThesaurus und wordfreq gemerged
- QuellenBadges je Treffer (OT/WF) - QuellenBadges je Treffer (OT/WF)
- Zugängliche UI (A11y: Fieldset/Legend, ARIAHinweise, SkipLink, semantische Liste) - Zugängliche UI (A11y: Fieldset/Legend, ARIAHinweise, SkipLink, semantische Liste)
- SEOMetas (Description, Canonical, Open Graph, Twitter) - SEOMetas (Description, Canonical, Open Graph, Twitter)
- DockerImage mit Gunicorn - DockerImage mit Gunicorn
## Demo
![https://wh.elpatron.me](./screenshot-mobile.png)
Wordle-Cheater live bei [https://wh.elpatron.me](https://wh.elpatron.me).
## Projektstruktur ## Projektstruktur
```text ```text
@@ -28,12 +39,23 @@ wordle-helper/
│ └── words_de_5_sources.json # Wort→Quellen (ot/wf) │ └── words_de_5_sources.json # Wort→Quellen (ot/wf)
├── Dockerfile # Produktionsimage (Gunicorn) ├── Dockerfile # Produktionsimage (Gunicorn)
├── requirements.txt # PythonAbhängigkeiten ├── requirements.txt # PythonAbhängigkeiten
└──
--- ```
## Für Sysadmins (Betrieb) ## Für Sysadmins (Betrieb)
### Environment-Variablen
Für das Admin-Dashboard müssen folgende Variablen gesetzt werden:
```bash
# Flask Secret Key (für Sessions)
export FLASK_SECRET_KEY="your-super-secret-key-change-this-in-production"
# Admin-Passwort für das Statistik-Dashboard
export ADMIN_PASSWORD="your-secure-admin-password"
```
### Docker (empfohlen) ### Docker (empfohlen)
- Build: - Build:
@@ -49,6 +71,7 @@ docker run --rm -p 8000:8000 wordle-cheater
``` ```
- HealthCheck (lokal): `http://localhost:8000/` - HealthCheck (lokal): `http://localhost:8000/`
- AdminDashboard: `http://localhost:8000/stats` (passwortgeschützt)
Hinweise: Hinweise:

369
app.py
View File

@@ -1,81 +1,332 @@
from pathlib import Path from pathlib import Path
import json import json
import logging
import os
from datetime import datetime, timedelta
from typing import Tuple, Dict, List from typing import Tuple, Dict, List
from flask import Flask, render_template, request from flask import Flask, render_template, request, send_from_directory, session, redirect, url_for, flash
from functools import wraps
app = Flask(__name__) app = Flask(__name__)
app.secret_key = os.environ.get('FLASK_SECRET_KEY', 'dev-secret-key-change-in-production')
# Admin-Passwort aus Environment-Variable
ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', 'admin123')
# Logging konfigurieren
# Logs-Verzeichnis erstellen, falls es nicht existiert
logs_dir = Path(__file__).parent / "logs"
logs_dir.mkdir(exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(logs_dir / 'app.log', encoding='utf-8'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
def log_page_view(page: str, user_agent: str = None):
"""Protokolliert Seitenaufrufe ohne IP-Adressen"""
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
user_agent_clean = user_agent[:100] if user_agent else 'Unknown'
logger.info(f"PAGE_VIEW: {page} | User-Agent: {user_agent_clean}")
def log_search_query(search_params: dict, user_agent: str = None):
"""Protokolliert Suchanfragen ohne IP-Adressen"""
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
user_agent_clean = user_agent[:100] if user_agent else 'Unknown'
# Suchparameter für Logging vorbereiten
pos_str = ''.join(search_params.get('pos', [''] * 5))
includes = search_params.get('includes', '')
excludes = search_params.get('excludes', '')
sources = []
if search_params.get('use_ot'):
sources.append('OT')
if search_params.get('use_wf'):
sources.append('WF')
logger.info(f"SEARCH: pos='{pos_str}' includes='{includes}' excludes='{excludes}' sources={sources} | User-Agent: {user_agent_clean}")
def login_required(f):
"""Decorator für passwortgeschützte Routen"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not session.get('logged_in'):
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated_function
def get_statistics():
"""Liest und analysiert die Log-Dateien für Statistiken"""
stats = {
'total_page_views': 0,
'total_searches': 0,
'page_views_by_page': {},
'searches_by_source': {'OT': 0, 'WF': 0, 'Both': 0},
'recent_activity': [],
'top_search_patterns': {}
}
try:
# Aktuelle Log-Datei lesen
log_file = logs_dir / 'app.log'
if log_file.exists():
with open(log_file, 'r', encoding='utf-8') as f:
for line in f:
if 'PAGE_VIEW:' in line:
stats['total_page_views'] += 1
# Seite extrahieren
if 'PAGE_VIEW: ' in line:
page = line.split('PAGE_VIEW: ')[1].split(' |')[0]
stats['page_views_by_page'][page] = stats['page_views_by_page'].get(page, 0) + 1
elif 'SEARCH:' in line:
stats['total_searches'] += 1
# Quellen extrahieren
if 'sources=[' in line:
sources_part = line.split('sources=')[1].split(']')[0]
if 'OT' in sources_part and 'WF' in sources_part:
stats['searches_by_source']['Both'] += 1
elif 'OT' in sources_part:
stats['searches_by_source']['OT'] += 1
elif 'WF' in sources_part:
stats['searches_by_source']['WF'] += 1
# Suchmuster extrahieren
if 'pos=' in line:
pos_part = line.split('pos=\'')[1].split('\'')[0]
if pos_part:
stats['top_search_patterns'][pos_part] = stats['top_search_patterns'].get(pos_part, 0) + 1
# Letzte 10 Aktivitäten
if len(stats['recent_activity']) < 10:
timestamp = line.split(' - ')[0] if ' - ' in line else ''
if timestamp:
stats['recent_activity'].append({
'timestamp': timestamp,
'line': line.strip()
})
# Backup-Dateien auch durchsuchen
for backup_file in logs_dir.glob("app_*.log.gz"):
try:
import gzip
with gzip.open(backup_file, 'rt', encoding='utf-8') as f:
for line in f:
if 'PAGE_VIEW:' in line:
stats['total_page_views'] += 1
elif 'SEARCH:' in line:
stats['total_searches'] += 1
except Exception as e:
logger.error(f"Fehler beim Lesen der Backup-Datei {backup_file}: {e}")
except Exception as e:
logger.error(f"Fehler beim Lesen der Statistiken: {e}")
return stats
def cleanup_old_logs():
"""Bereinigt Log-Dateien älter als 7 Tage"""
try:
log_file = logs_dir / 'app.log'
if log_file.exists():
# Prüfe Datei-Alter
file_age = datetime.now() - datetime.fromtimestamp(log_file.stat().st_mtime)
if file_age > timedelta(days=7):
# Log-Datei komprimieren und umbenennen
backup_name = f"app_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log.gz"
backup_path = logs_dir / backup_name
# Komprimiere mit gzip (falls verfügbar)
import gzip
with open(log_file, 'rb') as f_in:
with gzip.open(backup_path, 'wb') as f_out:
f_out.writelines(f_in)
# Lösche alte Log-Datei
log_file.unlink()
logger.info(f"Log-Datei komprimiert und gesichert: {backup_name}")
# Lösche alte Backup-Dateien (älter als 30 Tage)
for backup_file in logs_dir.glob("app_*.log.gz"):
backup_age = datetime.now() - datetime.fromtimestamp(backup_file.stat().st_mtime)
if backup_age > timedelta(days=30):
backup_file.unlink()
logger.info(f"Alte Backup-Datei gelöscht: {backup_file.name}")
except Exception as e:
logger.error(f"Fehler bei der Log-Bereinigung: {e}")
def load_words() -> Tuple[List[str], Dict[str, List[str]]]: def load_words() -> Tuple[List[str], Dict[str, List[str]]]:
data_dir = Path(__file__).parent / "data" data_dir = Path(__file__).parent / "data"
txt_path = data_dir / "words_de_5.txt" txt_path = data_dir / "words_de_5.txt"
json_path = data_dir / "words_de_5_sources.json" json_path = data_dir / "words_de_5_sources.json"
words: List[str] = [] words: List[str] = []
sources_map: Dict[str, List[str]] = {} sources_map: Dict[str, List[str]] = {}
if txt_path.exists(): if txt_path.exists():
with txt_path.open("r", encoding="utf-8") as f: with txt_path.open("r", encoding="utf-8") as f:
for line in f: for line in f:
word = line.strip().lower() word = line.strip().lower()
if len(word) == 5 and word.isalpha(): if len(word) == 5 and word.isalpha():
words.append(word) words.append(word)
if json_path.exists(): if json_path.exists():
try: try:
sources_map = json.loads(json_path.read_text(encoding="utf-8")) sources_map = json.loads(json_path.read_text(encoding="utf-8"))
except Exception: except Exception:
sources_map = {} sources_map = {}
return words, sources_map return words, sources_map
def filter_words(words: List[str], position_letters: List[str], includes_text: str, excludes_text: str) -> List[str]: def filter_words(words: List[str], position_letters: List[str], includes_text: str, excludes_text: str) -> List[str]:
results: List[str] = [] results: List[str] = []
includes_letters = [ch for ch in includes_text.lower() if ch.isalpha()] includes_letters = [ch for ch in includes_text.lower() if ch.isalpha()]
excludes_letters = [ch for ch in excludes_text.lower() if ch.isalpha()] excludes_letters = [ch for ch in excludes_text.lower() if ch.isalpha()]
for word in words: for word in words:
# feste Positionen # feste Positionen
if any(ch and word[idx] != ch for idx, ch in enumerate(position_letters)): if any(ch and word[idx] != ch for idx, ch in enumerate(position_letters)):
continue continue
# muss-enthalten # muss-enthalten
if not all(ch in word for ch in includes_letters): if not all(ch in word for ch in includes_letters):
continue continue
# darf-nicht-enthalten # darf-nicht-enthalten
if any(ch in word for ch in excludes_letters): if any(ch in word for ch in excludes_letters):
continue continue
results.append(word) results.append(word)
return results return results
@app.route("/", methods=["GET", "POST"]) @app.route("/", methods=["GET", "POST"])
def index(): def index():
all_words, sources_map = load_words() # Log-Bereinigung bei jedem Seitenaufruf prüfen (nur alle 24h)
results: List[str] | None = None cleanup_old_logs()
pos: List[str] = ["", "", "", "", ""]
includes: str = "" # Seitenaufruf protokollieren
excludes: str = "" log_page_view("index", request.headers.get('User-Agent'))
if request.method == "POST":
pos = [ all_words, sources_map = load_words()
(request.form.get("pos1") or "").strip().lower(), results_display: List[str] | None = None
(request.form.get("pos2") or "").strip().lower(), pos: List[str] = ["", "", "", "", ""]
(request.form.get("pos3") or "").strip().lower(), includes: str = ""
(request.form.get("pos4") or "").strip().lower(), excludes: str = ""
(request.form.get("pos5") or "").strip().lower(), use_ot: bool = True
] use_wf: bool = False
includes = (request.form.get("includes") or "").strip() if request.method == "POST":
excludes = (request.form.get("excludes") or "").strip() pos = [
results = filter_words(all_words, pos, includes, excludes) (request.form.get("pos1") or "").strip().lower(),
return render_template( (request.form.get("pos2") or "").strip().lower(),
"index.html", (request.form.get("pos3") or "").strip().lower(),
results=results, (request.form.get("pos4") or "").strip().lower(),
pos=pos, (request.form.get("pos5") or "").strip().lower(),
includes=includes, ]
excludes=excludes, includes = (request.form.get("includes") or "").strip()
words_count=len(all_words), excludes = (request.form.get("excludes") or "").strip()
sources_map=sources_map, use_ot = request.form.get("use_ot") is not None
) use_wf = request.form.get("use_wf") is not None
# Falls keine Quelle gewählt ist, standardmäßig OpenThesaurus aktivieren
if not use_ot and not use_wf:
use_ot = True
# Suchanfrage protokollieren
search_params = {
'pos': pos,
'includes': includes,
'excludes': excludes,
'use_ot': use_ot,
'use_wf': use_wf
}
log_search_query(search_params, request.headers.get('User-Agent'))
# 1) Buchstaben-/Positionssuche über alle Wörter
matched = filter_words(all_words, pos, includes, excludes)
# 2) Quellen-Filter nur auf Ergebnisansicht anwenden
allowed = set()
if use_ot:
allowed.add("ot")
if use_wf:
allowed.add("wf")
if allowed:
results_display = [w for w in matched if any(src in allowed for src in sources_map.get(w, []))]
else:
# Keine Quelle gewählt → leere Anzeige (Suche wurde dennoch ausgeführt)
results_display = []
return render_template(
"index.html",
results=results_display,
pos=pos,
includes=includes,
excludes=excludes,
words_count=len(all_words),
sources_map=sources_map,
use_ot=use_ot,
use_wf=use_wf,
error_message=None,
)
@app.route('/manifest.webmanifest')
def manifest_file():
log_page_view("manifest", request.headers.get('User-Agent'))
return send_from_directory(Path(__file__).parent / 'static', 'manifest.webmanifest', mimetype='application/manifest+json')
@app.route('/sw.js')
def service_worker():
# Service Worker muss auf Top-Level liegen
log_page_view("service_worker", request.headers.get('User-Agent'))
return send_from_directory(Path(__file__).parent / 'static', 'sw.js', mimetype='application/javascript')
@app.route('/screenshot.png')
def screenshot_image():
"""Liefert das OpenGraph/Twitter Vorschaubild aus dem Projektstamm."""
log_page_view("screenshot", request.headers.get('User-Agent'))
return send_from_directory(Path(__file__).parent, 'screenshot.png', mimetype='image/png')
@app.route('/login', methods=['GET', 'POST'])
def login():
"""Login-Seite für das Admin-Dashboard"""
if request.method == 'POST':
password = request.form.get('password')
logger.info(f"Login-Versuch: Passwort-Länge: {len(password) if password else 0}, ADMIN_PASSWORD gesetzt: {bool(ADMIN_PASSWORD)}")
if password == ADMIN_PASSWORD:
session['logged_in'] = True
flash('Erfolgreich angemeldet!', 'success')
logger.info("Login erfolgreich")
return redirect(url_for('stats'))
else:
flash('Falsches Passwort!', 'error')
logger.warning(f"Login fehlgeschlagen - eingegebenes Passwort: '{password}', erwartetes: '{ADMIN_PASSWORD}'")
return render_template('login.html')
@app.route('/logout')
def logout():
"""Logout-Funktion"""
session.pop('logged_in', None)
flash('Erfolgreich abgemeldet!', 'success')
return redirect(url_for('index'))
@app.route('/stats')
@login_required
def stats():
"""Statistik-Dashboard (passwortgeschützt)"""
log_page_view("stats", request.headers.get('User-Agent'))
statistics = get_statistics()
return render_template('stats.html', stats=statistics)
if __name__ == "__main__": if __name__ == "__main__":
app.run(debug=True) logger.info(f"App gestartet - ADMIN_PASSWORD gesetzt: {bool(ADMIN_PASSWORD)}, Länge: {len(ADMIN_PASSWORD) if ADMIN_PASSWORD else 0}")
app.run(debug=True)

31
docker-compose.yml Normal file
View File

@@ -0,0 +1,31 @@
services:
wordle-helper:
build: .
ports:
- "8000:8000"
volumes:
# Logs-Verzeichnis als Volume mounten
- ./logs:/app/logs
# Optional: Statische Dateien für Entwicklung
- ./static:/app/static:ro
environment:
- PYTHONUNBUFFERED=1
- FLASK_ENV=production
- FLASK_SECRET_KEY=${FLASK_SECRET_KEY:-your-secret-key-change-this}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
restart: unless-stopped
# Container läuft als nicht-root User für bessere Sicherheit
user: "1000:1000"
# Healthcheck für Container-Status
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
# Explizite Volume-Definition für Logs
logs:
driver: local

8
robots.txt Normal file
View File

@@ -0,0 +1,8 @@
User-agent: *
Allow: /
# Sitemap
Sitemap: https://wh.elpatron.me/sitemap.xml
# Crawl-delay für freundliches Crawling
Crawl-delay: 1

BIN
screenshot-mobile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

9
sitemap.xml Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://wh.elpatron.me/</loc>
<lastmod>2024-01-01</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
</urlset>

View File

@@ -0,0 +1,18 @@
{
"name": "WordleCheater",
"short_name": "WCheater",
"description": "Hilft bei der Lösung deutschsprachiger WordleRätsel mit Positions- und Buchstabenfiltern.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0b1220",
"theme_color": "#0b1220",
"icons": [
{
"src": "/static/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}

39
static/sw.js Normal file
View File

@@ -0,0 +1,39 @@
const CACHE_NAME = 'wordle-cheater-v1';
const APP_SHELL = [
'/',
'/static/favicon.svg',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => Promise.all(
keys.map((k) => (k === CACHE_NAME ? null : caches.delete(k)))
))
);
});
self.addEventListener('fetch', (event) => {
const { request } = event;
// Netzwerk zuerst für HTML, sonst Cache-First
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).catch(() => caches.match('/'))
);
return;
}
event.respondWith(
caches.match(request).then((cached) =>
cached || fetch(request).then((resp) => {
const copy = resp.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
return resp;
})
)
);
});

View File

@@ -11,10 +11,17 @@
<meta property="og:title" content="WordleCheater (DE)" /> <meta property="og:title" content="WordleCheater (DE)" />
<meta property="og:description" content="Finde deutsche 5BuchstabenWörter mit Positions- und Buchstabenfiltern. Quellen: OpenThesaurus & wordfreq." /> <meta property="og:description" content="Finde deutsche 5BuchstabenWörter mit Positions- und Buchstabenfiltern. Quellen: OpenThesaurus & wordfreq." />
<meta property="og:url" content="{{ request.url_root }}" /> <meta property="og:url" content="{{ request.url_root }}" />
<meta property="og:site_name" content="WordleCheater (DE)" />
<meta property="og:locale" content="de_DE" />
<meta property="og:image" content="{{ url_for('screenshot_image', _external=True) }}" />
<meta name="twitter:card" content="summary" /> <meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="WordleCheater (DE)" /> <meta name="twitter:title" content="WordleCheater (DE)" />
<meta name="twitter:description" content="Finde deutsche 5BuchstabenWörter mit Positions- und Buchstabenfiltern. Quellen: OpenThesaurus & wordfreq." /> <meta name="twitter:description" content="Finde deutsche 5BuchstabenWörter mit Positions- und Buchstabenfiltern. Quellen: OpenThesaurus & wordfreq." />
<meta name="twitter:image" content="{{ url_for('screenshot_image', _external=True) }}" />
<link rel="alternate" hreflang="de" href="{{ request.url_root }}" />
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}" /> <link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#0b1220" />
<meta name="color-scheme" content="light dark" /> <meta name="color-scheme" content="light dark" />
<script> <script>
(function() { (function() {
@@ -26,52 +33,47 @@
} catch (e) {} } catch (e) {}
})(); })();
</script> </script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "WordleCheater (DE)",
"url": "{{ request.url_root }}",
"applicationCategory": "UtilitiesApplication",
"operatingSystem": "Web",
"description": "Finde deutsche 5BuchstabenWörter anhand bekannter Buchstaben und Positionen. Quellen: OpenThesaurus & wordfreq.",
"inLanguage": "de",
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "EUR" }
}
</script>
<style> <style>
:root { :root { --bg:#ffffff; --text:#111827; --muted:#6b7280; --badge-bg:#e5e7eb; --badge-text:#111827; --border:#e5e7eb; --skip-bg:#111827; --skip-text:#ffffff; --button-bg:#111827; --button-text:#ffffff; --input-bg:#ffffff; --input-text:#111827; --error:#b91c1c; }
--bg: #ffffff; [data-theme="dark"] { --bg:#0b1220; --text:#e5e7eb; --muted:#9ca3af; --badge-bg:#374151; --badge-text:#f9fafb; --border:#334155; --skip-bg:#e5e7eb; --skip-text:#111827; --button-bg:#e5e7eb; --button-text:#111827; --input-bg:#111827; --input-text:#e5e7eb; --error:#ef4444; }
--text: #111827;
--muted: #6b7280;
--badge-bg: #e5e7eb;
--badge-text: #111827;
--border: #e5e7eb;
--skip-bg: #111827;
--skip-text: #ffffff;
--button-bg: #111827;
--button-text: #ffffff;
--input-bg: #ffffff;
--input-text: #111827;
}
[data-theme="dark"] {
--bg: #0b1220;
--text: #e5e7eb;
--muted: #9ca3af;
--badge-bg: #374151;
--badge-text: #f9fafb;
--border: #334155;
--skip-bg: #e5e7eb;
--skip-text: #111827;
--button-bg: #e5e7eb;
--button-text: #111827;
--input-bg: #111827;
--input-text: #e5e7eb;
}
html, body { background: var(--bg); color: var(--text); } html, body { background: var(--bg); color: var(--text); }
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; margin: 2rem; } body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; margin: 2rem; }
.container { max-width: 800px; margin: 0 auto; } .container { max-width: 800px; margin: 0 auto; }
.page-header { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; } .page-header { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
.page-header h1 { margin: 0; } .page-header h1 { margin: 0; }
@media (max-width: 480px) { @media (max-width: 480px) { .page-header h1 { font-size: 1.5rem; } }
.page-header h1 { font-size: 1.5rem; }
}
.grid { display: grid; grid-template-columns: repeat(5, 3rem); gap: .5rem; } .grid { display: grid; grid-template-columns: repeat(5, 3rem); gap: .5rem; }
.grid input { text-align: center; font-size: 1.25rem; padding: .4rem; background: var(--input-bg); color: var(--input-text); border: 1px solid var(--border); border-radius: .375rem; } .grid input { text-align: center; font-size: 1.25rem; padding: .4rem; background: var(--input-bg); color: var(--input-text); border: 1px solid var(--border); border-radius: .375rem; font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', 'Monaco', 'Courier New', monospace; text-transform: uppercase; }
.grid input.filled { background: #6baa64; color: #ffffff; }
.text-input { font-size: 1.1rem; padding: .5rem; background: var(--input-bg); color: var(--input-text); border: 1px solid var(--border); border-radius: .375rem; width: 100%; box-sizing: border-box; font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', 'Monaco', 'Courier New', monospace; text-transform: uppercase; }
.letter-input { width: calc(1ch + 2rem); padding: .5rem 1rem; font-size: 1rem; line-height: 1; background: var(--input-bg); color: var(--input-text); border: 1px solid var(--border); border-radius: .5rem; text-align: center; text-transform: uppercase; font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', 'Monaco', 'Courier New', monospace; }
.plus-button { width: calc(1ch + 2rem); text-align: center; line-height: 1; display: inline-flex; align-items: center; justify-content: center; font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', 'Monaco', 'Courier New', monospace; }
label { font-weight: 600; display: block; margin-top: 1rem; margin-bottom: .25rem; } label { font-weight: 600; display: block; margin-top: 1rem; margin-bottom: .25rem; }
.results { margin-top: 1.5rem; } .results { margin-top: 1.5rem; }
.results-box { border: 1px solid var(--border); border-radius: .5rem; padding: .75rem; }
.badge { display: inline-block; padding: .25rem .5rem; background: var(--badge-bg); color: var(--badge-text); border-radius: .375rem; margin-right: .25rem; margin-bottom: .25rem; } .badge { display: inline-block; padding: .25rem .5rem; background: var(--badge-bg); color: var(--badge-text); border-radius: .375rem; margin-right: .25rem; margin-bottom: .25rem; }
.badge .letter { display: inline-block; }
.badge .letter.included { background: #ffc100; color: #ffffff; border-radius: .25rem; padding: 0 .1rem; }
#includes-list .badge, #excludes-list .badge { cursor: pointer; }
#includes-list .badge { background: #ffc100; color: #ffffff; }
.source { font-size: .75rem; padding: .1rem .35rem; border-radius: .25rem; margin-left: .25rem; } .source { font-size: .75rem; padding: .1rem .35rem; border-radius: .25rem; margin-left: .25rem; }
.source.ot { background: #dbeafe; color: #1e40af; } .source.ot { background: #dbeafe; color: #1e40af; }
.source.wf { background: #dcfce7; color: #065f46; } .source.wf { background: #dcfce7; color: #065f46; }
button { margin-top: 1rem; padding: .5rem 1rem; font-size: 1rem; } button { margin-top: 1rem; padding: .5rem 1rem; font-size: 1rem; margin-right: 0.5rem; background: var(--button-bg); color: var(--button-text); border: 1px solid var(--border); border-radius: .5rem; cursor: pointer; }
.reset-button { background: var(--muted); }
summary { cursor: pointer; } summary { cursor: pointer; }
.footer { margin-top: 2rem; font-size: .9rem; color: var(--muted); } .footer { margin-top: 2rem; font-size: .9rem; color: var(--muted); }
.footer a { color: inherit; text-decoration: underline; } .footer a { color: inherit; text-decoration: underline; }
@@ -79,12 +81,17 @@
.skip-link { position: absolute; left: -9999px; top: auto; width: 1px; height: 1px; overflow: hidden; } .skip-link { position: absolute; left: -9999px; top: auto; width: 1px; height: 1px; overflow: hidden; }
.skip-link:focus { position: static; width: auto; height: auto; padding: .5rem .75rem; background: var(--skip-bg); color: var(--skip-text); border-radius: .25rem; } .skip-link:focus { position: static; width: auto; height: auto; padding: .5rem .75rem; background: var(--skip-bg); color: var(--skip-text); border-radius: .25rem; }
.hint { margin-top: .25rem; color: var(--muted); font-size: .9rem; } .hint { margin-top: .25rem; color: var(--muted); font-size: .9rem; }
.inline-controls { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.filter-box { border: 1px solid var(--border); border-radius: .5rem; padding: .5rem .75rem; margin: .5rem 0 1rem; }
.word-list { list-style: none; padding: 0; margin: 0; } .word-list { list-style: none; padding: 0; margin: 0; }
#includes-list, #excludes-list { margin-top: .75rem; }
.word-list li { display: inline-block; margin: 0 .25rem .25rem 0; } .word-list li { display: inline-block; margin: 0 .25rem .25rem 0; }
fieldset { border: 1px solid var(--border); border-radius: .5rem; padding: .75rem; } fieldset { border: 1px solid var(--border); border-radius: .5rem; padding: .75rem; }
legend { font-weight: 700; padding: 0 .25rem; } legend { font-weight: 700; padding: 0 .25rem; }
.theme-toggle { background: var(--button-bg); color: var(--button-text); border: 1px solid var(--border); border-radius: .5rem; padding: .4rem .6rem; font-size: .95rem; margin-top: 0; } .theme-toggle { background: var(--button-bg); color: var(--button-text); border: 1px solid var(--border); border-radius: .5rem; padding: .4rem .6rem; font-size: .95rem; margin-top: 0; }
.theme-toggle:focus { outline: 2px solid #3b82f6; outline-offset: 2px; } .theme-toggle:focus { outline: 2px solid #3b82f6; outline-offset: 2px; }
.inline-controls .plus-button { margin-top: 0; margin-right: 0; }
.drop-target { outline: 2px dashed #3b82f6; outline-offset: 2px; }
</style> </style>
</head> </head>
<body> <body>
@@ -97,7 +104,7 @@
<p>Wortliste geladen: <strong>{{ words_count }}</strong> Wörter</p> <p>Wortliste geladen: <strong>{{ words_count }}</strong> Wörter</p>
<main id="main" role="main"> <main id="main" role="main">
<form method="post" aria-describedby="form-hint"> <form id="search-form" method="post" aria-describedby="form-hint">
<p id="form-hint" class="hint">Gib bekannte Buchstaben ein. Leere Felder werden ignoriert.</p> <p id="form-hint" class="hint">Gib bekannte Buchstaben ein. Leere Felder werden ignoriert.</p>
<fieldset> <fieldset>
@@ -109,46 +116,80 @@
<input id="pos4" name="pos4" maxlength="1" aria-label="Position 4" inputmode="text" autocomplete="off" pattern="[A-Za-zÄÖÜäöüß]" value="{{ pos[3] }}" /> <input id="pos4" name="pos4" maxlength="1" aria-label="Position 4" inputmode="text" autocomplete="off" pattern="[A-Za-zÄÖÜäöüß]" value="{{ pos[3] }}" />
<input id="pos5" name="pos5" maxlength="1" aria-label="Position 5" inputmode="text" autocomplete="off" pattern="[A-Za-zÄÖÜäöüß]" value="{{ pos[4] }}" /> <input id="pos5" name="pos5" maxlength="1" aria-label="Position 5" inputmode="text" autocomplete="off" pattern="[A-Za-zÄÖÜäöüß]" value="{{ pos[4] }}" />
</div> </div>
<p id="pos-hint" class="hint">Je Feld genau ein Buchstabe. Umlaute (ä, ö, ü) und ß sind erlaubt.</p> <p id="pos-hint" class="hint">Je Feld ein Buchstabe. Ziehe einen Buchstaben heraus, wenn du ihn löschen möchtest.</p>
</fieldset> </fieldset>
<label for="includes">Weitere enthaltene Buchstaben (beliebige Reihenfolge)</label> <label for="includes-input-one">Weitere enthaltene Buchstaben (beliebige Reihenfolge)</label>
<input id="includes" name="includes" aria-describedby="includes-hint" inputmode="text" autocomplete="off" value="{{ includes }}" /> <div class="inline-controls" aria-describedby="includes-hint">
<p id="includes-hint" class="hint">Mehrere Buchstaben ohne Trennzeichen eingeben (z.B. „aei“).</p> <input id="includes-input-one" maxlength="1" aria-label="Buchstabe hinzufügen (enthalten)" inputmode="text" autocomplete="off" pattern="[A-Za-zÄÖÜäöüß]" class="letter-input" />
<button type="button" id="includes-add-button" class="plus-button" aria-label="Buchstabe zu 'Enthalten' hinzufügen">+</button>
<input type="hidden" id="includes" name="includes" value="{{ includes }}" />
</div>
<ul id="includes-list" class="word-list" aria-live="polite"></ul>
<p id="includes-hint" class="hint">Gib einen Buchstaben ein und füge ihn mit „+“ zur Liste hinzu. Ziehe ihn auf ein Positionsfeld, wenn er dort passen würde.</p>
<label for="excludes">Ausgeschlossene Buchstaben</label> <label for="excludes-input-one">Ausgeschlossene Buchstaben</label>
<input id="excludes" name="excludes" aria-describedby="excludes-hint" inputmode="text" autocomplete="off" value="{{ excludes }}" /> <div class="inline-controls" aria-describedby="excludes-hint">
<p id="excludes-hint" class="hint">Buchstaben, die nicht vorkommen (z.B. „rst“).</p> <input id="excludes-input-one" maxlength="1" aria-label="Buchstabe hinzufügen (ausschließen)" inputmode="text" autocomplete="off" pattern="[A-Za-zÄÖÜäöüß]" class="letter-input" />
<button type="button" id="excludes-add-button" class="plus-button" aria-label="Buchstabe zu 'Ausschließen' hinzufügen">+</button>
<input type="hidden" id="excludes" name="excludes" value="{{ excludes }}" />
</div>
<ul id="excludes-list" class="word-list" aria-live="polite"></ul>
<p id="excludes-hint" class="hint">Gib einen Buchstaben ein und füge ihn mit „+“ zur Liste hinzu.</p>
<button type="submit">Suchen</button> <button type="submit">Suchen</button>
<button type="button" id="reset-button" class="reset-button">Zurücksetzen</button>
</form> </form>
{% if results is not none %} {% if results is not none %}
<div class="results" id="results" role="region" aria-labelledby="results-title"> <div class="results" id="results" role="region" aria-labelledby="results-title">
<h2 id="results-title">Vorschläge ({{ results|length }})</h2> <h2 id="results-title">Vorschläge (<span id="visible-count">{{ results|length }}</span>)</h2>
{% if results|length == 0 %} <fieldset class="filter-box" role="group">
<p>Keine Treffer. Bitte Bedingungen anpassen.</p> <legend>WortquellenFilter</legend>
{% else %} <div class="inline-controls">
<details open> <label><input id="filter-ot" type="checkbox" name="use_ot" form="search-form" {% if use_ot %}checked{% endif %}/> OT (OpenThesaurus)</label>
<summary>Liste anzeigen</summary> <label><input id="filter-wf" type="checkbox" name="use_wf" form="search-form" {% if use_wf %}checked{% endif %}/> WF (wordfreq)</label>
<ul class="word-list"> </div>
{% for w in results %} </fieldset>
<li> <fieldset class="filter-box" role="group">
<span class="badge">{{ w }} <legend>Umlaute</legend>
{% set srcs = sources_map.get(w, []) %} <div class="inline-controls">
{% for s in srcs %} <label><input id="filter-umlaut" type="checkbox" /> Umlaute einbeziehen (ä, ö, ü, ß)</label>
{% if s == 'ot' %} </div>
<span class="source ot">OT</span> </fieldset>
{% elif s == 'wf' %} <div class="results-box">
<span class="source wf">WF</span> {% if results|length == 0 %}
{% endif %} <p>Keine Treffer. Bitte Bedingungen anpassen.</p>
{% endfor %} {% else %}
</span> <details open>
</li> <summary>Liste anzeigen</summary>
{% endfor %} <ul class="word-list">
</ul> {% for w in results %}
</details> {% set srcs = sources_map.get(w, []) %}
{% endif %} {% set has_umlaut = ('ä' in w or 'ö' in w or 'ü' in w or 'ß' in w) %}
<li data-sources="{{ srcs|join(' ') }}" data-umlaut="{{ 1 if has_umlaut else 0 }}">
<span class="badge">
{% for ch in w %}
{% if ch in includes|lower %}
<span class="letter included">{{ ch }}</span>
{% else %}
<span class="letter">{{ ch }}</span>
{% endif %}
{% endfor %}
{% for s in srcs %}
{% if s == 'ot' %}
<span class="source ot">OT</span>
{% elif s == 'wf' %}
<span class="source wf">WF</span>
{% endif %}
{% endfor %}
</span>
</li>
{% endfor %}
</ul>
</details>
{% endif %}
</div>
<p> <p>
<strong>Legende:</strong> <strong>Legende:</strong>
<span class="source wf">WF</span> = <a href="https://pypi.org/project/wordfreq/" target="_blank" rel="noopener noreferrer">Wordfreq</a>, <span class="source wf">WF</span> = <a href="https://pypi.org/project/wordfreq/" target="_blank" rel="noopener noreferrer">Wordfreq</a>,
@@ -159,7 +200,7 @@
</main> </main>
<footer class="footer"> <footer class="footer">
Yet another <a href="https://gitea.elpatron.me/elpatron/wordle-cheater" rel="noopener noreferrer">Open Source Project</a>, vibe coded in less than 1 hour by <a href="mailto:elpatron@mailbox.org">Markus F.J. Busche</a> Yet another <a href="https://gitea.elpatron.me/elpatron/wordle-cheater" rel="noopener noreferrer">Open Source Project</a>, vibe coded in less than 4 hours by <a href="mailto:elpatron@mailbox.org">Markus F.J. Busche</a>
</footer> </footer>
</div> </div>
@@ -175,7 +216,6 @@
btn.textContent = theme === 'dark' ? '🌙' : '🌞'; btn.textContent = theme === 'dark' ? '🌙' : '🌞';
btn.title = 'Theme umschalten (' + (theme === 'dark' ? 'Dunkel' : 'Hell') + ')'; btn.title = 'Theme umschalten (' + (theme === 'dark' ? 'Dunkel' : 'Hell') + ')';
} }
// init label/state
setTheme(currentTheme()); setTheme(currentTheme());
btn.addEventListener('click', function() { btn.addEventListener('click', function() {
var next = currentTheme() === 'dark' ? 'light' : 'dark'; var next = currentTheme() === 'dark' ? 'light' : 'dark';
@@ -183,5 +223,243 @@
}); });
})(); })();
</script> </script>
<script>
function applyFilters() {
var ot = document.getElementById('filter-ot');
var wf = document.getElementById('filter-wf');
var uml = document.getElementById('filter-umlaut');
if (!ot || !wf) return;
var allowed = [];
if (ot.checked) allowed.push('ot');
if (wf.checked) allowed.push('wf');
var items = document.querySelectorAll('.word-list li');
var visible = 0;
items.forEach(function(li) {
var sources = (li.dataset.sources || '').split(/\s+/).filter(Boolean);
var hasUmlaut = li.dataset.umlaut === '1';
var showSource = allowed.length === 0 ? false : sources.some(function(s){ return allowed.indexOf(s) !== -1; });
var showUmlaut = uml && uml.checked ? true : !hasUmlaut; // an => alle, aus => ohne Umlaute
var show = showSource && showUmlaut;
li.style.display = show ? '' : 'none';
if (show) visible++;
});
var countEl = document.getElementById('visible-count');
if (countEl) countEl.textContent = String(visible);
}
function updateLettersList(listElementId, lettersString) {
var ul = document.getElementById(listElementId);
if (!ul) return;
while (ul.firstChild) ul.removeChild(ul.firstChild);
var letters = (lettersString || '')
.split('')
.filter(function(ch){ return !!ch; })
.map(function(ch){ return ch.toLowerCase(); })
.sort(function(a,b){ return a.localeCompare(b, 'de'); });
letters.forEach(function(ch){
var li = document.createElement('li');
var badge = document.createElement('span');
badge.className = 'badge';
var lower = ch.toLowerCase();
badge.textContent = lower.toUpperCase();
badge.setAttribute('data-letter', lower);
badge.title = 'Zum Entfernen klicken';
badge.setAttribute('aria-label', "Buchstabe '" + lower.toUpperCase() + "' entfernen");
// Drag & Drop für Includes-Liste aktivieren
if (listElementId === 'includes-list') {
badge.draggable = true;
badge.addEventListener('dragstart', function(e){
try {
e.dataTransfer.setData('text/plain', lower);
e.dataTransfer.setData('text/list', listElementId);
e.dataTransfer.effectAllowed = 'move';
} catch (err) {}
});
}
li.appendChild(badge);
ul.appendChild(li);
});
}
function removeLetterFromList(hiddenId, listId, letter) {
var hidden = document.getElementById(hiddenId);
if (!hidden) return;
var current = hidden.value || '';
var next = (current || '').split('').filter(function(ch){ return ch !== letter; }).join('');
hidden.value = next;
updateLettersList(listId, next);
}
function addLetterFromInput(inputId, hiddenId, listId) {
var input = document.getElementById(inputId);
var hidden = document.getElementById(hiddenId);
if (!input || !hidden) return;
var raw = (input.value || '').trim();
if (!raw) return;
var ch = raw[0];
if (!/[A-Za-zÄÖÜäöüß]/.test(ch)) { input.value = ''; return; }
var lower = ch.toLowerCase();
var current = hidden.value || '';
if (current.indexOf(lower) === -1) {
hidden.value = current + lower;
updateLettersList(listId, hidden.value);
}
input.value = '';
input.focus();
}
// Drag-Status für Positionsfelder
var dragSourcePosInput = null;
var dragAccepted = false;
window.addEventListener('load', function() {
var ot = document.getElementById('filter-ot');
var wf = document.getElementById('filter-wf');
var uml = document.getElementById('filter-umlaut');
if (ot) ot.addEventListener('change', applyFilters);
if (wf) wf.addEventListener('change', applyFilters);
if (uml) uml.addEventListener('change', applyFilters);
applyFilters();
// Status der Positionsfelder (filled) initial setzen und bei Eingaben aktualisieren
function updatePosFilledState() {
for (var i = 1; i <= 5; i++) {
var el = document.getElementById('pos' + i);
if (!el) continue;
if ((el.value || '').trim()) el.classList.add('filled');
else el.classList.remove('filled');
}
}
updatePosFilledState();
for (var i = 1; i <= 5; i++) {
(function(idx){
var el = document.getElementById('pos' + idx);
if (!el) return;
el.addEventListener('input', updatePosFilledState);
el.addEventListener('change', updatePosFilledState);
})(i);
}
// Listen initial aus Hidden-Feldern rendern
var hiddenInc = document.getElementById('includes');
var hiddenExc = document.getElementById('excludes');
updateLettersList('includes-list', hiddenInc ? hiddenInc.value : '');
updateLettersList('excludes-list', hiddenExc ? hiddenExc.value : '');
// Add-Buttons verdrahten
var addIncBtn = document.getElementById('includes-add-button');
if (addIncBtn) addIncBtn.addEventListener('click', function(){ addLetterFromInput('includes-input-one', 'includes', 'includes-list'); });
var addExcBtn = document.getElementById('excludes-add-button');
if (addExcBtn) addExcBtn.addEventListener('click', function(){ addLetterFromInput('excludes-input-one', 'excludes', 'excludes-list'); });
// Enter-Handling in den Ein-Feld-Eingaben
var incInputOne = document.getElementById('includes-input-one');
if (incInputOne) incInputOne.addEventListener('keydown', function(e){ if (e.key === 'Enter') { e.preventDefault(); addLetterFromInput('includes-input-one', 'includes', 'includes-list'); } });
var excInputOne = document.getElementById('excludes-input-one');
if (excInputOne) excInputOne.addEventListener('keydown', function(e){ if (e.key === 'Enter') { e.preventDefault(); addLetterFromInput('excludes-input-one', 'excludes', 'excludes-list'); } });
// Entfernen per Klick auf Badge (Event Delegation)
var incList = document.getElementById('includes-list');
if (incList) incList.addEventListener('click', function(e){
var t = e.target;
if (t && t.classList && t.classList.contains('badge')) {
var letter = (t.getAttribute('data-letter') || t.textContent || '').trim().toLowerCase();
if (letter) removeLetterFromList('includes', 'includes-list', letter);
}
});
var excList = document.getElementById('excludes-list');
if (excList) excList.addEventListener('click', function(e){
var t = e.target;
if (t && t.classList && t.classList.contains('badge')) {
var letter = (t.getAttribute('data-letter') || t.textContent || '').trim().toLowerCase();
if (letter) removeLetterFromList('excludes', 'excludes-list', letter);
}
});
// Drop-Ziele für Positionsfelder (pos1..pos5)
for (var i = 1; i <= 5; i++) {
(function(idx){
var input = document.getElementById('pos' + idx);
if (!input) return;
// Als Drag-Quelle nutzbar machen
input.draggable = true;
input.addEventListener('dragstart', function(e){
var val = (input.value || '').trim().toLowerCase();
if (!val) { try { e.preventDefault(); } catch (err) {} return; }
dragSourcePosInput = input;
dragAccepted = false;
try {
e.dataTransfer.setData('text/plain', val);
e.dataTransfer.setData('text/source', 'pos');
e.dataTransfer.effectAllowed = 'move';
} catch (err) {}
});
input.addEventListener('dragend', function(){
if (dragSourcePosInput === input && !dragAccepted) {
input.value = '';
input.classList.remove('filled');
}
dragSourcePosInput = null;
dragAccepted = false;
});
input.addEventListener('dragover', function(e){
e.preventDefault();
try { e.dataTransfer.dropEffect = 'move'; } catch (err) {}
input.classList.add('drop-target');
});
input.addEventListener('dragleave', function(){ input.classList.remove('drop-target'); });
input.addEventListener('drop', function(e){
e.preventDefault();
input.classList.remove('drop-target');
var letter = (e.dataTransfer.getData('text/plain') || '').trim().toLowerCase();
var sourceList = e.dataTransfer.getData('text/list') || '';
var sourceType = e.dataTransfer.getData('text/source') || '';
if (letter && /[a-zäöüß]/i.test(letter) && (sourceList === 'includes-list' || sourceType === 'pos')) {
dragAccepted = true;
input.value = letter;
if ((input.value || '').trim()) input.classList.add('filled');
if (sourceList === 'includes-list') {
removeLetterFromList('includes', 'includes-list', letter);
}
if (sourceType === 'pos' && dragSourcePosInput && dragSourcePosInput !== input) {
dragSourcePosInput.value = '';
dragSourcePosInput.classList.remove('filled');
}
input.focus();
}
});
})(i);
}
// Reset-Button Funktionalität
var resetButton = document.getElementById('reset-button');
if (resetButton) {
resetButton.addEventListener('click', function() {
// Alle Positionsfelder zurücksetzen
for (var i = 1; i <= 5; i++) {
var posField = document.getElementById('pos' + i);
if (posField) { posField.value = ''; posField.classList.remove('filled'); }
}
// Weitere Felder zurücksetzen
var includesField = document.getElementById('includes');
if (includesField) includesField.value = '';
var includesList = document.getElementById('includes-list');
if (includesList) includesList.innerHTML = '';
var incInput = document.getElementById('includes-input-one');
if (incInput) incInput.value = '';
var excludesField = document.getElementById('excludes');
if (excludesField) excludesField.value = '';
var excludesList = document.getElementById('excludes-list');
if (excludesList) excludesList.innerHTML = '';
var excInput = document.getElementById('excludes-input-one');
if (excInput) excInput.value = '';
// Suchergebnisse ausblenden
var resultsDiv = document.getElementById('results');
if (resultsDiv) {
resultsDiv.style.display = 'none';
}
});
}
});
</script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js');
});
}
</script>
</body> </body>
</html> </html>

117
templates/login.html Normal file
View File

@@ -0,0 +1,117 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Admin Login - WordleCheater</title>
<meta name="robots" content="noindex,nofollow" />
<style>
:root { --bg:#ffffff; --text:#111827; --muted:#6b7280; --border:#e5e7eb; --button-bg:#111827; --button-text:#ffffff; --error:#b91c1c; --success:#065f46; }
[data-theme="dark"] { --bg:#0b1220; --text:#e5e7eb; --muted:#9ca3af; --border:#334155; --button-bg:#e5e7eb; --button-text:#111827; --error:#ef4444; --success:#10b981; }
html, body { background: var(--bg); color: var(--text); margin: 0; padding: 0; height: 100vh; }
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; display: flex; align-items: center; justify-content: center; }
.login-container {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 2rem;
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
max-width: 400px;
width: 90%;
}
.login-header { text-align: center; margin-bottom: 2rem; }
.login-header h1 { margin: 0; color: var(--text); }
.login-header p { color: var(--muted); margin: 0.5rem 0 0 0; }
.form-group { margin-bottom: 1.5rem; }
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: 600; color: var(--text); }
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--bg);
color: var(--text);
font-size: 1rem;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.submit-btn {
width: 100%;
padding: 0.75rem;
background: var(--button-bg);
color: var(--button-text);
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.submit-btn:hover { opacity: 0.9; }
.submit-btn:active { transform: translateY(1px); }
.flash-messages { margin-bottom: 1.5rem; }
.flash {
padding: 0.75rem;
border-radius: 0.375rem;
margin-bottom: 0.5rem;
font-weight: 500;
}
.flash.error { background: var(--error); color: white; }
.flash.success { background: var(--success); color: white; }
.back-link {
text-align: center;
margin-top: 1.5rem;
}
.back-link a {
color: var(--muted);
text-decoration: none;
font-size: 0.9rem;
}
.back-link a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="login-container">
<div class="login-header">
<h1>🔐 Admin Login</h1>
<p>Statistik-Dashboard</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="POST">
<div class="form-group">
<label for="password">Passwort:</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
</div>
<button type="submit" class="submit-btn">Anmelden</button>
</form>
<div class="back-link">
<a href="{{ url_for('index') }}">← Zurück zur Hauptseite</a>
</div>
</div>
</body>
</html>

244
templates/stats.html Normal file
View File

@@ -0,0 +1,244 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Statistiken - WordleCheater</title>
<meta name="robots" content="noindex,nofollow" />
<style>
:root { --bg:#ffffff; --text:#111827; --muted:#6b7280; --border:#e5e7eb; --button-bg:#111827; --button-text:#ffffff; --accent:#3b82f6; --success:#10b981; --warning:#f59e0b; }
[data-theme="dark"] { --bg:#0b1220; --text:#e5e7eb; --muted:#9ca3af; --border:#334155; --button-bg:#e5e7eb; --button-text:#111827; --accent:#60a5fa; --success:#34d399; --warning:#fbbf24; }
html, body { background: var(--bg); color: var(--text); margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; line-height: 1.6; }
.container { max-width: 1200px; margin: 0 auto; padding: 2rem; }
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.header h1 { margin: 0; color: var(--text); }
.header .actions { display: flex; gap: 1rem; }
.btn {
padding: 0.5rem 1rem;
background: var(--button-bg);
color: var(--button-text);
border: none;
border-radius: 0.375rem;
text-decoration: none;
font-size: 0.9rem;
cursor: pointer;
}
.btn:hover { opacity: 0.9; }
.btn.secondary { background: var(--muted); }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.stat-card h3 { margin: 0 0 1rem 0; color: var(--text); font-size: 1.1rem; }
.stat-number { font-size: 2.5rem; font-weight: bold; color: var(--accent); margin-bottom: 0.5rem; }
.stat-description { color: var(--muted); font-size: 0.9rem; }
.chart-container {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.chart-container h3 { margin: 0 0 1rem 0; color: var(--text); }
.bar-chart { display: flex; align-items: end; gap: 0.5rem; height: 200px; }
.bar {
background: var(--accent);
min-width: 40px;
border-radius: 0.25rem 0.25rem 0 0;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
.bar-label {
position: absolute;
bottom: -25px;
font-size: 0.8rem;
color: var(--muted);
transform: rotate(-45deg);
transform-origin: top left;
}
.bar-value {
position: absolute;
top: -25px;
font-size: 0.8rem;
color: var(--text);
font-weight: 600;
}
.recent-activity {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1.5rem;
}
.recent-activity h3 { margin: 0 0 1rem 0; color: var(--text); }
.activity-item {
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
font-family: monospace;
font-size: 0.85rem;
}
.activity-item:last-child { border-bottom: none; }
.activity-timestamp { color: var(--accent); font-weight: 600; }
.search-patterns {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1.5rem;
}
.search-patterns h3 { margin: 0 0 1rem 0; color: var(--text); }
.pattern-item {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
}
.pattern-item:last-child { border-bottom: none; }
.pattern-text { font-family: monospace; color: var(--text); }
.pattern-count { color: var(--accent); font-weight: 600; }
.no-data {
text-align: center;
color: var(--muted);
padding: 2rem;
font-style: italic;
}
@media (max-width: 768px) {
.container { padding: 1rem; }
.header { flex-direction: column; gap: 1rem; align-items: stretch; }
.stats-grid { grid-template-columns: 1fr; }
.bar-chart { height: 150px; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📊 Statistik-Dashboard</h1>
<div class="actions">
<a href="{{ url_for('index') }}" class="btn secondary">← Hauptseite</a>
<a href="{{ url_for('logout') }}" class="btn">Abmelden</a>
</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<h3>📈 Gesamte Seitenaufrufe</h3>
<div class="stat-number">{{ stats.total_page_views }}</div>
<div class="stat-description">Alle protokollierten Seitenaufrufe</div>
</div>
<div class="stat-card">
<h3>🔍 Gesamte Suchvorgänge</h3>
<div class="stat-number">{{ stats.total_searches }}</div>
<div class="stat-description">Alle durchgeführten Wortsuche</div>
</div>
<div class="stat-card">
<h3>📱 Seitenaufrufe pro Seite</h3>
{% if stats.page_views_by_page %}
{% for page, count in stats.page_views_by_page.items() %}
<div style="margin-bottom: 0.5rem;">
<strong>{{ page }}:</strong> {{ count }}
</div>
{% endfor %}
{% else %}
<div class="no-data">Keine Daten verfügbar</div>
{% endif %}
</div>
</div>
<div class="chart-container">
<h3>🔍 Suchvorgänge nach Quellen</h3>
{% if stats.searches_by_source and (stats.searches_by_source.OT > 0 or stats.searches_by_source.WF > 0 or stats.searches_by_source.Both > 0) %}
<div class="bar-chart">
{% set max_count = [stats.searches_by_source.OT, stats.searches_by_source.WF, stats.searches_by_source.Both] | max %}
{% if stats.searches_by_source.OT > 0 %}
<div class="bar" style="height: {{ (stats.searches_by_source.OT / max_count * 150) + 50 }}px;">
<div class="bar-value">{{ stats.searches_by_source.OT }}</div>
<div class="bar-label">OpenThesaurus</div>
</div>
{% endif %}
{% if stats.searches_by_source.WF > 0 %}
<div class="bar" style="height: {{ (stats.searches_by_source.WF / max_count * 150) + 50 }}px;">
<div class="bar-value">{{ stats.searches_by_source.WF }}</div>
<div class="bar-label">Wordfreq</div>
</div>
{% endif %}
{% if stats.searches_by_source.Both > 0 %}
<div class="bar" style="height: {{ (stats.searches_by_source.Both / max_count * 150) + 50 }}px;">
<div class="bar-value">{{ stats.searches_by_source.Both }}</div>
<div class="bar-label">Beide</div>
</div>
{% endif %}
</div>
{% else %}
<div class="no-data">Keine Suchdaten verfügbar</div>
{% endif %}
</div>
{% if stats.top_search_patterns %}
<div class="search-patterns">
<h3>🎯 Häufigste Suchmuster (Positionen)</h3>
{% for pattern, count in stats.top_search_patterns.items() | sort(attribute='1', reverse=true) %}
<div class="pattern-item">
<span class="pattern-text">{{ pattern or '(leer)' }}</span>
<span class="pattern-count">{{ count }}</span>
</div>
{% endfor %}
</div>
{% endif %}
<div class="recent-activity">
<h3>⏰ Letzte Aktivitäten</h3>
{% if stats.recent_activity %}
{% for activity in stats.recent_activity %}
<div class="activity-item">
<span class="activity-timestamp">{{ activity.timestamp }}</span>
<span>{{ activity.line.split(' - ', 2)[2] if ' - ' in activity.line else activity.line }}</span>
</div>
{% endfor %}
{% else %}
<div class="no-data">Keine Aktivitäten verfügbar</div>
{% endif %}
</div>
</div>
</body>
</html>