From ecfe5c09a831e0b659c808a01fc593e974cbc796 Mon Sep 17 00:00:00 2001 From: ScriptSorcerer20 Date: Thu, 26 Mar 2026 11:06:27 +0100 Subject: [PATCH 01/15] Simple chessboard design + slove button (shows you the solution with the algorithm) and marks conflicts with damen red. conflict still has some errors (damen despawn somethimes) and css is very simple. --- .../__pycache__/main_routes.cpython-311.pyc | Bin 1006 -> 3283 bytes app/routes/main_routes.py | 53 ++++++- app/static/css/dashboard.css | 116 ++++++++++++++ app/templates/dashboard.html | 145 +++++++++++++++--- app/templates/index.html | 2 +- 5 files changed, 290 insertions(+), 26 deletions(-) create mode 100644 app/static/css/dashboard.css diff --git a/app/routes/__pycache__/main_routes.cpython-311.pyc b/app/routes/__pycache__/main_routes.cpython-311.pyc index 506fedf814e44ded2daa3b5565810373da0a9955..6d84cdad740e1bfb63718b98e357b9289c750d05 100644 GIT binary patch literal 3283 zcmb7GU2GFq7QT0W{5RttVj2R(G(ekRAVF1jMT#t-%TGaN(@LRIYh`6kJd@bqpKxc~ zq>-cbR#X{Bei&9DOx2b0#1=uNzU=G1?YoU!bu2ZmK6n+agm~GWd;OExDQbJ- zIl1@TbAQe~-#zF2)9?2opl95F(m#m^{YX0<655TeYZ#&XNJT2npe&B#EEngpe4Nh; zaRE~~ml3nBxGU?9yD{RBhE-nkJme613}1`lUd_9~_UcGF|5f23tp6Ck7OCQG^bnel zI|3A!UOU&-J6Ch5?*7?*Yss%&%cFW1@DpF}xmBruy}19iE6Ba4QT@B-2Gqa|iUwa$ z+oFQw{Zpo(Es}K3aNIZad@eozxf3K>PSr@l(6Wme#n2o#(QXwq-H38dAd_E6=Mq$q zCYtKV$pQg&BT>*b;s{wKoqL5(CA&8V4Zi(tjX>i*GP?JSz@NS6g_Cj9E50;rzdVsL zvY99*01EU7Z2?EGE+@~-&LmTXj5hnh%$3=z7e9J$R!OFeaaGA`xpAYQk*qepNb++T zEjz0$F3ysC!O-+sdagvPJ+b(?Bc$?KjRavy7kqjMSP4Cs4pd`x=~zQLRuhg9KL}TR zr&L8x&E*xM_Si&`N$uL20M>7l(Zw7>Pq#S8&1b6N444_pEeQrmegFe5b&l}WpZ8F@Q z7DB~7r}skOBKk{uRVd=3uwyN;D5}CucCEUrC=|I?o8Zd6qFChHy|Cfk-bEEvSGsSf zB!|%qGNf%SI(r4~viA%<8nD}Ljo-IpMWFKV|BMv4GPU>{_ z2%{FHM1~D=1jO_y=-&gQ&tRYuD_ykZkhQP!=h7wH7cdKz6Qv8?#&iF`y-BNZ=gi$R zb^oD;|4gZ%VSot)DheTS|5E?VXI&j03^dxo58SYFjOSF0S>mJM`YLjZ)n^T??cEdi*3W- zEW0*9x|_;8)F0@}9hcva0+j}x9WE&M;RGBwx{3ioLXieX0K(%g7|2i&@K;1J*cdcu zki{#|-n9qU(W;Em{UFP{pr#Ru3CP;*e(SFD2N>1YfPv?1DPSF@&)t^a_(2|iDi2pb-gt-p zef>#Y9&N~@HF?yQ2h7XQ!$Z|Qcc$)6HId*QVa&W>2M5h1ORrw22S*w~kn;W!J2GO4 zR`QO|4(+$R)uS8u!B{5$L6SR90 z7(?lKO6?HIj3g9{V|4hhz+O@+p-qps1RxwJUA6r|^P=@m`AX?hQxL_yP4o*a;1%Kg zzmW|hF;Kc{Du26n`&#MRGiktjw=P8*QlutCm^stYpd-#_6#XWlz+z^#Y6q4gBsP>_ zwHl%BVCAYm3`IduCF{TG4Jroe`}la@C|H-<`)IzHaO p$D7<<{8keIqv|KRl8Me1%XK`|z(Y-L2t%?1M)mghNSK)U=4YYNydwYr delta 396 zcmcaC`Hr1$IWI340}$+evNkh;aU!3D(=i}#IztLW3S$mKE@Kp9E>jc}BZ$wG!<@?! z#gfY!#mdOQ#E{CE!ko&sjER9^H4sBU6niTB0*;A=!V{ZSSUF+r{X#x0DeNr_%Ycej z!<3@&qPS8xf*CY9UxMT{nQn19<&>rt6lLa>+~O}v%}YrwiZ4mcEyzhMNu9iwv6PWz zvNBU7Bk$x|CRs-A$+MYCRat- - - + Dashboard + + - -
-

Create an account

- {% if error %} -

{{ error }}

- {% endif %} +
+
+

Create an account

+

Create your account here and enable authenticator app 2FA later in your login settings.

-
- - - - - -
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +

{{ message }}

+ {% endfor %} + {% endif %} + {% endwith %} + + {% if error %} +

{{ error }}

+ {% endif %} + +
+ + + + + +

Use at least 8 characters.

+ + +
+
+ + - \ No newline at end of file + diff --git a/app/templates/two_factor_verify.html b/app/templates/two_factor_verify.html new file mode 100644 index 0000000..e5af10b --- /dev/null +++ b/app/templates/two_factor_verify.html @@ -0,0 +1,49 @@ + + + + + + + Verify Login + + + + + + +
+
+

Verify your login

+

Open your authenticator app and enter the current 6-digit code.

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +

{{ message }}

+ {% endfor %} + {% endif %} + {% endwith %} + + {% if error %} +

{{ error }}

+ {% endif %} + +
+ + +

Codes refresh automatically every 30 seconds in your authenticator app.

+ + +
+
+
+ + + + + diff --git a/database_setup.sql b/database_setup.sql index 161cd8b..216606d 100644 --- a/database_setup.sql +++ b/database_setup.sql @@ -1,16 +1,28 @@ 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, -- JSON String (z.B. "[0,4,7,5,2,6,1,3]") - mode TEXT NOT NULL, -- "queens" oder "rooks" + 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 '', From 02cb1d0a640a88fa7927e398a001cad11c13e6b1 Mon Sep 17 00:00:00 2001 From: ScriptSorcerer20 Date: Wed, 1 Apr 2026 11:54:40 +0200 Subject: [PATCH 08/15] added step wise solution and an animation. --- __pycache__/run.cpython-312.pyc | Bin 0 -> 275 bytes app/__init__.py | 5 +- app/__pycache__/__init__.cpython-311.pyc | Bin 2477 -> 2477 bytes app/__pycache__/__init__.cpython-312.pyc | Bin 4439 -> 2108 bytes .../__pycache__/gamestate.cpython-311.pyc | Bin 1747 -> 1747 bytes .../__pycache__/gamestate.cpython-312.pyc | Bin 1743 -> 1743 bytes app/models/__pycache__/user.cpython-311.pyc | Bin 1475 -> 1475 bytes .../__pycache__/login_routes.cpython-311.pyc | Bin 3515 -> 3515 bytes .../__pycache__/main_routes.cpython-311.pyc | Bin 13583 -> 17527 bytes .../__pycache__/main_routes.cpython-312.pyc | Bin 11962 -> 15316 bytes app/routes/main_routes.py | 121 ++++- app/static/css/dashboard.css | 133 ++++++ app/static/js/dashboard.js | 432 +++++++++++++++++- app/templates/dashboard.html | 25 +- 14 files changed, 689 insertions(+), 27 deletions(-) create mode 100644 __pycache__/run.cpython-312.pyc diff --git a/__pycache__/run.cpython-312.pyc b/__pycache__/run.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b970ad63162bc8b0a8b0a76f84b7232f8a14f530 GIT binary patch literal 275 zcmX@j%ge<81pJTAWTpe@#~=<2Fhd!i{eX<=3@HpLj5!Rsj8Tk?3``8EjH{v2Q4Fa} zDNNZK3=BoWsSH`{FzpCBg?SCjYDNg3k)e{cl2w!KC5WrZc#A8!C^fMpH9oPR;1)-G zd~RZ9UVMBAP=YljHK{b+Pm|>qGf=JwlIY~ z;;_lhPbtkwwJYKQ3WMw`)&deAm>C%v?=na|WD{u4X<&Z9%-vCSiCOXn3wyhJqx%Gv S2KTEh;vbk9Sfq+TVF3UJPd&{5 literal 0 HcmV?d00001 diff --git a/app/__init__.py b/app/__init__.py index ddb86b3..621c801 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,7 +1,8 @@ -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() diff --git a/app/__pycache__/__init__.cpython-311.pyc b/app/__pycache__/__init__.cpython-311.pyc index c75a2cb53ecd25c07bde4182f6d7f95ab4362cfc..f13f5cffc8dfe2f8c316f537ff1ca170f21840ce 100644 GIT binary patch delta 257 zcmZ20yjGZZIWI340}#A;aVGQDL|#?CD25b<6s8=;T&5@{Muv$F(xPn3fby$BiohU> zorxipWnzK25o0ieCfiGppr0n=EmpUj#NuoqgU2U7Ju}ZYF)uMawFt=P3J&yf%t_8j z&8^(Hb1e&_?B;aVDU6IbLf mM2I<)|8m&dayEeB0|xO6sOScR$^}$(gF*cQDw zorxipZDN7A5mPXOCfiGppeExjR=1qQ;%q-4iz_(L$1x{4BQ>`Y$m8+JPtVNrP0UM7 zPc7QGb1e&_%;t2~DU6JqoByz#VKlwPng-HX#0)g1ND@fg5&#Rt7Z>IvLQE-Q0SR+M ogmXZ~P5#SaZ!3O*0R`P);A{ZH8w~0fP|*zrl?$k7awVq;01UrM-2eap diff --git a/app/__pycache__/__init__.cpython-312.pyc b/app/__pycache__/__init__.cpython-312.pyc index ae97128424de79a8757e3b913bbd82e78df38ceb..e7768a0ce75829f99a5bab57a2bc7ad0b1896dbc 100644 GIT binary patch delta 692 zcmYLGO=uHA7@gU_WRvVB+9qimE3Fn~g+{BTCs89-FIBLmmql3C>>5{RH*sbI1}zQf zMG!>h;8E}<9u-d>MDe6Yp}5+E2cgAFp@NW$&bI;m4sYLkGxNTW{WcD~DVZM)BZow7 zZG8$7`LQ{+zAV%e7XeHXFx4Lv-A?DI;{;IaH0Y?_R@AY_E|l2gUA_jTIG9+Rs#a)C;(ifRe*H7j9^n~ouMOw{}1kT=&Uec zr|b{ncZ$zOusZfFn|owDFdi0v%R>Fq_(JLIdQH$BBw)ax(fecN#j^aNtS;`)SUEZl zvFbHg6_tVKQ|=Jh8CAe34IOrixWlDURoK)7mL!}mM2Ekt#d}x!(wEXcZuX5`WfB+Dn7=qJwT}P* literal 4439 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 n)bIe+{T|i#o2AOKcrAz!T6P(*X diff --git a/app/models/__pycache__/gamestate.cpython-311.pyc b/app/models/__pycache__/gamestate.cpython-311.pyc index 9a1f79a2f5a8535fcb9975de9cf305e3db026cce..15f2556c1dd6a1c22e048fbd7d8d72b698dd5ef1 100644 GIT binary patch delta 22 ccmcc2dzqJaIWI340}x0*I+JO*k@pxI07zQ~h5!Hn delta 22 ccmcc2dzqJaIWI340}zy~J(Vf5k@pxI07+d2lK=n! diff --git a/app/models/__pycache__/gamestate.cpython-312.pyc b/app/models/__pycache__/gamestate.cpython-312.pyc index db13518392aa5ee34fb3b8da4780cd331dbc795d..7606bb26f25e6ca489169a9434b2f42cdcf3b7cc 100644 GIT binary patch delta 22 ccmX@ld!Co~G%qg~0}x0*I+JO*k@pZA07?%Ao&W#< delta 22 ccmX@ld!Co~G%qg~0}zy~J(Vf5k@pZA080@Ds{jB1 diff --git a/app/models/__pycache__/user.cpython-311.pyc b/app/models/__pycache__/user.cpython-311.pyc index 0748aa0ac9ad887b021489ae3b80c092f71d8fda..1ecb09acf640c7fe0cbdd9e7b05c761acc9be631 100644 GIT binary patch delta 20 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 delta 3292 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 DZfkK9 delta 3140 zcma)8TWlOx8J@Fq*}D_39bfhmuN~X#u^p>PjO(-^p%6QD+PF&W1Z8O0jCW@3NoIGJ zGwZmCT5M1%DtPGSv}y{yq*S6xourjq@c;_tfv5H*&?2F!Kp^n|ybzK+01uS^KjU3H z#*r9lfB)zF-}%pfKmKgtXQvuIiN_-XeD7ZS*!pGi#fG-U+W|p%RWJmD*n;N&B^rTs zzz&o|O)N>8RFXBhq-Y93xo8JVAuUu2YhhSRhHOVlQ7y`O#g3KYS{(SG)?kFREk;<| z8W2p;h?tGba1(x7!fYrKBYKf&O@a-eS=Qr!gEeV(ZOf5kLSuDmDSAaVxSMrO-@=|}_THC1%#n)#xB&Y@Nn7+asE zy&y?4S5a4v2Sw7Mij(8hYQU4JUY<2QQJ-;D*%PU=;E4stW(%QFl4c)-Cgd(S=(7KW zrb&(+4K}mc@Oz^w?7?A0uT;#kK?iUdq&wjc9YT^qvKxpOt=M|O%zta%G|O}sn}~EO zXd@kAl}IlcVy{N#CUUreg?dF$<+ZbP1ow9#c@T&z13CQ__$c=><~sJdRn0El3&QQ} z(dfh6%69ff^i*#n?5UC`*_K=7HhKzpYd9q@=+4a-i?*o-Xo_WHsaCEr>T9HqbJnE? z;P98(vDhHVtqgDbh{VumIt*lzjrlAia%Mau?T@-ydY5Vn2K6a+{*opn8N)~r&jL|$sX zSCV161t>1Jz}@nl4mlKJ1bS43&fxjDYuaX^Y8rVzdZ^>;C}6+MbU~Q^nMunwtfyDn zx1S`CV7KG&2v!3fMZ(L6Cs3MmAb<{R;?Io(SrYynO5yNVD?-AoO9IQUugz)k4@&8@tXeU6;wIbN%K9_Mes35wO? z$>$(P2K1RiF;H|8iHVZ1S)K<|F}Z`dDFIQ#o@D4%ou_-Xnb@;k(~aCWxiDuI&eCrK z@B;f=*G&k+&vz{M^MG^Z2I@iOTp4c&`b}86INPCa*bCil@^^uMiCya+hU1CepB!0& zHCT6FE!YM3Xo0oYxUNs~)R_k-F2epL(3@j}xj5){`dTAl*c0J*=c$z!`W8r_nqYmY zG&|BCB`xe^e{aLp$TDUDzGs5fHugk+y6X&Dvf+C=Seu7L@L_>79qiY;Gp(I9q!F#| zsPRt+S?55~;+esGcwwH=DQFfb?YFm3H?Q%gyjV}I|b|2U8ib#5z8H49iA}O zP5{Qw2~Y3FK4#YkIunU?3Fg5x*`Ee7ynoiZ#gmQsQpKhC-vEpt({(MU4BcTEo>Fj1 zC9CSE4*OUQ(WmP7o=0xuy|{M_h#LZuUH$y=5JvxCGQ`2dS3vqE!X)<2;Op$Moju{Z zTS5tC>G0iVwzzXYBsJT zC(0NCPcZsG-w7NSyrpRYIZQ4e6P{eOC=58R69)_r0euO%myw{yR{~y;4>QZ~{i@%1 z>w#?K$AI`1{M>mKA020>ckO}M_VTU;viKuZ@H&z=kf5*h2SB`_0b>QGiW;T&GmO54 zXl@oJgW%dmaV=xy|+c^=}5W5vr=`YwQegFrOy5c|WP-C$hf@b`&v9CrCE zpg0xaxv7_>8oR$$b9f4$1zJQ>H_}TS92gKvDf4k-%io%Ruh&JHd6k6|-O!t%85aH30ox zVBygx7jwX>J^qB-2Rx+Q*4?ud2Y?@7K6P?2_KO5dnD?UJ8(Z-mu-zdn z2mb}+W?w;63z9Ze6Uyhy_(tZvU};ge%K4efy7veFA>fZVFD_o#`1Ikh!pDIRcs@-j z&O|=Ic&|{bZHnc_)5-%$(a{Z$&A&tYz{+{s+)qCgglz&&S@$l`TOuLkb0PY<5NEIL z-5*Jl)b0HuN#2gJ+k4+)*B@%`es1A%%jM8g=2N2nj%@iyAW4FEL?N&hN&hVgmbV^a L^247<7}fs*K4HV5 diff --git a/app/routes/main_routes.py b/app/routes/main_routes.py index c1b77b2..15ea363 100644 --- a/app/routes/main_routes.py +++ b/app/routes/main_routes.py @@ -1,5 +1,6 @@ import json import random +from datetime import datetime from flask import Blueprint, jsonify, render_template, request from flask_login import current_user, login_required @@ -27,6 +28,10 @@ def parse_favorite_flag(raw_value): return str(raw_value).lower() in {"1", "true", "yes", "on"} +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 = [] @@ -64,6 +69,105 @@ def solve_rooks(board_size): 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) @@ -149,12 +253,24 @@ def solve_queens_route(): return jsonify(solve_queens(board_size)) +@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)) + + @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(): @@ -166,6 +282,7 @@ def save_game(): 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")) + timestamp = datetime.utcnow() game_state = GameState( user_id=current_user.id, @@ -174,7 +291,9 @@ def save_game(): board_size=board_size, save_name=save_name, save_note=save_note, - is_favorite=is_favorite + is_favorite=is_favorite, + created_at=timestamp, + updated_at=timestamp ) db.session.add(game_state) diff --git a/app/static/css/dashboard.css b/app/static/css/dashboard.css index c3ca90f..bf902fd 100644 --- a/app/static/css/dashboard.css +++ b/app/static/css/dashboard.css @@ -44,6 +44,12 @@ body { text-align: center; } +#statusMessage { + min-height: 24px; + margin: 0 0 8px; + font-weight: bold; +} + .board { overflow-x: auto; padding-bottom: 8px; @@ -91,6 +97,21 @@ body { transform: scale(1.05); } +.cell.solver-place { + box-shadow: inset 0 0 0 4px #22c55e; + animation: solverPulse 0.35s ease; +} + +.cell.solver-remove { + box-shadow: inset 0 0 0 4px #f59e0b; + animation: solverPulse 0.35s ease; +} + +.cell.solver-solution { + box-shadow: inset 0 0 0 4px #2563eb; + animation: solverPulse 0.35s ease; +} + .white { background: #e2e8f0; } @@ -107,6 +128,10 @@ body { /* 🎛️ Buttons */ .controls { margin-top: 15px; + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 10px; } .btn { @@ -123,6 +148,12 @@ body { background: #2563eb; } +.btn:disabled, +.btn:disabled:hover { + background: #94a3b8; + cursor: not-allowed; +} + .btn.secondary { background: #64748b; } @@ -163,6 +194,79 @@ body { background: #22c55e; } +.solver-panel { + margin-top: 12px; + padding: 16px; + border-radius: 12px; + border: 1px solid #dbe4ee; + background: #f8fafc; + text-align: left; +} + +.solver-panel-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; +} + +.solver-panel h3 { + margin: 0 0 6px; + color: #1e293b; +} + +.solver-message { + margin: 0; + color: #475569; +} + +.solver-step-info { + margin: 0; + padding: 6px 10px; + border-radius: 999px; + background: #dbeafe; + color: #1d4ed8; + font-size: 14px; + font-weight: bold; + white-space: nowrap; +} + +.solver-controls { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 14px; +} + +.solver-controls .btn, +.controls .btn { + margin: 0; +} + +.solver-speed { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 12px; + margin-top: 14px; +} + +.solver-speed input[type="range"] { + width: 100%; +} + +.solver-speed-label { + font-weight: bold; + color: #334155; +} + +.solver-speed-value { + min-width: 62px; + text-align: right; + color: #475569; + font-variant-numeric: tabular-nums; +} + .board-size-info { margin: 0 0 10px; color: #475569; @@ -175,10 +279,14 @@ body { width: min(320px, 85vw); height: 100vh; padding: 24px; + box-sizing: border-box; background: white; box-shadow: -10px 0 24px rgba(15, 23, 42, 0.18); transform: translateX(100%); transition: transform 0.25s ease; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; z-index: 30; } @@ -329,6 +437,20 @@ body { pointer-events: auto; } +@keyframes solverPulse { + 0% { + transform: scale(0.97); + } + + 50% { + transform: scale(1.03); + } + + 100% { + transform: scale(1); + } +} + @media (max-width: 720px) { .container { padding: 12px; @@ -337,4 +459,15 @@ body { .navbar { padding: 15px 18px; } + + .solver-panel-header, + .solver-speed { + grid-template-columns: 1fr; + display: grid; + } + + .solver-step-info, + .solver-speed-value { + justify-self: start; + } } diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js index 437d712..ca8b30c 100644 --- a/app/static/js/dashboard.js +++ b/app/static/js/dashboard.js @@ -2,17 +2,34 @@ const DEFAULT_BOARD_SIZE = 8; const MIN_BOARD_SIZE = 4; const MAX_BOARD_SIZE = 13; const EMPTY_CELL = -1; +const DEFAULT_SOLVER_MESSAGE = "Bereite einen Ablauf vor und spiele ihn Schritt fuer Schritt ab."; let mode = "queens"; let boardSize = DEFAULT_BOARD_SIZE; let pendingBoardSize = DEFAULT_BOARD_SIZE; let board = createEmptyBoard(boardSize); +let solverSteps = []; +let solverStepIndex = -1; +let solverTimerId = null; +let solverSpeedMs = 320; +let solverLoading = false; +let solverRequestId = 0; +let currentSolverHighlight = null; + const boardDiv = document.getElementById("board"); const statusMessage = document.getElementById("statusMessage"); const queenList = document.getElementById("queenList"); const btnQueens = document.getElementById("btnQueens"); const btnRooks = document.getElementById("btnRooks"); +const btnPrepareSolve = document.getElementById("btnPrepareSolve"); +const btnInstantSolve = document.getElementById("btnInstantSolve"); +const btnNextStep = document.getElementById("btnNextStep"); +const btnPlayPause = document.getElementById("btnPlayPause"); +const solverMessage = document.getElementById("solverMessage"); +const solverStepInfo = document.getElementById("solverStepInfo"); +const solverSpeedInput = document.getElementById("solverSpeedInput"); +const solverSpeedValue = document.getElementById("solverSpeedValue"); const settingsSidebar = document.getElementById("settingsSidebar"); const sidebarBackdrop = document.getElementById("sidebarBackdrop"); const saveSidebar = document.getElementById("saveSidebar"); @@ -63,6 +80,21 @@ function setStatus(message = "", color = "") { statusMessage.style.color = color; } +function setSolverMessage(message = DEFAULT_SOLVER_MESSAGE) { + solverMessage.textContent = message; +} + +function updateSolverSpeed(rawValue) { + solverSpeedMs = Number(rawValue); + solverSpeedInput.value = String(solverSpeedMs); + solverSpeedValue.textContent = `${solverSpeedMs} ms`; + + if (isSolverPlaying()) { + stopSolverPlayback(false); + scheduleNextSolverPlaybackStep(); + } +} + function updateBoardLayout() { const cellSize = Math.max(32, Math.min(60, Math.floor(480 / boardSize))); @@ -70,10 +102,85 @@ function updateBoardLayout() { boardDiv.style.setProperty("--cell-size", `${cellSize}px`); } +function isSolverPlaying() { + return solverTimerId !== null; +} + +function hasPreparedSolverTrace() { + return solverSteps.length > 0; +} + +function hasCompletedSolverTrace() { + return hasPreparedSolverTrace() && solverStepIndex >= solverSteps.length - 1; +} + +function stopSolverPlayback(refreshControls = true) { + if (solverTimerId !== null) { + window.clearTimeout(solverTimerId); + solverTimerId = null; + } + + if (refreshControls) { + updateSolverControls(); + } +} + +function updateSolverControls() { + const currentStep = Math.max(0, solverStepIndex + 1); + + btnPrepareSolve.disabled = solverLoading; + btnInstantSolve.disabled = solverLoading; + btnNextStep.disabled = solverLoading || hasCompletedSolverTrace(); + btnPlayPause.disabled = solverLoading; + + btnPrepareSolve.textContent = solverLoading ? "Preparing..." : "Solve Step-by-Step"; + + if (isSolverPlaying()) { + btnPlayPause.textContent = "Pause"; + } else if (hasCompletedSolverTrace()) { + btnPlayPause.textContent = "Replay Animation"; + } else { + btnPlayPause.textContent = "Play Animation"; + } + + solverStepInfo.textContent = `Schritt ${currentStep} / ${solverSteps.length}`; +} + +function discardSolverTrace(message = DEFAULT_SOLVER_MESSAGE) { + solverRequestId += 1; + solverLoading = false; + stopSolverPlayback(false); + + solverSteps = []; + solverStepIndex = -1; + currentSolverHighlight = null; + + setSolverMessage(message); + updateSolverControls(); +} + +function rewindSolverTrace() { + stopSolverPlayback(false); + solverStepIndex = -1; + currentSolverHighlight = null; + board = createEmptyBoard(boardSize); + setSolverMessage("Ablauf zurueckgesetzt. Starte die Animation erneut oder gehe weiter mit Next Step."); + drawBoard(); + updateSolverControls(); +} + function setMode(newMode) { + if (mode === newMode) { + return; + } + mode = newMode; - resetBoard(); updateModeButtons(); + resetBoard({ + statusMessageText: `${getPieceLabel(mode)}-Modus aktiviert.`, + statusColor: "#2563eb", + solverMessageText: `${getPieceLabel(mode)}-Solver bereit. ${DEFAULT_SOLVER_MESSAGE}` + }); } function closeAllPanels() { @@ -99,8 +206,6 @@ function getConflicts() { 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) { @@ -116,16 +221,16 @@ function checkSolution() { const allPlaced = board.every((col) => col !== EMPTY_CELL); if (!allPlaced) { - setStatus("❗ Noch nicht alle Figuren platziert.", "orange"); + setStatus("Noch nicht alle Figuren platziert.", "#d97706"); return; } if (getConflicts().length > 0) { - setStatus("❌ Es gibt Konflikte!", "red"); + setStatus("Es gibt Konflikte auf dem Brett.", "red"); return; } - setStatus("🎉 Perfekt geloest!", "green"); + setStatus("Perfekt geloest.", "green"); } function drawBoard() { @@ -134,6 +239,7 @@ function drawBoard() { updateBoardLayout(); const conflicts = getConflicts(); + const highlight = currentSolverHighlight; for (let row = -1; row < boardSize; row++) { for (let col = -1; col < boardSize; col++) { @@ -150,10 +256,19 @@ function drawBoard() { } else { cell.classList.add("cell", (row + col) % 2 === 0 ? "white" : "black"); + if (highlight && highlight.row === row && highlight.col === col) { + cell.classList.add( + highlight.type === "remove" + ? "solver-remove" + : highlight.type === "solution" + ? "solver-solution" + : "solver-place" + ); + } + if (board[row] === col) { cell.textContent = mode === "queens" ? "♛" : "♜"; - // Highlight pieces that are part of at least one conflict pair. if (conflicts.some(([conflictRow, conflictCol]) => conflictRow === row && conflictCol === col)) { cell.classList.add("invalid"); } @@ -170,6 +285,18 @@ function drawBoard() { } function placeQueen(row, col) { + if (solverLoading) { + return; + } + + if (hasPreparedSolverTrace() || isSolverPlaying()) { + discardSolverTrace("Solver-Ablauf verworfen, weil das Brett manuell geaendert wurde."); + setStatus("Solver-Ablauf verworfen. Bereite ihn bei Bedarf neu vor.", "#d97706"); + } else { + setStatus(); + } + + currentSolverHighlight = null; board[row] = board[row] === col ? EMPTY_CELL : col; drawBoard(); } @@ -182,7 +309,9 @@ function previewBoardSize(size) { function applyBoardSize() { boardSize = pendingBoardSize; + discardSolverTrace(`${getPieceLabel(mode)}-Solver bereit. ${DEFAULT_SOLVER_MESSAGE}`); board = createEmptyBoard(boardSize); + currentSolverHighlight = null; updateBoardSizeDisplays(boardSize); setStatus(`Brettgroesse auf ${boardSize} x ${boardSize} gesetzt.`, "#2563eb"); drawBoard(); @@ -358,30 +487,284 @@ async function openSavePointsPanel() { } function applyLoadedGame(data) { - board = data.board; + discardSolverTrace(`${getPieceLabel(data.mode || "queens")}-Solver bereit. ${DEFAULT_SOLVER_MESSAGE}`); + + board = Array.isArray(data.board) ? data.board.slice() : createEmptyBoard(DEFAULT_BOARD_SIZE); boardSize = Array.isArray(data.board) ? clampBoardSize(data.board.length) : DEFAULT_BOARD_SIZE; pendingBoardSize = boardSize; - mode = data.mode; + mode = data.mode === "rooks" ? "rooks" : "queens"; + currentSolverHighlight = null; updateModeButtons(); updateBoardSizeDisplays(boardSize); drawBoard(); } -async function solve() { - const endpoint = mode === "rooks" ? "/solve_rooks" : "/solve"; - const response = await fetch(`${endpoint}?size=${boardSize}`); - const solution = await response.json(); +function applySolverStep(step) { + board = Array.isArray(step.board) ? step.board.slice() : createEmptyBoard(boardSize); - // 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"); + if (Number.isInteger(step.row) && Number.isInteger(step.col)) { + currentSolverHighlight = { + row: step.row, + col: step.col, + type: step.type + }; + } else if (step.type === "solution" && solverStepIndex > 0) { + const previousStep = solverSteps[solverStepIndex - 1]; + currentSolverHighlight = Number.isInteger(previousStep.row) && Number.isInteger(previousStep.col) + ? { + row: previousStep.row, + col: previousStep.col, + type: "solution" + } + : null; + } else { + currentSolverHighlight = null; + } + + setSolverMessage(step.message || DEFAULT_SOLVER_MESSAGE); drawBoard(); + updateSolverControls(); } -function resetBoard() { +function advanceSolverStep() { + if (!hasPreparedSolverTrace() || hasCompletedSolverTrace()) { + stopSolverPlayback(false); + updateSolverControls(); + return false; + } + + solverStepIndex += 1; + applySolverStep(solverSteps[solverStepIndex]); + + if (hasCompletedSolverTrace()) { + stopSolverPlayback(false); + setStatus(`Loesung fuer ${boardSize} x ${boardSize} Schritt fuer Schritt aufgebaut.`, "green"); + updateSolverControls(); + } + + return true; +} + +function scheduleNextSolverPlaybackStep() { + stopSolverPlayback(false); + + solverTimerId = window.setTimeout(() => { + solverTimerId = null; + + const advanced = advanceSolverStep(); + + if (advanced && !hasCompletedSolverTrace()) { + scheduleNextSolverPlaybackStep(); + return; + } + + updateSolverControls(); + }, solverSpeedMs); + + updateSolverControls(); +} + +function startSolverPlayback(stepImmediately = true) { + if (!hasPreparedSolverTrace() || hasCompletedSolverTrace()) { + updateSolverControls(); + return; + } + + if (stepImmediately) { + const advanced = advanceSolverStep(); + + if (!advanced || hasCompletedSolverTrace()) { + updateSolverControls(); + return; + } + } + + scheduleNextSolverPlaybackStep(); +} + +async function prepareSolutionTrace() { + const endpoint = mode === "rooks" ? "/solve_rooks_trace" : "/solve_trace"; + const requestId = ++solverRequestId; + + solverLoading = true; + stopSolverPlayback(false); + solverSteps = []; + solverStepIndex = -1; + currentSolverHighlight = null; + setSolverMessage(`Berechne ${getPieceLabel(mode)}-Ablauf fuer ${boardSize} x ${boardSize}...`); + setStatus(`Solver berechnet die Schritte fuer ${boardSize} x ${boardSize}.`, "#2563eb"); + updateSolverControls(); + + try { + const response = await fetch(`${endpoint}?size=${boardSize}`); + + if (!response.ok) { + throw new Error("trace request failed"); + } + + const trace = await response.json(); + + if (requestId !== solverRequestId) { + return false; + } + + solverSteps = Array.isArray(trace.steps) ? trace.steps : []; + solverStepIndex = -1; + board = Array.isArray(trace.initial_board) ? trace.initial_board.slice() : createEmptyBoard(boardSize); + currentSolverHighlight = null; + + if (!trace.solved || solverSteps.length === 0) { + setSolverMessage("Keine Loesung gefunden."); + setStatus("Keine Loesung fuer dieses Brett gefunden.", "red"); + drawBoard(); + return false; + } + + setSolverMessage( + mode === "queens" + ? `Ablauf bereit mit ${solverSteps.length} Schritten. Nutze Next Step oder Play Animation, um das Backtracking zu verfolgen.` + : `Ablauf bereit mit ${solverSteps.length} Schritten. Nutze Next Step oder starte die Animation.` + ); + setStatus("Solver-Ablauf bereit. Nutze Next Step oder Play Animation.", "#2563eb"); + drawBoard(); + return true; + } catch (error) { + if (requestId !== solverRequestId) { + return false; + } + + setSolverMessage("Der Solver-Ablauf konnte nicht geladen werden."); + setStatus("Der Solver-Ablauf konnte nicht geladen werden.", "red"); + return false; + } finally { + if (requestId === solverRequestId) { + solverLoading = false; + updateSolverControls(); + } + } +} + +async function nextSolverStep() { + if (solverLoading) { + return; + } + + if (!hasPreparedSolverTrace()) { + const prepared = await prepareSolutionTrace(); + + if (!prepared) { + return; + } + } + + if (isSolverPlaying()) { + stopSolverPlayback(false); + } + + if (!advanceSolverStep()) { + setStatus("Der Solver-Ablauf ist bereits abgeschlossen.", "#2563eb"); + } +} + +async function toggleSolverPlayback() { + if (solverLoading) { + return; + } + + if (isSolverPlaying()) { + stopSolverPlayback(); + setStatus("Solver-Animation pausiert.", "#2563eb"); + return; + } + + if (!hasPreparedSolverTrace()) { + const prepared = await prepareSolutionTrace(); + + if (!prepared) { + return; + } + } + + if (hasCompletedSolverTrace()) { + rewindSolverTrace(); + } + + setStatus("Solver-Animation laeuft.", "#2563eb"); + startSolverPlayback(true); +} + +async function solve() { + await prepareSolutionTrace(); +} + +async function instantSolve() { + if (solverLoading) { + return; + } + + const endpoint = mode === "rooks" ? "/solve_rooks" : "/solve"; + const requestId = ++solverRequestId; + + solverLoading = true; + stopSolverPlayback(false); + solverSteps = []; + solverStepIndex = -1; + currentSolverHighlight = null; + setSolverMessage(`Lade sofortige ${getPieceLabel(mode)}-Loesung fuer ${boardSize} x ${boardSize}...`); + setStatus(`Sofortige Loesung wird geladen.`, "#2563eb"); + updateSolverControls(); + + try { + const response = await fetch(`${endpoint}?size=${boardSize}`); + + if (!response.ok) { + throw new Error("instant solve request failed"); + } + + const solution = await response.json(); + + if (requestId !== solverRequestId) { + return; + } + + board = mode === "queens" && Array.isArray(solution[0]) ? solution[0].slice() : solution.slice(); + currentSolverHighlight = null; + setSolverMessage("Sofortige Loesung geladen. Starte Solve Step-by-Step, wenn du den Ablauf sehen moechtest."); + setStatus(`Loesung fuer ${boardSize} x ${boardSize} sofort geladen.`, "green"); + drawBoard(); + } catch (error) { + if (requestId !== solverRequestId) { + return; + } + + setSolverMessage("Die sofortige Loesung konnte nicht geladen werden."); + setStatus("Die sofortige Loesung konnte nicht geladen werden.", "red"); + } finally { + if (requestId === solverRequestId) { + solverLoading = false; + updateSolverControls(); + } + } +} + +function resetBoard(options = {}) { + const { + statusMessageText = "", + statusColor = "", + solverMessageText = `${getPieceLabel(mode)}-Solver bereit. ${DEFAULT_SOLVER_MESSAGE}` + } = options; + + discardSolverTrace(solverMessageText); board = createEmptyBoard(boardSize); - setStatus(); + currentSolverHighlight = null; + + if (statusMessageText) { + setStatus(statusMessageText, statusColor); + } else { + setStatus(); + } + drawBoard(); } @@ -401,7 +784,7 @@ function updateQueenList() { li.textContent = `${mode === "queens" ? "Dame" : "Turm"} ${count}: ${position}`; queenList.appendChild(li); - count++; + count += 1; } } @@ -422,7 +805,7 @@ async function saveGame() { }); if (!response.ok) { - setStatus("❌ Der Speicherstand konnte nicht gespeichert werden.", "red"); + setStatus("Der Speicherstand konnte nicht gespeichert werden.", "red"); return; } @@ -443,7 +826,7 @@ async function loadSavePoint(savePointId) { const response = await fetch(`/load/${savePointId}`); if (!response.ok) { - setStatus("❌ Dieser Speicherstand konnte nicht geladen werden.", "red"); + setStatus("Dieser Speicherstand konnte nicht geladen werden.", "red"); return; } @@ -460,7 +843,7 @@ async function toggleSavePointFavorite(savePointId) { }); if (!response.ok) { - setStatus("❌ Favorit konnte nicht aktualisiert werden.", "red"); + setStatus("Favorit konnte nicht aktualisiert werden.", "red"); return; } @@ -480,7 +863,7 @@ async function deleteSavePoint(savePointId) { }); if (!response.ok) { - setStatus("❌ Speicherpunkt konnte nicht geloescht werden.", "red"); + setStatus("Speicherpunkt konnte nicht geloescht werden.", "red"); return; } @@ -491,4 +874,7 @@ async function deleteSavePoint(savePointId) { pendingBoardSize = boardSize; updateModeButtons(); updateBoardSizeDisplays(boardSize); +updateSolverSpeed(solverSpeedMs); +setSolverMessage(`${getPieceLabel(mode)}-Solver bereit. ${DEFAULT_SOLVER_MESSAGE}`); +updateSolverControls(); drawBoard(); diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 1c82d34..72fddbd 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -61,8 +61,31 @@

+
+
+
+

Solver-Ablauf

+

Bereite einen Ablauf vor und spiele ihn Schritt fuer Schritt ab.

+
+

Schritt 0 / 0

+
+ +
+ + + + +
+ +
+ + + 320 ms +
+
+
- From 59b48683a8a8ad9b4de9d36c3c99ac69df482c89 Mon Sep 17 00:00:00 2001 From: ScriptSorcerer20 Date: Sat, 4 Apr 2026 23:15:36 +0200 Subject: [PATCH 09/15] added missing features, time counter, step counter, game instructions and table view for savepoints. --- app/__init__.py | 1 + app/models/gamestate.py | 4 + app/routes/main_routes.py | 18 ++ app/static/css/dashboard.css | 190 ++++++++++++++++++++- app/static/js/dashboard.js | 318 +++++++++++++++++++++++++++++++++-- app/templates/dashboard.html | 78 ++++++++- 6 files changed, 589 insertions(+), 20 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 21dc324..9abff38 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -7,6 +7,7 @@ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() login_manager = LoginManager() + def create_app(): app = Flask(__name__) diff --git a/app/models/gamestate.py b/app/models/gamestate.py index 7aef035..ddb1ab2 100644 --- a/app/models/gamestate.py +++ b/app/models/gamestate.py @@ -1,5 +1,6 @@ from .. import db + class GameState(db.Model): __tablename__ = "game_state" @@ -12,6 +13,9 @@ class GameState(db.Model): 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, diff --git a/app/routes/main_routes.py b/app/routes/main_routes.py index 15ea363..ad17578 100644 --- a/app/routes/main_routes.py +++ b/app/routes/main_routes.py @@ -28,6 +28,15 @@ 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}" @@ -180,6 +189,9 @@ def serialize_game_state(game_state): "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") } @@ -282,6 +294,9 @@ def save_game(): 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( @@ -292,6 +307,9 @@ def save_game(): 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 ) diff --git a/app/static/css/dashboard.css b/app/static/css/dashboard.css index fe15596..b83c8e2 100644 --- a/app/static/css/dashboard.css +++ b/app/static/css/dashboard.css @@ -94,6 +94,68 @@ body { text-align: center; } +.game-card-header { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; +} + +.game-card-header h2 { + margin: 0; +} + +.info-button { + width: 38px; + height: 38px; + border: none; + border-radius: 999px; + background: #dbeafe; + color: #1d4ed8; + font-size: 20px; + font-weight: bold; + cursor: pointer; +} + +.info-button:hover { + background: #bfdbfe; +} + +.game-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin: 18px 0 12px; +} + +.game-stat-card { + padding: 14px 16px; + border-radius: 12px; + background: #f8fafc; + border: 1px solid #dbe4ee; + text-align: left; +} + +.game-stat-label { + margin: 0 0 8px; + font-size: 14px; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.game-stat-value { + margin: 0; + font-size: 26px; + font-weight: bold; + color: #1e293b; + font-variant-numeric: tabular-nums; +} + +.game-stat-status { + font-size: 20px; +} + #statusMessage { min-height: 24px; margin: 0 0 8px; @@ -478,6 +540,55 @@ body { margin: 0; } +.history-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 14px; +} + +.history-header h3 { + margin: 0; +} + +.history-subtitle { + margin: 6px 0 0; + color: #64748b; +} + +.history-table-wrapper { + overflow-x: auto; +} + +.history-table { + width: 100%; + border-collapse: collapse; + min-width: 720px; +} + +.history-table th, +.history-table td { + padding: 12px 10px; + border-bottom: 1px solid #e2e8f0; + text-align: left; +} + +.history-table th { + color: #334155; + font-size: 14px; +} + +.history-table td { + color: #475569; + font-size: 14px; +} + +.history-empty { + color: #64748b; + text-align: center !important; +} + .btn.ghost { background: #e2e8f0; color: #1e293b; @@ -502,6 +613,73 @@ body { pointer-events: auto; } +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.42); + opacity: 0; + pointer-events: none; + transition: opacity 0.25s ease; + z-index: 40; +} + +.modal-backdrop.visible { + opacity: 1; + pointer-events: auto; +} + +.modal { + position: fixed; + top: 50%; + left: 50%; + width: min(620px, calc(100vw - 32px)); + max-height: min(82vh, 720px); + padding: 24px; + box-sizing: border-box; + background: white; + border-radius: 18px; + box-shadow: 0 20px 60px rgba(15, 23, 42, 0.28); + transform: translate(-50%, -48%); + opacity: 0; + pointer-events: none; + transition: opacity 0.25s ease, transform 0.25s ease; + overflow-y: auto; + z-index: 50; +} + +.modal.open { + opacity: 1; + pointer-events: auto; + transform: translate(-50%, -50%); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.modal-header h3 { + margin: 0; +} + +.modal-content { + margin-top: 16px; + color: #334155; + text-align: left; +} + +.modal-content h4 { + margin: 16px 0 8px; + color: #1e293b; +} + +.modal-content p { + margin: 0; + line-height: 1.6; +} + @keyframes solverPulse { 0% { transform: scale(0.97); @@ -536,8 +714,18 @@ body { display: grid; } + .game-stats { + grid-template-columns: 1fr; + } + + .history-header, + .modal-header { + grid-template-columns: 1fr; + display: grid; + } + .solver-step-info, .solver-speed-value { justify-self: start; } -} \ No newline at end of file +} diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js index ca8b30c..691af6e 100644 --- a/app/static/js/dashboard.js +++ b/app/static/js/dashboard.js @@ -3,11 +3,17 @@ const MIN_BOARD_SIZE = 4; const MAX_BOARD_SIZE = 13; const EMPTY_CELL = -1; const DEFAULT_SOLVER_MESSAGE = "Bereite einen Ablauf vor und spiele ihn Schritt fuer Schritt ab."; +const GAME_STORAGE_KEY = "eight-queens-dashboard-session-v1"; let mode = "queens"; let boardSize = DEFAULT_BOARD_SIZE; let pendingBoardSize = DEFAULT_BOARD_SIZE; let board = createEmptyBoard(boardSize); +let stepCount = 0; +let elapsedBeforeTimerStartMs = 0; +let timerStartedAtMs = Date.now(); +let gameTimerId = null; +let isSolved = false; let solverSteps = []; let solverStepIndex = -1; @@ -20,6 +26,9 @@ let currentSolverHighlight = null; const boardDiv = document.getElementById("board"); const statusMessage = document.getElementById("statusMessage"); const queenList = document.getElementById("queenList"); +const stepCounterValue = document.getElementById("stepCounterValue"); +const timerValue = document.getElementById("timerValue"); +const gameProgressState = document.getElementById("gameProgressState"); const btnQueens = document.getElementById("btnQueens"); const btnRooks = document.getElementById("btnRooks"); const btnPrepareSolve = document.getElementById("btnPrepareSolve"); @@ -49,6 +58,9 @@ const savePointModeFilter = document.getElementById("savePointModeFilter"); const savePointSizeFilter = document.getElementById("savePointSizeFilter"); const savePointSortFilter = document.getElementById("savePointSortFilter"); const savePointFavoritesOnly = document.getElementById("savePointFavoritesOnly"); +const timeHistoryTableBody = document.getElementById("timeHistoryTableBody"); +const infoModal = document.getElementById("infoModal"); +const infoModalBackdrop = document.getElementById("infoModalBackdrop"); function createEmptyBoard(size) { return Array(size).fill(EMPTY_CELL); @@ -62,6 +74,116 @@ function getColumnLabel(index) { return String.fromCharCode(65 + index); } +function sanitizeBoard(candidateBoard, size) { + if (!Array.isArray(candidateBoard) || candidateBoard.length !== size) { + return createEmptyBoard(size); + } + + return candidateBoard.map((value) => { + const numericValue = Number(value); + + if (!Number.isInteger(numericValue)) { + return EMPTY_CELL; + } + + return numericValue >= 0 && numericValue < size ? numericValue : EMPTY_CELL; + }); +} + +function formatDuration(totalSeconds) { + const safeSeconds = Math.max(0, Number(totalSeconds) || 0); + const hours = Math.floor(safeSeconds / 3600); + const minutes = Math.floor((safeSeconds % 3600) / 60); + const seconds = safeSeconds % 60; + + return [hours, minutes, seconds] + .map((value) => String(value).padStart(2, "0")) + .join(":"); +} + +function getElapsedMilliseconds() { + if (isSolved) { + return elapsedBeforeTimerStartMs; + } + + return elapsedBeforeTimerStartMs + Math.max(0, Date.now() - timerStartedAtMs); +} + +function persistCurrentSession() { + try { + window.localStorage.setItem( + GAME_STORAGE_KEY, + JSON.stringify({ + mode, + boardSize, + board, + stepCount, + elapsedMs: getElapsedMilliseconds(), + isSolved + }) + ); + } catch (error) { + // Ignore unavailable storage so the game keeps working normally. + } +} + +function updateProgressDisplay() { + stepCounterValue.textContent = String(stepCount); + timerValue.textContent = formatDuration(Math.floor(getElapsedMilliseconds() / 1000)); + gameProgressState.textContent = isSolved ? "Geloest" : "Laeuft"; + gameProgressState.style.color = isSolved ? "#15803d" : "#1d4ed8"; +} + +function stopGameTimer() { + if (gameTimerId !== null) { + window.clearInterval(gameTimerId); + gameTimerId = null; + } +} + +function startGameTimer() { + stopGameTimer(); + + if (isSolved) { + updateProgressDisplay(); + persistCurrentSession(); + return; + } + + timerStartedAtMs = Date.now(); + gameTimerId = window.setInterval(() => { + updateProgressDisplay(); + persistCurrentSession(); + }, 1000); + + updateProgressDisplay(); + persistCurrentSession(); +} + +function resetProgress({ stepCountValue = 0, elapsedMs = 0, solved = false } = {}) { + stepCount = Math.max(0, Number(stepCountValue) || 0); + elapsedBeforeTimerStartMs = Math.max(0, Number(elapsedMs) || 0); + isSolved = Boolean(solved); + timerStartedAtMs = Date.now(); + + if (isSolved) { + stopGameTimer(); + updateProgressDisplay(); + persistCurrentSession(); + return; + } + + startGameTimer(); +} + +function ensureFreshRunAfterSolvedBoard() { + if (!isSolved) { + return; + } + + resetProgress(); +} + function updateModeButtons() { btnQueens.classList.toggle("active", mode === "queens"); btnRooks.classList.toggle("active", mode === "rooks"); @@ -187,26 +309,27 @@ function closeAllPanels() { toggleSidebar(false); toggleSavePanel(false); toggleSavePointsPanel(false); + toggleInfoModal(false); } -function getConflicts() { +function getConflicts(selectedBoard = board, selectedMode = mode) { const conflicts = []; for (let rowA = 0; rowA < boardSize; rowA++) { - if (board[rowA] === EMPTY_CELL) { + if (selectedBoard[rowA] === EMPTY_CELL) { continue; } for (let rowB = rowA + 1; rowB < boardSize; rowB++) { - if (board[rowB] === EMPTY_CELL) { + if (selectedBoard[rowB] === EMPTY_CELL) { continue; } - const colA = board[rowA]; - const colB = board[rowB]; + const colA = selectedBoard[rowA]; + const colB = selectedBoard[rowB]; const sameColumn = colA === colB; const sameDiagonal = Math.abs(colA - colB) === Math.abs(rowA - rowB); - const hasConflict = mode === "rooks" ? sameColumn : sameColumn || sameDiagonal; + const hasConflict = selectedMode === "rooks" ? sameColumn : sameColumn || sameDiagonal; if (hasConflict) { conflicts.push([rowA, colA], [rowB, colB]); @@ -217,6 +340,23 @@ function getConflicts() { return conflicts; } +function isBoardSolved(selectedBoard = board, selectedMode = mode) { + const allPlaced = selectedBoard.every((col) => col !== EMPTY_CELL); + return allPlaced && getConflicts(selectedBoard, selectedMode).length === 0; +} + +function finishSolvedRun() { + if (isSolved) { + return; + } + + elapsedBeforeTimerStartMs = getElapsedMilliseconds(); + isSolved = true; + stopGameTimer(); + updateProgressDisplay(); + persistCurrentSession(); +} + function checkSolution() { const allPlaced = board.every((col) => col !== EMPTY_CELL); @@ -230,7 +370,8 @@ function checkSolution() { return; } - setStatus("Perfekt geloest.", "green"); + finishSolvedRun(); + setStatus(`Perfekt geloest in ${formatDuration(Math.floor(getElapsedMilliseconds() / 1000))}.`, "green"); } function drawBoard() { @@ -289,6 +430,8 @@ function placeQueen(row, col) { return; } + ensureFreshRunAfterSolvedBoard(); + if (hasPreparedSolverTrace() || isSolverPlaying()) { discardSolverTrace("Solver-Ablauf verworfen, weil das Brett manuell geaendert wurde."); setStatus("Solver-Ablauf verworfen. Bereite ihn bei Bedarf neu vor.", "#d97706"); @@ -298,7 +441,16 @@ function placeQueen(row, col) { currentSolverHighlight = null; board[row] = board[row] === col ? EMPTY_CELL : col; + stepCount += 1; drawBoard(); + updateProgressDisplay(); + + if (isBoardSolved()) { + finishSolvedRun(); + setStatus(`Perfekt geloest in ${formatDuration(Math.floor(getElapsedMilliseconds() / 1000))}.`, "green"); + } else { + persistCurrentSession(); + } } function previewBoardSize(size) { @@ -312,6 +464,7 @@ function applyBoardSize() { discardSolverTrace(`${getPieceLabel(mode)}-Solver bereit. ${DEFAULT_SOLVER_MESSAGE}`); board = createEmptyBoard(boardSize); currentSolverHighlight = null; + resetProgress(); updateBoardSizeDisplays(boardSize); setStatus(`Brettgroesse auf ${boardSize} x ${boardSize} gesetzt.`, "#2563eb"); drawBoard(); @@ -357,6 +510,14 @@ function toggleSavePointsPanel(forceOpen) { savePointsSidebar.setAttribute("aria-hidden", String(!shouldOpen)); } +function toggleInfoModal(forceOpen) { + const shouldOpen = typeof forceOpen === "boolean" ? forceOpen : !infoModal.classList.contains("open"); + + infoModal.classList.toggle("open", shouldOpen); + infoModalBackdrop.classList.toggle("visible", shouldOpen); + infoModal.setAttribute("aria-hidden", String(!shouldOpen)); +} + function getPieceLabel(selectedMode) { return selectedMode === "queens" ? "Dame" : "Turm"; } @@ -423,6 +584,10 @@ function createSavePointActionButton(label, className, onClick) { return button; } +function getModeLabel(selectedMode) { + return selectedMode === "rooks" ? "Tuerme" : "Damen"; +} + function renderSavePoints(savePoints) { savePointsList.innerHTML = ""; @@ -455,6 +620,10 @@ function renderSavePoints(savePoints) { note.textContent = savePoint.save_note ? `Notiz: ${savePoint.save_note}` : "Notiz: Keine Notiz"; actions.className = "save-point-actions"; + const progressMeta = document.createElement("p"); + progressMeta.className = "save-point-meta"; + progressMeta.textContent = `Schritte: ${savePoint.step_count || 0} | Zeit: ${formatDuration(savePoint.elapsed_seconds)} | Status: ${savePoint.is_solved ? "Geloest" : "Offen"}`; + actions.appendChild(createSavePointActionButton("Laden", "btn", () => loadSavePoint(savePoint.id))); actions.appendChild(createSavePointActionButton( savePoint.is_favorite ? "Favorit entfernen" : "Als Favorit", @@ -467,6 +636,7 @@ function renderSavePoints(savePoints) { item.appendChild(createdAt); item.appendChild(updatedAt); item.appendChild(boardMeta); + item.appendChild(progressMeta); item.appendChild(preview); item.appendChild(note); item.appendChild(actions); @@ -474,6 +644,36 @@ function renderSavePoints(savePoints) { } } +function renderTimeHistory(savePoints) { + timeHistoryTableBody.innerHTML = ""; + + if (savePoints.length === 0) { + timeHistoryTableBody.innerHTML = 'Noch keine gespeicherten Zeiten vorhanden.'; + return; + } + + for (const savePoint of savePoints) { + const row = document.createElement("tr"); + const values = [ + `${savePoint.is_favorite ? "★ " : ""}${savePoint.save_name}`, + getModeLabel(savePoint.mode), + `${savePoint.board_size} x ${savePoint.board_size}`, + String(savePoint.step_count || 0), + formatDuration(savePoint.elapsed_seconds), + savePoint.is_solved ? "Geloest" : "Offen", + savePoint.updated_at + ]; + + for (const value of values) { + const cell = document.createElement("td"); + cell.textContent = value; + row.appendChild(cell); + } + + timeHistoryTableBody.appendChild(row); + } +} + async function refreshSavePoints() { const queryString = getSavePointFilters(); const response = await fetch(`/save_points${queryString ? `?${queryString}` : ""}`); @@ -481,6 +681,17 @@ async function refreshSavePoints() { renderSavePoints(savePoints); } +async function refreshTimeHistory() { + const response = await fetch("/save_points?sort=updated"); + + if (!response.ok) { + return; + } + + const savePoints = await response.json(); + renderTimeHistory(savePoints); +} + async function openSavePointsPanel() { await refreshSavePoints(); toggleSavePointsPanel(true); @@ -489,19 +700,28 @@ async function openSavePointsPanel() { function applyLoadedGame(data) { discardSolverTrace(`${getPieceLabel(data.mode || "queens")}-Solver bereit. ${DEFAULT_SOLVER_MESSAGE}`); - board = Array.isArray(data.board) ? data.board.slice() : createEmptyBoard(DEFAULT_BOARD_SIZE); - boardSize = Array.isArray(data.board) ? clampBoardSize(data.board.length) : DEFAULT_BOARD_SIZE; + const restoredBoardSize = Array.isArray(data.board) ? clampBoardSize(data.board.length) : DEFAULT_BOARD_SIZE; + + boardSize = restoredBoardSize; pendingBoardSize = boardSize; mode = data.mode === "rooks" ? "rooks" : "queens"; + board = sanitizeBoard(data.board, boardSize); currentSolverHighlight = null; + resetProgress({ + stepCountValue: data.step_count, + elapsedMs: Number(data.elapsed_seconds || 0) * 1000, + solved: data.is_solved || isBoardSolved(board, mode) + }); updateModeButtons(); updateBoardSizeDisplays(boardSize); drawBoard(); + updateProgressDisplay(); + persistCurrentSession(); } function applySolverStep(step) { - board = Array.isArray(step.board) ? step.board.slice() : createEmptyBoard(boardSize); + board = sanitizeBoard(step.board, boardSize); if (Number.isInteger(step.row) && Number.isInteger(step.col)) { currentSolverHighlight = { @@ -524,6 +744,7 @@ function applySolverStep(step) { setSolverMessage(step.message || DEFAULT_SOLVER_MESSAGE); drawBoard(); + persistCurrentSession(); updateSolverControls(); } @@ -538,6 +759,7 @@ function advanceSolverStep() { applySolverStep(solverSteps[solverStepIndex]); if (hasCompletedSolverTrace()) { + finishSolvedRun(); stopSolverPlayback(false); setStatus(`Loesung fuer ${boardSize} x ${boardSize} Schritt fuer Schritt aufgebaut.`, "green"); updateSolverControls(); @@ -618,6 +840,7 @@ async function prepareSolutionTrace() { setSolverMessage("Keine Loesung gefunden."); setStatus("Keine Loesung fuer dieses Brett gefunden.", "red"); drawBoard(); + persistCurrentSession(); return false; } @@ -730,9 +953,11 @@ async function instantSolve() { board = mode === "queens" && Array.isArray(solution[0]) ? solution[0].slice() : solution.slice(); currentSolverHighlight = null; + finishSolvedRun(); setSolverMessage("Sofortige Loesung geladen. Starte Solve Step-by-Step, wenn du den Ablauf sehen moechtest."); setStatus(`Loesung fuer ${boardSize} x ${boardSize} sofort geladen.`, "green"); drawBoard(); + persistCurrentSession(); } catch (error) { if (requestId !== solverRequestId) { return; @@ -758,6 +983,7 @@ function resetBoard(options = {}) { discardSolverTrace(solverMessageText); board = createEmptyBoard(boardSize); currentSolverHighlight = null; + resetProgress(); if (statusMessageText) { setStatus(statusMessageText, statusColor); @@ -800,7 +1026,10 @@ async function saveGame() { boardSize, saveName: saveNameInput.value, saveNote: saveNoteInput.value, - isFavorite: saveFavoriteInput.checked + isFavorite: saveFavoriteInput.checked, + stepCount, + elapsedSeconds: Math.floor(getElapsedMilliseconds() / 1000), + isSolved }) }); @@ -813,6 +1042,7 @@ async function saveGame() { toggleSavePanel(false); await refreshSavePoints(); + await refreshTimeHistory(); setStatus(`Speicherpunkt "${data.save_point.save_name}" gespeichert.`, "green"); } @@ -848,6 +1078,7 @@ async function toggleSavePointFavorite(savePointId) { } await refreshSavePoints(); + await refreshTimeHistory(); setStatus("Favoritenstatus aktualisiert.", "#2563eb"); } @@ -868,13 +1099,64 @@ async function deleteSavePoint(savePointId) { } await refreshSavePoints(); + await refreshTimeHistory(); setStatus("Speicherpunkt geloescht.", "#2563eb"); } -pendingBoardSize = boardSize; -updateModeButtons(); -updateBoardSizeDisplays(boardSize); -updateSolverSpeed(solverSpeedMs); -setSolverMessage(`${getPieceLabel(mode)}-Solver bereit. ${DEFAULT_SOLVER_MESSAGE}`); -updateSolverControls(); -drawBoard(); +function restoreSessionFromStorage() { + try { + const rawSession = window.localStorage.getItem(GAME_STORAGE_KEY); + + if (!rawSession) { + return false; + } + + const savedSession = JSON.parse(rawSession); + const restoredBoardSize = clampBoardSize(savedSession.boardSize || DEFAULT_BOARD_SIZE); + const restoredMode = savedSession.mode === "rooks" ? "rooks" : "queens"; + + boardSize = restoredBoardSize; + pendingBoardSize = restoredBoardSize; + mode = restoredMode; + board = sanitizeBoard(savedSession.board, restoredBoardSize); + currentSolverHighlight = null; + + updateModeButtons(); + updateBoardSizeDisplays(boardSize); + drawBoard(); + resetProgress({ + stepCountValue: savedSession.stepCount, + elapsedMs: savedSession.elapsedMs, + solved: savedSession.isSolved || isBoardSolved(board, mode) + }); + + return true; + } catch (error) { + return false; + } +} + +async function initializeDashboard() { + pendingBoardSize = boardSize; + updateModeButtons(); + updateBoardSizeDisplays(boardSize); + updateSolverSpeed(solverSpeedMs); + setSolverMessage(`${getPieceLabel(mode)}-Solver bereit. ${DEFAULT_SOLVER_MESSAGE}`); + updateSolverControls(); + + if (!restoreSessionFromStorage()) { + resetProgress(); + drawBoard(); + } + + updateProgressDisplay(); + await refreshTimeHistory(); +} + +window.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + closeAllPanels(); + } +}); + +initializeDashboard(); diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 1c09088..93129e1 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -59,10 +59,29 @@
-

♟️ Damen- und Tuerme-Spiel

+
+

♟️ Damen- und Tuerme-Spiel

+ +

Aktuelle Brettgroesse: 8 x 8

+
+
+

Schritte

+

0

+
+
+

Zeit

+

00:00:00

+
+
+

Status

+

Laeuft

+
+
+

@@ -103,6 +122,37 @@

📋 Figuren Positionen

    + +
    +
    +
    +

    ⏱️ Bisherige Zeiten

    +

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

    +
    + +
    + +
    + + + + + + + + + + + + + + + + + +
    Save-PointModusBrettSchritteZeitStatusGespeichert
    Noch keine gespeicherten Zeiten vorhanden.
    +
    +
    + {% include "partials/footer.html" %} + diff --git a/app/templates/login_settings.html b/app/templates/login_settings.html index ae87425..47f23a8 100644 --- a/app/templates/login_settings.html +++ b/app/templates/login_settings.html @@ -6,6 +6,7 @@ Login Settings + @@ -152,6 +153,8 @@ + {% include "partials/footer.html" %} + diff --git a/app/templates/partials/footer.html b/app/templates/partials/footer.html new file mode 100644 index 0000000..fc7f403 --- /dev/null +++ b/app/templates/partials/footer.html @@ -0,0 +1,12 @@ + diff --git a/app/templates/privacy.html b/app/templates/privacy.html new file mode 100644 index 0000000..0c1ce2c --- /dev/null +++ b/app/templates/privacy.html @@ -0,0 +1,132 @@ + + + + + + + Datenschutzerklärung + + + + + + + +
    + + + + + + + + + + + + + + + + + +
    + + {% include "partials/footer.html" %} + + + + \ No newline at end of file diff --git a/app/templates/sign_up.html b/app/templates/sign_up.html index 50c7cb4..22d2ec3 100644 --- a/app/templates/sign_up.html +++ b/app/templates/sign_up.html @@ -6,6 +6,7 @@ Sign Up + @@ -51,6 +52,8 @@ + {% include "partials/footer.html" %} + diff --git a/app/templates/two_factor_verify.html b/app/templates/two_factor_verify.html index e8ad1de..2fa6aa3 100644 --- a/app/templates/two_factor_verify.html +++ b/app/templates/two_factor_verify.html @@ -6,6 +6,7 @@ Verify Login + @@ -48,6 +49,8 @@ + {% include "partials/footer.html" %} + From 1beb1ce6305c7480697fbc0511633ca214b845db Mon Sep 17 00:00:00 2001 From: ScriptSorcerer20 Date: Wed, 8 Apr 2026 11:06:37 +0200 Subject: [PATCH 14/15] added comments and fixed some wrong text. --- README.md | 9 +--- app/__init__.py | 17 ++++++++ app/routes/login_routes.py | 54 ++++++++++++++++++++++- app/routes/main_routes.py | 49 ++++++++++++++++++++- app/static/js/dashboard.js | 2 +- app/templates/dashboard.html | 83 +++++++++++++++++++++--------------- app/templates/privacy.html | 14 +++--- 7 files changed, 176 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 9ff9134..3874215 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,6 @@ project-root/ │ ├── app/ │ ├── __init__.py # Flask App Factory -│ ├── config.py # Konfiguration (DB, Secrets, etc.) │ │ │ ├── models/ # Datenbankmodelle │ │ ├── __init__.py @@ -22,12 +21,7 @@ project-root/ │ │ ├── solver_routes.py │ │ └── job_routes.py │ │ -│ ├── services/ # Businesslogik (Solver etc.) -│ │ ├── __init__.py -│ │ ├── solver_service.py -│ │ └── auth_service.py -│ │ -│ ├── templates/ # HTML (Jinja Templates) +│ ├── templates/ # HTML │ │ ├── base.html │ │ ├── login.html │ │ ├── dashboard.html @@ -42,6 +36,5 @@ project-root/ │ ├── run.py # Startpunkt der Anwendung ├── requirements.txt -├── .env # DB-Zugangsdaten, Secrets └── README.md ``` \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index 9abff38..9315507 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,18 +1,30 @@ +"""Application factory and shared Flask extensions. + +This file creates the Flask application, configures the database and login +manager, imports the data models so SQLAlchemy knows about them, and registers +the route blueprints used by the project. +""" + import os from flask import Flask from flask_login import LoginManager from flask_sqlalchemy import SQLAlchemy +# Shared extension instances are created once here and attached to the app db = SQLAlchemy() login_manager = LoginManager() def create_app(): + """Build and configure the Flask application instance.""" app = Flask(__name__) + # Build an absolute path to the application directory so the SQLite + # database file can be referenced reliably from any working directory. BASE_DIR = os.path.abspath(os.path.dirname(__file__)) + # Core Flask and SQLAlchemy configuration. app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(BASE_DIR, "app.db") app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-secret-key") app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False @@ -20,22 +32,27 @@ def create_app(): app.config["REMEMBER_COOKIE_HTTPONLY"] = True app.config["SESSION_COOKIE_SAMESITE"] = "Lax" + # Connect the shared extension objects to this specific app instance. db.init_app(app) login_manager.init_app(app) login_manager.login_view = "login.login" login_manager.login_message_category = "info" + # Import the models before create_all() so SQLAlchemy has the table from .models.user import User from .models.gamestate import GameState from .models.login_attempt import LoginAttempt + # Create any database tables that do not exist yet. with app.app_context(): db.create_all() @login_manager.user_loader def load_user(user_id): + """Reload the logged-in user from the session-stored user id.""" return db.session.get(User, int(user_id)) + # Import and register the blueprints that contain the route handlers. from .routes.login_routes import login_bp from .routes.main_routes import main_bp diff --git a/app/routes/login_routes.py b/app/routes/login_routes.py index 9d3148d..a2b52cc 100644 --- a/app/routes/login_routes.py +++ b/app/routes/login_routes.py @@ -1,3 +1,11 @@ +"""Authentication routes and login-related helper functions. + +This module handles user registration, login, logout, password changes, +login-attempt throttling, and authenticator-app based two-factor +authentication. It also contains the utility functions that keep these flows +consistent across the different route handlers. +""" + import base64 import binascii import hashlib @@ -16,11 +24,13 @@ from .. import db from ..models.login_attempt import LoginAttempt from ..models.user import User +# Blueprint that groups all authentication and account-security routes. login_bp = Blueprint("login", __name__) +# constants MIN_PASSWORD_LENGTH = 8 MAX_PASSWORD_LENGTH = 128 -MAX_LOGIN_FAILURES = 5 +MAX_LOGIN_FAILURES = 8 LOGIN_LOCK_MINUTES = 15 TWO_FACTOR_CODE_DIGITS = 6 TWO_FACTOR_TIME_STEP_SECONDS = 30 @@ -32,10 +42,12 @@ DUMMY_PASSWORD_HASH = generate_password_hash("dummy-password-for-timing-protecti def utcnow(): + """Return the current UTC time as datetime.""" return datetime.utcnow() def get_utc_timestamp(moment=None): + """Convert a datetime into a Unix timestamp in UTC seconds.""" if moment is None: return int(datetime.now(timezone.utc).timestamp()) @@ -46,16 +58,19 @@ def get_utc_timestamp(moment=None): def normalize_username(username): + """Trim surrounding whitespace from the username field.""" return (username or "").strip() def validate_username(username): + """Return an error message if the username violates the allowed pattern.""" if not USERNAME_PATTERN.fullmatch(username): return "Username must be 3-20 characters and only use letters, numbers, dots, dashes, or underscores." return None def validate_password(password): + """Return an error message if the password length is outside the allowed range.""" if len(password) < MIN_PASSWORD_LENGTH: return f"Password must be at least {MIN_PASSWORD_LENGTH} characters long." @@ -66,6 +81,7 @@ def validate_password(password): def get_request_ip(): + """Determine the client IP address, preferring a reverse-proxy header when present.""" forwarded_for = request.headers.get("X-Forwarded-For", "") if forwarded_for: @@ -75,6 +91,11 @@ def get_request_ip(): def login_attempt_buckets(username): + """Return the lockout buckets tracked for this login attempt. + + The app tracks failures both per username+IP and per IP-only bucket to + slow down brute-force attempts against many usernames from the same client. + """ normalized_username = normalize_username(username).lower() or "__empty__" ip_address = get_request_ip() return [ @@ -84,6 +105,7 @@ def login_attempt_buckets(username): def get_or_create_attempt(ip_address, bucket_key): + """Fetch an existing login-attempt record or create a new one.""" attempt = LoginAttempt.query.filter_by(ip_address=ip_address, username=bucket_key).first() if attempt is None: @@ -94,12 +116,14 @@ def get_or_create_attempt(ip_address, bucket_key): def clear_expired_lock(attempt, now): + """Reset the failure counter when a temporary lockout has expired.""" if attempt.locked_until and attempt.locked_until <= now: attempt.failed_attempts = 0 attempt.locked_until = None def get_active_lock(username): + """Return the latest active lockout time for this username/IP combination.""" now = utcnow() active_until = None @@ -119,6 +143,7 @@ def get_active_lock(username): def record_failed_login(username): + """Increase the failure counter and start a temporary lock when needed.""" now = utcnow() locked_until = None @@ -137,6 +162,7 @@ def record_failed_login(username): def clear_login_attempts(username): + """Remove tracked failed attempts after a successful login.""" for ip_address, bucket_key in login_attempt_buckets(username): attempt = LoginAttempt.query.filter_by(ip_address=ip_address, username=bucket_key).first() @@ -147,6 +173,7 @@ def clear_login_attempts(username): def lookup_user_by_username(username): + """Look up a user case-insensitively so login is not case-sensitive.""" if not username: return None @@ -154,29 +181,35 @@ def lookup_user_by_username(username): def minutes_until(target_time): + """Return the remaining lockout time in rounded-up whole minutes.""" seconds = max(1, int((target_time - utcnow()).total_seconds())) return max(1, (seconds + 59) // 60) def normalize_two_factor_secret(secret): + """Normalize a Base32 secret by removing spaces and uppercasing it.""" return (secret or "").strip().replace(" ", "").upper() def generate_two_factor_secret(): + """Generate a new random Base32 secret for authenticator apps.""" return base64.b32encode(secrets.token_bytes(20)).decode("ascii").rstrip("=") def format_two_factor_secret(secret): + """Insert spaces into the secret to make manual entry easier.""" normalized = normalize_two_factor_secret(secret) return " ".join(normalized[index:index + 4] for index in range(0, len(normalized), 4)) def clear_pending_two_factor(user=None): + """Remove the session values used during the login 2FA verification step.""" session.pop("pending_2fa_user_id", None) session.pop("pending_2fa_attempts", None) def get_pending_two_factor_setup_secret(user): + """Return the temporary secret generated during 2FA setup for this user.""" if session.get("pending_2fa_setup_user_id") != user.id: return None @@ -185,11 +218,13 @@ def get_pending_two_factor_setup_secret(user): def clear_pending_two_factor_setup(): + """Remove the session values used during authenticator setup.""" session.pop("pending_2fa_setup_user_id", None) session.pop("pending_2fa_setup_secret", None) def build_totp_uri(user, secret): + """Build the otpauth URI consumed by authenticator applications.""" normalized_secret = normalize_two_factor_secret(secret) label = quote(f"{TWO_FACTOR_ISSUER}:{user.username}") issuer = quote(TWO_FACTOR_ISSUER) @@ -201,12 +236,14 @@ def build_totp_uri(user, secret): def decode_two_factor_secret(secret): + """Decode the Base32 secret into raw bytes for HMAC processing.""" normalized_secret = normalize_two_factor_secret(secret) padded_secret = normalized_secret + ("=" * (-len(normalized_secret) % 8)) return base64.b32decode(padded_secret, casefold=True) def generate_totp_code(secret, for_time=None): + """Generate a time-based one-time password from the shared secret.""" timestamp = get_utc_timestamp(for_time) counter = timestamp // TWO_FACTOR_TIME_STEP_SECONDS key = decode_two_factor_secret(secret) @@ -219,6 +256,7 @@ def generate_totp_code(secret, for_time=None): def verify_totp_code(secret, code, at_time=None): + """Validate a 2FA code, allowing a small time window for clock drift.""" normalized_code = (code or "").strip() if not normalized_code.isdigit() or len(normalized_code) != TWO_FACTOR_CODE_DIGITS: @@ -239,6 +277,7 @@ def verify_totp_code(secret, code, at_time=None): def get_pending_two_factor_user(): + """Load the user who is currently in the middle of the login 2FA step.""" user_id = session.get("pending_2fa_user_id") if not user_id: @@ -248,6 +287,7 @@ def get_pending_two_factor_user(): def render_login_settings(error=None): + """Render the login settings page with optional 2FA setup data.""" pending_secret = get_pending_two_factor_setup_secret(current_user) formatted_secret = format_two_factor_secret(pending_secret) if pending_secret else None provisioning_uri = build_totp_uri(current_user, pending_secret) if pending_secret else None @@ -262,6 +302,7 @@ def render_login_settings(error=None): @login_bp.route("/register", methods=["GET", "POST"]) def register(): + """Create a new user account after validating the submitted credentials.""" if current_user.is_authenticated: return redirect(url_for("main.dashboard")) @@ -293,6 +334,7 @@ def register(): @login_bp.route("/login", methods=["GET", "POST"]) def login(): + """Authenticate a user and start the 2FA step when required.""" if current_user.is_authenticated: return redirect(url_for("main.dashboard")) @@ -303,6 +345,7 @@ def login(): if not username or not password: return render_template("login.html", error="Username and password are required.", username=username) + # Stop early if this user or IP bucket is currently locked. locked_until = get_active_lock(username) if locked_until and locked_until > utcnow(): @@ -313,6 +356,8 @@ def login(): ) user = lookup_user_by_username(username) + # Use a dummy hash when the user does not exist so the timing stays + # similar and does not reveal valid usernames. password_hash = user.password if user else DUMMY_PASSWORD_HASH password_matches = check_password_hash(password_hash, password) @@ -330,6 +375,7 @@ def login(): clear_login_attempts(username) if user.two_factor_enabled: + # Delay the full login until the authenticator code has been verified. if not user.two_factor_secret: return render_template( "login.html", @@ -353,6 +399,7 @@ def login(): @login_bp.route("/login/verify", methods=["GET", "POST"]) def verify_two_factor(): + """Complete login by verifying the authenticator-app code.""" if current_user.is_authenticated: return redirect(url_for("main.dashboard")) @@ -373,6 +420,7 @@ def verify_two_factor(): attempts = int(session.get("pending_2fa_attempts", 0)) + # Limit repeated guesses during the second factor step as well. if attempts >= MAX_TWO_FACTOR_ATTEMPTS: clear_pending_two_factor(user) db.session.commit() @@ -413,6 +461,7 @@ def verify_two_factor(): @login_bp.route("/login/settings", methods=["GET", "POST"]) @login_required def login_settings(): + """Handle password changes and authenticator-based 2FA settings.""" if request.method == "POST": action = request.form.get("action") @@ -420,6 +469,8 @@ def login_settings(): if current_user.two_factor_enabled: return render_login_settings(error="Authenticator app 2FA is already enabled.") + # Store the generated secret in the session until the user confirms + # it with a valid authenticator code. secret = generate_two_factor_secret() session["pending_2fa_setup_user_id"] = current_user.id session["pending_2fa_setup_secret"] = secret @@ -486,6 +537,7 @@ def login_settings(): @login_bp.route("/logout", methods=["POST"]) @login_required def logout(): + """Log the user out and clear any unfinished authentication state.""" clear_pending_two_factor() clear_pending_two_factor_setup() logout_user() diff --git a/app/routes/main_routes.py b/app/routes/main_routes.py index 321b5e7..fcca6a5 100644 --- a/app/routes/main_routes.py +++ b/app/routes/main_routes.py @@ -1,3 +1,10 @@ +"""Main application routes for pages, puzzle solving, and save points. + +This module contains the public pages, the dashboard, the board validation +logic for queens and rooks, solver endpoints, and the CRUD-style endpoints +used to store and restore saved games for a logged-in user. +""" + import json import random from datetime import datetime @@ -12,10 +19,12 @@ DEFAULT_BOARD_SIZE = 8 MIN_BOARD_SIZE = 4 MAX_BOARD_SIZE = 13 +# Blueprint that groups the main site and game-related routes. main_bp = Blueprint("main", __name__) def parse_board_size(raw_size, default=DEFAULT_BOARD_SIZE): + """Convert a user-supplied board size into a safe integer range.""" try: board_size = int(raw_size) except (TypeError, ValueError): @@ -25,10 +34,12 @@ def parse_board_size(raw_size, default=DEFAULT_BOARD_SIZE): def parse_favorite_flag(raw_value): + """Interpret common truthy values from query parameters or JSON payloads.""" return str(raw_value).lower() in {"1", "true", "yes", "on"} def parse_non_negative_int(raw_value, default=0): + """Parse an integer and clamp negative values to zero.""" try: value = int(raw_value) except (TypeError, ValueError): @@ -38,14 +49,17 @@ def parse_non_negative_int(raw_value, default=0): def get_position_label(row, col, board_size): + """Return a chess-style board label such as A8 or C5.""" return f"{chr(65 + col)}{board_size - row}" def solve_queens(board_size): + """Return every valid N-Queens solution for the requested board size.""" queen_positions = [-1] * board_size queen_solutions = [] def is_queen_position_safe(row, col): + """Check whether the new queen conflicts with any earlier queen.""" for previous_row in range(row): previous_col = queen_positions[previous_row] @@ -58,6 +72,7 @@ def solve_queens(board_size): return True def place_queen(row): + """Backtracking search that fills the board row by row.""" if row == board_size: queen_solutions.append(queen_positions[:]) return @@ -73,16 +88,19 @@ def solve_queens(board_size): def solve_rooks(board_size): + """Return one valid rook arrangement with exactly one rook per row/column.""" rook_positions = list(range(board_size)) random.shuffle(rook_positions) return rook_positions def build_queens_trace(board_size): + """Build a step-by-step trace that explains the queens backtracking search.""" queen_positions = [-1] * board_size steps = [] def is_queen_position_safe(row, col): + """Reuse the same safety rules used by the queens solver.""" for previous_row in range(row): previous_col = queen_positions[previous_row] @@ -95,13 +113,14 @@ def build_queens_trace(board_size): return True def place_queen(row): + """Record every placement and backtracking step until a solution is found.""" if row == board_size: steps.append({ "type": "solution", "row": None, "col": None, "board": queen_positions[:], - "message": f"Loesung gefunden. Alle {board_size} Damen stehen ohne Konflikte." + "message": f"Lösung gefunden. Alle {board_size} Damen stehen ohne Konflikte." }) return True @@ -111,6 +130,8 @@ def build_queens_trace(board_size): queen_positions[row] = col position = get_position_label(row, col, board_size) + # Store a snapshot of the current board so the frontend can replay + # the solving process visually. steps.append({ "type": "place", "row": row, @@ -145,6 +166,7 @@ def build_queens_trace(board_size): def build_rooks_trace(board_size): + """Build a simple placement trace for the rook mode.""" rook_positions = solve_rooks(board_size) board = [-1] * board_size steps = [] @@ -152,6 +174,8 @@ def build_rooks_trace(board_size): for row, col in enumerate(rook_positions): board[row] = col position = get_position_label(row, col, board_size) + # As with the queens trace, each step includes a full board snapshot for + # straightforward frontend rendering. steps.append({ "type": "place", "row": row, @@ -178,6 +202,7 @@ def build_rooks_trace(board_size): def serialize_game_state(game_state): + """Convert a database model into JSON-friendly data for the frontend.""" board = json.loads(game_state.board) return { @@ -198,6 +223,7 @@ def serialize_game_state(game_state): def build_save_point_query(): + """Create a filtered and sorted query for the current user's save points.""" query = GameState.query.filter_by(user_id=current_user.id) selected_mode = request.args.get("mode") @@ -205,6 +231,7 @@ def build_save_point_query(): favorites_only = request.args.get("favorites_only") sort_by = request.args.get("sort", "newest") + # Apply optional filters only when the request contains a supported value. if selected_mode in {"queens", "rooks"}: query = query.filter_by(mode=selected_mode) @@ -226,6 +253,7 @@ def build_save_point_query(): @main_bp.route("/") def home(): + """Render the public landing page and choose a matching default language.""" initial_language = request.accept_languages.best_match(["de", "en"]) or "de" return render_template("index.html", initial_language=initial_language) @@ -233,27 +261,33 @@ def home(): @main_bp.route("/dashboard") @login_required def dashboard(): + """Render the logged-in dashboard.""" return render_template("dashboard.html", user=current_user) @main_bp.route("/privacy_policy") def privacy_policy(): + """Render the privacy policy page.""" return render_template("privacy.html") @main_bp.route("/imprint") def imprint(): + """Render the imprint/legal notice page.""" return render_template("impressum.html") @main_bp.route("/check", methods=["POST"]) def check(): + """Validate whether a piece can be placed on the requested square.""" data = request.json board = data["board"] row = data["row"] col = data["col"] piece_mode = data.get("mode", "queens") + # The board is stored as a list where the index is the row and the value is + # the column of the placed piece. -1 means the row is still empty. for current_row, current_col in enumerate(board): if current_col == -1 or current_row == row: continue @@ -264,6 +298,7 @@ def check(): if same_column: return jsonify({"valid": False}) + # Diagonal conflicts only matter for queens, not for rooks. if piece_mode == "queens" and same_diagonal: return jsonify({"valid": False}) @@ -272,24 +307,28 @@ def check(): @main_bp.route("/solve") def solve_queens_route(): + """Return all queens solutions for the requested board size.""" board_size = parse_board_size(request.args.get("size")) return jsonify(solve_queens(board_size)) @main_bp.route("/solve_trace") def solve_queens_trace_route(): + """Return a step-by-step queens solving trace.""" board_size = parse_board_size(request.args.get("size")) return jsonify(build_queens_trace(board_size)) @main_bp.route("/solve_rooks") def solve_rooks_route(): + """Return one rook solution for the requested board size.""" board_size = parse_board_size(request.args.get("size")) return jsonify(solve_rooks(board_size)) @main_bp.route("/solve_rooks_trace") def solve_rooks_trace_route(): + """Return a step-by-step rook placement trace.""" board_size = parse_board_size(request.args.get("size")) return jsonify(build_rooks_trace(board_size)) @@ -297,6 +336,7 @@ def solve_rooks_trace_route(): @main_bp.route("/save", methods=["POST"]) @login_required def save_game(): + """Store the current board state as a new save point for the user.""" data = request.json board = data["board"] @@ -310,6 +350,7 @@ def save_game(): is_solved = bool(data.get("isSolved")) timestamp = datetime.utcnow() + # Save the board as JSON text so it can be restored exactly as the user left it. game_state = GameState( user_id=current_user.id, board=json.dumps(board), @@ -337,6 +378,7 @@ def save_game(): @main_bp.route("/save_points") @login_required def list_save_points(): + """Return all save points for the current user after filtering and sorting.""" save_points = build_save_point_query().all() return jsonify([serialize_game_state(game_state) for game_state in save_points]) @@ -344,6 +386,7 @@ def list_save_points(): @main_bp.route("/save_points//favorite", methods=["POST"]) @login_required def toggle_save_point_favorite(game_id): + """Toggle the favorite flag for one saved game.""" game_state = GameState.query.filter_by(id=game_id, user_id=current_user.id).first() if not game_state: @@ -358,6 +401,7 @@ def toggle_save_point_favorite(game_id): @main_bp.route("/save_points/", methods=["DELETE"]) @login_required def delete_save_point(game_id): + """Delete one save point owned by the current user.""" game_state = GameState.query.filter_by(id=game_id, user_id=current_user.id).first() if not game_state: @@ -372,12 +416,14 @@ def delete_save_point(game_id): @main_bp.route("/load") @login_required def load_game(): + """Load the newest save point for the current user.""" latest_game = ( GameState.query.filter_by(user_id=current_user.id) .order_by(GameState.created_at.desc(), GameState.id.desc()) .first() ) + # If the user has never saved a game, return a clean default board. if not latest_game: return jsonify({"board": [-1] * DEFAULT_BOARD_SIZE, "mode": "queens"}) @@ -387,6 +433,7 @@ def load_game(): @main_bp.route("/load/") @login_required def load_specific_game(game_id): + """Load a specific save point owned by the current user.""" game_state = GameState.query.filter_by(id=game_id, user_id=current_user.id).first() if not game_state: diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js index ab320cc..60aaa41 100644 --- a/app/static/js/dashboard.js +++ b/app/static/js/dashboard.js @@ -236,7 +236,7 @@ const translations = { favoriteAddButton: "Als Favorit", favoriteRemoveButton: "Favorit entfernen", deleteButton: "Löschen", - celebrationTitle: "Richtig geloöst!", + celebrationTitle: "Richtig gelöst!", celebrationText: ({ modeLabel, duration }) => `Stark gemacht. Deine ${modeLabel.toLowerCase()}-Lösung ist konfliktfrei in ${duration}.`, settingsApplied: ({ size }) => `Brettgrösse auf ${size} x ${size} gesetzt.`, modeActivated: ({ piece }) => `${piece}-Modus aktiviert.`, diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 2db34c4..45379c1 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -24,8 +24,8 @@ -