diff --git a/README.md b/README.md index 0fe6d2e..e8372a2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,6 @@ project-root/ │ ├── app/ │ ├── __init__.py # Flask App Factory -│ ├── config.py # Konfiguration (DB, Secrets, etc.) │ │ │ ├── models/ # Datenbankmodelle │ │ ├── __init__.py @@ -22,7 +21,7 @@ project-root/ │ │ ├── solver_routes.py │ │ └── job_routes.py │ │ -│ ├── templates/ # HTML (Jinja Templates) +│ ├── templates/ # HTML │ │ ├── base.html │ │ ├── login.html │ │ ├── dashboard.html @@ -37,6 +36,5 @@ project-root/ │ ├── run.py # Startpunkt der Anwendung ├── requirements.txt -├── .env # DB-Zugangsdaten, Secrets └── README.md ``` diff --git a/app/__init__.py b/app/__init__.py index 5fd7acc..9315507 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,33 +1,58 @@ -from flask import Flask -import os -from flask_sqlalchemy import SQLAlchemy -from flask_login import LoginManager +"""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 + +from flask import Flask +from flask_login import LoginManager +from flask_sqlalchemy import SQLAlchemy + +# Shared extension instances are created once here and attached to the app db = SQLAlchemy() login_manager = LoginManager() + def create_app(): + """Build and configure the Flask application instance.""" 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__)) + # Core Flask and SQLAlchemy configuration. app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(BASE_DIR, "app.db") - app.config["SECRET_KEY"] = "dev-secret-key" + app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-secret-key") app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["SESSION_COOKIE_HTTPONLY"] = True + app.config["REMEMBER_COOKIE_HTTPONLY"] = True + app.config["SESSION_COOKIE_SAMESITE"] = "Lax" + # Connect the shared extension objects to this specific app instance. db.init_app(app) login_manager.init_app(app) login_manager.login_view = "login.login" + login_manager.login_message_category = "info" + # Import the models before create_all() so SQLAlchemy has the table from .models.user import User + from .models.gamestate import GameState + from .models.login_attempt import LoginAttempt + # Create any database tables that do not exist yet. with app.app_context(): db.create_all() @login_manager.user_loader def load_user(user_id): + """Reload the logged-in user from the session-stored 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.main_routes import main_bp diff --git a/app/__pycache__/__init__.cpython-311.pyc b/app/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 7124496..0000000 Binary files a/app/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/app/app.db b/app/app.db index 479ce3a..d8d2fca 100644 Binary files a/app/app.db and b/app/app.db differ diff --git a/app/models/__pycache__/gamestate.cpython-311.pyc b/app/models/__pycache__/gamestate.cpython-311.pyc new file mode 100644 index 0000000..15f2556 Binary files /dev/null and b/app/models/__pycache__/gamestate.cpython-311.pyc differ diff --git a/app/models/__pycache__/gamestate.cpython-312.pyc b/app/models/__pycache__/gamestate.cpython-312.pyc new file mode 100644 index 0000000..7606bb2 Binary files /dev/null and b/app/models/__pycache__/gamestate.cpython-312.pyc differ diff --git a/app/models/__pycache__/login_attempt.cpython-311.pyc b/app/models/__pycache__/login_attempt.cpython-311.pyc new file mode 100644 index 0000000..9aa4c7a Binary files /dev/null and b/app/models/__pycache__/login_attempt.cpython-311.pyc differ diff --git a/app/models/__pycache__/login_attempt.cpython-312.pyc b/app/models/__pycache__/login_attempt.cpython-312.pyc new file mode 100644 index 0000000..65fbb7e Binary files /dev/null and b/app/models/__pycache__/login_attempt.cpython-312.pyc differ diff --git a/app/models/__pycache__/user.cpython-311.pyc b/app/models/__pycache__/user.cpython-311.pyc index 0748aa0..bc150d6 100644 Binary files a/app/models/__pycache__/user.cpython-311.pyc and b/app/models/__pycache__/user.cpython-311.pyc differ diff --git a/app/models/__pycache__/user.cpython-312.pyc b/app/models/__pycache__/user.cpython-312.pyc new file mode 100644 index 0000000..fdc64d3 Binary files /dev/null and b/app/models/__pycache__/user.cpython-312.pyc differ diff --git a/app/models/gamestate.py b/app/models/gamestate.py new file mode 100644 index 0000000..ddb1ab2 --- /dev/null +++ b/app/models/gamestate.py @@ -0,0 +1,25 @@ +from .. import db + + +class GameState(db.Model): + __tablename__ = "game_state" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, nullable=False) + + board = db.Column(db.Text, nullable=False) + mode = db.Column(db.String(10), nullable=False) + board_size = db.Column(db.Integer, nullable=False, default=8) + save_name = db.Column(db.String(80), nullable=False, default="Unnamed Save") + save_note = db.Column(db.Text, nullable=False, default="") + is_favorite = db.Column(db.Boolean, nullable=False, default=False) + 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")) + is_solved = db.Column(db.Boolean, nullable=False, default=False, server_default=db.text("0")) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now()) + updated_at = db.Column( + db.DateTime, + nullable=False, + server_default=db.func.now(), + onupdate=db.func.now() + ) diff --git a/app/models/login_attempt.py b/app/models/login_attempt.py new file mode 100644 index 0000000..cb9baf2 --- /dev/null +++ b/app/models/login_attempt.py @@ -0,0 +1,19 @@ +from .. import db + + +class LoginAttempt(db.Model): + __tablename__ = "login_attempts" + + id = db.Column(db.Integer, primary_key=True) + ip_address = db.Column(db.String(45), nullable=False) + username = db.Column(db.String(80), nullable=False) + failed_attempts = db.Column(db.Integer, nullable=False, default=0, server_default=db.text("0")) + locked_until = 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): + return f"" diff --git a/app/models/user.py b/app/models/user.py index 1a70419..ef6e199 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -1,15 +1,17 @@ from .. import db from flask_login import UserMixin + class User(UserMixin, db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(20), unique=True, nullable=False) - password = db.Column(db.String(255), nullable=False) # <- größer! - email = db.Column(db.String(120), unique=True, nullable=True) + password = db.Column(db.String(255), nullable=False) created_at = db.Column(db.DateTime, server_default=db.func.now()) last_login = db.Column(db.DateTime) + two_factor_enabled = db.Column(db.Boolean, nullable=False, default=False, server_default=db.text("0")) + two_factor_secret = db.Column(db.String(64)) def __repr__(self): return f"" diff --git a/app/routes/__pycache__/login_routes.cpython-311.pyc b/app/routes/__pycache__/login_routes.cpython-311.pyc index 1b6cba1..0bc2bad 100644 Binary files a/app/routes/__pycache__/login_routes.cpython-311.pyc and b/app/routes/__pycache__/login_routes.cpython-311.pyc differ diff --git a/app/routes/__pycache__/login_routes.cpython-312.pyc b/app/routes/__pycache__/login_routes.cpython-312.pyc new file mode 100644 index 0000000..698eb89 Binary files /dev/null and b/app/routes/__pycache__/login_routes.cpython-312.pyc differ diff --git a/app/routes/__pycache__/main_routes.cpython-311.pyc b/app/routes/__pycache__/main_routes.cpython-311.pyc index 506fedf..7e04893 100644 Binary files a/app/routes/__pycache__/main_routes.cpython-311.pyc and b/app/routes/__pycache__/main_routes.cpython-311.pyc differ diff --git a/app/routes/__pycache__/main_routes.cpython-312.pyc b/app/routes/__pycache__/main_routes.cpython-312.pyc new file mode 100644 index 0000000..ab7d6ab Binary files /dev/null and b/app/routes/__pycache__/main_routes.cpython-312.pyc differ diff --git a/app/routes/login_routes.py b/app/routes/login_routes.py index a849065..a2b52cc 100644 --- a/app/routes/login_routes.py +++ b/app/routes/login_routes.py @@ -1,30 +1,332 @@ -from flask import Blueprint, render_template, request, url_for, redirect, flash -from werkzeug.security import generate_password_hash, check_password_hash -from flask_login import login_user, login_required, logout_user -from ..models.user import User -from .. import db +"""Authentication routes and login-related helper functions. +This module handles user registration, login, logout, password changes, +login-attempt throttling, and authenticator-app based two-factor +authentication. It also contains the utility functions that keep these flows +consistent across the different route handlers. +""" + +import base64 +import binascii +import hashlib +import hmac +import re +import secrets +import struct +from datetime import datetime, timedelta, timezone +from urllib.parse import quote + +from flask import Blueprint, flash, redirect, render_template, request, session, url_for +from flask_login import current_user, login_required, login_user, logout_user +from werkzeug.security import check_password_hash, generate_password_hash + +from .. import db +from ..models.login_attempt import LoginAttempt +from ..models.user import User + +# Blueprint that groups all authentication and account-security routes. login_bp = Blueprint("login", __name__) +# constants +MIN_PASSWORD_LENGTH = 8 +MAX_PASSWORD_LENGTH = 128 +MAX_LOGIN_FAILURES = 8 +LOGIN_LOCK_MINUTES = 15 +TWO_FACTOR_CODE_DIGITS = 6 +TWO_FACTOR_TIME_STEP_SECONDS = 30 +TWO_FACTOR_VALID_WINDOW = 1 +TWO_FACTOR_ISSUER = "8 Queens Solver" +MAX_TWO_FACTOR_ATTEMPTS = 5 +USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_.-]{3,20}$") +DUMMY_PASSWORD_HASH = generate_password_hash("dummy-password-for-timing-protection") + + +def utcnow(): + """Return the current UTC time as datetime.""" + return datetime.utcnow() + + +def get_utc_timestamp(moment=None): + """Convert a datetime into a Unix timestamp in UTC seconds.""" + if moment is None: + return int(datetime.now(timezone.utc).timestamp()) + + if moment.tzinfo is None: + moment = moment.replace(tzinfo=timezone.utc) + + return int(moment.timestamp()) + + +def normalize_username(username): + """Trim surrounding whitespace from the username field.""" + return (username or "").strip() + + +def validate_username(username): + """Return an error message if the username violates the allowed pattern.""" + if not USERNAME_PATTERN.fullmatch(username): + return "Username must be 3-20 characters and only use letters, numbers, dots, dashes, or underscores." + return None + + +def validate_password(password): + """Return an error message if the password length is outside the allowed range.""" + if len(password) < MIN_PASSWORD_LENGTH: + return f"Password must be at least {MIN_PASSWORD_LENGTH} characters long." + + if len(password) > MAX_PASSWORD_LENGTH: + return f"Password must be at most {MAX_PASSWORD_LENGTH} characters long." + + return None + + +def get_request_ip(): + """Determine the client IP address, preferring a reverse-proxy header when present.""" + forwarded_for = request.headers.get("X-Forwarded-For", "") + + if forwarded_for: + return forwarded_for.split(",")[0].strip()[:45] or "unknown" + + return (request.remote_addr or "unknown")[:45] + + +def login_attempt_buckets(username): + """Return the lockout buckets tracked for this login attempt. + + The app tracks failures both per username+IP and per IP-only bucket to + slow down brute-force attempts against many usernames from the same client. + """ + normalized_username = normalize_username(username).lower() or "__empty__" + ip_address = get_request_ip() + return [ + (ip_address, normalized_username), + (ip_address, "__ip__"), + ] + + +def get_or_create_attempt(ip_address, bucket_key): + """Fetch an existing login-attempt record or create a new one.""" + attempt = LoginAttempt.query.filter_by(ip_address=ip_address, username=bucket_key).first() + + if attempt is None: + attempt = LoginAttempt(ip_address=ip_address, username=bucket_key) + db.session.add(attempt) + + return attempt + + +def clear_expired_lock(attempt, now): + """Reset the failure counter when a temporary lockout has expired.""" + if attempt.locked_until and attempt.locked_until <= now: + attempt.failed_attempts = 0 + attempt.locked_until = None + + +def get_active_lock(username): + """Return the latest active lockout time for this username/IP combination.""" + now = utcnow() + active_until = None + + for ip_address, bucket_key in login_attempt_buckets(username): + 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): + """Increase the failure counter and start a temporary lock when needed.""" + 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): + """Remove tracked failed attempts after a successful login.""" + 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): + """Look up a user case-insensitively so login is not case-sensitive.""" + if not username: + return None + + return User.query.filter(db.func.lower(User.username) == username.lower()).first() + + +def minutes_until(target_time): + """Return the remaining lockout time in rounded-up whole minutes.""" + seconds = max(1, int((target_time - utcnow()).total_seconds())) + return max(1, (seconds + 59) // 60) + + +def normalize_two_factor_secret(secret): + """Normalize a Base32 secret by removing spaces and uppercasing it.""" + return (secret or "").strip().replace(" ", "").upper() + + +def generate_two_factor_secret(): + """Generate a new random Base32 secret for authenticator apps.""" + return base64.b32encode(secrets.token_bytes(20)).decode("ascii").rstrip("=") + + +def format_two_factor_secret(secret): + """Insert spaces into the secret to make manual entry easier.""" + normalized = normalize_two_factor_secret(secret) + return " ".join(normalized[index:index + 4] for index in range(0, len(normalized), 4)) + + +def clear_pending_two_factor(user=None): + """Remove the session values used during the login 2FA verification step.""" + session.pop("pending_2fa_user_id", None) + session.pop("pending_2fa_attempts", None) + + +def get_pending_two_factor_setup_secret(user): + """Return the temporary secret generated during 2FA setup for this user.""" + if session.get("pending_2fa_setup_user_id") != user.id: + return None + + secret = normalize_two_factor_secret(session.get("pending_2fa_setup_secret")) + return secret or None + + +def clear_pending_two_factor_setup(): + """Remove the session values used during authenticator setup.""" + session.pop("pending_2fa_setup_user_id", None) + session.pop("pending_2fa_setup_secret", None) + + +def build_totp_uri(user, secret): + """Build the otpauth URI consumed by authenticator applications.""" + normalized_secret = normalize_two_factor_secret(secret) + label = quote(f"{TWO_FACTOR_ISSUER}:{user.username}") + issuer = quote(TWO_FACTOR_ISSUER) + return ( + f"otpauth://totp/{label}" + f"?secret={normalized_secret}&issuer={issuer}" + f"&algorithm=SHA1&digits={TWO_FACTOR_CODE_DIGITS}&period={TWO_FACTOR_TIME_STEP_SECONDS}" + ) + + +def decode_two_factor_secret(secret): + """Decode the Base32 secret into raw bytes for HMAC processing.""" + normalized_secret = normalize_two_factor_secret(secret) + padded_secret = normalized_secret + ("=" * (-len(normalized_secret) % 8)) + return base64.b32decode(padded_secret, casefold=True) + + +def generate_totp_code(secret, for_time=None): + """Generate a time-based one-time password from the shared secret.""" + timestamp = get_utc_timestamp(for_time) + counter = timestamp // TWO_FACTOR_TIME_STEP_SECONDS + key = decode_two_factor_secret(secret) + message = struct.pack(">Q", counter) + digest = hmac.new(key, message, hashlib.sha1).digest() + offset = digest[-1] & 0x0F + truncated_hash = struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF + code = truncated_hash % (10 ** TWO_FACTOR_CODE_DIGITS) + return f"{code:0{TWO_FACTOR_CODE_DIGITS}d}" + + +def verify_totp_code(secret, code, at_time=None): + """Validate a 2FA code, allowing a small time window for clock drift.""" + normalized_code = (code or "").strip() + + if not normalized_code.isdigit() or len(normalized_code) != TWO_FACTOR_CODE_DIGITS: + return False + + current_time = at_time or utcnow() + + try: + for window in range(-TWO_FACTOR_VALID_WINDOW, TWO_FACTOR_VALID_WINDOW + 1): + window_time = current_time + timedelta(seconds=window * TWO_FACTOR_TIME_STEP_SECONDS) + + if secrets.compare_digest(generate_totp_code(secret, window_time), normalized_code): + return True + except (TypeError, ValueError, binascii.Error): + return False + + return False + + +def get_pending_two_factor_user(): + """Load the user who is currently in the middle of the login 2FA step.""" + user_id = session.get("pending_2fa_user_id") + + if not user_id: + return None + + return db.session.get(User, user_id) + + +def render_login_settings(error=None): + """Render the login settings page with optional 2FA setup data.""" + pending_secret = get_pending_two_factor_setup_secret(current_user) + 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"]) def register(): + """Create a new user account after validating the submitted credentials.""" + if current_user.is_authenticated: + return redirect(url_for("main.dashboard")) + if request.method == "POST": - username = request.form.get("username") - password = request.form.get("password") + username = normalize_username(request.form.get("username")) + password = request.form.get("password") or "" - if User.query.filter_by(username=username).first(): - return render_template("sign_up.html", error="Username already taken!") + username_error = validate_username(username) + password_error = validate_password(password) - hashed_password = generate_password_hash(password) + if username_error: + return render_template("sign_up.html", error=username_error, username=username) - new_user = User( - username=username, - password=hashed_password - ) + if password_error: + return render_template("sign_up.html", error=password_error, username=username) + if lookup_user_by_username(username): + return render_template("sign_up.html", error="Username already taken!", username=username) + + new_user = User(username=username, password=generate_password_hash(password)) db.session.add(new_user) db.session.commit() + flash("Account created successfully. You can log in now.", "success") return redirect(url_for("login.login")) return render_template("sign_up.html") @@ -32,23 +334,212 @@ def register(): @login_bp.route("/login", methods=["GET", "POST"]) def login(): + """Authenticate a user and start the 2FA step when required.""" + if current_user.is_authenticated: + return redirect(url_for("main.dashboard")) + if request.method == "POST": - username = request.form.get("username") - password = request.form.get("password") + username = normalize_username(request.form.get("username")) + password = request.form.get("password") or "" - user = User.query.filter_by(username=username).first() + if not username or not password: + return render_template("login.html", error="Username and password are required.", username=username) - if user and check_password_hash(user.password, password): - login_user(user) - return redirect(url_for("main.dashboard")) + # Stop early if this user or IP bucket is currently locked. + locked_until = get_active_lock(username) - return render_template("login.html", error="Invalid username or password") + if locked_until and locked_until > utcnow(): + return render_template( + "login.html", + error=f"Too many failed login attempts. Please try again in about {minutes_until(locked_until)} minute(s).", + username=username, + ) + + user = lookup_user_by_username(username) + # Use a dummy hash when the user does not exist so the timing stays + # similar and does not reveal valid usernames. + password_hash = user.password if user else DUMMY_PASSWORD_HASH + password_matches = check_password_hash(password_hash, password) + + if not user or not password_matches: + locked_until = record_failed_login(username) + error_message = "Invalid username or password." + + if locked_until: + error_message = ( + f"Too many failed login attempts. Please try again in about {minutes_until(locked_until)} minute(s)." + ) + + return render_template("login.html", error=error_message, username=username) + + clear_login_attempts(username) + + if user.two_factor_enabled: + # Delay the full login until the authenticator code has been verified. + if not user.two_factor_secret: + return render_template( + "login.html", + error="This account requires two-factor authentication, but no authenticator app is configured.", + username=username, + ) + + session["pending_2fa_user_id"] = user.id + session["pending_2fa_attempts"] = 0 + flash("Enter the current 6-digit code from your authenticator app.", "info") + return redirect(url_for("login.verify_two_factor")) + + user.last_login = utcnow() + db.session.commit() + login_user(user) + clear_pending_two_factor() + return redirect(url_for("main.dashboard")) return render_template("login.html") -@login_bp.route('/logout') + +@login_bp.route("/login/verify", methods=["GET", "POST"]) +def verify_two_factor(): + """Complete login by verifying the authenticator-app code.""" + if current_user.is_authenticated: + return redirect(url_for("main.dashboard")) + + user = get_pending_two_factor_user() + + if user is None: + flash("Your verification session expired. Please log in again.", "error") + return redirect(url_for("login.login")) + + if request.method == "POST": + code = (request.form.get("code") or "").strip() + + if not code.isdigit() or len(code) != TWO_FACTOR_CODE_DIGITS: + return render_template( + "two_factor_verify.html", + error="Please enter the 6-digit verification code.", + ) + + attempts = int(session.get("pending_2fa_attempts", 0)) + + # Limit repeated guesses during the second factor step as well. + if attempts >= MAX_TWO_FACTOR_ATTEMPTS: + clear_pending_two_factor(user) + db.session.commit() + flash("Too many invalid 2FA attempts. Please log in again.", "error") + return redirect(url_for("login.login")) + + if not user.two_factor_secret: + clear_pending_two_factor() + flash("Authenticator app 2FA is not configured for this account.", "error") + return redirect(url_for("login.login")) + + if not verify_totp_code(user.two_factor_secret, code): + attempts += 1 + session["pending_2fa_attempts"] = attempts + + if attempts >= MAX_TWO_FACTOR_ATTEMPTS: + clear_pending_two_factor(user) + db.session.commit() + flash("Too many invalid 2FA attempts. Please log in again.", "error") + return redirect(url_for("login.login")) + + remaining_attempts = MAX_TWO_FACTOR_ATTEMPTS - attempts + return render_template( + "two_factor_verify.html", + error=f"Invalid authenticator code. You have {remaining_attempts} attempt(s) left.", + ) + + clear_pending_two_factor(user) + user.last_login = utcnow() + db.session.commit() + login_user(user) + flash("Two-factor authentication successful.", "success") + return redirect(url_for("main.dashboard")) + + return render_template("two_factor_verify.html") + + +@login_bp.route("/login/settings", methods=["GET", "POST"]) +@login_required +def login_settings(): + """Handle password changes and authenticator-based 2FA settings.""" + if request.method == "POST": + action = request.form.get("action") + + if action == "start_two_factor_setup": + if current_user.two_factor_enabled: + return render_login_settings(error="Authenticator app 2FA is already enabled.") + + # Store the generated secret in the session until the user confirms + # it with a valid authenticator code. + secret = generate_two_factor_secret() + session["pending_2fa_setup_user_id"] = current_user.id + session["pending_2fa_setup_secret"] = secret + flash("Add the secret to your authenticator app, then confirm it with the current 6-digit code.", "info") + return redirect(url_for("login.login_settings")) + + if action == "confirm_two_factor_setup": + pending_secret = get_pending_two_factor_setup_secret(current_user) + + if not pending_secret: + return render_login_settings(error="Start the authenticator setup first.") + + code = (request.form.get("code") or "").strip() + + if not verify_totp_code(pending_secret, code): + return render_login_settings(error="Invalid authenticator code. Please try the current code again.") + + current_user.two_factor_secret = pending_secret + current_user.two_factor_enabled = True + clear_pending_two_factor_setup() + db.session.commit() + flash("Authenticator app 2FA enabled.", "success") + return redirect(url_for("login.login_settings")) + + if action == "cancel_two_factor_setup": + clear_pending_two_factor_setup() + flash("Authenticator app setup cancelled.", "info") + return redirect(url_for("login.login_settings")) + + if action == "disable_two_factor": + current_user.two_factor_enabled = False + current_user.two_factor_secret = None + clear_pending_two_factor_setup() + db.session.commit() + flash("Authenticator app 2FA disabled.", "success") + return redirect(url_for("login.login_settings")) + + if action == "password": + current_password = request.form.get("current_password") or "" + new_password = request.form.get("new_password") or "" + confirm_password = request.form.get("confirm_password") or "" + + if not check_password_hash(current_user.password, current_password): + return render_login_settings(error="Your current password is incorrect.") + + password_error = validate_password(new_password) + + if password_error: + return render_login_settings(error=password_error) + + if new_password != confirm_password: + return render_login_settings(error="The new passwords do not match.") + + current_user.password = generate_password_hash(new_password) + db.session.commit() + flash("Password updated successfully.", "success") + return redirect(url_for("login.login_settings")) + + return render_login_settings(error="Unknown settings action.") + + return render_login_settings() + + +@login_bp.route("/logout", methods=["POST"]) @login_required def logout(): + """Log the user out and clear any unfinished authentication state.""" + clear_pending_two_factor() + clear_pending_two_factor_setup() logout_user() - flash('You have been logged out', 'info') - return redirect(url_for('login.login')) + flash("You have been logged out.", "info") + return redirect(url_for("login.login")) diff --git a/app/routes/main_routes.py b/app/routes/main_routes.py index 5909e0a..fcca6a5 100644 --- a/app/routes/main_routes.py +++ b/app/routes/main_routes.py @@ -1,13 +1,442 @@ -from flask import Blueprint, render_template -from flask_login import login_required, current_user +"""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 random +from datetime import datetime + +from flask import Blueprint, jsonify, render_template, request +from flask_login import current_user, login_required + +from .. import db +from ..models.gamestate import GameState + +DEFAULT_BOARD_SIZE = 8 +MIN_BOARD_SIZE = 4 +MAX_BOARD_SIZE = 13 + +# Blueprint that groups the main site and game-related routes. main_bp = Blueprint("main", __name__) + +def parse_board_size(raw_size, default=DEFAULT_BOARD_SIZE): + """Convert a user-supplied board size into a safe integer range.""" + try: + board_size = int(raw_size) + except (TypeError, ValueError): + board_size = default + + return max(MIN_BOARD_SIZE, min(MAX_BOARD_SIZE, board_size)) + + +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"} + + +def parse_non_negative_int(raw_value, default=0): + """Parse an integer and clamp negative values to zero.""" + try: + value = int(raw_value) + except (TypeError, ValueError): + return default + + return max(0, value) + + +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}" + + +def solve_queens(board_size): + """Return every valid N-Queens solution for the requested board size.""" + queen_positions = [-1] * board_size + queen_solutions = [] + + def is_queen_position_safe(row, col): + """Check whether the new queen conflicts with any earlier queen.""" + for previous_row in range(row): + previous_col = queen_positions[previous_row] + + if previous_col == col: + return False + + if abs(previous_col - col) == abs(previous_row - row): + return False + + return True + + def place_queen(row): + """Backtracking search that fills the board row by row.""" + if row == board_size: + queen_solutions.append(queen_positions[:]) + return + + for col in range(board_size): + if is_queen_position_safe(row, col): + queen_positions[row] = col + place_queen(row + 1) + queen_positions[row] = -1 + + place_queen(0) + return queen_solutions + + +def solve_rooks(board_size): + """Return one valid rook arrangement with exactly one rook per row/column.""" + rook_positions = list(range(board_size)) + random.shuffle(rook_positions) + return rook_positions + + +def build_queens_trace(board_size): + """Build a step-by-step trace that explains the queens backtracking search.""" + queen_positions = [-1] * board_size + steps = [] + + def is_queen_position_safe(row, col): + """Reuse the same safety rules used by the queens solver.""" + for previous_row in range(row): + previous_col = queen_positions[previous_row] + + if previous_col == col: + return False + + if abs(previous_col - col) == abs(previous_row - row): + return False + + return True + + def place_queen(row): + """Record every placement and backtracking step until a solution is found.""" + if row == board_size: + steps.append({ + "type": "solution", + "row": None, + "col": None, + "board": queen_positions[:], + "message": f"Lösung gefunden. Alle {board_size} Damen stehen ohne Konflikte." + }) + return True + + for col in range(board_size): + if not is_queen_position_safe(row, col): + continue + + queen_positions[row] = col + 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({ + "type": "place", + "row": row, + "col": col, + "board": queen_positions[:], + "message": f"Dame auf {position} gesetzt." + }) + + if place_queen(row + 1): + return True + + queen_positions[row] = -1 + steps.append({ + "type": "remove", + "row": row, + "col": col, + "board": queen_positions[:], + "message": f"Backtracking: Dame von {position} entfernt." + }) + + return False + + solved = place_queen(0) + + return { + "mode": "queens", + "board_size": board_size, + "initial_board": [-1] * board_size, + "steps": steps, + "solved": solved + } + + +def build_rooks_trace(board_size): + """Build a simple placement trace for the rook mode.""" + rook_positions = solve_rooks(board_size) + board = [-1] * board_size + steps = [] + + for row, col in enumerate(rook_positions): + board[row] = col + 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({ + "type": "place", + "row": row, + "col": col, + "board": board[:], + "message": f"Turm auf {position} gesetzt." + }) + + steps.append({ + "type": "solution", + "row": None, + "col": None, + "board": board[:], + "message": f"Loesung gefunden. Alle {board_size} Tuerme stehen ohne Konflikte." + }) + + return { + "mode": "rooks", + "board_size": board_size, + "initial_board": [-1] * board_size, + "steps": steps, + "solved": True + } + + +def serialize_game_state(game_state): + """Convert a database model into JSON-friendly data for the frontend.""" + board = json.loads(game_state.board) + + return { + "id": game_state.id, + "board": board, + "mode": game_state.mode, + "board_size": game_state.board_size, + "save_name": game_state.save_name, + "save_note": game_state.save_note, + "is_favorite": game_state.is_favorite, + "pieces_placed": sum(1 for column in board if column != -1), + "step_count": game_state.step_count, + "elapsed_seconds": game_state.elapsed_seconds, + "is_solved": game_state.is_solved, + "created_at": game_state.created_at.strftime("%Y-%m-%d %H:%M:%S"), + "updated_at": game_state.updated_at.strftime("%Y-%m-%d %H:%M:%S") + } + + +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) + + selected_mode = request.args.get("mode") + board_size = request.args.get("board_size") + favorites_only = request.args.get("favorites_only") + sort_by = request.args.get("sort", "newest") + + # Apply optional filters only when the request contains a supported value. + if selected_mode in {"queens", "rooks"}: + query = query.filter_by(mode=selected_mode) + + if board_size not in {None, "", "all"}: + query = query.filter_by(board_size=parse_board_size(board_size)) + + if parse_favorite_flag(favorites_only): + query = query.filter_by(is_favorite=True) + + sort_options = { + "newest": GameState.created_at.desc(), + "oldest": GameState.created_at.asc(), + "updated": GameState.updated_at.desc(), + "favorites": GameState.is_favorite.desc() + } + + return query.order_by(sort_options.get(sort_by, GameState.created_at.desc()), GameState.id.desc()) + + @main_bp.route("/") def home(): - return render_template("index.html") + """Render the public landing page and choose a matching default language.""" + initial_language = request.accept_languages.best_match(["de", "en"]) or "de" + return render_template("index.html", initial_language=initial_language) -@main_bp.route('/dashboard') + +@main_bp.route("/dashboard") @login_required def dashboard(): - return render_template('dashboard.html', user=current_user) + """Render the logged-in dashboard.""" + return render_template("dashboard.html", user=current_user) + + +@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"]) +def check(): + """Validate whether a piece can be placed on the requested square.""" + data = request.json + board = data["board"] + row = data["row"] + col = data["col"] + piece_mode = data.get("mode", "queens") + + # The board is stored as a list where the index is the row and the value is + # the column of the placed piece. -1 means the row is still empty. + for current_row, current_col in enumerate(board): + if current_col == -1 or current_row == row: + continue + + same_column = current_col == col + same_diagonal = abs(current_col - col) == abs(current_row - row) + + if same_column: + return jsonify({"valid": False}) + + # Diagonal conflicts only matter for queens, not for rooks. + if piece_mode == "queens" and same_diagonal: + return jsonify({"valid": False}) + + return jsonify({"valid": True}) + + +@main_bp.route("/solve") +def solve_queens_route(): + """Return all queens solutions for the requested board size.""" + board_size = parse_board_size(request.args.get("size")) + return jsonify(solve_queens(board_size)) + + +@main_bp.route("/solve_trace") +def solve_queens_trace_route(): + """Return a step-by-step queens solving trace.""" + board_size = parse_board_size(request.args.get("size")) + return jsonify(build_queens_trace(board_size)) + + +@main_bp.route("/solve_rooks") +def solve_rooks_route(): + """Return one rook solution for the requested board size.""" + board_size = parse_board_size(request.args.get("size")) + return jsonify(solve_rooks(board_size)) + + +@main_bp.route("/solve_rooks_trace") +def solve_rooks_trace_route(): + """Return a step-by-step rook placement trace.""" + board_size = parse_board_size(request.args.get("size")) + return jsonify(build_rooks_trace(board_size)) + + +@main_bp.route("/save", methods=["POST"]) +@login_required +def save_game(): + """Store the current board state as a new save point for the user.""" + data = request.json + + board = data["board"] + piece_mode = data["mode"] + board_size = parse_board_size(data.get("boardSize"), default=len(board)) + save_name = (data.get("saveName") or "").strip() or f"{piece_mode.title()} Save" + save_note = (data.get("saveNote") or "").strip() + is_favorite = bool(data.get("isFavorite")) + step_count = parse_non_negative_int(data.get("stepCount")) + elapsed_seconds = parse_non_negative_int(data.get("elapsedSeconds")) + is_solved = bool(data.get("isSolved")) + timestamp = datetime.utcnow() + + # Save the board as JSON text so it can be restored exactly as the user left it. + game_state = GameState( + user_id=current_user.id, + board=json.dumps(board), + mode=piece_mode, + board_size=board_size, + save_name=save_name, + save_note=save_note, + is_favorite=is_favorite, + step_count=step_count, + elapsed_seconds=elapsed_seconds, + is_solved=is_solved, + created_at=timestamp, + updated_at=timestamp + ) + + db.session.add(game_state) + db.session.commit() + + return jsonify({ + "status": "saved", + "save_point": serialize_game_state(game_state) + }) + + +@main_bp.route("/save_points") +@login_required +def list_save_points(): + """Return all save points for the current user after filtering and sorting.""" + save_points = build_save_point_query().all() + return jsonify([serialize_game_state(game_state) for game_state in save_points]) + + +@main_bp.route("/save_points//favorite", methods=["POST"]) +@login_required +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() + + if not game_state: + return jsonify({"error": "Save point not found"}), 404 + + game_state.is_favorite = not game_state.is_favorite + db.session.commit() + + return jsonify(serialize_game_state(game_state)) + + +@main_bp.route("/save_points/", methods=["DELETE"]) +@login_required +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() + + if not game_state: + return jsonify({"error": "Save point not found"}), 404 + + db.session.delete(game_state) + db.session.commit() + + return jsonify({"status": "deleted"}) + + +@main_bp.route("/load") +@login_required +def load_game(): + """Load the newest save point for the current user.""" + latest_game = ( + GameState.query.filter_by(user_id=current_user.id) + .order_by(GameState.created_at.desc(), GameState.id.desc()) + .first() + ) + + # If the user has never saved a game, return a clean default board. + if not latest_game: + return jsonify({"board": [-1] * DEFAULT_BOARD_SIZE, "mode": "queens"}) + + return jsonify(serialize_game_state(latest_game)) + + +@main_bp.route("/load/") +@login_required +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() + + if not game_state: + return jsonify({"error": "Save point not found"}), 404 + + return jsonify(serialize_game_state(game_state)) diff --git a/app/static/css/dashboard.css b/app/static/css/dashboard.css new file mode 100644 index 0000000..75accb6 --- /dev/null +++ b/app/static/css/dashboard.css @@ -0,0 +1,1089 @@ +:root { + --bg: #f4efe7; + --bg-accent: #efe3cd; + --surface: rgba(255, 250, 242, 0.88); + --surface-strong: #fffaf2; + --surface-soft: rgba(255, 247, 235, 0.76); + --text: #1e1f1d; + --muted: #5f615c; + --navy: #1f3140; + --navy-soft: #324d63; + --gold: #c68b2c; + --gold-soft: #e7bf77; + --gold-pale: #f1ddae; + --line: rgba(31, 49, 64, 0.12); + --shadow: 0 24px 60px rgba(37, 42, 48, 0.14); + --success-color: #166534; + --info-color: #1d4ed8; + --error-color: #991b1b; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +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; +} + +button, +input, +select, +textarea { + font: inherit; +} + +.dashboard-nav { + width: 100%; + margin: 0; + padding: 18px max(16px, calc((100vw - 1200px) / 2)) 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + position: sticky; + top: 0; + z-index: 20; + border-top: 0; + 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; +} + +.nav-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; + gap: 12px; +} + +.nav-link, +.nav-link-button { + 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); + color: #ecf4fb; + font-weight: 700; + cursor: pointer; + transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; +} + +.lang-switch { + min-width: 68px; + border-color: rgba(231, 191, 119, 0.45); +} + +.nav-link:hover, +.nav-link-button:hover { + transform: translateY(-2px); + border-color: rgba(231, 191, 119, 0.45); +} + +.nav-logout-form { + margin: 0; +} + +.account-shortcut { + display: inline-flex; + align-items: center; + gap: 10px; + min-height: 46px; + padding: 8px 12px 8px 8px; + background: rgba(255, 255, 255, 0.08); + border: 2px solid rgba(231, 191, 119, 0.26); + color: #fff7eb; + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; +} + +.account-shortcut:hover { + transform: translateY(-2px); + border-color: rgba(231, 191, 119, 0.48); +} + +.account-avatar { + width: 36px; + height: 36px; + display: grid; + place-items: center; + border-radius: 999px; + background: linear-gradient(135deg, var(--gold), var(--gold-soft)); + color: #2d2418; + font-weight: 700; +} + +.account-copy { + display: grid; + gap: 2px; +} + +.account-label { + font-size: 12px; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.account-name { + font-family: "Trebuchet MS", "Segoe UI", sans-serif; + font-size: 14px; +} + +.settings-trigger { + position: fixed; + left: 0; + top: 180px; + z-index: 18; + display: inline-flex; + align-items: center; + gap: 10px; + padding: 14px 16px; + border: 0; + border-left: 0; + background: linear-gradient(135deg, var(--navy), var(--navy-soft)); + color: #fff7eb; + box-shadow: 0 16px 34px rgba(31, 49, 64, 0.18); + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.settings-trigger:hover { + transform: translateX(4px); +} + +.settings-trigger-arrow { + font-size: 20px; + line-height: 1; +} + +.settings-trigger-label { + font-size: 14px; + font-weight: 700; + letter-spacing: 0.04em; +} + +.container { + width: min(1200px, calc(100vw - 32px)); + margin: 0 auto; + padding: 34px 0 56px; +} + +.card { + padding: 28px; + margin-bottom: 24px; + border-radius: 12px; + background: linear-gradient(135deg, rgba(255, 250, 242, 0.94), rgba(245, 230, 205, 0.8)); + border: 1px solid var(--line); + box-shadow: var(--shadow); +} + +.flash-stack { + display: grid; + gap: 12px; + margin-bottom: 18px; +} + +.flash-message { + margin: 0; + padding: 12px 14px; + border-radius: 8px; + font-family: "Trebuchet MS", "Segoe UI", sans-serif; +} + +.flash-message.success { + color: var(--success-color); + background: rgba(220, 252, 231, 0.92); +} + +.flash-message.info { + color: var(--info-color); + background: rgba(219, 234, 254, 0.92); +} + +.flash-message.error { + color: var(--error-color); + background: rgba(254, 226, 226, 0.92); +} + +.section-kicker { + margin: 0 0 12px; + color: var(--navy-soft); + text-transform: uppercase; + letter-spacing: 0.14em; + font-size: 13px; + font-weight: 700; +} + +.dashboard-intro { + text-align: center; +} + +.dashboard-intro h1 { + margin: 0; + line-height: 0.96; + letter-spacing: -0.03em; + font-size: clamp(2.4rem, 4.4vw, 4.8rem); + color: #182029; +} + +.dashboard-intro-text, +.board-mode-text, +.solver-message, +.solver-group-text, +.history-subtitle, +.board-size-info, +.sidebar p, +.sidebar-hint, +.modal-content p, +.save-point-meta, +.save-point-preview, +.save-point-note { + font-family: "Trebuchet MS", "Segoe UI", sans-serif; + line-height: 1.65; + color: var(--muted); +} + +.dashboard-intro-text { + max-width: 760px; + margin: 18px auto 0; + font-size: 18px; +} + +.btn { + min-height: 50px; + padding: 12px 18px; + border: none; + border-radius: 0; + background: linear-gradient(135deg, var(--gold), var(--gold-soft)); + color: #2d2418; + font-weight: 700; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease; +} + +.btn:hover { + transform: translateY(-2px); + box-shadow: 0 12px 24px rgba(31, 49, 64, 0.16); +} + +.btn:active { + transform: scale(0.98); +} + +.btn:disabled, +.btn:disabled:hover { + transform: none; + opacity: 0.55; + box-shadow: none; + cursor: not-allowed; +} + +.btn.secondary { + background: linear-gradient(135deg, var(--navy), var(--navy-soft)); + color: #f9fbfd; +} + +.btn.danger, +.btn.ghost { + background: rgba(255, 255, 255, 0.68); + color: var(--navy); + border: 1px solid rgba(31, 49, 64, 0.16); +} + +.btn.active, +.btn.accent { + background: linear-gradient(135deg, var(--gold), #f0c35b); + color: #241b0d; +} + +.mode-switch { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 16px; + margin-top: 28px; +} + +.mode-switch .btn { + min-width: 220px; + min-height: 68px; + padding: 18px 28px; + font-size: 1.15rem; +} + +.game-card { + text-align: center; +} + +.game-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + text-align: left; +} + +.game-card-copy { + max-width: 760px; +} + +.game-card h2, +.card h3, +.solver-panel h3, +.sidebar h3, +.modal-header h3 { + margin: 0; + color: #182029; +} + +.game-card h2 { + line-height: 0.98; + letter-spacing: -0.03em; + font-size: clamp(2rem, 3.2vw, 3.2rem); +} + +.board-mode-text { + margin: 16px 0 0; + font-size: 18px; +} + +.info-button { + min-height: 48px; + padding: 12px 18px; + border: 1px solid rgba(31, 49, 64, 0.14); + background: rgba(255, 255, 255, 0.66); + color: var(--navy); + font-weight: 700; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.info-button:hover { + transform: translateY(-2px); + box-shadow: 0 12px 24px rgba(31, 49, 64, 0.12); +} + +.board-size-info { + margin: 18px 0 10px; + font-size: 16px; +} + +.game-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + margin: 18px 0 14px; + text-align: left; +} + +.game-stat-card { + padding: 16px 18px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.56); + border: 1px solid rgba(31, 49, 64, 0.08); +} + +.game-stat-label { + margin: 0 0 8px; + font-size: 13px; + color: var(--navy-soft); + text-transform: uppercase; + letter-spacing: 0.12em; + font-weight: 700; +} + +.game-stat-value { + margin: 0; + font-size: 28px; + font-weight: 700; + color: #1e293b; + font-variant-numeric: tabular-nums; +} + +.game-stat-status { + font-size: 22px; +} + +#statusMessage { + min-height: 24px; + margin: 0 0 10px; + font-family: "Trebuchet MS", "Segoe UI", sans-serif; + font-weight: 700; +} + +.solved-celebration { + margin: 0 auto 18px; + padding: 18px 20px; + max-width: 760px; + border: 1px solid rgba(198, 139, 44, 0.28); + background: + radial-gradient(circle at top left, rgba(255, 255, 255, 0.75), transparent 35%), + linear-gradient(135deg, rgba(240, 195, 91, 0.96), rgba(231, 191, 119, 0.9)); + color: #2d2418; + box-shadow: 0 18px 40px rgba(198, 139, 44, 0.18); + animation: solvedPop 0.55s ease; +} + +.solved-celebration-title { + margin: 0; + font-size: clamp(1.8rem, 3vw, 2.6rem); + font-weight: 700; + letter-spacing: -0.03em; +} + +.solved-celebration-text { + margin: 8px 0 0; + font-family: "Trebuchet MS", "Segoe UI", sans-serif; + font-size: 16px; + line-height: 1.55; +} + +.board { + overflow-x: auto; + padding-bottom: 8px; +} + +.board-grid { + --label-size: var(--cell-size); + --cell-size: 60px; + display: grid; + grid-template-columns: var(--label-size) repeat(var(--board-size, 8), var(--cell-size)); + grid-template-rows: var(--label-size) repeat(var(--board-size, 8), var(--cell-size)); + margin: 20px auto; + width: max-content; + background: var(--navy); + border: 6px solid var(--navy); + border-radius: 10px; + overflow: hidden; + max-width: 100%; +} + +.label { + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + background: var(--navy); + color: #fff7eb; + width: var(--label-size); + height: var(--label-size); + font-size: clamp(13px, 1.4vw, 18px); + text-align: center; +} + +.cell { + width: var(--cell-size); + height: var(--cell-size); + display: flex; + align-items: center; + justify-content: center; + font-size: calc(var(--cell-size) * 0.5); + cursor: pointer; + transition: transform 0.2s ease, background 0.2s ease; +} + +.cell:hover { + transform: scale(1.05); +} + +.white { + background: #f7e6ca; +} + +.black { + background: #92613a; + color: #fff5ea; +} + +.invalid { + 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 { + margin: 0; +} + +.solver-step-info { + margin: 0; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.74); + border: 1px solid rgba(31, 49, 64, 0.12); + color: var(--navy); + font-size: 14px; + font-weight: 700; + 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 { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 12px; + margin-top: 16px; +} + +.solver-speed input[type="range"] { + width: 100%; +} + +.solver-speed-label { + font-weight: 700; + color: var(--navy); +} + +.solver-speed-value { + min-width: 62px; + text-align: right; + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +.controls { + margin-top: 18px; + display: flex; + justify-content: center; + flex-wrap: wrap; + 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, +.history-header, +.sidebar-header, +.modal-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.section-badge { + padding: 8px 12px; + background: rgba(255, 255, 255, 0.66); + border: 1px solid rgba(31, 49, 64, 0.1); + color: var(--navy); + font-family: "Trebuchet MS", "Segoe UI", sans-serif; + font-size: 14px; + font-weight: 700; +} + +#queenList { + list-style: none; + padding: 0; + margin: 18px 0 0; + display: grid; + gap: 10px; +} + +#queenList li { + padding: 12px 14px; + background: rgba(255, 255, 255, 0.56); + border: 1px solid rgba(31, 49, 64, 0.08); + border-radius: 8px; + font-family: "Trebuchet MS", "Segoe UI", sans-serif; + color: var(--navy); +} + +#queenList li.empty { + color: var(--muted); +} + +.history-header h3 { + margin-top: 4px; +} + +.history-subtitle { + margin: 8px 0 0; + font-size: 15px; +} + +.history-table-wrapper { + overflow-x: auto; + margin-top: 18px; +} + +.history-table { + width: 100%; + border-collapse: collapse; + min-width: 720px; +} + +.history-table th, +.history-table td { + padding: 12px 10px; + border-bottom: 1px solid rgba(31, 49, 64, 0.08); + text-align: left; + font-family: "Trebuchet MS", "Segoe UI", sans-serif; +} + +.history-table th { + color: var(--navy); + font-size: 14px; +} + +.history-table td { + color: var(--muted); + font-size: 14px; +} + +.history-empty { + color: var(--muted); + text-align: center !important; +} + +.sidebar { + position: fixed; + top: 0; + right: 0; + width: min(360px, 92vw); + height: 100vh; + padding: 24px; + background: linear-gradient(180deg, rgba(255, 250, 242, 0.98), rgba(245, 230, 205, 0.96)); + border-left: 1px solid rgba(31, 49, 64, 0.12); + box-shadow: -10px 0 24px rgba(15, 23, 42, 0.18); + transform: translateX(100%); + transition: transform 0.25s ease; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; + z-index: 30; +} + +.sidebar.open { + transform: translateX(0); +} + +.settings-sidebar { + left: 0; + right: auto; + border-left: 0; + border-right: 1px solid rgba(31, 49, 64, 0.12); + box-shadow: 10px 0 24px rgba(15, 23, 42, 0.18); + transform: translateX(-100%); +} + +.settings-sidebar.open { + transform: translateX(0); +} + +.save-points-sidebar { + width: min(400px, 94vw); +} + +.sidebar-label { + display: block; + margin-top: 18px; + margin-bottom: 8px; + font-weight: 700; + color: var(--navy); +} + +.sidebar input[type="range"] { + width: 100%; +} + +.sidebar-input, +.sidebar select, +.sidebar textarea, +.sidebar input[type="text"] { + width: 100%; + padding: 12px 14px; + border: 1px solid rgba(31, 49, 64, 0.16); + border-radius: 0; + background: rgba(255, 255, 255, 0.9); + color: var(--text); +} + +.sidebar-textarea { + resize: vertical; + min-height: 96px; +} + +.sidebar-checkbox { + display: flex; + align-items: center; + gap: 10px; + margin: 16px 0 18px; + color: var(--navy); + font-family: "Trebuchet MS", "Segoe UI", sans-serif; +} + +.sidebar-size-row { + margin: 12px 0 18px; + font-size: 18px; +} + +.save-points-list { + display: grid; + gap: 12px; + margin-top: 18px; +} + +.save-point-filters { + display: grid; + gap: 8px; + margin-top: 18px; + padding-bottom: 16px; + border-bottom: 1px solid rgba(31, 49, 64, 0.08); +} + +.save-point-card { + border: 1px solid rgba(31, 49, 64, 0.1); + padding: 14px; + background: rgba(255, 255, 255, 0.56); +} + +.save-point-card h4 { + margin: 0 0 8px; + color: #1e293b; +} + +.save-point-meta, +.save-point-preview, +.save-point-note { + margin: 6px 0 0; + font-size: 14px; +} + +.save-point-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} + +.sidebar-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.3); + opacity: 0; + pointer-events: none; + transition: opacity 0.25s ease; + z-index: 20; +} + +.sidebar-backdrop.visible { + opacity: 1; + pointer-events: auto; +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.42); + opacity: 0; + pointer-events: none; + transition: opacity 0.25s ease; + z-index: 40; +} + +.modal-backdrop.visible { + opacity: 1; + pointer-events: auto; +} + +.modal { + position: fixed; + top: 50%; + left: 50%; + width: min(680px, calc(100vw - 32px)); + max-height: min(82vh, 720px); + padding: 24px; + background: linear-gradient(180deg, rgba(255, 250, 242, 0.98), rgba(245, 230, 205, 0.96)); + border-radius: 12px; + border: 1px solid rgba(31, 49, 64, 0.12); + box-shadow: 0 20px 60px rgba(15, 23, 42, 0.28); + transform: translate(-50%, -48%); + opacity: 0; + pointer-events: none; + transition: opacity 0.25s ease, transform 0.25s ease; + overflow-y: auto; + z-index: 50; +} + +.modal.open { + opacity: 1; + pointer-events: auto; + transform: translate(-50%, -50%); +} + +.modal-content { + margin-top: 16px; + text-align: left; +} + +.modal-content h4 { + margin: 18px 0 8px; + color: var(--navy); +} + +.cell.solver-place { + box-shadow: inset 0 0 0 4px #2f8f53; + animation: solverPulse 0.35s ease; +} + +.cell.solver-remove { + box-shadow: inset 0 0 0 4px #d08a24; + animation: solverPulse 0.35s ease; +} + +.cell.solver-solution { + box-shadow: inset 0 0 0 4px #2563eb; + animation: solverPulse 0.35s ease; +} + +@keyframes solverPulse { + 0% { + transform: scale(0.97); + } + + 50% { + transform: scale(1.03); + } + + 100% { + transform: scale(1); + } +} + +@keyframes solvedPop { + 0% { + opacity: 0; + transform: translateY(-10px) scale(0.96); + } + + 60% { + opacity: 1; + transform: translateY(2px) scale(1.02); + } + + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@media (max-width: 980px) { + .solver-groups { + grid-template-columns: 1fr; + } + + .solver-controls { + grid-template-columns: 1fr; + } + + .game-card-header, + .history-header, + .section-header { + flex-direction: column; + } +} + +@media (max-width: 760px) { + .dashboard-nav { + position: static; + padding: 18px 16px; + border-left-width: 0; + border-right-width: 0; + flex-direction: column; + align-items: flex-start; + } + + .brand { + width: 100%; + font-size: 16px; + } + + .nav-actions { + width: 100%; + justify-content: flex-start; + } + + .account-name { + display: none; + } + + .settings-trigger { + top: auto; + bottom: 16px; + left: 12px; + max-width: calc(100vw - 24px); + } + + .container { + width: 100%; + padding-top: 22px; + } + + .card { + padding: 22px 18px; + } + + .game-card { + padding-left: 8px; + padding-right: 8px; + } + + .board { + margin-left: -4px; + margin-right: -4px; + padding-bottom: 4px; + } + + .board-grid { + margin: 12px auto; + border-width: 4px; + border-radius: 8px; + } + + .label { + font-size: 12px; + } + + .game-stats, + .solver-speed { + grid-template-columns: 1fr; + } + + .mode-switch .btn { + min-width: min(100%, 280px); + } + + .controls { + justify-content: stretch; + } + + .controls>.btn, + .save-point-actions .btn { + width: 100%; + } + + .controls-save-row { + width: 100%; + } + + .sidebar { + width: min(100%, calc(100vw - 24px)); + } + + .modal { + width: min(100%, calc(100vw - 24px)); + padding: 20px; + } +} diff --git a/app/static/css/footer.css b/app/static/css/footer.css new file mode 100644 index 0000000..b6a3203 --- /dev/null +++ b/app/static/css/footer.css @@ -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; + } +} diff --git a/app/static/css/index.css b/app/static/css/index.css new file mode 100644 index 0000000..1aaef0c --- /dev/null +++ b/app/static/css/index.css @@ -0,0 +1,514 @@ +:root { + --bg: #f4efe7; + --bg-accent: #efe3cd; + --surface: rgba(255, 250, 242, 0.82); + --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; +} + +html { + scroll-behavior: smooth; +} + +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; +} + +main { + width: min(1200px, calc(100vw - 32px)); + margin: 0 auto; + padding: 34px 0 56px; +} + +.landing-nav { + width: 100%; + margin: 0; + padding: 18px max(16px, calc((100vw - 1200px) / 2)) 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + position: sticky; + top: 0; + z-index: 20; + border-top: 0; + 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; +} + +.landing-links { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 14px; + color: #ecf4fb; +} + +.landing-links a:not(.nav-cta) { + padding: 12px 14px; + border: 2px solid transparent; + background: rgba(255, 255, 255, 0.04); + opacity: 0.92; +} + +.landing-links a:not(.nav-cta):hover { + opacity: 1; + border-color: rgba(231, 191, 119, 0.45); +} + +.lang-switch, +.nav-cta, +.hero-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 13px 20px; + font-weight: 700; + transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; +} + +.lang-switch { + min-width: 68px; + border: 2px solid rgba(231, 191, 119, 0.45); + background: rgba(255, 255, 255, 0.06); + color: #fff7eb; + cursor: pointer; + font-family: inherit; +} + +.nav-cta { + background: linear-gradient(135deg, var(--gold), var(--gold-soft)); + color: #2d2418; +} + +.nav-cta.secondary { + background: rgba(255, 255, 255, 0.08); + color: #f8fafc; + border: 1px solid rgba(255, 255, 255, 0.16); +} + +.lang-switch:hover, +.nav-cta:hover, +.hero-button:hover { + transform: translateY(-2px); +} + +.hero { + padding: 88px 0 56px; + display: grid; + grid-template-columns: minmax(0, 1.08fr) minmax(320px, 0.92fr); + gap: 42px; + align-items: center; +} + +.eyebrow { + margin: 0 0 12px; + color: var(--navy-soft); + text-transform: uppercase; + letter-spacing: 0.14em; + font-size: 13px; + font-weight: 700; +} + +.hero h1, +.info-band h2, +.section-heading h2, +.cta-panel h2 { + margin: 0; + line-height: 0.95; + letter-spacing: -0.03em; + font-size: clamp(2.8rem, 5vw, 5.5rem); + color: #182029; +} + +.hero-text, +.info-band p, +.feature-card p, +.journey-step p, +.cta-panel p { + font-family: "Trebuchet MS", "Segoe UI", sans-serif; + font-size: 18px; + line-height: 1.7; + color: var(--muted); +} + +.hero-text { + max-width: 640px; + margin: 24px 0 0; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 14px; + margin-top: 30px; +} + +.hero-button.primary { + background: linear-gradient(135deg, var(--navy), var(--navy-soft)); + color: #f9fbfd; + box-shadow: 0 14px 30px rgba(31, 49, 64, 0.22); +} + +.hero-button.secondary { + border: 1px solid rgba(31, 49, 64, 0.16); + background: rgba(255, 255, 255, 0.5); + color: var(--navy); +} + +.hero-facts { + margin-top: 30px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.hero-facts article { + padding: 18px; + border-radius: 10px; + background: rgba(255, 250, 242, 0.65); + border: 1px solid rgba(31, 49, 64, 0.08); + box-shadow: var(--shadow); +} + +.hero-facts strong { + display: block; + font-size: 18px; + color: var(--navy); +} + +.hero-facts span { + display: block; + margin-top: 6px; + font-family: "Trebuchet MS", "Segoe UI", sans-serif; + color: var(--muted); + line-height: 1.5; +} + +.hero-visual { + position: relative; + min-height: 620px; + display: grid; + place-items: center; +} + +.board-showcase { + width: min(100%, 520px); + aspect-ratio: 1; + padding: 18px; + display: grid; + grid-template-columns: repeat(8, 1fr); + gap: 0; + border-radius: 14px; + background: + linear-gradient(145deg, rgba(255, 255, 255, 0.85), rgba(247, 233, 210, 0.7)), + rgba(255, 255, 255, 0.5); + box-shadow: var(--shadow); + position: relative; + overflow: hidden; +} + +.board-cell { + position: relative; + display: grid; + place-items: center; + font-size: clamp(20px, 2vw, 30px); + transition: transform 0.45s ease, opacity 0.45s ease, background 0.45s ease; +} + +.board-cell.light { + background: #f7e6ca; +} + +.board-cell.dark { + background: #92613a; +} + +.board-cell.highlight { + background: #2f485f; +} + +.board-cell.queen::after { + content: "♛"; + color: #fff3df; + text-shadow: 0 8px 18px rgba(0, 0, 0, 0.28); + transform: scale(1); + animation: queenPulse 1.8s ease-in-out infinite; +} + +.floating-card { + position: absolute; + padding: 16px 18px; + border-radius: 8px; + background: rgba(255, 250, 242, 0.85); + border: 1px solid rgba(31, 49, 64, 0.08); + box-shadow: 0 18px 35px rgba(31, 49, 64, 0.14); + backdrop-filter: blur(10px); +} + +.solver-card { + top: 26px; + right: -8px; +} + +.timer-card { + left: -8px; + bottom: 34px; +} + +.floating-label { + margin: 0; + font-family: "Trebuchet MS", "Segoe UI", sans-serif; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--muted); +} + +.floating-value { + margin: 6px 0 0; + font-size: 22px; + color: var(--navy); +} + +.info-band, +.feature-section, +.journey-section, +.cta-section { + padding: 38px 0; +} + +.info-band-inner, +.cta-panel { + padding: 36px; + border-radius: 12px; + background: linear-gradient(135deg, rgba(255, 250, 242, 0.88), rgba(245, 230, 205, 0.7)); + border: 1px solid rgba(31, 49, 64, 0.08); + box-shadow: var(--shadow); +} + +.info-band h2 { + font-size: clamp(2.2rem, 4vw, 4.2rem); + margin-bottom: 18px; +} + +.section-heading { + max-width: 760px; + margin-bottom: 28px; +} + +.section-heading h2 { + font-size: clamp(2.2rem, 3.8vw, 4rem); +} + +.feature-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} + +.feature-card, +.journey-step { + padding: 26px; + border-radius: 10px; + background: rgba(255, 250, 242, 0.72); + border: 1px solid rgba(31, 49, 64, 0.08); + box-shadow: var(--shadow); +} + +.feature-icon { + display: inline-grid; + place-items: center; + width: 54px; + height: 54px; + border-radius: 8px; + background: linear-gradient(135deg, var(--navy), var(--navy-soft)); + color: #fff3df; + font-size: 28px; +} + +.feature-card h3, +.journey-step h3 { + margin: 18px 0 10px; + font-size: 28px; + color: #1c2833; +} + +.journey-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; +} + +.step-number { + display: inline-block; + margin-bottom: 18px; + color: var(--gold); + font-size: 14px; + font-weight: 700; + letter-spacing: 0.16em; +} + +.cta-panel { + text-align: center; +} + +.cta-panel h2 { + font-size: clamp(2.2rem, 4vw, 4.4rem); +} + +.cta-panel p { + max-width: 760px; + margin: 18px auto 0; +} + +.cta-panel .hero-actions { + justify-content: center; +} + +@keyframes queenPulse { + 0%, + 100% { + transform: scale(1) translateY(0); + } + + 50% { + transform: scale(1.08) translateY(-2px); + } +} + +@media (max-width: 980px) { + .hero { + grid-template-columns: 1fr; + gap: 28px; + padding-top: 54px; + } + + .hero-visual { + min-height: auto; + } + + .journey-grid, + .feature-grid, + .hero-facts { + grid-template-columns: 1fr; + } + + .solver-card, + .timer-card { + position: static; + } + + .hero-visual { + gap: 16px; + } +} + +@media (max-width: 760px) { + .landing-nav { + position: static; + width: 100%; + padding: 18px 16px; + border-left-width: 0; + border-right-width: 0; + gap: 14px; + align-items: flex-start; + flex-direction: column; + } + + .brand { + width: 100%; + justify-content: space-between; + gap: 8px; + font-size: 16px; + } + + .brand-mark { + display: none; + } + + .landing-links { + justify-content: flex-start; + gap: 10px; + width: 100%; + } + + .landing-links a:not(.nav-cta), + .lang-switch, + .nav-cta { + padding: 11px 14px; + } + + main { + width: min(100%, calc(100vw - 24px)); + } + + .hero h1, + .info-band h2, + .section-heading h2, + .cta-panel h2 { + line-height: 1.02; + } + + .info-band-inner, + .cta-panel, + .feature-card, + .journey-step { + padding: 24px; + } + + .floating-card { + width: 100%; + } +} diff --git a/app/static/css/legal.css b/app/static/css/legal.css new file mode 100644 index 0000000..ee0fd99 --- /dev/null +++ b/app/static/css/legal.css @@ -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; + } +} diff --git a/app/static/css/login.css b/app/static/css/login.css index 2035741..f86f1c0 100644 --- a/app/static/css/login.css +++ b/app/static/css/login.css @@ -1,12 +1,21 @@ :root { - --primary-bg-color: #f4f6f8; - --secondary-bg-color: white; - --nav-bg-color: #333; -} - -@font-face { - font-family: "Geist Sans"; - src: url("https://assets.codepen.io/605876/GeistVF.ttf") format("truetype"); + --bg: #f4efe7; + --bg-accent: #efe3cd; + --surface: rgba(255, 250, 242, 0.88); + --surface-strong: #fffaf2; + --surface-soft: rgba(255, 247, 235, 0.72); + --text-color: #1e1f1d; + --muted-text-color: #5f615c; + --navy: #1f3140; + --navy-soft: #324d63; + --gold: #c68b2c; + --gold-soft: #e7bf77; + --gold-pale: #f1ddae; + --line: rgba(31, 49, 64, 0.12); + --shadow: 0 24px 60px rgba(37, 42, 48, 0.14); + --success-color: #166534; + --info-color: #1d4ed8; + --error-color: #991b1b; } *, @@ -18,54 +27,197 @@ body { margin: 0; min-height: 100vh; + color: var(--text-color); + 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; +} + +.auth-nav { + width: 100%; + margin: 0; + padding: 18px max(16px, calc((100vw - 1200px) / 2)) 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + position: sticky; + top: 0; + z-index: 10; + border-top: 0; + 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; - font-family: "Geist Sans", system-ui, sans-serif; - - background: linear-gradient(135deg, #c6dff4, #7c8384); + border-radius: 8px; + background: linear-gradient(135deg, var(--gold), var(--gold-soft)); + color: #2c2215; + font-size: 22px; } - -nav { - width: 100%; - background: var(--nav-bg-color); - padding: 15px 30px; -} - -nav ul { +.nav-links { + margin: 0; + padding: 0; list-style: none; display: flex; - justify-content: flex-end; - gap: 20px; -} - -nav a { - color: white; - text-decoration: none; - font-size: 16px; -} - -nav a:hover { - color: #4facfe; -} - -/* Center login section */ -main { - min-height: calc(100vh - 60px); - /* full screen minus navbar */ - display: flex; - flex-direction: column; - justify-content: center; align-items: center; + flex-wrap: wrap; + gap: 12px; + color: #ecf4fb; } -/* Title */ -main h1 { +.nav-links a, +.nav-button { + 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); + color: inherit; + font: inherit; + font-weight: 700; + cursor: pointer; + transition: transform 0.2s ease, border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; +} + +.nav-links a:hover, +.nav-button:hover { + transform: translateY(-2px); + border-color: rgba(231, 191, 119, 0.45); + color: #fff7eb; +} + +.nav-form { + margin: 0; + display: flex; + flex-direction: row; + align-items: center; + gap: 0; + padding: 0; + background: transparent; + border: none; + color: #ecf4fb; +} + +.auth-shell { + min-height: calc(100vh - 78px); + display: flex; + align-items: center; + justify-content: center; + padding: 38px 16px 48px; +} + +.settings-shell { + align-items: flex-start; +} + +.auth-panel { + width: min(460px, 100%); + padding: 34px; + border-radius: 12px; + background: linear-gradient(135deg, rgba(255, 250, 242, 0.94), rgba(245, 230, 205, 0.8)); + border: 1px solid var(--line); + box-shadow: var(--shadow); + animation: fadeIn 0.8s ease; +} + +.settings-panel { + width: min(980px, 100%); +} + +.settings-topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + flex-wrap: wrap; margin-bottom: 20px; } -main { - animation: fadeIn 0.8s ease; +.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 { + display: grid; + gap: 24px; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); } @keyframes fadeIn { @@ -80,72 +232,206 @@ main { } } - -/* Form box */ -form { - background: rgba(255, 255, 255, 0.15); - backdrop-filter: blur(12px); - padding: 35px; - border-radius: 12px; - width: 320px; - display: flex; - flex-direction: column; - - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); - border: 1px solid rgba(255, 255, 255, 0.2); +h1, +h2 { + margin-top: 0; + color: #182029; } +h1 { + margin-bottom: 10px; + line-height: 0.98; + letter-spacing: -0.03em; + font-size: clamp(2.2rem, 4vw, 3.6rem); +} + +h2 { + margin-bottom: 10px; + font-size: 1.55rem; +} + +.muted, +.field-hint { + font-family: "Trebuchet MS", "Segoe UI", sans-serif; + line-height: 1.6; + color: var(--muted-text-color); +} + +.muted { + margin: 0 0 20px; + font-size: 17px; +} + +form { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 18px; + padding: 24px; + border-radius: 10px; + background: var(--surface-soft); + border: 1px solid rgba(31, 49, 64, 0.1); +} -/* Labels */ label { - margin-top: 10px; - margin-bottom: 5px; + margin-top: 8px; font-size: 14px; + font-weight: 700; + color: var(--navy); + letter-spacing: 0.03em; } -/* Inputs */ -form { - background: rgba(255, 255, 255, 0.15); - backdrop-filter: blur(12px); - padding: 35px; - border-radius: 12px; - width: 320px; - display: flex; - flex-direction: column; - - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); - border: 1px solid rgba(255, 255, 255, 0.2); +input { + width: 100%; + padding: 12px 14px; + border: 1px solid rgba(31, 49, 64, 0.16); + border-radius: 0; + font: inherit; + background: rgba(255, 255, 255, 0.86); + color: var(--text-color); } +input:focus { + outline: 2px solid rgba(198, 139, 44, 0.28); + border-color: rgba(198, 139, 44, 0.55); +} + +.field-hint { + margin: 0; + font-size: 13px; +} -/* Button */ button { - margin-top: 20px; - padding: 12px; + margin-top: 14px; + min-height: 50px; + padding: 12px 18px; border: none; - background: linear-gradient(135deg, #e9eff5, #daebf0); - color: rgb(0, 0, 0); - border-radius: 8px; + border-radius: 0; + background: linear-gradient(135deg, var(--gold), var(--gold-soft)); + color: #2d2418; cursor: pointer; font-size: 16px; - - transition: all 0.2s ease; + font-weight: 700; + transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease; } button:hover { transform: translateY(-2px); - box-shadow: 0 6px 15px rgba(0, 0, 0, 0.2); + box-shadow: 0 12px 24px rgba(31, 49, 64, 0.16); } button:active { - transform: scale(0.97); + transform: scale(0.98); } - -/* Error message */ -.error { - color: red; - margin-bottom: 15px; +.button-row { + display: flex; + flex-wrap: wrap; + gap: 12px; } -/* button anim */ \ No newline at end of file +.button-row button { + flex: 1 1 180px; +} + +.secondary-button { + background: linear-gradient(135deg, var(--navy), var(--navy-soft)); + color: #f9fbfd; +} + +.message { + margin: 0 0 14px; + padding: 12px 14px; + border-radius: 8px; + font-size: 14px; + font-family: "Trebuchet MS", "Segoe UI", sans-serif; +} + +.message.error { + color: var(--error-color); + background: rgba(254, 226, 226, 0.92); +} + +.message.success { + color: var(--success-color); + background: rgba(220, 252, 231, 0.92); +} + +.message.info { + color: var(--info-color); + background: rgba(219, 234, 254, 0.92); +} + +.qr-panel { + display: flex; + justify-content: center; + margin: 8px 0 6px; + padding: 18px; + border-radius: 10px; + background: rgba(255, 250, 242, 0.72); + border: 1px solid rgba(31, 49, 64, 0.12); +} + +.qr-code { + display: flex; + align-items: center; + justify-content: center; + min-width: 220px; + min-height: 220px; + padding: 10px; + border-radius: 8px; + background: #ffffff; + color: var(--muted-text-color); + text-align: center; +} + +.qr-code img { + display: block; +} + +@media (max-width: 760px) { + .auth-nav { + position: static; + padding: 18px 16px; + border-left-width: 0; + border-right-width: 0; + flex-direction: column; + align-items: flex-start; + } + + .brand { + width: 100%; + font-size: 16px; + } + + .nav-links { + width: 100%; + } + + .nav-links a, + .nav-button { + padding: 10px 14px; + } + + .settings-topbar { + align-items: stretch; + } + + .back-link, + .lang-switch { + width: 100%; + justify-content: center; + } + + .auth-shell { + padding: 22px 12px 28px; + } + + .auth-panel { + padding: 24px 18px; + } + + form { + padding: 18px; + } +} diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js new file mode 100644 index 0000000..60aaa41 --- /dev/null +++ b/app/static/js/dashboard.js @@ -0,0 +1,1699 @@ +const DEFAULT_BOARD_SIZE = 8; +const MIN_BOARD_SIZE = 4; +const MAX_BOARD_SIZE = 13; +const EMPTY_CELL = -1; +const GAME_STORAGE_KEY = "eight-queens-dashboard-session-v1"; + +let mode = "queens"; +let boardSize = DEFAULT_BOARD_SIZE; +let pendingBoardSize = DEFAULT_BOARD_SIZE; +let board = createEmptyBoard(boardSize); +let stepCount = 0; +let elapsedBeforeTimerStartMs = 0; +let timerStartedAtMs = Date.now(); +let gameTimerId = null; +let isSolved = false; + +let solverSteps = []; +let solverStepIndex = -1; +let solverTimerId = null; +let solverSpeedMs = 320; +let solverLoading = false; +let solverRequestId = 0; +let currentSolverHighlight = null; + +const boardDiv = document.getElementById("board"); +const statusMessage = document.getElementById("statusMessage"); +const queenList = document.getElementById("queenList"); +const pieceCountBadge = document.getElementById("pieceCountBadge"); +const langSwitch = document.getElementById("langSwitch"); +const gameModeEyebrow = document.getElementById("gameModeEyebrow"); +const gameTitle = document.getElementById("gameTitle"); +const boardModeText = document.getElementById("boardModeText"); +const boardSizeInfoPrefix = document.getElementById("boardSizeInfoPrefix"); +const pieceListTitle = document.getElementById("pieceListTitle"); +const infoModalTitle = document.getElementById("infoModalTitle"); +const infoModeRuleText = document.getElementById("infoModeRuleText"); +const infoModalProblemHeading = document.getElementById("infoModalProblemHeading"); +const infoModalProblemText = document.getElementById("infoModalProblemText"); +const stepCounterValue = document.getElementById("stepCounterValue"); +const timerValue = document.getElementById("timerValue"); +const gameProgressState = document.getElementById("gameProgressState"); +const solvedCelebration = document.getElementById("solvedCelebration"); +const solvedCelebrationTitle = document.getElementById("solvedCelebrationTitle"); +const solvedCelebrationText = document.getElementById("solvedCelebrationText"); +const btnQueens = document.getElementById("btnQueens"); +const btnRooks = document.getElementById("btnRooks"); +const btnPrepareSolve = document.getElementById("btnPrepareSolve"); +const btnInstantSolve = document.getElementById("btnInstantSolve"); +const btnNextStep = document.getElementById("btnNextStep"); +const btnPlayPause = document.getElementById("btnPlayPause"); +const solverMessage = document.getElementById("solverMessage"); +const solverStepInfo = document.getElementById("solverStepInfo"); +const solverSpeedInput = document.getElementById("solverSpeedInput"); +const solverSpeedValue = document.getElementById("solverSpeedValue"); +const settingsSidebar = document.getElementById("settingsSidebar"); +const settingsTrigger = document.getElementById("settingsTrigger"); +const settingsTriggerArrow = document.getElementById("settingsTriggerArrow"); +const settingsTriggerLabel = document.getElementById("settingsTriggerLabel"); +const sidebarBackdrop = document.getElementById("sidebarBackdrop"); +const saveSidebar = document.getElementById("saveSidebar"); +const saveBackdrop = document.getElementById("saveBackdrop"); +const savePointsSidebar = document.getElementById("savePointsSidebar"); +const savePointsBackdrop = document.getElementById("savePointsBackdrop"); +const boardSizeInput = document.getElementById("boardSizeInput"); +const boardSizeValue = document.getElementById("boardSizeValue"); +const boardSizeValueMirror = document.getElementById("boardSizeValueMirror"); +const sidebarBoardSizeValue = document.getElementById("sidebarBoardSizeValue"); +const sidebarBoardSizeValueMirror = document.getElementById("sidebarBoardSizeValueMirror"); +const savePointsList = document.getElementById("savePointsList"); +const saveNameInput = document.getElementById("saveNameInput"); +const saveNoteInput = document.getElementById("saveNoteInput"); +const saveFavoriteInput = document.getElementById("saveFavoriteInput"); +const savePointModeFilter = document.getElementById("savePointModeFilter"); +const savePointSizeFilter = document.getElementById("savePointSizeFilter"); +const savePointSortFilter = document.getElementById("savePointSortFilter"); +const savePointFavoritesOnly = document.getElementById("savePointFavoritesOnly"); +const timeHistoryTableBody = document.getElementById("timeHistoryTableBody"); +const infoModal = document.getElementById("infoModal"); +const infoModalBackdrop = document.getElementById("infoModalBackdrop"); + +const SUPPORTED_LANGUAGES = ["de", "en"]; +let currentLanguage = "de"; + +const MODE_COPY = { + de: { + queens: { + singular: "Dame", + plural: "Damen", + eyebrow: "Damen-Modus", + gameTitle: "Damen-Spielbrett", + boardText: "Platziere Damen so, dass keine zwei dieselbe Spalte oder Diagonale teilen.", + infoTitle: "Spielregeln und Acht-Damen-Problem", + infoRuleText: "Im Damen-Modus dürfen sich Damen weder in derselben Spalte noch diagonal angreifen. Mit einem Klick setzt oder entfernst du eine Figur.", + infoProblemHeading: "Was ist das Acht-Damen-Problem?", + infoProblemText: "Das klassische Acht-Damen-Problem fragt nach einer Anordnung von acht Damen auf einem 8x8-Brett, bei der sich keine zwei Damen schlagen. Es ist ein bekanntes Beispiel für kombinatorische Suche und Backtracking." + }, + rooks: { + singular: "Turm", + plural: "Türme", + eyebrow: "Türme-Modus", + gameTitle: "Türme-Spielbrett", + boardText: "Platziere Türme so, dass keine zwei dieselbe Spalte teilen.", + infoTitle: "Spielregeln und Acht-Türme-Problem", + infoRuleText: "Im Türme-Modus dürfen sich Türme nicht in derselben Spalte stehen. Diagonalen spielen hier keine Rolle. Mit einem Klick setzt oder entfernst du eine Figur.", + infoProblemHeading: "Was ist das Acht-Türme-Problem?", + infoProblemText: "Beim Acht-Türme-Problem suchst du eine Anordnung von acht Türmen auf einem 8x8-Brett, bei der sich keine zwei Türme angreifen. Es ist eine einfachere Variante, um systematische Suche und Konfliktprüfung sichtbar zu machen." + } + }, + en: { + queens: { + singular: "Queen", + plural: "Queens", + eyebrow: "Queens Mode", + gameTitle: "Queens Board", + boardText: "Place queens so that no two share the same column or diagonal.", + infoTitle: "Rules and the Eight Queens Puzzle", + infoRuleText: "In queens mode, queens may not attack each other in the same column or on diagonals. Click once to place or remove a piece.", + infoProblemHeading: "What is the Eight Queens puzzle?", + infoProblemText: "The classic Eight Queens puzzle asks for eight queens on an 8x8 board so that no two queens attack each other. It is a well-known example of combinatorial search and backtracking." + }, + rooks: { + singular: "Rook", + plural: "Rooks", + eyebrow: "Rooks Mode", + gameTitle: "Rooks Board", + boardText: "Place rooks so that no two share the same column.", + infoTitle: "Rules and the Eight Rooks Puzzle", + infoRuleText: "In rooks mode, rooks may not stand in the same column. Diagonals do not matter here. Click once to place or remove a piece.", + infoProblemHeading: "What is the Eight Rooks puzzle?", + infoProblemText: "In the Eight Rooks puzzle, you arrange eight rooks on an 8x8 board so that no two rooks attack each other. It is a simpler variant that helps visualize systematic search and conflict checking." + } + } +}; + +const translations = { + de: { + pageTitle: "Dashboard", + navBrand: "8-Damen- und 8-Türme Solver", + navDashboard: "Dashboard", + navLogout: "Logout", + accountLabel: "Account", + accountShortcutAria: "Konto und Login-Einstellungen öffnen", + introKicker: "Puzzle-Modus", + introTitle: "Wähl deinen Modus und löse das Brett auf deine Art.", + introText: "Wechsle zwischen Damen und Türmen, nutze Animation oder Sofort-Lösung und öffne die Brett-Einstellungen jederzeit ueber den linken Seiten-Tab.", + modeSwitchAria: "Spielmodus", + btnQueens: "Damen", + btnRooks: "Türme", + infoButton: "Regeln & Hilfe", + gameStatsAria: "Spielstatus", + stepsLabel: "Schritte", + timeLabel: "Zeit", + statusLabel: "Status", + progressRunning: "Läuft", + progressSolved: "Gelöst", + boardSizePrefix: ({ modeLabel }) => `Aktuelle Brettgrösse im ${modeLabel}-Modus:`, + solverKicker: "Solver", + solverTitle: "Lösungswerkzeuge", + animationSolverTitle: "Animations-Solver", + animationSolverText: "Bereite den Lösungsweg vor, gehe ihn manuell Schritt für Schritt durch oder spiele die Animation automatisch ab.", + instantSolverTitle: "Sofort-Lösung", + instantSolverText: "Lade direkt eine gültige Lösung, ohne Zwischenschritte oder Animation.", + defaultSolverMessage: "Bereite einen Ablauf vor und spiele ihn Schritt für Schritt ab.", + solverReady: ({ modeLabel }) => `Solver für ${modeLabel} bereit. ${t("defaultSolverMessage")}`, + solverSpeedLabel: "Animationsgeschwindigkeit", + btnPrepareSolve: "Animation vorbereiten", + btnPrepareSolveLoading: "Animation wird vorbereitet...", + btnNextStep: "Nächster Schritt", + btnPlayPause: "Animation abspielen", + btnPlayReplay: "Animation erneut abspielen", + btnPlayPauseRunning: "Animation pausieren", + btnInstantSolve: "Sofort lösen", + btnReset: "Brett zurücksetzen", + settingsTrigger: "Brett-Einstellungen", + settingsSidebarTitle: "Brett-Einstellungen", + settingsSidebarClose: "Schliessen", + settingsSidebarText: "Passe die Brettgroesse zwischen 4 und 13 an.", + boardSizeInputLabel: "Grösse", + applyBoardSizeButton: "Grösse anwenden", + settingsSidebarHint: "Beim ändern wird das Brett zur Sicherheit zurückgesetzt. Auf dem Handy ist 8x8 eingeplant, grössere Bretter können abgeschnitten wirken.", + btnSave: "Speichern", + btnLoad: "Save-Points", + saveSidebarTitle: "Speicherpunkt", + saveSidebarClose: "Schliessen", + saveSidebarText: "Gib dem Spielstand einen klaren Namen und optional eine kurze Notiz.", + saveNameLabel: "Name", + saveNamePlaceholder: "z. B. Fast gelöste Dame 8x8", + saveNoteLabel: "Notiz", + saveNotePlaceholder: "z. B. Gute Startposition fuer Türmetest", + saveFavoriteLabel: "Als Favorit markieren", + saveNowButton: "Jetzt speichern", + savePointsSidebarTitle: "Save-Points", + savePointsSidebarClose: "Schliessen", + savePointsSidebarText: "Wähle einen Speicherstand aus der Liste.", + savePointModeFilterLabel: "Modus", + savePointSizeFilterLabel: "Brettgrösse", + savePointSortFilterLabel: "Sortierung", + savePointFavoritesOnlyLabel: "Nur Favoriten anzeigen", + savePointsEmptyState: "Noch keine Speicherstände gefunden.", + filterAll: "Alle", + filterNewest: "Neueste zuerst", + filterOldest: "älteste zuerst", + filterUpdated: "Zuletzt bearbeitet", + filterFavorites: "Favoriten zuerst", + historyKicker: "Save-Points", + historyTitle: "Gespeicherte Zeiten und Fortschritte", + historySubtitle: "Die Tabelle zeigt gespeicherte Save-Points mit Zeit, Schritten und Status.", + btnRefreshHistory: "Aktualisieren", + historyHeadSave: "Save-Point", + historyHeadMode: "Modus", + historyHeadBoard: "Brett", + historyHeadSteps: "Schritte", + historyHeadTime: "Zeit", + historyHeadStatus: "Status", + historyHeadSaved: "Gespeichert", + infoModalClose: "Schliessen", + infoModalIntro: "Setze pro Reihe genau eine Figur auf das Brett. Eine gültige Lösung hat keine Konflikte.", + infoModalModeHeading: "So funktioniert der aktuelle Modus", + infoModalTimeHeading: "Was zeigen Zeit und Schritte?", + 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 Save-Point abgelegt.", + pieceListTitle: ({ modeLabel }) => `Positionen der ${modeLabel}`, + historyEmpty: "Noch keine gespeicherten Zeiten vorhanden.", + placedWord: "gesetzt", + noSavePointsFiltered: "Keine Speicherstände fuer diese Filter gefunden.", + previewEmpty: "Noch keine Figuren gesetzt", + createdLabel: "Erstellt", + updatedLabel: "Aktualisiert", + previewLabel: "Vorschau", + noteLabel: "Notiz", + noNote: "Keine Notiz", + stepsMeta: "Schritte", + timeMeta: "Zeit", + solvedMeta: "Gelöst", + openMeta: "Offen", + loadButton: "Laden", + favoriteAddButton: "Als Favorit", + favoriteRemoveButton: "Favorit entfernen", + deleteButton: "Löschen", + celebrationTitle: "Richtig gelöst!", + celebrationText: ({ modeLabel, duration }) => `Stark gemacht. Deine ${modeLabel.toLowerCase()}-Lösung ist konfliktfrei in ${duration}.`, + settingsApplied: ({ size }) => `Brettgrösse auf ${size} x ${size} gesetzt.`, + modeActivated: ({ piece }) => `${piece}-Modus aktiviert.`, + solverDiscardedManual: "Solver-Ablauf verworfen, weil das Brett manuell geändert wurde.", + solverDiscardedStatus: "Solver-Ablauf verworfen. Bereite ihn bei Bedarf neu vor.", + solvedPerfect: ({ duration }) => `Perfekt gelöst in ${duration}.`, + animationReset: "Animations-Ablauf zurückgesetzt. Starte die Animation erneut oder gehe weiter mit Nächster Schritt.", + animationPreparing: ({ modeLabel, size }) => `Berechne den Animations-Ablauf für ${modeLabel.toLowerCase()} auf ${size} x ${size}...`, + animationPreparingStatus: ({ size }) => `Animations-Solver berechnet die Schritte fuer ${size} x ${size}.`, + noSolution: "Keine Lösung gefunden.", + noSolutionStatus: "Keine Lösung fuer dieses Brett gefunden.", + animationReady: ({ steps, queensMode }) => queensMode + ? `Animations-Ablauf bereit mit ${steps} Schritten. Nutze Nächster Schritt oder Animation abspielen, um das Backtracking zu verfolgen.` + : `Animations-Ablauf bereit mit ${steps} Schritten. Nutze Nächster Schritt oder Animation abspielen.`, + animationReadyStatus: "Animations-Solver bereit. Nutze Nächster Schritt oder Animation abspielen.", + animationLoadError: "Der Solver-Ablauf konnte nicht geladen werden.", + animationFinishedStatus: ({ size }) => `Loesung für ${size} x ${size} Schritt fuer Schritt aufgebaut.`, + animationAlreadyFinished: "Der Animations-Ablauf ist bereits abgeschlossen.", + animationPaused: "Animations-Solver pausiert.", + animationRunning: "Animations-Solver läuft.", + instantLoading: ({ modeLabel, size }) => `Lade die Sofort-Lösung für ${modeLabel.toLowerCase()} auf ${size} x ${size}...`, + instantLoadingStatus: "Sofort-Lösung wird geladen.", + instantLoaded: "Sofort-Lösung geladen. Starte den Animations-Solver, wenn du den Lösungsweg sehen möchtest.", + instantLoadedStatus: ({ size }) => `Sofort-Loesung für ${size} x ${size} geladen.`, + instantLoadError: "Die Sofort-Lösung konnte nicht geladen werden.", + saveError: "Der Speicherstand konnte nicht gespeichert werden.", + saveSuccess: ({ name }) => `Speicherpunkt "${name}" gespeichert.`, + loadError: "Dieser Speicherstand konnte nicht geladen werden.", + loadSuccess: ({ name }) => `Speicherpunkt "${name}" geladen.`, + favoriteError: "Favorit konnte nicht aktualisiert werden.", + favoriteSuccess: "Favoritenstatus aktualisiert.", + deleteConfirm: "Diesen Speicherpunkt wirklich löschen?", + deleteError: "Speicherpunkt konnte nicht gelöscht werden.", + deleteSuccess: "Speicherpunkt geloescht.", + pieceCount: ({ count }) => `${count} gesetzt`, + pieceEmpty: ({ modeLabel }) => `Noch keine ${modeLabel.toLowerCase()} gesetzt.`, + saveDefaultName: ({ piece, size, count }) => `${piece} ${size}x${size} - ${count} gesetzt`, + filterQueens: "Damen", + filterRooks: "Türme", + solverStepInfo: ({ currentStep, totalSteps }) => `Schritt ${currentStep} / ${totalSteps}`, + speedValue: ({ speed }) => `${speed} ms`, + languageSwitchAria: "Sprache wechseln", + footerText: "Schulprojekt rund um das Acht-Damen- und Acht-Türme-Problem.", + footerPrivacy: "Datenschutzerklärung", + footerImprint: "Impressum", + footerNavAria: "Rechtliche Hinweise" + }, + en: { + pageTitle: "Dashboard", + navBrand: "8 Queens and 8 Rooks Solver", + navDashboard: "Dashboard", + navLogout: "Logout", + accountLabel: "Account", + accountShortcutAria: "Open account and login settings", + introKicker: "Puzzle Mode", + introTitle: "Choose your mode and solve the board your way.", + introText: "Switch between queens and rooks, use animation or instant solve, and open board settings anytime from the left side tab.", + modeSwitchAria: "Game mode", + btnQueens: "Queens", + btnRooks: "Rooks", + infoButton: "Rules & Help", + gameStatsAria: "Game status", + stepsLabel: "Steps", + timeLabel: "Time", + statusLabel: "Status", + progressRunning: "Running", + progressSolved: "Solved", + boardSizePrefix: ({ modeLabel }) => `Current board size in ${modeLabel} mode:`, + solverKicker: "Solver", + solverTitle: "Solving Tools", + animationSolverTitle: "Animation Solver", + animationSolverText: "Prepare the solving path, step through it manually, or play the animation automatically.", + instantSolverTitle: "Instant Solve", + instantSolverText: "Load a valid solution directly, without intermediate steps or animation.", + defaultSolverMessage: "Prepare a solving trace and play it back step by step.", + solverReady: ({ modeLabel }) => `Solver for ${modeLabel} is ready. ${t("defaultSolverMessage")}`, + solverSpeedLabel: "Animation speed", + btnPrepareSolve: "Prepare animation", + btnPrepareSolveLoading: "Preparing animation...", + btnNextStep: "Next step", + btnPlayPause: "Play animation", + btnPlayReplay: "Replay animation", + btnPlayPauseRunning: "Pause animation", + btnInstantSolve: "Solve instantly", + btnReset: "Reset board", + settingsTrigger: "Board settings", + settingsSidebarTitle: "Board Settings", + settingsSidebarClose: "Close", + settingsSidebarText: "Adjust the board size between 4 and 13.", + boardSizeInputLabel: "Size", + applyBoardSizeButton: "Apply size", + settingsSidebarHint: "Changing the size resets the board for safety. On phones, 8x8 is the intended maximum for a reliable fit and larger boards may appear cropped.", + btnSave: "Save", + btnLoad: "Save points", + saveSidebarTitle: "Save Point", + saveSidebarClose: "Close", + saveSidebarText: "Give the current run a clear name and optionally a short note.", + saveNameLabel: "Name", + saveNamePlaceholder: "e.g. Nearly solved queen 8x8", + saveNoteLabel: "Note", + saveNotePlaceholder: "e.g. Strong starting position for rook test", + saveFavoriteLabel: "Mark as favorite", + saveNowButton: "Save now", + savePointsSidebarTitle: "Save Points", + savePointsSidebarClose: "Close", + savePointsSidebarText: "Choose a saved state from the list.", + savePointModeFilterLabel: "Mode", + savePointSizeFilterLabel: "Board size", + savePointSortFilterLabel: "Sorting", + savePointFavoritesOnlyLabel: "Show favorites only", + savePointsEmptyState: "No saved states yet.", + filterAll: "All", + filterNewest: "Newest first", + filterOldest: "Oldest first", + filterUpdated: "Recently updated", + filterFavorites: "Favorites first", + historyKicker: "Save Points", + historyTitle: "Saved Times and Progress", + historySubtitle: "The table shows saved runs with time, steps, and status.", + btnRefreshHistory: "Refresh", + historyHeadSave: "Save Point", + historyHeadMode: "Mode", + historyHeadBoard: "Board", + historyHeadSteps: "Steps", + historyHeadTime: "Time", + historyHeadStatus: "Status", + historyHeadSaved: "Saved", + infoModalClose: "Close", + infoModalIntro: "Place exactly one piece in each row. A valid solution has no conflicts.", + infoModalModeHeading: "How the current mode works", + infoModalTimeHeading: "What do time and steps show?", + infoModalTimeText: "The step counter records your moves on the board. Time keeps running for the current game even if you reload the page. When saving, time and steps are stored together with the save point.", + pieceListTitle: ({ modeLabel }) => `${modeLabel} positions`, + historyEmpty: "No saved times yet.", + placedWord: "placed", + noSavePointsFiltered: "No saved states found for these filters.", + previewEmpty: "No pieces placed yet", + createdLabel: "Created", + updatedLabel: "Updated", + previewLabel: "Preview", + noteLabel: "Note", + noNote: "No note", + stepsMeta: "Steps", + timeMeta: "Time", + solvedMeta: "Solved", + openMeta: "Open", + loadButton: "Load", + favoriteAddButton: "Favorite", + favoriteRemoveButton: "Remove favorite", + deleteButton: "Delete", + celebrationTitle: "Correctly solved!", + celebrationText: ({ modeLabel, duration }) => `Well done. Your ${modeLabel.toLowerCase()} solution is conflict-free in ${duration}.`, + settingsApplied: ({ size }) => `Board size set to ${size} x ${size}.`, + modeActivated: ({ piece }) => `${piece} mode activated.`, + solverDiscardedManual: "Solver trace discarded because the board was changed manually.", + solverDiscardedStatus: "Solver trace discarded. Prepare it again if needed.", + solvedPerfect: ({ duration }) => `Perfectly solved in ${duration}.`, + animationReset: "Animation trace reset. Start the animation again or continue with Next step.", + animationPreparing: ({ modeLabel, size }) => `Preparing the animation trace for ${modeLabel.toLowerCase()} on ${size} x ${size}...`, + animationPreparingStatus: ({ size }) => `Animation solver is preparing the steps for ${size} x ${size}.`, + noSolution: "No solution found.", + noSolutionStatus: "No solution found for this board.", + animationReady: ({ steps, queensMode }) => queensMode + ? `Animation trace ready with ${steps} steps. Use Next step or Play animation to follow the backtracking process.` + : `Animation trace ready with ${steps} steps. Use Next step or Play animation.`, + animationReadyStatus: "Animation solver ready. Use Next step or Play animation.", + animationLoadError: "The solver trace could not be loaded.", + animationFinishedStatus: ({ size }) => `Solution for ${size} x ${size} was built step by step.`, + animationAlreadyFinished: "The animation trace is already complete.", + animationPaused: "Animation solver paused.", + animationRunning: "Animation solver is running.", + instantLoading: ({ modeLabel, size }) => `Loading the instant solution for ${modeLabel.toLowerCase()} on ${size} x ${size}...`, + instantLoadingStatus: "Instant solution is loading.", + instantLoaded: "Instant solution loaded. Start the animation solver if you want to see the solving path.", + instantLoadedStatus: ({ size }) => `Instant solution for ${size} x ${size} loaded.`, + instantLoadError: "The instant solution could not be loaded.", + saveError: "The save point could not be saved.", + saveSuccess: ({ name }) => `Save point "${name}" saved.`, + loadError: "This save point could not be loaded.", + loadSuccess: ({ name }) => `Save point "${name}" loaded.`, + favoriteError: "Favorite status could not be updated.", + favoriteSuccess: "Favorite status updated.", + deleteConfirm: "Really delete this save point?", + deleteError: "Save point could not be deleted.", + deleteSuccess: "Save point deleted.", + pieceCount: ({ count }) => `${count} placed`, + pieceEmpty: ({ modeLabel }) => `No ${modeLabel.toLowerCase()} placed yet.`, + saveDefaultName: ({ piece, size, count }) => `${piece} ${size}x${size} - ${count} placed`, + filterQueens: "Queens", + filterRooks: "Rooks", + solverStepInfo: ({ currentStep, totalSteps }) => `Step ${currentStep} / ${totalSteps}`, + speedValue: ({ speed }) => `${speed} ms`, + languageSwitchAria: "Switch language", + footerText: "School project about the Eight Queens and Eight Rooks problem.", + footerPrivacy: "Privacy Policy", + footerImprint: "Imprint", + footerNavAria: "Legal information" + } +}; + +function createEmptyBoard(size) { + return Array(size).fill(EMPTY_CELL); +} + +function clampBoardSize(size) { + return Math.min(MAX_BOARD_SIZE, Math.max(MIN_BOARD_SIZE, Number(size))); +} + +function getColumnLabel(index) { + return String.fromCharCode(65 + index); +} + +function normalizeLanguage(language) { + const shortLanguage = String(language || "").trim().slice(0, 2).toLowerCase(); + return SUPPORTED_LANGUAGES.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() { + return normalizeLanguage(navigator.language || navigator.userLanguage || "de"); +} + +function t(key, params = {}) { + const value = translations[currentLanguage][key]; + return typeof value === "function" ? value(params) : value; +} + +function getDefaultSolverMessage() { + return t("defaultSolverMessage"); +} + +function applyTextTranslations() { + document.documentElement.lang = currentLanguage; + document.body.dataset.language = currentLanguage; + + document.querySelectorAll("[data-i18n]").forEach((element) => { + const key = element.dataset.i18n; + const translatedText = t(key); + + if (translatedText === undefined) { + return; + } + + if (element.tagName === "TITLE" || String(translatedText).includes(" { + const translatedText = t(element.dataset.i18nPlaceholder); + + if (translatedText === undefined) { + return; + } + + element.setAttribute("placeholder", translatedText); + }); + + document.querySelectorAll("[data-i18n-aria-label]").forEach((element) => { + const translatedText = t(element.dataset.i18nAriaLabel); + + if (translatedText === undefined) { + return; + } + + element.setAttribute("aria-label", translatedText); + }); +} + +function setLanguage(language) { + currentLanguage = normalizeLanguage(language); + applyTextTranslations(); + + langSwitch.textContent = currentLanguage.toUpperCase(); + langSwitch.setAttribute("aria-label", t("languageSwitchAria")); + langSwitch.title = currentLanguage === "de" ? "Switch to English" : "Zu Deutsch wechseln"; + + updateSettingsTrigger(); + updateModeContent(); + updateQueenList(); + updateProgressDisplay(); + updateSolverSpeed(solverSpeedMs); + updateSolverControls(); + + if (!solverLoading && !hasPreparedSolverTrace()) { + setSolverMessage(t("solverReady", { modeLabel: getModeLabel(mode) })); + } + + storeLanguage(currentLanguage); +} + +function getModeCopy(selectedMode = mode, language = currentLanguage) { + return MODE_COPY[language][selectedMode === "rooks" ? "rooks" : "queens"]; +} + +function sanitizeBoard(candidateBoard, size) { + if (!Array.isArray(candidateBoard) || candidateBoard.length !== size) { + return createEmptyBoard(size); + } + + return candidateBoard.map((value) => { + const numericValue = Number(value); + + if (!Number.isInteger(numericValue)) { + return EMPTY_CELL; + } + + return numericValue >= 0 && numericValue < size ? numericValue : EMPTY_CELL; + }); +} + +function formatDuration(totalSeconds) { + const safeSeconds = Math.max(0, Number(totalSeconds) || 0); + const hours = Math.floor(safeSeconds / 3600); + const minutes = Math.floor((safeSeconds % 3600) / 60); + const seconds = safeSeconds % 60; + + return [hours, minutes, seconds] + .map((value) => String(value).padStart(2, "0")) + .join(":"); +} + +function getElapsedMilliseconds() { + if (isSolved) { + return elapsedBeforeTimerStartMs; + } + + return elapsedBeforeTimerStartMs + Math.max(0, Date.now() - timerStartedAtMs); +} + +function persistCurrentSession() { + try { + window.localStorage.setItem( + GAME_STORAGE_KEY, + JSON.stringify({ + mode, + boardSize, + board, + stepCount, + elapsedMs: getElapsedMilliseconds(), + isSolved + }) + ); + } catch (error) { + // Ignore unavailable storage so the game keeps working normally. + } +} + +function updateProgressDisplay() { + stepCounterValue.textContent = String(stepCount); + timerValue.textContent = formatDuration(Math.floor(getElapsedMilliseconds() / 1000)); + gameProgressState.textContent = isSolved ? t("progressSolved") : t("progressRunning"); + gameProgressState.style.color = isSolved ? "#15803d" : "#1d4ed8"; + updateSolvedCelebration(); +} + +function stopGameTimer() { + if (gameTimerId !== null) { + window.clearInterval(gameTimerId); + gameTimerId = null; + } +} + +function startGameTimer() { + stopGameTimer(); + + if (isSolved) { + updateProgressDisplay(); + persistCurrentSession(); + return; + } + + timerStartedAtMs = Date.now(); + gameTimerId = window.setInterval(() => { + updateProgressDisplay(); + persistCurrentSession(); + }, 1000); + + updateProgressDisplay(); + persistCurrentSession(); +} + +function resetProgress({ stepCountValue = 0, elapsedMs = 0, solved = false } = {}) { + stepCount = Math.max(0, Number(stepCountValue) || 0); + elapsedBeforeTimerStartMs = Math.max(0, Number(elapsedMs) || 0); + isSolved = Boolean(solved); + timerStartedAtMs = Date.now(); + + if (isSolved) { + stopGameTimer(); + updateProgressDisplay(); + persistCurrentSession(); + return; + } + + startGameTimer(); +} + +function ensureFreshRunAfterSolvedBoard() { + if (!isSolved) { + return; + } + + resetProgress(); +} + +function updateModeButtons() { + btnQueens.classList.toggle("active", mode === "queens"); + btnRooks.classList.toggle("active", mode === "rooks"); + btnQueens.classList.toggle("secondary", mode !== "queens"); + btnRooks.classList.toggle("secondary", mode !== "rooks"); +} + +function updateModeContent() { + const copy = getModeCopy(mode); + + gameModeEyebrow.textContent = copy.eyebrow; + gameTitle.textContent = copy.gameTitle; + boardModeText.textContent = copy.boardText; + pieceListTitle.textContent = t("pieceListTitle", { modeLabel: copy.plural }); + infoModalTitle.textContent = copy.infoTitle; + infoModeRuleText.textContent = copy.infoRuleText; + infoModalProblemHeading.textContent = copy.infoProblemHeading; + infoModalProblemText.textContent = copy.infoProblemText; + boardSizeInfoPrefix.textContent = t("boardSizePrefix", { modeLabel: copy.plural }); +} + +function updateSolvedCelebration() { + if (!isSolved) { + solvedCelebration.hidden = true; + return; + } + + solvedCelebrationTitle.textContent = t("celebrationTitle"); + solvedCelebrationText.textContent = t("celebrationText", { + modeLabel: getModeLabel(mode), + duration: formatDuration(Math.floor(getElapsedMilliseconds() / 1000)) + }); + solvedCelebration.hidden = false; +} + +function updateSettingsTrigger(isOpen = settingsSidebar.classList.contains("open")) { + settingsTrigger.setAttribute("aria-expanded", String(isOpen)); + settingsTriggerArrow.textContent = isOpen ? "←" : "→"; + settingsTriggerLabel.textContent = t("settingsTrigger"); +} + +function updateBoardSizeDisplays(size) { + boardSizeValue.textContent = size; + boardSizeValueMirror.textContent = size; + sidebarBoardSizeValue.textContent = size; + sidebarBoardSizeValueMirror.textContent = size; + boardSizeInput.value = size; +} + +function setStatus(message = "", color = "") { + statusMessage.textContent = message; + statusMessage.style.color = color; +} + +function setSolverMessage(message = getDefaultSolverMessage()) { + solverMessage.textContent = message; +} + +function updateSolverSpeed(rawValue) { + solverSpeedMs = Number(rawValue); + solverSpeedInput.value = String(solverSpeedMs); + solverSpeedValue.textContent = t("speedValue", { speed: solverSpeedMs }); + + if (isSolverPlaying()) { + stopSolverPlayback(false); + scheduleNextSolverPlaybackStep(); + } +} + +function updateBoardLayout() { + let cellSize = Math.max(32, Math.min(60, Math.floor(480 / boardSize))); + let labelSize = cellSize; + + if (window.innerWidth <= 760) { + const labelRatio = 0.72; + const availableWidth = Math.max(250, window.innerWidth - 26); + cellSize = Math.max(24, Math.floor(availableWidth / (boardSize + labelRatio))); + labelSize = Math.max(18, Math.floor(cellSize * labelRatio)); + } + + boardDiv.style.setProperty("--board-size", boardSize); + boardDiv.style.setProperty("--cell-size", `${cellSize}px`); + boardDiv.style.setProperty("--label-size", `${labelSize}px`); +} + +function isSolverPlaying() { + return solverTimerId !== null; +} + +function hasPreparedSolverTrace() { + return solverSteps.length > 0; +} + +function hasCompletedSolverTrace() { + return hasPreparedSolverTrace() && solverStepIndex >= solverSteps.length - 1; +} + +function stopSolverPlayback(refreshControls = true) { + if (solverTimerId !== null) { + window.clearTimeout(solverTimerId); + solverTimerId = null; + } + + if (refreshControls) { + updateSolverControls(); + } +} + +function updateSolverControls() { + const currentStep = Math.max(0, solverStepIndex + 1); + + btnPrepareSolve.disabled = solverLoading; + btnInstantSolve.disabled = solverLoading; + btnNextStep.disabled = solverLoading || hasCompletedSolverTrace(); + btnPlayPause.disabled = solverLoading; + + btnPrepareSolve.textContent = solverLoading ? t("btnPrepareSolveLoading") : t("btnPrepareSolve"); + + if (isSolverPlaying()) { + btnPlayPause.textContent = t("btnPlayPauseRunning"); + } else if (hasCompletedSolverTrace()) { + btnPlayPause.textContent = t("btnPlayReplay"); + } else { + btnPlayPause.textContent = t("btnPlayPause"); + } + + solverStepInfo.textContent = t("solverStepInfo", { + currentStep, + totalSteps: solverSteps.length + }); +} + +function discardSolverTrace(message = getDefaultSolverMessage()) { + solverRequestId += 1; + solverLoading = false; + stopSolverPlayback(false); + + solverSteps = []; + solverStepIndex = -1; + currentSolverHighlight = null; + + setSolverMessage(message); + updateSolverControls(); +} + +function rewindSolverTrace() { + stopSolverPlayback(false); + solverStepIndex = -1; + currentSolverHighlight = null; + board = createEmptyBoard(boardSize); + setSolverMessage(t("animationReset")); + drawBoard(); + updateSolverControls(); +} + +function setMode(newMode) { + if (mode === newMode) { + return; + } + + mode = newMode; + updateModeButtons(); + updateModeContent(); + resetBoard({ + statusMessageText: t("modeActivated", { piece: getPieceLabel(mode) }), + statusColor: "#2563eb", + solverMessageText: t("solverReady", { modeLabel: getModeLabel(mode) }) + }); +} + +function closeAllPanels() { + toggleSidebar(false); + toggleSavePanel(false); + toggleSavePointsPanel(false); + toggleInfoModal(false); +} + +function getConflicts(selectedBoard = board, selectedMode = mode) { + const conflicts = []; + + for (let rowA = 0; rowA < boardSize; rowA++) { + if (selectedBoard[rowA] === EMPTY_CELL) { + continue; + } + + for (let rowB = rowA + 1; rowB < boardSize; rowB++) { + if (selectedBoard[rowB] === EMPTY_CELL) { + continue; + } + + const colA = selectedBoard[rowA]; + const colB = selectedBoard[rowB]; + const sameColumn = colA === colB; + const sameDiagonal = Math.abs(colA - colB) === Math.abs(rowA - rowB); + const hasConflict = selectedMode === "rooks" ? sameColumn : sameColumn || sameDiagonal; + + if (hasConflict) { + conflicts.push([rowA, colA], [rowB, colB]); + } + } + } + + return conflicts; +} + +function isBoardSolved(selectedBoard = board, selectedMode = mode) { + const allPlaced = selectedBoard.every((col) => col !== EMPTY_CELL); + return allPlaced && getConflicts(selectedBoard, selectedMode).length === 0; +} + +function finishSolvedRun() { + if (isSolved) { + return; + } + + elapsedBeforeTimerStartMs = getElapsedMilliseconds(); + isSolved = true; + stopGameTimer(); + updateProgressDisplay(); + persistCurrentSession(); +} + +function drawBoard() { + boardDiv.innerHTML = ""; + boardDiv.className = "board-grid"; + updateBoardLayout(); + + const conflicts = getConflicts(); + const highlight = currentSolverHighlight; + + for (let row = -1; row < boardSize; row++) { + for (let col = -1; col < boardSize; col++) { + const cell = document.createElement("div"); + + if (row === -1 && col === -1) { + cell.classList.add("label"); + } else if (row === -1) { + cell.textContent = getColumnLabel(col); + cell.classList.add("label"); + } else if (col === -1) { + cell.textContent = boardSize - row; + cell.classList.add("label"); + } else { + cell.classList.add("cell", (row + col) % 2 === 0 ? "white" : "black"); + + if (highlight && highlight.row === row && highlight.col === col) { + cell.classList.add( + highlight.type === "remove" + ? "solver-remove" + : highlight.type === "solution" + ? "solver-solution" + : "solver-place" + ); + } + + if (board[row] === col) { + cell.textContent = mode === "queens" ? "♛" : "♜"; + + if (conflicts.some(([conflictRow, conflictCol]) => conflictRow === row && conflictCol === col)) { + cell.classList.add("invalid"); + } + } + + cell.onclick = () => placeQueen(row, col); + } + + boardDiv.appendChild(cell); + } + } + + updateQueenList(); +} + +function placeQueen(row, col) { + if (solverLoading) { + return; + } + + ensureFreshRunAfterSolvedBoard(); + + if (hasPreparedSolverTrace() || isSolverPlaying()) { + discardSolverTrace(t("solverDiscardedManual")); + setStatus(t("solverDiscardedStatus"), "#d97706"); + } else { + setStatus(); + } + + currentSolverHighlight = null; + board[row] = board[row] === col ? EMPTY_CELL : col; + stepCount += 1; + drawBoard(); + updateProgressDisplay(); + + if (isBoardSolved()) { + finishSolvedRun(); + setStatus(t("solvedPerfect", { duration: formatDuration(Math.floor(getElapsedMilliseconds() / 1000)) }), "green"); + } else { + persistCurrentSession(); + } +} + +function previewBoardSize(size) { + pendingBoardSize = clampBoardSize(size); + sidebarBoardSizeValue.textContent = pendingBoardSize; + sidebarBoardSizeValueMirror.textContent = pendingBoardSize; +} + +function applyBoardSize() { + boardSize = pendingBoardSize; + discardSolverTrace(t("solverReady", { modeLabel: getModeLabel(mode) })); + board = createEmptyBoard(boardSize); + currentSolverHighlight = null; + resetProgress(); + updateBoardSizeDisplays(boardSize); + setStatus(t("settingsApplied", { size: boardSize }), "#2563eb"); + drawBoard(); + toggleSidebar(false); +} + +function toggleSidebar(forceOpen) { + const shouldOpen = typeof forceOpen === "boolean" ? forceOpen : !settingsSidebar.classList.contains("open"); + + if (shouldOpen) { + toggleSavePanel(false); + toggleSavePointsPanel(false); + } + + settingsSidebar.classList.toggle("open", shouldOpen); + sidebarBackdrop.classList.toggle("visible", shouldOpen); + settingsSidebar.setAttribute("aria-hidden", String(!shouldOpen)); + updateSettingsTrigger(shouldOpen); +} + +function toggleSavePanel(forceOpen) { + const shouldOpen = typeof forceOpen === "boolean" ? forceOpen : !saveSidebar.classList.contains("open"); + + if (shouldOpen) { + toggleSidebar(false); + toggleSavePointsPanel(false); + } + + saveSidebar.classList.toggle("open", shouldOpen); + saveBackdrop.classList.toggle("visible", shouldOpen); + saveSidebar.setAttribute("aria-hidden", String(!shouldOpen)); +} + +function toggleSavePointsPanel(forceOpen) { + const shouldOpen = typeof forceOpen === "boolean" ? forceOpen : !savePointsSidebar.classList.contains("open"); + + if (shouldOpen) { + toggleSidebar(false); + toggleSavePanel(false); + } + + savePointsSidebar.classList.toggle("open", shouldOpen); + savePointsBackdrop.classList.toggle("visible", shouldOpen); + savePointsSidebar.setAttribute("aria-hidden", String(!shouldOpen)); +} + +function toggleInfoModal(forceOpen) { + const shouldOpen = typeof forceOpen === "boolean" ? forceOpen : !infoModal.classList.contains("open"); + + infoModal.classList.toggle("open", shouldOpen); + infoModalBackdrop.classList.toggle("visible", shouldOpen); + infoModal.setAttribute("aria-hidden", String(!shouldOpen)); +} + +function getPieceLabel(selectedMode) { + return getModeCopy(selectedMode).singular; +} + +function getPlacedPieceCount() { + return board.filter((col) => col !== EMPTY_CELL).length; +} + +function resetSaveForm() { + saveNameInput.value = t("saveDefaultName", { + piece: getPieceLabel(mode), + size: boardSize, + count: getPlacedPieceCount() + }); + saveNoteInput.value = ""; + saveFavoriteInput.checked = false; +} + +function openSavePanel() { + resetSaveForm(); + toggleSavePanel(true); +} + +function getBoardPreview(savedBoard, limit = 4) { + const preview = []; + + for (let row = 0; row < savedBoard.length; row++) { + if (savedBoard[row] === EMPTY_CELL) { + continue; + } + + preview.push(`${getColumnLabel(savedBoard[row])}${savedBoard.length - row}`); + + if (preview.length === limit) { + break; + } + } + + return preview.length > 0 ? preview.join(", ") : t("previewEmpty"); +} + +function getSavePointFilters() { + const params = new URLSearchParams(); + + if (savePointModeFilter.value !== "all") { + params.set("mode", savePointModeFilter.value); + } + + if (savePointSizeFilter.value !== "all") { + params.set("board_size", savePointSizeFilter.value); + } + + params.set("sort", savePointSortFilter.value); + + if (savePointFavoritesOnly.checked) { + params.set("favorites_only", "true"); + } + + return params.toString(); +} + +function createSavePointActionButton(label, className, onClick) { + const button = document.createElement("button"); + button.className = className; + button.type = "button"; + button.textContent = label; + button.onclick = onClick; + return button; +} + +function getModeLabel(selectedMode) { + return getModeCopy(selectedMode).plural; +} + +function renderSavePoints(savePoints) { + savePointsList.innerHTML = ""; + + if (savePoints.length === 0) { + savePointsList.innerHTML = ``; + return; + } + + for (const savePoint of savePoints) { + const item = document.createElement("article"); + const title = document.createElement("h4"); + const createdAt = document.createElement("p"); + const updatedAt = document.createElement("p"); + const boardMeta = document.createElement("p"); + const preview = document.createElement("p"); + const note = document.createElement("p"); + const actions = document.createElement("div"); + + item.className = "save-point-card"; + title.textContent = `${savePoint.is_favorite ? "★ " : ""}${savePoint.save_name}`; + createdAt.className = "save-point-meta"; + createdAt.textContent = `${t("createdLabel")}: ${savePoint.created_at}`; + updatedAt.className = "save-point-meta"; + updatedAt.textContent = `${t("updatedLabel")}: ${savePoint.updated_at}`; + boardMeta.className = "save-point-meta"; + boardMeta.textContent = `${getPieceLabel(savePoint.mode)} | ${savePoint.board_size} x ${savePoint.board_size} | ${savePoint.pieces_placed}/${savePoint.board_size} ${t("placedWord")}`; + preview.className = "save-point-preview"; + preview.textContent = `${t("previewLabel")}: ${getBoardPreview(savePoint.board)}`; + note.className = "save-point-note"; + note.textContent = savePoint.save_note ? `${t("noteLabel")}: ${savePoint.save_note}` : `${t("noteLabel")}: ${t("noNote")}`; + actions.className = "save-point-actions"; + + const progressMeta = document.createElement("p"); + progressMeta.className = "save-point-meta"; + progressMeta.textContent = `${t("stepsMeta")}: ${savePoint.step_count || 0} | ${t("timeMeta")}: ${formatDuration(savePoint.elapsed_seconds)} | ${t("statusLabel")}: ${savePoint.is_solved ? t("solvedMeta") : t("openMeta")}`; + + actions.appendChild(createSavePointActionButton(t("loadButton"), "btn", () => loadSavePoint(savePoint.id))); + actions.appendChild(createSavePointActionButton( + savePoint.is_favorite ? t("favoriteRemoveButton") : t("favoriteAddButton"), + "btn secondary", + () => toggleSavePointFavorite(savePoint.id) + )); + actions.appendChild(createSavePointActionButton(t("deleteButton"), "btn ghost", () => deleteSavePoint(savePoint.id))); + + item.appendChild(title); + item.appendChild(createdAt); + item.appendChild(updatedAt); + item.appendChild(boardMeta); + item.appendChild(progressMeta); + item.appendChild(preview); + item.appendChild(note); + item.appendChild(actions); + savePointsList.appendChild(item); + } +} + +function renderTimeHistory(savePoints) { + timeHistoryTableBody.innerHTML = ""; + + if (savePoints.length === 0) { + timeHistoryTableBody.innerHTML = `${t("historyEmpty")}`; + return; + } + + for (const savePoint of savePoints) { + const row = document.createElement("tr"); + const values = [ + `${savePoint.is_favorite ? "★ " : ""}${savePoint.save_name}`, + getModeLabel(savePoint.mode), + `${savePoint.board_size} x ${savePoint.board_size}`, + String(savePoint.step_count || 0), + formatDuration(savePoint.elapsed_seconds), + savePoint.is_solved ? t("solvedMeta") : t("openMeta"), + savePoint.updated_at + ]; + + for (const value of values) { + const cell = document.createElement("td"); + cell.textContent = value; + row.appendChild(cell); + } + + timeHistoryTableBody.appendChild(row); + } +} + +async function refreshSavePoints() { + const queryString = getSavePointFilters(); + const response = await fetch(`/save_points${queryString ? `?${queryString}` : ""}`); + const savePoints = await response.json(); + renderSavePoints(savePoints); +} + +async function refreshTimeHistory() { + const response = await fetch("/save_points?sort=updated"); + + if (!response.ok) { + return; + } + + const savePoints = await response.json(); + renderTimeHistory(savePoints); +} + +async function openSavePointsPanel() { + await refreshSavePoints(); + toggleSavePointsPanel(true); +} + +function applyLoadedGame(data) { + discardSolverTrace(t("solverReady", { modeLabel: getModeLabel(data.mode || "queens") })); + + const restoredBoardSize = Array.isArray(data.board) ? clampBoardSize(data.board.length) : DEFAULT_BOARD_SIZE; + + boardSize = restoredBoardSize; + pendingBoardSize = boardSize; + mode = data.mode === "rooks" ? "rooks" : "queens"; + board = sanitizeBoard(data.board, boardSize); + currentSolverHighlight = null; + resetProgress({ + stepCountValue: data.step_count, + elapsedMs: Number(data.elapsed_seconds || 0) * 1000, + solved: data.is_solved || isBoardSolved(board, mode) + }); + + updateModeButtons(); + updateModeContent(); + updateBoardSizeDisplays(boardSize); + drawBoard(); + updateProgressDisplay(); + persistCurrentSession(); +} + +function applySolverStep(step) { + board = sanitizeBoard(step.board, boardSize); + + if (Number.isInteger(step.row) && Number.isInteger(step.col)) { + currentSolverHighlight = { + row: step.row, + col: step.col, + type: step.type + }; + } else if (step.type === "solution" && solverStepIndex > 0) { + const previousStep = solverSteps[solverStepIndex - 1]; + currentSolverHighlight = Number.isInteger(previousStep.row) && Number.isInteger(previousStep.col) + ? { + row: previousStep.row, + col: previousStep.col, + type: "solution" + } + : null; + } else { + currentSolverHighlight = null; + } + + setSolverMessage(step.message || getDefaultSolverMessage()); + drawBoard(); + persistCurrentSession(); + updateSolverControls(); +} + +function advanceSolverStep() { + if (!hasPreparedSolverTrace() || hasCompletedSolverTrace()) { + stopSolverPlayback(false); + updateSolverControls(); + return false; + } + + solverStepIndex += 1; + applySolverStep(solverSteps[solverStepIndex]); + + if (hasCompletedSolverTrace()) { + finishSolvedRun(); + stopSolverPlayback(false); + setStatus(t("animationFinishedStatus", { size: boardSize }), "green"); + updateSolverControls(); + } + + return true; +} + +function scheduleNextSolverPlaybackStep() { + stopSolverPlayback(false); + + solverTimerId = window.setTimeout(() => { + solverTimerId = null; + + const advanced = advanceSolverStep(); + + if (advanced && !hasCompletedSolverTrace()) { + scheduleNextSolverPlaybackStep(); + return; + } + + updateSolverControls(); + }, solverSpeedMs); + + updateSolverControls(); +} + +function startSolverPlayback(stepImmediately = true) { + if (!hasPreparedSolverTrace() || hasCompletedSolverTrace()) { + updateSolverControls(); + return; + } + + if (stepImmediately) { + const advanced = advanceSolverStep(); + + if (!advanced || hasCompletedSolverTrace()) { + updateSolverControls(); + return; + } + } + + scheduleNextSolverPlaybackStep(); +} + +async function prepareSolutionTrace() { + const endpoint = mode === "rooks" ? "/solve_rooks_trace" : "/solve_trace"; + const requestId = ++solverRequestId; + + solverLoading = true; + stopSolverPlayback(false); + solverSteps = []; + solverStepIndex = -1; + currentSolverHighlight = null; + setSolverMessage(t("animationPreparing", { modeLabel: getModeLabel(mode), size: boardSize })); + setStatus(t("animationPreparingStatus", { size: boardSize }), "#2563eb"); + updateSolverControls(); + + try { + const response = await fetch(`${endpoint}?size=${boardSize}`); + + if (!response.ok) { + throw new Error("trace request failed"); + } + + const trace = await response.json(); + + if (requestId !== solverRequestId) { + return false; + } + + solverSteps = Array.isArray(trace.steps) ? trace.steps : []; + solverStepIndex = -1; + board = Array.isArray(trace.initial_board) ? trace.initial_board.slice() : createEmptyBoard(boardSize); + currentSolverHighlight = null; + + if (!trace.solved || solverSteps.length === 0) { + setSolverMessage(t("noSolution")); + setStatus(t("noSolutionStatus"), "red"); + drawBoard(); + persistCurrentSession(); + return false; + } + + setSolverMessage(t("animationReady", { steps: solverSteps.length, queensMode: mode === "queens" })); + setStatus(t("animationReadyStatus"), "#2563eb"); + drawBoard(); + return true; + } catch (error) { + if (requestId !== solverRequestId) { + return false; + } + + setSolverMessage(t("animationLoadError")); + setStatus(t("animationLoadError"), "red"); + return false; + } finally { + if (requestId === solverRequestId) { + solverLoading = false; + updateSolverControls(); + } + } +} + +async function nextSolverStep() { + if (solverLoading) { + return; + } + + if (!hasPreparedSolverTrace()) { + const prepared = await prepareSolutionTrace(); + + if (!prepared) { + return; + } + } + + if (isSolverPlaying()) { + stopSolverPlayback(false); + } + + if (!advanceSolverStep()) { + setStatus(t("animationAlreadyFinished"), "#2563eb"); + } +} + +async function toggleSolverPlayback() { + if (solverLoading) { + return; + } + + if (isSolverPlaying()) { + stopSolverPlayback(); + setStatus(t("animationPaused"), "#2563eb"); + return; + } + + if (!hasPreparedSolverTrace()) { + const prepared = await prepareSolutionTrace(); + + if (!prepared) { + return; + } + } + + if (hasCompletedSolverTrace()) { + rewindSolverTrace(); + } + + setStatus(t("animationRunning"), "#2563eb"); + startSolverPlayback(true); +} + +async function solve() { + await prepareSolutionTrace(); +} + +async function instantSolve() { + if (solverLoading) { + return; + } + + const endpoint = mode === "rooks" ? "/solve_rooks" : "/solve"; + const requestId = ++solverRequestId; + + solverLoading = true; + stopSolverPlayback(false); + solverSteps = []; + solverStepIndex = -1; + currentSolverHighlight = null; + setSolverMessage(t("instantLoading", { modeLabel: getModeLabel(mode), size: boardSize })); + setStatus(t("instantLoadingStatus"), "#2563eb"); + updateSolverControls(); + + try { + const response = await fetch(`${endpoint}?size=${boardSize}`); + + if (!response.ok) { + throw new Error("instant solve request failed"); + } + + const solution = await response.json(); + + if (requestId !== solverRequestId) { + return; + } + + board = mode === "queens" && Array.isArray(solution[0]) ? solution[0].slice() : solution.slice(); + currentSolverHighlight = null; + finishSolvedRun(); + setSolverMessage(t("instantLoaded")); + setStatus(t("instantLoadedStatus", { size: boardSize }), "green"); + drawBoard(); + persistCurrentSession(); + } catch (error) { + if (requestId !== solverRequestId) { + return; + } + + setSolverMessage(t("instantLoadError")); + setStatus(t("instantLoadError"), "red"); + } finally { + if (requestId === solverRequestId) { + solverLoading = false; + updateSolverControls(); + } + } +} + +function resetBoard(options = {}) { + const { + statusMessageText = "", + statusColor = "", + solverMessageText = t("solverReady", { modeLabel: getModeLabel(mode) }) + } = options; + + discardSolverTrace(solverMessageText); + board = createEmptyBoard(boardSize); + currentSolverHighlight = null; + resetProgress(); + + if (statusMessageText) { + setStatus(statusMessageText, statusColor); + } else { + setStatus(); + } + + drawBoard(); +} + +function updateQueenList() { + queenList.innerHTML = ""; + let count = 1; + + for (let row = 0; row < boardSize; row++) { + if (board[row] === EMPTY_CELL) { + continue; + } + + const col = board[row]; + const position = `${getColumnLabel(col)}${boardSize - row}`; + const li = document.createElement("li"); + + li.textContent = `${getPieceLabel(mode)} ${count}: ${position}`; + queenList.appendChild(li); + count += 1; + } + + const placedPieces = count - 1; + pieceCountBadge.textContent = t("pieceCount", { count: placedPieces }); + + if (placedPieces === 0) { + const emptyItem = document.createElement("li"); + emptyItem.className = "empty"; + emptyItem.textContent = t("pieceEmpty", { modeLabel: getModeLabel(mode) }); + queenList.appendChild(emptyItem); + } +} + +async function saveGame() { + const response = await fetch("/save", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + board, + mode, + boardSize, + saveName: saveNameInput.value, + saveNote: saveNoteInput.value, + isFavorite: saveFavoriteInput.checked, + stepCount, + elapsedSeconds: Math.floor(getElapsedMilliseconds() / 1000), + isSolved + }) + }); + + if (!response.ok) { + setStatus(t("saveError"), "red"); + return; + } + + const data = await response.json(); + + toggleSavePanel(false); + await refreshSavePoints(); + await refreshTimeHistory(); + setStatus(t("saveSuccess", { name: data.save_point.save_name }), "green"); +} + +async function loadGame() { + const response = await fetch("/load"); + const data = await response.json(); + applyLoadedGame(data); +} + +async function loadSavePoint(savePointId) { + const response = await fetch(`/load/${savePointId}`); + + if (!response.ok) { + setStatus(t("loadError"), "red"); + return; + } + + const data = await response.json(); + + applyLoadedGame(data); + setStatus(t("loadSuccess", { name: data.save_name }), "green"); + toggleSavePointsPanel(false); +} + +async function toggleSavePointFavorite(savePointId) { + const response = await fetch(`/save_points/${savePointId}/favorite`, { + method: "POST" + }); + + if (!response.ok) { + setStatus(t("favoriteError"), "red"); + return; + } + + await refreshSavePoints(); + await refreshTimeHistory(); + setStatus(t("favoriteSuccess"), "#2563eb"); +} + +async function deleteSavePoint(savePointId) { + const confirmed = window.confirm(t("deleteConfirm")); + + if (!confirmed) { + return; + } + + const response = await fetch(`/save_points/${savePointId}`, { + method: "DELETE" + }); + + if (!response.ok) { + setStatus(t("deleteError"), "red"); + return; + } + + await refreshSavePoints(); + await refreshTimeHistory(); + setStatus(t("deleteSuccess"), "#2563eb"); +} + +function restoreSessionFromStorage() { + try { + const rawSession = window.localStorage.getItem(GAME_STORAGE_KEY); + + if (!rawSession) { + return false; + } + + const savedSession = JSON.parse(rawSession); + const restoredBoardSize = clampBoardSize(savedSession.boardSize || DEFAULT_BOARD_SIZE); + const restoredMode = savedSession.mode === "rooks" ? "rooks" : "queens"; + + boardSize = restoredBoardSize; + pendingBoardSize = restoredBoardSize; + mode = restoredMode; + board = sanitizeBoard(savedSession.board, restoredBoardSize); + currentSolverHighlight = null; + + updateModeButtons(); + updateModeContent(); + updateBoardSizeDisplays(boardSize); + drawBoard(); + resetProgress({ + stepCountValue: savedSession.stepCount, + elapsedMs: savedSession.elapsedMs, + solved: savedSession.isSolved || isBoardSolved(board, mode) + }); + + return true; + } catch (error) { + return false; + } +} + +async function initializeDashboard() { + pendingBoardSize = boardSize; + updateModeButtons(); + updateBoardSizeDisplays(boardSize); + setLanguage(getStoredLanguage() || detectBrowserLanguage()); + updateSettingsTrigger(false); + setSolverMessage(t("solverReady", { modeLabel: getModeLabel(mode) })); + + if (!restoreSessionFromStorage()) { + resetProgress(); + drawBoard(); + } + + updateProgressDisplay(); + await refreshTimeHistory(); +} + +if (langSwitch) { + langSwitch.addEventListener("click", async () => { + setLanguage(currentLanguage === "de" ? "en" : "de"); + resetSaveForm(); + await refreshTimeHistory(); + + if (savePointsSidebar.classList.contains("open")) { + await refreshSavePoints(); + } + }); +} + +window.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + closeAllPanels(); + } +}); + +window.addEventListener("resize", () => { + drawBoard(); +}); + +initializeDashboard(); diff --git a/app/static/js/index.js b/app/static/js/index.js new file mode 100644 index 0000000..c2e7b1f --- /dev/null +++ b/app/static/js/index.js @@ -0,0 +1,257 @@ +const HERO_BOARD_SIZE = 8; +const heroBoard = document.getElementById("heroBoard"); +const langSwitch = document.getElementById("langSwitch"); +const supportedLanguages = ["de", "en"]; +const initialLanguage = window.LANDING_INITIAL_LANGUAGE || "de"; + +const translations = { + de: { + "meta.title": "8-Damen- und 8-Türme Solver", + "nav.brand": "8-Damen- und 8-Türme Solver", + "nav.problem": "Problem", + "nav.features": "Features", + "nav.journey": "Ablauf", + "nav.login": "Login", + "nav.signup": "Create Account", + "hero.eyebrow": "Interaktives Lernen mit Strategie, Logik und Backtracking", + "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.primaryCta": "Jetzt starten", + "hero.secondaryCta": "Bereits Account? Login", + "hero.fact1Title": "Skalierbar", + "hero.fact1Text": "Brettgrösse flexibel anpassen", + "hero.fact2Title": "Schritt für Schritt", + "hero.fact2Text": "Solver mit Animation", + "hero.fact3Title": "Save-Points", + "hero.fact3Text": "Zeit und Fortschritt behalten", + "hero.solverLabel": "Solver", + "hero.solverValue": "Backtracking live", + "hero.challengeLabel": "Challenge", + "hero.challengeValue": "Zeit und Schritte messen", + "problem.eyebrow": "Warum dieses Problem spannend ist", + "problem.title": "Die Acht-Damen- und Acht-Türme-Probleme sind mehr als Schachrätsel.", + "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.

