Add deletion
This commit is contained in:
parent
5f91521229
commit
245465a6fc
145
app/app.py
145
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 datetime
|
||||||
import json
|
import json
|
||||||
|
|
@ -15,53 +15,94 @@ DEFAULT_CATEGORIES = [
|
||||||
{"name": "sticky ___", "items": ["tape", "fingers", "situation", "wicket"], "level": 3},
|
{"name": "sticky ___", "items": ["tape", "fingers", "situation", "wicket"], "level": 3},
|
||||||
{"name": "how i feel, spelled backwards", "items": ["live", "deliver", "desserts", "denier"], "level": 4},
|
{"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):
|
def get_puzzle_numbers(cursor):
|
||||||
rows = cursor.execute("SELECT number FROM puzzle ORDER BY number").fetchall()
|
rows = cursor.execute("SELECT number FROM puzzle ORDER BY number").fetchall()
|
||||||
return [row[0] for row in rows]
|
return [row[0] for row in rows]
|
||||||
|
|
||||||
def get_puzzle_categories(cursor, puzzle_number):
|
def get_puzzle_summaries(cursor):
|
||||||
row = cursor.execute("SELECT data FROM puzzle WHERE number = ?", [puzzle_number]).fetchone()
|
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:
|
if not row:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
payload = json.loads(row[0])
|
payload = json.loads(row[2])
|
||||||
categories = payload.get("categories")
|
categories = payload.get("categories")
|
||||||
if isinstance(categories, list) and len(categories) == 4:
|
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):
|
except (json.JSONDecodeError, TypeError):
|
||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def render_puzzle(puzzle_number=None):
|
def render_puzzle(puzzle_number):
|
||||||
connection = sqlite3.connect(config.db_file)
|
with sqlite3.connect(config.db_file) as connection:
|
||||||
cursor = connection.cursor()
|
cursor = connection.cursor()
|
||||||
puzzle_numbers = get_puzzle_numbers(cursor)
|
puzzle_numbers = get_puzzle_numbers(cursor)
|
||||||
|
if puzzle_number != 0:
|
||||||
if puzzle_number is None and puzzle_numbers:
|
record = get_puzzle_record(cursor, puzzle_number)
|
||||||
puzzle_number = puzzle_numbers[-1]
|
if record is None:
|
||||||
|
|
||||||
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)
|
abort(404)
|
||||||
else:
|
|
||||||
categories = DEFAULT_CATEGORIES
|
|
||||||
|
|
||||||
connection.close()
|
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"]
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'index.html',
|
'index.html',
|
||||||
categories=categories,
|
categories=categories,
|
||||||
|
author=author,
|
||||||
|
creation_date=creation_date,
|
||||||
puzzle_numbers=puzzle_numbers,
|
puzzle_numbers=puzzle_numbers,
|
||||||
current_puzzle=puzzle_number,
|
current_puzzle=puzzle_number,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
return render_puzzle()
|
return redirect(url_for('puzzle_by_number', puzzle_number=0))
|
||||||
|
|
||||||
@app.route('/<int:puzzle_number>')
|
@app.route('/<int:puzzle_number>')
|
||||||
def puzzle_by_number(puzzle_number):
|
def puzzle_by_number(puzzle_number):
|
||||||
|
|
@ -69,11 +110,13 @@ def puzzle_by_number(puzzle_number):
|
||||||
|
|
||||||
@app.route('/new', methods=['GET', 'POST'])
|
@app.route('/new', methods=['GET', 'POST'])
|
||||||
def new_puzzle():
|
def new_puzzle():
|
||||||
|
default_creation_date = today_display_date()
|
||||||
if request.method == 'GET':
|
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()
|
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 = []
|
categories = []
|
||||||
for i in range(1, 5):
|
for i in range(1, 5):
|
||||||
name = request.form.get(f"category_{i}_name", "").strip()
|
name = request.form.get(f"category_{i}_name", "").strip()
|
||||||
|
|
@ -88,7 +131,15 @@ def new_puzzle():
|
||||||
return render_template(
|
return render_template(
|
||||||
'new.html',
|
'new.html',
|
||||||
error="Author is required.",
|
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
|
form=request.form
|
||||||
), 400
|
), 400
|
||||||
|
|
||||||
|
|
@ -96,24 +147,21 @@ def new_puzzle():
|
||||||
return render_template(
|
return render_template(
|
||||||
'new.html',
|
'new.html',
|
||||||
error="Each category needs a name and exactly 4 words.",
|
error="Each category needs a name and exactly 4 words.",
|
||||||
creation_date=creation_date,
|
creation_date=raw_creation_date,
|
||||||
form=request.form
|
form=request.form
|
||||||
), 400
|
), 400
|
||||||
|
|
||||||
data = json.dumps({"categories": categories}, separators=(",", ":"))
|
data = json.dumps({"categories": categories}, separators=(",", ":"))
|
||||||
connection = sqlite3.connect(config.db_file)
|
with sqlite3.connect(config.db_file) as connection:
|
||||||
cursor = connection.cursor()
|
cursor = connection.cursor()
|
||||||
row = cursor.execute("SELECT COALESCE(MAX(number), 0) + 1 FROM puzzle").fetchone()
|
|
||||||
number = row[0]
|
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO puzzle (number, author, creation_date, data)
|
INSERT INTO puzzle (author, creation_date, data)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
""",
|
""",
|
||||||
[number, author, creation_date, data]
|
[author, creation_date, data]
|
||||||
)
|
)
|
||||||
connection.commit()
|
number = cursor.lastrowid
|
||||||
connection.close()
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'new.html',
|
'new.html',
|
||||||
|
|
@ -121,6 +169,37 @@ def new_puzzle():
|
||||||
creation_date=creation_date
|
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__':
|
if __name__ == '__main__':
|
||||||
subprocess.run(["python3", "make_db.py"], check=True)
|
subprocess.run(["python3", "make_db.py"], check=True)
|
||||||
app.run(host='0.0.0.0', port=8000)
|
app.run(host='0.0.0.0', port=8000)
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,17 @@ import config
|
||||||
os.makedirs(config.db_dir, exist_ok=True)
|
os.makedirs(config.db_dir, exist_ok=True)
|
||||||
connection = sqlite3.connect(config.db_file)
|
connection = sqlite3.connect(config.db_file)
|
||||||
cursor = connection.cursor()
|
cursor = connection.cursor()
|
||||||
|
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS puzzle(
|
CREATE TABLE IF NOT EXISTS puzzle(
|
||||||
number INTEGER PRIMARY KEY,
|
number INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
author TEXT,
|
author TEXT,
|
||||||
creation_date TEXT,
|
creation_date TEXT,
|
||||||
data CLOB
|
data CLOB
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
connection.commit()
|
connection.commit()
|
||||||
connection.close()
|
connection.close()
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,15 @@ body {
|
||||||
h1 {
|
h1 {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
border-bottom: 2px solid #000;
|
border-bottom: 2px solid #000;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 8px;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
}
|
}
|
||||||
|
.puzzle-meta {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #5a594e;
|
||||||
|
}
|
||||||
.header-links {
|
.header-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
@ -175,6 +181,62 @@ button:disabled {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
line-height: 22px;
|
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) {
|
@media (max-width: 640px) {
|
||||||
.meta-row {
|
.meta-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|
@ -182,4 +244,8 @@ button:disabled {
|
||||||
.builder-words {
|
.builder-words {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.delete-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Delete Connections Puzzle</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<nav class="header-links">
|
||||||
|
<a href="/">home</a>
|
||||||
|
<a href="/new">new</a>
|
||||||
|
<a href="/delete" class="active">delete</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1>Connections</h1>
|
||||||
|
<p class="subtitle">Delete Puzzle</p>
|
||||||
|
|
||||||
|
<div class="delete-list">
|
||||||
|
{% for puzzle in puzzles %}
|
||||||
|
<div class="delete-row">
|
||||||
|
<div class="delete-meta">
|
||||||
|
<strong>#{{ puzzle.number }}</strong>
|
||||||
|
<span>By {{ puzzle.author }} | {{ puzzle.creation_date }}</span>
|
||||||
|
</div>
|
||||||
|
<form method="post" class="delete-form" data-number="{{ puzzle.number }}">
|
||||||
|
<input type="hidden" name="number" value="{{ puzzle.number }}">
|
||||||
|
<button type="button" class="danger-btn">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="delete-empty">No stored puzzles to delete.</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="message" class="{{ 'ok' if deleted else '' }}">
|
||||||
|
{% if deleted %}Deleted puzzle #{{ deleted }}.{% elif error == 'invalid' %}Invalid puzzle number.{% elif error == 'missing' %}Puzzle not found.{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog id="confirmDeleteDialog" class="confirm-dialog">
|
||||||
|
<form method="dialog" class="confirm-content">
|
||||||
|
<p id="confirmText">Delete this puzzle?</p>
|
||||||
|
<div class="controls">
|
||||||
|
<button value="cancel">Cancel</button>
|
||||||
|
<button value="confirm" class="danger-btn">Delete</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const dialog = document.getElementById('confirmDeleteDialog');
|
||||||
|
const confirmText = document.getElementById('confirmText');
|
||||||
|
let pendingForm = null;
|
||||||
|
|
||||||
|
document.querySelectorAll('.delete-form .danger-btn').forEach((button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
pendingForm = button.closest('.delete-form');
|
||||||
|
const number = pendingForm.dataset.number;
|
||||||
|
confirmText.textContent = `Delete puzzle #${number}? This cannot be undone.`;
|
||||||
|
dialog.showModal();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.addEventListener('close', () => {
|
||||||
|
if (dialog.returnValue === 'confirm' && pendingForm) {
|
||||||
|
pendingForm.submit();
|
||||||
|
}
|
||||||
|
pendingForm = null;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
|
@ -10,14 +10,17 @@
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<nav class="header-links">
|
<nav class="header-links">
|
||||||
<a href="/">/</a>
|
<a href="/">home</a>
|
||||||
|
<a href="/0" class="{{ 'active' if current_puzzle == 0 else '' }}">0</a>
|
||||||
{% for number in puzzle_numbers %}
|
{% for number in puzzle_numbers %}
|
||||||
<a href="/{{ number }}" class="{{ 'active' if current_puzzle == number else '' }}">/{{ number }}</a>
|
<a href="/{{ number }}" class="{{ 'active' if current_puzzle == number else '' }}">{{ number }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<a href="/new">/new</a>
|
<a href="/new">new</a>
|
||||||
|
<a href="/delete">delete</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<h1>Connections</h1>
|
<h1>Connections</h1>
|
||||||
|
<div class="puzzle-meta">By {{ author }} | {{ creation_date }}</div>
|
||||||
|
|
||||||
<div id="grid"></div>
|
<div id="grid"></div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,12 @@
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<nav class="header-links">
|
||||||
|
<a href="/">home</a>
|
||||||
|
<a href="/new" class="active">new</a>
|
||||||
|
<a href="/delete">delete</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<h1>Connections</h1>
|
<h1>Connections</h1>
|
||||||
<p class="subtitle">Create Puzzle</p>
|
<p class="subtitle">Create Puzzle</p>
|
||||||
|
|
||||||
|
|
@ -20,7 +26,13 @@
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Creation Date
|
Creation Date
|
||||||
<input type="date" name="creation_date" value="{{ (form.creation_date if form else creation_date) }}" required>
|
<input
|
||||||
|
type="text"
|
||||||
|
name="creation_date"
|
||||||
|
placeholder="15 March 2026"
|
||||||
|
value="{{ (form.creation_date if form else creation_date) }}"
|
||||||
|
required
|
||||||
|
>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue