Add puzzle creation
This commit is contained in:
parent
d52af8f024
commit
5f91521229
119
app/app.py
119
app/app.py
|
|
@ -1,13 +1,126 @@
|
||||||
from flask import Flask, send_file, render_template, request
|
from flask import Flask, abort, render_template, request
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import config
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
DEFAULT_CATEGORIES = [
|
||||||
|
{"name": "linux commands", "items": ["cd", "touch", "cat", "find"], "level": 1},
|
||||||
|
{"name": "types of HTML input", "items": ["radio", "password", "submit", "text"], "level": 2},
|
||||||
|
{"name": "sticky ___", "items": ["tape", "fingers", "situation", "wicket"], "level": 3},
|
||||||
|
{"name": "how i feel, spelled backwards", "items": ["live", "deliver", "desserts", "denier"], "level": 4},
|
||||||
|
]
|
||||||
|
|
||||||
|
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()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = json.loads(row[0])
|
||||||
|
categories = payload.get("categories")
|
||||||
|
if isinstance(categories, list) and len(categories) == 4:
|
||||||
|
return 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)
|
||||||
|
|
||||||
|
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:
|
||||||
|
categories = DEFAULT_CATEGORIES
|
||||||
|
|
||||||
|
connection.close()
|
||||||
|
return render_template(
|
||||||
|
'index.html',
|
||||||
|
categories=categories,
|
||||||
|
puzzle_numbers=puzzle_numbers,
|
||||||
|
current_puzzle=puzzle_number,
|
||||||
|
)
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
return render_template('index.html')
|
return render_puzzle()
|
||||||
|
|
||||||
|
@app.route('/<int:puzzle_number>')
|
||||||
|
def puzzle_by_number(puzzle_number):
|
||||||
|
return render_puzzle(puzzle_number)
|
||||||
|
|
||||||
|
@app.route('/new', methods=['GET', 'POST'])
|
||||||
|
def new_puzzle():
|
||||||
|
if request.method == 'GET':
|
||||||
|
return render_template('new.html', creation_date=datetime.date.today().isoformat())
|
||||||
|
|
||||||
|
author = request.form.get("author", "").strip()
|
||||||
|
creation_date = request.form.get("creation_date", "").strip() or datetime.date.today().isoformat()
|
||||||
|
categories = []
|
||||||
|
for i in range(1, 5):
|
||||||
|
name = request.form.get(f"category_{i}_name", "").strip()
|
||||||
|
words = []
|
||||||
|
for j in range(1, 5):
|
||||||
|
word = request.form.get(f"category_{i}_word_{j}", "").strip()
|
||||||
|
if word:
|
||||||
|
words.append(word)
|
||||||
|
categories.append({"name": name, "items": words, "level": i})
|
||||||
|
|
||||||
|
if not author:
|
||||||
|
return render_template(
|
||||||
|
'new.html',
|
||||||
|
error="Author is required.",
|
||||||
|
creation_date=creation_date,
|
||||||
|
form=request.form
|
||||||
|
), 400
|
||||||
|
|
||||||
|
if any(not cat["name"] or len(cat["items"]) != 4 for cat in categories):
|
||||||
|
return render_template(
|
||||||
|
'new.html',
|
||||||
|
error="Each category needs a name and exactly 4 words.",
|
||||||
|
creation_date=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()
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'new.html',
|
||||||
|
success=f"Saved puzzle #{number}.",
|
||||||
|
creation_date=creation_date
|
||||||
|
)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
subprocess.run(["python3", "make_db.py"])
|
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)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
db_dir = "/connections/db"
|
||||||
|
db_file = f"{db_dir}/data.db"
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
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,
|
||||||
|
author TEXT,
|
||||||
|
creation_date TEXT,
|
||||||
|
data CLOB
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
connection.close()
|
||||||
|
|
@ -19,6 +19,26 @@ h1 {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
}
|
}
|
||||||
|
.header-links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.header-links a {
|
||||||
|
border: 1px solid #000;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #000;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.header-links a.active {
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
#grid {
|
#grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
|
@ -84,3 +104,82 @@ button:disabled {
|
||||||
height: 24px;
|
height: 24px;
|
||||||
color: #d33;
|
color: #d33;
|
||||||
}
|
}
|
||||||
|
#message.ok {
|
||||||
|
color: #2b7d32;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
margin-top: -8px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
.builder {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 700px;
|
||||||
|
}
|
||||||
|
.meta-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.meta-row label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: 13px;
|
||||||
|
gap: 6px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.meta-row input {
|
||||||
|
border: 1px solid #b8b8ae;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.builder-grid {
|
||||||
|
max-width: 700px;
|
||||||
|
}
|
||||||
|
.builder-row {
|
||||||
|
height: auto;
|
||||||
|
padding: 10px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.builder-row > input {
|
||||||
|
border: 1px solid #8f8f8f;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.builder-words {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.builder-words input {
|
||||||
|
border: 1px solid #8f8f8f;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.ghost-link {
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 25px;
|
||||||
|
border: 1px solid #000;
|
||||||
|
background: white;
|
||||||
|
color: #000;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.meta-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.builder-words {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,13 @@
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<nav class="header-links">
|
||||||
|
<a href="/">/</a>
|
||||||
|
{% for number in puzzle_numbers %}
|
||||||
|
<a href="/{{ number }}" class="{{ 'active' if current_puzzle == number else '' }}">/{{ number }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
<a href="/new">/new</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<h1>Connections</h1>
|
<h1>Connections</h1>
|
||||||
|
|
||||||
|
|
@ -23,13 +30,7 @@
|
||||||
<div id="message"></div>
|
<div id="message"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// EDIT YOUR CATEGORIES HERE
|
const categories = {{ categories | tojson }};
|
||||||
const categories = [
|
|
||||||
{name: "linux commands", items: ["cd", "touch", "cat", "find"], level: 1},
|
|
||||||
{name: "types of HTML input", items: ["radio", "password", "submit", "text"], level: 2},
|
|
||||||
{name: "sticky ___", items: ["tape", "fingers", "situation", "wicket"], level: 3},
|
|
||||||
{name: "how i feel, spelled backwards", items: ["live", "deliver", "desserts", "denier"], level: 4}
|
|
||||||
];
|
|
||||||
|
|
||||||
let words = categories.flatMap(cat => cat.items).sort(() => Math.random() - 0.5);
|
let words = categories.flatMap(cat => cat.items).sort(() => Math.random() - 0.5);
|
||||||
let selected = [];
|
let selected = [];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Create Connections Puzzle</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>Connections</h1>
|
||||||
|
<p class="subtitle">Create Puzzle</p>
|
||||||
|
|
||||||
|
<form method="post" class="builder">
|
||||||
|
<div class="meta-row">
|
||||||
|
<label>
|
||||||
|
Author
|
||||||
|
<input type="text" name="author" value="{{ (form.author if form else '') }}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Creation Date
|
||||||
|
<input type="date" name="creation_date" value="{{ (form.creation_date if form else creation_date) }}" required>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="grid" class="builder-grid">
|
||||||
|
{% for i in range(1, 5) %}
|
||||||
|
<div class="card solved builder-row" style="background-color: var(--lvl-{{ i }})">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="category_{{ i }}_name"
|
||||||
|
placeholder="Category {{ i }} name"
|
||||||
|
value="{{ (form['category_' ~ i ~ '_name'] if form else '') }}"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<div class="builder-words">
|
||||||
|
{% for j in range(1, 5) %}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="category_{{ i }}_word_{{ j }}"
|
||||||
|
placeholder="Word {{ j }}"
|
||||||
|
value="{{ (form['category_' ~ i ~ '_word_' ~ j] if form else '') }}"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<button type="submit">Save Puzzle</button>
|
||||||
|
<a class="ghost-link" href="/">Back to Puzzle</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="message" class="{{ 'ok' if success else '' }}">
|
||||||
|
{% if error %}{{ error }}{% elif success %}{{ success }}{% endif %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
|
@ -7,3 +7,5 @@ services:
|
||||||
stop_signal: SIGINT
|
stop_signal: SIGINT
|
||||||
ports:
|
ports:
|
||||||
- '8003:8000'
|
- '8003:8000'
|
||||||
|
volumes:
|
||||||
|
- "./db:/connections/db"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue