Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d182dc1c7 | |||
| 62bd333dc4 | |||
| 65a60356dd | |||
| 047ac6e239 | |||
| e57aa37623 | |||
| eb78c22c17 | |||
| bc81820d9d | |||
| a32f2d0c6e | |||
| bcf18812eb | |||
| 7f66fa3ef4 | |||
| a4dc91e3ae | |||
| 7d208f5552 | |||
| 045d4bf575 | |||
| 1beb1ce630 | |||
| 26e4d0dd27 | |||
| fc94d812d2 | |||
| b27c0ebcac | |||
| d94c4dd4d6 |
@@ -1,12 +1,13 @@
|
|||||||
# acht-damen-tuerme-problem
|
# acht-damen-tuerme-problem
|
||||||
IDPA Arbeit im23a Kristian Anastasia und Jérôme
|
IDPA Arbeit im23a Kristian Anastasia und Jérôme
|
||||||
|
|
||||||
|
**Link to the Website**: https://bachmannj.ch/idpa/
|
||||||
|
|
||||||
```
|
```
|
||||||
project-root/
|
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 +23,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 +38,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
|
||||||
```
|
```
|
||||||
+27
-1
@@ -1,18 +1,34 @@
|
|||||||
|
"""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(config_overrides=None):
|
||||||
|
"""Build and configure the Flask application instance.
|
||||||
|
|
||||||
|
Optional configuration overrides make it possible to run the same app
|
||||||
|
logic against an isolated test database without changing production code.
|
||||||
|
"""
|
||||||
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 +36,32 @@ 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"
|
||||||
|
|
||||||
|
# Tests can inject their own database and runtime settings here so each run
|
||||||
|
# stays isolated from the real application data.
|
||||||
|
if config_overrides:
|
||||||
|
app.config.update(config_overrides)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
|
"""Database models for persisted puzzle save points."""
|
||||||
|
|
||||||
from .. import db
|
from .. import db
|
||||||
|
|
||||||
|
|
||||||
class GameState(db.Model):
|
class GameState(db.Model):
|
||||||
|
"""Persist one saved puzzle session for a specific user."""
|
||||||
|
|
||||||
__tablename__ = "game_state"
|
__tablename__ = "game_state"
|
||||||
|
|
||||||
|
# These identifiers connect a save point to one concrete database row and
|
||||||
|
# to the user who owns it, which is necessary so save points stay private.
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
user_id = db.Column(db.Integer, nullable=False)
|
user_id = db.Column(db.Integer, nullable=False)
|
||||||
|
|
||||||
|
# The remaining fields describe the board snapshot and the surrounding
|
||||||
|
# progress information, because a meaningful restore needs more than just
|
||||||
|
# piece coordinates.
|
||||||
board = db.Column(db.Text, nullable=False)
|
board = db.Column(db.Text, nullable=False)
|
||||||
mode = db.Column(db.String(10), nullable=False)
|
mode = db.Column(db.String(10), nullable=False)
|
||||||
board_size = db.Column(db.Integer, nullable=False, default=8)
|
board_size = db.Column(db.Integer, nullable=False, default=8)
|
||||||
@@ -16,6 +25,9 @@ class GameState(db.Model):
|
|||||||
step_count = db.Column(db.Integer, nullable=False, default=0, server_default=db.text("0"))
|
step_count = db.Column(db.Integer, nullable=False, default=0, server_default=db.text("0"))
|
||||||
elapsed_seconds = db.Column(db.Integer, nullable=False, default=0, server_default=db.text("0"))
|
elapsed_seconds = db.Column(db.Integer, nullable=False, default=0, server_default=db.text("0"))
|
||||||
is_solved = db.Column(db.Boolean, nullable=False, default=False, server_default=db.text("0"))
|
is_solved = db.Column(db.Boolean, nullable=False, default=False, server_default=db.text("0"))
|
||||||
|
|
||||||
|
# Timestamps make it possible to sort, filter, and explain save points in
|
||||||
|
# the UI without asking the user to remember when each run was created.
|
||||||
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
|
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
|
||||||
updated_at = db.Column(
|
updated_at = db.Column(
|
||||||
db.DateTime,
|
db.DateTime,
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
|
"""Database model for temporary login-throttling information."""
|
||||||
|
|
||||||
from .. import db
|
from .. import db
|
||||||
|
|
||||||
|
|
||||||
class LoginAttempt(db.Model):
|
class LoginAttempt(db.Model):
|
||||||
|
"""Track failed login attempts per IP address."""
|
||||||
|
|
||||||
__tablename__ = "login_attempts"
|
__tablename__ = "login_attempts"
|
||||||
|
|
||||||
|
# The unique IP address is the central lookup key because the lockout
|
||||||
|
# policy is applied to the origin of failed requests, not to one account.
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
ip_address = db.Column(db.String(45), nullable=False)
|
ip_address = db.Column(db.String(45), unique=True, nullable=False)
|
||||||
username = db.Column(db.String(80), nullable=False)
|
|
||||||
|
# These values explain how far an address is into the lock flow and when
|
||||||
|
# the application may safely allow another login attempt.
|
||||||
failed_attempts = db.Column(db.Integer, nullable=False, default=0, server_default=db.text("0"))
|
failed_attempts = db.Column(db.Integer, nullable=False, default=0, server_default=db.text("0"))
|
||||||
locked_until = db.Column(db.DateTime)
|
locked_until = db.Column(db.DateTime)
|
||||||
last_failed_at = db.Column(db.DateTime)
|
last_failed_at = db.Column(db.DateTime)
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
db.UniqueConstraint("ip_address", "username", name="uq_login_attempt_ip_username"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<LoginAttempt {self.ip_address}:{self.username}>"
|
"""Return a compact identifier for debugging and logging.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return f"<LoginAttempt {self.ip_address}>"
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
|
"""Database model for application user accounts."""
|
||||||
|
|
||||||
from .. import db
|
from .. import db
|
||||||
from flask_login import UserMixin
|
from flask_login import UserMixin
|
||||||
|
|
||||||
|
|
||||||
class User(UserMixin, db.Model):
|
class User(UserMixin, db.Model):
|
||||||
|
"""Represent one authenticated account in the application."""
|
||||||
|
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
# These core identity fields are the minimum needed to create and safely
|
||||||
|
# authenticate an account.
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
username = db.Column(db.String(20), unique=True, nullable=False)
|
username = db.Column(db.String(20), unique=True, nullable=False)
|
||||||
password = db.Column(db.String(255), nullable=False)
|
password = db.Column(db.String(255), nullable=False)
|
||||||
|
|
||||||
|
# The metadata and 2FA fields support account history and stronger account
|
||||||
|
# protection without changing the basic login flow.
|
||||||
created_at = db.Column(db.DateTime, server_default=db.func.now())
|
created_at = db.Column(db.DateTime, server_default=db.func.now())
|
||||||
last_login = db.Column(db.DateTime)
|
last_login = db.Column(db.DateTime)
|
||||||
two_factor_enabled = db.Column(db.Boolean, nullable=False, default=False, server_default=db.text("0"))
|
two_factor_enabled = db.Column(db.Boolean, nullable=False, default=False, server_default=db.text("0"))
|
||||||
two_factor_secret = db.Column(db.String(64))
|
two_factor_secret = db.Column(db.String(64))
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
|
"""Return a readable representation for debugging output.
|
||||||
|
"""
|
||||||
|
|
||||||
return f"<User {self.username}>"
|
return f"<User {self.username}>"
|
||||||
|
|||||||
+119
-79
@@ -1,3 +1,5 @@
|
|||||||
|
"""Authentication routes and security helpers for account access."""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -16,12 +18,15 @@ 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
|
||||||
|
|
||||||
|
# The blueprint keeps all account-related routes in one security-focused module.
|
||||||
login_bp = Blueprint("login", __name__)
|
login_bp = Blueprint("login", __name__)
|
||||||
|
|
||||||
|
# Central limits are defined once so registration, login, and settings all
|
||||||
|
# enforce the same rules and do not drift apart over time.
|
||||||
MIN_PASSWORD_LENGTH = 8
|
MIN_PASSWORD_LENGTH = 8
|
||||||
MAX_PASSWORD_LENGTH = 128
|
MAX_PASSWORD_LENGTH = 128
|
||||||
MAX_LOGIN_FAILURES = 5
|
MAX_LOGIN_FAILURES = 4
|
||||||
LOGIN_LOCK_MINUTES = 15
|
LOGIN_LOCK_MINUTES = 30
|
||||||
TWO_FACTOR_CODE_DIGITS = 6
|
TWO_FACTOR_CODE_DIGITS = 6
|
||||||
TWO_FACTOR_TIME_STEP_SECONDS = 30
|
TWO_FACTOR_TIME_STEP_SECONDS = 30
|
||||||
TWO_FACTOR_VALID_WINDOW = 1
|
TWO_FACTOR_VALID_WINDOW = 1
|
||||||
@@ -31,11 +36,16 @@ USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_.-]{3,20}$")
|
|||||||
DUMMY_PASSWORD_HASH = generate_password_hash("dummy-password-for-timing-protection")
|
DUMMY_PASSWORD_HASH = generate_password_hash("dummy-password-for-timing-protection")
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# General helper functions
|
||||||
|
|
||||||
def utcnow():
|
def utcnow():
|
||||||
|
"""Return the current UTC time for security-related comparisons."""
|
||||||
return datetime.utcnow()
|
return datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
def get_utc_timestamp(moment=None):
|
def get_utc_timestamp(moment=None):
|
||||||
|
"""Convert a datetime into UTC seconds for time-based calculations."""
|
||||||
if moment is None:
|
if moment is None:
|
||||||
return int(datetime.now(timezone.utc).timestamp())
|
return int(datetime.now(timezone.utc).timestamp())
|
||||||
|
|
||||||
@@ -46,16 +56,50 @@ def get_utc_timestamp(moment=None):
|
|||||||
|
|
||||||
|
|
||||||
def normalize_username(username):
|
def normalize_username(username):
|
||||||
|
"""Normalize usernames so harmless whitespace does not affect login or signup."""
|
||||||
return (username or "").strip()
|
return (username or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_user_by_username(username):
|
||||||
|
"""Find usernames case-insensitively to keep login user-friendly."""
|
||||||
|
if not username:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return User.query.filter(db.func.lower(User.username) == username.lower()).first()
|
||||||
|
|
||||||
|
|
||||||
|
def minutes_until(target_time):
|
||||||
|
"""Round remaining lock time up so the feedback stays conservative."""
|
||||||
|
seconds = max(1, int((target_time - utcnow()).total_seconds()))
|
||||||
|
return max(1, (seconds + 59) // 60)
|
||||||
|
|
||||||
|
|
||||||
|
def render_login_settings(error=None):
|
||||||
|
"""Render login settings with the current 2FA setup state included."""
|
||||||
|
pending_secret = get_pending_two_factor_setup_secret(current_user)
|
||||||
|
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
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"login_settings.html",
|
||||||
|
error=error,
|
||||||
|
pending_two_factor_secret=formatted_secret,
|
||||||
|
pending_two_factor_uri=provisioning_uri,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 1. Password enforcement
|
||||||
|
|
||||||
def validate_username(username):
|
def validate_username(username):
|
||||||
|
"""Reject usernames that would not fit the project's account policy."""
|
||||||
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):
|
||||||
|
"""Reject passwords that fall outside the shared account policy."""
|
||||||
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."
|
||||||
|
|
||||||
@@ -65,7 +109,11 @@ def validate_password(password):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 2. Anti brute force script
|
||||||
|
|
||||||
def get_request_ip():
|
def get_request_ip():
|
||||||
|
"""Identify the request source so repeated failed attempts can be throttled."""
|
||||||
forwarded_for = request.headers.get("X-Forwarded-For", "")
|
forwarded_for = request.headers.get("X-Forwarded-For", "")
|
||||||
|
|
||||||
if forwarded_for:
|
if forwarded_for:
|
||||||
@@ -74,109 +122,90 @@ def get_request_ip():
|
|||||||
return (request.remote_addr or "unknown")[:45]
|
return (request.remote_addr or "unknown")[:45]
|
||||||
|
|
||||||
|
|
||||||
def login_attempt_buckets(username):
|
def get_or_create_login_attempt():
|
||||||
normalized_username = normalize_username(username).lower() or "__empty__"
|
"""Persist failed-attempt state so throttling survives across requests."""
|
||||||
ip_address = get_request_ip()
|
ip_address = get_request_ip()
|
||||||
return [
|
attempt = LoginAttempt.query.filter_by(ip_address=ip_address).first()
|
||||||
(ip_address, normalized_username),
|
|
||||||
(ip_address, "__ip__"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_attempt(ip_address, bucket_key):
|
|
||||||
attempt = LoginAttempt.query.filter_by(ip_address=ip_address, username=bucket_key).first()
|
|
||||||
|
|
||||||
if attempt is None:
|
if attempt is None:
|
||||||
attempt = LoginAttempt(ip_address=ip_address, username=bucket_key)
|
attempt = LoginAttempt(ip_address=ip_address)
|
||||||
db.session.add(attempt)
|
db.session.add(attempt)
|
||||||
|
|
||||||
return attempt
|
return attempt
|
||||||
|
|
||||||
|
|
||||||
def clear_expired_lock(attempt, now):
|
def clear_expired_lock(attempt, now):
|
||||||
|
"""Remove stale lockouts so temporary blocks do not outlive their purpose."""
|
||||||
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():
|
||||||
|
"""Check whether the current IP is still inside a lockout window."""
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
active_until = None
|
attempt = LoginAttempt.query.filter_by(ip_address=get_request_ip()).first()
|
||||||
|
|
||||||
for ip_address, bucket_key in login_attempt_buckets(username):
|
if not attempt:
|
||||||
attempt = LoginAttempt.query.filter_by(ip_address=ip_address, username=bucket_key).first()
|
|
||||||
|
|
||||||
if not attempt:
|
|
||||||
continue
|
|
||||||
|
|
||||||
clear_expired_lock(attempt, now)
|
|
||||||
|
|
||||||
if attempt.locked_until and (active_until is None or attempt.locked_until > active_until):
|
|
||||||
active_until = attempt.locked_until
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
return active_until
|
|
||||||
|
|
||||||
|
|
||||||
def record_failed_login(username):
|
|
||||||
now = utcnow()
|
|
||||||
locked_until = None
|
|
||||||
|
|
||||||
for ip_address, bucket_key in login_attempt_buckets(username):
|
|
||||||
attempt = get_or_create_attempt(ip_address, bucket_key)
|
|
||||||
clear_expired_lock(attempt, now)
|
|
||||||
attempt.failed_attempts = (attempt.failed_attempts or 0) + 1
|
|
||||||
attempt.last_failed_at = now
|
|
||||||
|
|
||||||
if attempt.failed_attempts >= MAX_LOGIN_FAILURES:
|
|
||||||
attempt.locked_until = now + timedelta(minutes=LOGIN_LOCK_MINUTES)
|
|
||||||
locked_until = attempt.locked_until
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
return locked_until
|
|
||||||
|
|
||||||
|
|
||||||
def clear_login_attempts(username):
|
|
||||||
for ip_address, bucket_key in login_attempt_buckets(username):
|
|
||||||
attempt = LoginAttempt.query.filter_by(ip_address=ip_address, username=bucket_key).first()
|
|
||||||
|
|
||||||
if attempt:
|
|
||||||
db.session.delete(attempt)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def lookup_user_by_username(username):
|
|
||||||
if not username:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return User.query.filter(db.func.lower(User.username) == username.lower()).first()
|
clear_expired_lock(attempt, now)
|
||||||
|
db.session.commit()
|
||||||
|
return attempt.locked_until
|
||||||
|
|
||||||
|
|
||||||
def minutes_until(target_time):
|
def record_failed_login():
|
||||||
seconds = max(1, int((target_time - utcnow()).total_seconds()))
|
"""Increase the failure counter so repeated guessing becomes expensive."""
|
||||||
return max(1, (seconds + 59) // 60)
|
now = utcnow()
|
||||||
|
attempt = get_or_create_login_attempt()
|
||||||
|
clear_expired_lock(attempt, now)
|
||||||
|
attempt.failed_attempts = (attempt.failed_attempts or 0) + 1
|
||||||
|
attempt.last_failed_at = now
|
||||||
|
|
||||||
|
if attempt.failed_attempts >= MAX_LOGIN_FAILURES:
|
||||||
|
attempt.locked_until = now + timedelta(minutes=LOGIN_LOCK_MINUTES)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return attempt.locked_until
|
||||||
|
|
||||||
|
|
||||||
|
def clear_login_attempts():
|
||||||
|
"""Reset the throttle state after a legitimate login succeeds."""
|
||||||
|
attempt = LoginAttempt.query.filter_by(ip_address=get_request_ip()).first()
|
||||||
|
|
||||||
|
if attempt:
|
||||||
|
db.session.delete(attempt)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 3. 2-factor authentication
|
||||||
|
|
||||||
def normalize_two_factor_secret(secret):
|
def normalize_two_factor_secret(secret):
|
||||||
|
"""Normalize shared secrets so formatting differences do not break validation."""
|
||||||
return (secret or "").strip().replace(" ", "").upper()
|
return (secret or "").strip().replace(" ", "").upper()
|
||||||
|
|
||||||
|
|
||||||
def generate_two_factor_secret():
|
def generate_two_factor_secret():
|
||||||
|
"""Generate a fresh secret so every account has its own 2FA seed."""
|
||||||
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):
|
||||||
|
"""Group the secret for humans because setup may happen by manual typing."""
|
||||||
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):
|
||||||
|
"""Clear unfinished login verification state from the session."""
|
||||||
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 only the setup secret that belongs to the current account."""
|
||||||
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 +214,13 @@ def get_pending_two_factor_setup_secret(user):
|
|||||||
|
|
||||||
|
|
||||||
def clear_pending_two_factor_setup():
|
def clear_pending_two_factor_setup():
|
||||||
|
"""Remove unfinished authenticator setup state from the session."""
|
||||||
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):
|
||||||
|
"""Create the otpauth URI so authenticator apps can import the account."""
|
||||||
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 +232,14 @@ def build_totp_uri(user, secret):
|
|||||||
|
|
||||||
|
|
||||||
def decode_two_factor_secret(secret):
|
def decode_two_factor_secret(secret):
|
||||||
|
"""Decode the stored Base32 secret into raw bytes for code generation."""
|
||||||
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 code locally so the app can verify authenticator input itself."""
|
||||||
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 +252,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):
|
||||||
|
"""Allow a small time drift so valid users are not locked out unnecessarily."""
|
||||||
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 +273,7 @@ def verify_totp_code(secret, code, at_time=None):
|
|||||||
|
|
||||||
|
|
||||||
def get_pending_two_factor_user():
|
def get_pending_two_factor_user():
|
||||||
|
"""Load the account that is currently waiting for login verification."""
|
||||||
user_id = session.get("pending_2fa_user_id")
|
user_id = session.get("pending_2fa_user_id")
|
||||||
|
|
||||||
if not user_id:
|
if not user_id:
|
||||||
@@ -247,21 +282,12 @@ def get_pending_two_factor_user():
|
|||||||
return db.session.get(User, user_id)
|
return db.session.get(User, user_id)
|
||||||
|
|
||||||
|
|
||||||
def render_login_settings(error=None):
|
# -----------------------------------------------------------------------------
|
||||||
pending_secret = get_pending_two_factor_setup_secret(current_user)
|
# Routes
|
||||||
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
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
"login_settings.html",
|
|
||||||
error=error,
|
|
||||||
pending_two_factor_secret=formatted_secret,
|
|
||||||
pending_two_factor_uri=provisioning_uri,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@login_bp.route("/register", methods=["GET", "POST"])
|
@login_bp.route("/register", methods=["GET", "POST"])
|
||||||
def register():
|
def register():
|
||||||
|
"""Create a user account after validating the configured account rules."""
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for("main.dashboard"))
|
return redirect(url_for("main.dashboard"))
|
||||||
|
|
||||||
@@ -281,6 +307,7 @@ def register():
|
|||||||
if lookup_user_by_username(username):
|
if lookup_user_by_username(username):
|
||||||
return render_template("sign_up.html", error="Username already taken!", username=username)
|
return render_template("sign_up.html", error="Username already taken!", username=username)
|
||||||
|
|
||||||
|
# Passwords are stored hashed so a leaked database does not reveal them directly.
|
||||||
new_user = User(username=username, password=generate_password_hash(password))
|
new_user = User(username=username, password=generate_password_hash(password))
|
||||||
db.session.add(new_user)
|
db.session.add(new_user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -293,6 +320,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 branch into 2FA only 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,7 +331,8 @@ 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)
|
||||||
|
|
||||||
locked_until = get_active_lock(username)
|
# Locked IPs are rejected early so the expensive password check is not repeated.
|
||||||
|
locked_until = get_active_lock()
|
||||||
|
|
||||||
if locked_until and locked_until > utcnow():
|
if locked_until and locked_until > utcnow():
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -313,11 +342,13 @@ def login():
|
|||||||
)
|
)
|
||||||
|
|
||||||
user = lookup_user_by_username(username)
|
user = lookup_user_by_username(username)
|
||||||
|
|
||||||
|
# A dummy hash keeps timing similar even when the username does not exist.
|
||||||
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)
|
||||||
|
|
||||||
if not user or not password_matches:
|
if not user or not password_matches:
|
||||||
locked_until = record_failed_login(username)
|
locked_until = record_failed_login()
|
||||||
error_message = "Invalid username or password."
|
error_message = "Invalid username or password."
|
||||||
|
|
||||||
if locked_until:
|
if locked_until:
|
||||||
@@ -327,9 +358,10 @@ def login():
|
|||||||
|
|
||||||
return render_template("login.html", error=error_message, username=username)
|
return render_template("login.html", error=error_message, username=username)
|
||||||
|
|
||||||
clear_login_attempts(username)
|
clear_login_attempts()
|
||||||
|
|
||||||
if user.two_factor_enabled:
|
if user.two_factor_enabled:
|
||||||
|
# The actual login is delayed until the second factor proves account ownership.
|
||||||
if not user.two_factor_secret:
|
if not user.two_factor_secret:
|
||||||
return render_template(
|
return render_template(
|
||||||
"login.html",
|
"login.html",
|
||||||
@@ -346,6 +378,7 @@ def login():
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
login_user(user)
|
login_user(user)
|
||||||
clear_pending_two_factor()
|
clear_pending_two_factor()
|
||||||
|
session["start_dashboard_with_default_mode"] = True
|
||||||
return redirect(url_for("main.dashboard"))
|
return redirect(url_for("main.dashboard"))
|
||||||
|
|
||||||
return render_template("login.html")
|
return render_template("login.html")
|
||||||
@@ -353,6 +386,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():
|
||||||
|
"""Finish login by validating 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 +407,7 @@ def verify_two_factor():
|
|||||||
|
|
||||||
attempts = int(session.get("pending_2fa_attempts", 0))
|
attempts = int(session.get("pending_2fa_attempts", 0))
|
||||||
|
|
||||||
|
# The second factor gets its own retry limit so guessing stays impractical.
|
||||||
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()
|
||||||
@@ -405,6 +440,7 @@ def verify_two_factor():
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
login_user(user)
|
login_user(user)
|
||||||
flash("Two-factor authentication successful.", "success")
|
flash("Two-factor authentication successful.", "success")
|
||||||
|
session["start_dashboard_with_default_mode"] = True
|
||||||
return redirect(url_for("main.dashboard"))
|
return redirect(url_for("main.dashboard"))
|
||||||
|
|
||||||
return render_template("two_factor_verify.html")
|
return render_template("two_factor_verify.html")
|
||||||
@@ -413,6 +449,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 +457,7 @@ 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.")
|
||||||
|
|
||||||
|
# The secret stays temporary until the user proves the app was configured correctly.
|
||||||
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
|
||||||
@@ -462,6 +500,7 @@ def login_settings():
|
|||||||
new_password = request.form.get("new_password") or ""
|
new_password = request.form.get("new_password") or ""
|
||||||
confirm_password = request.form.get("confirm_password") or ""
|
confirm_password = request.form.get("confirm_password") or ""
|
||||||
|
|
||||||
|
# The old password is checked first so nobody can change credentials from a stolen session alone.
|
||||||
if not check_password_hash(current_user.password, current_password):
|
if not check_password_hash(current_user.password, current_password):
|
||||||
return render_login_settings(error="Your current password is incorrect.")
|
return render_login_settings(error="Your current password is incorrect.")
|
||||||
|
|
||||||
@@ -486,6 +525,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 unfinished security state."""
|
||||||
clear_pending_two_factor()
|
clear_pending_two_factor()
|
||||||
clear_pending_two_factor_setup()
|
clear_pending_two_factor_setup()
|
||||||
logout_user()
|
logout_user()
|
||||||
|
|||||||
+152
-20
@@ -1,8 +1,15 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, render_template, request
|
from flask import Blueprint, jsonify, render_template, request, session
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
|
|
||||||
from .. import db
|
from .. import db
|
||||||
@@ -12,10 +19,62 @@ 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 create_empty_board(board_size):
|
||||||
|
"""Create a square board matrix with no placed pieces."""
|
||||||
|
return [[0 for _ in range(board_size)] for _ in range(board_size)]
|
||||||
|
|
||||||
|
|
||||||
|
def build_board_from_positions(piece_positions, board_size):
|
||||||
|
"""Convert the legacy one-piece-per-row format into a board matrix."""
|
||||||
|
board = create_empty_board(board_size)
|
||||||
|
|
||||||
|
# Older save points may only store one column per row, so they are rebuilt
|
||||||
|
# here to keep the rest of the code on one consistent board format.
|
||||||
|
for row, col in enumerate(piece_positions):
|
||||||
|
if isinstance(col, int) and 0 <= col < board_size:
|
||||||
|
board[row][col] = 1
|
||||||
|
|
||||||
|
return board
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_board(candidate_board, board_size):
|
||||||
|
"""Normalize stored boards so legacy and matrix formats can both be used."""
|
||||||
|
if not isinstance(candidate_board, list) or len(candidate_board) != board_size:
|
||||||
|
return create_empty_board(board_size)
|
||||||
|
|
||||||
|
if all(isinstance(row, list) and len(row) == board_size for row in candidate_board):
|
||||||
|
normalized_board = create_empty_board(board_size)
|
||||||
|
|
||||||
|
# Values are normalized to 0 or 1 so saved data stays tolerant toward
|
||||||
|
# older or slightly different truthy formats.
|
||||||
|
for row_index, row in enumerate(candidate_board):
|
||||||
|
for col_index, value in enumerate(row):
|
||||||
|
normalized_board[row_index][col_index] = 1 if value else 0
|
||||||
|
|
||||||
|
return normalized_board
|
||||||
|
|
||||||
|
return build_board_from_positions(candidate_board, board_size)
|
||||||
|
|
||||||
|
|
||||||
|
def iter_piece_positions(board):
|
||||||
|
"""Yield every occupied square from the normalized board matrix."""
|
||||||
|
for row_index, row in enumerate(board):
|
||||||
|
for col_index, value in enumerate(row):
|
||||||
|
if value:
|
||||||
|
yield row_index, col_index
|
||||||
|
|
||||||
|
|
||||||
|
def count_placed_pieces(board):
|
||||||
|
"""Count how many pieces are currently placed on the board."""
|
||||||
|
return sum(1 for _ in iter_piece_positions(board))
|
||||||
|
|
||||||
|
|
||||||
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 +84,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 +99,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,7 +122,9 @@ 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:
|
||||||
|
# A copy is stored because the same list is reused while backtracking.
|
||||||
queen_solutions.append(queen_positions[:])
|
queen_solutions.append(queen_positions[:])
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -73,16 +139,22 @@ 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))
|
||||||
|
|
||||||
|
# Randomizing the order keeps rook mode from always returning the same
|
||||||
|
# visually predictable arrangement.
|
||||||
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,27 +167,33 @@ 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": build_board_from_positions(queen_positions, board_size),
|
||||||
"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
|
||||||
|
|
||||||
for col in range(board_size):
|
candidate_columns = list(range(board_size))
|
||||||
|
random.shuffle(candidate_columns)
|
||||||
|
|
||||||
|
for col in candidate_columns:
|
||||||
if not is_queen_position_safe(row, col):
|
if not is_queen_position_safe(row, col):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
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,
|
||||||
"col": col,
|
"col": col,
|
||||||
"board": queen_positions[:],
|
"board": build_board_from_positions(queen_positions, board_size),
|
||||||
"message": f"Dame auf {position} gesetzt."
|
"message": f"Dame auf {position} gesetzt."
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -127,7 +205,7 @@ def build_queens_trace(board_size):
|
|||||||
"type": "remove",
|
"type": "remove",
|
||||||
"row": row,
|
"row": row,
|
||||||
"col": col,
|
"col": col,
|
||||||
"board": queen_positions[:],
|
"board": build_board_from_positions(queen_positions, board_size),
|
||||||
"message": f"Backtracking: Dame von {position} entfernt."
|
"message": f"Backtracking: Dame von {position} entfernt."
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -138,25 +216,28 @@ def build_queens_trace(board_size):
|
|||||||
return {
|
return {
|
||||||
"mode": "queens",
|
"mode": "queens",
|
||||||
"board_size": board_size,
|
"board_size": board_size,
|
||||||
"initial_board": [-1] * board_size,
|
"initial_board": create_empty_board(board_size),
|
||||||
"steps": steps,
|
"steps": steps,
|
||||||
"solved": solved
|
"solved": solved
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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 = create_empty_board(board_size)
|
||||||
steps = []
|
steps = []
|
||||||
|
|
||||||
for row, col in enumerate(rook_positions):
|
for row, col in enumerate(rook_positions):
|
||||||
board[row] = col
|
board[row][col] = 1
|
||||||
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,
|
||||||
"col": col,
|
"col": col,
|
||||||
"board": board[:],
|
"board": [board_row[:] for board_row in board],
|
||||||
"message": f"Turm auf {position} gesetzt."
|
"message": f"Turm auf {position} gesetzt."
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -164,22 +245,25 @@ def build_rooks_trace(board_size):
|
|||||||
"type": "solution",
|
"type": "solution",
|
||||||
"row": None,
|
"row": None,
|
||||||
"col": None,
|
"col": None,
|
||||||
"board": board[:],
|
"board": [board_row[:] for board_row in board],
|
||||||
"message": f"Loesung gefunden. Alle {board_size} Tuerme stehen ohne Konflikte."
|
"message": f"Loesung gefunden. Alle {board_size} Tuerme stehen ohne Konflikte."
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"mode": "rooks",
|
"mode": "rooks",
|
||||||
"board_size": board_size,
|
"board_size": board_size,
|
||||||
"initial_board": [-1] * board_size,
|
"initial_board": create_empty_board(board_size),
|
||||||
"steps": steps,
|
"steps": steps,
|
||||||
"solved": True
|
"solved": True
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def serialize_game_state(game_state):
|
def serialize_game_state(game_state):
|
||||||
board = json.loads(game_state.board)
|
"""Convert a database model into JSON-friendly data for the frontend."""
|
||||||
|
board = normalize_board(json.loads(game_state.board), game_state.board_size)
|
||||||
|
|
||||||
|
# The frontend receives ready-to-render metadata here so it does not need
|
||||||
|
# to re-derive common values from raw database fields.
|
||||||
return {
|
return {
|
||||||
"id": game_state.id,
|
"id": game_state.id,
|
||||||
"board": board,
|
"board": board,
|
||||||
@@ -188,7 +272,7 @@ def serialize_game_state(game_state):
|
|||||||
"save_name": game_state.save_name,
|
"save_name": game_state.save_name,
|
||||||
"save_note": game_state.save_note,
|
"save_note": game_state.save_note,
|
||||||
"is_favorite": game_state.is_favorite,
|
"is_favorite": game_state.is_favorite,
|
||||||
"pieces_placed": sum(1 for column in board if column != -1),
|
"pieces_placed": count_placed_pieces(board),
|
||||||
"step_count": game_state.step_count,
|
"step_count": game_state.step_count,
|
||||||
"elapsed_seconds": game_state.elapsed_seconds,
|
"elapsed_seconds": game_state.elapsed_seconds,
|
||||||
"is_solved": game_state.is_solved,
|
"is_solved": game_state.is_solved,
|
||||||
@@ -198,6 +282,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 +290,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)
|
||||||
|
|
||||||
@@ -221,11 +307,13 @@ def build_save_point_query():
|
|||||||
"favorites": GameState.is_favorite.desc()
|
"favorites": GameState.is_favorite.desc()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Sorting is centralized here so list views and reloads stay consistent.
|
||||||
return query.order_by(sort_options.get(sort_by, GameState.created_at.desc()), GameState.id.desc())
|
return query.order_by(sort_options.get(sort_by, GameState.created_at.desc()), GameState.id.desc())
|
||||||
|
|
||||||
|
|
||||||
@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 +321,54 @@ def home():
|
|||||||
@main_bp.route("/dashboard")
|
@main_bp.route("/dashboard")
|
||||||
@login_required
|
@login_required
|
||||||
def dashboard():
|
def dashboard():
|
||||||
return render_template("dashboard.html", user=current_user)
|
"""Render the logged-in dashboard."""
|
||||||
|
|
||||||
|
# The flag is consumed once so only the immediate next dashboard load starts
|
||||||
|
# from the default state after a fresh login.
|
||||||
|
force_default_dashboard_mode = bool(session.pop("start_dashboard_with_default_mode", False))
|
||||||
|
return render_template(
|
||||||
|
"dashboard.html",
|
||||||
|
user=current_user,
|
||||||
|
force_default_dashboard_mode=force_default_dashboard_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/privacy_policy")
|
||||||
|
def privacy_policy():
|
||||||
|
"""Render the privacy policy page."""
|
||||||
|
return render_template("privacy.html")
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/imprint")
|
||||||
|
def imprint():
|
||||||
|
"""Render the imprint/legal notice page."""
|
||||||
|
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")
|
||||||
|
normalized_board = normalize_board(board, len(board))
|
||||||
|
|
||||||
for current_row, current_col in enumerate(board):
|
for current_row, current_col in iter_piece_positions(normalized_board):
|
||||||
if current_col == -1 or current_row == row:
|
if current_row == row and current_col == col:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# The same conflict rules are shared between manual placement and the
|
||||||
|
# solver so the UI behaves consistently with the backend.
|
||||||
|
same_row = current_row == row
|
||||||
same_column = current_col == col
|
same_column = current_col == col
|
||||||
same_diagonal = abs(current_col - col) == abs(current_row - row)
|
same_diagonal = abs(current_col - col) == abs(current_row - row)
|
||||||
|
|
||||||
if same_column:
|
if same_row or 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})
|
||||||
|
|
||||||
@@ -262,24 +377,33 @@ def check():
|
|||||||
|
|
||||||
@main_bp.route("/solve")
|
@main_bp.route("/solve")
|
||||||
def solve_queens_route():
|
def solve_queens_route():
|
||||||
|
"""Return one random queens 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_queens(board_size))
|
queen_solutions = solve_queens(board_size)
|
||||||
|
|
||||||
|
if not queen_solutions:
|
||||||
|
return jsonify([])
|
||||||
|
|
||||||
|
return jsonify(random.choice(queen_solutions))
|
||||||
|
|
||||||
|
|
||||||
@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))
|
||||||
|
|
||||||
@@ -287,6 +411,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"]
|
||||||
@@ -300,6 +425,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),
|
||||||
@@ -327,6 +453,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])
|
||||||
|
|
||||||
@@ -334,6 +461,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:
|
||||||
@@ -348,6 +476,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:
|
||||||
@@ -362,14 +491,16 @@ 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()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Returning a default board keeps the dashboard usable even before the first save.
|
||||||
if not latest_game:
|
if not latest_game:
|
||||||
return jsonify({"board": [-1] * DEFAULT_BOARD_SIZE, "mode": "queens"})
|
return jsonify({"board": create_empty_board(DEFAULT_BOARD_SIZE), "mode": "queens"})
|
||||||
|
|
||||||
return jsonify(serialize_game_state(latest_game))
|
return jsonify(serialize_game_state(latest_game))
|
||||||
|
|
||||||
@@ -377,6 +508,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:
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ textarea {
|
|||||||
|
|
||||||
.lang-switch {
|
.lang-switch {
|
||||||
min-width: 68px;
|
min-width: 68px;
|
||||||
|
border-color: rgba(231, 191, 119, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link:hover,
|
.nav-link:hover,
|
||||||
@@ -482,9 +483,17 @@ textarea {
|
|||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board {
|
.board-stage {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
justify-items: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-shell {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-grid {
|
.board-grid {
|
||||||
@@ -543,30 +552,30 @@ textarea {
|
|||||||
background: #d65555 !important;
|
background: #d65555 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.solver-panel {
|
|
||||||
margin-top: 18px;
|
|
||||||
padding: 18px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid rgba(31, 49, 64, 0.1);
|
|
||||||
background: rgba(255, 250, 242, 0.74);
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.solver-panel-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.solver-panel h3 {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.solver-message {
|
.solver-message {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.board-navigator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
width: min(100%, 620px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-nav-button {
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.solver-message-inline {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 760px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
.solver-step-info {
|
.solver-step-info {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
@@ -578,63 +587,31 @@ textarea {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.solver-groups {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1.35fr) minmax(260px, 0.65fr);
|
|
||||||
gap: 16px;
|
|
||||||
margin-top: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.solver-group {
|
|
||||||
padding: 18px;
|
|
||||||
border: 1px solid rgba(31, 49, 64, 0.1);
|
|
||||||
background: rgba(255, 255, 255, 0.58);
|
|
||||||
}
|
|
||||||
|
|
||||||
.solver-group h4 {
|
|
||||||
margin: 0 0 8px;
|
|
||||||
color: #182029;
|
|
||||||
font-size: 1.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.solver-group-text {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.solver-controls {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instant-solver-actions {
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instant-solver-actions .btn {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.solver-speed {
|
.solver-speed {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
grid-template-areas:
|
||||||
|
"label label"
|
||||||
|
"input value";
|
||||||
|
align-items: end;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.solver-speed input[type="range"] {
|
.solver-speed input[type="range"] {
|
||||||
|
grid-area: input;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.solver-speed-label {
|
.solver-speed-label {
|
||||||
|
grid-area: label;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--navy);
|
color: var(--navy);
|
||||||
}
|
}
|
||||||
|
|
||||||
.solver-speed-value {
|
.solver-speed-value {
|
||||||
|
grid-area: value;
|
||||||
min-width: 62px;
|
min-width: 62px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -649,19 +626,6 @@ textarea {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-save-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
width: min(420px, 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-sidebar-actions {
|
|
||||||
margin-top: 18px;
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-header,
|
.section-header,
|
||||||
.history-header,
|
.history-header,
|
||||||
.sidebar-header,
|
.sidebar-header,
|
||||||
@@ -785,6 +749,34 @@ textarea {
|
|||||||
width: min(400px, 94vw);
|
width: min(400px, 94vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
margin-top: 22px;
|
||||||
|
padding-top: 22px;
|
||||||
|
border-top: 1px solid rgba(31, 49, 64, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section:first-of-type {
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: #182029;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section .section-kicker {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-action-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-label {
|
.sidebar-label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
@@ -979,14 +971,6 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.solver-groups {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.solver-controls {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-card-header,
|
.game-card-header,
|
||||||
.history-header,
|
.history-header,
|
||||||
.section-header {
|
.section-header {
|
||||||
@@ -1039,7 +1023,7 @@ textarea {
|
|||||||
padding-right: 8px;
|
padding-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board {
|
.board-shell {
|
||||||
margin-left: -4px;
|
margin-left: -4px;
|
||||||
margin-right: -4px;
|
margin-right: -4px;
|
||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
@@ -1060,6 +1044,10 @@ textarea {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.board-navigator {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.mode-switch .btn {
|
.mode-switch .btn {
|
||||||
min-width: min(100%, 280px);
|
min-width: min(100%, 280px);
|
||||||
}
|
}
|
||||||
@@ -1069,14 +1057,11 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.controls>.btn,
|
.controls>.btn,
|
||||||
|
.board-nav-button,
|
||||||
.save-point-actions .btn {
|
.save-point-actions .btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-save-row {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: min(100%, calc(100vw - 24px));
|
width: min(100%, calc(100vw - 24px));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
:root {
|
||||||
|
--footer-surface: rgba(18, 33, 45, 0.94);
|
||||||
|
--footer-line: rgba(231, 191, 119, 0.28);
|
||||||
|
--footer-text: #f7f1e6;
|
||||||
|
--footer-muted: rgba(247, 241, 230, 0.72);
|
||||||
|
--footer-link-bg: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
body > main,
|
||||||
|
body > .container {
|
||||||
|
flex: 1 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
body > main.auth-shell {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-footer {
|
||||||
|
margin-top: 32px;
|
||||||
|
border-top: 1px solid var(--footer-line);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(17, 29, 39, 0.98), rgba(31, 49, 64, 0.96));
|
||||||
|
color: var(--footer-text);
|
||||||
|
box-shadow: 0 -18px 40px rgba(31, 49, 64, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-footer-inner {
|
||||||
|
width: min(1200px, calc(100vw - 32px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 26px 0 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-footer-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-footer-brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-footer-mark {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, #c68b2c, #e7bf77);
|
||||||
|
color: #2c2215;
|
||||||
|
font-size: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-footer-text {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Trebuchet MS", "Segoe UI", sans-serif;
|
||||||
|
color: var(--footer-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-footer-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-footer-links a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 1px solid rgba(231, 191, 119, 0.24);
|
||||||
|
background: var(--footer-link-bg);
|
||||||
|
color: var(--footer-text);
|
||||||
|
font-family: "Trebuchet MS", "Segoe UI", sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: transform 0.2s ease, border-color 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-footer-links a:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: rgba(231, 191, 119, 0.45);
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.page-footer-inner {
|
||||||
|
padding: 22px 0 26px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-footer-links {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f4efe7;
|
||||||
|
--surface: rgba(255, 250, 242, 0.9);
|
||||||
|
--surface-strong: #fffaf2;
|
||||||
|
--text: #1e1f1d;
|
||||||
|
--muted: #5f615c;
|
||||||
|
--navy: #1f3140;
|
||||||
|
--navy-soft: #324d63;
|
||||||
|
--gold: #c68b2c;
|
||||||
|
--gold-soft: #e7bf77;
|
||||||
|
--line: rgba(31, 49, 64, 0.12);
|
||||||
|
--shadow: 0 24px 60px rgba(37, 42, 48, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: Georgia, "Times New Roman", serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(255, 255, 255, 0.9), transparent 24%),
|
||||||
|
radial-gradient(circle at 82% 18%, rgba(198, 139, 44, 0.2), transparent 20%),
|
||||||
|
linear-gradient(180deg, #f7f0e5 0%, #efe5d5 52%, #f4efe7 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-nav {
|
||||||
|
width: 100%;
|
||||||
|
padding: 18px max(16px, calc((100vw - 1200px) / 2)) 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
border-right: 4px solid #12212d;
|
||||||
|
border-bottom: 4px solid #12212d;
|
||||||
|
border-left: 4px solid #12212d;
|
||||||
|
background: linear-gradient(180deg, rgba(17, 29, 39, 0.96), rgba(31, 49, 64, 0.94));
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
box-shadow: 0 18px 40px rgba(31, 49, 64, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: #fff7eb;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, var(--gold), var(--gold-soft));
|
||||||
|
color: #2c2215;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-nav-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
color: #ecf4fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-nav-links a,
|
||||||
|
.legal-nav-links .lang-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 46px;
|
||||||
|
padding: 11px 16px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
font-weight: 700;
|
||||||
|
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-switch {
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid rgba(231, 191, 119, 0.45);
|
||||||
|
color: #ecf4fb;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-nav-links .lang-switch {
|
||||||
|
min-width: 68px;
|
||||||
|
font: inherit;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-nav-links a:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: rgba(231, 191, 119, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-nav-links .lang-switch:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: rgba(255, 222, 170, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-main {
|
||||||
|
width: min(980px, calc(100vw - 32px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 0 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-hero {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 34px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: linear-gradient(135deg, rgba(255, 250, 242, 0.94), rgba(245, 230, 205, 0.8));
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-eyebrow {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--navy-soft);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-hero h1,
|
||||||
|
.legal-card h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #182029;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-hero h1 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
line-height: 0.98;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
font-size: clamp(2.4rem, 4vw, 4.4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-lead,
|
||||||
|
.legal-card p,
|
||||||
|
.legal-list {
|
||||||
|
font-family: "Trebuchet MS", "Segoe UI", sans-serif;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-card {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 28px 30px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255, 250, 242, 0.82);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-note {
|
||||||
|
border-left: 6px solid var(--gold);
|
||||||
|
background: linear-gradient(135deg, rgba(255, 247, 235, 0.98), rgba(241, 221, 174, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-card h2 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-card p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-card p+p,
|
||||||
|
.legal-card p+.legal-list,
|
||||||
|
.legal-list+p {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-list li+li {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.legal-nav {
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 16px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-main {
|
||||||
|
padding-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-hero,
|
||||||
|
.legal-card {
|
||||||
|
padding: 24px 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -117,6 +117,14 @@ a {
|
|||||||
|
|
||||||
.nav-form {
|
.nav-form {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #ecf4fb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-shell {
|
.auth-shell {
|
||||||
@@ -145,6 +153,67 @@ a {
|
|||||||
width: min(980px, 100%);
|
width: min(980px, 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(31, 49, 64, 0.08);
|
||||||
|
border: 1px solid rgba(31, 49, 64, 0.12);
|
||||||
|
font-family: "Trebuchet MS", "Segoe UI", sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--navy);
|
||||||
|
transition: transform 0.2s ease, background 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
background: rgba(31, 49, 64, 0.12);
|
||||||
|
border-color: rgba(31, 49, 64, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link-arrow {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-switch,
|
||||||
|
#logoutButton {
|
||||||
|
margin-top: 0;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 11px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-switch {
|
||||||
|
min-width: 58px;
|
||||||
|
background: rgba(31, 49, 64, 0.08);
|
||||||
|
border: 2px solid rgba(31, 49, 64, 0.18);
|
||||||
|
color: var(--navy);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-switch:hover {
|
||||||
|
color: var(--navy);
|
||||||
|
background: rgba(31, 49, 64, 0.12);
|
||||||
|
border-color: rgba(31, 49, 64, 0.18);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#logoutButton {
|
||||||
|
color: #fff7eb;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-grid {
|
.settings-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
@@ -344,6 +413,16 @@ button:active {
|
|||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-topbar {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link,
|
||||||
|
.lang-switch {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-shell {
|
.auth-shell {
|
||||||
padding: 22px 12px 28px;
|
padding: 22px 12px 28px;
|
||||||
}
|
}
|
||||||
|
|||||||
+466
-358
File diff suppressed because it is too large
Load Diff
+76
-98
@@ -1,61 +1,69 @@
|
|||||||
|
// settings for preview board
|
||||||
const HERO_BOARD_SIZE = 8;
|
const HERO_BOARD_SIZE = 8;
|
||||||
const heroBoard = document.getElementById("heroBoard");
|
const heroBoard = document.getElementById("heroBoard");
|
||||||
|
|
||||||
|
//translation for English and German
|
||||||
|
const AppLanguage = window.AppLanguage;
|
||||||
const langSwitch = document.getElementById("langSwitch");
|
const langSwitch = document.getElementById("langSwitch");
|
||||||
const supportedLanguages = ["de", "en"];
|
|
||||||
const initialLanguage = window.LANDING_INITIAL_LANGUAGE || "de";
|
const initialLanguage = window.LANDING_INITIAL_LANGUAGE || "de";
|
||||||
|
let currentLanguage = initialLanguage;
|
||||||
|
|
||||||
const translations = {
|
const translations = {
|
||||||
de: {
|
de: {
|
||||||
"meta.title": "8-Damen- und 8-Türme Solver",
|
"meta.title": "8-Damen- und 8-Türme Solver",
|
||||||
"nav.brand": "8-Damen- und 8-Türme Solver",
|
"nav.brand": "8-Damen- und 8-Türme Solver",
|
||||||
"nav.problem": "Problem",
|
"nav.problem": "Problem",
|
||||||
"nav.features": "Features",
|
"nav.features": "Funktionen",
|
||||||
"nav.journey": "Ablauf",
|
"nav.journey": "Ablauf",
|
||||||
"nav.login": "Login",
|
"nav.login": "Anmelden",
|
||||||
"nav.signup": "Create Account",
|
"nav.signup": "Account erstellen",
|
||||||
"hero.eyebrow": "Interaktives Lernen mit Strategie, Logik und Backtracking",
|
"hero.eyebrow": "Interaktives Lernen mit Strategie, Logik und Backtracking",
|
||||||
"hero.title": "Entdecke das Acht-Damen- und Acht-Türme-Problem.",
|
"hero.title": "Entdecke das Acht-Damen- und Acht-Türme-Problem.",
|
||||||
"hero.text": "Diese App macht aus einem klassischen Informatik- und Mathematikproblem ein interaktives Erlebnis: Setze Damen oder Türme, verfolge den Solver Schritt fuer Schritt, miss deine Zeit und speichere clevere Zwischenstände.",
|
"hero.text": "Diese App macht aus einem klassischen Informatik- und Mathematikproblem ein interaktives Erlebnis: Setze Damen oder Türme, verfolge den Solver Schritt für Schritt, miss deine Zeit und speichere clevere Zwischenstände.",
|
||||||
"hero.primaryCta": "Jetzt starten",
|
"hero.primaryCta": "Jetzt starten",
|
||||||
"hero.secondaryCta": "Bereits Account? Login",
|
"hero.secondaryCta": "Bereits ein Account? Anmelden",
|
||||||
"hero.fact1Title": "Skalierbar",
|
"hero.fact1Title": "Skalierbar",
|
||||||
"hero.fact1Text": "Brettgrösse flexibel anpassen",
|
"hero.fact1Text": "Brettgrösse flexibel anpassen",
|
||||||
"hero.fact2Title": "Schritt für Schritt",
|
"hero.fact2Title": "Schritt für Schritt",
|
||||||
"hero.fact2Text": "Solver mit Animation",
|
"hero.fact2Text": "Solver mit Animation",
|
||||||
"hero.fact3Title": "Save-Points",
|
"hero.fact3Title": "Speicherstände",
|
||||||
"hero.fact3Text": "Zeit und Fortschritt behalten",
|
"hero.fact3Text": "Zeit und Fortschritt behalten",
|
||||||
"hero.solverLabel": "Solver",
|
"hero.solverLabel": "Lösungshelfer",
|
||||||
"hero.solverValue": "Backtracking live",
|
"hero.solverValue": "Backtracking in Echtzeit",
|
||||||
"hero.challengeLabel": "Challenge",
|
"hero.challengeLabel": "Herausforderung",
|
||||||
"hero.challengeValue": "Zeit und Schritte messen",
|
"hero.challengeValue": "Zeit und Schritte messen",
|
||||||
"problem.eyebrow": "Warum dieses Problem spannend ist",
|
"problem.eyebrow": "Was ist das Acht-Damen-Problem?",
|
||||||
"problem.title": "Die Acht-Damen- und Acht-Türme-Probleme sind mehr als Schachrätsel.",
|
"problem.title": "Eine klassische schachmathematische Aufgabe mit überraschend vielen Lösungen.",
|
||||||
"problem.text": "Beim Acht-Damen-Problem suchst du eine Anordnung von acht Damen auf einem Schachbrett, sodass sich keine zwei Damen schlagen. Beim Acht-Tuerme-Problem gilt dieselbe Idee für Türme, nur mit einfacheren Konfliktregeln. Beide Aufgaben zeigen, wie Mustererkennung, systematische Suche und Backtracking funktionieren.<br><br>Gerade beim Damenproblem wird schnell sichtbar, warum Strategie wichtig ist: Es gibt unzählige moegliche Platzierungen, aber nur sehr wenige wirklich gültige Loesungen. Genau das macht diese App spannend, weil man nicht nur das Ergebnis sieht, sondern auch den Weg dorthin.",
|
"problem.text": "Das Acht-Damen-Problem ist eine schachmathematische Aufgabe: Acht Damen sollen auf einem Schachbrett so aufgestellt werden, dass keine zwei Damen dieselbe Reihe, Spalte oder Diagonale teilen. Das Problem lässt sich auf beliebige Brettgrössen verallgemeinern, indem n Damen auf einem n x n-Brett platziert werden.<br><br>Für das klassische 8x8-Brett gibt es 92 Lösungen. Zählt man Stellungen, die nur durch Spiegelung oder Drehung entstehen, als gleich, bleiben 12 eindeutige Lösungen übrig.<br><br><a href=\"https://de-academic.com/dic.nsf/dewiki/13248\" target=\"_blank\" rel=\"noopener noreferrer\">Quelle: de-academic.com</a>",
|
||||||
"features.eyebrow": "Was die Applikation bietet",
|
"features.eyebrow": "Was die Applikation bietet",
|
||||||
"features.title": "Von der Idee bis zur gelösten Stellung.",
|
"features.title": "Von der Idee bis zur gelösten Stellung.",
|
||||||
"features.card1Title": "Interaktives Brett",
|
"features.card1Title": "Interaktives Brett",
|
||||||
"features.card1Text": "Setze Damen oder Türme direkt auf dem Brett und prüfe, ob deine Anordnung konfliktfrei ist.",
|
"features.card1Text": "Setze Damen oder Türme direkt auf dem Brett und prüfe, ob deine Anordnung konfliktfrei ist.",
|
||||||
"features.card2Title": "Solver mit Animation",
|
"features.card2Title": "Solver mit Animation",
|
||||||
"features.card2Text": "Beobachte den Loesungsweg Schritt fuer Schritt und verstehe, wie Backtracking arbeitet.",
|
"features.card2Text": "Beobachte den Lösungsweg Schritt für Schritt und verstehe, wie Backtracking arbeitet.",
|
||||||
"features.card3Title": "Zeit und Schritte",
|
"features.card3Title": "Zeit und Schritte",
|
||||||
"features.card3Text": "Miss deinen Fortschritt während des Spiels und vergleiche unterschiedliche Versuche.",
|
"features.card3Text": "Miss deinen Fortschritt während des Spiels und vergleiche unterschiedliche Versuche.",
|
||||||
"features.card4Title": "Save-Points",
|
"features.card4Title": "Speicherstände",
|
||||||
"features.card4Text": "Speichere Zwischenstände mit Notizen, Zeiten und Schrittzahl fuer späteres Weiterarbeiten.",
|
"features.card4Text": "Speichere Zwischenstände mit Notizen, Zeiten und Schrittzahl für späteres Weiterarbeiten.",
|
||||||
"features.card5Title": "Skalierbare Bretter",
|
"features.card5Title": "Skalierbare Bretter",
|
||||||
"features.card5Text": "Passe die Brettgrösse an und erkunde die Probleme nicht nur auf dem klassischen 8x8-Brett.",
|
"features.card5Text": "Passe die Brettgrösse an und erkunde die Probleme nicht nur auf dem klassischen 8x8-Brett.",
|
||||||
"journey.eyebrow": "So funktioniert dein Einstieg",
|
"journey.eyebrow": "So funktioniert dein Einstieg",
|
||||||
"journey.title": "In wenigen Schritten vom ersten Besuch zur eigenen Lösung.",
|
"journey.title": "In wenigen Schritten vom ersten Besuch zur eigenen Lösung.",
|
||||||
"journey.step1Title": "Account erstellen",
|
"journey.step1Title": "Account erstellen",
|
||||||
"journey.step1Text": "Registriere dich und erhalte Zugang zu Dashboard, Save-Points und deinem Fortschritt.",
|
"journey.step1Text": "Registriere dich und erhalte Zugang zum Dashboard, zu Speicherständen und zu deinem Fortschritt.",
|
||||||
"journey.step2Title": "Spielmodus wählen",
|
"journey.step2Title": "Spielmodus wählen",
|
||||||
"journey.step2Text": "Arbeite mit Damen oder Türmen und passe die Brettgrösse deinem Ziel an.",
|
"journey.step2Text": "Arbeite mit Damen oder Türmen und passe die Brettgrösse deinem Ziel an.",
|
||||||
"journey.step3Title": "Loesen oder analysieren",
|
"journey.step3Title": "Lösen oder analysieren",
|
||||||
"journey.step3Text": "Teste eigene Ideen oder lass dir die Lösung visuell vom Solver lösen lassen, dabei wird 1:1 gezeigt wie der Solver vorgeht.",
|
"journey.step3Text": "Teste eigene Ideen oder lass dir die Lösung visuell vom Solver zeigen, damit du den Lösungsweg besser nachvollziehen kannst.",
|
||||||
"cta.eyebrow": "Bereit für den ersten Versuch?",
|
"cta.eyebrow": "Bereit für den ersten Versuch?",
|
||||||
"cta.title": "Mach aus einem bekannten Problem ein sichtbares Lernerlebnis.",
|
"cta.title": "Mach aus einem bekannten Problem ein sichtbares Lernerlebnis.",
|
||||||
"cta.text": "Starte mit einem Account, speichere deine Zwischenstände und finde heraus, wie schnell du eine konfliktfreie Lösung erreichst.",
|
"cta.text": "Starte mit einem Account, speichere deine Zwischenstände und finde heraus, wie schnell du eine konfliktfreie Lösung erreichst.",
|
||||||
"cta.primaryCta": "Create Account",
|
"cta.primaryCta": "Account erstellen",
|
||||||
"cta.secondaryCta": "Login"
|
"cta.secondaryCta": "Anmelden",
|
||||||
|
"footerText": "Schulprojekt rund um das Acht-Damen- und Acht-Türme-Problem.",
|
||||||
|
"footerPrivacy": "Datenschutzerklärung",
|
||||||
|
"footerImprint": "Impressum",
|
||||||
|
"footerNavAria": "Rechtliche Hinweise"
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
"meta.title": "8 Queens and 8 Rooks Solver",
|
"meta.title": "8 Queens and 8 Rooks Solver",
|
||||||
@@ -63,13 +71,13 @@ const translations = {
|
|||||||
"nav.problem": "Problem",
|
"nav.problem": "Problem",
|
||||||
"nav.features": "Features",
|
"nav.features": "Features",
|
||||||
"nav.journey": "Journey",
|
"nav.journey": "Journey",
|
||||||
"nav.login": "Login",
|
"nav.login": "Sign in",
|
||||||
"nav.signup": "Create Account",
|
"nav.signup": "Create Account",
|
||||||
"hero.eyebrow": "Interactive learning with strategy, logic, and backtracking",
|
"hero.eyebrow": "Interactive learning with strategy, logic, and backtracking",
|
||||||
"hero.title": "Discover the Eight Queens and Eight Rooks puzzle.",
|
"hero.title": "Discover the Eight Queens and Eight Rooks puzzle.",
|
||||||
"hero.text": "This app turns a classic computer science and mathematics problem into an interactive experience: place queens or rooks, follow the solver step by step, track your time, and save clever checkpoints.",
|
"hero.text": "This app turns a classic computer science and mathematics problem into an interactive experience: place queens or rooks, follow the solver step by step, track your time, and save clever checkpoints.",
|
||||||
"hero.primaryCta": "Get Started",
|
"hero.primaryCta": "Get Started",
|
||||||
"hero.secondaryCta": "Already have an account? Login",
|
"hero.secondaryCta": "Already have an account? Sign in",
|
||||||
"hero.fact1Title": "Scalable",
|
"hero.fact1Title": "Scalable",
|
||||||
"hero.fact1Text": "Adjust the board size freely",
|
"hero.fact1Text": "Adjust the board size freely",
|
||||||
"hero.fact2Title": "Step by step",
|
"hero.fact2Title": "Step by step",
|
||||||
@@ -80,9 +88,9 @@ const translations = {
|
|||||||
"hero.solverValue": "Backtracking in motion",
|
"hero.solverValue": "Backtracking in motion",
|
||||||
"hero.challengeLabel": "Challenge",
|
"hero.challengeLabel": "Challenge",
|
||||||
"hero.challengeValue": "Measure time and steps",
|
"hero.challengeValue": "Measure time and steps",
|
||||||
"problem.eyebrow": "Why this puzzle matters",
|
"problem.eyebrow": "What is the Eight Queens problem?",
|
||||||
"problem.title": "The Eight Queens and Eight Rooks problems are more than chess puzzles.",
|
"problem.title": "A classic chess-related math puzzle with surprisingly many solutions.",
|
||||||
"problem.text": "In the Eight Queens problem, you search for a way to place eight queens on a chessboard so that no two queens can attack each other. The Eight Rooks problem follows the same idea for rooks, but with simpler conflict rules. Both tasks show how pattern recognition, systematic search, and backtracking work.<br><br>The queens puzzle quickly makes it obvious why strategy matters: there are countless possible placements, but only a few truly valid solutions. That is what makes this app interesting, because you do not just see the result, you also see the path to it.",
|
"problem.text": "The Eight Queens problem is a chess-related mathematical puzzle: eight queens must be placed on a chessboard so that no two queens share the same row, column, or diagonal. The idea can be generalized to boards of any size by placing n queens on an n x n board.<br><br>On the classic 8x8 board there are 92 solutions. If positions that only differ by reflection or rotation are counted as the same, 12 unique solutions remain.<br><br><a href=\"https://de-academic.com/dic.nsf/dewiki/13248\" target=\"_blank\" rel=\"noopener noreferrer\">Source: de-academic.com</a>",
|
||||||
"features.eyebrow": "What the application offers",
|
"features.eyebrow": "What the application offers",
|
||||||
"features.title": "From the first idea to a solved position.",
|
"features.title": "From the first idea to a solved position.",
|
||||||
"features.card1Title": "Interactive board",
|
"features.card1Title": "Interactive board",
|
||||||
@@ -107,10 +115,51 @@ const translations = {
|
|||||||
"cta.title": "Turn a famous puzzle into a visible learning experience.",
|
"cta.title": "Turn a famous puzzle into a visible learning experience.",
|
||||||
"cta.text": "Start with an account, save your checkpoints, and find out how quickly you can reach a conflict-free solution.",
|
"cta.text": "Start with an account, save your checkpoints, and find out how quickly you can reach a conflict-free solution.",
|
||||||
"cta.primaryCta": "Create Account",
|
"cta.primaryCta": "Create Account",
|
||||||
"cta.secondaryCta": "Login"
|
"cta.secondaryCta": "Sign in",
|
||||||
|
"footerText": "School project about the Eight Queens and Eight Rooks problem.",
|
||||||
|
"footerPrivacy": "Privacy Policy",
|
||||||
|
"footerImprint": "Imprint",
|
||||||
|
"footerNavAria": "Legal information"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function t(key) {
|
||||||
|
return translations[currentLanguage]?.[key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTranslations(language) {
|
||||||
|
currentLanguage = language;
|
||||||
|
|
||||||
|
AppLanguage.applyTranslations({
|
||||||
|
translate: (key) => t(key),
|
||||||
|
allowHtml: (element, translatedText) => element.tagName === "TITLE" || String(translatedText).includes("<br"),
|
||||||
|
attributeMappings: [
|
||||||
|
{
|
||||||
|
selector: "[data-i18n-aria-label]",
|
||||||
|
datasetKey: "i18nAriaLabel",
|
||||||
|
attribute: "aria-label"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const languageController = AppLanguage.createController({
|
||||||
|
button: langSwitch,
|
||||||
|
defaultLanguage: initialLanguage,
|
||||||
|
onApply(language, helpers) {
|
||||||
|
applyTranslations(language);
|
||||||
|
helpers.updateLanguageSwitch({
|
||||||
|
ariaLabel: language === "de" ? "Switch language to English" : "Sprache auf Deutsch wechseln",
|
||||||
|
title: language === "de" ? "Switch to English" : "Zu Deutsch wechseln"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
languageController.init();
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// simple pattern for preview board
|
||||||
const queenPatterns = [
|
const queenPatterns = [
|
||||||
[0, 4, 7, 5, 2, 6, 1, 3],
|
[0, 4, 7, 5, 2, 6, 1, 3],
|
||||||
[1, 3, 5, 7, 2, 0, 6, 4],
|
[1, 3, 5, 7, 2, 0, 6, 4],
|
||||||
@@ -119,69 +168,6 @@ const queenPatterns = [
|
|||||||
|
|
||||||
let currentPatternIndex = 0;
|
let currentPatternIndex = 0;
|
||||||
|
|
||||||
function normalizeLanguage(language) {
|
|
||||||
const shortLanguage = String(language || "").trim().slice(0, 2).toLowerCase();
|
|
||||||
return supportedLanguages.includes(shortLanguage) ? shortLanguage : "de";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStoredLanguage() {
|
|
||||||
try {
|
|
||||||
return window.localStorage.getItem("preferredLanguage");
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function storeLanguage(language) {
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem("preferredLanguage", language);
|
|
||||||
} catch (error) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectBrowserLanguage() {
|
|
||||||
const browserLanguage = navigator.language || navigator.userLanguage || initialLanguage;
|
|
||||||
return normalizeLanguage(browserLanguage);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setLanguage(language) {
|
|
||||||
const resolvedLanguage = normalizeLanguage(language);
|
|
||||||
const dictionary = translations[resolvedLanguage];
|
|
||||||
const i18nElements = document.querySelectorAll("[data-i18n]");
|
|
||||||
|
|
||||||
document.documentElement.lang = resolvedLanguage;
|
|
||||||
document.body.dataset.language = resolvedLanguage;
|
|
||||||
|
|
||||||
i18nElements.forEach((element) => {
|
|
||||||
const key = element.dataset.i18n;
|
|
||||||
const translatedText = dictionary[key];
|
|
||||||
|
|
||||||
if (!translatedText) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (element.tagName === "TITLE" || translatedText.includes("<br")) {
|
|
||||||
element.innerHTML = translatedText;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
element.textContent = translatedText;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (langSwitch) {
|
|
||||||
langSwitch.textContent = resolvedLanguage.toUpperCase();
|
|
||||||
langSwitch.setAttribute("aria-label", resolvedLanguage === "de" ? "Switch language to English" : "Sprache auf Deutsch wechseln");
|
|
||||||
langSwitch.title = resolvedLanguage === "de" ? "Switch to English" : "Zu Deutsch wechseln";
|
|
||||||
}
|
|
||||||
|
|
||||||
storeLanguage(resolvedLanguage);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPreferredLanguage() {
|
|
||||||
return normalizeLanguage(getStoredLanguage() || detectBrowserLanguage() || initialLanguage);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createHeroBoard() {
|
function createHeroBoard() {
|
||||||
if (!heroBoard) {
|
if (!heroBoard) {
|
||||||
return;
|
return;
|
||||||
@@ -222,16 +208,8 @@ function rotatePattern() {
|
|||||||
applyPattern(queenPatterns[currentPatternIndex]);
|
applyPattern(queenPatterns[currentPatternIndex]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (langSwitch) {
|
|
||||||
langSwitch.addEventListener("click", () => {
|
|
||||||
const nextLanguage = document.documentElement.lang === "de" ? "en" : "de";
|
|
||||||
setLanguage(nextLanguage);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
createHeroBoard();
|
createHeroBoard();
|
||||||
applyPattern(queenPatterns[currentPatternIndex]);
|
applyPattern(queenPatterns[currentPatternIndex]);
|
||||||
setLanguage(getPreferredLanguage());
|
|
||||||
|
|
||||||
if (heroBoard) {
|
if (heroBoard) {
|
||||||
window.setInterval(rotatePattern, 2200);
|
window.setInterval(rotatePattern, 2200);
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
// handels the language translation
|
||||||
|
|
||||||
|
(function attachAppLanguage(window, document) {
|
||||||
|
const DEFAULT_LANGUAGE = "de";
|
||||||
|
const DEFAULT_STORAGE_KEY = "preferredLanguage";
|
||||||
|
const DEFAULT_SUPPORTED_LANGUAGES = ["de", "en"];
|
||||||
|
|
||||||
|
function resolveSupportedLanguages(supportedLanguages = DEFAULT_SUPPORTED_LANGUAGES) {
|
||||||
|
const normalizedLanguages = Array.isArray(supportedLanguages)
|
||||||
|
? supportedLanguages
|
||||||
|
.map((language) => String(language || "").trim().slice(0, 2).toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return normalizedLanguages.length > 0 ? normalizedLanguages : DEFAULT_SUPPORTED_LANGUAGES.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLanguage(
|
||||||
|
language,
|
||||||
|
{
|
||||||
|
supportedLanguages = DEFAULT_SUPPORTED_LANGUAGES,
|
||||||
|
defaultLanguage = DEFAULT_LANGUAGE
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const normalizedSupportedLanguages = resolveSupportedLanguages(supportedLanguages);
|
||||||
|
const normalizedDefaultLanguage = normalizedSupportedLanguages.includes(defaultLanguage)
|
||||||
|
? defaultLanguage
|
||||||
|
: normalizedSupportedLanguages[0] || DEFAULT_LANGUAGE;
|
||||||
|
const shortLanguage = String(language || "").trim().slice(0, 2).toLowerCase();
|
||||||
|
|
||||||
|
return normalizedSupportedLanguages.includes(shortLanguage) ? shortLanguage : normalizedDefaultLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoredLanguage({ storageKey = DEFAULT_STORAGE_KEY } = {}) {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(storageKey);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeLanguage(language, { storageKey = DEFAULT_STORAGE_KEY } = {}) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(storageKey, language);
|
||||||
|
} catch (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectBrowserLanguage(options = {}) {
|
||||||
|
return normalizeLanguage(navigator.language || navigator.userLanguage || options.defaultLanguage, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreferredLanguage(
|
||||||
|
{
|
||||||
|
supportedLanguages = DEFAULT_SUPPORTED_LANGUAGES,
|
||||||
|
defaultLanguage = DEFAULT_LANGUAGE,
|
||||||
|
storageKey = DEFAULT_STORAGE_KEY,
|
||||||
|
fallbackLanguage = defaultLanguage
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
return normalizeLanguage(
|
||||||
|
getStoredLanguage({ storageKey }) || detectBrowserLanguage({ supportedLanguages, defaultLanguage }) || fallbackLanguage,
|
||||||
|
{
|
||||||
|
supportedLanguages,
|
||||||
|
defaultLanguage: fallbackLanguage
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextLanguage(language, supportedLanguages = DEFAULT_SUPPORTED_LANGUAGES) {
|
||||||
|
const normalizedSupportedLanguages = resolveSupportedLanguages(supportedLanguages);
|
||||||
|
const currentLanguage = normalizeLanguage(language, {
|
||||||
|
supportedLanguages: normalizedSupportedLanguages,
|
||||||
|
defaultLanguage: normalizedSupportedLanguages[0]
|
||||||
|
});
|
||||||
|
const currentIndex = normalizedSupportedLanguages.indexOf(currentLanguage);
|
||||||
|
|
||||||
|
if (currentIndex === -1 || normalizedSupportedLanguages.length < 2) {
|
||||||
|
return currentLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedSupportedLanguages[(currentIndex + 1) % normalizedSupportedLanguages.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDocumentLanguage(language) {
|
||||||
|
if (document.documentElement) {
|
||||||
|
document.documentElement.lang = language;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.body) {
|
||||||
|
document.body.dataset.language = language;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTextTranslations({
|
||||||
|
translate,
|
||||||
|
selector = "[data-i18n]",
|
||||||
|
allowHtml = () => false
|
||||||
|
} = {}) {
|
||||||
|
if (typeof translate !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll(selector).forEach((element) => {
|
||||||
|
const key = element.dataset.i18n;
|
||||||
|
const translatedText = translate(key, element);
|
||||||
|
|
||||||
|
if (translatedText === undefined || translatedText === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowHtml(element, translatedText, key)) {
|
||||||
|
element.innerHTML = String(translatedText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
element.textContent = String(translatedText);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAttributeTranslations({
|
||||||
|
translate,
|
||||||
|
mappings = []
|
||||||
|
} = {}) {
|
||||||
|
if (typeof translate !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mappings.forEach(({ selector, datasetKey, attribute }) => {
|
||||||
|
document.querySelectorAll(selector).forEach((element) => {
|
||||||
|
const key = element.dataset[datasetKey];
|
||||||
|
const translatedText = translate(key, element, attribute);
|
||||||
|
|
||||||
|
if (translatedText === undefined || translatedText === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
element.setAttribute(attribute, String(translatedText));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTranslations({
|
||||||
|
translate,
|
||||||
|
selector = "[data-i18n]",
|
||||||
|
allowHtml,
|
||||||
|
attributeMappings = []
|
||||||
|
} = {}) {
|
||||||
|
applyTextTranslations({ translate, selector, allowHtml });
|
||||||
|
applyAttributeTranslations({ translate, mappings: attributeMappings });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLanguageSwitch(
|
||||||
|
button,
|
||||||
|
{
|
||||||
|
language,
|
||||||
|
label = String(language || "").toUpperCase(),
|
||||||
|
ariaLabel,
|
||||||
|
title
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
if (!button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.textContent = label;
|
||||||
|
|
||||||
|
if (ariaLabel !== undefined) {
|
||||||
|
button.setAttribute("aria-label", ariaLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title !== undefined) {
|
||||||
|
button.title = title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createController({
|
||||||
|
button = document.getElementById("langSwitch"),
|
||||||
|
defaultLanguage = DEFAULT_LANGUAGE,
|
||||||
|
supportedLanguages = DEFAULT_SUPPORTED_LANGUAGES,
|
||||||
|
storageKey = DEFAULT_STORAGE_KEY,
|
||||||
|
onApply = () => { }
|
||||||
|
} = {}) {
|
||||||
|
const options = {
|
||||||
|
defaultLanguage,
|
||||||
|
supportedLanguages,
|
||||||
|
storageKey
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentLanguage = normalizeLanguage(defaultLanguage, options);
|
||||||
|
|
||||||
|
function apply(language, source = "apply") {
|
||||||
|
currentLanguage = normalizeLanguage(language, options);
|
||||||
|
setDocumentLanguage(currentLanguage);
|
||||||
|
|
||||||
|
onApply(currentLanguage, {
|
||||||
|
source,
|
||||||
|
button,
|
||||||
|
applyTranslations,
|
||||||
|
updateLanguageSwitch: (switchOptions = {}) => updateLanguageSwitch(button, {
|
||||||
|
language: currentLanguage,
|
||||||
|
...switchOptions
|
||||||
|
}),
|
||||||
|
getNextLanguage: () => getNextLanguage(currentLanguage, supportedLanguages),
|
||||||
|
normalizeLanguage: (value) => normalizeLanguage(value, options)
|
||||||
|
});
|
||||||
|
|
||||||
|
storeLanguage(currentLanguage, { storageKey });
|
||||||
|
return currentLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
return apply(getNextLanguage(currentLanguage, supportedLanguages), "toggle");
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(initialLanguage = getPreferredLanguage({
|
||||||
|
supportedLanguages,
|
||||||
|
defaultLanguage,
|
||||||
|
storageKey,
|
||||||
|
fallbackLanguage: defaultLanguage
|
||||||
|
})) {
|
||||||
|
if (button) {
|
||||||
|
button.addEventListener("click", toggle);
|
||||||
|
}
|
||||||
|
|
||||||
|
return apply(initialLanguage, "init");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
apply,
|
||||||
|
getCurrentLanguage: () => currentLanguage,
|
||||||
|
getPreferredLanguage: (fallbackLanguage = defaultLanguage) => getPreferredLanguage({
|
||||||
|
supportedLanguages,
|
||||||
|
defaultLanguage,
|
||||||
|
storageKey,
|
||||||
|
fallbackLanguage
|
||||||
|
}),
|
||||||
|
init,
|
||||||
|
toggle
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.AppLanguage = {
|
||||||
|
DEFAULT_LANGUAGE,
|
||||||
|
DEFAULT_STORAGE_KEY,
|
||||||
|
DEFAULT_SUPPORTED_LANGUAGES: DEFAULT_SUPPORTED_LANGUAGES.slice(),
|
||||||
|
applyAttributeTranslations,
|
||||||
|
applyTextTranslations,
|
||||||
|
applyTranslations,
|
||||||
|
createController,
|
||||||
|
detectBrowserLanguage,
|
||||||
|
getNextLanguage,
|
||||||
|
getPreferredLanguage,
|
||||||
|
getStoredLanguage,
|
||||||
|
normalizeLanguage,
|
||||||
|
setDocumentLanguage,
|
||||||
|
storeLanguage,
|
||||||
|
updateLanguageSwitch
|
||||||
|
};
|
||||||
|
})(window, document);
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
//translation for English and German
|
||||||
|
|
||||||
|
const AppLanguage = window.AppLanguage;
|
||||||
|
const langSwitch = document.getElementById("langSwitch");
|
||||||
|
const legalPage = document.body.dataset.legalPage;
|
||||||
|
let currentLanguage = AppLanguage.DEFAULT_LANGUAGE;
|
||||||
|
|
||||||
|
const translations = {
|
||||||
|
de: {
|
||||||
|
common: {
|
||||||
|
navBrand: "8-Damen- und 8-Türme Solver",
|
||||||
|
navHome: "Startseite",
|
||||||
|
navDashboard: "Dashboard",
|
||||||
|
navLogin: "Login",
|
||||||
|
heroEyebrow: "Rechtliche Hinweise",
|
||||||
|
footerText: "Schulprojekt rund um das Acht-Damen- und Acht-Türme-Problem.",
|
||||||
|
footerPrivacy: "Datenschutzerklärung",
|
||||||
|
footerImprint: "Impressum",
|
||||||
|
footerNavAria: "Rechtliche Hinweise",
|
||||||
|
languageSwitchAria: "Sprache wechseln"
|
||||||
|
},
|
||||||
|
imprint: {
|
||||||
|
pageTitle: "Impressum",
|
||||||
|
heroTitle: "Impressum",
|
||||||
|
contactTitle: "Kontakt",
|
||||||
|
contactName: "Jérôme Bachmann",
|
||||||
|
contactEmailLabel: "E-Mail:",
|
||||||
|
projectResponsibilityTitle: "Projektverantwortung",
|
||||||
|
projectResponsibilityText: "Dieses Webprojekt wurde im Rahmen eines Schulprojekts zum Acht-Damen- und Acht-Türme-Problem erstellt. Inhaltlich verantwortlich ist die jeweils oben genannte verantwortliche Stelle.",
|
||||||
|
contentLiabilityTitle: "Haftung für Inhalte",
|
||||||
|
contentLiabilityText: "Die Inhalte dieser Website wurden mit Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte kann jedoch keine Gewähr übernommen werden. Bei Bekanntwerden von Rechtsverletzungen werden entsprechende Inhalte umgehend überarbeitet oder entfernt.",
|
||||||
|
linksLiabilityTitle: "Haftung für Links",
|
||||||
|
linksLiabilityText: "Diese Website kann Links zu externen Angeboten enthalten. Für deren Inhalte sind ausschliesslich die jeweiligen Betreiber verantwortlich. Eine permanente inhaltliche Kontrolle verlinkter Seiten ist ohne konkrete Anhaltspunkte einer Rechtsverletzung nicht zumutbar.",
|
||||||
|
versionTitle: "Stand",
|
||||||
|
versionText: "Stand dieser Vorlage: April 2026"
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
pageTitle: "Datenschutzerklärung",
|
||||||
|
heroTitle: "Datenschutzerklärung",
|
||||||
|
heroLead: "Diese Seite beschreibt, welche personenbezogenen Daten im Rahmen dieses Schulprojekts verarbeitet werden.",
|
||||||
|
dataProcessedTitle: "1. Welche Daten verarbeitet werden",
|
||||||
|
dataProcessedText: "Im Rahmen der Nutzung dieser Webanwendung können insbesondere folgende Daten verarbeitet werden:",
|
||||||
|
dataProcessedItem1: "Benutzername und verschlüsseltes Passwort bei der Registrierung und beim Login",
|
||||||
|
dataProcessedItem2: "Daten zur Zwei-Faktor-Authentifizierung, falls diese freiwillig aktiviert wird",
|
||||||
|
dataProcessedItem3: "gespeicherte Spielstände, Notizen, Schrittzahlen, Zeiten und Favoriten",
|
||||||
|
dataProcessedItem4: "technische Verbindungsdaten, die durch den Server oder das Hosting entstehen koennen",
|
||||||
|
processingPurposeTitle: "2. Zweck der Verarbeitung",
|
||||||
|
processingPurposeText: "Die Verarbeitung erfolgt, um die Funktionen dieser Anwendung bereitzustellen. Dazu gehoeren insbesondere:",
|
||||||
|
processingPurposeItem1: "Erstellung und Verwaltung von Benutzerkonten",
|
||||||
|
processingPurposeItem2: "sicherer Login in das persönliche Dashboard",
|
||||||
|
processingPurposeItem3: "optionale Absicherung des Kontos per Authenticator-App",
|
||||||
|
processingPurposeItem4: "Speichern und Laden von Spielständen",
|
||||||
|
processingPurposeItem5: "technischer Betrieb, Fehleranalyse und Missbrauchsschutz",
|
||||||
|
legalBasisTitle: "3. Rechtsgrundlage",
|
||||||
|
legalBasisText: "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 funktionsfähigen Webangebot. Falls eine ausdrückliche Einwilligung erforderlich ist, wird diese gesondert eingeholt.",
|
||||||
|
storageTitle: "4. Speicherung und Löschung",
|
||||||
|
storageText: "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 gelöscht oder für das Projekt nicht mehr benötigt werden.",
|
||||||
|
thirdPartyTitle: "5. Weitergabe an Dritte",
|
||||||
|
thirdPartyText: "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 keine Daten weitergegeben.",
|
||||||
|
rightsTitle: "6. Rechte betroffener Personen",
|
||||||
|
rightsText: "Betroffene Personen haben im Rahmen des anwendbaren Datenschutzrechts insbesondere das Recht auf:",
|
||||||
|
rightsItem1: "Auskunft über die verarbeiteten personenbezogenen Daten",
|
||||||
|
rightsItem2: "Berichtigung unrichtiger Daten",
|
||||||
|
rightsItem3: "Löschung oder Einschränkung der Verarbeitung",
|
||||||
|
rightsItem4: "Widerspruch gegen die Verarbeitung",
|
||||||
|
rightsItem5: "Herausgabe der bereitgestellten Daten, soweit anwendbar",
|
||||||
|
securityTitle: "7. Datensicherheit",
|
||||||
|
securityText: "Es werden technische und organisatorische Massnahmen getroffen, um gespeicherte Daten vor Verlust, Missbrauch oder unberechtigtem Zugriff zu schützen. Dazu gehoeren insbesondere passwortgeschützte Konten und die Möglichkeit, Zwei-Faktor-Authentifizierung zu aktivieren.",
|
||||||
|
versionTitle: "8. Stand",
|
||||||
|
versionText: "Stand dieser Vorlage: April 2026"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
common: {
|
||||||
|
navBrand: "8 Queens and 8 Rooks Solver",
|
||||||
|
navHome: "Home",
|
||||||
|
navDashboard: "Dashboard",
|
||||||
|
navLogin: "Login",
|
||||||
|
heroEyebrow: "Legal information",
|
||||||
|
footerText: "School project about the Eight Queens and Eight Rooks problem.",
|
||||||
|
footerPrivacy: "Privacy Policy",
|
||||||
|
footerImprint: "Imprint",
|
||||||
|
footerNavAria: "Legal information",
|
||||||
|
languageSwitchAria: "Switch language"
|
||||||
|
},
|
||||||
|
imprint: {
|
||||||
|
pageTitle: "Imprint",
|
||||||
|
heroTitle: "Imprint",
|
||||||
|
contactTitle: "Contact",
|
||||||
|
contactName: "Jérôme Bachmann",
|
||||||
|
contactEmailLabel: "Email:",
|
||||||
|
projectResponsibilityTitle: "Project responsibility",
|
||||||
|
projectResponsibilityText: "This web project was created as part of a school project on the Eight Queens and Eight Rooks problem. The entity named above is responsible for the content.",
|
||||||
|
contentLiabilityTitle: "Liability for content",
|
||||||
|
contentLiabilityText: "The content of this website was created with care. However, no guarantee can be given for the accuracy, completeness, or timeliness of the content. If legal violations become known, the relevant content will be revised or removed without delay.",
|
||||||
|
linksLiabilityTitle: "Liability for links",
|
||||||
|
linksLiabilityText: "This website may contain links to external offers. Their content is the sole responsibility of the respective operators. Permanent monitoring of linked pages is not reasonable without concrete indications of a legal violation.",
|
||||||
|
versionTitle: "Version",
|
||||||
|
versionText: "Version of this template: April 2026"
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
pageTitle: "Privacy Policy",
|
||||||
|
heroTitle: "Privacy Policy",
|
||||||
|
heroLead: "This page describes which personal data is processed as part of this school project.",
|
||||||
|
dataProcessedTitle: "1. Which data is processed",
|
||||||
|
dataProcessedText: "When using this web application, the following data in particular may be processed:",
|
||||||
|
dataProcessedItem1: "username and encrypted password during registration and login",
|
||||||
|
dataProcessedItem2: "two-factor authentication data if this feature is activated voluntarily",
|
||||||
|
dataProcessedItem3: "saved game states, notes, step counts, times, and favorites",
|
||||||
|
dataProcessedItem4: "technical connection data that may arise through the server or hosting",
|
||||||
|
processingPurposeTitle: "2. Purpose of processing",
|
||||||
|
processingPurposeText: "The processing is carried out to provide the functions of this application. This includes in particular:",
|
||||||
|
processingPurposeItem1: "creating and managing user accounts",
|
||||||
|
processingPurposeItem2: "secure login to the personal dashboard",
|
||||||
|
processingPurposeItem3: "optional account protection using an authenticator app",
|
||||||
|
processingPurposeItem4: "saving and loading game states",
|
||||||
|
processingPurposeItem5: "technical operation, error analysis, and abuse prevention",
|
||||||
|
legalBasisTitle: "3. Legal basis",
|
||||||
|
legalBasisText: "Where personal data is processed, this is generally done to provide the application and its functions, as well as on the basis of the legitimate interest in a secure and functional web offering. If explicit consent is required, it will be obtained separately.",
|
||||||
|
storageTitle: "4. Storage and deletion",
|
||||||
|
storageText: "Personal data is stored only as long as it is needed for the respective purpose. User accounts and related game states generally remain stored until they are deleted or are no longer needed for the project.",
|
||||||
|
thirdPartyTitle: "5. Disclosure to third parties",
|
||||||
|
thirdPartyText: "Personal data is shared only if this is necessary for operating the application, for example through a hosting provider or technical service providers. Otherwise, no data is shared.",
|
||||||
|
rightsTitle: "6. Rights of data subjects",
|
||||||
|
rightsText: "Within the framework of applicable data protection law, data subjects have in particular the right to:",
|
||||||
|
rightsItem1: "information about the personal data being processed",
|
||||||
|
rightsItem2: "correction of incorrect data",
|
||||||
|
rightsItem3: "deletion of data or restriction of processing",
|
||||||
|
rightsItem4: "object to the processing",
|
||||||
|
rightsItem5: "receive the data that has been provided, where applicable",
|
||||||
|
securityTitle: "7. Data security",
|
||||||
|
securityText: "Technical and organizational measures are taken to protect stored data against loss, misuse, or unauthorized access. These include password-protected accounts and the option to enable two-factor authentication.",
|
||||||
|
versionTitle: "8. Version",
|
||||||
|
versionText: "Version of this template: April 2026"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function t(key) {
|
||||||
|
const commonTranslation = translations[currentLanguage]?.common?.[key];
|
||||||
|
|
||||||
|
if (commonTranslation) {
|
||||||
|
return commonTranslation;
|
||||||
|
}
|
||||||
|
|
||||||
|
return translations[currentLanguage]?.[legalPage]?.[key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTranslations() {
|
||||||
|
AppLanguage.applyTranslations({
|
||||||
|
translate: (key) => t(key),
|
||||||
|
attributeMappings: [
|
||||||
|
{
|
||||||
|
selector: "[data-i18n-aria-label]",
|
||||||
|
datasetKey: "i18nAriaLabel",
|
||||||
|
attribute: "aria-label"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const languageController = AppLanguage.createController({
|
||||||
|
button: langSwitch,
|
||||||
|
onApply(language, helpers) {
|
||||||
|
currentLanguage = language;
|
||||||
|
applyTranslations();
|
||||||
|
helpers.updateLanguageSwitch({
|
||||||
|
ariaLabel: t("languageSwitchAria") || "Sprache wechseln",
|
||||||
|
title: language === "de" ? "Switch to English" : "Zu Deutsch wechseln"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
languageController.init();
|
||||||
+203
-3
@@ -1,5 +1,205 @@
|
|||||||
|
//translation for English and German
|
||||||
|
|
||||||
|
const AppLanguage = window.AppLanguage;
|
||||||
|
const langSwitch = document.getElementById("langSwitch");
|
||||||
|
|
||||||
|
const translations = {
|
||||||
|
de: {
|
||||||
|
pageTitle: "Login-Einstellungen",
|
||||||
|
navBrand: "8-Damen- und 8-Türme Solver",
|
||||||
|
navLogin: "Anmelden",
|
||||||
|
navRegister: "Account erstellen",
|
||||||
|
navDashboard: "Dashboard",
|
||||||
|
navSettings: "Login-Einstellungen",
|
||||||
|
navLogout: "Logout",
|
||||||
|
backToDashboard: "Zurück zum Dashboard",
|
||||||
|
backToDashboardAria: "Zurück zum Dashboard",
|
||||||
|
languageSwitchAria: "Sprache wechseln",
|
||||||
|
settingsTitle: "Login-Einstellungen",
|
||||||
|
settingsSubtitle: "Verwalte die Authenticator-App-2FA und dein Passwort, ohne den Registrierungsablauf zu ändern.",
|
||||||
|
twoFactorTitle: "Authenticator-App-2FA",
|
||||||
|
twoFactorEnabledHint: "2FA ist für dieses Konto aktuell aktiviert.",
|
||||||
|
twoFactorEnabledAppHint: "Nutze Google Authenticator, Microsoft Authenticator, Authy oder eine andere TOTP-App, um deine 6-stelligen Login-Codes zu generieren.",
|
||||||
|
disableTwoFactorButton: "2FA deaktivieren",
|
||||||
|
disableTwoFactorLoading: "2FA wird deaktiviert...",
|
||||||
|
finishSetupTitle: "2FA-Einrichtung abschließen",
|
||||||
|
finishSetupHint: "Scanne diesen QR-Code mit deiner Authenticator-App und gib danach den aktuellen 6-stelligen Code zur Bestätigung ein.",
|
||||||
|
scanFallbackHint: "Wenn das Scannen nicht funktioniert, kannst du den manuellen Einrichtungsschlüssel unten verwenden.",
|
||||||
|
manualSetupKey: "Manueller Einrichtungsschlüssel",
|
||||||
|
setupUriLabel: "Einrichtungs-URI",
|
||||||
|
setupUriHint: "Diese URI wird für den QR-Setup verwendet und kann als Fallback für kompatible Apps kopiert werden.",
|
||||||
|
authenticatorCodeLabel: "Authenticator-Code",
|
||||||
|
confirmAndEnableButton: "Bestätigen und aktivieren",
|
||||||
|
confirmAndEnableLoading: "2FA wird aktiviert...",
|
||||||
|
cancelSetupTitle: "Einrichtung abbrechen",
|
||||||
|
cancelSetupHint: "Brich die aktuelle Authenticator-Einrichtung ab, wenn du mit einem neuen Schlüssel neu starten möchtest.",
|
||||||
|
cancelSetupButton: "Einrichtung abbrechen",
|
||||||
|
startSetupHint: "Schütze dein Konto mit 6-stelligen Codes aus einer Authenticator-App.",
|
||||||
|
startSetupHintFollowup: "Nach dem Start der Einrichtung erhältst du einen QR-Code zum Scannen und bestätigst dann einmal mit einem aktuellen Code.",
|
||||||
|
startSetupButton: "Einrichtung starten",
|
||||||
|
startSetupLoading: "Einrichtung wird gestartet...",
|
||||||
|
changePasswordTitle: "Passwort ändern",
|
||||||
|
currentPasswordLabel: "Aktuelles Passwort",
|
||||||
|
newPasswordLabel: "Neues Passwort",
|
||||||
|
confirmPasswordLabel: "Neues Passwort bestätigen",
|
||||||
|
updatePasswordButton: "Passwort aktualisieren",
|
||||||
|
updatePasswordLoading: "Passwort wird aktualisiert...",
|
||||||
|
qrLoadError: "QR-Vorschau konnte nicht geladen werden.",
|
||||||
|
footerText: "Schulprojekt rund um das Acht-Damen- und Acht-Türme-Problem.",
|
||||||
|
footerPrivacy: "Datenschutzerklärung",
|
||||||
|
footerImprint: "Impressum",
|
||||||
|
footerNavAria: "Rechtliche Hinweise"
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
pageTitle: "Login Settings",
|
||||||
|
navBrand: "8 Queens and 8 Rooks Solver",
|
||||||
|
navLogin: "Login",
|
||||||
|
navRegister: "Create account",
|
||||||
|
navDashboard: "Dashboard",
|
||||||
|
navSettings: "Login settings",
|
||||||
|
navLogout: "Logout",
|
||||||
|
backToDashboard: "Back to dashboard",
|
||||||
|
backToDashboardAria: "Back to dashboard",
|
||||||
|
languageSwitchAria: "Switch language",
|
||||||
|
settingsTitle: "Login settings",
|
||||||
|
settingsSubtitle: "Manage authenticator app 2FA and your password without changing the registration flow.",
|
||||||
|
twoFactorTitle: "Authenticator app 2FA",
|
||||||
|
twoFactorEnabledHint: "2FA is currently enabled for this account.",
|
||||||
|
twoFactorEnabledAppHint: "Use Google Authenticator, Microsoft Authenticator, Authy, or another TOTP app to generate your 6-digit login codes.",
|
||||||
|
disableTwoFactorButton: "Disable 2FA",
|
||||||
|
disableTwoFactorLoading: "Disabling 2FA...",
|
||||||
|
finishSetupTitle: "Finish 2FA setup",
|
||||||
|
finishSetupHint: "Scan this QR code with your authenticator app, then enter the current 6-digit code to confirm.",
|
||||||
|
scanFallbackHint: "If scanning does not work, you can still use the manual setup key below.",
|
||||||
|
manualSetupKey: "Manual setup key",
|
||||||
|
setupUriLabel: "Setup URI",
|
||||||
|
setupUriHint: "This URI is used to generate the QR setup and can be copied as a fallback for compatible apps.",
|
||||||
|
authenticatorCodeLabel: "Authenticator code",
|
||||||
|
confirmAndEnableButton: "Confirm and enable",
|
||||||
|
confirmAndEnableLoading: "Enabling 2FA...",
|
||||||
|
cancelSetupTitle: "Cancel setup",
|
||||||
|
cancelSetupHint: "Cancel the current authenticator setup if you want to restart with a new secret.",
|
||||||
|
cancelSetupButton: "Cancel setup",
|
||||||
|
startSetupHint: "Protect your account with 6-digit codes from an authenticator app.",
|
||||||
|
startSetupHintFollowup: "After starting setup, you will get a QR code to scan and then confirm once with a live code.",
|
||||||
|
startSetupButton: "Start setup",
|
||||||
|
startSetupLoading: "Starting setup...",
|
||||||
|
changePasswordTitle: "Change password",
|
||||||
|
currentPasswordLabel: "Current password",
|
||||||
|
newPasswordLabel: "New password",
|
||||||
|
confirmPasswordLabel: "Confirm new password",
|
||||||
|
updatePasswordButton: "Update password",
|
||||||
|
updatePasswordLoading: "Updating password...",
|
||||||
|
qrLoadError: "QR preview could not be loaded.",
|
||||||
|
footerText: "School project about the Eight Queens and Eight Rooks problem.",
|
||||||
|
footerPrivacy: "Privacy Policy",
|
||||||
|
footerImprint: "Imprint",
|
||||||
|
footerNavAria: "Legal information"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const serverMessageTranslations = {
|
||||||
|
de: {
|
||||||
|
"Authenticator app 2FA is already enabled.": "Authenticator-App-2FA ist bereits aktiviert.",
|
||||||
|
"Add the secret to your authenticator app, then confirm it with the current 6-digit code.": "Füge den Schlüssel zu deiner Authenticator-App hinzu und bestätige ihn dann mit dem aktuellen 6-stelligen Code.",
|
||||||
|
"Start the authenticator setup first.": "Starte zuerst die Authenticator-Einrichtung.",
|
||||||
|
"Invalid authenticator code. Please try the current code again.": "Ungültiger Authenticator-Code. Bitte versuche den aktuellen Code erneut.",
|
||||||
|
"Authenticator app 2FA enabled.": "Authenticator-App-2FA wurde aktiviert.",
|
||||||
|
"Authenticator app setup cancelled.": "Die Einrichtung der Authenticator-App wurde abgebrochen.",
|
||||||
|
"Authenticator app 2FA disabled.": "Authenticator-App-2FA wurde deaktiviert.",
|
||||||
|
"Your current password is incorrect.": "Dein aktuelles Passwort ist falsch.",
|
||||||
|
"The new passwords do not match.": "Die neuen Passwörter stimmen nicht überein.",
|
||||||
|
"Password updated successfully.": "Das Passwort wurde erfolgreich aktualisiert.",
|
||||||
|
"Unknown settings action.": "Unbekannte Einstellungsaktion."
|
||||||
|
},
|
||||||
|
en: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentLanguage = AppLanguage.DEFAULT_LANGUAGE;
|
||||||
|
|
||||||
|
function t(key) {
|
||||||
|
return translations[currentLanguage]?.[key] ?? translations.en[key] ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function translateServerMessage(message) {
|
||||||
|
const originalMessage = String(message || "").trim();
|
||||||
|
|
||||||
|
if (!originalMessage || currentLanguage === "en") {
|
||||||
|
return originalMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exactMatch = serverMessageTranslations[currentLanguage]?.[originalMessage];
|
||||||
|
|
||||||
|
if (exactMatch) {
|
||||||
|
return exactMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minLengthMatch = originalMessage.match(/^Password must be at least (\d+) characters long\.$/);
|
||||||
|
|
||||||
|
if (minLengthMatch) {
|
||||||
|
return `Das Passwort muss mindestens ${minLengthMatch[1]} Zeichen lang sein.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxLengthMatch = originalMessage.match(/^Password must be at most (\d+) characters long\.$/);
|
||||||
|
|
||||||
|
if (maxLengthMatch) {
|
||||||
|
return `Das Passwort darf höchstens ${maxLengthMatch[1]} Zeichen lang sein.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTextTranslations() {
|
||||||
|
AppLanguage.applyTranslations({
|
||||||
|
translate: (key) => t(key),
|
||||||
|
attributeMappings: [
|
||||||
|
{
|
||||||
|
selector: "[data-i18n-aria-label]",
|
||||||
|
datasetKey: "i18nAriaLabel",
|
||||||
|
attribute: "aria-label"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: "[data-i18n-loading-label]",
|
||||||
|
datasetKey: "i18nLoadingLabel",
|
||||||
|
attribute: "data-loading-label"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-i18n-loading-label]").forEach((element) => {
|
||||||
|
if (element.dataset.loadingLabelApplied === "true") {
|
||||||
|
element.textContent = element.dataset.loadingLabel;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll(".message").forEach((element) => {
|
||||||
|
if (!element.dataset.originalText) {
|
||||||
|
element.dataset.originalText = element.textContent.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
element.textContent = translateServerMessage(element.dataset.originalText);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const languageController = AppLanguage.createController({
|
||||||
|
button: langSwitch,
|
||||||
|
onApply(language, helpers) {
|
||||||
|
currentLanguage = language;
|
||||||
|
applyTextTranslations();
|
||||||
|
helpers.updateLanguageSwitch({
|
||||||
|
ariaLabel: t("languageSwitchAria"),
|
||||||
|
title: currentLanguage === "de" ? "Switch to English" : "Zu Deutsch wechseln"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
languageController.init();
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
const magneticButtons = document.querySelectorAll(".magnetic");
|
const magneticButtons = document.querySelectorAll(".magnetic");
|
||||||
|
|
||||||
|
// magnetic button effect for desktop user
|
||||||
magneticButtons.forEach((button) => {
|
magneticButtons.forEach((button) => {
|
||||||
button.addEventListener("mousemove", (event) => {
|
button.addEventListener("mousemove", (event) => {
|
||||||
const rect = button.getBoundingClientRect();
|
const rect = button.getBoundingClientRect();
|
||||||
@@ -37,8 +237,8 @@ document.querySelectorAll("form").forEach((form) => {
|
|||||||
|
|
||||||
if (loadingLabel) {
|
if (loadingLabel) {
|
||||||
submitter.dataset.loadingLabelApplied = "true";
|
submitter.dataset.loadingLabelApplied = "true";
|
||||||
submitter.dataset.originalLabel = submitter.innerText;
|
submitter.dataset.originalLabel = submitter.textContent;
|
||||||
submitter.innerText = loadingLabel;
|
submitter.textContent = loadingLabel;
|
||||||
submitter.style.opacity = "0.7";
|
submitter.style.opacity = "0.7";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -52,7 +252,7 @@ document.querySelectorAll(".qr-code[data-qr-value]").forEach((container) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window.QRCode !== "function") {
|
if (typeof window.QRCode !== "function") {
|
||||||
container.textContent = "QR preview could not be loaded.";
|
container.textContent = t("qrLoadError");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+179
-141
@@ -4,35 +4,37 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Dashboard</title>
|
<title data-i18n="pageTitle">Dashboard</title>
|
||||||
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/dashboard.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/dashboard.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/footer.css') }}">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body data-force-default-mode="{{ 'true' if force_default_dashboard_mode else 'false' }}">
|
||||||
<nav class="dashboard-nav">
|
<nav class="dashboard-nav">
|
||||||
<a class="brand" href="{{ url_for('main.home') }}">
|
<a class="brand" href="{{ url_for('main.home') }}">
|
||||||
<span class="brand-mark">♛</span>
|
<span class="brand-mark">♛</span>
|
||||||
<span>8-Damen- und 8-Türme Solver</span>
|
<span data-i18n="navBrand">8-Damen- und 8-Türme Solver</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="nav-actions">
|
<div class="nav-actions">
|
||||||
<a class="nav-link" href="{{ url_for('main.dashboard') }}" id="navDashboardLink">Dashboard</a>
|
<a class="nav-link" href="{{ url_for('main.dashboard') }}" id="navDashboardLink"
|
||||||
|
data-i18n="navDashboard">Dashboard</a>
|
||||||
|
|
||||||
<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') }}"
|
<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">Account</span>
|
<span class="account-label" id="accountLabel" data-i18n="accountLabel">Account</span>
|
||||||
<span class="account-name">{{ current_user.username }}</span>
|
<span class="account-name">{{ current_user.username }}</span>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<form action="{{ url_for('login.logout') }}" method="post" class="nav-logout-form">
|
<form action="{{ url_for('login.logout') }}" method="post" class="nav-logout-form">
|
||||||
<button class="nav-link-button" type="submit">Logout</button>
|
<button class="nav-link-button" type="submit" data-i18n="navLogout">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -40,7 +42,7 @@
|
|||||||
<button class="settings-trigger" id="settingsTrigger" onclick="toggleSidebar()" type="button"
|
<button class="settings-trigger" id="settingsTrigger" onclick="toggleSidebar()" type="button"
|
||||||
aria-controls="settingsSidebar" aria-expanded="false">
|
aria-controls="settingsSidebar" aria-expanded="false">
|
||||||
<span class="settings-trigger-arrow" id="settingsTriggerArrow">→</span>
|
<span class="settings-trigger-arrow" id="settingsTriggerArrow">→</span>
|
||||||
<span class="settings-trigger-label" id="settingsTriggerLabel">Brett-Einstellungen</span>
|
<span class="settings-trigger-label" id="settingsTriggerLabel">Spiel-Einstellungen</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -55,16 +57,22 @@
|
|||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
<section class="dashboard-intro card">
|
<section class="dashboard-intro card">
|
||||||
<p class="section-kicker" id="introKicker">Puzzle-Modus</p>
|
<p class="section-kicker" id="introKicker" data-i18n="introKicker">Puzzle-Modus</p>
|
||||||
<h1 id="introTitle">Wähl deinen Modus und löse das Brett auf deine Art.</h1>
|
<h1 id="introTitle" data-i18n="introTitle">Wähl deinen Modus und löse das Brett auf deine Art.</h1>
|
||||||
<p class="dashboard-intro-text" id="introText">
|
<p class="dashboard-intro-text" id="introText" data-i18n="introText">
|
||||||
Wechsle zwischen Damen und Türmen, nutze Animation oder Sofort-Lösung und öffne die
|
Wechsle zwischen Damen und Türmen, nutze Animation oder Sofort-Lösung und öffne die
|
||||||
Brett-Einstellungen jederzeit über den linken Seiten-Tab.
|
Brett-Einstellungen jederzeit über den linken Seiten-Tab.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mode-switch" aria-label="Spielmodus">
|
<div class="mode-switch" data-i18n-aria-label="modeSwitchAria" aria-label="Spielmodus">
|
||||||
<button class="btn active" onclick="setMode('queens')" id="btnQueens" type="button">♛ Damen</button>
|
<button class="btn secondary" onclick="setMode('rooks')" id="btnRooks" type="button">
|
||||||
<button class="btn secondary" onclick="setMode('rooks')" id="btnRooks" type="button">♜ Tuerme</button>
|
<span aria-hidden="true">♜</span>
|
||||||
|
<span data-i18n="btnRooks">Türme</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn active" onclick="setMode('queens')" id="btnQueens" type="button">
|
||||||
|
<span aria-hidden="true">♛</span>
|
||||||
|
<span data-i18n="btnQueens">Damen</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -74,12 +82,14 @@
|
|||||||
<p class="section-kicker" id="gameModeEyebrow">Damen-Modus</p>
|
<p class="section-kicker" id="gameModeEyebrow">Damen-Modus</p>
|
||||||
<h2 id="gameTitle">Damen-Spielbrett</h2>
|
<h2 id="gameTitle">Damen-Spielbrett</h2>
|
||||||
<p class="board-mode-text" id="boardModeText">
|
<p class="board-mode-text" id="boardModeText">
|
||||||
Platziere Damen so, dass keine zwei dieselbe Spalte oder Diagonale teilen.
|
Platziere Damen frei auf dem Brett. Konflikte entstehen in derselben Reihe, Spalte oder
|
||||||
|
Diagonale.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="info-button" id="infoButton" onclick="toggleInfoModal(true)" type="button"
|
<button class="info-button" id="infoButton" onclick="toggleInfoModal(true)" type="button"
|
||||||
aria-label="Spielregeln und Hilfe anzeigen">Regeln & Hilfe</button>
|
data-i18n="infoButton" data-i18n-aria-label="infoButton"
|
||||||
|
aria-label="Spielregeln und Hilfe anzeigen">Spielanleitung</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="board-size-info" id="boardSizeInfo"><span id="boardSizeInfoPrefix">Aktuelle Brettgrösse im
|
<p class="board-size-info" id="boardSizeInfo"><span id="boardSizeInfoPrefix">Aktuelle Brettgrösse im
|
||||||
@@ -87,17 +97,17 @@
|
|||||||
<span id="boardSizeValue">8</span> x <span id="boardSizeValueMirror">8</span>
|
<span id="boardSizeValue">8</span> x <span id="boardSizeValueMirror">8</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="game-stats" aria-label="Spielstatus">
|
<div class="game-stats" data-i18n-aria-label="gameStatsAria" aria-label="Spielstatus">
|
||||||
<article class="game-stat-card">
|
<article class="game-stat-card">
|
||||||
<p class="game-stat-label" id="stepsLabel">Schritte</p>
|
<p class="game-stat-label" id="stepsLabel" data-i18n="stepsLabel">Schritte</p>
|
||||||
<p class="game-stat-value" id="stepCounterValue">0</p>
|
<p class="game-stat-value" id="stepCounterValue">0</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="game-stat-card">
|
<article class="game-stat-card">
|
||||||
<p class="game-stat-label" id="timeLabel">Zeit</p>
|
<p class="game-stat-label" id="timeLabel" data-i18n="timeLabel">Zeit</p>
|
||||||
<p class="game-stat-value" id="timerValue">00:00:00</p>
|
<p class="game-stat-value" id="timerValue">00:00:00</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="game-stat-card">
|
<article class="game-stat-card">
|
||||||
<p class="game-stat-label" id="statusLabel">Status</p>
|
<p class="game-stat-label" id="statusLabel" data-i18n="statusLabel">Status</p>
|
||||||
<p class="game-stat-value game-stat-status" id="gameProgressState">Läuft</p>
|
<p class="game-stat-value game-stat-status" id="gameProgressState">Läuft</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,67 +119,31 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="board" id="board"></div>
|
<div class="board-stage">
|
||||||
|
<div class="board-shell">
|
||||||
<div class="controls">
|
<div id="board"></div>
|
||||||
<button class="btn secondary" onclick="resetBoard()" id="btnReset" type="button">Brett
|
|
||||||
zuruecksetzen</button>
|
|
||||||
<div class="controls-save-row">
|
|
||||||
<button class="btn" onclick="openSavePanel()" id="btnSave" type="button">Speichern</button>
|
|
||||||
<button class="btn secondary" onclick="openSavePointsPanel()" id="btnLoad"
|
|
||||||
type="button">Save-Points</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="board-navigator" id="boardNavigator" hidden>
|
||||||
|
<button class="btn secondary board-nav-button" onclick="previousSolverStep()" id="btnPrevStep"
|
||||||
|
type="button" data-i18n="btnPrevStep">
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
<p class="solver-step-info" id="solverStepInfo">Schritt 0 / 0</p>
|
||||||
|
<button class="btn secondary board-nav-button" onclick="nextSolverStep()" id="btnNextStep"
|
||||||
|
type="button" data-i18n="btnNextStep">
|
||||||
|
Vorwärts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="solver-message solver-message-inline" id="solverMessage">Lade eine Animation und steuere sie
|
||||||
|
am Brett oder spiele sie automatisch ab.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="solver-panel">
|
<div class="controls">
|
||||||
<div class="solver-panel-header">
|
<button class="btn secondary" onclick="resetBoard()" id="btnReset" type="button"
|
||||||
<div>
|
data-i18n="btnReset">Brett
|
||||||
<p class="section-kicker" id="solverKicker">Solver</p>
|
zurücksetzen</button>
|
||||||
<h3 id="solverTitle">Lösungswerkzeuge</h3>
|
|
||||||
<p class="solver-message" id="solverMessage">Bereite einen Ablauf vor und spiele ihn Schritt
|
|
||||||
für Schritt ab.</p>
|
|
||||||
</div>
|
|
||||||
<p class="solver-step-info" id="solverStepInfo">Schritt 0 / 0</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="solver-groups">
|
|
||||||
<section class="solver-group">
|
|
||||||
<h4 id="animationSolverTitle">Animations-Solver</h4>
|
|
||||||
<p class="solver-group-text" id="animationSolverText">
|
|
||||||
Bereite den Lösungsweg vor, gehe ihn manüll Schritt für Schritt durch oder spiele die
|
|
||||||
Animation automatisch ab.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="solver-controls">
|
|
||||||
<button class="btn" onclick="prepareSolutionTrace()" id="btnPrepareSolve"
|
|
||||||
type="button">Animation vorbereiten</button>
|
|
||||||
<button class="btn secondary" onclick="nextSolverStep()" id="btnNextStep"
|
|
||||||
type="button">Nächster Schritt</button>
|
|
||||||
<button class="btn secondary" onclick="toggleSolverPlayback()" id="btnPlayPause"
|
|
||||||
type="button">Animation abspielen</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="solver-speed">
|
|
||||||
<label class="solver-speed-label" id="solverSpeedLabel"
|
|
||||||
for="solverSpeedInput">Animationsgeschwindigkeit</label>
|
|
||||||
<input id="solverSpeedInput" type="range" min="80" max="900" step="20" value="320"
|
|
||||||
oninput="updateSolverSpeed(this.value)">
|
|
||||||
<span class="solver-speed-value" id="solverSpeedValue">320 ms</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="solver-group instant-solver-group">
|
|
||||||
<h4 id="instantSolverTitle">Sofort-Lösung</h4>
|
|
||||||
<p class="solver-group-text" id="instantSolverText">
|
|
||||||
Lade direkt eine gültige Lösung, ohne Zwischenschritte oder Animation.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="instant-solver-actions">
|
|
||||||
<button class="btn accent" onclick="instantSolve()" id="btnInstantSolve"
|
|
||||||
type="button">Sofort lösen</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -184,32 +158,34 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="history-header">
|
<div class="history-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="section-kicker" id="historyKicker">Save-Points</p>
|
<p class="section-kicker" id="historyKicker" data-i18n="historyKicker">Save-Points</p>
|
||||||
<h3 id="historyTitle">Gespeicherte Zeiten und Fortschritte</h3>
|
<h3 id="historyTitle" data-i18n="historyTitle">Gespeicherte Zeiten und Fortschritte</h3>
|
||||||
<p class="history-subtitle" id="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">Aktualisieren</button>
|
data-i18n="btnRefreshHistory">Aktualisieren</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="history-table-wrapper">
|
<div class="history-table-wrapper">
|
||||||
<table class="history-table">
|
<table class="history-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th id="historyHeadSave">Save-Point</th>
|
<th id="historyHeadSave" data-i18n="historyHeadSave">Save-Point</th>
|
||||||
<th id="historyHeadMode">Modus</th>
|
<th id="historyHeadMode" data-i18n="historyHeadMode">Modus</th>
|
||||||
<th id="historyHeadBoard">Brett</th>
|
<th id="historyHeadBoard" data-i18n="historyHeadBoard">Brett</th>
|
||||||
<th id="historyHeadSteps">Schritte</th>
|
<th id="historyHeadSteps" data-i18n="historyHeadSteps">Schritte</th>
|
||||||
<th id="historyHeadTime">Zeit</th>
|
<th id="historyHeadTime" data-i18n="historyHeadTime">Zeit</th>
|
||||||
<th id="historyHeadStatus">Status</th>
|
<th id="historyHeadStatus" data-i18n="historyHeadStatus">Status</th>
|
||||||
<th id="historyHeadSaved">Gespeichert</th>
|
<th id="historyHeadSaved" data-i18n="historyHeadSaved">Gespeichert</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="timeHistoryTableBody">
|
<tbody id="timeHistoryTableBody">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" class="history-empty">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>
|
||||||
@@ -217,92 +193,147 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% include "partials/footer.html" %}
|
||||||
|
|
||||||
<aside class="sidebar settings-sidebar" id="settingsSidebar" aria-hidden="true">
|
<aside class="sidebar settings-sidebar" id="settingsSidebar" aria-hidden="true">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<h3 id="settingsSidebarTitle">Brett-Einstellungen</h3>
|
<h3 id="settingsSidebarTitle" data-i18n="settingsSidebarTitle">Spiel-Einstellungen</h3>
|
||||||
<button class="btn secondary sidebar-close" onclick="toggleSidebar(false)" id="settingsSidebarClose"
|
<button class="btn secondary sidebar-close" onclick="toggleSidebar(false)" id="settingsSidebarClose"
|
||||||
type="button">Schliessen</button>
|
type="button" data-i18n="settingsSidebarClose">Schliessen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p id="settingsSidebarText">Passe die Brettgrösse zwischen 4 und 13 an.</p>
|
<section class="settings-section">
|
||||||
|
<p class="section-kicker" id="boardSettingsKicker" data-i18n="boardSettingsKicker">Brett</p>
|
||||||
|
<h4 id="boardSettingsTitle" data-i18n="boardSettingsTitle">Brettgrösse</h4>
|
||||||
|
<p id="settingsSidebarText" data-i18n="settingsSidebarText">Passe die Brettgrösse zwischen 4 und 13 an.</p>
|
||||||
|
|
||||||
<label class="sidebar-label" id="boardSizeInputLabel" for="boardSizeInput">Grösse</label>
|
<label class="sidebar-label" id="boardSizeInputLabel" for="boardSizeInput"
|
||||||
<input id="boardSizeInput" type="range" min="4" max="13" value="8" oninput="previewBoardSize(this.value)">
|
data-i18n="boardSizeInputLabel">Grösse</label>
|
||||||
<div class="sidebar-size-row">
|
<input id="boardSizeInput" type="range" min="4" max="13" value="8" oninput="previewBoardSize(this.value)">
|
||||||
<strong><span id="sidebarBoardSizeValue">8</span> x <span id="sidebarBoardSizeValueMirror">8</span></strong>
|
<div class="sidebar-size-row">
|
||||||
</div>
|
<strong><span id="sidebarBoardSizeValue">8</span> x <span
|
||||||
|
id="sidebarBoardSizeValueMirror">8</span></strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="btn" onclick="applyBoardSize()" id="applyBoardSizeButton" type="button">Grösse anwenden</button>
|
<button class="btn" onclick="applyBoardSize()" id="applyBoardSizeButton" type="button"
|
||||||
<p class="sidebar-hint" id="settingsSidebarHint">Beim Ändern wird das Brett zur Sicherheit zurückgesetzt. Auf
|
data-i18n="applyBoardSizeButton">Grösse anwenden</button>
|
||||||
dem Handy ist 8x8 eingeplant, grössere Bretter können abgeschnitten wirken.</p>
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-section">
|
||||||
|
<p class="section-kicker" id="saveSettingsKicker" data-i18n="saveSettingsKicker">Speichern</p>
|
||||||
|
<h4 id="saveSettingsTitle" data-i18n="saveSettingsTitle">Fortschritt speichern</h4>
|
||||||
|
<p id="saveSettingsText" data-i18n="saveSettingsText">Öffne hier die Optionen zum Speichern oder Laden
|
||||||
|
deiner Save-Points.</p>
|
||||||
|
|
||||||
|
<div class="settings-action-grid">
|
||||||
|
<button class="btn" onclick="openSavePanel()" id="btnSave" type="button"
|
||||||
|
data-i18n="btnSave">Speichern</button>
|
||||||
|
<button class="btn secondary" onclick="openSavePointsPanel()" id="btnLoad" type="button"
|
||||||
|
data-i18n="btnLoad">Save-Points</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-section">
|
||||||
|
<p class="section-kicker" id="solutionSettingsKicker" data-i18n="solutionSettingsKicker">Lösung</p>
|
||||||
|
<h4 id="solutionSettingsTitle" data-i18n="solutionSettingsTitle">Lösungswerkzeuge</h4>
|
||||||
|
<p id="solutionSettingsText" data-i18n="solutionSettingsText">Lade eine Animation, steuere sie am Brett mit
|
||||||
|
Vorwärts und Zurück oder spiele sie automatisch ab.</p>
|
||||||
|
|
||||||
|
<div class="settings-action-grid">
|
||||||
|
<button class="btn" onclick="prepareSolutionTrace()" id="btnPrepareSolve" type="button"
|
||||||
|
data-i18n="btnPrepareSolve">Animation laden</button>
|
||||||
|
<button class="btn secondary" onclick="toggleSolverPlayback()" id="btnPlayPause" type="button"
|
||||||
|
data-i18n="btnPlayPause">Animation abspielen</button>
|
||||||
|
<button class="btn accent" onclick="instantSolve()" id="btnInstantSolve" type="button"
|
||||||
|
data-i18n="btnInstantSolve">Sofort lösen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="solver-speed">
|
||||||
|
<label class="solver-speed-label" id="solverSpeedLabel" for="solverSpeedInput"
|
||||||
|
data-i18n="solverSpeedLabel">Animationsgeschwindigkeit</label>
|
||||||
|
<input id="solverSpeedInput" type="range" min="80" max="900" step="20" value="320"
|
||||||
|
oninput="updateSolverSpeed(this.value)">
|
||||||
|
<span class="solver-speed-value" id="solverSpeedValue">320 ms</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<aside class="sidebar save-sidebar" id="saveSidebar" aria-hidden="true">
|
<aside class="sidebar save-sidebar" id="saveSidebar" aria-hidden="true">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<h3 id="saveSidebarTitle">Speicherpunkt</h3>
|
<h3 id="saveSidebarTitle" data-i18n="saveSidebarTitle">Speicherpunkt</h3>
|
||||||
<button class="btn secondary sidebar-close" onclick="toggleSavePanel(false)" id="saveSidebarClose"
|
<button class="btn secondary sidebar-close" onclick="toggleSavePanel(false)" id="saveSidebarClose"
|
||||||
type="button">Schliessen</button>
|
type="button" data-i18n="saveSidebarClose">Schliessen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p id="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">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"
|
||||||
placeholder="z. B. Fast gelöste Dame 8x8">
|
placeholder="z. B. Fast gelöste Dame 8x8" data-i18n-placeholder="saveNamePlaceholder">
|
||||||
|
|
||||||
<label class="sidebar-label" id="saveNoteLabel" for="saveNoteInput">Notiz</label>
|
<label class="sidebar-label" id="saveNoteLabel" for="saveNoteInput" data-i18n="saveNoteLabel">Notiz</label>
|
||||||
<textarea class="sidebar-input sidebar-textarea" id="saveNoteInput" rows="4" maxlength="240"
|
<textarea class="sidebar-input sidebar-textarea" id="saveNoteInput" rows="4" maxlength="240"
|
||||||
placeholder="z. B. Gute Startposition für Türmetest"></textarea>
|
placeholder="z. B. Gute Startposition für Türmetest" data-i18n-placeholder="saveNotePlaceholder"></textarea>
|
||||||
|
|
||||||
<label class="sidebar-checkbox">
|
<label class="sidebar-checkbox">
|
||||||
<input id="saveFavoriteInput" type="checkbox">
|
<input id="saveFavoriteInput" type="checkbox">
|
||||||
<span id="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">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">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<h3 id="savePointsSidebarTitle">Save-Points</h3>
|
<h3 id="savePointsSidebarTitle" data-i18n="savePointsSidebarTitle">Save-Points</h3>
|
||||||
<button class="btn secondary sidebar-close" onclick="toggleSavePointsPanel(false)"
|
<button class="btn secondary sidebar-close" onclick="toggleSavePointsPanel(false)"
|
||||||
id="savePointsSidebarClose" type="button">Schliessen</button>
|
id="savePointsSidebarClose" type="button" data-i18n="savePointsSidebarClose">Schliessen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p id="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">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">Alle</option>
|
<option value="all" data-i18n="filterAll">Alle</option>
|
||||||
<option value="queens">Damen</option>
|
<option value="queens" data-i18n="filterQueens">Damen</option>
|
||||||
<option value="rooks">Türme</option>
|
<option value="rooks" data-i18n="filterRooks">Türme</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<label class="sidebar-label" id="savePointSizeFilterLabel" for="savePointSizeFilter">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">Alle</option>
|
<option value="all" data-i18n="filterAll">Alle</option>
|
||||||
{% for board_size in range(4, 14) %}
|
{% for board_size in range(4, 14) %}
|
||||||
<option value="{{ board_size }}">{{ board_size }} x {{ board_size }}</option>
|
<option value="{{ board_size }}">{{ board_size }} x {{ board_size }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<label class="sidebar-label" id="savePointSortFilterLabel" for="savePointSortFilter">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">Neueste zuerst</option>
|
<option value="newest" data-i18n="filterNewest">Neueste zuerst</option>
|
||||||
<option value="oldest">älteste zuerst</option>
|
<option value="oldest" data-i18n="filterOldest">älteste zuerst</option>
|
||||||
<option value="updated">Zuletzt bearbeitet</option>
|
<option value="updated" data-i18n="filterUpdated">Zuletzt bearbeitet</option>
|
||||||
<option value="favorites">Favoriten zuerst</option>
|
<option value="favorites" data-i18n="filterFavorites">Favoriten zuerst</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<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">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">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>
|
||||||
|
|
||||||
@@ -313,31 +344,38 @@
|
|||||||
<div class="modal-backdrop" id="infoModalBackdrop" onclick="toggleInfoModal(false)"></div>
|
<div class="modal-backdrop" id="infoModalBackdrop" onclick="toggleInfoModal(false)"></div>
|
||||||
<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">Spielanleitung</h3>
|
||||||
<button class="btn secondary sidebar-close" onclick="toggleInfoModal(false)"
|
<button class="btn secondary sidebar-close" onclick="toggleInfoModal(false)" type="button"
|
||||||
type="button">Schliessen</button>
|
data-i18n="infoModalClose">Schliessen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<p>Setze pro Reihe genau eine Figur auf das Brett. Eine gültige Lösung hat keine Konflikte.</p>
|
<p data-i18n="infoModalIntro">Das Schachbrett besteht aus vielen quadratischen Feldern. Jedes Feld kann
|
||||||
|
leer sein oder genau eine Figur enthalten. Deine Aufgabe ist es, Figuren auf das Brett zu setzen,
|
||||||
|
ohne dass sie sich gegenseitig angreifen. Das Puzzle ist erst gelöst, wenn genau so viele Figuren auf
|
||||||
|
dem Brett stehen wie das Brett Felder pro Seite hat, also zum Beispiel 8 Figuren auf einem
|
||||||
|
8x8-Brett.</p>
|
||||||
|
|
||||||
<h4>So funktioniert der aktuelle Modus</h4>
|
<h4 data-i18n="infoModalModeHeading">Grundregeln</h4>
|
||||||
<p id="infoModeRuleText">Im Damen-Modus dürfen sich Damen weder in derselben Spalte noch diagonal
|
<p id="infoModeRuleText">In diesem Modus spielst du mit Damen. Eine Dame ist die Figur mit dem Symbol ♛.
|
||||||
angreifen. Mit einem Klick setzt oder entfernst du eine Figur.</p>
|
Sie darf keine andere Dame in derselben waagerechten Reihe, senkrechten Spalte oder schrägen
|
||||||
|
Diagonale treffen. Mit einem Klick setzt du eine Dame auf ein freies Feld oder entfernst sie wieder.</p>
|
||||||
|
|
||||||
<h4 id="infoModalProblemHeading">Was ist das Acht-Damen-Problem?</h4>
|
<h4 id="infoModalProblemHeading">Was ist eine Dame?</h4>
|
||||||
<p id="infoModalProblemText">Das klassische Acht-Damen-Problem fragt nach einer Anordnung von acht Damen
|
<p id="infoModalProblemText">Eine Dame ist eine Schachfigur. In diesem Spiel musst du keine Schachregeln
|
||||||
auf einem 8x8-Brett, bei der sich keine zwei Damen schlagen. Es ist ein bekanntes Beispiel für
|
lernen, sondern nur wissen: Eine Dame kann gerade und schräg über das Brett wirken. Darum müssen
|
||||||
kombinatorische Suche und Backtracking.</p>
|
Damen genug Abstand haben, damit sie sich nicht gegenseitig erreichen.</p>
|
||||||
|
|
||||||
<h4>Was zeigen Zeit und Schritte?</h4>
|
<h4 data-i18n="infoModalTimeHeading">Was zeigen Zeit und Schritte?</h4>
|
||||||
<p>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>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/language.js') }}" defer></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/dashboard.js') }}" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title data-i18n="pageTitle">Impressum</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/legal.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/footer.css') }}">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body data-legal-page="imprint">
|
||||||
|
<header class="legal-header">
|
||||||
|
<nav class="legal-nav">
|
||||||
|
<a class="brand" href="{{ url_for('main.home') }}">
|
||||||
|
<span class="brand-mark">♛</span>
|
||||||
|
<span data-i18n="navBrand">8-Damen- und 8-Türme Solver</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="legal-nav-links">
|
||||||
|
<button class="lang-switch" id="langSwitch" type="button" aria-label="Sprache wechseln">DE</button>
|
||||||
|
<a href="{{ url_for('main.home') }}" data-i18n="navHome">Startseite</a>
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<a href="{{ url_for('main.dashboard') }}" data-i18n="navDashboard">Dashboard</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('login.login') }}" data-i18n="navLogin">Login</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="legal-main">
|
||||||
|
<section class="legal-hero">
|
||||||
|
<p class="legal-eyebrow" data-i18n="heroEyebrow">Rechtliche Hinweise</p>
|
||||||
|
<h1 data-i18n="heroTitle">Impressum</h1>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="legal-card">
|
||||||
|
<h2 data-i18n="contactTitle">Kontakt</h2>
|
||||||
|
<p>
|
||||||
|
<strong data-i18n="contactName">Jérôme Bachmann</strong><br>
|
||||||
|
<span data-i18n="contactEmailLabel">E-Mail:</span> jerome.bachmann@bachmannj.ch<br>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="legal-card">
|
||||||
|
<h2 data-i18n="projectResponsibilityTitle">Projektverantwortung</h2>
|
||||||
|
<p data-i18n="projectResponsibilityText">
|
||||||
|
Dieses Webprojekt wurde im Rahmen eines Schulprojekts zum Acht-Damen- und Acht-Türme-Problem
|
||||||
|
erstellt. Inhaltlich verantwortlich ist die jeweils oben genannte verantwortliche Stelle.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="legal-card">
|
||||||
|
<h2 data-i18n="contentLiabilityTitle">Haftung für Inhalte</h2>
|
||||||
|
<p data-i18n="contentLiabilityText">
|
||||||
|
Die Inhalte dieser Website wurden mit Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und
|
||||||
|
Aktualität der Inhalte kann jedoch keine Gewähr übernommen werden. Bei Bekanntwerden von
|
||||||
|
Rechtsverletzungen werden entsprechende Inhalte umgehend überarbeitet oder entfernt.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="legal-card">
|
||||||
|
<h2 data-i18n="linksLiabilityTitle">Haftung fuer Links</h2>
|
||||||
|
<p data-i18n="linksLiabilityText">
|
||||||
|
Diese Website kann Links zu externen Angeboten enthalten. Fuer deren Inhalte sind ausschliesslich die
|
||||||
|
jeweiligen Betreiber verantwortlich. Eine permanente inhaltliche Kontrolle verlinkter Seiten ist ohne
|
||||||
|
konkrete Anhaltspunkte einer Rechtsverletzung nicht zumutbar.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="legal-card">
|
||||||
|
<h2 data-i18n="versionTitle">Stand</h2>
|
||||||
|
<p data-i18n="versionText">Stand dieser Vorlage: April 2026</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{% include "partials/footer.html" %}
|
||||||
|
<script src="{{ url_for('static', filename='js/language.js') }}" defer></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/legal.js') }}" defer></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
+40
-46
@@ -4,24 +4,25 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title data-i18n="meta.title">8-Damen- und 8-Tuerme Solver</title>
|
<title data-i18n="meta.title">8-Damen- und 8-Türme Solver</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/footer.css') }}">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<header class="landing-nav">
|
<header class="landing-nav">
|
||||||
<a class="brand" href="{{ url_for('main.home') }}">
|
<a class="brand" href="{{ url_for('main.home') }}">
|
||||||
<span class="brand-mark">♛</span>
|
<span class="brand-mark">♛</span>
|
||||||
<span data-i18n="nav.brand">8-Damen- und 8-Tuerme Solver</span>
|
<span data-i18n="nav.brand">8-Damen- und 8-Türme Solver</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<nav class="landing-links">
|
<nav class="landing-links">
|
||||||
<a href="#problem" data-i18n="nav.problem">Problem</a>
|
<a href="#problem" data-i18n="nav.problem">Problem</a>
|
||||||
<a href="#features" data-i18n="nav.features">Features</a>
|
<a href="#features" data-i18n="nav.features">Funktionen</a>
|
||||||
<a href="#journey" data-i18n="nav.journey">Ablauf</a>
|
<a href="#journey" data-i18n="nav.journey">Ablauf</a>
|
||||||
<button class="lang-switch" id="langSwitch" type="button" aria-label="Sprache wechseln">DE</button>
|
<button class="lang-switch" id="langSwitch" type="button" aria-label="Sprache wechseln">DE</button>
|
||||||
<a class="nav-cta secondary" href="{{ url_for('login.login') }}" data-i18n="nav.login">Login</a>
|
<a class="nav-cta secondary" href="{{ url_for('login.login') }}" data-i18n="nav.login">Anmelden</a>
|
||||||
<a class="nav-cta" href="{{ url_for('login.register') }}" data-i18n="nav.signup">Create Account</a>
|
<a class="nav-cta" href="{{ url_for('login.register') }}" data-i18n="nav.signup">Account erstellen</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -32,42 +33,28 @@
|
|||||||
</p>
|
</p>
|
||||||
<h1 data-i18n="hero.title">Entdecke das Acht-Damen- und Acht-Türme-Problem.</h1>
|
<h1 data-i18n="hero.title">Entdecke das Acht-Damen- und Acht-Türme-Problem.</h1>
|
||||||
<p class="hero-text" data-i18n="hero.text">
|
<p class="hero-text" data-i18n="hero.text">
|
||||||
Diese App macht aus einem klassischen Informatik- und Mathematikproblem ein interaktives Erlebnis:
|
Diese Webanwendung macht aus einem klassischen Informatik- und Mathematikproblem ein interaktives
|
||||||
|
Erlebnis:
|
||||||
Setze Damen oder Türme, verfolge den Solver Schritt für Schritt, miss deine Zeit und speichere
|
Setze Damen oder Türme, verfolge den Solver Schritt für Schritt, miss deine Zeit und speichere
|
||||||
clevere Zwischenstände.
|
Zwischenstände.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a class="hero-button primary" href="{{ url_for('login.register') }}"
|
<a class="hero-button primary" href="{{ url_for('login.register') }}"
|
||||||
data-i18n="hero.primaryCta">Jetzt starten</a>
|
data-i18n="hero.primaryCta">Jetzt starten</a>
|
||||||
<a class="hero-button secondary" href="{{ url_for('login.login') }}"
|
<a class="hero-button secondary" href="{{ url_for('login.login') }}"
|
||||||
data-i18n="hero.secondaryCta">Bereits Account? Login</a>
|
data-i18n="hero.secondaryCta">Bereits ein Account? Anmelden</a>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hero-facts">
|
|
||||||
<article>
|
|
||||||
<strong data-i18n="hero.fact1Title">Skalierbar</strong>
|
|
||||||
<span data-i18n="hero.fact1Text">Brettgrösse flexibel anpassen</span>
|
|
||||||
</article>
|
|
||||||
<article>
|
|
||||||
<strong data-i18n="hero.fact2Title">Schritt für Schritt</strong>
|
|
||||||
<span data-i18n="hero.fact2Text">Solver mit Animation</span>
|
|
||||||
</article>
|
|
||||||
<article>
|
|
||||||
<strong data-i18n="hero.fact3Title">Save-Points</strong>
|
|
||||||
<span data-i18n="hero.fact3Text">Zeit und Fortschritt behalten</span>
|
|
||||||
</article>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hero-visual">
|
<div class="hero-visual">
|
||||||
<div class="board-showcase" id="heroBoard" aria-hidden="true"></div>
|
<div class="board-showcase" id="heroBoard" aria-hidden="true"></div>
|
||||||
<div class="floating-card solver-card">
|
<div class="floating-card solver-card">
|
||||||
<p class="floating-label" data-i18n="hero.solverLabel">Solver</p>
|
<p class="floating-label" data-i18n="hero.solverLabel">Lösungshelfer</p>
|
||||||
<p class="floating-value" data-i18n="hero.solverValue">Backtracking live</p>
|
<p class="floating-value" data-i18n="hero.solverValue">Backtracking in Echtzeit</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="floating-card timer-card">
|
<div class="floating-card timer-card">
|
||||||
<p class="floating-label" data-i18n="hero.challengeLabel">Challenge</p>
|
<p class="floating-label" data-i18n="hero.challengeLabel">Herausforderung</p>
|
||||||
<p class="floating-value" data-i18n="hero.challengeValue">Zeit und Schritte messen</p>
|
<p class="floating-value" data-i18n="hero.challengeValue">Zeit und Schritte messen</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,17 +62,21 @@
|
|||||||
|
|
||||||
<section class="info-band" id="problem">
|
<section class="info-band" id="problem">
|
||||||
<div class="info-band-inner">
|
<div class="info-band-inner">
|
||||||
<p class="eyebrow" data-i18n="problem.eyebrow">Warum dieses Problem spannend ist</p>
|
<p class="eyebrow" data-i18n="problem.eyebrow">Was ist das Acht-Damen-Problem?</p>
|
||||||
<h2 data-i18n="problem.title">Die Acht-Damen- und Acht-Tuerme-Probleme sind mehr als Schachrätsel.</h2>
|
<h2 data-i18n="problem.title">Eine klassische schachmathematische Aufgabe mit überraschend vielen
|
||||||
|
Lösungen.</h2>
|
||||||
<p data-i18n="problem.text">
|
<p data-i18n="problem.text">
|
||||||
Beim Acht-Damen-Problem suchst du eine Anordnung von acht Damen auf einem Schachbrett, sodass sich
|
Das Acht-Damen-Problem ist eine schachmathematische Aufgabe: Acht Damen sollen auf einem
|
||||||
keine zwei Damen schlagen. Beim Acht-Türme-Problem gilt dieselbe Idee für Türme, nur mit
|
Schachbrett so aufgestellt werden, dass keine zwei Damen dieselbe Reihe, Spalte oder Diagonale
|
||||||
einfacheren Konfliktregeln. Beide Aufgaben zeigen, wie Mustererkennung, systematische Suche und
|
teilen. Das Problem lässt sich auf beliebige Brettgrössen verallgemeinern, indem n Damen auf einem
|
||||||
Backtracking funktionieren.
|
n x n-Brett platziert werden.
|
||||||
<br><br>
|
<br><br>
|
||||||
Gerade beim Damenproblem wird schnell sichtbar, warum Strategie wichtig ist: Es gibt unzählige
|
Für das klassische 8x8-Brett gibt es 92 Lösungen. Zählt man Stellungen, die nur durch Spiegelung
|
||||||
mögliche Platzierungen, aber nur sehr wenige wirklich gültige Lösungen. Genau das macht diese
|
oder Drehung entstehen, als gleich, bleiben 12 eindeutige Lösungen übrig.
|
||||||
App spannend, weil man nicht nur das Ergebnis sieht, sondern auch den Weg dorthin.
|
<br><br>
|
||||||
|
<a href="https://de-academic.com/dic.nsf/dewiki/13248" target="_blank"
|
||||||
|
rel="noopener noreferrer">Quelle:
|
||||||
|
de-academic.com</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -100,7 +91,7 @@
|
|||||||
<article class="feature-card">
|
<article class="feature-card">
|
||||||
<span class="feature-icon">♟</span>
|
<span class="feature-icon">♟</span>
|
||||||
<h3 data-i18n="features.card1Title">Interaktives Brett</h3>
|
<h3 data-i18n="features.card1Title">Interaktives Brett</h3>
|
||||||
<p data-i18n="features.card1Text">Setze Damen oder Tuerme direkt auf dem Brett und prüfe, ob deine
|
<p data-i18n="features.card1Text">Setze Damen oder Türme direkt auf dem Brett und prüfe, ob deine
|
||||||
Anordnung konfliktfrei ist.</p>
|
Anordnung konfliktfrei ist.</p>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@@ -108,7 +99,8 @@
|
|||||||
<span class="feature-icon">⚙</span>
|
<span class="feature-icon">⚙</span>
|
||||||
<h3 data-i18n="features.card2Title">Solver mit Animation</h3>
|
<h3 data-i18n="features.card2Title">Solver mit Animation</h3>
|
||||||
<p data-i18n="features.card2Text">Beobachte den Lösungsweg Schritt für Schritt und verstehe, wie
|
<p data-i18n="features.card2Text">Beobachte den Lösungsweg Schritt für Schritt und verstehe, wie
|
||||||
Backtracking arbeitet.</p>
|
Backtracking arbeitet.
|
||||||
|
</p>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="feature-card">
|
<article class="feature-card">
|
||||||
@@ -120,8 +112,8 @@
|
|||||||
|
|
||||||
<article class="feature-card">
|
<article class="feature-card">
|
||||||
<span class="feature-icon">💾</span>
|
<span class="feature-icon">💾</span>
|
||||||
<h3 data-i18n="features.card4Title">Save-Points</h3>
|
<h3 data-i18n="features.card4Title">Speicherstände</h3>
|
||||||
<p data-i18n="features.card4Text">Speichere Zwischenstaende mit Notizen, Zeiten und Schrittzahl für
|
<p data-i18n="features.card4Text">Speichere Zwischenstände mit Notizen, Zeiten und Schrittzahl für
|
||||||
späteres Weiterarbeiten.</p>
|
späteres Weiterarbeiten.</p>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@@ -144,8 +136,8 @@
|
|||||||
<article class="journey-step">
|
<article class="journey-step">
|
||||||
<span class="step-number">01</span>
|
<span class="step-number">01</span>
|
||||||
<h3 data-i18n="journey.step1Title">Account erstellen</h3>
|
<h3 data-i18n="journey.step1Title">Account erstellen</h3>
|
||||||
<p data-i18n="journey.step1Text">Registriere dich und erhalte Zugang zu Dashboard, Save-Points und
|
<p data-i18n="journey.step1Text">Registriere dich und erhalte Zugang zum Dashboard, zu
|
||||||
deinem Fortschritt.</p>
|
Speicherständen und zu deinem Fortschritt.</p>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="journey-step">
|
<article class="journey-step">
|
||||||
@@ -159,8 +151,7 @@
|
|||||||
<span class="step-number">03</span>
|
<span class="step-number">03</span>
|
||||||
<h3 data-i18n="journey.step3Title">Lösen oder analysieren</h3>
|
<h3 data-i18n="journey.step3Title">Lösen oder analysieren</h3>
|
||||||
<p data-i18n="journey.step3Text">Teste eigene Ideen oder lass dir die Lösung visuell vom Solver
|
<p data-i18n="journey.step3Text">Teste eigene Ideen oder lass dir die Lösung visuell vom Solver
|
||||||
lösen lassen, dabei wird 1:1
|
zeigen, damit du den Lösungsweg besser nachvollziehen kannst.</p>
|
||||||
gezeigt wie der Solver vorgeht.</p>
|
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -176,18 +167,21 @@
|
|||||||
|
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a class="hero-button primary" href="{{ url_for('login.register') }}"
|
<a class="hero-button primary" href="{{ url_for('login.register') }}"
|
||||||
data-i18n="cta.primaryCta">Create Account</a>
|
data-i18n="cta.primaryCta">Account erstellen</a>
|
||||||
<a class="hero-button secondary" href="{{ url_for('login.login') }}"
|
<a class="hero-button secondary" href="{{ url_for('login.login') }}"
|
||||||
data-i18n="cta.secondaryCta">Login</a>
|
data-i18n="cta.secondaryCta">Anmelden</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{% include "partials/footer.html" %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
window.LANDING_INITIAL_LANGUAGE = "{{ initial_language }}";
|
window.LANDING_INITIAL_LANGUAGE = "{{ initial_language }}";
|
||||||
</script>
|
</script>
|
||||||
<script src="{{ url_for('static', filename='js/index.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/language.js') }}" defer></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/index.js') }}" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -6,25 +6,27 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Login</title>
|
<title>Login</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/footer.css') }}">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<nav class="auth-nav">
|
<nav class="auth-nav">
|
||||||
<a class="brand" href="{{ url_for('main.home') }}">
|
<a class="brand" href="{{ url_for('main.home') }}">
|
||||||
<span class="brand-mark">♛</span>
|
<span class="brand-mark">♛</span>
|
||||||
<span>8-Damen- und 8-Tuerme Solver</span>
|
<span class="nav-brand" data-i18n="navBrand">8-Damen- und 8-Türme Solver</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<ul class="nav-links">
|
<ul class="nav-links">
|
||||||
<li><a href="{{ url_for('login.login') }}">Login</a></li>
|
<li><a href="{{ url_for('login.login') }}" data-i18n="navLogin">Login</a></li>
|
||||||
<li><a href="{{ url_for('login.register') }}">Create account</a></li>
|
<li><a href="{{ url_for('login.register') }}" data-i18n="navRegister">Create account</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="auth-shell">
|
<main class="auth-shell">
|
||||||
<section class="auth-panel">
|
<section class="auth-panel">
|
||||||
<h1>Login to your account</h1>
|
<h1>Login to your account</h1>
|
||||||
<p class="muted">If two-factor authentication is enabled, enter the current 6-digit code from your authenticator app after the password check.</p>
|
<p class="muted">If two-factor authentication is enabled, enter the current 6-digit code from your
|
||||||
|
authenticator app after the password check.</p>
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
@@ -50,6 +52,9 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{% include "partials/footer.html" %}
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/language.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Login Settings</title>
|
<title data-i18n="pageTitle">Login Settings</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/footer.css') }}">
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" defer></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -13,22 +14,33 @@
|
|||||||
<nav class="auth-nav">
|
<nav class="auth-nav">
|
||||||
<a class="brand" href="{{ url_for('main.home') }}">
|
<a class="brand" href="{{ url_for('main.home') }}">
|
||||||
<span class="brand-mark">♛</span>
|
<span class="brand-mark">♛</span>
|
||||||
<span>8-Damen- und 8-Tuerme Solver</span>
|
<span data-i18n="navBrand">8-Damen- und 8-Türme Solver</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<ul class="nav-links">
|
<ul class="nav-links">
|
||||||
<li><a href="{{ url_for('main.dashboard') }}">Dashboard</a></li>
|
<li><a href="{{ url_for('main.dashboard') }}" data-i18n="navDashboard">Dashboard</a></li>
|
||||||
<li><a href="{{ url_for('login.login_settings') }}">Login settings</a></li>
|
<li><a href="{{ url_for('login.login_settings') }}" data-i18n="navSettings">Login settings</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<form action="{{ url_for('login.logout') }}" method="post" class="nav-form">
|
<form action="{{ url_for('login.logout') }}" method="post" class="nav-form">
|
||||||
<button type="submit" class="nav-button">Logout</button>
|
<button type="submit" class="nav-button" id="logoutButton" data-i18n="navLogout">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="auth-shell settings-shell">
|
<main class="auth-shell settings-shell">
|
||||||
<section class="auth-panel settings-panel">
|
<section class="auth-panel settings-panel">
|
||||||
<h1>Login settings</h1>
|
<div class="settings-topbar">
|
||||||
<p class="muted">Manage authenticator app 2FA and your password without changing the registration flow.</p>
|
<a class="back-link" href="{{ url_for('main.dashboard') }}" data-i18n-aria-label="backToDashboardAria"
|
||||||
|
aria-label="Back to dashboard">
|
||||||
|
<span class="back-link-arrow" aria-hidden="true">←</span>
|
||||||
|
<span data-i18n="backToDashboard">Back to dashboard</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button class="nav-button lang-switch" id="langSwitch" type="button" aria-label="Switch language">DE</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 data-i18n="settingsTitle">Login settings</h1>
|
||||||
|
<p class="muted" data-i18n="settingsSubtitle">Manage authenticator app 2FA and your password without
|
||||||
|
changing the registration flow.</p>
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
@@ -47,81 +59,103 @@
|
|||||||
<form action="{{ url_for('login.login_settings') }}" method="post">
|
<form action="{{ url_for('login.login_settings') }}" method="post">
|
||||||
<input type="hidden" name="action" value="disable_two_factor">
|
<input type="hidden" name="action" value="disable_two_factor">
|
||||||
|
|
||||||
<h2>Authenticator app 2FA</h2>
|
<h2 data-i18n="twoFactorTitle">Authenticator app 2FA</h2>
|
||||||
<p class="field-hint">2FA is currently enabled for this account.</p>
|
<p class="field-hint" data-i18n="twoFactorEnabledHint">2FA is currently enabled for this account.</p>
|
||||||
<p class="field-hint">Use Google Authenticator, Microsoft Authenticator, Authy, or another TOTP app to generate your 6-digit login codes.</p>
|
<p class="field-hint" data-i18n="twoFactorEnabledAppHint">Use Google Authenticator, Microsoft
|
||||||
|
Authenticator, Authy, or another TOTP app
|
||||||
|
to generate your 6-digit login codes.</p>
|
||||||
|
|
||||||
<button class="secondary-button" type="submit" data-loading-label="Disabling 2FA...">Disable 2FA</button>
|
<button class="secondary-button" type="submit" data-loading-label="Disabling 2FA..."
|
||||||
|
data-i18n="disableTwoFactorButton" data-i18n-loading-label="disableTwoFactorLoading">Disable
|
||||||
|
2FA</button>
|
||||||
</form>
|
</form>
|
||||||
{% elif pending_two_factor_secret %}
|
{% elif pending_two_factor_secret %}
|
||||||
<form action="{{ url_for('login.login_settings') }}" method="post">
|
<form action="{{ url_for('login.login_settings') }}" method="post">
|
||||||
<input type="hidden" name="action" value="confirm_two_factor_setup">
|
<input type="hidden" name="action" value="confirm_two_factor_setup">
|
||||||
|
|
||||||
<h2>Finish 2FA setup</h2>
|
<h2 data-i18n="finishSetupTitle">Finish 2FA setup</h2>
|
||||||
<p class="field-hint">Scan this QR code with your authenticator app, then enter the current 6-digit code to confirm.</p>
|
<p class="field-hint" data-i18n="finishSetupHint">Scan this QR code with your authenticator app,
|
||||||
|
then enter the current 6-digit
|
||||||
|
code to confirm.</p>
|
||||||
|
|
||||||
<div class="qr-panel">
|
<div class="qr-panel">
|
||||||
<div class="qr-code" data-qr-value="{{ pending_two_factor_uri }}"></div>
|
<div class="qr-code" data-qr-value="{{ pending_two_factor_uri }}"></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="field-hint">If scanning does not work, you can still use the manual setup key below.</p>
|
<p class="field-hint" data-i18n="scanFallbackHint">If scanning does not work, you can still use the
|
||||||
|
manual setup key below.</p>
|
||||||
|
|
||||||
<label for="two_factor_secret">Manual setup key</label>
|
<label for="two_factor_secret" data-i18n="manualSetupKey">Manual setup key</label>
|
||||||
<input id="two_factor_secret" type="text" value="{{ pending_two_factor_secret }}" readonly>
|
<input id="two_factor_secret" type="text" value="{{ pending_two_factor_secret }}" readonly>
|
||||||
|
|
||||||
<label for="two_factor_uri">Setup URI</label>
|
<label for="two_factor_uri" data-i18n="setupUriLabel">Setup URI</label>
|
||||||
<input id="two_factor_uri" type="text" value="{{ pending_two_factor_uri }}" readonly>
|
<input id="two_factor_uri" type="text" value="{{ pending_two_factor_uri }}" readonly>
|
||||||
<p class="field-hint">This URI is used to generate the QR setup and can be copied as a fallback for compatible apps.</p>
|
<p class="field-hint" data-i18n="setupUriHint">This URI is used to generate the QR setup and can be
|
||||||
|
copied as a fallback for
|
||||||
|
compatible apps.</p>
|
||||||
|
|
||||||
<label for="code">Authenticator code</label>
|
<label for="code" data-i18n="authenticatorCodeLabel">Authenticator code</label>
|
||||||
<input id="code" type="text" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required>
|
<input id="code" type="text" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6"
|
||||||
|
required>
|
||||||
|
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button class="magnetic" type="submit" data-loading-label="Enabling 2FA...">Confirm and enable</button>
|
<button class="magnetic" type="submit" data-loading-label="Enabling 2FA..."
|
||||||
|
data-i18n="confirmAndEnableButton" data-i18n-loading-label="confirmAndEnableLoading">Confirm and
|
||||||
|
enable</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form action="{{ url_for('login.login_settings') }}" method="post">
|
<form action="{{ url_for('login.login_settings') }}" method="post">
|
||||||
<input type="hidden" name="action" value="cancel_two_factor_setup">
|
<input type="hidden" name="action" value="cancel_two_factor_setup">
|
||||||
|
|
||||||
<h2>Cancel setup</h2>
|
<h2 data-i18n="cancelSetupTitle">Cancel setup</h2>
|
||||||
<p class="field-hint">Cancel the current authenticator setup if you want to restart with a new secret.</p>
|
<p class="field-hint" data-i18n="cancelSetupHint">Cancel the current authenticator setup if you want
|
||||||
|
to restart with a new
|
||||||
|
secret.</p>
|
||||||
|
|
||||||
<button class="secondary-button" type="submit">Cancel setup</button>
|
<button class="secondary-button" type="submit" data-i18n="cancelSetupButton">Cancel setup</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form action="{{ url_for('login.login_settings') }}" method="post">
|
<form action="{{ url_for('login.login_settings') }}" method="post">
|
||||||
<input type="hidden" name="action" value="start_two_factor_setup">
|
<input type="hidden" name="action" value="start_two_factor_setup">
|
||||||
|
|
||||||
<h2>Authenticator app 2FA</h2>
|
<h2 data-i18n="twoFactorTitle">Authenticator app 2FA</h2>
|
||||||
<p class="field-hint">Protect your account with 6-digit codes from an authenticator app.</p>
|
<p class="field-hint" data-i18n="startSetupHint">Protect your account with 6-digit codes from an
|
||||||
<p class="field-hint">After starting setup, you will get a QR code to scan and then confirm once with a live code.</p>
|
authenticator app.</p>
|
||||||
|
<p class="field-hint" data-i18n="startSetupHintFollowup">After starting setup, you will get a QR code to scan and then confirm once
|
||||||
|
with a live code.</p>
|
||||||
|
|
||||||
<button class="magnetic" type="submit" data-loading-label="Starting setup...">Start setup</button>
|
<button class="magnetic" type="submit" data-loading-label="Starting setup..."
|
||||||
|
data-i18n="startSetupButton" data-i18n-loading-label="startSetupLoading">Start setup</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form action="{{ url_for('login.login_settings') }}" method="post">
|
<form action="{{ url_for('login.login_settings') }}" method="post">
|
||||||
<input type="hidden" name="action" value="password">
|
<input type="hidden" name="action" value="password">
|
||||||
|
|
||||||
<h2>Change password</h2>
|
<h2 data-i18n="changePasswordTitle">Change password</h2>
|
||||||
|
|
||||||
<label for="current_password">Current password</label>
|
<label for="current_password" data-i18n="currentPasswordLabel">Current password</label>
|
||||||
<input id="current_password" type="password" name="current_password" minlength="8" maxlength="128"
|
<input id="current_password" type="password" name="current_password" minlength="8" maxlength="128"
|
||||||
required>
|
required>
|
||||||
|
|
||||||
<label for="new_password">New password</label>
|
<label for="new_password" data-i18n="newPasswordLabel">New password</label>
|
||||||
<input id="new_password" type="password" name="new_password" minlength="8" maxlength="128" required>
|
<input id="new_password" type="password" name="new_password" minlength="8" maxlength="128" required>
|
||||||
|
|
||||||
<label for="confirm_password">Confirm new password</label>
|
<label for="confirm_password" data-i18n="confirmPasswordLabel">Confirm new password</label>
|
||||||
<input id="confirm_password" type="password" name="confirm_password" minlength="8" maxlength="128"
|
<input id="confirm_password" type="password" name="confirm_password" minlength="8" maxlength="128"
|
||||||
required>
|
required>
|
||||||
|
|
||||||
<button class="magnetic" type="submit" data-loading-label="Updating password...">Update password</button>
|
<button class="magnetic" type="submit" data-loading-label="Updating password..."
|
||||||
|
data-i18n="updatePasswordButton" data-i18n-loading-label="updatePasswordLoading">Update
|
||||||
|
password</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{% include "partials/footer.html" %}
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/language.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<footer class="page-footer">
|
||||||
|
<div class="page-footer-inner">
|
||||||
|
<div class="page-footer-copy">
|
||||||
|
<p class="page-footer-text" data-i18n="footerText">Schulprojekt rund um das Acht-Damen- und Acht-Türme-Problem.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="page-footer-links" data-i18n-aria-label="footerNavAria" aria-label="Rechtliche Hinweise">
|
||||||
|
<a href="{{ url_for('main.privacy_policy') }}" data-i18n="footerPrivacy">Datenschutzerklärung</a>
|
||||||
|
<a href="{{ url_for('main.imprint') }}" data-i18n="footerImprint">Impressum</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title data-i18n="pageTitle">Datenschutzerklärung</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/legal.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/footer.css') }}">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body data-legal-page="privacy">
|
||||||
|
<header class="legal-header">
|
||||||
|
<nav class="legal-nav">
|
||||||
|
<a class="brand" href="{{ url_for('main.home') }}">
|
||||||
|
<span class="brand-mark">♛</span>
|
||||||
|
<span data-i18n="navBrand">8-Damen- und 8-Türme Solver</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="legal-nav-links">
|
||||||
|
<button class="lang-switch" id="langSwitch" type="button" aria-label="Sprache wechseln">DE</button>
|
||||||
|
<a href="{{ url_for('main.home') }}" data-i18n="navHome">Startseite</a>
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<a href="{{ url_for('main.dashboard') }}" data-i18n="navDashboard">Dashboard</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('login.login') }}" data-i18n="navLogin">Login</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="legal-main">
|
||||||
|
<section class="legal-hero">
|
||||||
|
<p class="legal-eyebrow" data-i18n="heroEyebrow">Rechtliche Hinweise</p>
|
||||||
|
<h1 data-i18n="heroTitle">Datenschutzerklärung</h1>
|
||||||
|
<p class="legal-lead" data-i18n="heroLead">
|
||||||
|
Diese Seite beschreibt, welche personenbezogenen Daten im Rahmen dieses Schulprojekts verarbeitet
|
||||||
|
werden.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="legal-card">
|
||||||
|
<h2 data-i18n="dataProcessedTitle">1. Welche Daten verarbeitet werden</h2>
|
||||||
|
<p data-i18n="dataProcessedText">Im Rahmen der Nutzung dieser Webanwendung können insbesondere folgende
|
||||||
|
Daten verarbeitet werden:</p>
|
||||||
|
<ul class="legal-list">
|
||||||
|
<li data-i18n="dataProcessedItem1">Benutzername und verschlüsseltes Passwort bei der Registrierung und
|
||||||
|
beim Login</li>
|
||||||
|
<li data-i18n="dataProcessedItem2">Daten zur Zwei-Faktor-Authentifizierung, falls diese freiwillig
|
||||||
|
aktiviert wird</li>
|
||||||
|
<li data-i18n="dataProcessedItem3">gespeicherte Spielstände, Notizen, Schrittzahlen, Zeiten und
|
||||||
|
Favoriten</li>
|
||||||
|
<li data-i18n="dataProcessedItem4">technische Verbindungsdaten, die durch den Server oder das Hosting
|
||||||
|
entstehen koennen</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="legal-card">
|
||||||
|
<h2 data-i18n="processingPurposeTitle">2. Zweck der Verarbeitung</h2>
|
||||||
|
<p data-i18n="processingPurposeText">Die Verarbeitung erfolgt, um die Funktionen dieser Anwendung
|
||||||
|
bereitzustellen. Dazu gehoeren insbesondere:
|
||||||
|
</p>
|
||||||
|
<ul class="legal-list">
|
||||||
|
<li data-i18n="processingPurposeItem1">Erstellung und Verwaltung von Benutzerkonten</li>
|
||||||
|
<li data-i18n="processingPurposeItem2">sicherer Login in das persönliche Dashboard</li>
|
||||||
|
<li data-i18n="processingPurposeItem3">optionale Absicherung des Kontos per Authenticator-App</li>
|
||||||
|
<li data-i18n="processingPurposeItem4">Speichern und Laden von Spielständen</li>
|
||||||
|
<li data-i18n="processingPurposeItem5">technischer Betrieb, Fehleranalyse und Missbrauchsschutz</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="legal-card">
|
||||||
|
<h2 data-i18n="legalBasisTitle">3. Rechtsgrundlage</h2>
|
||||||
|
<p data-i18n="legalBasisText">
|
||||||
|
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
|
||||||
|
funktionsfaehigen Webangebot. Falls eine ausdrückliche Einwilligung erforderlich ist, wird diese
|
||||||
|
gesondert eingeholt.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="legal-card">
|
||||||
|
<h2 data-i18n="storageTitle">4. Speicherung und Löschung</h2>
|
||||||
|
<p data-i18n="storageText">
|
||||||
|
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
|
||||||
|
gelöscht oder für das Projekt nicht mehr benötigt werden.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="legal-card">
|
||||||
|
<h2 data-i18n="thirdPartyTitle">5. Weitergabe an Dritte</h2>
|
||||||
|
<p data-i18n="thirdPartyText">
|
||||||
|
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
|
||||||
|
keine Daten weitergegeben.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="legal-card">
|
||||||
|
<h2 data-i18n="rightsTitle">6. Rechte betroffener Personen</h2>
|
||||||
|
<p data-i18n="rightsText">Betroffene Personen haben im Rahmen des anwendbaren Datenschutzrechts insbesondere
|
||||||
|
das Recht auf:</p>
|
||||||
|
<ul class="legal-list">
|
||||||
|
<li data-i18n="rightsItem1">Auskunft über die verarbeiteten personenbezogenen Daten</li>
|
||||||
|
<li data-i18n="rightsItem2">Berichtigung unrichtiger Daten</li>
|
||||||
|
<li data-i18n="rightsItem3">Löschung oder Einschraenkung der Verarbeitung</li>
|
||||||
|
<li data-i18n="rightsItem4">Widerspruch gegen die Verarbeitung</li>
|
||||||
|
<li data-i18n="rightsItem5">Herausgabe der bereitgestellten Daten, soweit anwendbar</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="legal-card">
|
||||||
|
<h2 data-i18n="securityTitle">7. Datensicherheit</h2>
|
||||||
|
<p data-i18n="securityText">
|
||||||
|
Es werden technische und organisatorische Massnahmen getroffen, um gespeicherte Daten vor Verlust,
|
||||||
|
Missbrauch oder unberechtigtem Zugriff zu schuetzen. Dazu gehoeren insbesondere passwortgeschuetzte
|
||||||
|
Konten und die Möglichkeit, Zwei-Faktor-Authentifizierung zu aktivieren.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="legal-card">
|
||||||
|
<h2 data-i18n="versionTitle">8. Stand</h2>
|
||||||
|
<p data-i18n="versionText">Stand dieser Vorlage: April 2026</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{% include "partials/footer.html" %}
|
||||||
|
<script src="{{ url_for('static', filename='js/language.js') }}" defer></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/legal.js') }}" defer></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -6,18 +6,19 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Sign Up</title>
|
<title>Sign Up</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/footer.css') }}">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<nav class="auth-nav">
|
<nav class="auth-nav">
|
||||||
<a class="brand" href="{{ url_for('main.home') }}">
|
<a class="brand" href="{{ url_for('main.home') }}">
|
||||||
<span class="brand-mark">♛</span>
|
<span class="brand-mark">♛</span>
|
||||||
<span>8-Damen- und 8-Tuerme Solver</span>
|
<span data-i18n="navBrand">8-Damen- und 8-Türme Solver</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<ul class="nav-links">
|
<ul class="nav-links">
|
||||||
<li><a href="{{ url_for('login.login') }}">Login</a></li>
|
<li><a href="{{ url_for('login.login') }}" data-i18n="navLogin">Login</a></li>
|
||||||
<li><a href="{{ url_for('login.register') }}">Create account</a></li>
|
<li><a href="{{ url_for('login.register') }}" data-i18n="navRegister">Create account</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -51,6 +52,9 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{% include "partials/footer.html" %}
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/language.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -6,18 +6,19 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Verify Login</title>
|
<title>Verify Login</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/footer.css') }}">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<nav class="auth-nav">
|
<nav class="auth-nav">
|
||||||
<a class="brand" href="{{ url_for('main.home') }}">
|
<a class="brand" href="{{ url_for('main.home') }}">
|
||||||
<span class="brand-mark">♛</span>
|
<span class="brand-mark">♛</span>
|
||||||
<span>8-Damen- und 8-Tuerme Solver</span>
|
<span data-i18n="navBrand">8-Damen- und 8-Türme Solver</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<ul class="nav-links">
|
<ul class="nav-links">
|
||||||
<li><a href="{{ url_for('login.login') }}">Login</a></li>
|
<li><a href="{{ url_for('login.login') }}" data-i18n="navLogin">Login</a></li>
|
||||||
<li><a href="{{ url_for('login.register') }}">Create account</a></li>
|
<li><a href="{{ url_for('login.register') }}" data-i18n="navRegister">Create account</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -48,6 +49,9 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{% include "partials/footer.html" %}
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/language.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
+2
-4
@@ -10,12 +10,10 @@ CREATE TABLE users (
|
|||||||
|
|
||||||
CREATE TABLE login_attempts (
|
CREATE TABLE login_attempts (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
ip_address VARCHAR(45) NOT NULL,
|
ip_address VARCHAR(45) NOT NULL UNIQUE,
|
||||||
username VARCHAR(80) NOT NULL,
|
|
||||||
failed_attempts INTEGER NOT NULL DEFAULT 0,
|
failed_attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
locked_until DATETIME,
|
locked_until DATETIME,
|
||||||
last_failed_at DATETIME,
|
last_failed_at DATETIME
|
||||||
CONSTRAINT uq_login_attempt_ip_username UNIQUE (ip_address, username)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE game_state (
|
CREATE TABLE game_state (
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,86 @@
|
|||||||
|
# REST-API-Konzept
|
||||||
|
|
||||||
|
Dieses API-Konzept beschreibt die wichtigsten Schnittstellen der Anwendung zum Acht-Damen-und-Türme-Problem. Die API wird vom Dashboard verwendet, um Spielbretter zu prüfen, Loesungen zu berechnen und Spielstände zu speichern oder zu laden.
|
||||||
|
|
||||||
|
## Allgemeine Angaben
|
||||||
|
|
||||||
|
| Punkt | Beschreibung |
|
||||||
|
|---|---|
|
||||||
|
| Datenformat | JSON |
|
||||||
|
| Authentifizierung | Session-Cookie nach Login |
|
||||||
|
| Geschützte Endpunkte | Speicherstand-Funktionen benoetigen einen angemeldeten Benutzer |
|
||||||
|
| Brettgroesse | 4 bis 13 Felder pro Seite |
|
||||||
|
| Standard-Brettgroesse | 8 x 8 |
|
||||||
|
| Spielmodi | `queens` für Damen, `rooks` für Türme |
|
||||||
|
|
||||||
|
## Solver-API
|
||||||
|
|
||||||
|
| Methode | Pfad | Zweck | Parameter / Body | Authentifizierung |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| POST | `/check` | Prüft, ob eine Figur auf ein bestimmtes Feld gesetzt werden darf. | JSON: `board`, `row`, `col`, `mode` | Nein |
|
||||||
|
| GET | `/solve` | Gibt Loesungen für das Damenproblem zurück. | Query: `size` | Nein |
|
||||||
|
| GET | `/solve_trace` | Gibt eine Damen-Loesung Schritt für Schritt zurück. | Query: `size` | Nein |
|
||||||
|
| GET | `/solve_rooks` | Gibt eine gültige Turm-Loesung zurück. | Query: `size` | Nein |
|
||||||
|
| GET | `/solve_rooks_trace` | Gibt eine Turm-Loesung Schritt für Schritt zurück. | Query: `size` | Nein |
|
||||||
|
|
||||||
|
## Speicherstand-API
|
||||||
|
|
||||||
|
| Methode | Pfad | Zweck | Parameter / Body | Authentifizierung |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| POST | `/save` | Speichert den aktuellen Spielstand des angemeldeten Benutzers. | JSON: Spielbrett, Modus, Name, Notiz, Favorit, Schritte, Zeit, geloest | Ja |
|
||||||
|
| GET | `/save_points` | Listet alle gespeicherten Spielstände des angemeldeten Benutzers auf. | Optional: `mode`, `board_size`, `favorites_only`, `sort` | Ja |
|
||||||
|
| GET | `/load` | Lädt den neusten gespeicherten Spielstand. | Keine | Ja |
|
||||||
|
| GET | `/load/{id}` | Lädt einen bestimmten gespeicherten Spielstand. | Pfadparameter: `id` | Ja |
|
||||||
|
| POST | `/save_points/{id}/favorite` | Schaltet den Favoritenstatus eines Speicherstandes um. | Pfadparameter: `id` | Ja |
|
||||||
|
| DELETE | `/save_points/{id}` | Loescht einen bestimmten Speicherstand. | Pfadparameter: `id` | Ja |
|
||||||
|
|
||||||
|
## Login- und Seitenrouten
|
||||||
|
|
||||||
|
| Methode | Pfad | Zweck | Typ |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET / POST | `/register` | Neues Benutzerkonto erstellen. | Formularroute |
|
||||||
|
| GET / POST | `/login` | Benutzer anmelden. | Formularroute |
|
||||||
|
| GET / POST | `/login/verify` | Zwei-Faktor-Code prüfen. | Formularroute |
|
||||||
|
| GET / POST | `/login/settings` | Passwort und Zwei-Faktor-Authentifizierung verwalten. | Formularroute |
|
||||||
|
| POST | `/logout` | Benutzer abmelden. | Formularroute |
|
||||||
|
| GET | `/` | Startseite anzeigen. | Seitenroute |
|
||||||
|
| GET | `/dashboard` | Dashboard anzeigen. | Seitenroute |
|
||||||
|
| GET | `/privacy_policy` | Datenschutzerklärung anzeigen. | Seitenroute |
|
||||||
|
| GET | `/imprint` | Impressum anzeigen. | Seitenroute |
|
||||||
|
|
||||||
|
## Wichtige Request-Daten
|
||||||
|
|
||||||
|
| Feld | Verwendung | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| `board` | `/check`, `/save` | Zweidimensionale Matrix des Spielbretts. `0` bedeutet leer, `1` bedeutet Figur. |
|
||||||
|
| `row` | `/check` | Zeile des zu prüfenden Feldes. |
|
||||||
|
| `col` | `/check` | Spalte des zu prüfenden Feldes. |
|
||||||
|
| `mode` | `/check`, `/save`, `/save_points` | Spielmodus: `queens` oder `rooks`. |
|
||||||
|
| `size` | Solver-Endpunkte | Gewünschte Brettgroesse. |
|
||||||
|
| `boardSize` | `/save` | Groesse des gespeicherten Spielbretts. |
|
||||||
|
| `saveName` | `/save` | Name des Speicherstandes. |
|
||||||
|
| `saveNote` | `/save` | Optionale Notiz zum Speicherstand. |
|
||||||
|
| `isFavorite` | `/save` | Gibt an, ob der Speicherstand als Favorit markiert ist. |
|
||||||
|
| `stepCount` | `/save` | Anzahl der ausgeführten Schritte. |
|
||||||
|
| `elapsedSeconds` | `/save` | Vergangene Spielzeit in Sekunden. |
|
||||||
|
| `isSolved` | `/save` | Gibt an, ob das Spiel geloest wurde. |
|
||||||
|
|
||||||
|
## Wichtige Response-Daten
|
||||||
|
|
||||||
|
| Feld | Verwendung | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| `valid` | `/check` | Gibt zurück, ob der Zug erlaubt ist. |
|
||||||
|
| `status` | `/save`, `/save_points/{id}` | Statusmeldung, zum Beispiel `saved` oder `deleted`. |
|
||||||
|
| `save_point` | `/save` | Neu gespeicherter Spielstand. |
|
||||||
|
| `steps` | Trace-Endpunkte | Liste der einzelnen Loesungsschritte. |
|
||||||
|
| `solved` | Trace-Endpunkte | Gibt an, ob eine Loesung gefunden wurde. |
|
||||||
|
| `error` | Fehlerfälle | Fehlermeldung, zum Beispiel wenn ein Speicherstand nicht gefunden wurde. |
|
||||||
|
|
||||||
|
## Fehlerfälle
|
||||||
|
|
||||||
|
| Situation | Beispielantwort | HTTP-Status |
|
||||||
|
|---|---|---|
|
||||||
|
| Speicherstand existiert nicht oder gehoert einem anderen Benutzer. | `{ "error": "Save point not found" }` | 404 |
|
||||||
|
| Benutzer ist nicht angemeldet und ruft geschützte Route auf. | Weiterleitung zur Login-Seite | 302 |
|
||||||
|
| Ungültige Brettgroesse wird übergeben. | Wert wird auf erlaubten Bereich begrenzt. | 200 |
|
||||||
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
if PROJECT_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, PROJECT_ROOT)
|
||||||
|
|
||||||
|
os.chdir(PROJECT_ROOT)
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
|
||||||
|
|
||||||
|
application = create_app()
|
||||||
|
app = application
|
||||||
Binary file not shown.
@@ -0,0 +1,68 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
if str(PROJECT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from app import create_app, db
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app_instance(tmp_path):
|
||||||
|
db_path = Path(tmp_path) / "test_app.db"
|
||||||
|
app = create_app(
|
||||||
|
{
|
||||||
|
"TESTING": True,
|
||||||
|
"SECRET_KEY": "test-secret-key",
|
||||||
|
"SQLALCHEMY_DATABASE_URI": f"sqlite:///{db_path}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
db.drop_all()
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
yield app
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
db.session.remove()
|
||||||
|
db.drop_all()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app_instance):
|
||||||
|
return app_instance.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user_factory(app_instance):
|
||||||
|
def _create_user(username="alice", password="Password123!", **extra_fields):
|
||||||
|
with app_instance.app_context():
|
||||||
|
user = User(
|
||||||
|
username=username,
|
||||||
|
password=generate_password_hash(password),
|
||||||
|
**extra_fields,
|
||||||
|
)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
return user.id
|
||||||
|
|
||||||
|
return _create_user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def login_as(client):
|
||||||
|
def _login(username="alice", password="Password123!"):
|
||||||
|
return client.post(
|
||||||
|
"/login",
|
||||||
|
data={"username": username, "password": password},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _login
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Testdurchführung
|
||||||
|
|
||||||
|
## Durchführungsrahmen
|
||||||
|
|
||||||
|
- Datum: 2026-04-24
|
||||||
|
- Ausführung auf zwei Ebenen:
|
||||||
|
- Unit-Tests
|
||||||
|
- Integrations-/Routentests
|
||||||
|
- Werkzeug: `pytest 8.3.4`
|
||||||
|
- Testkommando:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 /tmp/acht-damen-test-venv/bin/pytest -q test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bewertung der durchgeführten Testfälle
|
||||||
|
|
||||||
|
| ID | Testfall | Ebene | Erwartung | Resultat | Bewertung | Nachvollziehbare Bewertung |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| U-MAIN-01 | Brettgrösse wird begrenzt | Unit | Grenzwerte werden korrekt behandelt | Bestanden | OK | Kleine, grosse und ungültige Werte wurden korrekt auf den erlaubten Bereich abgebildet |
|
||||||
|
| U-MAIN-02 | Favoriten-Flag wird erkannt | Unit | Typische truthy Werte werden korrekt erkannt | Bestanden | OK | Die Hilfsfunktion unterschied erfolgreich zwischen gültigen truthy und anderen Werten |
|
||||||
|
| U-MAIN-03 | Nicht-negative Ganzzahlen werden abgesichert | Unit | Negative und ungültige Werte werden abgefangen | Bestanden | OK | Negative Werte wurden auf 0 begrenzt, ungültige Eingaben fielen auf den Default zurück |
|
||||||
|
| U-MAIN-04 | Legacy-Brett wird umgewandelt | Unit | Positionsliste wird zur Matrix | Bestanden | OK | Altes Speicherformat konnte verlustfrei in das aktülle Format übernommen werden |
|
||||||
|
| U-MAIN-05 | Matrixwerte werden normalisiert | Unit | Nur 0/1 bleiben übrig | Bestanden | OK | Truthy- und falsy-Werte wurden korrekt vereinheitlicht |
|
||||||
|
| U-MAIN-06 | Damen-Solver für 4x4 | Unit | Gültige Standardlösungen werden gefunden | Bestanden | OK | Der Solver lieferte die fachlich erwarteten zwei 4x4-Lösungen |
|
||||||
|
| U-MAIN-07 | Turm-Trace ist vollständig | Unit | Pro Reihe ein Schritt plus Abschluss | Bestanden | OK | Der Trace enthielt die erwartete Struktur für das Frontend |
|
||||||
|
| U-LOGIN-01 | Gültiger Username | Unit | Gültiger Name wird akzeptiert | Bestanden | OK | Ein Name im erlaubten Format wurde ohne Fehlermeldung akzeptiert |
|
||||||
|
| U-LOGIN-02 | Ungültiger Username | Unit | Fehlerhafte Zeichen werden abgelehnt | Bestanden | OK | Ein Name mit Leerzeichen/Sonderzeichen wurde korrekt abgewiesen |
|
||||||
|
| U-LOGIN-03 | Zu kurzes Passwort | Unit | Mindestlänge wird erzwungen | Bestanden | OK | Die Funktion lieferte die passende Fehlermeldung für zu kurze Passwörter |
|
||||||
|
| U-LOGIN-04 | Zu langes Passwort | Unit | Maximallänge wird erzwungen | Bestanden | OK | Auch überlange Passwörter wurden korrekt abgefangen |
|
||||||
|
| U-LOGIN-05 | 2FA-Secret-Normalisierung | Unit | Secret wird korrekt bereinigt und formatiert | Bestanden | OK | Schreibweisen mit Leerzeichen/Kleinschreibung wurden korrekt vereinheitlicht |
|
||||||
|
| U-LOGIN-06 | TOTP zur gleichen Zeit | Unit | Richtiger Code ist gültig | Bestanden | OK | Erzeugter Code wurde bei identischem Zeitpunkt akzeptiert |
|
||||||
|
| U-LOGIN-07 | TOTP mit Zeitfenster | Unit | Code bleibt im gültigen Nachbarfenster gültig | Bestanden | OK | Das erlaubte Zeitfenster funktionierte wie vorgesehen |
|
||||||
|
| U-LOGIN-08 | Fehlerhafte TOTP-Eingabe | Unit | Falsch formatierte Codes werden abgelehnt | Bestanden | OK | Nicht numerische oder zu kurze Eingaben wurden nicht akzeptiert |
|
||||||
|
| I-APP-01 | Dashboard ohne Login | Integration | Weiterleitung auf Login | Bestanden | OK | Die geschützte Route war ohne Authentifizierung nicht direkt erreichbar |
|
||||||
|
| I-APP-02 | Registrierung | Integration | Benutzer wird erstellt und umgeleitet | Bestanden | OK | Nach dem POST war der Benutzer in der Testdatenbank vorhanden |
|
||||||
|
| I-APP-03 | Login bei gesperrter IP | Integration | Sperrmeldung statt Login | Bestanden | OK | Die Anti-Brute-Force-Logik blockierte den Login im gesperrten Zeitraum korrekt |
|
||||||
|
| I-APP-04 | Dame vs. Turm bei Diagonale | Integration | Dame ungültig, Turm gültig | Bestanden | OK | Die Route `/check` setzte die unterschiedlichen Fachregeln korrekt um |
|
||||||
|
| I-APP-05 | Speichern und Laden | Integration | Spielstand bleibt konsistent | Bestanden | OK | Gespeicherter Spielstand konnte mit denselben Daten wieder geladen werden |
|
||||||
|
| I-APP-06 | Favoriten filtern | Integration | Nur markierte Save-Points erscheinen im Filter | Bestanden | OK | Das Umschalten des Favoritenstatus wirkte sich korrekt auf die Filterliste aus |
|
||||||
|
|
||||||
|
## Ergebnis
|
||||||
|
|
||||||
|
- Anzahl Testebenen: 2
|
||||||
|
- Anzahl dokumentierter Testfälle: 21
|
||||||
|
- Bestandene Testfälle: 21
|
||||||
|
- Nicht bestandene Testfälle: 0
|
||||||
|
|
||||||
|
Die Testdurchführung zeigt, dass die zentrale Fachlogik, die
|
||||||
|
Authentifizierungslogik und die wichtigsten Datenflüsse der Anwendung für die
|
||||||
|
Abgabe nachvollziehbar und erfolgreich überprüft wurden.
|
||||||
|
|
||||||
|
## Hinweis zu Warnungen
|
||||||
|
|
||||||
|
Beim Testlauf traten nur Warnungen auf, aber keine Fehler:
|
||||||
|
|
||||||
|
- DeprecationWarnings zu `datetime.utcnow()` in bestehendem Anwendungscode
|
||||||
|
- Pytest-Cache-Warnungen wegen schreibgeschütztem Dateisystem im Projektpfad
|
||||||
|
|
||||||
|
Diese Warnungen haben das Ergebnis der Testfälle nicht beeinflusst. Alle
|
||||||
|
geplanten und ausgeführten Tests wurden bestanden.
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.models.gamestate import GameState
|
||||||
|
from app.models.login_attempt import LoginAttempt
|
||||||
|
from app.models.user import User
|
||||||
|
from app.routes.login_routes import utcnow
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_requires_login(client):
|
||||||
|
response = client.get("/dashboard")
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert "/login" in response.headers["Location"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_creates_user_and_redirects_to_login(client, app_instance):
|
||||||
|
response = client.post(
|
||||||
|
"/register",
|
||||||
|
data={"username": "newuser", "password": "Password123!"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert "/login" in response.headers["Location"]
|
||||||
|
|
||||||
|
with app_instance.app_context():
|
||||||
|
saved_user = User.query.filter_by(username="newuser").first()
|
||||||
|
assert saved_user is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_blocks_requests_from_a_locked_ip(client, app_instance, user_factory):
|
||||||
|
user_factory(username="lockeduser", password="Password123!")
|
||||||
|
|
||||||
|
with app_instance.app_context():
|
||||||
|
db.session.add(
|
||||||
|
LoginAttempt(
|
||||||
|
ip_address="203.0.113.10",
|
||||||
|
failed_attempts=4,
|
||||||
|
locked_until=utcnow() + timedelta(minutes=5),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/login",
|
||||||
|
data={"username": "lockeduser", "password": "Password123!"},
|
||||||
|
headers={"X-Forwarded-For": "203.0.113.10"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"Too many failed login attempts" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_endpoint_applies_different_rules_for_queens_and_rooks(client):
|
||||||
|
board = [
|
||||||
|
[1, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
]
|
||||||
|
|
||||||
|
queens_response = client.post(
|
||||||
|
"/check",
|
||||||
|
json={"board": board, "row": 1, "col": 1, "mode": "queens"},
|
||||||
|
)
|
||||||
|
rooks_response = client.post(
|
||||||
|
"/check",
|
||||||
|
json={"board": board, "row": 1, "col": 1, "mode": "rooks"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert queens_response.status_code == 200
|
||||||
|
assert rooks_response.status_code == 200
|
||||||
|
assert queens_response.get_json() == {"valid": False}
|
||||||
|
assert rooks_response.get_json() == {"valid": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_solve_endpoint_returns_one_valid_4x4_queens_solution(client):
|
||||||
|
response = client.get("/solve?size=4")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert tuple(response.get_json()) in {
|
||||||
|
(1, 3, 0, 2),
|
||||||
|
(2, 0, 3, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticated_user_can_save_and_load_a_game_state(client, app_instance, user_factory, login_as):
|
||||||
|
user_factory(username="alice", password="Password123!")
|
||||||
|
|
||||||
|
login_response = login_as("alice", "Password123!")
|
||||||
|
assert login_response.status_code == 302
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"board": [
|
||||||
|
[0, 1, 0, 0],
|
||||||
|
[0, 0, 0, 1],
|
||||||
|
[1, 0, 0, 0],
|
||||||
|
[0, 0, 1, 0],
|
||||||
|
],
|
||||||
|
"mode": "queens",
|
||||||
|
"boardSize": 4,
|
||||||
|
"saveName": "test-save",
|
||||||
|
"saveNote": "unit integration save",
|
||||||
|
"isFavorite": False,
|
||||||
|
"stepCount": 7,
|
||||||
|
"elapsedSeconds": 42,
|
||||||
|
"isSolved": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
save_response = client.post("/save", json=payload)
|
||||||
|
assert save_response.status_code == 200
|
||||||
|
|
||||||
|
saved_data = save_response.get_json()["save_point"]
|
||||||
|
load_response = client.get(f"/load/{saved_data['id']}")
|
||||||
|
|
||||||
|
assert load_response.status_code == 200
|
||||||
|
loaded_data = load_response.get_json()
|
||||||
|
assert loaded_data["board"] == payload["board"]
|
||||||
|
assert loaded_data["mode"] == payload["mode"]
|
||||||
|
assert loaded_data["step_count"] == payload["stepCount"]
|
||||||
|
assert loaded_data["elapsed_seconds"] == payload["elapsedSeconds"]
|
||||||
|
assert loaded_data["is_solved"] is True
|
||||||
|
|
||||||
|
with app_instance.app_context():
|
||||||
|
assert GameState.query.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_favorite_toggle_changes_filtered_save_point_list(client, user_factory, login_as):
|
||||||
|
user_factory(username="favuser", password="Password123!")
|
||||||
|
|
||||||
|
login_response = login_as("favuser", "Password123!")
|
||||||
|
assert login_response.status_code == 302
|
||||||
|
|
||||||
|
save_response = client.post(
|
||||||
|
"/save",
|
||||||
|
json={
|
||||||
|
"board": [
|
||||||
|
[1, 0, 0, 0],
|
||||||
|
[0, 1, 0, 0],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
],
|
||||||
|
"mode": "rooks",
|
||||||
|
"boardSize": 4,
|
||||||
|
"saveName": "favorite-candidate",
|
||||||
|
"saveNote": "",
|
||||||
|
"isFavorite": False,
|
||||||
|
"stepCount": 2,
|
||||||
|
"elapsedSeconds": 5,
|
||||||
|
"isSolved": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
save_id = save_response.get_json()["save_point"]["id"]
|
||||||
|
|
||||||
|
filtered_before = client.get("/save_points?favorites_only=true")
|
||||||
|
assert filtered_before.status_code == 200
|
||||||
|
assert filtered_before.get_json() == []
|
||||||
|
|
||||||
|
toggle_response = client.post(f"/save_points/{save_id}/favorite")
|
||||||
|
assert toggle_response.status_code == 200
|
||||||
|
assert toggle_response.get_json()["is_favorite"] is True
|
||||||
|
|
||||||
|
filtered_after = client.get("/save_points?favorites_only=true")
|
||||||
|
assert filtered_after.status_code == 200
|
||||||
|
assert len(filtered_after.get_json()) == 1
|
||||||
|
assert filtered_after.get_json()[0]["id"] == save_id
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from app.routes.login_routes import (
|
||||||
|
format_two_factor_secret,
|
||||||
|
generate_totp_code,
|
||||||
|
normalize_two_factor_secret,
|
||||||
|
validate_password,
|
||||||
|
validate_username,
|
||||||
|
verify_totp_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_username_accepts_supported_format():
|
||||||
|
assert validate_username("valid_user-01") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_username_rejects_unsupported_characters():
|
||||||
|
assert validate_username("bad user!") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_password_rejects_too_short_passwords():
|
||||||
|
assert validate_password("short") == "Password must be at least 8 characters long."
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_password_rejects_too_long_passwords():
|
||||||
|
assert validate_password("a" * 129) == "Password must be at most 128 characters long."
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_and_format_two_factor_secret_prepare_manual_input():
|
||||||
|
secret = "jbsw y3dp ehpk 3pxp"
|
||||||
|
|
||||||
|
assert normalize_two_factor_secret(secret) == "JBSWY3DPEHPK3PXP"
|
||||||
|
assert format_two_factor_secret(secret) == "JBSW Y3DP EHPK 3PXP"
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_totp_code_accepts_code_generated_for_same_time():
|
||||||
|
secret = "JBSWY3DPEHPK3PXP"
|
||||||
|
moment = datetime(2026, 4, 24, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
code = generate_totp_code(secret, moment)
|
||||||
|
|
||||||
|
assert verify_totp_code(secret, code, moment) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_totp_code_accepts_code_in_adjacent_time_window():
|
||||||
|
secret = "JBSWY3DPEHPK3PXP"
|
||||||
|
moment = datetime(2026, 4, 24, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
code = generate_totp_code(secret, moment)
|
||||||
|
|
||||||
|
assert verify_totp_code(secret, code, moment + timedelta(seconds=30)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_totp_code_rejects_malformed_code_input():
|
||||||
|
secret = "JBSWY3DPEHPK3PXP"
|
||||||
|
moment = datetime(2026, 4, 24, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
assert verify_totp_code(secret, "12ab56", moment) is False
|
||||||
|
assert verify_totp_code(secret, "12345", moment) is False
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from app.routes.main_routes import (
|
||||||
|
build_rooks_trace,
|
||||||
|
count_placed_pieces,
|
||||||
|
normalize_board,
|
||||||
|
parse_board_size,
|
||||||
|
parse_favorite_flag,
|
||||||
|
parse_non_negative_int,
|
||||||
|
solve_queens,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_board_size_clamps_small_large_and_invalid_values():
|
||||||
|
assert parse_board_size("2") == 4
|
||||||
|
assert parse_board_size("99") == 13
|
||||||
|
assert parse_board_size("abc") == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_favorite_flag_accepts_common_truthy_values():
|
||||||
|
assert parse_favorite_flag("true") is True
|
||||||
|
assert parse_favorite_flag("1") is True
|
||||||
|
assert parse_favorite_flag("on") is True
|
||||||
|
assert parse_favorite_flag("false") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_non_negative_int_clamps_negative_and_invalid_values():
|
||||||
|
assert parse_non_negative_int("-7") == 0
|
||||||
|
assert parse_non_negative_int("15") == 15
|
||||||
|
assert parse_non_negative_int("abc", default=3) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_board_converts_legacy_position_list_into_matrix():
|
||||||
|
normalized = normalize_board([1, 3, 0, 2], 4)
|
||||||
|
|
||||||
|
assert normalized == [
|
||||||
|
[0, 1, 0, 0],
|
||||||
|
[0, 0, 0, 1],
|
||||||
|
[1, 0, 0, 0],
|
||||||
|
[0, 0, 1, 0],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_board_reduces_matrix_values_to_zero_or_one():
|
||||||
|
normalized = normalize_board([[0, 2], [True, None]], 2)
|
||||||
|
|
||||||
|
assert normalized == [[0, 1], [1, 0]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_solve_queens_returns_the_two_expected_solutions_for_4x4():
|
||||||
|
solutions = {tuple(solution) for solution in solve_queens(4)}
|
||||||
|
|
||||||
|
assert solutions == {
|
||||||
|
(1, 3, 0, 2),
|
||||||
|
(2, 0, 3, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_rooks_trace_contains_one_placement_per_row_plus_solution():
|
||||||
|
trace = build_rooks_trace(5)
|
||||||
|
placement_steps = [step for step in trace["steps"] if step["type"] == "place"]
|
||||||
|
|
||||||
|
assert trace["solved"] is True
|
||||||
|
assert len(trace["steps"]) == 6
|
||||||
|
assert len(placement_steps) == 5
|
||||||
|
assert sorted(step["col"] for step in placement_steps) == [0, 1, 2, 3, 4]
|
||||||
|
assert count_placed_pieces(trace["steps"][-1]["board"]) == 5
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Testkonzept
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Dieses Testkonzept beschreibt, wie die wichtigsten Funktionen der Webanwendung
|
||||||
|
vor der Abgabe ueberprueft werden. Der Fokus liegt auf den fachlich und
|
||||||
|
technisch kritischen Bereichen:
|
||||||
|
|
||||||
|
- Brettlogik fuer Damen und Tuerme
|
||||||
|
- Authentifizierung und Sicherheitsfunktionen
|
||||||
|
- Speichern, Laden und Filtern von Spielständen
|
||||||
|
|
||||||
|
## Randbedingungen / Umfeld
|
||||||
|
|
||||||
|
- Projekt: 8-Damen- und 8-Tuerme Solver
|
||||||
|
- Testdatum Planung: 2026-04-24
|
||||||
|
- Betriebssystem: Linux 6.6.87.2-microsoft-standard-WSL2 x86_64
|
||||||
|
- Python-Version: 3.12.3
|
||||||
|
- Testwerkzeug: pytest 8.3.4
|
||||||
|
- Webtestmittel: Flask Test Client
|
||||||
|
- Datenbank im Test: isolierte SQLite-Testdatenbank pro Testlauf
|
||||||
|
- Testordner: `test/`
|
||||||
|
|
||||||
|
Die Tests werden absichtlich getrennt von der produktiven Datenbank
|
||||||
|
durchgefuehrt. Dadurch wird verhindert, dass echte Benutzerdaten oder
|
||||||
|
Spielstände verändert werden.
|
||||||
|
|
||||||
|
## Testmittel und Methoden
|
||||||
|
|
||||||
|
Es werden zwei voneinander unabhängige Testebenen eingesetzt:
|
||||||
|
|
||||||
|
1. Unit-Tests
|
||||||
|
Diese prüfen einzelne Funktionen direkt, zum Beispiel Eingabevalidierung,
|
||||||
|
Brettnormalisierung oder TOTP-Prüfung.
|
||||||
|
2. Integrations- und Routentests
|
||||||
|
Diese prüfen das Zusammenspiel mehrerer Komponenten über HTTP-Routen, zum
|
||||||
|
Beispiel Registrierung, Login, Validierung, Speichern und Laden.
|
||||||
|
|
||||||
|
Eingesetzte Mittel:
|
||||||
|
|
||||||
|
- `pytest` für die Testausführung und Bewertung
|
||||||
|
- Flask Test Client für Request/Response-Tests
|
||||||
|
- temporäre SQLite-Datenbank für isolierte Testdaten
|
||||||
|
|
||||||
|
## Testfälle (Drehbuch)
|
||||||
|
|
||||||
|
| ID | Ebene | Testziel | Testmethode / Eingabe | Erwartetes Resultat |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| U-MAIN-01 | Unit | Brettgroesse absichern | `parse_board_size()` mit zu kleinen, zu grossen und ungültigen Werten | Werte werden korrekt auf den erlaubten Bereich begrenzt |
|
||||||
|
| U-MAIN-02 | Unit | Favoriten-Flag normalisieren | `parse_favorite_flag()` mit typischen truthy/falsy Werten | Nur definierte truthy Werte werden als `True` interpretiert |
|
||||||
|
| U-MAIN-03 | Unit | Ganze Zahlen robust verarbeiten | `parse_non_negative_int()` mit negativen und ungültigen Werten | Negative Werte werden auf 0 begrenzt, ungültige Werte fallen auf den Default zurück |
|
||||||
|
| U-MAIN-04 | Unit | Alte Speicherformate unterstützen | `normalize_board()` mit Legacy-Positionsliste | Es entsteht ein korrektes Brettmatrix-Format |
|
||||||
|
| U-MAIN-05 | Unit | Uneinheitliche Matrixwerte normalisieren | `normalize_board()` mit truthy/falsy Werten | Es entstehen nur 0- und 1-Werte |
|
||||||
|
| U-MAIN-06 | Unit | Damen-Solver fachlich absichern | `solve_queens(4)` | Es werden genau die gültigen 4x4-Loesungen gefunden |
|
||||||
|
| U-MAIN-07 | Unit | Turm-Trace prüfen | `build_rooks_trace(5)` | Es gibt pro Reihe genau einen Platzierungsschritt plus Abschluss |
|
||||||
|
| U-LOGIN-01 | Unit | Gültigen Username akzeptieren | `validate_username()` mit gültigem Namen | Rückgabe ist `None` |
|
||||||
|
| U-LOGIN-02 | Unit | Ungültigen Username ablehnen | `validate_username()` mit unerlaubten Zeichen | Es wird eine Fehlermeldung geliefert |
|
||||||
|
| U-LOGIN-03 | Unit | Zu kurzes Passwort ablehnen | `validate_password()` mit zu kurzem Passwort | Es wird die Mindestlängen-Fehlermeldung geliefert |
|
||||||
|
| U-LOGIN-04 | Unit | Zu langes Passwort ablehnen | `validate_password()` mit zu langem Passwort | Es wird die Maximalfehler-Meldung geliefert |
|
||||||
|
| U-LOGIN-05 | Unit | 2FA-Secret vereinheitlichen | `normalize_two_factor_secret()` und `format_two_factor_secret()` | Secret wird korrekt bereinigt und formatiert |
|
||||||
|
| U-LOGIN-06 | Unit | TOTP im Normalfall prüfen | `generate_totp_code()` + `verify_totp_code()` zur gleichen Zeit | Gültiger Code wird akzeptiert |
|
||||||
|
| U-LOGIN-07 | Unit | TOTP mit Zeitfenster prüfen | Code wird mit 30 Sekunden Abweichung geprüft | Gültiger Code wird wegen Validierungsfenster akzeptiert |
|
||||||
|
| U-LOGIN-08 | Unit | Fehlerhafte TOTP-Eingabe ablehnen | `verify_totp_code()` mit falschem Format | Ungültige Codes werden abgelehnt |
|
||||||
|
| I-APP-01 | Integration | Schutz geschützter Seite prüfen | `GET /dashboard` ohne Login | Weiterleitung auf Login |
|
||||||
|
| I-APP-02 | Integration | Registrierung prüfen | `POST /register` mit gültigen Daten | Benutzer wird erstellt und Weiterleitung auf Login erfolgt |
|
||||||
|
| I-APP-03 | Integration | Anti-Brute-Force prüfen | `POST /login` mit gesperrter IP | Login wird mit Sperrmeldung abgelehnt |
|
||||||
|
| I-APP-04 | Integration | Fachregel für Brett prüfen | `POST /check` für Dame und Turm auf diagonalem Feld | Dame: ungültig, Turm: gültig |
|
||||||
|
| I-APP-05 | Integration | Speichern und Laden prüfen | Login, danach `POST /save` und `GET /load/<id>` | Spielstand wird korrekt gespeichert und identisch geladen |
|
||||||
|
| I-APP-06 | Integration | Favoritenlogik prüfen | Login, Save-Point speichern, Favorit toggeln, gefiltert abfragen | Filter liefert nur entsprechend markierte Save-Points |
|
||||||
|
|
||||||
|
## Erwartete Ergebnisse
|
||||||
|
|
||||||
|
- Alle Unit-Tests müssen erfolgreich durchlaufen.
|
||||||
|
- Alle Integrations-Tests müssen erfolgreich durchlaufen.
|
||||||
|
- Keine produktiven Daten dürfen verändert werden.
|
||||||
|
- Fehler müssen in `pytest` klar sichtbar sein.
|
||||||
|
- Die Suite umfasst 21 konkrete Pytest-Testfälle auf zwei Ebenen.
|
||||||
|
|
||||||
|
## Durchführung
|
||||||
|
|
||||||
|
Die geplanten Tests werden mit `pytest` aus dem Projektwurzelverzeichnis
|
||||||
|
ausgeführt. Die konkrete Bewertung und das Resultat werden in
|
||||||
|
`test/testkonzept.md` festgehalten.
|
||||||
Reference in New Issue
Block a user