7 Commits

29 changed files with 2071 additions and 336 deletions
+26 -1
View File
@@ -2,27 +2,52 @@ from flask import Flask
import os import os
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager from flask_login import LoginManager
from sqlalchemy import inspect, text
db = SQLAlchemy() db = SQLAlchemy()
login_manager = LoginManager() login_manager = LoginManager()
def apply_startup_migrations():
inspector = inspect(db.engine)
if "users" in inspector.get_table_names():
existing_columns = {column["name"] for column in inspector.get_columns("users")}
user_column_migrations = {
"two_factor_enabled": "ALTER TABLE users ADD COLUMN two_factor_enabled BOOLEAN NOT NULL DEFAULT 0",
"two_factor_secret": "ALTER TABLE users ADD COLUMN two_factor_secret VARCHAR(64)",
}
for column_name, statement in user_column_migrations.items():
if column_name not in existing_columns:
db.session.execute(text(statement))
db.session.commit()
def create_app(): def create_app():
app = Flask(__name__) app = Flask(__name__)
BASE_DIR = os.path.abspath(os.path.dirname(__file__)) BASE_DIR = os.path.abspath(os.path.dirname(__file__))
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(BASE_DIR, "app.db") app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(BASE_DIR, "app.db")
app.config["SECRET_KEY"] = "dev-secret-key" app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-secret-key")
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["REMEMBER_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
db.init_app(app) db.init_app(app)
login_manager.init_app(app) login_manager.init_app(app)
login_manager.login_view = "login.login" login_manager.login_view = "login.login"
login_manager.login_message_category = "info"
from .models.user import User from .models.user import User
from .models.gamestate import GameState
from .models.login_attempt import LoginAttempt
with app.app_context(): with app.app_context():
db.create_all() db.create_all()
apply_startup_migrations()
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id): def load_user(user_id):
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+21
View File
@@ -0,0 +1,21 @@
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)
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
updated_at = db.Column(
db.DateTime,
nullable=False,
server_default=db.func.now(),
onupdate=db.func.now()
)
+19
View File
@@ -0,0 +1,19 @@
from .. import db
class LoginAttempt(db.Model):
__tablename__ = "login_attempts"
id = db.Column(db.Integer, primary_key=True)
ip_address = db.Column(db.String(45), nullable=False)
username = db.Column(db.String(80), nullable=False)
failed_attempts = db.Column(db.Integer, nullable=False, default=0, server_default=db.text("0"))
locked_until = db.Column(db.DateTime)
last_failed_at = db.Column(db.DateTime)
__table_args__ = (
db.UniqueConstraint("ip_address", "username", name="uq_login_attempt_ip_username"),
)
def __repr__(self):
return f"<LoginAttempt {self.ip_address}:{self.username}>"
+4 -2
View File
@@ -1,15 +1,17 @@
from .. import db from .. import db
from flask_login import UserMixin from flask_login import UserMixin
class User(UserMixin, db.Model): class User(UserMixin, db.Model):
__tablename__ = "users" __tablename__ = "users"
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(20), unique=True, nullable=False) username = db.Column(db.String(20), unique=True, nullable=False)
password = db.Column(db.String(255), nullable=False) # <- größer! password = db.Column(db.String(255), nullable=False)
email = db.Column(db.String(120), unique=True, nullable=True)
created_at = db.Column(db.DateTime, server_default=db.func.now()) created_at = db.Column(db.DateTime, server_default=db.func.now())
last_login = db.Column(db.DateTime) last_login = db.Column(db.DateTime)
two_factor_enabled = db.Column(db.Boolean, nullable=False, default=False, server_default=db.text("0"))
two_factor_secret = db.Column(db.String(64))
def __repr__(self): def __repr__(self):
return f"<User {self.username}>" return f"<User {self.username}>"
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+466 -27
View File
@@ -1,30 +1,291 @@
from flask import Blueprint, render_template, request, url_for, redirect, flash import base64
from werkzeug.security import generate_password_hash, check_password_hash import binascii
from flask_login import login_user, login_required, logout_user import hashlib
from ..models.user import User 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 .. import db
from ..models.login_attempt import LoginAttempt
from ..models.user import User
login_bp = Blueprint("login", __name__) login_bp = Blueprint("login", __name__)
@login_bp.route("/register", methods=["GET", "POST"]) MIN_PASSWORD_LENGTH = 8
def register(): MAX_PASSWORD_LENGTH = 128
if request.method == "POST": MAX_LOGIN_FAILURES = 5
username = request.form.get("username") LOGIN_LOCK_MINUTES = 15
password = request.form.get("password") 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")
if User.query.filter_by(username=username).first():
return render_template("sign_up.html", error="Username already taken!")
hashed_password = generate_password_hash(password) def utcnow():
return datetime.utcnow()
new_user = User(
username=username, def get_utc_timestamp(moment=None):
password=hashed_password 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 = normalize_username(request.form.get("username"))
password = request.form.get("password") or ""
username_error = validate_username(username)
password_error = validate_password(password)
if username_error:
return render_template("sign_up.html", error=username_error, username=username)
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.add(new_user)
db.session.commit() db.session.commit()
flash("Account created successfully. You can log in now.", "success")
return redirect(url_for("login.login")) return redirect(url_for("login.login"))
return render_template("sign_up.html") return render_template("sign_up.html")
@@ -32,23 +293,201 @@ def register():
@login_bp.route("/login", methods=["GET", "POST"]) @login_bp.route("/login", methods=["GET", "POST"])
def login(): def login():
if request.method == "POST": if current_user.is_authenticated:
username = request.form.get("username")
password = request.form.get("password")
user = User.query.filter_by(username=username).first()
if user and check_password_hash(user.password, password):
login_user(user)
return redirect(url_for("main.dashboard")) return redirect(url_for("main.dashboard"))
return render_template("login.html", error="Invalid username or password") if request.method == "POST":
username = normalize_username(request.form.get("username"))
password = request.form.get("password") or ""
if not username or not password:
return render_template("login.html", error="Username and password are required.", username=username)
locked_until = get_active_lock(username)
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") 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 @login_required
def logout(): def logout():
clear_pending_two_factor()
clear_pending_two_factor_setup()
logout_user() logout_user()
flash('You have been logged out', 'info') flash("You have been logged out.", "info")
return redirect(url_for('login.login')) return redirect(url_for("login.login"))
+213 -41
View File
@@ -1,17 +1,123 @@
from flask import Blueprint, jsonify, render_template, request import json
from flask_login import login_required, current_user
import random import random
from flask import Blueprint, jsonify, render_template, request
from flask_login import current_user, login_required
from .. import db
from ..models.gamestate import GameState
DEFAULT_BOARD_SIZE = 8
MIN_BOARD_SIZE = 4
MAX_BOARD_SIZE = 13
main_bp = Blueprint("main", __name__) 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 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 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),
"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("/") @main_bp.route("/")
def home(): def home():
return render_template("index.html") return render_template("index.html")
@main_bp.route('/dashboard')
@main_bp.route("/dashboard")
@login_required @login_required
def dashboard(): def dashboard():
return render_template('dashboard.html', user=current_user) return render_template("dashboard.html", user=current_user)
@main_bp.route("/check", methods=["POST"]) @main_bp.route("/check", methods=["POST"])
def check(): def check():
@@ -19,57 +125,123 @@ def check():
board = data["board"] board = data["board"]
row = data["row"] row = data["row"]
col = data["col"] col = data["col"]
piece_mode = data.get("mode", "queens")
for r in range(len(board)): for current_row, current_col in enumerate(board):
c = board[r] if current_col == -1 or current_row == row:
if c == -1 or r == row:
continue continue
# gleiche Spalte same_column = current_col == col
if c == col: same_diagonal = abs(current_col - col) == abs(current_row - row)
if same_column:
return jsonify({"valid": False}) return jsonify({"valid": False})
# gleiche Diagonale if piece_mode == "queens" and same_diagonal:
if abs(c - col) == abs(r - row):
return jsonify({"valid": False}) return jsonify({"valid": False})
return jsonify({"valid": True}) return jsonify({"valid": True})
@main_bp.route("/solve") @main_bp.route("/solve")
def solve(): def solve_queens_route():
n = 8 board_size = parse_board_size(request.args.get("size"))
solutions = [] return jsonify(solve_queens(board_size))
board = [-1] * n
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
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") @main_bp.route("/solve_rooks")
def solve_rooks_route(): def solve_rooks_route():
return jsonify(solve_rooks(8)) board_size = parse_board_size(request.args.get("size"))
return jsonify(solve_rooks(board_size))
def solve_rooks(n=8):
solution = list(range(n))
random.shuffle(solution)
return solution
@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"))
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
)
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))
+261 -7
View File
@@ -24,6 +24,29 @@ body {
text-decoration: underline; text-decoration: underline;
} }
.nav-right {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.nav-logout-form {
margin: 0 0 0 15px;
}
.nav-link-button {
padding: 0;
border: none;
background: none;
color: white;
font: inherit;
cursor: pointer;
}
.nav-link-button:hover {
text-decoration: underline;
}
/* 📦 Layout */ /* 📦 Layout */
.container { .container {
max-width: 900px; max-width: 900px;
@@ -40,20 +63,55 @@ body {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
} }
.flash-stack {
display: grid;
gap: 12px;
margin-bottom: 18px;
}
.flash-message {
margin: 0;
padding: 12px 14px;
border-radius: 10px;
}
.flash-message.success {
color: #166534;
background: #dcfce7;
}
.flash-message.info {
color: #1d4ed8;
background: #dbeafe;
}
.flash-message.error {
color: #b91c1c;
background: #fee2e2;
}
.game-card { .game-card {
text-align: center; text-align: center;
} }
.board {
overflow-x: auto;
padding-bottom: 8px;
}
/* ♟️ Board */ /* ♟️ Board */
.board-grid { .board-grid {
--label-size: 30px;
--cell-size: 60px;
display: grid; display: grid;
grid-template-columns: 30px repeat(8, 60px); grid-template-columns: var(--label-size) repeat(var(--board-size, 8), var(--cell-size));
grid-template-rows: 30px repeat(8, 60px); grid-template-rows: var(--label-size) repeat(var(--board-size, 8), var(--cell-size));
margin: 20px auto; margin: 20px auto;
width: max-content; width: max-content;
border: 6px solid #1e293b; border: 6px solid #1e293b;
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow: hidden;
max-width: 100%;
} }
.label { .label {
@@ -63,17 +121,18 @@ body {
font-weight: bold; font-weight: bold;
background: #aec8eb; background: #aec8eb;
color: #1e293b; color: #1e293b;
width: var(--label-size);
height: var(--label-size);
font-size: 14px;
} }
.cell { .cell {
width: 60px; width: var(--cell-size);
height: 60px; height: var(--cell-size);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 30px; font-size: calc(var(--cell-size) * 0.5);
cursor: pointer; cursor: pointer;
transition: 0.2s; transition: 0.2s;
} }
@@ -153,3 +212,198 @@ body {
.btn.active { .btn.active {
background: #22c55e; background: #22c55e;
} }
.board-size-info {
margin: 0 0 10px;
color: #475569;
}
.sidebar {
position: fixed;
top: 0;
right: 0;
width: min(320px, 85vw);
height: 100vh;
padding: 24px;
background: white;
box-shadow: -10px 0 24px rgba(15, 23, 42, 0.18);
transform: translateX(100%);
transition: transform 0.25s ease;
z-index: 30;
}
.sidebar.open {
transform: translateX(0);
}
.save-points-sidebar {
width: min(380px, 92vw);
}
.save-sidebar {
width: min(360px, 90vw);
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.sidebar-close {
margin: 0;
}
.sidebar-label {
display: block;
margin-top: 18px;
margin-bottom: 8px;
font-weight: bold;
}
.sidebar input[type="range"] {
width: 100%;
}
.sidebar-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font: inherit;
box-sizing: border-box;
background: #fff;
}
.sidebar-textarea {
resize: vertical;
min-height: 96px;
}
.sidebar-checkbox {
display: flex;
align-items: center;
gap: 10px;
margin: 16px 0 18px;
color: #334155;
}
.sidebar-size-row {
margin: 12px 0 18px;
font-size: 18px;
}
.sidebar-hint {
color: #64748b;
font-size: 14px;
}
.save-points-list {
display: grid;
gap: 12px;
margin-top: 18px;
}
.save-point-filters {
display: grid;
gap: 8px;
margin-top: 18px;
padding-bottom: 16px;
border-bottom: 1px solid #e2e8f0;
}
.save-point-card {
border: 1px solid #dbe4ee;
border-radius: 10px;
padding: 14px;
background: #f8fafc;
}
.save-point-card h4 {
margin: 0 0 8px;
color: #1e293b;
}
.save-point-meta {
margin: 4px 0;
color: #475569;
font-size: 14px;
}
.save-point-preview {
margin: 10px 0 0;
color: #334155;
font-size: 14px;
word-break: break-word;
}
.save-point-note {
margin: 10px 0 0;
color: #475569;
font-size: 14px;
}
.save-point-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.save-point-actions .btn {
margin: 0;
}
.inline-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.inline-actions form {
margin: 0;
}
.inline-actions .btn {
margin: 0;
}
.btn.ghost {
background: #e2e8f0;
color: #1e293b;
}
.btn.ghost:hover {
background: #cbd5e1;
}
.sidebar-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.3);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease;
z-index: 20;
}
.sidebar-backdrop.visible {
opacity: 1;
pointer-events: auto;
}
@media (max-width: 720px) {
.container {
padding: 12px;
}
.navbar {
padding: 15px 18px;
}
.nav-logout-form {
margin-left: 0;
}
}
+210 -64
View File
@@ -1,7 +1,12 @@
:root { :root {
--primary-bg-color: #f4f6f8; --nav-bg-color: #172033;
--secondary-bg-color: white; --panel-bg-color: rgba(255, 255, 255, 0.18);
--nav-bg-color: #333; --panel-border-color: rgba(255, 255, 255, 0.24);
--text-color: #172033;
--muted-text-color: #475569;
--success-color: #15803d;
--info-color: #1d4ed8;
--error-color: #b91c1c;
} }
@font-face { @font-face {
@@ -18,24 +23,30 @@
body { body {
margin: 0; margin: 0;
min-height: 100vh; min-height: 100vh;
display: grid; color: var(--text-color);
place-items: center;
font-family: "Geist Sans", system-ui, sans-serif; font-family: "Geist Sans", system-ui, sans-serif;
background:
background: linear-gradient(135deg, #c6dff4, #7c8384); radial-gradient(circle at top left, rgba(255, 255, 255, 0.45), transparent 32%),
linear-gradient(135deg, #d2ecff 0%, #b8c6db 48%, #7f8ea3 100%);
} }
nav { nav {
width: 100%; width: 100%;
background: var(--nav-bg-color); min-height: 68px;
padding: 15px 30px; padding: 15px 30px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
background: var(--nav-bg-color);
} }
nav ul { nav ul {
margin: 0;
padding: 0;
list-style: none; list-style: none;
display: flex; display: flex;
justify-content: flex-end; flex-wrap: wrap;
gap: 20px; gap: 20px;
} }
@@ -46,28 +57,68 @@ nav a {
} }
nav a:hover { nav a:hover {
color: #4facfe; color: #93c5fd;
} }
/* Center login section */ .nav-form {
main { margin: 0;
min-height: calc(100vh - 60px); padding: 0;
/* full screen minus navbar */ width: auto;
background: none;
border: 0;
box-shadow: none;
backdrop-filter: none;
}
.nav-button {
margin: 0;
padding: 0;
background: none;
border: 0;
color: white;
font: inherit;
cursor: pointer;
}
.nav-button:hover {
transform: none;
box-shadow: none;
color: #93c5fd;
}
.auth-shell {
min-height: calc(100vh - 68px);
display: flex; display: flex;
flex-direction: column;
justify-content: center;
align-items: center; align-items: center;
justify-content: center;
padding: 32px 16px;
} }
/* Title */ .settings-shell {
main h1 { align-items: flex-start;
margin-bottom: 20px;
} }
main { .auth-panel {
width: min(420px, 100%);
padding: 34px;
border-radius: 20px;
background: var(--panel-bg-color);
border: 1px solid var(--panel-border-color);
backdrop-filter: blur(16px);
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.18);
animation: fadeIn 0.8s ease; animation: fadeIn 0.8s ease;
} }
.settings-panel {
width: min(900px, 100%);
}
.settings-grid {
display: grid;
gap: 24px;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
@keyframes fadeIn { @keyframes fadeIn {
from { from {
opacity: 0; opacity: 0;
@@ -80,72 +131,167 @@ main {
} }
} }
h1,
/* Form box */ h2 {
form { margin-top: 0;
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);
} }
.muted {
margin: 0 0 20px;
color: var(--muted-text-color);
line-height: 1.5;
}
form {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 18px;
padding: 24px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.22);
}
/* Labels */
label { label {
margin-top: 10px; margin-top: 8px;
margin-bottom: 5px;
font-size: 14px; font-size: 14px;
font-weight: 600;
} }
/* Inputs */ input {
form { width: 100%;
background: rgba(255, 255, 255, 0.15); padding: 12px 14px;
backdrop-filter: blur(12px); border: 1px solid rgba(71, 85, 105, 0.25);
padding: 35px; border-radius: 10px;
border-radius: 12px; font: inherit;
width: 320px; background: rgba(255, 255, 255, 0.86);
color: var(--text-color);
}
input:focus {
outline: 2px solid rgba(59, 130, 246, 0.35);
border-color: rgba(59, 130, 246, 0.5);
}
.field-hint {
margin: 0;
font-size: 13px;
color: var(--muted-text-color);
}
.checkbox-row {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 10px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); margin: 10px 0 6px;
border: 1px solid rgba(255, 255, 255, 0.2);
} }
.checkbox-row input {
width: auto;
}
/* Button */
button { button {
margin-top: 20px; margin-top: 14px;
padding: 12px; padding: 12px 16px;
border: none; border: none;
background: linear-gradient(135deg, #e9eff5, #daebf0); border-radius: 10px;
color: rgb(0, 0, 0); background: linear-gradient(135deg, #eff6ff, #dbeafe);
border-radius: 8px; color: #172033;
cursor: pointer; cursor: pointer;
font-size: 16px; font-size: 16px;
font-weight: 600;
transition: all 0.2s ease; transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
} }
button:hover { button:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.2); box-shadow: 0 10px 24px rgba(15, 23, 42, 0.18);
} }
button:active { button:active {
transform: scale(0.97); transform: scale(0.98);
} }
.button-row {
/* Error message */ display: flex;
.error { flex-wrap: wrap;
color: red; gap: 12px;
margin-bottom: 15px;
} }
/* button anim */ .button-row button {
flex: 1 1 160px;
}
.secondary-button {
background: linear-gradient(135deg, #e2e8f0, #cbd5e1);
}
.message {
margin: 0 0 14px;
padding: 12px 14px;
border-radius: 10px;
font-size: 14px;
}
.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: 16px;
background: rgba(255, 255, 255, 0.38);
border: 1px solid rgba(255, 255, 255, 0.24);
}
.qr-code {
display: flex;
align-items: center;
justify-content: center;
min-width: 220px;
min-height: 220px;
padding: 10px;
border-radius: 14px;
background: #ffffff;
color: var(--muted-text-color);
text-align: center;
}
.qr-code img {
display: block;
}
@media (max-width: 640px) {
nav {
padding: 16px 20px;
flex-direction: column;
align-items: flex-start;
}
.auth-shell {
padding: 20px 12px 28px;
}
.auth-panel {
padding: 24px 18px;
}
form {
padding: 18px;
}
}
+406 -111
View File
@@ -1,53 +1,110 @@
let mode = "queens"; // oder "rooks" const DEFAULT_BOARD_SIZE = 8;
const MIN_BOARD_SIZE = 4;
const MAX_BOARD_SIZE = 13;
const EMPTY_CELL = -1;
let mode = "queens";
let boardSize = DEFAULT_BOARD_SIZE;
let pendingBoardSize = DEFAULT_BOARD_SIZE;
let board = createEmptyBoard(boardSize);
const boardDiv = document.getElementById("board");
const statusMessage = document.getElementById("statusMessage");
const queenList = document.getElementById("queenList");
const btnQueens = document.getElementById("btnQueens");
const btnRooks = document.getElementById("btnRooks");
const settingsSidebar = document.getElementById("settingsSidebar");
const sidebarBackdrop = document.getElementById("sidebarBackdrop");
const saveSidebar = document.getElementById("saveSidebar");
const saveBackdrop = document.getElementById("saveBackdrop");
const savePointsSidebar = document.getElementById("savePointsSidebar");
const savePointsBackdrop = document.getElementById("savePointsBackdrop");
const boardSizeInput = document.getElementById("boardSizeInput");
const boardSizeValue = document.getElementById("boardSizeValue");
const boardSizeValueMirror = document.getElementById("boardSizeValueMirror");
const sidebarBoardSizeValue = document.getElementById("sidebarBoardSizeValue");
const sidebarBoardSizeValueMirror = document.getElementById("sidebarBoardSizeValueMirror");
const savePointsList = document.getElementById("savePointsList");
const saveNameInput = document.getElementById("saveNameInput");
const saveNoteInput = document.getElementById("saveNoteInput");
const saveFavoriteInput = document.getElementById("saveFavoriteInput");
const savePointModeFilter = document.getElementById("savePointModeFilter");
const savePointSizeFilter = document.getElementById("savePointSizeFilter");
const savePointSortFilter = document.getElementById("savePointSortFilter");
const savePointFavoritesOnly = document.getElementById("savePointFavoritesOnly");
function createEmptyBoard(size) {
return Array(size).fill(EMPTY_CELL);
}
function clampBoardSize(size) {
return Math.min(MAX_BOARD_SIZE, Math.max(MIN_BOARD_SIZE, Number(size)));
}
function getColumnLabel(index) {
return String.fromCharCode(65 + index);
}
function updateModeButtons() {
btnQueens.classList.toggle("active", mode === "queens");
btnRooks.classList.toggle("active", mode === "rooks");
}
function updateBoardSizeDisplays(size) {
boardSizeValue.textContent = size;
boardSizeValueMirror.textContent = size;
sidebarBoardSizeValue.textContent = size;
sidebarBoardSizeValueMirror.textContent = size;
boardSizeInput.value = size;
}
function setStatus(message = "", color = "") {
statusMessage.textContent = message;
statusMessage.style.color = color;
}
function updateBoardLayout() {
const cellSize = Math.max(32, Math.min(60, Math.floor(480 / boardSize)));
boardDiv.style.setProperty("--board-size", boardSize);
boardDiv.style.setProperty("--cell-size", `${cellSize}px`);
}
function setMode(newMode) { function setMode(newMode) {
mode = newMode; mode = newMode;
resetBoard(); resetBoard();
updateModeButtons();
document.getElementById("btnQueens").classList.remove("active");
document.getElementById("btnRooks").classList.remove("active");
if (mode === "queens") {
document.getElementById("btnQueens").classList.add("active");
} else {
document.getElementById("btnRooks").classList.add("active");
}
drawBoard();
} }
let board = Array(8).fill(-1); function closeAllPanels() {
const boardDiv = document.getElementById("board"); toggleSidebar(false);
toggleSavePanel(false);
toggleSavePointsPanel(false);
}
function getConflicts() { function getConflicts() {
let conflicts = []; const conflicts = [];
for (let r1 = 0; r1 < 8; r1++) { for (let rowA = 0; rowA < boardSize; rowA++) {
if (board[r1] === -1) continue; if (board[rowA] === EMPTY_CELL) {
continue;
for (let r2 = r1 + 1; r2 < 8; r2++) {
if (board[r2] === -1) continue;
let c1 = board[r1];
let c2 = board[r2];
// 🔴 Türme: nur gleiche Spalte
if (mode === "rooks") {
if (c1 === c2) {
conflicts.push([r1, c1]);
conflicts.push([r2, c2]);
}
} }
// 🔴 Damen: Spalte + Diagonale for (let rowB = rowA + 1; rowB < boardSize; rowB++) {
else if (mode === "queens") { if (board[rowB] === EMPTY_CELL) {
if ( continue;
c1 === c2 ||
Math.abs(c1 - c2) === Math.abs(r1 - r2)
) {
conflicts.push([r1, c1]);
conflicts.push([r2, c2]);
} }
const colA = board[rowA];
const colB = board[rowB];
const sameColumn = colA === colB;
const sameDiagonal = Math.abs(colA - colB) === Math.abs(rowA - rowB);
// Rooks only attack vertically, queens also attack diagonally.
const hasConflict = mode === "rooks" ? sameColumn : sameColumn || sameDiagonal;
if (hasConflict) {
conflicts.push([rowA, colA], [rowB, colB]);
} }
} }
} }
@@ -56,71 +113,48 @@ function getConflicts() {
} }
function checkSolution() { function checkSolution() {
const status = document.getElementById("statusMessage"); const allPlaced = board.every((col) => col !== EMPTY_CELL);
const allPlaced = board.every(col => col !== -1);
if (!allPlaced) { if (!allPlaced) {
status.textContent = "❗ Noch nicht alle Figuren platziert."; setStatus("❗ Noch nicht alle Figuren platziert.", "orange");
status.style.color = "orange";
return; return;
} }
const conflicts = getConflicts(); if (getConflicts().length > 0) {
setStatus("❌ Es gibt Konflikte!", "red");
if (conflicts.length > 0) {
status.textContent = "❌ Es gibt Konflikte!";
status.style.color = "red";
return; return;
} }
status.textContent = "🎉 Perfekt gelöst!"; setStatus("🎉 Perfekt geloest!", "green");
status.style.color = "green";
} }
function drawBoard() { function drawBoard() {
boardDiv.innerHTML = ""; boardDiv.innerHTML = "";
boardDiv.className = "board-grid"; boardDiv.className = "board-grid";
updateBoardLayout();
const conflicts = getConflicts(); const conflicts = getConflicts();
const letters = ["A", "B", "C", "D", "E", "F", "G", "H"];
for (let row = -1; row < 8; row++) {
for (let col = -1; col < 8; col++) {
for (let row = -1; row < boardSize; row++) {
for (let col = -1; col < boardSize; col++) {
const cell = document.createElement("div"); const cell = document.createElement("div");
// 🔹 Ecke oben links
if (row === -1 && col === -1) { if (row === -1 && col === -1) {
cell.classList.add("label"); cell.classList.add("label");
} } else if (row === -1) {
cell.textContent = getColumnLabel(col);
// 🔹 Buchstaben (oben)
else if (row === -1) {
cell.textContent = letters[col];
cell.classList.add("label"); cell.classList.add("label");
} } else if (col === -1) {
cell.textContent = boardSize - row;
// 🔹 Zahlen (links)
else if (col === -1) {
cell.textContent = 8 - row;
cell.classList.add("label"); cell.classList.add("label");
}
// 🔹 echtes Spielfeld
else {
cell.classList.add("cell");
if ((row + col) % 2 === 0) {
cell.classList.add("white");
} else { } else {
cell.classList.add("black"); cell.classList.add("cell", (row + col) % 2 === 0 ? "white" : "black");
}
if (board[row] === col) { if (board[row] === col) {
cell.textContent = (mode === "queens") ? "♛" : "♜"; cell.textContent = mode === "queens" ? "♛" : "♜";
if (conflicts.some(([r, c]) => r === row && c === col)) { // Highlight pieces that are part of at least one conflict pair.
if (conflicts.some(([conflictRow, conflictCol]) => conflictRow === row && conflictCol === col)) {
cell.classList.add("invalid"); cell.classList.add("invalid");
} }
} }
@@ -136,64 +170,325 @@ function drawBoard() {
} }
function placeQueen(row, col) { function placeQueen(row, col) {
if (board[row] === col) { board[row] = board[row] === col ? EMPTY_CELL : col;
board[row] = -1;
} else {
board[row] = col;
}
drawBoard(); drawBoard();
updateQueenList();
} }
async function solve() { function previewBoardSize(size) {
pendingBoardSize = clampBoardSize(size);
sidebarBoardSizeValue.textContent = pendingBoardSize;
sidebarBoardSizeValueMirror.textContent = pendingBoardSize;
}
if (mode === "rooks") { function applyBoardSize() {
const res = await fetch("/solve_rooks"); boardSize = pendingBoardSize;
const solution = await res.json(); board = createEmptyBoard(boardSize);
updateBoardSizeDisplays(boardSize);
board = solution; setStatus(`Brettgroesse auf ${boardSize} x ${boardSize} gesetzt.`, "#2563eb");
drawBoard(); drawBoard();
toggleSidebar(false);
}
function toggleSidebar(forceOpen) {
const shouldOpen = typeof forceOpen === "boolean" ? forceOpen : !settingsSidebar.classList.contains("open");
if (shouldOpen) {
toggleSavePanel(false);
toggleSavePointsPanel(false);
}
settingsSidebar.classList.toggle("open", shouldOpen);
sidebarBackdrop.classList.toggle("visible", shouldOpen);
settingsSidebar.setAttribute("aria-hidden", String(!shouldOpen));
}
function toggleSavePanel(forceOpen) {
const shouldOpen = typeof forceOpen === "boolean" ? forceOpen : !saveSidebar.classList.contains("open");
if (shouldOpen) {
toggleSidebar(false);
toggleSavePointsPanel(false);
}
saveSidebar.classList.toggle("open", shouldOpen);
saveBackdrop.classList.toggle("visible", shouldOpen);
saveSidebar.setAttribute("aria-hidden", String(!shouldOpen));
}
function toggleSavePointsPanel(forceOpen) {
const shouldOpen = typeof forceOpen === "boolean" ? forceOpen : !savePointsSidebar.classList.contains("open");
if (shouldOpen) {
toggleSidebar(false);
toggleSavePanel(false);
}
savePointsSidebar.classList.toggle("open", shouldOpen);
savePointsBackdrop.classList.toggle("visible", shouldOpen);
savePointsSidebar.setAttribute("aria-hidden", String(!shouldOpen));
}
function getPieceLabel(selectedMode) {
return selectedMode === "queens" ? "Dame" : "Turm";
}
function getPlacedPieceCount() {
return board.filter((col) => col !== EMPTY_CELL).length;
}
function resetSaveForm() {
saveNameInput.value = `${getPieceLabel(mode)} ${boardSize}x${boardSize} - ${getPlacedPieceCount()} gesetzt`;
saveNoteInput.value = "";
saveFavoriteInput.checked = false;
}
function openSavePanel() {
resetSaveForm();
toggleSavePanel(true);
}
function getBoardPreview(savedBoard, limit = 4) {
const preview = [];
for (let row = 0; row < savedBoard.length; row++) {
if (savedBoard[row] === EMPTY_CELL) {
continue;
}
preview.push(`${getColumnLabel(savedBoard[row])}${savedBoard.length - row}`);
if (preview.length === limit) {
break;
}
}
return preview.length > 0 ? preview.join(", ") : "Noch keine Figuren gesetzt";
}
function getSavePointFilters() {
const params = new URLSearchParams();
if (savePointModeFilter.value !== "all") {
params.set("mode", savePointModeFilter.value);
}
if (savePointSizeFilter.value !== "all") {
params.set("board_size", savePointSizeFilter.value);
}
params.set("sort", savePointSortFilter.value);
if (savePointFavoritesOnly.checked) {
params.set("favorites_only", "true");
}
return params.toString();
}
function createSavePointActionButton(label, className, onClick) {
const button = document.createElement("button");
button.className = className;
button.type = "button";
button.textContent = label;
button.onclick = onClick;
return button;
}
function renderSavePoints(savePoints) {
savePointsList.innerHTML = "";
if (savePoints.length === 0) {
savePointsList.innerHTML = '<p class="sidebar-hint">Keine Speicherstaende fuer diese Filter gefunden.</p>';
return; return;
} }
// Damen (wie vorher) for (const savePoint of savePoints) {
const res = await fetch("/solve"); const item = document.createElement("article");
const solution = await res.json(); const title = document.createElement("h4");
const createdAt = document.createElement("p");
const updatedAt = document.createElement("p");
const boardMeta = document.createElement("p");
const preview = document.createElement("p");
const note = document.createElement("p");
const actions = document.createElement("div");
board = Array.isArray(solution[0]) ? solution[0] : solution; item.className = "save-point-card";
title.textContent = `${savePoint.is_favorite ? "★ " : ""}${savePoint.save_name}`;
createdAt.className = "save-point-meta";
createdAt.textContent = `Erstellt: ${savePoint.created_at}`;
updatedAt.className = "save-point-meta";
updatedAt.textContent = `Aktualisiert: ${savePoint.updated_at}`;
boardMeta.className = "save-point-meta";
boardMeta.textContent = `${getPieceLabel(savePoint.mode)} | ${savePoint.board_size} x ${savePoint.board_size} | ${savePoint.pieces_placed}/${savePoint.board_size} gesetzt`;
preview.className = "save-point-preview";
preview.textContent = `Vorschau: ${getBoardPreview(savePoint.board)}`;
note.className = "save-point-note";
note.textContent = savePoint.save_note ? `Notiz: ${savePoint.save_note}` : "Notiz: Keine Notiz";
actions.className = "save-point-actions";
actions.appendChild(createSavePointActionButton("Laden", "btn", () => loadSavePoint(savePoint.id)));
actions.appendChild(createSavePointActionButton(
savePoint.is_favorite ? "Favorit entfernen" : "Als Favorit",
"btn secondary",
() => toggleSavePointFavorite(savePoint.id)
));
actions.appendChild(createSavePointActionButton("Loeschen", "btn ghost", () => deleteSavePoint(savePoint.id)));
item.appendChild(title);
item.appendChild(createdAt);
item.appendChild(updatedAt);
item.appendChild(boardMeta);
item.appendChild(preview);
item.appendChild(note);
item.appendChild(actions);
savePointsList.appendChild(item);
}
}
async function refreshSavePoints() {
const queryString = getSavePointFilters();
const response = await fetch(`/save_points${queryString ? `?${queryString}` : ""}`);
const savePoints = await response.json();
renderSavePoints(savePoints);
}
async function openSavePointsPanel() {
await refreshSavePoints();
toggleSavePointsPanel(true);
}
function applyLoadedGame(data) {
board = data.board;
boardSize = Array.isArray(data.board) ? clampBoardSize(data.board.length) : DEFAULT_BOARD_SIZE;
pendingBoardSize = boardSize;
mode = data.mode;
updateModeButtons();
updateBoardSizeDisplays(boardSize);
drawBoard(); drawBoard();
} }
async function solve() {
const endpoint = mode === "rooks" ? "/solve_rooks" : "/solve";
const response = await fetch(`${endpoint}?size=${boardSize}`);
const solution = await response.json();
// The queens endpoint may return multiple solutions; we display the first one.
board = mode === "queens" && Array.isArray(solution[0]) ? solution[0] : solution;
setStatus(`Loesung fuer ${boardSize} x ${boardSize} geladen.`, "green");
drawBoard();
}
function resetBoard() { function resetBoard() {
const status = document.getElementById("statusMessage"); board = createEmptyBoard(boardSize);
board = Array(8).fill(-1); setStatus();
drawBoard(); drawBoard();
updateQueenList();
status.textContent = "";
} }
drawBoard();
function updateQueenList() { function updateQueenList() {
const list = document.getElementById("queenList"); queenList.innerHTML = "";
list.innerHTML = "";
const letters = ["A", "B", "C", "D", "E", "F", "G", "H"];
let count = 1; let count = 1;
for (let row = 0; row < 8; row++) { for (let row = 0; row < boardSize; row++) {
if (board[row] !== -1) { if (board[row] === EMPTY_CELL) {
let col = board[row]; continue;
}
let pos = letters[col] + (8 - row);
const col = board[row];
const position = `${getColumnLabel(col)}${boardSize - row}`;
const li = document.createElement("li"); const li = document.createElement("li");
li.textContent = `${mode === "queens" ? "Dame" : "Turm"} ${count}: ${pos}`;
list.appendChild(li); li.textContent = `${mode === "queens" ? "Dame" : "Turm"} ${count}: ${position}`;
queenList.appendChild(li);
count++; count++;
} }
}
} }
async function saveGame() {
const response = await fetch("/save", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
board,
mode,
boardSize,
saveName: saveNameInput.value,
saveNote: saveNoteInput.value,
isFavorite: saveFavoriteInput.checked
})
});
if (!response.ok) {
setStatus("❌ Der Speicherstand konnte nicht gespeichert werden.", "red");
return;
}
const data = await response.json();
toggleSavePanel(false);
await refreshSavePoints();
setStatus(`Speicherpunkt "${data.save_point.save_name}" gespeichert.`, "green");
}
async function loadGame() {
const response = await fetch("/load");
const data = await response.json();
applyLoadedGame(data);
}
async function loadSavePoint(savePointId) {
const response = await fetch(`/load/${savePointId}`);
if (!response.ok) {
setStatus("❌ Dieser Speicherstand konnte nicht geladen werden.", "red");
return;
}
const data = await response.json();
applyLoadedGame(data);
setStatus(`Speicherpunkt "${data.save_name}" geladen.`, "green");
toggleSavePointsPanel(false);
}
async function toggleSavePointFavorite(savePointId) {
const response = await fetch(`/save_points/${savePointId}/favorite`, {
method: "POST"
});
if (!response.ok) {
setStatus("❌ Favorit konnte nicht aktualisiert werden.", "red");
return;
}
await refreshSavePoints();
setStatus("Favoritenstatus aktualisiert.", "#2563eb");
}
async function deleteSavePoint(savePointId) {
const confirmed = window.confirm("Diesen Speicherpunkt wirklich loeschen?");
if (!confirmed) {
return;
}
const response = await fetch(`/save_points/${savePointId}`, {
method: "DELETE"
});
if (!response.ok) {
setStatus("❌ Speicherpunkt konnte nicht geloescht werden.", "red");
return;
}
await refreshSavePoints();
setStatus("Speicherpunkt geloescht.", "#2563eb");
}
pendingBoardSize = boardSize;
updateModeButtons();
updateBoardSizeDisplays(boardSize);
drawBoard();
+60 -23
View File
@@ -1,32 +1,69 @@
const button = document.querySelector(".magnetic"); const magneticButtons = document.querySelectorAll(".magnetic");
button.addEventListener("mousemove", (e) => { magneticButtons.forEach((button) => {
button.addEventListener("mousemove", (event) => {
const rect = button.getBoundingClientRect(); const rect = button.getBoundingClientRect();
const x = e.clientX - rect.left - rect.width / 2; const x = event.clientX - rect.left - rect.width / 2;
const y = e.clientY - rect.top - rect.height / 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.addEventListener("mouseleave", () => {
button.style.transform = "translate(0,0)"; 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);
}); });
}); });
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", () => { window.clearTimeout(input._loginGlowTimeout);
button.innerText = "Logging in..."; input._loginGlowTimeout = window.setTimeout(() => {
button.style.opacity = "0.7"; 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
});
}); });
+115 -16
View File
@@ -6,24 +6,33 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard</title> <title>Dashboard</title>
<link rel="stylesheet" href="../static/css/dashboard.css"> <link rel="stylesheet" href="{{ url_for('static', filename='css/dashboard.css') }}">
</head> </head>
<body> <body>
<!-- 🔝 Navbar -->
<nav class="navbar"> <nav class="navbar">
<div class="nav-left">♛ 8-Queens App</div> <div class="nav-left">♛ 8-Queens App</div>
<div class="nav-right"> <div class="nav-right">
<a href="{{ url_for('main.dashboard') }}">Dashboard</a> <a href="{{ url_for('main.dashboard') }}">Dashboard</a>
<a href="{{ url_for('login.logout') }}">Logout</a> <a href="{{ url_for('login.login_settings') }}">Login settings</a>
<form action="{{ url_for('login.logout') }}" method="post" class="nav-logout-form">
<button class="nav-link-button" type="submit">Logout</button>
</form>
</div> </div>
</nav> </nav>
<!-- 📦 Container -->
<div class="container"> <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"> <div class="card">
<h2>👋 Willkommen, {{ current_user.username }}</h2> <h2>👋 Willkommen, {{ current_user.username }}</h2>
@@ -31,29 +40,28 @@
<p><strong>ID:</strong> {{ current_user.id }}</p> <p><strong>ID:</strong> {{ current_user.id }}</p>
<p><strong>Username:</strong> {{ current_user.username }}</p> <p><strong>Username:</strong> {{ current_user.username }}</p>
{% if current_user.email %} <p><strong>2FA:</strong> {% if current_user.two_factor_enabled %}Authenticator app enabled{% else %}Disabled{% endif %}</p>
<p><strong>Email:</strong> {{ current_user.email }}</p>
{% else %}
<p><strong>Email:</strong> Not set</p>
{% endif %}
<p><strong>Erstellt:</strong> {{ current_user.created_at }}</p> <p><strong>Erstellt:</strong> {{ current_user.created_at }}</p>
</div> </div>
<div class="inline-actions">
<a class="btn secondary" href="{{ url_for('login.login_settings') }}">Open login settings</a>
<form action="{{ url_for('login.logout') }}" method="post"> <form action="{{ url_for('login.logout') }}" method="post">
<button class="btn danger" type="submit">Logout</button> <button class="btn danger" type="submit">Logout</button>
</form> </form>
</div> </div>
</div>
<div class="mode-switch"> <div class="mode-switch">
<button class="btn active" onclick="setMode('queens')" id="btnQueens">♛ Damen</button> <button class="btn active" onclick="setMode('queens')" id="btnQueens">♛ Damen</button>
<button class="btn secondary" onclick="setMode('rooks')" id="btnRooks">♜ Türme</button> <button class="btn secondary" onclick="setMode('rooks')" id="btnRooks">♜ Türme</button>
<button class="btn secondary" onclick="toggleSidebar()" id="btnSidebar">Board Settings</button>
</div> </div>
<!-- ♟️ Game Card -->
<div class="card game-card"> <div class="card game-card">
<h2>♟️ 8-Damen Spiel</h2> <h2>♟️ Damen- und Tuerme-Spiel</h2>
<p class="board-size-info">Aktuelle Brettgroesse: <span id="boardSizeValue">8</span> x <span
id="boardSizeValueMirror">8</span></p>
<p id="statusMessage"></p> <p id="statusMessage"></p>
<div class="board" id="board"></div> <div class="board" id="board"></div>
@@ -62,16 +70,107 @@
<button class="btn" onclick="solve()">Solve</button> <button class="btn" onclick="solve()">Solve</button>
<button class="btn" onclick="checkSolution()">Check</button> <button class="btn" onclick="checkSolution()">Check</button>
<button class="btn secondary" onclick="resetBoard()">Reset</button> <button class="btn secondary" onclick="resetBoard()">Reset</button>
<button class="btn" onclick="openSavePanel()">💾 Save</button>
<button class="btn" onclick="openSavePointsPanel()">📂 Load</button>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<h3>📋 Damen Positionen</h3> <h3>📋 Figuren Positionen</h3>
<ul id="queenList"></ul> <ul id="queenList"></ul>
</div> </div>
</div> </div>
<script src="../static/js/dashboard.js"></script> <aside class="sidebar" id="settingsSidebar" aria-hidden="true">
<div class="sidebar-header">
<h3>Brettgroesse</h3>
<button class="btn secondary sidebar-close" onclick="toggleSidebar(false)" type="button">Schliessen</button>
</div>
<p>Wähle eine Grösse zwischen 4 und 13.</p>
<label class="sidebar-label" for="boardSizeInput">Groesse</label>
<input id="boardSizeInput" type="range" min="4" max="13" value="8" oninput="previewBoardSize(this.value)">
<div class="sidebar-size-row">
<strong><span id="sidebarBoardSizeValue">8</span> x <span id="sidebarBoardSizeValueMirror">8</span></strong>
</div>
<button class="btn" onclick="applyBoardSize()" type="button">Apply Size</button>
<p class="sidebar-hint">Das Brett wird beim Ändern zur Sicherheit zurückgesetzt.</p>
</aside>
<aside class="sidebar save-sidebar" id="saveSidebar" aria-hidden="true">
<div class="sidebar-header">
<h3>Speicherpunkt</h3>
<button class="btn secondary sidebar-close" onclick="toggleSavePanel(false)" type="button">Schliessen</button>
</div>
<p>Gib dem Spielstand einen klaren Namen und optional eine kurze Notiz.</p>
<label class="sidebar-label" for="saveNameInput">Name</label>
<input class="sidebar-input" id="saveNameInput" type="text" maxlength="80" placeholder="z. B. Fast geloeste Dame 8x8">
<label class="sidebar-label" for="saveNoteInput">Notiz</label>
<textarea class="sidebar-input sidebar-textarea" id="saveNoteInput" rows="4" maxlength="240"
placeholder="z. B. Gute Startposition fuer Tuermetest"></textarea>
<label class="sidebar-checkbox">
<input id="saveFavoriteInput" type="checkbox">
Als Favorit markieren
</label>
<button class="btn" onclick="saveGame()" type="button">Jetzt speichern</button>
</aside>
<aside class="sidebar save-points-sidebar" id="savePointsSidebar" aria-hidden="true">
<div class="sidebar-header">
<h3>Save Points</h3>
<button class="btn secondary sidebar-close" onclick="toggleSavePointsPanel(false)" type="button">Schliessen</button>
</div>
<p>Waehle einen Speicherstand aus der Liste.</p>
<div class="save-point-filters">
<label class="sidebar-label" 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">Tuerme</option>
</select>
<label class="sidebar-label" for="savePointSizeFilter">Brettgroesse</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>
<label class="sidebar-label" for="savePointSortFilter">Sortierung</label>
<select class="sidebar-input" id="savePointSortFilter" onchange="refreshSavePoints()">
<option value="newest">Neueste zuerst</option>
<option value="oldest">Aelteste zuerst</option>
<option value="updated">Zuletzt bearbeitet</option>
<option value="favorites">Favoriten zuerst</option>
</select>
<label class="sidebar-checkbox">
<input id="savePointFavoritesOnly" type="checkbox" onchange="refreshSavePoints()">
Nur Favoriten anzeigen
</label>
</div>
<div class="save-points-list" id="savePointsList">
<p class="sidebar-hint">Noch keine Speicherstaende gefunden.</p>
</div>
</aside>
<div class="sidebar-backdrop" id="sidebarBackdrop" onclick="toggleSidebar(false)"></div>
<div class="sidebar-backdrop" id="saveBackdrop" onclick="toggleSavePanel(false)"></div>
<div class="sidebar-backdrop" id="savePointsBackdrop" onclick="toggleSavePointsPanel(false)"></div>
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
</body> </body>
+25 -13
View File
@@ -5,35 +5,47 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login</title> <title>Login</title>
<link rel="stylesheet" href="../static/css/login.css"> <link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
<link rel="stylesheet" href="../static/css/login.css">
</link>
</head> </head>
<body> <body>
<nav> <nav>
<ul> <ul>
<li><a href="{{ url_for('login.login') }}"><span>Login</span></a></li> <li><a href="{{ url_for('login.login') }}">Login</a></li>
<li><a href="{{ url_for('login.register') }}"><span>Create account</span></a></li> <li><a href="{{ url_for('login.register') }}">Create account</a></li>
</ul> </ul>
</nav> </nav>
<main> <main class="auth-shell">
<section class="auth-panel">
<h1>Login to your account</h1> <h1>Login to your account</h1>
<p class="muted">If two-factor authentication is enabled, enter the current 6-digit code from your authenticator app after the password check.</p>
{% 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 %} {% if error %}
<p class="error">{{ error }}</p> <p class="message error">{{ error }}</p>
{% endif %} {% endif %}
<form action="{{ url_for('login.login') }}" method="post"> <form action="{{ url_for('login.login') }}" method="post">
<label for="username">Username:</label> <label for="username">Username</label>
<input type="text" name="username" required /> <input id="username" type="text" name="username" maxlength="20" required value="{{ username or '' }}">
<label for="password">Password:</label>
<input type="password" name="password" required /> <label for="password">Password</label>
<button class="magnetic" type="submit">Login</button> <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> </form>
</section>
</main> </main>
<script src="../static/js/login.js" defer></script>
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
</body> </body>
</html> </html>
+123
View File
@@ -0,0 +1,123 @@
<!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>
<ul>
<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>
+25 -8
View File
@@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign Up</title> <title>Sign Up</title>
<link rel="stylesheet" href="../static/css/login.css"> <link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
</head> </head>
<body> <body>
@@ -15,21 +15,38 @@
<li><a href="{{ url_for('login.register') }}">Create account</a></li> <li><a href="{{ url_for('login.register') }}">Create account</a></li>
</ul> </ul>
</nav> </nav>
<main>
<main class="auth-shell">
<section class="auth-panel">
<h1>Create an account</h1> <h1>Create an account</h1>
<p class="muted">Create your account here and enable authenticator app 2FA later in your login settings.</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 %} {% if error %}
<p class="error">{{ error }}</p> <p class="message error">{{ error }}</p>
{% endif %} {% endif %}
<form action="{{ url_for('login.register') }}" method="post"> <form action="{{ url_for('login.register') }}" method="post">
<label for="username">Username:</label> <label for="username">Username</label>
<input type="text" name="username" required /> <input id="username" type="text" name="username" maxlength="20" required value="{{ username or '' }}">
<label for="password">Password:</label>
<input type="password" name="password" required /> <label for="password">Password</label>
<button type="submit">Sign Up</button> <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> </form>
</section>
</main> </main>
<script src="{{ url_for('static', filename='js/login.js') }}" defer></script>
</body> </body>
</html> </html>
+49
View File
@@ -0,0 +1,49 @@
<!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>
<ul>
<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>
+29 -4
View File
@@ -1,7 +1,32 @@
CREATE TABLE users ( CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY, id INTEGER PRIMARY KEY AUTOINCREMENT,
username VARCHAR(20) NOT NULL UNIQUE, username VARCHAR(20) NOT NULL UNIQUE,
password VARCHAR(80) NOT NULL, password VARCHAR(255) NOT NULL,
email VARCHAR(120) NOT NULL UNIQUE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
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,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );