Compare commits
26 Commits
bf524d692e
...
main
Author | SHA1 | Date | |
---|---|---|---|
61d01e1e11 | |||
8c13e470ad | |||
526f030661 | |||
223e2aa007 | |||
c3290c071a | |||
80cb551ecc | |||
06b8910b02 | |||
36cace78a6 | |||
63a970cb43 | |||
12bf7e6276 | |||
fc54056572 | |||
a52a7ac2cf | |||
d730e6b266 | |||
fcbcb07e76 | |||
bcc6869d13 | |||
634806ec44 | |||
6560acd1d9 | |||
4d4ecf70b8 | |||
e4890bf2f2 | |||
f5deb5a839 | |||
177e4d01ce | |||
1cb32a2268 | |||
44f14bcffd | |||
c93a813c96 | |||
b2cd91b970 | |||
ced79efe0a |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,6 +1,8 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
.env
|
||||
.DS_Store
|
||||
|
86
LOGGING.md
Normal file
86
LOGGING.md
Normal 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/
|
||||
```
|
27
README.md
27
README.md
@@ -5,12 +5,23 @@ Hilfs‑Web‑App für deutsche Wordle‑Rätsel. Nutzer geben bekannte Buchstab
|
||||
## Features
|
||||
|
||||
- Filter nach Positionen (1–5), enthaltenen und ausgeschlossenen Buchstaben
|
||||
- Enthaltene/Ausgeschlossene Buchstaben per Ein‑Zeichen‑Eingabe und „+“-Button hinzufügen
|
||||
- Ausgewählte Buchstaben werden als Badges angezeigt, Klick entfernt den Buchstaben wieder
|
||||
- Alphabetische Sortierung der ausgewählten Buchstaben (deutsche Locale)
|
||||
- Drag‑and‑Drop: Buchstaben aus „Enthalten“ direkt auf die Felder `pos1`–`pos5` ziehen
|
||||
- Deutsche Wortliste (nur 5 Buchstaben), aus OpenThesaurus und wordfreq gemerged
|
||||
- Quellen‑Badges je Treffer (OT/WF)
|
||||
- Zugängliche UI (A11y: Fieldset/Legend, ARIA‑Hinweise, Skip‑Link, semantische Liste)
|
||||
- SEO‑Metas (Description, Canonical, Open Graph, Twitter)
|
||||
- Docker‑Image mit Gunicorn
|
||||
|
||||
## Demo
|
||||
|
||||

|
||||
|
||||
Wordle-Cheater live bei [https://wh.elpatron.me](https://wh.elpatron.me).
|
||||
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```text
|
||||
@@ -28,12 +39,23 @@ wordle-helper/
|
||||
│ └── words_de_5_sources.json # Wort→Quellen (ot/wf)
|
||||
├── Dockerfile # Produktionsimage (Gunicorn)
|
||||
├── requirements.txt # Python‑Abhängigkeiten
|
||||
└──
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
## 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)
|
||||
|
||||
- Build:
|
||||
@@ -49,6 +71,7 @@ docker run --rm -p 8000:8000 wordle-cheater
|
||||
```
|
||||
|
||||
- Health‑Check (lokal): `http://localhost:8000/`
|
||||
- Admin‑Dashboard: `http://localhost:8000/stats` (passwortgeschützt)
|
||||
|
||||
Hinweise:
|
||||
|
||||
|
259
app.py
259
app.py
@@ -1,9 +1,166 @@
|
||||
from pathlib import Path
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
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.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]]]:
|
||||
@@ -50,11 +207,19 @@ def filter_words(words: List[str], position_letters: List[str], includes_text: s
|
||||
|
||||
@app.route("/", methods=["GET", "POST"])
|
||||
def index():
|
||||
# Log-Bereinigung bei jedem Seitenaufruf prüfen (nur alle 24h)
|
||||
cleanup_old_logs()
|
||||
|
||||
# Seitenaufruf protokollieren
|
||||
log_page_view("index", request.headers.get('User-Agent'))
|
||||
|
||||
all_words, sources_map = load_words()
|
||||
results: List[str] | None = None
|
||||
results_display: List[str] | None = None
|
||||
pos: List[str] = ["", "", "", "", ""]
|
||||
includes: str = ""
|
||||
excludes: str = ""
|
||||
use_ot: bool = True
|
||||
use_wf: bool = False
|
||||
if request.method == "POST":
|
||||
pos = [
|
||||
(request.form.get("pos1") or "").strip().lower(),
|
||||
@@ -65,17 +230,103 @@ def index():
|
||||
]
|
||||
includes = (request.form.get("includes") or "").strip()
|
||||
excludes = (request.form.get("excludes") or "").strip()
|
||||
results = filter_words(all_words, pos, includes, excludes)
|
||||
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,
|
||||
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__":
|
||||
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
31
docker-compose.yml
Normal 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
8
robots.txt
Normal 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
BIN
screenshot-mobile.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 117 KiB |
BIN
screenshot.png
Normal file
BIN
screenshot.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 67 KiB |
9
sitemap.xml
Normal file
9
sitemap.xml
Normal 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>
|
18
static/manifest.webmanifest
Normal file
18
static/manifest.webmanifest
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "Wordle‑Cheater",
|
||||
"short_name": "W‑Cheater",
|
||||
"description": "Hilft bei der Lösung deutschsprachiger Wordle‑Rä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
39
static/sw.js
Normal 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;
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
@@ -11,10 +11,17 @@
|
||||
<meta property="og:title" content="Wordle‑Cheater (DE)" />
|
||||
<meta property="og:description" content="Finde deutsche 5‑Buchstaben‑Wörter mit Positions- und Buchstabenfiltern. Quellen: OpenThesaurus & wordfreq." />
|
||||
<meta property="og:url" content="{{ request.url_root }}" />
|
||||
<meta property="og:site_name" content="Wordle‑Cheater (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:title" content="Wordle‑Cheater (DE)" />
|
||||
<meta name="twitter:description" content="Finde deutsche 5‑Buchstaben‑Wö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="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#0b1220" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<script>
|
||||
(function() {
|
||||
@@ -26,52 +33,47 @@
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
"name": "Wordle‑Cheater (DE)",
|
||||
"url": "{{ request.url_root }}",
|
||||
"applicationCategory": "UtilitiesApplication",
|
||||
"operatingSystem": "Web",
|
||||
"description": "Finde deutsche 5‑Buchstaben‑Wörter anhand bekannter Buchstaben und Positionen. Quellen: OpenThesaurus & wordfreq.",
|
||||
"inLanguage": "de",
|
||||
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "EUR" }
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
: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;
|
||||
}
|
||||
[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;
|
||||
}
|
||||
: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; }
|
||||
[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; }
|
||||
html, body { background: var(--bg); color: var(--text); }
|
||||
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; margin: 2rem; }
|
||||
.container { max-width: 800px; margin: 0 auto; }
|
||||
.page-header { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
|
||||
.page-header h1 { margin: 0; }
|
||||
@media (max-width: 480px) {
|
||||
.page-header h1 { font-size: 1.5rem; }
|
||||
}
|
||||
@media (max-width: 480px) { .page-header h1 { font-size: 1.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; }
|
||||
.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 .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.ot { background: #dbeafe; color: #1e40af; }
|
||||
.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; }
|
||||
.footer { margin-top: 2rem; font-size: .9rem; color: var(--muted); }
|
||||
.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: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; }
|
||||
.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; }
|
||||
#includes-list, #excludes-list { margin-top: .75rem; }
|
||||
.word-list li { display: inline-block; margin: 0 .25rem .25rem 0; }
|
||||
fieldset { border: 1px solid var(--border); border-radius: .5rem; padding: .75rem; }
|
||||
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: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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -97,7 +104,7 @@
|
||||
<p>Wortliste geladen: <strong>{{ words_count }}</strong> Wörter</p>
|
||||
|
||||
<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>
|
||||
|
||||
<fieldset>
|
||||
@@ -109,23 +116,48 @@
|
||||
<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] }}" />
|
||||
</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>
|
||||
|
||||
<label for="includes">Weitere enthaltene Buchstaben (beliebige Reihenfolge)</label>
|
||||
<input id="includes" name="includes" aria-describedby="includes-hint" inputmode="text" autocomplete="off" value="{{ includes }}" />
|
||||
<p id="includes-hint" class="hint">Mehrere Buchstaben ohne Trennzeichen eingeben (z. B. „aei“).</p>
|
||||
<label for="includes-input-one">Weitere enthaltene Buchstaben (beliebige Reihenfolge)</label>
|
||||
<div class="inline-controls" aria-describedby="includes-hint">
|
||||
<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>
|
||||
<input id="excludes" name="excludes" aria-describedby="excludes-hint" inputmode="text" autocomplete="off" value="{{ excludes }}" />
|
||||
<p id="excludes-hint" class="hint">Buchstaben, die nicht vorkommen (z. B. „rst“).</p>
|
||||
<label for="excludes-input-one">Ausgeschlossene Buchstaben</label>
|
||||
<div class="inline-controls" aria-describedby="excludes-hint">
|
||||
<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="button" id="reset-button" class="reset-button">Zurücksetzen</button>
|
||||
</form>
|
||||
|
||||
{% if results is not none %}
|
||||
<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>
|
||||
<fieldset class="filter-box" role="group">
|
||||
<legend>Wortquellen‑Filter</legend>
|
||||
<div class="inline-controls">
|
||||
<label><input id="filter-ot" type="checkbox" name="use_ot" form="search-form" {% if use_ot %}checked{% endif %}/> OT (OpenThesaurus)</label>
|
||||
<label><input id="filter-wf" type="checkbox" name="use_wf" form="search-form" {% if use_wf %}checked{% endif %}/> WF (wordfreq)</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="filter-box" role="group">
|
||||
<legend>Umlaute</legend>
|
||||
<div class="inline-controls">
|
||||
<label><input id="filter-umlaut" type="checkbox" /> Umlaute einbeziehen (ä, ö, ü, ß)</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="results-box">
|
||||
{% if results|length == 0 %}
|
||||
<p>Keine Treffer. Bitte Bedingungen anpassen.</p>
|
||||
{% else %}
|
||||
@@ -133,9 +165,17 @@
|
||||
<summary>Liste anzeigen</summary>
|
||||
<ul class="word-list">
|
||||
{% for w in results %}
|
||||
<li>
|
||||
<span class="badge">{{ w }}
|
||||
{% set srcs = sources_map.get(w, []) %}
|
||||
{% 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>
|
||||
@@ -149,6 +189,7 @@
|
||||
</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p>
|
||||
<strong>Legende:</strong>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -175,7 +216,6 @@
|
||||
btn.textContent = theme === 'dark' ? '🌙' : '🌞';
|
||||
btn.title = 'Theme umschalten (' + (theme === 'dark' ? 'Dunkel' : 'Hell') + ')';
|
||||
}
|
||||
// init label/state
|
||||
setTheme(currentTheme());
|
||||
btn.addEventListener('click', function() {
|
||||
var next = currentTheme() === 'dark' ? 'light' : 'dark';
|
||||
@@ -183,5 +223,243 @@
|
||||
});
|
||||
})();
|
||||
</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>
|
||||
</html>
|
||||
|
117
templates/login.html
Normal file
117
templates/login.html
Normal 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 - Wordle‑Cheater</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
244
templates/stats.html
Normal 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 - Wordle‑Cheater</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>
|
Reference in New Issue
Block a user