diff --git a/app/__pycache__/__init__.cpython-311.pyc b/app/__pycache__/__init__.cpython-311.pyc index 3a6324c..c75a2cb 100644 Binary files a/app/__pycache__/__init__.cpython-311.pyc and b/app/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/__pycache__/__init__.cpython-312.pyc b/app/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..ae97128 Binary files /dev/null and b/app/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/models/__pycache__/gamestate.cpython-311.pyc b/app/models/__pycache__/gamestate.cpython-311.pyc index 02fc1f9..9a1f79a 100644 Binary files a/app/models/__pycache__/gamestate.cpython-311.pyc and b/app/models/__pycache__/gamestate.cpython-311.pyc differ diff --git a/app/models/__pycache__/gamestate.cpython-312.pyc b/app/models/__pycache__/gamestate.cpython-312.pyc new file mode 100644 index 0000000..db13518 Binary files /dev/null and b/app/models/__pycache__/gamestate.cpython-312.pyc differ diff --git a/app/models/gamestate.py b/app/models/gamestate.py index d68aa08..7aef035 100644 --- a/app/models/gamestate.py +++ b/app/models/gamestate.py @@ -1,11 +1,21 @@ from .. import db -from flask_login import UserMixin -class GameState(UserMixin, db.Model): +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) # JSON String - mode = db.Column(db.String(10), nullable=False) # queens / rooks - - created_at = db.Column(db.DateTime, default=db.func.now()) + 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() + ) diff --git a/app/routes/__pycache__/main_routes.cpython-311.pyc b/app/routes/__pycache__/main_routes.cpython-311.pyc index c5a0bbf..ec65ad5 100644 Binary files a/app/routes/__pycache__/main_routes.cpython-311.pyc and b/app/routes/__pycache__/main_routes.cpython-311.pyc differ diff --git a/app/routes/__pycache__/main_routes.cpython-312.pyc b/app/routes/__pycache__/main_routes.cpython-312.pyc index 7282671..237fe6e 100644 Binary files a/app/routes/__pycache__/main_routes.cpython-312.pyc and b/app/routes/__pycache__/main_routes.cpython-312.pyc differ diff --git a/app/routes/main_routes.py b/app/routes/main_routes.py index cf8405c..c1b77b2 100644 --- a/app/routes/main_routes.py +++ b/app/routes/main_routes.py @@ -23,6 +23,10 @@ def parse_board_size(raw_size, default=DEFAULT_BOARD_SIZE): 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 = [] @@ -67,12 +71,43 @@ def serialize_game_state(game_state): "id": game_state.id, "board": board, "mode": game_state.mode, - "board_size": len(board), + "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") + "created_at": game_state.created_at.strftime("%Y-%m-%d %H:%M:%S"), + "updated_at": game_state.updated_at.strftime("%Y-%m-%d %H:%M:%S") } +def build_save_point_query(): + query = GameState.query.filter_by(user_id=current_user.id) + + selected_mode = request.args.get("mode") + board_size = request.args.get("board_size") + favorites_only = request.args.get("favorites_only") + sort_by = request.args.get("sort", "newest") + + if selected_mode in {"queens", "rooks"}: + query = query.filter_by(mode=selected_mode) + + if board_size not in {None, "", "all"}: + query = query.filter_by(board_size=parse_board_size(board_size)) + + if parse_favorite_flag(favorites_only): + query = query.filter_by(is_favorite=True) + + sort_options = { + "newest": GameState.created_at.desc(), + "oldest": GameState.created_at.asc(), + "updated": GameState.updated_at.desc(), + "favorites": GameState.is_favorite.desc() + } + + return query.order_by(sort_options.get(sort_by, GameState.created_at.desc()), GameState.id.desc()) + + @main_bp.route("/") def home(): return render_template("index.html") @@ -127,29 +162,63 @@ def save_game(): 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 = GameState( + game_state = GameState( user_id=current_user.id, board=json.dumps(board), - mode=piece_mode + mode=piece_mode, + board_size=board_size, + save_name=save_name, + save_note=save_note, + is_favorite=is_favorite ) - db.session.add(game) + db.session.add(game_state) db.session.commit() - return jsonify({"status": "saved"}) + return jsonify({ + "status": "saved", + "save_point": serialize_game_state(game_state) + }) @main_bp.route("/save_points") @login_required def list_save_points(): - saved_games = ( - GameState.query.filter_by(user_id=current_user.id) - .order_by(GameState.id.desc()) - .all() - ) + save_points = build_save_point_query().all() + return jsonify([serialize_game_state(game_state) for game_state in save_points]) - return jsonify([serialize_game_state(game_state) for game_state in saved_games]) + +@main_bp.route("/save_points//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/", 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") @@ -157,25 +226,22 @@ def list_save_points(): def load_game(): latest_game = ( GameState.query.filter_by(user_id=current_user.id) - .order_by(GameState.id.desc()) + .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({ - "board": json.loads(latest_game.board), - "mode": latest_game.mode - }) + return jsonify(serialize_game_state(latest_game)) @main_bp.route("/load/") @login_required def load_specific_game(game_id): - selected_game = GameState.query.filter_by(id=game_id, user_id=current_user.id).first() + game_state = GameState.query.filter_by(id=game_id, user_id=current_user.id).first() - if not selected_game: + if not game_state: return jsonify({"error": "Save point not found"}), 404 - return jsonify(serialize_game_state(selected_game)) + return jsonify(serialize_game_state(game_state)) diff --git a/app/static/css/dashboard.css b/app/static/css/dashboard.css index ee0e1b3..c3ca90f 100644 --- a/app/static/css/dashboard.css +++ b/app/static/css/dashboard.css @@ -190,6 +190,10 @@ body { width: min(380px, 92vw); } +.save-sidebar { + width: min(360px, 90vw); +} + .sidebar-header { display: flex; justify-content: space-between; @@ -212,6 +216,29 @@ body { 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; @@ -228,6 +255,14 @@ body { 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; @@ -253,6 +288,32 @@ body { 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; +} + +.btn.ghost { + background: #e2e8f0; + color: #1e293b; +} + +.btn.ghost:hover { + background: #cbd5e1; +} + .sidebar-backdrop { position: fixed; inset: 0; diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js index abb2334..437d712 100644 --- a/app/static/js/dashboard.js +++ b/app/static/js/dashboard.js @@ -15,6 +15,8 @@ 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"); @@ -23,6 +25,13 @@ 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); @@ -67,6 +76,12 @@ function setMode(newMode) { updateModeButtons(); } +function closeAllPanels() { + toggleSidebar(false); + toggleSavePanel(false); + toggleSavePointsPanel(false); +} + function getConflicts() { const conflicts = []; @@ -178,6 +193,7 @@ function toggleSidebar(forceOpen) { const shouldOpen = typeof forceOpen === "boolean" ? forceOpen : !settingsSidebar.classList.contains("open"); if (shouldOpen) { + toggleSavePanel(false); toggleSavePointsPanel(false); } @@ -186,9 +202,27 @@ function toggleSidebar(forceOpen) { 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)); @@ -198,6 +232,21 @@ 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 = []; @@ -216,11 +265,40 @@ function getBoardPreview(savedBoard, limit = 4) { 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 = ''; + savePointsList.innerHTML = ''; return; } @@ -228,42 +306,54 @@ function renderSavePoints(savePoints) { const item = document.createElement("article"); const title = document.createElement("h4"); const createdAt = document.createElement("p"); + const updatedAt = document.createElement("p"); const boardMeta = document.createElement("p"); const preview = document.createElement("p"); - const loadButton = document.createElement("button"); + const note = document.createElement("p"); + const actions = document.createElement("div"); item.className = "save-point-card"; - title.textContent = `Save Point #${savePoint.id}`; + title.textContent = `${savePoint.is_favorite ? "★ " : ""}${savePoint.save_name}`; createdAt.className = "save-point-meta"; - createdAt.textContent = `Gespeichert: ${savePoint.created_at}`; + 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"; - loadButton.className = "btn"; - loadButton.type = "button"; - loadButton.textContent = "Diesen laden"; - loadButton.onclick = () => loadSavePoint(savePoint.id); + 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(loadButton); + item.appendChild(note); + item.appendChild(actions); savePointsList.appendChild(item); } } async function refreshSavePoints() { - const response = await fetch("/save_points"); + const queryString = getSavePointFilters(); + const response = await fetch(`/save_points${queryString ? `?${queryString}` : ""}`); const savePoints = await response.json(); renderSavePoints(savePoints); } async function openSavePointsPanel() { await refreshSavePoints(); - toggleSidebar(false); toggleSavePointsPanel(true); } @@ -316,7 +406,7 @@ function updateQueenList() { } async function saveGame() { - await fetch("/save", { + const response = await fetch("/save", { method: "POST", headers: { "Content-Type": "application/json" @@ -324,12 +414,23 @@ async function saveGame() { body: JSON.stringify({ board, mode, - boardSize + 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(); - alert("💾 Spiel gespeichert!"); + setStatus(`Speicherpunkt "${data.save_point.save_name}" gespeichert.`, "green"); } async function loadGame() { @@ -349,10 +450,44 @@ async function loadSavePoint(savePointId) { const data = await response.json(); applyLoadedGame(data); - setStatus(`Speicherstand #${savePointId} geladen.`, "green"); + 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); diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 9c988c1..1c82d34 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -66,7 +66,7 @@ - + @@ -95,6 +95,29 @@ + + + diff --git a/database_setup.sql b/database_setup.sql index c2ece83..161cd8b 100644 --- a/database_setup.sql +++ b/database_setup.sql @@ -9,7 +9,12 @@ CREATE TABLE users ( 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" - created_at DATETIME DEFAULT CURRENT_TIMESTAMP -); \ No newline at end of file + board TEXT NOT NULL, -- JSON String (z.B. "[0,4,7,5,2,6,1,3]") + mode TEXT NOT NULL, -- "queens" oder "rooks" + 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 +);