Compare commits
31 Commits
750bee4991
...
main
Author | SHA1 | Date | |
---|---|---|---|
c3290c071a | |||
80cb551ecc | |||
06b8910b02 | |||
36cace78a6 | |||
63a970cb43 | |||
12bf7e6276 | |||
fc54056572 | |||
a52a7ac2cf | |||
d730e6b266 | |||
fcbcb07e76 | |||
bcc6869d13 | |||
634806ec44 | |||
6560acd1d9 | |||
4d4ecf70b8 | |||
e4890bf2f2 | |||
f5deb5a839 | |||
177e4d01ce | |||
1cb32a2268 | |||
44f14bcffd | |||
c93a813c96 | |||
b2cd91b970 | |||
ced79efe0a | |||
bf524d692e | |||
adf54635de | |||
f35503e0b2 | |||
3a8a16934c | |||
d34dbb61ae | |||
cb4b579e57 | |||
b4be064fab | |||
f87a56e55d | |||
801081c1d6 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
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/
|
||||||
|
```
|
55
README.md
55
README.md
@@ -5,31 +5,57 @@ Hilfs‑Web‑App für deutsche Wordle‑Rätsel. Nutzer geben bekannte Buchstab
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Filter nach Positionen (1–5), enthaltenen und ausgeschlossenen Buchstaben
|
- 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
|
- Deutsche Wortliste (nur 5 Buchstaben), aus OpenThesaurus und wordfreq gemerged
|
||||||
- Quellen‑Badges je Treffer (OT/WF)
|
- Quellen‑Badges je Treffer (OT/WF)
|
||||||
- Zugängliche UI (A11y: Fieldset/Legend, ARIA‑Hinweise, Skip‑Link, semantische Liste)
|
- Zugängliche UI (A11y: Fieldset/Legend, ARIA‑Hinweise, Skip‑Link, semantische Liste)
|
||||||
- SEO‑Metas (Description, Canonical, Open Graph, Twitter)
|
- SEO‑Metas (Description, Canonical, Open Graph, Twitter)
|
||||||
- Docker‑Image mit Gunicorn
|
- Docker‑Image mit Gunicorn
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Wordle-Cheater live bei [https://wh.elpatron.me](https://wh.elpatron.me).
|
||||||
|
|
||||||
|
|
||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
|
|
||||||
```
|
```text
|
||||||
app.py # Flask‑App
|
wordle-helper/
|
||||||
scripts/generate_wordlist.py # Generator für Wortliste + Quellen‑JSON
|
├── app.py # Flask‑App
|
||||||
templates/index.html # UI (Jinja2‑Template)
|
├── scripts/
|
||||||
static/favicon.svg # Favicon (SVG)
|
│ └── generate_wordlist.py # Generator für Wortliste + Quellen‑JSON
|
||||||
data/openthesaurus.txt # Quelle OpenThesaurus (Text)
|
├── templates/
|
||||||
data/words_de_5.txt # generierte Wortliste
|
│ └── index.html # UI (Jinja2‑Template)
|
||||||
data/words_de_5_sources.json # Wort→Quellen (ot/wf)
|
├── static/
|
||||||
Dockerfile # Produktionsimage (Gunicorn)
|
│ └── favicon.svg # Favicon (SVG)
|
||||||
requirements.txt # Python‑Abhängigkeiten
|
├── data/
|
||||||
LICENSE # MIT‑Lizenz
|
│ ├── openthesaurus.txt # Quelle OpenThesaurus (Text)
|
||||||
```
|
│ ├── words_de_5.txt # generierte Wortliste
|
||||||
|
│ └── words_de_5_sources.json # Wort→Quellen (ot/wf)
|
||||||
|
├── Dockerfile # Produktionsimage (Gunicorn)
|
||||||
|
├── requirements.txt # Python‑Abhä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:
|
||||||
@@ -45,8 +71,10 @@ docker run --rm -p 8000:8000 wordle-cheater
|
|||||||
```
|
```
|
||||||
|
|
||||||
- Health‑Check (lokal): `http://localhost:8000/`
|
- Health‑Check (lokal): `http://localhost:8000/`
|
||||||
|
- Admin‑Dashboard: `http://localhost:8000/stats` (passwortgeschützt)
|
||||||
|
|
||||||
Hinweise:
|
Hinweise:
|
||||||
|
|
||||||
- Das Image generiert die Wortliste beim Build. Wird `data/openthesaurus.txt` aktualisiert, Image neu bauen.
|
- Das Image generiert die Wortliste beim Build. Wird `data/openthesaurus.txt` aktualisiert, Image neu bauen.
|
||||||
- Gunicorn startet mit 3 Worker (`gthread`, 2 Threads). Passen Sie Worker/Threads an Ihre CPU an.
|
- Gunicorn startet mit 3 Worker (`gthread`, 2 Threads). Passen Sie Worker/Threads an Ihre CPU an.
|
||||||
- Hinter einem Reverse Proxy (z. B. Nginx) betreiben; Beispiel:
|
- Hinter einem Reverse Proxy (z. B. Nginx) betreiben; Beispiel:
|
||||||
@@ -61,6 +89,7 @@ location / {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Ohne Docker (Service)
|
### Ohne Docker (Service)
|
||||||
|
|
||||||
- Python 3.12 bereitstellen
|
- Python 3.12 bereitstellen
|
||||||
- Virtuelle Umgebung erstellen, Abhängigkeiten installieren (siehe Entwickler‑Setup)
|
- Virtuelle Umgebung erstellen, Abhängigkeiten installieren (siehe Entwickler‑Setup)
|
||||||
- Start per Gunicorn:
|
- Start per Gunicorn:
|
||||||
|
259
app.py
259
app.py
@@ -1,9 +1,166 @@
|
|||||||
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]]]:
|
||||||
@@ -50,11 +207,19 @@ def filter_words(words: List[str], position_letters: List[str], includes_text: s
|
|||||||
|
|
||||||
@app.route("/", methods=["GET", "POST"])
|
@app.route("/", methods=["GET", "POST"])
|
||||||
def index():
|
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()
|
all_words, sources_map = load_words()
|
||||||
results: List[str] | None = None
|
results_display: List[str] | None = None
|
||||||
pos: List[str] = ["", "", "", "", ""]
|
pos: List[str] = ["", "", "", "", ""]
|
||||||
includes: str = ""
|
includes: str = ""
|
||||||
excludes: str = ""
|
excludes: str = ""
|
||||||
|
use_ot: bool = True
|
||||||
|
use_wf: bool = False
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
pos = [
|
pos = [
|
||||||
(request.form.get("pos1") or "").strip().lower(),
|
(request.form.get("pos1") or "").strip().lower(),
|
||||||
@@ -65,17 +230,103 @@ def index():
|
|||||||
]
|
]
|
||||||
includes = (request.form.get("includes") or "").strip()
|
includes = (request.form.get("includes") or "").strip()
|
||||||
excludes = (request.form.get("excludes") 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(
|
return render_template(
|
||||||
"index.html",
|
"index.html",
|
||||||
results=results,
|
results=results_display,
|
||||||
pos=pos,
|
pos=pos,
|
||||||
includes=includes,
|
includes=includes,
|
||||||
excludes=excludes,
|
excludes=excludes,
|
||||||
words_count=len(all_words),
|
words_count=len(all_words),
|
||||||
sources_map=sources_map,
|
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__":
|
||||||
|
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)
|
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.png
Normal file
BIN
screenshot.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 69 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,43 +11,100 @@
|
|||||||
<meta property="og:title" content="Wordle‑Cheater (DE)" />
|
<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: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: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:card" content="summary" />
|
||||||
<meta name="twitter:title" content="Wordle‑Cheater (DE)" />
|
<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: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="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() {
|
||||||
|
try {
|
||||||
|
var saved = localStorage.getItem('theme');
|
||||||
|
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
var theme = saved || (prefersDark ? 'dark' : 'light');
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
} 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>
|
<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; --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; }
|
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 h1 { margin: 0; }
|
||||||
|
@media (max-width: 480px) { .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; }
|
.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; }
|
||||||
.badge { display: inline-block; padding: .25rem .5rem; background: #e5e7eb; color: #111827; border-radius: .375rem; margin-right: .25rem; margin-bottom: .25rem; }
|
.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 { 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: #6b7280; }
|
.footer { margin-top: 2rem; font-size: .9rem; color: var(--muted); }
|
||||||
.footer a { color: inherit; text-decoration: underline; }
|
.footer a { color: inherit; text-decoration: underline; }
|
||||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 1px, 1px); white-space: nowrap; border: 0; }
|
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 1px, 1px); white-space: nowrap; border: 0; }
|
||||||
.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: #111827; color: #ffffff; 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: #374151; 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 #e5e7eb; 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: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>
|
||||||
<a href="#results" class="skip-link">Zum Ergebnisbereich springen</a>
|
<a href="#results" class="skip-link">Zum Ergebnisbereich springen</a>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<header class="page-header">
|
||||||
|
<button id="theme-toggle" class="theme-toggle" aria-pressed="false" aria-label="Theme umschalten" title="Theme umschalten">🌞/🌙</button>
|
||||||
<h1>Wordle‑Cheater (Deutsch)</h1>
|
<h1>Wordle‑Cheater (Deutsch)</h1>
|
||||||
|
</header>
|
||||||
<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>
|
||||||
@@ -59,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="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>
|
||||||
|
<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 %}
|
{% if results|length == 0 %}
|
||||||
<p>Keine Treffer. Bitte Bedingungen anpassen.</p>
|
<p>Keine Treffer. Bitte Bedingungen anpassen.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -83,9 +165,17 @@
|
|||||||
<summary>Liste anzeigen</summary>
|
<summary>Liste anzeigen</summary>
|
||||||
<ul class="word-list">
|
<ul class="word-list">
|
||||||
{% for w in results %}
|
{% for w in results %}
|
||||||
<li>
|
|
||||||
<span class="badge">{{ w }}
|
|
||||||
{% set srcs = sources_map.get(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 %}
|
{% for s in srcs %}
|
||||||
{% if s == 'ot' %}
|
{% if s == 'ot' %}
|
||||||
<span class="source ot">OT</span>
|
<span class="source ot">OT</span>
|
||||||
@@ -99,18 +189,277 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
<p>
|
<p>
|
||||||
<strong>Legende:</strong>
|
<strong>Legende:</strong>
|
||||||
<span class="source wf">WF</span> = Wordfreq,
|
<span class="source wf">WF</span> = <a href="https://pypi.org/project/wordfreq/" target="_blank" rel="noopener noreferrer">Wordfreq</a>,
|
||||||
<span class="source ot">OT</span> = OpenThesaurus
|
<span class="source ot">OT</span> = <a href="https://www.openthesaurus.de" target="_blank" rel="noopener noreferrer">OpenThesaurus</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
Made in 2025 with ❤️ and ☕ 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>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var btn = document.getElementById('theme-toggle');
|
||||||
|
if (!btn) return;
|
||||||
|
function currentTheme() { return document.documentElement.getAttribute('data-theme') || 'light'; }
|
||||||
|
function setTheme(theme) {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
try { localStorage.setItem('theme', theme); } catch (e) {}
|
||||||
|
btn.setAttribute('aria-pressed', theme === 'dark');
|
||||||
|
btn.textContent = theme === 'dark' ? '🌙' : '🌞';
|
||||||
|
btn.title = 'Theme umschalten (' + (theme === 'dark' ? 'Dunkel' : 'Hell') + ')';
|
||||||
|
}
|
||||||
|
setTheme(currentTheme());
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var next = currentTheme() === 'dark' ? 'light' : 'dark';
|
||||||
|
setTheme(next);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</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
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