Compare commits

..

26 Commits

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

2
.gitignore vendored
View File

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

86
LOGGING.md Normal file
View File

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

View File

@@ -5,12 +5,23 @@ HilfsWebApp für deutsche WordleRätsel. Nutzer geben bekannte Buchstab
## Features
- Filter nach Positionen (15), enthaltenen und ausgeschlossenen Buchstaben
- Enthaltene/Ausgeschlossene Buchstaben per EinZeichenEingabe und „+“-Button hinzufügen
- Ausgewählte Buchstaben werden als Badges angezeigt, Klick entfernt den Buchstaben wieder
- Alphabetische Sortierung der ausgewählten Buchstaben (deutsche Locale)
- DragandDrop: Buchstaben aus „Enthalten“ direkt auf die Felder `pos1``pos5` ziehen
- Deutsche Wortliste (nur 5 Buchstaben), aus OpenThesaurus und wordfreq gemerged
- QuellenBadges je Treffer (OT/WF)
- Zugängliche UI (A11y: Fieldset/Legend, ARIAHinweise, SkipLink, semantische Liste)
- SEOMetas (Description, Canonical, Open Graph, Twitter)
- DockerImage mit Gunicorn
## Demo
![https://wh.elpatron.me](./screenshot-mobile.png)
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 # PythonAbhä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
```
- HealthCheck (lokal): `http://localhost:8000/`
- AdminDashboard: `http://localhost:8000/stats` (passwortgeschützt)
Hinweise:

259
app.py
View File

@@ -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
View File

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

8
robots.txt Normal file
View File

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

BIN
screenshot-mobile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

9
sitemap.xml Normal file
View File

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

View File

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

39
static/sw.js Normal file
View File

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

View File

@@ -11,10 +11,17 @@
<meta property="og:title" content="WordleCheater (DE)" />
<meta property="og:description" content="Finde deutsche 5BuchstabenWörter mit Positions- und Buchstabenfiltern. Quellen: OpenThesaurus & wordfreq." />
<meta property="og:url" content="{{ request.url_root }}" />
<meta property="og:site_name" content="WordleCheater (DE)" />
<meta property="og:locale" content="de_DE" />
<meta property="og:image" content="{{ url_for('screenshot_image', _external=True) }}" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="WordleCheater (DE)" />
<meta name="twitter:description" content="Finde deutsche 5BuchstabenWörter mit Positions- und Buchstabenfiltern. Quellen: OpenThesaurus & wordfreq." />
<meta name="twitter:image" content="{{ url_for('screenshot_image', _external=True) }}" />
<link rel="alternate" hreflang="de" href="{{ request.url_root }}" />
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}" />
<link rel="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": "WordleCheater (DE)",
"url": "{{ request.url_root }}",
"applicationCategory": "UtilitiesApplication",
"operatingSystem": "Web",
"description": "Finde deutsche 5BuchstabenWörter anhand bekannter Buchstaben und Positionen. Quellen: OpenThesaurus & wordfreq.",
"inLanguage": "de",
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "EUR" }
}
</script>
<style>
: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>WortquellenFilter</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
View File

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

244
templates/stats.html Normal file
View File

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