30 Commits

Author SHA1 Message Date
ScriptSorcerer eb78c22c17 fixed another spelling mistake 2026-04-19 16:06:34 +02:00
ScriptSorcerer bc81820d9d Issue-20 echnaged some things mentioned in the issues. 2026-04-09 17:31:56 +02:00
ScriptSorcerer a32f2d0c6e Merge branch 'dev' of https://github.com/ScriptSorcerer20/acht-damen-tuerme-problem into dev 2026-04-09 10:34:16 +02:00
ScriptSorcerer bcf18812eb Seperate language translation in diffrent file and sort files 2026-04-09 10:34:09 +02:00
ScriptSorcerer 7d208f5552 Merge branch 'main' into dev 2026-04-08 11:28:25 +02:00
ScriptSorcerer 045d4bf575 file for depolyment on cpanel 2026-04-08 11:26:42 +02:00
ScriptSorcerer 1beb1ce630 added comments and fixed some wrong text. 2026-04-08 11:06:37 +02:00
ScriptSorcerer 26e4d0dd27 Issue #17 Added imprint and privacy policy page and added englisch language support to it.
imprint still unfinished
2026-04-07 17:42:51 +02:00
ScriptSorcerer fc94d812d2 fixed some missing translations and added a back button to login_settings. 2026-04-07 11:14:20 +02:00
ScriptSorcerer b27c0ebcac Merge pull request #18 from ScriptSorcerer20/8-improve-design
8 improve design
2026-04-07 08:38:52 +02:00
ScriptSorcerer aa569e6cff made dashboard style consistent to landingpage and login, also added translation for english and german 2026-04-05 02:18:31 +02:00
ScriptSorcerer 19a6bbd392 Made consistent design for landing page and login. Landing page has all the text and design.
Added language support for english and german (swappable)
2026-04-05 00:42:25 +02:00
ScriptSorcerer 59b48683a8 added missing features, time counter, step counter, game instructions and table view for savepoints. 2026-04-04 23:15:36 +02:00
ScriptSorcerer 21fe673ab4 Merge branch '12-fortschritt-abspeichern' into dev 2026-04-04 11:25:27 +02:00
ScriptSorcerer 48e00fb2b0 Merge pull request #14 from ScriptSorcerer20/3-improved-login
implemented optional authentification app and stricter password and u…
2026-04-04 11:08:01 +02:00
ScriptSorcerer 02cb1d0a64 added step wise solution and an animation. 2026-04-01 11:54:40 +02:00
ScriptSorcerer 9cc1a22232 implemented optional authentification app and stricter password and user rules.
Also against brute force.
2026-04-01 10:38:29 +02:00
ScriptSorcerer f2de165c55 Merge pull request #13 from ScriptSorcerer20/12-fortschritt-abspeichern
12 fortschritt abspeichern
2026-03-30 19:31:14 +02:00
ScriptSorcerer b90d6d0f03 Improved save and load system, added description and sort options 2026-03-30 19:22:14 +02:00
ScriptSorcerer bc377b6e33 improved javascript structure, added load selection and made chessboard selectable 2026-03-30 19:03:13 +02:00
ScriptSorcerer b808b0017b implemented new model for database, new routes for save. Fixed logout not allowing post requests 2026-03-30 13:22:04 +02:00
ScriptSorcerer 5781eef189 Merge pull request #11 from ScriptSorcerer20/10-acht-tuerme-algorithmus
implemented "acht-damen algorithmus", seperated javascript from html,…
2026-03-29 18:02:35 +02:00
ScriptSorcerer cfcb495d17 Merge pull request #9 from ScriptSorcerer20/4-acht-damen-algorithmus
4 acht damen algorithmus
2026-03-29 18:00:38 +02:00
ScriptSorcerer 79cf3240e1 implemented "acht-damen algorithmus", seperated javascript from html, made a simple check function. 2026-03-29 17:54:11 +02:00
ScriptSorcerer 81d689c1ba Added chess board specification (number and cahracters), aswell as a detail view for the damen 2026-03-26 19:38:56 +01:00
ScriptSorcerer d94c4dd4d6 Update README to remove services directory section
Removed service files section from README.
2026-03-26 11:22:45 +01:00
ScriptSorcerer ecfe5c09a8 Simple chessboard design + slove button (shows you the solution with the algorithm) and marks conflicts with damen red.
conflict still has some errors (damen despawn somethimes) and css is very simple.
2026-03-26 11:06:27 +01:00
ScriptSorcerer 10e36d9aee Merge pull request #7 from ScriptSorcerer20/main
Update Dev branch
2026-03-23 13:04:40 +01:00
ScriptSorcerer cb05b091d8 Merge pull request #6 from ScriptSorcerer20/1-design-improvement
added gradients to backgroudn added functional button animation
2026-03-23 12:59:50 +01:00
ScriptSorcerer 34dfa118d9 added gradients to backgroudn added functional button animation
chnaged color design (grey style)
2026-03-23 12:55:59 +01:00
43 changed files with 7234 additions and 429 deletions
+2 -9
View File
@@ -6,7 +6,6 @@ project-root/
│
├── app/
│ ├── __init__.py # Flask App Factory
│ ├── config.py # Konfiguration (DB, Secrets, etc.)
│ │
│ ├── models/ # Datenbankmodelle
│ │ ├── __init__.py
@@ -22,12 +21,7 @@ project-root/
│ │ ├── solver_routes.py
│ │ └── job_routes.py
│ │
│ ├── services/ # Businesslogik (Solver etc.)
│ │ ├── __init__.py
│ │ ├── solver_service.py
│ │ └── auth_service.py
│ │
│ ├── templates/ # HTML (Jinja Templates)
│ ├── templates/ # HTML
│ │ ├── base.html
│ │ ├── login.html
│ │ ├── dashboard.html
@@ -42,6 +36,5 @@ project-root/
│
├── run.py # Startpunkt der Anwendung
├── requirements.txt
├── .env # DB-Zugangsdaten, Secrets
└── README.md
```
```
+30 -5
View File
@@ -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
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+25
View File
@@ -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()
)
+19
View File
@@ -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"<LoginAttempt {self.ip_address}:{self.username}>"
+4 -2
View File
@@ -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"<User {self.username}>"
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+515 -24
View File
@@ -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"))
+479 -8
View File
@@ -1,17 +1,488 @@
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 create_empty_board(board_size):
"""Create a square board matrix with no placed pieces."""
return [[0 for _ in range(board_size)] for _ in range(board_size)]
def build_board_from_positions(piece_positions, board_size):
"""Convert the legacy one-piece-per-row format into a board matrix."""
board = create_empty_board(board_size)
for row, col in enumerate(piece_positions):
if isinstance(col, int) and 0 <= col < board_size:
board[row][col] = 1
return board
def normalize_board(candidate_board, board_size):
"""Normalize stored boards so legacy and matrix formats can both be used."""
if not isinstance(candidate_board, list) or len(candidate_board) != board_size:
return create_empty_board(board_size)
if all(isinstance(row, list) and len(row) == board_size for row in candidate_board):
normalized_board = create_empty_board(board_size)
for row_index, row in enumerate(candidate_board):
for col_index, value in enumerate(row):
normalized_board[row_index][col_index] = 1 if value else 0
return normalized_board
return build_board_from_positions(candidate_board, board_size)
def iter_piece_positions(board):
"""Yield every occupied square from the normalized board matrix."""
for row_index, row in enumerate(board):
for col_index, value in enumerate(row):
if value:
yield row_index, col_index
def count_placed_pieces(board):
"""Count how many pieces are currently placed on the board."""
return sum(1 for _ in iter_piece_positions(board))
def parse_board_size(raw_size, default=DEFAULT_BOARD_SIZE):
"""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": build_board_from_positions(queen_positions, board_size),
"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": build_board_from_positions(queen_positions, board_size),
"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": build_board_from_positions(queen_positions, board_size),
"message": f"Backtracking: Dame von {position} entfernt."
})
return False
solved = place_queen(0)
return {
"mode": "queens",
"board_size": board_size,
"initial_board": create_empty_board(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 = create_empty_board(board_size)
steps = []
for row, col in enumerate(rook_positions):
board[row][col] = 1
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_row[:] for board_row in board],
"message": f"Turm auf {position} gesetzt."
})
steps.append({
"type": "solution",
"row": None,
"col": None,
"board": [board_row[:] for board_row in board],
"message": f"Loesung gefunden. Alle {board_size} Tuerme stehen ohne Konflikte."
})
return {
"mode": "rooks",
"board_size": board_size,
"initial_board": create_empty_board(board_size),
"steps": steps,
"solved": True
}
def serialize_game_state(game_state):
"""Convert a database model into JSON-friendly data for the frontend."""
board = normalize_board(json.loads(game_state.board), game_state.board_size)
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": count_placed_pieces(board),
"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('/solver')
def solver():
return render_template('solver.html')
@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")
normalized_board = normalize_board(board, len(board))
for current_row, current_col in iter_piece_positions(normalized_board):
if current_row == row and current_col == col:
continue
same_row = current_row == row
same_column = current_col == col
same_diagonal = abs(current_col - col) == abs(current_row - row)
if same_row or 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/<int:game_id>/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/<int:game_id>", 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": create_empty_board(DEFAULT_BOARD_SIZE), "mode": "queens"})
return jsonify(serialize_game_state(latest_game))
@main_bp.route("/load/<int:game_id>")
@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))
File diff suppressed because it is too large Load Diff
+111
View File
@@ -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;
}
}
+514
View File
@@ -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%;
}
}
+220
View File
@@ -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;
}
}
+391 -95
View File
@@ -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,124 +27,411 @@
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;
border-radius: 8px;
background: linear-gradient(135deg, var(--gold), var(--gold-soft));
color: #2c2215;
font-size: 22px;
}
/* MAGNET EFFECT */
.magnetic {
padding: 10px 20px;
border: none;
background: #4facfe;
color: white;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
transition: transform 0.2s ease;
}
.magnetic:hover {
background: #2d8cf0;
}
/* Reset */
* {
box-sizing: border-box;
.nav-links {
margin: 0;
padding: 0;
font-family: Arial, Helvetica, sans-serif;
}
/* Page setup */
body {
min-height: 100vh;
background: var(--primary-bg-color);
}
/* Navbar */
nav {
width: 100%;
background: var(--nav-bg-color);
padding: 15px 30px;
}
nav ul {
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;
}
/* Form box */
.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 {
from {
opacity: 0;
transform: translateY(15px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
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 {
background: var(--secondary-bg-color);
padding: 30px;
border-radius: 8px;
width: 300px;
display: flex;
flex-direction: column;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
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 */
input {
padding: 10px;
border: 1px solid #ccc;
border-radius: 6px;
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);
}
/* Button */
.button {
margin-top: 20px;
padding: 10px;
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 {
margin-top: 14px;
min-height: 50px;
padding: 12px 18px;
border: none;
background: #4facfe;
color: white;
border-radius: 6px;
border-radius: 0;
background: linear-gradient(135deg, var(--gold), var(--gold-soft));
color: #2d2418;
cursor: pointer;
font-size: 16px;
font-weight: 700;
transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
}
button:hover {
background: #2d8cf0;
transform: translateY(-2px);
box-shadow: 0 12px 24px rgba(31, 49, 64, 0.16);
}
/* Error message */
.error {
color: red;
margin-bottom: 15px;
}
button:active {
transform: scale(0.98);
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.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;
}
}
-125
View File
@@ -1,125 +0,0 @@
:root {
--bg-color: #f4f6f8;
--text-color: #333;
--board-border: #2c3e50;
--square-light: #ecf0f1;
--square-dark: #95a5a6;
--accent-color: #3498db;
--btn-hover: #2980b9;
--shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
}
h1 {
font-weight: 300;
letter-spacing: -0.5px;
margin-bottom: 2rem;
color: var(--board-border);
}
.container {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.chessboard {
display: grid;
grid-template-columns: repeat(8, 50px);
grid-template-rows: repeat(8, 50px);
border: 5px solid var(--board-border);
user-select: none;
}
.square {
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
}
.square.light {
background-color: var(--square-light);
}
.square.dark {
background-color: var(--square-dark);
}
.queen {
color: var(--board-border);
text-shadow: 0 2px 2px rgba(0,0,0,0.2);
cursor: pointer;
transition: transform 0.2s ease;
}
.queen:hover {
transform: scale(1.1);
}
.controls {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
button {
background-color: var(--accent-color);
color: white;
border: none;
padding: 0.6rem 1.2rem;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s ease;
}
button:hover {
background-color: var(--btn-hover);
}
button.secondary {
background-color: transparent;
border: 1px solid var(--accent-color);
color: var(--accent-color);
}
button.secondary:hover {
background-color: rgba(52, 152, 219, 0.1);
}
@media (max-width: 600px) {
.chessboard {
grid-template-columns: repeat(8, 1fr);
grid-template-rows: repeat(8, 1fr);
width: 100%;
max-width: 400px;
height: auto;
aspect-ratio: 1/1;
}
.square {
width: 100%;
height: 100%;
font-size: 1.5rem;
}
}
File diff suppressed because it is too large Load Diff
+216
View File
@@ -0,0 +1,216 @@
// settings for preview board
const HERO_BOARD_SIZE = 8;
const heroBoard = document.getElementById("heroBoard");
//translation for English and German
const AppLanguage = window.AppLanguage;
const langSwitch = document.getElementById("langSwitch");
const initialLanguage = window.LANDING_INITIAL_LANGUAGE || "de";
let currentLanguage = initialLanguage;
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": "Funktionen",
"nav.journey": "Ablauf",
"nav.login": "Anmelden",
"nav.signup": "Account erstellen",
"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 für Schritt, miss deine Zeit und speichere clevere Zwischenstände.",
"hero.primaryCta": "Jetzt starten",
"hero.secondaryCta": "Bereits ein Account? Anmelden",
"hero.fact1Title": "Skalierbar",
"hero.fact1Text": "Brettgrösse flexibel anpassen",
"hero.fact2Title": "Schritt für Schritt",
"hero.fact2Text": "Solver mit Animation",
"hero.fact3Title": "Speicherstände",
"hero.fact3Text": "Zeit und Fortschritt behalten",
"hero.solverLabel": "Lösungshelfer",
"hero.solverValue": "Backtracking in Echtzeit",
"hero.challengeLabel": "Herausforderung",
"hero.challengeValue": "Zeit und Schritte messen",
"problem.eyebrow": "Was ist das Acht-Damen-Problem?",
"problem.title": "Eine klassische schachmathematische Aufgabe mit überraschend vielen Lösungen.",
"problem.text": "Das Acht-Damen-Problem ist eine schachmathematische Aufgabe: Acht Damen sollen auf einem Schachbrett so aufgestellt werden, dass keine zwei Damen dieselbe Reihe, Spalte oder Diagonale teilen. Das Problem lässt sich auf beliebige Brettgrössen verallgemeinern, indem n Damen auf einem n x n-Brett platziert werden.<br><br>Für das klassische 8x8-Brett gibt es 92 Lösungen. Zählt man Stellungen, die nur durch Spiegelung oder Drehung entstehen, als gleich, bleiben 12 eindeutige Lösungen übrig.<br><br><a href=\"https://de-academic.com/dic.nsf/dewiki/13248\" target=\"_blank\" rel=\"noopener noreferrer\">Quelle: de-academic.com</a>",
"features.eyebrow": "Was die Applikation bietet",
"features.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 für 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": "Speicherstände",
"features.card4Text": "Speichere Zwischenstände mit Notizen, Zeiten und Schrittzahl für 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 zum Dashboard, zu Speicherständen und zu 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 zeigen, damit du den Lösungsweg besser nachvollziehen kannst.",
"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": "Account erstellen",
"cta.secondaryCta": "Anmelden",
"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": "Sign in",
"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? Sign in",
"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": "What is the Eight Queens problem?",
"problem.title": "A classic chess-related math puzzle with surprisingly many solutions.",
"problem.text": "The Eight Queens problem is a chess-related mathematical puzzle: eight queens must be placed on a chessboard so that no two queens share the same row, column, or diagonal. The idea can be generalized to boards of any size by placing n queens on an n x n board.<br><br>On the classic 8x8 board there are 92 solutions. If positions that only differ by reflection or rotation are counted as the same, 12 unique solutions remain.<br><br><a href=\"https://de-academic.com/dic.nsf/dewiki/13248\" target=\"_blank\" rel=\"noopener noreferrer\">Source: de-academic.com</a>",
"features.eyebrow": "What the application offers",
"features.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": "Sign in",
"footerText": "School project about the Eight Queens and Eight Rooks problem.",
"footerPrivacy": "Privacy Policy",
"footerImprint": "Imprint",
"footerNavAria": "Legal information"
}
};
function t(key) {
return translations[currentLanguage]?.[key] ?? null;
}
function applyTranslations(language) {
currentLanguage = language;
AppLanguage.applyTranslations({
translate: (key) => t(key),
allowHtml: (element, translatedText) => element.tagName === "TITLE" || String(translatedText).includes("<br"),
attributeMappings: [
{
selector: "[data-i18n-aria-label]",
datasetKey: "i18nAriaLabel",
attribute: "aria-label"
}
]
});
}
const languageController = AppLanguage.createController({
button: langSwitch,
defaultLanguage: initialLanguage,
onApply(language, helpers) {
applyTranslations(language);
helpers.updateLanguageSwitch({
ariaLabel: language === "de" ? "Switch language to English" : "Sprache auf Deutsch wechseln",
title: language === "de" ? "Switch to English" : "Zu Deutsch wechseln"
});
}
});
languageController.init();
// ----------------------------------------------------------------------------------------
// simple pattern for preview board
const queenPatterns = [
[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 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]);
}
createHeroBoard();
applyPattern(queenPatterns[currentPatternIndex]);
if (heroBoard) {
window.setInterval(rotatePattern, 2200);
}
+261
View File
@@ -0,0 +1,261 @@
// handels the language translation
(function attachAppLanguage(window, document) {
const DEFAULT_LANGUAGE = "de";
const DEFAULT_STORAGE_KEY = "preferredLanguage";
const DEFAULT_SUPPORTED_LANGUAGES = ["de", "en"];
function resolveSupportedLanguages(supportedLanguages = DEFAULT_SUPPORTED_LANGUAGES) {
const normalizedLanguages = Array.isArray(supportedLanguages)
? supportedLanguages
.map((language) => String(language || "").trim().slice(0, 2).toLowerCase())
.filter(Boolean)
: [];
return normalizedLanguages.length > 0 ? normalizedLanguages : DEFAULT_SUPPORTED_LANGUAGES.slice();
}
function normalizeLanguage(
language,
{
supportedLanguages = DEFAULT_SUPPORTED_LANGUAGES,
defaultLanguage = DEFAULT_LANGUAGE
} = {}
) {
const normalizedSupportedLanguages = resolveSupportedLanguages(supportedLanguages);
const normalizedDefaultLanguage = normalizedSupportedLanguages.includes(defaultLanguage)
? defaultLanguage
: normalizedSupportedLanguages[0] || DEFAULT_LANGUAGE;
const shortLanguage = String(language || "").trim().slice(0, 2).toLowerCase();
return normalizedSupportedLanguages.includes(shortLanguage) ? shortLanguage : normalizedDefaultLanguage;
}
function getStoredLanguage({ storageKey = DEFAULT_STORAGE_KEY } = {}) {
try {
return window.localStorage.getItem(storageKey);
} catch (error) {
return null;
}
}
function storeLanguage(language, { storageKey = DEFAULT_STORAGE_KEY } = {}) {
try {
window.localStorage.setItem(storageKey, language);
} catch (error) {
return;
}
}
function detectBrowserLanguage(options = {}) {
return normalizeLanguage(navigator.language || navigator.userLanguage || options.defaultLanguage, options);
}
function getPreferredLanguage(
{
supportedLanguages = DEFAULT_SUPPORTED_LANGUAGES,
defaultLanguage = DEFAULT_LANGUAGE,
storageKey = DEFAULT_STORAGE_KEY,
fallbackLanguage = defaultLanguage
} = {}
) {
return normalizeLanguage(
getStoredLanguage({ storageKey }) || detectBrowserLanguage({ supportedLanguages, defaultLanguage }) || fallbackLanguage,
{
supportedLanguages,
defaultLanguage: fallbackLanguage
}
);
}
function getNextLanguage(language, supportedLanguages = DEFAULT_SUPPORTED_LANGUAGES) {
const normalizedSupportedLanguages = resolveSupportedLanguages(supportedLanguages);
const currentLanguage = normalizeLanguage(language, {
supportedLanguages: normalizedSupportedLanguages,
defaultLanguage: normalizedSupportedLanguages[0]
});
const currentIndex = normalizedSupportedLanguages.indexOf(currentLanguage);
if (currentIndex === -1 || normalizedSupportedLanguages.length < 2) {
return currentLanguage;
}
return normalizedSupportedLanguages[(currentIndex + 1) % normalizedSupportedLanguages.length];
}
function setDocumentLanguage(language) {
if (document.documentElement) {
document.documentElement.lang = language;
}
if (document.body) {
document.body.dataset.language = language;
}
}
function applyTextTranslations({
translate,
selector = "[data-i18n]",
allowHtml = () => false
} = {}) {
if (typeof translate !== "function") {
return;
}
document.querySelectorAll(selector).forEach((element) => {
const key = element.dataset.i18n;
const translatedText = translate(key, element);
if (translatedText === undefined || translatedText === null) {
return;
}
if (allowHtml(element, translatedText, key)) {
element.innerHTML = String(translatedText);
return;
}
element.textContent = String(translatedText);
});
}
function applyAttributeTranslations({
translate,
mappings = []
} = {}) {
if (typeof translate !== "function") {
return;
}
mappings.forEach(({ selector, datasetKey, attribute }) => {
document.querySelectorAll(selector).forEach((element) => {
const key = element.dataset[datasetKey];
const translatedText = translate(key, element, attribute);
if (translatedText === undefined || translatedText === null) {
return;
}
element.setAttribute(attribute, String(translatedText));
});
});
}
function applyTranslations({
translate,
selector = "[data-i18n]",
allowHtml,
attributeMappings = []
} = {}) {
applyTextTranslations({ translate, selector, allowHtml });
applyAttributeTranslations({ translate, mappings: attributeMappings });
}
function updateLanguageSwitch(
button,
{
language,
label = String(language || "").toUpperCase(),
ariaLabel,
title
} = {}
) {
if (!button) {
return;
}
button.textContent = label;
if (ariaLabel !== undefined) {
button.setAttribute("aria-label", ariaLabel);
}
if (title !== undefined) {
button.title = title;
}
}
function createController({
button = document.getElementById("langSwitch"),
defaultLanguage = DEFAULT_LANGUAGE,
supportedLanguages = DEFAULT_SUPPORTED_LANGUAGES,
storageKey = DEFAULT_STORAGE_KEY,
onApply = () => { }
} = {}) {
const options = {
defaultLanguage,
supportedLanguages,
storageKey
};
let currentLanguage = normalizeLanguage(defaultLanguage, options);
function apply(language, source = "apply") {
currentLanguage = normalizeLanguage(language, options);
setDocumentLanguage(currentLanguage);
onApply(currentLanguage, {
source,
button,
applyTranslations,
updateLanguageSwitch: (switchOptions = {}) => updateLanguageSwitch(button, {
language: currentLanguage,
...switchOptions
}),
getNextLanguage: () => getNextLanguage(currentLanguage, supportedLanguages),
normalizeLanguage: (value) => normalizeLanguage(value, options)
});
storeLanguage(currentLanguage, { storageKey });
return currentLanguage;
}
function toggle() {
return apply(getNextLanguage(currentLanguage, supportedLanguages), "toggle");
}
function init(initialLanguage = getPreferredLanguage({
supportedLanguages,
defaultLanguage,
storageKey,
fallbackLanguage: defaultLanguage
})) {
if (button) {
button.addEventListener("click", toggle);
}
return apply(initialLanguage, "init");
}
return {
apply,
getCurrentLanguage: () => currentLanguage,
getPreferredLanguage: (fallbackLanguage = defaultLanguage) => getPreferredLanguage({
supportedLanguages,
defaultLanguage,
storageKey,
fallbackLanguage
}),
init,
toggle
};
}
window.AppLanguage = {
DEFAULT_LANGUAGE,
DEFAULT_STORAGE_KEY,
DEFAULT_SUPPORTED_LANGUAGES: DEFAULT_SUPPORTED_LANGUAGES.slice(),
applyAttributeTranslations,
applyTextTranslations,
applyTranslations,
createController,
detectBrowserLanguage,
getNextLanguage,
getPreferredLanguage,
getStoredLanguage,
normalizeLanguage,
setDocumentLanguage,
storeLanguage,
updateLanguageSwitch
};
})(window, document);
+174
View File
@@ -0,0 +1,174 @@
//translation for English and German
const AppLanguage = window.AppLanguage;
const langSwitch = document.getElementById("langSwitch");
const legalPage = document.body.dataset.legalPage;
let currentLanguage = AppLanguage.DEFAULT_LANGUAGE;
const translations = {
de: {
common: {
navBrand: "8-Damen- und 8-Türme Solver",
navHome: "Startseite",
navDashboard: "Dashboard",
navLogin: "Login",
heroEyebrow: "Rechtliche Hinweise",
footerText: "Schulprojekt rund um das Acht-Damen- und Acht-Türme-Problem.",
footerPrivacy: "Datenschutzerklärung",
footerImprint: "Impressum",
footerNavAria: "Rechtliche Hinweise",
languageSwitchAria: "Sprache wechseln"
},
imprint: {
pageTitle: "Impressum",
heroTitle: "Impressum",
contactTitle: "Kontakt",
contactName: "Jérôme Bachmann",
contactEmailLabel: "E-Mail:",
projectResponsibilityTitle: "Projektverantwortung",
projectResponsibilityText: "Dieses Webprojekt wurde im Rahmen eines Schulprojekts zum Acht-Damen- und Acht-Türme-Problem erstellt. Inhaltlich verantwortlich ist die jeweils oben genannte verantwortliche Stelle.",
contentLiabilityTitle: "Haftung für Inhalte",
contentLiabilityText: "Die Inhalte dieser Website wurden mit Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte kann jedoch keine Gewähr übernommen werden. Bei Bekanntwerden von Rechtsverletzungen werden entsprechende Inhalte umgehend überarbeitet oder entfernt.",
linksLiabilityTitle: "Haftung für Links",
linksLiabilityText: "Diese Website kann Links zu externen Angeboten enthalten. Für deren Inhalte sind ausschliesslich die jeweiligen Betreiber verantwortlich. Eine permanente inhaltliche Kontrolle verlinkter Seiten ist ohne konkrete Anhaltspunkte einer Rechtsverletzung nicht zumutbar.",
versionTitle: "Stand",
versionText: "Stand dieser Vorlage: April 2026"
},
privacy: {
pageTitle: "Datenschutzerklärung",
heroTitle: "Datenschutzerklärung",
heroLead: "Diese Seite beschreibt, welche personenbezogenen Daten im Rahmen dieses Schulprojekts verarbeitet werden.",
dataProcessedTitle: "1. Welche Daten verarbeitet werden",
dataProcessedText: "Im Rahmen der Nutzung dieser Webanwendung können insbesondere folgende Daten verarbeitet werden:",
dataProcessedItem1: "Benutzername und verschlüsseltes Passwort bei der Registrierung und beim Login",
dataProcessedItem2: "Daten zur Zwei-Faktor-Authentifizierung, falls diese freiwillig aktiviert wird",
dataProcessedItem3: "gespeicherte Spielstände, Notizen, Schrittzahlen, Zeiten und Favoriten",
dataProcessedItem4: "technische Verbindungsdaten, die durch den Server oder das Hosting entstehen koennen",
processingPurposeTitle: "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 funktionsfähigen 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 Einschränkung 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 schützen. Dazu gehoeren insbesondere passwortgeschützte 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 t(key) {
const commonTranslation = translations[currentLanguage]?.common?.[key];
if (commonTranslation) {
return commonTranslation;
}
return translations[currentLanguage]?.[legalPage]?.[key] ?? null;
}
function applyTranslations() {
AppLanguage.applyTranslations({
translate: (key) => t(key),
attributeMappings: [
{
selector: "[data-i18n-aria-label]",
datasetKey: "i18nAriaLabel",
attribute: "aria-label"
}
]
});
}
const languageController = AppLanguage.createController({
button: langSwitch,
onApply(language, helpers) {
currentLanguage = language;
applyTranslations();
helpers.updateLanguageSwitch({
ariaLabel: t("languageSwitchAria") || "Sprache wechseln",
title: language === "de" ? "Switch to English" : "Zu Deutsch wechseln"
});
}
});
languageController.init();
+265 -10
View File
@@ -1,14 +1,269 @@
const button = document.querySelector(".magnetic")
//translation for English and German
button.addEventListener("mousemove", (e) => {
const rect = button.getBoundingClientRect()
const AppLanguage = window.AppLanguage;
const langSwitch = document.getElementById("langSwitch");
const x = e.clientX - rect.left - rect.width / 2
const y = e.clientY - rect.top - rect.height / 2
const translations = {
de: {
pageTitle: "Login-Einstellungen",
navBrand: "8-Damen- und 8-Türme Solver",
navLogin: "Anmelden",
navRegister: "Account erstellen",
navDashboard: "Dashboard",
navSettings: "Login-Einstellungen",
navLogout: "Logout",
backToDashboard: "Zurück zum Dashboard",
backToDashboardAria: "Zurück zum Dashboard",
languageSwitchAria: "Sprache wechseln",
settingsTitle: "Login-Einstellungen",
settingsSubtitle: "Verwalte die Authenticator-App-2FA und dein Passwort, ohne den Registrierungsablauf zu ändern.",
twoFactorTitle: "Authenticator-App-2FA",
twoFactorEnabledHint: "2FA ist für dieses Konto aktuell aktiviert.",
twoFactorEnabledAppHint: "Nutze Google Authenticator, Microsoft Authenticator, Authy oder eine andere TOTP-App, um deine 6-stelligen Login-Codes zu generieren.",
disableTwoFactorButton: "2FA deaktivieren",
disableTwoFactorLoading: "2FA wird deaktiviert...",
finishSetupTitle: "2FA-Einrichtung abschließen",
finishSetupHint: "Scanne diesen QR-Code mit deiner Authenticator-App und gib danach den aktuellen 6-stelligen Code zur Bestätigung ein.",
scanFallbackHint: "Wenn das Scannen nicht funktioniert, kannst du den manuellen Einrichtungsschlüssel unten verwenden.",
manualSetupKey: "Manueller Einrichtungsschlüssel",
setupUriLabel: "Einrichtungs-URI",
setupUriHint: "Diese URI wird für den QR-Setup verwendet und kann als Fallback für kompatible Apps kopiert werden.",
authenticatorCodeLabel: "Authenticator-Code",
confirmAndEnableButton: "Bestätigen und aktivieren",
confirmAndEnableLoading: "2FA wird aktiviert...",
cancelSetupTitle: "Einrichtung abbrechen",
cancelSetupHint: "Brich die aktuelle Authenticator-Einrichtung ab, wenn du mit einem neuen Schlüssel neu starten möchtest.",
cancelSetupButton: "Einrichtung abbrechen",
startSetupHint: "Schütze dein Konto mit 6-stelligen Codes aus einer Authenticator-App.",
startSetupHintFollowup: "Nach dem Start der Einrichtung erhältst du einen QR-Code zum Scannen und bestätigst dann einmal mit einem aktuellen Code.",
startSetupButton: "Einrichtung starten",
startSetupLoading: "Einrichtung wird gestartet...",
changePasswordTitle: "Passwort ändern",
currentPasswordLabel: "Aktuelles Passwort",
newPasswordLabel: "Neues Passwort",
confirmPasswordLabel: "Neues Passwort bestätigen",
updatePasswordButton: "Passwort aktualisieren",
updatePasswordLoading: "Passwort wird aktualisiert...",
qrLoadError: "QR-Vorschau konnte nicht geladen werden.",
footerText: "Schulprojekt rund um das Acht-Damen- und Acht-Türme-Problem.",
footerPrivacy: "Datenschutzerklärung",
footerImprint: "Impressum",
footerNavAria: "Rechtliche Hinweise"
},
en: {
pageTitle: "Login Settings",
navBrand: "8 Queens and 8 Rooks Solver",
navLogin: "Login",
navRegister: "Create account",
navDashboard: "Dashboard",
navSettings: "Login settings",
navLogout: "Logout",
backToDashboard: "Back to dashboard",
backToDashboardAria: "Back to dashboard",
languageSwitchAria: "Switch language",
settingsTitle: "Login settings",
settingsSubtitle: "Manage authenticator app 2FA and your password without changing the registration flow.",
twoFactorTitle: "Authenticator app 2FA",
twoFactorEnabledHint: "2FA is currently enabled for this account.",
twoFactorEnabledAppHint: "Use Google Authenticator, Microsoft Authenticator, Authy, or another TOTP app to generate your 6-digit login codes.",
disableTwoFactorButton: "Disable 2FA",
disableTwoFactorLoading: "Disabling 2FA...",
finishSetupTitle: "Finish 2FA setup",
finishSetupHint: "Scan this QR code with your authenticator app, then enter the current 6-digit code to confirm.",
scanFallbackHint: "If scanning does not work, you can still use the manual setup key below.",
manualSetupKey: "Manual setup key",
setupUriLabel: "Setup URI",
setupUriHint: "This URI is used to generate the QR setup and can be copied as a fallback for compatible apps.",
authenticatorCodeLabel: "Authenticator code",
confirmAndEnableButton: "Confirm and enable",
confirmAndEnableLoading: "Enabling 2FA...",
cancelSetupTitle: "Cancel setup",
cancelSetupHint: "Cancel the current authenticator setup if you want to restart with a new secret.",
cancelSetupButton: "Cancel setup",
startSetupHint: "Protect your account with 6-digit codes from an authenticator app.",
startSetupHintFollowup: "After starting setup, you will get a QR code to scan and then confirm once with a live code.",
startSetupButton: "Start setup",
startSetupLoading: "Starting setup...",
changePasswordTitle: "Change password",
currentPasswordLabel: "Current password",
newPasswordLabel: "New password",
confirmPasswordLabel: "Confirm new password",
updatePasswordButton: "Update password",
updatePasswordLoading: "Updating password...",
qrLoadError: "QR preview could not be loaded.",
footerText: "School project about the Eight Queens and Eight Rooks problem.",
footerPrivacy: "Privacy Policy",
footerImprint: "Imprint",
footerNavAria: "Legal information"
}
};
button.style.transform = `translate(${x * 0.3}px, ${y * 0.3}px)`
})
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: {}
};
button.addEventListener("mouseleave", () => {
button.style.transform = "translate(0,0)"
})
let currentLanguage = AppLanguage.DEFAULT_LANGUAGE;
function t(key) {
return translations[currentLanguage]?.[key] ?? translations.en[key] ?? key;
}
function translateServerMessage(message) {
const originalMessage = String(message || "").trim();
if (!originalMessage || currentLanguage === "en") {
return originalMessage;
}
const exactMatch = serverMessageTranslations[currentLanguage]?.[originalMessage];
if (exactMatch) {
return exactMatch;
}
const minLengthMatch = originalMessage.match(/^Password must be at least (\d+) characters long\.$/);
if (minLengthMatch) {
return `Das Passwort muss mindestens ${minLengthMatch[1]} Zeichen lang sein.`;
}
const maxLengthMatch = originalMessage.match(/^Password must be at most (\d+) characters long\.$/);
if (maxLengthMatch) {
return `Das Passwort darf höchstens ${maxLengthMatch[1]} Zeichen lang sein.`;
}
return originalMessage;
}
function applyTextTranslations() {
AppLanguage.applyTranslations({
translate: (key) => t(key),
attributeMappings: [
{
selector: "[data-i18n-aria-label]",
datasetKey: "i18nAriaLabel",
attribute: "aria-label"
},
{
selector: "[data-i18n-loading-label]",
datasetKey: "i18nLoadingLabel",
attribute: "data-loading-label"
}
]
});
document.querySelectorAll("[data-i18n-loading-label]").forEach((element) => {
if (element.dataset.loadingLabelApplied === "true") {
element.textContent = element.dataset.loadingLabel;
}
});
document.querySelectorAll(".message").forEach((element) => {
if (!element.dataset.originalText) {
element.dataset.originalText = element.textContent.trim();
}
element.textContent = translateServerMessage(element.dataset.originalText);
});
}
const languageController = AppLanguage.createController({
button: langSwitch,
onApply(language, helpers) {
currentLanguage = language;
applyTextTranslations();
helpers.updateLanguageSwitch({
ariaLabel: t("languageSwitchAria"),
title: currentLanguage === "de" ? "Switch to English" : "Zu Deutsch wechseln"
});
}
});
languageController.init();
// -----------------------------------------------------------------------------------------------
const magneticButtons = document.querySelectorAll(".magnetic");
// magnetic button effect for desktop user
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)";
});
});
document.querySelectorAll("input").forEach((input) => {
input.addEventListener("input", () => {
input.style.boxShadow = "0 0 8px rgba(59, 130, 246, 0.4)";
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
});
});
+362 -32
View File
@@ -1,51 +1,381 @@
<!DOCTYPE html>
<html lang="en">
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard</title>
<title data-i18n="pageTitle">Dashboard</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/dashboard.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/footer.css') }}">
</head>
<body>
<nav class="dashboard-nav">
<a class="brand" href="{{ url_for('main.home') }}">
<span class="brand-mark">♛</span>
<span data-i18n="navBrand">8-Damen- und 8-Türme Solver</span>
</a>
<nav>
<a href="{{ url_for('main.dashboard') }}">Dashboard</a>
<a href="{{ url_for('login.logout') }}">Logout</a>
</nav>
<div class="nav-actions">
<a class="nav-link" href="{{ url_for('main.dashboard') }}" id="navDashboardLink"
data-i18n="navDashboard">Dashboard</a>
<div class="container">
<button class="nav-link-button lang-switch" id="langSwitch" type="button"
aria-label="Sprache wechseln">DE</button>
```
<h1>Dashboard</h1>
<a class="account-shortcut" href="{{ url_for('login.login_settings') }}"
data-i18n-aria-label="accountShortcutAria" aria-label="Konto und Login-Einstellungen öffnen">
<span class="account-avatar" aria-hidden="true">{{ current_user.username[:1]|upper }}</span>
<span class="account-copy">
<span class="account-label" id="accountLabel" data-i18n="accountLabel">Account</span>
<span class="account-name">{{ current_user.username }}</span>
</span>
</a>
<p>Welcome back <strong>{{ current_user.username }}</strong> 👋</p>
<div class="user-info">
<h3>Your Account</h3>
<p><strong>User ID:</strong> {{ current_user.id }}</p>
<p><strong>Username:</strong> {{ current_user.username }}</p>
{% if current_user.email %}
<p><strong>Email:</strong> {{ current_user.email }}</p>
{% else %}
<p><strong>Email:</strong> Not set</p>
{% endif %}
<p><strong>Account created:</strong> {{ current_user.created_at }}</p>
</div>
<div class="logout">
<form action="{{ url_for('login.logout') }}" method="post">
<button type="submit">Logout</button>
<form action="{{ url_for('login.logout') }}" method="post" class="nav-logout-form">
<button class="nav-link-button" type="submit" data-i18n="navLogout">Logout</button>
</form>
</div>
```
</nav>
<button class="settings-trigger" id="settingsTrigger" onclick="toggleSidebar()" type="button"
aria-controls="settingsSidebar" aria-expanded="false">
<span class="settings-trigger-arrow" id="settingsTriggerArrow">→</span>
<span class="settings-trigger-label" id="settingsTriggerLabel">Spiel-Einstellungen</span>
</button>
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-stack">
{% for category, message in messages %}
<p class="flash-message {{ category }}">{{ message }}</p>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<section class="dashboard-intro card">
<p class="section-kicker" id="introKicker" data-i18n="introKicker">Puzzle-Modus</p>
<h1 id="introTitle" data-i18n="introTitle">Wähl deinen Modus und löse das Brett auf deine Art.</h1>
<p class="dashboard-intro-text" id="introText" data-i18n="introText">
Wechsle zwischen Damen und Türmen, nutze Animation oder Sofort-Lösung und öffne die
Brett-Einstellungen jederzeit über den linken Seiten-Tab.
</p>
<div class="mode-switch" data-i18n-aria-label="modeSwitchAria" aria-label="Spielmodus">
<button class="btn secondary" onclick="setMode('rooks')" id="btnRooks" type="button">
<span aria-hidden="true">♜</span>
<span data-i18n="btnRooks">Türme</span>
</button>
<button class="btn active" onclick="setMode('queens')" id="btnQueens" type="button">
<span aria-hidden="true">♛</span>
<span data-i18n="btnQueens">Damen</span>
</button>
</div>
</section>
<div class="card game-card">
<div class="game-card-header">
<div class="game-card-copy">
<p class="section-kicker" id="gameModeEyebrow">Damen-Modus</p>
<h2 id="gameTitle">Damen-Spielbrett</h2>
<p class="board-mode-text" id="boardModeText">
Platziere Damen frei auf dem Brett. Konflikte entstehen in derselben Reihe, Spalte oder
Diagonale.
</p>
</div>
<button class="info-button" id="infoButton" onclick="toggleInfoModal(true)" type="button"
data-i18n="infoButton" data-i18n-aria-label="infoButton"
aria-label="Spielregeln und Hilfe anzeigen">Spielanleitung</button>
</div>
<p class="board-size-info" id="boardSizeInfo"><span id="boardSizeInfoPrefix">Aktuelle Brettgrösse im
Damen-Modus:</span>
<span id="boardSizeValue">8</span> x <span id="boardSizeValueMirror">8</span>
</p>
<div class="game-stats" data-i18n-aria-label="gameStatsAria" aria-label="Spielstatus">
<article class="game-stat-card">
<p class="game-stat-label" id="stepsLabel" data-i18n="stepsLabel">Schritte</p>
<p class="game-stat-value" id="stepCounterValue">0</p>
</article>
<article class="game-stat-card">
<p class="game-stat-label" id="timeLabel" data-i18n="timeLabel">Zeit</p>
<p class="game-stat-value" id="timerValue">00:00:00</p>
</article>
<article class="game-stat-card">
<p class="game-stat-label" id="statusLabel" data-i18n="statusLabel">Status</p>
<p class="game-stat-value game-stat-status" id="gameProgressState">Läuft</p>
</article>
</div>
<p id="statusMessage"></p>
<div class="solved-celebration" id="solvedCelebration" aria-live="polite" hidden>
<p class="solved-celebration-title" id="solvedCelebrationTitle">Richtig gelöst!</p>
<p class="solved-celebration-text" id="solvedCelebrationText">Stark gemacht. Das Brett ist konfliktfrei.
</p>
</div>
<div class="board-stage">
<div class="board-shell">
<div id="board"></div>
</div>
<div class="board-navigator" id="boardNavigator" hidden>
<button class="btn secondary board-nav-button" onclick="previousSolverStep()" id="btnPrevStep"
type="button" data-i18n="btnPrevStep">
Zurück
</button>
<p class="solver-step-info" id="solverStepInfo">Schritt 0 / 0</p>
<button class="btn secondary board-nav-button" onclick="nextSolverStep()" id="btnNextStep"
type="button" data-i18n="btnNextStep">
Vorwärts
</button>
</div>
<p class="solver-message solver-message-inline" id="solverMessage">Lade eine Animation und steuere sie
am Brett oder spiele sie automatisch ab.</p>
</div>
<div class="controls">
<button class="btn secondary" onclick="resetBoard()" id="btnReset" type="button"
data-i18n="btnReset">Brett
zurücksetzen</button>
</div>
</div>
<div class="card piece-card">
<div class="section-header">
<h3 id="pieceListTitle">Positionen der Damen</h3>
<span class="section-badge" id="pieceCountBadge">0 gesetzt</span>
</div>
<ul id="queenList"></ul>
</div>
<div class="card">
<div class="history-header">
<div>
<p class="section-kicker" id="historyKicker" data-i18n="historyKicker">Save-Points</p>
<h3 id="historyTitle" data-i18n="historyTitle">Gespeicherte Zeiten und Fortschritte</h3>
<p class="history-subtitle" id="historySubtitle" data-i18n="historySubtitle">Die Tabelle zeigt
gespeicherte Save-Points mit
Zeit, Schritten und
Status.</p>
</div>
<button class="btn secondary" onclick="refreshTimeHistory()" id="btnRefreshHistory" type="button"
data-i18n="btnRefreshHistory">Aktualisieren</button>
</div>
<div class="history-table-wrapper">
<table class="history-table">
<thead>
<tr>
<th id="historyHeadSave" data-i18n="historyHeadSave">Save-Point</th>
<th id="historyHeadMode" data-i18n="historyHeadMode">Modus</th>
<th id="historyHeadBoard" data-i18n="historyHeadBoard">Brett</th>
<th id="historyHeadSteps" data-i18n="historyHeadSteps">Schritte</th>
<th id="historyHeadTime" data-i18n="historyHeadTime">Zeit</th>
<th id="historyHeadStatus" data-i18n="historyHeadStatus">Status</th>
<th id="historyHeadSaved" data-i18n="historyHeadSaved">Gespeichert</th>
</tr>
</thead>
<tbody id="timeHistoryTableBody">
<tr>
<td colspan="7" class="history-empty" data-i18n="historyEmpty">Noch keine gespeicherten
Zeiten vorhanden.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
{% include "partials/footer.html" %}
<aside class="sidebar settings-sidebar" id="settingsSidebar" aria-hidden="true">
<div class="sidebar-header">
<h3 id="settingsSidebarTitle" data-i18n="settingsSidebarTitle">Spiel-Einstellungen</h3>
<button class="btn secondary sidebar-close" onclick="toggleSidebar(false)" id="settingsSidebarClose"
type="button" data-i18n="settingsSidebarClose">Schliessen</button>
</div>
<section class="settings-section">
<p class="section-kicker" id="boardSettingsKicker" data-i18n="boardSettingsKicker">Brett</p>
<h4 id="boardSettingsTitle" data-i18n="boardSettingsTitle">Brettgrösse</h4>
<p id="settingsSidebarText" data-i18n="settingsSidebarText">Passe die Brettgrösse zwischen 4 und 13 an.</p>
<label class="sidebar-label" id="boardSizeInputLabel" for="boardSizeInput"
data-i18n="boardSizeInputLabel">Grösse</label>
<input id="boardSizeInput" type="range" min="4" max="13" value="8" oninput="previewBoardSize(this.value)">
<div class="sidebar-size-row">
<strong><span id="sidebarBoardSizeValue">8</span> x <span
id="sidebarBoardSizeValueMirror">8</span></strong>
</div>
<button class="btn" onclick="applyBoardSize()" id="applyBoardSizeButton" type="button"
data-i18n="applyBoardSizeButton">Grösse anwenden</button>
<p class="sidebar-hint" id="settingsSidebarHint" data-i18n="settingsSidebarHint">Beim Ändern wird das Brett
zur Sicherheit zurückgesetzt. Auf
dem Handy ist 8x8 eingeplant, grössere Bretter können abgeschnitten wirken.</p>
</section>
<section class="settings-section">
<p class="section-kicker" id="saveSettingsKicker" data-i18n="saveSettingsKicker">Speichern</p>
<h4 id="saveSettingsTitle" data-i18n="saveSettingsTitle">Fortschritt speichern</h4>
<p id="saveSettingsText" data-i18n="saveSettingsText">Öffne hier die Optionen zum Speichern oder Laden
deiner Save-Points.</p>
<div class="settings-action-grid">
<button class="btn" onclick="openSavePanel()" id="btnSave" type="button"
data-i18n="btnSave">Speichern</button>
<button class="btn secondary" onclick="openSavePointsPanel()" id="btnLoad" type="button"
data-i18n="btnLoad">Save-Points</button>
</div>
</section>
<section class="settings-section">
<p class="section-kicker" id="solutionSettingsKicker" data-i18n="solutionSettingsKicker">Lösung</p>
<h4 id="solutionSettingsTitle" data-i18n="solutionSettingsTitle">Lösungswerkzeuge</h4>
<p id="solutionSettingsText" data-i18n="solutionSettingsText">Lade eine Animation, steuere sie am Brett mit
Vorwärts und Zurück oder spiele sie automatisch ab.</p>
<div class="settings-action-grid">
<button class="btn" onclick="prepareSolutionTrace()" id="btnPrepareSolve" type="button"
data-i18n="btnPrepareSolve">Animation laden</button>
<button class="btn secondary" onclick="toggleSolverPlayback()" id="btnPlayPause" type="button"
data-i18n="btnPlayPause">Animation abspielen</button>
<button class="btn accent" onclick="instantSolve()" id="btnInstantSolve" type="button"
data-i18n="btnInstantSolve">Sofort lösen</button>
</div>
<div class="solver-speed">
<label class="solver-speed-label" id="solverSpeedLabel" for="solverSpeedInput"
data-i18n="solverSpeedLabel">Animationsgeschwindigkeit</label>
<input id="solverSpeedInput" type="range" min="80" max="900" step="20" value="320"
oninput="updateSolverSpeed(this.value)">
<span class="solver-speed-value" id="solverSpeedValue">320 ms</span>
</div>
</section>
</aside>
<aside class="sidebar save-sidebar" id="saveSidebar" aria-hidden="true">
<div class="sidebar-header">
<h3 id="saveSidebarTitle" data-i18n="saveSidebarTitle">Speicherpunkt</h3>
<button class="btn secondary sidebar-close" onclick="toggleSavePanel(false)" id="saveSidebarClose"
type="button" data-i18n="saveSidebarClose">Schliessen</button>
</div>
<p id="saveSidebarText" data-i18n="saveSidebarText">Gib dem Spielstand einen klaren Namen und optional eine
kurze Notiz.</p>
<label class="sidebar-label" id="saveNameLabel" for="saveNameInput" data-i18n="saveNameLabel">Name</label>
<input class="sidebar-input" id="saveNameInput" type="text" maxlength="80"
placeholder="z. B. Fast gelöste Dame 8x8" data-i18n-placeholder="saveNamePlaceholder">
<label class="sidebar-label" id="saveNoteLabel" for="saveNoteInput" data-i18n="saveNoteLabel">Notiz</label>
<textarea class="sidebar-input sidebar-textarea" id="saveNoteInput" rows="4" maxlength="240"
placeholder="z. B. Gute Startposition für Türmetest" data-i18n-placeholder="saveNotePlaceholder"></textarea>
<label class="sidebar-checkbox">
<input id="saveFavoriteInput" type="checkbox">
<span id="saveFavoriteLabel" data-i18n="saveFavoriteLabel">Als Favorit markieren</span>
</label>
<button class="btn" onclick="saveGame()" id="saveNowButton" type="button" data-i18n="saveNowButton">Jetzt
speichern</button>
</aside>
<aside class="sidebar save-points-sidebar" id="savePointsSidebar" aria-hidden="true">
<div class="sidebar-header">
<h3 id="savePointsSidebarTitle" data-i18n="savePointsSidebarTitle">Save-Points</h3>
<button class="btn secondary sidebar-close" onclick="toggleSavePointsPanel(false)"
id="savePointsSidebarClose" type="button" data-i18n="savePointsSidebarClose">Schliessen</button>
</div>
<p id="savePointsSidebarText" data-i18n="savePointsSidebarText">Wähle einen Speicherstand aus der Liste.</p>
<div class="save-point-filters">
<label class="sidebar-label" id="savePointModeFilterLabel" for="savePointModeFilter"
data-i18n="savePointModeFilterLabel">Modus</label>
<select class="sidebar-input" id="savePointModeFilter" onchange="refreshSavePoints()">
<option value="all" data-i18n="filterAll">Alle</option>
<option value="queens" data-i18n="filterQueens">Damen</option>
<option value="rooks" data-i18n="filterRooks">Türme</option>
</select>
<label class="sidebar-label" id="savePointSizeFilterLabel" for="savePointSizeFilter"
data-i18n="savePointSizeFilterLabel">Brettgrösse</label>
<select class="sidebar-input" id="savePointSizeFilter" onchange="refreshSavePoints()">
<option value="all" data-i18n="filterAll">Alle</option>
{% for board_size in range(4, 14) %}
<option value="{{ board_size }}">{{ board_size }} x {{ board_size }}</option>
{% endfor %}
</select>
<label class="sidebar-label" id="savePointSortFilterLabel" for="savePointSortFilter"
data-i18n="savePointSortFilterLabel">Sortierung</label>
<select class="sidebar-input" id="savePointSortFilter" onchange="refreshSavePoints()">
<option value="newest" data-i18n="filterNewest">Neueste zuerst</option>
<option value="oldest" data-i18n="filterOldest">älteste zuerst</option>
<option value="updated" data-i18n="filterUpdated">Zuletzt bearbeitet</option>
<option value="favorites" data-i18n="filterFavorites">Favoriten zuerst</option>
</select>
<label class="sidebar-checkbox">
<input id="savePointFavoritesOnly" type="checkbox" onchange="refreshSavePoints()">
<span id="savePointFavoritesOnlyLabel" data-i18n="savePointFavoritesOnlyLabel">Nur Favoriten
anzeigen</span>
</label>
</div>
<div class="save-points-list" id="savePointsList">
<p class="sidebar-hint" id="savePointsEmptyState" data-i18n="savePointsEmptyState">Noch keine Speicherstände
gefunden.</p>
</div>
</aside>
<div class="sidebar-backdrop" id="sidebarBackdrop" onclick="toggleSidebar(false)"></div>
<div class="sidebar-backdrop" id="saveBackdrop" onclick="toggleSavePanel(false)"></div>
<div class="sidebar-backdrop" id="savePointsBackdrop" onclick="toggleSavePointsPanel(false)"></div>
<div class="modal-backdrop" id="infoModalBackdrop" onclick="toggleInfoModal(false)"></div>
<section class="modal" id="infoModal" aria-hidden="true" aria-labelledby="infoModalTitle">
<div class="modal-header">
<h3 id="infoModalTitle">Spielanleitung</h3>
<button class="btn secondary sidebar-close" onclick="toggleInfoModal(false)" type="button"
data-i18n="infoModalClose">Schliessen</button>
</div>
<div class="modal-content">
<p data-i18n="infoModalIntro">Das Schachbrett besteht aus vielen quadratischen Feldern. Jedes Feld kann
leer sein oder genau eine Figur enthalten. Deine Aufgabe ist es, Figuren auf das Brett zu setzen,
ohne dass sie sich gegenseitig angreifen. Das Puzzle ist erst gelöst, wenn genau so viele Figuren auf
dem Brett stehen wie das Brett Felder pro Seite hat, also zum Beispiel 8 Figuren auf einem
8x8-Brett.</p>
<h4 data-i18n="infoModalModeHeading">Grundregeln</h4>
<p id="infoModeRuleText">In diesem Modus spielst du mit Damen. Eine Dame ist die Figur mit dem Symbol ♛.
Sie darf keine andere Dame in derselben waagerechten Reihe, senkrechten Spalte oder schrägen
Diagonale treffen. Mit einem Klick setzt du eine Dame auf ein freies Feld oder entfernst sie wieder.</p>
<h4 id="infoModalProblemHeading">Was ist eine Dame?</h4>
<p id="infoModalProblemText">Eine Dame ist eine Schachfigur. In diesem Spiel musst du keine Schachregeln
lernen, sondern nur wissen: Eine Dame kann gerade und schräg über das Brett wirken. Darum müssen
Damen genug Abstand haben, damit sie sich nicht gegenseitig erreichen.</p>
<h4 data-i18n="infoModalTimeHeading">Was zeigen Zeit und Schritte?</h4>
<p data-i18n="infoModalTimeText">Der Schrittzähler erfasst deine Züge auf dem Brett. Die Zeit läuft für das
aktuelle Spiel weiter,
auch wenn du die Seite neu lädst. Beim Speichern werden Zeit und Schritte zusammen mit dem
Save-Point abgelegt.</p>
</div>
</section>
<script src="{{ url_for('static', filename='js/language.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/dashboard.js') }}" defer></script>
</body>
</html>
</html>
+83
View File
@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title data-i18n="pageTitle">Impressum</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/legal.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/footer.css') }}">
</head>
<body data-legal-page="imprint">
<header class="legal-header">
<nav class="legal-nav">
<a class="brand" href="{{ url_for('main.home') }}">
<span class="brand-mark">♛</span>
<span data-i18n="navBrand">8-Damen- und 8-Türme Solver</span>
</a>
<div class="legal-nav-links">
<button class="lang-switch" id="langSwitch" type="button" aria-label="Sprache wechseln">DE</button>
<a href="{{ url_for('main.home') }}" data-i18n="navHome">Startseite</a>
{% if current_user.is_authenticated %}
<a href="{{ url_for('main.dashboard') }}" data-i18n="navDashboard">Dashboard</a>
{% else %}
<a href="{{ url_for('login.login') }}" data-i18n="navLogin">Login</a>
{% endif %}
</div>
</nav>
</header>
<main class="legal-main">
<section class="legal-hero">
<p class="legal-eyebrow" data-i18n="heroEyebrow">Rechtliche Hinweise</p>
<h1 data-i18n="heroTitle">Impressum</h1>
</section>
<section class="legal-card">
<h2 data-i18n="contactTitle">Kontakt</h2>
<p>
<strong data-i18n="contactName">Jérôme Bachmann</strong><br>
<span data-i18n="contactEmailLabel">E-Mail:</span> jerome.bachmann@bachmannj.ch<br>
</p>
</section>
<section class="legal-card">
<h2 data-i18n="projectResponsibilityTitle">Projektverantwortung</h2>
<p data-i18n="projectResponsibilityText">
Dieses Webprojekt wurde im Rahmen eines Schulprojekts zum Acht-Damen- und Acht-Türme-Problem
erstellt. Inhaltlich verantwortlich ist die jeweils oben genannte verantwortliche Stelle.
</p>
</section>
<section class="legal-card">
<h2 data-i18n="contentLiabilityTitle">Haftung für Inhalte</h2>
<p data-i18n="contentLiabilityText">
Die Inhalte dieser Website wurden mit Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und
Aktualität der Inhalte kann jedoch keine Gewähr übernommen werden. Bei Bekanntwerden von
Rechtsverletzungen werden entsprechende Inhalte umgehend überarbeitet oder entfernt.
</p>
</section>
<section class="legal-card">
<h2 data-i18n="linksLiabilityTitle">Haftung fuer Links</h2>
<p data-i18n="linksLiabilityText">
Diese Website kann Links zu externen Angeboten enthalten. Fuer deren Inhalte sind ausschliesslich die
jeweiligen Betreiber verantwortlich. Eine permanente inhaltliche Kontrolle verlinkter Seiten ist ohne
konkrete Anhaltspunkte einer Rechtsverletzung nicht zumutbar.
</p>
</section>
<section class="legal-card">
<h2 data-i18n="versionTitle">Stand</h2>
<p data-i18n="versionText">Stand dieser Vorlage: April 2026</p>
</section>
</main>
{% include "partials/footer.html" %}
<script src="{{ url_for('static', filename='js/language.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/legal.js') }}" defer></script>
</body>
</html>
+169 -48
View File
@@ -1,66 +1,187 @@
<!DOCTYPE html>
<html lang="en">
<html lang="{{ initial_language }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Solver App{% endblock %}</title>
<!-- CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
{% block head_extra %}{% endblock %}
<title data-i18n="meta.title">8-Damen- und 8-Türme Solver</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/footer.css') }}">
</head>
<body>
<header class="landing-nav">
<a class="brand" href="{{ url_for('main.home') }}">
<span class="brand-mark">♛</span>
<span data-i18n="nav.brand">8-Damen- und 8-Türme Solver</span>
</a>
<!-- Navigation -->
<header class="navbar">
<div class="container">
<h1 class="logo">SolverApp</h1>
<nav>
<a href="/">Home</a>
<a href="/solver">Solver</a>
{% if session.get('user_id') %}
<a href="/dashboard">Dashboard</a>
<a href="/logout">Logout</a>
{% else %}
<a href="{{ url_for('login.login') }}">Login</a>
{% endif %}
</nav>
</div>
<nav class="landing-links">
<a href="#problem" data-i18n="nav.problem">Problem</a>
<a href="#features" data-i18n="nav.features">Funktionen</a>
<a href="#journey" data-i18n="nav.journey">Ablauf</a>
<button class="lang-switch" id="langSwitch" type="button" aria-label="Sprache wechseln">DE</button>
<a class="nav-cta secondary" href="{{ url_for('login.login') }}" data-i18n="nav.login">Anmelden</a>
<a class="nav-cta" href="{{ url_for('login.register') }}" data-i18n="nav.signup">Account erstellen</a>
</nav>
</header>
<!-- Flash messages -->
<main class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-container">
{% for category, message in messages %}
<div class="flash {{ category }}">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<main>
<section class="hero">
<div class="hero-copy">
<p class="eyebrow" data-i18n="hero.eyebrow">Interaktives Lernen mit Strategie, Logik und Backtracking
</p>
<h1 data-i18n="hero.title">Entdecke das Acht-Damen- und Acht-Türme-Problem.</h1>
<p class="hero-text" data-i18n="hero.text">
Diese Webanwendung macht aus einem klassischen Informatik- und Mathematikproblem ein interaktives
Erlebnis:
Setze Damen oder Türme, verfolge den Solver Schritt für Schritt, miss deine Zeit und speichere
Zwischenstände.
</p>
<!-- Page Content -->
{% block content %}{% endblock %}
<div class="hero-actions">
<a class="hero-button primary" href="{{ url_for('login.register') }}"
data-i18n="hero.primaryCta">Jetzt starten</a>
<a class="hero-button secondary" href="{{ url_for('login.login') }}"
data-i18n="hero.secondaryCta">Bereits ein Account? Anmelden</a>
</div>
</div>
<div class="hero-visual">
<div class="board-showcase" id="heroBoard" aria-hidden="true"></div>
<div class="floating-card solver-card">
<p class="floating-label" data-i18n="hero.solverLabel">Lösungshelfer</p>
<p class="floating-value" data-i18n="hero.solverValue">Backtracking in Echtzeit</p>
</div>
<div class="floating-card timer-card">
<p class="floating-label" data-i18n="hero.challengeLabel">Herausforderung</p>
<p class="floating-value" data-i18n="hero.challengeValue">Zeit und Schritte messen</p>
</div>
</div>
</section>
<section class="info-band" id="problem">
<div class="info-band-inner">
<p class="eyebrow" data-i18n="problem.eyebrow">Was ist das Acht-Damen-Problem?</p>
<h2 data-i18n="problem.title">Eine klassische schachmathematische Aufgabe mit überraschend vielen
Lösungen.</h2>
<p data-i18n="problem.text">
Das Acht-Damen-Problem ist eine schachmathematische Aufgabe: Acht Damen sollen auf einem
Schachbrett so aufgestellt werden, dass keine zwei Damen dieselbe Reihe, Spalte oder Diagonale
teilen. Das Problem lässt sich auf beliebige Brettgrössen verallgemeinern, indem n Damen auf einem
n x n-Brett platziert werden.
<br><br>
Für das klassische 8x8-Brett gibt es 92 Lösungen. Zählt man Stellungen, die nur durch Spiegelung
oder Drehung entstehen, als gleich, bleiben 12 eindeutige Lösungen übrig.
<br><br>
<a href="https://de-academic.com/dic.nsf/dewiki/13248" target="_blank"
rel="noopener noreferrer">Quelle:
de-academic.com</a>
</p>
</div>
</section>
<section class="feature-section" id="features">
<div class="section-heading">
<p class="eyebrow" data-i18n="features.eyebrow">Was die Applikation bietet</p>
<h2 data-i18n="features.title">Von der Idee bis zur gelösten Stellung.</h2>
</div>
<div class="feature-grid">
<article class="feature-card">
<span class="feature-icon">♟</span>
<h3 data-i18n="features.card1Title">Interaktives Brett</h3>
<p data-i18n="features.card1Text">Setze Damen oder Türme direkt auf dem Brett und prüfe, ob deine
Anordnung konfliktfrei ist.</p>
</article>
<article class="feature-card">
<span class="feature-icon">⚙</span>
<h3 data-i18n="features.card2Title">Solver mit Animation</h3>
<p data-i18n="features.card2Text">Beobachte den Lösungsweg Schritt für Schritt und verstehe, wie
Backtracking arbeitet.
</p>
</article>
<article class="feature-card">
<span class="feature-icon">⏱</span>
<h3 data-i18n="features.card3Title">Zeit und Schritte</h3>
<p data-i18n="features.card3Text">Miss deinen Fortschritt während des Spiels und vergleiche
unterschiedliche Versuche.</p>
</article>
<article class="feature-card">
<span class="feature-icon">💾</span>
<h3 data-i18n="features.card4Title">Speicherstände</h3>
<p data-i18n="features.card4Text">Speichere Zwischenstände mit Notizen, Zeiten und Schrittzahl für
späteres Weiterarbeiten.</p>
</article>
<article class="feature-card">
<span class="feature-icon">▦</span>
<h3 data-i18n="features.card5Title">Skalierbare Bretter</h3>
<p data-i18n="features.card5Text">Passe die Brettgrösse an und erkunde die Probleme nicht nur auf
dem klassischen 8x8-Brett.</p>
</article>
</div>
</section>
<section class="journey-section" id="journey">
<div class="section-heading">
<p class="eyebrow" data-i18n="journey.eyebrow">So funktioniert dein Einstieg</p>
<h2 data-i18n="journey.title">In wenigen Schritten vom ersten Besuch zur eigenen Lösung.</h2>
</div>
<div class="journey-grid">
<article class="journey-step">
<span class="step-number">01</span>
<h3 data-i18n="journey.step1Title">Account erstellen</h3>
<p data-i18n="journey.step1Text">Registriere dich und erhalte Zugang zum Dashboard, zu
Speicherständen und zu deinem Fortschritt.</p>
</article>
<article class="journey-step">
<span class="step-number">02</span>
<h3 data-i18n="journey.step2Title">Spielmodus wählen</h3>
<p data-i18n="journey.step2Text">Arbeite mit Damen oder Türmen und passe die Brettgrösse deinem Ziel
an.</p>
</article>
<article class="journey-step">
<span class="step-number">03</span>
<h3 data-i18n="journey.step3Title">Lösen oder analysieren</h3>
<p data-i18n="journey.step3Text">Teste eigene Ideen oder lass dir die Lösung visuell vom Solver
zeigen, damit du den Lösungsweg besser nachvollziehen kannst.</p>
</article>
</div>
</section>
<section class="cta-section">
<div class="cta-panel">
<p class="eyebrow" data-i18n="cta.eyebrow">Bereit für den ersten Versuch?</p>
<h2 data-i18n="cta.title">Mach aus einem bekannten Problem ein sichtbares Lernerlebnis.</h2>
<p data-i18n="cta.text">
Starte mit einem Account, speichere deine Zwischenstände und finde heraus, wie schnell du eine
konfliktfreie Lösung erreichst.
</p>
<div class="hero-actions">
<a class="hero-button primary" href="{{ url_for('login.register') }}"
data-i18n="cta.primaryCta">Account erstellen</a>
<a class="hero-button secondary" href="{{ url_for('login.login') }}"
data-i18n="cta.secondaryCta">Anmelden</a>
</div>
</div>
</section>
</main>
<!-- Footer -->
<footer class="footer">
<div class="container">
<p>© {{ 2026 }} SolverApp</p>
</div>
</footer>
{% include "partials/footer.html" %}
<!-- JS -->
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% block scripts %}{% endblock %}
<script>
window.LANDING_INITIAL_LANGUAGE = "{{ initial_language }}";
</script>
<script src="{{ url_for('static', filename='js/language.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/index.js') }}" defer></script>
</body>
</html>
+43 -21
View File
@@ -5,35 +5,57 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login</title>
<link rel="stylesheet" href="../static/css/login.css">
<link rel="stylesheet" href="../static/css/login.css">
</link>
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/footer.css') }}">
</head>
<body>
<nav>
<ul>
<li><a href="{{ url_for('login.login') }}"><span>Login</span></a></li>
<li><a href="{{ url_for('login.register') }}"><span></span>Create account</span></a></li>
<nav class="auth-nav">
<a class="brand" href="{{ url_for('main.home') }}">
<span class="brand-mark">♛</span>
<span class="nav-brand" data-i18n="navBrand">8-Damen- und 8-Türme Solver</span>
</a>
<ul class="nav-links">
<li><a href="{{ url_for('login.login') }}" data-i18n="navLogin">Login</a></li>
<li><a href="{{ url_for('login.register') }}" data-i18n="navRegister">Create account</a></li>
</ul>
</nav>
<main>
<h1>Login to your account</h1>
<main class="auth-shell">
<section class="auth-panel">
<h1>Login to your account</h1>
<p class="muted">If two-factor authentication is enabled, enter the current 6-digit code from your
authenticator app after the password check.</p>
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<p class="message {{ category }}">{{ message }}</p>
{% endfor %}
{% endif %}
{% endwith %}
<form action="{{ url_for('login.login') }}" method="post">
<label for="username">Username:</label>
<input type="text" name="username" required />
<label for="password">Password:</label>
<input type="password" name="password" required />
<button class="magnetic" type="submit">Login</button>
</form>
{% if error %}
<p class="message error">{{ error }}</p>
{% endif %}
<form action="{{ url_for('login.login') }}" method="post">
<label for="username">Username</label>
<input id="username" type="text" name="username" maxlength="20" required value="{{ username or '' }}">
<label for="password">Password</label>
<input id="password" type="password" name="password" minlength="8" maxlength="128" required>
<button class="magnetic" type="submit" data-loading-label="Checking login...">Login</button>
</form>
</section>
</main>
<script src="../static/js/login.js" defer></script>
{% include "partials/footer.html" %}
<script src="{{ url_for('static', filename='js/language.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
</body>
</html>
</html>
+162
View File
@@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title data-i18n="pageTitle">Login Settings</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/footer.css') }}">
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" defer></script>
</head>
<body>
<nav class="auth-nav">
<a class="brand" href="{{ url_for('main.home') }}">
<span class="brand-mark">♛</span>
<span data-i18n="navBrand">8-Damen- und 8-Türme Solver</span>
</a>
<ul class="nav-links">
<li><a href="{{ url_for('main.dashboard') }}" data-i18n="navDashboard">Dashboard</a></li>
<li><a href="{{ url_for('login.login_settings') }}" data-i18n="navSettings">Login settings</a></li>
</ul>
<form action="{{ url_for('login.logout') }}" method="post" class="nav-form">
<button type="submit" class="nav-button" id="logoutButton" data-i18n="navLogout">Logout</button>
</form>
</nav>
<main class="auth-shell settings-shell">
<section class="auth-panel settings-panel">
<div class="settings-topbar">
<a class="back-link" href="{{ url_for('main.dashboard') }}" data-i18n-aria-label="backToDashboardAria"
aria-label="Back to dashboard">
<span class="back-link-arrow" aria-hidden="true">&larr;</span>
<span data-i18n="backToDashboard">Back to dashboard</span>
</a>
<button class="nav-button lang-switch" id="langSwitch" type="button" aria-label="Switch language">DE</button>
</div>
<h1 data-i18n="settingsTitle">Login settings</h1>
<p class="muted" data-i18n="settingsSubtitle">Manage authenticator app 2FA and your password without
changing the registration flow.</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<p class="message {{ category }}">{{ message }}</p>
{% endfor %}
{% endif %}
{% endwith %}
{% if error %}
<p class="message error">{{ error }}</p>
{% endif %}
<div class="settings-grid">
{% if current_user.two_factor_enabled %}
<form action="{{ url_for('login.login_settings') }}" method="post">
<input type="hidden" name="action" value="disable_two_factor">
<h2 data-i18n="twoFactorTitle">Authenticator app 2FA</h2>
<p class="field-hint" data-i18n="twoFactorEnabledHint">2FA is currently enabled for this account.</p>
<p class="field-hint" data-i18n="twoFactorEnabledAppHint">Use Google Authenticator, Microsoft
Authenticator, Authy, or another TOTP app
to generate your 6-digit login codes.</p>
<button class="secondary-button" type="submit" data-loading-label="Disabling 2FA..."
data-i18n="disableTwoFactorButton" data-i18n-loading-label="disableTwoFactorLoading">Disable
2FA</button>
</form>
{% elif pending_two_factor_secret %}
<form action="{{ url_for('login.login_settings') }}" method="post">
<input type="hidden" name="action" value="confirm_two_factor_setup">
<h2 data-i18n="finishSetupTitle">Finish 2FA setup</h2>
<p class="field-hint" data-i18n="finishSetupHint">Scan this QR code with your authenticator app,
then enter the current 6-digit
code to confirm.</p>
<div class="qr-panel">
<div class="qr-code" data-qr-value="{{ pending_two_factor_uri }}"></div>
</div>
<p class="field-hint" data-i18n="scanFallbackHint">If scanning does not work, you can still use the
manual setup key below.</p>
<label for="two_factor_secret" data-i18n="manualSetupKey">Manual setup key</label>
<input id="two_factor_secret" type="text" value="{{ pending_two_factor_secret }}" readonly>
<label for="two_factor_uri" data-i18n="setupUriLabel">Setup URI</label>
<input id="two_factor_uri" type="text" value="{{ pending_two_factor_uri }}" readonly>
<p class="field-hint" data-i18n="setupUriHint">This URI is used to generate the QR setup and can be
copied as a fallback for
compatible apps.</p>
<label for="code" data-i18n="authenticatorCodeLabel">Authenticator code</label>
<input id="code" type="text" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6"
required>
<div class="button-row">
<button class="magnetic" type="submit" data-loading-label="Enabling 2FA..."
data-i18n="confirmAndEnableButton" data-i18n-loading-label="confirmAndEnableLoading">Confirm and
enable</button>
</div>
</form>
<form action="{{ url_for('login.login_settings') }}" method="post">
<input type="hidden" name="action" value="cancel_two_factor_setup">
<h2 data-i18n="cancelSetupTitle">Cancel setup</h2>
<p class="field-hint" data-i18n="cancelSetupHint">Cancel the current authenticator setup if you want
to restart with a new
secret.</p>
<button class="secondary-button" type="submit" data-i18n="cancelSetupButton">Cancel setup</button>
</form>
{% else %}
<form action="{{ url_for('login.login_settings') }}" method="post">
<input type="hidden" name="action" value="start_two_factor_setup">
<h2 data-i18n="twoFactorTitle">Authenticator app 2FA</h2>
<p class="field-hint" data-i18n="startSetupHint">Protect your account with 6-digit codes from an
authenticator app.</p>
<p class="field-hint" data-i18n="startSetupHintFollowup">After starting setup, you will get a QR code to scan and then confirm once
with a live code.</p>
<button class="magnetic" type="submit" data-loading-label="Starting setup..."
data-i18n="startSetupButton" data-i18n-loading-label="startSetupLoading">Start setup</button>
</form>
{% endif %}
<form action="{{ url_for('login.login_settings') }}" method="post">
<input type="hidden" name="action" value="password">
<h2 data-i18n="changePasswordTitle">Change password</h2>
<label for="current_password" data-i18n="currentPasswordLabel">Current password</label>
<input id="current_password" type="password" name="current_password" minlength="8" maxlength="128"
required>
<label for="new_password" data-i18n="newPasswordLabel">New password</label>
<input id="new_password" type="password" name="new_password" minlength="8" maxlength="128" required>
<label for="confirm_password" data-i18n="confirmPasswordLabel">Confirm new password</label>
<input id="confirm_password" type="password" name="confirm_password" minlength="8" maxlength="128"
required>
<button class="magnetic" type="submit" data-loading-label="Updating password..."
data-i18n="updatePasswordButton" data-i18n-loading-label="updatePasswordLoading">Update
password</button>
</form>
</div>
</section>
</main>
{% include "partials/footer.html" %}
<script src="{{ url_for('static', filename='js/language.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
<footer class="page-footer">
<div class="page-footer-inner">
<div class="page-footer-copy">
<p class="page-footer-text" data-i18n="footerText">Schulprojekt rund um das Acht-Damen- und Acht-Türme-Problem.</p>
</div>
<nav class="page-footer-links" data-i18n-aria-label="footerNavAria" aria-label="Rechtliche Hinweise">
<a href="{{ url_for('main.privacy_policy') }}" data-i18n="footerPrivacy">Datenschutzerklärung</a>
<a href="{{ url_for('main.imprint') }}" data-i18n="footerImprint">Impressum</a>
</nav>
</div>
</footer>
+133
View File
@@ -0,0 +1,133 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title data-i18n="pageTitle">Datenschutzerklärung</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/legal.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/footer.css') }}">
</head>
<body data-legal-page="privacy">
<header class="legal-header">
<nav class="legal-nav">
<a class="brand" href="{{ url_for('main.home') }}">
<span class="brand-mark">♛</span>
<span data-i18n="navBrand">8-Damen- und 8-Türme Solver</span>
</a>
<div class="legal-nav-links">
<button class="lang-switch" id="langSwitch" type="button" aria-label="Sprache wechseln">DE</button>
<a href="{{ url_for('main.home') }}" data-i18n="navHome">Startseite</a>
{% if current_user.is_authenticated %}
<a href="{{ url_for('main.dashboard') }}" data-i18n="navDashboard">Dashboard</a>
{% else %}
<a href="{{ url_for('login.login') }}" data-i18n="navLogin">Login</a>
{% endif %}
</div>
</nav>
</header>
<main class="legal-main">
<section class="legal-hero">
<p class="legal-eyebrow" data-i18n="heroEyebrow">Rechtliche Hinweise</p>
<h1 data-i18n="heroTitle">Datenschutzerklärung</h1>
<p class="legal-lead" data-i18n="heroLead">
Diese Seite beschreibt, welche personenbezogenen Daten im Rahmen dieses Schulprojekts verarbeitet
werden.
</p>
</section>
<section class="legal-card">
<h2 data-i18n="dataProcessedTitle">1. Welche Daten verarbeitet werden</h2>
<p data-i18n="dataProcessedText">Im Rahmen der Nutzung dieser Webanwendung können insbesondere folgende
Daten verarbeitet werden:</p>
<ul class="legal-list">
<li data-i18n="dataProcessedItem1">Benutzername und verschlüsseltes Passwort bei der Registrierung und
beim Login</li>
<li data-i18n="dataProcessedItem2">Daten zur Zwei-Faktor-Authentifizierung, falls diese freiwillig
aktiviert wird</li>
<li data-i18n="dataProcessedItem3">gespeicherte Spielstände, Notizen, Schrittzahlen, Zeiten und
Favoriten</li>
<li data-i18n="dataProcessedItem4">technische Verbindungsdaten, die durch den Server oder das Hosting
entstehen koennen</li>
</ul>
</section>
<section class="legal-card">
<h2 data-i18n="processingPurposeTitle">2. Zweck der Verarbeitung</h2>
<p data-i18n="processingPurposeText">Die Verarbeitung erfolgt, um die Funktionen dieser Anwendung
bereitzustellen. Dazu gehoeren insbesondere:
</p>
<ul class="legal-list">
<li data-i18n="processingPurposeItem1">Erstellung und Verwaltung von Benutzerkonten</li>
<li data-i18n="processingPurposeItem2">sicherer Login in das persönliche Dashboard</li>
<li data-i18n="processingPurposeItem3">optionale Absicherung des Kontos per Authenticator-App</li>
<li data-i18n="processingPurposeItem4">Speichern und Laden von Spielständen</li>
<li data-i18n="processingPurposeItem5">technischer Betrieb, Fehleranalyse und Missbrauchsschutz</li>
</ul>
</section>
<section class="legal-card">
<h2 data-i18n="legalBasisTitle">3. Rechtsgrundlage</h2>
<p data-i18n="legalBasisText">
Soweit personenbezogene Daten verarbeitet werden, erfolgt dies in der Regel zur Bereitstellung der
Anwendung und ihrer Funktionen sowie aufgrund des berechtigten Interesses an einem sicheren und
funktionsfaehigen Webangebot. Falls eine ausdrückliche Einwilligung erforderlich ist, wird diese
gesondert eingeholt.
</p>
</section>
<section class="legal-card">
<h2 data-i18n="storageTitle">4. Speicherung und Löschung</h2>
<p data-i18n="storageText">
Personenbezogene Daten werden nur so lange gespeichert, wie sie für den jeweiligen Zweck benötigt
werden. Benutzerkonten und zugehörige Spielstände bleiben grundsätzlich gespeichert, bis sie
gelöscht oder für das Projekt nicht mehr benötigt werden.
</p>
</section>
<section class="legal-card">
<h2 data-i18n="thirdPartyTitle">5. Weitergabe an Dritte</h2>
<p data-i18n="thirdPartyText">
Eine Weitergabe personenbezogener Daten erfolgt nur, wenn dies für den Betrieb der Anwendung
erforderlich ist, etwa durch einen Hosting-Anbieter oder technische Dienstleister. Ansonsten werden
keine Daten weitergegeben.
</p>
</section>
<section class="legal-card">
<h2 data-i18n="rightsTitle">6. Rechte betroffener Personen</h2>
<p data-i18n="rightsText">Betroffene Personen haben im Rahmen des anwendbaren Datenschutzrechts insbesondere
das Recht auf:</p>
<ul class="legal-list">
<li data-i18n="rightsItem1">Auskunft über die verarbeiteten personenbezogenen Daten</li>
<li data-i18n="rightsItem2">Berichtigung unrichtiger Daten</li>
<li data-i18n="rightsItem3">Löschung oder Einschraenkung der Verarbeitung</li>
<li data-i18n="rightsItem4">Widerspruch gegen die Verarbeitung</li>
<li data-i18n="rightsItem5">Herausgabe der bereitgestellten Daten, soweit anwendbar</li>
</ul>
</section>
<section class="legal-card">
<h2 data-i18n="securityTitle">7. Datensicherheit</h2>
<p data-i18n="securityText">
Es werden technische und organisatorische Massnahmen getroffen, um gespeicherte Daten vor Verlust,
Missbrauch oder unberechtigtem Zugriff zu schuetzen. Dazu gehoeren insbesondere passwortgeschuetzte
Konten und die Möglichkeit, Zwei-Faktor-Authentifizierung zu aktivieren.
</p>
</section>
<section class="legal-card">
<h2 data-i18n="versionTitle">8. Stand</h2>
<p data-i18n="versionText">Stand dieser Vorlage: April 2026</p>
</section>
</main>
{% include "partials/footer.html" %}
<script src="{{ url_for('static', filename='js/language.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/legal.js') }}" defer></script>
</body>
</html>
+44 -18
View File
@@ -5,31 +5,57 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign Up</title>
<link rel="stylesheet" href="../static/css/login.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/footer.css') }}">
</head>
<body>
<nav>
<ul>
<li><a href="{{ url_for('login.login') }}">Login</a></li>
<li><a href="{{ url_for('login.register') }}">Create account</a></li>
<nav class="auth-nav">
<a class="brand" href="{{ url_for('main.home') }}">
<span class="brand-mark">♛</span>
<span data-i18n="navBrand">8-Damen- und 8-Türme Solver</span>
</a>
<ul class="nav-links">
<li><a href="{{ url_for('login.login') }}" data-i18n="navLogin">Login</a></li>
<li><a href="{{ url_for('login.register') }}" data-i18n="navRegister">Create account</a></li>
</ul>
</nav>
<main>
<h1>Create an account</h1>
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<main class="auth-shell">
<section class="auth-panel">
<h1>Create an account</h1>
<p class="muted">Create your account here and enable authenticator app 2FA later in your login settings.</p>
<form action="{{ url_for('login.register') }}" method="post">
<label for="username">Username:</label>
<input type="text" name="username" required />
<label for="password">Password:</label>
<input type="password" name="password" required />
<button type="submit">Sign Up</button>
</form>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<p class="message {{ category }}">{{ message }}</p>
{% endfor %}
{% endif %}
{% endwith %}
{% if error %}
<p class="message error">{{ error }}</p>
{% endif %}
<form action="{{ url_for('login.register') }}" method="post">
<label for="username">Username</label>
<input id="username" type="text" name="username" maxlength="20" required value="{{ username or '' }}">
<label for="password">Password</label>
<input id="password" type="password" name="password" minlength="8" maxlength="128" required>
<p class="field-hint">Use at least 8 characters.</p>
<button class="magnetic" type="submit" data-loading-label="Creating account...">Sign Up</button>
</form>
</section>
</main>
{% include "partials/footer.html" %}
<script src="{{ url_for('static', filename='js/language.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
</body>
</html>
</html>
-28
View File
@@ -1,28 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>8-Damen-Problem</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/solver.css') }}">
</head>
<body>
<div class="container">
<h1>8-Damen-Problem Test</h1>
<div class="chessboard">
{% for row in range(8) %}
{% for col in range(8) %}
<div class="square {{ 'light' if (row + col) % 2 == 0 else 'dark' }}" data-row="{{ row }}" data-col="{{ col }}">
<!-- Queens will be placed here -->
</div>
{% endfor %}
{% endfor %}
</div>
<div class="controls">
<button id="solveBtn">Solve</button>
<button id="resetBtn" class="secondary">Reset</button>
</div>
</div>
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verify Login</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/footer.css') }}">
</head>
<body>
<nav class="auth-nav">
<a class="brand" href="{{ url_for('main.home') }}">
<span class="brand-mark">♛</span>
<span data-i18n="navBrand">8-Damen- und 8-Türme Solver</span>
</a>
<ul class="nav-links">
<li><a href="{{ url_for('login.login') }}" data-i18n="navLogin">Login</a></li>
<li><a href="{{ url_for('login.register') }}" data-i18n="navRegister">Create account</a></li>
</ul>
</nav>
<main class="auth-shell">
<section class="auth-panel">
<h1>Verify your login</h1>
<p class="muted">Open your authenticator app and enter the current 6-digit code.</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<p class="message {{ category }}">{{ message }}</p>
{% endfor %}
{% endif %}
{% endwith %}
{% if error %}
<p class="message error">{{ error }}</p>
{% endif %}
<form action="{{ url_for('login.verify_two_factor') }}" method="post">
<label for="code">Verification code</label>
<input id="code" type="text" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required>
<p class="field-hint">Codes refresh automatically every 30 seconds in your authenticator app.</p>
<button class="magnetic" type="submit" data-loading-label="Verifying...">Verify</button>
</form>
</section>
</main>
{% include "partials/footer.html" %}
<script src="{{ url_for('static', filename='js/language.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
</body>
</html>
+32 -4
View File
@@ -1,7 +1,35 @@
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
id INTEGER PRIMARY KEY AUTOINCREMENT,
username VARCHAR(20) NOT NULL UNIQUE,
password VARCHAR(80) NOT NULL,
email VARCHAR(120) NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
password VARCHAR(255) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME,
two_factor_enabled BOOLEAN NOT NULL DEFAULT 0,
two_factor_secret VARCHAR(64)
);
CREATE TABLE login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address VARCHAR(45) NOT NULL,
username VARCHAR(80) NOT NULL,
failed_attempts INTEGER NOT NULL DEFAULT 0,
locked_until DATETIME,
last_failed_at DATETIME,
CONSTRAINT uq_login_attempt_ip_username UNIQUE (ip_address, username)
);
CREATE TABLE game_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
board TEXT NOT NULL,
mode TEXT NOT NULL,
board_size INTEGER NOT NULL DEFAULT 8,
save_name VARCHAR(80) NOT NULL DEFAULT 'Unnamed Save',
save_note TEXT NOT NULL DEFAULT '',
is_favorite BOOLEAN NOT NULL DEFAULT 0,
step_count INTEGER NOT NULL DEFAULT 0,
elapsed_seconds INTEGER NOT NULL DEFAULT 0,
is_solved BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
+16
View File
@@ -0,0 +1,16 @@
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
os.chdir(PROJECT_ROOT)
from app import create_app
application = create_app()
app = application
BIN
View File
Binary file not shown.