diff --git a/app/app.py b/app/app.py index fbb05dc..1a142c9 100755 --- a/app/app.py +++ b/app/app.py @@ -1,4 +1,4 @@ -from flask import Flask, abort, render_template, request +from flask import Flask, abort, redirect, render_template, request, url_for import datetime import json @@ -15,53 +15,94 @@ DEFAULT_CATEGORIES = [ {"name": "sticky ___", "items": ["tape", "fingers", "situation", "wicket"], "level": 3}, {"name": "how i feel, spelled backwards", "items": ["live", "deliver", "desserts", "denier"], "level": 4}, ] +DEFAULT_AUTHOR = "John" +DEFAULT_CREATION_DATE = "15 March 2026" + +def today_display_date(): + today = datetime.date.today() + return f"{today.day} {today.strftime('%B %Y')}" + +def normalize_creation_date(raw_date): + if not raw_date: + return None + + text = raw_date.strip() + candidate_formats = ["%Y-%m-%d", "%d %B %Y", "%d %b %Y"] + for fmt in candidate_formats: + try: + parsed = datetime.datetime.strptime(text, fmt).date() + return f"{parsed.day} {parsed.strftime('%B %Y')}" + except ValueError: + continue + return None def get_puzzle_numbers(cursor): rows = cursor.execute("SELECT number FROM puzzle ORDER BY number").fetchall() return [row[0] for row in rows] -def get_puzzle_categories(cursor, puzzle_number): - row = cursor.execute("SELECT data FROM puzzle WHERE number = ?", [puzzle_number]).fetchone() +def get_puzzle_summaries(cursor): + rows = cursor.execute( + "SELECT number, author, creation_date FROM puzzle ORDER BY number" + ).fetchall() + return [ + { + "number": row[0], + "author": row[1] or "Unknown", + "creation_date": normalize_creation_date(row[2]) or "-", + } + for row in rows + ] + +def get_puzzle_record(cursor, puzzle_number): + row = cursor.execute( + "SELECT author, creation_date, data FROM puzzle WHERE number = ?", + [puzzle_number] + ).fetchone() if not row: return None try: - payload = json.loads(row[0]) + payload = json.loads(row[2]) categories = payload.get("categories") if isinstance(categories, list) and len(categories) == 4: - return categories + return { + "author": row[0] or "Unknown", + "creation_date": normalize_creation_date(row[1]) or "-", + "categories": categories, + } except (json.JSONDecodeError, TypeError): return None return None -def render_puzzle(puzzle_number=None): - connection = sqlite3.connect(config.db_file) - cursor = connection.cursor() - puzzle_numbers = get_puzzle_numbers(cursor) +def render_puzzle(puzzle_number): + with sqlite3.connect(config.db_file) as connection: + cursor = connection.cursor() + puzzle_numbers = get_puzzle_numbers(cursor) + if puzzle_number != 0: + record = get_puzzle_record(cursor, puzzle_number) + if record is None: + abort(404) - if puzzle_number is None and puzzle_numbers: - puzzle_number = puzzle_numbers[-1] - - if puzzle_number is not None: - categories = get_puzzle_categories(cursor, puzzle_number) - if categories is None and puzzle_number in puzzle_numbers: - categories = DEFAULT_CATEGORIES - elif categories is None: - connection.close() - abort(404) - else: + if puzzle_number == 0: categories = DEFAULT_CATEGORIES + author = DEFAULT_AUTHOR + creation_date = DEFAULT_CREATION_DATE + else: + categories = record["categories"] + author = record["author"] + creation_date = record["creation_date"] - connection.close() return render_template( 'index.html', categories=categories, + author=author, + creation_date=creation_date, puzzle_numbers=puzzle_numbers, current_puzzle=puzzle_number, ) @app.route('/') def index(): - return render_puzzle() + return redirect(url_for('puzzle_by_number', puzzle_number=0)) @app.route('/') def puzzle_by_number(puzzle_number): @@ -69,11 +110,13 @@ def puzzle_by_number(puzzle_number): @app.route('/new', methods=['GET', 'POST']) def new_puzzle(): + default_creation_date = today_display_date() if request.method == 'GET': - return render_template('new.html', creation_date=datetime.date.today().isoformat()) + return render_template('new.html', creation_date=default_creation_date) author = request.form.get("author", "").strip() - creation_date = request.form.get("creation_date", "").strip() or datetime.date.today().isoformat() + raw_creation_date = request.form.get("creation_date", "").strip() or default_creation_date + creation_date = normalize_creation_date(raw_creation_date) categories = [] for i in range(1, 5): name = request.form.get(f"category_{i}_name", "").strip() @@ -88,7 +131,15 @@ def new_puzzle(): return render_template( 'new.html', error="Author is required.", - creation_date=creation_date, + creation_date=raw_creation_date, + form=request.form + ), 400 + + if not creation_date: + return render_template( + 'new.html', + error='Creation date must be in the format "Day Month Year" (example: 15 March 2026).', + creation_date=raw_creation_date, form=request.form ), 400 @@ -96,24 +147,21 @@ def new_puzzle(): return render_template( 'new.html', error="Each category needs a name and exactly 4 words.", - creation_date=creation_date, + creation_date=raw_creation_date, form=request.form ), 400 data = json.dumps({"categories": categories}, separators=(",", ":")) - connection = sqlite3.connect(config.db_file) - cursor = connection.cursor() - row = cursor.execute("SELECT COALESCE(MAX(number), 0) + 1 FROM puzzle").fetchone() - number = row[0] - cursor.execute( - """ - INSERT INTO puzzle (number, author, creation_date, data) - VALUES (?, ?, ?, ?) - """, - [number, author, creation_date, data] - ) - connection.commit() - connection.close() + with sqlite3.connect(config.db_file) as connection: + cursor = connection.cursor() + cursor.execute( + """ + INSERT INTO puzzle (author, creation_date, data) + VALUES (?, ?, ?) + """, + [author, creation_date, data] + ) + number = cursor.lastrowid return render_template( 'new.html', @@ -121,6 +169,37 @@ def new_puzzle(): creation_date=creation_date ) +@app.route('/delete', methods=['GET', 'POST']) +def delete_puzzle(): + if request.method == 'POST': + number = request.form.get("number", "").strip() + try: + puzzle_number = int(number) + except ValueError: + return redirect(url_for('delete_puzzle', error="invalid")) + + if puzzle_number <= 0: + return redirect(url_for('delete_puzzle', error="invalid")) + + with sqlite3.connect(config.db_file) as connection: + cursor = connection.cursor() + cursor.execute("DELETE FROM puzzle WHERE number = ?", [puzzle_number]) + deleted = cursor.rowcount > 0 + + if deleted: + return redirect(url_for('delete_puzzle', deleted=puzzle_number)) + return redirect(url_for('delete_puzzle', error="missing")) + + with sqlite3.connect(config.db_file) as connection: + cursor = connection.cursor() + puzzles = get_puzzle_summaries(cursor) + return render_template( + 'delete.html', + puzzles=puzzles, + deleted=request.args.get("deleted"), + error=request.args.get("error"), + ) + if __name__ == '__main__': subprocess.run(["python3", "make_db.py"], check=True) app.run(host='0.0.0.0', port=8000) diff --git a/app/make_db.py b/app/make_db.py index 43a63d3..6e211e3 100644 --- a/app/make_db.py +++ b/app/make_db.py @@ -6,15 +6,17 @@ import config os.makedirs(config.db_dir, exist_ok=True) connection = sqlite3.connect(config.db_file) cursor = connection.cursor() + cursor.execute( """ CREATE TABLE IF NOT EXISTS puzzle( - number INTEGER PRIMARY KEY, + number INTEGER PRIMARY KEY AUTOINCREMENT, author TEXT, creation_date TEXT, data CLOB ) """ ) + connection.commit() connection.close() diff --git a/app/static/css/style.css b/app/static/css/style.css index a0a6517..2a9f17e 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -16,9 +16,15 @@ body { h1 { text-transform: uppercase; border-bottom: 2px solid #000; - margin-bottom: 20px; + margin-bottom: 8px; letter-spacing: 2px; } +.puzzle-meta { + margin-bottom: 16px; + font-size: 13px; + font-weight: bold; + color: #5a594e; +} .header-links { display: flex; flex-wrap: wrap; @@ -175,6 +181,62 @@ button:disabled { text-decoration: none; line-height: 22px; } +.danger-btn { + border-color: #9c1d1d; + color: #9c1d1d; +} +.danger-btn:hover { + background: #9c1d1d; + color: #fff; +} +.delete-list { + width: 100%; + max-width: 700px; + display: grid; + gap: 10px; +} +.delete-row { + border: 1px solid #d7d7cf; + border-radius: 8px; + padding: 12px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; +} +.delete-meta { + display: flex; + flex-direction: column; + gap: 3px; +} +.delete-meta span { + font-size: 13px; + color: #5a594e; +} +.delete-empty { + border: 1px dashed #c2c2ba; + border-radius: 8px; + padding: 16px; + text-align: center; + font-weight: bold; +} +.confirm-dialog { + border: 1px solid #000; + border-radius: 10px; + padding: 18px; + width: min(90vw, 420px); +} +.confirm-dialog::backdrop { + background: rgba(0, 0, 0, 0.45); +} +.confirm-content { + display: grid; + gap: 16px; +} +.confirm-content p { + margin: 0; + font-weight: bold; +} @media (max-width: 640px) { .meta-row { grid-template-columns: 1fr; @@ -182,4 +244,8 @@ button:disabled { .builder-words { grid-template-columns: 1fr; } + .delete-row { + flex-direction: column; + align-items: stretch; + } } diff --git a/app/templates/delete.html b/app/templates/delete.html new file mode 100644 index 0000000..c5eb073 --- /dev/null +++ b/app/templates/delete.html @@ -0,0 +1,75 @@ + + + + + + + Delete Connections Puzzle + + + + + + +

Connections

+

Delete Puzzle

+ +
+ {% for puzzle in puzzles %} +
+
+ #{{ puzzle.number }} + By {{ puzzle.author }} | {{ puzzle.creation_date }} +
+
+ + +
+
+ {% else %} +
No stored puzzles to delete.
+ {% endfor %} +
+ +
+ {% if deleted %}Deleted puzzle #{{ deleted }}.{% elif error == 'invalid' %}Invalid puzzle number.{% elif error == 'missing' %}Puzzle not found.{% endif %} +
+ + +
+

Delete this puzzle?

+
+ + +
+
+
+ + + + + diff --git a/app/templates/index.html b/app/templates/index.html index 0abf0e5..807987f 100755 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -10,14 +10,17 @@

Connections

+
By {{ author }} | {{ creation_date }}
diff --git a/app/templates/new.html b/app/templates/new.html index 9f67007..9cad0b7 100644 --- a/app/templates/new.html +++ b/app/templates/new.html @@ -9,6 +9,12 @@ + +

Connections

Create Puzzle

@@ -20,7 +26,13 @@