Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa569e6cff | |||
| 19a6bbd392 | |||
| 59b48683a8 | |||
| 21fe673ab4 | |||
| 48e00fb2b0 | |||
| 02cb1d0a64 | |||
| 9cc1a22232 | |||
| f2de165c55 | |||
| b90d6d0f03 | |||
| bc377b6e33 | |||
| b808b0017b | |||
| 5781eef189 | |||
| cfcb495d17 | |||
| 79cf3240e1 |
+11
-3
@@ -1,25 +1,33 @@
|
||||
from flask import Flask
|
||||
import os
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
from flask import Flask
|
||||
from flask_login import LoginManager
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
db = SQLAlchemy()
|
||||
login_manager = LoginManager()
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
|
||||
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
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"
|
||||
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
login_manager.login_view = "login.login"
|
||||
login_manager.login_message_category = "info"
|
||||
|
||||
from .models.user import User
|
||||
from .models.gamestate import GameState
|
||||
from .models.login_attempt import LoginAttempt
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
Binary file not shown.
BIN
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.
@@ -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()
|
||||
)
|
||||
@@ -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
@@ -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.
+462
-23
@@ -1,30 +1,291 @@
|
||||
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
|
||||
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
|
||||
|
||||
login_bp = Blueprint("login", __name__)
|
||||
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
MAX_PASSWORD_LENGTH = 128
|
||||
MAX_LOGIN_FAILURES = 5
|
||||
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 datetime.utcnow()
|
||||
|
||||
|
||||
def get_utc_timestamp(moment=None):
|
||||
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):
|
||||
return (username or "").strip()
|
||||
|
||||
|
||||
def validate_username(username):
|
||||
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):
|
||||
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():
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
if attempt.locked_until and attempt.locked_until <= now:
|
||||
attempt.failed_attempts = 0
|
||||
attempt.locked_until = None
|
||||
|
||||
|
||||
def get_active_lock(username):
|
||||
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):
|
||||
now = utcnow()
|
||||
locked_until = None
|
||||
|
||||
for ip_address, bucket_key in login_attempt_buckets(username):
|
||||
attempt = get_or_create_attempt(ip_address, bucket_key)
|
||||
clear_expired_lock(attempt, now)
|
||||
attempt.failed_attempts = (attempt.failed_attempts or 0) + 1
|
||||
attempt.last_failed_at = now
|
||||
|
||||
if attempt.failed_attempts >= MAX_LOGIN_FAILURES:
|
||||
attempt.locked_until = now + timedelta(minutes=LOGIN_LOCK_MINUTES)
|
||||
locked_until = attempt.locked_until
|
||||
|
||||
db.session.commit()
|
||||
return locked_until
|
||||
|
||||
|
||||
def clear_login_attempts(username):
|
||||
for ip_address, bucket_key in login_attempt_buckets(username):
|
||||
attempt = LoginAttempt.query.filter_by(ip_address=ip_address, username=bucket_key).first()
|
||||
|
||||
if attempt:
|
||||
db.session.delete(attempt)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def lookup_user_by_username(username):
|
||||
if not username:
|
||||
return None
|
||||
|
||||
return User.query.filter(db.func.lower(User.username) == username.lower()).first()
|
||||
|
||||
|
||||
def minutes_until(target_time):
|
||||
seconds = max(1, int((target_time - utcnow()).total_seconds()))
|
||||
return max(1, (seconds + 59) // 60)
|
||||
|
||||
|
||||
def normalize_two_factor_secret(secret):
|
||||
return (secret or "").strip().replace(" ", "").upper()
|
||||
|
||||
|
||||
def generate_two_factor_secret():
|
||||
return base64.b32encode(secrets.token_bytes(20)).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def format_two_factor_secret(secret):
|
||||
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):
|
||||
session.pop("pending_2fa_user_id", None)
|
||||
session.pop("pending_2fa_attempts", None)
|
||||
|
||||
|
||||
def get_pending_two_factor_setup_secret(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():
|
||||
session.pop("pending_2fa_setup_user_id", None)
|
||||
session.pop("pending_2fa_setup_secret", None)
|
||||
|
||||
|
||||
def build_totp_uri(user, secret):
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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():
|
||||
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):
|
||||
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():
|
||||
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 +293,201 @@ def register():
|
||||
|
||||
@login_bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
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"))
|
||||
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)
|
||||
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:
|
||||
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():
|
||||
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))
|
||||
|
||||
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():
|
||||
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.")
|
||||
|
||||
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():
|
||||
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"))
|
||||
|
||||
+354
-33
@@ -1,16 +1,240 @@
|
||||
import json
|
||||
import random
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, jsonify, render_template, request
|
||||
from flask_login import login_required, current_user
|
||||
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
|
||||
|
||||
main_bp = Blueprint("main", __name__)
|
||||
|
||||
|
||||
def parse_board_size(raw_size, default=DEFAULT_BOARD_SIZE):
|
||||
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):
|
||||
return str(raw_value).lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def parse_non_negative_int(raw_value, default=0):
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
return max(0, value)
|
||||
|
||||
|
||||
def get_position_label(row, col, board_size):
|
||||
return f"{chr(65 + col)}{board_size - row}"
|
||||
|
||||
|
||||
def solve_queens(board_size):
|
||||
queen_positions = [-1] * board_size
|
||||
queen_solutions = []
|
||||
|
||||
def is_queen_position_safe(row, col):
|
||||
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):
|
||||
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):
|
||||
rook_positions = list(range(board_size))
|
||||
random.shuffle(rook_positions)
|
||||
return rook_positions
|
||||
|
||||
|
||||
def build_queens_trace(board_size):
|
||||
queen_positions = [-1] * board_size
|
||||
steps = []
|
||||
|
||||
def is_queen_position_safe(row, col):
|
||||
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):
|
||||
if row == board_size:
|
||||
steps.append({
|
||||
"type": "solution",
|
||||
"row": None,
|
||||
"col": None,
|
||||
"board": queen_positions[:],
|
||||
"message": f"Loesung 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)
|
||||
steps.append({
|
||||
"type": "place",
|
||||
"row": row,
|
||||
"col": col,
|
||||
"board": queen_positions[:],
|
||||
"message": f"Dame auf {position} gesetzt."
|
||||
})
|
||||
|
||||
if place_queen(row + 1):
|
||||
return True
|
||||
|
||||
queen_positions[row] = -1
|
||||
steps.append({
|
||||
"type": "remove",
|
||||
"row": row,
|
||||
"col": col,
|
||||
"board": queen_positions[:],
|
||||
"message": f"Backtracking: Dame von {position} entfernt."
|
||||
})
|
||||
|
||||
return False
|
||||
|
||||
solved = place_queen(0)
|
||||
|
||||
return {
|
||||
"mode": "queens",
|
||||
"board_size": board_size,
|
||||
"initial_board": [-1] * board_size,
|
||||
"steps": steps,
|
||||
"solved": solved
|
||||
}
|
||||
|
||||
|
||||
def build_rooks_trace(board_size):
|
||||
rook_positions = solve_rooks(board_size)
|
||||
board = [-1] * board_size
|
||||
steps = []
|
||||
|
||||
for row, col in enumerate(rook_positions):
|
||||
board[row] = col
|
||||
position = get_position_label(row, col, board_size)
|
||||
steps.append({
|
||||
"type": "place",
|
||||
"row": row,
|
||||
"col": col,
|
||||
"board": board[:],
|
||||
"message": f"Turm auf {position} gesetzt."
|
||||
})
|
||||
|
||||
steps.append({
|
||||
"type": "solution",
|
||||
"row": None,
|
||||
"col": None,
|
||||
"board": board[:],
|
||||
"message": f"Loesung gefunden. Alle {board_size} Tuerme stehen ohne Konflikte."
|
||||
})
|
||||
|
||||
return {
|
||||
"mode": "rooks",
|
||||
"board_size": board_size,
|
||||
"initial_board": [-1] * board_size,
|
||||
"steps": steps,
|
||||
"solved": True
|
||||
}
|
||||
|
||||
|
||||
def serialize_game_state(game_state):
|
||||
board = json.loads(game_state.board)
|
||||
|
||||
return {
|
||||
"id": game_state.id,
|
||||
"board": board,
|
||||
"mode": game_state.mode,
|
||||
"board_size": game_state.board_size,
|
||||
"save_name": game_state.save_name,
|
||||
"save_note": game_state.save_note,
|
||||
"is_favorite": game_state.is_favorite,
|
||||
"pieces_placed": sum(1 for column in board if column != -1),
|
||||
"step_count": game_state.step_count,
|
||||
"elapsed_seconds": game_state.elapsed_seconds,
|
||||
"is_solved": game_state.is_solved,
|
||||
"created_at": game_state.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"updated_at": game_state.updated_at.strftime("%Y-%m-%d %H:%M:%S")
|
||||
}
|
||||
|
||||
|
||||
def build_save_point_query():
|
||||
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")
|
||||
|
||||
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")
|
||||
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)
|
||||
return render_template("dashboard.html", user=current_user)
|
||||
|
||||
|
||||
@main_bp.route("/check", methods=["POST"])
|
||||
def check():
|
||||
@@ -18,47 +242,144 @@ def check():
|
||||
board = data["board"]
|
||||
row = data["row"]
|
||||
col = data["col"]
|
||||
piece_mode = data.get("mode", "queens")
|
||||
|
||||
for r in range(len(board)):
|
||||
c = board[r]
|
||||
|
||||
if c == -1 or r == row:
|
||||
for current_row, current_col in enumerate(board):
|
||||
if current_col == -1 or current_row == row:
|
||||
continue
|
||||
|
||||
# gleiche Spalte
|
||||
if c == col:
|
||||
same_column = current_col == col
|
||||
same_diagonal = abs(current_col - col) == abs(current_row - row)
|
||||
|
||||
if same_column:
|
||||
return jsonify({"valid": False})
|
||||
|
||||
# gleiche Diagonale
|
||||
if abs(c - col) == abs(r - row):
|
||||
if piece_mode == "queens" and same_diagonal:
|
||||
return jsonify({"valid": False})
|
||||
|
||||
return jsonify({"valid": True})
|
||||
|
||||
|
||||
@main_bp.route("/solve")
|
||||
def solve():
|
||||
n = 8
|
||||
solutions = []
|
||||
board = [-1] * n
|
||||
def solve_queens_route():
|
||||
board_size = parse_board_size(request.args.get("size"))
|
||||
return jsonify(solve_queens(board_size))
|
||||
|
||||
def is_safe(row, col):
|
||||
for r in range(row):
|
||||
c = board[r]
|
||||
if c == col or abs(c - col) == abs(r - row):
|
||||
return False
|
||||
return True
|
||||
|
||||
def backtrack(row):
|
||||
if row == n:
|
||||
solutions.append(board[:])
|
||||
return
|
||||
@main_bp.route("/solve_trace")
|
||||
def solve_queens_trace_route():
|
||||
board_size = parse_board_size(request.args.get("size"))
|
||||
return jsonify(build_queens_trace(board_size))
|
||||
|
||||
for col in range(n):
|
||||
if is_safe(row, col):
|
||||
board[row] = col
|
||||
backtrack(row + 1)
|
||||
board[row] = -1
|
||||
|
||||
backtrack(0)
|
||||
return jsonify(solutions)
|
||||
@main_bp.route("/solve_rooks")
|
||||
def solve_rooks_route():
|
||||
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():
|
||||
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():
|
||||
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()
|
||||
|
||||
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():
|
||||
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):
|
||||
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):
|
||||
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():
|
||||
latest_game = (
|
||||
GameState.query.filter_by(user_id=current_user.id)
|
||||
.order_by(GameState.created_at.desc(), GameState.id.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
if not latest_game:
|
||||
return jsonify({"board": [-1] * 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):
|
||||
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))
|
||||
|
||||
+1018
-73
File diff suppressed because it is too large
Load Diff
@@ -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%;
|
||||
}
|
||||
}
|
||||
+295
-88
@@ -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,56 +27,130 @@
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--text-color);
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 255, 255, 0.9), transparent 24%),
|
||||
radial-gradient(circle at 82% 18%, rgba(198, 139, 44, 0.2), transparent 20%),
|
||||
linear-gradient(180deg, #f7f0e5 0%, #efe5d5 52%, #f4efe7 100%);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.auth-nav {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 18px max(16px, calc((100vw - 1200px) / 2)) 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
border-top: 0;
|
||||
border-right: 4px solid #12212d;
|
||||
border-bottom: 4px solid #12212d;
|
||||
border-left: 4px solid #12212d;
|
||||
background: linear-gradient(180deg, rgba(17, 29, 39, 0.96), rgba(31, 49, 64, 0.94));
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 18px 40px rgba(31, 49, 64, 0.18);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #fff7eb;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-family: "Geist Sans", system-ui, sans-serif;
|
||||
|
||||
background: linear-gradient(135deg, #c6dff4, #7c8384);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, var(--gold), var(--gold-soft));
|
||||
color: #2c2215;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
nav {
|
||||
width: 100%;
|
||||
background: var(--nav-bg-color);
|
||||
padding: 15px 30px;
|
||||
}
|
||||
|
||||
nav ul {
|
||||
.nav-links {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
nav a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
nav a:hover {
|
||||
color: #4facfe;
|
||||
}
|
||||
|
||||
/* Center login section */
|
||||
main {
|
||||
min-height: calc(100vh - 60px);
|
||||
/* full screen minus navbar */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
color: #ecf4fb;
|
||||
}
|
||||
|
||||
/* Title */
|
||||
main h1 {
|
||||
margin-bottom: 20px;
|
||||
.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;
|
||||
}
|
||||
|
||||
main {
|
||||
.nav-links a:hover,
|
||||
.nav-button:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(231, 191, 119, 0.45);
|
||||
color: #fff7eb;
|
||||
}
|
||||
|
||||
.nav-form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.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-grid {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -80,72 +163,196 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Form box */
|
||||
form {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 35px;
|
||||
border-radius: 12px;
|
||||
width: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
h1,
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
color: #182029;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 10px;
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.03em;
|
||||
font-size: clamp(2.2rem, 4vw, 3.6rem);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 10px;
|
||||
font-size: 1.55rem;
|
||||
}
|
||||
|
||||
.muted,
|
||||
.field-hint {
|
||||
font-family: "Trebuchet MS", "Segoe UI", sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0 0 20px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
padding: 24px;
|
||||
border-radius: 10px;
|
||||
background: var(--surface-soft);
|
||||
border: 1px solid rgba(31, 49, 64, 0.1);
|
||||
}
|
||||
|
||||
/* Labels */
|
||||
label {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 5px;
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--navy);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
form {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 35px;
|
||||
border-radius: 12px;
|
||||
width: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(31, 49, 64, 0.16);
|
||||
border-radius: 0;
|
||||
font: inherit;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: 2px solid rgba(198, 139, 44, 0.28);
|
||||
border-color: rgba(198, 139, 44, 0.55);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Button */
|
||||
button {
|
||||
margin-top: 20px;
|
||||
padding: 12px;
|
||||
margin-top: 14px;
|
||||
min-height: 50px;
|
||||
padding: 12px 18px;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #e9eff5, #daebf0);
|
||||
color: rgb(0, 0, 0);
|
||||
border-radius: 8px;
|
||||
border-radius: 0;
|
||||
background: linear-gradient(135deg, var(--gold), var(--gold-soft));
|
||||
color: #2d2418;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 700;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 0 12px 24px rgba(31, 49, 64, 0.16);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: scale(0.97);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
|
||||
/* Error message */
|
||||
.error {
|
||||
color: red;
|
||||
margin-bottom: 15px;
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* button anim */
|
||||
.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;
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
padding: 22px 12px 28px;
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
padding: 24px 18px;
|
||||
}
|
||||
|
||||
form {
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,238 @@
|
||||
const HERO_BOARD_SIZE = 8;
|
||||
const heroBoard = document.getElementById("heroBoard");
|
||||
const langSwitch = document.getElementById("langSwitch");
|
||||
const supportedLanguages = ["de", "en"];
|
||||
const initialLanguage = window.LANDING_INITIAL_LANGUAGE || "de";
|
||||
|
||||
const translations = {
|
||||
de: {
|
||||
"meta.title": "8-Damen- und 8-Türme Solver",
|
||||
"nav.brand": "8-Damen- und 8-Türme Solver",
|
||||
"nav.problem": "Problem",
|
||||
"nav.features": "Features",
|
||||
"nav.journey": "Ablauf",
|
||||
"nav.login": "Login",
|
||||
"nav.signup": "Create Account",
|
||||
"hero.eyebrow": "Interaktives Lernen mit Strategie, Logik und Backtracking",
|
||||
"hero.title": "Entdecke das Acht-Damen- und Acht-Türme-Problem.",
|
||||
"hero.text": "Diese App macht aus einem klassischen Informatik- und Mathematikproblem ein interaktives Erlebnis: Setze Damen oder Türme, verfolge den Solver Schritt fuer Schritt, miss deine Zeit und speichere clevere Zwischenstände.",
|
||||
"hero.primaryCta": "Jetzt starten",
|
||||
"hero.secondaryCta": "Bereits Account? Login",
|
||||
"hero.fact1Title": "Skalierbar",
|
||||
"hero.fact1Text": "Brettgrösse flexibel anpassen",
|
||||
"hero.fact2Title": "Schritt für Schritt",
|
||||
"hero.fact2Text": "Solver mit Animation",
|
||||
"hero.fact3Title": "Save-Points",
|
||||
"hero.fact3Text": "Zeit und Fortschritt behalten",
|
||||
"hero.solverLabel": "Solver",
|
||||
"hero.solverValue": "Backtracking live",
|
||||
"hero.challengeLabel": "Challenge",
|
||||
"hero.challengeValue": "Zeit und Schritte messen",
|
||||
"problem.eyebrow": "Warum dieses Problem spannend ist",
|
||||
"problem.title": "Die Acht-Damen- und Acht-Türme-Probleme sind mehr als Schachrätsel.",
|
||||
"problem.text": "Beim Acht-Damen-Problem suchst du eine Anordnung von acht Damen auf einem Schachbrett, sodass sich keine zwei Damen schlagen. Beim Acht-Tuerme-Problem gilt dieselbe Idee für Türme, nur mit einfacheren Konfliktregeln. Beide Aufgaben zeigen, wie Mustererkennung, systematische Suche und Backtracking funktionieren.<br><br>Gerade beim Damenproblem wird schnell sichtbar, warum Strategie wichtig ist: Es gibt unzählige moegliche Platzierungen, aber nur sehr wenige wirklich gültige Loesungen. Genau das macht diese App spannend, weil man nicht nur das Ergebnis sieht, sondern auch den Weg dorthin.",
|
||||
"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 Loesungsweg Schritt fuer Schritt und verstehe, wie Backtracking arbeitet.",
|
||||
"features.card3Title": "Zeit und Schritte",
|
||||
"features.card3Text": "Miss deinen Fortschritt während des Spiels und vergleiche unterschiedliche Versuche.",
|
||||
"features.card4Title": "Save-Points",
|
||||
"features.card4Text": "Speichere Zwischenstände mit Notizen, Zeiten und Schrittzahl fuer späteres Weiterarbeiten.",
|
||||
"features.card5Title": "Skalierbare Bretter",
|
||||
"features.card5Text": "Passe die Brettgrösse an und erkunde die Probleme nicht nur auf dem klassischen 8x8-Brett.",
|
||||
"journey.eyebrow": "So funktioniert dein Einstieg",
|
||||
"journey.title": "In wenigen Schritten vom ersten Besuch zur eigenen Lösung.",
|
||||
"journey.step1Title": "Account erstellen",
|
||||
"journey.step1Text": "Registriere dich und erhalte Zugang zu Dashboard, Save-Points und deinem Fortschritt.",
|
||||
"journey.step2Title": "Spielmodus wählen",
|
||||
"journey.step2Text": "Arbeite mit Damen oder Türmen und passe die Brettgrösse deinem Ziel an.",
|
||||
"journey.step3Title": "Loesen oder analysieren",
|
||||
"journey.step3Text": "Teste eigene Ideen oder lass dir die Lösung visuell vom Solver lösen lassen, dabei wird 1:1 gezeigt wie der Solver vorgeht.",
|
||||
"cta.eyebrow": "Bereit für den ersten Versuch?",
|
||||
"cta.title": "Mach aus einem bekannten Problem ein sichtbares Lernerlebnis.",
|
||||
"cta.text": "Starte mit einem Account, speichere deine Zwischenstände und finde heraus, wie schnell du eine konfliktfreie Lösung erreichst.",
|
||||
"cta.primaryCta": "Create Account",
|
||||
"cta.secondaryCta": "Login"
|
||||
},
|
||||
en: {
|
||||
"meta.title": "8 Queens and 8 Rooks Solver",
|
||||
"nav.brand": "8 Queens and 8 Rooks Solver",
|
||||
"nav.problem": "Problem",
|
||||
"nav.features": "Features",
|
||||
"nav.journey": "Journey",
|
||||
"nav.login": "Login",
|
||||
"nav.signup": "Create Account",
|
||||
"hero.eyebrow": "Interactive learning with strategy, logic, and backtracking",
|
||||
"hero.title": "Discover the Eight Queens and Eight Rooks puzzle.",
|
||||
"hero.text": "This app turns a classic computer science and mathematics problem into an interactive experience: place queens or rooks, follow the solver step by step, track your time, and save clever checkpoints.",
|
||||
"hero.primaryCta": "Get Started",
|
||||
"hero.secondaryCta": "Already have an account? Login",
|
||||
"hero.fact1Title": "Scalable",
|
||||
"hero.fact1Text": "Adjust the board size freely",
|
||||
"hero.fact2Title": "Step by step",
|
||||
"hero.fact2Text": "Animated solver walkthrough",
|
||||
"hero.fact3Title": "Save points",
|
||||
"hero.fact3Text": "Keep your time and progress",
|
||||
"hero.solverLabel": "Solver",
|
||||
"hero.solverValue": "Backtracking in motion",
|
||||
"hero.challengeLabel": "Challenge",
|
||||
"hero.challengeValue": "Measure time and steps",
|
||||
"problem.eyebrow": "Why this puzzle matters",
|
||||
"problem.title": "The Eight Queens and Eight Rooks problems are more than chess puzzles.",
|
||||
"problem.text": "In the Eight Queens problem, you search for a way to place eight queens on a chessboard so that no two queens can attack each other. The Eight Rooks problem follows the same idea for rooks, but with simpler conflict rules. Both tasks show how pattern recognition, systematic search, and backtracking work.<br><br>The queens puzzle quickly makes it obvious why strategy matters: there are countless possible placements, but only a few truly valid solutions. That is what makes this app interesting, because you do not just see the result, you also see the path to it.",
|
||||
"features.eyebrow": "What the application offers",
|
||||
"features.title": "From the first idea to a solved position.",
|
||||
"features.card1Title": "Interactive board",
|
||||
"features.card1Text": "Place queens or rooks directly on the board and check whether your setup is conflict-free.",
|
||||
"features.card2Title": "Animated solver",
|
||||
"features.card2Text": "Watch the solving path step by step and understand how backtracking works.",
|
||||
"features.card3Title": "Time and steps",
|
||||
"features.card3Text": "Track your progress during play and compare different attempts.",
|
||||
"features.card4Title": "Save points",
|
||||
"features.card4Text": "Store checkpoints with notes, time, and step count so you can continue later.",
|
||||
"features.card5Title": "Scalable boards",
|
||||
"features.card5Text": "Adjust the board size and explore the puzzle beyond the classic 8x8 board.",
|
||||
"journey.eyebrow": "How your first session works",
|
||||
"journey.title": "Reach your own solution in just a few steps.",
|
||||
"journey.step1Title": "Create an account",
|
||||
"journey.step1Text": "Sign up and unlock the dashboard, save points, and your tracked progress.",
|
||||
"journey.step2Title": "Choose a game mode",
|
||||
"journey.step2Text": "Work with queens or rooks and tune the board size to your goal.",
|
||||
"journey.step3Title": "Solve or analyze",
|
||||
"journey.step3Text": "Test your own ideas or let the solver show the solution visually, with a 1:1 view of each solving step.",
|
||||
"cta.eyebrow": "Ready for your first attempt?",
|
||||
"cta.title": "Turn a famous puzzle into a visible learning experience.",
|
||||
"cta.text": "Start with an account, save your checkpoints, and find out how quickly you can reach a conflict-free solution.",
|
||||
"cta.primaryCta": "Create Account",
|
||||
"cta.secondaryCta": "Login"
|
||||
}
|
||||
};
|
||||
|
||||
const queenPatterns = [
|
||||
[0, 4, 7, 5, 2, 6, 1, 3],
|
||||
[1, 3, 5, 7, 2, 0, 6, 4],
|
||||
[2, 5, 7, 0, 3, 6, 4, 1]
|
||||
];
|
||||
|
||||
let currentPatternIndex = 0;
|
||||
|
||||
function normalizeLanguage(language) {
|
||||
const shortLanguage = String(language || "").trim().slice(0, 2).toLowerCase();
|
||||
return supportedLanguages.includes(shortLanguage) ? shortLanguage : "de";
|
||||
}
|
||||
|
||||
function getStoredLanguage() {
|
||||
try {
|
||||
return window.localStorage.getItem("preferredLanguage");
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function storeLanguage(language) {
|
||||
try {
|
||||
window.localStorage.setItem("preferredLanguage", language);
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function detectBrowserLanguage() {
|
||||
const browserLanguage = navigator.language || navigator.userLanguage || initialLanguage;
|
||||
return normalizeLanguage(browserLanguage);
|
||||
}
|
||||
|
||||
function setLanguage(language) {
|
||||
const resolvedLanguage = normalizeLanguage(language);
|
||||
const dictionary = translations[resolvedLanguage];
|
||||
const i18nElements = document.querySelectorAll("[data-i18n]");
|
||||
|
||||
document.documentElement.lang = resolvedLanguage;
|
||||
document.body.dataset.language = resolvedLanguage;
|
||||
|
||||
i18nElements.forEach((element) => {
|
||||
const key = element.dataset.i18n;
|
||||
const translatedText = dictionary[key];
|
||||
|
||||
if (!translatedText) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (element.tagName === "TITLE" || translatedText.includes("<br")) {
|
||||
element.innerHTML = translatedText;
|
||||
return;
|
||||
}
|
||||
|
||||
element.textContent = translatedText;
|
||||
});
|
||||
|
||||
if (langSwitch) {
|
||||
langSwitch.textContent = resolvedLanguage.toUpperCase();
|
||||
langSwitch.setAttribute("aria-label", resolvedLanguage === "de" ? "Switch language to English" : "Sprache auf Deutsch wechseln");
|
||||
langSwitch.title = resolvedLanguage === "de" ? "Switch to English" : "Zu Deutsch wechseln";
|
||||
}
|
||||
|
||||
storeLanguage(resolvedLanguage);
|
||||
}
|
||||
|
||||
function getPreferredLanguage() {
|
||||
return normalizeLanguage(getStoredLanguage() || detectBrowserLanguage() || initialLanguage);
|
||||
}
|
||||
|
||||
function createHeroBoard() {
|
||||
if (!heroBoard) {
|
||||
return;
|
||||
}
|
||||
|
||||
heroBoard.innerHTML = "";
|
||||
|
||||
for (let row = 0; row < HERO_BOARD_SIZE; row += 1) {
|
||||
for (let col = 0; col < HERO_BOARD_SIZE; col += 1) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = `board-cell ${(row + col) % 2 === 0 ? "light" : "dark"}`;
|
||||
cell.dataset.row = String(row);
|
||||
cell.dataset.col = String(col);
|
||||
heroBoard.appendChild(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyPattern(pattern) {
|
||||
if (!heroBoard) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cells = heroBoard.querySelectorAll(".board-cell");
|
||||
|
||||
cells.forEach((cell) => {
|
||||
const row = Number(cell.dataset.row);
|
||||
const col = Number(cell.dataset.col);
|
||||
const hasQueen = pattern[row] === col;
|
||||
|
||||
cell.classList.toggle("queen", hasQueen);
|
||||
cell.classList.toggle("highlight", hasQueen);
|
||||
});
|
||||
}
|
||||
|
||||
function rotatePattern() {
|
||||
currentPatternIndex = (currentPatternIndex + 1) % queenPatterns.length;
|
||||
applyPattern(queenPatterns[currentPatternIndex]);
|
||||
}
|
||||
|
||||
if (langSwitch) {
|
||||
langSwitch.addEventListener("click", () => {
|
||||
const nextLanguage = document.documentElement.lang === "de" ? "en" : "de";
|
||||
setLanguage(nextLanguage);
|
||||
});
|
||||
}
|
||||
|
||||
createHeroBoard();
|
||||
applyPattern(queenPatterns[currentPatternIndex]);
|
||||
setLanguage(getPreferredLanguage());
|
||||
|
||||
if (heroBoard) {
|
||||
window.setInterval(rotatePattern, 2200);
|
||||
}
|
||||
+61
-24
@@ -1,32 +1,69 @@
|
||||
const button = document.querySelector(".magnetic");
|
||||
const magneticButtons = document.querySelectorAll(".magnetic");
|
||||
|
||||
button.addEventListener("mousemove", (e) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left - rect.width / 2;
|
||||
const y = e.clientY - rect.top - rect.height / 2;
|
||||
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.2}px, ${y * 0.2}px)`;
|
||||
});
|
||||
button.style.transform = `translate(${x * 0.15}px, ${y * 0.15}px)`;
|
||||
});
|
||||
|
||||
button.addEventListener("mouseleave", () => {
|
||||
button.style.transform = "translate(0,0)";
|
||||
});
|
||||
|
||||
const inputs = document.querySelectorAll("input");
|
||||
|
||||
inputs.forEach(input => {
|
||||
input.addEventListener("input", () => {
|
||||
input.style.boxShadow = "0 0 8px rgba(79,172,254,0.6)";
|
||||
|
||||
setTimeout(() => {
|
||||
input.style.boxShadow = "";
|
||||
}, 300);
|
||||
button.addEventListener("mouseleave", () => {
|
||||
button.style.transform = "translate(0, 0)";
|
||||
});
|
||||
});
|
||||
|
||||
const form = document.querySelector("form");
|
||||
document.querySelectorAll("input").forEach((input) => {
|
||||
input.addEventListener("input", () => {
|
||||
input.style.boxShadow = "0 0 8px rgba(59, 130, 246, 0.4)";
|
||||
|
||||
form.addEventListener("submit", () => {
|
||||
button.innerText = "Logging in...";
|
||||
button.style.opacity = "0.7";
|
||||
window.clearTimeout(input._loginGlowTimeout);
|
||||
input._loginGlowTimeout = window.setTimeout(() => {
|
||||
input.style.boxShadow = "";
|
||||
}, 250);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("form").forEach((form) => {
|
||||
form.addEventListener("submit", (event) => {
|
||||
const submitter = event.submitter;
|
||||
|
||||
if (!submitter || submitter.dataset.loadingLabelApplied === "true") {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadingLabel = submitter.dataset.loadingLabel;
|
||||
|
||||
if (loadingLabel) {
|
||||
submitter.dataset.loadingLabelApplied = "true";
|
||||
submitter.dataset.originalLabel = submitter.innerText;
|
||||
submitter.innerText = 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 = "QR preview could not be loaded.";
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
|
||||
new window.QRCode(container, {
|
||||
text: qrValue,
|
||||
width: 220,
|
||||
height: 220,
|
||||
colorDark: "#172033",
|
||||
colorLight: "#ffffff",
|
||||
correctLevel: window.QRCode.CorrectLevel.M
|
||||
});
|
||||
});
|
||||
|
||||
+285
-150
@@ -6,203 +6,338 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dashboard</title>
|
||||
|
||||
<link rel="stylesheet" href="../static/css/dashboard.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/dashboard.css') }}">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav class="dashboard-nav">
|
||||
<a class="brand" href="{{ url_for('main.home') }}">
|
||||
<span class="brand-mark">♛</span>
|
||||
<span>8-Damen- und 8-Türme Solver</span>
|
||||
</a>
|
||||
|
||||
<!-- 🔝 Navbar -->
|
||||
<nav class="navbar">
|
||||
<div class="nav-left">♛ 8-Queens App</div>
|
||||
<div class="nav-right">
|
||||
<a href="{{ url_for('main.dashboard') }}">Dashboard</a>
|
||||
<a href="{{ url_for('login.logout') }}">Logout</a>
|
||||
<div class="nav-actions">
|
||||
<a class="nav-link" href="{{ url_for('main.dashboard') }}" id="navDashboardLink">Dashboard</a>
|
||||
|
||||
<button class="nav-link-button lang-switch" id="langSwitch" type="button"
|
||||
aria-label="Sprache wechseln">DE</button>
|
||||
|
||||
<a class="account-shortcut" href="{{ url_for('login.login_settings') }}"
|
||||
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">Account</span>
|
||||
<span class="account-name">{{ current_user.username }}</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<form action="{{ url_for('login.logout') }}" method="post" class="nav-logout-form">
|
||||
<button class="nav-link-button" type="submit">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 📦 Container -->
|
||||
<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">Brett-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 %}
|
||||
|
||||
<!-- 👤 User Card -->
|
||||
<div class="card">
|
||||
<h2>👋 Willkommen, {{ current_user.username }}</h2>
|
||||
<section class="dashboard-intro card">
|
||||
<p class="section-kicker" id="introKicker">Puzzle-Modus</p>
|
||||
<h1 id="introTitle">Wähl deinen Modus und löse das Brett auf deine Art.</h1>
|
||||
<p class="dashboard-intro-text" id="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="user-info">
|
||||
<p><strong>ID:</strong> {{ current_user.id }}</p>
|
||||
<p><strong>Username:</strong> {{ current_user.username }}</p>
|
||||
<div class="mode-switch" aria-label="Spielmodus">
|
||||
<button class="btn active" onclick="setMode('queens')" id="btnQueens" type="button">♛ Damen</button>
|
||||
<button class="btn secondary" onclick="setMode('rooks')" id="btnRooks" type="button">♜ Tuerme</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if current_user.email %}
|
||||
<p><strong>Email:</strong> {{ current_user.email }}</p>
|
||||
{% else %}
|
||||
<p><strong>Email:</strong> Not set</p>
|
||||
{% endif %}
|
||||
<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 so, dass keine zwei dieselbe Spalte oder Diagonale teilen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p><strong>Erstellt:</strong> {{ current_user.created_at }}</p>
|
||||
<button class="info-button" id="infoButton" onclick="toggleInfoModal(true)" type="button"
|
||||
aria-label="Spielregeln und Hilfe anzeigen">Regeln & Hilfe</button>
|
||||
</div>
|
||||
|
||||
<form action="{{ url_for('login.logout') }}" method="post">
|
||||
<button class="btn danger" type="submit">Logout</button>
|
||||
</form>
|
||||
</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>
|
||||
|
||||
<!-- ♟️ Game Card -->
|
||||
<div class="card game-card">
|
||||
<h2>♟️ 8-Damen Spiel</h2>
|
||||
<div class="game-stats" aria-label="Spielstatus">
|
||||
<article class="game-stat-card">
|
||||
<p class="game-stat-label" id="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">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">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" id="board"></div>
|
||||
|
||||
<div class="controls">
|
||||
<button class="btn" onclick="solve()">Solve</button>
|
||||
<button class="btn secondary" onclick="resetBoard()">Reset</button>
|
||||
<button class="btn secondary" onclick="resetBoard()" id="btnReset" type="button">Brett
|
||||
zuruecksetzen</button>
|
||||
<div class="controls-save-row">
|
||||
<button class="btn" onclick="openSavePanel()" id="btnSave" type="button">Speichern</button>
|
||||
<button class="btn secondary" onclick="openSavePointsPanel()" id="btnLoad"
|
||||
type="button">Save-Points</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="solver-panel">
|
||||
<div class="solver-panel-header">
|
||||
<div>
|
||||
<p class="section-kicker" id="solverKicker">Solver</p>
|
||||
<h3 id="solverTitle">Lösungswerkzeuge</h3>
|
||||
<p class="solver-message" id="solverMessage">Bereite einen Ablauf vor und spiele ihn Schritt
|
||||
für Schritt ab.</p>
|
||||
</div>
|
||||
<p class="solver-step-info" id="solverStepInfo">Schritt 0 / 0</p>
|
||||
</div>
|
||||
|
||||
<div class="solver-groups">
|
||||
<section class="solver-group">
|
||||
<h4 id="animationSolverTitle">Animations-Solver</h4>
|
||||
<p class="solver-group-text" id="animationSolverText">
|
||||
Bereite den Lösungsweg vor, gehe ihn manüll Schritt für Schritt durch oder spiele die
|
||||
Animation automatisch ab.
|
||||
</p>
|
||||
|
||||
<div class="solver-controls">
|
||||
<button class="btn" onclick="prepareSolutionTrace()" id="btnPrepareSolve"
|
||||
type="button">Animation vorbereiten</button>
|
||||
<button class="btn secondary" onclick="nextSolverStep()" id="btnNextStep"
|
||||
type="button">Nächster Schritt</button>
|
||||
<button class="btn secondary" onclick="toggleSolverPlayback()" id="btnPlayPause"
|
||||
type="button">Animation abspielen</button>
|
||||
</div>
|
||||
|
||||
<div class="solver-speed">
|
||||
<label class="solver-speed-label" id="solverSpeedLabel"
|
||||
for="solverSpeedInput">Animationsgeschwindigkeit</label>
|
||||
<input id="solverSpeedInput" type="range" min="80" max="900" step="20" value="320"
|
||||
oninput="updateSolverSpeed(this.value)">
|
||||
<span class="solver-speed-value" id="solverSpeedValue">320 ms</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="solver-group instant-solver-group">
|
||||
<h4 id="instantSolverTitle">Sofort-Lösung</h4>
|
||||
<p class="solver-group-text" id="instantSolverText">
|
||||
Lade direkt eine gültige Lösung, ohne Zwischenschritte oder Animation.
|
||||
</p>
|
||||
|
||||
<div class="instant-solver-actions">
|
||||
<button class="btn accent" onclick="instantSolve()" id="btnInstantSolve"
|
||||
type="button">Sofort lösen</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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">Save-Points</p>
|
||||
<h3 id="historyTitle">Gespeicherte Zeiten und Fortschritte</h3>
|
||||
<p class="history-subtitle" id="historySubtitle">Die Tabelle zeigt gespeicherte Save-Points mit
|
||||
Zeit, Schritten und
|
||||
Status.</p>
|
||||
</div>
|
||||
<button class="btn secondary" onclick="refreshTimeHistory()" id="btnRefreshHistory"
|
||||
type="button">Aktualisieren</button>
|
||||
</div>
|
||||
|
||||
<div class="history-table-wrapper">
|
||||
<table class="history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th id="historyHeadSave">Save-Point</th>
|
||||
<th id="historyHeadMode">Modus</th>
|
||||
<th id="historyHeadBoard">Brett</th>
|
||||
<th id="historyHeadSteps">Schritte</th>
|
||||
<th id="historyHeadTime">Zeit</th>
|
||||
<th id="historyHeadStatus">Status</th>
|
||||
<th id="historyHeadSaved">Gespeichert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="timeHistoryTableBody">
|
||||
<tr>
|
||||
<td colspan="7" class="history-empty">Noch keine gespeicherten Zeiten vorhanden.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let board = Array(8).fill(-1);
|
||||
const boardDiv = document.getElementById("board");
|
||||
<aside class="sidebar settings-sidebar" id="settingsSidebar" aria-hidden="true">
|
||||
<div class="sidebar-header">
|
||||
<h3 id="settingsSidebarTitle">Brett-Einstellungen</h3>
|
||||
<button class="btn secondary sidebar-close" onclick="toggleSidebar(false)" id="settingsSidebarClose"
|
||||
type="button">Schliessen</button>
|
||||
</div>
|
||||
|
||||
function getConflicts() {
|
||||
let conflicts = [];
|
||||
<p id="settingsSidebarText">Passe die Brettgrösse zwischen 4 und 13 an.</p>
|
||||
|
||||
for (let r1 = 0; r1 < 8; r1++) {
|
||||
if (board[r1] === -1) continue;
|
||||
<label class="sidebar-label" id="boardSizeInputLabel" for="boardSizeInput">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>
|
||||
|
||||
for (let r2 = r1 + 1; r2 < 8; r2++) {
|
||||
if (board[r2] === -1) continue;
|
||||
<button class="btn" onclick="applyBoardSize()" id="applyBoardSizeButton" type="button">Grösse anwenden</button>
|
||||
<p class="sidebar-hint" id="settingsSidebarHint">Beim Ändern wird das Brett zur Sicherheit zurückgesetzt. Auf
|
||||
dem Handy ist 8x8 eingeplant, grössere Bretter können abgeschnitten wirken.</p>
|
||||
</aside>
|
||||
|
||||
let c1 = board[r1];
|
||||
let c2 = board[r2];
|
||||
<aside class="sidebar save-sidebar" id="saveSidebar" aria-hidden="true">
|
||||
<div class="sidebar-header">
|
||||
<h3 id="saveSidebarTitle">Speicherpunkt</h3>
|
||||
<button class="btn secondary sidebar-close" onclick="toggleSavePanel(false)" id="saveSidebarClose"
|
||||
type="button">Schliessen</button>
|
||||
</div>
|
||||
|
||||
if (
|
||||
c1 === c2 ||
|
||||
Math.abs(c1 - c2) === Math.abs(r1 - r2)
|
||||
) {
|
||||
conflicts.push([r1, c1]);
|
||||
conflicts.push([r2, c2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
<p id="saveSidebarText">Gib dem Spielstand einen klaren Namen und optional eine kurze Notiz.</p>
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
<label class="sidebar-label" id="saveNameLabel" for="saveNameInput">Name</label>
|
||||
<input class="sidebar-input" id="saveNameInput" type="text" maxlength="80"
|
||||
placeholder="z. B. Fast gelöste Dame 8x8">
|
||||
|
||||
function drawBoard() {
|
||||
boardDiv.innerHTML = "";
|
||||
boardDiv.className = "board-grid";
|
||||
<label class="sidebar-label" id="saveNoteLabel" for="saveNoteInput">Notiz</label>
|
||||
<textarea class="sidebar-input sidebar-textarea" id="saveNoteInput" rows="4" maxlength="240"
|
||||
placeholder="z. B. Gute Startposition für Türmetest"></textarea>
|
||||
|
||||
const conflicts = getConflicts();
|
||||
const letters = ["A", "B", "C", "D", "E", "F", "G", "H"];
|
||||
<label class="sidebar-checkbox">
|
||||
<input id="saveFavoriteInput" type="checkbox">
|
||||
<span id="saveFavoriteLabel">Als Favorit markieren</span>
|
||||
</label>
|
||||
|
||||
for (let row = -1; row < 8; row++) {
|
||||
for (let col = -1; col < 8; col++) {
|
||||
<button class="btn" onclick="saveGame()" id="saveNowButton" type="button">Jetzt speichern</button>
|
||||
</aside>
|
||||
|
||||
const cell = document.createElement("div");
|
||||
<aside class="sidebar save-points-sidebar" id="savePointsSidebar" aria-hidden="true">
|
||||
<div class="sidebar-header">
|
||||
<h3 id="savePointsSidebarTitle">Save-Points</h3>
|
||||
<button class="btn secondary sidebar-close" onclick="toggleSavePointsPanel(false)"
|
||||
id="savePointsSidebarClose" type="button">Schliessen</button>
|
||||
</div>
|
||||
|
||||
// 🔹 Ecke oben links
|
||||
if (row === -1 && col === -1) {
|
||||
cell.classList.add("label");
|
||||
}
|
||||
<p id="savePointsSidebarText">Wähle einen Speicherstand aus der Liste.</p>
|
||||
|
||||
// 🔹 Buchstaben (oben)
|
||||
else if (row === -1) {
|
||||
cell.textContent = letters[col];
|
||||
cell.classList.add("label");
|
||||
}
|
||||
<div class="save-point-filters">
|
||||
<label class="sidebar-label" id="savePointModeFilterLabel" for="savePointModeFilter">Modus</label>
|
||||
<select class="sidebar-input" id="savePointModeFilter" onchange="refreshSavePoints()">
|
||||
<option value="all">Alle</option>
|
||||
<option value="queens">Damen</option>
|
||||
<option value="rooks">Türme</option>
|
||||
</select>
|
||||
|
||||
// 🔹 Zahlen (links)
|
||||
else if (col === -1) {
|
||||
cell.textContent = 8 - row;
|
||||
cell.classList.add("label");
|
||||
}
|
||||
<label class="sidebar-label" id="savePointSizeFilterLabel" for="savePointSizeFilter">Brettgrösse</label>
|
||||
<select class="sidebar-input" id="savePointSizeFilter" onchange="refreshSavePoints()">
|
||||
<option value="all">Alle</option>
|
||||
{% for board_size in range(4, 14) %}
|
||||
<option value="{{ board_size }}">{{ board_size }} x {{ board_size }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
// 🔹 echtes Spielfeld
|
||||
else {
|
||||
cell.classList.add("cell");
|
||||
<label class="sidebar-label" id="savePointSortFilterLabel" for="savePointSortFilter">Sortierung</label>
|
||||
<select class="sidebar-input" id="savePointSortFilter" onchange="refreshSavePoints()">
|
||||
<option value="newest">Neueste zuerst</option>
|
||||
<option value="oldest">älteste zuerst</option>
|
||||
<option value="updated">Zuletzt bearbeitet</option>
|
||||
<option value="favorites">Favoriten zuerst</option>
|
||||
</select>
|
||||
|
||||
if ((row + col) % 2 === 0) {
|
||||
cell.classList.add("white");
|
||||
} else {
|
||||
cell.classList.add("black");
|
||||
}
|
||||
<label class="sidebar-checkbox">
|
||||
<input id="savePointFavoritesOnly" type="checkbox" onchange="refreshSavePoints()">
|
||||
<span id="savePointFavoritesOnlyLabel">Nur Favoriten anzeigen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
if (board[row] === col) {
|
||||
cell.textContent = "♛";
|
||||
<div class="save-points-list" id="savePointsList">
|
||||
<p class="sidebar-hint" id="savePointsEmptyState">Noch keine Speicherstände gefunden.</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
if (conflicts.some(([r, c]) => r === row && c === col)) {
|
||||
cell.classList.add("invalid");
|
||||
}
|
||||
}
|
||||
<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>
|
||||
|
||||
cell.onclick = () => placeQueen(row, col);
|
||||
}
|
||||
<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">Spielregeln und Acht-Damen-Problem</h3>
|
||||
<button class="btn secondary sidebar-close" onclick="toggleInfoModal(false)"
|
||||
type="button">Schliessen</button>
|
||||
</div>
|
||||
|
||||
boardDiv.appendChild(cell);
|
||||
}
|
||||
}
|
||||
<div class="modal-content">
|
||||
<p>Setze pro Reihe genau eine Figur auf das Brett. Eine gültige Lösung hat keine Konflikte.</p>
|
||||
|
||||
updateQueenList();
|
||||
}
|
||||
<h4>So funktioniert der aktuelle Modus</h4>
|
||||
<p id="infoModeRuleText">Im Damen-Modus dürfen sich Damen weder in derselben Spalte noch diagonal
|
||||
angreifen. Mit einem Klick setzt oder entfernst du eine Figur.</p>
|
||||
|
||||
function placeQueen(row, col) {
|
||||
if (board[row] === col) {
|
||||
board[row] = -1;
|
||||
} else {
|
||||
board[row] = col;
|
||||
}
|
||||
drawBoard();
|
||||
updateQueenList();
|
||||
}
|
||||
<h4 id="infoModalProblemHeading">Was ist das Acht-Damen-Problem?</h4>
|
||||
<p id="infoModalProblemText">Das klassische Acht-Damen-Problem fragt nach einer Anordnung von acht Damen
|
||||
auf einem 8x8-Brett, bei der sich keine zwei Damen schlagen. Es ist ein bekanntes Beispiel für
|
||||
kombinatorische Suche und Backtracking.</p>
|
||||
|
||||
async function solve() {
|
||||
const res = await fetch("/solve");
|
||||
const solutions = await res.json();
|
||||
|
||||
board = solutions[0];
|
||||
drawBoard();
|
||||
}
|
||||
|
||||
function resetBoard() {
|
||||
board = Array(8).fill(-1);
|
||||
drawBoard();
|
||||
updateQueenList();
|
||||
}
|
||||
|
||||
drawBoard();
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
<h3>📋 Damen Positionen</h3>
|
||||
<ul id="queenList"></ul>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function updateQueenList() {
|
||||
const list = document.getElementById("queenList");
|
||||
list.innerHTML = "";
|
||||
|
||||
const letters = ["A", "B", "C", "D", "E", "F", "G", "H"];
|
||||
|
||||
let count = 1;
|
||||
|
||||
for (let row = 0; row < 8; row++) {
|
||||
if (board[row] !== -1) {
|
||||
let col = board[row];
|
||||
|
||||
let pos = letters[col] + (8 - row);
|
||||
|
||||
const li = document.createElement("li");
|
||||
li.textContent = `Dame ${count}: ${pos}`;
|
||||
|
||||
list.appendChild(li);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
<h4>Was zeigen Zeit und Schritte?</h4>
|
||||
<p>Der Schrittzähler erfasst deine Züge auf dem Brett. Die Zeit läuft für das aktuelle Spiel weiter,
|
||||
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/dashboard.js') }}"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+176
-49
@@ -1,66 +1,193 @@
|
||||
<!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-Tuerme Solver</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.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-Tuerme Solver</span>
|
||||
</a>
|
||||
|
||||
<!-- Navigation -->
|
||||
<header class="navbar">
|
||||
<div class="container">
|
||||
<h1 class="logo">SolverApp</h1>
|
||||
|
||||
<nav>
|
||||
<a href="/">Home</a>
|
||||
<a href="/dashboard">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">Features</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">Login</a>
|
||||
<a class="nav-cta" href="{{ url_for('login.register') }}" data-i18n="nav.signup">Create Account</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 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.
|
||||
</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 Account? Login</a>
|
||||
</div>
|
||||
|
||||
<div class="hero-facts">
|
||||
<article>
|
||||
<strong data-i18n="hero.fact1Title">Skalierbar</strong>
|
||||
<span data-i18n="hero.fact1Text">Brettgrösse flexibel anpassen</span>
|
||||
</article>
|
||||
<article>
|
||||
<strong data-i18n="hero.fact2Title">Schritt für Schritt</strong>
|
||||
<span data-i18n="hero.fact2Text">Solver mit Animation</span>
|
||||
</article>
|
||||
<article>
|
||||
<strong data-i18n="hero.fact3Title">Save-Points</strong>
|
||||
<span data-i18n="hero.fact3Text">Zeit und Fortschritt behalten</span>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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">Solver</p>
|
||||
<p class="floating-value" data-i18n="hero.solverValue">Backtracking live</p>
|
||||
</div>
|
||||
<div class="floating-card timer-card">
|
||||
<p class="floating-label" data-i18n="hero.challengeLabel">Challenge</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">Warum dieses Problem spannend ist</p>
|
||||
<h2 data-i18n="problem.title">Die Acht-Damen- und Acht-Tuerme-Probleme sind mehr als Schachrätsel.</h2>
|
||||
<p data-i18n="problem.text">
|
||||
Beim Acht-Damen-Problem suchst du eine Anordnung von acht Damen auf einem Schachbrett, sodass sich
|
||||
keine zwei Damen schlagen. Beim Acht-Türme-Problem gilt dieselbe Idee für Türme, nur mit
|
||||
einfacheren Konfliktregeln. Beide Aufgaben zeigen, wie Mustererkennung, systematische Suche und
|
||||
Backtracking funktionieren.
|
||||
<br><br>
|
||||
Gerade beim Damenproblem wird schnell sichtbar, warum Strategie wichtig ist: Es gibt unzählige
|
||||
mögliche Platzierungen, aber nur sehr wenige wirklich gültige Lösungen. Genau das macht diese
|
||||
App spannend, weil man nicht nur das Ergebnis sieht, sondern auch den Weg dorthin.
|
||||
</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 Tuerme 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">Save-Points</h3>
|
||||
<p data-i18n="features.card4Text">Speichere Zwischenstaende 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 zu Dashboard, Save-Points und
|
||||
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
|
||||
lösen lassen, dabei wird 1:1
|
||||
gezeigt wie der Solver vorgeht.</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">Create Account</a>
|
||||
<a class="hero-button secondary" href="{{ url_for('login.login') }}"
|
||||
data-i18n="cta.secondaryCta">Login</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<p>© {{ 2026 }} SolverApp</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- 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/index.js') }}"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+38
-21
@@ -5,35 +5,52 @@
|
||||
<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') }}">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="{{ url_for('login.login') }}"><span>Login</span></a></li>
|
||||
<li><a href="{{ url_for('login.register') }}"><span>Create account</span></a></li>
|
||||
<nav class="auth-nav">
|
||||
<a class="brand" href="{{ url_for('main.home') }}">
|
||||
<span class="brand-mark">♛</span>
|
||||
<span>8-Damen- und 8-Tuerme Solver</span>
|
||||
</a>
|
||||
|
||||
<ul class="nav-links">
|
||||
<li><a href="{{ url_for('login.login') }}">Login</a></li>
|
||||
<li><a href="{{ url_for('login.register') }}">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>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login Settings</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.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>8-Damen- und 8-Tuerme Solver</span>
|
||||
</a>
|
||||
|
||||
<ul class="nav-links">
|
||||
<li><a href="{{ url_for('main.dashboard') }}">Dashboard</a></li>
|
||||
<li><a href="{{ url_for('login.login_settings') }}">Login settings</a></li>
|
||||
</ul>
|
||||
<form action="{{ url_for('login.logout') }}" method="post" class="nav-form">
|
||||
<button type="submit" class="nav-button">Logout</button>
|
||||
</form>
|
||||
</nav>
|
||||
|
||||
<main class="auth-shell settings-shell">
|
||||
<section class="auth-panel settings-panel">
|
||||
<h1>Login settings</h1>
|
||||
<p class="muted">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>Authenticator app 2FA</h2>
|
||||
<p class="field-hint">2FA is currently enabled for this account.</p>
|
||||
<p class="field-hint">Use Google Authenticator, Microsoft Authenticator, Authy, or another TOTP app to generate your 6-digit login codes.</p>
|
||||
|
||||
<button class="secondary-button" type="submit" data-loading-label="Disabling 2FA...">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>Finish 2FA setup</h2>
|
||||
<p class="field-hint">Scan this QR code with your authenticator app, then enter the current 6-digit code to confirm.</p>
|
||||
|
||||
<div class="qr-panel">
|
||||
<div class="qr-code" data-qr-value="{{ pending_two_factor_uri }}"></div>
|
||||
</div>
|
||||
<p class="field-hint">If scanning does not work, you can still use the manual setup key below.</p>
|
||||
|
||||
<label for="two_factor_secret">Manual setup key</label>
|
||||
<input id="two_factor_secret" type="text" value="{{ pending_two_factor_secret }}" readonly>
|
||||
|
||||
<label for="two_factor_uri">Setup URI</label>
|
||||
<input id="two_factor_uri" type="text" value="{{ pending_two_factor_uri }}" readonly>
|
||||
<p class="field-hint">This URI is used to generate the QR setup and can be copied as a fallback for compatible apps.</p>
|
||||
|
||||
<label for="code">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...">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>Cancel setup</h2>
|
||||
<p class="field-hint">Cancel the current authenticator setup if you want to restart with a new secret.</p>
|
||||
|
||||
<button class="secondary-button" type="submit">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>Authenticator app 2FA</h2>
|
||||
<p class="field-hint">Protect your account with 6-digit codes from an authenticator app.</p>
|
||||
<p class="field-hint">After starting setup, you will get a QR code to scan and then confirm once with a live code.</p>
|
||||
|
||||
<button class="magnetic" type="submit" data-loading-label="Starting setup...">Start setup</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<form action="{{ url_for('login.login_settings') }}" method="post">
|
||||
<input type="hidden" name="action" value="password">
|
||||
|
||||
<h2>Change password</h2>
|
||||
|
||||
<label for="current_password">Current password</label>
|
||||
<input id="current_password" type="password" name="current_password" minlength="8" maxlength="128"
|
||||
required>
|
||||
|
||||
<label for="new_password">New password</label>
|
||||
<input id="new_password" type="password" name="new_password" minlength="8" maxlength="128" required>
|
||||
|
||||
<label for="confirm_password">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...">Update password</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+38
-16
@@ -5,31 +5,53 @@
|
||||
<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') }}">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav>
|
||||
<ul>
|
||||
<nav class="auth-nav">
|
||||
<a class="brand" href="{{ url_for('main.home') }}">
|
||||
<span class="brand-mark">♛</span>
|
||||
<span>8-Damen- und 8-Tuerme Solver</span>
|
||||
</a>
|
||||
|
||||
<ul class="nav-links">
|
||||
<li><a href="{{ url_for('login.login') }}">Login</a></li>
|
||||
<li><a href="{{ url_for('login.register') }}">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>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<!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') }}">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav class="auth-nav">
|
||||
<a class="brand" href="{{ url_for('main.home') }}">
|
||||
<span class="brand-mark">♛</span>
|
||||
<span>8-Damen- und 8-Tuerme Solver</span>
|
||||
</a>
|
||||
|
||||
<ul class="nav-links">
|
||||
<li><a href="{{ url_for('login.login') }}">Login</a></li>
|
||||
<li><a href="{{ url_for('login.register') }}">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>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+32
-4
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user