Gerade beim Damenproblem wird schnell sichtbar, warum Strategie wichtig ist: Es gibt unzählige moegliche Platzierungen, aber nur sehr wenige wirklich gültige Lösungen. Genau das macht diese App spannend, weil man nicht nur das Ergebnis sieht, sondern auch den Weg dorthin.", + "features.eyebrow": "Was die Applikation bietet", + "features.title": "Von der Idee bis zur gelösten Stellung.", + "features.card1Title": "Interaktives Brett", + "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.card2Text": "Beobachte den Lösungsweg Schritt fuer Schritt und verstehe, wie Backtracking arbeitet.", + "features.card3Title": "Zeit und Schritte", + "features.card3Text": "Miss deinen Fortschritt während des Spiels und vergleiche unterschiedliche Versuche.", + "features.card4Title": "Save-Points", + "features.card4Text": "Speichere Zwischenstände mit Notizen, Zeiten und Schrittzahl fuer späteres Weiterarbeiten.", + "features.card5Title": "Skalierbare Bretter", + "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.title": "In wenigen Schritten vom ersten Besuch zur eigenen Lösung.", + "journey.step1Title": "Account erstellen", + "journey.step1Text": "Registriere dich und erhalte Zugang zu Dashboard, Save-Points und deinem Fortschritt.", + "journey.step2Title": "Spielmodus wählen", + "journey.step2Text": "Arbeite mit Damen oder Türmen und passe die Brettgrösse deinem Ziel an.", + "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.", + "cta.eyebrow": "Bereit für den ersten Versuch?", + "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.primaryCta": "Create Account", + "cta.secondaryCta": "Login", + "footerText": "Schulprojekt rund um das Acht-Damen- und Acht-Türme-Problem.", + "footerPrivacy": "Datenschutzerklärung", + "footerImprint": "Impressum", + "footerNavAria": "Rechtliche Hinweise" + }, + en: { + "meta.title": "8 Queens and 8 Rooks Solver", + "nav.brand": "8 Queens and 8 Rooks Solver", + "nav.problem": "Problem", + "nav.features": "Features", + "nav.journey": "Journey", + "nav.login": "Login", + "nav.signup": "Create Account", + "hero.eyebrow": "Interactive learning with strategy, logic, and backtracking", + "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.primaryCta": "Get Started", + "hero.secondaryCta": "Already have an account? Login", + "hero.fact1Title": "Scalable", + "hero.fact1Text": "Adjust the board size freely", + "hero.fact2Title": "Step by step", + "hero.fact2Text": "Animated solver walkthrough", + "hero.fact3Title": "Save points", + "hero.fact3Text": "Keep your time and progress", + "hero.solverLabel": "Solver", + "hero.solverValue": "Backtracking in motion", + "hero.challengeLabel": "Challenge", + "hero.challengeValue": "Measure time and steps", + "problem.eyebrow": "Why this puzzle matters", + "problem.title": "The Eight Queens and Eight Rooks problems are more than chess puzzles.", + "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.

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.", + "features.eyebrow": "What the application offers", + "features.title": "From the first idea to a solved position.", + "features.card1Title": "Interactive board", + "features.card1Text": "Place queens or rooks directly on the board and check whether your setup is conflict-free.", + "features.card2Title": "Animated solver", + "features.card2Text": "Watch the solving path step by step and understand how backtracking works.", + "features.card3Title": "Time and steps", + "features.card3Text": "Track your progress during play and compare different attempts.", + "features.card4Title": "Save points", + "features.card4Text": "Store checkpoints with notes, time, and step count so you can continue later.", + "features.card5Title": "Scalable boards", + "features.card5Text": "Adjust the board size and explore the puzzle beyond the classic 8x8 board.", + "journey.eyebrow": "How your first session works", + "journey.title": "Reach your own solution in just a few steps.", + "journey.step1Title": "Create an account", + "journey.step1Text": "Sign up and unlock the dashboard, save points, and your tracked progress.", + "journey.step2Title": "Choose a game mode", + "journey.step2Text": "Work with queens or rooks and tune the board size to your goal.", + "journey.step3Title": "Solve or analyze", + "journey.step3Text": "Test your own ideas or let the solver show the solution visually, with a 1:1 view of each solving step.", + "cta.eyebrow": "Ready for your first attempt?", + "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.primaryCta": "Create Account", + "cta.secondaryCta": "Login", + "footerText": "School project about the Eight Queens and Eight Rooks problem.", + "footerPrivacy": "Privacy Policy", + "footerImprint": "Imprint", + "footerNavAria": "Legal information" + } +}; + +const queenPatterns = [ + [0, 4, 7, 5, 2, 6, 1, 3], + [1, 3, 5, 7, 2, 0, 6, 4], + [2, 5, 7, 0, 3, 6, 4, 1] +]; + +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(" { + const key = element.dataset.i18nAriaLabel; + const translatedText = dictionary[key]; + + if (!translatedText) { + return; + } + + element.setAttribute("aria-label", 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() { + if (!heroBoard) { + return; + } + + heroBoard.innerHTML = ""; + + for (let row = 0; row < HERO_BOARD_SIZE; row += 1) { + for (let col = 0; col < HERO_BOARD_SIZE; col += 1) { + const cell = document.createElement("div"); + cell.className = `board-cell ${(row + col) % 2 === 0 ? "light" : "dark"}`; + cell.dataset.row = String(row); + cell.dataset.col = String(col); + heroBoard.appendChild(cell); + } + } +} + +function applyPattern(pattern) { + if (!heroBoard) { + return; + } + + const cells = heroBoard.querySelectorAll(".board-cell"); + + cells.forEach((cell) => { + const row = Number(cell.dataset.row); + const col = Number(cell.dataset.col); + const hasQueen = pattern[row] === col; + + cell.classList.toggle("queen", hasQueen); + cell.classList.toggle("highlight", hasQueen); + }); +} + +function rotatePattern() { + currentPatternIndex = (currentPatternIndex + 1) % queenPatterns.length; + applyPattern(queenPatterns[currentPatternIndex]); +} + +if (langSwitch) { + langSwitch.addEventListener("click", () => { + const nextLanguage = document.documentElement.lang === "de" ? "en" : "de"; + setLanguage(nextLanguage); + }); +} + +createHeroBoard(); +applyPattern(queenPatterns[currentPatternIndex]); +setLanguage(getPreferredLanguage()); + +if (heroBoard) { + window.setInterval(rotatePattern, 2200); +} diff --git a/app/static/js/legal.js b/app/static/js/legal.js new file mode 100644 index 0000000..d8869fd --- /dev/null +++ b/app/static/js/legal.js @@ -0,0 +1,242 @@ +const SUPPORTED_LANGUAGES = ["de", "en"]; +const DEFAULT_LANGUAGE = "de"; +const LANGUAGE_STORAGE_KEY = "preferredLanguage"; + +const langSwitch = document.getElementById("langSwitch"); +const legalPage = document.body.dataset.legalPage; + +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 fuer Inhalte", + contentLiabilityText: "Die Inhalte dieser Website wurden mit Sorgfalt erstellt. Für die Richtigkeit, Vollstaendigkeit und Aktualitaet der Inhalte kann jedoch keine Gewaehr übernommen werden. Bei Bekanntwerden von Rechtsverletzungen werden entsprechende Inhalte umgehend überarbeitet oder entfernt.", + linksLiabilityTitle: "Haftung fuer Links", + 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.", + 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: "3. 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: "4. 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 funktionsfaehigen Webangebot. Falls eine ausdrückliche Einwilligung erforderlich ist, wird diese gesondert eingeholt.", + storageTitle: "5. 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: "6. 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: "7. 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 Einschraenkung der Verarbeitung", + rightsItem4: "Widerspruch gegen die Verarbeitung", + rightsItem5: "Herausgabe der bereitgestellten Daten, soweit anwendbar", + securityTitle: "8. Datensicherheit", + 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.", + versionTitle: "9. 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: "3. 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: "4. 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: "5. 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: "6. 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: "7. 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: "8. 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: "9. Version", + versionText: "Version of this template: April 2026" + } + } +}; + +function normalizeLanguage(language) { + const shortLanguage = String(language || "").trim().slice(0, 2).toLowerCase(); + return SUPPORTED_LANGUAGES.includes(shortLanguage) ? shortLanguage : DEFAULT_LANGUAGE; +} + +function getStoredLanguage() { + try { + return window.localStorage.getItem(LANGUAGE_STORAGE_KEY); + } catch (error) { + return null; + } +} + +function storeLanguage(language) { + try { + window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language); + } catch (error) { + return; + } +} + +function detectBrowserLanguage() { + return normalizeLanguage(navigator.language || navigator.userLanguage || DEFAULT_LANGUAGE); +} + +function t(language, key) { + const commonTranslation = translations[language]?.common?.[key]; + + if (commonTranslation) { + return commonTranslation; + } + + return translations[language]?.[legalPage]?.[key] ?? null; +} + +function translateFooter(language) { + const footerText = document.querySelector(".page-footer-text"); + const footerNav = document.querySelector(".page-footer-links"); + const privacyLink = document.querySelector('.page-footer-links a[href$="/privacy-policy"]'); + const imprintLink = document.querySelector('.page-footer-links a[href$="/impressum"]'); + + if (footerText) { + footerText.textContent = t(language, "footerText") || footerText.textContent; + } + + if (footerNav) { + footerNav.setAttribute("aria-label", t(language, "footerNavAria") || footerNav.getAttribute("aria-label") || ""); + } + + if (privacyLink) { + privacyLink.textContent = t(language, "footerPrivacy") || privacyLink.textContent; + } + + if (imprintLink) { + imprintLink.textContent = t(language, "footerImprint") || imprintLink.textContent; + } +} + +function applyTranslations(language) { + document.documentElement.lang = language; + document.body.dataset.language = language; + + document.querySelectorAll("[data-i18n]").forEach((element) => { + const translatedText = t(language, element.dataset.i18n); + + if (!translatedText) { + return; + } + + element.textContent = translatedText; + }); + + document.querySelectorAll("[data-i18n-aria-label]").forEach((element) => { + const translatedText = t(language, element.dataset.i18nAriaLabel); + + if (!translatedText) { + return; + } + + element.setAttribute("aria-label", translatedText); + }); + + translateFooter(language); + + if (langSwitch) { + langSwitch.textContent = language.toUpperCase(); + langSwitch.setAttribute("aria-label", t(language, "languageSwitchAria") || "Sprache wechseln"); + langSwitch.title = language === "de" ? "Switch to English" : "Zu Deutsch wechseln"; + } + + storeLanguage(language); +} + +function setLanguage(language) { + applyTranslations(normalizeLanguage(language)); +} + +setLanguage(getStoredLanguage() || detectBrowserLanguage()); + +if (langSwitch) { + langSwitch.addEventListener("click", () => { + setLanguage(document.documentElement.lang === "de" ? "en" : "de"); + }); +} diff --git a/app/static/js/login.js b/app/static/js/login.js index 7b22fe2..30b38a5 100644 --- a/app/static/js/login.js +++ b/app/static/js/login.js @@ -1,32 +1,298 @@ -const button = document.querySelector(".magnetic"); +const SUPPORTED_LANGUAGES = ["de", "en"]; +const DEFAULT_LANGUAGE = "de"; +const LANGUAGE_STORAGE_KEY = "preferredLanguage"; -button.addEventListener("mousemove", (e) => { - const rect = button.getBoundingClientRect(); - const x = e.clientX - rect.left - rect.width / 2; - const y = e.clientY - rect.top - rect.height / 2; +const langSwitch = document.getElementById("langSwitch"); - button.style.transform = `translate(${x * 0.2}px, ${y * 0.2}px)`; -}); +const translations = { + de: { + pageTitle: "Login-Einstellungen", + navBrand: "8-Damen- und 8-Türme Solver", + 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-Damen- und 8-Türme Solver", + 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" + } +}; -button.addEventListener("mouseleave", () => { - button.style.transform = "translate(0,0)"; -}); +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: {} +}; -const inputs = document.querySelectorAll("input"); +let currentLanguage = DEFAULT_LANGUAGE; -inputs.forEach(input => { - input.addEventListener("input", () => { - input.style.boxShadow = "0 0 8px rgba(79,172,254,0.6)"; +function normalizeLanguage(language) { + const shortLanguage = String(language || "").trim().slice(0, 2).toLowerCase(); + return SUPPORTED_LANGUAGES.includes(shortLanguage) ? shortLanguage : DEFAULT_LANGUAGE; +} - setTimeout(() => { - input.style.boxShadow = ""; - }, 300); +function getStoredLanguage() { + try { + return window.localStorage.getItem(LANGUAGE_STORAGE_KEY); + } catch (error) { + return null; + } +} + +function storeLanguage(language) { + try { + window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language); + } catch (error) { + return; + } +} + +function detectBrowserLanguage() { + return normalizeLanguage(navigator.language || navigator.userLanguage || 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() { + document.documentElement.lang = currentLanguage; + + document.querySelectorAll("[data-i18n]").forEach((element) => { + const translatedText = t(element.dataset.i18n); + + if (element.tagName === "TITLE") { + element.textContent = translatedText; + return; + } + + element.textContent = translatedText; + }); + + document.querySelectorAll("[data-i18n-aria-label]").forEach((element) => { + element.setAttribute("aria-label", t(element.dataset.i18nAriaLabel)); + }); + + document.querySelectorAll("[data-i18n-loading-label]").forEach((element) => { + const translatedLoadingLabel = t(element.dataset.i18nLoadingLabel); + element.dataset.loadingLabel = translatedLoadingLabel; + + if (element.dataset.loadingLabelApplied === "true") { + element.textContent = translatedLoadingLabel; + } + }); + + document.querySelectorAll(".message").forEach((element) => { + if (!element.dataset.originalText) { + element.dataset.originalText = element.textContent.trim(); + } + + element.textContent = translateServerMessage(element.dataset.originalText); + }); + + if (langSwitch) { + langSwitch.textContent = currentLanguage.toUpperCase(); + langSwitch.setAttribute("aria-label", t("languageSwitchAria")); + langSwitch.title = currentLanguage === "de" ? "Switch to English" : "Zu Deutsch wechseln"; + } +} + +function setLanguage(language) { + currentLanguage = normalizeLanguage(language); + applyTextTranslations(); + storeLanguage(currentLanguage); +} + +setLanguage(getStoredLanguage() || detectBrowserLanguage()); + +if (langSwitch) { + langSwitch.addEventListener("click", () => { + setLanguage(currentLanguage === "de" ? "en" : "de"); + }); +} + +const magneticButtons = document.querySelectorAll(".magnetic"); + +magneticButtons.forEach((button) => { + button.addEventListener("mousemove", (event) => { + const rect = button.getBoundingClientRect(); + const x = event.clientX - rect.left - rect.width / 2; + const y = event.clientY - rect.top - rect.height / 2; + + button.style.transform = `translate(${x * 0.15}px, ${y * 0.15}px)`; + }); + + button.addEventListener("mouseleave", () => { + button.style.transform = "translate(0, 0)"; }); }); -const form = document.querySelector("form"); +document.querySelectorAll("input").forEach((input) => { + input.addEventListener("input", () => { + input.style.boxShadow = "0 0 8px rgba(59, 130, 246, 0.4)"; -form.addEventListener("submit", () => { - button.innerText = "Logging in..."; - button.style.opacity = "0.7"; + window.clearTimeout(input._loginGlowTimeout); + input._loginGlowTimeout = window.setTimeout(() => { + input.style.boxShadow = ""; + }, 250); + }); +}); + +document.querySelectorAll("form").forEach((form) => { + form.addEventListener("submit", (event) => { + const submitter = event.submitter; + + if (!submitter || submitter.dataset.loadingLabelApplied === "true") { + return; + } + + const loadingLabel = submitter.dataset.loadingLabel; + + if (loadingLabel) { + submitter.dataset.loadingLabelApplied = "true"; + submitter.dataset.originalLabel = submitter.textContent; + submitter.textContent = loadingLabel; + submitter.style.opacity = "0.7"; + } + }); +}); + +document.querySelectorAll(".qr-code[data-qr-value]").forEach((container) => { + const qrValue = container.dataset.qrValue; + + if (!qrValue) { + return; + } + + if (typeof window.QRCode !== "function") { + container.textContent = t("qrLoadError"); + return; + } + + container.innerHTML = ""; + + new window.QRCode(container, { + text: qrValue, + width: 220, + height: 220, + colorDark: "#172033", + colorLight: "#ffffff", + correctLevel: window.QRCode.CorrectLevel.M + }); }); diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 96397ba..45379c1 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -1,51 +1,370 @@ - - + - Dashboard + Dashboard + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +

{{ message }}

+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+

Puzzle-Modus

+

Wähl deinen Modus und löse das Brett auf deine Art.

+

+ Wechsle zwischen Damen und Türmen, nutze Animation oder Sofort-Lösung und öffne die + Brett-Einstellungen jederzeit über den linken Seiten-Tab. +

+ +
+ + +
+
+ +
+
+
+

Damen-Modus

+

Damen-Spielbrett

+

+ Platziere Damen so, dass keine zwei dieselbe Spalte oder Diagonale teilen. +

+
+ + +
+ +

Aktuelle Brettgrösse im + Damen-Modus: + 8 x 8 +

+ +
+
+

Schritte

+

0

+
+
+

Zeit

+

00:00:00

+
+
+

Status

+

Läuft

+
+
+ +

+ + +
+ +
+ +
+ + +
+
+ +
+
+
+

Solver

+

Lösungswerkzeuge

+

Bereite einen Ablauf vor und spiele ihn Schritt + für Schritt ab.

+
+

Schritt 0 / 0

+
+ +
+
+

Animations-Solver

+

+ Bereite den Lösungsweg vor, gehe ihn manüll Schritt für Schritt durch oder spiele die + Animation automatisch ab. +

+ +
+ + + +
+ +
+ + + 320 ms +
+
+ +
+

Sofort-Lösung

+

+ Lade direkt eine gültige Lösung, ohne Zwischenschritte oder Animation. +

+ +
+ +
+
+
+
+
+ +
+
+

Positionen der Damen

+ 0 gesetzt +
+
    +
    + +
    +
    +
    +

    Save-Points

    +

    Gespeicherte Zeiten und Fortschritte

    +

    Die Tabelle zeigt + gespeicherte Save-Points mit + Zeit, Schritten und + Status.

    +
    + +
    + +
    + + + + + + + + + + + + + + + + + +
    Save-PointModusBrettSchritteZeitStatusGespeichert
    Noch keine gespeicherten + Zeiten vorhanden.
    +
    +
    + {% include "partials/footer.html" %} + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/templates/impressum.html b/app/templates/impressum.html new file mode 100644 index 0000000..bfca517 --- /dev/null +++ b/app/templates/impressum.html @@ -0,0 +1,82 @@ + + + + + + + Impressum + + + + + + + +
    + + + + + + + + + + + +
    + + {% include "partials/footer.html" %} + + + + \ No newline at end of file diff --git a/app/templates/index.html b/app/templates/index.html index 3d2f07b..8222126 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1,66 +1,196 @@ - + - {% block title %}Solver App{% endblock %} - - - - - {% block head_extra %}{% endblock %} + 8-Damen- und 8-Tuerme Solver + + +
    + + ♛ + 8-Damen- und 8-Tuerme Solver + - - - -
    - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
    - {% for category, message in messages %} -
    - {{ message }} -
    - {% endfor %} -
    - {% endif %} - {% endwith %} +
    +
    +
    +

    Interaktives Lernen mit Strategie, Logik und Backtracking +

    +

    Entdecke das Acht-Damen- und Acht-Türme-Problem.

    +

    + 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. +

    - - {% block content %}{% endblock %} + + +
    +
    + Skalierbar + Brettgrösse flexibel anpassen +
    +
    + Schritt für Schritt + Solver mit Animation +
    +
    + Save-Points + Zeit und Fortschritt behalten +
    +
    +
    + +
    + +
    +

    Solver

    +

    Backtracking live

    +
    +
    +

    Challenge

    +

    Zeit und Schritte messen

    +
    +
    +
    + +
    +
    +

    Warum dieses Problem spannend ist

    +

    Die Acht-Damen- und Acht-Tuerme-Probleme sind mehr als Schachrätsel.

    +

    + Beim Acht-Damen-Problem suchst du eine Anordnung von acht Damen auf einem Schachbrett, sodass sich + keine zwei Damen schlagen. Beim Acht-Türme-Problem gilt dieselbe Idee für Türme, nur mit + einfacheren Konfliktregeln. Beide Aufgaben zeigen, wie Mustererkennung, systematische Suche und + Backtracking funktionieren. +

    + Gerade beim Damenproblem wird schnell sichtbar, warum Strategie wichtig ist: Es gibt unzählige + mögliche Platzierungen, aber nur sehr wenige wirklich gültige Lösungen. Genau das macht diese + App spannend, weil man nicht nur das Ergebnis sieht, sondern auch den Weg dorthin. +

    +
    +
    + +
    +
    +

    Was die Applikation bietet

    +

    Von der Idee bis zur gelösten Stellung.

    +
    + +
    +
    + ♟ +

    Interaktives Brett

    +

    Setze Damen oder Tuerme direkt auf dem Brett und prüfe, ob deine + Anordnung konfliktfrei ist.

    +
    + +
    + ⚙ +

    Solver mit Animation

    +

    Beobachte den Lösungsweg Schritt für Schritt und verstehe, wie + Backtracking arbeitet.

    +
    + +
    + ⏱ +

    Zeit und Schritte

    +

    Miss deinen Fortschritt während des Spiels und vergleiche + unterschiedliche Versuche.

    +
    + +
    + 💾 +

    Save-Points

    +

    Speichere Zwischenstaende mit Notizen, Zeiten und Schrittzahl für + späteres Weiterarbeiten.

    +
    + +
    + ▦ +

    Skalierbare Bretter

    +

    Passe die Brettgrösse an und erkunde die Probleme nicht nur auf + dem klassischen 8x8-Brett.

    +
    +
    +
    + +
    +
    +

    So funktioniert dein Einstieg

    +

    In wenigen Schritten vom ersten Besuch zur eigenen Lösung.

    +
    + +
    +
    + 01 +

    Account erstellen

    +

    Registriere dich und erhalte Zugang zu Dashboard, Save-Points und + deinem Fortschritt.

    +
    + +
    + 02 +

    Spielmodus wählen

    +

    Arbeite mit Damen oder Türmen und passe die Brettgrösse deinem Ziel + an.

    +
    + +
    + 03 +

    Lösen oder analysieren

    +

    Teste eigene Ideen oder lass dir die Lösung visuell vom Solver + lösen lassen, dabei wird 1:1 + gezeigt wie der Solver vorgeht.

    +
    +
    +
    + +
    +
    +

    Bereit für den ersten Versuch?

    +

    Mach aus einem bekannten Problem ein sichtbares Lernerlebnis.

    +

    + Starte mit einem Account, speichere deine Zwischenstände und finde heraus, wie schnell du eine + konfliktfreie Lösung erreichst. +

    + + +
    +
    - -
    -
    -

    © {{ 2026 }} SolverApp

    -
    -
    + {% include "partials/footer.html" %} - - - {% block scripts %}{% endblock %} + + - \ No newline at end of file + diff --git a/app/templates/login.html b/app/templates/login.html index 00152b2..1ef3298 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -5,35 +5,55 @@ Login - - - + + -