added comments and fixed some wrong text.

This commit is contained in:
2026-04-08 11:06:37 +02:00
parent 26e4d0dd27
commit 1beb1ce630
7 changed files with 176 additions and 52 deletions
+1 -8
View File
@@ -6,7 +6,6 @@ project-root/
│ │
├── app/ ├── app/
│ ├── __init__.py # Flask App Factory │ ├── __init__.py # Flask App Factory
│ ├── config.py # Konfiguration (DB, Secrets, etc.)
│ │ │ │
│ ├── models/ # Datenbankmodelle │ ├── models/ # Datenbankmodelle
│ │ ├── __init__.py │ │ ├── __init__.py
@@ -22,12 +21,7 @@ project-root/
│ │ ├── solver_routes.py │ │ ├── solver_routes.py
│ │ └── job_routes.py │ │ └── job_routes.py
│ │ │ │
│ ├── services/ # Businesslogik (Solver etc.) │ ├── templates/ # HTML
│ │ ├── __init__.py
│ │ ├── solver_service.py
│ │ └── auth_service.py
│ │
│ ├── templates/ # HTML (Jinja Templates)
│ │ ├── base.html │ │ ├── base.html
│ │ ├── login.html │ │ ├── login.html
│ │ ├── dashboard.html │ │ ├── dashboard.html
@@ -42,6 +36,5 @@ project-root/
│ │
├── run.py # Startpunkt der Anwendung ├── run.py # Startpunkt der Anwendung
├── requirements.txt ├── requirements.txt
├── .env # DB-Zugangsdaten, Secrets
└── README.md └── README.md
``` ```
+17
View File
@@ -1,18 +1,30 @@
"""Application factory and shared Flask extensions.
This file creates the Flask application, configures the database and login
manager, imports the data models so SQLAlchemy knows about them, and registers
the route blueprints used by the project.
"""
import os import os
from flask import Flask from flask import Flask
from flask_login import LoginManager from flask_login import LoginManager
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
# Shared extension instances are created once here and attached to the app
db = SQLAlchemy() db = SQLAlchemy()
login_manager = LoginManager() login_manager = LoginManager()
def create_app(): def create_app():
"""Build and configure the Flask application instance."""
app = Flask(__name__) app = Flask(__name__)
# Build an absolute path to the application directory so the SQLite
# database file can be referenced reliably from any working directory.
BASE_DIR = os.path.abspath(os.path.dirname(__file__)) BASE_DIR = os.path.abspath(os.path.dirname(__file__))
# Core Flask and SQLAlchemy configuration.
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(BASE_DIR, "app.db") app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(BASE_DIR, "app.db")
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-secret-key") app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-secret-key")
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
@@ -20,22 +32,27 @@ def create_app():
app.config["REMEMBER_COOKIE_HTTPONLY"] = True app.config["REMEMBER_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax" app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
# Connect the shared extension objects to this specific app instance.
db.init_app(app) db.init_app(app)
login_manager.init_app(app) login_manager.init_app(app)
login_manager.login_view = "login.login" login_manager.login_view = "login.login"
login_manager.login_message_category = "info" login_manager.login_message_category = "info"
# Import the models before create_all() so SQLAlchemy has the table
from .models.user import User from .models.user import User
from .models.gamestate import GameState from .models.gamestate import GameState
from .models.login_attempt import LoginAttempt from .models.login_attempt import LoginAttempt
# Create any database tables that do not exist yet.
with app.app_context(): with app.app_context():
db.create_all() db.create_all()
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id): def load_user(user_id):
"""Reload the logged-in user from the session-stored user id."""
return db.session.get(User, int(user_id)) return db.session.get(User, int(user_id))
# Import and register the blueprints that contain the route handlers.
from .routes.login_routes import login_bp from .routes.login_routes import login_bp
from .routes.main_routes import main_bp from .routes.main_routes import main_bp
+53 -1
View File
@@ -1,3 +1,11 @@
"""Authentication routes and login-related helper functions.
This module handles user registration, login, logout, password changes,
login-attempt throttling, and authenticator-app based two-factor
authentication. It also contains the utility functions that keep these flows
consistent across the different route handlers.
"""
import base64 import base64
import binascii import binascii
import hashlib import hashlib
@@ -16,11 +24,13 @@ from .. import db
from ..models.login_attempt import LoginAttempt from ..models.login_attempt import LoginAttempt
from ..models.user import User from ..models.user import User
# Blueprint that groups all authentication and account-security routes.
login_bp = Blueprint("login", __name__) login_bp = Blueprint("login", __name__)
# constants
MIN_PASSWORD_LENGTH = 8 MIN_PASSWORD_LENGTH = 8
MAX_PASSWORD_LENGTH = 128 MAX_PASSWORD_LENGTH = 128
MAX_LOGIN_FAILURES = 5 MAX_LOGIN_FAILURES = 8
LOGIN_LOCK_MINUTES = 15 LOGIN_LOCK_MINUTES = 15
TWO_FACTOR_CODE_DIGITS = 6 TWO_FACTOR_CODE_DIGITS = 6
TWO_FACTOR_TIME_STEP_SECONDS = 30 TWO_FACTOR_TIME_STEP_SECONDS = 30
@@ -32,10 +42,12 @@ DUMMY_PASSWORD_HASH = generate_password_hash("dummy-password-for-timing-protecti
def utcnow(): def utcnow():
"""Return the current UTC time as datetime."""
return datetime.utcnow() return datetime.utcnow()
def get_utc_timestamp(moment=None): def get_utc_timestamp(moment=None):
"""Convert a datetime into a Unix timestamp in UTC seconds."""
if moment is None: if moment is None:
return int(datetime.now(timezone.utc).timestamp()) return int(datetime.now(timezone.utc).timestamp())
@@ -46,16 +58,19 @@ def get_utc_timestamp(moment=None):
def normalize_username(username): def normalize_username(username):
"""Trim surrounding whitespace from the username field."""
return (username or "").strip() return (username or "").strip()
def validate_username(username): def validate_username(username):
"""Return an error message if the username violates the allowed pattern."""
if not USERNAME_PATTERN.fullmatch(username): if not USERNAME_PATTERN.fullmatch(username):
return "Username must be 3-20 characters and only use letters, numbers, dots, dashes, or underscores." return "Username must be 3-20 characters and only use letters, numbers, dots, dashes, or underscores."
return None return None
def validate_password(password): def validate_password(password):
"""Return an error message if the password length is outside the allowed range."""
if len(password) < MIN_PASSWORD_LENGTH: if len(password) < MIN_PASSWORD_LENGTH:
return f"Password must be at least {MIN_PASSWORD_LENGTH} characters long." return f"Password must be at least {MIN_PASSWORD_LENGTH} characters long."
@@ -66,6 +81,7 @@ def validate_password(password):
def get_request_ip(): def get_request_ip():
"""Determine the client IP address, preferring a reverse-proxy header when present."""
forwarded_for = request.headers.get("X-Forwarded-For", "") forwarded_for = request.headers.get("X-Forwarded-For", "")
if forwarded_for: if forwarded_for:
@@ -75,6 +91,11 @@ def get_request_ip():
def login_attempt_buckets(username): def login_attempt_buckets(username):
"""Return the lockout buckets tracked for this login attempt.
The app tracks failures both per username+IP and per IP-only bucket to
slow down brute-force attempts against many usernames from the same client.
"""
normalized_username = normalize_username(username).lower() or "__empty__" normalized_username = normalize_username(username).lower() or "__empty__"
ip_address = get_request_ip() ip_address = get_request_ip()
return [ return [
@@ -84,6 +105,7 @@ def login_attempt_buckets(username):
def get_or_create_attempt(ip_address, bucket_key): def get_or_create_attempt(ip_address, bucket_key):
"""Fetch an existing login-attempt record or create a new one."""
attempt = LoginAttempt.query.filter_by(ip_address=ip_address, username=bucket_key).first() attempt = LoginAttempt.query.filter_by(ip_address=ip_address, username=bucket_key).first()
if attempt is None: if attempt is None:
@@ -94,12 +116,14 @@ def get_or_create_attempt(ip_address, bucket_key):
def clear_expired_lock(attempt, now): def clear_expired_lock(attempt, now):
"""Reset the failure counter when a temporary lockout has expired."""
if attempt.locked_until and attempt.locked_until <= now: if attempt.locked_until and attempt.locked_until <= now:
attempt.failed_attempts = 0 attempt.failed_attempts = 0
attempt.locked_until = None attempt.locked_until = None
def get_active_lock(username): def get_active_lock(username):
"""Return the latest active lockout time for this username/IP combination."""
now = utcnow() now = utcnow()
active_until = None active_until = None
@@ -119,6 +143,7 @@ def get_active_lock(username):
def record_failed_login(username): def record_failed_login(username):
"""Increase the failure counter and start a temporary lock when needed."""
now = utcnow() now = utcnow()
locked_until = None locked_until = None
@@ -137,6 +162,7 @@ def record_failed_login(username):
def clear_login_attempts(username): def clear_login_attempts(username):
"""Remove tracked failed attempts after a successful login."""
for ip_address, bucket_key in login_attempt_buckets(username): for ip_address, bucket_key in login_attempt_buckets(username):
attempt = LoginAttempt.query.filter_by(ip_address=ip_address, username=bucket_key).first() attempt = LoginAttempt.query.filter_by(ip_address=ip_address, username=bucket_key).first()
@@ -147,6 +173,7 @@ def clear_login_attempts(username):
def lookup_user_by_username(username): def lookup_user_by_username(username):
"""Look up a user case-insensitively so login is not case-sensitive."""
if not username: if not username:
return None return None
@@ -154,29 +181,35 @@ def lookup_user_by_username(username):
def minutes_until(target_time): def minutes_until(target_time):
"""Return the remaining lockout time in rounded-up whole minutes."""
seconds = max(1, int((target_time - utcnow()).total_seconds())) seconds = max(1, int((target_time - utcnow()).total_seconds()))
return max(1, (seconds + 59) // 60) return max(1, (seconds + 59) // 60)
def normalize_two_factor_secret(secret): def normalize_two_factor_secret(secret):
"""Normalize a Base32 secret by removing spaces and uppercasing it."""
return (secret or "").strip().replace(" ", "").upper() return (secret or "").strip().replace(" ", "").upper()
def generate_two_factor_secret(): def generate_two_factor_secret():
"""Generate a new random Base32 secret for authenticator apps."""
return base64.b32encode(secrets.token_bytes(20)).decode("ascii").rstrip("=") return base64.b32encode(secrets.token_bytes(20)).decode("ascii").rstrip("=")
def format_two_factor_secret(secret): def format_two_factor_secret(secret):
"""Insert spaces into the secret to make manual entry easier."""
normalized = normalize_two_factor_secret(secret) normalized = normalize_two_factor_secret(secret)
return " ".join(normalized[index:index + 4] for index in range(0, len(normalized), 4)) return " ".join(normalized[index:index + 4] for index in range(0, len(normalized), 4))
def clear_pending_two_factor(user=None): def clear_pending_two_factor(user=None):
"""Remove the session values used during the login 2FA verification step."""
session.pop("pending_2fa_user_id", None) session.pop("pending_2fa_user_id", None)
session.pop("pending_2fa_attempts", None) session.pop("pending_2fa_attempts", None)
def get_pending_two_factor_setup_secret(user): def get_pending_two_factor_setup_secret(user):
"""Return the temporary secret generated during 2FA setup for this user."""
if session.get("pending_2fa_setup_user_id") != user.id: if session.get("pending_2fa_setup_user_id") != user.id:
return None return None
@@ -185,11 +218,13 @@ def get_pending_two_factor_setup_secret(user):
def clear_pending_two_factor_setup(): def clear_pending_two_factor_setup():
"""Remove the session values used during authenticator setup."""
session.pop("pending_2fa_setup_user_id", None) session.pop("pending_2fa_setup_user_id", None)
session.pop("pending_2fa_setup_secret", None) session.pop("pending_2fa_setup_secret", None)
def build_totp_uri(user, secret): def build_totp_uri(user, secret):
"""Build the otpauth URI consumed by authenticator applications."""
normalized_secret = normalize_two_factor_secret(secret) normalized_secret = normalize_two_factor_secret(secret)
label = quote(f"{TWO_FACTOR_ISSUER}:{user.username}") label = quote(f"{TWO_FACTOR_ISSUER}:{user.username}")
issuer = quote(TWO_FACTOR_ISSUER) issuer = quote(TWO_FACTOR_ISSUER)
@@ -201,12 +236,14 @@ def build_totp_uri(user, secret):
def decode_two_factor_secret(secret): def decode_two_factor_secret(secret):
"""Decode the Base32 secret into raw bytes for HMAC processing."""
normalized_secret = normalize_two_factor_secret(secret) normalized_secret = normalize_two_factor_secret(secret)
padded_secret = normalized_secret + ("=" * (-len(normalized_secret) % 8)) padded_secret = normalized_secret + ("=" * (-len(normalized_secret) % 8))
return base64.b32decode(padded_secret, casefold=True) return base64.b32decode(padded_secret, casefold=True)
def generate_totp_code(secret, for_time=None): def generate_totp_code(secret, for_time=None):
"""Generate a time-based one-time password from the shared secret."""
timestamp = get_utc_timestamp(for_time) timestamp = get_utc_timestamp(for_time)
counter = timestamp // TWO_FACTOR_TIME_STEP_SECONDS counter = timestamp // TWO_FACTOR_TIME_STEP_SECONDS
key = decode_two_factor_secret(secret) key = decode_two_factor_secret(secret)
@@ -219,6 +256,7 @@ def generate_totp_code(secret, for_time=None):
def verify_totp_code(secret, code, at_time=None): def verify_totp_code(secret, code, at_time=None):
"""Validate a 2FA code, allowing a small time window for clock drift."""
normalized_code = (code or "").strip() normalized_code = (code or "").strip()
if not normalized_code.isdigit() or len(normalized_code) != TWO_FACTOR_CODE_DIGITS: if not normalized_code.isdigit() or len(normalized_code) != TWO_FACTOR_CODE_DIGITS:
@@ -239,6 +277,7 @@ def verify_totp_code(secret, code, at_time=None):
def get_pending_two_factor_user(): def get_pending_two_factor_user():
"""Load the user who is currently in the middle of the login 2FA step."""
user_id = session.get("pending_2fa_user_id") user_id = session.get("pending_2fa_user_id")
if not user_id: if not user_id:
@@ -248,6 +287,7 @@ def get_pending_two_factor_user():
def render_login_settings(error=None): def render_login_settings(error=None):
"""Render the login settings page with optional 2FA setup data."""
pending_secret = get_pending_two_factor_setup_secret(current_user) pending_secret = get_pending_two_factor_setup_secret(current_user)
formatted_secret = format_two_factor_secret(pending_secret) if pending_secret else None formatted_secret = format_two_factor_secret(pending_secret) if pending_secret else None
provisioning_uri = build_totp_uri(current_user, pending_secret) if pending_secret else None provisioning_uri = build_totp_uri(current_user, pending_secret) if pending_secret else None
@@ -262,6 +302,7 @@ def render_login_settings(error=None):
@login_bp.route("/register", methods=["GET", "POST"]) @login_bp.route("/register", methods=["GET", "POST"])
def register(): def register():
"""Create a new user account after validating the submitted credentials."""
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for("main.dashboard")) return redirect(url_for("main.dashboard"))
@@ -293,6 +334,7 @@ def register():
@login_bp.route("/login", methods=["GET", "POST"]) @login_bp.route("/login", methods=["GET", "POST"])
def login(): def login():
"""Authenticate a user and start the 2FA step when required."""
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for("main.dashboard")) return redirect(url_for("main.dashboard"))
@@ -303,6 +345,7 @@ def login():
if not username or not password: if not username or not password:
return render_template("login.html", error="Username and password are required.", username=username) return render_template("login.html", error="Username and password are required.", username=username)
# Stop early if this user or IP bucket is currently locked.
locked_until = get_active_lock(username) locked_until = get_active_lock(username)
if locked_until and locked_until > utcnow(): if locked_until and locked_until > utcnow():
@@ -313,6 +356,8 @@ def login():
) )
user = lookup_user_by_username(username) user = lookup_user_by_username(username)
# Use a dummy hash when the user does not exist so the timing stays
# similar and does not reveal valid usernames.
password_hash = user.password if user else DUMMY_PASSWORD_HASH password_hash = user.password if user else DUMMY_PASSWORD_HASH
password_matches = check_password_hash(password_hash, password) password_matches = check_password_hash(password_hash, password)
@@ -330,6 +375,7 @@ def login():
clear_login_attempts(username) clear_login_attempts(username)
if user.two_factor_enabled: if user.two_factor_enabled:
# Delay the full login until the authenticator code has been verified.
if not user.two_factor_secret: if not user.two_factor_secret:
return render_template( return render_template(
"login.html", "login.html",
@@ -353,6 +399,7 @@ def login():
@login_bp.route("/login/verify", methods=["GET", "POST"]) @login_bp.route("/login/verify", methods=["GET", "POST"])
def verify_two_factor(): def verify_two_factor():
"""Complete login by verifying the authenticator-app code."""
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for("main.dashboard")) return redirect(url_for("main.dashboard"))
@@ -373,6 +420,7 @@ def verify_two_factor():
attempts = int(session.get("pending_2fa_attempts", 0)) attempts = int(session.get("pending_2fa_attempts", 0))
# Limit repeated guesses during the second factor step as well.
if attempts >= MAX_TWO_FACTOR_ATTEMPTS: if attempts >= MAX_TWO_FACTOR_ATTEMPTS:
clear_pending_two_factor(user) clear_pending_two_factor(user)
db.session.commit() db.session.commit()
@@ -413,6 +461,7 @@ def verify_two_factor():
@login_bp.route("/login/settings", methods=["GET", "POST"]) @login_bp.route("/login/settings", methods=["GET", "POST"])
@login_required @login_required
def login_settings(): def login_settings():
"""Handle password changes and authenticator-based 2FA settings."""
if request.method == "POST": if request.method == "POST":
action = request.form.get("action") action = request.form.get("action")
@@ -420,6 +469,8 @@ def login_settings():
if current_user.two_factor_enabled: if current_user.two_factor_enabled:
return render_login_settings(error="Authenticator app 2FA is already enabled.") return render_login_settings(error="Authenticator app 2FA is already enabled.")
# Store the generated secret in the session until the user confirms
# it with a valid authenticator code.
secret = generate_two_factor_secret() secret = generate_two_factor_secret()
session["pending_2fa_setup_user_id"] = current_user.id session["pending_2fa_setup_user_id"] = current_user.id
session["pending_2fa_setup_secret"] = secret session["pending_2fa_setup_secret"] = secret
@@ -486,6 +537,7 @@ def login_settings():
@login_bp.route("/logout", methods=["POST"]) @login_bp.route("/logout", methods=["POST"])
@login_required @login_required
def logout(): def logout():
"""Log the user out and clear any unfinished authentication state."""
clear_pending_two_factor() clear_pending_two_factor()
clear_pending_two_factor_setup() clear_pending_two_factor_setup()
logout_user() logout_user()
+48 -1
View File
@@ -1,3 +1,10 @@
"""Main application routes for pages, puzzle solving, and save points.
This module contains the public pages, the dashboard, the board validation
logic for queens and rooks, solver endpoints, and the CRUD-style endpoints
used to store and restore saved games for a logged-in user.
"""
import json import json
import random import random
from datetime import datetime from datetime import datetime
@@ -12,10 +19,12 @@ DEFAULT_BOARD_SIZE = 8
MIN_BOARD_SIZE = 4 MIN_BOARD_SIZE = 4
MAX_BOARD_SIZE = 13 MAX_BOARD_SIZE = 13
# Blueprint that groups the main site and game-related routes.
main_bp = Blueprint("main", __name__) main_bp = Blueprint("main", __name__)
def parse_board_size(raw_size, default=DEFAULT_BOARD_SIZE): def parse_board_size(raw_size, default=DEFAULT_BOARD_SIZE):
"""Convert a user-supplied board size into a safe integer range."""
try: try:
board_size = int(raw_size) board_size = int(raw_size)
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -25,10 +34,12 @@ def parse_board_size(raw_size, default=DEFAULT_BOARD_SIZE):
def parse_favorite_flag(raw_value): def parse_favorite_flag(raw_value):
"""Interpret common truthy values from query parameters or JSON payloads."""
return str(raw_value).lower() in {"1", "true", "yes", "on"} return str(raw_value).lower() in {"1", "true", "yes", "on"}
def parse_non_negative_int(raw_value, default=0): def parse_non_negative_int(raw_value, default=0):
"""Parse an integer and clamp negative values to zero."""
try: try:
value = int(raw_value) value = int(raw_value)
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -38,14 +49,17 @@ def parse_non_negative_int(raw_value, default=0):
def get_position_label(row, col, board_size): def get_position_label(row, col, board_size):
"""Return a chess-style board label such as A8 or C5."""
return f"{chr(65 + col)}{board_size - row}" return f"{chr(65 + col)}{board_size - row}"
def solve_queens(board_size): def solve_queens(board_size):
"""Return every valid N-Queens solution for the requested board size."""
queen_positions = [-1] * board_size queen_positions = [-1] * board_size
queen_solutions = [] queen_solutions = []
def is_queen_position_safe(row, col): def is_queen_position_safe(row, col):
"""Check whether the new queen conflicts with any earlier queen."""
for previous_row in range(row): for previous_row in range(row):
previous_col = queen_positions[previous_row] previous_col = queen_positions[previous_row]
@@ -58,6 +72,7 @@ def solve_queens(board_size):
return True return True
def place_queen(row): def place_queen(row):
"""Backtracking search that fills the board row by row."""
if row == board_size: if row == board_size:
queen_solutions.append(queen_positions[:]) queen_solutions.append(queen_positions[:])
return return
@@ -73,16 +88,19 @@ def solve_queens(board_size):
def solve_rooks(board_size): def solve_rooks(board_size):
"""Return one valid rook arrangement with exactly one rook per row/column."""
rook_positions = list(range(board_size)) rook_positions = list(range(board_size))
random.shuffle(rook_positions) random.shuffle(rook_positions)
return rook_positions return rook_positions
def build_queens_trace(board_size): def build_queens_trace(board_size):
"""Build a step-by-step trace that explains the queens backtracking search."""
queen_positions = [-1] * board_size queen_positions = [-1] * board_size
steps = [] steps = []
def is_queen_position_safe(row, col): def is_queen_position_safe(row, col):
"""Reuse the same safety rules used by the queens solver."""
for previous_row in range(row): for previous_row in range(row):
previous_col = queen_positions[previous_row] previous_col = queen_positions[previous_row]
@@ -95,13 +113,14 @@ def build_queens_trace(board_size):
return True return True
def place_queen(row): def place_queen(row):
"""Record every placement and backtracking step until a solution is found."""
if row == board_size: if row == board_size:
steps.append({ steps.append({
"type": "solution", "type": "solution",
"row": None, "row": None,
"col": None, "col": None,
"board": queen_positions[:], "board": queen_positions[:],
"message": f"Loesung gefunden. Alle {board_size} Damen stehen ohne Konflikte." "message": f"Lösung gefunden. Alle {board_size} Damen stehen ohne Konflikte."
}) })
return True return True
@@ -111,6 +130,8 @@ def build_queens_trace(board_size):
queen_positions[row] = col queen_positions[row] = col
position = get_position_label(row, col, board_size) position = get_position_label(row, col, board_size)
# Store a snapshot of the current board so the frontend can replay
# the solving process visually.
steps.append({ steps.append({
"type": "place", "type": "place",
"row": row, "row": row,
@@ -145,6 +166,7 @@ def build_queens_trace(board_size):
def build_rooks_trace(board_size): def build_rooks_trace(board_size):
"""Build a simple placement trace for the rook mode."""
rook_positions = solve_rooks(board_size) rook_positions = solve_rooks(board_size)
board = [-1] * board_size board = [-1] * board_size
steps = [] steps = []
@@ -152,6 +174,8 @@ def build_rooks_trace(board_size):
for row, col in enumerate(rook_positions): for row, col in enumerate(rook_positions):
board[row] = col board[row] = col
position = get_position_label(row, col, board_size) position = get_position_label(row, col, board_size)
# As with the queens trace, each step includes a full board snapshot for
# straightforward frontend rendering.
steps.append({ steps.append({
"type": "place", "type": "place",
"row": row, "row": row,
@@ -178,6 +202,7 @@ def build_rooks_trace(board_size):
def serialize_game_state(game_state): def serialize_game_state(game_state):
"""Convert a database model into JSON-friendly data for the frontend."""
board = json.loads(game_state.board) board = json.loads(game_state.board)
return { return {
@@ -198,6 +223,7 @@ def serialize_game_state(game_state):
def build_save_point_query(): def build_save_point_query():
"""Create a filtered and sorted query for the current user's save points."""
query = GameState.query.filter_by(user_id=current_user.id) query = GameState.query.filter_by(user_id=current_user.id)
selected_mode = request.args.get("mode") selected_mode = request.args.get("mode")
@@ -205,6 +231,7 @@ def build_save_point_query():
favorites_only = request.args.get("favorites_only") favorites_only = request.args.get("favorites_only")
sort_by = request.args.get("sort", "newest") sort_by = request.args.get("sort", "newest")
# Apply optional filters only when the request contains a supported value.
if selected_mode in {"queens", "rooks"}: if selected_mode in {"queens", "rooks"}:
query = query.filter_by(mode=selected_mode) query = query.filter_by(mode=selected_mode)
@@ -226,6 +253,7 @@ def build_save_point_query():
@main_bp.route("/") @main_bp.route("/")
def home(): def home():
"""Render the public landing page and choose a matching default language."""
initial_language = request.accept_languages.best_match(["de", "en"]) or "de" initial_language = request.accept_languages.best_match(["de", "en"]) or "de"
return render_template("index.html", initial_language=initial_language) return render_template("index.html", initial_language=initial_language)
@@ -233,27 +261,33 @@ def home():
@main_bp.route("/dashboard") @main_bp.route("/dashboard")
@login_required @login_required
def dashboard(): def dashboard():
"""Render the logged-in dashboard."""
return render_template("dashboard.html", user=current_user) return render_template("dashboard.html", user=current_user)
@main_bp.route("/privacy_policy") @main_bp.route("/privacy_policy")
def privacy_policy(): def privacy_policy():
"""Render the privacy policy page."""
return render_template("privacy.html") return render_template("privacy.html")
@main_bp.route("/imprint") @main_bp.route("/imprint")
def imprint(): def imprint():
"""Render the imprint/legal notice page."""
return render_template("impressum.html") return render_template("impressum.html")
@main_bp.route("/check", methods=["POST"]) @main_bp.route("/check", methods=["POST"])
def check(): def check():
"""Validate whether a piece can be placed on the requested square."""
data = request.json data = request.json
board = data["board"] board = data["board"]
row = data["row"] row = data["row"]
col = data["col"] col = data["col"]
piece_mode = data.get("mode", "queens") piece_mode = data.get("mode", "queens")
# The board is stored as a list where the index is the row and the value is
# the column of the placed piece. -1 means the row is still empty.
for current_row, current_col in enumerate(board): for current_row, current_col in enumerate(board):
if current_col == -1 or current_row == row: if current_col == -1 or current_row == row:
continue continue
@@ -264,6 +298,7 @@ def check():
if same_column: if same_column:
return jsonify({"valid": False}) return jsonify({"valid": False})
# Diagonal conflicts only matter for queens, not for rooks.
if piece_mode == "queens" and same_diagonal: if piece_mode == "queens" and same_diagonal:
return jsonify({"valid": False}) return jsonify({"valid": False})
@@ -272,24 +307,28 @@ def check():
@main_bp.route("/solve") @main_bp.route("/solve")
def solve_queens_route(): def solve_queens_route():
"""Return all queens solutions for the requested board size."""
board_size = parse_board_size(request.args.get("size")) board_size = parse_board_size(request.args.get("size"))
return jsonify(solve_queens(board_size)) return jsonify(solve_queens(board_size))
@main_bp.route("/solve_trace") @main_bp.route("/solve_trace")
def solve_queens_trace_route(): def solve_queens_trace_route():
"""Return a step-by-step queens solving trace."""
board_size = parse_board_size(request.args.get("size")) board_size = parse_board_size(request.args.get("size"))
return jsonify(build_queens_trace(board_size)) return jsonify(build_queens_trace(board_size))
@main_bp.route("/solve_rooks") @main_bp.route("/solve_rooks")
def solve_rooks_route(): def solve_rooks_route():
"""Return one rook solution for the requested board size."""
board_size = parse_board_size(request.args.get("size")) board_size = parse_board_size(request.args.get("size"))
return jsonify(solve_rooks(board_size)) return jsonify(solve_rooks(board_size))
@main_bp.route("/solve_rooks_trace") @main_bp.route("/solve_rooks_trace")
def solve_rooks_trace_route(): def solve_rooks_trace_route():
"""Return a step-by-step rook placement trace."""
board_size = parse_board_size(request.args.get("size")) board_size = parse_board_size(request.args.get("size"))
return jsonify(build_rooks_trace(board_size)) return jsonify(build_rooks_trace(board_size))
@@ -297,6 +336,7 @@ def solve_rooks_trace_route():
@main_bp.route("/save", methods=["POST"]) @main_bp.route("/save", methods=["POST"])
@login_required @login_required
def save_game(): def save_game():
"""Store the current board state as a new save point for the user."""
data = request.json data = request.json
board = data["board"] board = data["board"]
@@ -310,6 +350,7 @@ def save_game():
is_solved = bool(data.get("isSolved")) is_solved = bool(data.get("isSolved"))
timestamp = datetime.utcnow() timestamp = datetime.utcnow()
# Save the board as JSON text so it can be restored exactly as the user left it.
game_state = GameState( game_state = GameState(
user_id=current_user.id, user_id=current_user.id,
board=json.dumps(board), board=json.dumps(board),
@@ -337,6 +378,7 @@ def save_game():
@main_bp.route("/save_points") @main_bp.route("/save_points")
@login_required @login_required
def list_save_points(): def list_save_points():
"""Return all save points for the current user after filtering and sorting."""
save_points = build_save_point_query().all() save_points = build_save_point_query().all()
return jsonify([serialize_game_state(game_state) for game_state in save_points]) return jsonify([serialize_game_state(game_state) for game_state in save_points])
@@ -344,6 +386,7 @@ def list_save_points():
@main_bp.route("/save_points/<int:game_id>/favorite", methods=["POST"]) @main_bp.route("/save_points/<int:game_id>/favorite", methods=["POST"])
@login_required @login_required
def toggle_save_point_favorite(game_id): def toggle_save_point_favorite(game_id):
"""Toggle the favorite flag for one saved game."""
game_state = GameState.query.filter_by(id=game_id, user_id=current_user.id).first() game_state = GameState.query.filter_by(id=game_id, user_id=current_user.id).first()
if not game_state: if not game_state:
@@ -358,6 +401,7 @@ def toggle_save_point_favorite(game_id):
@main_bp.route("/save_points/<int:game_id>", methods=["DELETE"]) @main_bp.route("/save_points/<int:game_id>", methods=["DELETE"])
@login_required @login_required
def delete_save_point(game_id): def delete_save_point(game_id):
"""Delete one save point owned by the current user."""
game_state = GameState.query.filter_by(id=game_id, user_id=current_user.id).first() game_state = GameState.query.filter_by(id=game_id, user_id=current_user.id).first()
if not game_state: if not game_state:
@@ -372,12 +416,14 @@ def delete_save_point(game_id):
@main_bp.route("/load") @main_bp.route("/load")
@login_required @login_required
def load_game(): def load_game():
"""Load the newest save point for the current user."""
latest_game = ( latest_game = (
GameState.query.filter_by(user_id=current_user.id) GameState.query.filter_by(user_id=current_user.id)
.order_by(GameState.created_at.desc(), GameState.id.desc()) .order_by(GameState.created_at.desc(), GameState.id.desc())
.first() .first()
) )
# If the user has never saved a game, return a clean default board.
if not latest_game: if not latest_game:
return jsonify({"board": [-1] * DEFAULT_BOARD_SIZE, "mode": "queens"}) return jsonify({"board": [-1] * DEFAULT_BOARD_SIZE, "mode": "queens"})
@@ -387,6 +433,7 @@ def load_game():
@main_bp.route("/load/<int:game_id>") @main_bp.route("/load/<int:game_id>")
@login_required @login_required
def load_specific_game(game_id): def load_specific_game(game_id):
"""Load a specific save point owned by the current user."""
game_state = GameState.query.filter_by(id=game_id, user_id=current_user.id).first() game_state = GameState.query.filter_by(id=game_id, user_id=current_user.id).first()
if not game_state: if not game_state:
+1 -1
View File
@@ -236,7 +236,7 @@ const translations = {
favoriteAddButton: "Als Favorit", favoriteAddButton: "Als Favorit",
favoriteRemoveButton: "Favorit entfernen", favoriteRemoveButton: "Favorit entfernen",
deleteButton: "Löschen", deleteButton: "Löschen",
celebrationTitle: "Richtig geloöst!", celebrationTitle: "Richtig gelöst!",
celebrationText: ({ modeLabel, duration }) => `Stark gemacht. Deine ${modeLabel.toLowerCase()}-Lösung ist konfliktfrei in ${duration}.`, celebrationText: ({ modeLabel, duration }) => `Stark gemacht. Deine ${modeLabel.toLowerCase()}-Lösung ist konfliktfrei in ${duration}.`,
settingsApplied: ({ size }) => `Brettgrösse auf ${size} x ${size} gesetzt.`, settingsApplied: ({ size }) => `Brettgrösse auf ${size} x ${size} gesetzt.`,
modeActivated: ({ piece }) => `${piece}-Modus aktiviert.`, modeActivated: ({ piece }) => `${piece}-Modus aktiviert.`,
+47 -32
View File
@@ -24,8 +24,8 @@
<button class="nav-link-button lang-switch" id="langSwitch" type="button" <button class="nav-link-button lang-switch" id="langSwitch" type="button"
aria-label="Sprache wechseln">DE</button> aria-label="Sprache wechseln">DE</button>
<a class="account-shortcut" href="{{ url_for('login.login_settings') }}" data-i18n-aria-label="accountShortcutAria" <a class="account-shortcut" href="{{ url_for('login.login_settings') }}"
aria-label="Konto und Login-Einstellungen öffnen"> data-i18n-aria-label="accountShortcutAria" aria-label="Konto und Login-Einstellungen öffnen">
<span class="account-avatar" aria-hidden="true">{{ current_user.username[:1]|upper }}</span> <span class="account-avatar" aria-hidden="true">{{ current_user.username[:1]|upper }}</span>
<span class="account-copy"> <span class="account-copy">
<span class="account-label" id="accountLabel" data-i18n="accountLabel">Account</span> <span class="account-label" id="accountLabel" data-i18n="accountLabel">Account</span>
@@ -121,13 +121,14 @@
<div class="board" id="board"></div> <div class="board" id="board"></div>
<div class="controls"> <div class="controls">
<button class="btn secondary" onclick="resetBoard()" id="btnReset" type="button" data-i18n="btnReset">Brett <button class="btn secondary" onclick="resetBoard()" id="btnReset" type="button"
zuruecksetzen</button> data-i18n="btnReset">Brett
zurücksetzen</button>
<div class="controls-save-row"> <div class="controls-save-row">
<button class="btn" onclick="openSavePanel()" id="btnSave" type="button" <button class="btn" onclick="openSavePanel()" id="btnSave" type="button"
data-i18n="btnSave">Speichern</button> data-i18n="btnSave">Speichern</button>
<button class="btn secondary" onclick="openSavePointsPanel()" id="btnLoad" <button class="btn secondary" onclick="openSavePointsPanel()" id="btnLoad" type="button"
type="button" data-i18n="btnLoad">Save-Points</button> data-i18n="btnLoad">Save-Points</button>
</div> </div>
</div> </div>
@@ -151,17 +152,17 @@
</p> </p>
<div class="solver-controls"> <div class="solver-controls">
<button class="btn" onclick="prepareSolutionTrace()" id="btnPrepareSolve" <button class="btn" onclick="prepareSolutionTrace()" id="btnPrepareSolve" type="button"
type="button" data-i18n="btnPrepareSolve">Animation vorbereiten</button> data-i18n="btnPrepareSolve">Animation vorbereiten</button>
<button class="btn secondary" onclick="nextSolverStep()" id="btnNextStep" <button class="btn secondary" onclick="nextSolverStep()" id="btnNextStep" type="button"
type="button" data-i18n="btnNextStep">Nächster Schritt</button> data-i18n="btnNextStep">Nächster Schritt</button>
<button class="btn secondary" onclick="toggleSolverPlayback()" id="btnPlayPause" <button class="btn secondary" onclick="toggleSolverPlayback()" id="btnPlayPause"
type="button" data-i18n="btnPlayPause">Animation abspielen</button> type="button" data-i18n="btnPlayPause">Animation abspielen</button>
</div> </div>
<div class="solver-speed"> <div class="solver-speed">
<label class="solver-speed-label" id="solverSpeedLabel" <label class="solver-speed-label" id="solverSpeedLabel" for="solverSpeedInput"
for="solverSpeedInput" data-i18n="solverSpeedLabel">Animationsgeschwindigkeit</label> data-i18n="solverSpeedLabel">Animationsgeschwindigkeit</label>
<input id="solverSpeedInput" type="range" min="80" max="900" step="20" value="320" <input id="solverSpeedInput" type="range" min="80" max="900" step="20" value="320"
oninput="updateSolverSpeed(this.value)"> oninput="updateSolverSpeed(this.value)">
<span class="solver-speed-value" id="solverSpeedValue">320 ms</span> <span class="solver-speed-value" id="solverSpeedValue">320 ms</span>
@@ -175,8 +176,8 @@
</p> </p>
<div class="instant-solver-actions"> <div class="instant-solver-actions">
<button class="btn accent" onclick="instantSolve()" id="btnInstantSolve" <button class="btn accent" onclick="instantSolve()" id="btnInstantSolve" type="button"
type="button" data-i18n="btnInstantSolve">Sofort lösen</button> data-i18n="btnInstantSolve">Sofort lösen</button>
</div> </div>
</section> </section>
</div> </div>
@@ -196,12 +197,13 @@
<div> <div>
<p class="section-kicker" id="historyKicker" data-i18n="historyKicker">Save-Points</p> <p class="section-kicker" id="historyKicker" data-i18n="historyKicker">Save-Points</p>
<h3 id="historyTitle" data-i18n="historyTitle">Gespeicherte Zeiten und Fortschritte</h3> <h3 id="historyTitle" data-i18n="historyTitle">Gespeicherte Zeiten und Fortschritte</h3>
<p class="history-subtitle" id="historySubtitle" data-i18n="historySubtitle">Die Tabelle zeigt gespeicherte Save-Points mit <p class="history-subtitle" id="historySubtitle" data-i18n="historySubtitle">Die Tabelle zeigt
gespeicherte Save-Points mit
Zeit, Schritten und Zeit, Schritten und
Status.</p> Status.</p>
</div> </div>
<button class="btn secondary" onclick="refreshTimeHistory()" id="btnRefreshHistory" <button class="btn secondary" onclick="refreshTimeHistory()" id="btnRefreshHistory" type="button"
type="button" data-i18n="btnRefreshHistory">Aktualisieren</button> data-i18n="btnRefreshHistory">Aktualisieren</button>
</div> </div>
<div class="history-table-wrapper"> <div class="history-table-wrapper">
@@ -219,7 +221,8 @@
</thead> </thead>
<tbody id="timeHistoryTableBody"> <tbody id="timeHistoryTableBody">
<tr> <tr>
<td colspan="7" class="history-empty" data-i18n="historyEmpty">Noch keine gespeicherten Zeiten vorhanden.</td> <td colspan="7" class="history-empty" data-i18n="historyEmpty">Noch keine gespeicherten
Zeiten vorhanden.</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -238,14 +241,17 @@
<p id="settingsSidebarText" data-i18n="settingsSidebarText">Passe die Brettgrösse zwischen 4 und 13 an.</p> <p id="settingsSidebarText" data-i18n="settingsSidebarText">Passe die Brettgrösse zwischen 4 und 13 an.</p>
<label class="sidebar-label" id="boardSizeInputLabel" for="boardSizeInput" data-i18n="boardSizeInputLabel">Grösse</label> <label class="sidebar-label" id="boardSizeInputLabel" for="boardSizeInput"
data-i18n="boardSizeInputLabel">Grösse</label>
<input id="boardSizeInput" type="range" min="4" max="13" value="8" oninput="previewBoardSize(this.value)"> <input id="boardSizeInput" type="range" min="4" max="13" value="8" oninput="previewBoardSize(this.value)">
<div class="sidebar-size-row"> <div class="sidebar-size-row">
<strong><span id="sidebarBoardSizeValue">8</span> x <span id="sidebarBoardSizeValueMirror">8</span></strong> <strong><span id="sidebarBoardSizeValue">8</span> x <span id="sidebarBoardSizeValueMirror">8</span></strong>
</div> </div>
<button class="btn" onclick="applyBoardSize()" id="applyBoardSizeButton" type="button" data-i18n="applyBoardSizeButton">Grösse anwenden</button> <button class="btn" onclick="applyBoardSize()" id="applyBoardSizeButton" type="button"
<p class="sidebar-hint" id="settingsSidebarHint" data-i18n="settingsSidebarHint">Beim Ändern wird das Brett zur Sicherheit zurückgesetzt. Auf data-i18n="applyBoardSizeButton">Grösse anwenden</button>
<p class="sidebar-hint" id="settingsSidebarHint" data-i18n="settingsSidebarHint">Beim Ändern wird das Brett zur
Sicherheit zurückgesetzt. Auf
dem Handy ist 8x8 eingeplant, grössere Bretter können abgeschnitten wirken.</p> dem Handy ist 8x8 eingeplant, grössere Bretter können abgeschnitten wirken.</p>
</aside> </aside>
@@ -256,7 +262,8 @@
type="button" data-i18n="saveSidebarClose">Schliessen</button> type="button" data-i18n="saveSidebarClose">Schliessen</button>
</div> </div>
<p id="saveSidebarText" data-i18n="saveSidebarText">Gib dem Spielstand einen klaren Namen und optional eine kurze Notiz.</p> <p id="saveSidebarText" data-i18n="saveSidebarText">Gib dem Spielstand einen klaren Namen und optional eine
kurze Notiz.</p>
<label class="sidebar-label" id="saveNameLabel" for="saveNameInput" data-i18n="saveNameLabel">Name</label> <label class="sidebar-label" id="saveNameLabel" for="saveNameInput" data-i18n="saveNameLabel">Name</label>
<input class="sidebar-input" id="saveNameInput" type="text" maxlength="80" <input class="sidebar-input" id="saveNameInput" type="text" maxlength="80"
@@ -271,7 +278,8 @@
<span id="saveFavoriteLabel" data-i18n="saveFavoriteLabel">Als Favorit markieren</span> <span id="saveFavoriteLabel" data-i18n="saveFavoriteLabel">Als Favorit markieren</span>
</label> </label>
<button class="btn" onclick="saveGame()" id="saveNowButton" type="button" data-i18n="saveNowButton">Jetzt speichern</button> <button class="btn" onclick="saveGame()" id="saveNowButton" type="button" data-i18n="saveNowButton">Jetzt
speichern</button>
</aside> </aside>
<aside class="sidebar save-points-sidebar" id="savePointsSidebar" aria-hidden="true"> <aside class="sidebar save-points-sidebar" id="savePointsSidebar" aria-hidden="true">
@@ -284,14 +292,16 @@
<p id="savePointsSidebarText" data-i18n="savePointsSidebarText">Wähle einen Speicherstand aus der Liste.</p> <p id="savePointsSidebarText" data-i18n="savePointsSidebarText">Wähle einen Speicherstand aus der Liste.</p>
<div class="save-point-filters"> <div class="save-point-filters">
<label class="sidebar-label" id="savePointModeFilterLabel" for="savePointModeFilter" data-i18n="savePointModeFilterLabel">Modus</label> <label class="sidebar-label" id="savePointModeFilterLabel" for="savePointModeFilter"
data-i18n="savePointModeFilterLabel">Modus</label>
<select class="sidebar-input" id="savePointModeFilter" onchange="refreshSavePoints()"> <select class="sidebar-input" id="savePointModeFilter" onchange="refreshSavePoints()">
<option value="all" data-i18n="filterAll">Alle</option> <option value="all" data-i18n="filterAll">Alle</option>
<option value="queens" data-i18n="filterQueens">Damen</option> <option value="queens" data-i18n="filterQueens">Damen</option>
<option value="rooks" data-i18n="filterRooks">Türme</option> <option value="rooks" data-i18n="filterRooks">Türme</option>
</select> </select>
<label class="sidebar-label" id="savePointSizeFilterLabel" for="savePointSizeFilter" data-i18n="savePointSizeFilterLabel">Brettgrösse</label> <label class="sidebar-label" id="savePointSizeFilterLabel" for="savePointSizeFilter"
data-i18n="savePointSizeFilterLabel">Brettgrösse</label>
<select class="sidebar-input" id="savePointSizeFilter" onchange="refreshSavePoints()"> <select class="sidebar-input" id="savePointSizeFilter" onchange="refreshSavePoints()">
<option value="all" data-i18n="filterAll">Alle</option> <option value="all" data-i18n="filterAll">Alle</option>
{% for board_size in range(4, 14) %} {% for board_size in range(4, 14) %}
@@ -299,7 +309,8 @@
{% endfor %} {% endfor %}
</select> </select>
<label class="sidebar-label" id="savePointSortFilterLabel" for="savePointSortFilter" data-i18n="savePointSortFilterLabel">Sortierung</label> <label class="sidebar-label" id="savePointSortFilterLabel" for="savePointSortFilter"
data-i18n="savePointSortFilterLabel">Sortierung</label>
<select class="sidebar-input" id="savePointSortFilter" onchange="refreshSavePoints()"> <select class="sidebar-input" id="savePointSortFilter" onchange="refreshSavePoints()">
<option value="newest" data-i18n="filterNewest">Neueste zuerst</option> <option value="newest" data-i18n="filterNewest">Neueste zuerst</option>
<option value="oldest" data-i18n="filterOldest">älteste zuerst</option> <option value="oldest" data-i18n="filterOldest">älteste zuerst</option>
@@ -309,12 +320,14 @@
<label class="sidebar-checkbox"> <label class="sidebar-checkbox">
<input id="savePointFavoritesOnly" type="checkbox" onchange="refreshSavePoints()"> <input id="savePointFavoritesOnly" type="checkbox" onchange="refreshSavePoints()">
<span id="savePointFavoritesOnlyLabel" data-i18n="savePointFavoritesOnlyLabel">Nur Favoriten anzeigen</span> <span id="savePointFavoritesOnlyLabel" data-i18n="savePointFavoritesOnlyLabel">Nur Favoriten
anzeigen</span>
</label> </label>
</div> </div>
<div class="save-points-list" id="savePointsList"> <div class="save-points-list" id="savePointsList">
<p class="sidebar-hint" id="savePointsEmptyState" data-i18n="savePointsEmptyState">Noch keine Speicherstände gefunden.</p> <p class="sidebar-hint" id="savePointsEmptyState" data-i18n="savePointsEmptyState">Noch keine Speicherstände
gefunden.</p>
</div> </div>
</aside> </aside>
@@ -326,12 +339,13 @@
<section class="modal" id="infoModal" aria-hidden="true" aria-labelledby="infoModalTitle"> <section class="modal" id="infoModal" aria-hidden="true" aria-labelledby="infoModalTitle">
<div class="modal-header"> <div class="modal-header">
<h3 id="infoModalTitle">Spielregeln und Acht-Damen-Problem</h3> <h3 id="infoModalTitle">Spielregeln und Acht-Damen-Problem</h3>
<button class="btn secondary sidebar-close" onclick="toggleInfoModal(false)" <button class="btn secondary sidebar-close" onclick="toggleInfoModal(false)" type="button"
type="button" data-i18n="infoModalClose">Schliessen</button> data-i18n="infoModalClose">Schliessen</button>
</div> </div>
<div class="modal-content"> <div class="modal-content">
<p data-i18n="infoModalIntro">Setze pro Reihe genau eine Figur auf das Brett. Eine gültige Lösung hat keine Konflikte.</p> <p data-i18n="infoModalIntro">Setze pro Reihe genau eine Figur auf das Brett. Eine gültige Lösung hat keine
Konflikte.</p>
<h4 data-i18n="infoModalModeHeading">So funktioniert der aktuelle Modus</h4> <h4 data-i18n="infoModalModeHeading">So funktioniert der aktuelle Modus</h4>
<p id="infoModeRuleText">Im Damen-Modus dürfen sich Damen weder in derselben Spalte noch diagonal <p id="infoModeRuleText">Im Damen-Modus dürfen sich Damen weder in derselben Spalte noch diagonal
@@ -343,7 +357,8 @@
kombinatorische Suche und Backtracking.</p> kombinatorische Suche und Backtracking.</p>
<h4 data-i18n="infoModalTimeHeading">Was zeigen Zeit und Schritte?</h4> <h4 data-i18n="infoModalTimeHeading">Was zeigen Zeit und Schritte?</h4>
<p data-i18n="infoModalTimeText">Der Schrittzähler erfasst deine Züge auf dem Brett. Die Zeit läuft für das aktuelle Spiel weiter, <p data-i18n="infoModalTimeText">Der Schrittzähler erfasst deine Züge auf dem Brett. Die Zeit läuft für das
aktuelle Spiel weiter,
auch wenn du die Seite neu lädst. Beim Speichern werden Zeit und Schritte zusammen mit dem auch wenn du die Seite neu lädst. Beim Speichern werden Zeit und Schritte zusammen mit dem
Save-Point abgelegt.</p> Save-Point abgelegt.</p>
</div> </div>
+7 -7
View File
@@ -56,7 +56,7 @@
</section> </section>
<section class="legal-card"> <section class="legal-card">
<h2 data-i18n="processingPurposeTitle">3. Zweck der Verarbeitung</h2> <h2 data-i18n="processingPurposeTitle">2. Zweck der Verarbeitung</h2>
<p data-i18n="processingPurposeText">Die Verarbeitung erfolgt, um die Funktionen dieser Anwendung <p data-i18n="processingPurposeText">Die Verarbeitung erfolgt, um die Funktionen dieser Anwendung
bereitzustellen. Dazu gehoeren insbesondere: bereitzustellen. Dazu gehoeren insbesondere:
</p> </p>
@@ -70,7 +70,7 @@
</section> </section>
<section class="legal-card"> <section class="legal-card">
<h2 data-i18n="legalBasisTitle">4. Rechtsgrundlage</h2> <h2 data-i18n="legalBasisTitle">3. Rechtsgrundlage</h2>
<p data-i18n="legalBasisText"> <p data-i18n="legalBasisText">
Soweit personenbezogene Daten verarbeitet werden, erfolgt dies in der Regel zur Bereitstellung der Soweit personenbezogene Daten verarbeitet werden, erfolgt dies in der Regel zur Bereitstellung der
Anwendung und ihrer Funktionen sowie aufgrund des berechtigten Interesses an einem sicheren und Anwendung und ihrer Funktionen sowie aufgrund des berechtigten Interesses an einem sicheren und
@@ -80,7 +80,7 @@
</section> </section>
<section class="legal-card"> <section class="legal-card">
<h2 data-i18n="storageTitle">5. Speicherung und Löschung</h2> <h2 data-i18n="storageTitle">4. Speicherung und Löschung</h2>
<p data-i18n="storageText"> <p data-i18n="storageText">
Personenbezogene Daten werden nur so lange gespeichert, wie sie für den jeweiligen Zweck benötigt Personenbezogene Daten werden nur so lange gespeichert, wie sie für den jeweiligen Zweck benötigt
werden. Benutzerkonten und zugehörige Spielstände bleiben grundsätzlich gespeichert, bis sie werden. Benutzerkonten und zugehörige Spielstände bleiben grundsätzlich gespeichert, bis sie
@@ -89,7 +89,7 @@
</section> </section>
<section class="legal-card"> <section class="legal-card">
<h2 data-i18n="thirdPartyTitle">6. Weitergabe an Dritte</h2> <h2 data-i18n="thirdPartyTitle">5. Weitergabe an Dritte</h2>
<p data-i18n="thirdPartyText"> <p data-i18n="thirdPartyText">
Eine Weitergabe personenbezogener Daten erfolgt nur, wenn dies für den Betrieb der Anwendung Eine Weitergabe personenbezogener Daten erfolgt nur, wenn dies für den Betrieb der Anwendung
erforderlich ist, etwa durch einen Hosting-Anbieter oder technische Dienstleister. Ansonsten werden erforderlich ist, etwa durch einen Hosting-Anbieter oder technische Dienstleister. Ansonsten werden
@@ -98,7 +98,7 @@
</section> </section>
<section class="legal-card"> <section class="legal-card">
<h2 data-i18n="rightsTitle">7. Rechte betroffener Personen</h2> <h2 data-i18n="rightsTitle">6. Rechte betroffener Personen</h2>
<p data-i18n="rightsText">Betroffene Personen haben im Rahmen des anwendbaren Datenschutzrechts insbesondere <p data-i18n="rightsText">Betroffene Personen haben im Rahmen des anwendbaren Datenschutzrechts insbesondere
das Recht auf:</p> das Recht auf:</p>
<ul class="legal-list"> <ul class="legal-list">
@@ -111,7 +111,7 @@
</section> </section>
<section class="legal-card"> <section class="legal-card">
<h2 data-i18n="securityTitle">8. Datensicherheit</h2> <h2 data-i18n="securityTitle">7. Datensicherheit</h2>
<p data-i18n="securityText"> <p data-i18n="securityText">
Es werden technische und organisatorische Massnahmen getroffen, um gespeicherte Daten vor Verlust, Es werden technische und organisatorische Massnahmen getroffen, um gespeicherte Daten vor Verlust,
Missbrauch oder unberechtigtem Zugriff zu schuetzen. Dazu gehoeren insbesondere passwortgeschuetzte Missbrauch oder unberechtigtem Zugriff zu schuetzen. Dazu gehoeren insbesondere passwortgeschuetzte
@@ -120,7 +120,7 @@
</section> </section>
<section class="legal-card"> <section class="legal-card">
<h2 data-i18n="versionTitle">9. Stand</h2> <h2 data-i18n="versionTitle">8. Stand</h2>
<p data-i18n="versionText">Stand dieser Vorlage: April 2026</p> <p data-i18n="versionText">Stand dieser Vorlage: April 2026</p>
</section> </section>
</main> </main>